1315 lines
44 KiB
Python
1315 lines
44 KiB
Python
"""Low-level USB access to j5create JUC500 cable (0711:7500 IF5)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import array
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import usb.core
|
|
import usb.util
|
|
|
|
VID = 0x0711
|
|
PID = 0x7500
|
|
PID_JUC500 = 0x7500
|
|
|
|
CABLE_MODEL_JUC500 = "JUC500"
|
|
DEFAULT_CABLE_MODEL = CABLE_MODEL_JUC500
|
|
|
|
IF_CDC_COMM = 0
|
|
IF_CDC_DATA = 1
|
|
IF_VENDOR = 5
|
|
|
|
EP_DATA_OUT = 0x08
|
|
EP_DATA_IN = 0x89
|
|
EP_CTRL_OUT = 0x0A
|
|
EP_CTRL_IN = 0x8B
|
|
|
|
MAX_PACKET = 512
|
|
# Bumped when USB connect / cable-switch logic changes (peer must restart to load).
|
|
XFER_BUILD = "2026-07-21-juc500-auto-refresh"
|
|
# libusb splits into wMaxPacketSize; larger URBs cut Python→kernel round-trips.
|
|
BULK_XFER_SIZE = 64 * 1024
|
|
_IS_WIN = sys.platform == "win32"
|
|
# libusb is not reliably re-entrant across threads — serialize enum vs concurrent I/O callers.
|
|
_USB_ENUM_LOCK = threading.RLock()
|
|
_DEVICES_CACHE: list[Any] = []
|
|
_DEVICES_CACHE_AT = 0.0
|
|
_DEVICES_CACHE_TTL = 0.75
|
|
|
|
_PRODUCT_HINTS = (
|
|
"smart data",
|
|
"data link",
|
|
"wormhole",
|
|
"juc500",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CableLayout:
|
|
"""JUC500 IF5 bulk endpoint map."""
|
|
|
|
if_num: int
|
|
ep_data_out: int
|
|
ep_data_in: int
|
|
ep_ctrl_out: int
|
|
ep_ctrl_in: int
|
|
max_packet: int = 512
|
|
|
|
|
|
def default_if5_layout() -> CableLayout:
|
|
return CableLayout(
|
|
IF_VENDOR, EP_DATA_OUT, EP_DATA_IN, EP_CTRL_OUT, EP_CTRL_IN, MAX_PACKET
|
|
)
|
|
|
|
|
|
def _bulk_endpoints(intf) -> tuple[list[int], list[int], int]:
|
|
outs: list[int] = []
|
|
ins: list[int] = []
|
|
max_pkt = 512
|
|
for ep in intf:
|
|
if (ep.bmAttributes & 3) != 2:
|
|
continue
|
|
max_pkt = max(max_pkt, int(ep.wMaxPacketSize))
|
|
addr = int(ep.bEndpointAddress)
|
|
if addr & 0x80:
|
|
ins.append(addr)
|
|
else:
|
|
outs.append(addr)
|
|
return outs, ins, max_pkt
|
|
|
|
|
|
def _get_device_configuration(dev: usb.core.Device):
|
|
"""Read config descriptors; WinUSB often rejects get/set_configuration."""
|
|
try:
|
|
return dev.get_active_configuration()
|
|
except (usb.core.USBError, NotImplementedError):
|
|
try:
|
|
return dev[0]
|
|
except Exception:
|
|
pass
|
|
if _IS_WIN:
|
|
raise
|
|
try:
|
|
dev.set_configuration(1)
|
|
return dev.get_active_configuration()
|
|
except (usb.core.USBError, NotImplementedError) as exc:
|
|
raise RuntimeError("USB configuration 읽기 실패") from exc
|
|
|
|
|
|
def _is_juc500(vid: int, pid: int) -> bool:
|
|
return int(vid) == VID and int(pid) == PID_JUC500
|
|
|
|
|
|
def supports_file_transfer(info: DeviceInfo) -> bool:
|
|
"""True for JUC500 Smart Data Link IF5."""
|
|
return (
|
|
_is_juc500(int(getattr(info, "vid", VID) or VID), int(info.pid))
|
|
and int(getattr(info, "if_num", IF_VENDOR)) == IF_VENDOR
|
|
)
|
|
|
|
|
|
def device_supports_file_transfer(dev: usb.core.Device) -> bool:
|
|
return _is_juc500(int(dev.idVendor), int(dev.idProduct))
|
|
|
|
|
|
def detect_cable_layout(dev: usb.core.Device) -> CableLayout:
|
|
"""Map JUC500 IF5 bulk endpoints (0x08/0x89 + 0x0A/0x8B)."""
|
|
vid = int(dev.idVendor)
|
|
pid = int(dev.idProduct)
|
|
if not _is_juc500(vid, pid):
|
|
raise RuntimeError(
|
|
"JUC500(0711:7500) 장치가 아닙니다. "
|
|
"Smart Data Link 케이블인지 확인하세요."
|
|
)
|
|
|
|
try:
|
|
cfg = _get_device_configuration(dev)
|
|
except Exception:
|
|
if _IS_WIN:
|
|
return default_if5_layout()
|
|
raise RuntimeError(
|
|
"JUC500 IF5(0x08/0x89)를 찾지 못했습니다 — "
|
|
"케이블을 다시 연결하거나 다른 USB 포트를 사용하세요."
|
|
)
|
|
|
|
for intf in cfg:
|
|
ifnum = int(intf.bInterfaceNumber)
|
|
if ifnum != IF_VENDOR:
|
|
continue
|
|
outs, ins, max_pkt = _bulk_endpoints(intf)
|
|
if 0x08 in outs and 0x89 in ins:
|
|
ctrl_out = 0x0A if 0x0A in outs else outs[min(1, len(outs) - 1)]
|
|
ctrl_in = 0x8B if 0x8B in ins else ins[min(1, len(ins) - 1)]
|
|
return CableLayout(
|
|
IF_VENDOR, 0x08, 0x89, ctrl_out, ctrl_in, max(max_pkt, 512)
|
|
)
|
|
|
|
if _IS_WIN:
|
|
return default_if5_layout()
|
|
|
|
raise RuntimeError(
|
|
"JUC500 IF5(0x08/0x89)를 찾지 못했습니다 — "
|
|
"케이블을 다시 연결하거나 다른 USB 포트를 사용하세요."
|
|
)
|
|
|
|
|
|
def find_juc500_device(
|
|
devices: list[usb.core.Device],
|
|
*,
|
|
bus: Optional[int] = None,
|
|
address: Optional[int] = None,
|
|
serial: str = "",
|
|
) -> Optional[usb.core.Device]:
|
|
"""Pick JUC500 (0711:7500) by bus/address, serial, or first match."""
|
|
serial = (serial or "").strip()
|
|
candidates = [d for d in devices if _is_juc500(int(d.idVendor), int(d.idProduct))]
|
|
|
|
if bus is not None and address is not None:
|
|
for d in candidates:
|
|
if int(d.bus) == int(bus) and int(d.address) == int(address):
|
|
return d
|
|
|
|
if serial:
|
|
for d in candidates:
|
|
if _safe_str(d, getattr(d, "iSerialNumber", 0), "").strip() == serial:
|
|
return d
|
|
|
|
if bus is not None:
|
|
for d in candidates:
|
|
if int(d.bus) == int(bus):
|
|
return d
|
|
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def data_link_devices(devices: list[DeviceInfo]) -> list[DeviceInfo]:
|
|
"""USB nodes usable for JUC500 IF5 file transfer."""
|
|
return [d for d in devices if supports_file_transfer(d)]
|
|
|
|
|
|
def available_cable_models(devices: list[DeviceInfo] | None = None) -> tuple[str, ...]:
|
|
"""Cable types to show in the UI — JUC500 only when present."""
|
|
if devices is None:
|
|
try:
|
|
devices = list_devices()
|
|
except Exception:
|
|
return ()
|
|
if data_link_devices(devices):
|
|
return (CABLE_MODEL_JUC500,)
|
|
return ()
|
|
|
|
|
|
def _juc500_winusb_hint() -> str:
|
|
return (
|
|
"JUC500 Mac↔Win: 공식 Wormhole 종료 → Zadig(관리자) → List All Devices → "
|
|
"0711:7500 (Smart Data Link) → Interface 5 (MI_05) → WinUSB → Replace Driver → "
|
|
"케이블 재연결 후 start.bat. Windows에서도 이 juc500 앱을 실행하세요."
|
|
)
|
|
|
|
|
|
def _package_roots() -> list[Path]:
|
|
here = Path(__file__).resolve().parent
|
|
roots = [here.parent, Path.cwd()]
|
|
if getattr(sys, "frozen", False):
|
|
roots.insert(0, Path(sys.executable).resolve().parent)
|
|
mei = getattr(sys, "_MEIPASS", None)
|
|
if mei:
|
|
roots.insert(0, Path(mei))
|
|
for base in list(roots):
|
|
p = base
|
|
for _ in range(4):
|
|
roots.append(p)
|
|
if (p / "vendor").is_dir():
|
|
break
|
|
p = p.parent
|
|
seen: set[str] = set()
|
|
out: list[Path] = []
|
|
for r in roots:
|
|
key = str(r)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
out.append(r)
|
|
return out
|
|
|
|
|
|
def _libusb_candidates() -> list[str]:
|
|
names = []
|
|
if _IS_WIN:
|
|
names = ["libusb-1.0.dll"]
|
|
elif sys.platform == "darwin":
|
|
names = ["libusb-1.0.dylib"]
|
|
else:
|
|
names = ["libusb-1.0.so", "libusb-1.0.so.0"]
|
|
|
|
candidates: list[str] = []
|
|
if getattr(sys, "frozen", False):
|
|
mei = getattr(sys, "_MEIPASS", None)
|
|
exe_dir = Path(sys.executable).resolve().parent
|
|
if mei:
|
|
for n in names:
|
|
candidates.append(str(Path(mei) / n))
|
|
for n in names:
|
|
candidates.append(str(exe_dir / n))
|
|
candidates.append(str(exe_dir / "_internal" / n))
|
|
|
|
for root in _package_roots():
|
|
for n in names:
|
|
candidates.append(str(root / "vendor" / n))
|
|
candidates.append(str(root / n))
|
|
|
|
if _IS_WIN:
|
|
py_dir = Path(sys.executable).resolve().parent
|
|
for n in names:
|
|
candidates.append(str(py_dir / n))
|
|
candidates.append(str(py_dir / "DLLs" / n))
|
|
pf = os.environ.get("PROGRAMFILES", r"C:\Program Files")
|
|
candidates.append(os.path.join(pf, "libusb", "libusb-1.0.dll"))
|
|
candidates.append("libusb-1.0.dll")
|
|
elif sys.platform == "darwin":
|
|
candidates += [
|
|
os.path.expanduser("~/homebrew/lib/libusb-1.0.dylib"),
|
|
"/opt/homebrew/lib/libusb-1.0.dylib",
|
|
"/usr/local/lib/libusb-1.0.dylib",
|
|
]
|
|
else:
|
|
candidates += ["libusb-1.0.so", "libusb-1.0.so.0"]
|
|
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for c in candidates:
|
|
if c and c not in seen:
|
|
seen.add(c)
|
|
out.append(c)
|
|
return out
|
|
|
|
|
|
_BACKEND = None
|
|
_BACKEND_PATH: Optional[str] = None
|
|
|
|
|
|
def _libusb_backend():
|
|
"""Locate / cache libusb-1.0 backend."""
|
|
global _BACKEND, _BACKEND_PATH
|
|
if _BACKEND is not None:
|
|
return _BACKEND
|
|
|
|
import usb.backend.libusb1
|
|
|
|
last_err: Optional[BaseException] = None
|
|
for path in _libusb_candidates():
|
|
absolute = os.path.sep in path or (len(path) > 1 and path[1] == ":")
|
|
if absolute and not os.path.exists(path):
|
|
continue
|
|
try:
|
|
be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p)
|
|
if be is not None:
|
|
list(usb.core.find(find_all=True, backend=be))
|
|
_BACKEND = be
|
|
_BACKEND_PATH = path
|
|
return be
|
|
except Exception as exc: # noqa: BLE001
|
|
last_err = exc
|
|
continue
|
|
|
|
try:
|
|
be = usb.backend.libusb1.get_backend()
|
|
if be is not None:
|
|
list(usb.core.find(find_all=True, backend=be))
|
|
_BACKEND = be
|
|
_BACKEND_PATH = "(system)"
|
|
return be
|
|
except Exception as exc: # noqa: BLE001
|
|
last_err = exc
|
|
|
|
hint = (
|
|
"Windows: 프로젝트의 vendor\\libusb-1.0.dll 을 "
|
|
".venv\\Scripts\\ 또는 python.exe 옆로 복사하세요."
|
|
if _IS_WIN
|
|
else "macOS: brew install libusb"
|
|
)
|
|
detail = f" ({last_err})" if last_err else ""
|
|
raise RuntimeError(f"libusb-1.0 를 찾을 수 없습니다{detail}. {hint}")
|
|
|
|
|
|
def libusb_path() -> Optional[str]:
|
|
try:
|
|
_libusb_backend()
|
|
except Exception:
|
|
return None
|
|
return _BACKEND_PATH
|
|
|
|
|
|
def _usb_err_text(exc: BaseException) -> str:
|
|
parts = [str(exc)]
|
|
strerror = getattr(exc, "strerror", None)
|
|
if strerror and strerror not in parts[0]:
|
|
parts.append(str(strerror))
|
|
return " ".join(parts).lower()
|
|
|
|
|
|
def _is_access_denied(exc: BaseException) -> bool:
|
|
text = _usb_err_text(exc)
|
|
errno = getattr(exc, "errno", None)
|
|
return (
|
|
errno in (13, 5)
|
|
or "access denied" in text
|
|
or "permission" in text
|
|
or "busy" in text
|
|
or "resource busy" in text
|
|
)
|
|
|
|
|
|
def _is_not_found(exc: BaseException) -> bool:
|
|
text = _usb_err_text(exc)
|
|
errno = getattr(exc, "errno", None)
|
|
return (
|
|
errno in (2, 19)
|
|
or "entity not found" in text
|
|
or "not found" in text
|
|
or "no device" in text
|
|
)
|
|
|
|
|
|
def _is_not_supported(exc: BaseException) -> bool:
|
|
text = _usb_err_text(exc)
|
|
errno = getattr(exc, "errno", None)
|
|
return (
|
|
errno in (95, 40)
|
|
or "not supported" in text
|
|
or "unimplemented" in text
|
|
or "enosys" in text
|
|
)
|
|
|
|
|
|
def _is_overflow(exc: BaseException) -> bool:
|
|
text = _usb_err_text(exc)
|
|
errno = getattr(exc, "errno", None)
|
|
return errno == 84 or "overflow" in text
|
|
|
|
|
|
def _claim_failure_message(ifnum: int, exc: BaseException) -> str:
|
|
base = f"USB interface {ifnum} 점유 실패: {exc}"
|
|
if not _IS_WIN:
|
|
return base
|
|
if _is_access_denied(exc):
|
|
return (
|
|
f"{base}\n\n"
|
|
"Interface 5가 이미 다른 프로세스에 점유되어 있습니다.\n"
|
|
"가장 흔한 원인:\n"
|
|
" • JUC500 창/프로세스가 이미 실행 중 (start.bat 를 여러 번 실행)\n"
|
|
" • 공식 Wormhole / GoBridge 가 Interface 5 를 사용 중\n\n"
|
|
"해결:\n"
|
|
"1) 작업 관리자에서 python.exe / juc500_app 중복 종료 후 하나만 실행\n"
|
|
"2) GUI의 'Wormhole 종료' 또는 `python -m juc500_xfer kill-wormhole`\n"
|
|
"3) 그래도 안 되면 Zadig로 MI_05 → WinUSB 확인 후 케이블 재연결"
|
|
)
|
|
if _is_not_supported(exc):
|
|
return (
|
|
f"{base}\n\n"
|
|
"WinUSB가 해당 libusb API를 지원하지 않습니다. "
|
|
"Interface 5 (MI_05)에 WinUSB가 설치되어 있는지 Zadig로 확인하세요.\n"
|
|
f"{_juc500_winusb_hint()}"
|
|
)
|
|
return (
|
|
f"{base}\n"
|
|
"Zadig에서 Smart Data Link → Interface 5 (MI_05) 에 WinUSB를 설치하세요."
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DeviceInfo:
|
|
bus: int
|
|
address: int
|
|
serial: str
|
|
product: str
|
|
manufacturer: str
|
|
vid: int = VID
|
|
pid: int = PID
|
|
model: str = CABLE_MODEL_JUC500
|
|
if_num: int = IF_VENDOR
|
|
|
|
|
|
def _safe_str(dev: usb.core.Device, index: int, default: str = "") -> str:
|
|
if not index:
|
|
return default
|
|
try:
|
|
return (usb.util.get_string(dev, index) or default).strip()
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _looks_like_juc500(dev: usb.core.Device, product: str) -> bool:
|
|
if not _is_juc500(int(dev.idVendor), int(dev.idProduct)):
|
|
return False
|
|
p = (product or "").lower()
|
|
return any(h in p for h in _PRODUCT_HINTS) or int(dev.idProduct) == PID_JUC500
|
|
|
|
|
|
def iter_raw_devices():
|
|
backend = _libusb_backend()
|
|
return usb.core.find(find_all=True, backend=backend)
|
|
|
|
|
|
def find_juc500_devices() -> list[usb.core.Device]:
|
|
"""Enumerate JUC500 (0711:7500) candidates."""
|
|
with _USB_ENUM_LOCK:
|
|
found: list[usb.core.Device] = []
|
|
for dev in iter_raw_devices():
|
|
try:
|
|
vid = int(dev.idVendor)
|
|
pid = int(dev.idProduct)
|
|
except Exception:
|
|
continue
|
|
if _is_juc500(vid, pid):
|
|
found.append(dev)
|
|
continue
|
|
if vid != VID:
|
|
continue
|
|
product = _safe_str(dev, getattr(dev, "iProduct", 0), "")
|
|
if _looks_like_juc500(dev, product):
|
|
found.append(dev)
|
|
found.sort(key=lambda d: (int(d.bus), int(d.address)))
|
|
return found
|
|
|
|
|
|
def order_devices(
|
|
devices: list[DeviceInfo],
|
|
*,
|
|
serial: str | None = None,
|
|
model: str | None = None,
|
|
) -> list[DeviceInfo]:
|
|
"""Pick connection order when multiple JUC500 cables are plugged in."""
|
|
del model # JUC500-only; kwarg kept for API compatibility
|
|
if not devices:
|
|
return []
|
|
|
|
def rank(d: DeviceInfo) -> tuple[int, int, int]:
|
|
r_serial = 0 if serial and d.serial == serial.strip() else 1
|
|
return (r_serial, d.bus, d.address)
|
|
|
|
return sorted(devices, key=rank)
|
|
|
|
|
|
def list_devices(*, force: bool = False) -> list[DeviceInfo]:
|
|
global _DEVICES_CACHE, _DEVICES_CACHE_AT
|
|
now = time.monotonic()
|
|
if not force and _DEVICES_CACHE and (now - _DEVICES_CACHE_AT) < _DEVICES_CACHE_TTL:
|
|
return list(_DEVICES_CACHE)
|
|
got = _USB_ENUM_LOCK.acquire(timeout=1.5)
|
|
if not got:
|
|
return list(_DEVICES_CACHE)
|
|
try:
|
|
now = time.monotonic()
|
|
if not force and _DEVICES_CACHE and (now - _DEVICES_CACHE_AT) < _DEVICES_CACHE_TTL:
|
|
return list(_DEVICES_CACHE)
|
|
devices = _list_devices_unlocked()
|
|
_DEVICES_CACHE = devices
|
|
_DEVICES_CACHE_AT = time.monotonic()
|
|
return list(devices)
|
|
finally:
|
|
_USB_ENUM_LOCK.release()
|
|
|
|
|
|
def _list_devices_unlocked() -> list[DeviceInfo]:
|
|
out: list[DeviceInfo] = []
|
|
for dev in find_juc500_devices():
|
|
product = (
|
|
_safe_str(dev, getattr(dev, "iProduct", 0), "Smart Data Link")
|
|
or "Smart Data Link"
|
|
)
|
|
vid = int(dev.idVendor)
|
|
pid = int(dev.idProduct)
|
|
if_num = IF_VENDOR
|
|
if device_supports_file_transfer(dev):
|
|
try:
|
|
if_num = int(detect_cable_layout(dev).if_num)
|
|
except Exception:
|
|
pass
|
|
out.append(
|
|
DeviceInfo(
|
|
bus=int(dev.bus),
|
|
address=int(dev.address),
|
|
serial=_safe_str(dev, getattr(dev, "iSerialNumber", 0)),
|
|
product=product,
|
|
manufacturer=_safe_str(dev, getattr(dev, "iManufacturer", 0)),
|
|
vid=vid,
|
|
pid=pid,
|
|
model=CABLE_MODEL_JUC500,
|
|
if_num=if_num,
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def _macos_ioreg_vid0711() -> list[dict[str, str]]:
|
|
"""Fallback: IORegistry may show JUC500 when libusb enumeration is empty."""
|
|
if _IS_WIN or sys.platform != "darwin":
|
|
return []
|
|
try:
|
|
proc = subprocess.run(
|
|
["ioreg", "-p", "IOUSB", "-l", "-w", "0"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
check=False,
|
|
)
|
|
text = proc.stdout or ""
|
|
if not text:
|
|
return []
|
|
rows: list[dict[str, str]] = []
|
|
block: list[str] = []
|
|
for line in text.splitlines():
|
|
if line.startswith(" +-o ") or line.startswith(" | +-o "):
|
|
if block:
|
|
row = _parse_ioreg_usb_block(block)
|
|
if row:
|
|
rows.append(row)
|
|
block = [line]
|
|
elif block:
|
|
block.append(line)
|
|
if block:
|
|
row = _parse_ioreg_usb_block(block)
|
|
if row:
|
|
rows.append(row)
|
|
return rows
|
|
except Exception as exc: # noqa: BLE001
|
|
return [{"error": str(exc)}]
|
|
|
|
|
|
def _parse_ioreg_usb_block(lines: list[str]) -> dict[str, str] | None:
|
|
import re
|
|
|
|
body = "\n".join(lines)
|
|
vid_m = re.search(r'"idVendor"\s*=\s*(0x[0-9a-fA-F]+|\d+)', body)
|
|
if not vid_m:
|
|
return None
|
|
raw_vid = vid_m.group(1)
|
|
vid = int(raw_vid, 16) if raw_vid.lower().startswith("0x") else int(raw_vid)
|
|
if vid != VID:
|
|
return None
|
|
pid_m = re.search(r'"idProduct"\s*=\s*(0x[0-9a-fA-F]+|\d+)', body)
|
|
pid = 0
|
|
if pid_m:
|
|
raw_pid = pid_m.group(1)
|
|
pid = int(raw_pid, 16) if raw_pid.lower().startswith("0x") else int(raw_pid)
|
|
if pid != PID_JUC500:
|
|
return None
|
|
name_m = re.search(r'"USB Product Name"\s*=\s*"([^"]*)"', body)
|
|
name = name_m.group(1) if name_m else ""
|
|
loc_m = re.search(r'"locationID"\s*=\s*(0x[0-9a-fA-F]+|\d+)', body)
|
|
loc = loc_m.group(1) if loc_m else ""
|
|
return {
|
|
"vid": f"{vid:04x}",
|
|
"pid": f"{pid:04x}",
|
|
"name": name,
|
|
"location": str(loc),
|
|
}
|
|
|
|
|
|
def _windows_pnp_vid0711() -> list[dict[str, str]]:
|
|
"""Fallback: ask Windows PnP if VID_0711 is present (even without WinUSB)."""
|
|
if not _IS_WIN:
|
|
return []
|
|
ps = (
|
|
"Get-PnpDevice -PresentOnly | "
|
|
"Where-Object { $_.InstanceId -match 'VID_0711' } | "
|
|
"Select-Object Status, Class, FriendlyName, InstanceId | "
|
|
"ConvertTo-Json -Compress"
|
|
)
|
|
try:
|
|
proc = subprocess.run(
|
|
["powershell", "-NoProfile", "-Command", ps],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
check=False,
|
|
)
|
|
raw = (proc.stdout or "").strip()
|
|
if not raw:
|
|
return []
|
|
import json
|
|
|
|
data = json.loads(raw)
|
|
if isinstance(data, dict):
|
|
data = [data]
|
|
return [
|
|
{
|
|
"status": str(d.get("Status") or ""),
|
|
"class": str(d.get("Class") or ""),
|
|
"name": str(d.get("FriendlyName") or ""),
|
|
"id": str(d.get("InstanceId") or ""),
|
|
}
|
|
for d in data
|
|
]
|
|
except Exception as exc: # noqa: BLE001
|
|
return [{"error": str(exc)}]
|
|
|
|
|
|
@dataclass
|
|
class DoctorReport:
|
|
ok: bool
|
|
libusb_path: Optional[str] = None
|
|
libusb_error: Optional[str] = None
|
|
usb_device_count: int = 0
|
|
juc500: list[dict[str, Any]] = field(default_factory=list)
|
|
sample_devices: list[str] = field(default_factory=list)
|
|
pnp_vid0711: list[dict[str, str]] = field(default_factory=list)
|
|
ioreg_vid0711: list[dict[str, str]] = field(default_factory=list)
|
|
hints: list[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
def doctor() -> DoctorReport:
|
|
report = DoctorReport(ok=False)
|
|
try:
|
|
be = _libusb_backend()
|
|
report.libusb_path = _BACKEND_PATH
|
|
all_devs = list(usb.core.find(find_all=True, backend=be))
|
|
report.usb_device_count = len(all_devs)
|
|
for d in all_devs[:25]:
|
|
prod = _safe_str(d, getattr(d, "iProduct", 0), "")
|
|
report.sample_devices.append(
|
|
f"{d.idVendor:04x}:{d.idProduct:04x} bus={d.bus} addr={d.address} {prod!r}"
|
|
)
|
|
for info in list_devices():
|
|
report.juc500.append(asdict(info))
|
|
except Exception as exc: # noqa: BLE001
|
|
report.libusb_error = str(exc)
|
|
report.hints.append(str(exc))
|
|
|
|
report.pnp_vid0711 = _windows_pnp_vid0711()
|
|
report.ioreg_vid0711 = _macos_ioreg_vid0711()
|
|
|
|
if report.juc500:
|
|
report.ok = True
|
|
if _IS_WIN:
|
|
report.hints.append(_juc500_winusb_hint())
|
|
report.hints.append(
|
|
"JUC500 케이블이 libusb로 보입니다. GUI에서 「연결」 또는 자동 연결을 기다리세요. "
|
|
"(양쪽 PC 모두 IF5 Smart Data Link)"
|
|
)
|
|
else:
|
|
if report.libusb_error:
|
|
report.hints.append(
|
|
"libusb DLL 문제일 수 있습니다. vendor\\libusb-1.0.dll 을 "
|
|
".venv\\Scripts\\ 에 복사한 뒤 다시 실행하세요."
|
|
)
|
|
elif report.usb_device_count == 0:
|
|
report.hints.append(
|
|
"libusb가 USB 장치를 하나도 보지 못합니다. "
|
|
"다른 USB 장치도 안 보이면 DLL/권한 문제입니다."
|
|
)
|
|
elif report.pnp_vid0711 and not any("error" in x for x in report.pnp_vid0711):
|
|
report.hints.append(
|
|
"Windows는 VID_0711 케이블을 인식하지만 libusb가 열지 못합니다. "
|
|
"Zadig로 WinUSB 설치: Smart Data Link → Interface 5(MI_05). "
|
|
"공식 Wormhole 드라이버가 있으면 제거하세요."
|
|
)
|
|
elif report.ioreg_vid0711 and not any("error" in x for x in report.ioreg_vid0711):
|
|
names = ", ".join(
|
|
f"{x.get('vid', '?')}:{x.get('pid', '?')} {x.get('name', '')}".strip()
|
|
for x in report.ioreg_vid0711[:3]
|
|
)
|
|
report.hints.append(
|
|
f"macOS IORegistry에는 JUC500 케이블이 보입니다 ({names}) — "
|
|
"libusb만 못 찾는 상태입니다. USB 허브/포트를 바꾸고 케이블을 다시 연결한 뒤 "
|
|
"GUI를 재시작하세요."
|
|
)
|
|
else:
|
|
report.hints.append(
|
|
"이 PC에서 JUC500(0711:7500)이 보이지 않습니다. "
|
|
"케이블을 이 PC USB 포트에 직접 연결했는지, "
|
|
"반대쪽은 상대 PC에 꽂혀 있는지 확인하세요. "
|
|
"(양쪽 끝이 각각 다른 PC에 연결되어야 합니다.)"
|
|
)
|
|
if _IS_WIN:
|
|
report.hints.append(
|
|
"장치 관리자에서 'Smart Data Link' / '알 수 없는 장치' / "
|
|
"WORMHOLE CD 롬이 있는지 확인하세요."
|
|
)
|
|
report.hints.append(_juc500_winusb_hint())
|
|
return report
|
|
|
|
|
|
def _backend_claim_interface(
|
|
dev: usb.core.Device,
|
|
*,
|
|
ifn: int,
|
|
claimed: list[int],
|
|
required_ifn: int | None = None,
|
|
handle: int | None = None,
|
|
) -> None:
|
|
"""Claim IF for bulk I/O. WinUSB per-interface (Zadig MI_05) may return NOT_SUPPORTED."""
|
|
ctx = dev._ctx
|
|
|
|
def _do(h: int) -> None:
|
|
if ifn in ctx._claimed_intf:
|
|
if ifn not in claimed:
|
|
claimed.append(ifn)
|
|
return
|
|
try:
|
|
ctx.backend.claim_interface(h, ifn)
|
|
ctx._claimed_intf.add(ifn)
|
|
except Exception as exc:
|
|
if _IS_WIN and (
|
|
_is_not_supported(exc) or isinstance(exc, NotImplementedError)
|
|
):
|
|
ctx._claimed_intf.add(ifn)
|
|
elif _is_access_denied(exc) and (
|
|
required_ifn is None or ifn == required_ifn
|
|
):
|
|
raise
|
|
else:
|
|
raise RuntimeError(_claim_failure_message(ifn, exc)) from exc
|
|
if ifn not in claimed:
|
|
claimed.append(ifn)
|
|
|
|
if handle is not None:
|
|
_do(handle)
|
|
return
|
|
with ctx.lock:
|
|
_do(ctx.managed_open())
|
|
|
|
|
|
class Juc500Link:
|
|
"""Claim Vendor bulk IF5 and provide framed bulk I/O for JUC500."""
|
|
|
|
def __init__(
|
|
self,
|
|
serial: Optional[str] = None,
|
|
*,
|
|
bus: Optional[int] = None,
|
|
address: Optional[int] = None,
|
|
model: Optional[str] = None,
|
|
timeout_ms: int = 2000,
|
|
):
|
|
del model # JUC500-only; kwarg kept for API compatibility
|
|
self.timeout_ms = timeout_ms
|
|
self._dev: Optional[usb.core.Device] = None
|
|
self._claimed: list[int] = []
|
|
self._serial = serial
|
|
self._pick_bus = bus
|
|
self._pick_address = address
|
|
self._layout = default_if5_layout()
|
|
self._rx_stash = bytearray()
|
|
self._rx_stash_lock = threading.Lock()
|
|
self.open()
|
|
|
|
def open(self) -> None:
|
|
matches = find_juc500_devices()
|
|
if not matches:
|
|
rep = doctor()
|
|
hints = "\n".join(f"- {h}" for h in rep.hints)
|
|
raise RuntimeError(
|
|
"JUC500(0711:7500 Smart Data Link) 장치를 찾지 못했습니다.\n" + hints
|
|
)
|
|
|
|
chosen_dev = find_juc500_device(
|
|
matches,
|
|
bus=self._pick_bus,
|
|
address=self._pick_address,
|
|
serial=(self._serial or "").strip(),
|
|
)
|
|
if chosen_dev is None:
|
|
raise RuntimeError(
|
|
"JUC500(0711:7500) 파일 전송용 USB 장치를 찾지 못했습니다.\n"
|
|
+ (_juc500_winusb_hint() if _IS_WIN else "케이블을 다시 연결하세요.")
|
|
)
|
|
|
|
if self._pick_bus is not None and self._pick_address is not None:
|
|
if (
|
|
int(chosen_dev.bus) != int(self._pick_bus)
|
|
or int(chosen_dev.address) != int(self._pick_address)
|
|
):
|
|
self._pick_bus = int(chosen_dev.bus)
|
|
self._pick_address = int(chosen_dev.address)
|
|
|
|
candidates: list[usb.core.Device] = [chosen_dev]
|
|
if self._serial:
|
|
serial_matches = [
|
|
d
|
|
for d in matches
|
|
if _safe_str(d, getattr(d, "iSerialNumber", 0)) == self._serial.strip()
|
|
and device_supports_file_transfer(d)
|
|
]
|
|
if serial_matches:
|
|
candidates = serial_matches
|
|
elif (
|
|
_safe_str(chosen_dev, getattr(chosen_dev, "iSerialNumber", 0))
|
|
!= self._serial.strip()
|
|
):
|
|
raise RuntimeError(
|
|
f"시리얼 '{self._serial}' 에 해당하는 JUC500 케이블이 없습니다."
|
|
)
|
|
|
|
self._dev = None
|
|
last_err: Exception | None = None
|
|
for chosen in candidates:
|
|
try:
|
|
self._try_open_device(chosen)
|
|
break
|
|
except Exception as exc: # noqa: BLE001
|
|
last_err = exc
|
|
self._force_close()
|
|
else:
|
|
detail = f"\n마지막 오류: {last_err}" if last_err else ""
|
|
if last_err and (
|
|
_is_not_supported(last_err)
|
|
or "not supported" in str(last_err).lower()
|
|
or "unimplemented" in str(last_err).lower()
|
|
):
|
|
if _IS_WIN:
|
|
detail += (
|
|
"\n\nZadig → List All Devices → 0711:7500 → "
|
|
"Interface 5 (MI_05) → WinUSB → Replace Driver"
|
|
)
|
|
raise RuntimeError(
|
|
f"JUC500 USB Interface {self._layout.if_num} 를 열 수 없습니다."
|
|
+ detail
|
|
) from last_err
|
|
|
|
if not _IS_WIN and self._layout.if_num == IF_VENDOR:
|
|
self._claim(IF_CDC_COMM, required=False)
|
|
self._claim(IF_CDC_DATA, required=False)
|
|
|
|
def _try_open_device(self, chosen: usb.core.Device) -> None:
|
|
self._dev = chosen
|
|
self._claimed = []
|
|
self._layout = detect_cable_layout(chosen)
|
|
if _IS_WIN:
|
|
self._configure_windows()
|
|
else:
|
|
self._configure_posix()
|
|
self._claim(self._layout.if_num, required=True)
|
|
self._ensure_backend_ready()
|
|
self._probe_link()
|
|
|
|
def wake_bridge(self) -> None:
|
|
"""Best-effort ctrl/data poke so the peer host starts draining IN."""
|
|
try:
|
|
self.write_ctrl(b"\x00ALIVE", timeout_ms=120)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.read_ctrl(MAX_PACKET, timeout_ms=60)
|
|
except Exception:
|
|
pass
|
|
|
|
def _probe_link(self) -> None:
|
|
"""Soft-wake IF5 — bulk OUT often stalls until peer claims IN / WinUSB is bound."""
|
|
self.wake_bridge()
|
|
|
|
def _reopen_device(self) -> None:
|
|
"""Re-enumerate after hotplug / Entity not found on bulk I/O."""
|
|
if self._dev is None:
|
|
raise RuntimeError("USB 장치 없음")
|
|
bus = int(self._dev.bus)
|
|
addr = int(self._dev.address)
|
|
serial = _safe_str(self._dev, getattr(self._dev, "iSerialNumber", 0))
|
|
self._force_close()
|
|
matches = find_juc500_devices()
|
|
if not matches:
|
|
raise RuntimeError("USB 장치를 다시 찾지 못했습니다. 케이블을 확인하세요.")
|
|
chosen = find_juc500_device(
|
|
matches,
|
|
bus=bus,
|
|
address=addr,
|
|
serial=serial,
|
|
)
|
|
if chosen is None:
|
|
seed = find_juc500_device(matches, serial=serial)
|
|
if seed is None:
|
|
seed = next((x for x in matches if int(x.bus) == bus), matches[0])
|
|
for d in [seed] + [x for x in matches if x is not seed]:
|
|
try:
|
|
self._try_open_device(d)
|
|
return
|
|
except Exception:
|
|
self._force_close()
|
|
raise RuntimeError(
|
|
"USB 장치 주소가 바뀌었습니다. GUI를 재시작하거나 케이블을 다시 연결하세요."
|
|
)
|
|
self._try_open_device(chosen)
|
|
|
|
def _force_close(self) -> None:
|
|
try:
|
|
self.close()
|
|
except Exception:
|
|
pass
|
|
self._dev = None
|
|
self._claimed = []
|
|
|
|
def _configure_windows(self) -> None:
|
|
assert self._dev is not None
|
|
try:
|
|
self._dev._ctx.managed_open()
|
|
except Exception:
|
|
pass
|
|
self._ensure_cfg_cache(force=True)
|
|
|
|
def _ensure_cfg_cache(self, *, force: bool = False) -> None:
|
|
if self._dev is None:
|
|
return
|
|
try:
|
|
ctx = self._dev._ctx
|
|
if force or getattr(ctx, "_active_cfg_index", None) is None:
|
|
ctx._active_cfg_index = 0
|
|
except Exception:
|
|
pass
|
|
|
|
def _ensure_backend_ready(self) -> None:
|
|
assert self._dev is not None
|
|
ifn = self._layout.if_num
|
|
try:
|
|
try:
|
|
self._dev._ctx.managed_open()
|
|
except Exception as open_exc:
|
|
if not (
|
|
_IS_WIN
|
|
and (
|
|
_is_not_supported(open_exc)
|
|
or isinstance(open_exc, NotImplementedError)
|
|
)
|
|
):
|
|
raise
|
|
_backend_claim_interface(
|
|
self._dev,
|
|
ifn=ifn,
|
|
claimed=self._claimed,
|
|
required_ifn=ifn,
|
|
)
|
|
except Exception as exc:
|
|
if _IS_WIN and (
|
|
_is_not_supported(exc) or isinstance(exc, NotImplementedError)
|
|
):
|
|
if ifn not in self._claimed:
|
|
self._claimed.append(ifn)
|
|
self._ensure_cfg_cache(force=True)
|
|
return
|
|
if _is_access_denied(exc) and ifn == self._layout.if_num:
|
|
try:
|
|
from .wormhole_kill import kill_wormhole_processes
|
|
|
|
kill_wormhole_processes(force=True)
|
|
time.sleep(0.6)
|
|
_backend_claim_interface(
|
|
self._dev,
|
|
ifn=ifn,
|
|
claimed=self._claimed,
|
|
required_ifn=ifn,
|
|
)
|
|
self._ensure_cfg_cache(force=True)
|
|
return
|
|
except Exception:
|
|
pass
|
|
if isinstance(exc, RuntimeError):
|
|
raise
|
|
raise RuntimeError(_claim_failure_message(ifn, exc)) from exc
|
|
self._ensure_cfg_cache(force=True)
|
|
|
|
def _configure_posix(self) -> None:
|
|
assert self._dev is not None
|
|
try:
|
|
cfg = self._dev.get_active_configuration()
|
|
except usb.core.USBError:
|
|
self._dev.set_configuration(1)
|
|
cfg = self._dev.get_active_configuration()
|
|
if cfg.bConfigurationValue != 1:
|
|
self._dev.set_configuration(1)
|
|
|
|
def _claim(self, ifnum: int, *, required: bool) -> None:
|
|
assert self._dev is not None
|
|
if not _IS_WIN:
|
|
try:
|
|
if self._dev.is_kernel_driver_active(ifnum):
|
|
self._dev.detach_kernel_driver(ifnum)
|
|
except (NotImplementedError, usb.core.USBError):
|
|
pass
|
|
try:
|
|
usb.util.claim_interface(self._dev, ifnum)
|
|
except (usb.core.USBError, NotImplementedError) as exc:
|
|
if not required and (
|
|
_is_not_supported(exc) or isinstance(exc, NotImplementedError)
|
|
):
|
|
return
|
|
if _is_access_denied(exc) and ifnum == self._layout.if_num:
|
|
try:
|
|
from .wormhole_kill import kill_wormhole_processes
|
|
|
|
kill_wormhole_processes(force=True)
|
|
time.sleep(0.6)
|
|
if not _IS_WIN:
|
|
try:
|
|
if self._dev.is_kernel_driver_active(ifnum):
|
|
self._dev.detach_kernel_driver(ifnum)
|
|
except (NotImplementedError, usb.core.USBError):
|
|
pass
|
|
usb.util.claim_interface(self._dev, ifnum)
|
|
self._claimed.append(ifnum)
|
|
return
|
|
except Exception:
|
|
pass
|
|
if required:
|
|
raise RuntimeError(_claim_failure_message(ifnum, exc)) from exc
|
|
return
|
|
self._claimed.append(ifnum)
|
|
|
|
@property
|
|
def serial(self) -> str:
|
|
assert self._dev is not None
|
|
return _safe_str(self._dev, getattr(self._dev, "iSerialNumber", 0))
|
|
|
|
@property
|
|
def product(self) -> str:
|
|
assert self._dev is not None
|
|
return _safe_str(self._dev, getattr(self._dev, "iProduct", 0), "Smart Data Link")
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return CABLE_MODEL_JUC500
|
|
|
|
@property
|
|
def vid_pid(self) -> tuple[int, int]:
|
|
assert self._dev is not None
|
|
return int(self._dev.idVendor), int(self._dev.idProduct)
|
|
|
|
@property
|
|
def layout(self) -> CableLayout:
|
|
return self._layout
|
|
|
|
def write_data(
|
|
self,
|
|
payload: bytes,
|
|
timeout_ms: Optional[int] = None,
|
|
*,
|
|
broadcast: bool = False,
|
|
) -> int:
|
|
del broadcast # JUC500-only; kwarg kept for API compatibility
|
|
if self._dev is None:
|
|
return 0
|
|
return self._bulk_write(self._layout.ep_data_out, payload, timeout_ms)
|
|
|
|
def read_data(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
|
|
if self._dev is None:
|
|
return b""
|
|
if size == MAX_PACKET:
|
|
size = self._layout.max_packet
|
|
with self._rx_stash_lock:
|
|
if self._rx_stash:
|
|
n = min(size, len(self._rx_stash))
|
|
out = bytes(self._rx_stash[:n])
|
|
del self._rx_stash[:n]
|
|
return out
|
|
return self._bulk_read(self._layout.ep_data_in, size, timeout_ms)
|
|
|
|
def _stash_rx(self, data: bytes) -> None:
|
|
if not data:
|
|
return
|
|
with self._rx_stash_lock:
|
|
self._rx_stash.extend(data)
|
|
if len(self._rx_stash) > 8 * 1024 * 1024:
|
|
del self._rx_stash[:-4 * 1024 * 1024]
|
|
|
|
def write_ctrl(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
|
|
return self._bulk_write(self._layout.ep_ctrl_out, payload, timeout_ms)
|
|
|
|
def read_ctrl(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
|
|
if size == MAX_PACKET:
|
|
size = self._layout.max_packet
|
|
return self._bulk_read(self._layout.ep_ctrl_in, size, timeout_ms)
|
|
|
|
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
|
|
if self._dev is None:
|
|
return b""
|
|
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
|
try:
|
|
return self._backend_bulk_read(ep, size, to)
|
|
except usb.core.USBTimeoutError:
|
|
return b""
|
|
except usb.core.USBError as exc:
|
|
if _is_overflow(exc):
|
|
for bigger in (max(size, 512) * 2, 4096, 16384, 65536):
|
|
if bigger <= size:
|
|
continue
|
|
try:
|
|
return self._backend_bulk_read(ep, bigger, to)
|
|
except usb.core.USBTimeoutError:
|
|
return b""
|
|
except usb.core.USBError as exc2:
|
|
if _is_overflow(exc2):
|
|
size = bigger
|
|
continue
|
|
if _is_not_supported(exc2):
|
|
raise RuntimeError(
|
|
f"USB 읽기 실패(EP 0x{ep:02x}): {exc2}\n"
|
|
"Windows: Interface 5(MI_05)에 WinUSB가 잡혀 있는지 "
|
|
"Zadig로 확인하세요."
|
|
) from exc2
|
|
return b""
|
|
return b""
|
|
if _is_not_supported(exc):
|
|
raise RuntimeError(
|
|
f"USB 읽기 실패(EP 0x{ep:02x}): {exc}\n"
|
|
"Windows: Interface 5(MI_05)에 WinUSB가 잡혀 있는지 "
|
|
"Zadig로 확인하세요."
|
|
) from exc
|
|
return b""
|
|
|
|
def _backend_bulk_write(self, ep: int, data: bytes, timeout_ms: int) -> int:
|
|
assert self._dev is not None
|
|
ifn = self._layout.if_num
|
|
ctx = self._dev._ctx
|
|
with ctx.lock:
|
|
handle = ctx.managed_open()
|
|
_backend_claim_interface(
|
|
self._dev,
|
|
ifn=ifn,
|
|
claimed=self._claimed,
|
|
required_ifn=ifn,
|
|
handle=handle,
|
|
)
|
|
backend = ctx.backend
|
|
buff = array.array("B", data)
|
|
return int(backend.bulk_write(handle, ep, ifn, buff, timeout_ms))
|
|
|
|
def _backend_bulk_read(self, ep: int, size: int, timeout_ms: int) -> bytes:
|
|
assert self._dev is not None
|
|
ifn = self._layout.if_num
|
|
ctx = self._dev._ctx
|
|
with ctx.lock:
|
|
handle = ctx.managed_open()
|
|
_backend_claim_interface(
|
|
self._dev,
|
|
ifn=ifn,
|
|
claimed=self._claimed,
|
|
required_ifn=ifn,
|
|
handle=handle,
|
|
)
|
|
backend = ctx.backend
|
|
buff = usb.util.create_buffer(size)
|
|
n = int(backend.bulk_read(handle, ep, ifn, buff, timeout_ms))
|
|
if n <= 0:
|
|
return b""
|
|
return bytes(buff[:n])
|
|
|
|
def clear_data_halt(self) -> None:
|
|
if self._dev is None:
|
|
return
|
|
try:
|
|
ctx = self._dev._ctx
|
|
with ctx.lock:
|
|
handle = ctx.managed_open()
|
|
backend = ctx.backend
|
|
for ep in (
|
|
self._layout.ep_data_out,
|
|
self._layout.ep_data_in,
|
|
self._layout.ep_ctrl_out,
|
|
self._layout.ep_ctrl_in,
|
|
):
|
|
try:
|
|
backend.clear_halt(handle, ep)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
def _bulk_write(self, ep: int, payload: bytes, timeout_ms: Optional[int]) -> int:
|
|
assert self._dev is not None
|
|
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
|
mp = self._layout.max_packet
|
|
if len(payload) > mp:
|
|
to = max(to, min(15000, 2000 + (len(payload) // mp) * 4))
|
|
sent = 0
|
|
view = memoryview(payload)
|
|
while sent < len(payload):
|
|
chunk = bytes(view[sent : sent + BULK_XFER_SIZE])
|
|
try:
|
|
n = self._backend_bulk_write(ep, chunk, to)
|
|
except usb.core.USBTimeoutError:
|
|
self.clear_data_halt()
|
|
if ep == self._layout.ep_data_out:
|
|
for _ in range(16):
|
|
try:
|
|
got = self._backend_bulk_read(
|
|
self._layout.ep_data_in, BULK_XFER_SIZE, 40
|
|
)
|
|
except Exception:
|
|
break
|
|
if not got:
|
|
break
|
|
self._stash_rx(got)
|
|
try:
|
|
n = self._backend_bulk_write(ep, chunk, max(to, 3000))
|
|
except usb.core.USBTimeoutError:
|
|
n = 0
|
|
off = 0
|
|
while off < len(chunk):
|
|
piece = chunk[off : off + mp]
|
|
n += self._backend_bulk_write(ep, piece, max(to, 1500))
|
|
off += len(piece)
|
|
except usb.core.USBError as exc:
|
|
if _is_not_found(exc):
|
|
try:
|
|
self._reopen_device()
|
|
n = self._backend_bulk_write(ep, chunk, to)
|
|
except Exception as exc2:
|
|
raise RuntimeError(
|
|
f"USB 쓰기 실패(EP 0x{ep:02x}): Entity not found — "
|
|
"케이블·IF5 WinUSB·상대 GUI 실행 상태를 확인하세요."
|
|
) from exc2
|
|
elif _is_not_supported(exc):
|
|
raise RuntimeError(
|
|
f"USB 쓰기 실패(EP 0x{ep:02x}): {exc}\n"
|
|
"Windows: Interface 5(MI_05)에 WinUSB가 잡혀 있는지 Zadig로 확인하세요.\n"
|
|
"최신 dist-windows 빌드인지, 공식 Wormhole가 종료됐는지도 확인하세요."
|
|
) from exc
|
|
raise
|
|
sent += int(n)
|
|
if n == 0:
|
|
break
|
|
return sent
|
|
|
|
def drain(self, duration_s: float = 0.2) -> None:
|
|
deadline = time.monotonic() + duration_s
|
|
while time.monotonic() < deadline:
|
|
got = False
|
|
for reader in (self.read_data, self.read_ctrl):
|
|
try:
|
|
chunk = reader(self._layout.max_packet, timeout_ms=50)
|
|
except RuntimeError:
|
|
break
|
|
if chunk:
|
|
got = True
|
|
if not got:
|
|
break
|
|
|
|
def close(self) -> None:
|
|
if self._dev is None:
|
|
return
|
|
for ifnum in list(self._claimed):
|
|
try:
|
|
usb.util.release_interface(self._dev, ifnum)
|
|
except Exception:
|
|
pass
|
|
self._claimed.clear()
|
|
try:
|
|
usb.util.dispose_resources(self._dev)
|
|
except Exception:
|
|
pass
|
|
self._dev = None
|
|
|
|
def __enter__(self) -> "Juc500Link":
|
|
return self
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
self.close()
|