Files
juc500/juc500_xfer/usb_link.py
T
joyharam 2035c0b4fe Clarify Windows Access denied on USB interface 5
Point users to Zadig WinUSB binding for MI_05 and stopping official
Wormhole drivers that hold exclusive claim on interface 5.

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

560 lines
18 KiB
Python

"""Low-level USB access to the j5create JUC500 Smart Data Link cable.
Device: VID 0x0711 / PID 0x7500 (product "Smart Data Link"), composite:
IF5 Vendor bulk is required for our protocol on Windows (WinUSB).
"""
from __future__ import annotations
import os
import subprocess
import sys
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
# Some firmware / sibling OEM IDs under the same vendor
ALT_PIDS = {0x7500, 0x5500, 0x7000, 0x0700}
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
_IS_WIN = sys.platform == "win32"
_PRODUCT_HINTS = ("smart data", "data link", "wormhole", "juc500", "oti")
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))
# walk up a few levels looking for vendor/
for base in list(roots):
p = base
for _ in range(4):
roots.append(p)
if (p / "vendor").is_dir():
break
p = p.parent
# unique preserve order
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:
# venv Scripts / beside python
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") # PATH search
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"]
# dedupe
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:
# smoke: enumerate (may be empty)
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 _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"
"Windows가 Interface 5를 다른 드라이버(공식 Wormhole 등)로 붙잡고 있습니다.\n"
"아래를 순서대로 하세요:\n"
"1) 작업 관리자에서 Wormhole / SmartDataLink / j5create 관련 프로그램 모두 종료\n"
"2) Zadig(https://zadig.akeo.ie/) 실행 (관리자)\n"
"3) Options → List All Devices\n"
"4) 목록에서 'Smart Data Link (Interface 5)' 또는 InstanceId에 MI_05 가 있는 항목 선택\n"
"5) Driver 를 WinUSB 로 바꾼 뒤 Replace Driver\n"
"6) 케이블 뽑았다가 다시 꽂고 JUC500 재실행 → 연결\n"
"장치 관리자에서 Interface 5가 'WinUSB' / 'libusb-winusb' 인지도 확인하세요."
)
return (
f"{base}\n"
f"Zadig에서 Smart Data Link → Interface {ifnum} (MI_0{ifnum}) 에 WinUSB를 설치하세요."
)
@dataclass
class DeviceInfo:
bus: int
address: int
serial: str
product: str
manufacturer: str
vid: int = VID
pid: int = PID
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 dev.idVendor != VID:
return False
if int(dev.idProduct) in ALT_PIDS or int(dev.idProduct) == PID:
return True
p = (product or "").lower()
return any(h in p for h in _PRODUCT_HINTS)
def iter_raw_devices():
backend = _libusb_backend()
return usb.core.find(find_all=True, backend=backend)
def find_juc500_devices() -> list[usb.core.Device]:
found: list[usb.core.Device] = []
for dev in iter_raw_devices():
product = _safe_str(dev, getattr(dev, "iProduct", 0), "")
if _looks_like_juc500(dev, product):
found.append(dev)
# Prefer exact 0711:7500 first
found.sort(key=lambda d: (0 if (d.idVendor == VID and d.idProduct == PID) else 1))
return found
def list_devices() -> list[DeviceInfo]:
out: list[DeviceInfo] = []
for dev in find_juc500_devices():
out.append(
DeviceInfo(
bus=int(dev.bus),
address=int(dev.address),
serial=_safe_str(dev, getattr(dev, "iSerialNumber", 0)),
product=_safe_str(dev, getattr(dev, "iProduct", 0), "Smart Data Link")
or "Smart Data Link",
manufacturer=_safe_str(dev, getattr(dev, "iManufacturer", 0)),
vid=int(dev.idVendor),
pid=int(dev.idProduct),
)
)
return out
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)
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()
if report.juc500:
report.ok = True
report.hints.append("JUC500 장치가 libusb로 보입니다. 「연결」을 진행하세요.")
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가 JUC500을 못 엽니다. "
"Zadig로 Smart Data Link → Interface 5(MI_05)에 WinUSB를 설치하세요. "
"공식 Wormhole 드라이버가 있으면 제거하세요."
)
else:
report.hints.append(
"이 PC에서 JUC500(0711:7500)이 보이지 않습니다. "
"케이블을 이 PC USB 포트에 직접 연결했는지, "
"반대쪽은 상대 PC에 꽂혀 있는지 확인하세요. "
"(양쪽 끝이 각각 다른 PC에 연결되어야 합니다.)"
)
if _IS_WIN:
report.hints.append(
"장치 관리자에서 'Smart Data Link' / '알 수 없는 장치' / "
"WORMHOLE CD 롬이 있는지 확인하세요."
)
return report
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:
matches = find_juc500_devices()
if not matches:
rep = doctor()
hints = "\n".join(f"- {h}" for h in rep.hints)
raise RuntimeError(
"JUC500(Smart Data Link / 0711:7500) 장치를 찾지 못했습니다.\n" + hints
)
chosen = None
for dev in matches:
if self._serial:
s = _safe_str(dev, getattr(dev, "iSerialNumber", 0))
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()
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:
assert self._dev is not None
try:
self._dev.set_configuration(1)
except usb.core.USBError:
pass
try:
ctx = self._dev._ctx
if getattr(ctx, "_active_cfg_index", None) is None:
ctx._active_cfg_index = 0
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:
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))
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
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()