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:
@@ -67,6 +67,16 @@ python -m juc500_xfer info
|
|||||||
|
|
||||||
또는 `scripts\setup_windows.bat` 실행.
|
또는 `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에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다.
|
양쪽 PC에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다.
|
||||||
|
|||||||
+32
-3
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .protocol import Session
|
from .protocol import Session
|
||||||
from .usb_link import list_devices, Juc500Link
|
from .usb_link import list_devices, Juc500Link, doctor
|
||||||
|
|
||||||
|
|
||||||
def _progress(prefix: str):
|
def _progress(prefix: str):
|
||||||
@@ -34,13 +34,39 @@ def _progress(prefix: str):
|
|||||||
def cmd_info(_args: argparse.Namespace) -> int:
|
def cmd_info(_args: argparse.Namespace) -> int:
|
||||||
devices = list_devices()
|
devices = list_devices()
|
||||||
if not devices:
|
if not devices:
|
||||||
print("연결된 JUC500 장치가 없습니다.")
|
print("연결된 JUC500 장치가 없습니다. 진단: python -m juc500_xfer doctor")
|
||||||
return 1
|
return 1
|
||||||
for d in devices:
|
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
|
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:
|
def cmd_probe(args: argparse.Namespace) -> int:
|
||||||
with Juc500Link(serial=args.serial) as link:
|
with Juc500Link(serial=args.serial) as link:
|
||||||
print(f"열림: serial={link.serial!r}")
|
print(f"열림: serial={link.serial!r}")
|
||||||
@@ -114,6 +140,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
s = sub.add_parser("info", help="연결된 JUC500 장치 목록")
|
s = sub.add_parser("info", help="연결된 JUC500 장치 목록")
|
||||||
s.set_defaults(func=cmd_info)
|
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 = sub.add_parser("probe", help="USB 인터페이스 점유 및 송수신 프로브")
|
||||||
s.add_argument("--serial", default=None)
|
s.add_argument("--serial", default=None)
|
||||||
s.set_defaults(func=cmd_probe)
|
s.set_defaults(func=cmd_probe)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from .protocol import Session
|
from .protocol import Session
|
||||||
from .usb_link import Juc500Link, list_devices
|
from .usb_link import Juc500Link, list_devices, doctor
|
||||||
|
|
||||||
DEFAULT_GUI_PORT = 8765
|
DEFAULT_GUI_PORT = 8765
|
||||||
|
|
||||||
@@ -398,8 +398,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _api_get(self, path: str, qs: dict[str, list[str]]) -> None:
|
def _api_get(self, path: str, qs: dict[str, list[str]]) -> None:
|
||||||
if path == "/api/status":
|
if path == "/api/status":
|
||||||
|
try:
|
||||||
devices = list_devices()
|
devices = list_devices()
|
||||||
|
list_err = None
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
devices = []
|
||||||
|
list_err = str(exc)
|
||||||
cable = devices[0] if devices else None
|
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(
|
self._json(
|
||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
@@ -417,13 +432,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"root": STATE.peer_name or "JUC500",
|
"root": STATE.peer_name or "JUC500",
|
||||||
"remote_start": "",
|
"remote_start": "",
|
||||||
"remote_receive": "",
|
"remote_receive": "",
|
||||||
"auto_status": STATE.auto_status,
|
"auto_status": auto if not _session_alive() else STATE.auto_status,
|
||||||
"progress": STATE.last_progress,
|
"progress": STATE.last_progress,
|
||||||
"device": {
|
"device": {
|
||||||
"serial": cable.serial if cable else None,
|
"serial": cable.serial if cable else None,
|
||||||
"product": cable.product if cable else None,
|
"product": cable.product if cable else None,
|
||||||
"count": len(devices),
|
"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": {
|
"inputshare": {
|
||||||
"supported": False,
|
"supported": False,
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
@@ -434,6 +452,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if path == "/api/doctor":
|
||||||
|
self._json(200, doctor().to_dict())
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/api/settings/ports":
|
if path == "/api/settings/ports":
|
||||||
# Reuse settings modal: expose local_root / serial as "ports"-like rows
|
# Reuse settings modal: expose local_root / serial as "ports"-like rows
|
||||||
self._json(
|
self._json(
|
||||||
|
|||||||
+289
-106
@@ -1,32 +1,26 @@
|
|||||||
"""Low-level USB access to the j5create JUC500 Smart Data Link cable.
|
"""Low-level USB access to the j5create JUC500 Smart Data Link cable.
|
||||||
|
|
||||||
The cable enumerates as USB VID:PID 0711:7500 with six interfaces:
|
Device: VID 0x0711 / PID 0x7500 (product "Smart Data Link"), composite:
|
||||||
|
IF5 Vendor bulk is required for our protocol on Windows (WinUSB).
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import asdict, dataclass, field
|
||||||
from typing import Optional
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
import usb.core
|
import usb.core
|
||||||
import usb.util
|
import usb.util
|
||||||
|
|
||||||
VID = 0x0711
|
VID = 0x0711
|
||||||
PID = 0x7500
|
PID = 0x7500
|
||||||
|
# Some firmware / sibling OEM IDs under the same vendor
|
||||||
|
ALT_PIDS = {0x7500, 0x5500, 0x7000, 0x0700}
|
||||||
|
|
||||||
IF_CDC_COMM = 0
|
IF_CDC_COMM = 0
|
||||||
IF_CDC_DATA = 1
|
IF_CDC_DATA = 1
|
||||||
@@ -37,66 +31,147 @@ EP_DATA_IN = 0x89
|
|||||||
EP_CTRL_OUT = 0x0A
|
EP_CTRL_OUT = 0x0A
|
||||||
EP_CTRL_IN = 0x8B
|
EP_CTRL_IN = 0x8B
|
||||||
|
|
||||||
EP_CDC_OUT = 0x02
|
|
||||||
EP_CDC_IN = 0x83
|
|
||||||
|
|
||||||
MAX_PACKET = 512
|
MAX_PACKET = 512
|
||||||
|
|
||||||
_IS_WIN = sys.platform == "win32"
|
_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):
|
if getattr(sys, "frozen", False):
|
||||||
|
roots.insert(0, Path(sys.executable).resolve().parent)
|
||||||
mei = getattr(sys, "_MEIPASS", None)
|
mei = getattr(sys, "_MEIPASS", None)
|
||||||
if mei:
|
if mei:
|
||||||
candidates += [
|
roots.insert(0, Path(mei))
|
||||||
os.path.join(mei, "libusb-1.0.dll"),
|
# walk up a few levels looking for vendor/
|
||||||
os.path.join(mei, "libusb-1.0.dylib"),
|
for base in list(roots):
|
||||||
os.path.join(mei, "libusb-1.0.so"),
|
p = base
|
||||||
]
|
for _ in range(4):
|
||||||
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
roots.append(p)
|
||||||
candidates += [
|
if (p / "vendor").is_dir():
|
||||||
os.path.join(exe_dir, "libusb-1.0.dll"),
|
break
|
||||||
os.path.join(exe_dir, "_internal", "libusb-1.0.dll"),
|
p = p.parent
|
||||||
]
|
# unique preserve order
|
||||||
if sys.platform == "darwin":
|
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 += [
|
candidates += [
|
||||||
os.path.expanduser("~/homebrew/lib/libusb-1.0.dylib"),
|
os.path.expanduser("~/homebrew/lib/libusb-1.0.dylib"),
|
||||||
"/opt/homebrew/lib/libusb-1.0.dylib",
|
"/opt/homebrew/lib/libusb-1.0.dylib",
|
||||||
"/usr/local/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:
|
else:
|
||||||
candidates += ["libusb-1.0.so", "libusb-1.0.so.0"]
|
candidates += ["libusb-1.0.so", "libusb-1.0.so.0"]
|
||||||
|
|
||||||
for path in candidates:
|
# dedupe
|
||||||
if not path:
|
seen: set[str] = set()
|
||||||
continue
|
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] == ":")
|
absolute = os.path.sep in path or (len(path) > 1 and path[1] == ":")
|
||||||
if absolute and not os.path.exists(path):
|
if absolute and not os.path.exists(path):
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p)
|
be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p)
|
||||||
if be is not None:
|
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
|
return be
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_err = exc
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
be = usb.backend.libusb1.get_backend()
|
be = usb.backend.libusb1.get_backend()
|
||||||
if be is None:
|
if be is not None:
|
||||||
raise RuntimeError(
|
list(usb.core.find(find_all=True, backend=be))
|
||||||
"libusb-1.0 를 찾을 수 없습니다. "
|
_BACKEND = be
|
||||||
"macOS: brew install libusb / Windows: vendor/libusb-1.0.dll 배치"
|
_BACKEND_PATH = "(system)"
|
||||||
)
|
|
||||||
return be
|
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:
|
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:
|
def _is_not_supported(exc: BaseException) -> bool:
|
||||||
text = _usb_err_text(exc)
|
text = _usb_err_text(exc)
|
||||||
return (
|
return "not supported" in text or "unimplemented" in text or "enosys" in text
|
||||||
"not supported" in text
|
|
||||||
or "unimplemented" in text
|
|
||||||
or "enosys" in text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -123,36 +194,171 @@ class DeviceInfo:
|
|||||||
serial: str
|
serial: str
|
||||||
product: str
|
product: str
|
||||||
manufacturer: 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]:
|
def list_devices() -> list[DeviceInfo]:
|
||||||
backend = _libusb_backend()
|
|
||||||
out: list[DeviceInfo] = []
|
out: list[DeviceInfo] = []
|
||||||
for dev in usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend):
|
for dev in find_juc500_devices():
|
||||||
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(
|
out.append(
|
||||||
DeviceInfo(
|
DeviceInfo(
|
||||||
bus=dev.bus,
|
bus=int(dev.bus),
|
||||||
address=dev.address,
|
address=int(dev.address),
|
||||||
serial=serial.strip(),
|
serial=_safe_str(dev, getattr(dev, "iSerialNumber", 0)),
|
||||||
product=product.strip(),
|
product=_safe_str(dev, getattr(dev, "iProduct", 0), "Smart Data Link")
|
||||||
manufacturer=manufacturer.strip(),
|
or "Smart Data Link",
|
||||||
|
manufacturer=_safe_str(dev, getattr(dev, "iManufacturer", 0)),
|
||||||
|
vid=int(dev.idVendor),
|
||||||
|
pid=int(dev.idProduct),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
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:
|
class Juc500Link:
|
||||||
"""Claim Vendor (+ optional CDC) interfaces and provide framed bulk I/O."""
|
"""Claim Vendor (+ optional CDC) interfaces and provide framed bulk I/O."""
|
||||||
|
|
||||||
@@ -164,28 +370,18 @@ class Juc500Link:
|
|||||||
self.open()
|
self.open()
|
||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
backend = _libusb_backend()
|
matches = find_juc500_devices()
|
||||||
matches = list(
|
|
||||||
usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend)
|
|
||||||
)
|
|
||||||
if not matches:
|
if not matches:
|
||||||
|
rep = doctor()
|
||||||
|
hints = "\n".join(f"- {h}" for h in rep.hints)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"JUC500(0711:7500 Smart Data Link) 를 찾지 못했습니다. "
|
"JUC500(Smart Data Link / 0711:7500) 장치를 찾지 못했습니다.\n" + hints
|
||||||
"케이블이 연결되어 있는지 확인하세요."
|
|
||||||
+ (
|
|
||||||
" Windows: Zadig로 Interface 5에 WinUSB를 설치했는지 확인하세요."
|
|
||||||
if _IS_WIN
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
chosen = None
|
chosen = None
|
||||||
for dev in matches:
|
for dev in matches:
|
||||||
if self._serial:
|
if self._serial:
|
||||||
try:
|
s = _safe_str(dev, getattr(dev, "iSerialNumber", 0))
|
||||||
s = (usb.util.get_string(dev, dev.iSerialNumber) or "").strip()
|
|
||||||
except Exception:
|
|
||||||
s = ""
|
|
||||||
if s != self._serial.strip():
|
if s != self._serial.strip():
|
||||||
continue
|
continue
|
||||||
chosen = dev
|
chosen = dev
|
||||||
@@ -200,29 +396,20 @@ class Juc500Link:
|
|||||||
else:
|
else:
|
||||||
self._configure_posix()
|
self._configure_posix()
|
||||||
|
|
||||||
# Protocol uses Vendor IF5 only. CDC is best-effort.
|
|
||||||
self._claim(IF_VENDOR, required=True)
|
self._claim(IF_VENDOR, required=True)
|
||||||
self._claim(IF_CDC_DATA, required=False)
|
self._claim(IF_CDC_DATA, required=False)
|
||||||
self._claim(IF_CDC_COMM, required=False)
|
self._claim(IF_CDC_COMM, required=False)
|
||||||
|
|
||||||
def _configure_windows(self) -> None:
|
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
|
assert self._dev is not None
|
||||||
try:
|
try:
|
||||||
self._dev.set_configuration(1)
|
self._dev.set_configuration(1)
|
||||||
except usb.core.USBError:
|
except usb.core.USBError:
|
||||||
pass
|
pass
|
||||||
# Ensure pyusb ResourceManager will not call get_configuration().
|
|
||||||
try:
|
try:
|
||||||
ctx = self._dev._ctx
|
ctx = self._dev._ctx
|
||||||
if getattr(ctx, "_active_cfg_index", None) is None:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -251,9 +438,9 @@ class Juc500Link:
|
|||||||
hint = ""
|
hint = ""
|
||||||
if _IS_WIN:
|
if _IS_WIN:
|
||||||
hint = (
|
hint = (
|
||||||
f"\nZadig에서 Smart Data Link → Interface {ifnum} 에 "
|
f"\nZadig에서 Smart Data Link → Interface {ifnum} (MI_0{ifnum}) 에 "
|
||||||
"WinUSB 드라이버를 설치하세요. "
|
"WinUSB 를 설치하세요. 데이터 전송은 Interface 5가 필수입니다.\n"
|
||||||
"(데이터 전송은 Interface 5가 필수입니다.)"
|
"공식 j5create Wormhole 드라이버가 있으면 제거하고 다시 시도하세요."
|
||||||
)
|
)
|
||||||
raise RuntimeError(f"USB interface {ifnum} 점유 실패: {exc}{hint}") from exc
|
raise RuntimeError(f"USB interface {ifnum} 점유 실패: {exc}{hint}") from exc
|
||||||
return
|
return
|
||||||
@@ -262,10 +449,7 @@ class Juc500Link:
|
|||||||
@property
|
@property
|
||||||
def serial(self) -> str:
|
def serial(self) -> str:
|
||||||
assert self._dev is not None
|
assert self._dev is not None
|
||||||
try:
|
return _safe_str(self._dev, getattr(self._dev, "iSerialNumber", 0))
|
||||||
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:
|
def write_data(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
|
||||||
return self._bulk_write(EP_DATA_OUT, payload, timeout_ms)
|
return self._bulk_write(EP_DATA_OUT, payload, timeout_ms)
|
||||||
@@ -314,7 +498,6 @@ class Juc500Link:
|
|||||||
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
|
||||||
# Transient pipe errors — treat like empty during poll
|
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
def drain(self, duration_s: float = 0.2) -> None:
|
def drain(self, duration_s: float = 0.2) -> None:
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ if errorlevel 1 (
|
|||||||
python -m pip install --find-links=vendor\wheels -e .
|
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.
|
||||||
echo Setup done. Next:
|
echo Setup done. Next:
|
||||||
echo .venv\Scripts\activate
|
echo .venv\Scripts\activate
|
||||||
|
echo python -m juc500_xfer doctor
|
||||||
echo python -m juc500_xfer info
|
echo python -m juc500_xfer info
|
||||||
echo python -m juc500_xfer gui
|
echo python -m juc500_xfer gui
|
||||||
echo See docs\WINDOWS.md for Zadig / WinUSB steps.
|
echo See docs\WINDOWS.md for Zadig / WinUSB steps.
|
||||||
|
|||||||
Reference in New Issue
Block a user