Files
juc500/juc500_xfer/usb_link.py
T
joyharam ec99c5eac6 Fix Windows WinUSB not-supported error on connect
Skip get_configuration/kernel-driver APIs on Windows, seed pyusb
config cache, and require only Vendor interface 5 for transfers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 10:41:37 +09:00

354 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Low-level USB access to the j5create JUC500 Smart Data Link cable.
The cable enumerates as USB VID:PID 0711:7500 with six interfaces:
0 CDC Communication (control notifications)
1 CDC Data (bulk 0x02 / 0x83) – proprietary control path
2 Mass Storage (WORMHOLE virtual CD, claimed by OS)
3 HID mouse (KM switch, claimed by OS)
4 HID keyboard (KM switch, claimed by OS)
5 Vendor Specific (bulk 0x08/0x89 + 0x0A/0x8B) – data bridge
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
import os
import sys
import time
from dataclasses import dataclass
from typing import Optional
import usb.core
import usb.util
VID = 0x0711
PID = 0x7500
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
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."""
import usb.backend.libusb1
candidates = []
if getattr(sys, "frozen", False):
mei = getattr(sys, "_MEIPASS", None)
if mei:
candidates += [
os.path.join(mei, "libusb-1.0.dll"),
os.path.join(mei, "libusb-1.0.dylib"),
os.path.join(mei, "libusb-1.0.so"),
]
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
candidates += [
os.path.join(exe_dir, "libusb-1.0.dll"),
os.path.join(exe_dir, "_internal", "libusb-1.0.dll"),
]
if 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",
]
elif _IS_WIN:
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.dirname(here)
candidates += [
os.path.join(root, "vendor", "libusb-1.0.dll"),
os.path.join(os.environ.get("PROGRAMFILES", ""), "libusb", "libusb-1.0.dll"),
"libusb-1.0.dll",
]
else:
candidates += ["libusb-1.0.so", "libusb-1.0.so.0"]
for path in candidates:
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:
raise RuntimeError(
"libusb-1.0 를 찾을 수 없습니다. "
"macOS: brew install libusb / Windows: vendor/libusb-1.0.dll 배치"
)
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
address: int
serial: str
product: str
manufacturer: str
def list_devices() -> list[DeviceInfo]:
backend = _libusb_backend()
out: list[DeviceInfo] = []
for dev in usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend):
try:
serial = usb.util.get_string(dev, dev.iSerialNumber) or ""
except Exception:
serial = ""
try:
product = usb.util.get_string(dev, dev.iProduct) or "Smart Data Link"
except Exception:
product = "Smart Data Link"
try:
manufacturer = usb.util.get_string(dev, dev.iManufacturer) or ""
except Exception:
manufacturer = ""
out.append(
DeviceInfo(
bus=dev.bus,
address=dev.address,
serial=serial.strip(),
product=product.strip(),
manufacturer=manufacturer.strip(),
)
)
return out
class Juc500Link:
"""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
self._dev: Optional[usb.core.Device] = None
self._claimed: list[int] = []
self._serial = serial
self.open()
def open(self) -> None:
backend = _libusb_backend()
matches = list(
usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend)
)
if not matches:
raise RuntimeError(
"JUC500(0711:7500 Smart Data Link) 를 찾지 못했습니다. "
"케이블이 연결되어 있는지 확인하세요."
+ (
" Windows: Zadig로 Interface 5에 WinUSB를 설치했는지 확인하세요."
if _IS_WIN
else ""
)
)
chosen = None
for dev in matches:
if self._serial:
try:
s = (usb.util.get_string(dev, dev.iSerialNumber) or "").strip()
except Exception:
s = ""
if s != self._serial.strip():
continue
chosen = dev
break
if chosen is None:
raise RuntimeError(f"시리얼 '{self._serial}' 에 해당하는 JUC500 이 없습니다.")
self._dev = chosen
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(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 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
def serial(self) -> str:
assert self._dev is not None
try:
return (usb.util.get_string(self._dev, self._dev.iSerialNumber) or "").strip()
except Exception:
return ""
def write_data(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
return self._bulk_write(EP_DATA_OUT, payload, timeout_ms)
def read_data(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
return self._bulk_read(EP_DATA_IN, size, timeout_ms)
def write_ctrl(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
return self._bulk_write(EP_CTRL_OUT, payload, timeout_ms)
def read_ctrl(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
return self._bulk_read(EP_CTRL_IN, size, timeout_ms)
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
sent = 0
view = memoryview(payload)
while sent < len(payload):
chunk = view[sent : sent + MAX_PACKET]
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
return sent
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
assert self._dev is not None
to = self.timeout_ms if timeout_ms is None else timeout_ms
try:
data = self._dev.read(ep, size, timeout=to)
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):
try:
chunk = reader(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()