Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 4 additions & 5 deletions c64cast/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
134 changes: 55 additions & 79 deletions c64cast/teensyrom_dma.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,13 @@
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
from collections import deque
from typing import Any

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -133,97 +130,76 @@ def close(self) -> None: ...
def description(self) -> str: ...


# The Teensy enumerates as a driverless USB-CDC device whose node name is
# /dev/cu.usbmodem<serial><iface> — 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."""
# 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<serial>, Linux /dev/ttyACM*, and Windows COM<N> — 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 _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:
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)
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:
payload = json.loads(proc.stdout)
except json.JSONDecodeError as e:
log.debug("TR serial auto-detect: bad system_profiler JSON: %s", e)
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 []
return _teensyrom_serials_from_profiler(payload)


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<serial>*` 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."""
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:
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):
Expand Down
75 changes: 75 additions & 0 deletions scripts/diags/tr_serial_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/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())
Loading