diff --git a/juc500_xfer/usb_link.py b/juc500_xfer/usb_link.py index 732cf35..f2d44b1 100644 --- a/juc500_xfer/usb_link.py +++ b/juc500_xfer/usb_link.py @@ -9,9 +9,9 @@ The cable enumerates as USB VID:PID 0711:7500 with six interfaces: 4 HID keyboard (KM switch, claimed by OS) 5 Vendor Specific (bulk 0x08/0x89 + 0x0A/0x8B) – data bridge -Official Wormhole software is Intel/x86_64-only and uses the deprecated -IOUSBDevice stack, which does not work reliably on Apple Silicon. This -module opens interfaces 1 and 5 via libusb for a Silicon-native pathway. +On Windows, WinUSB does not implement libusb_get_configuration / +kernel-driver detach. Only Interface 5 (Vendor) is required for our +protocol; CDC interfaces are optional. """ from __future__ import annotations @@ -28,25 +28,22 @@ import usb.util VID = 0x0711 PID = 0x7500 -# Interface numbers IF_CDC_COMM = 0 IF_CDC_DATA = 1 IF_VENDOR = 5 -# Vendor interface endpoints (file payload) EP_DATA_OUT = 0x08 EP_DATA_IN = 0x89 - -# Vendor interface endpoints (keepalive / control mux) EP_CTRL_OUT = 0x0A EP_CTRL_IN = 0x8B -# CDC data endpoints (alternate / reserved) EP_CDC_OUT = 0x02 EP_CDC_IN = 0x83 MAX_PACKET = 512 +_IS_WIN = sys.platform == "win32" + def _libusb_backend(): """Locate libusb-1.0 on macOS / Windows / Linux.""" @@ -72,7 +69,7 @@ def _libusb_backend(): "/opt/homebrew/lib/libusb-1.0.dylib", "/usr/local/lib/libusb-1.0.dylib", ] - elif sys.platform == "win32": + elif _IS_WIN: here = os.path.dirname(os.path.abspath(__file__)) root = os.path.dirname(here) candidates += [ @@ -84,14 +81,14 @@ def _libusb_backend(): candidates += ["libusb-1.0.so", "libusb-1.0.so.0"] for path in candidates: - if path and (path.endswith((".dll", ".dylib", ".so", ".so.0")) or os.path.exists(path)): - if path.endswith((".dll", ".dylib", ".so", ".so.0")) and not os.path.exists(path): - # bare DLL name: allow loader PATH search via find_library - if os.path.sep in path or (len(path) > 1 and path[1] == ":"): - continue - be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p) - if be is not None: - return be + if not path: + continue + absolute = os.path.sep in path or (len(path) > 1 and path[1] == ":") + if absolute and not os.path.exists(path): + continue + be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p) + if be is not None: + return be be = usb.backend.libusb1.get_backend() if be is None: @@ -102,6 +99,23 @@ def _libusb_backend(): return be +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_not_supported(exc: BaseException) -> bool: + text = _usb_err_text(exc) + return ( + "not supported" in text + or "unimplemented" in text + or "enosys" in text + ) + + @dataclass class DeviceInfo: bus: int @@ -140,7 +154,7 @@ def list_devices() -> list[DeviceInfo]: class Juc500Link: - """Claim CDC+Vendor interfaces and provide framed bulk I/O.""" + """Claim Vendor (+ optional CDC) interfaces and provide framed bulk I/O.""" def __init__(self, serial: Optional[str] = None, timeout_ms: int = 2000): self.timeout_ms = timeout_ms @@ -158,6 +172,11 @@ class Juc500Link: raise RuntimeError( "JUC500(0711:7500 Smart Data Link) 를 찾지 못했습니다. " "케이블이 연결되어 있는지 확인하세요." + + ( + " Windows: Zadig로 Interface 5에 WinUSB를 설치했는지 확인하세요." + if _IS_WIN + else "" + ) ) chosen = None @@ -175,32 +194,69 @@ class Juc500Link: raise RuntimeError(f"시리얼 '{self._serial}' 에 해당하는 JUC500 이 없습니다.") self._dev = chosen - # Avoid set_configuration() if the OS already activated config 1 - # (mass-storage CD is mounted). Re-setting can disrupt the volume. + + if _IS_WIN: + self._configure_windows() + else: + self._configure_posix() + + # Protocol uses Vendor IF5 only. CDC is best-effort. + self._claim(IF_VENDOR, required=True) + self._claim(IF_CDC_DATA, required=False) + self._claim(IF_CDC_COMM, required=False) + + def _configure_windows(self) -> None: + """WinUSB: libusb_get_configuration() is often unimplemented. + + pyusb caches the active config index after set_configuration(). + If set fails (already configured), we seed the cache so later + write()/read() do not call get_configuration() and raise + 'Operation not supported or unimplemented on this platform'. + """ + assert self._dev is not None + try: + self._dev.set_configuration(1) + except usb.core.USBError: + pass + # Ensure pyusb ResourceManager will not call get_configuration(). + try: + ctx = self._dev._ctx + if getattr(ctx, "_active_cfg_index", None) is None: + ctx._active_cfg_index = 0 # device has a single config + except Exception: + pass + + 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() + self._dev.set_configuration(1) cfg = self._dev.get_active_configuration() if cfg.bConfigurationValue != 1: self._dev.set_configuration(1) - for ifnum in (IF_CDC_DATA, IF_VENDOR): - self._claim(ifnum) - # CDC Comm is optional (interrupt notifications) - try: - self._claim(IF_CDC_COMM) - except usb.core.USBError: - pass - - def _claim(self, ifnum: int) -> None: + 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: - 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) + usb.util.claim_interface(self._dev, ifnum) + except usb.core.USBError as exc: + if required: + hint = "" + if _IS_WIN: + hint = ( + f"\nZadig에서 Smart Data Link → Interface {ifnum} 에 " + "WinUSB 드라이버를 설치하세요. " + "(데이터 전송은 Interface 5가 필수입니다.)" + ) + raise RuntimeError(f"USB interface {ifnum} 점유 실패: {exc}{hint}") from exc + return self._claimed.append(ifnum) @property @@ -226,12 +282,19 @@ class Juc500Link: 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 - # Split into max-packet chunks for bridge chips that dislike large URBs sent = 0 view = memoryview(payload) while sent < len(payload): chunk = view[sent : sent + MAX_PACKET] - n = self._dev.write(ep, chunk.tobytes(), timeout=to) + try: + n = self._dev.write(ep, chunk.tobytes(), timeout=to) + except usb.core.USBError as exc: + if _is_not_supported(exc): + raise RuntimeError( + f"USB 쓰기 실패(EP 0x{ep:02x}): {exc}\n" + "Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요." + ) from exc + raise sent += n if n == 0: break @@ -245,13 +308,24 @@ class Juc500Link: return bytes(data) except usb.core.USBTimeoutError: return b"" + except usb.core.USBError as exc: + if _is_not_supported(exc): + raise RuntimeError( + f"USB 읽기 실패(EP 0x{ep:02x}): {exc}\n" + "Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요." + ) from exc + # Transient pipe errors — treat like empty during poll + return b"" 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): - chunk = reader(MAX_PACKET, timeout_ms=50) + try: + chunk = reader(MAX_PACKET, timeout_ms=50) + except RuntimeError: + break if chunk: got = True if not got: diff --git a/packaging/windows/README-WIN.txt b/packaging/windows/README-WIN.txt index 2877e2c..c135535 100644 --- a/packaging/windows/README-WIN.txt +++ b/packaging/windows/README-WIN.txt @@ -5,11 +5,14 @@ JUC500 — Windows 11 사용 안내 2) 공식 j5create Wormhole 소프트웨어가 떠 있으면 종료/제거합니다. 3) Zadig(https://zadig.akeo.ie/) 로 Smart Data Link 장치에 WinUSB 를 설치합니다. - Options → List All Devices - - Smart Data Link → Interface 5 (Vendor) → WinUSB → Replace Driver - - (권장) Interface 1 (CDC Data) 도 WinUSB + - Smart Data Link → Interface 5 (Vendor Specific / MI_05) → WinUSB → Replace Driver - Interface 2(Mass Storage)·3·4(HID) 는 그대로 둡니다. + - (선택) Interface 1 도 WinUSB 가능하지만, 필수는 Interface 5 입니다. 4) JUC500.exe 를 실행합니다. 브라우저가 열리면 「연결」을 누릅니다. 5) Mac 쪽에서도 같은 프로그램을 실행한 뒤 연결합니다. +오류 "Operation not supported or unimplemented on this platform" + → 구버전 버그이거나 Interface 5 WinUSB 미설치. 최신 Setup/portable 로 업데이트하고 + Zadig에서 Interface 5 가 WinUSB 인지 다시 확인하세요. + 포터블: JUC500-portable.exe 는 설치 없이 실행할 수 있습니다. -(같은 WinUSB/libusb 설정이 필요합니다.)