From 4debad0f009d27f87f8a05b3f44b68fc58c7d79a Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 7 Jul 2026 10:09:46 -0500 Subject: [PATCH 1/2] feat(teensyrom): cross-platform USB-serial auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the macOS-only system_profiler + /dev/cu.usbmodem globbing with a single pyserial list_ports.comports() path that works on macOS, Linux, and Windows. Identify the board by its USB (VID 0x16C0, PID 0x0489) with a "teensyrom" product-string fallback, and return the enumerated device path directly (COMn / /dev/ttyACM* / /dev/cu.usbmodem*) — no per-platform node resolution. Add scripts/diags/tr_serial_probe.py to make a failed detect explain itself (env, per-port fields, match decision) instead of a bare None. --- CLAUDE.md | 2 +- c64cast/backend.py | 9 +-- c64cast/teensyrom_dma.py | 133 ++++++++++++------------------- scripts/diags/tr_serial_probe.py | 73 +++++++++++++++++ tests/test_teensyrom.py | 111 ++++++++++++++------------ 5 files changed, 187 insertions(+), 141 deletions(-) create mode 100644 scripts/diags/tr_serial_probe.py diff --git a/CLAUDE.md b/CLAUDE.md index 9bc3743..d6e2457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ scripts/c64cast.sh --config c64cast.toml scripts/c64cast.sh --doctor --skip-probe ``` -**Connection target (`-u/--url`).** A single scheme-aware string selects both the hardware backend and its endpoint (the granular `--backend`/`--tr-*`/`--dma-port` flags were removed in favor of this — `git log` for the rationale). The parser is [c64cast/connect.py](c64cast/connect.py) (`parse_connection_uri` → `ConnectionSpec` → `apply_to_config`); it decomposes into the existing config fields (`[hardware].backend`, `[ultimate64].url`/`dma_port`, `[teensyrom].transport`/`serial_port`/`host`/…), which stay the canonical store a TOML sets directly. `make_backend` is unchanged. Schemes: `u64://HOST` or `http(s)://HOST` (Ultimate — the only HTTP-speaking backend, so http is deterministically Ultimate); `tr://` (TeensyROM+ USB serial, device auto-detected on macOS), `tr:///dev/cu.usbmodemXYZ` or `tr://COM3` (explicit serial device), `tr://HOST[:PORT]` (TeensyROM+ TCP, default port 2112). Rare per-link knobs as `?query` params (`u64://host?dma_port=64`, `tr://host?tcp_port=2113`, `tr:///dev/…?baud=2000000`, `tr://?storage=usb`). `$C64CAST_URL` is the env fallback. On the CLI the `-u` target overrides the config's connection sections (single-system runs only — in ensemble mode connection comes from the per-system TOMLs). +**Connection target (`-u/--url`).** A single scheme-aware string selects both the hardware backend and its endpoint (the granular `--backend`/`--tr-*`/`--dma-port` flags were removed in favor of this — `git log` for the rationale). The parser is [c64cast/connect.py](c64cast/connect.py) (`parse_connection_uri` → `ConnectionSpec` → `apply_to_config`); it decomposes into the existing config fields (`[hardware].backend`, `[ultimate64].url`/`dma_port`, `[teensyrom].transport`/`serial_port`/`host`/…), which stay the canonical store a TOML sets directly. `make_backend` is unchanged. Schemes: `u64://HOST` or `http(s)://HOST` (Ultimate — the only HTTP-speaking backend, so http is deterministically Ultimate); `tr://` (TeensyROM+ USB serial, device auto-detected by USB VID/PID via pyserial on macOS/Linux/Windows), `tr:///dev/cu.usbmodemXYZ` or `tr://COM3` (explicit serial device), `tr://HOST[:PORT]` (TeensyROM+ TCP, default port 2112). Rare per-link knobs as `?query` params (`u64://host?dma_port=64`, `tr://host?tcp_port=2113`, `tr:///dev/…?baud=2000000`, `tr://?storage=usb`). `$C64CAST_URL` is the env fallback. On the CLI the `-u` target overrides the config's connection sections (single-system runs only — in ensemble mode connection comes from the per-system TOMLs). **Quick playback (positional `MEDIA` args).** Passing media files/dirs/globs/URLs as positional arguments (mutually exclusive with `--config`) builds an **in-memory-only** `Config` (no file on disk) with one scene per argument, in order, **no loop** (override with `--loop`). Each argument is mapped to a scene type by extension — video → `video`, `.sid` → `waveform`, image → `slideshow`, `.prg`/`.crt` → `launcher` — and a directory/glob is passed straight through as the scene's `file` spec (so the scene random-picks at setup, e.g. a dir of SIDs plays a random one). A URL becomes a `video`: direct media URLs play as-is (PyAV opens http(s)), and YouTube/other sites are resolved by yt-dlp (the optional `yt` extra) to a single progressive stream. URL resolution + audio-only rejection happen **once, in `config.build_scene`** (the single resolution path shared with config-driven runs — see the `config.py` note below), so `quickcast.classify_url` just stores the URL verbatim; it parses the URL's `?t=`/`&start=`/`#t=` timestamp (`90`, `90s`, `1m30s`, `1h2m3s`) offline into `start_s` so playback begins at that offset (no flag). Audio-only files (mp3/wav over a test pattern) are recognized but deferred to a follow-up. The classifier library is [c64cast/quickcast.py](c64cast/quickcast.py) (`build_config`; the shared URL resolver is `resolve_video_url`); `cli.main` dispatches to it via `_resolve_configs` when it sees positional args, then runs the result through the normal path (`build_stack` → `_run_playlists` → `teardown_stack`), so behavior matches a config-driven run. diff --git a/c64cast/backend.py b/c64cast/backend.py index 40bf4f1..5892509 100644 --- a/c64cast/backend.py +++ b/c64cast/backend.py @@ -682,17 +682,16 @@ def make_backend(cfg: Config) -> C64Backend: port = tr.serial_port if not port: # No explicit device — try to find the TR's USB-serial node - # (macOS only for now; other platforms return None). + # by its USB (VID, PID) across macOS/Linux/Windows. port = autodetect_serial_port() if port: log.info("[teensyrom] auto-detected serial device %s", port) if not port: raise ValueError( "[teensyrom].serial_port is required when transport = " - '"serial" — auto-detection found no attached TeensyROM ' - "(macOS only for now). Set it explicitly (e.g. " - "/dev/cu.usbmodem* or COM3) over a plain USB data cable — " - "not an FTDI null-modem cable." + '"serial" — auto-detection found no attached TeensyROM. ' + "Set it explicitly (e.g. /dev/cu.usbmodem* or COM3) over a " + "plain USB data cable — not an FTDI null-modem cable." ) transport = SerialTransport(port, tr.baud or DEFAULT_BAUD) transport_kind = "tr_serial" diff --git a/c64cast/teensyrom_dma.py b/c64cast/teensyrom_dma.py index 746e16a..a8b7a83 100644 --- a/c64cast/teensyrom_dma.py +++ b/c64cast/teensyrom_dma.py @@ -49,12 +49,8 @@ from __future__ import annotations import contextlib -import glob -import json import logging import socket -import subprocess -import sys import threading import time from abc import ABC, abstractmethod @@ -133,97 +129,68 @@ def close(self) -> None: ... def description(self) -> str: ... -# The Teensy enumerates as a driverless USB-CDC device whose node name is -# /dev/cu.usbmodem — the board's USB serial number plus a -# trailing interface digit. system_profiler reports the serial number under -# `USBDeviceKeySerialNumber` on a node named "TeensyROM". We query *both* USB -# data types so one call covers macOS generations: `SPUSBHostDataType` is the -# current key, `SPUSBDataType` the older one (system_profiler emits whichever -# exist; the recursive walk doesn't care which). -_MACOS_USB_PROFILE_CMD = ("system_profiler", "SPUSBHostDataType", "SPUSBDataType", "-json") -_TEENSYROM_USB_NAME = "TeensyROM" -_MACOS_CU_PREFIX = "/dev/cu.usbmodem" - - -def _teensyrom_serials_from_profiler(payload: object) -> list[str]: - """Walk a parsed `system_profiler -json` payload and return the USB serial - number of every attached TeensyROM (a recursive search, since the USB tree - nests devices under hub `_items`). Pure — unit-tested against a fixture.""" - out: list[str] = [] - - def walk(node: object) -> None: - if isinstance(node, dict): - if node.get("_name") == _TEENSYROM_USB_NAME: - serial = node.get("USBDeviceKeySerialNumber") - if serial: - out.append(str(serial)) - for value in node.values(): - walk(value) - elif isinstance(node, list): - for value in node: - walk(value) - - walk(payload) - return out - - -def _device_for_serial(serial: str, candidates: list[str] | None = None) -> str | None: - """Resolve a USB serial number to its `/dev/cu.usbmodem*` node by PREFIX - match — the device name carries a trailing interface digit - (serial 19307560 → /dev/cu.usbmodem193075601), so a literal append wouldn't - exist. `candidates` is injectable for tests; None globs the real /dev.""" - if candidates is None: - candidates = glob.glob(f"{_MACOS_CU_PREFIX}{serial}*") - return sorted(candidates)[0] if candidates else None - - -def _macos_teensyrom_serials() -> list[str]: - """Run system_profiler and extract TeensyROM USB serials. Best-effort: - returns [] (never raises) if the tool is missing, errors, or emits non-JSON.""" - try: - proc = subprocess.run( - _MACOS_USB_PROFILE_CMD, - capture_output=True, - text=True, - timeout=15, - check=False, - ) - except (OSError, subprocess.SubprocessError) as e: - log.debug("TR serial auto-detect: system_profiler unavailable: %s", e) - return [] - if proc.returncode != 0 or not proc.stdout: - log.debug("TR serial auto-detect: system_profiler exit=%d", proc.returncode) - return [] - try: - payload = json.loads(proc.stdout) - except json.JSONDecodeError as e: - log.debug("TR serial auto-detect: bad system_profiler JSON: %s", e) - return [] - return _teensyrom_serials_from_profiler(payload) +# The TeensyROM is a Teensy 4.1 enumerating as a USB-CDC device under PJRC's +# USB vendor id 0x16C0; the TeensyROM firmware's USB type reports product id +# 0x0489. We identify the board by (VID, PID) rather than by device-node name +# because that pair is the only stable, cross-platform key: macOS names the +# node /dev/cu.usbmodem, Linux /dev/ttyACM*, and Windows COM — none +# of them derivable from each other. Where the OS also surfaces the USB product +# string ("TeensyROM"), a name match is accepted as a fallback; Windows' generic +# usbser.sys driver drops that string (product is None, description is the bare +# "USB Serial Device (COMn)"), which is exactly why the (VID, PID) key leads. +_TEENSY_USB_VID = 0x16C0 +_TEENSYROM_USB_PID = 0x0489 +_TEENSYROM_PRODUCT_NAME = "teensyrom" # matched case-insensitively + + +def _is_teensyrom_port(info: object) -> bool: + """True if a pyserial ``ListPortInfo`` describes an attached TeensyROM. + + Pure + duck-typed (reads only ``.vid``/``.pid``/``.product``/``.description``) + so it is unit-testable without hardware. Primary key is the (VID, PID) pair; + a case-insensitive "teensyrom" product/description substring is accepted as a + fallback for platforms that expose the USB product string.""" + vid = getattr(info, "vid", None) + pid = getattr(info, "pid", None) + if vid == _TEENSY_USB_VID and pid == _TEENSYROM_USB_PID: + return True + for text in (getattr(info, "product", None), getattr(info, "description", None)): + if text and _TEENSYROM_PRODUCT_NAME in text.lower(): + return True + return False def autodetect_serial_port() -> str | None: """Best-effort: find the attached TeensyROM's USB-serial device path. - macOS only for now (other platforms return None — to be added later): query - system_profiler for the board's USB serial number, then glob - `/dev/cu.usbmodem*` for the matching node. Returns the path, or None - when not on macOS, no TeensyROM is attached, or no device node matches (the - caller then falls back to requiring an explicit `[teensyrom].serial_port`).""" - if sys.platform != "darwin": - return None # Linux (/dev/serial/by-id) + Windows TBD - matches = [(s, _device_for_serial(s)) for s in _macos_teensyrom_serials()] - found = [(serial, dev) for serial, dev in matches if dev] + Cross-platform via pyserial's ``list_ports.comports()`` (macOS + /dev/cu.usbmodem*, Linux /dev/ttyACM*, Windows COMn) — each entry already + carries the resolved device path plus USB (VID, PID) and product metadata, so + no per-platform node resolution is needed. Returns the device of the first + matching board, or None when pyserial is missing, no TeensyROM is attached, + or none matches (the caller then falls back to requiring an explicit + ``[teensyrom].serial_port``). Never raises.""" + try: + from serial.tools import list_ports # lazy: only the serial transport needs it + except ImportError as e: + log.debug("TR serial auto-detect: pyserial unavailable: %s", e) + return None + try: + ports = list(list_ports.comports()) + except Exception as e: # pragma: no cover - defensive; comports enumerates the OS + log.debug("TR serial auto-detect: comports() failed: %s", e) + return None + found: list[str] = sorted(str(p.device) for p in ports if _is_teensyrom_port(p)) if not found: return None if len(found) > 1: log.warning( "TR serial auto-detect: multiple TeensyROM boards attached (%s) — using " "%s; set [teensyrom].serial_port to pick a specific one", - [serial for serial, _ in found], - found[0][1], + found, + found[0], ) - return found[0][1] + return found[0] class SerialTransport(TRTransport): diff --git a/scripts/diags/tr_serial_probe.py b/scripts/diags/tr_serial_probe.py new file mode 100644 index 0000000..d619c9b --- /dev/null +++ b/scripts/diags/tr_serial_probe.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +"""Explain TeensyROM USB-serial auto-detection on this host. + +`autodetect_serial_port()` returns a bare `None` for three unrelated reasons — +pyserial missing, `comports()` raising, or no port matching — which makes a +failed auto-detect impossible to diagnose from the return value alone. This tool +runs the *same* enumeration and prints, per port, every field the matcher reads +plus its accept/reject decision, so a silent `None` becomes a specific cause. + +Cross-platform (macOS/Linux/Windows). No c64cast hardware needed — it only +touches the local USB serial bus. + + python scripts/diags/tr_serial_probe.py + # or, to be sure you hit the project env that has pyserial: + uv run python scripts/diags/tr_serial_probe.py +""" + +from __future__ import annotations + +import sys + +import _diaglib # noqa: F401 (path bootstrap: makes `import c64cast` work from any cwd) + + +def main() -> int: + print(f"python : {sys.executable}") + print(f"platform : {sys.platform}") + + try: + import serial + from serial.tools import list_ports + except ImportError as e: + print(f"pyserial : NOT IMPORTABLE ({e})") + print("\n=> This is why auto-detect returns None. Install the 'tr' extra") + print(" into the interpreter above (uv sync --extra tr), or run this") + print(" via `uv run python ...` so it uses the project .venv.") + return 1 + print(f"pyserial : {getattr(serial, '__version__', '?')}") + + from c64cast.teensyrom_dma import ( + _TEENSY_USB_VID, + _TEENSYROM_USB_PID, + _is_teensyrom_port, + autodetect_serial_port, + ) + + print(f"looking for : VID={_TEENSY_USB_VID:#06x} PID={_TEENSYROM_USB_PID:#06x} " + f'(or product/description containing "teensyrom")\n') + + ports = list(list_ports.comports()) + if not ports: + print("comports() : returned NO ports at all.") + print("\n=> The OS is not exposing any serial device. Check the USB data") + print(" cable (not charge-only), the driver, and that the board is on.") + return 1 + + print(f"comports() : {len(ports)} port(s)\n") + for p in ports: + vid = f"{p.vid:#06x}" if p.vid is not None else "None" + pid = f"{p.pid:#06x}" if p.pid is not None else "None" + decision = "MATCH" if _is_teensyrom_port(p) else "no" + print(f" [{decision:>5}] {p.device}") + print(f" vid={vid} pid={pid} serial={p.serial_number!r}") + print(f" product={p.product!r} description={p.description!r}") + print(f" hwid={p.hwid!r}") + + result = autodetect_serial_port() + print(f"\nautodetect_serial_port() => {result!r}") + return 0 if result else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_teensyrom.py b/tests/test_teensyrom.py index a95fff0..4224b20 100644 --- a/tests/test_teensyrom.py +++ b/tests/test_teensyrom.py @@ -535,64 +535,71 @@ def test_reu_backend_unchanged(self): self.assertIs(cfg.video.use_reu_staged, True) +class _FakePort: + """Minimal stand-in for pyserial's ListPortInfo (duck-typed by the matcher).""" + + def __init__(self, device, vid=None, pid=None, product=None, description=None): + self.device = device + self.vid = vid + self.pid = pid + self.product = product + self.description = description + + class TRSerialAutodetectTest(unittest.TestCase): - """Pure-helper tests for macOS serial-device auto-detection (no subprocess, - no real /dev).""" - - def test_serials_from_profiler_walks_nested_usb_tree(self): - from c64cast.teensyrom_dma import _teensyrom_serials_from_profiler - - # system_profiler nests devices under hub `_items`; the walk recurses. - payload = { - "SPUSBHostDataType": [ - { - "_name": "USB hub", - "_items": [ - {"_name": "TeensyROM", "USBDeviceKeySerialNumber": "19307560"}, - {"_name": "Some Webcam", "USBDeviceKeySerialNumber": "deadbeef"}, - ], - } - ] - } - self.assertEqual(_teensyrom_serials_from_profiler(payload), ["19307560"]) - - def test_serials_from_profiler_empty_when_no_teensyrom(self): - from c64cast.teensyrom_dma import _teensyrom_serials_from_profiler - - self.assertEqual(_teensyrom_serials_from_profiler({"SPUSBHostDataType": []}), []) - - def test_device_for_serial_prefix_matches_trailing_iface_digit(self): - from c64cast.teensyrom_dma import _device_for_serial - - # Real case: serial 19307560 -> /dev/cu.usbmodem193075601 (the device - # node carries a trailing interface digit, so a literal append fails; - # a prefix match resolves it). - self.assertEqual( - _device_for_serial("19307560", ["/dev/cu.usbmodem193075601"]), - "/dev/cu.usbmodem193075601", - ) - - def test_device_for_serial_none_when_no_match(self): - from c64cast.teensyrom_dma import _device_for_serial - - self.assertIsNone(_device_for_serial("19307560", [])) - - def test_device_for_serial_picks_lowest_when_multiple_ifaces(self): - from c64cast.teensyrom_dma import _device_for_serial - - self.assertEqual( - _device_for_serial( - "19307560", ["/dev/cu.usbmodem193075603", "/dev/cu.usbmodem193075601"] - ), - "/dev/cu.usbmodem193075601", - ) + """Cross-platform serial-device auto-detection via pyserial's list_ports + (no hardware; fake ListPortInfo objects, comports() patched).""" + + # A TeensyROM as it enumerates on Windows: correct (VID, PID) but the + # generic usbser.sys driver exposes no product string. + WINDOWS = _FakePort( + "COM19", vid=0x16C0, pid=0x0489, product=None, description="USB Serial Device (COM19)" + ) + # As it enumerates on macOS/Linux, where the USB product string surfaces. + MACOS = _FakePort( + "/dev/cu.usbmodem193075601", vid=0x16C0, pid=0x0489, product="TeensyROM" + ) + OTHER = _FakePort("COM3", vid=0x1234, pid=0x5678, product="Some Modem") + + def test_matches_by_vid_pid_when_product_missing(self): + from c64cast.teensyrom_dma import _is_teensyrom_port + + self.assertTrue(_is_teensyrom_port(self.WINDOWS)) + + def test_matches_by_product_name_fallback(self): + from c64cast.teensyrom_dma import _is_teensyrom_port + + # Product string alone identifies it even if the (VID, PID) is unknown. + self.assertTrue(_is_teensyrom_port(_FakePort("COM7", product="TeensyROM v1"))) - def test_autodetect_returns_none_off_macos(self): + def test_rejects_non_teensyrom_port(self): + from c64cast.teensyrom_dma import _is_teensyrom_port + + self.assertFalse(_is_teensyrom_port(self.OTHER)) + + def _patch_comports(self, ports): + return mock.patch("serial.tools.list_ports.comports", return_value=ports) + + def test_autodetect_returns_matching_device(self): from c64cast import teensyrom_dma - with mock.patch.object(teensyrom_dma.sys, "platform", "linux"): + with self._patch_comports([self.OTHER, self.WINDOWS]): + self.assertEqual(teensyrom_dma.autodetect_serial_port(), "COM19") + + def test_autodetect_returns_none_when_no_match(self): + from c64cast import teensyrom_dma + + with self._patch_comports([self.OTHER]): self.assertIsNone(teensyrom_dma.autodetect_serial_port()) + def test_autodetect_warns_and_picks_lowest_on_multiple_boards(self): + from c64cast import teensyrom_dma + + second = _FakePort("COM20", vid=0x16C0, pid=0x0489) + with self._patch_comports([second, self.WINDOWS]): + with self.assertLogs("c64cast.teensyrom_dma", level="WARNING"): + self.assertEqual(teensyrom_dma.autodetect_serial_port(), "COM19") + class MakeBackendTest(unittest.TestCase): def test_serial_requires_port_when_autodetect_fails(self): From 387d19d93b6bed5f5226f26ca7774314ddd754f5 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 7 Jul 2026 10:25:40 -0500 Subject: [PATCH 2/2] fix(teensyrom): make serial auto-detect tests independent of the tr extra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI installs only the midi extra, so pyserial is absent and the tests that patched serial.tools.list_ports.comports errored at patch time, while ruff format also wanted two files reflowed. Isolate the pyserial call behind _list_comports() (returns [] when pyserial is missing or enumeration fails) so the auto-detect tests patch that helper instead of the serial module — no pyserial needed. Apply ruff format. --- c64cast/teensyrom_dma.py | 31 ++++++++++++++++++++----------- scripts/diags/tr_serial_probe.py | 6 ++++-- tests/test_teensyrom.py | 8 ++++---- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/c64cast/teensyrom_dma.py b/c64cast/teensyrom_dma.py index a8b7a83..7d42374 100644 --- a/c64cast/teensyrom_dma.py +++ b/c64cast/teensyrom_dma.py @@ -55,6 +55,7 @@ import time from abc import ABC, abstractmethod from collections import deque +from typing import Any log = logging.getLogger(__name__) @@ -160,6 +161,24 @@ def _is_teensyrom_port(info: object) -> bool: return False +def _list_comports() -> list[Any]: + """Enumerate serial ports via pyserial, returning ``ListPortInfo``-like + objects (typed ``Any`` — pyserial ships no types and the matcher reads the + fields via getattr). Best-effort: returns [] if pyserial (the ``tr`` extra) + is missing or enumeration fails. Isolated behind this helper so tests can + inject fake ports without requiring the extra to be installed.""" + try: + from serial.tools import list_ports # lazy: only the serial transport needs it + except ImportError as e: + log.debug("TR serial auto-detect: pyserial unavailable: %s", e) + return [] + try: + return list(list_ports.comports()) + except Exception as e: # pragma: no cover - defensive; comports enumerates the OS + log.debug("TR serial auto-detect: comports() failed: %s", e) + return [] + + def autodetect_serial_port() -> str | None: """Best-effort: find the attached TeensyROM's USB-serial device path. @@ -170,17 +189,7 @@ def autodetect_serial_port() -> str | None: matching board, or None when pyserial is missing, no TeensyROM is attached, or none matches (the caller then falls back to requiring an explicit ``[teensyrom].serial_port``). Never raises.""" - try: - from serial.tools import list_ports # lazy: only the serial transport needs it - except ImportError as e: - log.debug("TR serial auto-detect: pyserial unavailable: %s", e) - return None - try: - ports = list(list_ports.comports()) - except Exception as e: # pragma: no cover - defensive; comports enumerates the OS - log.debug("TR serial auto-detect: comports() failed: %s", e) - return None - found: list[str] = sorted(str(p.device) for p in ports if _is_teensyrom_port(p)) + found: list[str] = sorted(str(p.device) for p in _list_comports() if _is_teensyrom_port(p)) if not found: return None if len(found) > 1: diff --git a/scripts/diags/tr_serial_probe.py b/scripts/diags/tr_serial_probe.py index d619c9b..1c24cec 100644 --- a/scripts/diags/tr_serial_probe.py +++ b/scripts/diags/tr_serial_probe.py @@ -44,8 +44,10 @@ def main() -> int: autodetect_serial_port, ) - print(f"looking for : VID={_TEENSY_USB_VID:#06x} PID={_TEENSYROM_USB_PID:#06x} " - f'(or product/description containing "teensyrom")\n') + print( + f"looking for : VID={_TEENSY_USB_VID:#06x} PID={_TEENSYROM_USB_PID:#06x} " + f'(or product/description containing "teensyrom")\n' + ) ports = list(list_ports.comports()) if not ports: diff --git a/tests/test_teensyrom.py b/tests/test_teensyrom.py index 4224b20..518ae3d 100644 --- a/tests/test_teensyrom.py +++ b/tests/test_teensyrom.py @@ -556,9 +556,7 @@ class TRSerialAutodetectTest(unittest.TestCase): "COM19", vid=0x16C0, pid=0x0489, product=None, description="USB Serial Device (COM19)" ) # As it enumerates on macOS/Linux, where the USB product string surfaces. - MACOS = _FakePort( - "/dev/cu.usbmodem193075601", vid=0x16C0, pid=0x0489, product="TeensyROM" - ) + MACOS = _FakePort("/dev/cu.usbmodem193075601", vid=0x16C0, pid=0x0489, product="TeensyROM") OTHER = _FakePort("COM3", vid=0x1234, pid=0x5678, product="Some Modem") def test_matches_by_vid_pid_when_product_missing(self): @@ -578,7 +576,9 @@ def test_rejects_non_teensyrom_port(self): self.assertFalse(_is_teensyrom_port(self.OTHER)) def _patch_comports(self, ports): - return mock.patch("serial.tools.list_ports.comports", return_value=ports) + # Patch the isolated enumeration helper (not serial.tools.list_ports) + # so these tests don't require the 'tr' extra / pyserial in CI. + return mock.patch("c64cast.teensyrom_dma._list_comports", return_value=ports) def test_autodetect_returns_matching_device(self): from c64cast import teensyrom_dma