emdashes begone (#10847)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–2s pio startup cost.
|
||||
changes, so subsequent calls don't pay the 1-2s pio startup cost.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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 [start−5s, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–2 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 1–2 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:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user