From 64284f1afcc3cca42eeb7b08fc6efedd4051ad51 Mon Sep 17 00:00:00 2001 From: joyharam <150105283@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:51:10 +0900 Subject: [PATCH] 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 --- README.md | 10 + juc500_xfer/cli.py | 35 +++- juc500_xfer/gui_server.py | 28 ++- juc500_xfer/usb_link.py | 405 +++++++++++++++++++++++++++----------- scripts/setup_windows.bat | 9 + 5 files changed, 370 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 3a097ea..9e9a37d 100644 --- a/README.md +++ b/README.md @@ -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에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다. diff --git a/juc500_xfer/cli.py b/juc500_xfer/cli.py index f655bbc..89f6cd7 100644 --- a/juc500_xfer/cli.py +++ b/juc500_xfer/cli.py @@ -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) diff --git a/juc500_xfer/gui_server.py b/juc500_xfer/gui_server.py index 17fcb9d..0025369 100644 --- a/juc500_xfer/gui_server.py +++ b/juc500_xfer/gui_server.py @@ -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": - devices = list_devices() + 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( diff --git a/juc500_xfer/usb_link.py b/juc500_xfer/usb_link.py index f2d44b1..1bc35ea 100644 --- a/juc500_xfer/usb_link.py +++ b/juc500_xfer/usb_link.py @@ -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 - be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p) - if be is not None: - return be + 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 - 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 + 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: @@ -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: diff --git a/scripts/setup_windows.bat b/scripts/setup_windows.bat index 2602f34..cdd014c 100644 --- a/scripts/setup_windows.bat +++ b/scripts/setup_windows.bat @@ -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.