emdashes begone (#10847)

This commit is contained in:
Tom
2026-07-01 19:01:27 -05:00
committed by GitHub
co-authored by GitHub
parent dee94e0758
commit 3becaf2d95
276 changed files with 1795 additions and 1793 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""Meshtastic MCP server device discovery, PlatformIO tooling, and device admin."""
"""Meshtastic MCP server - device discovery, PlatformIO tooling, and device admin."""
__version__ = "0.1.0"
+4 -4
View File
@@ -222,7 +222,7 @@ def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]
# Treat the section as the root; the rest of the path walks into it.
leaf_parent, field = _walk_to_field(container, segments[1:] or [])
# Use `is_repeated` (modern upb protobuf API) rather than the
# deprecated `label == LABEL_REPEATED` check the C-extension
# deprecated `label == LABEL_REPEATED` check - the C-extension
# FieldDescriptor in protobuf >= 5.x doesn't expose `.label` at
# all, and `is_repeated` is the supported replacement that works
# across both the pure-python and upb backends.
@@ -313,7 +313,7 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
When enabled, firmware emits log lines as protobuf `LogRecord` messages
over the StreamAPI instead of raw text. meshtastic-python surfaces them
on pubsub topic `meshtastic.log.line`, which flows through the SAME
SerialInterface our tests already hold open no `pio device monitor`
SerialInterface our tests already hold open - no `pio device monitor`
needed, no port-contention with admin/info calls.
Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs &&
@@ -322,7 +322,7 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
re-applied after reset.
Previously-documented concurrency hazard (emitLogRecord sharing the
main packet-emission buffers) has been fixed see `StreamAPI.h`
main packet-emission buffers) has been fixed - see `StreamAPI.h`
where the log path now owns dedicated `fromRadioScratchLog` /
`txBufLog` buffers, and `StreamAPI::emitTxBuffer` +
`StreamAPI::emitLogRecord` both serialize their `stream->write`
@@ -366,7 +366,7 @@ def send_input_event(
"""Inject an InputBroker event (button press / key / gesture) into the UI.
Wraps `AdminMessage.send_input_event` (handled in firmware at
src/modules/AdminModule.cpp::handleSendInputEvent). Local-only no PKI
src/modules/AdminModule.cpp::handleSendInputEvent). Local-only - no PKI
warmup needed since the admin message is addressed to `my_node_num`.
`event_code` accepts an int, a case-insensitive name
+3 -3
View File
@@ -1,11 +1,11 @@
"""Board / PlatformIO env enumeration.
Parses `pio project config --json-output` a nested list of
`[section_name, [[key, value], ...]]` pairs into a dict keyed by env name,
Parses `pio project config --json-output` - a nested list of
`[section_name, [[key, value], ...]]` pairs - into a dict keyed by env name,
extracting the `custom_meshtastic_*` metadata the firmware variants expose.
The parsed config is cached and invalidated when `platformio.ini`'s mtime
changes, so subsequent calls don't pay the 12s pio startup cost.
changes, so subsequent calls don't pay the 1-2s pio startup cost.
"""
from __future__ import annotations
+9 -9
View File
@@ -1,19 +1,19 @@
"""Cross-platform USB-webcam capture for UI tests + the `capture_screen` tool.
Backends:
- `opencv` cv2.VideoCapture (AVFoundation on macOS, V4L2 on Linux).
- `ffmpeg` subprocess shelling out to the system `ffmpeg` binary. Slower
- `opencv` - cv2.VideoCapture (AVFoundation on macOS, V4L2 on Linux).
- `ffmpeg` - subprocess shelling out to the system `ffmpeg` binary. Slower
per frame, but zero Python deps beyond stdlib.
- `null` no-op stub returning a 1×1 black PNG. Used when no camera is
- `null` - no-op stub returning a 1×1 black PNG. Used when no camera is
configured; keeps code paths alive without forcing every operator to
hook up hardware.
Environment variables (read at `get_camera()` call time):
- `MESHTASTIC_UI_CAMERA_BACKEND` one of `opencv` / `ffmpeg` / `null` /
- `MESHTASTIC_UI_CAMERA_BACKEND` - one of `opencv` / `ffmpeg` / `null` /
`auto` (default). `auto` picks opencv if `cv2` imports, else ffmpeg if
`ffmpeg --version` resolves, else null.
- `MESHTASTIC_UI_CAMERA_DEVICE` generic default (index or path).
- `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` per-role override, e.g.
- `MESHTASTIC_UI_CAMERA_DEVICE` - generic default (index or path).
- `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` - per-role override, e.g.
`MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3.
Role suffix is uppercased before lookup.
@@ -76,7 +76,7 @@ class OpenCVBackend:
"On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access."
)
# Drop the first few frames auto-exposure + white-balance settle.
# Drop the first few frames - auto-exposure + white-balance settle.
for _ in range(warmup_frames):
self._cap.read()
# Detect a stuck black-frame camera early rather than silently
@@ -159,7 +159,7 @@ class FfmpegBackend:
return out.stdout
def close(self) -> None:
pass # stateless each capture spawns a new process
pass # stateless - each capture spawns a new process
# ---------- Null backend ---------------------------------------------------
@@ -197,7 +197,7 @@ def get_camera(role: str | None = None) -> CameraBackend:
"""Return a CameraBackend for the given device role (e.g. `"esp32s3"`).
Falls back to `NullBackend` if no camera is configured or the selected
backend fails to init tests should treat captures as best-effort
backend fails to init - tests should treat captures as best-effort
evidence, not a blocker.
"""
backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower()
@@ -2,5 +2,5 @@
Modules here are loaded on-demand by `[project.scripts]` entries in
`pyproject.toml`. They are NOT imported by `meshtastic_mcp.server` or the
admin/info tool surface the MCP server stays pure stdio JSON-RPC.
admin/info tool surface - the MCP server stays pure stdio JSON-RPC.
"""
@@ -2,7 +2,7 @@
``pio.py`` / ``hw_tools.py`` tee subprocess output (``pio run -t upload``,
``esptool erase_flash``, ``nrfutil dfu``, etc.) to ``tests/flash.log``
line-by-line as it arrives controlled by the ``MESHTASTIC_MCP_FLASH_LOG``
line-by-line as it arrives - controlled by the ``MESHTASTIC_MCP_FLASH_LOG``
env var that ``run-tests.sh`` sets. The TUI tails that file so the operator
sees live flash progress in the pytest pane instead of 3 minutes of silence
during ``test_00_bake``.
@@ -25,7 +25,7 @@ class FlashLogTailer(threading.Thread):
``post`` is invoked with a single ``str`` for every new line. Lines are
stripped of trailing newlines; empty lines after stripping are dropped.
The file may not exist yet when this thread starts it's truncated by
The file may not exist yet when this thread starts - it's truncated by
``run-tests.sh`` at session start, but if the tailer races the shell,
we tolerate FileNotFoundError for up to ``wait_s`` seconds.
"""
+4 -4
View File
@@ -2,14 +2,14 @@
Complements v1's reportlog-tail worker. ``tests/conftest.py`` owns a
session-scoped autouse fixture (``_firmware_log_stream``) that mirrors
every ``meshtastic.log.line`` pubsub event to ``tests/fwlog.jsonl``
every ``meshtastic.log.line`` pubsub event to ``tests/fwlog.jsonl`` -
one JSON object per line:
{"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "..."}
The TUI tails that file from a worker thread; each new line becomes a
:class:`FirmwareLogLine` message posted to the App. Same pattern as the
reportlog tail worker truncate on launch, tolerate missing file for
reportlog tail worker - truncate on launch, tolerate missing file for
30 s, back off at EOF.
Kept in its own module so the (large) ``test_tui.py`` stays focused on
@@ -30,14 +30,14 @@ class FirmwareLogTailer(threading.Thread):
``post`` is the App's ``post_message`` (or any callable that accepts a
single payload arg). We pass parsed dicts rather than constructing
Textual Message objects here keeps this module free of the
Textual Message objects here - keeps this module free of the
textual dependency so it's unit-testable in a bare venv.
Parameters
----------
path:
Path to ``tests/fwlog.jsonl``. The file may not exist yet at
startup pytest only creates it once the session fixture runs.
startup - pytest only creates it once the session fixture runs.
post:
Callable invoked with a dict ``{"ts", "port", "line"}`` for every
new line parsed from the file.
@@ -2,7 +2,7 @@
Persists one JSON object per pytest run to
``mcp-server/tests/.history/runs.jsonl``. The TUI reads the last N
entries on launch to render a duration sparkline in the header a
entries on launch to render a duration sparkline in the header - a
quick read on whether the suite is slowing down over time.
Schema (keep small; the file can grow for months):
@@ -14,7 +14,7 @@ minimum viable failure context into a tarball under
└── env.json seed, run #, pytest version, platform, hostname
Separate module so the logic can be unit-tested without Textual. The
TUI glue is thin one key binding calls :func:`build_reproducer_bundle`
TUI glue is thin - one key binding calls :func:`build_reproducer_bundle`
with the focused test's state and shows the path in a modal.
"""
@@ -35,7 +35,7 @@ from typing import Any, Iterable
@dataclass
class ReproContext:
"""Everything :func:`build_reproducer_bundle` needs. Shaped to map
cleanly onto the state the TUI already tracks no extra data
cleanly onto the state the TUI already tracks - no extra data
collection required at export time."""
nodeid: str
@@ -70,7 +70,7 @@ def _filtered_fwlog(
if not fwlog_path.is_file():
return b""
if start_ts is None or stop_ts is None:
# Without a time window, include the whole file rare; happens
# Without a time window, include the whole file - rare; happens
# when a test fails in setup before pytest emitted a start ts.
try:
return fwlog_path.read_bytes()
@@ -115,14 +115,14 @@ Exported by `meshtastic-mcp-test-tui` on {t}.
| File | Contents |
|---|---|
| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. |
| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test - includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. |
| `fwlog.jsonl` | Firmware log lines (from `meshtastic.log.line` pubsub) filtered to [start5s, stop+5s] around the test's run window. Each line is `{{ts, port, line}}`. |
| `devices.json` | Per-device snapshot at export time: `device_info` + `lora` config per detected role. |
| `env.json` | Python version, platform, hostname, seed, run number. |
## How to triage
1. Open `test_report.json` and read `longrepr` + `sections` most failures explain themselves there.
1. Open `test_report.json` and read `longrepr` + `sections` - most failures explain themselves there.
2. If the failure is a mesh/telemetry assertion, `fwlog.jsonl` is where the answer usually lives. Grep for `Error=`, `NAK`, `PKI_UNKNOWN_PUBKEY`, `Skip send`, `Guru Meditation`, or the uptime timestamps around the assertion event.
3. Compare `devices.json` against the expected state (e.g. `num_nodes >= 2`, `primary_channel == "McpTest"`, `region == "US"`). If fields disagree with the seed-derived USERPREFS profile, the device probably wasn't baked with this session's profile.
@@ -139,7 +139,7 @@ def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
"""Build a tarball under ``ctx.output_dir`` and return its path.
Parent dirs are created as needed. Errors during optional sections
(devices, env) are swallowed the bundle is still useful without
(devices, env) are swallowed - the bundle is still useful without
them; refusing to export because the device poller had a hiccup
would be worse than the export missing a file.
"""
@@ -159,7 +159,7 @@ def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
# README
_add("README.md", _readme(ctx).encode("utf-8"))
# test_report.json reconstruct from the fields the TUI stashes.
# test_report.json - reconstruct from the fields the TUI stashes.
test_report = {
"nodeid": ctx.nodeid,
"outcome": "failed",
@@ -208,7 +208,7 @@ def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
def iter_entries(archive_path: pathlib.Path) -> Iterable[str]:
"""Yield member names used by callers that want to confirm the bundle shape."""
"""Yield member names - used by callers that want to confirm the bundle shape."""
with tarfile.open(archive_path, "r:gz") as tar:
for m in tar.getmembers():
yield m.name
+3 -3
View File
@@ -22,7 +22,7 @@ class UiCaptureTailer(threading.Thread):
"""Recursively watch a captures root for new `transcript.md` lines.
Invokes ``post(test_id, line)`` for each new line, where ``test_id``
is derived from the path the sanitized nodeid directory name.
is derived from the path - the sanitized nodeid directory name.
"""
def __init__(
@@ -46,7 +46,7 @@ class UiCaptureTailer(threading.Thread):
try:
self._scan_once()
except Exception:
# Best-effort tailer never bring down the TUI because a
# Best-effort tailer - never bring down the TUI because a
# directory vanished mid-scan.
pass
time.sleep(self._poll_interval)
@@ -62,7 +62,7 @@ class UiCaptureTailer(threading.Thread):
except OSError:
continue
if size < offset:
# File truncated / rewritten reset and re-emit.
# File truncated / rewritten - reset and re-emit.
offset = 0
if size == offset:
continue
+61 -61
View File
@@ -6,19 +6,19 @@ The TUI *wraps* ``run-tests.sh``; it never replaces it. Same script, same
env-var resolution, same ``userPrefs.jsonc`` session fixture. Four data
sources drive live state:
1. ``tests/reportlog.jsonl`` written by ``pytest-reportlog``. Tailed in a
1. ``tests/reportlog.jsonl`` - written by ``pytest-reportlog``. Tailed in a
worker thread; each JSON line is published as a :class:`ReportLogEvent`
message. This is the authoritative source for tree population + per-test
outcome.
2. The pytest subprocess ``stdout`` + ``stderr`` streams line-by-line,
2. The pytest subprocess ``stdout`` + ``stderr`` streams - line-by-line,
published as :class:`PytestLine` messages and rendered verbatim in the
pytest pane.
3. ``tests/fwlog.jsonl`` firmware log stream. Written by the
3. ``tests/fwlog.jsonl`` - firmware log stream. Written by the
``_firmware_log_stream`` autouse session fixture in ``conftest.py``
(mirrors every ``meshtastic.log.line`` pubsub event), tailed by the
:class:`FirmwareLogTailer` worker, displayed in a wrap-enabled
RichLog with cycleable port filter.
4. ``devices.list_devices()`` + ``info.device_info(port)`` polled only at
4. ``devices.list_devices()`` + ``info.device_info(port)`` - polled only at
startup and again after ``RunFinished``. Device polling while pytest
holds a SerialInterface would deadlock on the exclusive port lock; the
existing ``hub_devices`` fixture is session-scoped so there is no safe
@@ -64,7 +64,7 @@ from typing import Any, Iterator
# bake → unit → mesh → telemetry → monitor → fleet → admin → provisioning
# so the counters table reads top-to-bottom in execution order.
#
# "bake" is the synthetic tier for `tests/test_00_bake.py` the file sits
# "bake" is the synthetic tier for `tests/test_00_bake.py` - the file sits
# at the `tests/` root rather than under a tier subdirectory, so without
# this mapping `_tier_of_nodeid` would return "other" and the bake outcomes
# would be silently dropped from both the tier table and the history
@@ -139,7 +139,7 @@ class LeafReport:
duration_s: float = 0.0
longrepr: str = ""
# Captured stdout / stderr / firmware-log sections from the test's
# `TestReport.sections` shown in the failure-detail modal.
# `TestReport.sections` - shown in the failure-detail modal.
sections: list[tuple[str, str]] = field(default_factory=list)
# Wall-clock start/stop from the TestReport event. Used by the
# reproducer exporter (`x`) to filter `tests/fwlog.jsonl` down to
@@ -175,7 +175,7 @@ class State:
"""Shared state owned by the App; written by workers under `lock`.
UI code reads via Textual Message handlers which run on the UI thread
in the order workers called `post_message` so reads don't need the
in the order workers called `post_message` - so reads don't need the
lock themselves.
"""
@@ -184,7 +184,7 @@ class State:
default_factory=lambda: {t: TierCounters(tier=t) for t in TIERS}
)
leaves: dict[str, LeafReport] = field(default_factory=dict)
# Ordered list of nodeids in the order they were first seen lets us
# Ordered list of nodeids in the order they were first seen - lets us
# rebuild the tree deterministically.
nodeid_order: list[str] = field(default_factory=list)
devices: list[DeviceRow] = field(default_factory=list)
@@ -212,13 +212,13 @@ def _tier_of_nodeid(nodeid: str) -> str:
"""Map a pytest nodeid to its tier bucket. Unknown → 'other'.
`tests/test_00_bake.py::...` is special-cased to the synthetic `bake`
tier it's a top-level file (no tier subdirectory) so the generic
tier - it's a top-level file (no tier subdirectory) so the generic
"second path segment" logic would miss it and route the bake outcomes
into the non-existent `other` bucket.
"""
parts = nodeid.split("/", 2)
if len(parts) >= 2 and parts[0] == "tests":
# Bake file sits at `tests/test_00_bake.py` dedicated bucket.
# Bake file sits at `tests/test_00_bake.py` - dedicated bucket.
if parts[1].startswith("test_00_bake"):
return "bake"
candidate = parts[1]
@@ -249,7 +249,7 @@ def _roles_from_nodeid(nodeid: str) -> set[str]:
- ``test_foo[nrf52]`` → {"nrf52"} (baked_single)
- ``test_foo[nrf52->esp32s3]`` → {"nrf52", "esp32s3"} (mesh_pair)
Unparametrized tests (no bracket) return an empty set the caller
Unparametrized tests (no bracket) return an empty set - the caller
should fall back to "this test involves ALL detected devices" rather
than pretending it touches none.
"""
@@ -329,7 +329,7 @@ def _format_duration(seconds: float) -> str:
# ---------------------------------------------------------------------------
# Textual imports (lazy only when main() runs, so `_parse_events` can be
# Textual imports (lazy - only when main() runs, so `_parse_events` can be
# imported by smoke tests without requiring textual installed in every env)
# ---------------------------------------------------------------------------
@@ -367,7 +367,7 @@ def _import_textual() -> Any:
# ---------------------------------------------------------------------------
# main() the important scaffolding lives here so that when we bail out
# main() - the important scaffolding lives here so that when we bail out
# before entering the Textual event loop (missing terminal, --help, etc.)
# nothing has grabbed the screen yet.
# ---------------------------------------------------------------------------
@@ -414,7 +414,7 @@ def main(argv: list[str] | None = None) -> int:
# workers race pytest file-creation; starting from a known-empty state
# avoids mid-line-decode confusion from the prior run. The fwlog session
# fixture also truncates on its end, and run-tests.sh truncates the
# flashlog triple-truncate is deliberate (whichever side creates the
# flashlog - triple-truncate is deliberate (whichever side creates the
# file first, it starts empty).
for p in (reportlog, fwlog, flashlog):
try:
@@ -434,7 +434,7 @@ def main(argv: list[str] | None = None) -> int:
# as it arrives. The TUI tails that file and routes each line to the
# pytest pane so the operator sees live flash progress during long
# `pio run -t upload` / `esptool erase_flash` operations. run-tests.sh
# also sets this when invoked directly `setdefault` so the wrapper's
# also sets this when invoked directly - `setdefault` so the wrapper's
# value wins when present.
os.environ.setdefault("MESHTASTIC_MCP_FLASH_LOG", str(flashlog))
@@ -442,7 +442,7 @@ def main(argv: list[str] | None = None) -> int:
# env / argv handling without getting into Textual's alternate screen.
if args.no_tui:
cmd = [str(run_tests), *pytest_args]
os.execv(str(run_tests), cmd) # noqa: S606 intentional
os.execv(str(run_tests), cmd) # noqa: S606 - intentional
# Textual UI import is deferred so `--help` and `--no-tui` do not pay
# the ~40 MB startup cost.
@@ -512,7 +512,7 @@ def _build_app(
force Textual's import cost.
"""
# Helper modules lazy-imported here so the top-of-file import cost
# Helper modules - lazy-imported here so the top-of-file import cost
# only kicks in when main() has decided to run the TUI.
from . import _flashlog as _flashlog_mod
from . import _fwlog as _fwlog_mod
@@ -540,7 +540,7 @@ def _build_app(
super().__init__()
class FlashLogLine(tx.Message):
"""Plain-text line from `tests/flash.log` pio / esptool / nrfutil /
"""Plain-text line from `tests/flash.log` - pio / esptool / nrfutil /
picotool output tee'd by `pio._run_capturing`. Routed to the pytest
pane so the operator sees live flash progress during `test_00_bake`
instead of 3 minutes of pytest-captured silence."""
@@ -550,7 +550,7 @@ def _build_app(
super().__init__()
class UiCaptureLine(tx.Message):
"""Live line from the UI-tier camera transcript one per
"""Live line from the UI-tier camera transcript - one per
`frame_capture()` call. Posted only when the camera panel is
enabled via `MESHTASTIC_UI_TUI_CAMERA=1`."""
@@ -640,7 +640,7 @@ def _build_app(
class DevicePollerWorker(threading.Thread):
"""Poll list_devices() + device_info() at startup and after RunFinished.
Deliberately NOT polling during the run `hub_devices` is a
Deliberately NOT polling during the run - `hub_devices` is a
session-scoped fixture holding SerialInterfaces across the whole
session, and device_info() would deadlock on the exclusive port
lock. Header shows "(stale)" during the gap.
@@ -806,7 +806,7 @@ def _build_app(
def on_mount(self) -> None:
log = self.query_one("#coverage-log", tx.RichLog)
if not self._path.is_file():
log.write("(no coverage data tool_coverage.json not written yet)")
log.write("(no coverage data - tool_coverage.json not written yet)")
log.write("")
log.write("Coverage is emitted at pytest_sessionfinish; this")
log.write("file appears after the suite completes.")
@@ -937,7 +937,7 @@ def _build_app(
# Firmware-log port filter: None = all, else exact port match.
self._fwlog_filter: str | None = None
# Ordered set of distinct ports we've seen firmware log lines
# from the `l` key cycles through these.
# from - the `l` key cycles through these.
self._fwlog_ports: list[str] = []
# Cross-run history.
self._history_store = _history_mod.HistoryStore(
@@ -970,7 +970,7 @@ def _build_app(
highlight=False,
markup=False,
# `wrap=True` so long firmware log lines (some
# hit ~200 chars full packet hex dumps plus
# hit ~200 chars - full packet hex dumps plus
# source tags) don't get truncated at the
# right edge. The right pane is ~50% of the
# terminal so even a wide terminal has a
@@ -981,7 +981,7 @@ def _build_app(
)
if self._ui_camera_enabled:
yield tx.Static(
"UI camera latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
"UI camera - latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
id="uicap-header",
)
with tx.Horizontal(id="uicap-pane"):
@@ -1004,11 +1004,11 @@ def _build_app(
def on_mount(self) -> None:
# Tier-counters table. `add_column` (singular) lets us pick
# the key explicitly `add_columns` (plural) in textual 8.x
# the key explicitly - `add_columns` (plural) in textual 8.x
# returns auto-generated keys that are tedious to track
# separately, and update_cell(column_key=<label>) silently
# no-ops because the key is not the label. "Progress" is the
# new v2 column a small [===== ] bar; see `_progress_bar`.
# new v2 column - a small [===== ] bar; see `_progress_bar`.
tier_table = self.query_one("#tier-table", tx.DataTable)
for col in (
"Tier",
@@ -1023,7 +1023,7 @@ def _build_app(
for t in TIERS:
tier_table.add_row(t, "0", "0", "0", "0", "0", "", key=t)
# Device table. "Status" shows which test (if any) is currently
# running on this device derived from the running_nodeid plus
# running on this device - derived from the running_nodeid plus
# role inference from the nodeid's `[...]` parametrization.
dev_table = self.query_one("#device-table", tx.DataTable)
for col in (
@@ -1042,14 +1042,14 @@ def _build_app(
self._device_worker.start()
self._reportlog_worker = ReportlogWorker(self, self._reportlog, self._stop)
self._reportlog_worker.start()
# Firmware log tail worker publishes FirmwareLogLine messages.
# Firmware log tail worker - publishes FirmwareLogLine messages.
self._fwlog_worker = _fwlog_mod.FirmwareLogTailer(
path=self._fwlog,
post=lambda rec: self.post_message(FirmwareLogLine(rec)),
stop=self._stop,
)
self._fwlog_worker.start()
# Flash log tail worker plain-text pio/esptool/nrfutil/picotool
# Flash log tail worker - plain-text pio/esptool/nrfutil/picotool
# output tee'd by `pio._run_capturing`. Routes each line into the
# pytest pane so the operator has live feedback during long flash
# operations (`pio run -t upload` is ~3 min of silence otherwise).
@@ -1059,7 +1059,7 @@ def _build_app(
stop=self._stop,
)
self._flashlog_worker.start()
# UI-capture transcript tailer only runs when the camera panel
# UI-capture transcript tailer - only runs when the camera panel
# is enabled. Watches tests/ui_captures/**/transcript.md for new
# lines as UI tests execute.
if self._ui_camera_enabled:
@@ -1078,7 +1078,7 @@ def _build_app(
# Header tick (seed / runtime / sparkline re-renders at 1 Hz).
# Also refreshes the device-status column so the per-test elapsed
# time climbs live during silent test bodies (flash, long mesh
# timeouts, etc.) cheap: device-table is 1-2 rows.
# timeouts, etc.) - cheap: device-table is 1-2 rows.
self.set_interval(1.0, self._on_tick)
def _header_text(self) -> str:
@@ -1114,8 +1114,8 @@ def _build_app(
The device-status cell embeds the running test's elapsed time
(`RUNNING: test_bake_nrf52 (1:23)`), which needs to re-render
each second during long silent test bodies. Cheap O(devices),
which is 12 rows in practice. Skipped when no test is
each second during long silent test bodies. Cheap - O(devices),
which is 1-2 rows in practice. Skipped when no test is
running so we don't burn cycles when the TUI is idle.
"""
self._refresh_header()
@@ -1134,7 +1134,7 @@ def _build_app(
# --junitxml=tests/junit.xml -v --tb=short
# plus an unconditional `--report-log` append at the end. If we
# pre-append `--report-log` here when `extra_args` is empty, $#
# becomes 1 and the whole defaults block is skipped pytest
# becomes 1 and the whole defaults block is skipped - pytest
# then runs without the `tests/` positional (discovers from the
# mcp-server root and potentially drags in production modules
# named `test_*.py`), without the HTML/junit reports the /test
@@ -1234,11 +1234,11 @@ def _build_app(
# bake doesn't match. Without this
# branch, those tests would never
# register in the tree and the tier
# counters would silently lie e.g.
# counters would silently lie - e.g.
# the telemetry tier showed 0/0/0
# while 4 tests were actually skipped.
# `rerun` (pytest-rerunfailures): rewind to pending.
# Teardown outcomes are intentionally ignored a
# Teardown outcomes are intentionally ignored - a
# teardown failure shouldn't overwrite the call's
# authoritative pass/fail.
if when == "call" and outcome in ("passed", "failed", "skipped"):
@@ -1250,7 +1250,7 @@ def _build_app(
return
if rt == "SessionFinish":
return
# Unknown ignore silently.
# Unknown - ignore silently.
def on_pytest_line(self, message: Any) -> None:
log = self.query_one("#pytest-log", tx.RichLog)
@@ -1271,8 +1271,8 @@ def _build_app(
def on_ui_capture_line(self, message: Any) -> None:
"""Route a UI-capture transcript line into the camera panel.
Each line is already formatted by frame_capture e.g.
`1. **initial** frame 2/8 name=home OCR: ...`. We write
Each line is already formatted by frame_capture - e.g.
`1. **initial** - frame 2/8 name=home - OCR: ...`. We write
the text into the RichLog AND try to render the corresponding
PNG on the left side (requires rich-pixels, Pillow).
"""
@@ -1288,7 +1288,7 @@ def _build_app(
def _render_latest_ui_capture(self, test_id: str, line: str) -> None:
"""Find the PNG that corresponds to `line` and render it on the
left of the uicap pane. Soft-fails if rich-pixels isn't
installed or the PNG isn't found operator still has the text
installed or the PNG isn't found - operator still has the text
transcript on the right.
"""
try:
@@ -1297,7 +1297,7 @@ def _build_app(
except ImportError:
return
# Transcript lines look like `1. **label** ...`. Pull the leading
# Transcript lines look like `1. **label** - ...`. Pull the leading
# integer to locate the capture file.
import re as _re
@@ -1306,7 +1306,7 @@ def _build_app(
return
step = int(m.group(1))
# Captures directory is sibling of tests/ mirror the path the
# Captures directory is sibling of tests/ - mirror the path the
# tailer watches. Search both likely layouts (in-mcp-server vs.
# firmware-root invocation).
candidates = [
@@ -1317,7 +1317,7 @@ def _build_app(
if captures_root is None:
return
# Drill into <session_seed>/<test_id>/ test_id is the
# Drill into <session_seed>/<test_id>/ - test_id is the
# sanitized nodeid the tailer already passed through.
matches = list(captures_root.rglob(f"{test_id}/{step:03d}-*.png"))
if not matches:
@@ -1351,7 +1351,7 @@ def _build_app(
port = rec.get("port")
line = rec.get("line", "")
# Track distinct ports for `l` filter cycling. The ordered-set
# trick list membership is fine here because `_fwlog_ports`
# trick - list membership - is fine here because `_fwlog_ports`
# is tiny (2-3 entries for a typical lab).
if port and port not in self._fwlog_ports:
self._fwlog_ports.append(port)
@@ -1368,7 +1368,7 @@ def _build_app(
log = self.query_one("#fwlog-log", tx.RichLog)
port_tag = ""
if port:
# Show only the last path component `/dev/cu.usbmodem1101`
# Show only the last path component - `/dev/cu.usbmodem1101`
# is long; `usbmodem1101` is enough when the filter is
# "all".
tail = port.rsplit("/", 1)[-1]
@@ -1393,13 +1393,13 @@ def _build_app(
for row in message.rows:
info = row.info or {}
role = row.role or "?"
fw = info.get("firmware_version", "")
hw = info.get("hw_model", "")
region = info.get("region", "")
channel = info.get("primary_channel", "")
fw = info.get("firmware_version", "-")
hw = info.get("hw_model", "-")
region = info.get("region", "-")
channel = info.get("primary_channel", "-")
peers = info.get("num_nodes")
if peers is None:
peers = ""
peers = "-"
else:
peers = str(max(int(peers) - 1, 0)) # exclude self
status = self._status_for_role(role)
@@ -1421,7 +1421,7 @@ def _build_app(
A running test whose nodeid doesn't carry an explicit role
parametrization (no `[...]` bracket) is treated as touching
every device that matches how `test_bidirectional` and the
every device - that matches how `test_bidirectional` and the
pytest_sessionstart-level tests work in practice.
The trailing `(M:SS)` is live-updated by `_on_tick` at 1 Hz
@@ -1436,7 +1436,7 @@ def _build_app(
return "idle"
short = _testname_of_nodeid(nodeid)
# Compute elapsed for the live counter. Budget 8 chars at the
# end of the cell `(12:34)` plus a space. Shorten `short`
# end of the cell - `(12:34)` plus a space. Shorten `short`
# first, then tack on the elapsed suffix.
started = self._state.running_started_at
elapsed_suffix = ""
@@ -1454,7 +1454,7 @@ def _build_app(
"""Update the Status cell for every detected device.
Called whenever `running_nodeid` transitions (setup → call).
Cheap: O(devices) which is 12 rows in practice.
Cheap: O(devices) which is 1-2 rows in practice.
"""
try:
dev_table = self.query_one("#device-table", tx.DataTable)
@@ -1468,7 +1468,7 @@ def _build_app(
)
except Exception:
# Row key might not exist yet if a snapshot hasn't
# populated it harmless; next snapshot will carry
# populated it - harmless; next snapshot will carry
# the fresh status value.
pass
@@ -1481,7 +1481,7 @@ def _build_app(
# Trigger a fresh device poll now that ports are free again.
if self._device_worker is not None:
self._device_worker.trigger()
# Persist a history record one line per run, tailed by the
# Persist a history record - one line per run, tailed by the
# sparkline on every subsequent TUI launch.
duration_s = time.monotonic() - self._start_time
passed = sum(t.passed for t in self._state.tiers.values())
@@ -1526,13 +1526,13 @@ def _build_app(
leaf = self._state.leaves.get(nodeid)
if leaf is None:
# First event for this nodeid is the report itself (no
# collection event seen) register on the fly.
# collection event seen) - register on the fly.
self._register_leaf(nodeid)
leaf = self._state.leaves[nodeid]
prev = leaf.outcome
leaf.outcome = outcome
leaf.duration_s = float(ev.get("duration", 0.0) or 0.0)
# Wall-clock start/stop pytest-reportlog emits these as
# Wall-clock start/stop - pytest-reportlog emits these as
# float seconds (Unix epoch). Used by the reproducer exporter
# to window fwlog.jsonl down to just the failure's context.
start = ev.get("start")
@@ -1661,7 +1661,7 @@ def _build_app(
if getattr(node, "data", None):
target = str(node.data) # leaf: full nodeid
else:
# Internal node derive a pytest arg.
# Internal node - derive a pytest arg.
labels = []
cur: Any = node
while cur is not None and cur.parent is not None:
@@ -1720,7 +1720,7 @@ def _build_app(
self.bell()
return
try:
# macOS + Linux cover falls through silently on failure.
# macOS + Linux cover - falls through silently on failure.
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, str(self._report_html)]) # noqa: S603,S607
except Exception:
@@ -1863,7 +1863,7 @@ def _build_app(
# interrupted (SIGINT during a test body) it may linger.
self._state.running_nodeid = None
self._state.running_started_at = None
# Device status cells need to go back to "idle" otherwise
# Device status cells need to go back to "idle" - otherwise
# the prior run's RUNNING: marker sticks until the next test
# actually starts.
self._refresh_device_status()
@@ -1881,7 +1881,7 @@ def _build_app(
log = self.query_one("#pytest-log", tx.RichLog)
log.write("")
log.write("[tui] --- re-run ---")
# Clear the fwlog pane too it's fresh context for the new run.
# Clear the fwlog pane too - it's fresh context for the new run.
try:
self.query_one("#fwlog-log", tx.RichLog).clear()
except Exception:
+6 -6
View File
@@ -42,7 +42,7 @@ def parse_tcp_port(port: str) -> tuple[str, int]:
"""Parse `tcp://host[:port]` → (host, port). Defaults to 4403.
Validates host shape (non-empty, no path separators) and port range
(1..65535). Raises `ConnectionError` on malformed input never lets
(1..65535). Raises `ConnectionError` on malformed input - never lets
a raw `ValueError` bubble up to a tool surface.
"""
if not port.startswith(TCP_SCHEME):
@@ -65,7 +65,7 @@ def parse_tcp_port(port: str) -> tuple[str, int]:
if any(c in host for c in ("/", "\\")):
raise ConnectionError(
f"Invalid TCP endpoint {port!r}: host {host!r} contains a path "
"separator. TCP hostnames cannot contain '/' or '\\' did you "
"separator. TCP hostnames cannot contain '/' or '\\' - did you "
"pass a serial port path or a Windows drive path by mistake?"
)
if not (1 <= tcp_port <= 65535):
@@ -95,7 +95,7 @@ def normalize_tcp_endpoint(endpoint: str) -> str:
def reject_if_tcp(port: str | None, tool_name: str) -> None:
"""Raise if `port` is a TCP endpoint for tools that need real USB
"""Raise if `port` is a TCP endpoint - for tools that need real USB
hardware (flash, bootloader, vendor escape hatches, serial monitor).
Only checks the explicit arg; auto-selection via env var is the caller's
@@ -143,7 +143,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
For serial: raises `ConnectionError` immediately if another serial
session holds the port (a `pio device monitor` in `serial_sessions/`).
For TCP: no exclusive-access requirement, so the serial-session check
is skipped but the `port_lock` still serializes parallel `connect()`
is skipped - but the `port_lock` still serializes parallel `connect()`
calls to the same daemon endpoint.
`timeout_s` is plumbed through to both `SerialInterface(timeout=...)`
@@ -164,7 +164,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
lock = registry.port_lock(resolved)
if not lock.acquire(blocking=False):
raise ConnectionError(
f"TCP endpoint {resolved} is busy another device operation "
f"TCP endpoint {resolved} is busy - another device operation "
"is in flight. Retry shortly."
)
@@ -204,7 +204,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
lock = registry.port_lock(resolved)
if not lock.acquire(blocking=False):
raise ConnectionError(
f"Port {resolved} is busy another device operation is in flight. "
f"Port {resolved} is busy - another device operation is in flight. "
"Retry shortly."
)
+3 -3
View File
@@ -29,7 +29,7 @@ def _tcp_endpoint_from_env() -> dict[str, Any] | None:
If the env var is malformed (non-integer port, path-like host, etc.),
return an entry with `likely_meshtastic=False` and the parser error in
the description, rather than raising `list_devices` is the diagnostic
the description, rather than raising - `list_devices` is the diagnostic
tool a user reaches for when their env var isn't working, so it must
not crash on misconfiguration.
"""
@@ -48,7 +48,7 @@ def _tcp_endpoint_from_env() -> dict[str, Any] | None:
# user can see exactly what they set and why it was rejected.
# Don't double the scheme if the user already prefixed `tcp://`.
port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}"
description = f"meshtasticd (TCP) invalid MESHTASTIC_MCP_TCP_HOST: {e}"
description = f"meshtasticd (TCP) - invalid MESHTASTIC_MCP_TCP_HOST: {e}"
likely = False
return {
"port": port,
@@ -122,7 +122,7 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
# Stable ordering: likely_meshtastic first; within rank, TCP wins over
# USB (explicit env-var configuration takes precedence over USB
# enumeration); then by port path. A misconfigured TCP entry has
# likely_meshtastic=False and lands among the other ignored entries
# likely_meshtastic=False and lands among the other ignored entries -
# it does NOT pre-empt real USB devices at the top of the list.
results.sort(
key=lambda r: (
+12 -12
View File
@@ -1,4 +1,4 @@
"""Fake NodeDB fixture push Portduino file copy + hardware XModem upload.
"""Fake NodeDB fixture push - Portduino file copy + hardware XModem upload.
The fixture pipeline is two-stage:
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
@@ -78,7 +78,7 @@ def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
# ---------------------------------------------------------------------------
# Compile step shells out to bin/seed-json-to-proto.py so the MCP module
# Compile step - shells out to bin/seed-json-to-proto.py so the MCP module
# doesn't have to duplicate the proto-encoding logic.
# ---------------------------------------------------------------------------
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
@@ -117,7 +117,7 @@ def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
# ---------------------------------------------------------------------------
# Portduino push file copy into ~/.portduino/<config>/prefs/
# Portduino push - file copy into ~/.portduino/<config>/prefs/
# ---------------------------------------------------------------------------
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
home = pathlib.Path.home()
@@ -152,7 +152,7 @@ def _push_portduino(
# ---------------------------------------------------------------------------
# Hardware push XModem over BLE/serial via the meshtastic Python interface.
# Hardware push - XModem over BLE/serial via the meshtastic Python interface.
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class _AckEvent:
@@ -165,7 +165,7 @@ def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEve
return q.get(timeout=timeout_s)
except queue.Empty as exc:
raise FixtureError(
f"XModem response timeout after {timeout_s:.1f}s device not responding"
f"XModem response timeout after {timeout_s:.1f}s - device not responding"
) from exc
@@ -180,14 +180,14 @@ def _push_hardware(
try:
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
from pubsub import pub
except ImportError as exc: # pragma: no cover dep missing
except ImportError as exc: # pragma: no cover - dep missing
raise FixtureError(
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
) from exc
if is_tcp_port(port):
raise FixtureError(
"hardware push over TCP/portduino is not supported use "
"hardware push over TCP/portduino is not supported - use "
"target='portduino' to drop the fixture directly into the prefs dir."
)
@@ -322,7 +322,7 @@ def _push_hardware(
# ---------------------------------------------------------------------------
# Public entry point registered as an MCP tool in server.py.
# Public entry point - registered as an MCP tool in server.py.
# ---------------------------------------------------------------------------
def push_fake_nodedb(
size: int,
@@ -338,11 +338,11 @@ def push_fake_nodedb(
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
Args:
size: 250, 500, 1000, or 2000 selects which committed seed JSONL to use.
size: 250, 500, 1000, or 2000 - selects which committed seed JSONL to use.
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
or BLE identifier. TCP endpoints are rejected use target="portduino"
or BLE identifier. TCP endpoints are rejected - use target="portduino"
instead.
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
@@ -354,7 +354,7 @@ def push_fake_nodedb(
test scenario.
Returns:
dict with transport, bytes, sha256, etc. depends on target.
dict with transport, bytes, sha256, etc. - depends on target.
"""
if size not in _VALID_SIZES:
@@ -371,7 +371,7 @@ def push_fake_nodedb(
if target == "hardware":
if not confirm:
raise FixtureError(
"hardware push writes flash and triggers a reboot pass confirm=True."
"hardware push writes flash and triggers a reboot - pass confirm=True."
)
if not port:
raise FixtureError(
+7 -7
View File
@@ -47,7 +47,7 @@ def _require_confirm(confirm: bool, operation: str) -> None:
def _reject_native_env(env: str, operation: str) -> None:
"""`native*` envs build a host executable, not firmware there's no
"""`native*` envs build a host executable, not firmware - there's no
upload step. The user wants `build` (or just runs the binary directly).
"""
if env.startswith("native"):
@@ -121,7 +121,7 @@ def build(
`{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]`
prefix on every log line. Combines with the recorder so heap shows
up at log cadence (much higher resolution than the ~60 s LocalStats
packet) see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
packet) - see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
expand to bare `-D<NAME>` (presence-only flags).
"""
args = ["run", "-e", env]
@@ -183,10 +183,10 @@ def flash(
) -> dict[str, Any]:
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
`userprefs_overrides` (optional): see `build()` the rebuild-before-upload
`userprefs_overrides` (optional): see `build()` - the rebuild-before-upload
that pio performs will pick up the injected values.
`build_flags` (optional): same shape as `build()` `PLATFORMIO_BUILD_FLAGS`
`build_flags` (optional): same shape as `build()` - `PLATFORMIO_BUILD_FLAGS`
is exported for the rebuild-before-upload, so the uploaded firmware
actually carries the flags. Without this propagation, `pio run -t upload`
would relink without the env var and silently drop them. Common use:
@@ -361,7 +361,7 @@ def update_flash(
def _do_1200bps_touch(port: str, settle_ms: int, touch_timeout_s: float = 3.0) -> None:
"""Open port at 1200 baud and close, bounded by a worker thread.
Both the open and the close can block on a busy CDC device we wrap the
Both the open and the close can block on a busy CDC device - we wrap the
whole thing in a worker so the caller returns in at most `touch_timeout_s`
regardless. The touch is signal-only: the USB configuration change to
1200 baud alone is enough to trip the Adafruit bootloader's reset, so a
@@ -433,7 +433,7 @@ def touch_1200bps(
poll_timeout_s: float = 8.0,
retries: int = 2,
) -> dict[str, Any]:
"""Open port at 1200 baud, close immediately triggers USB CDC bootloader.
"""Open port at 1200 baud, close immediately - triggers USB CDC bootloader.
Works for: nRF52840 (Adafruit bootloader), ESP32-S3 (native USB download
mode), RP2040 (when built with 1200bps-reset stdio), Arduino Leonardo/Micro.
@@ -442,7 +442,7 @@ def touch_1200bps(
VID/PID (0x239A / 0x0029) for up to `poll_timeout_s` seconds. Adafruit's
bootloader docs note a touch sometimes needs to be repeated, so this
retries up to `retries` times. The returned `new_port` is the bootloader
port (distinct from the app port) exactly what's needed for `pio run
port (distinct from the app port) - exactly what's needed for `pio run
-t upload` to drive nrfutil.
For non-nRF52 devices (ESP32-S3, RP2040, Arduino), falls back to
+2 -2
View File
@@ -1,7 +1,7 @@
"""Direct wrappers around vendor flashing tools: esptool, nrfutil, picotool.
These are escape hatches. Prefer the pio-based tools in flash.py when they
cover the operation pio knows the correct offsets, protocols, and filters
cover the operation - pio knows the correct offsets, protocols, and filters
for every supported board. Use these when pio doesn't: to erase a bricked
ESP32, DFU-flash an nRF52 zip package, or inspect an RP2040's bootloader.
@@ -216,7 +216,7 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]:
def picotool_info(port: str | None = None) -> dict[str, Any]:
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
only picotool auto-detects."""
only - picotool auto-detects."""
connection.reject_if_tcp(port, "picotool_info")
binary = config.picotool_bin()
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
@@ -2,7 +2,7 @@
Used by `admin.send_input_event` + `tests/ui/` so callers can say
`InputEventCode.RIGHT` instead of hard-coding 20. Values MUST stay in sync
with the firmware enum unit test `tests/unit/test_input_event_codes.py`
with the firmware enum - unit test `tests/unit/test_input_event_codes.py`
pins the mapping.
"""
+1 -1
View File
@@ -181,7 +181,7 @@ def telemetry_timeline(
"""
end = time.time()
if isinstance(window, (int, float)):
# Numeric `window` is a duration in seconds "last N seconds".
# Numeric `window` is a duration in seconds - "last N seconds".
# Without this branch, `_parse_time(-N)` would treat -N as an
# absolute epoch timestamp (i.e., Jan 1 1970 minus N seconds),
# producing a wildly negative `start` and matching nothing.
+3 -3
View File
@@ -1,10 +1,10 @@
"""OCR wrapper for UI tests + the `capture_screen` tool.
Auto-selects a reader in priority order:
1. `easyocr` (deep-learning, high quality on OLED screens but ~100 MB
1. `easyocr` (deep-learning, high quality on OLED screens - but ~100 MB
model download on first use).
2. `pytesseract` (requires system `tesseract` binary on PATH).
3. `null` returns `""` with a warning. Tests fall back to log + image
3. `null` - returns `""` with a warning. Tests fall back to log + image
evidence when OCR is unavailable.
Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto`
@@ -132,7 +132,7 @@ def warm() -> None:
Pytest session fixture calls this once so the first real capture doesn't
eat the model-load latency.
"""
# A 64×32 white PNG decodes clean, no text to extract.
# A 64×32 white PNG - decodes clean, no text to extract.
white_png = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e"
"0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080"
+6 -6
View File
@@ -6,12 +6,12 @@ have a single place that owns timeouts, buffer sizes, JSON parsing, and the
`run()` has two execution paths:
* Fast path (default): `subprocess.run(capture_output=True)` buffered, one
* Fast path (default): `subprocess.run(capture_output=True)` - buffered, one
return; fine for sub-second pio calls like `pio --version` or
`pio project config --json-output`.
* Streaming path: when the `MESHTASTIC_MCP_FLASH_LOG` env var is set, each
output line is tee'd to that file as it arrives via a threaded reader.
The TUI tails the file to give live flash progress otherwise a 3-minute
The TUI tails the file to give live flash progress - otherwise a 3-minute
`pio run -t upload` is completely silent to the operator.
`hw_tools.py` shares the streaming helper via `pio._run_capturing()` so
@@ -110,7 +110,7 @@ def _run_capturing(
reader threads accumulate into result strings AND append each line to
the flash log file. Stdout and stderr stay separate in the return value
(so `stderr_tail` still means stderr), but are interleaved in the log
file in the order they arrived that's what a human wants to read.
file in the order they arrived - that's what a human wants to read.
"""
log_path = _flash_log_path()
t0 = time.monotonic()
@@ -119,7 +119,7 @@ def _run_capturing(
env = {**os.environ, **extra_env}
if log_path is None:
# Fast path unchanged.
# Fast path - unchanged.
proc = subprocess.run(
list(argv),
cwd=str(cwd) if cwd else None,
@@ -172,7 +172,7 @@ def _run_capturing(
log_fh.flush()
except OSError:
# Log file disappeared (umount, operator deleted the dir).
# Don't let that bubble up the subprocess output is still
# Don't let that bubble up - the subprocess output is still
# collected in-memory for the return value.
try:
log_fh.close()
@@ -248,7 +248,7 @@ def run(
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
non-zero exit; set `check=False` to inspect `returncode` manually.
`extra_env` merges into the subprocess environment used for
`extra_env` merges into the subprocess environment - used for
`PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time
toggles that can't be expressed as command-line args.
@@ -5,7 +5,7 @@ Two flavors of log line cross our pubsub subscription:
accumulates bytes between protobuf frames and emits the full
firmware-formatted line, e.g.
"INFO | 12:34:56 12345 [Main] Booting"
level, HH:MM:SS, uptime seconds, thread bracket, then message.
- level, HH:MM:SS, uptime seconds, thread bracket, then message.
2. LogRecord protobuf path (debug_log_api enabled): the lib calls
`_handleLogLine(record.message)` with ONLY the message body. The
level/source/time fields on the LogRecord are dropped before
@@ -29,7 +29,7 @@ from typing import Any
# Match: LEVEL | HH:MM:SS UPTIME [Thread] message
# HH:MM:SS may be ??:??:?? when RTC isn't valid. The level alternation
# below is the canonical list DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
# below is the canonical list - DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
# macros must stay in sync with these strings.
_LINE_RE = re.compile(
r"""
@@ -84,7 +84,7 @@ _HEAP_BRACKET_RE = re.compile(r"^heap\s+(?P<heap>\d+)$")
def parse_log_line(line: str) -> dict[str, Any]:
"""Best-effort decompose a raw firmware log line.
Returns a dict with at least `line` (the original, unmodified ANSI
Returns a dict with at least `line` (the original, unmodified - ANSI
codes preserved for fidelity). Adds `level`, `tag`, `clock`,
`uptime_s`, and `msg` when the full prefix is present.
@@ -93,7 +93,7 @@ def parse_log_line(line: str) -> dict[str, Any]:
(the BLE/StreamAPI path inherited the colored body in some builds).
We strip ANSI before regex matching so the prefix survives.
- DEBUG_HEAP injects `[heap N]` after the thread bracket. When NO
thread name is set, the heap takes the thread bracket position
thread name is set, the heap takes the thread bracket position -
looks like `[heap 12345] msg`. We detect that shape and move it
out of `tag` and into `heap_free`.
@@ -137,7 +137,7 @@ def parse_log_line(line: str) -> dict[str, Any]:
msg = m.group("msg")
out["msg"] = msg
else:
# No prefix bare LogRecord.message body. Inspect the whole
# No prefix - bare LogRecord.message body. Inspect the whole
# line for DEBUG_HEAP-style content; the heap-prefix and
# thread-leak patterns can survive on either path.
msg = clean
@@ -204,7 +204,7 @@ _TELEMETRY_VARIANTS = (
def extract_telemetry(packet: dict[str, Any]) -> dict[str, Any] | None:
"""Pull the telemetry variant + flat fields out of a `meshtastic.receive.telemetry`
packet. Returns None when the shape isn't what we expect so the
packet. Returns None when the shape isn't what we expect - so the
caller can fall back to a generic packets.jsonl row.
"""
if not isinstance(packet, dict):
@@ -251,7 +251,7 @@ def summarize_packet(
packet: dict[str, Any], *, payload_hex_len: int = 64
) -> dict[str, Any]:
"""Reduce a packet dict to a stable, queryable summary. Drops the
full payload bytes the recorder records summaries, not pcaps.
full payload bytes - the recorder records summaries, not pcaps.
"""
if not isinstance(packet, dict):
return {"raw_type": type(packet).__name__}
@@ -2,17 +2,17 @@
Subscribes once to the meshtastic pubsub fan-out and writes four append-only
JSONL streams under `mcp-server/.mtlog/`. The pubsub fan-out is
process-global a single subscription captures every active interface
process-global - a single subscription captures every active interface
without per-connection bookkeeping.
Files:
logs.jsonl every `meshtastic.log.line` event (best-effort prefix
logs.jsonl - every `meshtastic.log.line` event (best-effort prefix
parsed for level/tag/uptime; raw `line` always preserved)
telemetry.jsonl `meshtastic.receive.telemetry` packets, flattened by
telemetry.jsonl - `meshtastic.receive.telemetry` packets, flattened by
variant (device / local / environment / power / etc.)
packets.jsonl every other `meshtastic.receive.*` packet, summarized
packets.jsonl - every other `meshtastic.receive.*` packet, summarized
(portnum, hops, RSSI/SNR, payload size + 64-byte hex)
events.jsonl connection lifecycle, node-DB updates, and manual
events.jsonl - connection lifecycle, node-DB updates, and manual
`mark_event` rows. Lower volume; useful for aligning
timelines.
@@ -83,7 +83,7 @@ class Recorder:
self._started = False
def pause(self, reason: str | None = None) -> None:
# Write the pause marker BEFORE flipping the flag `_write_event`
# Write the pause marker BEFORE flipping the flag - `_write_event`
# short-circuits when paused, so the order matters for this event
# to actually land in events.jsonl.
self._write_event(
@@ -108,7 +108,7 @@ class Recorder:
def _wire_pubsub(self) -> None:
from pubsub import pub # type: ignore[import-untyped]
# Subscribers one per topic. Each pubsub publisher sends
# Subscribers - one per topic. Each pubsub publisher sends
# keyword args matching its handler's signature; pubsub
# introspects the function signature to route args.
bindings = [
@@ -225,7 +225,7 @@ class Recorder:
Same parse + heap-synthesis path as `_on_log_line`, but receives
the raw text-formatted line (full level/clock/uptime/thread/`[heap N]`/
body). On DEBUG_HEAP builds in text mode this gives us per-log-line
heap data far higher cadence than LocalStats, and works without
heap data - far higher cadence than LocalStats, and works without
protobuf API mode (no SerialInterface required).
"""
files = self._files_snapshot()
@@ -255,7 +255,7 @@ class Recorder:
files["logs"].write(record)
# Synthesize a heap_free telemetry sample whenever the line
# carries one same logic as _on_log_line, tagged source so
# carries one - same logic as _on_log_line, tagged source so
# consumers can distinguish text-mode tap from protobuf path.
heap_free = parsed.get("heap_free")
if isinstance(heap_free, int):
@@ -285,7 +285,7 @@ class Recorder:
tags = parsers.interface_label(interface)
extracted = parsers.extract_telemetry(packet)
if extracted is None:
# Couldn't extract a known variant fall through to the
# Couldn't extract a known variant - fall through to the
# generic `_on_receive` path, which will still fire for
# this packet via the parent topic.
return
@@ -304,7 +304,7 @@ class Recorder:
def _on_receive(self, packet: dict[str, Any], interface: Any = None) -> None:
# Generic-receive fires for EVERY packet. Telemetry packets get
# recorded twice (here and in _on_telemetry) that's intentional:
# recorded twice (here and in _on_telemetry) - that's intentional:
# packets.jsonl is the universal record, telemetry.jsonl is the
# structured timeseries view.
files = self._files_snapshot()
@@ -338,7 +338,7 @@ class Recorder:
def _on_node_updated(
self, node: dict[str, Any] | None = None, interface: Any = None
) -> None:
# Lower-volume than packets but informative node ID, hops away,
# Lower-volume than packets but informative - node ID, hops away,
# last heard. Skip the user dict if absent.
try:
user = (node or {}).get("user") if isinstance(node, dict) else None
@@ -381,7 +381,7 @@ class Recorder:
"role": "marker",
"level": "MARK",
"tag": "mark_event",
"line": f"[mark] {label}" + (f" {note}" if note else ""),
"line": f"[mark] {label}" + (f" - {note}" if note else ""),
}
)
except Exception:
@@ -399,7 +399,7 @@ class Recorder:
) -> float:
ts = time.time()
# Lifecycle markers (recorder_start, recorder_pause, recorder_resume)
# arrive at choreographed moments `pause()` writes BEFORE flipping
# arrive at choreographed moments - `pause()` writes BEFORE flipping
# the flag and `resume()` writes AFTER clearing it, so those calls
# see _paused=False here. Other event kinds short-circuit when
# paused via the snapshot guard below.
@@ -6,7 +6,7 @@ it is closed, gzipped to `<name>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`,
and the live file resets to empty. Old archives past `keep_archives` are
unlinked oldest-first.
Size check is amortized `os.fstat` runs every `check_every` writes,
Size check is amortized - `os.fstat` runs every `check_every` writes,
not per-write, so the hot path stays at one `fh.write` + one `fh.flush`.
Threading: every public method acquires `self._lock`. The recorder runs
@@ -1,6 +1,6 @@
"""Long-running serial monitor sessions via `pio device monitor`.
Why pio instead of raw pyserial: pio applies the board's monitor_filters
Why pio instead of raw pyserial: pio applies the board's monitor_filters -
`esp32_exception_decoder` symbolicates crash stacks, `time` adds timestamps,
etc. Raw pyserial would give us bytes; pio gives us developer-grade logs.
@@ -53,7 +53,7 @@ def _drain(session: SerialSession) -> None:
own port. This is the text-mode tap path: when no SerialInterface is
open, the firmware emits full formatted lines (level + clock + uptime
+ thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we
fan them out to whoever is listening. Pubsub is best-effort
fan them out to whoever is listening. Pubsub is best-effort -
publish failures must never block the reader.
"""
# Lazy import: pubsub isn't required just to import this module
+28 -28
View File
@@ -1,4 +1,4 @@
"""FastMCP server wiring 43 tools across 9 categories (adds uhubctl power control).
"""FastMCP server wiring - 43 tools across 9 categories (adds uhubctl power control).
Each tool handler is a thin delegation to a named module (pio.py, admin.py,
etc.). Business logic does not live here.
@@ -32,7 +32,7 @@ app = FastMCP("meshtastic-mcp")
def _start_recorder() -> None:
# Persistent device-log capture. Starts on first import pubsub fan-out
# Persistent device-log capture. Starts on first import - pubsub fan-out
# is process-global, so subscribing here captures every active interface
# (whether opened by an MCP tool, a pytest fixture, or a serial_session).
# Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py
@@ -154,7 +154,7 @@ def pio_flash(
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for the
rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags
to actually land in the uploaded firmware without it, the implicit
to actually land in the uploaded firmware - without it, the implicit
rebuild relinks without the env var and silently drops them.
"""
return flash.flash(
@@ -219,7 +219,7 @@ def userprefs_manifest() -> dict[str, Any]:
"""Full manifest of USERPREFS_* keys the firmware knows about.
Combines `userPrefs.jsonc` (active + commented examples) with a scan of
`src/**` for `USERPREFS_<KEY>` references so every key the firmware
`src/**` for `USERPREFS_<KEY>` references - so every key the firmware
actually consumes shows up, even if undocumented in the jsonc.
Each entry has: key, active (is it uncommented), value (current), example
@@ -268,7 +268,7 @@ def userprefs_reset() -> dict[str, Any]:
"""Restore userPrefs.jsonc from the most recent MCP backup (if any).
The backup is only created by the legacy `userprefs_set` workflow (not
currently written automatically). Returns `{restored: bool, ...}` false
currently written automatically). Returns `{restored: bool, ...}` - false
when no backup is present, in which case the caller should edit the
jsonc directly.
"""
@@ -293,7 +293,7 @@ def userprefs_testing_profile(
- Run on a deterministic non-default LoRa slot (default 88 on US LONG_FAST,
well off the `hash("LongFast")` slot a stock production device uses)
- Join a private channel with a name and PSK that differ from public
defaults so no accidental mesh-with-production-devices
defaults - so no accidental mesh-with-production-devices
- Have MQTT disabled (no uplink/downlink bridge), so test traffic never
leaks to a public broker
- Optionally disable GPS for bench-test conditions
@@ -314,8 +314,8 @@ def userprefs_testing_profile(
(fine one-off, useless for multi-device clusters).
channel_name: primary channel name (≤11 chars). Default "McpTest".
channel_num: 1-indexed LoRa slot (0 = fall back to name-hash). Default
88 mid-upper US band, unlikely to collide with production slots.
region: short code one of US, EU_433, EU_868, CN, JP, ANZ, KR, TW,
88 - mid-upper US band, unlikely to collide with production slots.
region: short code - one of US, EU_433, EU_868, CN, JP, ANZ, KR, TW,
RU, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, LORA_24.
modem_preset: one of LONG_FAST, LONG_SLOW, LONG_MODERATE, VERY_LONG_SLOW,
MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, SHORT_TURBO.
@@ -345,7 +345,7 @@ def touch_1200bps(port: str, settle_ms: int = 250) -> dict[str, Any]:
After the touch, polls serial devices for up to 3 seconds and reports any
new port that appeared (the bootloader often enumerates as a different
device). Not destructive this is just a reset signal.
device). Not destructive - this is just a reset signal.
"""
return flash.touch_1200bps(port, settle_ms=settle_ms)
@@ -363,7 +363,7 @@ def serial_open(
"""Open a `pio device monitor` session reading from `port`.
If `env` is set, pio picks up monitor_speed and monitor_filters from
platformio.ini recommended for firmware debugging since it enables
platformio.ini - recommended for firmware debugging since it enables
esp32_exception_decoder / esp32_c3_exception_decoder for ESP32 envs.
Without `env`, uses the supplied baud and filters (default ["direct"]).
@@ -398,7 +398,7 @@ def serial_read(
or `since_cursor=0` to read from the start of the in-memory buffer.
Returns `dropped` = count of lines that aged out of the 10k-line ring
buffer between reads so a value > 0 means you missed data.
buffer between reads - so a value > 0 means you missed data.
"""
session = registry.get_session(session_id)
return serial_session.read_session(
@@ -502,13 +502,13 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
When true, firmware streams log lines as protobuf `LogRecord` messages
over the StreamAPI (topic `meshtastic.log.line` in meshtastic-python)
instead of raw text. Lets diagnostic clients capture firmware-side logs
through the SAME SerialInterface used for admin/info calls no
through the SAME SerialInterface used for admin/info calls - no
separate `pio device monitor` session needed, no exclusive-port-lock
conflict. Persists across reboot via NVS; wiped by factory_reset
unless re-applied.
The earlier emitLogRecord race (shared tx buffer) is fixed at the
firmware level the log path has a dedicated scratch + txBuf and
firmware level - the log path has a dedicated scratch + txBuf and
both emission paths serialize via a mutex. Safe to leave on under
traffic.
"""
@@ -625,7 +625,7 @@ def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]:
def uhubctl_list() -> list[dict[str, Any]]:
"""List every USB hub + per-port device attachment as seen by `uhubctl`.
Read-only no confirm required. Each hub entry includes its location
Read-only - no confirm required. Each hub entry includes its location
(`1-1.3`), descriptor, whether it supports Per-Port Power Switching,
and a list of populated ports with VID:PID of attached devices.
Useful for pre-flight checks before a destructive power-cycle call.
@@ -645,12 +645,12 @@ def uhubctl_power(
) -> dict[str, Any]:
"""Power a USB hub port on or off via `uhubctl -a on|off`.
Target the port by either (`location`, `port`) raw uhubctl syntax,
e.g. `location="1-1.3", port=2` OR by `role` ("nrf52", "esp32s3").
Target the port by either (`location`, `port`) - raw uhubctl syntax,
e.g. `location="1-1.3", port=2` - OR by `role` ("nrf52", "esp32s3").
Role lookup honors `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
`_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
`action="off"` requires `confirm=True` (destructive the attached
`action="off"` requires `confirm=True` (destructive - the attached
device will immediately disappear from the OS).
"""
from . import uhubctl as uhubctl_mod
@@ -678,7 +678,7 @@ def uhubctl_cycle(
) -> dict[str, Any]:
"""Power a USB hub port off, wait `delay_s` seconds, then on.
The typical hard-reset sequence shorter than off+on as two RPCs
The typical hard-reset sequence - shorter than off+on as two RPCs
because uhubctl handles the timing in-process. Target by (location,
port) or by role (see `uhubctl_power`). Requires `confirm=True`.
"""
@@ -714,7 +714,7 @@ def _resolve_uhubctl_target(
def esptool_chip_info(port: str) -> dict[str, Any]:
"""Run `esptool flash_id` and return chip, MAC, crystal, and flash size.
Read-only no confirm required. Prefer this over parsing pio upload logs
Read-only - no confirm required. Prefer this over parsing pio upload logs
when you just want to identify the chip.
"""
return hw_tools.esptool_chip_info(port)
@@ -738,7 +738,7 @@ def esptool_raw(
erase_flash, erase_region, merge_bin) require confirm=True.
Prefer the high-level `pio_flash` / `erase_and_flash` / `update_flash`
tools where possible they know board-specific offsets and protocols.
tools where possible - they know board-specific offsets and protocols.
"""
return hw_tools.esptool_raw(args, port=port, confirm=confirm)
@@ -747,7 +747,7 @@ def esptool_raw(
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
"""DFU-flash a .zip package to an nRF52840 via `nrfutil dfu serial`.
Prefer `pio_flash` for flashing firmware built from this repo pio handles
Prefer `pio_flash` for flashing firmware built from this repo - pio handles
the DFU invocation automatically. Use this tool when flashing a pre-built
release zip or a custom bootloader. Requires confirm=True.
"""
@@ -786,7 +786,7 @@ def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
# ---------- Persistent device-log capture (recorder) ----------------------
#
# The recorder is autouse it starts at server import and continuously
# The recorder is autouse - it starts at server import and continuously
# writes every meshtastic pubsub event to JSONL files under .mtlog/. These
# tools are query-only over those files, plus a few lifecycle controls.
@@ -810,7 +810,7 @@ def logs_window(
Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601.
Note: lines arriving via the LogRecord protobuf path (when
set_debug_log_api(True) is on) come without level prefix the
set_debug_log_api(True) is on) come without level prefix - the
meshtastic Python lib drops record.level before fan-out. For those,
`level` filter won't match; use `grep` instead.
"""
@@ -840,7 +840,7 @@ def telemetry_timeline(
heap_free_bytes) are normalized.
Returns slope_per_min (linear-regression slope, units/minute) so a
leak detector can read one number negative slope on free_heap over
leak detector can read one number - negative slope on free_heap over
a long window indicates a real leak.
LocalStats variant ("local") cadence is ~60 s (whatever the device's
@@ -868,7 +868,7 @@ def packets_window(
"""Recent mesh packets recorded by the recorder.
Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload
size + first 64 bytes hex) full payload bytes are not stored.
size + first 64 bytes hex) - full payload bytes are not stored.
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
"""
return log_query.packets_window(
@@ -927,7 +927,7 @@ def recorder_status() -> dict[str, Any]:
@app.tool()
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
"""Pause writes to all four streams. Pubsub subscriptions stay active
"""Pause writes to all four streams. Pubsub subscriptions stay active -
we just drop events on the floor while paused. Resume with `recorder_resume`.
Use when capturing a known-good baseline that you don't want to
@@ -983,9 +983,9 @@ def push_fake_nodedb(
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
Two transports:
target="portduino" file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
target="portduino" - file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
Fast, no device connection needed.
target="hardware" XModem upload over serial/BLE to /prefs/nodes.proto.
target="hardware" - XModem upload over serial/BLE to /prefs/nodes.proto.
Requires `port` + `confirm=True`. Triggers a reboot
so loadFromDisk picks up the new file at next boot.
+9 -9
View File
@@ -1,11 +1,11 @@
"""USB hub power control via `uhubctl` hard-recovery for wedged devices +
"""USB hub power control via `uhubctl` - hard-recovery for wedged devices +
deliberate offline-peer simulation for mesh tests.
Why: when a Meshtastic device's serial port wedges (stuck in a boot loop,
frozen USB CDC, crashed firmware that didn't reboot), the only recovery is
a physical unplug. uhubctl toggles VBUS per-port on any hub with Per-Port
Power Switching (PPPS) support which is most externally-powered hubs
from the last ~5 years so the harness can power-cycle a device
Power Switching (PPPS) support - which is most externally-powered hubs
from the last ~5 years - so the harness can power-cycle a device
programmatically.
Architecture:
@@ -24,7 +24,7 @@ without root, but Linux without udev rules (or old macOS with specific
driver quirks) still needs it. We run uhubctl non-root; if stderr
matches the classic permission pattern we raise `UhubctlError` with an
install hint pointing at the uhubctl docs. Auto-wrapping with `sudo`
would prompt in the middle of test runs bad for CI.
would prompt in the middle of test runs - bad for CI.
"""
from __future__ import annotations
@@ -60,7 +60,7 @@ class UhubctlError(RuntimeError):
# ---------- Role → VID map -------------------------------------------------
# Mirrors the default hub_profile in `mcp-server/tests/conftest.py:335`.
# Note: esp32s3 and esp32s3_alt share a logical role we search both.
# Note: esp32s3 and esp32s3_alt share a logical role - we search both.
ROLE_VIDS: dict[str, tuple[int, ...]] = {
"nrf52": (0x239A,),
"esp32s3": (0x303A, 0x10C4),
@@ -75,8 +75,8 @@ def _normalize_role(role: str) -> str:
# ---------- Core subprocess runner -----------------------------------------
# If uhubctl hits a permission problem most commonly Linux without the
# udev rules, or a macOS variant where the kernel holds the hub driver
# If uhubctl hits a permission problem - most commonly Linux without the
# udev rules, or a macOS variant where the kernel holds the hub driver -
# it prints something like "Permission denied. Try running as root".
# Linux error text varies; we match a broad substring rather than exact.
_PERM_ERROR_PATTERNS = (
@@ -170,7 +170,7 @@ def parse_list_output(output: str) -> list[dict[str, Any]]:
def list_hubs() -> list[dict[str, Any]]:
"""Enumerate every hub uhubctl can see, with per-port device attachments.
Pure read no power state changes. Useful as a pre-flight check before
Pure read - no power state changes. Useful as a pre-flight check before
a destructive `power_off` call.
"""
result = _run_uhubctl([], timeout=15.0)
@@ -189,7 +189,7 @@ def find_port_for_vid(
) -> list[tuple[str, int]]:
"""Return ALL (location, port) matches for a device VID (optionally +PID).
`only_ppps=True` filters out hubs that don't advertise PPPS we can't
`only_ppps=True` filters out hubs that don't advertise PPPS - we can't
control them anyway. Callers that want to diagnose a missing device can
pass `only_ppps=False` to see if the device is on a non-controllable
hub (and raise a clearer error).
+8 -8
View File
@@ -1,6 +1,6 @@
"""USERPREFS: build-time constants baked into the firmware binary.
The firmware repo has `userPrefs.jsonc` at its root a JSONC file with every
The firmware repo has `userPrefs.jsonc` at its root - a JSONC file with every
available USERPREFS_* key listed, most commented out. At build time,
`bin/platformio-custom.py` reads it, strips comments, and emits
`-DUSERPREFS_<KEY>=<value>` build flags into the compile step. Firmware code
@@ -10,7 +10,7 @@ owner name, LoRa region, OEM branding, MQTT credentials, etc.
This module:
1. Parses `userPrefs.jsonc` (preserving which keys are active vs commented)
2. Greps `src/` for the set of keys the firmware actually consumes (the
real discovery manifest anything here that isn't in the jsonc is still
real discovery manifest - anything here that isn't in the jsonc is still
a valid override)
3. Provides a context manager for temporarily swapping in overrides during
a build/flash, then restoring the original file
@@ -111,7 +111,7 @@ def read_state() -> dict[str, Any]:
def _scan_consumed_keys() -> dict[str, list[str]]:
"""Grep firmware src/ for USERPREFS_* references.
Returns {key: [relative_file_paths]} only includes files under `src/`.
Returns {key: [relative_file_paths]} - only includes files under `src/`.
"""
src_dir = config.firmware_root() / "src"
if not src_dir.is_dir():
@@ -153,7 +153,7 @@ def build_manifest() -> dict[str, Any]:
- `declared_in_jsonc` bool (key appears anywhere in userPrefs.jsonc)
- `consumed_by` list of source files that reference it
- `inferred_type`: one of "brace", "number", "bool", "enum", "string"
matches platformio-custom.py's value-wrapping switch
- matches platformio-custom.py's value-wrapping switch
"""
state = read_state()
consumed = _scan_consumed_keys()
@@ -210,7 +210,7 @@ def infer_type(value: str | None) -> str:
def _format_jsonc_line(key: str, value: str, commented: bool) -> str:
prefix = " // " if commented else " "
# Escape backslashes and quotes inside value the way platformio-custom.py
# expects the original jsonc uses raw strings for most content. Keep it
# expects - the original jsonc uses raw strings for most content. Keep it
# literal; callers are responsible for correct escaping if they pass
# dict/enum-init values that contain quotes.
return f'{prefix}"{key}": "{value}",'
@@ -289,7 +289,7 @@ def _stringify(value: Any) -> str:
bool → "true" / "false"; int/float → str(); anything else → str(value).
Callers passing brace-init strings (`"{ 0x01, 0x02, ... }"`) must format
them themselves this function doesn't try to synthesize them.
them themselves - this function doesn't try to synthesize them.
"""
if isinstance(value, bool):
return "true" if value else "false"
@@ -420,9 +420,9 @@ def build_testing_profile(
short_name: optional owner short-name stamp (≤4 chars). None = unset.
long_name: optional owner long-name stamp. None = unset.
disable_mqtt: if True (default), disables the MQTT module and the
uplink/downlink bridge on the primary channel so private test
uplink/downlink bridge on the primary channel - so private test
traffic never leaks to a public broker.
disable_position: if True, disables GPS + position broadcasts useful
disable_position: if True, disables GPS + position broadcasts - useful
when test devices sit on a bench without antennas.
enable_ui_log: if True, stamps `USERPREFS_UI_TEST_LOG=true` so the
firmware emits one `Screen: frame N/M name=... reason=...` log