Fix missing _is_not_supported and harden WinUSB config cache
Restores the helper accidentally removed when adding access-denied handling, retries I/O after seeding pyusb config index on Windows, and handles USB overflow reads without crashing the HELLO loop. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -81,6 +81,18 @@ GUI **Wormhole 종료** 버튼 또는 `python -m juc500_xfer kill-wormhole` 로
|
|||||||
|
|
||||||
## 사용법
|
## 사용법
|
||||||
|
|
||||||
|
### GUI (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 최초 1회
|
||||||
|
./scripts/setup_macos.sh
|
||||||
|
|
||||||
|
# 이후
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
인자를 넘기면 CLI로 동작합니다: `./start.sh doctor`, `./start.sh kill-wormhole` 등.
|
||||||
|
|
||||||
양쪽 PC에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다.
|
양쪽 PC에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다.
|
||||||
|
|
||||||
**Windows (수신 예)**
|
**Windows (수신 예)**
|
||||||
@@ -93,6 +105,8 @@ python -m juc500_xfer recv --dest Downloads -y
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m juc500_xfer send ~/Desktop/archive.zip
|
python -m juc500_xfer send ~/Desktop/archive.zip
|
||||||
|
# 또는
|
||||||
|
./start.sh send ~/Desktop/archive.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## Windows 설치파일
|
## Windows 설치파일
|
||||||
|
|||||||
+85
-4
@@ -194,6 +194,24 @@ def _is_access_denied(exc: BaseException) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_not_supported(exc: BaseException) -> bool:
|
||||||
|
"""WinUSB often returns LIBUSB_ERROR_NOT_SUPPORTED for get_configuration etc."""
|
||||||
|
text = _usb_err_text(exc)
|
||||||
|
errno = getattr(exc, "errno", None)
|
||||||
|
return (
|
||||||
|
errno in (95, 40) # ENOTSUP / Windows ERROR_NOT_SUPPORTED-ish
|
||||||
|
or "not supported" in text
|
||||||
|
or "unimplemented" in text
|
||||||
|
or "enosys" in text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_overflow(exc: BaseException) -> bool:
|
||||||
|
text = _usb_err_text(exc)
|
||||||
|
errno = getattr(exc, "errno", None)
|
||||||
|
return errno == 84 or "overflow" in text
|
||||||
|
|
||||||
|
|
||||||
def _claim_failure_message(ifnum: int, exc: BaseException) -> str:
|
def _claim_failure_message(ifnum: int, exc: BaseException) -> str:
|
||||||
base = f"USB interface {ifnum} 점유 실패: {exc}"
|
base = f"USB interface {ifnum} 점유 실패: {exc}"
|
||||||
if not _IS_WIN:
|
if not _IS_WIN:
|
||||||
@@ -211,6 +229,12 @@ def _claim_failure_message(ifnum: int, exc: BaseException) -> str:
|
|||||||
"6) 케이블 뽑았다가 다시 꽂고 JUC500 재실행 → 연결\n"
|
"6) 케이블 뽑았다가 다시 꽂고 JUC500 재실행 → 연결\n"
|
||||||
"장치 관리자에서 Interface 5가 'WinUSB' / 'libusb-winusb' 인지도 확인하세요."
|
"장치 관리자에서 Interface 5가 'WinUSB' / 'libusb-winusb' 인지도 확인하세요."
|
||||||
)
|
)
|
||||||
|
if _is_not_supported(exc):
|
||||||
|
return (
|
||||||
|
f"{base}\n\n"
|
||||||
|
"WinUSB가 해당 libusb API를 지원하지 않습니다. Interface 5(MI_05)에 "
|
||||||
|
"WinUSB가 맞는지 Zadig로 확인하고, 최신 JUC500 빌드를 사용하세요."
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"{base}\n"
|
f"{base}\n"
|
||||||
f"Zadig에서 Smart Data Link → Interface {ifnum} (MI_0{ifnum}) 에 WinUSB를 설치하세요."
|
f"Zadig에서 Smart Data Link → Interface {ifnum} (MI_0{ifnum}) 에 WinUSB를 설치하세요."
|
||||||
@@ -431,14 +455,28 @@ class Juc500Link:
|
|||||||
self._claim(IF_CDC_COMM, required=False)
|
self._claim(IF_CDC_COMM, required=False)
|
||||||
|
|
||||||
def _configure_windows(self) -> None:
|
def _configure_windows(self) -> None:
|
||||||
|
"""Avoid libusb_get_configuration — unimplemented on WinUSB.
|
||||||
|
|
||||||
|
Every pyusb read/write resolves endpoints via get_active_configuration(),
|
||||||
|
which calls backend.get_configuration() when the cache is empty and then
|
||||||
|
raises 'Operation not supported or unimplemented on this platform'.
|
||||||
|
Always seed _active_cfg_index after open.
|
||||||
|
"""
|
||||||
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, NotImplementedError):
|
||||||
pass
|
pass
|
||||||
|
self._ensure_cfg_cache(force=True)
|
||||||
|
|
||||||
|
def _ensure_cfg_cache(self, *, force: bool = False) -> None:
|
||||||
|
"""Seed pyusb config index so I/O never calls get_configuration on WinUSB."""
|
||||||
|
if self._dev is None:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
ctx = self._dev._ctx
|
ctx = self._dev._ctx
|
||||||
if getattr(ctx, "_active_cfg_index", None) is None:
|
if force or getattr(ctx, "_active_cfg_index", None) is None:
|
||||||
|
# Composite JUC500 exposes a single configuration at index 0.
|
||||||
ctx._active_cfg_index = 0
|
ctx._active_cfg_index = 0
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -506,6 +544,8 @@ class Juc500Link:
|
|||||||
|
|
||||||
def _bulk_write(self, ep: int, payload: bytes, timeout_ms: Optional[int]) -> int:
|
def _bulk_write(self, ep: int, payload: bytes, timeout_ms: Optional[int]) -> int:
|
||||||
assert self._dev is not None
|
assert self._dev is not None
|
||||||
|
if _IS_WIN:
|
||||||
|
self._ensure_cfg_cache()
|
||||||
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
||||||
sent = 0
|
sent = 0
|
||||||
view = memoryview(payload)
|
view = memoryview(payload)
|
||||||
@@ -514,12 +554,22 @@ class Juc500Link:
|
|||||||
try:
|
try:
|
||||||
n = self._dev.write(ep, chunk.tobytes(), timeout=to)
|
n = self._dev.write(ep, chunk.tobytes(), timeout=to)
|
||||||
except usb.core.USBError as exc:
|
except usb.core.USBError as exc:
|
||||||
if _is_not_supported(exc):
|
if _IS_WIN and _is_not_supported(exc):
|
||||||
|
self._ensure_cfg_cache(force=True)
|
||||||
|
try:
|
||||||
|
n = self._dev.write(ep, chunk.tobytes(), timeout=to)
|
||||||
|
except usb.core.USBError as exc2:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"USB 쓰기 실패(EP 0x{ep:02x}): {exc2}\n"
|
||||||
|
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
||||||
|
) from exc2
|
||||||
|
elif _is_not_supported(exc):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
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
|
||||||
raise
|
else:
|
||||||
|
raise
|
||||||
sent += n
|
sent += n
|
||||||
if n == 0:
|
if n == 0:
|
||||||
break
|
break
|
||||||
@@ -527,6 +577,8 @@ class Juc500Link:
|
|||||||
|
|
||||||
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
|
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
|
||||||
assert self._dev is not None
|
assert self._dev is not None
|
||||||
|
if _IS_WIN:
|
||||||
|
self._ensure_cfg_cache()
|
||||||
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
||||||
try:
|
try:
|
||||||
data = self._dev.read(ep, size, timeout=to)
|
data = self._dev.read(ep, size, timeout=to)
|
||||||
@@ -534,7 +586,36 @@ class Juc500Link:
|
|||||||
except usb.core.USBTimeoutError:
|
except usb.core.USBTimeoutError:
|
||||||
return b""
|
return b""
|
||||||
except usb.core.USBError as exc:
|
except usb.core.USBError as exc:
|
||||||
|
if _is_overflow(exc):
|
||||||
|
for bigger in (max(size, 512) * 2, 4096, 16384, 65536):
|
||||||
|
if bigger <= size:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = self._dev.read(ep, bigger, timeout=to)
|
||||||
|
return bytes(data)
|
||||||
|
except usb.core.USBTimeoutError:
|
||||||
|
return b""
|
||||||
|
except usb.core.USBError as exc2:
|
||||||
|
if _is_overflow(exc2):
|
||||||
|
size = bigger
|
||||||
|
continue
|
||||||
|
if _is_not_supported(exc2):
|
||||||
|
break
|
||||||
|
return b""
|
||||||
|
return b""
|
||||||
if _is_not_supported(exc):
|
if _is_not_supported(exc):
|
||||||
|
if _IS_WIN:
|
||||||
|
self._ensure_cfg_cache(force=True)
|
||||||
|
try:
|
||||||
|
data = self._dev.read(ep, size, timeout=to)
|
||||||
|
return bytes(data)
|
||||||
|
except usb.core.USBTimeoutError:
|
||||||
|
return b""
|
||||||
|
except usb.core.USBError as exc2:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"USB 읽기 실패(EP 0x{ep:02x}): {exc2}\n"
|
||||||
|
"Windows: Interface 5에 WinUSB가 잡혀 있는지 Zadig로 확인하세요."
|
||||||
|
) from exc2
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
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로 확인하세요."
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ pip install --no-index --find-links=vendor/wheels -r requirements.txt \
|
|||||||
pip install -e .
|
pip install -e .
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "설치 완료. 예:"
|
echo "설치 완료. 실행:"
|
||||||
|
echo " ./start.sh"
|
||||||
|
echo "예:"
|
||||||
echo " source $ROOT/.venv/bin/activate"
|
echo " source $ROOT/.venv/bin/activate"
|
||||||
echo " juc500 info"
|
echo " juc500 info"
|
||||||
echo " juc500 gui"
|
echo " juc500 gui"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# JUC500 GUI 실행 (인자 없으면 GUI, 있으면 CLI로 전달)
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if [[ -x "$ROOT/.venv/bin/python" ]]; then
|
||||||
|
PY="$ROOT/.venv/bin/python"
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
PY="$(command -v python3)"
|
||||||
|
else
|
||||||
|
echo "Python3 가 없습니다. 먼저 설치하세요:" >&2
|
||||||
|
echo " ./scripts/setup_macos.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$ROOT/.venv/bin/python" ]]; then
|
||||||
|
echo "경고: .venv 없음 — 시스템 Python 사용. 문제가 있으면 ./scripts/setup_macos.sh" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
|
|
||||||
|
# 의존성 빠른 확인
|
||||||
|
if ! "$PY" -c "import usb" 2>/dev/null; then
|
||||||
|
echo "pyusb 가 없습니다. 먼저 설치하세요:" >&2
|
||||||
|
echo " ./scripts/setup_macos.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$PY" "$ROOT/juc500_app.py" "$@"
|
||||||
Reference in New Issue
Block a user