Improve JUC500 device discovery and Windows doctor diagnostics

Widen libusb DLL search, copy DLL into venv on setup, match sibling
PIDs, and add doctor command with PnP VID_0711 hints for WinUSB.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
joyharam
2026-07-15 10:51:10 +09:00
parent 861b569994
commit 64284f1afc
5 changed files with 370 additions and 117 deletions
+10
View File
@@ -67,6 +67,16 @@ python -m juc500_xfer info
또는 `scripts\setup_windows.bat` 실행.
**장치가 안 보이면:**
```powershell
# libusb DLL 복사 (필수)
copy vendor\libusb-1.0.dll .venv\Scripts\libusb-1.0.dll
python -m juc500_xfer doctor
```
Zadig로 **Interface 5 (MI_05)** 에 WinUSB 설치 여부를 확인하세요.
## 사용법
양쪽 PC에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다.
+32 -3
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from . import __version__
from .protocol import Session
from .usb_link import list_devices, Juc500Link
from .usb_link import list_devices, Juc500Link, doctor
def _progress(prefix: str):
@@ -34,13 +34,39 @@ def _progress(prefix: str):
def cmd_info(_args: argparse.Namespace) -> int:
devices = list_devices()
if not devices:
print("연결된 JUC500 장치가 없습니다.")
print("연결된 JUC500 장치가 없습니다. 진단: python -m juc500_xfer doctor")
return 1
for d in devices:
print(f"bus={d.bus} addr={d.address} serial={d.serial!r} product={d.product!r}")
print(
f"{d.vid:04x}:{d.pid:04x} bus={d.bus} addr={d.address} "
f"serial={d.serial!r} product={d.product!r}"
)
return 0
def cmd_doctor(_args: argparse.Namespace) -> int:
rep = doctor()
print(f"libusb: {rep.libusb_path or '(없음)'}")
if rep.libusb_error:
print(f"libusb 오류: {rep.libusb_error}")
print(f"USB 장치 수(libusb): {rep.usb_device_count}")
print(f"JUC500 후보: {len(rep.juc500)}")
for j in rep.juc500:
print(f" - {j}")
if rep.sample_devices:
print("USB 샘플:")
for line in rep.sample_devices:
print(f" {line}")
if rep.pnp_vid0711:
print("Windows PnP VID_0711:")
for row in rep.pnp_vid0711:
print(f" - {row}")
print("안내:")
for h in rep.hints:
print(f" * {h}")
return 0 if rep.ok else 1
def cmd_probe(args: argparse.Namespace) -> int:
with Juc500Link(serial=args.serial) as link:
print(f"열림: serial={link.serial!r}")
@@ -114,6 +140,9 @@ def build_parser() -> argparse.ArgumentParser:
s = sub.add_parser("info", help="연결된 JUC500 장치 목록")
s.set_defaults(func=cmd_info)
s = sub.add_parser("doctor", help="USB/libusb/WinUSB 진단")
s.set_defaults(func=cmd_doctor)
s = sub.add_parser("probe", help="USB 인터페이스 점유 및 송수신 프로브")
s.add_argument("--serial", default=None)
s.set_defaults(func=cmd_probe)
+24 -2
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from typing import Any, Optional
from .protocol import Session
from .usb_link import Juc500Link, list_devices
from .usb_link import Juc500Link, list_devices, doctor
DEFAULT_GUI_PORT = 8765
@@ -398,8 +398,23 @@ class Handler(BaseHTTPRequestHandler):
def _api_get(self, path: str, qs: dict[str, list[str]]) -> None:
if path == "/api/status":
try:
devices = list_devices()
list_err = None
except Exception as exc: # noqa: BLE001
devices = []
list_err = str(exc)
cable = devices[0] if devices else None
if devices:
auto = STATE.auto_status if _session_alive() else "케이블 감지됨 — 연결을 누르세요"
elif list_err:
auto = f"USB 오류: {list_err}"
else:
auto = (
"JUC500 미감지 — python -m juc500_xfer doctor 로 진단하세요"
if not STATE.connecting
else STATE.auto_status
)
self._json(
200,
{
@@ -417,13 +432,16 @@ class Handler(BaseHTTPRequestHandler):
"root": STATE.peer_name or "JUC500",
"remote_start": "",
"remote_receive": "",
"auto_status": STATE.auto_status,
"auto_status": auto if not _session_alive() else STATE.auto_status,
"progress": STATE.last_progress,
"device": {
"serial": cable.serial if cable else None,
"product": cable.product if cable else None,
"count": len(devices),
"vid": f"{cable.vid:04x}" if cable else None,
"pid": f"{cable.pid:04x}" if cable else None,
},
"usb_error": list_err,
"inputshare": {
"supported": False,
"enabled": False,
@@ -434,6 +452,10 @@ class Handler(BaseHTTPRequestHandler):
)
return
if path == "/api/doctor":
self._json(200, doctor().to_dict())
return
if path == "/api/settings/ports":
# Reuse settings modal: expose local_root / serial as "ports"-like rows
self._json(
+289 -106
View File
@@ -1,32 +1,26 @@
"""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.
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 dataclass
from typing import Optional
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
@@ -37,66 +31,147 @@ 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"
_PRODUCT_HINTS = ("smart data", "data link", "wormhole", "juc500", "oti")
def _libusb_backend():
"""Locate libusb-1.0 on macOS / Windows / Linux."""
import usb.backend.libusb1
candidates = []
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:
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":
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",
]
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
# 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 None:
raise RuntimeError(
"libusb-1.0 를 찾을 수 없습니다. "
"macOS: brew install libusb / Windows: vendor/libusb-1.0.dll 배치"
)
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:
@@ -109,11 +184,7 @@ def _usb_err_text(exc: BaseException) -> str:
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
)
return "not supported" in text or "unimplemented" in text or "enosys" in text
@dataclass
@@ -123,36 +194,171 @@ class DeviceInfo:
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]:
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 = ""
for dev in find_juc500_devices():
out.append(
DeviceInfo(
bus=dev.bus,
address=dev.address,
serial=serial.strip(),
product=product.strip(),
manufacturer=manufacturer.strip(),
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."""
@@ -164,28 +370,18 @@ class Juc500Link:
self.open()
def open(self) -> None:
backend = _libusb_backend()
matches = list(
usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend)
)
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) 를 찾지 못했습니다. "
"케이블이 연결되어 있는지 확인하세요."
+ (
" Windows: Zadig로 Interface 5에 WinUSB를 설치했는지 확인하세요."
if _IS_WIN
else ""
)
"JUC500(Smart Data Link / 0711:7500) 장치를 찾지 못했습니다.\n" + hints
)
chosen = None
for dev in matches:
if self._serial:
try:
s = (usb.util.get_string(dev, dev.iSerialNumber) or "").strip()
except Exception:
s = ""
s = _safe_str(dev, getattr(dev, "iSerialNumber", 0))
if s != self._serial.strip():
continue
chosen = dev
@@ -200,29 +396,20 @@ class Juc500Link:
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
ctx._active_cfg_index = 0
except Exception:
pass
@@ -251,9 +438,9 @@ class Juc500Link:
hint = ""
if _IS_WIN:
hint = (
f"\nZadig에서 Smart Data Link → Interface {ifnum} 에 "
"WinUSB 드라이버를 설치하세요. "
"(데이터 전송은 Interface 5가 필수입니다.)"
f"\nZadig에서 Smart Data Link → Interface {ifnum} (MI_0{ifnum}) 에 "
"WinUSB 를 설치하세요. 데이터 전송은 Interface 5가 필수입니다.\n"
"공식 j5create Wormhole 드라이버가 있으면 제거하고 다시 시도하세요."
)
raise RuntimeError(f"USB interface {ifnum} 점유 실패: {exc}{hint}") from exc
return
@@ -262,10 +449,7 @@ class Juc500Link:
@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 ""
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)
@@ -314,7 +498,6 @@ class Juc500Link:
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:
+9
View File
@@ -16,9 +16,18 @@ if errorlevel 1 (
python -m pip install --find-links=vendor\wheels -e .
)
REM libusb DLL 을 venv Scripts 로 복사 (장치 인식에 필수)
if exist vendor\libusb-1.0.dll (
copy /Y vendor\libusb-1.0.dll .venv\Scripts\libusb-1.0.dll >nul
echo libusb-1.0.dll -^> .venv\Scripts\
) else (
echo [!] vendor\libusb-1.0.dll 없음 — USB 장치를 못 찾을 수 있습니다.
)
echo.
echo Setup done. Next:
echo .venv\Scripts\activate
echo python -m juc500_xfer doctor
echo python -m juc500_xfer info
echo python -m juc500_xfer gui
echo See docs\WINDOWS.md for Zadig / WinUSB steps.