Bypass pyusb Device I/O on WinUSB with direct libusb bulk transfers
Device.write/read always call get_configuration which WinUSB does not implement. Route all framed I/O through the backend with IF5 claimed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -193,6 +193,11 @@ class Session:
|
|||||||
try:
|
try:
|
||||||
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
|
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
# Peer not reading yet (FIFO full) → USB timeout; keep retrying quietly.
|
||||||
|
msg = str(exc)
|
||||||
|
if "timed out" in msg.lower() or "timeout" in msg.lower():
|
||||||
|
self.log("상대 수신 대기 중… (HELLO)")
|
||||||
|
else:
|
||||||
self.log(f"HELLO 재전송 실패: {exc}")
|
self.log(f"HELLO 재전송 실패: {exc}")
|
||||||
self._connected.wait(timeout=min(2.0, remaining))
|
self._connected.wait(timeout=min(2.0, remaining))
|
||||||
return self._peer_hello or "peer"
|
return self._peer_hello or "peer"
|
||||||
|
|||||||
+100
-56
@@ -6,6 +6,7 @@ Device: VID 0x0711 / PID 0x7500 (product "Smart Data Link"), composite:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import array
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -432,55 +433,93 @@ class Juc500Link:
|
|||||||
"JUC500(Smart Data Link / 0711:7500) 장치를 찾지 못했습니다.\n" + hints
|
"JUC500(Smart Data Link / 0711:7500) 장치를 찾지 못했습니다.\n" + hints
|
||||||
)
|
)
|
||||||
|
|
||||||
chosen = None
|
candidates = matches
|
||||||
for dev in matches:
|
|
||||||
if self._serial:
|
if self._serial:
|
||||||
s = _safe_str(dev, getattr(dev, "iSerialNumber", 0))
|
candidates = [
|
||||||
if s != self._serial.strip():
|
d
|
||||||
continue
|
for d in matches
|
||||||
chosen = dev
|
if _safe_str(d, getattr(d, "iSerialNumber", 0)) == self._serial.strip()
|
||||||
break
|
]
|
||||||
if chosen is None:
|
if not candidates:
|
||||||
raise RuntimeError(f"시리얼 '{self._serial}' 에 해당하는 JUC500 이 없습니다.")
|
raise RuntimeError(f"시리얼 '{self._serial}' 에 해당하는 JUC500 이 없습니다.")
|
||||||
|
|
||||||
self._dev = chosen
|
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 ""
|
||||||
|
raise RuntimeError(
|
||||||
|
"JUC500 USB Interface 5 를 열 수 없습니다." + detail
|
||||||
|
) from last_err
|
||||||
|
|
||||||
|
# Optional CDC — ignore failures (WinUSB often only on IF5)
|
||||||
|
self._claim(IF_CDC_DATA, required=False)
|
||||||
|
self._claim(IF_CDC_COMM, required=False)
|
||||||
|
|
||||||
|
def _try_open_device(self, chosen: usb.core.Device) -> None:
|
||||||
|
self._dev = chosen
|
||||||
|
self._claimed = []
|
||||||
if _IS_WIN:
|
if _IS_WIN:
|
||||||
self._configure_windows()
|
self._configure_windows()
|
||||||
else:
|
else:
|
||||||
self._configure_posix()
|
self._configure_posix()
|
||||||
|
|
||||||
self._claim(IF_VENDOR, required=True)
|
self._claim(IF_VENDOR, required=True)
|
||||||
self._claim(IF_CDC_DATA, required=False)
|
# Force backend path readiness (no Device.write get_configuration)
|
||||||
self._claim(IF_CDC_COMM, required=False)
|
self._ensure_backend_ready()
|
||||||
|
|
||||||
|
def _force_close(self) -> None:
|
||||||
|
try:
|
||||||
|
self.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._dev = None
|
||||||
|
self._claimed = []
|
||||||
|
|
||||||
def _configure_windows(self) -> None:
|
def _configure_windows(self) -> None:
|
||||||
"""Avoid libusb_get_configuration — unimplemented on WinUSB.
|
"""WinUSB: never call get_configuration / prefer skipping set_configuration.
|
||||||
|
|
||||||
Every pyusb read/write resolves endpoints via get_active_configuration(),
|
pyusb Device.read/write resolve endpoints via get_active_configuration(),
|
||||||
which calls backend.get_configuration() when the cache is empty and then
|
which calls libusb_get_configuration() — NOT_SUPPORTED on WinUSB.
|
||||||
raises 'Operation not supported or unimplemented on this platform'.
|
We seed caches and perform all bulk I/O through the backend directly.
|
||||||
Always seed _active_cfg_index after open.
|
|
||||||
"""
|
"""
|
||||||
assert self._dev is not None
|
assert self._dev is not None
|
||||||
|
# set_configuration is frequently NOT_SUPPORTED when the composite is
|
||||||
|
# already configured by Windows — skip rather than confuse WinUSB.
|
||||||
try:
|
try:
|
||||||
self._dev.set_configuration(1)
|
self._dev._ctx.managed_open()
|
||||||
except (usb.core.USBError, NotImplementedError):
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._ensure_cfg_cache(force=True)
|
self._ensure_cfg_cache(force=True)
|
||||||
|
|
||||||
def _ensure_cfg_cache(self, *, force: bool = False) -> None:
|
def _ensure_cfg_cache(self, *, force: bool = False) -> None:
|
||||||
"""Seed pyusb config index so I/O never calls get_configuration on WinUSB."""
|
"""Seed pyusb config index so leftover Device.* paths skip get_configuration."""
|
||||||
if self._dev is None:
|
if self._dev is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
ctx = self._dev._ctx
|
ctx = self._dev._ctx
|
||||||
if force or getattr(ctx, "_active_cfg_index", None) is None:
|
if force or getattr(ctx, "_active_cfg_index", None) is None:
|
||||||
# Composite JUC500 exposes a single configuration at index 0.
|
|
||||||
ctx._active_cfg_index = 0
|
ctx._active_cfg_index = 0
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _ensure_backend_ready(self) -> None:
|
||||||
|
assert self._dev is not None
|
||||||
|
ctx = self._dev._ctx
|
||||||
|
with ctx.lock:
|
||||||
|
handle = ctx.managed_open()
|
||||||
|
if IF_VENDOR not in ctx._claimed_intf:
|
||||||
|
ctx.backend.claim_interface(handle, IF_VENDOR)
|
||||||
|
ctx._claimed_intf.add(IF_VENDOR)
|
||||||
|
if IF_VENDOR not in self._claimed:
|
||||||
|
self._claimed.append(IF_VENDOR)
|
||||||
|
self._ensure_cfg_cache(force=True)
|
||||||
|
|
||||||
def _configure_posix(self) -> None:
|
def _configure_posix(self) -> None:
|
||||||
assert self._dev is not None
|
assert self._dev is not None
|
||||||
try:
|
try:
|
||||||
@@ -543,46 +582,36 @@ class Juc500Link:
|
|||||||
return self._bulk_read(EP_CTRL_IN, size, timeout_ms)
|
return self._bulk_read(EP_CTRL_IN, size, timeout_ms)
|
||||||
|
|
||||||
def _bulk_write(self, ep: int, payload: bytes, timeout_ms: Optional[int]) -> int:
|
def _bulk_write(self, ep: int, payload: bytes, timeout_ms: Optional[int]) -> int:
|
||||||
|
"""Bulk OUT via libusb backend (skips pyusb get_configuration on WinUSB)."""
|
||||||
assert self._dev is not None
|
assert self._dev is not None
|
||||||
if _IS_WIN:
|
|
||||||
self._ensure_cfg_cache()
|
|
||||||
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
||||||
sent = 0
|
sent = 0
|
||||||
view = memoryview(payload)
|
view = memoryview(payload)
|
||||||
while sent < len(payload):
|
while sent < len(payload):
|
||||||
chunk = view[sent : sent + MAX_PACKET]
|
chunk = bytes(view[sent : sent + MAX_PACKET])
|
||||||
try:
|
try:
|
||||||
n = self._dev.write(ep, chunk.tobytes(), timeout=to)
|
n = self._backend_bulk_write(ep, chunk, to)
|
||||||
|
except usb.core.USBTimeoutError:
|
||||||
|
raise
|
||||||
except usb.core.USBError as exc:
|
except usb.core.USBError as exc:
|
||||||
if _IS_WIN and _is_not_supported(exc):
|
if _is_not_supported(exc):
|
||||||
self._ensure_cfg_cache(force=True)
|
|
||||||
try:
|
|
||||||
n = self._dev.write(ep, chunk.tobytes(), timeout=to)
|
|
||||||
except usb.core.USBError as exc2:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"USB 쓰기 실패(EP 0x{ep:02x}): {exc2}\n"
|
|
||||||
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
|
||||||
) from exc2
|
|
||||||
elif _is_not_supported(exc):
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"USB 쓰기 실패(EP 0x{ep:02x}): {exc}\n"
|
f"USB 쓰기 실패(EP 0x{ep:02x}): {exc}\n"
|
||||||
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
"Windows: Interface 5(MI_05)에 WinUSB가 잡혀 있는지 Zadig로 확인하세요.\n"
|
||||||
|
"최신 dist-windows 빌드인지, 공식 Wormhole가 종료됐는지도 확인하세요."
|
||||||
) from exc
|
) from exc
|
||||||
else:
|
|
||||||
raise
|
raise
|
||||||
sent += n
|
sent += int(n)
|
||||||
if n == 0:
|
if n == 0:
|
||||||
break
|
break
|
||||||
return sent
|
return sent
|
||||||
|
|
||||||
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
|
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
|
||||||
|
"""Bulk IN via libusb backend (skips pyusb get_configuration on WinUSB)."""
|
||||||
assert self._dev is not None
|
assert self._dev is not None
|
||||||
if _IS_WIN:
|
|
||||||
self._ensure_cfg_cache()
|
|
||||||
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
||||||
try:
|
try:
|
||||||
data = self._dev.read(ep, size, timeout=to)
|
return self._backend_bulk_read(ep, size, to)
|
||||||
return bytes(data)
|
|
||||||
except usb.core.USBTimeoutError:
|
except usb.core.USBTimeoutError:
|
||||||
return b""
|
return b""
|
||||||
except usb.core.USBError as exc:
|
except usb.core.USBError as exc:
|
||||||
@@ -591,8 +620,7 @@ class Juc500Link:
|
|||||||
if bigger <= size:
|
if bigger <= size:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
data = self._dev.read(ep, bigger, timeout=to)
|
return self._backend_bulk_read(ep, bigger, to)
|
||||||
return bytes(data)
|
|
||||||
except usb.core.USBTimeoutError:
|
except usb.core.USBTimeoutError:
|
||||||
return b""
|
return b""
|
||||||
except usb.core.USBError as exc2:
|
except usb.core.USBError as exc2:
|
||||||
@@ -600,28 +628,44 @@ class Juc500Link:
|
|||||||
size = bigger
|
size = bigger
|
||||||
continue
|
continue
|
||||||
if _is_not_supported(exc2):
|
if _is_not_supported(exc2):
|
||||||
break
|
|
||||||
return b""
|
|
||||||
return b""
|
|
||||||
if _is_not_supported(exc):
|
|
||||||
if _IS_WIN:
|
|
||||||
self._ensure_cfg_cache(force=True)
|
|
||||||
try:
|
|
||||||
data = self._dev.read(ep, size, timeout=to)
|
|
||||||
return bytes(data)
|
|
||||||
except usb.core.USBTimeoutError:
|
|
||||||
return b""
|
|
||||||
except usb.core.USBError as exc2:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"USB 읽기 실패(EP 0x{ep:02x}): {exc2}\n"
|
f"USB 읽기 실패(EP 0x{ep:02x}): {exc2}\n"
|
||||||
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
||||||
) from exc2
|
) from exc2
|
||||||
|
return b""
|
||||||
|
return b""
|
||||||
|
if _is_not_supported(exc):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"USB 읽기 실패(EP 0x{ep:02x}): {exc}\n"
|
f"USB 읽기 실패(EP 0x{ep:02x}): {exc}\n"
|
||||||
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
||||||
) from exc
|
) from exc
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
def _backend_bulk_write(self, ep: int, data: bytes, timeout_ms: int) -> int:
|
||||||
|
assert self._dev is not None
|
||||||
|
ctx = self._dev._ctx
|
||||||
|
with ctx.lock:
|
||||||
|
handle = ctx.managed_open()
|
||||||
|
if IF_VENDOR not in ctx._claimed_intf:
|
||||||
|
ctx.backend.claim_interface(handle, IF_VENDOR)
|
||||||
|
ctx._claimed_intf.add(IF_VENDOR)
|
||||||
|
buff = array.array("B", data)
|
||||||
|
return int(ctx.backend.bulk_write(handle, ep, IF_VENDOR, buff, timeout_ms))
|
||||||
|
|
||||||
|
def _backend_bulk_read(self, ep: int, size: int, timeout_ms: int) -> bytes:
|
||||||
|
assert self._dev is not None
|
||||||
|
ctx = self._dev._ctx
|
||||||
|
with ctx.lock:
|
||||||
|
handle = ctx.managed_open()
|
||||||
|
if IF_VENDOR not in ctx._claimed_intf:
|
||||||
|
ctx.backend.claim_interface(handle, IF_VENDOR)
|
||||||
|
ctx._claimed_intf.add(IF_VENDOR)
|
||||||
|
buff = usb.util.create_buffer(size)
|
||||||
|
n = int(ctx.backend.bulk_read(handle, ep, IF_VENDOR, buff, timeout_ms))
|
||||||
|
if n <= 0:
|
||||||
|
return b""
|
||||||
|
return bytes(buff[:n])
|
||||||
|
|
||||||
def drain(self, duration_s: float = 0.2) -> None:
|
def drain(self, duration_s: float = 0.2) -> None:
|
||||||
deadline = time.monotonic() + duration_s
|
deadline = time.monotonic() + duration_s
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ JUC500 — Windows 11 사용 안내
|
|||||||
5) Mac 쪽에서도 같은 프로그램을 실행한 뒤 연결합니다.
|
5) Mac 쪽에서도 같은 프로그램을 실행한 뒤 연결합니다.
|
||||||
|
|
||||||
오류 "Operation not supported or unimplemented on this platform"
|
오류 "Operation not supported or unimplemented on this platform"
|
||||||
→ 구버전 버그이거나 Interface 5 WinUSB 미설치. 최신 Setup/portable 로 업데이트하고
|
→ pyusb가 WinUSB에서 get_configuration 을 호출할 때 발생합니다.
|
||||||
Zadig에서 Interface 5 가 WinUSB 인지 다시 확인하세요.
|
2026-07-15 이후 빌드(backend 직접 전송)로 교체하세요.
|
||||||
|
그래도 같으면 Zadig에서 Interface 5(MI_05) = WinUSB 인지 확인하고
|
||||||
|
케이블을 뽑았다 다시 꽂은 뒤 JUC500 을 재실행하세요.
|
||||||
|
|
||||||
포터블: JUC500-portable.exe 는 설치 없이 실행할 수 있습니다.
|
포터블: JUC500-portable.exe 는 설치 없이 실행할 수 있습니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user