Compare commits

...
Author SHA1 Message Date
Ben Meadors 2733af43f1 Merge branch 'master' into power-chatty 2026-05-11 12:06:42 -05:00
64fd61706d ThinkNode M7 (#8077)
* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* rename variant and add guard macros

* older G3 operational. M7 next.

* Split out G3 and M7 to different variants. Completely new PCB design. The G3 stays on 'PRIVATE_HW'

* Define button behaviour and use all of the device flash

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-05-11 11:46:13 -05:00
Ben Meadors 885bffa4e8 Merge branch 'master' into power-chatty 2026-05-11 10:05:48 -05:00
Ben Meadors 8877608858 Protos 2026-05-11 09:32:55 -05:00
Ben Meadors bde6a166d0 Merge branch 'master' into power-chatty 2026-05-11 08:11:20 -05:00
Ben Meadors dfcb685963 Update protos 2026-05-11 08:08:15 -05:00
Ben Meadors 9bc25b34fd Add guidance to use Throttle for time-based rate limiting in agent instructions 2026-05-11 07:42:04 -05:00
Ben Meadors 80bf8940a1 Make power status logging less chatty and track battery presence transitions 2026-05-11 07:39:07 -05:00
github-actions[bot]andvidplace7 33319aa4e2 Upgrade trunk (#10451)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-05-11 07:30:03 -05:00
Ben Meadors d79e62fd2a Chatty LLMs should pipe down 2026-05-10 10:20:10 -05:00
f6a954b97e Implement rotating JSONL recorder for persistent logging (#10428)
* Implement rotating JSONL recorder for persistent logging

* Fixes

* Update documentation and clean up imports in command files

* Address remaining recorder review feedback

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/2541773c-869a-463f-9fae-8505272c06ff

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* recorder: fix lock re-entry deadlock on start() and force_rotate_all()

The previous "Fixes" commit added `_files_snapshot()` which acquires
`self._lock` so handlers don't race with `stop()` clearing `_files`.
But two callers were already holding `self._lock` when they invoked
methods that go through the snapshot:

  - `start()` writes the `recorder_start` event from inside its `with
    self._lock:` block. `_write_event` -> `_files_snapshot` re-acquires
    the same non-reentrant `threading.Lock`, freezing process startup.

  - `force_rotate_all()` calls `self.status()` (which also acquires
    `self._lock`) while still holding the lock from rotating each file.

Both fixes release the lock before the call. The recorder_start marker
still lands in events.jsonl because the started/started_at flags are
already set when we write it.

Verified end-to-end against the standalone /tmp/verify_pr_fixes.py
harness — all 9 PR review-comment fixes pass, including pause/resume
event ordering and concurrent start/stop without KeyError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix markdown linting issues in leakhunt.md and repro.md

* Handle recorder startup and query review fixes

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Tighten recorder follow-up tests

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Stabilize recorder startup tests

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Remove brittle recorder startup test

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Polish recorder follow-up errors

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Refine recorder startup and regex errors

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Clean up recorder follow-up nits

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/78540a9f-fe62-4350-b252-0ae5621f0b8a

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Trunk

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:22:40 -05:00
BJK 10a7f1042b Fix screen geometry update for SH1107 display (#10444)
Added conditional block to update screen geometry for SH1107 128x128.
2026-05-09 13:20:50 -05:00
github-actions[bot]andthebentern b4234b7f11 Automated version bumps (#10419)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-05-08 19:05:55 -05:00
Jonathan Bennett 5512185cfe Make heartbeat LED play nice with other LEDs (#10423) 2026-05-08 16:03:39 -05:00
github-actions[bot]andvidplace7 a8a785bbb7 Upgrade trunk (#10418)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-05-08 05:43:02 -05:00
Jonathan Bennett 0f854862e7 Give ThinkNode-m4 a heartbeat (#10408) 2026-05-07 13:17:29 -05:00
renovate[bot] b246bcd72e Update libpax digest to df42474 (#10406)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 20:52:30 -05:00
33e2bb70e6 Enhance GPS search failure handling backoff logic (#10404)
* Enhance GPS search failure handling backoff logic

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Remove stray submodule gitlink for .claude worktree

A 160000 (gitlink) entry for .claude/worktrees/naughty-payne-60fdb7
pointing at f2923590bc was accidentally committed in 9db15780f. The
path isn't a real submodule — it's a Claude Code agent worktree that
shouldn't be tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:44:04 -05:00
Ben Meadors 220bb4d186 Smart pointers and memory management cleanup (#10400)
* Refactor memory management in Syslog and StoreForwardModule

* Implement destructor for Lock

* Refactor RotaryEncoder and PacketHistory to use smart pointers for better memory management

* CH341 should use unique_ptr for improved memory management

* Fix checks in PH

* Improve Syslog::vlogf to handle variable argument lists more safely

* Fix initOk method to use nullptr for null pointer check
2026-05-06 15:33:59 -05:00
jessm33andjessm33 6e810741f3 Fix GPS initialization logic for Portduino configuration (#10395)
Co-authored-by: jessm33 <root@example.com>
2026-05-05 17:28:22 -05:00
Ben Meadors 603cce2988 Add informSearchFailed method to update GPS power state handling (#10394) 2026-05-05 10:12:50 -05:00
renovate[bot] d559af8477 Update LovyanGFX to v1.2.21 (#10373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 12:29:04 +02:00
Ben Meadors 41f53177a1 Use OBS instead of launchpad (#10375) 2026-05-02 09:25:24 -04:00
github-actions[bot]andvidplace7 2d761f6453 Upgrade trunk (#10364)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-05-02 07:23:49 -05:00
renovate[bot] 7cb071c780 Update platform-native digest to cab4b21 (#10372)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 18:25:08 -04:00
Austin Lane 0240a00d09 MacOS: Re-Add Orcania/Yder 2026-05-01 10:55:32 -04:00
Austin c0fcf807c0 MacOS: Correct pkg-config name openssl for ulfius. (#10369) 2026-05-01 10:42:17 -04:00
55f40ecdfd Add ulfius webserver support to macos native target (#10366)
* Add ulfius webserver support to macos native target

* fix: update PiWebServer docs for macOS and add explicit cstring include

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/3ce82582-23e0-4afe-b22f-b24f81721488

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: add --cflags to openssl@3 pkg-config and fix apt package name

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/1a6c59aa-4393-4134-8cee-61eeee0e9127

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-01 08:56:49 -05:00
Jason P 90744ee0b7 Update PhoneAPI.cpp to reduce chattiness (#10367) 2026-05-01 08:46:53 -05:00
AustinandCopilot 4ee9598107 Docker: Install grpcio-tools from distro (#10358)
Use distro provided Python at build time (instead of the `python` images from dockerhub) and install `grpcio-tools` using the distro provided packages.

This should speed up build times, ESPECIALLY on riscv64 (where prebuilt `grpcio-tools` wheels are not provided on pip).

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 15:22:11 -05:00
Ben Meadors 7066abbb86 Fix MAC_from_string to use input parameter instead of global config for MAC address parsing (#10356)
* Fix MAC_from_string to use input parameter instead of global config for MAC address parsing

* Enhance MAC_from_string validation and error handling

* Add missing include for <cctype> in PortduinoGlue.cpp
2026-04-30 13:52:42 -05:00
Ben Meadors 21cef8c2e5 Add TCP support for Meshtastic MCP interface / tests and update docs (#10355)
* Add TCP support for Meshtastic MCP interface / tests and update docs

* Address TCP endpoint validation and error handling in connection

* TCP connection handling and device listing logic

* Fix docstring formatting in normalize_tcp_endpoint function
2026-04-30 13:51:29 -05:00
github-actions[bot]andthebentern 173ac58ed7 Update protobufs (#10357)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-30 10:45:20 -05:00
github-actions[bot]andvidplace7 83adfd417a Upgrade trunk (#10354)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-30 06:39:52 -05:00
AustinandCopilot 24d64a0013 Docker: Build for riscv64 (#10345)
Upstream support has been added in Debian and Alpine.
Only build as part of `docker_manifest` (Beta/Alpha/Daily) releases, because these will take a **while** thanks to qemu.

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:04:49 -05:00
Ben Meadors 3a87fc82c0 Add documentation for macOS support in Copilot and Agent instructions 2026-04-29 19:54:05 -05:00
Austin 478444eb02 Docker-Alpine: Align version between build/main stages (#10347)
FROM python:3.14-alpine3.23 AS builder
FROM alpine:3.23

the alpine version needs to match in both stages 😅
2026-04-29 20:31:59 -04:00
renovate[bot] ad23c42fcc Update meshtastic/device-ui digest to 4bf593a (#10346)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-29 19:09:21 -05:00
Ben Meadors 195f42af82 Doesn't FSCom 2026-04-29 16:57:21 -05:00
Ben Meadors 089af764ec Replace FSCom.format() with FSCom.rmDir() for directory cleanup in NodeDB::loadFromDisk() 2026-04-29 16:41:21 -05:00
Austin 7be5426f34 Do not FACTORY_INSTALL on ARCH_PORTDUINO (#10343) 2026-04-29 13:00:01 -05:00
github-actions[bot]andvidplace7 9ec63b5eb2 Upgrade trunk (#10336)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-29 12:55:48 -05:00
Austin 22a9346fe0 Debian: Correctly fail upon failure (#10341)
Fake success is BS! We should fail when we fail.
Fixes issues with Debian sourcedebs silently failing to build ocassionally (due to github 502s, etc).
2026-04-29 11:16:25 -05:00
renovate[bot] c0e52e6e1c Update meshtastic/device-ui digest to 1ddcc9d (#10328)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 19:10:17 -05:00
github-actions[bot]andvidplace7 11df30a85f Upgrade trunk (#10324)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-28 19:04:16 -05:00
Ben Meadors 8d8ff21e7c Add clamping logic for milliseconds conversion and unit tests (#10326)
* Add clamping logic for milliseconds conversion and unit tests

* Simplify comments in secondsToMsClamped function

Removed detailed comments about seconds to milliseconds conversion.
2026-04-28 19:03:50 -05:00
9c72767c01 macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320)
* macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup

Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86,
PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing
`pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's
bundled CH34x driver only matches the CH340 *UART* variant
(PID 0x7523), so the CH341A's interface 0 is left unclaimed and
libusb opens / configures / claims it directly via IOUSBHostInterface.
End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init
returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow.

Two changes:

1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of
   the literal `8` to `Ch341Hal::getSerialString()`. The function in
   `USBHal.h:61-68` treats `len` as buffer size and reserves one slot
   for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`),
   so passing 8 produced a 7-char serial — which then broke the
   `strlen(serial) == 8` check at line 502, skipping the auto-MAC
   derivation from serial + product string. On Linux this was masked
   by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157,
   but on macOS that fallback is `__linux__`-guarded so the serial path
   is mandatory and the truncation left `mac_address` empty, causing
   the daemon to exit with `*** Blank MAC Address not allowed!`.

2. **`variants/native/portduino/platformio.ini`**: expand the
   `[env:native-macos]` comment block with a "Real LoRa hardware on
   macOS" section. Documents:
   - Why no upstream library change is needed (Apple kext targets
     CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is
     correct for macOS in this case).
   - How to point `meshtasticd` at an existing platform-agnostic
     `bin/config.d/lora-*.yaml` for CH341 hardware.
   - The auto-MAC-derivation contract (now working with this fix).
   - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure
     mode where a third-party WCH `CH34xVCPDriver` *would* claim
     interface 0 (`kmutil unload -b <bundleID>` workaround).

No upstream library forks, no PR chain, no additional lib_deps —
the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does
the right thing on macOS already.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 10:54:01 -05:00
AustinandCopilot c0425d7444 Actions: Build MacOS binary (#10319)
Preliminary CI for the MacOS builds

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 13:33:19 -05:00
d7db0f5829 add heltec-v4-r8 board (#10268)
* add heltec-v4-r8 board

* Fixed default SPI pin and macro definition errors.

* update platformio.ini according device-ui LGFX display definitions

Co-authored-by: Copilot <copilot@github.com>

* fix commit reference

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: mverch67 <manuel.verch@gmx.de>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-04-27 09:47:41 -05:00
renovate[bot] 048e5187ba Update platform-native digest to 4ea5e09 (#10314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 07:50:13 -05:00
renovate[bot]andBen Meadors 4234fe6f86 Update meshtastic/device-ui digest to 7289329 (#10313)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-27 07:50:01 -05:00
126861fd16 Native MacOS hello world (#10309)
* Native MacOS hello world

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update variants/native/portduino/platformio.ini

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: ensure null-termination in getSerialString() and handle len==0

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e5647919-2255-48ad-bcaa-7a2c2fdbf212

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-27 07:07:49 -05:00
github-actions[bot]andvidplace7 47a6c4c6a0 Upgrade trunk (#10317)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-27 06:22:50 -05:00
87f1f9d349 fix(Router): localize p_encrypted to prevent recursive-overwrite leak (#10311)
Router::handleReceived stores its allocCopy of the encrypted packet in the
class member p_encrypted. callModules() invokes module replies that re-enter
the router via MeshService::sendToMesh -> Router::sendLocal, which on a
broadcast reply recursively calls handleReceived. The inner call overwrites
the outer's p_encrypted without releasing it; on the way out it nulls the
member, the outer release(p_encrypted) now releases nullptr, and the original
allocation is permanently leaked. ~381 B per recursion.

Promote p_encrypted to a function-local so each invocation owns its own copy
for its full lifetime. The MQTT-publish null check at the call site (added by
PR #9136 as a workaround for this bug) stays in place because allocCopy can
still legitimately return nullptr on packetPool exhaustion.

Copilot's review of PR #8999 (the original introduction) flagged this exact
pattern at merge time:
  "Storing p_encrypted as a class member can cause issues with recursive or
  concurrent calls to handleReceived() since each call would overwrite the
  previous packet pointer."

The historical reason for the member (S&F needing to retain the encrypted
copy across calls) was satisfied differently by PR #9799 (ServerAPI converted
to std::unique_ptr + cleanup on connection close), so the member is no longer
load-bearing.

Reproduces issues #9632 / #10101 / #8729 (heap leak when MeshMonitor
connected; TCP drops on Station G2 / LILYGO ServerAPI dump abort).

Hardware A/B on Station G2 under sustained TCP-API retry storm (open :4403,
request config, disconnect mid-stream, repeat at ~0.6/s) - 9 min run:

  | Build         | heapFree drift | rebootCount delta |
  | this patch    | -1.5 KB (noise)| 0                 |
  | stock 2.7.13  | -73 KB (8.1KB/min) | +1 (OOM crash) |

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-26 21:03:34 -05:00
4ccdd80090 Add search duration check for exceeding 15 minutes (#10293)
* Add search duration check for exceeding 15 minutes

Added a condition to check if the search duration exceeds 15 minutes, indicating too long of a search.

* trunk

* Fix searchedTooLong: move 15-min cap before UINT32_MAX check, cache elapsed, add constexpr

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/b7f74430-9e7e-4a6f-8095-6176c1eee972

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Update src/gps/GPSUpdateScheduling.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove dead UINT32_MAX branch from searchedTooLong

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6dad5b56-902e-4d0e-90c1-038a9c2df364

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-25 20:42:14 -05:00
Ben Meadors aec0805a27 Trying another guard approach 2026-04-25 15:32:19 -05:00
Ben Meadors 7800dc3c8d Enhance UTF-8 sanitization logic and add delays in test setup for reliable timing 2026-04-25 15:04:58 -05:00
Ben Meadors 554188e90e Fix main function to setup and loop for Unity test framework 2026-04-25 14:49:37 -05:00
renovate[bot] fb678b9337 Update platform-native digest to 135b91e (#10300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-25 14:44:06 -05:00
Ben Meadors 7347091055 Add script to show unmerged commits from develop to master 2026-04-25 06:10:45 -05:00
github-actions[bot]andthebentern 55f15076ca Update protobufs (#10295)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-25 06:08:07 -05:00
Jonathan Bennett 439b87b860 Detach power interrupts for sleep (#10230)
* Detach power interrupts for sleep

* Gate PMU IRQ behind a found PMU
2026-04-24 06:48:21 -05:00
Jonathan BennettandBen Meadors d47301defc Add PortduinoSetOptions to overwrite the realhardware bool (#10157)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-24 06:48:09 -05:00
Jonathan Bennett 04b819a7b5 Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280)
Fixes #9912  and #10170
2026-04-24 06:47:38 -05:00
github-actions[bot]andvidplace7 8e653122c7 Upgrade trunk (#10284)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-24 05:40:44 -05:00
renovate[bot] 5ea3d143da Update meshtastic-esp8266-oled-ssd1306 digest to 6bfd1f1 (#10277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 19:30:47 -05:00
Ben MeadorsandCopilot 56c897e826 Can't LOG when we don't have logging set up yet in the native test suite
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 14:42:29 -05:00
Ben Meadors 2cc13a1132 Sane sanitization 2026-04-23 14:19:33 -05:00
Jonathan Bennett 031f332ec1 We have HardwareRNG, let's use it! (#10274) 2026-04-23 13:16:49 -05:00
github-actions[bot]andvidplace7 48747ee43d Upgrade trunk (#10266)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-23 06:16:23 -05:00
Thomas Göttgens 7c27f4e2df Revert "Update LovyanGFX to v1.2.20 (#10232)" (#10269)
This reverts commit fb1de111d7.
2026-04-23 12:37:05 +02:00
Jonathan Bennett 92c0133ef9 Finish evil merge cleanup (#10253) 2026-04-22 21:47:20 -05:00
Ben Meadors 6171ad8c14 Remove duplicate GPIO init block in setup() from bad merge in PR #9378
The T-Deck-Pro V1.1 PR (#9378) had a backwards merge (e7b66281) that pulled
236 commits of master INTO a 1-commit fork branch, then a second merge
(8fd0a7f2) duplicated the entire T_DECK/T_DECK_PRO/T_LORA_PAGER/HACKADAY
peripheral init block in setup(). Also removes a stray
digitalWrite(BLE_LED, LED_STATE_OFF) in the HACKADAY section that was an
artifact of the evil merge at e7b66281.
2026-04-22 19:35:13 -05:00
Jason P d8b11f0b14 Improve options to align to names of UI options (#10240) 2026-04-22 11:42:25 -05:00
github-actions[bot]andvidplace7 a710841723 Upgrade trunk (#10236)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-22 11:35:07 -05:00
renovate[bot] fb1de111d7 Update LovyanGFX to v1.2.20 (#10232)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 11:34:48 -05:00
Jonathan Bennett 2b5daf2438 T watch pinfix (#10231)
* Minor button debugging bits

* pin0 is a pin, pin -1 means disabled
2026-04-21 20:02:56 -05:00
9361b85f47 feat(t5s3-epaper): add InkHUD port for LilyGo T5 E-Paper S3 Pro (#10211)
* niche: add InkHUD port for LilyGo T5-E-Paper-S3-Pro (ED047TC1)

Add a NicheGraphics EInk driver adapter for the 4.7" ED047TC1 parallel
e-paper display used on the T5-E-Paper-S3-Pro (H752-01). The driver
wraps FastEPD and handles the polarity difference between InkHUD's
buffer format (0xFF = white) and FastEPD's (0x00 = white).

Rewrite variants/esp32s3/t5s3_epaper/nicheGraphics.h which was an
incomplete copy of the Heltec VM-E290 setup referencing undefined SPI
pin macros and a non-existent BUTTON_PIN_SECONDARY. The board uses a
parallel display, not the small SPI DEPG0290BNS800 that was referenced.

* fix: guard inputBroker null dereference in TouchScreenImpl1::init()

When MESHTASTIC_EXCLUDE_INPUTBROKER is defined (e.g. InkHUD builds),
inputBroker is nullptr. Calling inputBroker->registerSource() in that
state caused a LoadProhibited panic on any board that has both
HAS_TOUCHSCREEN=1 and the InputBroker excluded.

Add a null check before registerSource() to prevent the crash.

* niche: fix display rotation for T5-E-Paper-S3-Pro InkHUD port

Set rotation=3 (270° CW) in nicheGraphics.h to correct for FastEPD
scanning the ED047TC1 panel in portrait orientation, resulting in
correct landscape display output.

* fix: update buffer format descriptions and remove polarity inversion for InkHUD and FastEPD

* fix: update ED047TC1 driver to handle inactive pixel borders and adjust safe-area dimensions

* fix: comment out ruler diagnostic for E-Ink driver

* feat: implement TouchInkHUDBridge for direct touch event handling in InkHUD

* niche: add FreeSans 18pt/24pt Win1253 (Greek) fonts for larger InkHUD displays

Add Win1253-encoded FreeSans 18pt and 24pt font headers to support Greek
script on larger InkHUD screens (e.g., the 4.7" ED047TC1 at ~234 DPI).
Register FREESANS_24PT_WIN1253 and FREESANS_18PT_WIN1253 macros in AppletFont.h.
Set fontLarge=24pt, fontMedium=18pt, fontSmall=12pt in nicheGraphics.h for the
T5-E-Paper-S3-Pro.

* feat(ed047tc1): use true partial update for FAST refresh

Replace fullUpdate(CLEAR_FAST) with partialUpdate() for FAST display
updates. FastEPD's partialUpdate() diffs pCurrent against pPrevious
and only applies the update waveform to rows that have changed, leaving
unchanged rows with a neutral signal.

This reduces visible flicker on routine updates (new messages, position
changes) — only the affected region of the screen refreshes. Full-screen
CLEAR_SLOW updates are preserved for periodic ghosting cleanup, driven
by InkHUD's setDisplayResilience() ratio.

* feat(t5s3-epaper): enable frontlight via LatchingBacklight

Wire up BOARD_BL_EN (GPIO11) to InkHUD's LatchingBacklight driver.
Enable the backlight menu item so users can toggle "Keep Backlight On"
via Settings. The backlight turns on automatically when the menu opens
and off when it closes.

* Fix RTC chip (PCF8563 not PCF85063) and GT911 I2C address collision

- variant.h used PCF85063_RTC but the board has a PCF8563. The difference
  is the RAM register: PCF85063 has 1 byte of RAM; PCF8563 does not. The
  PCF85063 driver was trying to write this register on init, failing every
  time, and setDateTime writes were silently discarded — RTC time was
  never persisted across reboots. Switch to PCF8563_RTC/PCF8563_INT.

  Before:
    [E][SensorPCF85063.hpp:375] initImpl(): Failed to write to RAM memory
      register. Maybe this chip is pcf8563.
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:23
    PCF85063 setDateTime 2026-04-05 18:40:59
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:19  ← lost

  After:
    PCF8563 found at address 0x51
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:37  ← persisted
    PCF8563 setDateTime 2026-04-05 18:58:44
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:44  ← round-trips

- GT911 touch was initialized with GT911_SLAVE_ADDRESS_L (0x5D), which
  collides with the SFA30 air quality sensor also at 0x5D on the same
  I2C bus. Switch to GT911_SLAVE_ADDRESS_H (0x14): the library drives
  INT high during reset to program the GT911 to address 0x14,
  eliminating the address conflict.

  Before:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:568] initImpl(): Try using 0x5D as the device address

  After:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:544] initImpl(): Try using 0x14 as the device address

* t5s3_epaper: fix GT911 ghost-SFA30 via early I2C address latch

Investigation findings
----------------------
Boot logs showed "SFA30 found at address 0x5d" on every cold power-on,
and AirQualityTelemetry was registering an SFA30 sensor. However, every
readMeasuredValues() call returned error 268 (0x010C = Sensirion
WriteError | I2cAddressNack), meaning the I2C write to 0x5D was being
NACK'd — inconsistent with a real SFA30.

Root cause: the GT911 touch controller latches its I2C address from the
INT pin level at reset time (GT911 datasheet §4.3). GPIO3 (INT) defaults
LOW on ESP32-S3 cold boot → GT911 always powers up at 0x5D
(SLAVE_ADDRESS_L). The I2C scanner runs before lateInitVariant() had a
chance to reprogram the chip.

The scanner's SFA30 detection (ScanI2CTwoWire.cpp) sends the 2-byte
command 0xD060 to 0x5D and requests 48 bytes back. GT911 ACKs the
write (treating it as a register address) and returns 48 bytes of
register data, passing the length check — a false-positive SFA30
detection.

Confirmed via second cold-boot log: after the previous commit moved GT911
to 0x14 in lateInitVariant(), address 0x5D *still* appeared in the scan
because the scanner runs first. The board has no physical SFA30 fitted.

Fix
---
Add the GT911 address-latch reset sequence to earlyInitVariant(), before
Wire is initialised and before the I2C scan runs. Per the datasheet:
drive RST LOW, drive INT HIGH (selects address 0x14 / SLAVE_ADDRESS_H),
hold >100 µs, release RST, wait >5 ms startup. GPIO-only, no Wire
dependency. lateInitVariant() then repeats this sequence internally via
touch.begin(); the double-reset is harmless.

Verified in boot log:
  Before: "SFA30 found at address 0x5d", 5 I2C devices, NACK errors
  After:  no SFA30 entry, 4 I2C devices (TCA9535/PCF8563/BQ27220/BQ25896),
          GT911 found at 0x14 and touch initialised successfully,
          AirQualityTelemetry registers no sensors (correct — no SFA30 present)

* t5s3_epaper: add variant_shutdown() for touch sleep and backlight off

Put GT911 into low-power standby (command 0x05) and drive BOARD_BL_EN
LOW before deep sleep to avoid unnecessary current draw.

* t5s3_epaper: fix touch gesture routing and coordinate mapping

readTouch() now transforms raw GT911 axes to visual-frame coordinates
based on the current display rotation (rotation=3 is the hardware
identity). This ensures TouchScreenBase detects swipe direction
correctly regardless of which rotation the user has selected.

TouchInkHUDBridge dynamically sets joystick.alignment = (4-rotation)%4
on each touch event so that (rotation+alignment)%4==0 always, keeping
nav calls pass-through without remapping.

nicheGraphics.h now calls loadSettings() first so that rotation is
persisted across reboots. rotation=3 and other first-boot defaults are
only applied when tips.firstBoot is set. alignment is recomputed from
the loaded rotation on every boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix GT911 sleep timing via notifyDeepSleep observer

touch.sleep() was called from variant_shutdown(), which runs inside
cpuDeepSleep() — after Wire.end() had already torn down the I2C bus in
doDeepSleep(). This caused Wire NULL TX buffer errors and left the GT911
awake during deep sleep.

Register a CallbackObserver on notifyDeepSleep, which fires before
Wire.end(), so the I2C command reaches the chip while the bus is live.
Pattern matches LatchingBacklight and other NicheGraphics components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix touch nav and applet defaults in nicheGraphics

Enable joystick mode post-begin so menu scroll and swipe-up/down
gestures are not silently dropped by the joystick.enabled gate in
Events.cpp. Activate DMs and Channel 0/1 applets with correct
autoshow defaults matching the mini-epaper-s3 reference pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update nicheGraphics.h

* t5s3_epaper: fix ED047TC1 driver docs and remove spurious beginPolling

Addressing PR review comments:

Remove beginPolling(1, 0) after the blocking FastEPD update — it
incorrectly set updateRunning=true for one loop cycle after the
hardware was already done, causing busy() to briefly return true.
Since isUpdateDone() always returns true, no polling is needed.

Also fix stale comments: safe-area buffer size was 944×532, now
944×523; V_OFFSET_ROWS didn't exist, replaced with the actual
V_OFFSET_TOP=9 / V_OFFSET_BOTTOM=8 constant names.

* t5s3_epaper: clean up applet addition formatting in setupNicheGraphics

* t5s3_epaper: guard ED047TC1.cpp against non-T5S3 InkHUD builds

The InkHUD base config pulls in all of src/graphics/niche/ so every
InkHUD device compiled ED047TC1.cpp, triggering the #error on line 48
for boards that define neither T5_S3_EPAPER_PRO_V1 nor V2.

Wrap the file body with #ifdef T5_S3_EPAPER_PRO so it is only compiled
for T5S3 targets. The #error is preserved inside the guard to catch
future hardware revisions that forget to update the driver.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-04-21 15:35:28 -05:00
Ben MeadorsandCopilot 68383c8bd5 Add encryption overview to agent instructions in AGENTS.md (#10207)
* Add encryption overview to agent instructions in AGENTS.md

* Update AGENTS.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Clarify nonce and wire overhead details in encryption section of copilot instructions

* Enhance encryption documentation in copilot instructions and agents guide for clarity on key management and reset behaviors

* Update .github/copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix botched merge conflict resolution

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-21 10:49:13 -05:00
TomandBen Meadors 76dea77929 Add authoring guide for native unit tests in README.md (#10201)
* Add authoring guide for native unit tests in README.md

* Enhance documentation for agent tooling and native unit tests in README and related files

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-21 10:49:08 -05:00
Clive Blackledge d7ba178bf1 Fix: prompt markdownlint md040 fix for new prompts. (#10199)
* Add ESP32 Power Management lessons learned document

Documents our experimentation with ESP-IDF DFS and why it doesn't
work well for Meshtastic (RTOS locks, BLE locks, USB issues).

Proposes simpler alternative: manual setCpuFrequencyMhz() control
with explicit triggers for when to go fast vs slow.

* docs(prompts): fix markdown fence language tags

* docs: remove ESP32 power management notes
2026-04-21 10:49:01 -05:00
Jennifer Sanchez 5f836cdf3b Added support for Spreading Factors 5 and 6 on compatible radios (#10160) 2026-04-21 10:48:33 -05:00
github-actions[bot]andvidplace7 a8a5315460 Upgrade trunk (#10221)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-21 17:14:12 +02:00
Jonathan Bennett d8e4389da2 No longer need undefines, thanks to #10179 (#10180) 2026-04-21 09:57:48 -05:00
Jonathan Bennett 25febfdeee More cleanly remove LED_BUILTIN (#10179)
* Test PR to remove LED_BUILTIN 

Comment out the LED_BUILTIN definition in platformio.ini

* Add LED_BUILTIN definition to nrf52840.ini
2026-04-21 09:57:41 -05:00
Ruledo f5be09c123 Add Luckfox Pico Max Waveshare Pico LoRa config (#10175)
Add a meshtasticd config for the Luckfox Pico Max with the Waveshare Pico LoRa SX1262 TCXO HAT.

Tested on hardware with successful SX1262 init, broadcast, and direct messaging.
2026-04-21 09:57:33 -05:00
23321c4588 Reduce key duplication by enabling hardware RNG (#8803)
* Reduce key duplication by enabling hardware RNG

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Use micros() for worst case random seed for nrf52

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Minor cleanup, remove dead code and clarify comment

* trunk

* Add useRadioEntropy bool, default false.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-21 09:57:06 -05:00
Catalin Patulea e1f5043489 Delete PointerQueue::dequeuePtrFromISR, unused since commit db766f1 (#99). (#10090) 2026-04-21 09:57:00 -05:00
Jaime Roldan 5d9a2564e4 fix(nodedb): force null-terminate name fields in UserLite/User conversions (#8174) (#10218) 2026-04-21 09:52:47 -05:00
nightjoker7 84ce1ea147 SX126x: re-apply 0x8B5 register in resetAGC() to preserve RX sensitivity (#10219)
The CALIBRATE_ALL (0x7F) command inside resetAGC() clears bit 0 of the
undocumented 0x8B5 register. That bit is set once in init() by #9571 and
#9777 to improve SX1262 RX sensitivity, and the AGC-reset path was not
re-applying it. Result: every SX1262 node silently loses the RX
sensitivity patch ~60s after boot and never recovers until reboot.

Empirically confirmed on Heltec Mesh Node T114 (nRF52840 + SX1262):
  - Post-calibration read of 0x8B5 = 0x04 (bit 0 cleared)
  - After re-apply: 0x05 (bit 0 set)
Reproducible every AGC_RESET_INTERVAL_MS tick.

Fix re-applies the register bit alongside the existing post-calibration
re-applies (setDio2AsRfSwitch, setRxBoostedGainMode).
2026-04-21 09:50:35 -05:00
renovate[bot] eba74fa6e2 Update GxEPD2 to v1.6.9 (#10212)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 06:08:28 -05:00
github-actions[bot]andvidplace7 8627bce1a1 Upgrade trunk (#10125)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-20 04:55:10 -05:00
Ben MeadorsandCopilot Autofix powered by AI de23e5199d Add USB camera and uhubctl support for new test suite. Also included some bug fixes (#10204)
* Add USB camera and uhubctl support for new test suite. Also added some bug fixes

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refactor test messages for clarity and consistency in regex tests

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-19 06:51:41 -05:00
236 changed files with 14671 additions and 646 deletions
+18 -5
View File
@@ -24,7 +24,12 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
- `mcp__meshtastic__get_config(section="lora", port=<p>)` — region, preset, channel_num, tx_power, hop_limit.
- Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=<p>, env=<inferred-env>)`, wait 3s, `serial_read(session_id=<s>, max_lines=100)`, `serial_close(session_id=<s>)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*<ROLE>` is set.
4. **Render per-device report** as:
4. **Hub health** (call once, not per-device): `mcp__meshtastic__uhubctl_list()` — enumerates every USB hub the host can see. Note which hubs advertise `ppps=true` and which hub hosts each Meshtastic device (cross-reference by VID). Flag it in the report if:
- No hub advertises PPPS → `tests/recovery/` can't run on this setup; hard-recovery via `uhubctl_cycle` isn't available.
- A Meshtastic device is on a non-PPPS hub → note it; operator may want to move the device to a PPPS hub to unlock auto-recovery.
- `uhubctl_list` raises `ConfigError: uhubctl not found` → just say `uhubctl not installed` in the report; don't treat as a fault.
5. **Render per-device report** as:
```text
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
@@ -33,20 +38,28 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
tx_power : 30 dBm, hop_limit=3
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
primary ch : McpTest
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts
```
Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub), flag it inline with a short `⚠︎ <one-line reason>`.
Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub, device on non-PPPS hub), flag it inline with a short `⚠︎ <one-line reason>`.
5. **Cross-device correlation** (only when >1 device is inspected):
6. **Cross-device correlation** (only when >1 device is inspected):
- Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it.
- Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh)
- Do the primary channel NAMES match? Mismatch = different PSK = no decode.
6. **Suggest next actions only for specific, recognisable failure modes**:
7. **Recorder slice (cheap, always available).** The mcp-server runs an autouse log recorder that's been collecting from every connected device. Pull two short slices to surface anything weird that's already happened:
- `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` — recent firmware errors. If empty, say "no recent errors"; don't manufacture concern.
- `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` — heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap.
- If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet — enable `set_debug_log_api(True)` to populate" and skip this step gracefully.
8. **Suggest next actions only for specific, recognisable failure modes**:
- Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path."
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
- Device unreachable → point at touch_1200bps + the CP2102-wedged-driver note in run-tests.sh.
- Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
- CP2102-wedged-driver on macOS → see the note in `run-tests.sh`.
- Heap slope strongly negative → "run `/leakhunt window=6h` for a full timeline + classification."
## What NOT to do
+103
View File
@@ -0,0 +1,103 @@
---
description: Hunt for memory leaks (and other slow degradations) by reading the persistent recorder's heap timeline + log slice over a window
argument-hint: [window=1h] [field=free_heap] [variant=local]
---
<!-- markdownlint-disable MD029 -->
# `/leakhunt` — read the recorder, classify a memory leak
Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags — the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`.
## Two signal paths — pick the right one
| Path | Build flag | Cadence | Per-thread attribution | Cost |
| --------------------- | ---------------- | -------------- | ---------------------- | ------------------------- |
| LocalStats packet | (default) | ~60 s | No | Free — always on |
| `[heap N]` log prefix | `-DDEBUG_HEAP=1` | every log line | Yes (Thread X leaked) | Bigger flash + log volume |
Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** — when the slope is real and you need to know which thread is leaking.
## What to do
1. **Parse `$ARGUMENTS`**: optional `window` (default `1h`, accepts `30m`/`6h`/`-3d`/etc.), optional `field` (default `free_heap`; alternates: `total_heap`, `battery_level`, anything in the LocalStats variant), optional `variant` (default `local`; alternates: `device`, `environment`, `power`, `airQuality`, `health`).
2. **Verify the recorder is alive** — call `mcp__meshtastic__recorder_status`. Check:
- `running == True`
- `files.telemetry.lines > 0` (at least one telemetry packet recorded — if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval)
- `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent — log that, not "leak detected")
3. **Detect whether DEBUG_HEAP is active** — `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path.
4. **Pull the timeline** — `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read:
- `samples` — how many raw points contributed
- `min`, `max` — total swing
- `slope_per_min` — units per minute (linear regression over the whole window)
5. **Pull the log context for the same window** — `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM).
6. **Pull marker events** so we know if the operator labeled phases — `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot.
6a. **(DEBUG_HEAP only) Per-thread attribution** — `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too — a thread with 50× small leaks is different from 1× big leak.
7. **Classify** based on what the data says, NOT on what you wish it said. Use these rules in order:
- **Insufficient data** (< 5 samples): say so. Suggest a longer window or longer wait. Stop.
- **Reboot mid-window**: if any `connection_lost` event is present AND `free_heap` jumped UP at that timestamp, the device rebooted. Note it; pre-reboot trend may be a leak but you only have part of the curve.
- **OOM-imminent**: any `Alloc an err=` or `out of memory` line in the log slice. This trumps everything; flag urgently.
- **Slow leak**: `slope_per_min < -50` AND `max - min > 1000` AND no reboot. The heap is monotonically (or near-monotonically) declining. Estimate time-to-zero: `min / -slope_per_min` minutes. Surface it.
- **Fragmentation suspect**: `slope_per_min` close to zero (|x| < 50) BUT min trends down across the window AND the log slice shows `Alloc an err` warnings WITHOUT total OOM. Means free total is OK but largest contiguous block is shrinking. Recommend a `DEBUG_HEAP` build to confirm.
- **Steady**: |slope_per_min| < 50, no error lines. Heap is fine.
- **Recovery curve**: slope is POSITIVE — heap recovered. Either a workload completed or GC fired. Note it; not a leak.
8. **Report**:
```text
/leakhunt window=6h field=free_heap variant=local
────────────────────────────────────────────────────
recorder : running, telem last_ts 8s ago
build : DEBUG_HEAP=ON (per-line prefix detected)
samples : 14,200 over 6h (cadence ~1.5s, log-line synth)
free_heap : min 92,344 / max 124,008 / range 31,664
slope : -82 bytes/min (negative — heap declining)
reboots : none in window
OOM events : none
error lines : 3× "Alloc an err=ESP_ERR_NO_MEM" at +4h12m, +5h08m, +5h44m
thread leaks : (DEBUG_HEAP) MeshPacket -3,124 B over 18 events
Router -1,408 B over 4 events
others -240 B
verdict : SLOW LEAK — primary suspect MeshPacket thread
est. time-to-OOM: ~1,127 min (~18.8 h) at current slope
evidence : (3 log line citations with uptimes)
```
Then: **what to do next.**
- SLOW LEAK, **DEBUG_HEAP off** → recommend rebuilding with the flag and re-running this skill. Concrete one-liner the operator can copy:
```text
mcp__meshtastic__build(env="<env>", build_flags={"DEBUG_HEAP": 1})
mcp__meshtastic__pio_flash(env="<env>", port="<port>", confirm=True)
```
After flash, set debug_log_api back on and wait one window; re-run `/leakhunt`.
- SLOW LEAK, **DEBUG_HEAP on** → cite the top-leaking thread name from step 6a. Point at the corresponding source file (`grep -rn "ThreadName(\"<name>\")" src/`); the operator decides what to fix.
- FRAGMENTATION SUSPECT → propose pre-allocating any per-packet buffers; or rebuilding with `CONFIG_HEAP_TASK_TRACKING=y` on ESP32 to see who's holding the largest blocks.
- OOM-IMMINENT → flag for immediate attention; don't wait for the next telemetry interval.
- STEADY → say so; stop. Don't invent problems.
## What NOT to do
- Don't assume a leak from a single dip. LocalStats fires every ~60 s and the firmware naturally allocates+frees on each broadcast cycle; one packet sees the trough. Look at the slope, not the deltas.
- Don't recommend code changes. This skill diagnoses; the operator decides what to fix.
- Don't enable `set_debug_log_api` automatically — if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run.
- Don't run heavy workloads to "trigger the leak." The recorder is passive; we read what's there.
## Companion: `mark_event` for stress runs
If the operator wants to test under stimulus (e.g. blast 50 broadcasts and see what the heap does), they can frame the experiment with markers:
```text
mark_event("burst-start")
… run the workload …
mark_event("burst-end")
/leakhunt window=15m
```
The markers land in both `events.jsonl` and `logs.jsonl`, so the report can show "free_heap dipped 8 KB during the burst window, recovered to baseline within 2 LocalStats cycles" → not a leak.
+6 -1
View File
@@ -3,6 +3,8 @@ description: Re-run a specific test N times in isolation to triage flakes, diff
argument-hint: <test-node-id> [count=5]
---
<!-- markdownlint-disable MD029 -->
# `/repro` — flakiness triage for one test
Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y."
@@ -40,11 +42,14 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps.
5a. **Archive recorder slices per attempt** (no extra device interaction; the recorder runs autouse). Right after each attempt finishes, capture its `(start_ts, end_ts)` and call `mcp__meshtastic__recorder_export(start=<start>, end=<end>, dest_dir="mcp-server/tests/repro_artifacts/<safe-test-id>/attempt_<n>/")`. This drops a `logs.jsonl`, `telemetry.jsonl`, `packets.jsonl`, and `events.jsonl` snapshot scoped to the attempt window. Use these for cross-attempt diffs in step 5: `jq '.line' logs.jsonl` is faster than re-running the test, and the telemetry slice lets you compare heap behavior across attempts.
6. **Classify the flake** into one of:
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
- **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup.
- **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer.
- **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. For a device that's wedged past `touch_1200bps`, the next escalation is `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed).
- **Device went dark mid-run** → fails from some attempt onward, never recovers, firmware log stops arriving. Almost always hardware: a Guru crash + frozen CDC. Hard-power-cycle via `uhubctl_cycle(role=..., confirm=True)` before the next iteration; if that also fails, escalate to replug.
- **Genuinely unknown** → say so; don't invent a root cause.
7. **Report back** with:
+9 -4
View File
@@ -19,16 +19,21 @@ Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn
2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped.
3. **On pass**: one-line summary of the form `N passed, M skipped in <duration>`. Don't enumerate the 52 test names — the user can read those. Do mention if any test was SKIPPED for a NON-placeholder reason (e.g. "role not present on hub" is worth flagging).
3. **On pass**: one-line summary of the form `N passed, M skipped in <duration>`. Don't enumerate the test names — the user can read those. Do mention any SKIPPED tests and name the cause:
- `"role not present on hub"` → device unplugged; operator knows to reconnect.
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped because the macro isn't in firmware yet; suggest `--force-bake`.
- `"uhubctl not installed"` → tests/recovery + peer-offline skipped; suggest `brew install uhubctl` / `apt install uhubctl`.
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the hub doesn't support per-port power; the tier will never run on that setup.
- `"opencv-python-headless is not installed"` → tests/ui auto-deselected by run-tests.sh; suggest `pip install -e 'mcp-server/.[ui]'`.
4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`).
4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). For UI-tier failures also glance at `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` — it records each step's frame + OCR.
5. **Classify the failure** as one of:
- **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro <test_node_id>` to confirm.
- **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery (replug USB, `touch_1200bps`, check `git status userPrefs.jsonc`).
- **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery in escalation order: (a) replug USB, (b) `touch_1200bps(port=...)` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role="nrf52", confirm=True)` when a device is fully wedged past DFU (needs `uhubctl` installed — `baked_single`'s auto-recovery hook does this once automatically). Also check `git status userPrefs.jsonc`.
- **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible.
6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, or USB replug, \_describe what to do* — don't execute. The operator decides.
6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or USB replug, \_describe what to do* — don't execute. The operator decides.
## Arguments handling
+4 -4
View File
@@ -75,11 +75,11 @@ body:
- type: checkboxes
id: mui
attributes:
label: Is this bug report about any UI component firmware like InkHUD or Meshtatic UI (MUI)?
label: Is this bug report about any UI (https://meshtastic.org/docs/configuration/device-uis/) component firmware?
options:
- label: Meshtastic UI aka MUI colorTFT
- label: InkHUD ePaper
- label: OLED slide UI on any display
- label: Meshtastic UI aka MUI
- label: InkHUD
- label: BaseUI
- type: input
id: version
+115 -18
View File
@@ -13,6 +13,7 @@ Meshtastic is an open-source LoRa mesh networking project for long-range, low-po
- **RP2040/RP2350** - Raspberry Pi Pico variants
- **STM32WL** - STM32 with integrated LoRa
- **Linux/Portduino** - Native Linux builds (Raspberry Pi, etc.)
- **macOS native** - Headless `meshtasticd` on Apple Silicon / x86_64; see `variants/native/portduino/platformio.ini` for Homebrew prereqs + CH341 LoRa setup
### Supported Radio Chips
@@ -70,6 +71,70 @@ PKI (Public Key Infrastructure) messages have special handling:
- Accepted on a special "PKI" channel
- Allow encrypted DMs between nodes that discovered each other on downlink-enabled channels
## Encryption & Key Management
Meshtastic packets on the air are typically encrypted one of two ways: the **per-channel symmetric** layer (AES-CTR with a shared PSK) for broadcasts and channel traffic, and the **per-peer PKI** layer (X25519 ECDH → AES-256-CCM) for direct messages and remote admin. A channel with a 0-byte PSK (or Ham mode, which wipes PSKs) transmits cleartext — see the size table below. Both are implemented in `src/mesh/CryptoEngine.cpp`; the send/receive dispatch lives in `src/mesh/Router.cpp`; admin authorization lives in `src/modules/AdminModule.cpp`.
### High-level model
- **Channels** are symmetric rooms: anyone with the PSK can read any message on the channel. Channel 0 is the "primary" channel and ships with the short-form default PSK on factory devices, forming the public mesh most users join. (The LoRa modem preset `LONG_FAST` lives on `config.lora.modem_preset` and is an independent field — don't conflate "channel 0 default PSK" with the modem preset name.)
- **DMs** addressed to a single node require PKI so that other holders of the channel PSK can't read them. Outside Ham mode, Meshtastic does not fall back to channel-symmetric encryption when the destination public key is unknown.
- **Remote admin** is a DM carrying an `AdminMessage`. The receiver only acts on it if the sender's public key is on its allowlist (`config.security.admin_key[0..2]`).
- **Ham mode** (`owner.is_licensed=true`, where `owner` is the local `meshtastic_User` record) disables PKI entirely and sends cleartext — FCC Part 97 prohibits encryption on amateur bands.
- **No ratchet, no session.** Every packet is encrypted from scratch — a stateless design that matches the high-loss, store-and-forward nature of LoRa.
### Symmetric channel encryption (AES-CTR)
`CryptoEngine::encryptPacket` / `decrypt` / `encryptAESCtr` in `src/mesh/CryptoEngine.cpp`.
- **Cipher**: AES-CTR, AES-128 or AES-256 depending on key length. Same routine in both directions (CTR is a stream cipher, so encrypt == decrypt).
- **Key**: `ChannelSettings.psk` bytes. Size semantics:
- **0 bytes** → no encryption, cleartext on the air
- **1 byte** → short-form index into the well-known `defaultpsk[]` in `src/mesh/Channels.h`. Index 0 = cleartext; 1 = defaultpsk unchanged; 2..255 = defaultpsk with its last byte incremented by (index − 1). This is what the CLI's `--ch-set psk default` produces.
- **16 bytes** → raw AES-128 key
- **32 bytes** → raw AES-256 key
- **2..15 bytes** → zero-padded to 16 and used as AES-128 (with a warn log); **17..31 bytes** → zero-padded to 32 and used as AES-256 (with a warn log). Defensive fallback for malformed PSK input, not something to rely on.
- **Nonce (128 bit)**: `packet_id` (u64 LE) ‖ `from_node` (u32 LE) ‖ `block_counter` (u32, starts at 0). Built in `CryptoEngine::initNonce`.
- **No AEAD**: channel packets carry no MAC, so the channel-hash byte is not an integrity or authenticity check. `Channels::getHash` is a 1-byte XOR-derived hint over the channel name bytes and PSK bytes that helps receivers pick a candidate channel/PSK for decryption. Because it is only a small hint and collisions are easy to find, it should be described purely as a PSK-selection aid, not as a security filter an attacker cannot bypass.
- **Channel 0 is special in one way only**: it's the channel the Router attempts PKI decryption on before falling through to AES-CTR. Non-zero channels always go straight to AES-CTR.
### PKI encryption for DMs (X25519 ECDH + AES-256-CCM)
`CryptoEngine::encryptCurve25519` / `decryptCurve25519` in `src/mesh/CryptoEngine.cpp`.
- **Keypair**: Curve25519 (aka X25519), 32-byte public + 32-byte private. Stored in `config.security.public_key` / `private_key`; the public half is mirrored into `owner.public_key` so it rides along in NodeInfo broadcasts and propagates through the mesh like any other identity field.
- **Key generation** (`generateKeyPair`): stirs `HardwareRNG::fill()` (64 B from platform TRNG when available), the 16-byte `myNodeInfo.device_id`, and a call to `random()` into the rweather/Crypto library's software RNG, then `Curve25519::dh1`. `regeneratePublicKey` recomputes the public half from a known private (used when restoring from backup).
- **Keygen entry points**: at boot, `NodeDB` calls `generateKeyPair` (or `regeneratePublicKey` when a stored private key is present and passes a low-entropy check) **directly** when `!owner.is_licensed` and `config.lora.region != UNSET`. `ensurePkiKeys` wraps the same logic for runtime/admin flows — it's the path `AdminModule::handleSetConfig` runs when first assigning a valid region or when security config is written; **do not assume it's the universal boot-time gate**, because the NodeDB path bypasses it.
- **Handshake**: `Curve25519::dh2(local_private, remote_public) → 32-byte shared secret → SHA-256 → 32-byte AES-256 key`. Recomputed per packet. The SHA-256 step is effectively a KDF over the raw ECDH output.
- **Cipher**: AES-256-CCM via `aes_ccm_ae` / `aes_ccm_ad` (`src/mesh/aes-ccm.cpp`). MAC length (the `M` parameter) is **8 bytes**. No AAD — the MAC covers ciphertext only.
- **Nonce (13 bytes / 104 bit)**: `aes_ccm_ae`/`aes_ccm_ad` use a 13-byte CCM nonce (`L = 2` is hardcoded in `src/mesh/aes-ccm.cpp`), not a 16-byte nonce. For PKI packets, `CryptoEngine::initNonce(fromNode, packetNum, extraNonce)` starts from the usual packet-derived nonce material, then overwrites nonce bytes `4..7` with a fresh 32-bit `extraNonce = random()`. The effective nonce bytes are therefore: bytes `0..3` = `packet_id`, bytes `4..7` = transmitted `extraNonce`, bytes `8..11` = `from_node`, byte `12` = `0x00`. The receiver reconstructs the same 13-byte nonce from the packet metadata plus the appended `extraNonce`.
- **Wire overhead**: 12 bytes appended to the ciphertext = 8-byte MAC ‖ 4-byte extraNonce. Defined as `MESHTASTIC_PKC_OVERHEAD = 12` in `src/mesh/RadioInterface.h`. Only the 4-byte `extraNonce` is sent; the rest of the 13-byte CCM nonce is reconstructed from packet fields as described above. The Router's send path checks this overhead against `MAX_LORA_PAYLOAD_LEN` before committing to PKI.
- **Send selection** (`Router::send`): the sender enters the PKI path when **all** hold — we're the originator AND not Ham mode AND not Portduino simradio AND not on the `serial`/`gpio` channels (unless the packet is already marked `pki_encrypted`) AND `config.security.private_key.size == 32` AND destination is a single node (not broadcast) AND the portnum isn't infrastructure. `TRACEROUTE_APP`, `NODEINFO_APP`, `ROUTING_APP`, and `POSITION_APP` are routed through channel encryption even when DMed (these need to be readable by relaying peers). Once on the PKI path, if the destination's public key isn't in our NodeDB the send **fails** with `PKI_SEND_FAIL_PUBLIC_KEY` — it does not silently fall back to channel encryption. If the client explicitly set `pki_encrypted=true` and any condition blocks PKI, the send fails with `PKI_FAILED`.
- **Receive selection** (`Router::perhapsDecode`): try PKI decrypt first when `channel == 0` AND `isToUs(p)` AND not broadcast AND both peers have public keys in NodeDB AND `rawSize > MESHTASTIC_PKC_OVERHEAD`. On success the packet gets `pki_encrypted=true` stamped and the sender's public key copied into `p->public_key` for downstream authorization.
### Remote admin authorization
Implemented in `src/modules/AdminModule.cpp` → `handleReceivedProtobuf`. The authorization check runs in this order:
1. **Response messages** — if `messageIsResponse(r)` is true (the payload is a response to one of our earlier admin requests), it's accepted without any further check. The in-file comment flags this as a known-untightened gap: a stricter implementation would remember which `public_key` we last queried and reject responses that don't match.
2. **Local admin** — `mp.from == 0` (phone app over BLE, serial CLI, internal module); never travels over the air. **Rejected** if `config.security.is_managed` is true, because managed devices expect admin to arrive over the air through an authorized remote path.
3. **Legacy admin channel (deprecated)** — the packet arrived on a channel named literally `"admin"`. Gated by `config.security.admin_channel_enabled`; returns `NOT_AUTHORIZED` if the flag is false. Kept for backward compatibility; new deployments should use PKI admin.
4. **PKI admin (preferred for remote)** — `mp.pki_encrypted == true` AND `mp.public_key` matches one of `config.security.admin_key[0..2]` (up to three authorized 32-byte Curve25519 public keys, typically copied from the admin node's own `user.public_key`).
5. **Fallthrough** → `NOT_AUTHORIZED`.
On top of authorization, any remote admin message that **mutates** state (not a request, not a response) also has to pass a session-key check (`checkPassKey`): the client must first pull a fresh 8-byte `session_passkey` via `get_admin_session_key_request`, then echo that passkey back in the mutating message. The device rotates the passkey after 150 s and rejects values older than 300 s — a narrow anti-replay window on top of the PKI layer.
`config.security.is_managed = true` disables **local** admin writes (`mp.from == 0` is rejected). It does not by itself force every admin action through PKI — the legacy `"admin"` channel still authorizes remote admin when `config.security.admin_channel_enabled == true`. The AdminModule refuses to persist `is_managed=true` unless at least one `admin_key` is populated — a deliberate guard against operators locking themselves out.
### Key-rotation hazards (actions that invalidate peers)
- **`factory_reset_device`** (the "full" variant, calls `NodeDB::factoryReset(eraseBleBonds=true)`) → **wipes** the X25519 private key; a fresh keypair is generated on the next region-set. Every existing peer holds the old public key, so DMs to this node silently fail PKI decrypt until every peer re-exchanges NodeInfo.
- **`factory_reset_config`** (the "partial" variant, calls `NodeDB::factoryReset()` with `eraseBleBonds=false`) → **preserves** the X25519 private key in `installDefaultConfig(preserveKey=true)`; the public key is zeroed and gets rebuilt from the preserved private key on the next boot via the NodeDB path's `regeneratePublicKey` call. Identity is preserved and the mesh does not need to re-exchange keys.
- **`region=UNSET → valid region`** → `ensurePkiKeys` runs inside the same `handleSetConfig` path; missing keys get generated at that moment.
- **Ham mode transitions** — entering Ham mode (`user.is_licensed=true`) runs `Channels::ensureLicensedOperation`, which **wipes every channel PSK** (all traffic becomes cleartext) and disables the legacy admin channel. The X25519 private key is preserved on the device but not used because `Router::send` skips PKI when `owner.is_licensed` is true. Leaving Ham mode re-enables PKI with the preserved keypair but does not restore the wiped channel PSKs — the operator has to re-set them.
- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast.
- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo.
## Project Structure
```
@@ -80,7 +145,7 @@ firmware/
│ │ ├── NodeDB.* # Node database management
│ │ ├── Router.* # Packet routing
│ │ ├── Channels.* # Channel management
│ │ ├── CryptoEngine.* # AES-CCM encryption
│ │ ├── CryptoEngine.* # AES-CTR (channels) + X25519 ECDH→AES-256-CCM (PKI for DMs/admin)
│ │ ├── *Interface.* # Radio interface implementations
│ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI)
│ │ ├── http/ # HTTP server (WebServer, ContentHandler)
@@ -131,6 +196,8 @@ firmware/
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
- Use `assert()` for invariants that should never fail
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
### Naming Conventions
@@ -296,6 +363,23 @@ Key defines in variant.h:
## Build System
## Agent Tooling Baseline
Mirror counterpart: `AGENTS.md` under **Agent Tooling Baseline**.
To reduce avoidable agent mistakes, assume these tools are available (or install them before significant repo work):
- **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs`
- **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing
- **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`)
- **Containerized native testing**: `docker` (fallback for non-Linux hosts; macOS can also build natively via `pio run -e native-macos`)
Fallback expectations for agents:
- If `rg` is unavailable, use `find` + `grep` instead of failing.
- For native tests on hosts without Linux deps, prefer `./bin/test-native-docker.sh`.
- The simulator helper script is `./bin/test-simulator.sh`.
Uses **PlatformIO** with custom scripts:
- `bin/platformio-pre.py` - Pre-build script
@@ -307,6 +391,7 @@ Build commands:
pio run -e tbeam # Build specific target
pio run -e tbeam -t upload # Build and upload
pio run -e native # Build native/Linux version
pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prereqs in variants/native/portduino/platformio.ini)
```
### Build Manifest
@@ -448,6 +533,8 @@ Run with: `pio test -e native`
Simulation testing: `bin/test-simulator.sh`
Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist).
### Hardware-in-the-loop tests (`mcp-server/tests/`)
Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules.
@@ -474,7 +561,7 @@ The repo registers the server via `.mcp.json` at the repo root — Claude Code p
**One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port.
### MCP tool surface (~32 tools)
### MCP tool surface (43 tools)
Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here.
@@ -482,11 +569,15 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v
- **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps`
- **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close`
- **Device reads**: `device_info`, `list_nodes`
- **Device writes** (all require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api`
- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset`
- **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile`
- **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw`
- **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` + `_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` — grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured.
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset` and `erase_and_flash`.
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`.
**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes".
### Hardware test suite (`mcp-server/run-tests.sh`)
@@ -494,14 +585,16 @@ The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf5
Suite tiers (collected + run in this order via `pytest_collection_modifyitems`):
1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile). No hardware.
1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile, uhubctl parser). No hardware.
2. `tests/test_00_bake.py` — flashes each detected device with current `userPrefs.jsonc` merged with the session's test profile. Has its own skip-if-already-baked check comparing region + primary channel to the session profile; skips cheaply on warm devices.
3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`.
3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. Includes `test_peer_offline_recovery` which uses uhubctl to physically power off one peer mid-conversation (requires uhubctl; skips without).
4. `tests/telemetry/` — `DEVICE_METRICS_APP` broadcast timing.
5. `tests/monitor/` — boot-log panic check.
6. `tests/fleet/` — PSK seed session isolation.
7. `tests/admin/` — channel URL roundtrip, owner persistence across reboot.
8. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset.
6. `tests/recovery/` — `uhubctl` power-cycle round-trip + NVS persistence across hard reset. Requires `uhubctl` installed and a PPPS-capable hub; entire tier auto-skips otherwise.
7. `tests/ui/` — input-broker-driven screen navigation with camera + OCR evidence.
8. `tests/fleet/` — PSK seed session isolation.
9. `tests/admin/` — channel URL roundtrip, owner persistence across reboot.
10. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset.
Invocation patterns:
@@ -586,15 +679,19 @@ If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` fl
### Recovery playbooks
| Symptom | First check | Fix |
| ---------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. |
| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. |
| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. |
| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. |
| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. |
| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. |
| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. |
| Symptom | First check | Fix |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. |
| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. |
| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. |
| Device fully wedged (Guru Meditation, frozen CDC, no DFU) | `list_devices` shows the VID but every admin call times out | `uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles the port via USB hub PPPS. `baked_single`'s auto-recovery hook does this once automatically if uhubctl is installed. Falls back to physical replug if no PPPS hub. |
| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. |
| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. |
| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. |
| Entire `tests/recovery/` tier skipped | `command -v uhubctl` | Expected if `uhubctl` isn't on PATH. Install via `brew install uhubctl` (macOS) or `apt install uhubctl` (Debian/Ubuntu). Also skips if no hub advertises PPPS. |
| Entire `tests/ui/` tier skipped ("firmware not baked with USERPREFS_UI_TEST_LOG") | reportlog.jsonl for the skip reason | Re-run with `--force-bake` so the UI-log macro gets compiled into the fresh firmware. First run after the Round-3 landing always re-bakes. |
| `tests/ui/` runs but captures are all 1×1 black PNGs | `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3` | Env var not set → `NullBackend`. Point a USB webcam at the heltec-v3 OLED and set the device index; `.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"` discovers it. |
| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. |
### Never do these without asking
+12 -5
View File
@@ -26,7 +26,12 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
- `get_config(section="lora", port=<p>)` → region, preset, channel_num, tx_power, hop_limit
- If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=<p>, env=<inferred>)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_<ROLE>` env var overrides it.
4. **Render per-device report** as a compact block:
4. **Hub health** (call once, not per-device): `uhubctl_list()` — enumerates every USB hub the host sees. Cross-reference each Meshtastic device's VID to find which hub + port it's on. Flag in the report if:
- No hub advertises `ppps=true` → `tests/recovery/` can't run; hard-recovery via `uhubctl_cycle` isn't available.
- A Meshtastic device is on a non-PPPS hub → note it; moving to a PPPS hub unlocks auto-recovery.
- `uhubctl_list` raises `ConfigError: uhubctl not found` → report as "uhubctl not installed"; don't treat as a device fault.
5. **Render per-device report** as a compact block:
```text
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
@@ -35,20 +40,22 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
tx_power : 30 dBm, hop_limit=3
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
primary ch : McpTest
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
firmware : no panics in last 3s
```
Flag abnormalities inline with `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, etc.
Flag abnormalities inline with `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc.
5. **Cross-device correlation** (when >1 device selected):
6. **Cross-device correlation** (when >1 device selected):
- Do both see each other in `nodesByNum`?
- Do `region`, `channel_num`, `modem_preset` match across devices?
- Do the primary channel names match? (Different name → different PSK → no decode.)
6. **Suggest next steps only for recognizable failure modes**, never speculatively:
7. **Suggest next steps only for recognizable failure modes**, never speculatively:
- Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` — the test's retry+nodeinfo-ping heals this."
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
- Device unreachable → refer operator to the touch_1200bps + CP2102-wedged-driver notes in `run-tests.sh`.
- Device unreachable, DFU reachable → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds and the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
- CP2102-wedged-driver on macOS → see `run-tests.sh` notes.
## Hard constraints
+2 -1
View File
@@ -46,7 +46,8 @@ Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one
- **LoRa airtime collision** — pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body.
- **PKI key staleness** — first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix.
- **NodeInfo cooldown** — `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup.
- **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc.
- **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. For a device wedged past `touch_1200bps`, recommend `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed).
- **Device went dark mid-run** — fails from some iteration onward and never recovers; firmware log stops arriving. Almost always a Guru crash with frozen CDC. Recommend `uhubctl_cycle` before the next iteration; escalate to replug if that also fails.
- **Unknown** — say so. Don't invent a root cause.
7. **Report back** with:
+9 -3
View File
@@ -21,19 +21,25 @@ Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md
2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only — call that out explicitly so the operator knows hardware tiers were skipped.
3. **On pass**: one-line summary like `N passed, M skipped in <duration>`. Don't enumerate test names. DO mention any non-placeholder SKIPs (things like "role not present on hub") because they indicate missing hardware or setup issues.
3. **On pass**: one-line summary like `N passed, M skipped in <duration>`. Don't enumerate test names. DO mention any non-placeholder SKIPs and name the cause:
- `"role not present on hub"` → device unplugged; operator should reconnect.
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped; the UI-log compile macro isn't in the baked firmware. Suggest `--force-bake`.
- `"uhubctl not installed"` → tests/recovery + `test_peer_offline_recovery` skipped. Suggest `brew install uhubctl` / `apt install uhubctl`.
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the attached hub doesn't support per-port power switching; won't run on that setup.
- `"opencv-python-headless is not installed"` → tests/ui auto-deselected by `run-tests.sh`. Suggest `pip install -e 'mcp-server/.[ui]'`.
4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise:
- test name
- one-line assertion message
- the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`)
- for UI-tier failures also check `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` (per-step frame + OCR)
5. **Classify each failure** as one of:
- **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro <test-id>` to confirm.
- **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest specific recovery (USB replug, `touch_1200bps`, `git status userPrefs.jsonc`).
- **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest recovery in escalation order: (a) replug USB, (b) `touch_1200bps` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role=..., confirm=True)` for a device wedged past DFU (needs `uhubctl` installed; `baked_single` does this once automatically when available). Also check `git status userPrefs.jsonc`.
- **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible.
6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval.
6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval.
## Arguments convention
+1 -1
View File
@@ -118,7 +118,7 @@ CallbackObserver<MyModule, const meshtastic::Status *> statusObserver =
Add test suite in `test/test_mymodule/`:
```
```text
test/
└── test_mymodule/
└── test_main.cpp
+1 -1
View File
@@ -6,7 +6,7 @@ Guide for adding a new Meshtastic hardware variant to the firmware.
Create under `variants/<arch>/<name>/`:
```
```text
variants/
├── esp32/ # ESP32
├── esp32s3/ # ESP32-S3
+8 -3
View File
@@ -32,10 +32,15 @@ jobs:
shell: bash
working-directory: meshtasticd
run: |
# Build-tools (notably platformio) come from the Meshtastic project
# on the OpenSUSE Build Service:
# https://build.opensuse.org/project/show/network:Meshtastic:build-tools
echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/build-tools/xUbuntu_24.04/ /' \
| sudo tee /etc/apt/sources.list.d/network:Meshtastic:build-tools.list
curl -fsSL https://download.opensuse.org/repositories/network:Meshtastic:build-tools/xUbuntu_24.04/Release.key \
| gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/network_Meshtastic_build-tools.gpg >/dev/null
sudo apt-get update -y --fix-missing
sudo apt-get install -y software-properties-common build-essential devscripts equivs
sudo add-apt-repository ppa:meshtastic/build-tools -y
sudo apt-get update -y --fix-missing
sudo apt-get install -y build-essential devscripts equivs
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
- name: Import GPG key
+51
View File
@@ -0,0 +1,51 @@
name: Build MacOS Binary
on:
workflow_call:
inputs:
macos_ver:
required: false
default: "26" # ARM64
type: string
permissions:
contents: read
jobs:
build-MacOS:
runs-on: macos-${{ inputs.macos_ver }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install deps
shell: bash
run: |
brew update
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius
- name: Get release version string
run: |
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
id: version
- name: Build for MacOS
run: |
platformio run -e native-macos
env:
PKG_VERSION: ${{ steps.version.outputs.long }}
# Errors in this step should not fail the entire workflow while MacOS support is in development.
continue-on-error: true
- name: List output files
run: ls -lah .pio/build/native-macos/
- name: Store binaries as an artifact
uses: actions/upload-artifact@v7
with:
name: firmware-macos-${{ inputs.macos_ver }}-${{ steps.version.outputs.long }}
overwrite: true
path: |
.pio/build/native-macos/meshtasticd
+3 -1
View File
@@ -73,7 +73,9 @@ jobs:
- name: Sanitize platform string
id: sanitize_platform
# Replace slashes with underscores
run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
env:
plat: ${{ inputs.platform }}
run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
- name: Docker login
if: ${{ inputs.push }}
+22
View File
@@ -43,6 +43,15 @@ jobs:
push: true
secrets: inherit
docker-debian-riscv64:
uses: ./.github/workflows/docker_build.yml
with:
distro: debian
platform: linux/riscv64
runs-on: ubuntu-24.04-arm
push: true
secrets: inherit
docker-alpine-amd64:
uses: ./.github/workflows/docker_build.yml
with:
@@ -70,16 +79,27 @@ jobs:
push: true
secrets: inherit
docker-alpine-riscv64:
uses: ./.github/workflows/docker_build.yml
with:
distro: alpine
platform: linux/riscv64
runs-on: ubuntu-24.04-arm
push: true
secrets: inherit
docker-manifest:
needs:
# Debian
- docker-debian-amd64
- docker-debian-arm64
- docker-debian-armv7
- docker-debian-riscv64
# Alpine
- docker-alpine-amd64
- docker-alpine-arm64
- docker-alpine-armv7
- docker-alpine-riscv64
runs-on: ubuntu-24.04
steps:
- name: Checkout code
@@ -162,6 +182,7 @@ jobs:
meshtastic/meshtasticd@${{ needs.docker-debian-amd64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-debian-arm64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-debian-armv7.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-debian-riscv64.outputs.digest }}
- name: Docker meta (Alpine)
id: meta_alpine
@@ -182,3 +203,4 @@ jobs:
meshtastic/meshtasticd@${{ needs.docker-alpine-amd64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-alpine-arm64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-alpine-armv7.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-alpine-riscv64.outputs.digest }}
+15
View File
@@ -116,6 +116,20 @@ jobs:
build_location: local
secrets: inherit
MacOS:
strategy:
fail-fast: false
matrix:
macos_ver:
- "26" # ARM64
# - '26-intel' # x86_64
- "15" # ARM64
# - '15-intel' # x86_64
uses: ./.github/workflows/build_macos_bin.yml
with:
macos_ver: ${{ matrix.macos_ver }}
# secrets: inherit
package-pio-deps-native-tft:
if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/package_pio_deps.yml
@@ -286,6 +300,7 @@ jobs:
- gather-artifacts
- build-debian-src
- package-pio-deps-native-tft
# - MacOS
steps:
- name: Checkout
uses: actions/checkout@v6
+9 -9
View File
@@ -4,22 +4,22 @@ cli:
plugins:
sources:
- id: trunk
ref: v1.7.6
ref: v1.9.0
uri: https://github.com/trunk-io/plugins
lint:
enabled:
- checkov@3.2.517
- renovate@43.110.9
- prettier@3.8.1
- trufflehog@3.94.3
- checkov@3.2.528
- renovate@43.150.0
- prettier@3.8.3
- trufflehog@3.95.2
- yamllint@1.38.0
- bandit@1.9.4
- trivy@0.69.3
- trivy@0.70.0
- taplo@0.10.0
- ruff@0.15.9
- ruff@0.15.12
- isort@8.0.1
- markdownlint@0.48.0
- oxipng@10.1.0
- oxipng@10.1.1
- svgo@4.0.1
- actionlint@1.7.12
- flake8@7.3.0
@@ -36,7 +36,7 @@ lint:
- bin/**
runtimes:
enabled:
- python@3.10.8
- python@3.14.4
- go@1.21.0
- node@22.16.0
actions:
+57 -26
View File
@@ -10,17 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
## Quick command reference
| Action | Command |
| -------------------------------- | ----------------------------------------------------------------------------------- |
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
| Run firmware unit tests (native) | `pio test -e native` |
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
| Format before commit | `trunk fmt` |
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
| Action | Command |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
| Run firmware unit tests (native) | `pio test -e native` |
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
| Format before commit | `trunk fmt` |
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
## MCP server (device + test automation)
@@ -48,6 +49,15 @@ Three test-and-diagnose workflows exist as slash commands:
Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index.
## Encryption at a glance
Two layers, both in `src/mesh/CryptoEngine.cpp`:
- **Channel (symmetric)** — **AES-CTR** with a channel-wide PSK (AES-128 or AES-256). Nonce = packet_id ‖ from_node ‖ block_counter. No AEAD; integrity is soft (channel-hash filter). The well-known default PSK lives in `src/mesh/Channels.h`; a 1-byte PSK is a short-form index into it.
- **Per-peer PKI** — **X25519 ECDH** (Curve25519, 32-byte keys) → SHA-256 → **AES-256-CCM** with an 8-byte MAC. Fresh 32-bit `extraNonce` per packet, sent in the clear alongside the MAC. 12-byte wire overhead (`MESHTASTIC_PKC_OVERHEAD`). Used for DMs. Also used for remote admin (`src/modules/AdminModule.cpp`), where AdminMessage authorization is gated by `config.security.admin_key[0..2]`. Disabled entirely in Ham mode (`user.is_licensed=true`).
Key rotation to never trigger casually: only the **full** factory reset (`factory_reset_device`, `eraseBleBonds=true`) wipes `security.private_key` and regenerates the keypair — every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The **partial** config reset (`factory_reset_config`) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking `security.private_key` via admin also triggers regen. See the **Encryption & Key Management** section of `.github/copilot-instructions.md` for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, `is_managed` scope, key-rotation hazards).
## House rules
- **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes.
@@ -56,6 +66,8 @@ Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
## Typical agent workflows
@@ -89,25 +101,44 @@ Sequence these; don't parallelize on the same port.
## Where to look
| Path | What's there |
| --------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) |
| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI |
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `fleet/`, `admin/`, `provisioning/` |
| `.claude/commands/` | Claude Code slash command bodies |
| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) |
| `.github/copilot-instructions.md` | **Primary agent instructions — read this** |
| `.github/workflows/` | CI pipelines |
| `.mcp.json` | MCP server registration for Claude Code |
| Path | What's there |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) |
| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI |
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` |
| `.claude/commands/` | Claude Code slash command bodies |
| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) |
| `.github/copilot-instructions.md` | **Primary agent instructions — read this** |
| `.github/workflows/` | CI pipelines |
| `.mcp.json` | MCP server registration for Claude Code |
## Recovery one-liners
- **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`.
- **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs.
- **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access.
- **Port busy?** `lsof <port>` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it.
- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned.
- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b <bundleID>`. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit.
## Environment variables (test harness)
| Var | Purpose |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MESHTASTIC_MCP_ENV_<ROLE>` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. |
| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp-<user>-<host>`. |
| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. |
| `MESHTASTIC_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. |
| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. |
| `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. |
| `MESHTASTIC_UHUBCTL_PORT_<ROLE>` | Pin a role to a specific hub port number. Required alongside `LOCATION_<ROLE>`. |
| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). |
| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. |
| `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). |
| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). |
| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. |
+3 -1
View File
@@ -3,15 +3,17 @@
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
FROM python:3.14-slim-trixie AS builder
FROM debian:trixie AS builder
ARG PIO_ENV=native
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Etc/UTC
# Install Dependencies
ENV PIP_ROOT_USER_ACTION=ignore
ENV PIP_BREAK_SYSTEM_PACKAGES=1
RUN apt-get update && apt-get install --no-install-recommends -y \
curl wget g++ zip git ca-certificates pkg-config \
python3-pip python3-grpc-tools \
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
+10 -3
View File
@@ -3,12 +3,19 @@
# trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
FROM python:3.14-alpine3.22 AS builder
# Ensure the Alpine version is updated in both stages of the container!
FROM alpine:3.23 AS builder
ARG PIO_ENV=native
ENV PIP_ROOT_USER_ACTION=ignore
# Enable Alpine community repository (for 'py3-grpcio-tools')
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v$(cut -d. -f1,2 /etc/alpine-release)/community" >> /etc/apk/repositories
# Install Dependencies
ENV PIP_ROOT_USER_ACTION=ignore
ENV PIP_BREAK_SYSTEM_PACKAGES=1
RUN apk --no-cache add \
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
py3-pip py3-grpcio-tools \
libgpiod-dev yaml-cpp-dev bluez-dev \
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
@@ -60,4 +67,4 @@ EXPOSE 4403
CMD [ "sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd" ]
HEALTHCHECK NONE
HEALTHCHECK NONE
+2 -1
View File
@@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION
pio pkg install --environment "$PIO_ENV" || platformioFailed
pio run --environment "$PIO_ENV" || platformioFailed
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)"
os_name=$(uname -s | tr '[:upper:]' '[:lower:]')
cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)"
cp bin/native-install.* $OUTDIR/
@@ -0,0 +1,31 @@
# For use with Armbian luckfox-pico-max
# Waveshare LoRa HAT for Raspberry Pi Pico
# https://www.waveshare.com/wiki/Pico-LoRa-SX1262
Meta:
name: luckfox-pico-max-ws-raspberry-pi-pico-hat
support: community
compatible:
- luckfox-pico-max # Armbian
Lora:
Module: sx1262
DIO2_AS_RF_SWITCH: true
DIO3_TCXO_VOLTAGE: true
spidev: spidev0.0
Busy: # GPIO1_C7 / GP2
pin: 55
gpiochip: 1
line: 23
CS: # GPIO1_C6 / GP3
pin: 54
gpiochip: 1
line: 22
Reset: # GPIO1_D1 / GP15
pin: 57
gpiochip: 1
line: 25
IRQ: # GPIO2_A2 / GP20
pin: 66
gpiochip: 2
line: 2
@@ -87,6 +87,9 @@
</screenshots>
<releases>
<release version="2.7.24" date="2026-05-08">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24</url>
</release>
<release version="2.7.23" date="2026-04-14">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23</url>
</release>
+118
View File
@@ -0,0 +1,118 @@
#!/bin/bash
# Script to show commits in develop that are not in master
# with their associated PR info and commit hashes
#
# Usage:
# ./show-unmerged-prs.sh # Show all unmerged commits
# ./show-unmerged-prs.sh --bugfix # Show only bugfix-labeled PRs
set -e
REPO="firmware"
OWNER="meshtastic"
BASE_BRANCH="master"
HEAD_BRANCH="develop"
LIMIT=100
FILTER_LABEL=""
# Parse arguments
for arg in "$@"; do
case $arg in
--bugfix)
FILTER_LABEL="bugfix"
shift
;;
--feature)
FILTER_LABEL="feature"
shift
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --bugfix Show only PRs labeled with 'bugfix'"
echo " --feature Show only PRs labeled with 'feature'"
echo " --help Show this help message"
exit 0
;;
esac
done
if [ -n "$FILTER_LABEL" ]; then
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH (filtered by label: $FILTER_LABEL)..."
else
echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH..."
fi
echo ""
# Check if gh CLI is available
if ! command -v gh &> /dev/null; then
echo "ERROR: GitHub CLI (gh) not found. Please install it first."
echo "Visit: https://cli.github.com/"
exit 1
fi
# Get commits in develop that are not in master
# For each commit, try to find associated PR
git fetch origin develop master 2>/dev/null || true
# Use git to get the list of commits
commits=$(git log --pretty=format:"%H|%s" origin/master..origin/develop | head -n $LIMIT)
count=0
displayed=0
echo "Commits in $HEAD_BRANCH not in $BASE_BRANCH:"
echo "=============================================="
echo ""
while IFS='|' read -r hash subject; do
((count++))
# Try to find the PR for this commit
# Extract PR number, title, description, and labels
pr_response=$(gh api -X GET "/repos/$OWNER/$REPO/commits/$hash/pulls" \
-H "Accept: application/vnd.github.v3+json" 2>/dev/null | \
jq -r '.[0] | "\(.number)|\(.title)|\(.body // "No description")|\(.labels | map(.name) | join(","))"' 2>/dev/null || echo "||||")
if [ -z "$pr_response" ] || [ "$pr_response" = "||||" ]; then
# If no PR found, skip if filter is active, otherwise show the commit
if [ -z "$FILTER_LABEL" ]; then
((displayed++))
echo "[$displayed] Commit: $hash"
echo " Subject: $subject"
echo " PR: Not found in GitHub"
echo ""
fi
else
IFS='|' read -r pr_num pr_title pr_desc pr_labels <<< "$pr_response"
# Check if filter matches
if [ -n "$FILTER_LABEL" ]; then
# Only show if the label is in the labels list
if ! echo "$pr_labels" | grep -q "$FILTER_LABEL"; then
continue
fi
fi
((displayed++))
echo "[$displayed] PR #$pr_num - $pr_title"
echo " Commit: $hash"
if [ -n "$pr_desc" ] && [ "$pr_desc" != "No description" ]; then
# Truncate description to 200 chars
desc_short="${pr_desc:0:200}"
[ ${#pr_desc} -gt 200 ] && desc_short+="..."
echo " Description: $desc_short"
fi
if [ -n "$pr_labels" ] && [ "$pr_labels" != "" ]; then
echo " Labels: $pr_labels"
fi
echo ""
fi
done <<< "$commits"
echo ""
if [ -n "$FILTER_LABEL" ]; then
echo "Done. Showing $displayed PRs with label '$FILTER_LABEL' from $displayed commits checked."
else
echo "Done. Showing $displayed commits from $HEAD_BRANCH not in $BASE_BRANCH."
fi
+42
View File
@@ -0,0 +1,42 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-D BOARD_HAS_PSRAM",
"-D ARDUINO_USB_CDC_ON_BOOT=0",
"-D ARDUINO_USB_MODE=0",
"-D ARDUINO_RUNNING_CORE=1",
"-D ARDUINO_EVENT_RUNNING_CORE=0"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "qio_opi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "ELECROW-ThinkNode-M7"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "ELECROW ThinkNode M7",
"upload": {
"flash_size": "8MB",
"maximum_ram_size": 524288,
"maximum_size": 8388608,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"require_upload_port": true,
"speed": 921600
},
"url": "https://www.elecrow.com",
"vendor": "ELECROW"
}
+43
View File
@@ -0,0 +1,43 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "default_16MB.csv",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "opi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "heltec_v4_r8"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"require_upload_port": true,
"speed": 921600
},
"url": "https://heltec.org/",
"vendor": "heltec"
}
+6
View File
@@ -1,3 +1,9 @@
meshtasticd (2.7.24.0) unstable; urgency=medium
* Version 2.7.24
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Fri, 08 May 2026 10:44:12 +0000
meshtasticd (2.7.23.0) unstable; urgency=medium
* Version 2.7.23
+1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/bash
set -e
export DEBEMAIL="jbennett@incomsystems.biz"
export PLATFORMIO_LIBDEPS_DIR=pio/libdeps
export PLATFORMIO_PACKAGES_DIR=pio/packages
+9
View File
@@ -7,6 +7,12 @@ __pycache__/
dist/
build/
# Persistent device-log capture (recorder + Datadog cursor).
# Cross-session JSONL streams written by the autouse Recorder singleton
# (see src/meshtastic_mcp/recorder/). Lives outside tests/ so the pytest
# fixture truncate doesn't touch it.
.mtlog/
# Test harness artifacts
tests/report.html
tests/junit.xml
@@ -24,3 +30,6 @@ tests/.tui-runs
tests/.history/
# Reproducer bundles (TUI `x` export on failed tests).
tests/reproducers/
# UI-tier camera captures + per-test transcripts. Regenerated every run;
# left on disk for human review between runs.
tests/ui_captures/
+153 -11
View File
@@ -61,7 +61,7 @@ Replace `<firmware-repo>` with the absolute path, e.g. `/Users/you/GitHub/firmwa
Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
## Tools (38)
## Tools (43)
### Discovery & metadata
@@ -130,6 +130,34 @@ _The tool tables below document 38 currently registered MCP server tools._
| `picotool_load` | Load a UF2 |
| `picotool_raw` | Pass-through |
### USB power control (uhubctl)
| Tool | What it does |
| --------------- | ----------------------------------------------------------- |
| `uhubctl_list` | Enumerate USB hubs + attached-device VID/PID (read-only) |
| `uhubctl_power` | Drive a hub port `on` or `off`; `off` requires confirm=True |
| `uhubctl_cycle` | Off → wait `delay_s` → on; confirm=True required |
Target a port by explicit `(location, port)` (raw uhubctl syntax like
`location="1-1.3", port=2`) or by `role` (`"nrf52"`, `"esp32s3"`). Role
lookup checks `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
`MESHTASTIC_UHUBCTL_PORT_<ROLE>` env vars first, then auto-detects via VID
against `uhubctl`'s output.
Requires [`uhubctl`](https://github.com/mvp/uhubctl) on PATH:
```bash
brew install uhubctl # macOS
apt install uhubctl # Debian/Ubuntu
```
Modern macOS + PPPS-capable hubs generally work without root. On Linux
without udev rules, or on old macOS with driver quirks, you may need
`sudo`. If uhubctl returns a permission error the MCP tool raises a
clear `UhubctlError` pointing at the
[udev-rules / sudo fallback](https://github.com/mvp/uhubctl#linux-usb-permissions)
rather than auto-`sudo`'ing mid-run.
## Safety
- **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude.
@@ -138,15 +166,73 @@ _The tool tables below document 38 currently registered MCP server tools._
## Environment variables
| Var | Default | Purpose |
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
| Var | Default | Purpose |
| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
## TCP / native-host nodes
The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd`
binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino).
The daemon exposes the meshtastic TCP API on port `4403` rather than a USB
serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`:
```bash
# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini
# for full Homebrew prereqs and CH341 LoRa-adapter setup).
pio run -e native-macos
~/.meshtasticd/meshtasticd
# 2. Point the MCP server at it.
export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403
```
**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the
USB adapter's serial-number / product strings. Many cheap CH341 dongles
(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0`
and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address
not allowed!`. Set the MAC explicitly in `config.yaml`:
```yaml
# Under General:
MACAddress: 02:CA:FE:BA:BE:01
```
Use a locally-administered address (first byte's second-LSB set, e.g.
`02:*` / `06:*` / `0A:*` / `0E:*`) to avoid colliding with a real OUI.
There is also a `--hwid AA:BB:CC:DD:EE:FF` CLI flag visible in
`meshtasticd --help`, but it is **currently broken** in
`MAC_from_string()` (`src/platform/portduino/PortduinoGlue.cpp`): the
function strips colons from its parameter but then reads bytes from the
global `portduino_config.mac_address`, so `--hwid` is silently overridden
when `MACAddress:` is also set, and crashes the daemon (uncaught
`std::invalid_argument: stoi: no conversion`) when it isn't. Use the YAML
form until that's fixed upstream.
`list_devices` will surface the daemon as `tcp://localhost:4403` with
`likely_meshtastic=True`, so `device_info`, `list_nodes`, `get_config`,
`set_config`, `set_owner`, `send_text`, `userprefs_*`, and the admin RPCs
auto-select it when no `port` is passed. Pass `port="tcp://other-host:9999"`
explicitly to target a different daemon.
**Tools that don't apply to a TCP/native node** (no USB hardware to operate
on) raise a clear `ConnectionError` rather than failing mysteriously:
`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`,
`serial_open` (use info/admin tools directly), and the vendor escape hatches
`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env
similarly raises — there's no upload step; use `build` and run the binary
directly.
The pytest harness in `tests/` still assumes USB-attached devices per role —
TCP-aware fixtures are not part of this surface yet.
## Hardware Test Suite
@@ -182,10 +268,22 @@ in the pre-flight header.
- **`unit`** — pure Python, no hardware. boards / PIO wrapper /
userPrefs-parse / testing-profile fixtures.
- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK,
traceroute, bidirectional. Parametrized over both directions.
traceroute, bidirectional. Parametrized over both directions. Includes
`test_peer_offline_recovery` which uses uhubctl to power-cycle one peer
mid-conversation and verifies the mesh recovers (skips without uhubctl).
- **`telemetry`** — periodic telemetry broadcast + on-demand request/reply
(`TELEMETRY_APP` with `wantResponse=True`).
- **`monitor`** — boot log has no panic markers within 60 s of reboot.
- **`recovery`** — `uhubctl` power-cycle round-trip: verifies the hub port
can be toggled off/on, the device re-enumerates with the same
`my_node_num`, and NVS-resident config (region, channel, modem preset)
survives a hard reset. Requires `uhubctl` on PATH; skips cleanly otherwise.
- **`ui`** — input-broker-driven screen navigation (`AdminMessage.send_input_event`
injection → `Screen::handleInputEvent` → frame transition). Parametrized
on the screen-bearing role (heltec-v3 OLED). Captures images via USB
webcam + OCRs them for HTML-report evidence. Requires `pip install -e '.[ui]'`
and `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=<index>`; tier is auto-deselected
if `cv2` isn't importable.
- **`fleet`** — PSK-seed isolation: two labs with different seeds never
overlap.
- **`admin`** — owner persistence across reboot, channel URL round-trip,
@@ -193,6 +291,42 @@ in the pre-flight header.
- **`provisioning`** — region/channel baking, userPrefs survive
`factory_reset(full=False)`.
#### UI tier setup
The `tests/ui/` tier drives the on-device OLED via the firmware's existing
`AdminMessage.send_input_event` RPC (no firmware changes required) and
verifies transitions via a macro-gated log line + camera + OCR. Summary:
1. Install extras: `pip install -e 'mcp-server/.[ui]'` — pulls in
`opencv-python-headless`, `numpy`, `easyocr`, `Pillow`. First easyocr
run downloads ~100 MB of models to `~/.EasyOCR/`; an autouse session
fixture pre-warms the reader so per-test OCR is <100 ms after that.
2. Point a USB webcam at the heltec-v3 OLED. Discover its index:
```bash
.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"
```
3. Export the per-role device env var:
```bash
export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0
```
4. Run:
```bash
./run-tests.sh tests/ui -v
```
Captures land under `tests/ui_captures/<session_seed>/<test_id>/`, one
PNG + `.ocr.txt` per `frame_capture()` call, with a per-test
`transcript.md` stepping through event → frame → OCR. The HTML report
embeds the full image strip inline (pass or fail).
On macOS, `cv2.VideoCapture(0)` triggers the TCC Camera permission prompt
on first use. Pre-grant Terminal (or your IDE's terminal) before running.
The `OpenCVBackend` fails fast on 10 consecutive black frames so a silent
permission denial surfaces as a clear error, not an empty PNG strip.
No camera? Set `MESHTASTIC_UI_CAMERA_BACKEND=null` (or leave the device var
unset). Tests still exercise the event-injection path and log assertions;
captures just become 1×1 black PNGs.
### Artifacts (regenerated every run, under `tests/`)
- `report.html` — self-contained pytest-html report. Each test gets a
@@ -217,6 +351,14 @@ Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
quit (SIGINT → SIGTERM → SIGKILL escalation).
Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera**
panel. Left side: the latest capture PNG rendered as Unicode half-blocks
(via `rich-pixels`, works in any terminal — no kitty/sixel required).
Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes
— OCR: Nodes 2/2") so you can see every event-injection and its result
as each UI test runs. Requires the `[ui]` extras for image rendering; the
transcript alone works without them.
### Slash commands
Three AI-assisted workflows are wired up for Claude Code operators
+15
View File
@@ -23,6 +23,21 @@ test = [
# consumers; revisit if install cost pushes back.
"textual>=0.50",
]
# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR
# model alone is ~100 MB and camera hardware is user-supplied.
# pip install -e '.[ui]' — full (OpenCV + easyocr)
# pip install -e '.[ui-min]' — image capture only, no OCR
ui = [
"opencv-python-headless>=4.9",
"numpy>=1.26",
"easyocr>=1.7",
"Pillow>=10.0",
# Renders the latest camera capture as Unicode half-blocks in the TUI
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel
# dependency. Pure Python, tiny.
"rich-pixels>=3.0",
]
ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"]
[project.scripts]
meshtastic-mcp = "meshtastic_mcp.__main__:main"
+34
View File
@@ -217,6 +217,40 @@ if [[ $# -eq 0 ]]; then
-v --tb=short
fi
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
# not installed, auto-deselect tests/ui so operators without the [ui]
# extra still get a green run. Printed in yellow; silent when cv2 is
# present.
_cv2_ok=0
if "$VENV_PY" -c "import cv2" >/dev/null 2>&1; then
_cv2_ok=1
fi
_running_ui=0
for _arg in "$@"; do
case "$_arg" in
*tests/ui* | tests/) _running_ui=1 ;;
*) ;;
esac
done
if [[ $_running_ui -eq 1 && $_cv2_ok -eq 0 ]]; then
printf '\033[33m[pre-flight] tests/ui tier detected, but opencv-python-headless is not installed — deselecting.\033[0m\n'
printf ' install with: .venv/bin/pip install -e "mcp-server/.[ui]"\n'
echo
set -- "$@" --ignore=tests/ui
fi
# Recovery tier needs `uhubctl` on PATH — it power-cycles devices via USB
# hub PPPS. The tier's conftest already skips cleanly, so this is just a
# friendly heads-up before the skip happens. `baked_single`'s auto-
# recovery hook also benefits from having uhubctl available across the
# whole suite.
if ! command -v uhubctl >/dev/null 2>&1; then
printf "\033[33m[pre-flight] uhubctl not found on PATH — recovery tier will skip, and\n"
printf " wedged-device auto-recovery is disabled.\033[0m\n"
printf " install with: brew install uhubctl (macOS) or apt install uhubctl (Debian/Ubuntu).\n"
echo
fi
# Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed
# their own `--report-log=...`). Consumers — notably the
# `meshtastic-mcp-test-tui` TUI — tail the reportlog for live per-test state.
+217
View File
@@ -0,0 +1,217 @@
{
"title": "Meshtastic Firmware — Recorder Stream",
"description": "Live view of `.mtlog/` streams shipped by `mtlog_to_datadog.py`. Heap, packet volume, log levels, errors. One row per port.",
"widgets": [
{
"definition": {
"title": "Free heap (bytes)",
"type": "timeseries",
"show_legend": true,
"requests": [
{
"queries": [
{
"name": "free_heap",
"data_source": "metrics",
"query": "avg:mesh.local.heap_free_bytes{service:meshtastic-firmware} by {port}"
}
],
"response_format": "timeseries",
"display_type": "line"
}
],
"yaxis": { "label": "bytes" }
}
},
{
"definition": {
"title": "Heap slope (bytes/min) — last 1h",
"type": "query_value",
"precision": 0,
"requests": [
{
"queries": [
{
"name": "slope",
"data_source": "metrics",
"query": "derivative(avg:mesh.local.heap_free_bytes{service:meshtastic-firmware})",
"aggregator": "avg"
}
],
"response_format": "scalar"
}
],
"conditional_formats": [
{ "comparator": "<", "value": -100, "palette": "white_on_red" },
{ "comparator": "<", "value": 0, "palette": "white_on_yellow" },
{ "comparator": ">=", "value": 0, "palette": "white_on_green" }
]
}
},
{
"definition": {
"title": "Total heap (bytes)",
"type": "timeseries",
"requests": [
{
"queries": [
{
"name": "total_heap",
"data_source": "metrics",
"query": "avg:mesh.local.heap_total_bytes{service:meshtastic-firmware} by {port}"
}
],
"response_format": "timeseries",
"display_type": "line"
}
]
}
},
{
"definition": {
"title": "Battery level (%)",
"type": "timeseries",
"requests": [
{
"queries": [
{
"name": "battery",
"data_source": "metrics",
"query": "avg:mesh.device.battery_level{service:meshtastic-firmware} by {port}"
}
],
"response_format": "timeseries",
"display_type": "line"
}
],
"yaxis": { "min": "0", "max": "105" }
}
},
{
"definition": {
"title": "Air utilization (TX %)",
"type": "timeseries",
"requests": [
{
"queries": [
{
"name": "airutil",
"data_source": "metrics",
"query": "avg:mesh.device.air_util_tx{service:meshtastic-firmware} by {port}"
}
],
"response_format": "timeseries",
"display_type": "line"
}
]
}
},
{
"definition": {
"title": "Channel utilization (%)",
"type": "timeseries",
"requests": [
{
"queries": [
{
"name": "chutil",
"data_source": "metrics",
"query": "avg:mesh.device.channel_utilization{service:meshtastic-firmware} by {port}"
}
],
"response_format": "timeseries",
"display_type": "line"
}
]
}
},
{
"definition": {
"title": "Log volume by level",
"type": "timeseries",
"show_legend": true,
"requests": [
{
"response_format": "timeseries",
"display_type": "bars",
"queries": [
{
"name": "log_count",
"data_source": "logs",
"indexes": ["*"],
"compute": { "aggregation": "count" },
"search": { "query": "service:meshtastic-firmware" },
"group_by": [
{
"facet": "@level",
"limit": 10,
"sort": { "order": "desc", "aggregation": "count" }
}
]
}
]
}
]
}
},
{
"definition": {
"title": "Recent ERROR / CRIT firmware logs",
"type": "list_stream",
"requests": [
{
"response_format": "event_list",
"query": {
"data_source": "logs_stream",
"query_string": "service:meshtastic-firmware (status:error OR @level:ERROR OR @level:CRIT)",
"indexes": [],
"sort": { "column": "timestamp", "order": "desc" }
},
"columns": [
{ "field": "timestamp", "width": "auto" },
{ "field": "host", "width": "auto" },
{ "field": "@port", "width": "auto" },
{ "field": "@level", "width": "auto" },
{ "field": "@thread", "width": "auto" },
{ "field": "message", "width": "stretch" }
]
}
]
}
},
{
"definition": {
"title": "Recorder marker events",
"type": "list_stream",
"requests": [
{
"response_format": "event_list",
"query": {
"data_source": "logs_stream",
"query_string": "service:meshtastic-firmware @level:MARK",
"indexes": [],
"sort": { "column": "timestamp", "order": "desc" }
},
"columns": [
{ "field": "timestamp", "width": "auto" },
{ "field": "host", "width": "auto" },
{ "field": "message", "width": "stretch" }
]
}
]
}
}
],
"template_variables": [
{
"name": "port",
"prefix": "port",
"available_values": [],
"default": "*"
},
{ "name": "host", "prefix": "host", "available_values": [], "default": "*" }
],
"layout_type": "ordered",
"notify_list": [],
"reflow_type": "auto"
}
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Forward selected recorder JSONL streams to Datadog.
Reads `.mtlog/logs.jsonl` and `.mtlog/telemetry.jsonl`, ships logs to the
Logs Intake API and telemetry numerics to the Metrics v2 series API.
Resumes from `.mtlog/.dd-cursor.json` so a daemon restart doesn't
duplicate rows already shipped from the current live files.
This forwarder does not currently backfill rotated `.jsonl.gz` archives.
If the recorder rotates before this process drains the live file, or the
forwarder is down across a rotation, those older rows are skipped.
Usage:
DD_API_KEY=... ./scripts/mtlog_to_datadog.py --tail
./scripts/mtlog_to_datadog.py --once # catch up + exit
./scripts/mtlog_to_datadog.py --since 3600 # backfill last hour from start
Default `DD_SITE` is `us5.datadoghq.com` — the team's Datadog instance.
Override via `DD_SITE=...` env var or `--site` flag for one-offs.
The forwarder is a separate process by design — a Datadog outage or
auth failure must not backpressure the recorder. We exit non-zero on
fatal config errors (missing API key) and keep retrying on transient
network/HTTP errors.
"""
from __future__ import annotations
import argparse
import json
import os
import socket
import sys
import time
from pathlib import Path
from typing import Any, Iterator
try:
import requests
except ImportError:
print(
"requests is required. Install it in the mcp-server venv: "
"uv pip install requests",
file=sys.stderr,
)
sys.exit(2)
_DEFAULT_LOG_DIR = Path(__file__).resolve().parents[1] / ".mtlog"
_LOG_INTAKE_TPL = "https://http-intake.logs.{site}/api/v2/logs"
_METRICS_TPL = "https://api.{site}/api/v2/series"
_LOG_BATCH = 50
_METRICS_BATCH = 100
_MAX_RETRIES = 5
_RETRY_BASE_S = 1.5
# --- streaming JSONL with byte-position cursor -------------------------
class _StreamReader:
"""Reads a single rotating JSONL with cursor-based resume.
This tails only the live `.jsonl` file. The recorder rotates files
(live `.jsonl` → `.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`), which means
the live file shrinks abruptly. We detect that via inode change OR live
size < cursor position, and reset the live-file cursor to 0.
"""
def __init__(self, path: Path, cursor: dict[str, Any]):
self.path = path
self.cursor = cursor
def _state(self) -> tuple[int, int]:
"""Return (inode, size) for the live file. (0, 0) if missing."""
try:
st = self.path.stat()
return (st.st_ino, st.st_size)
except FileNotFoundError:
return (0, 0)
def iter_new_records(self) -> Iterator[dict[str, Any]]:
ino, size = self._state()
last_ino = self.cursor.get("ino")
last_pos = int(self.cursor.get("pos") or 0)
if ino == 0:
return
if last_ino is not None and last_ino != ino:
# Rotation happened. Start over.
last_pos = 0
if last_pos > size:
# Live file truncated/shrunk under us — recorder rotated.
last_pos = 0
try:
with self.path.open("r", encoding="utf-8") as fh:
fh.seek(last_pos)
for line in fh:
line = line.rstrip("\n")
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
last_pos = fh.tell()
except FileNotFoundError:
return
self.cursor["ino"] = ino
self.cursor["pos"] = last_pos
def _load_cursor(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return {}
def _save_cursor(path: Path, data: dict[str, Any]) -> None:
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, separators=(",", ":")))
tmp.replace(path)
# --- Datadog clients ---------------------------------------------------
class _DDSession:
"""Pool one HTTPS session, share retry logic."""
def __init__(self, api_key: str, site: str, hostname: str) -> None:
self.api_key = api_key
self.site = site
self.hostname = hostname
self.session = requests.Session()
self.session.headers.update(
{
"DD-API-KEY": api_key,
"Content-Type": "application/json",
}
)
def _post(self, url: str, payload: Any) -> bool:
for attempt in range(_MAX_RETRIES):
try:
resp = self.session.post(url, json=payload, timeout=30)
except requests.RequestException as e:
_wait_retry(attempt, f"network error: {e}")
continue
if 200 <= resp.status_code < 300:
return True
if resp.status_code in (408, 429, 500, 502, 503, 504):
_wait_retry(
attempt,
f"HTTP {resp.status_code} retrying",
)
continue
print(
f"datadog refused: {resp.status_code} {resp.text[:200]}",
file=sys.stderr,
)
return False
return False
def send_logs(self, records: list[dict[str, Any]]) -> int:
if not records:
return 0
url = _LOG_INTAKE_TPL.format(site=self.site)
sent = 0
for i in range(0, len(records), _LOG_BATCH):
batch = records[i : i + _LOG_BATCH]
if self._post(url, batch):
sent += len(batch)
return sent
def send_metrics(self, series: list[dict[str, Any]]) -> int:
if not series:
return 0
url = _METRICS_TPL.format(site=self.site)
sent = 0
for i in range(0, len(series), _METRICS_BATCH):
batch = series[i : i + _METRICS_BATCH]
if self._post(url, {"series": batch}):
sent += len(batch)
return sent
def _wait_retry(attempt: int, reason: str) -> None:
wait = _RETRY_BASE_S * (2**attempt)
print(
f" retry {attempt + 1}/{_MAX_RETRIES} in {wait:.1f}s ({reason})",
file=sys.stderr,
)
time.sleep(wait)
# --- record → datadog payload ------------------------------------------
def _log_record_to_dd(rec: dict[str, Any], host: str) -> dict[str, Any]:
line = rec.get("line") or ""
tags = [
f"role:{rec.get('role')}",
f"port:{rec.get('port')}",
]
level = rec.get("level")
if level:
tags.append(f"level:{level}")
tag = rec.get("tag")
if tag:
tags.append(f"thread:{tag}")
return {
"ddsource": "meshtastic-firmware",
"service": "meshtastic-firmware",
"hostname": host,
"message": line,
"ddtags": ",".join(t for t in tags if t and "None" not in t),
"timestamp": int((rec.get("ts") or time.time()) * 1000),
"level": level,
}
def _telemetry_record_to_metrics(
rec: dict[str, Any], host: str
) -> list[dict[str, Any]]:
fields = rec.get("fields") or {}
if not isinstance(fields, dict):
return []
variant = rec.get("variant") or "unknown"
ts = int(rec.get("ts") or time.time())
out: list[dict[str, Any]] = []
tags = []
if rec.get("port"):
tags.append(f"port:{rec['port']}")
if rec.get("role"):
tags.append(f"role:{rec['role']}")
if rec.get("from_node"):
tags.append(f"from_node:{rec['from_node']}")
tags.append(f"variant:{variant}")
for field, value in fields.items():
if not isinstance(value, (int, float)) or isinstance(value, bool):
continue
metric = f"mesh.{variant}.{_metric_safe(field)}"
out.append(
{
"metric": metric,
"type": 3, # GAUGE
"points": [{"timestamp": ts, "value": float(value)}],
"tags": tags,
"resources": [{"type": "host", "name": host}],
}
)
return out
def _metric_safe(name: str) -> str:
# Lowercase, replace non-alnum with underscore for safe metric names.
return "".join(c.lower() if c.isalnum() else "_" for c in name)
# --- main loop ---------------------------------------------------------
def run(
log_dir: Path,
*,
once: bool,
since_seconds: float | None,
poll_interval: float,
dd: _DDSession,
) -> int:
cursor_path = log_dir / ".dd-cursor.json"
cursors = _load_cursor(cursor_path)
# `--since` overrides cursor: rewind to (now-since) timestamp.
# We can't seek by timestamp directly (cursor is byte position), so
# we just reset cursors to 0 and let the time filter in iter_new
# drop older records.
cutoff_ts: float | None = None
if since_seconds is not None:
cursors = {}
cutoff_ts = time.time() - since_seconds
sent_total = {"logs": 0, "telemetry": 0}
while True:
# logs.jsonl → DD logs
log_cursor = cursors.setdefault("logs", {})
log_batch: list[dict[str, Any]] = []
for rec in _StreamReader(log_dir / "logs.jsonl", log_cursor).iter_new_records():
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
continue
log_batch.append(_log_record_to_dd(rec, dd.hostname))
if log_batch:
n = dd.send_logs(log_batch)
sent_total["logs"] += n
print(f"logs: sent {n}/{len(log_batch)}")
# telemetry.jsonl → DD metrics
telem_cursor = cursors.setdefault("telemetry", {})
metric_series: list[dict[str, Any]] = []
for rec in _StreamReader(
log_dir / "telemetry.jsonl", telem_cursor
).iter_new_records():
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
continue
metric_series.extend(_telemetry_record_to_metrics(rec, dd.hostname))
if metric_series:
n = dd.send_metrics(metric_series)
sent_total["telemetry"] += n
print(f"telemetry: sent {n}/{len(metric_series)} metric points")
_save_cursor(cursor_path, cursors)
if once:
print(f"done. logs={sent_total['logs']} metrics={sent_total['telemetry']}")
return 0
time.sleep(poll_interval)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--log-dir",
default=str(_DEFAULT_LOG_DIR),
help="Path to .mtlog/ (default: mcp-server/.mtlog)",
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--once", action="store_true", help="Catch up then exit")
mode.add_argument(
"--tail",
action="store_true",
help="Daemon: poll forever (default)",
)
parser.add_argument(
"--since",
type=float,
default=None,
help="Backfill last N seconds. Resets cursor.",
)
parser.add_argument(
"--poll-interval",
type=float,
default=5.0,
help="Seconds between tail polls (default 5)",
)
parser.add_argument(
"--site",
default=os.environ.get("DD_SITE", "us5.datadoghq.com"),
help=(
"Datadog site. Default is the team's instance (us5.datadoghq.com). "
"Override via DD_SITE env var or this flag."
),
)
parser.add_argument(
"--host",
default=socket.gethostname(),
help="Hostname tag (default: socket.gethostname())",
)
args = parser.parse_args(argv)
api_key = os.environ.get("DD_API_KEY")
if not api_key:
print("DD_API_KEY env var required.", file=sys.stderr)
return 2
log_dir = Path(args.log_dir)
if not log_dir.exists():
print(
f"log dir {log_dir} does not exist — start the mcp-server first.",
file=sys.stderr,
)
return 2
dd = _DDSession(api_key=api_key, site=args.site, hostname=args.host)
once = args.once and not args.tail
return run(
log_dir,
once=once,
since_seconds=args.since,
poll_interval=args.poll_interval,
dd=dd,
)
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -356,6 +356,46 @@ def shutdown(
return {"ok": True, "shutting_down_in_s": seconds}
def send_input_event(
event_code: int | str,
kb_char: int = 0,
touch_x: int = 0,
touch_y: int = 0,
port: str | None = None,
) -> dict[str, Any]:
"""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
warmup needed since the admin message is addressed to `my_node_num`.
`event_code` accepts an int, a case-insensitive name
(`"RIGHT"` / `"input_broker_right"`), or an `InputEventCode`. The
firmware-side enum lives in src/input/InputBroker.h and is mirrored in
`meshtastic_mcp.input_events`.
"""
from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped]
from .input_events import coerce_event_code
code = coerce_event_code(event_code)
if not 0 <= kb_char <= 255:
raise ValueError(f"kb_char out of u8 range: {kb_char}")
if not 0 <= touch_x <= 65535:
raise ValueError(f"touch_x out of u16 range: {touch_x}")
if not 0 <= touch_y <= 65535:
raise ValueError(f"touch_y out of u16 range: {touch_y}")
with connect(port=port) as iface:
msg = admin_pb2.AdminMessage()
msg.send_input_event.event_code = code
msg.send_input_event.kb_char = kb_char
msg.send_input_event.touch_x = touch_x
msg.send_input_event.touch_y = touch_y
iface.localNode._sendAdmin(msg)
return {"ok": True, "event_code": code, "kb_char": kb_char}
def factory_reset(
port: str | None = None, confirm: bool = False, full: bool = False
) -> dict[str, Any]:
+286
View File
@@ -0,0 +1,286 @@
"""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
per frame, but zero Python deps beyond stdlib.
- `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` /
`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_ESP32S3=0` for the OLED-bearing heltec-v3.
Role suffix is uppercased before lookup.
Dependencies land in the optional `[ui]` extra; imports are lazy so clients
without `opencv-python-headless` installed can still import this module.
"""
from __future__ import annotations
import io
import os
import shutil
import subprocess
import sys
import time
import warnings
from pathlib import Path
from typing import Protocol
class CameraError(RuntimeError):
"""Raised when a camera backend fails to initialize or capture."""
class CameraBackend(Protocol):
name: str
def capture(self) -> bytes:
"""Return one PNG-encoded frame."""
...
def close(self) -> None: ...
# ---------- OpenCV backend -------------------------------------------------
class OpenCVBackend:
name = "opencv"
def __init__(self, device: int | str, warmup_frames: int = 5) -> None:
try:
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
except ImportError as exc:
raise CameraError(
"opencv backend requested but `cv2` is not installed. "
"Install the mcp-server [ui] extra: pip install -e '.[ui]'"
) from exc
self._cv2 = cv2
device_arg: int | str
if isinstance(device, str) and device.isdigit():
device_arg = int(device)
else:
device_arg = device
self._cap = cv2.VideoCapture(device_arg)
if not self._cap.isOpened():
raise CameraError(
f"cv2.VideoCapture({device_arg!r}) failed to open. "
"On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access."
)
# 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
# producing all-black captures.
ok, frame = self._cap.read()
if not ok or frame is None:
self._cap.release()
raise CameraError(f"camera {device_arg!r} opened but returned no frames")
def capture(self) -> bytes:
cv2 = self._cv2
ok, frame = self._cap.read()
if not ok or frame is None:
raise CameraError("cv2.VideoCapture.read() returned no frame")
success, buf = cv2.imencode(".png", frame)
if not success:
raise CameraError("cv2.imencode('.png', ...) failed")
return bytes(buf)
def close(self) -> None:
try:
self._cap.release()
except Exception: # noqa: BLE001
pass
# ---------- ffmpeg subprocess backend --------------------------------------
class FfmpegBackend:
name = "ffmpeg"
def __init__(self, device: int | str) -> None:
if shutil.which("ffmpeg") is None:
raise CameraError("ffmpeg backend requested but `ffmpeg` is not on PATH")
self._device = str(device)
# Platform-specific -f flag:
# macOS → avfoundation (index like "0")
# Linux → v4l2 (device like "/dev/video0" or "0")
if sys.platform == "darwin":
self._input_format = "avfoundation"
self._input_spec = self._device # bare index for avfoundation
else:
self._input_format = "v4l2"
self._input_spec = (
self._device
if self._device.startswith("/dev/")
else f"/dev/video{self._device}"
)
def capture(self) -> bytes:
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-f",
self._input_format,
"-i",
self._input_spec,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
]
try:
out = subprocess.run(
cmd, capture_output=True, check=True, timeout=15 # noqa: S603
)
except subprocess.CalledProcessError as exc:
raise CameraError(
f"ffmpeg capture failed (rc={exc.returncode}): {exc.stderr.decode(errors='replace')[:200]}"
) from exc
except subprocess.TimeoutExpired as exc:
raise CameraError("ffmpeg capture timed out after 15s") from exc
return out.stdout
def close(self) -> None:
pass # stateless — each capture spawns a new process
# ---------- Null backend ---------------------------------------------------
# A tiny valid 1×1 transparent PNG so callers always get a decodable image.
_BLACK_1X1_PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082"
)
class NullBackend:
name = "null"
def capture(self) -> bytes:
return _BLACK_1X1_PNG
def close(self) -> None:
pass
# ---------- Factory --------------------------------------------------------
def _resolve_device(role: str | None) -> str | None:
if role:
specific = os.environ.get(f"MESHTASTIC_UI_CAMERA_DEVICE_{role.upper()}")
if specific:
return specific
return os.environ.get("MESHTASTIC_UI_CAMERA_DEVICE")
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
evidence, not a blocker.
"""
backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower()
device = _resolve_device(role)
if backend in ("null", "none") or device is None:
return NullBackend()
if backend == "auto":
# Prefer opencv if importable; fall back to ffmpeg; else null.
try:
import cv2 # type: ignore[import-untyped] # noqa: F401,PLC0415
backend = "opencv"
except ImportError:
backend = "ffmpeg" if shutil.which("ffmpeg") else "null"
if backend == "opencv":
try:
return OpenCVBackend(device)
except CameraError as exc:
warnings.warn(
f"camera backend {backend!r} failed to initialize for device "
f"{device!r}: {exc}; falling back to null backend",
RuntimeWarning,
stacklevel=2,
)
return NullBackend()
if backend == "ffmpeg":
try:
return FfmpegBackend(device)
except CameraError as exc:
warnings.warn(
f"camera backend {backend!r} failed to initialize for device "
f"{device!r}: {exc}; falling back to null backend",
RuntimeWarning,
stacklevel=2,
)
return NullBackend()
if backend == "null":
return NullBackend()
raise CameraError(f"unknown MESHTASTIC_UI_CAMERA_BACKEND: {backend!r}")
def save_capture(png_bytes: bytes, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(png_bytes)
def capture_to_file(role: str | None, path: Path) -> dict[str, object]:
"""One-shot: open camera, capture, write PNG, close. Returns metadata."""
started = time.monotonic()
cam = get_camera(role)
try:
data = cam.capture()
finally:
cam.close()
save_capture(data, path)
return {
"backend": cam.name,
"path": str(path),
"bytes": len(data),
"elapsed_s": round(time.monotonic() - started, 3),
}
def _is_png(data: bytes) -> bool:
return data.startswith(b"\x89PNG\r\n\x1a\n")
# Exposed so callers can sanity-check a capture without a full PIL import.
__all__ = [
"CameraBackend",
"CameraError",
"FfmpegBackend",
"NullBackend",
"OpenCVBackend",
"capture_to_file",
"get_camera",
"save_capture",
]
# Keep `io` import used (pyflakes is picky) via a small guard used at import
# time to normalize stdin/stdout if a subclass ever needs it.
_ = io.BytesIO # noqa: SLF001
@@ -0,0 +1,83 @@
"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``.
Watches ``tests/ui_captures/<session_seed>/`` for new transcript lines
(one per ``frame_capture()`` call from the UI tier) and posts them to
the TUI. Enabled by ``MESHTASTIC_UI_TUI_CAMERA=1``.
Design mirrors ``_flashlog.py``:
- Daemon thread, cooperative stop via ``threading.Event``.
- Tolerates the captures directory not existing yet (UI tier hasn't run).
- Per-file seek state so we only forward genuinely-new lines.
"""
from __future__ import annotations
import pathlib
import threading
import time
from typing import Callable
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.
"""
def __init__(
self,
root: pathlib.Path,
post: Callable[[str, str], None],
stop: threading.Event,
*,
poll_interval: float = 0.5,
) -> None:
super().__init__(daemon=True, name="uicap-tail")
self._root = root
self._post = post
self._stop = stop
self._poll_interval = poll_interval
# path → byte offset we've already read through
self._offsets: dict[pathlib.Path, int] = {}
def run(self) -> None:
while not self._stop.is_set():
try:
self._scan_once()
except Exception:
# Best-effort tailer — never bring down the TUI because a
# directory vanished mid-scan.
pass
time.sleep(self._poll_interval)
def _scan_once(self) -> None:
if not self._root.is_dir():
return
for transcript in self._root.rglob("transcript.md"):
test_id = transcript.parent.name
offset = self._offsets.get(transcript, 0)
try:
size = transcript.stat().st_size
except OSError:
continue
if size < offset:
# File truncated / rewritten — reset and re-emit.
offset = 0
if size == offset:
continue
try:
with transcript.open("rb") as fh:
fh.seek(offset)
chunk = fh.read(size - offset).decode("utf-8", errors="replace")
except OSError:
continue
for line in chunk.splitlines():
line = line.rstrip()
if not line or line.startswith("#"):
continue
try:
self._post(test_id, line)
except Exception:
return
self._offsets[transcript] = size
@@ -518,6 +518,7 @@ def _build_app(
from . import _fwlog as _fwlog_mod
from . import _history as _history_mod
from . import _reproducer as _reproducer_mod
from . import _uicap as _uicap_mod
# ---------------- Messages ----------------
@@ -548,6 +549,16 @@ def _build_app(
self.line = line
super().__init__()
class UiCaptureLine(tx.Message):
"""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`."""
def __init__(self, test_id: str, line: str) -> None:
self.test_id = test_id
self.line = line
super().__init__()
class DeviceSnapshot(tx.Message):
def __init__(self, rows: list[DeviceRow]) -> None:
self.rows = rows
@@ -871,6 +882,10 @@ def _build_app(
#pytest-pane { height: 50%; border-bottom: solid $primary-background; }
#fwlog-header { height: 1; padding: 0 1; background: $panel; }
#fwlog-pane { height: 1fr; }
#uicap-header { height: 1; padding: 0 1; background: $boost; }
#uicap-pane { height: 14; border-top: solid $primary-background; }
#uicap-image { width: 36; border-right: solid $primary-background; padding: 0 1; }
#uicap-log { width: 1fr; height: 14; }
Tree { height: 100%; }
RichLog { height: 100%; }
#device-table { height: auto; max-height: 6; }
@@ -912,6 +927,11 @@ def _build_app(
self._device_worker: DevicePollerWorker | None = None
self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None
self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None
self._uicap_worker: _uicap_mod.UiCaptureTailer | None = None
# Env-gated; only mounts the UI-capture panel when operator asks for it.
self._ui_camera_enabled = bool(
int(os.environ.get("MESHTASTIC_UI_TUI_CAMERA", "0") or "0")
)
self._tree_filter: str = ""
self._sigint_count = 0
# Firmware-log port filter: None = all, else exact port match.
@@ -959,6 +979,22 @@ def _build_app(
wrap=True,
max_lines=5000,
)
if self._ui_camera_enabled:
yield tx.Static(
"UI camera — latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
id="uicap-header",
)
with tx.Horizontal(id="uicap-pane"):
yield tx.Static(
"(waiting…)", id="uicap-image", markup=False
)
yield tx.RichLog(
id="uicap-log",
highlight=False,
markup=False,
wrap=True,
max_lines=500,
)
yield tx.DataTable(id="device-table", show_cursor=False)
yield tx.Footer()
@@ -1023,6 +1059,21 @@ def _build_app(
stop=self._stop,
)
self._flashlog_worker.start()
# 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:
captures_root = self._root / "mcp-server" / "tests" / "ui_captures"
# When the TUI is launched from inside mcp-server (the usual
# case), `self._root` is already mcp-server/, so adjust:
if not captures_root.parent.name == "mcp-server":
captures_root = self._root / "tests" / "ui_captures"
self._uicap_worker = _uicap_mod.UiCaptureTailer(
root=captures_root,
post=lambda tid, line: self.post_message(UiCaptureLine(tid, line)),
stop=self._stop,
)
self._uicap_worker.start()
self._spawn_pytest(self._pytest_args)
# Header tick (seed / runtime / sparkline re-renders at 1 Hz).
# Also refreshes the device-status column so the per-test elapsed
@@ -1217,6 +1268,84 @@ def _build_app(
log = self.query_one("#pytest-log", tx.RichLog)
log.write(f"[flash] {message.line}")
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
the text into the RichLog AND try to render the corresponding
PNG on the left side (requires rich-pixels, Pillow).
"""
if not self._ui_camera_enabled:
return
try:
log_panel = self.query_one("#uicap-log", tx.RichLog)
except Exception:
return
log_panel.write(f"[{message.test_id}] {message.line}")
self._render_latest_ui_capture(message.test_id, message.line)
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
transcript on the right.
"""
try:
from PIL import Image # type: ignore[import-untyped]
from rich_pixels import Pixels # type: ignore[import-untyped]
except ImportError:
return
# Transcript lines look like `1. **label** — ...`. Pull the leading
# integer to locate the capture file.
import re as _re
m = _re.match(r"\s*(\d+)\.\s", line)
if not m:
return
step = int(m.group(1))
# Captures directory is sibling of tests/ — mirror the path the
# tailer watches. Search both likely layouts (in-mcp-server vs.
# firmware-root invocation).
candidates = [
self._root / "tests" / "ui_captures",
self._root / "mcp-server" / "tests" / "ui_captures",
]
captures_root = next((p for p in candidates if p.is_dir()), None)
if captures_root is None:
return
# 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:
return
png_path = matches[-1]
try:
img = Image.open(png_path).convert("RGB")
# Resize to fit ~32 cells wide × ~12 rows tall (half-block
# renderer gives 2× vertical resolution, so 32×24 px input
# lands at ~32×12 cells). Keep aspect ratio.
target_w = 60
w, h = img.size
target_h = max(1, int(h * (target_w / max(1, w))))
# Clamp: the image panel is 14 rows; half-blocks give 2 rows
# per vertical cell, so cap pixel height at ~26.
target_h = min(target_h, 26)
img = img.resize((target_w, target_h))
pixels = Pixels.from_image(img)
except Exception:
return
try:
image_widget = self.query_one("#uicap-image", tx.Static)
image_widget.update(pixels)
except Exception:
pass
def on_firmware_log_line(self, message: Any) -> None:
rec = message.record
port = rec.get("port")
+11
View File
@@ -135,3 +135,14 @@ def picotool_bin() -> Path:
("picotool",),
"Install via `brew install picotool` or build from https://github.com/raspberrypi/picotool.",
)
def uhubctl_bin() -> Path:
return _hw_tool(
"MESHTASTIC_UHUBCTL_BIN",
("uhubctl",),
"Install via `brew install uhubctl` (macOS) or `apt install uhubctl` "
"(Debian/Ubuntu). On Linux without the udev rules, or on older macOS "
"with certain hubs, you may need to run via `sudo`: "
"https://github.com/mvp/uhubctl#linux-usb-permissions",
)
+157 -12
View File
@@ -1,4 +1,4 @@
"""Context manager for meshtastic.SerialInterface connections.
"""Context manager for meshtastic interface connections (serial + TCP).
Every info/admin tool goes through `connect(port)` so we have a single place
that:
@@ -6,8 +6,16 @@ that:
- fails fast if a serial_session is already holding the port,
- guarantees `.close()` is called, even on exception.
The `SerialInterface` blocks on construction waiting for the node database;
that's fine for v1 since every tool is a short-lived request.
Two transports:
- Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`.
- TCP: a `meshtasticd` daemon (e.g. the native macOS / Linux Portduino
headless build) addressed as `tcp://host[:port]` (default port 4403).
Surfaced by `devices.list_devices()` when `MESHTASTIC_MCP_TCP_HOST` is
set, so `resolve_port(None)` auto-selects it like a USB candidate.
Both `SerialInterface` and `TCPInterface` block on construction waiting for
the node database; that's fine for v1 since every tool is a short-lived
request.
"""
from __future__ import annotations
@@ -17,20 +25,107 @@ from typing import Iterator
from . import devices, registry
DEFAULT_TCP_PORT = 4403
TCP_SCHEME = "tcp://"
TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST"
class ConnectionError(RuntimeError):
pass
def is_tcp_port(port: str | None) -> bool:
return bool(port) and port.startswith(TCP_SCHEME)
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
a raw `ValueError` bubble up to a tool surface.
"""
if not port.startswith(TCP_SCHEME):
raise ConnectionError(
f"Invalid TCP endpoint {port!r}: expected '{TCP_SCHEME}host[:port]'."
)
rest = port[len(TCP_SCHEME) :]
if ":" in rest:
host, port_str = rest.rsplit(":", 1)
try:
tcp_port = int(port_str)
except ValueError as e:
raise ConnectionError(
f"Invalid TCP endpoint {port!r}: port {port_str!r} is not an integer."
) from e
else:
host, tcp_port = rest, DEFAULT_TCP_PORT
if not host:
raise ConnectionError(f"Invalid TCP endpoint {port!r}: empty host.")
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 "
"pass a serial port path or a Windows drive path by mistake?"
)
if not (1 <= tcp_port <= 65535):
raise ConnectionError(
f"Invalid TCP endpoint {port!r}: port {tcp_port} out of range "
"(must be 1..65535)."
)
return host, tcp_port
def normalize_tcp_endpoint(endpoint: str) -> str:
r"""Normalize `host`, `host:port`, or `tcp://host[:port]` → canonical
`tcp://host:port` form. One place that owns the lock-key shape.
Defers all validation to `parse_tcp_port`, so path-like inputs
(`/dev/cu.foo`, `C:\Windows\…`), empty hosts, non-integer ports,
and out-of-range ports raise `ConnectionError` here too.
"""
if endpoint.startswith(TCP_SCHEME):
canonical = endpoint
elif ":" in endpoint:
canonical = f"{TCP_SCHEME}{endpoint}"
else:
canonical = f"{TCP_SCHEME}{endpoint}:{DEFAULT_TCP_PORT}"
host, port = parse_tcp_port(canonical)
return f"{TCP_SCHEME}{host}:{port}"
def reject_if_tcp(port: str | None, tool_name: str) -> None:
"""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
responsibility to handle if it matters.
"""
if is_tcp_port(port):
raise ConnectionError(
f"{tool_name} is not applicable to TCP/native nodes ({port}). "
"This tool requires USB-attached hardware."
)
def resolve_port(port: str | None) -> str:
"""Pick a port: explicit > sole likely_meshtastic candidate > error."""
"""Pick a port: explicit > sole likely_meshtastic candidate > error.
A `tcp://` string passes through (after canonicalization). When `port`
is None and no USB candidates are present, `MESHTASTIC_MCP_TCP_HOST`
is consulted via `devices.list_devices()`.
"""
if port:
if is_tcp_port(port):
return normalize_tcp_endpoint(port)
return port
candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]]
if not candidates:
raise ConnectionError(
"No Meshtastic devices detected. Plug one in or pass `port` explicitly. "
"Run `list_devices` with include_unknown=True to see all serial ports."
"No Meshtastic devices detected. Plug one in, set "
f"{TCP_HOST_ENV}=<host[:port]> for a meshtasticd daemon, "
"or pass `port` explicitly. Run `list_devices` with "
"include_unknown=True to see all serial ports."
)
if len(candidates) > 1:
ports = ", ".join(c["port"] for c in candidates)
@@ -43,17 +138,62 @@ def resolve_port(port: str | None) -> str:
@contextmanager
def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
"""Open a `meshtastic.SerialInterface` and always close it.
"""Open a meshtastic interface (serial or TCP) and always close it.
Raises `ConnectionError` immediately if another serial session holds the
port (a `pio device monitor` in `serial_sessions/`, for instance).
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()`
calls to the same daemon endpoint.
`timeout_s` is plumbed through to both `SerialInterface(timeout=...)`
and `TCPInterface(timeout=...)`. The meshtastic library uses the value
as the reply-wait deadline for `localNode.waitForConfig()` during
construction and for any subsequent admin RPC. `int()`-converted at
the boundary because the upstream API expects whole seconds.
"""
resolved = resolve_port(port)
timeout = int(timeout_s)
if is_tcp_port(resolved):
from meshtastic.tcp_interface import (
TCPInterface, # type: ignore[import-untyped]
)
host, tcp_port = parse_tcp_port(resolved)
lock = registry.port_lock(resolved)
if not lock.acquire(blocking=False):
raise ConnectionError(
f"TCP endpoint {resolved} is busy — another device operation "
"is in flight. Retry shortly."
)
iface = None
try:
iface = TCPInterface(
hostname=host,
portNumber=tcp_port,
connectNow=True,
noProto=False,
timeout=timeout,
)
yield iface
finally:
if iface is not None:
try:
iface.close()
except Exception:
pass
try:
lock.release()
except RuntimeError:
pass
return
from meshtastic.serial_interface import (
SerialInterface, # type: ignore[import-untyped]
)
resolved = resolve_port(port)
active = registry.active_session_for_port(resolved)
if active is not None:
raise ConnectionError(
@@ -70,7 +210,12 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
iface = None
try:
iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False)
iface = SerialInterface(
devPath=resolved,
connectNow=True,
noProto=False,
timeout=timeout,
)
yield iface
finally:
if iface is not None:
+63 -3
View File
@@ -1,13 +1,18 @@
"""USB/serial device discovery.
"""USB/serial + TCP device discovery.
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
a Meshtastic device" signal.
If `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` is set, a synthetic entry for the
`meshtasticd` daemon at that endpoint is prepended to the result, so
`resolve_port(None)` auto-selects it like a USB candidate.
"""
from __future__ import annotations
import os
from typing import Any
from serial.tools import list_ports
@@ -19,6 +24,45 @@ def _to_hex(value: int | None) -> str | None:
return f"0x{value:04x}"
def _tcp_endpoint_from_env() -> dict[str, Any] | None:
"""Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set.
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
tool a user reaches for when their env var isn't working, so it must
not crash on misconfiguration.
"""
host = os.environ.get("MESHTASTIC_MCP_TCP_HOST")
if not host:
return None
# Lazy import to avoid a circular dependency (connection imports devices).
from . import connection
try:
port = connection.normalize_tcp_endpoint(host)
description = "meshtasticd (TCP)"
likely = True
except connection.ConnectionError as e:
# Surface the raw env-var value plus the parser's reason so the
# 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}"
likely = False
return {
"port": port,
"vid": None,
"pid": None,
"description": description,
"manufacturer": None,
"product": None,
"serial_number": None,
"likely_meshtastic": likely,
"blacklisted": False,
}
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
"""Return enriched info for serial ports, flagging Meshtastic candidates.
@@ -70,6 +114,22 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
}
)
# Stable ordering: likely_meshtastic first, then by port path
results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"]))
# Append the TCP endpoint (if env var set) and sort everything together.
tcp_entry = _tcp_endpoint_from_env()
if tcp_entry is not None:
results.append(tcp_entry)
# 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 —
# it does NOT pre-empt real USB devices at the top of the list.
results.sort(
key=lambda r: (
not r["likely_meshtastic"],
not r["port"].startswith("tcp://"),
r["port"],
)
)
return results
+62 -2
View File
@@ -17,7 +17,7 @@ from typing import Any
import serial
from . import boards, config, devices, pio, userprefs
from . import boards, config, connection, devices, pio, userprefs
# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
@@ -46,6 +46,18 @@ 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
upload step. The user wants `build` (or just runs the binary directly).
"""
if env.startswith("native"):
raise FlashError(
f"{operation} is not applicable for env {env!r}: native envs "
"produce a host executable, not flashable firmware. Use `build` "
"instead, then run the resulting binary directly."
)
def _artifacts_for(env: str) -> list[Path]:
build_dir = config.firmware_root() / ".pio" / "build" / env
if not build_dir.is_dir():
@@ -96,18 +108,33 @@ def build(
env: str,
with_manifest: bool = True,
userprefs_overrides: dict[str, Any] | None = None,
build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Run `pio run -e <env>` and return artifact paths.
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
into userPrefs.jsonc for this build only. File is restored byte-for-byte
on exit. Use `userprefs_set()` for persistent changes.
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros to set for
this build only via `PLATFORMIO_BUILD_FLAGS`. Common useful flag:
`{"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
expand to bare `-D<NAME>` (presence-only flags).
"""
args = ["run", "-e", env]
if with_manifest:
args.extend(["-t", "mtjson"])
extra_env = _build_flags_env(build_flags) if build_flags else None
with userprefs.temporary_overrides(userprefs_overrides) as effective:
result = pio.run(args, timeout=pio.TIMEOUT_BUILD, check=False)
result = pio.run(
args,
timeout=pio.TIMEOUT_BUILD,
check=False,
extra_env=extra_env,
)
return {
"exit_code": result.returncode,
"artifacts": [str(p) for p in _artifacts_for(env)],
@@ -115,9 +142,27 @@ def build(
"stderr_tail": pio.tail_lines(result.stderr, 200),
"duration_s": round(result.duration_s, 2),
"userprefs": _userprefs_summary(effective),
"build_flags": dict(build_flags) if build_flags else None,
}
def _build_flags_env(build_flags: dict[str, Any]) -> dict[str, str]:
"""Translate `{"DEBUG_HEAP": 1, "FOO": "bar"}` → `{"PLATFORMIO_BUILD_FLAGS":
"-DDEBUG_HEAP=1 -DFOO=bar"}`. Bool True → bare `-D<NAME>`; False/None drop
the flag entirely. Other types stringify."""
parts: list[str] = []
for key, value in build_flags.items():
if value is False or value is None:
continue
if value is True:
parts.append(f"-D{key}")
else:
parts.append(f"-D{key}={value}")
if not parts:
return {}
return {"PLATFORMIO_BUILD_FLAGS": " ".join(parts)}
def clean(env: str) -> dict[str, Any]:
"""Run `pio run -e <env> -t clean`."""
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
@@ -134,18 +179,29 @@ def flash(
port: str,
confirm: bool = False,
userprefs_overrides: dict[str, Any] | None = None,
build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
`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`
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:
`build_flags={"DEBUG_HEAP": 1}` for the leak-hunt path.
"""
_require_confirm(confirm, "flash")
_reject_native_env(env, "flash")
connection.reject_if_tcp(port, "flash")
extra_env = _build_flags_env(build_flags) if build_flags else None
with userprefs.temporary_overrides(userprefs_overrides) as effective:
result = pio.run(
["run", "-e", env, "-t", "upload", "--upload-port", port],
timeout=pio.TIMEOUT_UPLOAD,
check=False,
extra_env=extra_env,
)
return {
"exit_code": result.returncode,
@@ -153,6 +209,7 @@ def flash(
"stderr_tail": pio.tail_lines(result.stderr, 200),
"duration_s": round(result.duration_s, 2),
"userprefs": _userprefs_summary(effective),
"build_flags": dict(build_flags) if build_flags else None,
}
@@ -200,6 +257,7 @@ def erase_and_flash(
in that case) since a cached factory.bin would not reflect the new prefs.
"""
_require_confirm(confirm, "erase_and_flash")
connection.reject_if_tcp(port, "erase_and_flash")
_check_esp32_env(env)
if userprefs_overrides and skip_build:
@@ -257,6 +315,7 @@ def update_flash(
overrides are provided we always force a rebuild.
"""
_require_confirm(confirm, "update_flash")
connection.reject_if_tcp(port, "update_flash")
_check_esp32_env(env)
if userprefs_overrides and skip_build:
@@ -391,6 +450,7 @@ def touch_1200bps(
Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`.
"""
connection.reject_if_tcp(port, "touch_1200bps")
before_list = devices.list_devices(include_unknown=True)
before_ports = {d["port"] for d in before_list}
+6 -1
View File
@@ -16,7 +16,7 @@ import subprocess
from pathlib import Path
from typing import Any, Sequence
from . import config, pio
from . import config, connection, pio
_TIMEOUT_SHORT = 30
_TIMEOUT_LONG = 600
@@ -102,6 +102,7 @@ def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]:
def esptool_chip_info(port: str) -> dict[str, Any]:
connection.reject_if_tcp(port, "esptool_chip_info")
binary = config.esptool_bin()
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
@@ -116,6 +117,7 @@ def esptool_chip_info(port: str) -> dict[str, Any]:
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
"""Full-chip erase. Leaves the device unbootable until reflashed."""
_require_confirm(confirm, "esptool_erase_flash")
connection.reject_if_tcp(port, "esptool_erase_flash")
binary = config.esptool_bin()
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
# first; if it fails with unknown command, retry old.
@@ -134,6 +136,7 @@ def esptool_raw(
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
if not args:
raise ToolError("args must not be empty")
connection.reject_if_tcp(port, "esptool_raw")
# Find the first non-flag arg (the subcommand).
subcommand = next((a for a in args if not a.startswith("-")), None)
if subcommand and subcommand.replace("-", "_") in {
@@ -156,6 +159,7 @@ NRFUTIL_DESTRUCTIVE = {"dfu", "settings"}
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
_require_confirm(confirm, "nrfutil_dfu")
connection.reject_if_tcp(port, "nrfutil_dfu")
pkg = Path(package_path).expanduser()
if not pkg.is_file():
raise ToolError(f"Package not found: {pkg}")
@@ -213,6 +217,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."""
connection.reject_if_tcp(port, "picotool_info")
binary = config.picotool_bin()
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
if res["exit_code"] != 0:
@@ -0,0 +1,67 @@
"""Python mirror of firmware `enum input_broker_event` (src/input/InputBroker.h).
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`
pins the mapping.
"""
from __future__ import annotations
from enum import IntEnum
class InputEventCode(IntEnum):
"""Button / key / gesture events dispatched by the firmware InputBroker."""
NONE = 0
SELECT = 10
SELECT_LONG = 11
UP_LONG = 12
DOWN_LONG = 13
UP = 17
DOWN = 18
LEFT = 19
RIGHT = 20
CANCEL = 24
BACK = 27
# Auto-incremented values in the C enum (27 + 1, +2, +3):
USER_PRESS = 28
ALT_PRESS = 29
ALT_LONG = 30
SHUTDOWN = 0x9B
GPS_TOGGLE = 0x9E
SEND_PING = 0xAF
FN_F1 = 0xF1
FN_F2 = 0xF2
FN_F3 = 0xF3
FN_F4 = 0xF4
FN_F5 = 0xF5
MATRIXKEY = 0xFE
ANYKEY = 0xFF
def coerce_event_code(value: int | str | InputEventCode) -> int:
"""Accept an int, a case-insensitive name, or an `InputEventCode` and return
the u8 wire value. Raises ValueError on unknown names / out-of-range ints.
"""
if isinstance(value, InputEventCode):
return int(value)
if isinstance(value, int):
if not 0 <= value <= 255:
raise ValueError(f"event_code out of u8 range: {value}")
return value
if isinstance(value, str):
key = value.upper().replace("-", "_")
if key.startswith("INPUT_BROKER_"):
key = key[len("INPUT_BROKER_") :]
try:
return int(InputEventCode[key])
except KeyError as exc:
known = ", ".join(m.name for m in InputEventCode)
raise ValueError(
f"unknown event code name {value!r}; known: {known}"
) from exc
raise TypeError(
f"event_code must be int|str|InputEventCode, got {type(value).__name__}"
)
+410
View File
@@ -0,0 +1,410 @@
"""Read-side queries over the recorder's JSONL streams.
Pure functions over `mcp-server/.mtlog/`. Streaming JSONL reader: never
loads a whole file. Time-bound queries short-circuit as soon as `ts`
exceeds the requested end. The recorder writes monotonically, so a
forward scan is cheap; we don't need an index.
All time arguments accept:
- epoch seconds (int/float)
- relative strings: "-15m", "-2h", "-3d", "now"
- ISO-ish absolute strings: "2026-05-07T14:30:00" (naive timestamps are
treated as UTC)
Tools that return data ALWAYS cap their output (max_lines / max_points
/ max), and report whether more matched than was returned.
"""
from __future__ import annotations
import gzip
import json
import re
import statistics
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
from .recorder.recorder import get_recorder
_REL_RE = re.compile(r"^\s*-\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$")
_REGEX_PREVIEW_MAX = 100
_REGEX_PREVIEW_TRUNCATE = 97
def _parse_time(value: Any, *, now: float | None = None) -> float:
"""Coerce to epoch seconds. Defaults `now` to `time.time()`."""
if value is None:
return time.time()
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
raise ValueError(f"invalid time: {value!r}")
s = value.strip().lower()
if s in ("", "now"):
return time.time() if now is None else now
m = _REL_RE.match(s)
if m:
n = float(m.group(1))
unit = m.group(2)
secs = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
base = time.time() if now is None else now
return base - secs
# Try ISO 8601. Accept naive (assume UTC) and Z-suffixed.
try:
if s.endswith("z"):
s = s[:-1] + "+00:00"
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except ValueError as e:
raise ValueError(f"unparseable time: {value!r}") from e
def _iter_jsonl(path: Path, *, since: float, until: float) -> Iterator[dict[str, Any]]:
"""Stream records in chronological order: rotated archives first
(oldest → newest by lex sort, which is chronological for our
`YYYYMMDD-HHMMSS-uuuuuu-NNNNN` archive naming), then the live file
last. The "keep last N" pop-front logic in the window queries
relies on records arriving in time order across files.
"""
files: list[Path] = []
# Gzipped archives are named "<stem>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz".
for archive in sorted(path.parent.glob(f"{path.stem}.*.jsonl.gz")):
files.append(archive)
if path.exists():
files.append(path)
for f in files:
opener = gzip.open if f.suffix == ".gz" else open
try:
with opener(f, "rt", encoding="utf-8") as fh: # type: ignore[arg-type]
for line in fh:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
ts = rec.get("ts")
if not isinstance(ts, (int, float)):
continue
if ts < since:
continue
if ts > until:
# Records are append-monotonic within a file, so
# the rest of this file is also past `until`.
# Archives can still overlap each other, so only
# short-circuit this file, not the whole scan.
break
yield rec
except (FileNotFoundError, OSError):
continue
# -- queries ------------------------------------------------------------
def logs_window(
start: Any = "-15m",
end: Any = "now",
*,
grep: str | None = None,
level: str | None = None,
tag: str | None = None,
port: str | None = None,
max_lines: int = 200,
) -> dict[str, Any]:
"""Recent firmware log lines, filtered.
`level` accepts a single level name or pipe-separated set
("WARN|ERROR|CRIT"). `grep` is a regex (Python re) over the raw
`line` field. Returns the last `max_lines` matches.
"""
s = _parse_time(start)
e = _parse_time(end)
levels = _split_set(level)
if grep:
try:
grep_re = re.compile(grep)
except re.error as exc:
preview = (
grep
if len(grep) <= _REGEX_PREVIEW_MAX
else f"{grep[:_REGEX_PREVIEW_TRUNCATE]}..."
)
raise ValueError(f"invalid grep regex {preview!r}: {exc}") from exc
else:
grep_re = None
base = get_recorder().base_dir
matched = 0
out: list[dict[str, Any]] = []
for rec in _iter_jsonl(base / "logs.jsonl", since=s, until=e):
if levels and rec.get("level") not in levels:
continue
if tag and rec.get("tag") != tag:
continue
if port and rec.get("port") != port:
continue
if grep_re and not grep_re.search(rec.get("line") or ""):
continue
matched += 1
out.append(rec)
if len(out) > max_lines:
out.pop(0) # keep the most recent N
return {
"lines": out,
"total_matched": matched,
"dropped": max(0, matched - max_lines),
"window": {"start": s, "end": e},
}
def telemetry_timeline(
window: Any = "1h",
*,
variant: str = "local",
field: str = "free_heap",
port: str | None = None,
max_points: int = 200,
) -> dict[str, Any]:
"""Timeseries of one telemetry field, downsampled.
`field` matches both the protobuf snake_case name (`free_heap`,
`heap_free_bytes`, `battery_level`) and camelCase (`freeHeap`).
Server-side bucket-mean downsamples to ≤ `max_points`. Returns
`slope_per_min` (linear regression slope, units/min) so a leak
detector can read one number.
"""
end = time.time()
if isinstance(window, (int, float)):
# 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.
start = end - float(window)
elif isinstance(window, str) and not window.startswith("-"):
# Bare string like "1h" is sugar for "-1h".
start = _parse_time(f"-{window}", now=end)
else:
start = _parse_time(window, now=end)
base = get_recorder().base_dir
raw: list[tuple[float, float]] = []
field_aliases = _field_aliases(field)
for rec in _iter_jsonl(base / "telemetry.jsonl", since=start, until=end):
if rec.get("variant") != variant:
continue
if port and rec.get("port") != port:
continue
fields = rec.get("fields") or {}
value: Any = None
for alias in field_aliases:
if alias in fields:
value = fields[alias]
break
if not isinstance(value, (int, float)):
continue
raw.append((float(rec["ts"]), float(value)))
if not raw:
return {
"points": [],
"samples": 0,
"min": None,
"max": None,
"slope_per_min": None,
"window": {"start": start, "end": end, "variant": variant, "field": field},
}
points = _downsample(raw, max_points=max_points)
values = [v for _, v in raw]
return {
"points": [{"ts": ts, "value": v} for ts, v in points],
"samples": len(raw),
"min": min(values),
"max": max(values),
"slope_per_min": _slope_per_min(raw),
"window": {"start": start, "end": end, "variant": variant, "field": field},
}
def packets_window(
start: Any = "-5m",
end: Any = "now",
*,
portnum: str | None = None,
from_node: str | None = None,
to_node: str | None = None,
max: int = 200,
) -> dict[str, Any]:
s = _parse_time(start)
e = _parse_time(end)
portnums = _split_set(portnum)
base = get_recorder().base_dir
matched = 0
out: list[dict[str, Any]] = []
for rec in _iter_jsonl(base / "packets.jsonl", since=s, until=e):
if portnums and rec.get("portnum") not in portnums:
continue
if from_node and str(rec.get("from_node")) != str(from_node):
continue
if to_node and str(rec.get("to_node")) != str(to_node):
continue
matched += 1
out.append(rec)
if len(out) > max:
out.pop(0)
return {
"packets": out,
"total_matched": matched,
"dropped": matched - max if matched > max else 0,
"window": {"start": s, "end": e},
}
def events_window(
start: Any = "-1h",
end: Any = "now",
*,
kind: str | None = None,
max: int = 200,
) -> dict[str, Any]:
s = _parse_time(start)
e = _parse_time(end)
kinds = _split_set(kind)
base = get_recorder().base_dir
matched = 0
out: list[dict[str, Any]] = []
for rec in _iter_jsonl(base / "events.jsonl", since=s, until=e):
if kinds and rec.get("kind") not in kinds:
continue
matched += 1
out.append(rec)
if len(out) > max:
out.pop(0)
return {
"events": out,
"total_matched": matched,
"dropped": matched - max if matched > max else 0,
"window": {"start": s, "end": e},
}
def export(
start: Any,
end: Any,
dest_dir: str,
*,
streams: list[str] | None = None,
) -> dict[str, Any]:
"""Bundle a slice of each requested stream into `dest_dir`.
For a notebook, a bug report, or a Datadog backfill. Output files
are uncompressed JSONL (callers gzip themselves if they want to).
"""
s = _parse_time(start)
e = _parse_time(end)
selected = streams or ["logs", "telemetry", "packets", "events"]
dest = Path(dest_dir)
dest.mkdir(parents=True, exist_ok=True)
base = get_recorder().base_dir
paths: dict[str, str] = {}
for stream in selected:
src = base / f"{stream}.jsonl"
if not src.exists() and not list(base.glob(f"{stream}.*.jsonl.gz")):
continue
out_path = dest / f"{stream}.jsonl"
n = 0
with out_path.open("w", encoding="utf-8") as fh:
for rec in _iter_jsonl(src, since=s, until=e):
fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
n += 1
paths[stream] = str(out_path)
paths[f"{stream}_count"] = str(n)
return {"dest_dir": str(dest), "paths": paths, "window": {"start": s, "end": e}}
# -- helpers ------------------------------------------------------------
def _split_set(value: str | None) -> set[str] | None:
if not value:
return None
return {v.strip() for v in value.split("|") if v.strip()}
def _field_aliases(field: str) -> list[str]:
"""Accept snake_case OR camelCase, plus a few legacy aliases."""
snake = field
camel = _snake_to_camel(field)
aliases = {snake, camel}
# Old protobuf fields (pre-LocalStats) used different names
legacy = {
"free_heap": ["free_heap", "freeHeap", "heap_free_bytes", "heapFreeBytes"],
"heap_free_bytes": [
"heap_free_bytes",
"heapFreeBytes",
"free_heap",
"freeHeap",
],
"total_heap": ["total_heap", "totalHeap", "heap_total_bytes", "heapTotalBytes"],
"heap_total_bytes": [
"heap_total_bytes",
"heapTotalBytes",
"total_heap",
"totalHeap",
],
}
if field in legacy:
aliases.update(legacy[field])
return list(aliases)
def _snake_to_camel(name: str) -> str:
parts = name.split("_")
return parts[0] + "".join(p.title() for p in parts[1:])
def _downsample(
points: list[tuple[float, float]], *, max_points: int
) -> list[tuple[float, float]]:
if len(points) <= max_points:
return points
# Even-bucket mean. Preserves shape better than nth-sample picking.
n = len(points)
bucket = n / max_points
out: list[tuple[float, float]] = []
i = 0
for k in range(max_points):
end = int((k + 1) * bucket)
end = min(end, n)
if end <= i:
continue
chunk = points[i:end]
ts = chunk[len(chunk) // 2][0]
val = statistics.fmean(v for _, v in chunk)
out.append((ts, val))
i = end
return out
def _slope_per_min(points: list[tuple[float, float]]) -> float | None:
"""Least-squares slope (units per minute). None if too few points."""
if len(points) < 2:
return None
xs = [t for t, _ in points]
ys = [v for _, v in points]
n = len(xs)
mean_x = sum(xs) / n
mean_y = sum(ys) / n
num = sum((xs[i] - mean_x) * (ys[i] - mean_y) for i in range(n))
den = sum((x - mean_x) ** 2 for x in xs)
if den == 0:
return None
slope_per_sec = num / den
return slope_per_sec * 60.0
+147
View File
@@ -0,0 +1,147 @@
"""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
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
evidence when OCR is unavailable.
Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto`
(default `auto`).
`ocr_text(png_bytes) -> str` is the only public entry point. The reader is
constructed lazily on first call and cached, so the easyocr cold-start cost
only hits once per process.
"""
from __future__ import annotations
import functools
import logging
import os
import shutil
import sys
from typing import Callable
log = logging.getLogger(__name__)
def _backend_choice() -> str:
return os.environ.get("MESHTASTIC_UI_OCR_BACKEND", "auto").lower()
@functools.lru_cache(maxsize=1)
def _reader() -> tuple[str, Callable[[bytes], str]]:
"""Return `(backend_name, callable)` for whichever OCR is available."""
choice = _backend_choice()
def _easyocr() -> tuple[str, Callable[[bytes], str]]:
import easyocr # type: ignore[import-untyped] # noqa: PLC0415
import numpy as np # type: ignore[import-untyped] # noqa: PLC0415
reader = easyocr.Reader(["en"], gpu=False, verbose=False)
def _run(png: bytes) -> str:
try:
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
arr = np.frombuffer(png, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
except ImportError:
# Fall back to PIL if cv2 isn't around.
from io import BytesIO # noqa: PLC0415
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
img = np.array(Image.open(BytesIO(png)).convert("RGB"))
try:
results = reader.readtext(img, detail=0, paragraph=True)
except Exception as exc: # noqa: BLE001
log.warning("easyocr failed: %s", exc)
return ""
return "\n".join(str(r) for r in results)
return "easyocr", _run
def _pytesseract() -> tuple[str, Callable[[bytes], str]]:
from io import BytesIO # noqa: PLC0415
import pytesseract # type: ignore[import-untyped] # noqa: PLC0415
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
if shutil.which("tesseract") is None:
raise ImportError("`tesseract` binary not on PATH")
def _run(png: bytes) -> str:
try:
return str(pytesseract.image_to_string(Image.open(BytesIO(png))))
except Exception as exc: # noqa: BLE001
log.warning("pytesseract failed: %s", exc)
return ""
return "pytesseract", _run
def _null() -> tuple[str, Callable[[bytes], str]]:
log.warning(
"OCR backend is null; install easyocr or tesseract for text extraction"
)
return "null", lambda _png: ""
if choice == "easyocr":
return _easyocr()
if choice == "pytesseract":
return _pytesseract()
if choice == "null":
return _null()
if choice != "auto":
print(
f"[ocr] unknown MESHTASTIC_UI_OCR_BACKEND={choice!r}; falling back to auto",
file=sys.stderr,
)
# auto mode
try:
return _easyocr()
except ImportError:
pass
try:
return _pytesseract()
except ImportError:
pass
return _null()
def ocr_text(png_bytes: bytes) -> str:
"""Run OCR on a PNG-encoded image and return the decoded text (possibly empty)."""
if not png_bytes:
return ""
_, run = _reader()
return run(png_bytes)
def backend_name() -> str:
"""Return the currently-selected backend name, initializing if necessary."""
name, _ = _reader()
return name
def warm() -> None:
"""Run one dummy inference so the easyocr cold-start cost is paid upfront.
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.
white_png = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e"
"0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080"
"0b010000ffff030000000000000049454e44ae426082"
)
try:
ocr_text(white_png)
except Exception as exc: # noqa: BLE001
log.warning("ocr.warm() failed: %s", exc)
__all__ = ["backend_name", "ocr_text", "warm"]
+15
View File
@@ -92,6 +92,7 @@ def _run_capturing(
cwd: Path | None = None,
timeout: float | None = None,
tee_header: str | None = None,
extra_env: dict[str, str] | None = None,
) -> tuple[int, str, str, float]:
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
@@ -99,6 +100,9 @@ def _run_capturing(
`subprocess.TimeoutExpired` on timeout (callers map this to their own
domain-specific error).
`extra_env` merges into the subprocess environment (parent env stays
intact). Used for `PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar.
Fast path: `subprocess.run(capture_output=True)` when no flash log is
configured (unchanged behavior).
@@ -110,6 +114,9 @@ def _run_capturing(
"""
log_path = _flash_log_path()
t0 = time.monotonic()
env = None
if extra_env:
env = {**os.environ, **extra_env}
if log_path is None:
# Fast path — unchanged.
@@ -119,6 +126,7 @@ def _run_capturing(
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
return (
proc.returncode,
@@ -145,6 +153,7 @@ def _run_capturing(
stderr=subprocess.PIPE,
text=True,
bufsize=1, # line-buffered
env=env,
)
stdout_chunks: list[str] = []
stderr_chunks: list[str] = []
@@ -232,12 +241,17 @@ def run(
cwd: Path | None = None,
timeout: float | None = TIMEOUT_DEFAULT,
check: bool = True,
extra_env: dict[str, str] | None = None,
) -> PioResult:
"""Invoke `pio <args>` and return captured output.
`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
`PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time
toggles that can't be expressed as command-line args.
If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file
line-by-line as it arrives (for live flash progress in the TUI).
"""
@@ -250,6 +264,7 @@ def run(
cwd=work_dir,
timeout=timeout,
tee_header=f"pio {' '.join(args)}",
extra_env=extra_env,
)
except subprocess.TimeoutExpired as exc:
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
@@ -0,0 +1,19 @@
"""Persistent device-log capture.
Singleton `Recorder` subscribes once to the meshtastic pubsub fan-out
(`meshtastic.log.line`, `meshtastic.receive.*`, `meshtastic.connection.*`)
and appends to four JSONL files under `mcp-server/.mtlog/`. Pubsub is
process-global so a single subscription captures every active interface
(serial / TCP / BLE) without any per-connection bookkeeping.
The recorder is opt-in-by-import: importing this package is a no-op; call
`get_recorder().start()` (which `server.py` does at FastMCP app init) to
begin writing. `pause()` / `resume()` exist for the rare case the user
wants a clean stretch of file (e.g. capturing a known-good baseline).
"""
from __future__ import annotations
from .recorder import Recorder, get_recorder
__all__ = ["Recorder", "get_recorder"]
@@ -0,0 +1,309 @@
"""Best-effort parsers for log lines and telemetry packets.
Two flavors of log line cross our pubsub subscription:
1. Text-mode path (debug_log_api disabled): the meshtastic Python lib
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.
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
pubsub fan-out. We get e.g. just "Booting".
Both arrive on `meshtastic.log.line`. The parser tries to recover a
level + thread when the prefix is present and falls back to level=None
otherwise. Consumers who want level filtering on protobuf-mode hosts
should grep the raw `line` field instead.
Telemetry: `meshtastic.receive.telemetry` packets carry one of several
metric variants in `packet["decoded"]["telemetry"]`. We flatten the
chosen variant into a {field: value} dict so callers don't have to
know the protobuf shape.
"""
from __future__ import annotations
import re
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_*
# macros must stay in sync with these strings.
_LINE_RE = re.compile(
r"""
^
(?P<level>DEBUG|INFO\ |WARN\ |ERROR|CRIT\ |TRACE|HEAP\ )
\s*\|\s*
(?P<clock>(?:\d{2}:\d{2}:\d{2})|(?:\?{2}:\?{2}:\?{2}))
\s+
(?P<uptime>\d+)
\s+
(?:\[(?P<thread>[^\]]+)\]\s+)?
(?P<msg>.*)
$
""",
re.VERBOSE,
)
# DEBUG_HEAP build prepends `[heap N] ` to every message body, AFTER the
# thread bracket. See src/RedirectablePrint.cpp:175.
_HEAP_PREFIX_RE = re.compile(r"^\[heap\s+(?P<heap>\d+)\]\s+(?P<rest>.*)$")
# OSThread leak/free detection. See src/concurrency/OSThread.cpp:89-91.
# Format: "------ Thread NAME leaked heap A -> B (delta) ------"
# "++++++ Thread NAME freed heap A -> B (delta) ++++++"
_THREAD_HEAP_RE = re.compile(
r"""
^[\-+]+\s*
Thread\s+(?P<thread>\S+)\s+
(?P<kind>leaked|freed)\s+heap\s+
(?P<before>-?\d+)\s*->\s*(?P<after>-?\d+)\s+
\((?P<delta>-?\d+)\)
""",
re.VERBOSE,
)
# Power.cpp:908 periodic heap status (DEBUG_HEAP only).
# Format: "Heap status: FREE/TOTAL bytes free (DELTA), running R/N threads"
_HEAP_STATUS_RE = re.compile(
r"""
Heap\s+status:\s+
(?P<free>\d+)\s*/\s*(?P<total>\d+)\s+bytes\s+free
(?:\s+\((?P<delta>-?\d+)\))?
""",
re.VERBOSE,
)
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
_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
codes preserved for fidelity). Adds `level`, `tag`, `clock`,
`uptime_s`, and `msg` when the full prefix is present.
Handles two firmware quirks:
- LogRecord.message can carry ANSI color escapes from RedirectablePrint
(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 —
looks like `[heap 12345] msg`. We detect that shape and move it
out of `tag` and into `heap_free`.
DEBUG_HEAP-build extras (when `[heap N]` is injected): `heap_free`
(bytes), and when a `Thread X leaked|freed heap` line is recognized,
`heap_event` = {kind, thread, before, after, delta}.
Never raises.
"""
out: dict[str, Any] = {"line": line}
if not line:
return out
# Strip ANSI escapes BEFORE any regex matching. The original `line`
# stays in `out["line"]` for fidelity / future grep.
clean = _ANSI_RE.sub("", line)
m = _LINE_RE.match(clean)
msg: str | None = None
if m:
level = m.group("level").rstrip()
out["level"] = level
out["clock"] = m.group("clock")
try:
out["uptime_s"] = int(m.group("uptime"))
except (TypeError, ValueError):
out["uptime_s"] = None
thread = m.group("thread")
if thread:
# If "thread" is actually the heap prefix taking the bracket
# position (DEBUG_HEAP build, no thread set), capture heap
# and leave tag unset.
hb = _HEAP_BRACKET_RE.match(thread.strip())
if hb:
try:
out["heap_free"] = int(hb.group("heap"))
except (TypeError, ValueError):
pass
else:
out["tag"] = thread
msg = m.group("msg")
out["msg"] = msg
else:
# 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
# DEBUG_HEAP per-line heap prefix: `[heap 92344] message`.
# Sits AFTER the thread bracket and BEFORE the message body, but
# for bare LogRecord lines it's at the start. Match it at the
# head of `msg`.
if msg:
hp = _HEAP_PREFIX_RE.match(msg)
if hp:
try:
out["heap_free"] = int(hp.group("heap"))
except (TypeError, ValueError):
pass
else:
# Strip the prefix from `msg` so a grep on the message
# body doesn't have to know about it.
out["msg"] = hp.group("rest")
msg = hp.group("rest")
# Thread-level leak/free detection.
thr = _THREAD_HEAP_RE.search(msg)
if thr:
try:
out["heap_event"] = {
"kind": thr.group("kind"),
"thread": thr.group("thread"),
"before": int(thr.group("before")),
"after": int(thr.group("after")),
"delta": int(thr.group("delta")),
}
except (TypeError, ValueError):
pass
# Power.cpp periodic "Heap status: F/T bytes free (D), running ..."
hs = _HEAP_STATUS_RE.search(msg)
if hs:
try:
out["heap_free"] = int(hs.group("free"))
out["heap_total"] = int(hs.group("total"))
if hs.group("delta") is not None:
out["heap_delta"] = int(hs.group("delta"))
except (TypeError, ValueError):
pass
return out
# -- Telemetry ----------------------------------------------------------
# Order matters: meshtastic-python decoded packets use the protobuf
# `oneof variant` field name (snake_case) as the dict key.
_TELEMETRY_VARIANTS = (
("device_metrics", "device"),
("local_stats", "local"),
("environment_metrics", "environment"),
("power_metrics", "power"),
("air_quality_metrics", "airQuality"),
("health_metrics", "health"),
("host_metrics", "host"),
)
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
caller can fall back to a generic packets.jsonl row.
"""
if not isinstance(packet, dict):
return None
decoded = packet.get("decoded")
if not isinstance(decoded, dict):
return None
telem = decoded.get("telemetry")
if not isinstance(telem, dict):
return None
# The Python lib produces dict-of-camelCase keys via MessageToDict.
# Try both camelCase and snake_case to be robust to lib version drift.
for snake, label in _TELEMETRY_VARIANTS:
camel = _snake_to_camel(snake)
for key in (snake, camel):
value = telem.get(key)
if isinstance(value, dict):
return {
"variant": label,
"fields": {k: _scalarize(v) for k, v in value.items()},
"time": telem.get("time"),
}
return None
def _snake_to_camel(name: str) -> str:
parts = name.split("_")
return parts[0] + "".join(p.title() for p in parts[1:])
def _scalarize(value: Any) -> Any:
"""Keep telemetry fields JSON-friendly. Lists/dicts pass through
untouched; bytes -> hex string; protobuf enums occasionally arrive
as ints (fine) or strings (also fine)."""
if isinstance(value, (bytes, bytearray, memoryview)):
return bytes(value).hex()
return value
# -- Generic packet summary ---------------------------------------------
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.
"""
if not isinstance(packet, dict):
return {"raw_type": type(packet).__name__}
decoded = packet.get("decoded") if isinstance(packet.get("decoded"), dict) else {}
portnum = decoded.get("portnum") if isinstance(decoded, dict) else None
payload = decoded.get("payload") if isinstance(decoded, dict) else None
payload_hex = None
payload_size = None
if isinstance(payload, (bytes, bytearray, memoryview)):
b = bytes(payload)
payload_size = len(b)
payload_hex = b[:payload_hex_len].hex() if b else ""
elif isinstance(payload, str):
# Some decoded payloads (text messages) come as decoded strings.
payload_size = len(payload)
payload_hex = None # not bytes
return {
"from_node": packet.get("fromId") or packet.get("from"),
"to_node": packet.get("toId") or packet.get("to"),
"portnum": portnum,
"hop_limit": packet.get("hopLimit"),
"want_ack": packet.get("wantAck"),
"rx_rssi": packet.get("rxRssi"),
"rx_snr": packet.get("rxSnr"),
"channel": packet.get("channel"),
"id": packet.get("id"),
"payload_size": payload_size,
"payload_hex_prefix": payload_hex,
}
# -- Interface identification ------------------------------------------
def interface_label(interface: Any) -> dict[str, Any]:
"""Stable identifier for the meshtastic interface that emitted an event.
Used as the `port`/`role` tag on every recorded row. SerialInterface
has `devPath`; TCPInterface has `hostname`+`portNumber`; BLEInterface
has `address`. Falls back to the class name when none of those exist.
"""
if interface is None:
return {"port": None, "role": None}
dev_path = getattr(interface, "devPath", None)
if dev_path:
return {"port": str(dev_path), "role": "serial"}
hostname = getattr(interface, "hostname", None)
if hostname:
port_num = getattr(interface, "portNumber", None)
endpoint = f"tcp://{hostname}:{port_num}" if port_num else f"tcp://{hostname}"
return {"port": endpoint, "role": "tcp"}
address = getattr(interface, "address", None)
if address:
return {"port": str(address), "role": "ble"}
return {"port": type(interface).__name__, "role": None}
@@ -0,0 +1,467 @@
"""Process-global recorder singleton.
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
without per-connection bookkeeping.
Files:
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
variant (device / local / environment / power / etc.)
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
`mark_event` rows. Lower volume; useful for aligning
timelines.
Pause/resume: `pause()` flips a flag; subscriptions stay registered. The
write methods short-circuit when paused, so we don't lose ordering when
resumed (we just have a gap). No queueing.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
from . import parsers
from .rotating import _RotatingJsonl
_DEFAULT_DIR = Path(__file__).resolve().parents[3] / ".mtlog"
log = logging.getLogger(__name__)
class Recorder:
"""Singleton write-side of the persistent log capture system."""
def __init__(self, base_dir: Path | None = None) -> None:
self.base_dir = Path(base_dir) if base_dir else _DEFAULT_DIR
self._lock = threading.RLock()
self._started = False
self._paused = False
self._pause_reason: str | None = None
self._started_at: float | None = None
self._handlers: list[tuple[str, Any]] = []
self._files: dict[str, _RotatingJsonl] = {}
# -- lifecycle ----------------------------------------------------
def start(self) -> None:
"""Idempotent. Safe to call from FastMCP app startup."""
with self._lock:
if self._started:
return
self.base_dir.mkdir(parents=True, exist_ok=True)
self._files = {
"logs": _RotatingJsonl(self.base_dir / "logs.jsonl"),
"telemetry": _RotatingJsonl(self.base_dir / "telemetry.jsonl"),
"packets": _RotatingJsonl(self.base_dir / "packets.jsonl"),
"events": _RotatingJsonl(self.base_dir / "events.jsonl"),
}
self._wire_pubsub()
self._started = True
self._started_at = time.time()
# Write the recorder_start marker after the initialization block.
# `_write_event()` re-checks recorder state via `_files_snapshot()`,
# so keeping this out of the setup block avoids nested lifecycle work.
self._write_event(kind="recorder_start", label="recorder_started")
def stop(self) -> None:
with self._lock:
if not self._started:
return
self._unwire_pubsub()
for f in self._files.values():
f.close()
self._files = {}
self._started = False
def pause(self, reason: str | None = None) -> None:
# 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(
kind="recorder_pause",
label="paused",
note=reason,
)
with self._lock:
self._paused = True
self._pause_reason = reason
def resume(self) -> None:
# Mirror of `pause()`: clear the flag first, then write the marker
# so it isn't suppressed by the still-paused short-circuit.
with self._lock:
self._paused = False
self._pause_reason = None
self._write_event(kind="recorder_resume", label="resumed")
# -- pubsub wiring ------------------------------------------------
def _wire_pubsub(self) -> None:
from pubsub import pub # type: ignore[import-untyped]
# Subscribers — one per topic. Each pubsub publisher sends
# keyword args matching its handler's signature; pubsub
# introspects the function signature to route args.
bindings = [
("meshtastic.log.line", self._on_log_line),
("meshtastic.serial.line", self._on_serial_line),
("meshtastic.receive", self._on_receive),
("meshtastic.receive.telemetry", self._on_telemetry),
("meshtastic.connection.established", self._on_connection_established),
("meshtastic.connection.lost", self._on_connection_lost),
("meshtastic.node.updated", self._on_node_updated),
]
for topic, handler in bindings:
try:
pub.subscribe(handler, topic)
self._handlers.append((topic, handler))
except Exception as exc:
# If pubsub refuses one binding (signature mismatch on
# an old lib version), log it and keep the rest.
log.warning("Recorder failed to subscribe to %s: %s", topic, exc)
def _unwire_pubsub(self) -> None:
from pubsub import pub # type: ignore[import-untyped]
for topic, handler in self._handlers:
try:
pub.unsubscribe(handler, topic)
except Exception:
pass
self._handlers.clear()
# -- handlers -----------------------------------------------------
#
# Pubsub callbacks must never raise. Every handler is wrapped in a
# try/except that swallows so a bug here can't take down the
# SerialInterface receive thread.
#
# Threading: handlers fire on whatever thread the meshtastic library
# dispatches from (varies by interface), while `stop()` clears
# `self._files` under `self._lock`. We snapshot `_files` under the
# lock at the top of each handler so a concurrent stop can't
# KeyError us mid-write. The actual file write goes through
# `_RotatingJsonl` which has its own lock.
def _files_snapshot(self) -> dict[str, _RotatingJsonl] | None:
"""Atomic-ish view of `self._files`. Returns None when the recorder
is paused or stopped, so handlers can early-exit cleanly without
racing `stop()`'s clear."""
with self._lock:
if not self._started or self._paused:
return None
return dict(self._files)
def _on_log_line(self, line: str, interface: Any = None) -> None:
files = self._files_snapshot()
if files is None:
return
try:
tags = parsers.interface_label(interface)
parsed = parsers.parse_log_line(str(line))
ts = time.time()
record: dict[str, Any] = {
"ts": ts,
"port": tags["port"],
"role": tags["role"],
"level": parsed.get("level"),
"tag": parsed.get("tag"),
"uptime_s": parsed.get("uptime_s"),
"line": parsed["line"],
}
# DEBUG_HEAP enrichments (only present when the firmware
# was built with -DDEBUG_HEAP=1). Surface as first-class
# fields so logs_window can grep/filter on them and so
# heap_free synthesizes a telemetry point below.
if "heap_free" in parsed:
record["heap_free"] = parsed["heap_free"]
if "heap_total" in parsed:
record["heap_total"] = parsed["heap_total"]
if "heap_delta" in parsed:
record["heap_delta"] = parsed["heap_delta"]
heap_event = parsed.get("heap_event")
if heap_event:
record["heap_event"] = heap_event
files["logs"].write(record)
# If the line carried a heap snapshot, also write it as a
# synthesized LocalStats-shaped row so telemetry_timeline
# picks it up at log cadence (much higher resolution than
# the ~60 s LocalStats packet). Tagged source=debug_heap so
# consumers can filter if mixing scales is unwanted.
heap_free = parsed.get("heap_free")
if isinstance(heap_free, int):
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
heap_total = parsed.get("heap_total")
if isinstance(heap_total, int):
fields["heap_total_bytes"] = heap_total
files["telemetry"].write(
{
"ts": ts,
"port": tags["port"],
"role": tags["role"],
"from_node": None,
"variant": "local",
"fields": fields,
"source": "debug_heap",
}
)
except Exception:
pass
def _on_serial_line(self, line: str, port: str | None = None) -> None:
"""Text-mode passive tap. Fired from `serial_session._drain` when a
`pio device monitor` subprocess is running.
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
protobuf API mode (no SerialInterface required).
"""
files = self._files_snapshot()
if files is None:
return
try:
parsed = parsers.parse_log_line(str(line))
ts = time.time()
record: dict[str, Any] = {
"ts": ts,
"port": port,
"role": "serial_session",
"level": parsed.get("level"),
"tag": parsed.get("tag"),
"uptime_s": parsed.get("uptime_s"),
"line": parsed["line"],
}
if "heap_free" in parsed:
record["heap_free"] = parsed["heap_free"]
if "heap_total" in parsed:
record["heap_total"] = parsed["heap_total"]
if "heap_delta" in parsed:
record["heap_delta"] = parsed["heap_delta"]
heap_event = parsed.get("heap_event")
if heap_event:
record["heap_event"] = heap_event
files["logs"].write(record)
# Synthesize a heap_free telemetry sample whenever the line
# 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):
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
heap_total = parsed.get("heap_total")
if isinstance(heap_total, int):
fields["heap_total_bytes"] = heap_total
files["telemetry"].write(
{
"ts": ts,
"port": port,
"role": "serial_session",
"from_node": None,
"variant": "local",
"fields": fields,
"source": "debug_heap_serial",
}
)
except Exception:
pass
def _on_telemetry(self, packet: dict[str, Any], interface: Any = None) -> None:
files = self._files_snapshot()
if files is None:
return
try:
tags = parsers.interface_label(interface)
extracted = parsers.extract_telemetry(packet)
if extracted is None:
# 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
record = {
"ts": time.time(),
"port": tags["port"],
"role": tags["role"],
"from_node": packet.get("fromId") or packet.get("from"),
"variant": extracted["variant"],
"fields": extracted["fields"],
"device_time": extracted.get("time"),
}
files["telemetry"].write(record)
except Exception:
pass
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:
# packets.jsonl is the universal record, telemetry.jsonl is the
# structured timeseries view.
files = self._files_snapshot()
if files is None:
return
try:
tags = parsers.interface_label(interface)
summary = parsers.summarize_packet(packet)
record = {
"ts": time.time(),
"port": tags["port"],
"role": tags["role"],
**summary,
}
files["packets"].write(record)
except Exception:
pass
def _on_connection_established(self, interface: Any = None) -> None:
self._write_event(
kind="connection_established",
interface=interface,
)
def _on_connection_lost(self, interface: Any = None) -> None:
self._write_event(
kind="connection_lost",
interface=interface,
)
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,
# last heard. Skip the user dict if absent.
try:
user = (node or {}).get("user") if isinstance(node, dict) else None
self._write_event(
kind="node_updated",
interface=interface,
data={
"num": (node or {}).get("num"),
"id": (user or {}).get("id"),
"short": (user or {}).get("shortName"),
"long": (user or {}).get("longName"),
"hops_away": (node or {}).get("hopsAway"),
"snr": (node or {}).get("snr"),
"last_heard": (node or {}).get("lastHeard"),
},
)
except Exception:
pass
# -- public write helpers -----------------------------------------
def mark_event(
self,
label: str,
note: str | None = None,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""User-facing marker. Writes to events.jsonl AND emits a
synthetic logs.jsonl row tagged level=MARK so timelines align.
"""
ts = self._write_event(kind="mark", label=label, note=note, data=data)
# Mirror into logs so a single logs_window grep finds it.
files = self._files_snapshot()
if files is not None:
try:
files["logs"].write(
{
"ts": ts,
"port": None,
"role": "marker",
"level": "MARK",
"tag": "mark_event",
"line": f"[mark] {label}" + (f" — {note}" if note else ""),
}
)
except Exception:
pass
return {"ts": ts, "label": label}
def _write_event(
self,
*,
kind: str,
label: str | None = None,
note: str | None = None,
interface: Any = None,
data: dict[str, Any] | None = None,
) -> float:
ts = time.time()
# Lifecycle markers (recorder_start, recorder_pause, recorder_resume)
# 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.
files = self._files_snapshot()
if files is None:
return ts
try:
tags = parsers.interface_label(interface)
files["events"].write(
{
"ts": ts,
"kind": kind,
"label": label,
"note": note,
"port": tags["port"],
"role": tags["role"],
"data": data,
}
)
except Exception:
pass
return ts
# -- introspection ------------------------------------------------
def status(self) -> dict[str, Any]:
with self._lock:
return {
"running": self._started,
"paused": self._paused,
"pause_reason": self._pause_reason,
"started_at": self._started_at,
"base_dir": str(self.base_dir),
"files": {name: f.status() for name, f in self._files.items()},
}
def force_rotate_all(self) -> dict[str, Any]:
"""Test/admin hook: rotate every stream right now."""
with self._lock:
files = list(self._files.values())
for f in files:
f.force_rotate()
# `status()` re-acquires `self._lock`; release before calling it.
return self.status()
# -- module-level singleton accessor ------------------------------------
_INSTANCE_LOCK = threading.Lock()
_INSTANCE: Recorder | None = None
def get_recorder() -> Recorder:
"""Return the process-global Recorder. Created on first call.
Honors `MESHTASTIC_MCP_LOG_DIR` env var for the base directory
(used by tests to redirect to a tmpdir).
"""
global _INSTANCE
with _INSTANCE_LOCK:
if _INSTANCE is None:
override = os.environ.get("MESHTASTIC_MCP_LOG_DIR")
base = Path(override) if override else None
_INSTANCE = Recorder(base_dir=base)
return _INSTANCE
@@ -0,0 +1,163 @@
"""Append-only JSONL writer with size-capped rotation.
A `_RotatingJsonl` owns one live `.jsonl` file. Writes are line-delimited
JSON objects (one row per call). When the live file exceeds `max_bytes`,
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,
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
several pubsub handlers on whatever thread the meshtastic library
dispatches from (varies by interface), and queries from MCP tool calls
arrive on the FastMCP request thread, so this lock is not optional.
"""
from __future__ import annotations
import gzip
import json
import os
import shutil
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
class _RotatingJsonl:
"""Append-only JSONL with size rotation. Thread-safe."""
def __init__(
self,
path: Path,
*,
max_bytes: int = 100 * 1024 * 1024,
keep_archives: int = 5,
check_every: int = 1000,
) -> None:
self.path = path
self.max_bytes = max_bytes
self.keep_archives = keep_archives
self.check_every = check_every
self._lock = threading.Lock()
self._fh: Any = None
self._writes_since_check = 0
self._rotations = 0
self._lines_written = 0
self._last_ts: float | None = None
self._open()
# -- lifecycle ----------------------------------------------------
def _open(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._fh = self.path.open("a", encoding="utf-8")
def close(self) -> None:
with self._lock:
if self._fh is not None:
try:
self._fh.close()
finally:
self._fh = None
# -- write --------------------------------------------------------
def write(self, record: dict[str, Any]) -> None:
"""Append one JSON object as a line. Triggers rotation if oversized."""
line = json.dumps(record, separators=(",", ":"), default=str) + "\n"
with self._lock:
if self._fh is None:
return
try:
self._fh.write(line)
self._fh.flush()
except Exception:
# Best-effort: a failed write must not crash the pubsub
# handler. Caller has no way to react anyway.
return
self._lines_written += 1
ts = record.get("ts")
if isinstance(ts, (int, float)):
self._last_ts = float(ts)
self._writes_since_check += 1
if self._writes_since_check >= self.check_every:
self._writes_since_check = 0
self._maybe_rotate()
# -- rotation -----------------------------------------------------
def _maybe_rotate(self) -> None:
# Caller holds self._lock.
try:
size = os.fstat(self._fh.fileno()).st_size
except OSError:
return
if size < self.max_bytes:
return
self._rotate_locked()
def _rotate_locked(self) -> None:
# Close, gzip-rename, reopen empty, prune oldest archives.
try:
self._fh.close()
except Exception:
pass
self._fh = None
# Microsecond-resolution timestamp + per-instance counter so back-
# to-back rotations (small max_bytes, repeated `force_rotate()`,
# or chatty test loops) get unique archive filenames. The lex
# sort order of `YYYYMMDD-HHMMSS-uuuuuu-NNNNN` is chronological,
# which `_prune_archives()` and `log_query._iter_jsonl()` both
# rely on.
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
archive = self.path.with_suffix(f".{stamp}-{self._rotations:05d}.jsonl.gz")
try:
with self.path.open("rb") as src, gzip.open(archive, "wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
self.path.unlink()
except Exception:
# Rotation is best-effort. If gzip fails, leave the file
# in place and re-open it; we'll try again next check.
pass
self._open()
self._rotations += 1
self._prune_archives()
def _prune_archives(self) -> None:
# Match siblings of self.path.name with `.jsonl.gz` suffix.
prefix = self.path.stem # "logs" for "logs.jsonl"
# Archive filenames are already lexicographically chronological.
# Prune by name, not mtime, so copied/restored files don't reorder.
archives = sorted(self.path.parent.glob(f"{prefix}.*.jsonl.gz"))
excess = len(archives) - self.keep_archives
for old in archives[: max(0, excess)]:
try:
old.unlink()
except OSError:
pass
def force_rotate(self) -> None:
"""Test/admin hook: rotate immediately regardless of size."""
with self._lock:
if self._fh is not None:
self._rotate_locked()
# -- introspection ------------------------------------------------
def status(self) -> dict[str, Any]:
with self._lock:
try:
size = os.fstat(self._fh.fileno()).st_size if self._fh else 0
except OSError:
size = 0
return {
"path": str(self.path),
"size": size,
"lines": self._lines_written,
"last_ts": self._last_ts,
"rotations": self._rotations,
}
@@ -46,7 +46,23 @@ class SerialSession:
def _drain(session: SerialSession) -> None:
"""Reader thread: line-by-line pull stdout into buffer."""
"""Reader thread: line-by-line pull stdout into buffer.
Each line is also published to the `meshtastic.serial.line` pubsub
topic so the persistent recorder can capture it without holding its
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 —
publish failures must never block the reader.
"""
# Lazy import: pubsub isn't required just to import this module
# (e.g., during static analysis), and we want a clean test surface.
try:
from pubsub import pub # type: ignore[import-untyped]
except Exception: # pragma: no cover - defensive
pub = None
assert session.proc.stdout is not None
try:
for line in session.proc.stdout:
@@ -54,6 +70,16 @@ def _drain(session: SerialSession) -> None:
with session.lock:
session.buffer.append(line_stripped)
session.total_lines += 1
if pub is not None:
try:
pub.sendMessage(
"meshtastic.serial.line",
line=line_stripped,
port=session.port,
)
except Exception:
# A subscriber raising must not break the reader.
pass
except Exception: # pragma: no cover - defensive
pass
finally:
@@ -71,6 +97,10 @@ def open_session(
If `env` is supplied, pio resolves baud and filters from platformio.ini.
Otherwise uses the supplied `baud` and `filters` (default `['direct']`).
"""
# Lazy import to avoid circular: registry imports serial_session.
from . import connection
connection.reject_if_tcp(port, "serial_open")
args = ["device", "monitor", "--port", port, "--no-reconnect"]
effective_filters: list[str]
effective_baud: int = baud
+378 -3
View File
@@ -1,4 +1,4 @@
"""FastMCP server wiring — 38 tools across 7 categories.
"""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.
@@ -6,6 +6,7 @@ etc.). Business logic does not live here.
from __future__ import annotations
import logging
from typing import Any
from mcp.server.fastmcp import FastMCP
@@ -17,14 +18,34 @@ from . import (
flash,
hw_tools,
info,
log_query,
registry,
serial_session,
)
from . import userprefs as userprefs_mod
from .recorder import get_recorder
log = logging.getLogger(__name__)
app = FastMCP("meshtastic-mcp")
def _start_recorder() -> None:
# 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
# for the full design. Recorder startup is best-effort: an unwritable
# log dir or pubsub mismatch should not take the MCP server down.
try:
get_recorder().start()
except Exception as exc:
log.warning("Failed to start persistent recorder: %s", exc)
_start_recorder()
# ---------- Discovery & metadata ------------------------------------------
@@ -75,6 +96,7 @@ def build(
env: str,
with_manifest: bool = True,
userprefs: dict[str, Any] | None = None,
build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build firmware for one env via `pio run -e <env>`.
@@ -86,8 +108,21 @@ def build(
build via userPrefs.jsonc injection. The file is restored after the build
completes. Use `userprefs_manifest` to discover available keys. Use
`userprefs_set` for persistent changes.
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for this build
only, injected via `PLATFORMIO_BUILD_FLAGS`. Common pattern:
`build_flags={"DEBUG_HEAP": 1}` enables per-thread leak detection + a
`[heap N]` prefix on every log line. The recorder picks the prefix up
automatically and synthesizes a high-resolution heap timeline that
`telemetry_timeline(field="free_heap")` can read alongside the normal
~60 s LocalStats packets. Pair with `/leakhunt` for classification.
"""
return flash.build(env, with_manifest=with_manifest, userprefs_overrides=userprefs)
return flash.build(
env,
with_manifest=with_manifest,
userprefs_overrides=userprefs,
build_flags=build_flags,
)
@app.tool()
@@ -105,6 +140,7 @@ def pio_flash(
port: str,
confirm: bool = False,
userprefs: dict[str, Any] | None = None,
build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
@@ -114,8 +150,19 @@ def pio_flash(
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
build via userPrefs.jsonc injection; restored after upload.
`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
rebuild relinks without the env var and silently drops them.
"""
return flash.flash(env, port, confirm=confirm, userprefs_overrides=userprefs)
return flash.flash(
env,
port,
confirm=confirm,
userprefs_overrides=userprefs,
build_flags=build_flags,
)
@app.tool()
@@ -513,6 +560,152 @@ def factory_reset(
return admin.factory_reset(port=port, confirm=confirm, full=full)
@app.tool()
def send_input_event(
event_code: int | str,
kb_char: int = 0,
touch_x: int = 0,
touch_y: int = 0,
port: str | None = None,
) -> dict[str, Any]:
"""Inject an InputBroker event (button / key / gesture) into the device UI.
Drives the same code path as a physical button press. Accepts a numeric
event code (0..255) or a name like `"RIGHT"`, `"SELECT"`, `"FN_F1"`.
Common codes: SELECT=10, UP=17, DOWN=18, LEFT=19, RIGHT=20, CANCEL=24,
BACK=27, FN_F1..F5=241..245.
"""
return admin.send_input_event(
event_code=event_code,
kb_char=kb_char,
touch_x=touch_x,
touch_y=touch_y,
port=port,
)
@app.tool()
def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]:
"""Grab a frame from the USB webcam pointed at the device screen.
Returns PNG bytes (base64), optional OCR text, and backend metadata.
Requires the `[ui]` extras (opencv-python-headless) and a camera
configured via `MESHTASTIC_UI_CAMERA_DEVICE[_<ROLE>]`. Falls back to a
1×1 black PNG from the null backend when no camera is configured.
"""
import base64
from . import camera as camera_mod
cam = camera_mod.get_camera(role)
try:
png = cam.capture()
finally:
cam.close()
result: dict[str, Any] = {
"backend": cam.name,
"bytes": len(png),
"image_base64": base64.b64encode(png).decode("ascii"),
}
if ocr:
from . import ocr as ocr_mod
result["ocr_backend"] = ocr_mod.backend_name()
result["ocr_text"] = ocr_mod.ocr_text(png)
return result
# ---------- USB power control (uhubctl) -----------------------------------
@app.tool()
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
(`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.
"""
from . import uhubctl as uhubctl_mod
return uhubctl_mod.list_hubs()
@app.tool()
def uhubctl_power(
action: str,
location: str | None = None,
port: int | None = None,
role: str | None = None,
confirm: bool = False,
) -> 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").
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
device will immediately disappear from the OS).
"""
from . import uhubctl as uhubctl_mod
action_lower = action.lower()
if action_lower not in {"on", "off"}:
raise ValueError(f"action must be 'on' or 'off', got {action!r}")
if action_lower == "off" and not confirm:
raise uhubctl_mod.UhubctlError(
"uhubctl_power action='off' requires confirm=True"
)
loc, p = _resolve_uhubctl_target(location, port, role)
if action_lower == "on":
return uhubctl_mod.power_on(loc, p)
return uhubctl_mod.power_off(loc, p)
@app.tool()
def uhubctl_cycle(
location: str | None = None,
port: int | None = None,
role: str | None = None,
delay_s: int = 2,
confirm: bool = False,
) -> 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
because uhubctl handles the timing in-process. Target by (location,
port) or by role (see `uhubctl_power`). Requires `confirm=True`.
"""
from . import uhubctl as uhubctl_mod
if not confirm:
raise uhubctl_mod.UhubctlError("uhubctl_cycle requires confirm=True")
if delay_s < 0 or delay_s > 60:
raise ValueError(f"delay_s must be 0..60, got {delay_s}")
loc, p = _resolve_uhubctl_target(location, port, role)
return uhubctl_mod.cycle(loc, p, delay_s=delay_s)
def _resolve_uhubctl_target(
location: str | None, port: int | None, role: str | None
) -> tuple[str, int]:
"""Shared arg-resolution for uhubctl_power + uhubctl_cycle."""
from . import uhubctl as uhubctl_mod
if role is not None:
if location is not None or port is not None:
raise ValueError("pass either `role` OR (`location` + `port`), not both")
return uhubctl_mod.resolve_target(role)
if location is None or port is None:
raise ValueError("must pass `role` or both `location` and `port`")
return (location, int(port))
# ---------- Direct hardware tools -----------------------------------------
@@ -588,3 +781,185 @@ def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
return hw_tools.picotool_raw(args, confirm=confirm)
# ---------- Persistent device-log capture (recorder) ----------------------
#
# 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.
@app.tool()
def logs_window(
start: str = "-15m",
end: str = "now",
grep: str | None = None,
level: str | None = None,
tag: str | None = None,
port: str | None = None,
max_lines: int = 200,
) -> dict[str, Any]:
"""Recent firmware log lines from the persistent recorder.
Filters by time window, regex over the line, level (single or
pipe-separated set like "WARN|ERROR|CRIT"), thread-name tag, and
interface port. Returns up to max_lines most-recent matches.
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
meshtastic Python lib drops record.level before fan-out. For those,
`level` filter won't match; use `grep` instead.
"""
return log_query.logs_window(
start=start,
end=end,
grep=grep,
level=level,
tag=tag,
port=port,
max_lines=max_lines,
)
@app.tool()
def telemetry_timeline(
window: str = "1h",
variant: str = "local",
field: str = "free_heap",
port: str | None = None,
max_points: int = 200,
) -> dict[str, Any]:
"""Time series of one telemetry field, downsampled to <= max_points.
`variant` ∈ device, local, environment, power, airQuality, health, host.
`field` accepts snake_case or camelCase; common aliases (free_heap ↔
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
a long window indicates a real leak.
LocalStats variant ("local") cadence is ~60 s (whatever the device's
`device_update_interval` is set to), so a 1 h window gives ~60 raw
points. Bucket-mean downsampling preserves shape.
"""
return log_query.telemetry_timeline(
window=window,
variant=variant,
field=field,
port=port,
max_points=max_points,
)
@app.tool()
def packets_window(
start: str = "-5m",
end: str = "now",
portnum: str | None = None,
from_node: str | None = None,
to_node: str | None = None,
max: int = 200,
) -> dict[str, Any]:
"""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.
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
"""
return log_query.packets_window(
start=start,
end=end,
portnum=portnum,
from_node=from_node,
to_node=to_node,
max=max,
)
@app.tool()
def events_window(
start: str = "-1h",
end: str = "now",
kind: str | None = None,
max: int = 200,
) -> dict[str, Any]:
"""Return recorder events: connection lifecycle, node updates, and `mark_event` markers.
`kind` ∈ recorder_start, recorder_pause, recorder_resume,
connection_established, connection_lost, node_updated, mark.
Pipe-separated sets ("connection_lost|connection_established") work.
"""
return log_query.events_window(start=start, end=end, kind=kind, max=max)
@app.tool()
def mark_event(
label: str,
note: str | None = None,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Drop a named marker into events.jsonl AND logs.jsonl.
Useful for aligning a timeline around a known stimulus: call before
and after a stress workload, then query telemetry_timeline /
logs_window with the markers' timestamps as bounds.
The marker also lands in logs.jsonl with level=MARK so a single
grep over logs picks it up.
"""
return get_recorder().mark_event(label=label, note=note, data=data)
@app.tool()
def recorder_status() -> dict[str, Any]:
"""Return recorder runtime info: running, paused, file sizes, last_ts per stream.
Use this to sanity-check that capture is working before you trust a
`logs_window` / `telemetry_timeline` result.
"""
return get_recorder().status()
@app.tool()
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
"""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
pollute with pre-test noise. Default state is recording; this is
rarely needed.
"""
get_recorder().pause(reason=reason)
return {"ok": True, "paused": True, "reason": reason}
@app.tool()
def recorder_resume() -> dict[str, Any]:
"""Resume writes after `recorder_pause`. No-op if already running."""
get_recorder().resume()
return {"ok": True, "paused": False}
@app.tool()
def recorder_export(
start: str,
end: str,
dest_dir: str,
streams: list[str] | None = None,
) -> dict[str, Any]:
"""Bundle a slice of the recorder's streams into `dest_dir`.
Writes one uncompressed JSONL per requested stream (logs / telemetry /
packets / events). Useful for: attaching to a bug report, feeding a
notebook, or backfilling Datadog after the fact.
"""
return log_query.export(
start=start,
end=end,
dest_dir=dest_dir,
streams=streams,
)
+321
View File
@@ -0,0 +1,321 @@
"""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
programmatically.
Architecture:
- `list_hubs()` parses `uhubctl` default output into structured records.
- `find_port_for_vid(vid)` walks the hubs to find which location+port
hosts a given USB VID.
- `resolve_target(role)` is the public entry for callers that know a role
(`nrf52`, `esp32s3`) but not a hub location: env-var pins win, VID
auto-detect falls back.
- `power_on`, `power_off`, `cycle` wrap the corresponding `uhubctl -a`
invocations, routed through `hw_tools._run` so they share tee-to-flash-
log + timeout handling with esptool / nrfutil / picotool.
Sudo policy: **fail fast**. Modern macOS + most PPPS-capable hubs work
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.
"""
from __future__ import annotations
import os
import re
from typing import Any, Sequence
from . import config, hw_tools
# ---------- Parser ---------------------------------------------------------
# Hub descriptor line:
# Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
_HUB_RE = re.compile(
r"^Current status for hub (?P<location>\S+)\s+\[(?P<descriptor>.+)\]\s*$"
)
# Port line:
# " Port 2: 0103 power enable connect [239a:8029 RAKwireless ...]"
# The bracketed section is absent for empty ports.
_PORT_RE = re.compile(
r"^\s+Port\s+(?P<port>\d+):\s+(?P<status>\S+)\s+(?P<flags>.*?)"
r"(?:\s+\[(?P<device_vid>[0-9a-fA-F]{4}):(?P<device_pid>[0-9a-fA-F]{4})(?:\s+(?P<device_desc>.+))?\])?\s*$"
)
class UhubctlError(RuntimeError):
"""Raised on uhubctl-specific failures: parse errors, permission denied,
hub-not-found, or PPPS not supported."""
# ---------- 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.
ROLE_VIDS: dict[str, tuple[int, ...]] = {
"nrf52": (0x239A,),
"esp32s3": (0x303A, 0x10C4),
}
def _normalize_role(role: str) -> str:
"""Collapse `esp32s3_alt` → `esp32s3` to match the tier conventions."""
return role.split("_alt", 1)[0].lower()
# ---------- 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 —
# 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 = (
"permission denied",
"operation not permitted",
"try running as root",
"need root",
"requires root",
)
def _run_uhubctl(args: Sequence[str], *, timeout: float = 30.0) -> dict[str, Any]:
"""Invoke uhubctl with the given args. Returns `hw_tools._run`'s dict.
Translates permission-denied failures into a `UhubctlError` with the
install hint, so callers don't have to match stderr themselves. Other
non-zero exits are returned as-is for the caller to interpret.
"""
binary = config.uhubctl_bin()
result = hw_tools._run(binary, args, timeout=timeout) # noqa: SLF001
if result["exit_code"] != 0:
combined = (result.get("stderr") or "") + "\n" + (result.get("stdout") or "")
lower = combined.lower()
if any(pat in lower for pat in _PERM_ERROR_PATTERNS):
raise UhubctlError(
"uhubctl exited with a permission error. Install the udev "
"rules on Linux, or try `sudo` as a fallback: "
"https://github.com/mvp/uhubctl#linux-usb-permissions\n"
f"stderr: {result.get('stderr_tail')!r}"
)
return result
# ---------- List / parse ---------------------------------------------------
def parse_list_output(output: str) -> list[dict[str, Any]]:
"""Parse the default `uhubctl` stdout into structured hubs.
Each hub: {
"location": "1-1.3",
"descriptor": "2109:2817 VIA Labs ...",
"vid": 0x2109,
"pid": 0x2817,
"ppps": bool,
"ports": [{"port": int, "status": str, "flags": str,
"device_vid": int | None, "device_pid": int | None,
"device_desc": str | None}, ...],
}
"""
hubs: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for line in output.splitlines():
hm = _HUB_RE.match(line)
if hm:
descriptor = hm.group("descriptor")
hub_vid, hub_pid = None, None
vid_match = re.match(r"([0-9a-fA-F]{4}):([0-9a-fA-F]{4})", descriptor)
if vid_match:
hub_vid = int(vid_match.group(1), 16)
hub_pid = int(vid_match.group(2), 16)
current = {
"location": hm.group("location"),
"descriptor": descriptor,
"vid": hub_vid,
"pid": hub_pid,
"ppps": ", ppps" in descriptor or descriptor.endswith("ppps"),
"ports": [],
}
hubs.append(current)
continue
pm = _PORT_RE.match(line)
if pm and current is not None:
device_vid = pm.group("device_vid")
device_pid = pm.group("device_pid")
current["ports"].append(
{
"port": int(pm.group("port")),
"status": pm.group("status"),
"flags": (pm.group("flags") or "").strip(),
"device_vid": int(device_vid, 16) if device_vid else None,
"device_pid": int(device_pid, 16) if device_pid else None,
"device_desc": (pm.group("device_desc") or "").strip() or None,
}
)
return hubs
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
a destructive `power_off` call.
"""
result = _run_uhubctl([], timeout=15.0)
if result["exit_code"] != 0:
raise UhubctlError(
f"uhubctl list failed (exit {result['exit_code']}): {result.get('stderr_tail')!r}"
)
return parse_list_output(result["stdout"])
# ---------- Lookup / resolution -------------------------------------------
def find_port_for_vid(
vid: int, pid: int | None = None, *, only_ppps: bool = True
) -> 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
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).
"""
hubs = list_hubs()
matches: list[tuple[str, int]] = []
for hub in hubs:
if only_ppps and not hub["ppps"]:
continue
for port in hub["ports"]:
if port["device_vid"] != vid:
continue
if pid is not None and port["device_pid"] != pid:
continue
matches.append((hub["location"], port["port"]))
return matches
def resolve_target(role: str) -> tuple[str, int]:
"""Resolve a Meshtastic role to (hub_location, port_number).
Priority:
1. Env vars `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` + `_PORT_<ROLE>`
(e.g. `MESHTASTIC_UHUBCTL_LOCATION_NRF52=1-1.3`, `_PORT_NRF52=2`).
2. VID auto-detect against `ROLE_VIDS[role]`, taking the first PPPS
match.
Raises `UhubctlError` on ambiguity (multiple matches) or no-match. The
env-var path exists specifically to disambiguate when two devices share
a VID.
"""
role = _normalize_role(role)
env_key_loc = f"MESHTASTIC_UHUBCTL_LOCATION_{role.upper()}"
env_key_port = f"MESHTASTIC_UHUBCTL_PORT_{role.upper()}"
loc = os.environ.get(env_key_loc)
port_str = os.environ.get(env_key_port)
if loc and port_str:
try:
return (loc, int(port_str))
except ValueError as exc:
raise UhubctlError(
f"{env_key_port}={port_str!r} is not a valid integer"
) from exc
if role not in ROLE_VIDS:
raise UhubctlError(
f"unknown role {role!r}; known roles: {sorted(ROLE_VIDS)}. "
f"Set {env_key_loc} + {env_key_port} to pin manually."
)
matches: list[tuple[str, int]] = []
for vid in ROLE_VIDS[role]:
matches.extend(find_port_for_vid(vid))
if not matches:
vids = ", ".join(f"0x{v:04x}" for v in ROLE_VIDS[role])
raise UhubctlError(
f"no controllable hub hosts a device with VID in {{{vids}}} "
f"for role={role!r}. Check the device is plugged into a "
f"PPPS-capable hub, or pin manually via {env_key_loc} + {env_key_port}."
)
if len(matches) > 1:
shown = ", ".join(f"{loc}:port{p}" for loc, p in matches)
raise UhubctlError(
f"ambiguous: multiple devices match role={role!r} ({shown}). "
f"Pin the target via {env_key_loc} + {env_key_port}."
)
return matches[0]
# ---------- Power actions --------------------------------------------------
def _action(
action: str,
location: str,
port: int,
*,
delay_s: int | None = None,
timeout: float = 30.0,
) -> dict[str, Any]:
args: list[str] = ["-a", action, "-l", location, "-p", str(port)]
if delay_s is not None:
args.extend(["-d", str(delay_s)])
# Suppress verbose "before" printout so our parser doesn't have to skip it.
args.append("-N")
result = _run_uhubctl(args, timeout=timeout)
if result["exit_code"] != 0:
raise UhubctlError(
f"uhubctl -a {action} -l {location} -p {port} failed "
f"(exit {result['exit_code']}): {result.get('stderr_tail')!r}"
)
return {
"action": action,
"location": location,
"port": port,
"delay_s": delay_s,
"duration_s": result["duration_s"],
}
def power_on(location: str, port: int) -> dict[str, Any]:
"""Drive the port VBUS high. Device re-enumerates in 1-3 s on healthy hubs."""
return _action("on", location, port)
def power_off(location: str, port: int) -> dict[str, Any]:
"""Drive the port VBUS low. Device disappears from `list_devices` immediately."""
return _action("off", location, port)
def cycle(location: str, port: int, delay_s: int = 2) -> dict[str, Any]:
"""Off → wait `delay_s` → on. The common hard-reset pattern."""
# uhubctl's own `-a cycle` handles the delay internally; we use a
# slightly longer timeout to accommodate delay_s + enumeration.
return _action("cycle", location, port, delay_s=delay_s, timeout=30.0 + delay_s * 2)
__all__ = [
"ROLE_VIDS",
"UhubctlError",
"cycle",
"find_port_for_vid",
"list_hubs",
"parse_list_output",
"power_off",
"power_on",
"resolve_target",
]
@@ -393,6 +393,7 @@ def build_testing_profile(
long_name: str | None = None,
disable_mqtt: bool = True,
disable_position: bool = False,
enable_ui_log: bool = False,
) -> dict[str, Any]:
"""Build a USERPREFS dict for an isolated test-mesh device.
@@ -423,6 +424,10 @@ def build_testing_profile(
traffic never leaks to a public broker.
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
line per frame transition. Test-only; off by default because the
log is chatty (multiple times per second during UI interaction).
"""
if region not in KNOWN_REGIONS:
@@ -475,6 +480,9 @@ def build_testing_profile(
prefs["USERPREFS_CONFIG_OWNER_LONG_NAME"] = long_name
if short_name is not None:
prefs["USERPREFS_CONFIG_OWNER_SHORT_NAME"] = short_name
if enable_ui_log:
# Consumed by `#ifdef USERPREFS_UI_TEST_LOG` in src/graphics/Screen.cpp.
prefs["USERPREFS_UI_TEST_LOG"] = True
return prefs
+112
View File
@@ -0,0 +1,112 @@
"""USB hub power control for tests — thin composition of the `uhubctl`
module + `_port_discovery.resolve_port_by_role`.
Why separate from the production module:
- `meshtastic_mcp.uhubctl.cycle` returns as soon as uhubctl exits (VBUS is
back on, but the device hasn't finished enumerating as a CDC port yet).
- Tests that want to immediately issue a `connect(port=...)` need the NEW
`/dev/cu.*` path, which can differ from the pre-cycle path on nRF52
boards (CDC re-enumeration assigns a fresh `cu.usbmodemNNNN`).
- `resolve_port_by_role` already handles that wait + path-resolution for
the `factory_reset` flow. Composing the two gives a one-call helper.
Also exposes `is_uhubctl_available()` so fixtures can skip cleanly when
uhubctl isn't installed — we never want "no uhubctl" to look like a test
failure.
"""
from __future__ import annotations
import time
from typing import Any
from meshtastic_mcp import config as config_mod
from meshtastic_mcp import uhubctl as uhubctl_mod
from ._port_discovery import resolve_port_by_role
def is_uhubctl_available() -> bool:
"""Return True iff `config.uhubctl_bin()` resolves AND the binary is callable.
Soft-fails silently — fixtures use this to `pytest.skip` with an
actionable message when the operator hasn't installed uhubctl.
"""
try:
config_mod.uhubctl_bin()
except Exception: # noqa: BLE001
return False
# Do NOT actually invoke uhubctl here — on macOS a non-sudo run would
# fail, which is a config issue, not a tool-missing issue. That gets
# surfaced to the user when they actually run a recovery action.
return True
def power_on(role: str) -> dict[str, Any]:
"""Power on the hub port hosting `role`. Does NOT wait for re-enumeration.
Use `power_cycle` or follow with `resolve_port_by_role` to block on readiness.
"""
loc, port = uhubctl_mod.resolve_target(role)
return uhubctl_mod.power_on(loc, port)
def power_off(role: str) -> dict[str, Any]:
"""Power off the hub port hosting `role`. The device disappears from
`list_devices` immediately.
"""
loc, port = uhubctl_mod.resolve_target(role)
return uhubctl_mod.power_off(loc, port)
def power_cycle(
role: str,
*,
delay_s: int = 2,
rediscover_timeout_s: float = 30.0,
) -> str:
"""Cycle the port hosting `role`, wait for re-enumeration, return the
new port path.
On nRF52 the post-cycle path typically matches the pre-cycle path, but
macOS may assign a different `/dev/cu.usbmodemNNNN` if the previous
CDC endpoint hasn't been fully released. `resolve_port_by_role`
handles that transparently.
"""
loc, port = uhubctl_mod.resolve_target(role)
uhubctl_mod.cycle(loc, port, delay_s=delay_s)
# After uhubctl exits, VBUS is on but the device may still be in
# bootloader init. Give it ~500 ms head-start before polling so we
# don't spam list_devices pointlessly.
time.sleep(0.5)
return resolve_port_by_role(role, timeout_s=rediscover_timeout_s)
def wait_for_absence(role: str, *, timeout_s: float = 10.0) -> None:
"""Block until a device matching `role` is NOT in `list_devices`.
Used by the recovery tier to assert power_off actually took effect.
Raises TimeoutError on failure.
"""
from meshtastic_mcp import devices as devices_mod
from ._port_discovery import _ROLE_VIDS, _coerce_vid # type: ignore[attr-defined]
if role not in _ROLE_VIDS:
raise ValueError(f"unknown role {role!r}")
wanted = _ROLE_VIDS[role]
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
found = devices_mod.list_devices(include_unknown=True)
if not any(_coerce_vid(d.get("vid")) in wanted for d in found):
return
time.sleep(0.3)
raise TimeoutError(f"role {role!r} still visible after {timeout_s}s of power_off")
__all__ = [
"is_uhubctl_available",
"power_cycle",
"power_off",
"power_on",
"wait_for_absence",
]
+137 -6
View File
@@ -123,15 +123,24 @@ def pytest_collection_modifyitems(
return (2, item.nodeid)
if "/monitor/" in path or "tests/monitor" in path:
return (3, item.nodeid)
if "/fleet/" in path or "tests/fleet" in path:
# Recovery tier: explicitly cycles device power via uhubctl. Slots
# between monitor (read-only) and ui (state-preserving) so any tier
# after it starts from a known re-enumerated + re-verified state.
if "/recovery/" in path or "tests/recovery" in path:
return (4, item.nodeid)
# UI tier slots here — read-only w.r.t. mesh state, only mutates
# the on-screen UI (BACK×5 guard restores home before each test).
if "/ui/" in path or "tests/ui" in path:
return (5, item.nodeid)
if "/fleet/" in path or "tests/fleet" in path:
return (6, item.nodeid)
# State-mutating tiers run last.
if "/admin/" in path or "tests/admin" in path:
return (5, item.nodeid)
return (7, item.nodeid)
if "/provisioning/" in path or "tests/provisioning" in path:
return (6, item.nodeid)
return (8, item.nodeid)
# Top-level + anything else falls between.
return (7, item.nodeid)
return (9, item.nodeid)
items.sort(key=sort_key)
@@ -156,13 +165,20 @@ def session_seed(request: pytest.FixtureRequest) -> str:
@pytest.fixture(scope="session")
def test_profile(session_seed: str) -> dict[str, Any]:
"""The canonical isolated-mesh test profile for this session."""
"""The canonical isolated-mesh test profile for this session.
`enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware
emits `Screen: frame N/M name=... reason=...` log lines per UI
transition — consumed by the `tests/ui/` tier. Harmless on boards
without a screen (the `#ifdef` sits behind `HAS_SCREEN`).
"""
return userprefs.build_testing_profile(
psk_seed=session_seed,
channel_name="McpTest",
channel_num=88,
region="US",
modem_preset="LONG_FAST",
enable_ui_log=True,
)
@@ -654,6 +670,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
def baked_single(
baked_mesh: dict[str, Any],
baked_single_role: str,
hub_devices: dict[str, str],
) -> dict[str, Any]:
"""Function-scoped: a single verified baked device.
@@ -662,10 +679,75 @@ def baked_single(
(e.g. `test_owner_survives_reboot[nrf52]` +
`test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role
and never skip a device that happens to be connected.
Auto-recovery: if the baked device fails a pre-test `device_info` probe
AND uhubctl is available, power-cycle the port once and retry. Without
uhubctl, surface the wedge as a clear skip. This catches "device got
stuck between tests" without masking persistent regressions (a second
wedge after cycling still skips).
"""
if baked_single_role not in baked_mesh:
pytest.skip(f"role {baked_single_role!r} not present on the hub")
return {"role": baked_single_role, **baked_mesh[baked_single_role]}
entry = baked_mesh[baked_single_role]
port = entry.get("port")
if port:
try:
_run_with_timeout(lambda: info.device_info(port=port, timeout_s=3.0), 5.0)
except Exception:
# Device didn't respond. Try a power-cycle recovery if uhubctl
# is installed; otherwise surface a skip that names the root
# cause clearly.
from tests import _power
if not _power.is_uhubctl_available():
pytest.skip(
f"device {baked_single_role!r} unresponsive on {port}; "
"install uhubctl (`brew install uhubctl` / `apt install "
"uhubctl`) for auto power-cycle recovery"
)
try:
new_port = _power.power_cycle(baked_single_role, delay_s=2)
except Exception as exc: # noqa: BLE001
pytest.skip(
f"device {baked_single_role!r} wedged and power-cycle "
f"failed: {exc}"
)
# Mutate both the session-scoped `hub_devices` map AND the
# baked_mesh entry so downstream fixtures see the recovered port.
hub_devices[baked_single_role] = new_port
baked_mesh[baked_single_role]["port"] = new_port
entry = baked_mesh[baked_single_role]
return {"role": baked_single_role, **entry}
@pytest.fixture
def power_cycle(
hub_devices: dict[str, str],
) -> Callable[..., str]:
"""Return a callable `(role, delay_s=2) -> new_port` that hard-resets the
hub port hosting `role`. Skips the test cleanly when uhubctl isn't
installed — never want "no uhubctl" to look like a test failure.
The callable mutates `hub_devices[role]` in place so subsequent fixture
lookups pick up the post-cycle port (mirrors the pattern in
provisioning/test_userprefs_survive_factory_reset.py).
"""
from tests import _power
if not _power.is_uhubctl_available():
pytest.skip(
"uhubctl not installed; this test needs it for power control. "
"Install via `brew install uhubctl` (macOS) or `apt install "
"uhubctl` (Debian/Ubuntu)."
)
def _cycle(role: str, delay_s: int = 2) -> str:
new_port = _power.power_cycle(role, delay_s=delay_s)
hub_devices[role] = new_port
return new_port
return _cycle
_DEFAULT_ROLE_ENVS = {
@@ -960,6 +1042,45 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any:
raise TimeoutError(f"operation did not complete within {timeout}s") from exc
def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
"""Embed per-step UI captures (PNG + OCR) into the pytest-html extras.
Runs for every UI-tier test on BOTH pass and fail so the HTML report
always shows the image strip + OCR transcript. Silently no-ops if
pytest-html isn't installed or the test didn't use `frame_capture`.
"""
captures = getattr(item, "_ui_captures", None)
if not captures:
return
try:
from pytest_html import extras as html_extras # type: ignore[import-untyped]
except ImportError:
return
existing = getattr(report, "extras", None) or []
extras_list = list(existing)
for cap in captures:
png_path = cap.get("png_path")
label = f"{cap.get('step', '?')}: {cap.get('label', '')}"
frame = cap.get("frame") or {}
frame_str = (
f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
)
if png_path:
try:
with open(png_path, "rb") as fh:
import base64
b64 = base64.b64encode(fh.read()).decode("ascii")
extras_list.append(html_extras.png(b64, name=f"{label}{frame_str}"))
except OSError:
pass
ocr = (cap.get("ocr_text") or "").strip()
if ocr:
extras_list.append(html_extras.text(ocr, name=f"OCR: {label}{frame_str}"))
report.extras = extras_list # type: ignore[attr-defined]
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any:
"""On test failure, attach serial capture + device state as report artifacts.
@@ -967,10 +1088,20 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck
port, unbaked firmware, dead board), the dump is skipped rather than
hanging the session.
For UI-tier tests, also embeds per-step camera captures + OCR on every
test (pass or fail) so the HTML report shows visual evidence of what
the device did.
"""
outcome = yield
report = outcome.get_result()
# Attach UI captures on any outcome (pass + fail) — these are the whole
# point of the UI tier. Do this before the failure-only branch below so
# passing tests still get their image strip.
if report.when == "call":
_attach_ui_captures(item, report)
if report.when != "call" or report.outcome != "failed":
return
@@ -0,0 +1,155 @@
"""Isolation test for peer-offline-then-back mid-conversation.
Verifies the mesh stack's behavior when a peer is physically powered
off mid-send via uhubctl, then powered back on.
Flow (parametrized over every directed mesh_pair):
1. Bilateral PKI warmup (same pattern as test_direct_with_ack).
2. TX sends a broadcast text "msg-1" — RX confirms receipt via pubsub.
3. Power OFF RX via uhubctl. The RX device disappears from the OS.
4. TX sends a directed text "msg-2" with wantAck=True. Firmware retries
internally for ~30s before giving up. Assertion: the packet object
was accepted by the TX stack (non-None) — we don't assert an ACK
since there's no peer to send one.
5. Power ON RX. Wait for re-enumeration + boot.
6. Bilateral PKI re-nudge — RX's in-RAM PKI cache was wiped on reboot,
so the first directed send may err=35 without a fresh NodeInfo ping.
7. TX sends a directed "msg-3" — RX receives it via pubsub, confirming
the mesh recovered.
Skips cleanly if uhubctl isn't installed (via the `power_cycle` fixture's
auto-skip). Skips for pair directions where RX isn't power-controllable
(e.g. a USB-IF hub that doesn't support PPPS for its port).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from tests import _power
from tests._port_discovery import resolve_port_by_role
from ._receive import ReceiveCollector, nudge_nodeinfo
@pytest.mark.timeout(360)
def test_peer_offline_then_recovers(
mesh_pair: dict[str, Any],
power_cycle, # noqa: ARG001 — forces uhubctl-availability skip
hub_devices: dict[str, str],
) -> None:
tx_port = mesh_pair["tx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
unique_pre = f"peer-offline-pre-{tx_role}-to-{rx_role}-{int(time.time())}"
unique_post = f"peer-offline-post-{tx_role}-to-{rx_role}-{int(time.time())}"
# Step 1 + 2: warm up + confirm baseline delivery works before the test.
with ReceiveCollector(
mesh_pair["rx"]["port"], topic="meshtastic.receive.text"
) as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
# Wait for bilateral PKI (RX pubkey in TX's nodesByNum).
deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
while time.monotonic() < deadline:
rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {})
if rec.get("user", {}).get("publicKey"):
break
if time.monotonic() - last_nudge > 15.0:
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
last_nudge = time.monotonic()
time.sleep(1.0)
else:
pytest.skip(
f"bilateral PKI never completed ({tx_role}→{rx_role}); "
"can't run the offline test without a warm baseline"
)
tx_iface.sendText(unique_pre, destinationId=rx_node_num, wantAck=True)
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique_pre,
timeout=30,
)
assert got is not None, (
f"baseline directed send ({tx_role}→{rx_role}) didn't land — "
"skipping offline test to avoid false positive"
)
# Step 3: power off RX. uhubctl skips the test with a clear message if
# the RX role isn't on a controllable hub.
try:
_power.power_off(rx_role)
except Exception as exc: # noqa: BLE001
pytest.skip(f"can't power-control {rx_role!r}: {exc}")
try:
_power.wait_for_absence(rx_role, timeout_s=10.0)
except TimeoutError:
_power.power_on(rx_role) # restore hub state before failing
resolve_port_by_role(rx_role, timeout_s=30.0)
pytest.fail(f"{rx_role!r} didn't disappear after power_off")
# Step 4: send to a peer that isn't there. Firmware will retry
# internally. We don't wait for an ACK (there won't be one); we just
# confirm TX's stack accepts the packet without crashing.
try:
with connect(port=tx_port) as tx_iface:
packet = tx_iface.sendText(
f"while-offline-{rx_role}",
destinationId=rx_node_num,
wantAck=True,
)
assert packet is not None
# Give firmware a moment to do a retry or two while RX is down.
time.sleep(5.0)
except Exception as exc: # noqa: BLE001 — TX should survive the peer being gone
# Restore RX before reraising so the bench state is sane.
_power.power_on(rx_role)
resolve_port_by_role(rx_role, timeout_s=30.0)
raise AssertionError(f"TX crashed when sending to offline peer: {exc}") from exc
# Step 5: power RX back on + rediscover.
_power.power_on(rx_role)
time.sleep(0.5)
new_rx_port = resolve_port_by_role(rx_role, timeout_s=30.0)
hub_devices[rx_role] = new_rx_port
# Step 6 + 7: bilateral re-warmup + directed send that should now work.
with ReceiveCollector(new_rx_port, topic="meshtastic.receive.text") as rx:
# RX rebooted → its PKI cache is gone. Re-warm.
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
time.sleep(3.0)
got = None
for _attempt in range(3):
packet = tx_iface.sendText(
unique_post,
destinationId=rx_node_num,
wantAck=True,
)
assert packet is not None
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique_post,
timeout=30,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(5.0)
assert got is not None, (
f"post-recovery directed send {unique_post!r} ({tx_role}→{rx_role}) "
"never landed — recovery path may be broken"
)
+6
View File
@@ -0,0 +1,6 @@
"""Recovery tier — exercises `uhubctl` power control end-to-end.
Requires `uhubctl` installed AND at least one connected device on a
PPPS-capable hub. The whole tier skips cleanly via
`tests/recovery/conftest.py::_recovery_tier_guard` when either is missing.
"""
+44
View File
@@ -0,0 +1,44 @@
"""Recovery-tier gating + shared helpers.
Session-scoped guard skips the whole tier when uhubctl isn't installed.
Tests under this directory assume uhubctl is callable AND that at least
one hub role is detected on a PPPS-capable port.
"""
from __future__ import annotations
import pytest
@pytest.fixture(scope="session", autouse=True)
def _recovery_tier_guard() -> None:
"""Skip the tier when uhubctl is unavailable OR no device is on a
PPPS-capable hub. Prints the specific reason so operators know what
to fix."""
from tests import _power
if not _power.is_uhubctl_available():
pytest.skip(
"uhubctl not installed; recovery tier needs it. "
"Install via `brew install uhubctl` or `apt install uhubctl`.",
allow_module_level=True,
)
# Probe: can we even list hubs? (A macOS user without sudo gets a
# permission error here — we'd rather find out once at tier-start than
# 6 tests later.)
from meshtastic_mcp import uhubctl
try:
hubs = uhubctl.list_hubs()
except uhubctl.UhubctlError as exc:
pytest.skip(
f"uhubctl list failed: {exc}. Try the udev rules or `sudo` as a fallback.",
allow_module_level=True,
)
if not any(h["ppps"] for h in hubs):
pytest.skip(
"no PPPS-capable hubs detected — recovery tier has nothing to exercise.",
allow_module_level=True,
)
@@ -0,0 +1,43 @@
"""Smoke test: `uhubctl_list` returns a well-formed structure.
No destructive action. Runs first in the tier as a sanity check that the
tier's dependencies (uhubctl binary + permissions) are actually satisfied.
"""
from __future__ import annotations
import pytest
from meshtastic_mcp import uhubctl
@pytest.mark.timeout(30)
def test_list_hubs_returns_at_least_one_ppps_hub() -> None:
hubs = uhubctl.list_hubs()
assert hubs, "uhubctl found no hubs at all — is a USB hub connected?"
assert any(h["ppps"] for h in hubs), (
"no PPPS-capable hubs detected; power control won't work. "
"Check that the hub supports Per-Port Power Switching."
)
@pytest.mark.timeout(30)
def test_list_hubs_structure(hub_devices: dict[str, str]) -> None:
hubs = uhubctl.list_hubs()
for hub in hubs:
assert "location" in hub and hub["location"]
assert "ports" in hub and isinstance(hub["ports"], list)
for port in hub["ports"]:
assert "port" in port and isinstance(port["port"], int)
assert "status" in port
# At least one of the detected Meshtastic roles should show up in some
# port's device_vid — otherwise the recovery tier can't drive them.
seen_vids = {
p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None
}
expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids
assert expected_any or not hub_devices, (
f"hub_devices detected roles {list(hub_devices)} but uhubctl sees "
f"VIDs {sorted(hex(v) for v in seen_vids)} — the devices may be on "
"a hub that uhubctl can't see (e.g. built-in laptop ports)."
)
@@ -0,0 +1,60 @@
"""Hard reset via uhubctl must NOT wipe NVS. Verify the test profile's
region + channel survive a power-cycle.
Guards against a regression where a firmware change treats unexpected
power loss as a factory-reset trigger (e.g. bad EEPROM wear-leveling,
erase-on-boot-for-safety). Such a regression would be catastrophic for
field deployments.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp import admin, info
from tests import _power
from tests._port_discovery import resolve_port_by_role
@pytest.mark.timeout(180)
def test_lora_config_survives_power_cycle(
baked_single: dict[str, object],
test_profile: dict[str, object],
) -> None:
role = baked_single["role"]
pre_port = baked_single["port"]
pre_config = admin.get_config(section="lora", port=pre_port)["config"]["lora"]
pre_region = pre_config.get("region")
pre_preset = pre_config.get("modem_preset")
assert pre_region, f"lora.region not set pre-cycle on {role}"
# Hard power-cycle.
_power.power_cycle(role, delay_s=2)
time.sleep(0.5)
new_port = resolve_port_by_role(role, timeout_s=30.0)
# Let the firmware complete boot before admin reads.
time.sleep(2.0)
# Quick readiness probe.
probe = info.device_info(port=new_port, timeout_s=10.0)
assert (
probe.get("my_node_num") is not None
), f"device {role!r} didn't respond after power-cycle"
post_config = admin.get_config(section="lora", port=new_port)["config"]["lora"]
post_region = post_config.get("region")
post_preset = post_config.get("modem_preset")
assert post_region == pre_region, (
f"lora.region wiped by power-cycle on {role}: "
f"pre={pre_region!r} post={post_region!r}"
)
assert post_preset == pre_preset, (
f"lora.modem_preset wiped by power-cycle on {role}: "
f"pre={pre_preset!r} post={post_preset!r}"
)
# Channel-0 name should also match the test profile.
pri_ch = admin.get_channel_url(port=new_port)
assert pri_ch.get("url"), f"channel URL empty after power-cycle on {role}"
@@ -0,0 +1,61 @@
"""Full power-cycle round-trip: off → verify gone → on → verify identity
preserved.
Parametrized over every connected role. Validates both the uhubctl
plumbing AND that the device survives a hard reset with the same
`my_node_num` (no firmware-level identity regeneration).
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp import info
from tests import _power
from tests._port_discovery import resolve_port_by_role
@pytest.mark.timeout(180)
def test_power_cycle_preserves_node_identity(
baked_single: dict[str, object],
) -> None:
role = baked_single["role"]
pre_port = baked_single["port"]
pre_node_num = baked_single["my_node_num"]
pre_fw = baked_single.get("firmware_version")
# Record pre-cycle state.
pre_info = info.device_info(port=pre_port, timeout_s=5.0)
assert pre_info.get("my_node_num") == pre_node_num
# Power off; confirm the device actually disappears from list_devices.
_power.power_off(role)
try:
_power.wait_for_absence(role, timeout_s=10.0)
except TimeoutError:
# If it didn't disappear, power it back on so we don't leave the
# hub in a weird state for the next test.
_power.power_on(role)
resolve_port_by_role(role, timeout_s=30.0)
pytest.fail(f"device {role!r} stayed visible after power_off")
# Power back on + re-discover port.
_power.power_on(role)
time.sleep(0.5) # head-start before polling
new_port = resolve_port_by_role(role, timeout_s=30.0)
# Give the firmware a moment to finish boot before we hit it with admin.
time.sleep(2.0)
post_info = info.device_info(port=new_port, timeout_s=10.0)
assert post_info.get("my_node_num") == pre_node_num, (
f"my_node_num changed across power-cycle: pre={pre_node_num:#x} "
f"post={post_info.get('my_node_num'):#x}"
)
# Firmware version must match (same bake, not a re-flash).
if pre_fw:
assert post_info.get("firmware_version") == pre_fw, (
f"firmware changed across cycle: pre={pre_fw} "
f"post={post_info.get('firmware_version')}"
)
+7
View File
@@ -73,6 +73,13 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
"reboot": ("meshtastic_mcp.admin", "reboot"),
"shutdown": ("meshtastic_mcp.admin", "shutdown"),
"factory_reset": ("meshtastic_mcp.admin", "factory_reset"),
"send_input_event": ("meshtastic_mcp.admin", "send_input_event"),
# `capture_screen` in server.py calls camera.get_camera — instrument that.
"capture_screen": ("meshtastic_mcp.camera", "get_camera"),
# USB power control via uhubctl.
"uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"),
"uhubctl_power": ("meshtastic_mcp.uhubctl", "power_on"),
"uhubctl_cycle": ("meshtastic_mcp.uhubctl", "cycle"),
# USERPREFS
"userprefs_manifest": ("meshtastic_mcp.userprefs", "build_manifest"),
"userprefs_get": ("meshtastic_mcp.userprefs", "read_state"),
+7
View File
@@ -0,0 +1,7 @@
"""UI tier — input-broker-driven screen navigation tests.
Only runs when a screen-bearing role (esp32s3/heltec-v3) is present on the
hub AND the firmware was baked with `enable_ui_log=True` (so the
`Screen: frame N/M name=... reason=...` log lines are emitted). The
`tests/ui/conftest.py` fixture forces that bake stamp.
"""
+176
View File
@@ -0,0 +1,176 @@
"""Parse `Screen: frame N/M name=X reason=Y` log lines from `_debug_log_buffer`.
The firmware emits one line per frame transition when
`USERPREFS_UI_TEST_LOG` is defined (see src/graphics/Screen.cpp). Tests use
these helpers to assert which frame is shown / to wait for a transition to
settle before taking a camera capture.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from typing import Iterable, Iterator
FRAME_RE = re.compile(
r"Screen: frame (?P<idx>\d+)/(?P<count>\d+) name=(?P<name>\S+) reason=(?P<reason>\S+)"
)
@dataclass(frozen=True)
class FrameEvent:
idx: int
count: int
name: str
reason: str
raw: str
@classmethod
def parse(cls, line: str) -> "FrameEvent | None":
m = FRAME_RE.search(line)
if not m:
return None
return cls(
idx=int(m["idx"]),
count=int(m["count"]),
name=m["name"],
reason=m["reason"],
raw=line,
)
def iter_frame_events(lines: Iterable[str]) -> Iterator[FrameEvent]:
for line in lines:
evt = FrameEvent.parse(line)
if evt is not None:
yield evt
def get_current_frame(lines: list[str]) -> FrameEvent | None:
"""Return the most recent FrameEvent in `lines`, or None if none found."""
for line in reversed(lines):
evt = FrameEvent.parse(line)
if evt is not None:
return evt
return None
def wait_for_frame(
lines: list[str],
expected_name: str,
*,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
reason: str | None = None,
) -> FrameEvent:
"""Poll `lines` (the `_debug_log_buffer`) until a FrameEvent with
`name=expected_name` appears after the call started. Raises TimeoutError
with context if it doesn't arrive in `timeout_s`.
`reason` optionally filters to events matching a specific cause
(e.g. `"fn_f1"`, `"next"`, `"rebuild"`).
"""
start_idx = len(lines)
deadline = time.monotonic() + timeout_s
last: FrameEvent | None = None
while time.monotonic() < deadline:
# Scan only lines appended since we started waiting.
for line in lines[start_idx:]:
evt = FrameEvent.parse(line)
if evt is None:
continue
last = evt
if evt.name == expected_name and (reason is None or evt.reason == reason):
return evt
time.sleep(poll_interval_s)
seen = [e.name for e in iter_frame_events(lines[start_idx:])]
raise TimeoutError(
f"frame name={expected_name!r} reason={reason!r} not seen in {timeout_s}s; "
f"saw {len(seen)} transition(s): {seen!r}; last={last!r}"
)
def wait_for_any_frame(
lines: list[str],
*,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
) -> FrameEvent:
"""Wait for ANY frame transition to appear after call-start. Useful for
`no-op` tests that want to confirm a transition did NOT happen (via
TimeoutError) vs. one that did.
"""
start_idx = len(lines)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
for line in lines[start_idx:]:
evt = FrameEvent.parse(line)
if evt is not None:
return evt
time.sleep(poll_interval_s)
raise TimeoutError(f"no frame transition in {timeout_s}s")
def wait_for_reason(
lines: list[str],
reason: str,
*,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
) -> FrameEvent:
"""Wait for a frame event with `reason=<reason>` after call-start.
Matches only on `reason` — useful when the caller knows *why* a
transition should happen (e.g. `fn_f1`, `rebuild`) but not which named
frame the firmware will land on for this particular board.
"""
start_idx = len(lines)
deadline = time.monotonic() + timeout_s
last: FrameEvent | None = None
while time.monotonic() < deadline:
for line in lines[start_idx:]:
evt = FrameEvent.parse(line)
if evt is None:
continue
last = evt
if evt.reason == reason:
return evt
time.sleep(poll_interval_s)
raise TimeoutError(
f"no frame with reason={reason!r} in {timeout_s}s; last={last!r}"
)
def assert_no_frame_change(
lines: list[str],
*,
wait_s: float = 2.0,
) -> None:
"""Assert that NO new FrameEvent lines arrive within `wait_s`.
Used by idempotency / no-op tests (e.g. BACK on home frame).
"""
start_idx = len(lines)
time.sleep(wait_s)
new = [
e for e in (FrameEvent.parse(ln) for ln in lines[start_idx:]) if e is not None
]
if new:
raise AssertionError(
f"expected no frame change in {wait_s}s, but saw {len(new)} event(s): "
f"{[(e.reason, e.name) for e in new]!r}"
)
__all__ = [
"FRAME_RE",
"FrameEvent",
"assert_no_frame_change",
"get_current_frame",
"iter_frame_events",
"wait_for_any_frame",
"wait_for_frame",
"wait_for_reason",
]
+381
View File
@@ -0,0 +1,381 @@
"""UI-tier fixtures: camera lifecycle, OCR warmup, per-test frame capture,
and a `ui_home_state` autouse guard that resets to the home frame before
every test (prevents state bleed if a prior test exited inside a menu).
The camera + OCR modules live in `meshtastic_mcp/{camera,ocr}.py` (production
code, so the `capture_screen` MCP tool can share them). These fixtures wire
them into pytest + write per-test captures to `tests/ui_captures/…`.
"""
from __future__ import annotations
import re
import shutil
import time
from pathlib import Path
from typing import Any, Iterator
import pytest
from meshtastic_mcp import admin as admin_mod
from meshtastic_mcp import camera as camera_mod
from meshtastic_mcp import ocr as ocr_mod
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import FrameEvent, get_current_frame, wait_for_frame
# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3
# SSD1306) qualifies today — nrf52 (rak4631) has no display.
UI_CAPABLE_ROLES = ("esp32s3",)
# Where per-test captures land. One subdirectory per session seed, then per
# sanitized test nodeid — identical pattern to other pytest artifacts.
CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures"
def _sanitize_nodeid(nodeid: str) -> str:
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", nodeid)
# ---------- Role gating ----------------------------------------------------
@pytest.fixture
def ui_capable_role(request: pytest.FixtureRequest, hub_devices: dict[str, Any]) -> str:
"""Resolve the single role the UI tier drives.
Today that's `esp32s3`. Skips if the hub doesn't have one. A future
multi-screen hub could pick a role per parametrization.
"""
for role in UI_CAPABLE_ROLES:
if role in hub_devices:
return role
pytest.skip(
f"no UI-capable role on hub; need one of {UI_CAPABLE_ROLES} in {sorted(hub_devices)}"
)
@pytest.fixture
def ui_port(ui_capable_role: str, hub_devices: dict[str, Any]) -> str:
port = (
hub_devices[ui_capable_role].get("port")
if isinstance(hub_devices[ui_capable_role], dict)
else hub_devices[ui_capable_role]
)
if not port:
pytest.skip(f"{ui_capable_role!r} has no usable port")
return port
# ---------- Camera + OCR session fixtures ---------------------------------
@pytest.fixture(scope="session")
def camera(ui_capable_role_session: str | None) -> Iterator[camera_mod.CameraBackend]:
"""Session-scoped camera backend. Closed at teardown.
Backend + device selected by env vars (see `meshtastic_mcp.camera`).
Falls through to `NullBackend` when no camera is configured, so the
tests run end-to-end on machines without hardware; they just won't
have useful images.
"""
role = ui_capable_role_session or "esp32s3"
cam = camera_mod.get_camera(role)
try:
yield cam
finally:
cam.close()
@pytest.fixture(scope="session")
def ui_capable_role_session(hub_devices: dict[str, Any]) -> str | None:
"""Session-scoped lookup mirroring `ui_capable_role` but non-skipping.
Used by the `camera` session fixture so it doesn't depend on a
test-scoped skip.
"""
for role in UI_CAPABLE_ROLES:
if role in hub_devices:
return role
return None
@pytest.fixture(scope="session", autouse=True)
def _ocr_warm() -> None:
"""Pay easyocr's ~100 MB / cold-start cost ONCE per session.
Subsequent `ocr_text()` calls hit the cached reader and return quickly.
Swallows errors — if OCR isn't installed, warm is a no-op.
"""
try:
ocr_mod.warm()
except Exception: # noqa: BLE001 — belt: never block the suite on OCR init
pass
@pytest.fixture(scope="session")
def _ui_screen_kept_on(
ui_capable_role_session: str | None, hub_devices: dict[str, Any]
) -> Iterator[None]:
"""Keep the OLED on throughout the UI tier so input events aren't dropped.
Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122)
silently DROPS any event that arrives while the screen is off — it just
wakes the screen and returns. Every first event in each test would
disappear. We set `display.screen_on_secs = 86400` at session start
(effectively "always on" for the test window) and restore the prior
value at teardown.
"""
if ui_capable_role_session is None:
yield
return
hub_entry = hub_devices[ui_capable_role_session]
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
if not port:
yield
return
original: int | None = None
try:
current = admin_mod.get_config(section="display", port=port)
original = int(
current.get("config", {}).get("display", {}).get("screen_on_secs") or 0
)
except Exception: # noqa: BLE001
pass
try:
admin_mod.set_config("display.screen_on_secs", 86400, port=port)
# Send one wake event so the screen is actually ON going into the
# first test. The event itself gets dropped (screenWasOff), but the
# wake side-effect sticks.
try:
admin_mod.send_input_event(event_code=int(InputEventCode.FN_F1), port=port)
except Exception: # noqa: BLE001
pass
time.sleep(1.5) # Let the screen finish its wake transition.
except (
Exception
): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error
pass
try:
yield
finally:
if original is not None:
try:
admin_mod.set_config("display.screen_on_secs", original, port=port)
except Exception: # noqa: BLE001
pass
# ---------- Per-test capture + transcript ----------------------------------
class FrameCapture:
"""Per-test capture recorder. Created once per test via the
`frame_capture` fixture; call with a label to snapshot the screen.
"""
def __init__(
self,
cam: camera_mod.CameraBackend,
dir_path: Path,
lines: list[str],
nodeid: str,
) -> None:
self._cam = cam
self._dir = dir_path
self._lines = lines
self._nodeid = nodeid
self._step = 0
self.captures: list[dict[str, Any]] = []
self._transcript_path = dir_path / "transcript.md"
self._dir.mkdir(parents=True, exist_ok=True)
self._transcript_path.write_text(
f"# {nodeid} — {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
encoding="utf-8",
)
def __call__(self, label: str) -> dict[str, Any]:
self._step += 1
stem = f"{self._step:03d}-{re.sub(r'[^a-zA-Z0-9_-]+', '-', label)}"
png_path = self._dir / f"{stem}.png"
ocr_path = self._dir / f"{stem}.ocr.txt"
try:
png = self._cam.capture()
except Exception as exc: # noqa: BLE001
png = b""
ocr_str = f"[capture error: {exc}]"
else:
camera_mod.save_capture(png, png_path)
try:
ocr_str = ocr_mod.ocr_text(png)
except Exception as exc: # noqa: BLE001
ocr_str = f"[ocr error: {exc}]"
ocr_path.write_text(ocr_str or "", encoding="utf-8")
frame = get_current_frame(self._lines)
entry: dict[str, Any] = {
"step": self._step,
"label": label,
"png_path": str(png_path) if png else None,
"ocr_text": ocr_str,
"frame": (
{
"idx": frame.idx,
"name": frame.name,
"reason": frame.reason,
}
if frame is not None
else None
),
}
self.captures.append(entry)
with self._transcript_path.open("a", encoding="utf-8") as fh:
frame_str = (
f"frame {frame.idx}/{frame.count} name={frame.name} reason={frame.reason}"
if frame is not None
else "frame <none>"
)
ocr_summary = (ocr_str or "").replace("\n", " / ")[:80]
fh.write(
f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n"
)
return entry
@pytest.fixture
def frame_capture(
request: pytest.FixtureRequest,
camera: camera_mod.CameraBackend,
session_seed: str,
) -> Iterator[FrameCapture]:
nodeid = _sanitize_nodeid(request.node.nodeid)
dir_path = CAPTURES_ROOT / session_seed / nodeid
# Fresh directory per test run so reruns don't mix old and new images.
if dir_path.exists():
shutil.rmtree(dir_path)
lines = getattr(request.node, "_debug_log_buffer", [])
fc = FrameCapture(camera, dir_path, lines, nodeid)
# Stash so pytest_runtest_makereport can embed captures in HTML extras.
request.node._ui_captures = fc.captures # type: ignore[attr-defined]
yield fc
# ---------- Pre-test home-state reset --------------------------------------
def _send_event(port: str, event: InputEventCode) -> None:
try:
admin_mod.send_input_event(event_code=int(event), port=port)
except Exception: # noqa: BLE001
# Treat a failed event as soft — the subsequent frame-log assertion
# surfaces the real problem with better context.
pass
@pytest.fixture(autouse=True)
def ui_home_state(
request: pytest.FixtureRequest,
hub_devices: dict[str, Any],
_ui_screen_kept_on: None,
) -> Iterator[None]:
"""Before every UI test, jump to frame 0 (usually `home`) via FN_F1 and
confirm the device emitted the expected frame log.
Why FN_F1 (not BACK): FN_F1 maps to `switchToFrame(0)` and ALWAYS
produces a `reason=fn_f1` log line, regardless of whatever frame the
prior test left us on. BACK is context-sensitive (dismisses overlays
on some frames, no-op on others) and can silently fail to transition.
This fixture doubles as the macro-presence detector: if no `fn_f1`
log arrives within 5 s, the firmware almost certainly wasn't baked
with `USERPREFS_UI_TEST_LOG`. Skip the tier with an actionable hint
instead of letting every test body fail with a confusing assertion.
Autouse scope is restricted to `tests/ui/` by virtue of this fixture
living in that directory's conftest.py — no explicit nodeid guard
needed (and earlier attempts at one were wrong, matching `/tests/ui/`
against a nodeid that has no leading slash).
"""
role = next((r for r in UI_CAPABLE_ROLES if r in hub_devices), None)
if role is None:
yield
return
hub_entry = hub_devices[role]
port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry
lines: list[str] = getattr(request.node, "_debug_log_buffer", [])
start_len = len(lines)
# First: a wake event. The screen should already be kept on by
# `_ui_screen_kept_on`, but belt + suspenders — if it somehow
# powered off (sleep after factory_reset, etc.), this first FN_F1
# gets dropped by InputBroker's screenWasOff guard. That's fine;
# the second FN_F1 below lands cleanly.
_send_event(port, InputEventCode.FN_F1)
time.sleep(0.4)
_send_event(port, InputEventCode.FN_F1)
# Wait for the fn_f1 transition log. Any new `reason=fn_f1` line
# after call-start counts — we don't care about the name (it should
# be `home` or `deviceFocused` depending on board-specific frame order).
from ._screen_log import wait_for_reason
try:
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
except TimeoutError:
# One more try — FreeRTOS queue may be draining slowly.
_send_event(port, InputEventCode.FN_F1)
try:
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
except TimeoutError:
# Look at what the _debug_log_buffer actually contains to
# disambiguate "macro off" from "macro on but event lost".
frame_lines = [ln for ln in lines[start_len:] if "Screen: frame" in ln]
processing_lines = [
ln for ln in lines[start_len:] if "Processing input event" in ln
]
if frame_lines:
pytest.skip(
f"ui_home_state: events fire but none reach Screen "
f"(saw {len(frame_lines)} frame line(s), "
f"{len(processing_lines)} admin inject(s)). "
f"Device may be in an unusual state — try `--force-bake`."
)
else:
pytest.skip(
"ui_home_state: no `Screen: frame` log after FN_F1. "
"Firmware not baked with USERPREFS_UI_TEST_LOG — "
"run with `--force-bake` to reflash, or verify the "
"macro is active in the bake."
)
yield
# ---------- Small helpers reused by tests ---------------------------------
def send_event(
port: str, event: InputEventCode | int | str, **kwargs: Any
) -> dict[str, Any]:
"""Thin wrapper so tests read `send_event(port, InputEventCode.RIGHT)`."""
return admin_mod.send_input_event(event_code=event, port=port, **kwargs)
__all__ = [
"FrameCapture",
"UI_CAPABLE_ROLES",
"send_event",
"wait_for_frame",
"FrameEvent",
]
# Make the helpers discoverable to test modules via `from .conftest import …`.
# pytest auto-loads conftest.py, but the symbols above are also re-exported
# for readability in the test files.
+61
View File
@@ -0,0 +1,61 @@
"""FN_F1..F5 directly jumps to frame 0..4 via Screen::handleInputEvent.
Parametrized over the 5 function keys. Each expects a
`Screen: frame <idx>/<count> name=... reason=fn_f<k>` log line, with
`idx == k-1`. We don't hardcode the frame *name* because the layout
depends on which modules are compiled in for this board.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_reason
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(120)
@pytest.mark.parametrize(
"event,expected_idx,reason",
[
(InputEventCode.FN_F1, 0, "fn_f1"),
(InputEventCode.FN_F2, 1, "fn_f2"),
(InputEventCode.FN_F3, 2, "fn_f3"),
(InputEventCode.FN_F4, 3, "fn_f4"),
(InputEventCode.FN_F5, 4, "fn_f5"),
],
ids=["FN_F1", "FN_F2", "FN_F3", "FN_F4", "FN_F5"],
)
def test_fn_jump_direct_frame(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
event: InputEventCode,
expected_idx: int,
reason: str,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
assert start.name in (
"home",
"deviceFocused",
), f"setup expected frame 0 landing, got {start.name!r}"
frame_capture("initial")
if start.count <= expected_idx:
pytest.skip(
f"device has {start.count} frames; FN_F{expected_idx + 1} needs > {expected_idx}"
)
send_event(ui_port, event)
time.sleep(0.1)
evt = wait_for_reason(lines, reason, timeout_s=5.0)
assert evt.idx == expected_idx, (
f"FN_F{expected_idx + 1} expected idx={expected_idx}, got {evt.idx} "
f"(name={evt.name}, count={evt.count})"
)
frame_capture(f"after-{reason}")
+61
View File
@@ -0,0 +1,61 @@
"""Out-of-bounds FN_F5 when the device has <5 frames: no crash, idx unchanged.
`Screen::handleInputEvent` dispatches FN_F5 unconditionally to
`ui->switchToFrame(4)`. The OLEDDisplayUi library typically clamps or
silently ignores out-of-range indices, but firmware bugs have existed
here — this test protects against a regression that would wedge the UI.
If this test fails, first check: did the device actually crash (Guru
Meditation in the log)? Or did switchToFrame accept an OOB index and
leave the UI blank?
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_reason
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(90)
def test_fn_f5_out_of_bounds(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None
if start.count > 5:
pytest.skip(
f"device has {start.count} frames; FN_F5 is in-bounds — not testing OOB here"
)
frame_capture("initial-home")
send_event(ui_port, InputEventCode.FN_F5)
time.sleep(0.5)
try:
wait_for_reason(lines, "fn_f5", timeout_s=3.0)
except TimeoutError:
# Firmware may have ignored the event entirely — acceptable.
pass
# Capture whatever is on screen (OCR will tell us if something weird
# happened). Device must remain responsive — subsequent events should
# still land.
frame_capture("after-fn_f5-oob")
# Send a RIGHT to confirm the UI is still alive. If this times out,
# the OOB switchToFrame wedged the UI.
send_event(ui_port, InputEventCode.RIGHT)
post = wait_for_reason(lines, "next", timeout_s=5.0)
assert (
post is not None
), "UI wedged after OOB FN_F5 — RIGHT no longer produces frame log"
frame_capture("after-recovery-right")
+68
View File
@@ -0,0 +1,68 @@
"""SELECT on the home frame opens the home menu; BACK closes it.
The home menu is an overlay (menuHandler::homeBaseMenu), not a frame
transition — so we verify via OCR difference between before/after
captures rather than a `Screen: frame` log line. The underlying
mechanism is still InputBroker → Screen::handleInputEvent → menu
callback.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(120)
def test_select_opens_home_menu(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None
if start.name not in ("home", "deviceFocused"):
pytest.skip(
f"SELECT on {start.name!r} doesn't open homeBaseMenu; "
"test is only valid when the landing frame is home/deviceFocused"
)
initial = frame_capture("initial")
send_event(ui_port, InputEventCode.SELECT)
time.sleep(0.8)
opened = frame_capture("after-select")
# The menu is an overlay (not a frame change). We cannot use log
# assertion — instead, OCR should differ because a menu list is now
# drawn on top.
initial_text = (initial.get("ocr_text") or "").strip()
opened_text = (opened.get("ocr_text") or "").strip()
if initial_text and opened_text:
# When OCR is available, require *some* difference between the two
# frames — even a single menu title changes the transcribed text.
assert initial_text != opened_text, (
f"expected OCR diff after SELECT; both read {initial_text!r}. "
"If both are empty, check camera alignment + OCR backend."
)
# Back out — the menu dismisses on BACK.
send_event(ui_port, InputEventCode.BACK)
time.sleep(0.8)
closed = frame_capture("after-back")
# Soft check: OCR after BACK should look different from the menu
# (either back to home or onto a previous frame — BACK's exact
# behavior when the menu is up vs. not-up varies). We don't assert
# equality because OLED rendering is pixel-stable but camera sampling
# introduces noise.
if opened_text and closed.get("ocr_text"):
close_text = (closed.get("ocr_text") or "").strip()
assert (
close_text != opened_text
), f"after BACK, OCR still looks like the menu: {close_text!r}"
@@ -0,0 +1,60 @@
"""Once we navigate to the textMessage frame, UP/DOWN exercises the
message-scroll path (or opens CannedMessages on empty devices).
Weaker than a "no frame change" assertion because on a fresh bench
device the message store is usually empty, and the firmware's UP
handler in that case launches CannedMessage — which DOES rebuild
frames. We just verify the path doesn't crash + produce captures for
visual inspection.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(180)
def test_up_down_on_textmessage_survives(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
frame_capture("initial")
# Walk RIGHT until we land on textMessage — up to 15 hops.
for _i in range(15):
send_event(ui_port, InputEventCode.RIGHT)
time.sleep(0.3)
current = get_current_frame(lines)
if current is not None and current.name == "textMessage":
break
else:
pytest.skip(
"couldn't reach textMessage frame within 15 RIGHTs — not present on this board"
)
wait_for_frame(lines, "textMessage", timeout_s=5.0)
frame_capture("on-textMessage")
# UP and DOWN exercise the message-scroll / canned-message-launch path.
# Capture after each so the HTML report shows any visual effect.
send_event(ui_port, InputEventCode.UP)
time.sleep(0.3)
frame_capture("after-up")
send_event(ui_port, InputEventCode.DOWN)
time.sleep(0.3)
frame_capture("after-down")
# Soft check: we should still be in a reachable frame (not wedged).
# The next test's `ui_home_state` will error out if the device is
# unresponsive, so we don't need a stricter guarantee here.
final = get_current_frame(lines)
assert final is not None, "no frame log after UP/DOWN — event path broke"
@@ -0,0 +1,93 @@
"""INPUT_BROKER_RIGHT cycles forward through frames; INPUT_BROKER_LEFT backs.
The simplest UI test: fire N RIGHT events and assert the frame index
moves forward by N (modulo frameCount). Each step captures an image +
OCR for the HTML report.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import get_current_frame, wait_for_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(120)
def test_input_right_cycles_frames(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
# FN_F1 in ui_home_state lands on frame 0. The name at frame 0 varies
# by board (home on heltec-v3, deviceFocused on others) — accept either.
assert start.name in (
"home",
"deviceFocused",
), f"setup expected home/deviceFocused at frame 0, got {start.name!r}"
frame_capture("initial")
visited = [start.idx]
for step in range(4):
send_event(ui_port, InputEventCode.RIGHT)
# Each RIGHT should bump the frame index by 1. The log fires with
# `reason=next` from showFrame(NEXT).
before_count = len(list(_frame_events(lines)))
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if len(list(_frame_events(lines))) > before_count:
break
time.sleep(0.1)
evt = get_current_frame(lines)
assert evt is not None
assert (
evt.reason == "next"
), f"step {step}: expected reason=next, got {evt.reason!r}"
visited.append(evt.idx)
frame_capture(f"after-right-{step + 1}")
# Sanity: each index should differ from its predecessor.
diffs = [visited[i + 1] - visited[i] for i in range(len(visited) - 1)]
assert all(
d in (1, -(start.count - 1)) for d in diffs
), f"expected monotonic +1 steps (or a wrap), got visited={visited} diffs={diffs}"
@pytest.mark.timeout(120)
def test_input_left_returns_to_home(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
"""After RIGHT×3 + LEFT×3, we should end up back on the starting frame."""
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None
start_name = start.name
frame_capture("initial")
for _ in range(3):
send_event(ui_port, InputEventCode.RIGHT)
time.sleep(0.3)
frame_capture("after-right-3")
for _ in range(3):
send_event(ui_port, InputEventCode.LEFT)
time.sleep(0.3)
# Back to whichever frame we started on (home or deviceFocused).
wait_for_frame(lines, start_name, timeout_s=5.0)
frame_capture(f"after-left-3-back-{start_name}")
def _frame_events(lines: list[str]) -> Any:
from ._screen_log import iter_frame_events
return iter_frame_events(lines)
@@ -0,0 +1,51 @@
"""On the nodelist_nodes frame, UP/DOWN scrolls the list via
`NodeListRenderer::scrollUp/scrollDown` (src/graphics/Screen.cpp:1779-1788).
The firmware returns 0 before notifying observers, so no frame-change
log fires. Verify the path doesn't crash and we stay on nodelist_nodes.
"""
from __future__ import annotations
import time
import pytest
from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import assert_no_frame_change, get_current_frame, wait_for_frame
from .conftest import FrameCapture, send_event
@pytest.mark.timeout(180)
def test_up_down_on_nodelist_no_frame_change(
ui_port: str,
frame_capture: FrameCapture,
request: pytest.FixtureRequest,
) -> None:
lines: list[str] = request.node._debug_log_buffer
frame_capture("initial")
# Walk RIGHT until we land on nodelist_nodes.
for _i in range(15):
send_event(ui_port, InputEventCode.RIGHT)
time.sleep(0.3)
current = get_current_frame(lines)
if current is not None and current.name == "nodelist_nodes":
break
else:
pytest.skip("couldn't reach nodelist_nodes within 15 RIGHTs")
wait_for_frame(lines, "nodelist_nodes", timeout_s=5.0)
frame_capture("on-nodelist")
# UP/DOWN on nodelist scroll internally + `return 0` before
# notifyObservers — no frame-change log. Verify.
send_event(ui_port, InputEventCode.UP)
assert_no_frame_change(lines, wait_s=1.5)
send_event(ui_port, InputEventCode.DOWN)
assert_no_frame_change(lines, wait_s=1.5)
final = get_current_frame(lines)
assert (
final is not None and final.name == "nodelist_nodes"
), f"UP/DOWN moved us off nodelist_nodes; now on {final!r}"
frame_capture("after-up-down")
+88
View File
@@ -0,0 +1,88 @@
"""Unit tests for the `build_flags` injection on `flash.build()`.
We don't actually run pio here — too slow, requires hardware-aware envs.
We test the translation layer (`_build_flags_env`) and that the env vars
are threaded through pio.run correctly via mock.
"""
from __future__ import annotations
from unittest.mock import patch
from meshtastic_mcp import flash, pio
class TestBuildFlagsEnv:
def test_simple_value(self) -> None:
out = flash._build_flags_env({"DEBUG_HEAP": 1})
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"}
def test_string_value(self) -> None:
out = flash._build_flags_env({"FOO": "bar"})
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DFOO=bar"}
def test_bool_true_is_bare_flag(self) -> None:
out = flash._build_flags_env({"DEBUG_HEAP": True})
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP"}
def test_bool_false_dropped(self) -> None:
out = flash._build_flags_env({"DEBUG_HEAP": False, "OTHER": 1})
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DOTHER=1"}
def test_none_dropped(self) -> None:
out = flash._build_flags_env({"DEBUG_HEAP": None})
assert out == {}
def test_multiple_combined(self) -> None:
out = flash._build_flags_env({"DEBUG_HEAP": 1, "FOO": "x", "BAR": True})
# Order isn't guaranteed in dict iteration, so check membership.
flags = out["PLATFORMIO_BUILD_FLAGS"].split()
assert set(flags) == {"-DDEBUG_HEAP=1", "-DFOO=x", "-DBAR"}
class TestBuildPropagatesFlags:
def test_extra_env_passed_to_pio_run(self) -> None:
# Mock pio.run so we don't actually invoke pio. Capture extra_env.
captured = {}
class _StubResult:
returncode = 0
stdout = ""
stderr = ""
duration_s = 0.1
def _stub(args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return _StubResult()
with patch.object(pio, "run", side_effect=_stub):
with patch.object(flash, "_artifacts_for", return_value=[]):
out = flash.build(
"fake-env",
with_manifest=False,
build_flags={"DEBUG_HEAP": 1},
)
assert captured["args"] == ["run", "-e", "fake-env"]
assert captured["kwargs"]["extra_env"] == {
"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"
}
assert out["build_flags"] == {"DEBUG_HEAP": 1}
def test_no_flags_means_no_extra_env(self) -> None:
captured = {}
class _StubResult:
returncode = 0
stdout = ""
stderr = ""
duration_s = 0.1
def _stub(args, **kwargs):
captured["kwargs"] = kwargs
return _StubResult()
with patch.object(pio, "run", side_effect=_stub):
with patch.object(flash, "_artifacts_for", return_value=[]):
flash.build("fake-env", with_manifest=False)
assert captured["kwargs"]["extra_env"] is None
@@ -0,0 +1,383 @@
"""TCP transport plumbing in connection.py + devices.py.
Pure-Python tests — no real device or daemon required. Mocks `TCPInterface`
when exercising `connect()`.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from meshtastic_mcp import connection, devices
# ---------- helpers --------------------------------------------------------
class TestIsTcpPort:
def test_tcp_scheme(self) -> None:
assert connection.is_tcp_port("tcp://localhost") is True
assert connection.is_tcp_port("tcp://localhost:4403") is True
assert connection.is_tcp_port("tcp://192.168.1.50:9999") is True
def test_serial_paths(self) -> None:
assert connection.is_tcp_port("/dev/cu.usbmodem1234") is False
assert connection.is_tcp_port("/dev/ttyUSB0") is False
assert connection.is_tcp_port("COM3") is False
def test_empty_or_none(self) -> None:
assert connection.is_tcp_port(None) is False
assert connection.is_tcp_port("") is False
class TestParseTcpPort:
def test_default_port(self) -> None:
assert connection.parse_tcp_port("tcp://localhost") == ("localhost", 4403)
def test_explicit_port(self) -> None:
assert connection.parse_tcp_port("tcp://localhost:9999") == (
"localhost",
9999,
)
def test_ip_with_port(self) -> None:
assert connection.parse_tcp_port("tcp://192.168.1.50:4403") == (
"192.168.1.50",
4403,
)
class TestNormalizeTcpEndpoint:
def test_bare_host(self) -> None:
assert connection.normalize_tcp_endpoint("localhost") == "tcp://localhost:4403"
def test_host_port(self) -> None:
assert (
connection.normalize_tcp_endpoint("localhost:5000")
== "tcp://localhost:5000"
)
def test_full_url(self) -> None:
assert (
connection.normalize_tcp_endpoint("tcp://1.2.3.4") == "tcp://1.2.3.4:4403"
)
assert (
connection.normalize_tcp_endpoint("tcp://1.2.3.4:9999")
== "tcp://1.2.3.4:9999"
)
def test_idempotent(self) -> None:
once = connection.normalize_tcp_endpoint("localhost:4403")
twice = connection.normalize_tcp_endpoint(once)
assert once == twice == "tcp://localhost:4403"
def test_path_like_endpoint_rejected(self) -> None:
# Serial port paths and Windows drive paths are common config typos
# (someone passes a serial path to MESHTASTIC_MCP_TCP_HOST). Reject
# rather than producing a nonsense `tcp:///dev/cu.foo:4403` URL.
with pytest.raises(connection.ConnectionError, match="path separator"):
connection.normalize_tcp_endpoint("/dev/cu.foo")
with pytest.raises(connection.ConnectionError):
connection.normalize_tcp_endpoint("tcp:///dev/cu.foo:4403")
with pytest.raises(connection.ConnectionError):
connection.normalize_tcp_endpoint(r"C:\Windows\System32")
def test_non_integer_port_rejected(self) -> None:
with pytest.raises(connection.ConnectionError, match="not an integer"):
connection.normalize_tcp_endpoint("tcp://host:notaport")
with pytest.raises(connection.ConnectionError, match="not an integer"):
connection.normalize_tcp_endpoint("host:notaport")
def test_empty_host_rejected(self) -> None:
with pytest.raises(connection.ConnectionError, match="empty host"):
connection.normalize_tcp_endpoint("tcp://:4403")
def test_port_out_of_range_rejected(self) -> None:
with pytest.raises(connection.ConnectionError, match="out of range"):
connection.normalize_tcp_endpoint("tcp://host:0")
with pytest.raises(connection.ConnectionError, match="out of range"):
connection.normalize_tcp_endpoint("tcp://host:65536")
with pytest.raises(connection.ConnectionError, match="out of range"):
connection.normalize_tcp_endpoint("host:99999")
class TestParseTcpPortValidation:
def test_missing_scheme_rejected(self) -> None:
# parse_tcp_port is a low-level helper that requires the scheme.
# Misuse should fail loudly rather than silently mis-parsing.
with pytest.raises(connection.ConnectionError, match="expected"):
connection.parse_tcp_port("localhost:4403")
def test_negative_port_rejected(self) -> None:
with pytest.raises(connection.ConnectionError, match="out of range"):
connection.parse_tcp_port("tcp://host:-1")
# ---------- reject_if_tcp --------------------------------------------------
class TestRejectIfTcp:
def test_rejects_tcp(self) -> None:
with pytest.raises(connection.ConnectionError, match="not applicable"):
connection.reject_if_tcp("tcp://localhost", "esptool_chip_info")
def test_passes_through_serial(self) -> None:
connection.reject_if_tcp("/dev/cu.usbmodem1", "esptool_chip_info") # no raise
def test_passes_through_none(self) -> None:
# None means "auto-detect"; not the explicit-arg case we guard.
connection.reject_if_tcp(None, "esptool_chip_info") # no raise
# ---------- resolve_port ---------------------------------------------------
class TestResolvePort:
def test_explicit_serial_passthrough(self) -> None:
assert connection.resolve_port("/dev/cu.usbmodem999") == "/dev/cu.usbmodem999"
def test_explicit_tcp_normalized(self) -> None:
assert connection.resolve_port("tcp://localhost") == "tcp://localhost:4403"
def test_no_port_no_devices_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
with patch.object(devices, "list_devices", return_value=[]):
with pytest.raises(
connection.ConnectionError, match="No Meshtastic devices"
):
connection.resolve_port(None)
def test_no_port_one_candidate_selected(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
fake = [{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}]
with patch.object(devices, "list_devices", return_value=fake):
assert connection.resolve_port(None) == "/dev/cu.usbmodem1"
def test_no_port_multiple_candidates_errors(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
fake = [
{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True},
{"port": "/dev/cu.usbmodem2", "likely_meshtastic": True},
]
with patch.object(devices, "list_devices", return_value=fake):
with pytest.raises(connection.ConnectionError, match="Multiple"):
connection.resolve_port(None)
def test_env_var_surfaces_tcp_via_devices(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
# Don't patch list_devices — let the real env-var path run, but stub
# the USB enumeration to keep the test hermetic.
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
assert connection.resolve_port(None) == "tcp://localhost:4403"
# ---------- devices.list_devices TCP entry --------------------------------
class TestDevicesTcpEntry:
def test_no_env_var_no_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
ds = devices.list_devices()
assert all(not d["port"].startswith("tcp://") for d in ds)
def test_env_var_adds_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "myhost:9999")
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
ds = devices.list_devices()
tcp = [d for d in ds if d["port"].startswith("tcp://")]
assert len(tcp) == 1
assert tcp[0]["port"] == "tcp://myhost:9999"
assert tcp[0]["likely_meshtastic"] is True
assert tcp[0]["description"] == "meshtasticd (TCP)"
def test_tcp_entry_first_in_results(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
ds = devices.list_devices()
assert ds, "expected at least the TCP entry"
assert ds[0]["port"].startswith("tcp://")
def test_invalid_env_var_does_not_break_list_devices(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# `list_devices` is the diagnostic tool reached for when an env var
# isn't working — it must not throw on misconfiguration.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
ds = devices.list_devices(include_unknown=True)
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
assert len(tcp) == 1
assert tcp[0]["likely_meshtastic"] is False
assert "invalid MESHTASTIC_MCP_TCP_HOST" in tcp[0]["description"]
assert "not an integer" in tcp[0]["description"]
def test_invalid_env_var_excluded_from_resolve_port_autodetect(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# `likely_meshtastic=False` keeps the bad TCP entry out of the
# auto-select path — `resolve_port(None)` should still report
# "no Meshtastic devices" rather than picking a broken endpoint.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
with pytest.raises(connection.ConnectionError, match="No Meshtastic"):
connection.resolve_port(None)
def test_invalid_env_var_does_not_double_tcp_scheme(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# If a user mistakenly sets `MESHTASTIC_MCP_TCP_HOST=tcp://host:bad`,
# the diagnostic entry must surface the raw value as-is rather than
# producing `tcp://tcp://host:bad`.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "tcp://host:notaport")
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
ds = devices.list_devices(include_unknown=True)
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
assert len(tcp) == 1
assert tcp[0]["port"] == "tcp://host:notaport"
assert "tcp://tcp://" not in tcp[0]["port"]
def test_invalid_env_var_does_not_pre_empt_real_usb_devices(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Sort ordering: a misconfigured TCP env var must NOT take position 0
# ahead of real USB candidates. Position 0 is reserved for the highest
# rank (likely_meshtastic=True), with TCP-before-USB as a tiebreaker
# within rank.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
# Stub a USB Meshtastic candidate (Espressif VID, port present in
# findPorts).
class FakeInfo:
def __init__(self, device: str, vid: int, pid: int) -> None:
self.device = device
self.vid = vid
self.pid = pid
self.description = "Heltec V3"
self.manufacturer = "Espressif"
self.product = "USB JTAG/serial"
self.serial_number = "abc"
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
with patch(
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
), patch(
"meshtastic.util.findPorts",
return_value=["/dev/cu.usbmodem4201"],
):
ds = devices.list_devices(include_unknown=True)
assert ds, "expected at least the USB + TCP entries"
# Real USB candidate must be at position 0 — it's likely_meshtastic.
assert ds[0]["port"] == "/dev/cu.usbmodem4201"
assert ds[0]["likely_meshtastic"] is True
# The malformed TCP entry exists but lands among the unlikely entries.
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
assert len(tcp) == 1
assert tcp[0]["likely_meshtastic"] is False
assert ds.index(tcp[0]) > 0
def test_likely_tcp_entry_wins_tiebreak_over_usb(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Conversely, a *valid* TCP env var should sort ahead of USB
# candidates of equal likely_meshtastic rank — explicit env-var
# configuration is a precedence signal.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403")
class FakeInfo:
def __init__(self, device: str, vid: int, pid: int) -> None:
self.device = device
self.vid = vid
self.pid = pid
self.description = "Heltec V3"
self.manufacturer = "Espressif"
self.product = "USB JTAG/serial"
self.serial_number = "abc"
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
with patch(
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
), patch(
"meshtastic.util.findPorts",
return_value=["/dev/cu.usbmodem4201"],
):
ds = devices.list_devices()
assert ds[0]["port"] == "tcp://localhost:4403"
assert ds[0]["likely_meshtastic"] is True
# ---------- connect() routing ---------------------------------------------
class TestConnectRoutesTcp:
def test_connect_uses_tcp_interface_for_tcp_port(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Verify the TCP branch instantiates `TCPInterface(hostname, portNumber)`
and never touches `SerialInterface`."""
# Make sure the env var doesn't leak in and confuse resolve_port.
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp, patch(
"meshtastic.serial_interface.SerialInterface"
) as mock_serial:
mock_tcp.return_value.close.return_value = None
with connection.connect(port="tcp://example.com:1234", timeout_s=12.0):
pass
mock_tcp.assert_called_once_with(
hostname="example.com",
portNumber=1234,
connectNow=True,
noProto=False,
timeout=12,
)
mock_serial.assert_not_called()
def test_connect_plumbs_timeout_to_serial_interface(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Verify the serial branch also propagates `timeout_s` so callers
passing a custom timeout to `device_info` / `list_nodes` / etc. don't
silently get the library default."""
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
with patch("meshtastic.serial_interface.SerialInterface") as mock_serial, patch(
"meshtastic.tcp_interface.TCPInterface"
) as mock_tcp:
mock_serial.return_value.close.return_value = None
with connection.connect(port="/dev/cu.fake", timeout_s=20.0):
pass
mock_serial.assert_called_once_with(
devPath="/dev/cu.fake",
connectNow=True,
noProto=False,
timeout=20,
)
mock_tcp.assert_not_called()
def test_connect_releases_lock_on_tcp_failure(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
mock_tcp.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
with connection.connect(port="tcp://locktest:4403"):
pass
# Lock should be released — a second connect attempt must not fail
# with "busy".
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
mock_tcp.return_value.close.return_value = None
with connection.connect(port="tcp://locktest:4403"):
pass
@@ -0,0 +1,90 @@
"""Pin `InputEventCode` values to the firmware `input_broker_event` enum.
If this test fails, someone changed the firmware enum (or this Python
mirror) and they must stay in sync — the admin RPC sends these as u8
wire values directly.
Also exercises `coerce_event_code` for the happy + error paths.
"""
from __future__ import annotations
import pytest
from meshtastic_mcp.input_events import InputEventCode, coerce_event_code
class TestInputEventCodeValues:
"""These values MUST match src/input/InputBroker.h exactly."""
def test_navigation_keys(self) -> None:
assert int(InputEventCode.UP) == 17
assert int(InputEventCode.DOWN) == 18
assert int(InputEventCode.LEFT) == 19
assert int(InputEventCode.RIGHT) == 20
def test_action_keys(self) -> None:
assert int(InputEventCode.SELECT) == 10
assert int(InputEventCode.CANCEL) == 24
assert int(InputEventCode.BACK) == 27
def test_long_press_variants(self) -> None:
assert int(InputEventCode.SELECT_LONG) == 11
assert int(InputEventCode.UP_LONG) == 12
assert int(InputEventCode.DOWN_LONG) == 13
def test_fn_keys(self) -> None:
assert int(InputEventCode.FN_F1) == 0xF1
assert int(InputEventCode.FN_F2) == 0xF2
assert int(InputEventCode.FN_F3) == 0xF3
assert int(InputEventCode.FN_F4) == 0xF4
assert int(InputEventCode.FN_F5) == 0xF5
def test_system_events(self) -> None:
assert int(InputEventCode.SHUTDOWN) == 0x9B
assert int(InputEventCode.GPS_TOGGLE) == 0x9E
assert int(InputEventCode.SEND_PING) == 0xAF
def test_auto_increment_block(self) -> None:
# C enum: `BACK = 27, USER_PRESS, ALT_PRESS, ALT_LONG` → 28, 29, 30.
assert int(InputEventCode.USER_PRESS) == 28
assert int(InputEventCode.ALT_PRESS) == 29
assert int(InputEventCode.ALT_LONG) == 30
class TestCoerceEventCode:
def test_int_passthrough(self) -> None:
assert coerce_event_code(20) == 20
assert coerce_event_code(0) == 0
assert coerce_event_code(255) == 255
def test_enum_passthrough(self) -> None:
assert coerce_event_code(InputEventCode.RIGHT) == 20
assert coerce_event_code(InputEventCode.FN_F1) == 0xF1
def test_name_case_insensitive(self) -> None:
assert coerce_event_code("right") == 20
assert coerce_event_code("RIGHT") == 20
assert coerce_event_code("Right") == 20
def test_input_broker_prefix_stripped(self) -> None:
assert coerce_event_code("INPUT_BROKER_FN_F1") == 0xF1
assert coerce_event_code("input_broker_select") == 10
def test_hyphen_and_underscore_equivalence(self) -> None:
assert coerce_event_code("fn-f1") == 0xF1
def test_int_out_of_range_raises(self) -> None:
with pytest.raises(ValueError, match="u8"):
coerce_event_code(256)
with pytest.raises(ValueError, match="u8"):
coerce_event_code(-1)
def test_unknown_name_raises(self) -> None:
with pytest.raises(ValueError, match="unknown event code name"):
coerce_event_code("NOT_A_KEY")
def test_wrong_type_raises(self) -> None:
with pytest.raises(TypeError):
coerce_event_code(1.5) # type: ignore[arg-type]
with pytest.raises(TypeError):
coerce_event_code(None) # type: ignore[arg-type]
+548
View File
@@ -0,0 +1,548 @@
"""Unit tests for the persistent device-log recorder.
Hardware-free: drives the Recorder through its `_on_*` handlers with
synthetic packet/line dicts, then queries via log_query. Validates
prefix parsing, telemetry variant dispatch, marker round-trip, time
window filtering, downsampling, slope estimation, and gzip rotation
+ archive pruning.
"""
from __future__ import annotations
import gzip
import json
import logging
import os
import time
from pathlib import Path
import pubsub
import pytest
from meshtastic_mcp import log_query
from meshtastic_mcp.recorder.parsers import (
extract_telemetry,
interface_label,
parse_log_line,
summarize_packet,
)
from meshtastic_mcp.recorder.recorder import Recorder
from meshtastic_mcp.recorder.rotating import _RotatingJsonl
# -- isolation: every test gets a fresh Recorder + tmp dir -----------
@pytest.fixture
def recorder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Recorder:
# Redirect both the Recorder and the module-level singleton lookup
# to the same tmp dir so log_query queries the same files we write.
monkeypatch.setenv("MESHTASTIC_MCP_LOG_DIR", str(tmp_path))
monkeypatch.setattr(
"meshtastic_mcp.recorder.recorder._INSTANCE", None, raising=False
)
r = Recorder(base_dir=tmp_path)
r.start()
monkeypatch.setattr("meshtastic_mcp.recorder.recorder._INSTANCE", r, raising=False)
yield r
r.stop()
class _FakeIface:
devPath = "/dev/cu.fake"
# -- parsers ---------------------------------------------------------
class TestParseLogLine:
def test_full_prefix(self) -> None:
out = parse_log_line("INFO | 12:34:56 12345 [Main] Booting")
assert out["level"] == "INFO"
assert out["tag"] == "Main"
assert out["uptime_s"] == 12345
assert out["msg"] == "Booting"
assert out["clock"] == "12:34:56"
def test_invalid_clock(self) -> None:
out = parse_log_line("WARN | ??:??:?? 7 [SerialConsole] Boot")
assert out["level"] == "WARN"
assert out["clock"] == "??:??:??"
assert out["uptime_s"] == 7
def test_no_thread_bracket(self) -> None:
out = parse_log_line("DEBUG | 00:00:00 0 raw message body")
assert out["level"] == "DEBUG"
assert out.get("tag") is None
assert out["msg"] == "raw message body"
def test_bare_message(self) -> None:
# LogRecord.message path — no level prefix at all.
out = parse_log_line("just a bare message")
assert "level" not in out or out.get("level") is None
assert out["line"] == "just a bare message"
def test_empty(self) -> None:
assert parse_log_line("") == {"line": ""}
def test_debug_heap_prefix_extracted(self) -> None:
out = parse_log_line("INFO | 12:34:56 12345 [Main] [heap 92344] Booting")
assert out["level"] == "INFO"
assert out["tag"] == "Main"
assert out["heap_free"] == 92344
assert out["msg"] == "Booting"
def test_debug_heap_prefix_on_bare_line(self) -> None:
# LogRecord.message path: no level prefix but still has [heap N].
out = parse_log_line("[heap 12345] some message")
assert out["heap_free"] == 12345
assert out["msg"] == "some message"
def test_thread_leak_event(self) -> None:
out = parse_log_line(
"HEAP | 00:00:01 100 [Power] [heap 90000] "
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------"
)
assert out["level"] == "HEAP"
assert out["heap_free"] == 90000
ev = out["heap_event"]
assert ev["kind"] == "leaked"
assert ev["thread"] == "MeshPacket"
assert ev["before"] == 92344
assert ev["after"] == 90000
assert ev["delta"] == -2344
def test_thread_freed_event(self) -> None:
out = parse_log_line(
"++++++ Thread Router freed heap 1000 -> 1500 (500) ++++++"
)
ev = out["heap_event"]
assert ev["kind"] == "freed"
assert ev["thread"] == "Router"
assert ev["delta"] == 500
def test_heap_status_periodic(self) -> None:
out = parse_log_line(
"HEAP | 00:00:30 30 [Power] "
"Heap status: 92344/200000 bytes free (-128), running 8/12 threads"
)
assert out["heap_free"] == 92344
assert out["heap_total"] == 200000
assert out["heap_delta"] == -128
class TestRecorderDebugHeapSynthesis:
def test_log_with_heap_writes_telemetry(self, recorder: "Recorder") -> None:
# When a log line carries [heap N], the recorder should also
# emit a synthesized telemetry row tagged source=debug_heap.
recorder._on_log_line(
"INFO | 00:00:00 1 [Main] [heap 88888] hello",
_FakeIface(),
)
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
assert len(synth) == 1
assert synth[0]["fields"]["heap_free_bytes"] == 88888
assert synth[0]["variant"] == "local"
def test_heap_status_writes_total_too(self, recorder: "Recorder") -> None:
recorder._on_log_line(
"HEAP | 00:00:30 30 [Power] "
"Heap status: 50000/200000 bytes free (-100), running 8/12 threads",
_FakeIface(),
)
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
assert synth[-1]["fields"]["heap_free_bytes"] == 50000
assert synth[-1]["fields"]["heap_total_bytes"] == 200000
def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None:
# Plain log line (no [heap N], no Heap status) — telemetry.jsonl
# should NOT gain a synth row.
before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface())
after = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
assert after == before
def test_thread_leak_event_persists_on_log_row(self, recorder: "Recorder") -> None:
recorder._on_log_line(
"HEAP | 00:00:01 100 [Power] [heap 90000] "
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------",
_FakeIface(),
)
rows = [
json.loads(r)
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
if r
]
evt_rows = [r for r in rows if r.get("heap_event")]
assert len(evt_rows) == 1
assert evt_rows[0]["heap_event"]["thread"] == "MeshPacket"
assert evt_rows[0]["heap_event"]["delta"] == -2344
class TestSerialTap:
def test_serial_line_records_log_and_synthesizes_heap(
self, recorder: "Recorder"
) -> None:
recorder._on_serial_line(
"INFO | 00:00:00 5 [Main] [heap 88888] tap-line",
port="/dev/cu.tap",
)
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
log_rows = [json.loads(r) for r in logs if r]
# Find the row from this call (port=/dev/cu.tap, role=serial_session)
tap_rows = [r for r in log_rows if r.get("port") == "/dev/cu.tap"]
assert len(tap_rows) == 1
assert tap_rows[0]["role"] == "serial_session"
assert tap_rows[0]["level"] == "INFO"
assert tap_rows[0]["tag"] == "Main"
assert tap_rows[0]["heap_free"] == 88888
synth = [json.loads(r) for r in telem if '"source":"debug_heap_serial"' in r]
assert len(synth) == 1
assert synth[0]["fields"]["heap_free_bytes"] == 88888
assert synth[0]["role"] == "serial_session"
def test_serial_line_thread_leak_event(self, recorder: "Recorder") -> None:
recorder._on_serial_line(
"HEAP | 00:00:30 30 [Power] [heap 53484] "
"------ Thread Router leaked heap 53612 -> 53484 (-128) ------",
port="/dev/cu.tap",
)
rows = [
json.loads(r)
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
if r
]
evt = [r for r in rows if r.get("heap_event")]
assert len(evt) == 1
assert evt[0]["heap_event"]["thread"] == "Router"
assert evt[0]["heap_event"]["delta"] == -128
# Heap also synthesized.
telem = (recorder.base_dir / "telemetry.jsonl").read_text()
assert '"source":"debug_heap_serial"' in telem
def test_serial_line_pause(self, recorder: "Recorder") -> None:
recorder.pause("baseline")
recorder._on_serial_line(
"INFO | 00:00:00 1 [t] [heap 1000] dropped",
port="/dev/cu.tap",
)
# Only the pause event row should exist; no tap row.
logs = (recorder.base_dir / "logs.jsonl").read_text()
assert "dropped" not in logs
def test_serial_line_handler_swallows_exceptions(
self, recorder: "Recorder"
) -> None:
# Hostile input — should not raise.
recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type]
recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type]
# Survived.
class TestExtractTelemetry:
def test_local_stats_camel(self) -> None:
pkt = {
"decoded": {
"telemetry": {
"localStats": {"heap_total_bytes": 1000, "heap_free_bytes": 600}
}
}
}
out = extract_telemetry(pkt)
assert out is not None
assert out["variant"] == "local"
assert out["fields"]["heap_free_bytes"] == 600
def test_device_metrics_snake(self) -> None:
pkt = {
"decoded": {
"telemetry": {"device_metrics": {"battery_level": 88, "voltage": 4.1}}
}
}
out = extract_telemetry(pkt)
assert out is not None
assert out["variant"] == "device"
assert out["fields"]["battery_level"] == 88
def test_unknown_variant_returns_none(self) -> None:
assert extract_telemetry({"decoded": {"telemetry": {"weird": {}}}}) is None
assert extract_telemetry({}) is None
assert extract_telemetry({"decoded": "not-a-dict"}) is None
class TestSummarizePacket:
def test_text_with_payload(self) -> None:
pkt = {
"fromId": "!abc",
"toId": "!def",
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
"hopLimit": 3,
}
out = summarize_packet(pkt)
assert out["from_node"] == "!abc"
assert out["portnum"] == "TEXT_MESSAGE_APP"
assert out["payload_size"] == 5
assert out["payload_hex_prefix"] == "68656c6c6f"
def test_no_decoded(self) -> None:
out = summarize_packet({"fromId": "!abc"})
assert out["from_node"] == "!abc"
assert out["portnum"] is None
class TestInterfaceLabel:
def test_serial(self) -> None:
assert interface_label(_FakeIface()) == {
"port": "/dev/cu.fake",
"role": "serial",
}
def test_tcp(self) -> None:
class T:
hostname = "node.lan"
portNumber = 4403
assert interface_label(T()) == {"port": "tcp://node.lan:4403", "role": "tcp"}
def test_unknown(self) -> None:
assert interface_label(object()) == {"port": "object", "role": None}
def test_none(self) -> None:
assert interface_label(None) == {"port": None, "role": None}
# -- recorder write side ---------------------------------------------
class TestRecorderWrites:
def test_log_line_is_recorded(self, recorder: Recorder) -> None:
recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface())
path = recorder.base_dir / "logs.jsonl"
rows = [json.loads(line) for line in path.read_text().splitlines() if line]
# First row is recorder_start_event mirror? No — that's events.jsonl only.
assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows)
def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None:
# _on_telemetry alone — only telemetry.jsonl
recorder._on_telemetry(
{
"fromId": "!abc",
"decoded": {"telemetry": {"localStats": {"heap_free_bytes": 600}}},
},
_FakeIface(),
)
telem_rows = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
assert any('"variant":"local"' in r for r in telem_rows)
def test_packets_summary(self, recorder: Recorder) -> None:
recorder._on_receive(
{
"fromId": "!abc",
"toId": "!def",
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi"},
},
_FakeIface(),
)
rows = (recorder.base_dir / "packets.jsonl").read_text().splitlines()
assert any('"portnum":"TEXT_MESSAGE_APP"' in r for r in rows)
def test_mark_event_round_trip(self, recorder: Recorder) -> None:
out = recorder.mark_event("checkpoint", note="midpoint")
assert "ts" in out
events = (recorder.base_dir / "events.jsonl").read_text().splitlines()
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
assert any('"label":"checkpoint"' in r and '"kind":"mark"' in r for r in events)
assert any('"level":"MARK"' in r and "checkpoint" in r for r in logs)
def test_pause_drops_writes(self, recorder: Recorder) -> None:
before = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
recorder.pause(reason="baseline")
recorder._on_log_line("INFO | 00:00:00 1 [t] swallowed", _FakeIface())
after = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
assert after == before
recorder.resume()
recorder._on_log_line("INFO | 00:00:00 2 [t] kept", _FakeIface())
post_resume = (recorder.base_dir / "logs.jsonl").read_text()
assert "kept" in post_resume
def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None:
# If the writer dies, the pubsub callback must NOT raise — that
# would crash the meshtastic receive thread.
bad_packet = object() # not a dict
recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type]
recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type]
recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type]
# No assertion needed — survival is the test.
# -- log_query read side ---------------------------------------------
class TestLogQuery:
def test_logs_window_grep_and_level(self, recorder: Recorder) -> None:
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
recorder._on_log_line("WARN | 12:00:01 2 [B] bravo failed", _FakeIface())
recorder._on_log_line("ERROR | 12:00:02 3 [C] charlie failed", _FakeIface())
out = log_query.logs_window(start="-1m", level="WARN|ERROR", max_lines=10)
assert out["total_matched"] == 2
levels = {r["level"] for r in out["lines"]}
assert levels == {"WARN", "ERROR"}
out2 = log_query.logs_window(start="-1m", grep=r"failed$", max_lines=10)
assert out2["total_matched"] == 2
def test_logs_window_invalid_regex(self, recorder: Recorder) -> None:
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
with pytest.raises(ValueError, match="invalid grep regex"):
log_query.logs_window(start="-1m", grep="(")
def test_telemetry_timeline_slope_and_downsample(self, recorder: Recorder) -> None:
# Synthesize a downward leak: 100 points, free_heap drops 1 byte/sample.
base_ts = time.time() - 60
for i in range(100):
recorder._files["telemetry"].write(
{
"ts": base_ts + i * 0.5,
"port": "/dev/cu.fake",
"role": "serial",
"from_node": "!abc",
"variant": "local",
"fields": {"heap_free_bytes": 10000 - i},
}
)
out = log_query.telemetry_timeline(
window="2m", variant="local", field="free_heap", max_points=10
)
assert out["samples"] == 100
assert len(out["points"]) <= 10
# Negative slope (heap dropping). Magnitude: 1 byte every 0.5s = 120/min.
assert out["slope_per_min"] is not None
assert out["slope_per_min"] < -100
def test_export_bundles_slice(self, recorder: Recorder, tmp_path: Path) -> None:
recorder._on_log_line("INFO | 00:00:00 1 [t] one", _FakeIface())
recorder._on_log_line("INFO | 00:00:00 2 [t] two", _FakeIface())
dest = tmp_path / "bundle"
out = log_query.export(start="-1m", end="now", dest_dir=str(dest))
assert (dest / "logs.jsonl").exists()
assert "logs" in out["paths"]
# -- time parser -----------------------------------------------------
class TestParseTime:
def test_relative(self) -> None:
now = 1_000_000.0
assert log_query._parse_time("-15m", now=now) == now - 900
assert log_query._parse_time("-2h", now=now) == now - 7200
assert log_query._parse_time("-1d", now=now) == now - 86400
def test_now_and_epoch(self) -> None:
now = 1_000_000.0
assert log_query._parse_time("now", now=now) == now
assert log_query._parse_time(now) == now
def test_iso(self) -> None:
ts = log_query._parse_time("2026-01-01T00:00:00Z")
assert isinstance(ts, float) and ts > 1_700_000_000
def test_naive_iso_assumes_utc(self) -> None:
assert log_query._parse_time("2026-01-01T00:00:00") == log_query._parse_time(
"2026-01-01T00:00:00Z"
)
def test_invalid(self) -> None:
with pytest.raises(ValueError):
log_query._parse_time("not a time")
# -- rotation --------------------------------------------------------
class TestRotation:
def test_size_cap_rotates_and_gzips(self, tmp_path: Path) -> None:
path = tmp_path / "rot.jsonl"
r = _RotatingJsonl(path, max_bytes=512, keep_archives=5, check_every=1)
for i in range(100):
r.write({"ts": float(i), "i": i, "pad": "x" * 40})
r.close()
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
assert archives, "expected at least one rotation"
# Archive content is valid gzip + valid JSONL
with gzip.open(archives[0], "rt") as fh:
first = json.loads(fh.readline())
assert "ts" in first
def test_archive_pruning(self, tmp_path: Path) -> None:
path = tmp_path / "rot.jsonl"
r = _RotatingJsonl(path, max_bytes=200, keep_archives=2, check_every=1)
# Force several rotations.
for _ in range(8):
for i in range(20):
r.write({"ts": float(i), "pad": "x" * 30})
r.force_rotate()
r.close()
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
assert len(archives) <= 2, f"expected ≤2 kept archives, got {len(archives)}"
def test_archive_pruning_uses_filename_order(self, tmp_path: Path) -> None:
path = tmp_path / "rot.jsonl"
r = _RotatingJsonl(path, keep_archives=2)
old = tmp_path / "rot.20260101-000000-000000-00000.jsonl.gz"
mid = tmp_path / "rot.20260101-000001-000000-00000.jsonl.gz"
new = tmp_path / "rot.20260101-000002-000000-00000.jsonl.gz"
for archive in (old, mid, new):
with gzip.open(archive, "wt", encoding="utf-8") as fh:
fh.write('{"ts":1}\n')
# Deliberately scramble mtimes so lexicographic filename order is
# the only stable chronological signal.
os.utime(old, (300, 300))
os.utime(mid, (100, 100))
os.utime(new, (200, 200))
r._prune_archives()
r.close()
archives = sorted(p.name for p in tmp_path.glob("rot.*.jsonl.gz"))
assert archives == [mid.name, new.name]
def test_force_rotate_when_below_threshold(self, tmp_path: Path) -> None:
path = tmp_path / "rot.jsonl"
r = _RotatingJsonl(path, max_bytes=10_000_000, check_every=999_999)
r.write({"ts": 1.0, "msg": "tiny"})
r.force_rotate()
r.write({"ts": 2.0, "msg": "after-rotate"})
r.close()
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
assert len(archives) == 1
assert path.exists()
assert "after-rotate" in path.read_text()
class TestRecorderLocks:
def test_force_rotate_all_returns_status(self, recorder: Recorder) -> None:
out = recorder.force_rotate_all()
assert out["running"] is True
assert out["files"]
def test_wire_pubsub_logs_subscription_failure(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
class FailingPubSubMock:
def subscribe(self, callback: object, topic: str) -> None:
raise RuntimeError("boom")
monkeypatch.setattr(pubsub, "pub", FailingPubSubMock())
recorder = Recorder(base_dir=tmp_path)
with caplog.at_level(logging.WARNING):
recorder._wire_pubsub()
assert (
"Recorder failed to subscribe to meshtastic.log.line: boom" in caplog.text
)
@@ -0,0 +1,148 @@
"""Pin the `uhubctl` default-output parser against canned real-world samples.
uhubctl's output format has been stable since v2.x but occasionally adds
new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose
regexes to tolerate additions; this test keeps us honest.
Samples captured from:
- v2.6.0 on macOS (Homebrew) — two USB2 hubs, one populated with an
nRF52 and a CP2102, plus chained USB3 hubs.
- v2.5.0 on Linux (hypothetical — reconstructed from the project README).
"""
from __future__ import annotations
import pytest
from meshtastic_mcp.uhubctl import (
ROLE_VIDS,
UhubctlError,
parse_list_output,
)
# Actual `uhubctl` stdout on the developer's macOS bench, Apr 2026.
_SAMPLE_MACOS_V26 = """\
Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
Port 1: 0100 power
Port 2: 0103 power enable connect [239a:8029 RAKwireless WisCore RAK4631 Board 920456B1E6972262]
Port 3: 0103 power enable connect [10c4:ea60 Silicon Labs CP2102 USB to UART Bridge Controller 0001]
Port 4: 0100 power
Current status for hub 1-2.3 [2109:0817 VIA Labs, Inc. USB3.0 Hub, USB 3.10, 4 ports, ppps]
Port 1: 02a0 power 5gbps Rx.Detect
Port 2: 02a0 power 5gbps Rx.Detect
Port 3: 02a0 power 5gbps Rx.Detect
Port 4: 02a0 power 5gbps Rx.Detect
Current status for hub 1-1 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
Port 1: 0100 power
Port 2: 0100 power
Port 3: 0503 power highspeed enable connect [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps]
Port 4: 0100 power
"""
# Minimal Linux-style sample (fewer hubs, shows a non-PPPS hub).
_SAMPLE_LINUX_NONPPPS = """\
Current status for hub 2-1.4 [05e3:0608 GenesysLogic USB2.1 Hub, USB 2.10, 4 ports]
Port 1: 0507 power highspeed suspend enable connect [239a:0029 Adafruit Feather Bootloader]
Port 2: 0100 power
Port 3: 0100 power
Port 4: 0100 power
"""
class TestParseListOutput:
def test_parses_macos_sample_hub_count(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
assert len(hubs) == 3
def test_parses_hub_location_and_vid(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
via_hub = hubs[0]
assert via_hub["location"] == "1-1.3"
assert via_hub["vid"] == 0x2109
assert via_hub["pid"] == 0x2817
assert via_hub["ppps"] is True
def test_parses_port_with_device(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
nrf52_hub = hubs[0]
port2 = next(p for p in nrf52_hub["ports"] if p["port"] == 2)
assert port2["device_vid"] == 0x239A
assert port2["device_pid"] == 0x8029
assert "RAKwireless" in port2["device_desc"]
def test_empty_port_has_no_device(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
nrf52_hub = hubs[0]
port1 = next(p for p in nrf52_hub["ports"] if p["port"] == 1)
assert port1["device_vid"] is None
assert port1["device_pid"] is None
assert port1["device_desc"] is None
def test_ports_count(self) -> None:
hubs = parse_list_output(_SAMPLE_MACOS_V26)
for hub in hubs:
assert len(hub["ports"]) == 4 # each sample hub has 4 ports
def test_non_ppps_hub_flagged(self) -> None:
hubs = parse_list_output(_SAMPLE_LINUX_NONPPPS)
assert len(hubs) == 1
assert hubs[0]["ppps"] is False
def test_handles_empty_input(self) -> None:
assert parse_list_output("") == []
def test_handles_malformed_lines_gracefully(self) -> None:
# Lines that don't match HUB_RE or PORT_RE are ignored silently.
garbage = "uhubctl: warning: something weird\n" + _SAMPLE_LINUX_NONPPPS
hubs = parse_list_output(garbage)
assert len(hubs) == 1
class TestRoleVids:
def test_nrf52_mapped(self) -> None:
assert 0x239A in ROLE_VIDS["nrf52"]
def test_esp32s3_covers_both_vids(self) -> None:
# Espressif native USB + CP2102 USB-UART on heltec-v3 boards.
assert 0x303A in ROLE_VIDS["esp32s3"]
assert 0x10C4 in ROLE_VIDS["esp32s3"]
class TestResolveTargetErrorPaths:
def test_unknown_role_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
from meshtastic_mcp.uhubctl import resolve_target
# Clear any env-var pinning that might make this pass accidentally.
for key in (
"MESHTASTIC_UHUBCTL_LOCATION_FLUX",
"MESHTASTIC_UHUBCTL_PORT_FLUX",
):
monkeypatch.delenv(key, raising=False)
with pytest.raises(UhubctlError, match="unknown role"):
resolve_target("flux")
def test_invalid_env_port_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
from meshtastic_mcp.uhubctl import resolve_target
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "1-1.3")
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "not-an-int")
with pytest.raises(UhubctlError, match="not a valid integer"):
resolve_target("nrf52")
def test_env_var_pinning_wins(self, monkeypatch: pytest.MonkeyPatch) -> None:
from meshtastic_mcp.uhubctl import resolve_target
# Env-var pinning should NOT require uhubctl to be running / installed.
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "9-9.9")
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "7")
assert resolve_target("nrf52") == ("9-9.9", 7)
def test_normalize_role_strips_alt_suffix(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from meshtastic_mcp.uhubctl import resolve_target
# esp32s3_alt collapses to esp32s3 for env-var lookup.
monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_ESP32S3", "2-2")
monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_ESP32S3", "3")
assert resolve_target("esp32s3_alt") == ("2-2", 3)
@@ -0,0 +1,80 @@
"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass.
The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`;
if the format string changes, this test — and the parser in
`tests/ui/_screen_log.py` — have to be updated together.
"""
from __future__ import annotations
from tests.ui._screen_log import FRAME_RE, FrameEvent, iter_frame_events
class TestFrameEventParse:
def test_exact_firmware_output(self) -> None:
raw = "Screen: frame 2/8 name=home reason=next"
evt = FrameEvent.parse(raw)
assert evt is not None
assert evt.idx == 2
assert evt.count == 8
assert evt.name == "home"
assert evt.reason == "next"
assert evt.raw == raw
def test_with_log_prefix(self) -> None:
"""Log lines may be preamble-wrapped by the firmware LOG_INFO macro
(timestamp, severity, etc.) — the regex uses .search() not .match()
so prefixes are fine."""
raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 "
evt = FrameEvent.parse(raw)
assert evt is not None
assert evt.idx == 4
assert evt.count == 12
assert evt.name == "nodelist_nodes"
assert evt.reason == "fn_f3"
def test_rebuild_reason(self) -> None:
evt = FrameEvent.parse("Screen: frame 0/5 name=deviceFocused reason=rebuild")
assert evt is not None
assert evt.reason == "rebuild"
def test_all_fn_reasons(self) -> None:
for k in range(1, 6):
evt = FrameEvent.parse(
f"Screen: frame {k - 1}/8 name=settings reason=fn_f{k}"
)
assert evt is not None and evt.reason == f"fn_f{k}"
def test_unknown_name_is_preserved(self) -> None:
"""If the reverse-map returns 'unknown', that still parses cleanly."""
evt = FrameEvent.parse("Screen: frame 99/100 name=unknown reason=prev")
assert evt is not None and evt.name == "unknown"
def test_non_matching_line_returns_none(self) -> None:
assert FrameEvent.parse("BOOT Booting firmware 2.7.23") is None
assert FrameEvent.parse("") is None
assert FrameEvent.parse("Screen: without the right format") is None
class TestIterFrameEvents:
def test_filters_non_matching_lines(self) -> None:
lines = [
"Booting...",
"Screen: frame 1/5 name=home reason=rebuild",
"Some other log line",
"Screen: frame 2/5 name=textMessage reason=next",
]
evts = list(iter_frame_events(lines))
assert len(evts) == 2
assert evts[0].reason == "rebuild"
assert evts[1].reason == "next"
class TestRegexAnchoring:
def test_regex_is_compiled(self) -> None:
assert FRAME_RE.search("Screen: frame 0/0 name=home reason=next") is not None
def test_regex_allows_unusual_names(self) -> None:
r"""Name is `\S+`, so compound names with underscores/digits match."""
m = FRAME_RE.search("Screen: frame 5/10 name=nodelist_hopsignal reason=fn_f2")
assert m is not None and m["name"] == "nodelist_hopsignal"
+3 -3
View File
@@ -29,6 +29,7 @@ build_flags = -Wno-missing-field-initializers
-DUSE_THREAD_NAMES
-DTINYGPS_OPTION_NO_CUSTOM_FIELDS
-DPB_ENABLE_MALLOC=1
-DPB_VALIDATE_UTF8=1
-DRADIOLIB_EXCLUDE_CC1101=1
-DRADIOLIB_EXCLUDE_NRF24=1
-DRADIOLIB_EXCLUDE_RF69=1
@@ -58,7 +59,6 @@ build_flags = -Wno-missing-field-initializers
-DMESHTASTIC_EXCLUDE_POWERMON=1
-DMESHTASTIC_EXCLUDE_STATUS=1
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
-DLED_BUILTIN=-1
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
#-D OLED_PL=1
#-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs
@@ -68,7 +68,7 @@ monitor_speed = 115200
monitor_filters = direct
lib_deps =
# renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/21e484f409cde18d44012caef84c244eb5ca28f3.zip
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip
# renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master
https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip
# renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master
@@ -126,7 +126,7 @@ lib_deps =
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
https://github.com/meshtastic/device-ui/archive/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip
https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip
; Common libs for environmental measurements in telemetry module
[environmental_base]
+16 -19
View File
@@ -26,6 +26,8 @@ SOFTWARE.*/
#include "DebugConfiguration.h"
#include <memory>
#ifdef ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
#endif
@@ -119,27 +121,22 @@ bool Syslog::vlogf(uint16_t pri, const char *fmt, va_list args)
bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args)
{
char *message;
size_t initialLen;
size_t len;
bool result;
// First measure the formatted length using a copy of args; passing args directly
// to vsnprintf consumes it, and reusing a consumed va_list is undefined behavior.
va_list args_measure;
va_copy(args_measure, args);
int needed = vsnprintf(nullptr, 0, fmt, args_measure);
va_end(args_measure);
initialLen = strlen(fmt);
if (needed < 0)
return false; // encoding error
message = new char[initialLen + 1];
auto message = std::unique_ptr<char[]>(new char[static_cast<size_t>(needed) + 1]);
int written = vsnprintf(message.get(), static_cast<size_t>(needed) + 1, fmt, args);
if (written < 0)
return false;
len = vsnprintf(message, initialLen + 1, fmt, args);
if (len > initialLen) {
delete[] message;
message = new char[len + 1];
vsnprintf(message, len + 1, fmt, args);
}
result = this->_sendLog(pri, appName, message);
delete[] message;
return result;
return this->_sendLog(pri, appName, message.get());
}
inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message)
@@ -154,7 +151,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess
if (!this->_enabled)
return false;
if ((this->_server == NULL && this->_ip == INADDR_NONE) || this->_port == 0)
if ((this->_server == NULL && this->_ip == IPAddress(0, 0, 0, 0)) || this->_port == 0)
return false;
// Check priority against priMask values.
+8 -1
View File
@@ -13,6 +13,11 @@ extern MemGet memGet;
#define LED_STATE_ON 1
#endif
// WIFI LED
#ifndef WIFI_STATE_ON
#define WIFI_STATE_ON 1
#endif
// -----------------------------------------------------------------------------
// DEBUG
// -----------------------------------------------------------------------------
@@ -147,7 +152,9 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
// Default Bluetooth PIN
#define defaultBLEPin 123456
#if HAS_ETHERNET && !defined(USE_WS5500)
#if HAS_ETHERNET && defined(USE_CH390D)
#include <ESP32_CH390.h>
#elif HAS_ETHERNET && !defined(USE_WS5500)
#include <RAK13800_W5100S.h>
#endif // HAS_ETHERNET
+111 -31
View File
@@ -717,37 +717,17 @@ bool Power::setup()
found = true;
#endif
}
#ifdef EXT_PWR_DETECT
attachInterrupt(
EXT_PWR_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef BATTERY_CHARGING_INV
attachInterrupt(
BATTERY_CHARGING_INV,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef EXT_CHRG_DETECT
attachInterrupt(
EXT_CHRG_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
},
CHANGE);
#endif
attachPowerInterrupts();
enabled = found;
low_voltage_counter = 0;
#ifdef ARCH_ESP32
// Register callbacks for before and after lightsleep
// Used to detach and reattach interrupts
lsObserver.observe(&notifyLightSleep);
lsEndObserver.observe(&notifyLightSleepEnd);
#endif
return found;
}
@@ -775,8 +755,10 @@ void Power::reboot()
rp2040.reboot();
#elif defined(ARCH_PORTDUINO)
deInitApiServer();
#ifdef __linux__
if (aLinuxInputImpl)
aLinuxInputImpl->deInit();
#endif
SPI.end();
Wire.end();
Serial1.end();
@@ -896,7 +878,16 @@ void Power::readPowerStatus()
// Notify any status instances that are observing us
const PowerStatus powerStatus2 = PowerStatus(hasBattery, usbPowered, isChargingNow, batteryVoltageMv, batteryChargePercent);
if (millis() > lastLogTime + 50 * 1000) {
// Log battery-presence transitions once; skip OptUnknown so we don't lie before the first probe.
static OptionalBool prevHasBattery = OptUnknown;
if (hasBattery != OptUnknown && hasBattery != prevHasBattery) {
LOG_INFO("Power: battery hardware %s", hasBattery == OptTrue ? "detected" : "absent (USB-only)");
prevHasBattery = hasBattery;
}
// Periodic telemetry only emits when a battery is actually present (otherwise values are constant -1/0).
if (hasBattery == OptTrue && !Throttle::isWithinTimespanMs(lastLogTime, 50 * 1000)) {
LOG_DEBUG("Battery: usbPower=%d, isCharging=%d, batMv=%d, batPct=%d", powerStatus2.getHasUSB(),
powerStatus2.getIsCharging(), powerStatus2.getBatteryVoltageMv(), powerStatus2.getBatteryChargePercent());
lastLogTime = millis();
@@ -1026,6 +1017,97 @@ int32_t Power::runOnce()
return (statusHandler && statusHandler->isInitialized()) ? (1000 * 20) : RUN_SAME;
}
#ifdef ARCH_ESP32
// Detach our class' interrupts before lightsleep
// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press
int Power::beforeLightSleep(void *unused)
{
LOG_WARN("Detaching power interrupts for sleep");
detachPowerInterrupts();
return 0; // Indicates success
}
// Reconfigure our interrupts
// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep
int Power::afterLightSleep(esp_sleep_wakeup_cause_t cause)
{
attachPowerInterrupts();
return 0; // Indicates success
}
#endif
/*
* Attach (or re-attach) hardware interrupts for power management
* Public method. Used outside class when waking from MCU sleep
*/
void Power::attachPowerInterrupts()
{
#ifdef EXT_PWR_DETECT
attachInterrupt(
EXT_PWR_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef BATTERY_CHARGING_INV
attachInterrupt(
BATTERY_CHARGING_INV,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
},
CHANGE);
#endif
#ifdef EXT_CHRG_DETECT
attachInterrupt(
EXT_CHRG_DETECT,
[]() {
power->setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
},
CHANGE);
#endif
#ifdef PMU_IRQ
if (PMU) {
attachInterrupt(
PMU_IRQ,
[] {
pmu_irq = true;
power->setIntervalFromNow(0);
runASAP = true;
},
FALLING);
}
#endif
}
/*
* Detach the "normal" button interrupts.
* Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep
*/
void Power::detachPowerInterrupts()
{
#ifdef EXT_PWR_DETECT
detachInterrupt(EXT_PWR_DETECT);
#endif
#ifdef BATTERY_CHARGING_INV
detachInterrupt(BATTERY_CHARGING_INV);
#endif
#ifdef EXT_CHRG_DETECT
detachInterrupt(EXT_CHRG_DETECT);
#endif
#ifdef PMU_IRQ
if (PMU) {
detachInterrupt(PMU_IRQ);
}
#endif
}
/**
* Init the power manager chip
*
@@ -1303,8 +1385,6 @@ bool Power::axpChipInit()
}
pinMode(PMU_IRQ, INPUT);
attachInterrupt(
PMU_IRQ, [] { pmu_irq = true; }, FALLING);
// we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ
// because it occurs repeatedly while there is no battery also it could cause
+1 -1
View File
@@ -1,8 +1,8 @@
#pragma once
#include "../freertosinc.h"
#include "Print.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <Print.h>
#include <stdarg.h>
#include <string>
+7
View File
@@ -14,6 +14,11 @@ Lock::Lock() : handle(xSemaphoreCreateBinary())
}
}
Lock::~Lock()
{
vSemaphoreDelete(handle);
}
void Lock::lock()
{
if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
@@ -30,6 +35,8 @@ void Lock::unlock()
#else
Lock::Lock() {}
Lock::~Lock() {}
void Lock::lock() {}
void Lock::unlock() {}
+1
View File
@@ -12,6 +12,7 @@ class Lock
{
public:
Lock();
~Lock();
Lock(const Lock &) = delete;
Lock &operator=(const Lock &) = delete;
+3
View File
@@ -157,6 +157,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#elif defined(HELTEC_MESH_NODE_T096)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7
#elif defined(HELTEC_V4_R8)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7
#else
// If a board enables USE_KCT8103L_PA but does not match a known variant and has
// not already provided a PA curve, fail at compile time to avoid unsafe defaults.
+1 -1
View File
@@ -34,6 +34,7 @@ class ScanI2C
SHT31,
SHT4X,
SHTC3,
SHTXX,
LPS22HB,
QMC6310U,
QMC6310N,
@@ -96,7 +97,6 @@ class ScanI2C
CW2015,
SCD30,
ADS1115,
CST3530,
} DeviceType;
// typedef uint8_t DeviceAddress;
+11 -3
View File
@@ -1017,10 +1017,13 @@ void GPS::up()
setPowerState(GPS_ACTIVE);
}
// We've got a GPS lock. Enter a low power state, potentially.
// We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially.
void GPS::down()
{
scheduling.informGotLock();
if (hasValidLocation)
scheduling.informGotLock();
else
scheduling.informSearchFailed();
uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs();
uint32_t sleepTime = scheduling.msUntilNextSearch();
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
@@ -1545,7 +1548,12 @@ std::unique_ptr<GPS> GPS::createGps()
_en_gpio = PIN_GPS_EN;
#endif
#ifdef ARCH_PORTDUINO
if (!portduino_config.has_gps)
if (portduino_config.has_gps) {
// These need to set as flags so later checks will pass on native and GPS will work.
// They are not used for any hardware access.
_rx_gpio = 1;
_tx_gpio = 1;
} else
return nullptr;
#endif
if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all
+43 -6
View File
@@ -15,6 +15,19 @@ void GPSUpdateScheduling::informGotLock()
searchEndedMs = millis();
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
updateLockTimePrediction();
consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix
}
// Search finished without obtaining a fix. We still need to mark the end time so
// the next sleep is timed correctly, but we must not feed the timeout duration
// into predictedMsToGetLock — doing so poisons msUntilNextSearch() and causes
// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles.
void GPSUpdateScheduling::informSearchFailed()
{
searchEndedMs = millis();
consecutiveFailures++;
LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000,
consecutiveFailures);
}
// Clear old lock-time prediction data.
@@ -25,6 +38,7 @@ void GPSUpdateScheduling::reset()
searchEndedMs = 0;
searchCount = 0;
predictedMsToGetLock = 0;
consecutiveFailures = 0;
}
// How many milliseconds before we should next search for GPS position
@@ -36,6 +50,20 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch()
// Target interval (seconds), between GPS updates
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
// After a failed search, back off: indoors / no-sky environments will keep failing,
// so wake at most once per broadcast interval rather than once per gps_update_interval.
// Capped at 1 hour so a user-configured very-long broadcast interval still retries
// periodically (in case conditions change). Reset on any successful lock.
if (consecutiveFailures > 0) {
constexpr uint32_t failureRetryCapMs = 60UL * 60UL * 1000UL; // 1 hour cap
uint32_t failureSleepMs =
Default::getConfiguredOrDefaultMs(config.position.position_broadcast_secs, default_broadcast_interval_secs);
if (failureSleepMs > failureRetryCapMs)
failureSleepMs = failureRetryCapMs;
if (updateInterval < failureSleepMs)
updateInterval = failureSleepMs;
}
// Check how long until we should start searching, to hopefully hit our target interval
uint32_t dueAtMs = searchEndedMs + updateInterval;
uint32_t compensatedStart = dueAtMs - predictedMsToGetLock;
@@ -70,20 +98,29 @@ bool GPSUpdateScheduling::isUpdateDue()
// Have we been searching for a GPS position for too long?
bool GPSUpdateScheduling::searchedTooLong()
{
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
constexpr uint32_t postFailureSearchMs = 5UL * oneMinuteMs; // Tighter dwell once we know the environment is hostile
uint32_t elapsed = elapsedSearchMs();
// Anything over 15 minutes is too long, regardless of the broadcast interval.
if (elapsed > maxSearchClampMs)
return true;
// After a prior failed search, shorten the dwell
if (consecutiveFailures > 0 && elapsed > postFailureSearchMs)
return true;
uint32_t minimumOrConfiguredSecs =
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
// If broadcast interval set to max, no such thing as "too long"
if (maxSearchMs == UINT32_MAX)
return false;
// If we've been searching longer than our position broadcast interval: that's too long
else if (elapsedSearchMs() > maxSearchMs)
if (elapsed > maxSearchMs)
return true;
// Otherwise, not too long yet!
else
return false;
return false;
}
// Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation
+3 -1
View File
@@ -8,7 +8,8 @@ class GPSUpdateScheduling
public:
// Marks the time of these events, for calculation use
void informSearching();
void informGotLock(); // Predicted lock-time is recalculated here
void informGotLock(); // Predicted lock-time is recalculated here
void informSearchFailed(); // Search ended without a fix; prediction is left untouched
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
@@ -24,6 +25,7 @@ class GPSUpdateScheduling
uint32_t searchEndedMs = 0;
uint32_t searchCount = 0;
uint32_t predictedMsToGetLock = 0;
uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
};
+85
View File
@@ -1265,6 +1265,10 @@ void Screen::setFrames(FrameFocus focus)
// Store the info about this frameset, for future setFrames calls
this->framesetInfo = fsi;
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("rebuild", ui->getUiState()->currentFrame);
#endif
setFastFramerate(); // Draw ASAP
}
@@ -1419,11 +1423,77 @@ void Screen::handleOnPress()
}
}
#ifdef USERPREFS_UI_TEST_LOG
void Screen::logFrameChange(const char *reason, uint8_t targetIdx)
{
// Reverse-map an index to a stable name string keyed off FramePositions
// field names — so the pytest harness can assert `name=nodelist_nodes`
// without caring about how the positions were ordered this boot.
const auto &p = framesetInfo.positions;
const char *name = "unknown";
if (targetIdx == p.home)
name = "home";
else if (targetIdx == p.deviceFocused)
name = "deviceFocused";
else if (targetIdx == p.textMessage)
name = "textMessage";
else if (targetIdx == p.nodelist_nodes)
name = "nodelist_nodes";
else if (targetIdx == p.nodelist_location)
name = "nodelist_location";
else if (targetIdx == p.nodelist_lastheard)
name = "nodelist_lastheard";
else if (targetIdx == p.nodelist_hopsignal)
name = "nodelist_hopsignal";
else if (targetIdx == p.nodelist_distance)
name = "nodelist_distance";
else if (targetIdx == p.nodelist_bearings)
name = "nodelist_bearings";
else if (targetIdx == p.system)
name = "system";
else if (targetIdx == p.gps)
name = "gps";
else if (targetIdx == p.lora)
name = "lora";
else if (targetIdx == p.clock)
name = "clock";
else if (targetIdx == p.chirpy)
name = "chirpy";
else if (targetIdx == p.fault)
name = "fault";
else if (targetIdx == p.waypoint)
name = "waypoint";
else if (targetIdx == p.focusedModule)
name = "focusedModule";
else if (targetIdx == p.log)
name = "log";
else if (targetIdx == p.settings)
name = "settings";
else if (targetIdx == p.wifi)
name = "wifi";
else if (p.firstFavorite != 255 && p.lastFavorite != 255 && targetIdx >= p.firstFavorite && targetIdx <= p.lastFavorite)
name = "favorite";
LOG_INFO("Screen: frame %u/%u name=%s reason=%s", (unsigned)targetIdx, (unsigned)framesetInfo.frameCount, name, reason);
}
#endif
void Screen::showFrame(FrameDirection direction)
{
// Only advance frames when UI is stable
if (ui->getUiState()->frameState == FIXED) {
#ifdef USERPREFS_UI_TEST_LOG
// Log the *intended* target before the (async) transition fires, so
// tests see a deterministic record of what was requested.
if (framesetInfo.frameCount > 0) {
uint8_t curr = ui->getUiState()->currentFrame;
uint8_t target = (direction == FrameDirection::NEXT)
? (uint8_t)((curr + 1) % framesetInfo.frameCount)
: (uint8_t)((curr + framesetInfo.frameCount - 1) % framesetInfo.frameCount);
logFrameChange(direction == FrameDirection::NEXT ? "next" : "prev", target);
}
#endif
if (direction == FrameDirection::NEXT) {
ui->nextFrame();
} else {
@@ -1755,22 +1825,37 @@ int Screen::handleInputEvent(const InputEvent *event)
showFrame(FrameDirection::NEXT);
} else if (event->inputEvent == INPUT_BROKER_FN_F1) {
this->ui->switchToFrame(0);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f1", 0);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F2) {
this->ui->switchToFrame(1);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f2", 1);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F3) {
this->ui->switchToFrame(2);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f3", 2);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F4) {
this->ui->switchToFrame(3);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f4", 3);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F5) {
this->ui->switchToFrame(4);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f5", 4);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_UP_LONG) {
+10
View File
@@ -673,6 +673,16 @@ class Screen : public concurrency::OSThread
void handleOnPress();
void handleStartFirmwareUpdateScreen();
#ifdef USERPREFS_UI_TEST_LOG
// Test-only: emits one LOG_INFO line on every frame transition so the
// pytest harness can assert which frame is shown. Gated behind a macro
// so the chatty log doesn't ship in release builds. Enabled via
// build_testing_profile(enable_ui_log=True) in mcp-server/userprefs.py.
// Member function (not free) because FramesetInfo is a private nested
// type — only methods of Screen can reach it.
void logFrameChange(const char *reason, uint8_t targetIdx);
#endif
// Info collected by setFrames method.
// Index location of specific frames.
// - Used to apply the FrameFocus parameter of setFrames
+15 -4
View File
@@ -428,7 +428,7 @@ static LGFX *tft = nullptr;
#elif defined(ST7789_CS)
#include <LovyanGFX.hpp> // Graphics and font library for ST7735 driver chip
#ifdef HELTEC_V4_TFT
#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)
#include "chsc6x.h"
#include "lgfx/v1/Touch.hpp"
namespace lgfx
@@ -450,7 +450,11 @@ class TOUCH_CHSC6X : public ITouch
bool init(void) override
{
if (chsc6xTouch == nullptr) {
#if (TOUCH_I2C_PORT == 1)
chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN);
#else
chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN);
#endif
}
chsc6xTouch->chsc6x_init();
return true;
@@ -487,7 +491,7 @@ class LGFX : public lgfx::LGFX_Device
#if HAS_TOUCHSCREEN
#if defined(T_WATCH_S3) || defined(ELECROW)
lgfx::Touch_FT5x06 _touch_instance;
#elif defined(HELTEC_V4_TFT)
#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)
lgfx::TOUCH_CHSC6X _touch_instance;
#else
lgfx::Touch_GT911 _touch_instance;
@@ -506,7 +510,11 @@ class LGFX : public lgfx::LGFX_Device
cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing
// 80MHz by an integer)
cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving
cfg.spi_3wire = false;
#ifdef SPI_3_WIRE
cfg.spi_3wire = SPI_3_WIRE;
#else
cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin
#endif
cfg.use_lock = true; // Set to true to use transaction locking
cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch /
// SPI_DMA_CH_AUTO=auto setting)
@@ -556,8 +564,11 @@ class LGFX : public lgfx::LGFX_Device
cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped
cfg.dlen_16bit =
false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI
#if defined(HAS_SDCARD)
cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.)
#else
cfg.bus_shared = false;
#endif
// Set the following only when the display is shifted with a driver with a variable number of pixels, such as the
// ST7735 or ILI9163.
// cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC
@@ -0,0 +1,122 @@
/*
NicheGraphics parallel E-Ink driver for the LilyGo T5-S3-ePaper-Pro (ED047TC1).
InkHUD buffer format : 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
Both formats share the same pixel layout and polarity (1 = white, 0 = black).
The InkHUD safe-area buffer (944×523) is copied into the centre of the physical
960×540 FastEPD buffer so content clears the panel's inactive edge border.
See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants.
*/
// Ruler diagnostic — uncomment to draw calibration lines at each physical edge.
// #define EINK_EDGE_LINES
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#ifdef T5_S3_EPAPER_PRO
#include "./ED047TC1.h"
#include "FastEPD.h"
#include "configuration.h"
using namespace NicheGraphics::Drivers;
void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
{
// Parallel display — SPI parameters are not used
(void)spi;
(void)pin_dc;
(void)pin_cs;
(void)pin_busy;
(void)pin_rst;
epaper = new FASTEPD;
#if defined(T5_S3_EPAPER_PRO_V1)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
// Initialize all PCA9535 port-0 pins as outputs / HIGH
for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT);
epaper->ioWrite(i, HIGH);
}
#else
#error "ED047TC1 driver: unsupported variant — define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2"
#endif
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true); // Blocking initial clear
}
void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
{
if (!epaper)
return;
// InkHUD renders into a DISPLAY_WIDTH × DISPLAY_HEIGHT safe-area buffer.
// We need to place that into the centre of the physical 960×540 FastEPD buffer,
// leaving blank margins at every edge to avoid the panel's inactive border.
const uint32_t srcRowBytes = (DISPLAY_WIDTH + 7) / 8; // bytes per row in InkHUD buffer (118)
const uint32_t dstRowBytes = (960 + 7) / 8; // bytes per row in physical buffer (120)
const uint32_t dstTotalRows = 540;
uint8_t *cur = epaper->currentBuffer();
// Fill physical buffer with white (0xFF = white in FastEPD 1bpp)
memset(cur, 0xFF, dstRowBytes * dstTotalRows);
// Copy each InkHUD row into the physical buffer with horizontal + vertical offsets
for (uint32_t row = 0; row < DISPLAY_HEIGHT; row++) {
const uint8_t *srcRow = imageData + row * srcRowBytes;
uint8_t *dstRow = cur + (row + V_OFFSET_TOP) * dstRowBytes + H_OFFSET_BYTES;
memcpy(dstRow, srcRow, srcRowBytes);
}
#ifdef EINK_EDGE_LINES
// Draw a 1px black box at the exact boundary of the safe area within the
// physical buffer. If the margins are correct, all 4 lines should be
// fully visible and right at the edge of the usable display area.
auto setPixelBlack = [&](uint32_t col, uint32_t row) { cur[row * dstRowBytes + col / 8] &= ~(0x80 >> (col % 8)); };
const uint32_t safeX = H_OFFSET_BYTES * 8;
const uint32_t safeY = V_OFFSET_TOP;
const uint32_t safeW = DISPLAY_WIDTH;
const uint32_t safeH = DISPLAY_HEIGHT;
// Top edge: horizontal line at safeY
for (uint32_t col = safeX; col < safeX + safeW; col++)
setPixelBlack(col, safeY);
// Bottom edge: horizontal line at safeY + safeH - 1
for (uint32_t col = safeX; col < safeX + safeW; col++)
setPixelBlack(col, safeY + safeH - 1);
// Left edge: vertical line at safeX
for (uint32_t row = safeY; row < safeY + safeH; row++)
setPixelBlack(safeX, row);
// Right edge: vertical line at safeX + safeW - 1
for (uint32_t row = safeY; row < safeY + safeH; row++)
setPixelBlack(safeX + safeW - 1, row);
#endif
if (type == FULL) {
epaper->fullUpdate(CLEAR_SLOW, false);
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
} else {
// FAST: true partial update — compares pCurrent vs pPrevious and only applies
// the update waveform to rows that actually changed. Unchanged rows get a neutral
// signal (no visible effect). partialUpdate() updates pPrevious internally.
epaper->partialUpdate(false, 0, dstTotalRows - 1);
}
}
#endif // T5_S3_EPAPER_PRO
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
@@ -0,0 +1,90 @@
/*
E-Ink display driver adapter
- ED047TC1 (via FastEPD library)
- Manufacturer: E Ink / used in LilyGo T5-E-Paper-S3-Pro
- Size: 4.7 inch
- Physical resolution: 960px x 540px
- Interface: 8-bit parallel (NOT SPI)
Unlike the other NicheGraphics EInk drivers, this one drives a parallel e-paper
panel via the FastEPD library. SPI parameters passed to begin() are ignored.
The ED047TC1 panel has an inactive pixel border on all four edges (~4–8 physical
pixels). DISPLAY_WIDTH / DISPLAY_HEIGHT expose a reduced "safe area" to InkHUD so
that content is never drawn into this dead zone. The update() method copies the
InkHUD frame buffer into the centre of the larger physical 960×540 buffer, using
H_OFFSET_BYTES (horizontal, whole bytes = 8 pixels per byte),
V_OFFSET_TOP and V_OFFSET_BOTTOM (vertical, pixel rows) to position it.
Changing these constants shifts content inward from each physical edge:
H_OFFSET_BYTES = 1 → 8px left margin, 8px right margin (960 – 8 – 8 = 944)
V_OFFSET_TOP = 9 → 9px top margin (asymmetric: top ≠ bottom)
V_OFFSET_BOTTOM = 8 → 8px bottom margin (540 – 9 – 8 = 523)
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./EInk.h"
// Forward declare to avoid pulling FastEPD into all translation units
class FASTEPD;
namespace NicheGraphics::Drivers
{
class ED047TC1 : public EInk
{
// Safe-area dimensions exposed to InkHUD (physical panel is 960×540).
//
// The ED047TC1 has an inactive pixel border on all physical edges.
// The physical buffer coordinates do NOT directly match the visual orientation
// due to FastEPD's portrait scan direction and InkHUD's rotation=3 (270° CW):
//
// Physical buffer Visual on device (rotation=3)
// ───────────────── ──────────────────────────────
// Physical LEFT cols → Visual TOP edge
// Physical RIGHT cols → Visual BOTTOM edge
// Physical TOP rows → Visual RIGHT edge
// Physical BOTTOM rows → Visual LEFT edge
//
// Offset constants shift the InkHUD safe-area away from each physical dead zone:
// H_OFFSET_BYTES : whole bytes from physical left (8px per byte, affects visual TOP)
// Physical right margin = 960 − H_OFFSET_BYTES×8 − DISPLAY_WIDTH (affects visual BOTTOM)
// V_OFFSET_TOP : pixel rows from physical top (affects visual RIGHT)
// V_OFFSET_BOTTOM: pixel rows from physical bottom (affects visual LEFT)
//
// Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible.
static constexpr uint16_t DISPLAY_WIDTH = 944; // 960 − H_OFFSET_BYTES×8 − right_margin (8+8 = 16px)
static constexpr uint16_t DISPLAY_HEIGHT = 523; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (9+8 = 17px)
static constexpr uint8_t H_OFFSET_BYTES = 1; // visual TOP : 8px physical left margin
// visual BOTTOM: 960−8−944=8px physical right margin
static constexpr uint8_t V_OFFSET_TOP = 9; // visual RIGHT : CONFIRMED OK
static constexpr uint8_t V_OFFSET_BOTTOM = 8; // visual LEFT : 8px physical bottom margin
static constexpr UpdateTypes supported = static_cast<UpdateTypes>(FULL | FAST);
public:
ED047TC1() : EInk(DISPLAY_WIDTH, DISPLAY_HEIGHT, supported) {}
// EInk interface — SPI params are not used for this parallel display
void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = 0xFF) override;
void update(uint8_t *imageData, UpdateTypes type) override;
protected:
bool isUpdateDone() override { return true; } // FastEPD updates are blocking
private:
FASTEPD *epaper = nullptr;
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -88,8 +88,12 @@ class AppletFont
// Greek
#include "graphics/niche/Fonts/FreeSans12pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans18pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans24pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans6pt_Win1253.h"
#include "graphics/niche/Fonts/FreeSans9pt_Win1253.h"
#define FREESANS_24PT_WIN1253 InkHUD::AppletFont(FreeSans24pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -5, 3)
#define FREESANS_18PT_WIN1253 InkHUD::AppletFont(FreeSans18pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -4, 2)
#define FREESANS_12PT_WIN1253 InkHUD::AppletFont(FreeSans12pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -3, 1)
#define FREESANS_9PT_WIN1253 InkHUD::AppletFont(FreeSans9pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -2, -1)
#define FREESANS_6PT_WIN1253 InkHUD::AppletFont(FreeSans6pt_Win1253, InkHUD::AppletFont::WINDOWS_1253, -1, -2)

Some files were not shown because too many files have changed in this diff Show More