Compare commits

..
Author SHA1 Message Date
Ben Meadors 35b0590408 Develop is 2.8 WIP now 2026-05-13 13:50:17 -05:00
Ben Meadors cbddf07bc8 Merge pull request #10472 from meshtastic/remove-arial 2026-05-13 12:01:02 -05:00
871194517d Ble banner (#8902)
* Drop unneeded Sizeof() instances

* Use SimpleBanner for BLE pin

* Support for different font sizes on notification banner

* Fix NRF52 BLE cppcheck shadow warning

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/de12b52c-49d5-452a-b3fb-344724649270

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

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
Co-authored-by: Jason P <applewiz@mac.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-05-13 11:02:19 -05:00
Ben Meadors 5a1d2b9ef4 Refine nRF52 flash optimization comment for FONT_LARGE_LOCAL definition 2026-05-13 10:52:05 -05:00
Ben Meadors 9cd3a86938 Cleanup 2026-05-13 10:43:16 -05:00
Ben Meadors 748668b8e9 Remove ARIAL24 on NRF52 2026-05-13 10:13:53 -05:00
Ben Meadors 778d1ad90f Merge remote-tracking branch 'origin/master' into develop 2026-05-13 09:40:16 -05:00
Ben Meadors 1ae4a538f5 Trunk 2026-05-13 09:27:05 -05:00
Andros Fenollosa c756bbe2c1 Fix WiFi TCP/HTTP services not starting without USB serial connected (#10460)
Move WiFi.onEvent(WiFiEvent) registration before createSSLCert() to
prevent a race where the ESP32 auto-reconnects during cert generation
and fires GOT_IP before the handler is attached, causing
onNetworkConnected() to never run and the TCP/HTTP API services to
never initialize when booting without USB serial.

Also call onNetworkConnected() from reconnectWiFi() on all platforms
(not just RP2040) as a safety net; it is already guarded by
APStartupComplete so it only runs once.
2026-05-13 09:26:44 -05:00
Austin 4c3ba612bb VSCode: Prepare for pioarduino transition (#10471)
Start reccomending the pioarduino VS Code extension instead of the PlatformIO extension.

pioarduino-based builds cannot complete correctly using the platformio extension. Normal platformio builds (nrf52, stm32) are unaffected//still work correctly.

Devs may need to delete their ~.platformio and .pio directories once after install in order to build properly.
2026-05-13 09:25:11 -05:00
Ben Meadors 75b7a7df4f Missed one 2026-05-13 08:50:15 -05:00
Ben MeadorsandCopilot Autofix powered by AI 59025e4820 Add initial support for Station G3 variant (#10457)
* Add initial support for Station G3 variant

* 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>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-13 08:07:24 -05:00
Ben Meadors 8110887be2 Turns out it's already excluded 2026-05-13 07:57:54 -05:00
Ben Meadors 5f734dabf9 Trunk 2026-05-13 06:44:51 -05:00
Andros Fenollosa 039ad42758 Fix WiFi TCP/HTTP services not starting without USB serial connected (#10460)
Move WiFi.onEvent(WiFiEvent) registration before createSSLCert() to
prevent a race where the ESP32 auto-reconnects during cert generation
and fires GOT_IP before the handler is attached, causing
onNetworkConnected() to never run and the TCP/HTTP API services to
never initialize when booting without USB serial.

Also call onNetworkConnected() from reconnectWiFi() on all platforms
(not just RP2040) as a safety net; it is already guarded by
APStartupComplete so it only runs once.
2026-05-13 06:43:36 -05:00
Ben Meadors 593909c26b Radiolib excludes 2026-05-13 06:42:49 -05:00
renovate[bot] 0a7b3c723e Update NeoPixel to v1.15.5 (#10466)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-13 10:57:48 +02:00
Ben Meadors 29a61dc75c Fix type declaration for ambientLightingThread and correct uint32_t usage in PacketHistory 2026-05-12 21:45:16 -05:00
Ben Meadors eead467ce6 Added NodeDB fixtures and refactored to use std maps for better memory efficiency (#10464)
* Added NodeDB fixtures and refactored to use std maps for better efficiency

* Defer NodeDB save during xmodem transfer to prevent mid-transfer fsFormat
2026-05-12 17:23:29 -05:00
Ben Meadors f3ae02c425 Cleanup comments 2026-05-12 16:32:00 -05:00
Ben Meadors d9cb74e4dd XModemAdapter: ensure file truncation before receiving and add isBusy() method to prevent concurrent writes 2026-05-12 15:38:56 -05:00
Dirk MuellerandBen Meadors 7ff6641f97 Fix missing potential null termination in xmodem filename handling (#10308)
* Fix missing potential null termination in xmodem filename handling

The packet size max is 128 bytes, and the filename is 128 bytes, so
potentially there is no NUL at the end. use strlcpy() as that takes
care of null termination even if buffer size is exceeded.

* Protect against theoretical buffer overflows in BLE logging

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-12 06:26:13 -05:00
github-actions[bot]andvidplace7 cd5d608e8d Upgrade trunk (#10461)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-05-12 06:07:09 -05:00
f7548e7c25 Remove gradient sync nonce and simplify replay handling (#10459)
* Remove gradient sync nonce and simplify replay handling

* Fix ONLY_CONFIG replay gating and stale gradient-sync comments

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/cfa93978-e2e0-4dc2-ba5f-b82b5b43cef8

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

* Add transport mechanism to replay packets for client filtering

* Comments

* Update protobuf definitions to include precision_bits in PositionLite

* Propagate position precision_bits and remove verbose NodeInfo sync log

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/41572cbc-408e-499d-b59e-00f330b5789f

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-11 21:42:07 -05:00
HarukiToredaandCopilot Autofix powered by AI b960121464 BaseUI: remove legacy single-message runtime path and keep multimessage flow (#10450)
* cleanup

* 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>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 20:53:41 -05:00
Ben Meadors b074645586 Change node pointer to const in JsonSerialize function 2026-05-11 17:16:32 -05:00
Ben Meadors 811dd427dd Update protobufs 2026-05-11 16:36:35 -05:00
Ben Meadors 7f5184281d Make power status logging less chatty and track battery presence transitions (#10453) 2026-05-11 16:09:33 -05:00
Jonathan Bennett 1c06b702dc Merge remote-tracking branch 'origin/master' into develop 2026-05-11 15:07:22 -05:00
github-actions[bot]andjp-bennett 59b4993861 Update protobufs (#10456)
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
2026-05-11 15:06:37 -05:00
Jonathan Bennett 4446b0f1a2 Add variantDefaultConfig and set eth_enabled to default true (#10454) 2026-05-11 14:51:21 -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
Jonathan Bennett da61a0db7d Refactor mutex handling in PhoneAPI.cpp
Replace LockGuard with explicit lock and unlock calls to avoid deadlock
2026-05-11 11:45:14 -05:00
8e99ffbe7e 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:33:13 -05:00
Ben Meadors 8877608858 Protos 2026-05-11 09:32:55 -05:00
Ben Meadors a23f923e64 Update subproject commit reference in protobufs 2026-05-11 08:09:39 -05:00
Ben Meadors 0522039830 Merge branch 'master' into develop 2026-05-11 08:09:10 -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
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
94bb21ecc7 2.8: NodeDB shrink, decoupling, and restructuring (#10413)
* 2.8: NodeDB refactor to decouple satellite entries and decrease size

* Regen

* Refactor node mute handling to use dedicated functions for clarity and consistency

* Develop ref

* Fix NodeDB review follow-ups

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6b1d6cf6-ed6b-43b6-95cb-8e141757664e

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

* Address review validation nits

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6b1d6cf6-ed6b-43b6-95cb-8e141757664e

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

* Trunk

* Potential fix for pull request finding

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

* Extract legacy NodeDatabase migration

* Fix remaining NodeDB review issues

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/c76b9a5a-7244-4fbc-9ef0-98091d8caaea

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

* Fixes

* Trunk

* Fix latest review compile follow-ups

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/5198da01-ec4c-4c16-8a09-68b8e6d5d410

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

* Fix cppcheck style warnings

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e60287ba-4ece-46e0-83d8-a6d89664c0bb

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

* Change pointer type for mesh node in set_favorite function

* Change pointer types for mesh node references to const in multiple applets

* Add NodeDB layout v25 documentation and migration guidelines

* Remove tests for uninitialized PacketHistory state due to undefined behavior

* Fix code block formatting in copilot instructions

---------

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-09 15:12:10 -05:00
SylwesterandBen Meadors 1bcabb893b mesh: bound the user-facing notification sprintf calls (#10437)
Two sites built ClientNotification messages with sprintf into a
fixed-size proto buffer with no length cap. The current format strings
fit comfortably, but a future caller editing either format string
without rechecking the buffer size would get a silent stack/heap
overrun. Switch to snprintf with sizeof so the bound is enforced at
the call site.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-09 13:32:42 -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
BJK fefd424901 Fix screen geometry update for SH1107 display (#10444)
Added conditional block to update screen geometry for SH1107 128x128.
2026-05-09 12:53:07 -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
github-actions[bot]andthebentern f63716c322 Automated version bumps (#10419)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-05-08 19:05:11 -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
5e2ca8aed4 LR2021 radio on NRF_Promicro (#10401)
* LR2021 radio on NRF_Promicro

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

* Refactor LR2021 interface includes and conditional compilation for improved clarity

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

* Refactor LR20x0 interface: remove unused includes and update comments for clarity

* Fix LR2021 max power definitions and add radio type detection tests

* remove potato radio type detection tests

* Include placeholder for DCDC - currently requires godmode

* Added godmode features - not enabled by default

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-07 20:24:39 -05:00
Jonathan Bennett 0f854862e7 Give ThinkNode-m4 a heartbeat (#10408) 2026-05-07 13:17:29 -05:00
Clive BlackledgeandBen Meadors 4553d1e0b1 Skip MQTT allocation when disabled (#10411)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-07 11:51:55 -05:00
Ben Meadors 784e3748e2 Merge remote-tracking branch 'origin/master' into develop 2026-05-07 11:45:04 -05:00
Tom b11d29ff31 fix(mesh): update reconfigure methods to return true instead of RADIOLIB_ERR_NONE (#10407) 2026-05-07 05:57:34 -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
jessm33andjessm33 cdc47a2aea Fix GPS initialization logic for Portduino configuration (#10395)
Co-authored-by: jessm33 <root@example.com>
2026-05-05 14:17:04 -05:00
Ben Meadors 603cce2988 Add informSearchFailed method to update GPS power state handling (#10394) 2026-05-05 10:12:50 -05:00
fcef46f4b0 dependency swap - INA3221Sensor (#10379)
* dependency swap - INA3221Sensor
update INA3221 initialization and measurement methods for compatibility with Rob Tillaart's library

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

* Addresses copilot review

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

* Potential fix for pull request finding

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

* Refine comments on USB detection and INA3221

Updated comments regarding USB detection and INA3221 usage.

* Fix static_assert conditions for INA3221 channel definitions

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

* moved macro defines earlier to allow better use.

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

* Add raw register read methods for bus voltage and shunt current in INA3221Sensor

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 06:52:07 -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
Jonathan Bennett bb86cf4e81 Merge remote-tracking branch 'origin/master' into develop 2026-05-04 18:10:59 -05:00
Jonathan Bennett 6ea0d5ebba Add TFT_BACKLIGHT_ON for cardputer to fix builds (#10387) 2026-05-04 16:19:48 -05:00
Jord b2bda3b07a Enable MESHTASTIC_PREHOP_DROP by default (#9924) 2026-05-02 16:38:06 -05:00
Jonathan Bennett b7a9555905 Update RadioLib dependency to a specific commit (#10370)
Exploratory PR to test new radiolib change
2026-05-01 12:50:07 -05:00
Andrew YongandBen Meadors 1eb860a3fc fix(stm32wl,nrf52,fs): flash hardening, FS platform unification, write-behind LFS cache (FORMAT BREAK) (#10171)
* stm32wl: check HAL_FLASH_Unlock() return in _internal_flash_erase

_internal_flash_prog already checks HAL_FLASH_Unlock() and returns
LFS_ERR_IO on failure. _internal_flash_erase discarded the return
value, proceeding to erase even if the flash was not unlocked.

Apply the same check for consistency and safety.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl: fix _internal_flash_prog to abort on first write error

Previously the programming loop continued to the next doubleword after
HAL_FLASH_Program() failed, potentially writing to invalid addresses
and returning a misleading error code only at the end (last iteration).
HAL_FLASH_Lock() was also skipped on the mid-loop early return path.

- Move bounds check before the loop (validate full range at once)
- Break on first HAL error so subsequent doublewords are not written
- Move HAL_FLASH_Lock() after the loop so it always runs

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl: clear stale flash SR error flags before erase and program

Stale error flags in FLASH->SR from a previous failed operation can
cause HAL_FLASH_Program() or HAL_FLASHEx_Erase() to return HAL_ERROR
immediately without attempting the operation.

Add __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS) after each
HAL_FLASH_Unlock() in both _internal_flash_prog and
_internal_flash_erase to ensure a clean state before each operation.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl: reject flash prog writes not aligned to 8-byte doubleword

The STM32WL HAL minimum write unit is one 64-bit doubleword (8 bytes).
_internal_flash_prog silently truncated any trailing bytes when size % 8
!= 0 because dw_count = size / 8 drops the remainder. Return LFS_ERR_INVAL
early so LittleFS sees the error rather than a silent short write.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(nrf52,fs): use atomic SafeFile rename instead of direct write

NRF52 was bypassing the .tmp/readback/rename path entirely — openFile()
deleted the target file and wrote directly to it, and close() returned
true without verifying the write or renaming anything.

Adafruit_LittleFS::rename() calls lfs_rename() directly (confirmed at
Adafruit_LittleFS.cpp:205). Remove both ARCH_NRF52 guards so NRF52
follows the same write-to-.tmp → readback-hash → rename path used by
all other platforms.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(admin): skip uiconfig.proto save on devices without a screen

handleStoreDeviceUIConfig() was writing /prefs/uiconfig.proto
unconditionally. MenuHandler.cpp is already gated behind #if HAS_SCREEN,
so there is no path that populates UI config on screen-less platforms.
Guard the save with #if HAS_SCREEN to avoid wasting a flash block on
devices that will never use it.

The read path (handleGetDeviceUIConfig) does not touch the filesystem
and needs no change.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fs: enable format-on-retry for all platforms in saveToDisk

The FSCom.format() call on save failure was guarded to ARCH_NRF52 with
a comment that other platforms were not ready (bug #4184). STM32WL was
added to the guard in a prior commit. All platforms now expose format
semantics and the retry logic is identical — remove the guard.

To keep NodeDB.cpp platform-agnostic and fix a CI failure on native-tft
(portduino's fs::FS has no format() method), introduce fsFormat() in
FSCommon as the single call-site for all callers:

  - Embedded (ESP32, NRF52, STM32WL, RP2040): delegates to FSCom.format()
  - Portduino: rmDir("/prefs") + FSBegin() (a no-op on portduino).
    rmDir("/prefs") is already called unconditionally by factoryReset()
    (NodeDB.cpp:504), so both primitives are proven on portduino.

Replace both direct FSCom.format() calls in NodeDB.cpp with fsFormat().

Note: we do not run portduino locally — portduino/native build testers
please verify the format-on-retry path.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* DO NOT MERGE: nrf52(fs): add File() default constructor bound to InternalFS

Adds File() to the Adafruit LittleFS File class (in the Meshtastic
Adafruit_nRF52_Arduino fork), delegating to File(InternalFS). This
matches the default-constructible File API on all other platforms.

The constructor is implemented in Adafruit_LittleFS_File.cpp rather
than inline in the header to avoid a circular include between
Adafruit_LittleFS_File.h and InternalFileSystem.h.

FOLLOW-UP REQUIRED: nrf52.ini points to a commit SHA on the
mesh-malaysia/Adafruit_nRF52_Arduino fork instead of the upstream
meshtastic framework. Once meshtastic/Adafruit_nRF52_Arduino#5 is
merged, revert nrf52.ini to point back to the upstream meshtastic
framework URL.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl(fs): add File() default constructor and document LFS tunables

Adds File() to STM32_LittleFS_Namespace::File, delegating to
File(InternalFS). Implemented in the .cpp to avoid a circular include
between STM32_LittleFS_File.h (which cannot include LittleFS.h) and
the InternalFS extern declaration.

This matches the File API on ESP32/RP2040/Portduino and is a
prerequisite for removing the ARCH_STM32WL guard in xmodem.h.

No behavior change — the constructor leaves the file in the same
closed/unattached state as File(InternalFS) would.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fs: remove arch-specific ifdefs from FSCommon, SafeFile, xmodem

Now that NRF52 and STM32WL have File() default constructors and NRF52
has working atomic SafeFile rename, the capability gaps are closed.
Remove all per-arch guards across the shared FS layer:

FSCommon.cpp — renameFile():
  Use FSCom.rename() on all platforms. Adafruit_LittleFS::rename()
  calls lfs_rename() directly (Adafruit_LittleFS.cpp:205). The
  copy+delete fallback on NRF52/RP2040 was never necessary.

FSCommon.cpp — getFiles():
  Replace four ARCH_ESP32 guards with a single filepath pointer at
  the top of the loop (file.path() on ESP32, file.name() elsewhere).
  Fix strcpy(fileInfo.file_name, filepath): bounded to
  sizeof(fileInfo.file_name)-1 with explicit NUL termination to prevent
  overflow of the 228-byte meshtastic_FileInfo::file_name array.

FSCommon.cpp — listDir():
  Same filepath pointer approach. NRF52/STM32WL were in an else-branch
  that only logged but never deleted — now all platforms follow the
  unified del path. 12 guards → 2.
  Fix three strncpy(buffer, ..., sizeof(buffer)) calls that did not
  NUL-terminate when source length >= sizeof(buffer) (255 bytes).
  Add explicit buffer[sizeof(buffer)-1] = '\0' after each.

FSCommon.cpp — rmDir():
  Use listDir(del=true) everywhere. The ARCH_NRF52 rmdir_r() path and
  the ARCH_ESP32|RP2040|PORTDUINO listDir() path collapse to one line.

SafeFile.cpp:
  ARCH_NRF52 bypass removed (handled in preceding commit).

xmodem.h:
  File file; now works on all platforms via default constructors
  added in the two preceding commits.

Remaining #ifdef ARCH_ESP32 in FSCommon.cpp: exactly 4, all for the
file.path() vs file.name() API difference (ESP32 Arduino LittleFS
returns the full path; all others return only the name). That
difference lives in the framework and cannot be closed here.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl(fs): add write-behind page cache, reduce virtual block size and FS reservation (FORMAT BREAK)

Adds a write-behind (RMW) page cache to the STM32WL LittleFS driver,
modelled after the NRF52 Adafruit approach (flash_cache.c). This allows
LFS to use 256-byte virtual blocks backed by 2048-byte physical pages:
the erase/prog callbacks accumulate changes in a 2 KB RAM buffer; the
sync callback (and page eviction on page-change) flushes with a single
HAL physical-erase + doubleword-program pass.

LFS tunables changed (FORMAT BREAK — superblock parameters):
  block_size:  2048 B → 256 B  (8 virtual blocks per physical page)
  read_size:   2048 B → 256 B  (= block_size)
  prog_size:   2048 B → 256 B  (= block_size; hardware min is 8 B)
  block_count: 112   → 80     (14 phys pages → 10 phys pages = 20 KiB)

Benefits:
  - Internal fragmentation: max 2047 B/file → max 255 B/file
  - Heap per open LFS file: ~4 KB → 512 B (prog + read buffers)
  - Code flash headroom: 6.7 KB → ~14.1 KB (+7.4 KB)
  - Block budget: 80 virtual blocks, worst-case peak ~20, ~60 free

Updates board_upload.maximum_size in wio-e5/platformio.ini from 233472
(256 KB − 28 KB) to 241664 (256 KB − 20 KB) to match the reduced FS
reservation.

Justification for the format break: the prior STM32WL firmware had
several flash write bugs fixed earlier in this series (missing error
flag clearing, no abort on first write failure, unaligned write
acceptance). These bugs very likely caused silent config corruption on
deployed devices. The format break should be treated as an enhancement:
it provides a clean, reliably-written starting point. Users will need
to reconfigure their device once after this update.

Correctness fixes applied to the cache implementation:
  - alignas(8) on _page_cache: the buffer was uint8_t[] (alignment 1)
    but _flash_cache_flush casts it to const uint64_t* — undefined
    behaviour per C++ standard, potential Cortex-M hardfault. alignas(8)
    guarantees the required alignment for the doubleword cast.
  - HAL_FLASH_Lock() return value: was discarded. Now assigned to
    lock_rc and propagated into rc if prior writes succeeded, so LFS
    sees the error rather than a false success.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl(fs): reduce FS reservation from 10 pages to 7 pages (FORMAT BREAK)

Reduces LFS_FLASH_TOTAL_SIZE from 10 × 2 KiB pages (20 KiB) to
7 × 2 KiB pages (14 KiB), freeing 6 KiB for firmware.

board_upload.maximum_size updated accordingly across all STM32WL variants:
  241664 (256 KiB - 20 KiB) → 247808 (256 KiB - 14 KiB)

This is a FORMAT BREAK: existing filesystems must be erased before use.

Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix(fs): return false in renameFile() when FSCom is not defined

Avoids undefined behavior and -Wreturn-type warnings in configurations
that compile FSCommon.cpp without a filesystem backend.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-01 08:25:19 -05:00
Jonathan Bennett 989b8620ba Merge remote-tracking branch 'origin/master' into develop 2026-04-30 10:49:26 -05:00
JoeandJonathan Bennett a0951f23c3 fix: MQTT connection on Portduino/Linux native nodes (#10330)
isConnectedToNetwork() always returned false on ARCH_PORTDUINO
because none of HAS_WIFI, HAS_ETHERNET, or USE_WS5500 are defined
for Linux native builds. This caused wantsLink() to always return
false, preventing the MQTT thread from ever connecting at boot.

Fix: return true for ARCH_PORTDUINO since Linux always has network
access available.

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-30 06:00:50 -05:00
HarukiToreda e19f531059 Update Screen.cpp (#10344) 2026-04-29 21:05:16 -05:00
6c7ffa1054 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 08:31:08 -05:00
f037ce2165 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:25:19 -05:00
Ben Meadors bc17d004ab Merge branch 'master' into develop 2026-04-27 08:05:22 -05:00
06a6c3ee20 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-26 22:07:07 -05:00
bfadf0c36a 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:02:42 -05:00
Jonathan Bennett 24c4162a75 Standardize PMU IRQ handling and enable power button cancel on tbeam-s3 (#10285)
* Standardize PMU IRQ handling and enable power button as cancel on tbeam s3

* Original T-beam, too
2026-04-26 19:58:23 -05:00
Ben Meadors b148fac340 Update framework version reference for Adafruit nRF52 to latest master branch 2026-04-26 09:49:39 -05:00
8dde4eeee1 BaseUI: Color Support for TFT Nodes (#10233)
* True Colors on TFT (Heltec Mesh Node T114, Heltec Vision Master T190, CardPuter Adv, T-Deck, T-Lora Pager)

* Theme support - New and some Classic Themes!

* Colored Compass

---------

Co-authored-by: Jason P <applewiz@mac.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-26 07:44:56 -05:00
Ben Meadors 4d4e14600c Merge remote-tracking branch 'origin/master' into develop 2026-04-25 15:55:16 -05:00
Ben Meadors 0bd8dee346 Merge remote-tracking branch 'origin/master' into develop 2026-04-25 15:06:28 -05:00
2828dbe4ca t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp ...again (#10297)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns t5s3_epaper with other variants like t_deck_pro.

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-25 13:36:31 -04:00
Ben Meadors a8c8fd7002 Remove redundant power interrupt methods for ESP32 2026-04-25 07:11:03 -05:00
Ben MeadorsandCopilot e7c02da24b Merge remote-tracking branch 'origin/master' into develop
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 06:41:38 -05:00
HarukiToreda 7421953e8f InkHUD: Add full touch support to T5s3 (#10286)
* InkHUD touch rework

* Applet Switcher

* Update ED047TC1.cpp

* trunk fix

* Custom tip screen for T5s3

* Update TouchScreenImpl1.cpp

* Update ED047TC1.cpp

* Delete variant.cpp
2026-04-25 05:22:24 -05:00
GeorgeandClaude Sonnet 4.6 9306e66067 fix(inkhud): scale MapApplet markers with fontSmall line height (#10288)
Marker boxes, the own-node bullseye, and the labeled-marker cross were
all hardcoded in pixels (11px box, r=8 circle, 12px cross). On the
T5S3 with a 12pt fontSmall (~17px line height) the hop-count digit
overflowed its box entirely. Sizes now derive from fontSmall.lineHeight()
so the applet renders correctly on both small (6pt) and large (12pt+)
display variants.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:20:39 -04:00
Jonathan Bennett 7adfc3f992 Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280)
Fixes #9912  and #10170
2026-04-24 15:17:33 +10:00
DmitryandCopilot Autofix powered by AI ba9cadc14d Fix INA226 detection for non-TI compatible chip (Silergy) (#10247)
* Fix INA226 detection for non-TI compatible chip (Silergy)

* Removed extra I2C transaction + 20ms delay on every scan of address 0x40 (including real SHT2x sensors).

Changes suggested by Copilot

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

* Apply formatting (trunk fmt)

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-24 13:09:37 +10:00
EmanueleandJonathan Bennett 924411de59 T-Watch S3 Power button managment (#9855)
* PMU interrupt pin defined in t-watch s3

* Implement button control on T-Watch S3

Added interrupt handling for the Power/Corona button on T-Watch S3, I use it to control screen state.

* Reducing labels

* Reducing labels

* Updated the comment

* ISR is now IRAM-safe

Updated interrupt management not to cause random crashes.

* Trunk

* Simplify and use INPUT_BROKER_CANCEL

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-23 19:53:59 -05:00
d9195944df PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc (#10251)
* PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc

The lost-and-found message was built with an unnecessary heap allocation:

    char *message = new char[60];
    sprintf(message, "..."...);
    ...
    delete[] message;

Two problems:

1. **Buffer too small.** The format string expands with two %f (IEEE 754
   doubles), which `sprintf` prints with full precision — easily 15+
   digits each plus separators — so the actual rendered string can run
   40-50 characters before even considering the emoji (4 UTF-8 bytes)
   and the embedded BEL. A pathological lat/lon can overflow 60 bytes
   and corrupt heap metadata. Unbounded `sprintf` with no size check.

2. **Heap churn in a GPS callback.** This function is called from the
   position-update path which is already heap-sensitive. An infrequent
   60-byte transient alloc isn't catastrophic, but stack is trivially
   available here and removes the failure mode entirely.

Fix: replace with a 128-byte stack buffer and `snprintf` bounded by
`sizeof(message)`. Drop the matching `delete[]` since there's nothing
to delete.

Behavior is identical on the happy path; the overflow case now
truncates safely instead of scribbling over heap.

* Potential fix for pull request finding

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

* PositionModule.cpp: add trailing newline for clang-format

* Address Copilot review: cleaner snprintf size handling

Review feedback from @Copilot on PR #10251: the ternary-plus-static-cast
form mixed signed/unsigned types (int written vs. pb_size_t payload.size
vs. size_t sizeof(message)) and was harder to read than necessary.

Cleaner form:
  const size_t msg_len = std::min(static_cast<size_t>(written), sizeof(message) - 1);
  p->decoded.payload.size = msg_len;

Same behaviour (clamp to buffer-minus-NUL) with one explicit cast and
a size_t variable that names the meaning. Handles the encoding-error
path (written < 0) separately so no bad values leak into payload.size.

* Trunk

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-23 18:20:07 -05:00
83a98c81f6 Hash table index for O(1) packet history lookups (#9499)
* Use hash table for O(1) lookup of recently seen packets

* Eliminate a packet lookup during deduplication

* Infinite loop checks for find and remove

* Consolidate conditional compilation

* Exclude hash table from minimal build

* Additional comment on hash table capacity

* Unit tests for packet history changes

* Update incorrect comment about size clamp

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

* Const

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 18:04:34 -05:00
Jonathan Bennett 837637b70c Only enable wakeup via EXT_CHRG_DETECT if we shut down due to low power (#10263) 2026-04-23 15:37:35 -05:00
Valentin V. Bartenev 7b3f58875a Fix example comment in airtime.h (#10275)
Looks like a copy'n'paste typo from the previous line.
It definitely meant to be RX_ALL_LOG according to comment.
2026-04-23 14:44:39 -05:00
Andrew Yong b2d980fc25 feat(Power): support EXT_PWR_DETECT_MODE & EXT_PWR_DETECT_VALUE, simplify EXT_PWR_DETECT (#10140)
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-04-23 14:32:17 -05:00
Andrew YongandJonathan Bennett 2ed7bba5e7 fix(Power): refactor EXT_CHRG_DETECT to compile-time macros (#10191)
Mirror the EXT_PWR_DETECT pattern: replace runtime static variables
(ext_chrg_detect_mode, ext_chrg_detect_value) with compile-time macros.
Auto-infer EXT_CHRG_DETECT_VALUE from EXT_CHRG_DETECT_MODE when the mode
is INPUT_PULLUP (→ LOW) or INPUT_PULLDOWN (→ HIGH); default to HIGH.

This fixes inverted polarity on variants that define EXT_CHRG_DETECT_MODE
INPUT_PULLUP without an explicit EXT_CHRG_DETECT_VALUE (e.g. russell):
previously the runtime default of HIGH caused isCharging() to return the
opposite of the correct value. With auto-inference the correct LOW active
level is now derived at compile time.

Remove the now-redundant EXT_CHRG_DETECT_VALUE HIGH from ELECROW-ThinkNode-M4
variant.h since HIGH is the inferred default.


Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <noreply@example.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-23 13:24:05 -05:00
Catalin Patulea 22d50fe437 NimbleBluetooth misc cleanups (#10264)
* Delete unused clearNVS() (last used in commit 761804b1).

* virtual methods: add 'override' to ensure we get the signature right.

This is a safety net for pioarduino/NimBLE work where there's multiple
similar variants of the same method (eg. onConnect) and it's easy to get
the wrong one and accidentally miss a callback.
2026-04-23 09:18:41 -05:00
Ben Meadors cde5a08bc5 Merge remote-tracking branch 'origin/master' into develop 2026-04-23 06:48:43 -05:00
nightjoker7andBen Meadors 55bf8c25fc PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration (#10256)
* PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration

The STATE_SEND_MODULECONFIG state machine iterates config_state through
ModuleConfigType enum values (1..MAX+1 = 16) and switches on
meshtastic_ModuleConfig_*_tag values. Two of the resulting tag values
land in the default / LOG_ERROR path:

1. `tak_tag` (16) — the meshtastic_ModuleConfig_TAKConfig struct exists
   in the protobuf and has a `.tak` member in payload_variant, but no
   PhoneAPI case ever sends it to the phone. As a result, Android
   clients can't read the persisted TAK (Team Awareness Kit) module
   config at all. Added case that sends moduleConfig.tak, matching the
   pattern used for all other module-config tags (paxcounter,
   traffic_management, etc.). NodeDB already persists the struct via
   the moduleConfig protobuf save; this just wires the read path to
   the phone.

2. Tag 14 — reserved gap in the oneof numbering. No payload_variant
   member exists at tag 14. Without this patch, every phone reconnect
   walks through config_state=14 and hits
   `LOG_ERROR("Unknown module config type %d", config_state)`. On an
   active deployment that's ~1,400 LOG_ERROR lines per day per node —
   burying real errors. Added explicit `case 14: break;` so the gap
   is silently skipped.

Also: lowered the `default:` log level from LOG_ERROR to LOG_DEBUG. A
truly-new unknown type number would indicate firmware lagging the
protobuf — annoying but not an error event worth LOG_ERROR, especially
since this path runs on every phone handshake. If a new ModuleConfig
tag appears, devs will notice via the phone UI missing it, not via log.

Observed on a Station G2 fleet: 1403 "Unknown module config type 16"
and 1427 "Unknown module config type 14" LOG_ERROR lines in 24 hours
from routine phone reconnects.

* Get rid of the placeholder

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 06:42:05 -05:00
nightjoker7 399dde0f4b Router: demote cross-channel decrypt failures from ERROR to DEBUG (#10259)
The "Invalid protobufs (bad psk?)" and "Invalid portnum (bad psk?)"
messages fire every time a neighbor transmits on a channel whose
8-bit hash matches one of ours but the PSK differs. In RF environments
with multiple mesh groups nearby this is routine — a single device
can see dozens of these per minute from SAR/MeshCA/private networks
sharing a hash collision.

LOG_ERROR for a benign "not our PSK" event:
- spams the log when you have any neighboring mesh group
- makes a genuine PSK misconfiguration on YOUR own channel
  indistinguishable from the constant cross-channel noise
- hides actual errors in the stream

LOG_DEBUG matches how similar decryption-failure paths are handled
elsewhere (eg. the PKC "decrypt attempted but failed" uses LOG_WARN).
Dropping the trailing "!" as well — these are expected events, not
exceptional ones.
2026-04-23 06:19:05 -05:00
nightjoker7andBen Meadors 66971a0a26 RadioLibInterface: clear static instance on destruction to prevent UAF (#10254)
The constructor sets `RadioLibInterface::instance = this` immediately,
before `init()` runs. `initLoRa()` in RadioInterface.cpp creates each
radio variant with `new SX1262Interface(...)` or similar, then calls
`init()`, and if init fails the `unique_ptr<RadioInterface>` is reset
to nullptr — destroying the object — while the static `instance`
pointer continues to point at the freed memory.

Main loop then checks `RadioLibInterface::instance != nullptr` and
calls `pollMissedIrqs()` or `resetAGC()` on the dangling pointer →
Guru Meditation (IllegalInstruction / LoadProhibited).

Reported in #9880 on an ESP32-S3 dev board without radio hardware
attached, where init always fails and the leftover pointer crashes
the device on the next `loop()` iteration.

Fix: add a virtual destructor to `RadioLibInterface` that clears the
static pointer iff it still references this object. A later successful
init() may have replaced `instance` with a different interface — the
`instance == this` guard preserves that case.

Fixes #9880

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 06:16:08 -05:00
Thomas Göttgens 4c24218afb Revert "Update LovyanGFX to v1.2.20 (#10232)" (#10269) (#10270)
This reverts commit fb1de111d7.
2026-04-23 12:40:27 +02:00
Ben Meadors 4b4914736f Merge remote-tracking branch 'origin/master' into develop 2026-04-22 21:52:25 -05:00
nightjoker7 a6b1a69630 StoreForwardModule::historyAdd: memcpy source size, not buffer capacity (#10250)
`memcpy(... p.payload.bytes, meshtastic_Constants_DATA_PAYLOAD_LEN)`
reads past the actual payload when the incoming packet's payload is
shorter than `DATA_PAYLOAD_LEN` (237 bytes). The code just above
already records the correct size:

    this->packetHistory[...].payload_size = p.payload.size;

but then the memcpy ignores that and copies the full buffer capacity,
pulling uninitialized / adjacent memory bytes into the history entry.
Those extra bytes are later rebroadcast whenever the Store & Forward
module replays the packet.

Fix: memcpy using `p.payload.size` (the actual payload length) instead
of the constant buffer capacity.

Classification: bounded out-of-bounds READ into the protobuf scratch
buffer. Not directly exploitable for RCE (the destination buffer is
also DATA_PAYLOAD_LEN), but leaks adjacent memory into replayed
packets and is a latent correctness bug.
2026-04-22 21:04:37 -05:00
Jonathan Bennett 28e705de5c Detach power interrupts for sleep (#10230)
* Detach power interrupts for sleep

* Gate PMU IRQ behind a found PMU
2026-04-22 14:27:48 -05:00
Austin fcb9ec0c2d t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp (#10241)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns t5s3_epaper with other variants like t_deck_pro.
2026-04-22 11:42:02 -05:00
Austin a4b55bc6f2 cardputer-adv: Move variant.cpp -> extra_variants/variant.cpp (#10242)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns cardputer-adv with other variants like t_deck_pro.
2026-04-22 11:41:49 -05:00
Jonathan Bennett b53fe7a1e7 T watch pinfix (#10231)
* Minor button debugging bits

* pin0 is a pin, pin -1 means disabled
2026-04-21 20:03:40 -05:00
Tom db9fdd6794 Fix: filter out SKIPPED tests in PlatformIO output to improve log clarity (#10214) 2026-04-21 16:43:35 -05:00
3b4c66439d 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:02 -05:00
HarukiToreda 945f4780ea BaseUI: Nodelist screen/favorite screen cleanup (#10197)
* nodelist screen cleanup

* Update UIRenderer.cpp

* Update src/graphics/draw/UIRenderer.cpp

* removed brackets from hop and made signal mutually exclusive
2026-04-21 10:31:29 -05:00
github-actions[bot]andcaveman99 0e38a15d46 Update protobufs (#10223)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-04-21 17:13:55 +02:00
Jaime Roldan 63bce1f01a fix(nodedb): force null-terminate name fields in UserLite/User conversions (#8174) (#10218) 2026-04-21 09:52:19 -05:00
nightjoker7 4090d9f2b3 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:01 -05:00
Ben MeadorsandCopilot d50caf231b 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-19 16:05:28 -05:00
TomandBen Meadors f396200d38 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-19 13:30:50 -05:00
Ben Meadors 6c04c37294 Merge remote-tracking branch 'origin/master' into develop 2026-04-19 12:50:12 -05:00
Clive Blackledge 34aa5e995b 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-18 08:29:30 -05:00
c8dac10348 Add MCP server for interacting with meshtastic devices and testing framework / TUI (#10194)
* Start of MCP server and test suite

* Add MCP server for interacting with meshtastic devices and testing framework / TUI

* Update mcp-server/README.md

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

* fix mcp-server review feedback from thread

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7

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

* Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control

* Semgrep fixes

* Trunk and semgrep fixes

* optimize pio streaming tee file writes

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

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

* chore: remove redundant log handle assignment

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

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

* Consolidate type imports and remove placeholder test files

* Add tests for config persistence and more exchange messages

* Refactor position test to validate on-demand request/reply behavior

* Remove  position request/reply test and update README for telemetry behavior

* Fix transmit history file to get removed on factory reset

---------

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-18 08:17:44 -05:00
HarukiToredaandCopilot aab4cd086f Compass improvements/refactoring (#10166)
* Infinite calibration loop fix

* Save calibration

* Screen refresh

* reduce repeated code

* reduce repeated code to reduce flash

* fix Waypoint compass size and no fix no heading labels

* Don't show compass unless we have a heading and location

* If no calculated heading from moving, we should have no heading

* Slow walking calculated heading and auto stale heading when not moving

* Triming flash space

* cleanup

* show "?" when no location or heading for distance and heading screen

* cleanup

* Stale heading logic

* final trim

* Compass Calibration screen redesign

* Trunk Fix

* Compile fix

* patch

* Update src/motion/MotionSensor.cpp

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

* Update WaypointModule.cpp

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-18 06:53:22 -05:00
Jason P 6208c243f9 BaseUI: Implementation of Status Message for Favorite and NodeList views (#9504)
* Implementation of Status Message

* Change drawNodeInfo to drawFavoriteNode

* Truncate overflow on Favorite frame

* Set MAX_RECENT_STATUSMESSAGES to 5 to meet memory usage targets
2026-04-17 08:42:56 -05:00
Jonathan Bennett 3a87e746a8 No longer need undefines, thanks to #10179 (#10180) 2026-04-16 21:34:28 -05:00
Ben Meadors cd04206334 Merge branch 'master' into develop 2026-04-16 21:22:17 -05:00
Jonathan Bennett 2768080edf 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-16 13:12:31 -05:00
Ruledo 466cc4cecd 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-16 10:41:06 -05:00
fe90c49795 fix/feat(stm32/russell): Serial2 build fix and BME680 support (#10097)
* fix(stm32/russell): define ENABLE_HWSERIAL2 and Serial2 pins

The Russell board variant was missed during Initial serialModule cleanup
(PR #9465), which began requiring Serial2 to be explicitly defined via
ENABLE_HWSERIAL2 and PIN_SERIAL2_TX/RX rather than relying on implicit
defaults, causing a build error.

Signed-off-by: Andrew Yong <me@ndoo.sg>

* feat(stm32/russell): add BME680 support, exclude modules to fit flash

The BME680 is hardware footprint compatible with the BME280 already
present on the Russell board, so add it as an additional lib dep to
enable environment sensing (temperature, humidity, pressure,
gas resistance).

The STM32 target has very limited flash. Even traceroute alone causes
overflow, so the following modules are excluded to stay within budget:

  - RANGETEST
  - DETECTIONSENSOR
  - EXTERNALNOTIFICATION
  - POWERSTRESS
  - NEIGHBORINFO
  - TRACEROUTE
  - WAYPOINT

AIR_QUALITY_SENSOR is also excluded as it requires the BSEC2 library
for real IAQ output, which alone overflows flash by ~44KB on this
target. The Adafruit BME680 library is used instead for raw sensor
readings.

Signed-off-by: Andrew Yong <me@ndoo.sg>

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-16 10:39:37 -05:00
Chloe Bethel 31418ca821 stm32wl: reserve 2KB of stack via linker script to match NRF52, change sbrkHeadroom to use the start of the reserved stack region instead of the current stack pointer
The linker script was created by merging variants/STM32WLxx/WL54JCI_WL55JCI_WLE4J(8-B-C)I_WLE5J(8-B-C)I/ldscript.ld and system/ldscript.ld from stm32duino/Arduino_Core_STM32.
2026-04-16 13:57:21 +01:00
Andrew Yong 0ee5777c15 stm32wl(mem): fix getFreeHeap() underreporting on dynamic sbrk heap
mallinfo().fordblks counts only free bytes within the committed arena.
On STM32WL (newlib sbrk heap) the arena grows lazily from _end toward SP,
so fordblks reads near-zero at early boot even when ~48 KB of addressable
space remains. This caused NodeDB::isFull() to fire prematurely and evict
nodes on a freshly booted device.

Fix getFreeHeap() to include uncommitted sbrk headroom (SP - sbrk(0)) so
the returned value reflects true available memory throughout the boot
lifecycle.

Introduce MESHTASTIC_DYNAMIC_SBRK_HEAP as an opt-in build flag (set in
stm32.ini) so the fix is gated to platforms with a dynamic sbrk heap
rather than a static heap. Future platforms with the same heap model can
opt in by adding this flag.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:57:21 +01:00
Andrew Yong 026213aab7 feat(stm32): Add STM32 ADC support to AnalogBatteryLevel (#9369)
Integrate STM32 battery monitoring into AnalogBatteryLevel, supporting
external GPIO ADC pins as well as internal VBAT channel.

Features:
- ADC reading using STM32 LL (Lower Layer) macros supporting external
  ADC channels and internal VBAT channel (AVBAT)
- ADC compensation using STM32 LL macros with factory-calibrated VREFINT
  (AVREF) for accurate voltage measurement
- LFP battery OCV curve for STM32WL using AVBAT (STM32 VDD absolute
  maximum supply voltage 3.9V, direct connection of Li-Po batteries is
  not supported)

Internal VBAT channel implemented in:
- Russell
- RAK3172

In these variants, ADC_MULTIPLIER = (1.01f * 3) = 3.30 as there is a
3:1 internal divider (DS13105 Rev 12 §5.3.21), and a bit of tolerance
as the actual 10% spec leads to readings much too high.


Assisted-by: Claude:sonnet-4-5

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-04-16 10:58:54 +01:00
Jonathan BennettandBen Meadors 0cab43fb43 Add PortduinoSetOptions to overwrite the realhardware bool (#10157)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-14 21:32:48 +02:00
github-actions[bot]andthebentern 1341cd4078 Automated version bumps (#10159)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-14 13:11:47 -05:00
Jennifer Sanchez 4059202a5c Added support for Spreading Factors 5 and 6 on compatible radios (#10160) 2026-04-14 13:11:36 -05:00
Ben MeadorsandCopilot d24d8806e1 Fix heap blowout on TBeams (#10155)
* Fix heap blowout on TBeams

* Update src/graphics/draw/MessageRenderer.cpp

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

* Set MESSAGE_HISTORY_LIMIT to 10 for original ESP32 to optimize RAM usage

* Optimize message frame allocation to prevent excessive memory usage

* Refine message history limits for resource-constrained builds and cap cached lines to prevent heap overflow

* Update src/graphics/draw/MessageRenderer.cpp

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-14 13:05:58 -05:00
Thomas Göttgens a67eb15ad3 fix last cppcheck issue (#10154) 2026-04-14 13:05:58 -05:00
Ben Meadors 9e182a595c Enhance release notes generation with commit range comparison 2026-04-14 13:05:58 -05:00
Tom Fifield 4587dc2d64 Add RADIOLIB_EXCLUDE_LR2021 in places that excluded LR11x0 (#10112)
To save resources, some devices where LR11x0 was never an option
excluded it from compilation, using RADIOLIB_EXCLUDE_LR11X0 .

As we will soon have LR2021 support, apply the same treatment
to that chip to these devices, by adding RADIOLIB_EXCLUDE_LR2021
2026-04-14 16:55:11 +02:00
github-actions[bot]andcaveman99 00fccd87f9 Update protobufs (#10161)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-04-14 15:27:05 +02:00
Andrew Yong 01bd4cfb73 feat(stm32wl): add reboot-to-bootloader support via enter_dfu_mode_request (#10158) 2026-04-14 13:46:33 +02:00
Jonathan Bennett 125c1b7f13 Make consoleInit() Reentrant, and initialize it earlier on native (#10156) 2026-04-14 06:41:04 -05:00
Thomas Göttgens d6cf67b6bc Clarify behavior when no radio instance is present
Add comment explaining silent behavior when no radio instance is available.
2026-04-14 12:19:58 +02:00
77f378dd53 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-13 12:05:23 -05:00
Ben Meadors 16dcafa7fb Merge remote-tracking branch 'origin/master' into develop 2026-04-13 06:28:41 -05:00
4ac74edf38 fix(native): implement BinarySemaphorePosix with proper pthread synchronization (#9895)
* fix(native): implement BinarySemaphorePosix with proper pthread synchronization

The BinarySemaphorePosix class (used on all Linux/portduino/native builds)
had stub implementations: give() was a no-op and take() just called
delay(msec) and returned false. This broke the cooperative thread scheduler
on native platforms — threads could not wake the main loop, radio RX
interrupts were missed, and telemetry never transmitted over the mesh.

Replace the stubs with a proper binary semaphore using pthread_mutex_t +
pthread_cond_t + bool signaled:

- take(msec): pthread_cond_timedwait with CLOCK_REALTIME timeout, consumes
  signal atomically (binary semaphore semantics)
- give(): sets signaled=true, signals condition variable
- giveFromISR(): delegates to give(), sets pxHigherPriorityTaskWoken

Tested on Raspberry Pi 3 Model B (ARM64, Debian Bookworm) with Adafruit
LoRa Radio Bonnet (SX1276). Before fix: no radio TX/RX, no telemetry on
mesh. After fix: bidirectional LoRa, MQTT gateway, telemetry all working.

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

* ARCH_PORTDUINO

* Refactor BinarySemaphorePosix header for ARCH_PORTDUINO

* Change preprocessor directive from ifndef to ifdef

* Gate new Semaphore code to Portduino and fix STM compilation

* Binary Semaphore Posix better error handling

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-12 22:41:25 -05:00
Jonathan Bennett 69495dcd98 Merge remote-tracking branch 'origin/master' into develop 2026-04-12 17:29:40 -05:00
Catalin Patulea 288d7a3e7b Revert "Add include directive for mbedtls error handling in build flags" (#10073)
This reverts commit 391928ed08.

Since esp32_https_server commit e2c9f98, this is no longer necessary.

Also, the esp32-common.ini '-include mbedtls/error.h' causes a build error
under IDF v5 (https://github.com/meshtastic/firmware/pull/9122#issuecomment-4185767085):

  <command-line>: fatal error: mbedtls/error.h: No such file or directory

so this change fixes that error.
2026-04-11 06:35:42 -05:00
Catalin Patulea 4990bf51e3 Delete PointerQueue::dequeuePtrFromISR, unused since commit db766f1 (#99). (#10090) 2026-04-10 16:20:25 -05:00
Tom b2c8cbb78d Enhance traffic management by adjusting position update interval and refining hop exhaustion logic based on channel congestion (#9921) 2026-04-10 10:53:04 -05:00
Jonathan Bennett 8061f83704 Modify log output to show milliseconds (#10115)
Updated timestamp format to include milliseconds.
2026-04-10 07:21:24 -05:00
Tom a3b49b9028 Add powerlimits to reconfigured radio settings as well as init settings. (#10025)
* Add powerlimits to reconfigured radio settings as well as init settings.

* Refactor preamble length handling for wide LoRa configurations

* Moved the preamble setting to the main class and made the references static
2026-04-09 16:18:05 -05:00
Ben Meadors 17945884a5 Merge remote-tracking branch 'origin/master' into develop 2026-04-09 06:21:33 -05:00
Ben Meadors 1116f06139 Merge remote-tracking branch 'origin/master' into develop 2026-04-08 13:37:25 -05:00
github-actions[bot]andthebentern c728cfdaf4 Automated version bumps (#10092)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-06 06:39:53 -05:00
9322bcdb21 fix: redact MQTT password from log output (#10064)
MQTT password was logged in cleartext via LOG_INFO when connecting to
the broker, exposing credentials to anyone with log access. Replace
the password format specifier with a static mask.

Co-authored-by: Patrickschell609 <patrickschell609@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 07:54:51 -05:00
2f19a1d7a4 Consolidate SHTs into one class (#9859)
* Consolidate SHTs into one class

* Remove separate SHT imports
* Create one single SHTXX sensor type
* Let the SHTXXSensor class handle variant detection

* Minor logging improvements

* Add functions to set accuracy on SHT3X and SHT4X

* Fix variable init in constructor

* Add bus to SHT sensor init

* Update src/modules/Telemetry/Sensor/SHTXXSensor.cpp

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

* Update src/modules/Telemetry/Sensor/SHTXXSensor.cpp

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

* Fix accuracy conditions on SHTXX

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

* Merge upstream

* Add SHT2X detection on 0x40

* Read second part of SHT2X serial number

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-04 06:51:15 -05:00
github-actions[bot]andfifieldt 71c8143f72 Update protobufs (#10074)
Co-authored-by: fifieldt <1287116+fifieldt@users.noreply.github.com>
2026-04-04 13:58:23 +11:00
Ben MeadorsandCopilot 222d3e6b62 Fix zero CR and add unit tests for applyModemConfig coding rate behavior (#10070)
* Fix zero CR and add unit tests for applyModemConfig coding rate behavior

* Apply suggestion from @Copilot

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

* fix: initialize region configuration in setUp for RadioInterface tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-04 08:27:39 +11:00
Chloe Bethel 2e6519bb98 Add a hardfault handler so it's more obvious when STM32 crashes. (#10071)
* Add hardfault handler so it's more obvious when STM32 crashes.

* thanks copilot
2026-04-03 14:50:39 +01:00
726d539174 fix: prevent division by zero in wind sensor averaging (#10059)
SerialModule's weather station parser divides by velCount and dirCount
to compute wind speed/direction averages. Both counters are only
incremented when their respective sensor readings arrive, but the
division runs whenever gotwind is true (set by EITHER reading) and
the averaging interval has elapsed.

If only WindDir arrives without WindSpeed (or vice versa), or if the
timer fires before any readings accumulate, the division produces
undefined behavior (floating-point divide by zero on embedded = NaN
or hardware fault depending on platform).

Fix: add velCount > 0 && dirCount > 0 guard to the averaging block.

Co-authored-by: Patrickschell609 <patrickschell609@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-03 06:07:59 -05:00
e7ee4bea18 Fix TFTDisplay::display to align pixels at 32-bit boundary (#9956)
* Fix TFTDisplay partial update: align spans to 32-bit boundary for GDMA

The device, such as esp32c6, require 32-bit alignment for the data.

The patch aligns start and end of the update pixels buffer.

* Update src/graphics/TFTDisplay.cpp

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

* Apply suggestion from @Copilot

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-02 20:57:19 -05:00
Tom efd2613bd7 feat: add new configuration files for LR11xx variants (#9761)
* feat: add new configuration files for LR11xx variants

* style: reformat RF switch mode table for improved readability
2026-04-01 10:46:27 +11:00
TomandBen Meadors f88bc732cc Improved manual build flow to make it easier (#8839)
* Improved flow to make easier

The emojis are intentional! I had minimal LLM input!!!

* try and fix input variable sanitisation

* and again

* again

* Copilot fixed it for me

* copilot didn't fix it for me

* copilot might have fixed it and I broke it by copypasting

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-31 07:53:59 -05:00
Ben Meadors 0746f8cfff Merge remote-tracking branch 'origin/master' into develop 2026-03-30 14:37:29 -05:00
Ben Meadors 0ac95e370b Merge remote-tracking branch 'origin/master' into develop 2026-03-30 14:10:25 -05:00
TomandBen Meadors 283ccb83d1 Configure NFC pins as GPIO for older bootloaders (#10016)
* Configure NFC pins as GPIO for older bootloaders

Should hopefully solve the issue in https://github.com/meshtastic/firmware/issues/9986

* Fix formatting of CONFIG_NFCT_PINS_AS_GPIOS flag in platformio.ini

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-30 09:03:10 -05:00
Philip LykovandBen Meadors 5319bc7c2c Fix W5100S socket exhaustion blocking MQTT and additional TCP clients (#9770)
The W5100S Ethernet chip has only 4 hardware sockets. On RAK4631
Ethernet gateways with syslog and NTP enabled, all 4 sockets were
permanently consumed (NTP UDP + Syslog UDP + TCP API listener + TCP
API client), leaving none for MQTT, DHCP lease renewal, or additional
TCP connections.

- NTP: Remove permanent timeClient.begin() at startup; NTPClient::update()
  auto-initializes when needed. Add timeClient.end() after each query to
  release the UDP socket immediately.
- Syslog: Remove socket allocation from Syslog::enable(). Open and close
  the UDP socket on-demand in _sendLog() around each message send.
- MQTT: Fix socket leak in isValidConfig() where a successful test
  connection was never closed (PubSubClient destructor does not call
  disconnect). Add explicit pubSub->disconnect() before returning.

Made-with: Cursor

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-30 07:12:23 -05:00
Ben Meadors db694f2f24 Merge pull request #10031 from meshtastic/master
Backmerge master -> develop
2026-03-29 15:01:39 -05:00
Ben Meadors 814f1289f6 Merge remote-tracking branch 'origin/master' into develop 2026-03-28 07:26:46 -05:00
ManuelandBen Meadors 0b7556b590 MUI: WiFi map tile download: heltec V4 adaptations (#10011)
* rotated MUI

* mui-maps heltec-v4 adaptations

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-27 15:39:26 -05:00
Ben Meadors f7e4ac3e43 Merge remote-tracking branch 'origin/master' into develop 2026-03-27 08:43:19 -05:00
Ethac.chenandBen Meadors c36ae159ed Fix rak_wismeshtag low‑voltage reboot hang after App configuration (#9897)
* Fix TAG low‑voltage reboot hang after App configuration

* nRF52: Move low-VDD System OFF logic to variant hook

* Addressed review

* serialize SAADC access with shared mutex for VDD and battery reads

* raise LPCOMP wake threshold to ensure rising-edge wake

* Trunk fmt

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-27 06:56:19 -05:00
Chloe Bethel 33e7f16c05 Supporting STM32WL is like squeezing blood from a stone (#10015) 2026-03-27 06:52:00 -05:00
Jason P d3a86841b9 Update External Notifications with a full redo of logic gates (#10006)
* Update External Notifications with a full redo of pathways

* Correct comments.

* Fix TWatch S3 and use HAS_DRV2605
2026-03-25 14:46:24 -05:00
Austin Lane e14b8d385a Remove unneeded GH perms
Reduce perms to least-necessary
Remove merge_queue.yml since it's never been used and is now stale
Remove comment-artifact, it hasn't worked in ages.
2026-03-24 08:13:59 -04:00
Austin 8ce1a872eb Add timeout to PPA uploads (#9989)
Don't allow dput to run for more than 15 minutes (successful runs take about ~8 minutes)
2026-03-23 20:15:56 -05:00
Austin Lane abfa346630 Cleanup GH Actions 2026-03-23 11:17:30 -04:00
stmka_playgoundandBen Meadors 2ffe2d829c Fixes #9850: Double space issue with Cyrillic OLED font (#9971)
* Fixes double space OLEDDisplayFontsRU.cpp

* Fixes double space OLEDDisplayFontsUA.cpp

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-23 07:06:53 -05:00
TomandBen Meadors bfaf6c6b20 fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users (#9958)
* fix(routing): prevent licensed users from rebroadcasting packets from unlicensed or unknown users

* fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 13:47:52 -05:00
d293d654a0 add heltec_mesh_node_t096 board. (#9960)
* add heltec_mesh_node_t096 board.

* Fixed the GPS reset pin comments.

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

* Added compiles if NUM_PA_POINTS is not defined.

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

* Correct the pin description.

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

* Specify the version of the dependency library TFT_eSPI.

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

* Adding fields missing from the .ini file.

* Modify the screen SPI frequency to 40 MHz.

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 09:54:46 -05:00
8384659608 fix: apply all LoRa config changes live without rebooting (#9962)
* fix: apply all LoRa config changes live without rebooting

All LoRa radio settings (SF, BW, CR, frequency, power, preset,
sx126x_rx_boosted_gain) now apply immediately via reconfigure()
without requiring a node reboot.

- AdminModule: requiresReboot = false for all LoRa config changes;
  LoRa changes were already handled by the configChanged observer
  calling reconfigure() but the reboot flag was set unnecessarily
- AdminModule: validate LORA_24 region against radio hardware at
  config time; reject with BAD_REQUEST if hardware lacks 2.4 GHz
  capability (wideLora() returns false or no radio instance)
- SX126xInterface/LR11x0Interface: apply sx126x_rx_boosted_gain in
  reconfigure(); register 0x08AC is writable in STDBY mode (SX1261/2
  datasheet §9.6); retention registers written so setting survives
  warm-sleep cycles; log warning on setter failure
- DebugRenderer: show BW/SF/CR on debug screen when custom modem is
  active instead of the preset name
- DisplayFormatters: clarify comment on getModemPresetDisplayName

* fix: remove redundant reboot after LoRa config changes in on-device menus

Region, frequency slot, and radio preset pickers in MenuHandler all
called reloadConfig() then immediately set rebootAtMsec. reloadConfig()
already fires the configChanged observer which calls reconfigure(), so
the forced reboot was unnecessary — same rationale as the parent commit.

* fix: guard LORA_24 region selection against hardware capability in on-device menu

Without a reboot, reconfigure() now applies region changes directly.
Previously getRadio() caught the LORA_24-on-sub-GHz mismatch post-reboot
and reverted to UNSET — that safety net is gone. Add an explicit wideLora()
check in LoraRegionPicker so sub-GHz-only hardware silently ignores LORA_24
selection instead of attempting a live reconfigure with an invalid frequency.

---------

Co-authored-by: elwimen <elwimen@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 09:39:41 -05:00
Robert Sasak a632d0133f Add LED_BUILTIN for variant tlora_v1 (#9973) 2026-03-22 08:42:13 -05:00
Okan ErturanandBen Meadors f982b58d61 Fix: Enable touch-to-backlight on T-Echo (not just T-Echo Plus) (#9953)
The touch-to-backlight feature was gated behind TTGO_T_ECHO_PLUS, but
the regular T-Echo has the same backlight pin (PIN_EINK_EN, P1.11).
This changes the guard to use PIN_EINK_EN only, so any device with an
e-ink backlight pin gets the feature.

Fixes #7630

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 06:43:41 -05:00
Ben Meadors efb0b299b2 Merge remote-tracking branch 'origin/master' into develop 2026-03-22 06:42:37 -05:00
eb80d3141d Fixes #9792 : Hop with Meshtastic ffff and ?dB is added to missing hop in traceroute (#9945)
* Fix issue 9792, decode packet for TR test

* Fix 9792: Assure packet id decoded for TR test

* Potential fix for pull request finding

Log improvement for failure to decode packet.

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

* trunk fmt

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
2026-03-21 07:33:45 +11:00
Ben Meadors 3604c1255d Consolidate PKI key generation logic into ensurePkiKeys method 2026-03-20 09:53:12 -05:00
TomandCopilot Autofix powered by AI 22d63fa69c Lora settings expansion and validation logic improvement (#9878)
* Enhance LoRa configuration with modem presets and validation logic

* Rename bootstrapLoRaConfigFromPreset tests to validateModemConfig for clarity and consistency

* additional tidy-ups to the validateModemConfig - still fundamentally broken at this point

* Enhance region validation by adding numPresets to RegionInfo and implementing validateRegionConfig in RadioInterface

* Add validation for modem configuration in applyModemConfig

* Fix region unset handling and improve modem config validation in handleSetConfig

* Refactor LoRa configuration validation methods and introduce clamping method for invalid settings

* Update handleSetConfig to use fromOthers parameter to either correct or reject invalid settings

* Fix some of the copilot review comments for LoRa configuration validation and clamping methods; add tests for region and preset handling

* Redid the slot default checking and calculation. Should resolve the outstanding comments.

* Add bandwidth calculation for LoRa modem preset fallback in clampConfigLora

* Remove unused preset name variable in validateConfigLora and fix default frequency slot check in applyModemConfig

* update tests for region handling

* Got the synthetic colleague to add mock service for testing

* Flash savings... hopefully

* Refactor modem preset handling to use sentinel values and improve default preset retrieval

* Refactor region handling to use profile structures for modem presets and channel calculations

* added comments for clarity on parameters

* Add shadow table tests and validateConfigLora enhancements for region presets

* Add isFromUs tests for preset validation in AdminModule

* Respond to copilot github review

* address copilot comments

* address null poointers

* fix build errors

* Fix the fix, undo the silly suggestions from synthetic reviewer.

* we all float here

* Fix include path for AdminModule in test_main.cpp

* Potential fix for pull request finding

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

* More suggestion fixes

* admin module merge conflicts

* admin module fixes from merge hell

* fix: initialize default frequency slot and custom channel name; update LNA mode handling

* save some bytes...

* fix: simplify error logging for bandwidth checks in LoRa configuration

* Update src/mesh/MeshRadio.h

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-20 07:43:47 -05:00
Ben Meadors fc030d2d19 Merge remote-tracking branch 'origin/master' into develop 2026-03-20 07:43:15 -05:00
Ben Meadors 4a534f02a4 fix(gps): prevent GPS re-enablement in NOT_PRESENT mode 2026-03-19 15:27:59 -05:00
rcatal01andBen Meadors 0ea408e12b fix: MQTT settings silently fail to persist when broker is unreachable (#9934)
* fix: MQTT settings silently fail to persist when broker is unreachable

isValidConfig() was testing broker connectivity via connectPubSub() as
part of config validation. When the broker was unreachable (network not
ready, DNS failure, server down), the function returned false, causing
AdminModule to skip saving settings entirely — silently.

This removes the connectivity test from isValidConfig(), which now only
validates configuration correctness (TLS support, default server port).
Connectivity is handled by the MQTT module's existing reconnect loop.

Fixes #9107

* Add client warning notification when MQTT broker is unreachable

Per maintainer feedback: instead of silently saving when the broker
can't be reached, send a WARNING notification to the client saying
"MQTT settings saved, but could not reach the MQTT server."

Settings still always persist regardless of connectivity — the core
fix from the previous commit is preserved. The notification is purely
advisory so users know to double-check their server address and
credentials if the connection test fails.

When the network is not available at all, the connectivity check is
skipped entirely with a log message.

* Address Copilot review feedback

- Fix warning message wording: "Settings will be saved" instead of
  "Settings saved" (notification fires before AdminModule persists)
- Add null check on clientNotificationPool.allocZeroed() to prevent
  crash if pool is exhausted (matches AdminModule::sendWarning pattern)
- Fix test comments to accurately describe conditional connectivity
  check behavior and IS_RUNNING_TESTS compile-out

* Remove connectivity check from isValidConfig entirely

Reverts the advisory connectivity check added in the previous commit.
While the intent was to warn users about unreachable brokers,
connectPubSub() mutates the isConnected state of the running MQTT
module and performs synchronous network operations that can block
the config-save path.

The cleanest approach: isValidConfig() validates config correctness
only (TLS support, default server port). The MQTT reconnect loop
handles connectivity after settings are persisted and the device
reboots. If the broker is unreachable, the user will see it in the
MQTT connection status — no special notification needed.

This returns to the simpler design from the first commit, which was
tested on hardware and confirmed working.

* Use lightweight TCP check instead of connectPubSub for validation

Per maintainer feedback: users need connectivity feedback, but
connectPubSub() mutates the module's isConnected state.

This uses a standalone MQTTClient TCP connection test that:
- Checks if the server IP/port is reachable
- Sends a WARNING notification if unreachable
- Does NOT establish an MQTT session or mutate any module state
- Does NOT block saving — isValidConfig always returns true

The TCP test client is created locally, used, and destroyed within
the function scope. No side effects on the running MQTT module.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-19 13:00:00 -05:00
644d0d4a15 Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio. (#9916)
* Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio.

PKI DMs sent over UDP multicast had their pki_encrypted flag and public_key fields explicitly cleared before being forwarded to the LoRa radio. This caused the receiving node to treat the packet as a channel-encrypted message it couldn't decrypt, silently dropping it.

The MQTT ingress path correctly preserves these fields. The UDP multicast ingress path should behave the same way.

* Zeroize MeshPacket before decoding

Zeroize MeshPacket before decoding to prevent data leakage.

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-19 10:52:52 -05:00
f04746a928 Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout (#9754)
* Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout

PoE power instability can brownout the W5100S while the nRF52 MCU keeps
running, causing all chip registers (MAC, IP, sockets) to revert to
defaults. The firmware had no mechanism to detect or recover from this.

Changes:
- Detect W5100S chip reset by periodically verifying MAC address register
  in reconnectETH(); on mismatch, perform full hardware reset and
  re-initialize Ethernet interface and services
- Add deInitApiServer() for clean API server teardown during recovery
- Add ~APIServerPort destructor to prevent memory leaks
- Switch nRF52 from EthernetServer::available() to accept() to prevent
  the same connected client from being repeatedly re-reported
- Add proactive dead-connection cleanup in APIServerPort::runOnce()
- Add 15-minute TCP idle timeout to close half-open connections that
  consume limited W5100S hardware sockets

Fixes meshtastic/firmware#6970

Made-with: Cursor

* Log actual elapsed idle time instead of constant timeout value

Address Copilot review comment: log millis() - lastContactMsec to show
the real time since last client activity, rather than always logging the
TCP_IDLE_TIMEOUT_MS constant.

Made-with: Cursor

* Update src/mesh/api/ServerAPI.h

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

* Stop UDP multicast handler during W5100S brownout recovery

After a W5100S chip brownout, the udpHandler isRunning flag stays
true while the underlying socket is dead. Without calling stop(),
the subsequent start() no-ops and multicast is silently broken
after recovery.

Made-with: Cursor

* Address Copilot review: recovery flags and timeout constant

Move ethStartupComplete and ntp_renew reset to immediately after
service teardown, before Ethernet.begin(). Previously, if DHCP
failed the early return left ethStartupComplete=true, preventing
service re-initialization on subsequent retries.

Replace #define TCP_IDLE_TIMEOUT_MS with static constexpr uint32_t
for type safety and better C++ practice.

Made-with: Cursor

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-19 08:37:39 -05:00
Wessel 1be2529fb9 Enable LNA by default for Heltec v4.3 (#9906)
It should only be disabled by users that have problems with it.
2026-03-19 08:11:10 -05:00
Andrew Yong 959756abf1 fix(tlora-pager): Remove SDCARD_USE_SPI1 so SX1262 and SD card can share SPI bus (#9870)
Problem:
- Inserting a µSD card causes RadioLib to hit a critical error and reboot
- Device enters a boot loop as the SD card remains inserted

Reproduction:
- Insert a µSD card and power on
- RadioLib reports a critical error on boot
- Device reboots, repeating indefinitely

Root cause:
- On T-Lora Pager, SX1262 and the µSD slot share the same physical SPI bus
  (same SCK/MOSI/MISO pins, differentiated only by CS)
- SDCARD_USE_SPI1 is intended for boards where SD is on a separate SPI bus;
  it initializes a second ESP32 SPI peripheral (SPI3) for SD
- SPI2 is already driving those same pins for LoRa, so both controllers
  simultaneously drive the same GPIO lines, causing bus contention

Fix:
- Remove SDCARD_USE_SPI1 so both devices share a single SPI peripheral (SPI2),
  with CS pins providing device selection as intended
- Tested on a custom fork of device-ui; LoRa and SD card map tiles both work
  correctly with an SD card inserted

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-03-19 07:20:15 -05:00
c88b802e32 Remove early return during scan of BME address for BMP sensors (#9935)
* Enable pre-hop drop handling by default

* Remove early break if BME/DPS sensors are not detected at the BME address

* revert sneaky change

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-19 07:12:50 -05:00
fw190d13 9f74fc11de hexDump: Add const to the buf parameter in hexDump. (#9944)
The function only reads the buffer, so marking it const clarifies intent
and prevents accidental modification.
2026-03-19 06:13:34 -05:00
HarukiToreda 2ef09d17b9 BaseUI: Emote Refactoring (#9896)
* Emote refactor for BaseUI

* Trunk Check

* Copilot suggestions
2026-03-17 20:42:37 -05:00
Ben Meadors 19d070c284 Trunk 2026-03-17 14:01:53 -05:00
Ben Meadors e24db2994b Merge remote-tracking branch 'origin/master' into develop 2026-03-17 13:28:25 -05:00
ac7a58cd45 Fix: Traceroute through MQTT misses uplink node if MQTT is encrypted (#9798)
* Attempt to fix issue 9713

* Code formatting issue.

* Remade the fix to follow Copilot observations on PR

* Rebuild after AI and GUVWAF

* Update src/mesh/Router.cpp

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

* Update src/mesh/Router.cpp

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

* trunk fmt

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: GUVWAF <thijs@havinga.eu>
Co-authored-by: GUVWAF <78759985+GUVWAF@users.noreply.github.com>
2026-03-17 06:48:29 +11:00
53c21eb30d Deprecate/block packets with a missing/invalid hop_start value (pre-hop firmware) (related to issue #7369) (#9476)
* Deprecate forwarding for invalid hop_start

* Add pre-hop packet drop policy

* Log ignored rebroadcasts for pre-hop packets

* Respect pre-hop policy ALLOW in routing gates

* Exempt local packets from pre-hop drop policy

* Format pre-hop log line

* Add MODERN_ONLY rebroadcast mode for pre-hop packets

* Simplify implementation for drop packet only behaviour

* Revert formatting-only changes

* Match ReliableRouter EOF formatting

* Make pre-hop drop a build-time flag

* Rework to compile/build flag MESHTASTIC_PREHOP_DROP

* Set MESHTASTIC_PREHOP_DROP off by default

* Inline pre-hop hop_start validity check

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jord <650645+DivineOmega@users.noreply.github.com>
2026-03-16 06:35:33 -05:00
Tom Fifield e51e6cad84 Remove GPS Baudrate locking for Seeed Xiao S3 Kit (#9374)
The Seeed Xiao S3 Kit's default GPS is an L76K which operates at 9600 baud, so when this variant was defined that baud rate was specified.

However, this is a development board and it is expected that users can attach their own devices. This includes GPS, which may operate at a different baud rate. The current fixed baud rate prevents this, so this patch removes that setting.

This will revert to the regular automatic probe method. This will successfully detect the L76K as before (the same speed as before since 9600 baud is the first baud rate checked), but also allow other GPSes at other baud rates to be detected.

Thanks to @ScarpMarc for the report

Fixes https://github.com/meshtastic/firmware/issues/9373#issuecomment-3774802763
2026-03-16 19:28:21 +11:00
Tom 4890f7084f Add spoof detection for UDP packets in UdpMulticastHandler (#9905)
* Add spoof detection for UDP packets in UdpMulticastHandler

* Implement isFromUs function for packet origin validation

* ampersand
2026-03-14 19:34:19 -05:00
oscgonfer 3fcbfe4370 Remove a bunch of warnings in SEN5X (#9884) 2026-03-12 06:18:56 -05:00
da808cb43b Automated version bumps (#9886)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-11 06:52:18 -05:00
Ben Meadors 82580c6798 Initialize LoRaFEMInterface with default fem_type 2026-03-11 06:48:46 -05:00
Clive BlackledgeandCopilot 016e68ec53 Traffic Management Module for packet forwarding logic (#9358)
* 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.

* Addition of traffic management module

* Fixing compile issues, but may still need to update protobufs.

* Fixing log2Floor in cuckoo hash function

* Adding support for traffic management in PhoneAPI.

* Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE.

* Adding station-g2 and portduino varients to be able to use this module.

* Spoofing from address for nodeinfo cache

* Changing name and behavior for zero_hop_telemetry / zero_hop_position

* Name change for exhausting telemetry packets and setting hop_limit to 1 so it will be 0 when sent.

* Updated hop logic, including exhaustRequested flag to bypass some checks later in the code.

* Reducing memory on nrf52 nodes further to 12 bytes per entry, 12KB total using 8 bit hashes with 0.4% collision. Probably ok. Adding portduino to the platforms that don't need to worry about memory as much.

* Fixing hopsAway for nodeinfo responses.

* traffic_management.nodeinfo_direct_response_min_hops -> traffic_management.nodeinfo_direct_response_max_hops

* Removing dry run mode

* Updates to UnifiedCacheEntry to use a common cache, created defaults for some values, reduced a couple bytes per entry by using a resolution-scale time selection based on configuration value.

* Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants.

* Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails.

* Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit.

* Creating consistent log messages

* Remove docs/ESP32_Power_Management.md from traffic_module

* Add unit tests for Traffic Management Module functionality

* Fixing compile issues, but may still need to update protobufs.

* Adding support for traffic management in PhoneAPI.

* Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE.

* Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants.

* Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails.

* Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit.

* Add mock classes and unit tests for Traffic Management Module functionality.

* Refactor setup and loop functions in test_main.cpp to include extern "C" linkage

* Update comment to include reduced  memory requirements

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

* Re-arranging comments for programmers with the attention span of less than 5 lines of code.

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

* Update comments in TrafficManagementModule to reflect changes in timestamp epoch handling and memory optimization details.

* bug: Use node-wide config_ok_to_mqtt setting for cached NodeInfo replies.

* Better way to handle clearing the ok_to_mqtt bit

* Add bucketing to cuckoo hashing, allowing for 95% occupied rate before major eviction problems.

* Extend nodeinfo cache for psram devices.

* Refactor traffic management to make hop exhaustion packet-scoped. Nice catch.

* Implement better position precision sanitization in TrafficManagementModule.

* Added logic in TrafficManagementModule to invalidate stale traffic state. Also, added some tests to avoid future me from creating a regression here.

* Fixing tests for native

* Enhance TrafficManagementModule to improve NodeInfo response handling and position deduplication logic. Added tests to ensure local packets bypass transit filters and that NodeInfo requests correctly update the requester information in the cache. Updated deduplication checks to prevent dropping valid position packets under certain conditions.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 06:12:12 -05:00
Catalin PatuleaandBen Meadors 79f469ce71 pioarduino Heltec v4: fix build due to LED_BUILTIN compile error. (#9875)
Fixes this build error:

  <command-line>: error: expected unqualified-id before '-' token
  /home/<snip>/.platformio/packages/framework-arduinoespressif32/variants/esp32s3/pins_arduino.h:15:22: note: in expansion of macro 'LED_BUILTIN'
     15 | static const uint8_t LED_BUILTIN = SOC_GPIO_PIN_COUNT + PIN_RGB_LED;
        |                      ^~~~~~~~~~~

More info: https://github.com/meshtastic/firmware/pull/9122#issuecomment-4028263894

The fix is consistent with variants/esp32s3/heltec_v3/platformio.ini

This commit is intentionally on the develop branch, because it's harmless to
develop branch, and makes us more ready for pioarduino when the time comes.

Heltec v4 introduced in: https://github.com/meshtastic/firmware/pull/7845

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-10 06:19:25 -05:00
HarukiToredaandCopilot 0501e177e9 T-mini Eink S3 Support for both InkHUD and BaseUI (#9856)
* Tmini Eink fix

* tuning

* better refresh

* Fix to lora pins to be like the original.

* Update pins_arduino.h

* removed dead flags from previous tests

* Update src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 17:37:34 -05:00
406 changed files with 26921 additions and 11326 deletions
+7 -1
View File
@@ -49,11 +49,17 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
- 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.
7. **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, 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.
+4
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,6 +42,8 @@ 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.
+7 -3
View File
@@ -8,17 +8,21 @@
"features": {
"ghcr.io/devcontainers/features/python:1": {
"installTools": true,
"version": "3.14"
"version": "3.13"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.cpptools",
"platformio.platformio-ide",
"Jason2866.esp-decoder",
"pioarduino.pioarduino-ide",
"Trunk.io"
],
"unwantedRecommendations": ["ms-azuretools.vscode-docker"],
"unwantedRecommendations": [
"ms-azuretools.vscode-docker",
"platformio.platformio-ide"
],
"settings": {
"extensions.ignoreRecommendations": true
}
-29
View File
@@ -1,29 +0,0 @@
# firmware Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-03-25
## Active Technologies
- Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets + Python standard library for inventory/intake tooling, existing Spec Kit artifacts, repository-local Copilot prompt/agent files, PlatformIO environment metadata conventions already in `variants/**/platformio.ini` (129-hardware-support-agent)
## Project Structure
```text
src/
tests/
```
## Commands
cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] pytest [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] ruff check .
## Code Style
Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets: Follow standard conventions
## Recent Changes
- 129-hardware-support-agent: Added Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets + Python standard library for inventory/intake tooling, existing Spec Kit artifacts, repository-local Copilot prompt/agent files, PlatformIO environment metadata conventions already in `variants/**/platformio.ini`
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->
-124
View File
@@ -1,124 +0,0 @@
---
description: Guide maintainers through Meshtastic hardware support context generation, board intake assessment, and optional scaffold generation.
---
# Hardware Support Workflow
Use this workflow when a maintainer wants to add support for a new board variant in the Meshtastic firmware repository.
## Goals
- Reuse repository-backed hardware patterns before drafting new board files.
- Keep all generated output scoped to the requested architecture and board.
- Stop and surface evidence gaps instead of inventing pin mappings or metadata.
## Required Inputs
Collect or confirm these fields before scaffold generation:
- PlatformIO environment name
- hardware model identifier
- display name
- architecture
Recommended additional inputs:
- hardware model slug
- actively supported flag
- support level
- source materials such as schematic, pinout, or datasheet links
- board notes covering revision scope and known uncertainty
## Workflow
### 1. Refresh Repository Context
Run from the repository root:
```bash
python3 bin/generate_hardware_support_context.py
```
Review [docs/hardware-support-context.md](../../docs/hardware-support-context.md) for architecture-specific examples, metadata keys, and inherited-default notes.
### 2. Capture The Intake Request
Create or update a JSON file matching the contract in [specs/129-hardware-support-agent/contracts/board-intake-contract.md](../../specs/129-hardware-support-agent/contracts/board-intake-contract.md).
### 3. Assess Intake Readiness
Run:
```bash
python3 bin/board_intake.py path/to/intake.json
```
What to look for:
- expected artifacts
- required metadata
- matched repository patterns
- evidence gaps
- risk flags
- next actions
- scaffold readiness decision
For CI-style gating, use:
```bash
python3 bin/board_intake.py path/to/intake.json --validate
```
If the assessment is not scaffold-ready, stop and resolve the blocking gaps before continuing.
### 4. Generate Scaffold Output When Ready
Only run this when the intake assessment reports `Scaffold ready: Yes`.
```bash
python3 bin/board_scaffold.py path/to/intake.json --output-dir generated/hardware-support
```
Expected outputs:
- draft `variant.h`
- draft `platformio.ini`
- optional `variant.cpp` for ESP32-family targets
Review all `// TODO: verify — ...` annotations before treating the scaffold as merge-ready.
### 5. Compile-Gate The Target Environment (Required)
The end stage must always validate that the target environment is at least compilable.
Run:
```bash
pio run -e <environment_name>
```
Expected behavior:
- If compile succeeds, include a "compile check passed" note in the review summary.
- If compile fails, treat it as a blocking issue and report the exact failing error.
- Do not mark the workflow complete while compile is failing.
Common first-pass blocker for new scaffolds:
- Missing or placeholder `board = ...` in `platformio.ini` causes `BoardConfig: Board is not defined`.
## Guardrails
- Do not change live firmware runtime code under `src/` as part of this workflow.
- Do not modify existing board definitions under `variants/` automatically.
- Do not guess unresolved radio, display, GPS, power, or input pin mappings.
- Treat multi-revision or multi-option board notes as blocking until the revision scope is explicit.
- Check inherited BSP defaults for `nrf52840`, `rp2040`, `stm32`, and `native` targets before declaring a missing define.
## Validation Expectations
- Run `trunk fmt --force` on touched Python workflow files.
- Re-run `python3 bin/generate_hardware_support_context.py` after changes affecting context output.
- Use fixture-driven smoke tests in `bin/fixtures/` for intake and scaffold workflows.
- Run `pio run -e <environment_name>` as a required final compile gate for the generated board environment.
- Report any skipped validation or remaining TODO annotations in the review notes.
-184
View File
@@ -1,184 +0,0 @@
---
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
## Operating Constraints
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
## Execution Steps
### 1. Initialize Analysis Context
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure)
Load only the minimal necessary context from each artifact:
**From spec.md:**
- Overview/Context
- Functional Requirements
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
- User Stories
- Edge Cases (if present)
**From plan.md:**
- Architecture/stack choices
- Data Model references
- Phases
- Technical constraints
**From tasks.md:**
- Task IDs
- Descriptions
- Phase grouping
- Parallel markers [P]
- Referenced file paths
**From constitution:**
- Load `.specify/memory/constitution.md` for principle validation
### 3. Build Semantic Models
Create internal representations (do not include raw artifacts in output):
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
- **User story/action inventory**: Discrete user actions with acceptance criteria
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
### 4. Detection Passes (Token-Efficient Analysis)
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
#### A. Duplication Detection
- Identify near-duplicate requirements
- Mark lower-quality phrasing for consolidation
#### B. Ambiguity Detection
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
#### C. Underspecification
- Requirements with verbs but missing object or measurable outcome
- User stories missing acceptance criteria alignment
- Tasks referencing files or components not defined in spec/plan
#### D. Constitution Alignment
- Any requirement or plan element conflicting with a MUST principle
- Missing mandated sections or quality gates from constitution
#### E. Coverage Gaps
- Requirements with zero associated tasks
- Tasks with no mapped requirement/story
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
#### F. Inconsistency
- Terminology drift (same concept named differently across files)
- Data entities referenced in plan but absent in spec (or vice versa)
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
### 5. Severity Assignment
Use this heuristic to prioritize findings:
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
### 6. Produce Compact Analysis Report
Output a Markdown report (no file writes) with the following structure:
## Specification Analysis Report
| ID | Category | Severity | Location(s) | Summary | Recommendation |
| --- | ----------- | -------- | ---------------- | ---------------------------- | ------------------------------------ |
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
(Add one row per finding; generate stable IDs prefixed by category initial.)
**Coverage Summary Table:**
| Requirement Key | Has Task? | Task IDs | Notes |
| --------------- | --------- | -------- | ----- |
**Constitution Alignment Issues:** (if any)
**Unmapped Tasks:** (if any)
**Metrics:**
- Total Requirements
- Total Tasks
- Coverage % (requirements with >=1 task)
- Ambiguity Count
- Duplication Count
- Critical Issues Count
### 7. Provide Next Actions
At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
### 8. Offer Remediation
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
## Operating Principles
### Context Efficiency
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
### Analysis Guidelines
- **NEVER modify files** (this is read-only analysis)
- **NEVER hallucinate missing sections** (if absent, report them accurately)
- **Prioritize constitution violations** (these are always CRITICAL)
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
- **Report zero issues gracefully** (emit success report with coverage statistics)
## Context
$ARGUMENTS
-295
View File
@@ -1,295 +0,0 @@
---
description: Generate a custom checklist for the current feature based on user requirements.
---
## Checklist Purpose: "Unit Tests for English"
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
**NOT for verification/testing**:
- ❌ NOT "Verify the button clicks correctly"
- ❌ NOT "Test error handling works"
- ❌ NOT "Confirm the API returns 200"
- ❌ NOT checking if code/implementation matches the spec
**FOR requirements quality validation**:
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Execution Steps
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
- All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
- Only ask about information that materially changes checklist content
- Be skipped individually if already unambiguous in `$ARGUMENTS`
- Prefer precision over breadth
Generation algorithm:
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
5. Formulate questions chosen from these archetypes:
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
Question formatting rules:
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
- Limit to A–E options maximum; omit table if a free-form answer is clearer
- Never ask the user to restate what they already said
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
Defaults when interaction impossible:
- Depth: Standard
- Audience: Reviewer (PR) if code-related; Author otherwise
- Focus: Top 2 relevance clusters
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
- Derive checklist theme (e.g., security, review, deploy, ux)
- Consolidate explicit must-have items mentioned by user
- Map focus selections to category scaffolding
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
4. **Load feature context**: Read from FEATURE_DIR:
- spec.md: Feature requirements and scope
- plan.md (if exists): Technical details, dependencies
- tasks.md (if exists): Implementation tasks
**Context Loading Strategy**:
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
- Prefer summarizing long sections into concise scenario/requirement bullets
- Use progressive disclosure: add follow-on retrieval only if gaps detected
- If source docs are large, generate interim summary items instead of embedding raw text
5. **Generate checklist** - Create "Unit Tests for Requirements":
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
- Generate unique checklist filename:
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
- Format: `[domain].md`
- File handling behavior:
- If file does NOT exist: Create new file and number items starting from CHK001
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
- Never delete or replace existing checklist content - always preserve and append
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
- **Completeness**: Are all necessary requirements present?
- **Clarity**: Are requirements unambiguous and specific?
- **Consistency**: Do requirements align with each other?
- **Measurability**: Can requirements be objectively verified?
- **Coverage**: Are all scenarios/edge cases addressed?
**Category Structure** - Group items by requirement quality dimensions:
- **Requirement Completeness** (Are all necessary requirements documented?)
- **Requirement Clarity** (Are requirements specific and unambiguous?)
- **Requirement Consistency** (Do requirements align without conflicts?)
- **Acceptance Criteria Quality** (Are success criteria measurable?)
- **Scenario Coverage** (Are all flows/cases addressed?)
- **Edge Case Coverage** (Are boundary conditions defined?)
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
- **Dependencies & Assumptions** (Are they documented and validated?)
- **Ambiguities & Conflicts** (What needs clarification?)
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
❌ **WRONG** (Testing implementation):
- "Verify landing page displays 3 episode cards"
- "Test hover states work on desktop"
- "Confirm logo click navigates home"
✅ **CORRECT** (Testing requirements quality):
- "Are the exact number and layout of featured episodes specified?" [Completeness]
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
- "Are loading states defined for asynchronous episode data?" [Completeness]
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
**ITEM STRUCTURE**:
Each item should follow this pattern:
- Question format asking about requirement quality
- Focus on what's WRITTEN (or not written) in the spec/plan
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
- Reference spec section `[Spec §X.Y]` when checking existing requirements
- Use `[Gap]` marker when checking for missing requirements
**EXAMPLES BY QUALITY DIMENSION**:
Completeness:
- "Are error handling requirements defined for all API failure modes? [Gap]"
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
Clarity:
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
Consistency:
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
Coverage:
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
Measurability:
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
**Scenario Classification & Coverage** (Requirements Quality Focus):
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
**Traceability Requirements**:
- MINIMUM: ≥80% of items MUST include at least one traceability reference
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
**Surface & Resolve Issues** (Requirements Quality Problems):
Ask questions about the requirements themselves:
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
**Content Consolidation**:
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
- Merge near-duplicates checking the same requirement aspect
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
- ❌ References to code execution, user actions, system behavior
- ❌ "Displays correctly", "works properly", "functions as expected"
- ❌ "Click", "navigate", "render", "load", "execute"
- ❌ Test cases, test plans, QA procedures
- ❌ Implementation details (frameworks, APIs, algorithms)
**✅ REQUIRED PATTERNS** - These test requirements quality:
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
- ✅ "Are requirements consistent between [section A] and [section B]?"
- ✅ "Can [requirement] be objectively measured/verified?"
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
- ✅ "Does the spec define [missing aspect]?"
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
- Focus areas selected
- Depth level
- Actor/timing
- Any explicit user-specified must-have items incorporated
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
- Simple, memorable filenames that indicate checklist purpose
- Easy identification and navigation in the `checklists/` folder
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
## Example Checklist Types & Sample Items
**UX Requirements Quality:** `ux.md`
Sample items (testing the requirements, NOT the implementation):
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
**API Requirements Quality:** `api.md`
Sample items:
- "Are error response formats specified for all failure scenarios? [Completeness]"
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
- "Are authentication requirements consistent across all endpoints? [Consistency]"
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
- "Is versioning strategy documented in requirements? [Gap]"
**Performance Requirements Quality:** `performance.md`
Sample items:
- "Are performance requirements quantified with specific metrics? [Clarity]"
- "Are performance targets defined for all critical user journeys? [Coverage]"
- "Are performance requirements under different load conditions specified? [Completeness]"
- "Can performance requirements be objectively measured? [Measurability]"
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
**Security Requirements Quality:** `security.md`
Sample items:
- "Are authentication requirements specified for all protected resources? [Coverage]"
- "Are data protection requirements defined for sensitive information? [Completeness]"
- "Is the threat model documented and requirements aligned to it? [Traceability]"
- "Are security requirements consistent with compliance obligations? [Consistency]"
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
## Anti-Examples: What NOT To Do
**❌ WRONG - These test implementation, not requirements:**
```markdown
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
```
**✅ CORRECT - These test requirements quality:**
```markdown
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
```
**Key Differences:**
- Wrong: Tests if the system works correctly
- Correct: Tests if the requirements are written correctly
- Wrong: Verification of behavior
- Correct: Validation of requirement quality
- Wrong: "Does it do X?"
- Correct: "Is X clearly specified?"
-181
View File
@@ -1,181 +0,0 @@
---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps:
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
Functional Scope & Behavior:
- Core user goals & success criteria
- Explicit out-of-scope declarations
- User roles / personas differentiation
Domain & Data Model:
- Entities, attributes, relationships
- Identity & uniqueness rules
- Lifecycle/state transitions
- Data volume / scale assumptions
Interaction & UX Flow:
- Critical user journeys / sequences
- Error/empty/loading states
- Accessibility or localization notes
Non-Functional Quality Attributes:
- Performance (latency, throughput targets)
- Scalability (horizontal/vertical, limits)
- Reliability & availability (uptime, recovery expectations)
- Observability (logging, metrics, tracing signals)
- Security & privacy (authN/Z, data protection, threat assumptions)
- Compliance / regulatory constraints (if any)
Integration & External Dependencies:
- External services/APIs and failure modes
- Data import/export formats
- Protocol/versioning assumptions
Edge Cases & Failure Handling:
- Negative scenarios
- Rate limiting / throttling
- Conflict resolution (e.g., concurrent edits)
Constraints & Tradeoffs:
- Technical constraints (language, storage, hosting)
- Explicit tradeoffs or rejected alternatives
Terminology & Consistency:
- Canonical glossary terms
- Avoided synonyms / deprecated terms
Completion Signals:
- Acceptance criteria testability
- Measurable Definition of Done style indicators
Misc / Placeholders:
- TODO markers / unresolved decisions
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
For each category with Partial or Missing status, add a candidate question opportunity unless:
- Clarification would not materially change implementation or validation strategy
- Information is better deferred to planning phase (note internally)
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
- Maximum of 5 total questions across the whole session.
- Each question must be answerable with EITHER:
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
- If more than 5 categories remain unresolved, select the top 5 by (Impact \* Uncertainty) heuristic.
4. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- For multiple‑choice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
- Common patterns in similar implementations
- Risk reduction (security, performance, maintainability)
- Alignment with any explicit project goals or constraints visible in the spec
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
- Format as: `**Recommended:** Option [X] - <reasoning>`
- Then render all options as a Markdown table:
| Option | Description |
| ------ | --------------------------------------------------------------------------------------------------- |
| A | <Option A description> |
| B | <Option B description> |
| C | <Option C description> (add D/E as needed up to 5) |
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
- For short‑answer style (no meaningful discrete options):
- Provide your **suggested answer** based on best practices and context.
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
- After the user answers:
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
- Stop asking further questions when:
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
- User signals completion ("done", "good", "no more"), OR
- You reach 5 asked questions.
- Never reveal future queued questions in advance.
- If no valid questions exist at start, immediately report no critical ambiguities.
5. Integration after EACH accepted answer (incremental update approach):
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
- For the first integrated answer in this session:
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
- Then immediately apply the clarification to the most appropriate section(s):
- Functional ambiguity → Update or add a bullet in Functional Requirements.
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
- Keep each inserted clarification minimal and testable (avoid narrative drift).
6. Validation (performed after EACH write plus final pass):
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
- Total asked (accepted) questions ≤ 5.
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
- Terminology consistency: same canonical term used across all updated sections.
7. Write the updated spec back to `FEATURE_SPEC`.
8. Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
- Suggested next command.
Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
Context for prioritization: $ARGUMENTS
@@ -1,84 +0,0 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
Follow this execution flow:
1. Load the existing constitution at `.specify/memory/constitution.md`.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
2. Collect/derive values for placeholders:
- If user input (conversation) supplies a value, use it.
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
- MINOR: New principle/section added or materially expanded guidance.
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Consistency propagation checklist (convert prior checklist into active validations):
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
- Follow-up TODOs if any placeholders intentionally deferred.
6. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
8. Output a final summary to the user with:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
Formatting & Style Requirements:
- Use Markdown headings exactly as in the template (do not demote/promote levels).
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
- Keep a single blank line between sections.
- Avoid trailing whitespace.
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
-207
View File
@@ -1,207 +0,0 @@
---
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before implementation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_implement` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
- Scan all checklist files in the checklists/ directory
- For each checklist, count:
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
- Completed items: Lines matching `- [X]` or `- [x]`
- Incomplete items: Lines matching `- [ ]`
- Create a status table:
```text
| Checklist | Total | Completed | Incomplete | Status |
|-----------|-------|-----------|------------|--------|
| ux.md | 12 | 12 | 0 | ✓ PASS |
| test.md | 8 | 5 | 3 | ✗ FAIL |
| security.md | 6 | 6 | 0 | ✓ PASS |
```
- Calculate overall status:
- **PASS**: All checklists have 0 incomplete items
- **FAIL**: One or more checklists have incomplete items
- **If any checklist is incomplete**:
- Display the table with incomplete item counts
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
- Wait for user response before continuing
- If user says "no" or "wait" or "stop", halt execution
- If user says "yes" or "proceed" or "continue", proceed to step 3
- **If all checklists are complete**:
- Display the table showing all checklists passed
- Automatically proceed to step 3
3. Load and analyze the implementation context:
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
4. **Project Setup Verification**:
- **REQUIRED**: Create/verify ignore files based on actual project setup:
**Detection & Creation Logic**:
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
```sh
git rev-parse --git-dir 2>/dev/null
```
- Check if Dockerfile\* exists or Docker in plan.md → create/verify .dockerignore
- Check if .eslintrc\* exists → create/verify .eslintignore
- Check if eslint.config.\* exists → ensure the config's `ignores` entries cover required patterns
- Check if .prettierrc\* exists → create/verify .prettierignore
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
- Check if terraform files (\*.tf) exist → create/verify .terraformignore
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
**If ignore file missing**: Create with full pattern set for detected technology
**Common Patterns by Technology** (from plan.md tech stack):
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
**Tool-Specific Patterns**:
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
5. Parse tasks.md structure and extract:
- **Task phases**: Setup, Tests, Core, Integration, Polish
- **Task dependencies**: Sequential vs parallel execution rules
- **Task details**: ID, description, file paths, parallel markers [P]
- **Execution flow**: Order and dependency requirements
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services
- **Polish and validation**: Unit tests, performance optimization, documentation
8. Progress tracking and error handling:
- Report progress after each completed task
- Halt execution if any non-parallel task fails
- For parallel tasks [P], continue with successful tasks, report failed ones
- Provide clear error messages with context for debugging
- Suggest next steps if implementation cannot proceed
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
9. Completion validation:
- Verify all required tasks are completed
- Check that implemented features match the original specification
- Validate that tests pass and coverage meets requirements
- Confirm the implementation follows the technical plan
- Report final status with summary of completed work
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_implement` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
-162
View File
@@ -1,162 +0,0 @@
---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks
send: true
- label: Create Checklist
agent: speckit.checklist
prompt: Create a checklist for the following domain...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before planning)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_plan` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
- Fill Constitution Check section from constitution
- Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md
- Phase 1: Update agent context by running the agent script
- Re-evaluate Constitution Check post-design
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_plan` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Phases
### Phase 0: Outline & Research
1. **Extract unknowns from Technical Context** above:
- For each NEEDS CLARIFICATION → research task
- For each dependency → best practices task
- For each integration → patterns task
2. **Generate and dispatch research agents**:
```text
For each unknown in Technical Context:
Task: "Research {unknown} for {feature context}"
For each technology choice:
Task: "Find best practices for {tech} in {domain}"
```
3. **Consolidate findings** in `research.md` using format:
- Decision: [what was chosen]
- Rationale: [why chosen]
- Alternatives considered: [what else evaluated]
**Output**: research.md with all NEEDS CLARIFICATION resolved
### Phase 1: Design & Contracts
**Prerequisites:** `research.md` complete
1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
- Identify what interfaces the project exposes to users or other systems
- Document the contract format appropriate for the project type
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh copilot`
- These scripts detect which AI agent is in use
- Update the appropriate agent-specific context file
- Add only new technology from current plan
- Preserve manual additions between markers
**Output**: data-model.md, /contracts/\*, quickstart.md, agent-specific file
## Key rules
- Use absolute paths
- ERROR on gate failures or unresolved clarifications
-313
View File
@@ -1,313 +0,0 @@
---
description: Create or update the feature specification from a natural language feature description.
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
- label: Clarify Spec Requirements
agent: speckit.clarify
prompt: Clarify specification requirements
send: true
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before specification)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this:
1. **Generate a concise short name** (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Create a 2-4 word short name that captures the essence of the feature
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
- Keep it concise but descriptive enough to understand the feature at a glance
- Examples:
- "I want to add user authentication" → "user-auth"
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
- "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout"
2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
**Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
- If `"sequential"` or absent, do not add any extra flag (default behavior)
- Bash example: `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" --json --short-name "user-auth" "Add user authentication"`
- Bash (timestamp): `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" --json --timestamp --short-name "user-auth" "Add user authentication"`
- PowerShell example: `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" -Json -ShortName "user-auth" "Add user authentication"`
- PowerShell (timestamp): `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
3. Load `.specify/templates/spec-template.md` to understand required sections.
4. Follow this execution flow:
1. Parse user description from Input
If empty: ERROR "No feature description provided"
2. Extract key concepts from description
Identify: actors, actions, data, constraints
3. For unclear aspects:
- Make informed guesses based on context and industry standards
- Only mark with [NEEDS CLARIFICATION: specific question] if:
- The choice significantly impacts feature scope or user experience
- Multiple reasonable interpretations exist with different implications
- No reasonable default exists
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
4. Fill User Scenarios & Testing section
If no clear user flow: ERROR "Cannot determine user scenarios"
5. Generate Functional Requirements
Each requirement must be testable
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
6. Define Success Criteria
Create measurable, technology-agnostic outcomes
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
Each criterion must be verifiable without implementation details
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
- [ ] Success criteria are technology-agnostic (no implementation details)
- [ ] All acceptance scenarios are defined
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
```
b. **Run Validation Check**: Review the spec against each checklist item:
- For each item, determine if it passes or fails
- Document specific issues found (quote relevant spec sections)
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to step 7
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues
2. Update the spec to address each issue
3. Re-run validation until all items pass (max 3 iterations)
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
- **If [NEEDS CLARIFICATION] markers remain**:
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
3. For each clarification needed (max 3), present options to user in this format:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
| ------ | ------------------------- | ------------------------------------- |
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
- Use consistent spacing with pipes aligned
- Each cell should have spaces around content: `| Content |` not `|Content|`
- Header separator must have at least 3 dashes: `|--------|`
- Test that the table renders correctly in markdown preview
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
6. Present all questions together before waiting for responses
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
9. Re-run validation after all clarifications are resolved
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
## Quick Guidelines
- Focus on **WHAT** users need and **WHY**.
- Avoid HOW to implement (no tech stack, APIs, code structure).
- Written for business stakeholders, not developers.
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
### Section Requirements
- **Mandatory sections**: Must be completed for every feature
- **Optional sections**: Include only when relevant to the feature
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
### For AI Generation
When creating this spec from a user prompt:
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
- Significantly impact feature scope or user experience
- Have multiple reasonable interpretations with different implications
- Lack any reasonable default
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
6. **Common areas needing clarification** (only if no reasonable default exists):
- Feature scope and boundaries (include/exclude specific use cases)
- User types and permissions (if multiple conflicting interpretations possible)
- Security/compliance requirements (when legally/financially significant)
**Examples of reasonable defaults** (don't ask about these):
- Data retention: Industry-standard practices for the domain
- Performance targets: Standard web/mobile app expectations unless specified
- Error handling: User-friendly messages with appropriate fallbacks
- Authentication method: Standard session-based or OAuth2 for web apps
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
### Success Criteria Guidelines
Success criteria must be:
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
4. **Verifiable**: Can be tested/validated without knowing implementation details
**Good examples**:
- "Users can complete checkout in under 3 minutes"
- "System supports 10,000 concurrent users"
- "95% of searches return results in under 1 second"
- "Task completion rate improves by 40%"
**Bad examples** (implementation-focused):
- "API response time is under 200ms" (too technical, use "Users see results instantly")
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
- "React components render efficiently" (framework-specific)
- "Redis cache hit rate above 80%" (technology-specific)
-209
View File
@@ -1,209 +0,0 @@
---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs:
- label: Analyze For Consistency
agent: speckit.analyze
prompt: Run a project analysis for consistency
send: true
- label: Implement Project
agent: speckit.implement
prompt: Start the implementation in phases
send: true
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before tasks generation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_tasks` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
- Note: Not all projects have all documents. Generate tasks based on what's available.
3. **Execute task generation workflow**:
- Load plan.md and extract tech stack, libraries, project structure
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
- If data-model.md exists: Extract entities and map to user stories
- If contracts/ exists: Map interface contracts to user stories
- If research.md exists: Extract decisions for setup tasks
- Generate tasks organized by user story (see Task Generation Rules below)
- Generate dependency graph showing user story completion order
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
- Phase 3+: One phase per user story (in priority order from spec.md)
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
- Final Phase: Polish & cross-cutting concerns
- All tasks must follow the strict checklist format (see Task Generation Rules below)
- Clear file paths for each task
- Dependencies section showing story completion order
- Parallel execution examples per story
- Implementation strategy section (MVP first, incremental delivery)
5. **Report**: Output path to generated tasks.md and summary:
- Total task count
- Task count per user story
- Parallel opportunities identified
- Independent test criteria for each story
- Suggested MVP scope (typically just User Story 1)
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_tasks` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
Context for task generation: $ARGUMENTS
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
## Task Generation Rules
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
### Checklist Format (REQUIRED)
Every task MUST strictly follow this format:
```text
- [ ] [TaskID] [P?] [Story?] Description with file path
```
**Format Components**:
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path
**Examples**:
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
### Task Organization
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
- Each user story (P1, P2, P3...) gets its own phase
- Map all related components to their story:
- Models needed for that story
- Services needed for that story
- Interfaces/UI needed for that story
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
- Map each interface contract → to the user story it serves
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
3. **From Data Model**:
- Map each entity to the user story(ies) that need it
- If entity serves multiple stories: Put in earliest story or Setup phase
- Relationships → service layer tasks in appropriate story phase
4. **From Setup/Infrastructure**:
- Shared infrastructure → Setup phase (Phase 1)
- Foundational/blocking tasks → Foundational phase (Phase 2)
- Story-specific setup → within that story's phase
### Phase Structure
- **Phase 1**: Setup (project initialization)
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
- Each phase should be a complete, independently testable increment
- **Final Phase**: Polish & Cross-Cutting Concerns
@@ -1,30 +0,0 @@
---
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
tools: ["github/github-mcp-server/issue_write"]
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. From the executed script, extract the path to **tasks**.
1. Get the Git remote by running:
```bash
git config --get remote.origin.url
```
> [!CAUTION]
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
> [!CAUTION]
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
+95
View File
@@ -135,6 +135,99 @@ On top of authorization, any remote admin message that **mutates** state (not a
- **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.
## NodeDB Layout (v25)
`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now — the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`.
### Slim `NodeInfoLite`
`UserLite` is flattened onto `NodeInfoLite` (no nested sub-message); `position` and `device_metrics` are removed entirely (tags reserved). MAC address is dropped. Long names are capped at 25 chars (`max_size:25` in `deviceonly.options`); `hw_model` and `role` are `int_size:8`. Encoded size dropped from ~166 B → ~105 B per node.
Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** — use the inline helpers in `src/mesh/NodeDB.h`:
```cpp
nodeInfoLiteHasUser(n) // bit 5 — user fields populated
nodeInfoLiteIsFavorite(n) // bit 3
nodeInfoLiteIsIgnored(n) // bit 4
nodeInfoLiteIsMuted(n) // bit 1
nodeInfoLiteIsLicensed(n) // bit 6 — Ham mode peer
nodeInfoLiteIsKeyManuallyVerified(n) // bit 0
nodeInfoLiteHasIsUnmessagable(n) // bit 8 — "is_unmessagable was sent"
nodeInfoLiteIsUnmessagable(n) // bit 7
// via_mqtt is bit 2 (mask exposed; predicate uses the mask directly)
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); // setter
```
### Satellite stores
Four `std::unordered_map<NodeNum, …>` members on `NodeDB`, each gated by its own build flag:
| Map | Value type | Build flag |
| ----------------- | ------------------------------- | ---------------------------------- |
| `nodePositions` | `meshtastic_PositionLite` | `MESHTASTIC_EXCLUDE_POSITIONDB` |
| `nodeTelemetry` | `meshtastic_DeviceMetrics` | `MESHTASTIC_EXCLUDE_TELEMETRYDB` |
| `nodeEnvironment` | `meshtastic_EnvironmentMetrics` | `MESHTASTIC_EXCLUDE_ENVIRONMENTDB` |
| `nodeStatus` | `meshtastic_StatusMessage` | `MESHTASTIC_EXCLUDE_STATUSDB` |
Defaults are ON (i.e., maps **excluded**) for STM32WL only — see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`.
All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** — concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception.
### Accessor convention
**Never hand out pointers into the maps.** Use the copy-out accessors on `NodeDB`:
```cpp
bool copyNodePosition(NodeNum, meshtastic_PositionLite &out) const;
bool copyNodeTelemetry(NodeNum, meshtastic_DeviceMetrics &out) const;
bool copyNodeEnvironment(NodeNum, meshtastic_EnvironmentMetrics &out) const;
bool copyNodeStatus(NodeNum, meshtastic_StatusMessage &out) const;
```
Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate — pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these.
Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) — these own the lock and the eviction hooks.
### Eviction
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly.
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
There is no capability flag and no special "gradient" nonce. The **default** sync flow is:
1. Config / module-config / channel / metadata segments (same as before).
2. `STATE_SEND_OWN_NODEINFO` — **our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`.
3. `STATE_SEND_OTHER_NODEINFOS` — every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`.
4. `STATE_SEND_FILEMANIFEST` → `STATE_SEND_COMPLETE_ID` — the phone sees `config_complete_id` and treats sync as done.
5. `STATE_SEND_PACKETS` — live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling — any code that already updates UI on `POSITION_APP` etc. works.
`PhoneAPI::sendConfigComplete()` arms `replayPhase = REPLAY_PHASE_POSITIONS` for default/full sync and `SPECIAL_NONCE_ONLY_NODES`, while `SPECIAL_NONCE_ONLY_CONFIG` skips replay. The drain runs inside `STATE_SEND_PACKETS` via `popReplayPacket()`, lower priority than live traffic. When all four phases drain, `replayPhase` flips back to `REPLAY_PHASE_IDLE` and the snapshot vectors get `shrink_to_fit`ed.
STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets — `popReplayPacket` advances through each phase in microseconds without emitting anything.
Special nonces that still mean something:
- `SPECIAL_NONCE_ONLY_CONFIG` (69420) — skip node sync entirely, just config.
- `SPECIAL_NONCE_ONLY_NODES` (69421) — skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain.
There are no other reserved nonces; everything else is a fresh random `want_config_id` from the client.
### v24 → v25 migration
The legacy migration code lives in **`src/mesh/NodeDBLegacyMigration.cpp`**, not in `NodeDB.cpp`. It owns the `meshtastic_NodeDatabase_Legacy` callback and `NodeDB::migrateLegacyNodeDatabase()`. The legacy proto descriptor is `protobufs/meshtastic/deviceonly_legacy.proto` (only included by the migration TU). The boot path peeks the file's leading version tag, runs the migration if `version < 25`, then re-saves in v25 layout. The legacy descriptor is scheduled for removal once `DEVICESTATE_MIN_VER` is bumped.
### Read-site rules of thumb
- Never `node->position.X` / `node->device_metrics.X` — those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`.
- Never `node->user.long_name` — `long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`.
- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` — use the bitfield helpers.
- Never assume `nodeDB->getMeshNode(num)->position.time` — call `copyNodePosition` and check the return.
- Don't lock `satelliteMutex` yourself in renderer code; the copy-out accessors already do.
Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) — bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema.
## Project Structure
```
@@ -196,6 +289,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
@@ -1,3 +0,0 @@
---
agent: hardware-support
---
@@ -1,3 +0,0 @@
---
agent: speckit.analyze
---
@@ -1,3 +0,0 @@
---
agent: speckit.checklist
---
@@ -1,3 +0,0 @@
---
agent: speckit.clarify
---
@@ -1,3 +0,0 @@
---
agent: speckit.constitution
---
@@ -1,3 +0,0 @@
---
agent: speckit.implement
---
-3
View File
@@ -1,3 +0,0 @@
---
agent: speckit.plan
---
@@ -1,3 +0,0 @@
---
agent: speckit.specify
---
-3
View File
@@ -1,3 +0,0 @@
---
agent: speckit.tasks
---
@@ -1,3 +0,0 @@
---
agent: speckit.taskstoissues
---
+39 -25
View File
@@ -4,9 +4,14 @@ on:
workflow_dispatch:
inputs:
# trunk-ignore(checkov/CKV_GHA_7)
target:
type: string
required: false
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
arch:
type: choice
options:
- all
- esp32
- esp32s3
- esp32c3
@@ -15,32 +20,18 @@ on:
- rp2040
- rp2350
- stm32
target:
type: string
required: false
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
# find-target:
# type: boolean
# default: true
# description: 'Find the available targets'
description: Choose an arch to limit the search, or 'all' to search all architectures.
default: all
permissions: read-all
jobs:
find-targets:
if: ${{ inputs.target == '' }}
strategy:
fail-fast: false
matrix:
arch:
- esp32
- esp32s3
- esp32c3
- esp32c6
- nrf52840
- rp2040
- rp2350
- stm32
- all
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -51,14 +42,37 @@ jobs:
- run: pip install -U platformio
- name: Generate matrix
id: jsonStep
env:
BUILDTARGET: ${{ inputs.target }}
MATRIXARCH: ${{ inputs.arch }}
run: |
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
echo "Targets:" >> $GITHUB_STEP_SUMMARY
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY
if [ "$BUILDTARGET" = "" ]; then
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY
else
echo "We build this one:" >> $GITHUB_STEP_SUMMARY
ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform')
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "$ARCH" == "" ]]; then
echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY
else
echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY
fi
echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY
echo "arch=$ARCH" >> $GITHUB_OUTPUT
fi
outputs:
arch: ${{ steps.jsonStep.outputs.arch }}
version:
if: ${{ inputs.target != '' }}
@@ -78,12 +92,12 @@ jobs:
build:
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
needs: [version]
needs: [version, find-targets]
uses: ./.github/workflows/build_firmware.yml
with:
version: ${{ needs.version.outputs.long }}
pio_env: ${{ inputs.target }}
platform: ${{ inputs.arch }}
platform: ${{ needs.find-targets.outputs.arch }}
gather-artifacts:
permissions:
+7 -1
View File
@@ -86,7 +86,13 @@ jobs:
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
- name: PlatformIO Tests
run: platformio test -e coverage -v --junit-output-path testreport.xml
run: |
set -o pipefail
# Filter out SKIPPED summary rows for hardware variants that can't run on the
# native host. They flood the log and make it harder to spot real failures.
# The JUnit XML is written directly to testreport.xml before the pipe, so
# the test artifact is unaffected.
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
- name: Save test results
if: always() # run this step even if previous step failed
+6
View File
@@ -56,3 +56,9 @@ CMakeLists.txt
.python3
.claude/scheduled_tasks.lock
userPrefs.jsonc.mcp-session-bak
# Fake-NodeDB fixture pipeline (bin/regen-fake-nodedbs.sh)
# JSONL seeds are committed (test/fixtures/nodedb/seed_v25_*.jsonl);
# compiled .proto outputs are ephemeral build artifacts.
build/fixtures/
bin/_generated/
-11
View File
@@ -1,11 +0,0 @@
{
"ai": "copilot",
"ai_commands_dir": null,
"ai_skills": false,
"branch_numbering": "sequential",
"here": true,
"offline": false,
"preset": null,
"script": "sh",
"speckit_version": "0.4.2"
}
-136
View File
@@ -1,136 +0,0 @@
<!--
Sync Impact Report
Version change: template -> 1.0.0
Modified principles:
- [PRINCIPLE_1_NAME] -> I. Safety-Critical Mesh Behavior
- [PRINCIPLE_2_NAME] -> II. Variant-Scoped Hardware Truth
- [PRINCIPLE_3_NAME] -> III. Verification by Targeted Evidence
- [PRINCIPLE_4_NAME] -> IV. Resource and Power Discipline
- [PRINCIPLE_5_NAME] -> V. Minimal, Reviewable Change Sets
Added sections:
- Engineering Constraints
- Delivery Workflow
Removed sections:
- None
Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md
- ⚠ pending .specify/templates/checklist-template.md (not required for current constitution alignment)
- ⚠ pending .specify/templates/agent-file-template.md (generic scaffold, no constitution-specific drift found)
Follow-up TODOs:
- None
-->
# Meshtastic Firmware Constitution
## Core Principles
### I. Safety-Critical Mesh Behavior
All feature and bug-fix work MUST preserve safe, predictable behavior on live mesh networks.
Changes that affect routing, airtime, broadcast intervals, MQTT bridging, channel handling,
or packet processing MUST document the operational impact on shared bandwidth, public-channel
abuse protections, and interoperability. Any relaxation of rate limits, encryption behavior,
or default-channel safeguards MUST be treated as a breaking governance change unless explicitly
approved and justified.
Rationale: Meshtastic devices operate in constrained radio environments where seemingly small
behavior changes can degrade network reliability, privacy, and fairness for other nodes.
### II. Variant-Scoped Hardware Truth
Board definitions, platform conditionals, and peripheral flags MUST reflect verified hardware
truth and MUST remain scoped to the exact supported target. New capabilities in `variant.h`,
`platformio.ini`, `pins_arduino.h`, or related board files MUST be backed by pin mappings,
chip selection, and power assumptions that are consistent with the board design. Cross-board
copying is prohibited unless every reused define is revalidated for the destination variant.
Rationale: This firmware spans many architectures and board revisions; incorrect hardware
declarations create silent regressions that are hard to detect until devices are flashed.
### III. Verification by Targeted Evidence
Every change MUST be validated by the smallest credible evidence that matches its risk.
At minimum, contributors MUST run formatting or static validation for touched files and MUST
run a targeted build, test, or simulation path for the affected platform when feasible.
Changes to shared core logic, protobufs, routing, or configuration defaults SHOULD include a
native test, simulator run, or equivalent cross-target evidence. If validation cannot be run,
the gap MUST be stated explicitly in the plan, tasks, and final review.
Rationale: The repository supports many targets, so quality depends on explicit validation
rather than assumptions that one successful build implies system-wide safety.
### IV. Resource and Power Discipline
Implementations MUST respect embedded constraints for memory, flash, CPU, battery, and radio
duty cycle. New dependencies, background tasks, logging, polling, display work, and peripheral
power use MUST be justified against the target hardware footprint. Defaults MUST prefer safe
operation on constrained devices, and network-facing behavior MUST account for scaling with
node count where existing project patterns provide that mechanism.
Rationale: Meshtastic firmware runs on low-power devices where unnecessary work directly harms
battery life, responsiveness, thermal behavior, and mesh capacity.
### V. Minimal, Reviewable Change Sets
Changes MUST solve the root problem with the smallest coherent diff that fits the existing
architecture and coding patterns. Unrelated refactors, opportunistic renames, and speculative
abstractions are prohibited in the same change unless they are required to make the fix safe.
Public behavior, configuration semantics, and generated artifacts MUST remain stable unless the
specification and plan explicitly call out the intended change.
Rationale: Small, scoped changes are easier to review across board variants and reduce the
risk of hidden regressions in a large multi-platform firmware repository.
## Engineering Constraints
The authoritative implementation context for this repository is `.github/copilot-instructions.md`.
Plans and tasks MUST align with the existing PlatformIO-based build system, generated protobuf
workflow, architecture-specific source layout, and hardware-variant structure already used in
the repository.
Feature work MUST honor these constraints:
- Code MUST follow existing logging, naming, threading, and configuration patterns.
- Default values and user-facing configuration changes MUST use existing `Default` helpers and
public-channel safeguards where applicable.
- Protobuf changes MUST include regeneration steps and identify downstream effects in generated
code and dependent modules.
- Build and validation steps MUST prefer repository-standard commands such as targeted `pio run`,
`pio test -e native`, simulator tooling, and `trunk fmt` where applicable.
- Platform-specific logic MUST be isolated to the narrowest valid architecture or variant scope.
## Delivery Workflow
Spec-driven work in this repository MUST produce artifacts that make operational risk visible
before code is written.
- Specifications MUST describe affected user or device behavior, impacted platforms, and edge
cases for unavailable peripherals, misconfigured variants, and constrained-network scenarios.
- Plans MUST include a Constitution Check that names the exact validation evidence, target
environments, and any justified deviations from the constitution.
- Tasks MUST be organized so that foundational hardware, protocol, or configuration work is
completed before feature-specific behavior that depends on it.
- Review and implementation notes MUST call out any skipped validation, generated-file updates,
migration considerations, or behavior changes requiring maintainer scrutiny.
## Governance
This constitution supersedes ad hoc workflow preferences for spec-driven work in this repository.
All plans, tasks, reviews, and implementation summaries MUST verify compliance with these
principles.
- Amendments MUST be documented in this file and reflected in dependent templates before new
work proceeds under the changed rule set.
- Versioning policy for this constitution follows semantic versioning.
MAJOR versions indicate removed or materially redefined principles.
MINOR versions indicate new principles, sections, or materially expanded guidance.
PATCH versions indicate clarifications, wording improvements, or non-semantic refinements.
- Compliance review is mandatory for every feature plan and code review. Any constitutional
violation MUST be listed in the plan's Complexity Tracking section or equivalent justification.
- Repository guidance files remain authoritative for implementation detail. Where conflict is
discovered, this constitution governs process and quality gates, while `.github/copilot-instructions.md`
governs repository-specific coding practice until the conflict is resolved by amendment.
**Version**: 1.0.0 | **Ratified**: 2026-03-25 | **Last Amended**: 2026-03-25
@@ -1,193 +0,0 @@
#!/usr/bin/env bash
# Consolidated prerequisite checking script
#
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
# It replaces the functionality previously spread across multiple scripts.
#
# Usage: ./check-prerequisites.sh [OPTIONS]
#
# OPTIONS:
# --json Output in JSON format
# --require-tasks Require tasks.md to exist (for implementation phase)
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
# --paths-only Only output path variables (no validation)
# --help, -h Show help message
#
# OUTPUTS:
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
set -e
# Parse command line arguments
JSON_MODE=false
REQUIRE_TASKS=false
INCLUDE_TASKS=false
PATHS_ONLY=false
for arg in "$@"; do
case "$arg" in
--json)
JSON_MODE=true
;;
--require-tasks)
REQUIRE_TASKS=true
;;
--include-tasks)
INCLUDE_TASKS=true
;;
--paths-only)
PATHS_ONLY=true
;;
--help | -h)
cat <<'EOF'
Usage: check-prerequisites.sh [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
--json Output in JSON format
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
./check-prerequisites.sh --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check-prerequisites.sh --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check-prerequisites.sh --paths-only
EOF
exit 0
;;
*)
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
exit 1
;;
esac
done
# Source common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths and validate branch
_paths_output=$(get_feature_paths) || {
echo "ERROR: Failed to resolve feature paths" >&2
exit 1
}
eval "$_paths_output"
unset _paths_output
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
if $PATHS_ONLY; then
if $JSON_MODE; then
# Minimal JSON paths payload (no validation performed)
if has_jq; then
jq -cn \
--arg repo_root "$REPO_ROOT" \
--arg branch "$CURRENT_BRANCH" \
--arg feature_dir "$FEATURE_DIR" \
--arg feature_spec "$FEATURE_SPEC" \
--arg impl_plan "$IMPL_PLAN" \
--arg tasks "$TASKS" \
'{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'
else
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")"
fi
else
echo "REPO_ROOT: $REPO_ROOT"
echo "BRANCH: $CURRENT_BRANCH"
echo "FEATURE_DIR: $FEATURE_DIR"
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "TASKS: $TASKS"
fi
exit 0
fi
# Validate required directories and files
if [[ ! -d $FEATURE_DIR ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
echo "Run /speckit.specify first to create the feature structure." >&2
exit 1
fi
if [[ ! -f $IMPL_PLAN ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.plan first to create the implementation plan." >&2
exit 1
fi
# Check for tasks.md if required
if $REQUIRE_TASKS && [[ ! -f $TASKS ]]; then
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.tasks first to create the task list." >&2
exit 1
fi
# Build list of available documents
docs=()
# Always check these optional docs
[[ -f $RESEARCH ]] && docs+=("research.md")
[[ -f $DATA_MODEL ]] && docs+=("data-model.md")
# Check contracts directory (only if it exists and has files)
if [[ -d $CONTRACTS_DIR ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
docs+=("contracts/")
fi
[[ -f $QUICKSTART ]] && docs+=("quickstart.md")
# Include tasks.md if requested and it exists
if $INCLUDE_TASKS && [[ -f $TASKS ]]; then
docs+=("tasks.md")
fi
# Output results
if $JSON_MODE; then
# Build JSON array of documents
if has_jq; then
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
fi
else
# Text output
echo "FEATURE_DIR:$FEATURE_DIR"
echo "AVAILABLE_DOCS:"
# Show status of each potential document
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
if $INCLUDE_TASKS; then
check_file "$TASKS" "tasks.md"
fi
fi
-329
View File
@@ -1,329 +0,0 @@
#!/usr/bin/env bash
# Common functions and variables for all scripts
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
find_specify_root() {
local dir="${1:-$(pwd)}"
# Normalize to absolute path to prevent infinite loop with relative paths
# Use -- to handle paths starting with - (e.g., -P, -L)
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
local prev_dir=""
while true; do
if [ -d "$dir/.specify" ]; then
echo "$dir"
return 0
fi
# Stop if we've reached filesystem root or dirname stops changing
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
break
fi
prev_dir="$dir"
dir="$(dirname "$dir")"
done
return 1
}
# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
get_repo_root() {
# First, look for .specify directory (spec-kit's own marker)
local specify_root
if specify_root=$(find_specify_root); then
echo "$specify_root"
return
fi
# Fallback to git if no .specify found
if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel
return
fi
# Final fallback to script location for non-git repos
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(cd "$script_dir/../../.." && pwd)
}
# Get current branch, with fallback for non-git repositories
get_current_branch() {
# First check if SPECIFY_FEATURE environment variable is set
if [[ -n ${SPECIFY_FEATURE-} ]]; then
echo "$SPECIFY_FEATURE"
return
fi
# Then check git if available at the spec-kit root (not parent)
local repo_root=$(get_repo_root)
if has_git; then
git -C "$repo_root" rev-parse --abbrev-ref HEAD
return
fi
# For non-git repos, try to find the latest feature directory
local specs_dir="$repo_root/specs"
if [[ -d $specs_dir ]]; then
local latest_feature=""
local highest=0
local latest_timestamp=""
for dir in "$specs_dir"/*; do
if [[ -d $dir ]]; then
local dirname=$(basename "$dir")
if [[ $dirname =~ ^([0-9]{8}-[0-9]{6})- ]]; then
# Timestamp-based branch: compare lexicographically
local ts="${BASH_REMATCH[1]}"
if [[ $ts > $latest_timestamp ]]; then
latest_timestamp="$ts"
latest_feature=$dirname
fi
elif [[ $dirname =~ ^([0-9]{3})- ]]; then
local number=${BASH_REMATCH[1]}
number=$((10#$number))
if [[ $number -gt $highest ]]; then
highest=$number
# Only update if no timestamp branch found yet
if [[ -z $latest_timestamp ]]; then
latest_feature=$dirname
fi
fi
fi
fi
done
if [[ -n $latest_feature ]]; then
echo "$latest_feature"
return
fi
fi
echo "main" # Final fallback
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
has_git() {
# First check if git command is available (before calling get_repo_root which may use git)
command -v git >/dev/null 2>&1 || return 1
local repo_root=$(get_repo_root)
# Check if .git exists (directory or file for worktrees/submodules)
[ -e "$repo_root/.git" ] || return 1
# Verify it's actually a valid git work tree
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
check_feature_branch() {
local branch="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ $has_git_repo != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
if [[ ! $branch =~ ^[0-9]{3}- ]] && [[ ! $branch =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
return 1
fi
return 0
}
get_feature_dir() { echo "$1/specs/$2"; }
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name="$2"
local specs_dir="$repo_root/specs"
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
local prefix=""
if [[ $branch_name =~ ^([0-9]{8}-[0-9]{6})- ]]; then
prefix="${BASH_REMATCH[1]}"
elif [[ $branch_name =~ ^([0-9]{3})- ]]; then
prefix="${BASH_REMATCH[1]}"
else
# If branch doesn't have a recognized prefix, fall back to exact match
echo "$specs_dir/$branch_name"
return
fi
# Search for directories in specs/ that start with this prefix
local matches=()
if [[ -d $specs_dir ]]; then
for dir in "$specs_dir"/"$prefix"-*; do
if [[ -d $dir ]]; then
matches+=("$(basename "$dir")")
fi
done
fi
# Handle results
if [[ ${#matches[@]} -eq 0 ]]; then
# No match found - return the branch name path (will fail later with clear error)
echo "$specs_dir/$branch_name"
elif [[ ${#matches[@]} -eq 1 ]]; then
# Exactly one match - perfect!
echo "$specs_dir/${matches[0]}"
else
# Multiple matches - this shouldn't happen with proper naming convention
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
echo "Please ensure only one spec directory exists per prefix." >&2
return 1
fi
}
get_feature_paths() {
local repo_root=$(get_repo_root)
local current_branch=$(get_current_branch)
local has_git_repo="false"
if has_git; then
has_git_repo="true"
fi
# Use prefix-based lookup to support multiple branches per spec
local feature_dir
if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
# Use printf '%q' to safely quote values, preventing shell injection
# via crafted branch names or paths containing special characters
printf 'REPO_ROOT=%q\n' "$repo_root"
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
printf 'HAS_GIT=%q\n' "$has_git_repo"
printf 'FEATURE_DIR=%q\n' "$feature_dir"
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
}
# Check if jq is available for safe JSON construction
has_jq() {
command -v jq >/dev/null 2>&1
}
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/\\r}"
s="${s//$'\b'/\\b}"
s="${s//$'\f'/\\f}"
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
# (U+0000/NUL cannot appear in bash strings and is excluded.)
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
local LC_ALL=C
local i char code
for ((i = 0; i < ${#s}; i++)); do
char="${s:i:1}"
printf -v code '%d' "'$char" 2>/dev/null || code=256
if ((code >= 1 && code <= 31)); then
printf '\\u%04x' "$code"
else
printf '%s' "$char"
fi
done
}
check_file() { [[ -f $1 ]] && echo " ✓ $2" || echo " ✗ $2"; }
check_dir() { [[ -d $1 && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
resolve_template() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"
# Priority 1: Project overrides
local override="$base/overrides/${template_name}.md"
[ -f "$override" ] && echo "$override" && return 0
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
# Read preset IDs sorted by priority (lower number = higher precedence).
# The python3 call is wrapped in an if-condition so that set -e does not
# abort the function when python3 exits non-zero (e.g. invalid JSON).
local sorted_presets=""
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
# python3 succeeded and returned preset IDs — search in priority order
while IFS= read -r preset_id; do
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done <<<"$sorted_presets"
fi
# python3 succeeded but registry has no presets — nothing to search
else
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
else
# Fallback: alphabetical directory order (no python3 available)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
fi
# Priority 3: Extension-provided templates
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
# Skip hidden directories (e.g. .backup, .cache)
case "$(basename "$ext")" in .*) continue ;; esac
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
# Priority 4: Core templates
local core="$base/${template_name}.md"
[ -f "$core" ] && echo "$core" && return 0
# Template not found in any location.
# Return 1 so callers can distinguish "not found" from "found".
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
return 1
}
-335
View File
@@ -1,335 +0,0 @@
#!/usr/bin/env bash
set -e
JSON_MODE=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--json)
JSON_MODE=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
# Check if the next argument is another option (starts with --)
if [[ $next_arg == --* ]]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
SHORT_NAME="$next_arg"
;;
--number)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ $next_arg == --* ]]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
BRANCH_NUMBER="$next_arg"
;;
--timestamp)
USE_TIMESTAMP=true
;;
--help | -h)
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i + 1))
done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
fi
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
# Only match sequential prefixes (###-*), skip timestamp dirs
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
fi
echo "$highest"
}
# Function to get highest number from git branches
get_highest_from_branches() {
local highest=0
# Get all branches (local and remote)
branches=$(git branch -a 2>/dev/null || echo "")
if [ -n "$branches" ]; then
while IFS= read -r branch; do
# Clean branch name: remove leading markers and remote prefixes
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
# Extract feature number if branch matches pattern ###-*
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done <<<"$branches"
fi
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number
check_existing_branches() {
local specs_dir="$1"
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune >/dev/null 2>&1 || true
# Get highest number from ALL branches (not just matching short name)
local highest_branch=$(get_highest_from_branches)
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
# Take the maximum of both
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
# Return next number
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Resolve repository root using common.sh functions which prioritize .specify over git
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
REPO_ROOT=$(get_repo_root)
# Check if git is available at this repo root (not a parent)
if has_git; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
mkdir -p "$SPECS_DIR"
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
# Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
# Convert to lowercase and split into words
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words=()
for word in $clean_name; do
# Skip empty words
[ -z "$word" ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -q "\b${word^^}\b"; then
# Keep short words if they appear as uppercase in original (likely acronyms)
meaningful_words+=("$word")
fi
fi
done
# If we have meaningful words, use first 3-4 of them
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
if [ $count -ge $max_words ]; then break; fi
if [ -n "$result" ]; then result="$result-"; fi
result="$result$word"
count=$((count + 1))
done
echo "$result"
else
# Fallback to original logic if no meaningful words found
local cleaned=$(clean_branch_name "$description")
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
# Use provided short name, just clean it up
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
# Generate from description with smart filtering
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
echo >&2 "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$((${#FEATURE_NUM} + 1))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
echo >&2 "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
echo >&2 "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
echo >&2 "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$HAS_GIT" = true ]; then
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
# Check if branch already exists
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$USE_TIMESTAMP" = true ]; then
echo >&2 "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
else
echo >&2 "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
fi
exit 1
else
echo >&2 "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
exit 1
fi
fi
else
echo >&2 "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR"
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
fi
# Inform the user how to persist the feature variable in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
else
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env bash
set -e
# Parse command line arguments
JSON_MODE=false
ARGS=()
for arg in "$@"; do
case "$arg" in
--json)
JSON_MODE=true
;;
--help | -h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
done
# Get script directory and load common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get all paths and variables from common functions
_paths_output=$(get_feature_paths) || {
echo "ERROR: Failed to resolve feature paths" >&2
exit 1
}
eval "$_paths_output"
unset _paths_output
# Check if we're on a proper feature branch (only for git repos)
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Ensure the feature directory exists
mkdir -p "$FEATURE_DIR"
# Copy plan template if it exists
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
if [[ -n $TEMPLATE ]] && [[ -f $TEMPLATE ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
echo "Copied plan template to $IMPL_PLAN"
else
echo "Warning: Plan template not found"
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi
# Output results
if $JSON_MODE; then
if has_jq; then
jq -cn \
--arg feature_spec "$FEATURE_SPEC" \
--arg impl_plan "$IMPL_PLAN" \
--arg specs_dir "$FEATURE_DIR" \
--arg branch "$CURRENT_BRANCH" \
--arg has_git "$HAS_GIT" \
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
else
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
echo "HAS_GIT: $HAS_GIT"
fi
@@ -1,840 +0,0 @@
#!/usr/bin/env bash
# Update agent context files with information from plan.md
#
# This script maintains AI agent context files by parsing feature specifications
# and updating agent-specific configuration files with project information.
#
# MAIN FUNCTIONS:
# 1. Environment Validation
# - Verifies git repository structure and branch information
# - Checks for required plan.md files and templates
# - Validates file permissions and accessibility
#
# 2. Plan Data Extraction
# - Parses plan.md files to extract project metadata
# - Identifies language/version, frameworks, databases, and project types
# - Handles missing or incomplete specification data gracefully
#
# 3. Agent File Management
# - Creates new agent context files from templates when needed
# - Updates existing agent files with new project information
# - Preserves manual additions and custom configurations
# - Supports multiple AI agent formats and directory structures
#
# 4. Content Generation
# - Generates language-specific build/test commands
# - Creates appropriate project directory structures
# - Updates technology stacks and recent changes sections
# - Maintains consistent formatting and timestamps
#
# 5. Multi-Agent Support
# - Handles agent-specific file paths and naming conventions
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic
# - Can update single agents or all existing agent files
# - Creates default Claude file if no agent files exist
#
# Usage: ./update-agent-context.sh [agent_type]
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic
# Leave empty to update all existing agent files
set -e
# Enable strict error handling
set -u
set -o pipefail
#==============================================================================
# Configuration and Global Variables
#==============================================================================
# Get script directory and load common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get all paths and variables from common functions
_paths_output=$(get_feature_paths) || {
echo "ERROR: Failed to resolve feature paths" >&2
exit 1
}
eval "$_paths_output"
unset _paths_output
NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code
AGENT_TYPE="${1-}"
# Agent-specific file paths
CLAUDE_FILE="$REPO_ROOT/CLAUDE.md"
GEMINI_FILE="$REPO_ROOT/GEMINI.md"
COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md"
CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
QWEN_FILE="$REPO_ROOT/QWEN.md"
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md"
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
QODER_FILE="$REPO_ROOT/QODER.md"
# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid
# updating the same file multiple times.
AMP_FILE="$AGENTS_FILE"
SHAI_FILE="$REPO_ROOT/SHAI.md"
TABNINE_FILE="$REPO_ROOT/TABNINE.md"
KIRO_FILE="$AGENTS_FILE"
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
BOB_FILE="$AGENTS_FILE"
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
KIMI_FILE="$REPO_ROOT/KIMI.md"
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
IFLOW_FILE="$REPO_ROOT/IFLOW.md"
# Template file
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
# Global variables for parsed plan data
NEW_LANG=""
NEW_FRAMEWORK=""
NEW_DB=""
NEW_PROJECT_TYPE=""
#==============================================================================
# Utility Functions
#==============================================================================
log_info() {
echo "INFO: $1"
}
log_success() {
echo "✓ $1"
}
log_error() {
echo "ERROR: $1" >&2
}
log_warning() {
echo "WARNING: $1" >&2
}
# Cleanup function for temporary files
cleanup() {
local exit_code=$?
# Disarm traps to prevent re-entrant loop
trap - EXIT INT TERM
rm -f /tmp/agent_update_*_$$
rm -f /tmp/manual_additions_$$
exit $exit_code
}
# Set up cleanup trap
trap cleanup EXIT INT TERM
#==============================================================================
# Validation Functions
#==============================================================================
validate_environment() {
# Check if we have a current branch/feature (git or non-git)
if [[ -z $CURRENT_BRANCH ]]; then
log_error "Unable to determine current feature"
if [[ $HAS_GIT == "true" ]]; then
log_info "Make sure you're on a feature branch"
else
log_info "Set SPECIFY_FEATURE environment variable or create a feature first"
fi
exit 1
fi
# Check if plan.md exists
if [[ ! -f $NEW_PLAN ]]; then
log_error "No plan.md found at $NEW_PLAN"
log_info "Make sure you're working on a feature with a corresponding spec directory"
if [[ $HAS_GIT != "true" ]]; then
log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first"
fi
exit 1
fi
# Check if template exists (needed for new files)
if [[ ! -f $TEMPLATE_FILE ]]; then
log_warning "Template file not found at $TEMPLATE_FILE"
log_warning "Creating new agent files will fail"
fi
}
#==============================================================================
# Plan Parsing Functions
#==============================================================================
extract_plan_field() {
local field_pattern="$1"
local plan_file="$2"
grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null |
head -1 |
sed "s|^\*\*${field_pattern}\*\*: ||" |
sed 's/^[ \t]*//;s/[ \t]*$//' |
grep -v "NEEDS CLARIFICATION" |
grep -v "^N/A$" || echo ""
}
parse_plan_data() {
local plan_file="$1"
if [[ ! -f $plan_file ]]; then
log_error "Plan file not found: $plan_file"
return 1
fi
if [[ ! -r $plan_file ]]; then
log_error "Plan file is not readable: $plan_file"
return 1
fi
log_info "Parsing plan data from $plan_file"
NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file")
NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file")
NEW_DB=$(extract_plan_field "Storage" "$plan_file")
NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file")
# Log what we found
if [[ -n $NEW_LANG ]]; then
log_info "Found language: $NEW_LANG"
else
log_warning "No language information found in plan"
fi
if [[ -n $NEW_FRAMEWORK ]]; then
log_info "Found framework: $NEW_FRAMEWORK"
fi
if [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]]; then
log_info "Found database: $NEW_DB"
fi
if [[ -n $NEW_PROJECT_TYPE ]]; then
log_info "Found project type: $NEW_PROJECT_TYPE"
fi
}
format_technology_stack() {
local lang="$1"
local framework="$2"
local parts=()
# Add non-empty parts
[[ -n $lang && $lang != "NEEDS CLARIFICATION" ]] && parts+=("$lang")
[[ -n $framework && $framework != "NEEDS CLARIFICATION" && $framework != "N/A" ]] && parts+=("$framework")
# Join with proper formatting
if [[ ${#parts[@]} -eq 0 ]]; then
echo ""
elif [[ ${#parts[@]} -eq 1 ]]; then
echo "${parts[0]}"
else
# Join multiple parts with " + "
local result="${parts[0]}"
for ((i = 1; i < ${#parts[@]}; i++)); do
result="$result + ${parts[i]}"
done
echo "$result"
fi
}
#==============================================================================
# Template and Content Generation Functions
#==============================================================================
get_project_structure() {
local project_type="$1"
if [[ $project_type == *"web"* ]]; then
echo 'backend/\nfrontend/\ntests/'
else
echo 'src/\ntests/'
fi
}
get_commands_for_language() {
local lang="$1"
case "$lang" in
*"Python"*)
echo "cd src && pytest && ruff check ."
;;
*"Rust"*)
echo "cargo test && cargo clippy"
;;
*"JavaScript"* | *"TypeScript"*)
echo 'npm test \&\& npm run lint'
;;
*)
echo "# Add commands for $lang"
;;
esac
}
get_language_conventions() {
local lang="$1"
echo "$lang: Follow standard conventions"
}
create_new_agent_file() {
local target_file="$1"
local temp_file="$2"
local project_name="$3"
local current_date="$4"
if [[ ! -f $TEMPLATE_FILE ]]; then
log_error "Template not found at $TEMPLATE_FILE"
return 1
fi
if [[ ! -r $TEMPLATE_FILE ]]; then
log_error "Template file is not readable: $TEMPLATE_FILE"
return 1
fi
log_info "Creating new agent context file from template..."
if ! cp "$TEMPLATE_FILE" "$temp_file"; then
log_error "Failed to copy template file"
return 1
fi
# Replace template placeholders
local project_structure
project_structure=$(get_project_structure "$NEW_PROJECT_TYPE")
local commands
commands=$(get_commands_for_language "$NEW_LANG")
local language_conventions
language_conventions=$(get_language_conventions "$NEW_LANG")
# Perform substitutions with error checking using safer approach
# Escape special characters for sed by using a different delimiter or escaping
local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g')
local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g')
local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g')
# Build technology stack and recent change strings conditionally
local tech_stack
if [[ -n $escaped_lang && -n $escaped_framework ]]; then
tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)"
elif [[ -n $escaped_lang ]]; then
tech_stack="- $escaped_lang ($escaped_branch)"
elif [[ -n $escaped_framework ]]; then
tech_stack="- $escaped_framework ($escaped_branch)"
else
tech_stack="- ($escaped_branch)"
fi
local recent_change
if [[ -n $escaped_lang && -n $escaped_framework ]]; then
recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework"
elif [[ -n $escaped_lang ]]; then
recent_change="- $escaped_branch: Added $escaped_lang"
elif [[ -n $escaped_framework ]]; then
recent_change="- $escaped_branch: Added $escaped_framework"
else
recent_change="- $escaped_branch: Added"
fi
local substitutions=(
"s|\[PROJECT NAME\]|$project_name|"
"s|\[DATE\]|$current_date|"
"s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|"
"s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g"
"s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|"
"s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|"
"s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|"
)
for substitution in "${substitutions[@]}"; do
if ! sed -i.bak -e "$substitution" "$temp_file"; then
log_error "Failed to perform substitution: $substitution"
rm -f "$temp_file" "$temp_file.bak"
return 1
fi
done
# Convert \n sequences to actual newlines
newline=$(printf '\n')
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
# Clean up backup files
rm -f "$temp_file.bak" "$temp_file.bak2"
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
if [[ $target_file == *.mdc ]]; then
local frontmatter_file
frontmatter_file=$(mktemp) || return 1
printf '%s\n' "---" "description: Project Development Guidelines" 'globs: ["**/*"]' "alwaysApply: true" "---" "" >"$frontmatter_file"
cat "$temp_file" >>"$frontmatter_file"
mv "$frontmatter_file" "$temp_file"
fi
return 0
}
update_existing_agent_file() {
local target_file="$1"
local current_date="$2"
log_info "Updating existing agent context file..."
# Use a single temporary file for atomic update
local temp_file
temp_file=$(mktemp) || {
log_error "Failed to create temporary file"
return 1
}
# Process the file in one pass
local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK")
local new_tech_entries=()
local new_change_entry=""
# Prepare new technology entries
if [[ -n $tech_stack ]] && ! grep -q "$tech_stack" "$target_file"; then
new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)")
fi
if [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]] && [[ $NEW_DB != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then
new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)")
fi
# Prepare new change entry
if [[ -n $tech_stack ]]; then
new_change_entry="- $CURRENT_BRANCH: Added $tech_stack"
elif [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]] && [[ $NEW_DB != "NEEDS CLARIFICATION" ]]; then
new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB"
fi
# Check if sections exist in the file
local has_active_technologies=0
local has_recent_changes=0
if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then
has_active_technologies=1
fi
if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then
has_recent_changes=1
fi
# Process file line by line
local in_tech_section=false
local in_changes_section=false
local tech_entries_added=false
local changes_entries_added=false
local existing_changes_count=0
local file_ended=false
while IFS= read -r line || [[ -n $line ]]; do
# Handle Active Technologies section
if [[ $line == "## Active Technologies" ]]; then
echo "$line" >>"$temp_file"
in_tech_section=true
continue
elif [[ $in_tech_section == true ]] && [[ $line =~ ^##[[:space:]] ]]; then
# Add new tech entries before closing the section
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
tech_entries_added=true
fi
echo "$line" >>"$temp_file"
in_tech_section=false
continue
elif [[ $in_tech_section == true ]] && [[ -z $line ]]; then
# Add new tech entries before empty line in tech section
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
tech_entries_added=true
fi
echo "$line" >>"$temp_file"
continue
fi
# Handle Recent Changes section
if [[ $line == "## Recent Changes" ]]; then
echo "$line" >>"$temp_file"
# Add new change entry right after the heading
if [[ -n $new_change_entry ]]; then
echo "$new_change_entry" >>"$temp_file"
fi
in_changes_section=true
changes_entries_added=true
continue
elif [[ $in_changes_section == true ]] && [[ $line =~ ^##[[:space:]] ]]; then
echo "$line" >>"$temp_file"
in_changes_section=false
continue
elif [[ $in_changes_section == true ]] && [[ $line == "- "* ]]; then
# Keep only first 2 existing changes
if [[ $existing_changes_count -lt 2 ]]; then
echo "$line" >>"$temp_file"
((existing_changes_count++))
fi
continue
fi
# Update timestamp
if [[ $line =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then
echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >>"$temp_file"
else
echo "$line" >>"$temp_file"
fi
done <"$target_file"
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries
if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
tech_entries_added=true
fi
# If sections don't exist, add them at the end of the file
if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
echo "" >>"$temp_file"
echo "## Active Technologies" >>"$temp_file"
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
tech_entries_added=true
fi
if [[ $has_recent_changes -eq 0 ]] && [[ -n $new_change_entry ]]; then
echo "" >>"$temp_file"
echo "## Recent Changes" >>"$temp_file"
echo "$new_change_entry" >>"$temp_file"
changes_entries_added=true
fi
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
if [[ $target_file == *.mdc ]]; then
if ! head -1 "$temp_file" | grep -q '^---'; then
local frontmatter_file
frontmatter_file=$(mktemp) || {
rm -f "$temp_file"
return 1
}
printf '%s\n' "---" "description: Project Development Guidelines" 'globs: ["**/*"]' "alwaysApply: true" "---" "" >"$frontmatter_file"
cat "$temp_file" >>"$frontmatter_file"
mv "$frontmatter_file" "$temp_file"
fi
fi
# Move temp file to target atomically
if ! mv "$temp_file" "$target_file"; then
log_error "Failed to update target file"
rm -f "$temp_file"
return 1
fi
return 0
}
#==============================================================================
# Main Agent File Update Function
#==============================================================================
update_agent_file() {
local target_file="$1"
local agent_name="$2"
if [[ -z $target_file ]] || [[ -z $agent_name ]]; then
log_error "update_agent_file requires target_file and agent_name parameters"
return 1
fi
log_info "Updating $agent_name context file: $target_file"
local project_name
project_name=$(basename "$REPO_ROOT")
local current_date
current_date=$(date +%Y-%m-%d)
# Create directory if it doesn't exist
local target_dir
target_dir=$(dirname "$target_file")
if [[ ! -d $target_dir ]]; then
if ! mkdir -p "$target_dir"; then
log_error "Failed to create directory: $target_dir"
return 1
fi
fi
if [[ ! -f $target_file ]]; then
# Create new file from template
local temp_file
temp_file=$(mktemp) || {
log_error "Failed to create temporary file"
return 1
}
if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then
if mv "$temp_file" "$target_file"; then
log_success "Created new $agent_name context file"
else
log_error "Failed to move temporary file to $target_file"
rm -f "$temp_file"
return 1
fi
else
log_error "Failed to create new agent file"
rm -f "$temp_file"
return 1
fi
else
# Update existing file
if [[ ! -r $target_file ]]; then
log_error "Cannot read existing file: $target_file"
return 1
fi
if [[ ! -w $target_file ]]; then
log_error "Cannot write to existing file: $target_file"
return 1
fi
if update_existing_agent_file "$target_file" "$current_date"; then
log_success "Updated existing $agent_name context file"
else
log_error "Failed to update existing agent file"
return 1
fi
fi
return 0
}
#==============================================================================
# Agent Selection and Processing
#==============================================================================
update_specific_agent() {
local agent_type="$1"
case "$agent_type" in
claude)
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
;;
gemini)
update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1
;;
copilot)
update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1
;;
cursor-agent)
update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1
;;
qwen)
update_agent_file "$QWEN_FILE" "Qwen Code" || return 1
;;
opencode)
update_agent_file "$AGENTS_FILE" "opencode" || return 1
;;
codex)
update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1
;;
windsurf)
update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1
;;
junie)
update_agent_file "$JUNIE_FILE" "Junie" || return 1
;;
kilocode)
update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1
;;
auggie)
update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1
;;
roo)
update_agent_file "$ROO_FILE" "Roo Code" || return 1
;;
codebuddy)
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1
;;
qodercli)
update_agent_file "$QODER_FILE" "Qoder CLI" || return 1
;;
amp)
update_agent_file "$AMP_FILE" "Amp" || return 1
;;
shai)
update_agent_file "$SHAI_FILE" "SHAI" || return 1
;;
tabnine)
update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1
;;
kiro-cli)
update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1
;;
agy)
update_agent_file "$AGY_FILE" "Antigravity" || return 1
;;
bob)
update_agent_file "$BOB_FILE" "IBM Bob" || return 1
;;
vibe)
update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1
;;
kimi)
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
;;
trae)
update_agent_file "$TRAE_FILE" "Trae" || return 1
;;
pi)
update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1
;;
iflow)
update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1
;;
generic)
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
;;
*)
log_error "Unknown agent type '$agent_type'"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic"
exit 1
;;
esac
}
# Helper: skip non-existent files and files already updated (dedup by
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
# Uses a linear array instead of associative array for bash 3.2 compatibility.
# Note: defined at top level because bash 3.2 does not support true
# nested/local functions. _updated_paths, _found_agent, and _all_ok are
# initialised exclusively inside update_all_existing_agents so that
# sourcing this script has no side effects on the caller's environment.
_update_if_new() {
local file="$1" name="$2"
[[ -f $file ]] || return 0
local real_path
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
local p
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
for p in "${_updated_paths[@]}"; do
[[ $p == "$real_path" ]] && return 0
done
fi
# Record the file as seen before attempting the update so that:
# (a) aliases pointing to the same path are not retried on failure
# (b) _found_agent reflects file existence, not update success
_updated_paths+=("$real_path")
_found_agent=true
update_agent_file "$file" "$name"
}
update_all_existing_agents() {
_found_agent=false
_updated_paths=()
local _all_ok=true
_update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false
_update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
_update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
_update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false
_update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false
_update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false
_update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false
_update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false
_update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false
_update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false
_update_if_new "$TRAE_FILE" "Trae" || _all_ok=false
_update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false
# If no agent files exist, create a default Claude file
if [[ $_found_agent == false ]]; then
log_info "No existing agent files found, creating default Claude file..."
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
fi
[[ $_all_ok == true ]]
}
print_summary() {
echo
log_info "Summary of changes:"
if [[ -n $NEW_LANG ]]; then
echo " - Added language: $NEW_LANG"
fi
if [[ -n $NEW_FRAMEWORK ]]; then
echo " - Added framework: $NEW_FRAMEWORK"
fi
if [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]]; then
echo " - Added database: $NEW_DB"
fi
echo
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]"
}
#==============================================================================
# Main Execution
#==============================================================================
main() {
# Validate environment before proceeding
validate_environment
log_info "=== Updating agent context files for feature $CURRENT_BRANCH ==="
# Parse the plan file to extract project information
if ! parse_plan_data "$NEW_PLAN"; then
log_error "Failed to parse plan data"
exit 1
fi
# Process based on agent type argument
local success=true
if [[ -z $AGENT_TYPE ]]; then
# No specific agent provided - update all existing agent files
log_info "No agent specified, updating all existing agent files..."
if ! update_all_existing_agents; then
success=false
fi
else
# Specific agent provided - update only that agent
log_info "Updating specific agent: $AGENT_TYPE"
if ! update_specific_agent "$AGENT_TYPE"; then
success=false
fi
fi
# Print summary
print_summary
if [[ $success == true ]]; then
log_success "Agent context update completed successfully"
exit 0
else
log_error "Agent context update completed with errors"
exit 1
fi
}
# Execute main function if script is run directly
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
main "$@"
fi
-28
View File
@@ -1,28 +0,0 @@
# [PROJECT NAME] Development Guidelines
Auto-generated from all feature plans. Last updated: [DATE]
## Active Technologies
[EXTRACTED FROM ALL PLAN.MD FILES]
## Project Structure
```text
[ACTUAL STRUCTURE FROM PLANS]
```
## Commands
[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]
## Code Style
[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]
## Recent Changes
[LAST 3 FEATURES AND WHAT THEY ADDED]
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->
-40
View File
@@ -1,40 +0,0 @@
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
**Purpose**: [Brief description of what this checklist covers]
**Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The /speckit.checklist command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->
## [Category 1]
- [ ] CHK001 First checklist item with clear action
- [ ] CHK002 Second checklist item
- [ ] CHK003 Third checklist item
## [Category 2]
- [ ] CHK004 Another category item
- [ ] CHK005 Item with specific criteria
- [ ] CHK006 Final item in this category
## Notes
- Check items off as completed: `[x]`
- Add comments or findings inline
- Link to relevant resources or documentation
- Items are numbered sequentially for easy reference
@@ -1,73 +0,0 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
-109
View File
@@ -1,109 +0,0 @@
# Implementation Plan: [FEATURE]
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary
[Extract from feature spec: primary requirement + technical approach from research]
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
## Constitution Check
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
- Safety-critical mesh impact is identified for any routing, airtime, MQTT, channel, or packet-path change.
- Variant and platform scope are explicit, and all hardware flags or pin mappings are verified against the target board.
- Validation evidence is defined, including the exact `pio`, native test, simulator, formatting, or static checks to run.
- Resource, power, memory, and dependency impact are assessed for the affected targets.
- Any constitutional violation or validation gap is documented with justification in Complexity Tracking.
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
src/
├── models/
├── services/
├── cli/
└── lib/
tests/
├── contract/
├── integration/
└── unit/
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
backend/
├── src/
│ ├── models/
│ ├── services/
│ └── api/
└── tests/
frontend/
├── src/
│ ├── components/
│ ├── pages/
│ └── services/
└── tests/
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
api/
└── [same as backend above]
ios/ or android/
└── [platform-specific structure: feature modules, UI flows, platform tests]
```
**Structure Decision**: [Document the selected structure and reference the real
directories captured above]
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
| -------------------------- | ------------------ | ------------------------------------ |
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
-140
View File
@@ -1,140 +0,0 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## User Scenarios & Testing _(mandatory)_
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - [Brief Title] (Priority: P1)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 2 - [Brief Title] (Priority: P2)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 3 - [Brief Title] (Priority: P3)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
[Add more user stories as needed, each with an assigned priority]
For firmware and hardware-facing work, each story MUST identify affected platforms or variants
and describe how the behavior is validated on its own.
### Edge Cases
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right edge cases.
-->
- What happens when [boundary condition]?
- How does system handle [error scenario]?
- What happens when the target hardware capability is absent, misdeclared, or only present on some variants?
- How does the system behave when radio, power, timing, or memory constraints are tighter than expected?
## Requirements _(mandatory)_
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
_Example of marking unclear requirements:_
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
Where relevant, requirements MUST also state:
- affected architectures, boards, or modules
- whether behavior changes public defaults, protocol compatibility, or generated artifacts
- any required validation evidence for high-risk mesh, hardware, or power behavior
### Key Entities _(include if feature involves data)_
- **[Entity 1]**: [What it represents, key attributes without implementation]
- **[Entity 2]**: [What it represents, relationships to other entities]
## Success Criteria _(mandatory)_
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
## Assumptions
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right assumptions based on reasonable defaults
chosen when the feature description did not specify certain details.
-->
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
- [Assumption about available hardware capabilities, board revisions, or build targets]
-259
View File
@@ -1,259 +0,0 @@
---
description: "Task list template for feature implementation"
---
# Tasks: [FEATURE NAME]
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: Validation tasks are REQUIRED whenever the constitution or feature risk profile demands evidence. Include targeted builds, native tests, simulator runs, protobuf regeneration checks, formatting, or other repository-standard validation appropriate to the change.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Single project**: `src/`, `tests/` at repository root
- **Web app**: `backend/src/`, `frontend/src/`
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
<!--
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The /speckit.tasks command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md
- Endpoints from contracts/
Tasks MUST be organized by user story so each story can be:
- Implemented independently
- Tested independently
- Delivered as an MVP increment
DO NOT keep these sample tasks in the generated tasks.md file.
============================================================================
-->
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [ ] T001 Create project structure per implementation plan
- [ ] T002 Initialize [language] project with [framework] dependencies
- [ ] T003 [P] Configure linting and formatting tools
- [ ] T00X Identify affected targets, variants, and validation commands
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
Examples of foundational tasks (adjust based on your project):
- [ ] T004 Setup database schema and migrations framework
- [ ] T005 [P] Implement authentication/authorization framework
- [ ] T006 [P] Setup API routing and middleware structure
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
- [ ] T00X Verify board-specific flags, pins, generated assets, or protocol prerequisites needed by all stories
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Validation for User Story 1 ⚠️
> **NOTE: Add the smallest credible validation for the story's risk level before declaring it complete**
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test\_[name].py
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test\_[name].py
- [ ] T01X [P] [US1] Run targeted build or simulation for affected platform(s)
### Implementation for User Story 1
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
- [ ] T01Y [US1] Confirm variant-scoped behavior and configuration defaults remain correct
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
---
## Phase 4: User Story 2 - [Title] (Priority: P2)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Validation for User Story 2 ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test\_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test\_[name].py
- [ ] T02X [P] [US2] Run targeted build or simulation for affected platform(s)
### Implementation for User Story 2
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
- [ ] T02Y [US2] Confirm resource, power, and compatibility impacts stay within plan constraints
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
---
## Phase 5: User Story 3 - [Title] (Priority: P3)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Validation for User Story 3 ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test\_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test\_[name].py
- [ ] T03X [P] [US3] Run targeted build or simulation for affected platform(s)
### Implementation for User Story 3
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T03Y [US3] Confirm backward compatibility or explicitly document intentional behavior changes
**Checkpoint**: All user stories should now be independently functional
---
[Add more user story phases as needed, following the same pattern]
---
## Phase N: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [ ] TXXX [P] Documentation updates in docs/
- [ ] TXXX Code cleanup and refactoring
- [ ] TXXX Performance optimization across all stories
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
- [ ] TXXX Security hardening
- [ ] TXXX Run quickstart.md validation
- [ ] TXXX Summarize skipped validations, migration notes, and generated-file updates for review
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
- User stories can then proceed in parallel (if staffed)
- Or sequentially in priority order (P1 → P2 → P3)
- **Polish (Final Phase)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
### Within Each User Story
- Tests (if included) MUST be written and FAIL before implementation
- Models before services
- Services before endpoints
- Core implementation before integration
- Story complete before moving to next priority
### Parallel Opportunities
- All Setup tasks marked [P] can run in parallel
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
- All tests for a user story marked [P] can run in parallel
- Models within a story marked [P] can run in parallel
- Different user stories can be worked on in parallel by different team members
---
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
# Launch all models for User Story 1 together:
Task: "Create [Entity1] model in src/models/[entity1].py"
Task: "Create [Entity2] model in src/models/[entity2].py"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
3. Complete Phase 3: User Story 1
4. **STOP and VALIDATE**: Test User Story 1 independently
5. Deploy/demo if ready
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
3. Add User Story 2 → Test independently → Deploy/Demo
4. Add User Story 3 → Test independently → Deploy/Demo
5. Each story adds value without breaking previous stories
### Parallel Team Strategy
With multiple developers:
1. Team completes Setup + Foundational together
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Stories complete and integrate independently
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
+10 -3
View File
@@ -4,14 +4,14 @@ cli:
plugins:
sources:
- id: trunk
ref: v1.8.0
ref: v1.9.0
uri: https://github.com/trunk-io/plugins
lint:
enabled:
- checkov@3.2.526
- checkov@3.2.528
- renovate@43.150.0
- prettier@3.8.3
- trufflehog@3.95.2
- trufflehog@3.95.3
- yamllint@1.38.0
- bandit@1.9.4
- trivy@0.70.0
@@ -34,6 +34,13 @@ lint:
- linters: [ALL]
paths:
- bin/**
# Fake-NodeDB fixture JSONL files contain deterministic synthetic
# public_key_hex (64-char hex) values that gitleaks misidentifies as
# generic-api-key. These are not secrets — they're test fixtures
# produced by bin/gen-fake-nodedb-seed.py with a fixed RNG seed.
- linters: [gitleaks]
paths:
- test/fixtures/nodedb/seed_v25_*.jsonl
runtimes:
enabled:
- python@3.14.4
+4 -4
View File
@@ -1,10 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
"Jason2866.esp-decoder",
"pioarduino.pioarduino-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
"ms-vscode.cpptools-extension-pack",
"platformio.platformio-ide"
]
}
-11
View File
@@ -10,16 +10,5 @@
},
"[powershell]": {
"editor.defaultFormatter": "ms-vscode.powershell"
},
"chat.promptFilesRecommendations": {
"speckit.constitution": true,
"speckit.specify": true,
"speckit.plan": true,
"speckit.tasks": true,
"speckit.implement": true
},
"chat.tools.terminal.autoApprove": {
".specify/scripts/bash/": true,
".specify/scripts/powershell/": true
}
}
+2
View File
@@ -66,6 +66,8 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
- **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
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Post-process protoc-generated Python files to live under a local namespace.
Called by bin/regen-py-protos.sh. Walks the generated *_pb2.py files in the
target directory and rewrites every `meshtastic` reference (imports, dotted
attribute access) to use the new namespace (e.g., `meshtastic_v25`).
Why: the .proto files declare `package meshtastic;`, so protoc emits
`from meshtastic import mesh_pb2 as ...` lines. That would shadow the PyPI
`meshtastic` package which other parts of the mcp-server depend on. Renaming
to a local namespace keeps both available.
Usage:
_rewrite_proto_namespace.py <generated_dir> <new_namespace>
"""
from __future__ import annotations
import pathlib
import re
import sys
def rewrite(dir_path: pathlib.Path, new_ns: str) -> int:
# Standard protoc import forms:
# from meshtastic.X_pb2 import ... (rare, for direct symbol pulls)
# from meshtastic import X_pb2 as ... (common, the cross-file ref)
# import meshtastic.X_pb2 (also possible)
pattern_dotted_from = re.compile(r"^from meshtastic\.", re.MULTILINE)
pattern_bare_from = re.compile(r"^from meshtastic import ", re.MULTILINE)
pattern_dotted_import = re.compile(r"^import meshtastic\.", re.MULTILINE)
count = 0
for p in dir_path.glob("*.py"):
text = p.read_text(encoding="utf-8")
new = pattern_dotted_from.sub(f"from {new_ns}.", text)
new = pattern_bare_from.sub(f"from {new_ns} import ", new)
new = pattern_dotted_import.sub(f"import {new_ns}.", new)
# NOTE: we deliberately leave `meshtastic/X.proto` source-filename
# references inside descriptor strings alone. The descriptor pool is
# keyed by source filename (independent of Python package layout), so
# those don't collide with the PyPI package's descriptors.
if new != text:
p.write_text(new, encoding="utf-8")
count += 1
return count
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: _rewrite_proto_namespace.py <generated_dir> <new_namespace>", file=sys.stderr)
return 2
dir_path = pathlib.Path(argv[0])
new_ns = argv[1]
if not dir_path.is_dir():
print(f"directory not found: {dir_path}", file=sys.stderr)
return 2
n = rewrite(dir_path, new_ns)
print(f"rewrote {n} file(s) in {dir_path} → namespace {new_ns}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-628
View File
@@ -1,628 +0,0 @@
#!/usr/bin/env python3
"""Board intake assessment for new Meshtastic hardware support.
Validates a board intake request against the repository hardware context,
identifies evidence gaps, and produces a structured readiness report.
Usage:
python3 bin/board_intake.py <intake.json>
python3 bin/board_intake.py <intake.json> --output report.md
python3 bin/board_intake.py <intake.json> --validate # gaps check only, exit 1 if not scaffold_ready
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONTEXT_PATH = ROOT / "docs" / "hardware-support-context.md"
# Metadata keys every new PlatformIO environment should declare.
REQUIRED_METADATA_KEYS = [
"custom_meshtastic_hw_model",
"custom_meshtastic_hw_model_slug",
"custom_meshtastic_architecture",
"custom_meshtastic_actively_supported",
"custom_meshtastic_support_level",
"custom_meshtastic_display_name",
]
RECOMMENDED_METADATA_KEYS = [
"custom_meshtastic_images",
"custom_meshtastic_tags",
"custom_meshtastic_requires_dfu",
"custom_meshtastic_partition_scheme",
]
# Pin groups that should be backed by evidence before scaffolding.
EVIDENCE_CATEGORIES = ["radio", "display", "input", "GPS", "power"]
# Architecture families that rely on BSP defaults for many pin defines.
BSP_DEFAULT_FAMILIES = {"nrf52840", "rp2040", "stm32", "native"}
# Known valid architectures from the repository.
KNOWN_ARCHITECTURES = {
"esp32",
"esp32-s3",
"esp32-c3",
"esp32-c6",
"esp32s2",
"nrf52840",
"rp2040",
"rp2350",
"stm32",
"native",
}
# ---------------------------------------------------------------------------
# T004 – BoardIntakeRequest dataclass
# ---------------------------------------------------------------------------
@dataclass
class BoardIntakeRequest:
"""Maintainer-supplied description of a proposed new board."""
# Required
environment_name: str
hardware_model: str
display_name: str
architecture: str
# Recommended
hardware_model_slug: str = ""
actively_supported: bool | None = None
support_level: str = ""
source_materials: list[str] = field(default_factory=list)
board_notes: str = ""
@classmethod
def from_dict(cls, data: dict) -> "BoardIntakeRequest":
return cls(
environment_name=data.get("environment_name", ""),
hardware_model=str(data.get("hardware_model", "")),
display_name=data.get("display_name", ""),
architecture=data.get("architecture", ""),
hardware_model_slug=data.get("hardware_model_slug", ""),
actively_supported=data.get("actively_supported"),
support_level=str(data.get("support_level", "")),
source_materials=list(data.get("source_materials", [])),
board_notes=data.get("board_notes", ""),
)
@classmethod
def from_json(cls, path: Path) -> "BoardIntakeRequest":
data = json.loads(path.read_text(encoding="utf-8"))
return cls.from_dict(data)
# ---------------------------------------------------------------------------
# T005 – EvidenceGap dataclass
# ---------------------------------------------------------------------------
@dataclass
class EvidenceGap:
"""A specific missing, conflicting, or ambiguous hardware fact."""
category: str # metadata | radio | display | input | GPS | power | storage | connectivity | revision-scope
description: str
affected_artifact: str
required_evidence: str
blocking: bool
# ---------------------------------------------------------------------------
# T006 – IntakeAssessment dataclass
# ---------------------------------------------------------------------------
@dataclass
class IntakeAssessment:
"""Structured result of evaluating a BoardIntakeRequest."""
request: BoardIntakeRequest
expected_artifacts: list[str]
required_metadata: list[str]
matched_patterns: list[dict]
evidence_gaps: list[EvidenceGap]
risk_flags: list[str]
next_actions: list[str]
scaffold_ready: bool
# ---------------------------------------------------------------------------
# T007 – load_hardware_context
# ---------------------------------------------------------------------------
def load_hardware_context(path: Path = DEFAULT_CONTEXT_PATH) -> dict:
"""Parse the generated hardware-support-context.md into a usable dict.
Returns:
{
"architecture_names": list[str], # families present in the inventory
"metadata_keys": list[str], # custom_meshtastic_* keys observed
"environments": dict[str, dict], # env_name -> {display, hw_model, hw_slug, variant_dir, arch}
}
"""
if not path.exists():
raise FileNotFoundError(
f"Hardware context not found at {path}. "
"Run: python3 bin/generate_hardware_support_context.py"
)
text = path.read_text(encoding="utf-8")
# Extract metadata keys from the "## Repository Metadata Inputs" section.
metadata_keys: list[str] = re.findall(r"`(custom_meshtastic_[^`]+)`", text)
metadata_keys = list(dict.fromkeys(metadata_keys)) # deduplicate, preserve order
# Extract architecture families from the "## Architecture and Environment Inventory" section.
arch_names: list[str] = re.findall(r"^### ([a-z0-9\-]+)\s*$", text, re.MULTILINE)
# Filter out sub-headings that are architecture names (exclude e.g. "nrf52840" inside examples)
# The inventory section has short arch names; filter to known set plus any that look like archs.
arch_names = [a for a in dict.fromkeys(arch_names) if not a[0].isupper()]
# Extract environments from inventory table rows.
environments: dict[str, dict] = {}
current_arch = ""
for line in text.splitlines():
arch_match = re.match(r"^### ([a-z0-9\-]+)\s*$", line)
if arch_match:
current_arch = arch_match.group(1)
continue
# Table row: | env | display | hw_model | hw_slug | variant_dir | categories |
row = re.match(
r"^\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|",
line,
)
if (
row
and not row.group(1).startswith("Environment")
and not row.group(1).startswith("---")
):
env_name = row.group(1).strip()
if env_name:
environments[env_name] = {
"display_name": row.group(2).strip(),
"hw_model": row.group(3).strip(),
"hw_slug": row.group(4).strip(),
"variant_dir": row.group(5).strip(),
"architecture": current_arch,
}
return {
"architecture_names": arch_names,
"metadata_keys": metadata_keys,
"environments": environments,
}
# ---------------------------------------------------------------------------
# T018 – validate_intake
# ---------------------------------------------------------------------------
def validate_intake(request: BoardIntakeRequest, context: dict) -> list[str]:
"""Check required fields and detect conflicts with existing environments/models.
Returns a list of validation error strings (empty = valid).
"""
errors: list[str] = []
if not request.environment_name:
errors.append("environment_name is required.")
if not request.hardware_model:
errors.append("hardware_model is required.")
if not request.display_name:
errors.append("display_name is required.")
if not request.architecture:
errors.append("architecture is required.")
if request.architecture and request.architecture not in KNOWN_ARCHITECTURES:
errors.append(
f"architecture '{request.architecture}' is not a known repository architecture. "
f"Known: {', '.join(sorted(KNOWN_ARCHITECTURES))}"
)
# Conflict: environment name already exists
if request.environment_name and request.environment_name in context.get(
"environments", {}
):
errors.append(
f"environment_name '{request.environment_name}' already exists in the repository. "
"Choose a unique name or confirm this is an intentional update."
)
# Conflict: hardware model already assigned to a different environment
if request.hardware_model:
for env_name, env_data in context.get("environments", {}).items():
if (
env_data.get("hw_model") == request.hardware_model
and env_name != request.environment_name
):
errors.append(
f"hardware_model '{request.hardware_model}' is already assigned to "
f"environment '{env_name}' ({env_data.get('display_name', '')!r}). "
"Verify this is a new model number or confirm the shared-model intent."
)
break # report once
return errors
# ---------------------------------------------------------------------------
# T019 – find_matched_patterns
# ---------------------------------------------------------------------------
def find_matched_patterns(request: BoardIntakeRequest, context: dict) -> list[dict]:
"""Return up to 3 existing environments closest to the request by architecture."""
envs = context.get("environments", {})
arch = request.architecture
# Prefer exact architecture match, then partial (e.g., "esp32" matches "esp32-s3").
exact: list[dict] = []
partial: list[dict] = []
for env_name, env_data in envs.items():
entry = {**env_data, "environment": env_name}
env_arch = env_data.get("architecture", "")
if env_arch == arch:
exact.append(entry)
elif arch and (env_arch.startswith(arch) or arch.startswith(env_arch)):
partial.append(entry)
candidates = exact + partial
# Prefer boards that have a display_name and hw_model (more complete entries).
candidates.sort(key=lambda e: (not e.get("display_name"), not e.get("hw_model")))
return candidates[:3]
# ---------------------------------------------------------------------------
# T020 – build_evidence_gaps
# ---------------------------------------------------------------------------
def build_evidence_gaps(request: BoardIntakeRequest) -> list[EvidenceGap]:
"""Identify missing pin-group evidence and metadata gaps."""
gaps: list[EvidenceGap] = []
has_sources = bool(request.source_materials)
if not has_sources:
# Every pin category is unresolvable without sources.
for cat in EVIDENCE_CATEGORIES:
gaps.append(
EvidenceGap(
category=cat,
description=(
f"No source materials supplied. {cat.capitalize()} pin mappings cannot be "
"verified without a schematic, pinout diagram, or vendor datasheet."
),
affected_artifact="variant.h",
required_evidence="Schematic, pinout image, or vendor board page",
blocking=True,
)
)
else:
# Sources exist but may still be incomplete; flag as non-blocking advisory.
for cat in EVIDENCE_CATEGORIES:
gaps.append(
EvidenceGap(
category=cat,
description=(
f"Source materials are present but {cat} pin assignments have not been "
"extracted and cross-checked against repository macro conventions."
),
affected_artifact="variant.h",
required_evidence=f"Explicit {cat} pin listing matched to repository #define names",
blocking=False,
)
)
# Metadata gaps
if not request.hardware_model_slug:
gaps.append(
EvidenceGap(
category="metadata",
description="hardware_model_slug is not set. The repository requires an UPPER_SNAKE_CASE slug for PlatformIO metadata.",
affected_artifact="platformio.ini (custom_meshtastic_hw_model_slug)",
required_evidence="Agreed slug from the project maintainers",
blocking=True,
)
)
if request.actively_supported is None:
gaps.append(
EvidenceGap(
category="metadata",
description="actively_supported is not specified. This controls CI matrix inclusion.",
affected_artifact="platformio.ini (custom_meshtastic_actively_supported)",
required_evidence="Maintainer decision on support status",
blocking=False,
)
)
if not request.support_level:
gaps.append(
EvidenceGap(
category="metadata",
description="support_level is not specified (expected: 1 = active, 2 = supported, 3 = extra).",
affected_artifact="platformio.ini (custom_meshtastic_support_level)",
required_evidence="Maintainer decision on support tier",
blocking=False,
)
)
# Revision-scope: multiple display/radio options without disambiguation.
# Match whole-word choice language rather than raw substrings so normal text
# like "radio" does not trigger the ambiguity gate.
note_text = request.board_notes.lower()
revision_scope_patterns = (
r"\brevision\b",
r"\bvariant\b",
r"\bvariants\b",
r"\boption\b",
r"\boptions\b",
r"\balternative\b",
r"\balternatives\b",
r"\bmulti\b",
r"\btwo\b",
r"\beither\b",
r"\bor\b",
)
if note_text and any(re.search(pattern, note_text) for pattern in revision_scope_patterns):
gaps.append(
EvidenceGap(
category="revision-scope",
description=(
"Board notes mention multiple variants, revisions, or options. "
"The intake must be scoped to a single hardware revision before scaffolding can proceed."
),
affected_artifact="variant.h, platformio.ini",
required_evidence="Explicit decision on which revision this intake covers",
blocking=True,
)
)
return gaps
# ---------------------------------------------------------------------------
# T021 – assess_intake
# ---------------------------------------------------------------------------
def assess_intake(request: BoardIntakeRequest, context: dict) -> IntakeAssessment:
"""Produce a full structured assessment from intake request and context."""
validation_errors = validate_intake(request, context)
matched = find_matched_patterns(request, context)
gaps = build_evidence_gaps(request)
expected_artifacts = [
f"variants/{request.architecture or '<architecture>'}/<variant-dir>/variant.h",
f"variants/{request.architecture or '<architecture>'}/<variant-dir>/platformio.ini (env:{request.environment_name or '<env>'})",
]
if request.architecture in {"esp32", "esp32-s3", "esp32-c3", "esp32-c6"}:
expected_artifacts.append(
"(optional) variants/.../variant.cpp — only if board requires custom init hooks"
)
expected_artifacts += [
"PlatformIO metadata: all required custom_meshtastic_* keys (see required_metadata below)",
"(optional) board image under branding/ or images/ if custom_meshtastic_images is set",
]
required_metadata = list(REQUIRED_METADATA_KEYS)
if request.architecture in BSP_DEFAULT_FAMILIES:
required_metadata.append(
f"(BSP note) {request.architecture} boards may inherit some pin defines from BSP headers — "
"check the Inherited Defaults section of docs/hardware-support-context.md before assuming a missing define is an error."
)
risk_flags: list[str] = []
for err in validation_errors:
risk_flags.append(f"Validation error: {err}")
if request.architecture in BSP_DEFAULT_FAMILIES:
risk_flags.append(
f"Architecture '{request.architecture}' uses BSP defaults for some pin defines. "
"Verify which macros are inherited before declaring them explicitly in variant.h."
)
if not matched:
risk_flags.append(
"No closely matched existing board found for this architecture. "
"Manual review of variant structure is required."
)
next_actions: list[str] = []
if validation_errors:
next_actions.append("Resolve validation errors before proceeding.")
blocking_gaps = [g for g in gaps if g.blocking]
non_blocking_gaps = [g for g in gaps if not g.blocking]
for gap in blocking_gaps:
next_actions.append(
f"Provide {gap.required_evidence} for {gap.category} ({gap.affected_artifact})."
)
if non_blocking_gaps:
next_actions.append(
f"Review {len(non_blocking_gaps)} non-blocking gap(s) before merging scaffold output."
)
if not next_actions:
next_actions.append(
"All required evidence is present. Proceed to scaffold generation."
)
scaffold_ready = len(validation_errors) == 0 and all(not g.blocking for g in gaps)
return IntakeAssessment(
request=request,
expected_artifacts=expected_artifacts,
required_metadata=required_metadata,
matched_patterns=matched,
evidence_gaps=gaps,
risk_flags=risk_flags,
next_actions=next_actions,
scaffold_ready=scaffold_ready,
)
# ---------------------------------------------------------------------------
# T022 – render_assessment_markdown
# ---------------------------------------------------------------------------
def render_assessment_markdown(assessment: IntakeAssessment) -> str:
req = assessment.request
lines: list[str] = []
lines.append(f"# Board Intake Assessment: `{req.environment_name or '(unnamed)'}`")
lines.append("")
lines.append(
f"**Scaffold ready**: {'✅ Yes' if assessment.scaffold_ready else '❌ No — see blocking gaps below'}"
)
lines.append("")
lines.append("## Request Summary")
lines.append("")
lines.append(f"- **Environment name**: `{req.environment_name}`")
lines.append(f"- **Hardware model**: `{req.hardware_model}`")
lines.append(
f"- **Hardware model slug**: `{req.hardware_model_slug or '(not set)'}`"
)
lines.append(f"- **Display name**: {req.display_name}")
lines.append(f"- **Architecture**: `{req.architecture}`")
if req.actively_supported is not None:
lines.append(f"- **Actively supported**: {req.actively_supported}")
if req.support_level:
lines.append(f"- **Support level**: {req.support_level}")
if req.source_materials:
lines.append("- **Source materials**:")
for src in req.source_materials:
lines.append(f" - {src}")
if req.board_notes:
lines.append(f"- **Board notes**: {req.board_notes}")
lines.append("")
lines.append("## Expected Artifacts")
lines.append("")
for artifact in assessment.expected_artifacts:
lines.append(f"- {artifact}")
lines.append("")
lines.append("## Required Metadata")
lines.append("")
for key in assessment.required_metadata:
lines.append(
f"- `{key}`" if key.startswith("custom_meshtastic") else f"- {key}"
)
lines.append("")
lines.append("## Matched Repository Patterns")
lines.append("")
if assessment.matched_patterns:
for pattern in assessment.matched_patterns:
name = pattern.get("display_name") or pattern.get("environment", "")
env = pattern.get("environment", "")
arch = pattern.get("architecture", "")
vdir = pattern.get("variant_dir", "")
lines.append(f"- **{name}** (`{env}`, {arch}) — `{vdir}`")
else:
lines.append("- No closely matched patterns found for this architecture.")
lines.append("")
blocking = [g for g in assessment.evidence_gaps if g.blocking]
non_blocking = [g for g in assessment.evidence_gaps if not g.blocking]
lines.append("## Evidence Gaps")
lines.append("")
if blocking:
lines.append("### Blocking")
lines.append("")
for gap in blocking:
lines.append(f"- **[{gap.category}]** {gap.description}")
lines.append(f" - Affected: `{gap.affected_artifact}`")
lines.append(f" - Required evidence: {gap.required_evidence}")
else:
lines.append("*No blocking evidence gaps.*")
lines.append("")
if non_blocking:
lines.append("### Non-blocking (review before merge)")
lines.append("")
for gap in non_blocking:
lines.append(f"- **[{gap.category}]** {gap.description}")
lines.append(f" - Affected: `{gap.affected_artifact}`")
lines.append(f" - Required evidence: {gap.required_evidence}")
lines.append("")
if assessment.risk_flags:
lines.append("## Risk Flags")
lines.append("")
for flag in assessment.risk_flags:
lines.append(f"- {flag}")
lines.append("")
lines.append("## Next Actions")
lines.append("")
for i, action in enumerate(assessment.next_actions, 1):
lines.append(f"{i}. {action}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# T023 – CLI entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Evaluate a board intake request against the repository hardware context."
)
parser.add_argument("intake", help="Path to the intake JSON file")
parser.add_argument(
"--output",
default="-",
help="Output path for the assessment markdown (default: stdout)",
)
parser.add_argument(
"--validate",
action="store_true",
help="Exit with code 1 if scaffold_ready is false (useful for CI gate checks)",
)
parser.add_argument(
"--context",
default=str(DEFAULT_CONTEXT_PATH),
help="Path to docs/hardware-support-context.md (default: auto-detected)",
)
args = parser.parse_args()
intake_path = Path(args.intake)
if not intake_path.exists():
print(f"Error: intake file not found: {intake_path}", file=sys.stderr)
sys.exit(2)
request = BoardIntakeRequest.from_json(intake_path)
context = load_hardware_context(Path(args.context))
assessment = assess_intake(request, context)
report = render_assessment_markdown(assessment)
if args.output == "-":
print(report)
else:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(report, encoding="utf-8")
print(f"Assessment written to {output_path}")
if args.validate and not assessment.scaffold_ready:
sys.exit(1)
if __name__ == "__main__":
main()
-392
View File
@@ -1,392 +0,0 @@
#!/usr/bin/env python3
"""Scaffold board-support files from a validated intake assessment."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from board_intake import (
BoardIntakeRequest,
EvidenceGap,
IntakeAssessment,
assess_intake,
load_hardware_context,
render_assessment_markdown,
)
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT_ROOT = ROOT / "generated" / "hardware-support"
ARCH_BASE_ENV = {
"esp32": "esp32_base",
"esp32-s3": "esp32s3_base",
"esp32-c3": "esp32c3_base",
"esp32-c6": "esp32c6_base",
"esp32s2": "esp32s2_base",
"nrf52840": "nrf52_base",
"rp2040": "rp2040_base",
"rp2350": "rp2350_base",
"stm32": "stm32_base",
"native": "native_base",
}
ARCH_VARIANT_ROOT = {
"esp32": "esp32",
"esp32-s3": "esp32s3",
"esp32-c3": "esp32c3",
"esp32-c6": "esp32c6",
"esp32s2": "esp32s2",
"nrf52840": "nrf52840",
"rp2040": "rp2040",
"rp2350": "rp2350",
"stm32": "stm32",
"native": "native",
}
PLACEHOLDER_DEFINES = {
"status": [
"#define LED_PIN // TODO: verify status LED pin if present",
],
"input": [
"#define BUTTON_PIN // TODO: verify user button pin",
],
"display": [
"#define HAS_SCREEN 1",
"#define USE_SSD1306 // TODO: verify display controller",
"#define I2C_SCL // TODO: verify display I2C clock pin",
"#define I2C_SDA // TODO: verify display I2C data pin",
],
"GPS": [
"#define GPS_RX_PIN // TODO: verify GPS RX pin or remove if GPS absent",
"#define GPS_TX_PIN // TODO: verify GPS TX pin or remove if GPS absent",
],
"power": [
"#define BATTERY_PIN // TODO: verify battery sense pin",
"#define ADC_MULTIPLIER // TODO: verify voltage divider ratio",
],
"radio": [
"#define USE_SX1262 // TODO: verify radio chip selection from schematic",
"#define SX126X_CS // TODO: verify radio chip-select pin",
"#define SX126X_BUSY // TODO: verify radio busy pin",
"#define SX126X_DIO1 // TODO: verify radio IRQ pin",
"#define SX126X_RESET // TODO: verify radio reset pin",
"#define LORA_SCK // TODO: verify radio SPI clock pin",
"#define LORA_MISO // TODO: verify radio SPI MISO pin",
"#define LORA_MOSI // TODO: verify radio SPI MOSI pin",
],
}
DEFINE_PATTERN = re.compile(r"^#define\s+([A-Za-z0-9_]+)(?:\s+(.*?))?\s*(?://.*)?$")
CATEGORY_MACROS = {
"status": ["LED_POWER", "LED_PIN", "LED_STATE_ON", "VEXT_ENABLE"],
"input": ["BUTTON_PIN", "BUTTON_NEED_PULLUP", "ROTARY_A", "ROTARY_B"],
"display": [
"HAS_SCREEN",
"USE_SSD1306",
"USE_SH1106",
"USE_TFTDISPLAY",
"USE_ST7789",
"I2C_SCL",
"I2C_SDA",
"I2C_SCL1",
"I2C_SDA1",
"TFT_HEIGHT",
"TFT_WIDTH",
"TFT_OFFSET_X",
"TFT_OFFSET_Y",
"SCREEN_ROTATE",
"SCREEN_TRANSITION_FRAMERATE",
],
"GPS": [
"HAS_GPS",
"GPS_RX_PIN",
"GPS_TX_PIN",
"GPS_BAUDRATE",
"PIN_GPS_PPS",
"GPS_THREAD_INTERVAL",
],
"power": [
"ADC_CTRL",
"ADC_CTRL_ENABLED",
"BATTERY_PIN",
"ADC_CHANNEL",
"ADC_ATTENUATION",
"ADC_MULTIPLIER",
"USE_POWERSAVE",
"SLEEP_TIME",
],
"radio": [
"USE_SX1262",
"USE_SX1268",
"USE_LR1121",
"USE_SX1280",
"LORA_DIO0",
"LORA_RESET",
"LORA_DIO1",
"LORA_DIO2",
"LORA_SCK",
"LORA_MISO",
"LORA_MOSI",
"LORA_CS",
"SX126X_CS",
"SX126X_DIO1",
"SX126X_BUSY",
"SX126X_RESET",
"SX126X_DIO2_AS_RF_SWITCH",
"SX126X_DIO3_TCXO_VOLTAGE",
"LR1121_IRQ_PIN",
"LR1121_NRESET_PIN",
"LR1121_BUSY_PIN",
"LR1121_SPI_NSS_PIN",
"LR1121_SPI_SCK_PIN",
"LR1121_SPI_MOSI_PIN",
"LR1121_SPI_MISO_PIN",
"LR11X0_DIO3_TCXO_VOLTAGE",
"LR11X0_DIO_AS_RF_SWITCH",
],
}
def slugify_variant_dir(req: BoardIntakeRequest) -> str:
return req.environment_name.replace("_", "-").lower()
def target_variant_dir(req: BoardIntakeRequest) -> Path:
arch_root = ARCH_VARIANT_ROOT.get(req.architecture, req.architecture)
return Path("variants") / arch_root / slugify_variant_dir(req)
def pick_pattern(assessment: IntakeAssessment) -> dict | None:
return assessment.matched_patterns[0] if assessment.matched_patterns else None
def source_basis_lines(assessment: IntakeAssessment, context: dict) -> list[str]:
lines = [
f"// Intake environment: {assessment.request.environment_name}",
f"// Intake architecture: {assessment.request.architecture}",
]
pattern = pick_pattern(assessment)
if pattern:
lines.append(
"// Repository pattern basis: "
f"{pattern.get('environment', '')} -> {pattern.get('variant_dir', '')}"
)
lines.append(
f"// Context source: {context.get('context_path', 'docs/hardware-support-context.md')}"
)
return lines
def parse_variant_defines(variant_path: Path) -> dict[str, str]:
defines: dict[str, str] = {}
if not variant_path.exists():
return defines
for raw_line in variant_path.read_text(encoding="utf-8").splitlines():
match = DEFINE_PATTERN.match(raw_line.strip())
if not match:
continue
name = match.group(1)
value = (match.group(2) or "1").strip()
defines[name] = value
return defines
def infer_category_lines(pattern_variant_path: Path, category: str) -> list[str]:
defines = parse_variant_defines(pattern_variant_path)
lines: list[str] = []
for macro_name in CATEGORY_MACROS[category]:
value = defines.get(macro_name)
if value is None:
continue
if value == "1":
lines.append(f"#define {macro_name}")
else:
lines.append(f"#define {macro_name} {value}")
if lines:
return lines
return PLACEHOLDER_DEFINES[category]
# T028
def generate_variant_h(assessment: IntakeAssessment, context: dict) -> str:
req = assessment.request
lines: list[str] = []
lines.extend(source_basis_lines(assessment, context))
lines.append("")
lines.append("#pragma once")
lines.append("")
pattern = pick_pattern(assessment)
pattern_variant_path = None
if pattern and pattern.get("variant_dir"):
pattern_variant_path = (ROOT / str(pattern["variant_dir"]) / "variant.h").resolve()
for category in ("status", "input", "display", "GPS", "power", "radio"):
lines.append(f"// {category}")
if pattern_variant_path is not None:
lines.extend(infer_category_lines(pattern_variant_path, category))
else:
lines.extend(PLACEHOLDER_DEFINES[category])
lines.append("")
lines.append("// Board identity")
macro_name = req.hardware_model_slug or req.environment_name.upper().replace(
"-", "_"
)
lines.append(f"#define {macro_name} 1")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# T029
def generate_platformio_env(assessment: IntakeAssessment) -> str:
req = assessment.request
extends = ARCH_BASE_ENV.get(req.architecture, f"{req.architecture}_base")
variant_dir = target_variant_dir(req)
build_define = (
req.hardware_model_slug or req.environment_name.upper().replace("-", "_")
).replace(" ", "_")
lines = [
f"[env:{req.environment_name}]",
f"custom_meshtastic_hw_model = {req.hardware_model}",
f"custom_meshtastic_hw_model_slug = {req.hardware_model_slug or 'TODO_SET_HW_MODEL_SLUG'}",
f"custom_meshtastic_architecture = {req.architecture}",
f"custom_meshtastic_actively_supported = {str(req.actively_supported).lower() if req.actively_supported is not None else 'TODO_SET_ACTIVELY_SUPPORTED'}",
f"custom_meshtastic_support_level = {req.support_level or 'TODO_SET_SUPPORT_LEVEL'}",
f"custom_meshtastic_display_name = {req.display_name}",
"custom_meshtastic_images = TODO_SET_IMAGES",
"custom_meshtastic_tags = TODO_SET_TAGS",
"custom_meshtastic_requires_dfu = TODO_SET_REQUIRES_DFU",
"custom_meshtastic_partition_scheme = TODO_SET_PARTITION_SCHEME",
"",
"board = TODO_SET_PLATFORMIO_BOARD",
f"extends = {extends}",
"build_flags =",
f" ${{{extends}.build_flags}}",
f" -D {build_define}",
f" -I {variant_dir.as_posix()}",
]
return "\n".join(lines).rstrip() + "\n"
# T030
def annotate_unresolved(content: str, gaps: list[EvidenceGap], is_ini: bool = False) -> str:
annotations = [
gap
for gap in gaps
if not gap.blocking
and gap.category in {"radio", "display", "input", "GPS", "power", "metadata"}
]
if not annotations:
return content
lines = content.splitlines()
todo_lines = [f"; TODO: verify — {gap.description}" for gap in annotations]
if is_ini:
# For INI files, insert TODOs after the first section header
if lines and lines[0].startswith("["):
return "\n".join(lines[:1] + todo_lines + [""] + lines[1:]) + "\n"
else:
# For .h files, prepend TODOs with C++ comment syntax
todo_lines = [f"// TODO: verify — {gap.description}" for gap in annotations]
return "\n".join(todo_lines + [""] + lines) + "\n"
return content
# T031
def scaffold_board(
assessment: IntakeAssessment, context: dict, output_dir: Path
) -> dict[str, Path]:
req = assessment.request
variant_dir = output_dir / target_variant_dir(req)
variant_dir.mkdir(parents=True, exist_ok=True)
variant_h_path = variant_dir / "variant.h"
platformio_path = variant_dir / "platformio.ini"
variant_h_content = annotate_unresolved(
generate_variant_h(assessment, context), assessment.evidence_gaps, is_ini=False
)
platformio_content = annotate_unresolved(
generate_platformio_env(assessment), assessment.evidence_gaps, is_ini=True
)
variant_h_path.write_text(variant_h_content, encoding="utf-8")
platformio_path.write_text(platformio_content, encoding="utf-8")
generated = {
"variant_h": variant_h_path,
"platformio": platformio_path,
}
if req.architecture in {"esp32", "esp32-s3", "esp32-c3", "esp32-c6"}:
variant_cpp_path = variant_dir / "variant.cpp"
variant_cpp_content = annotate_unresolved(
"\n".join(
[
'#include "variant.h"',
"",
"// Optional board-specific initialization hooks go here.",
"// Leave this file out if the board does not need custom startup behavior.",
]
)
+ "\n",
assessment.evidence_gaps,
)
variant_cpp_path.write_text(variant_cpp_content, encoding="utf-8")
generated["variant_cpp"] = variant_cpp_path
return generated
# T032 + T033
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate board-support scaffold files from an intake JSON file."
)
parser.add_argument("intake", help="Path to intake JSON")
parser.add_argument(
"--output-dir",
default=str(DEFAULT_OUTPUT_ROOT),
help="Directory where scaffold files will be written",
)
args = parser.parse_args()
intake_path = Path(args.intake)
request = BoardIntakeRequest.from_json(intake_path)
context = load_hardware_context()
context["context_path"] = "docs/hardware-support-context.md"
assessment = assess_intake(request, context)
if not assessment.scaffold_ready:
print(render_assessment_markdown(assessment))
sys.exit(1)
generated = scaffold_board(assessment, context, Path(args.output_dir))
print("Generated scaffold files:")
for name, path in generated.items():
print(f"- {name}: {path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,30 @@
---
Lora:
## Ebyte E80-900M22S
## This is a bit experimental
##
##
Module: lr1121
gpiochip: 1 # subtract 32 from the gpio numbers
DIO3_TCXO_VOLTAGE: 1.8
CS: 16 #pin6 / GPIO48 1C0
IRQ: 23 #pin17 / GPIO55 1C7
Busy: 22 #pin16 / GPIO54 1C6
Reset: 25 #pin13 / GPIO57 1D1
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
spiSpeed: 2000000
rfswitch_table:
pins: [DIO5, DIO6, DIO7]
MODE_STBY: [LOW, LOW, LOW]
MODE_RX: [LOW, HIGH, LOW]
MODE_TX: [HIGH, HIGH, LOW]
MODE_TX_HP: [HIGH, LOW, LOW]
MODE_TX_HF: [LOW, LOW, LOW]
MODE_GNSS: [LOW, LOW, HIGH]
MODE_WIFI: [LOW, LOW, LOW]
General:
MACAddressSource: eth0
@@ -0,0 +1,46 @@
---
Lora:
## Ebyte E80-900M22S
## This is a bit experimental
##
##
Module: lr1121
gpiochip: 1 # subtract 32 from the gpio numbers
DIO3_TCXO_VOLTAGE: 1.8
CS: 16 #pin6 / GPIO48 1C0
IRQ: 23 #pin17 / GPIO55 1C7
Busy: 22 #pin16 / GPIO54 1C6
Reset: 25 #pin13 / GPIO57 1D1
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
spiSpeed: 2000000
rfswitch_table:
pins:
- DIO5
- DIO6
MODE_STBY:
- LOW
- LOW
MODE_RX:
- HIGH
- LOW
MODE_TX:
- HIGH
- HIGH
MODE_TX_HP:
- LOW
- HIGH
MODE_TX_HF:
- LOW
- LOW
MODE_GNSS:
- LOW
- LOW
MODE_WIFI:
- LOW
- LOW
General:
MACAddressSource: eth0
@@ -0,0 +1,30 @@
---
Lora:
## Ebyte E80-900M22S
## This is a bit experimental
##
##
Module: lr1121
gpiochip: 1 # subtract 32 from the gpio numbers
DIO3_TCXO_VOLTAGE: 1.8
CS: 16 #pin6 / GPIO48 1C0
IRQ: 23 #pin17 / GPIO55 1C7
Busy: 22 #pin16 / GPIO54 1C6
Reset: 25 #pin13 / GPIO57 1D1
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
spiSpeed: 2000000
rfswitch_table:
pins: [DIO5, DIO6, DIO7]
MODE_STBY: [LOW, LOW, LOW]
MODE_RX: [LOW, LOW, LOW]
MODE_TX: [LOW, HIGH, LOW]
MODE_TX_HP: [HIGH, LOW, LOW]
# MODE_TX_HF: []
# MODE_GNSS: []
MODE_WIFI: [LOW, LOW, LOW]
General:
MACAddressSource: eth0
+18
View File
@@ -0,0 +1,18 @@
# Station G3 motherboard with a Raspberry Pi Zero 2W as the MCU daughterboard.
# Verify spidev / I2C device paths for your OS — they may differ.
Meta:
name: Station G3
support: community
compatible:
- raspberry-pi
Lora:
Module: sx1262
IRQ: 22 # BCM pin — wiki spec
Reset: 16 # BCM pin — wiki spec
Busy: 24 # BCM pin — wiki spec
# CS: 8 # BCM 8 = SPI0 CE0 (default); uncomment only to override
DIO2_AS_RF_SWITCH: true
DIO3_TCXO_VOLTAGE: true
spidev: spidev0.0
# SX126X_MAX_POWER: 19 # matches Station G2 firmware cap; raise carefully per PA jumper mode
-14
View File
@@ -1,14 +0,0 @@
{
"environment_name": "new_esp32s3_board",
"hardware_model": "999",
"hardware_model_slug": "NEW_ESP32S3_BOARD",
"display_name": "Acme ESP32-S3 Dev Board v1",
"architecture": "esp32-s3",
"actively_supported": true,
"support_level": "1",
"source_materials": [
"https://example.com/acme-esp32s3-schematic.pdf",
"https://example.com/acme-esp32s3-pinout.png"
],
"board_notes": "Initial bring-up for v1 PCB only. SX1262 radio on SPI2, OLED on I2C bus."
}
-6
View File
@@ -1,6 +0,0 @@
{
"environment_name": "hypothetical_esp32s3_board",
"hardware_model": "9981",
"display_name": "Hypothetical ESP32-S3 Board",
"architecture": "esp32-s3"
}
-13
View File
@@ -1,13 +0,0 @@
{
"environment_name": "new_nrf52_tracker",
"hardware_model": "998",
"hardware_model_slug": "NEW_NRF52_TRACKER",
"display_name": "Acme nRF52840 Tracker v2",
"architecture": "nrf52840",
"actively_supported": null,
"support_level": "2",
"source_materials": [
"https://example.com/acme-tracker-pinout.pdf"
],
"board_notes": "Two revisions exist: v2 (SSD1306 OLED) and v2a (no display). This intake covers v2 only."
}
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""Deterministic seed-data generator for the fake NodeDB fixture pipeline.
Writes a JSONL file describing N fake-but-realistic Meshtastic peers.
The output is hand-editable and committed; a sibling compile step
(bin/seed-json-to-proto.py) turns it into a binary `meshtastic_NodeDatabase`
v25 protobuf with fresh "now-relative" timestamps.
Determinism contract:
Same --seed -> byte-identical JSONL output, regardless of wall clock.
All timestamps are stored as `*_offset_sec` (seconds before "now"); the
compile step resolves them to absolute epochs at compile time.
Structural fields covered:
* NodeInfoLite header: num, long_name, short_name, hw_model, role,
public_key, snr, channel, hops_away, next_hop, bitfield flags
* PositionLite: lat/long Gaussian around --centroid, altitude, source
* DeviceMetrics: battery/voltage/util/uptime
* EnvironmentMetrics: temp/humidity/pressure/iaq
* StatusMessage: error_code (usually zero)
Active-board allow-list:
hw_model values are restricted to the intersection of
(a) variants with `custom_meshtastic_support_level = 1` in
variants/*/*/platformio.ini, AND
(b) values present in the `HardwareModel` enum in mesh.proto.
See HW_MODEL_WEIGHTS below. Deprecated boards (legacy TLORA / Heltec V1-2 /
classic TBEAM / TBEAM_V0P7 / Nano G1 / etc.) and fuzzer-only sentinels
(PORTDUINO, ANDROID_SIM, DIY_V1, ...) are excluded.
Active-role allow-list:
Excludes ROUTER_CLIENT (deprecated v2.3.15) and REPEATER (deprecated v2.7.11).
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import math
import pathlib
import random
import sys
# --------------------------------------------------------------------------
# Active-board allow-list (intersection of tier-1 variants + HardwareModel enum).
# Refresh by running:
# for f in $(find variants -name 'platformio.ini' | xargs grep -lE 'custom_meshtastic_support_level = 1'); do
# grep custom_meshtastic_hw_model_slug $f | awk -F= '{print $2}' | tr -d ' ';
# done | sort -u | comm -12 - <(python3 -c "from meshtastic.protobuf.mesh_pb2 import HardwareModel; print('\\n'.join(HardwareModel.keys()))" | sort)
# --------------------------------------------------------------------------
HW_MODEL_WEIGHTS: dict[str, float] = {
"HELTEC_V3": 14.0,
"T_DECK": 9.0,
"HELTEC_V4": 8.0,
"RAK4631": 8.0,
"HELTEC_MESH_POCKET": 6.0,
"TRACKER_T1000_E": 5.0,
"HELTEC_MESH_NODE_T114": 5.0,
"T_DECK_PRO": 5.0,
"LILYGO_TBEAM_S3_CORE": 4.0,
"HELTEC_WIRELESS_PAPER": 4.0,
"HELTEC_WSL_V3": 3.0,
"T_ECHO": 3.0,
"HELTEC_WIRELESS_TRACKER": 3.0,
"HELTEC_WIRELESS_TRACKER_V2": 2.0,
"HELTEC_VISION_MASTER_E290": 2.0,
"HELTEC_MESH_SOLAR": 2.0,
"SEEED_WIO_TRACKER_L1": 2.0,
"T_LORA_PAGER": 1.5,
"HELTEC_VISION_MASTER_E213": 1.5,
"T_ECHO_PLUS": 1.0,
"MUZI_BASE": 1.0,
"WISMESH_TAP_V2": 1.0,
"THINKNODE_M2": 1.0,
"THINKNODE_M5": 1.0,
"TLORA_T3_S3": 1.0,
# Long tail (uniform low weight across remaining tier-1 boards):
"HELTEC_V4_R8": 0.3,
"HELTEC_VISION_MASTER_T190": 0.3,
"HELTEC_HT62": 0.3,
"HELTEC_MESH_NODE_T096": 0.3,
"M5STACK_C6L": 0.3,
"MINI_EPAPER_S3": 0.3,
"MUZI_R1_NEO": 0.3,
"NOMADSTAR_METEOR_PRO": 0.3,
"RAK3312": 0.3,
"RAK3401": 0.3,
"SEEED_SOLAR_NODE": 0.3,
"SEEED_WIO_TRACKER_L1_EINK": 0.3,
"SENSECAP_INDICATOR": 0.3,
"TBEAM_1_WATT": 0.3,
"THINKNODE_M1": 0.3,
"THINKNODE_M3": 0.3,
"THINKNODE_M6": 0.3,
"T_ECHO_LITE": 0.3,
"WISMESH_TAG": 0.3,
"WISMESH_TAP": 0.3,
"XIAO_NRF52_KIT": 0.3,
"CROWPANEL": 0.3,
}
# Non-deprecated roles only.
ROLE_WEIGHTS: dict[str, float] = {
"CLIENT": 75.0,
"CLIENT_MUTE": 5.0,
"ROUTER": 7.0,
"TRACKER": 3.0,
"SENSOR": 2.0,
"CLIENT_HIDDEN": 2.0,
"ROUTER_LATE": 2.0,
"CLIENT_BASE": 2.0,
"TAK": 1.0,
"TAK_TRACKER": 0.5,
"LOST_AND_FOUND": 0.5,
}
# Name pools — 60 firsts × 60 lasts = 3600 combinations.
FIRSTS = [
"Quick", "Brave", "Silent", "Wild", "Lone", "Bright", "Red", "Blue",
"Green", "Black", "White", "Iron", "Steel", "Copper", "Silver", "Gold",
"Stone", "River", "Forest", "Mountain", "Canyon", "Desert", "Storm", "Sky",
"Solar", "Lunar", "Dawn", "Dusk", "Misty", "Frosty", "Sunny", "Shady",
"Happy", "Sleepy", "Drowsy", "Sneaky", "Sharp", "Smooth", "Rough", "Loud",
"Soft", "Slow", "Fast", "Tall", "Short", "Old", "New", "Tiny",
"Giant", "Hidden", "Lost", "Found", "Wandering", "Roving", "Drifting", "Floating",
"Burning", "Frozen", "Whispering", "Howling",
]
LASTS = [
"Phoenix", "Lion", "Bear", "Wolf", "Hawk", "Eagle", "Fox", "Lynx",
"Cougar", "Coyote", "Raven", "Owl", "Crow", "Falcon", "Heron", "Crane",
"Otter", "Badger", "Bison", "Elk", "Moose", "Stag", "Doe", "Hare",
"Marmot", "Mole", "Beaver", "Squirrel", "Mustang", "Bronco", "Pony", "Colt",
"Cobra", "Viper", "Mamba", "Adder", "Gecko", "Iguana", "Tortoise", "Turtle",
"Salmon", "Trout", "Bass", "Pike", "Shark", "Whale", "Dolphin", "Seal",
"Cactus", "Yucca", "Sage", "Juniper", "Pine", "Cedar", "Aspen", "Oak",
"Bluff", "Mesa", "Arroyo", "Ridge",
]
# Brief callsign pool for licensed-looking suffixes.
CALLSIGN_PREFIXES = ["KX", "WD", "N5", "KE", "AB", "W5", "K1", "KQ", "AE", "NM"]
# Only emojis that fit in 4 UTF-8 bytes (no variation selectors). short_name's
# nanopb max_size:5 (incl. NUL) limits content to 4 bytes. ❄️ / ☀️ would be
# 6 bytes due to U+FE0F variation selector — explicitly excluded.
EMOJI_SHORTNAMES = ["🦊", "🐺", "🦅", "🐢", "🌵", "🔥", "🌙",
"🌊", "🗻", "🌲", "🦌", "🐝", "🦂", "🦉",
"🦇", "🦋"]
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
NUM_RESERVED = 4 # firmware reserves 0..3 (per NodeDB constants)
NUM_MAX_EXCLUSIVE = 0x80000000 # restrict to positive int32 range for readability
def _weighted_choice(rng: random.Random, weights: dict[str, float]) -> str:
"""Deterministic weighted pick. Uses sorted keys so dict order is fixed."""
keys = sorted(weights.keys())
totals = [weights[k] for k in keys]
return rng.choices(keys, weights=totals, k=1)[0]
def _gen_long_name(rng: random.Random, is_licensed: bool) -> str:
base = f"{rng.choice(FIRSTS)} {rng.choice(LASTS)}"
if is_licensed:
prefix = rng.choice(CALLSIGN_PREFIXES)
# Two trailing alpha chars after the digit; keep within 25 - len(base) - 1
suffix = f" {prefix}{rng.randint(0,9)}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}"
# nanopb max_size:25 means C string fits 24 bytes + NUL.
if len(base) + len(suffix) <= 24:
base = base + suffix
# Hard cap to 24 chars (nanopb max_size:25 minus NUL).
return base[:24]
def _gen_short_name(rng: random.Random, long_name: str) -> str:
# 10% emoji-only short_name
if rng.random() < 0.10:
return rng.choice(EMOJI_SHORTNAMES)
first_char = long_name[0].upper() if long_name else "X"
alphanums = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return first_char + "".join(rng.choices(alphanums, k=3))
def _gen_hops_away(rng: random.Random) -> int:
# Geometric-ish: 0→55%, 1→25%, 2→12%, 3→5%, 4→2%, 5+→1%
r = rng.random()
if r < 0.55:
return 0
if r < 0.80:
return 1
if r < 0.92:
return 2
if r < 0.97:
return 3
if r < 0.99:
return 4
return rng.randint(5, 7)
def _gen_position(
rng: random.Random,
centroid_lat: float,
centroid_lon: float,
spread_km: float,
last_heard_offset_sec: int,
) -> dict:
# 1 deg ≈ 111 km at the equator; we use this as a flat approximation.
lat = centroid_lat + rng.gauss(0.0, spread_km / 111.0)
lon = centroid_lon + rng.gauss(0.0, spread_km / 111.0)
altitude = max(0, round(rng.gauss(1376.0, 250.0))) # T or C valley floor + relief
# Position was reported up to 300s before last_heard.
time_offset_sec = last_heard_offset_sec + rng.randint(0, 300)
return {
"latitude": round(lat, 6),
"longitude": round(lon, 6),
"altitude": altitude,
"time_offset_sec": time_offset_sec,
"location_source": "LOC_INTERNAL",
}
def _gen_telemetry(rng: random.Random) -> dict:
# 5% plugged-in (battery_level == 101); rest uniform [10..100].
if rng.random() < 0.05:
battery_level = 101
voltage = 4.20
else:
battery_level = rng.randint(10, 100)
voltage = round(3.3 + (battery_level / 100.0) * 0.9, 3)
# Beta distributions for low/right-skewed metrics; randomly draw via gammavariate.
def _beta(a: float, b: float) -> float:
x = rng.gammavariate(a, 1.0)
y = rng.gammavariate(b, 1.0)
return x / (x + y)
channel_utilization = round(_beta(2.0, 15.0) * 100.0, 2)
air_util_tx = round(_beta(1.5, 20.0) * 10.0, 3)
uptime_seconds = int(rng.expovariate(1.0 / 86400.0))
return {
"battery_level": battery_level,
"voltage": voltage,
"channel_utilization": channel_utilization,
"air_util_tx": air_util_tx,
"uptime_seconds": uptime_seconds,
}
def _gen_environment(rng: random.Random) -> dict:
return {
"temperature": round(rng.gauss(22.0, 8.0), 2),
"relative_humidity": round(min(100.0, max(0.0, rng.gauss(55.0, 20.0))), 2),
"barometric_pressure": round(rng.gauss(1013.0, 8.0), 2),
"iaq": int(min(500, max(0, round(rng.gauss(50.0, 30.0))))),
}
def _gen_status(rng: random.Random) -> dict:
# `StatusMessage` (mesh.proto:1445) has a single free-form `string status`.
# Most peers report a healthy short status; occasional alert string.
healthy = ["OK", "online", "active", "running", "ready", "nominal"]
alert = ["low-batt", "no-gps", "weak-signal", "rebooted", "offline-soon"]
if rng.random() < 0.92:
return {"status": rng.choice(healthy)}
return {"status": rng.choice(alert)}
def _gen_node(
rng: random.Random,
num: int,
centroid_lat: float,
centroid_lon: float,
spread_km: float,
coverage: dict[str, float],
last_heard_mean_sec: int,
last_heard_max_sec: int,
) -> dict:
is_licensed = rng.random() < 0.05
long_name = _gen_long_name(rng, is_licensed)
short_name = _gen_short_name(rng, long_name)
hw_model = _weighted_choice(rng, HW_MODEL_WEIGHTS)
role = _weighted_choice(rng, ROLE_WEIGHTS)
has_public_key = rng.random() < 0.92
public_key_hex = (
"".join(f"{rng.randint(0,255):02x}" for _ in range(32)) if has_public_key else ""
)
snr = round(max(-20.0, min(12.0, rng.gauss(6.0, 4.0))), 2)
channel = 0 if rng.random() < 0.90 else rng.randint(1, 7)
hops_away = _gen_hops_away(rng)
next_hop = rng.randint(0, 255) if hops_away > 0 else 0
last_heard_offset_sec = int(min(rng.expovariate(1.0 / last_heard_mean_sec), last_heard_max_sec))
bitfield = {
"has_user": True,
"is_favorite": rng.random() < 0.08,
"is_muted": rng.random() < 0.03,
"via_mqtt": rng.random() < 0.12,
"is_ignored": rng.random() < 0.01,
"is_licensed": is_licensed,
"has_is_unmessagable": True,
"is_unmessagable": rng.random() < 0.02,
"is_key_manually_verified": rng.random() < 0.04,
}
node: dict = {
"num": f"0x{num:08x}",
"long_name": long_name,
"short_name": short_name,
"hw_model": hw_model,
"role": role,
"public_key_hex": public_key_hex,
"snr": snr,
"channel": channel,
"hops_away": hops_away,
"next_hop": next_hop,
"last_heard_offset_sec": last_heard_offset_sec,
"bitfield": bitfield,
"position": (
_gen_position(rng, centroid_lat, centroid_lon, spread_km, last_heard_offset_sec)
if rng.random() < coverage["position"]
else None
),
"telemetry": _gen_telemetry(rng) if rng.random() < coverage["telemetry"] else None,
"environment": _gen_environment(rng) if rng.random() < coverage["environment"] else None,
"status": _gen_status(rng) if rng.random() < coverage["status"] else None,
}
return node
def _parse_my_node_num(s: str | None) -> int | None:
if s is None:
return None
s = s.strip()
if s.startswith("0x") or s.startswith("0X"):
return int(s, 16)
return int(s)
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(
description="Deterministic JSONL seed for the fake NodeDB fixture.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--count", type=int, required=True, help="Number of fake nodes to emit.")
p.add_argument("--seed", type=int, required=True, help="Deterministic seed.")
p.add_argument("--out", required=True, help="Output JSONL path.")
p.add_argument(
"--centroid",
default="33.1284,-107.2528",
help="LAT,LON centroid (default: Truth or Consequences, NM).",
)
p.add_argument("--spread-km", type=float, default=60.0, help="Gaussian std-dev in km.")
p.add_argument("--position-coverage", type=float, default=0.85)
p.add_argument("--telemetry-coverage", type=float, default=0.70)
p.add_argument("--environment-coverage", type=float, default=0.25)
p.add_argument("--status-coverage", type=float, default=0.40)
p.add_argument("--my-node-num", default=None, help="Exclude this NodeNum from generated set (hex or dec).")
p.add_argument("--last-heard-mean-sec", type=int, default=3600)
p.add_argument("--last-heard-max-sec", type=int, default=7 * 86400)
args = p.parse_args(argv)
if args.count <= 0:
print("--count must be positive", file=sys.stderr)
return 2
try:
centroid_lat, centroid_lon = (float(s) for s in args.centroid.split(","))
except ValueError:
print(f"--centroid must be LAT,LON; got {args.centroid!r}", file=sys.stderr)
return 2
my_node_num = _parse_my_node_num(args.my_node_num)
rng = random.Random(args.seed)
# 1) Generate a unique deterministic set of NodeNums.
nums: set[int] = set()
while len(nums) < args.count:
n = rng.randrange(NUM_RESERVED, NUM_MAX_EXCLUSIVE)
if my_node_num is not None and n == my_node_num:
continue
nums.add(n)
ordered_nums = sorted(nums) # sort to fix output order independent of set hash
# 2) Per-node generation (in num order, single RNG continues).
coverage = {
"position": args.position_coverage,
"telemetry": args.telemetry_coverage,
"environment": args.environment_coverage,
"status": args.status_coverage,
}
nodes = [
_gen_node(
rng,
n,
centroid_lat,
centroid_lon,
args.spread_km,
coverage,
args.last_heard_mean_sec,
args.last_heard_max_sec,
)
for n in ordered_nums
]
# 3) Write JSONL.
out_path = pathlib.Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
# `generated_at_iso` is informational; it does NOT affect determinism because
# we derive it from the seed, not from wall clock. (Same seed -> same string.)
generated_at = _dt.datetime.fromtimestamp(args.seed, tz=_dt.timezone.utc).isoformat().replace("+00:00", "Z")
meta = {
"_meta": {
"version": 25,
"seed": args.seed,
"count": args.count,
"centroid": [centroid_lat, centroid_lon],
"spread_km": args.spread_km,
"generated_at_iso": generated_at,
"my_node_num_excluded": (None if my_node_num is None else f"0x{my_node_num:08x}"),
"coverage": coverage,
"last_heard_mean_sec": args.last_heard_mean_sec,
"last_heard_max_sec": args.last_heard_max_sec,
}
}
with out_path.open("w", encoding="utf-8") as f:
# `ensure_ascii=False` so emoji short_names survive. `sort_keys=True` for
# determinism (insertion order varies by Python version otherwise).
f.write(json.dumps(meta, ensure_ascii=False, sort_keys=True) + "\n")
for node in nodes:
f.write(json.dumps(node, ensure_ascii=False, sort_keys=True) + "\n")
print(f"wrote {args.count} nodes to {out_path} ({out_path.stat().st_size} bytes)", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-592
View File
@@ -1,592 +0,0 @@
#!/usr/bin/env python3
"""Generate a markdown inventory of board-support metadata and common target-definition macros."""
from __future__ import annotations
import argparse
import re
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VARIANTS_DIR = ROOT / "variants"
OUTPUT_PATH = ROOT / "docs" / "hardware-support-context.md"
PLATFORMIO_METADATA_KEYS = [
"custom_meshtastic_hw_model",
"custom_meshtastic_hw_model_slug",
"custom_meshtastic_architecture",
"custom_meshtastic_actively_supported",
"custom_meshtastic_support_level",
"custom_meshtastic_display_name",
"custom_meshtastic_images",
"custom_meshtastic_tags",
"custom_meshtastic_requires_dfu",
"custom_meshtastic_partition_scheme",
]
MACRO_PATTERNS = {
"Input": [
"BUTTON_PIN",
"BUTTON_PIN_TOUCH",
"ALT_BUTTON_PIN",
"CANCEL_BUTTON_PIN",
"ROTARY_",
"KB_",
"INPUTDRIVER_",
],
"Radio": [
"USE_RF95",
"USE_SX126",
"USE_SX128",
"USE_LLCC68",
"USE_LR11",
"LORA_",
"SX126X_",
"SX128X_",
"LR1121_",
"RF95_",
],
"GPS": [
"HAS_GPS",
"GPS_",
"PIN_GPS_",
"GPS_DEFAULT_NOT_PRESENT",
],
"Display": [
"HAS_SCREEN",
"USE_TFTDISPLAY",
"USE_TFT",
"USE_SSD",
"USE_SH",
"USE_ST",
"TFT_",
"OLED_",
"SCREEN_",
"PIN_EINK_",
"EINK_",
"DISPLAY_",
"LGFX_",
"HAS_TFT",
],
"I2C/SPI": [
"I2C_",
"SPI_",
"PIN_SPI",
"WIRE_",
],
"Power": [
"BATTERY_",
"ADC_",
"PIN_POWER",
"USE_POWERSAVE",
"SLEEP_TIME",
"XPOWERS_",
"HAS_AXP",
"HAS_PPM",
"HAS_BQ",
"EXT_NOTIFY_OUT",
"FAN_CTRL_PIN",
"RF95_FAN_EN",
],
"Connectivity/Other": [
"HAS_WIFI",
"HAS_BLUETOOTH",
"HAS_ETHERNET",
"HAS_NEOPIXEL",
"HAS_I2S",
"HAS_TOUCH",
"HAS_TOUCHSCREEN",
"HAS_NFC",
"NFC_",
"USE_XL9555",
"EXPANDS_",
"PCF",
"RTC",
],
}
def classify_macro(name: str) -> str:
for category, prefixes in MACRO_PATTERNS.items():
for prefix in prefixes:
if name.startswith(prefix):
return category
return "Other"
def parse_platformio_envs(path: Path) -> list[dict[str, object]]:
envs: list[dict[str, object]] = []
current: dict[str, object] | None = None
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith(";"):
continue
if line.startswith("[") and line.endswith("]"):
if current:
envs.append(current)
current = {"section": line[1:-1], "metadata": {}}
continue
if current is None or "=" not in line:
continue
key, value = [part.strip() for part in line.split("=", 1)]
if key in PLATFORMIO_METADATA_KEYS:
current["metadata"][key] = value
if current:
envs.append(current)
return envs
def parse_variant_macros(path: Path) -> tuple[dict[str, str], Counter[str]]:
defines: dict[str, str] = {}
category_counts: Counter[str] = Counter()
pattern = re.compile(r"^#define\s+([A-Za-z0-9_]+)(?:\s+(.*?))?\s*(?://.*)?$")
for raw_line in path.read_text(encoding="utf-8").splitlines():
match = pattern.match(raw_line.strip())
if not match:
continue
name = match.group(1)
value = (match.group(2) or "1").strip()
defines[name] = value
category_counts[classify_macro(name)] += 1
return defines, category_counts
MACRO_DESCRIPTIONS: dict[str, str] = {
# ---------- Input ----------
"BUTTON_PIN": "Primary user button GPIO pin number.",
"ALT_BUTTON_PIN": "Secondary / alternate button pin, used on boards with two buttons.",
"CANCEL_BUTTON_PIN": "Button wired to cancel/back actions (e.g., T-Deck cancel key).",
"BUTTON_NEED_PULLUP": "Set to 1 when the button GPIO requires the internal pull-up to be enabled.",
"BUTTON_PIN_ALT": "Alternative spelling used by some older board families for a second button.",
"KB_BL_PIN": "Keyboard backlight control pin (e.g., T-LoRa Pager keyboard).",
"KB_INT": "Keyboard interrupt input pin; signals a keypress to the MCU.",
"KB_POWERON": "Pin used to power-on or enable the keyboard peripheral.",
"KB_SLAVE_ADDRESS": "I2C address of the keyboard controller IC.",
"INPUTDRIVER_ENCODER_TYPE": "Selects the rotary encoder driver variant (0 = none, 1+ = specific type).",
"INPUTDRIVER_TWO_WAY_ROCKER": "Enables the two-way rocker input driver.",
"INPUTDRIVER_TWO_WAY_ROCKER_RIGHT": "GPIO pin for the rightward rocker direction.",
"INPUTDRIVER_TWO_WAY_ROCKER_LEFT": "GPIO pin for the leftward rocker direction.",
"INPUTDRIVER_TWO_WAY_ROCKER_BTN": "GPIO pin for the rocker click/press direction.",
"ROTARY_A": "First encoder channel pin (clock / A signal).",
"ROTARY_B": "Second encoder channel pin (data / B signal).",
# ---------- Radio ----------
"USE_SX1262": "Select the SX1262 sub-GHz LoRa chip driver.",
"USE_SX1268": "Select the SX1268 sub-GHz LoRa chip driver (higher power variant of SX1262).",
"USE_SX1280": "Select the SX1280 2.4 GHz LoRa chip driver.",
"USE_RF95": "Select the RFM95/SX1276 legacy LoRa chip driver.",
"USE_LLCC68": "Select the LLCC68 low-cost LoRa chip driver.",
"USE_LR1110": "Select the LR1110 wideband radio driver.",
"USE_LR1120": "Select the LR1120 wideband radio driver.",
"USE_LR1121": "Select the LR1121 wideband radio driver.",
"LORA_CS": "LoRa radio SPI chip-select GPIO pin.",
"LORA_RESET": "LoRa radio hardware-reset GPIO pin (active low).",
"LORA_DIO0": "LoRa radio DIO0 interrupt pin (RF95/SX1276 done/RxDone).",
"LORA_DIO1": "LoRa radio DIO1 interrupt pin; on SX126x this is the primary IRQ line.",
"LORA_DIO2": "LoRa radio DIO2 pin; on RF95 used for FSK interrupt; on SX126x tx/rx control.",
"LORA_SCK": "LoRa radio SPI clock pin.",
"LORA_MISO": "LoRa radio SPI MISO pin.",
"LORA_MOSI": "LoRa radio SPI MOSI pin.",
"SX126X_CS": "SX126x chip-select pin (often same as LORA_CS).",
"SX126X_RESET": "SX126x reset pin (often same as LORA_RESET).",
"SX126X_BUSY": "SX126x BUSY pin; must be polled low before issuing SPI commands.",
"SX126X_DIO1": "SX126x DIO1 interrupt output used for all IRQ events.",
"SX126X_DIO3_TCXO_VOLTAGE": "Drives the TCXO regulator via DIO3; set to the supply voltage (e.g., 1.8).",
"SX126X_DIO2_AS_RF_SWITCH": "Set to 1 so the driver controls the TX/RX RF switch via DIO2.",
"LR1121_IRQ_PIN": "LR1121 interrupt request pin.",
"RF95_FAN_EN": "Enables a cooling fan for high-power RF95 installations.",
# ---------- GPS ----------
"HAS_GPS": "Set to 1 if the board has an on-board GPS receiver; 0 to disable GPS entirely.",
"GPS_RX_PIN": "UART RX pin connected to the GPS module TX output.",
"GPS_TX_PIN": "UART TX pin connected to the GPS module RX input.",
"PIN_GPS_EN": "GPIO to power-enable or power-gate the GPS module.",
"PIN_GPS_PPS": "GPS pulse-per-second input pin for timing synchronisation.",
"PIN_GPS_STANDBY": "Places the GPS into standby/low-power mode when driven.",
"PIN_GPS_RESET": "Hardware-reset line to the GPS module.",
"PIN_GPS_REINIT": "Pin used to trigger GPS re-initialisation sequences.",
"GPS_THREAD_INTERVAL": "Millisecond poll interval for the GPS background thread.",
"GPS_BAUDRATE": "UART baud rate for GPS serial communication.",
"GPS_L76K": "Selects the Quectel L76K GPS driver and protocol.",
"GPS_UBLOX": "Selects the u-blox GPS driver and UBX protocol.",
"GPS_EN_ACTIVE": "Logic level (HIGH or LOW) that enables the GPS power pin.",
"GPS_RESET_MODE": "Defines the reset signal polarity or protocol for the GPS chip.",
"GPS_DEFAULT_NOT_PRESENT": "Compiled-in default assuming no GPS; overridden at runtime if detected.",
# ---------- Display ----------
"HAS_SCREEN": "Set to 1 if the board has any display hardware.",
"USE_TFTDISPLAY": "Selects the TFT LCD driver path.",
"USE_SSD1306": "Selects the SSD1306 128×64 OLED driver over I2C.",
"USE_SH1106": "Selects the SH1106 128×64 OLED driver.",
"USE_ST7735": "Selects the ST7735 TFT SPI driver.",
"USE_ST7789": "Selects the ST7789 TFT SPI driver.",
"TFT_WIDTH": "Horizontal pixel resolution of the TFT display.",
"TFT_HEIGHT": "Vertical pixel resolution of the TFT display.",
"TFT_OFFSET_X": "Horizontal pixel offset for display alignment correction.",
"TFT_OFFSET_Y": "Vertical pixel offset for display alignment correction.",
"TFT_BL": "Backlight control PWM or GPIO pin for the TFT panel.",
"SCREEN_ROTATE": "Non-zero value rotates the display 180° for mounted-upside-down screens.",
"SCREEN_TRANSITION_FRAMERATE": "Target framerate for UI animations and transitions.",
"PIN_EINK_CS": "E-ink display SPI chip-select pin.",
"PIN_EINK_BUSY": "E-ink display BUSY output; high when a page update is in progress.",
"PIN_EINK_DC": "E-ink display data/command select pin.",
"PIN_EINK_RES": "E-ink display hardware-reset pin.",
"PIN_EINK_SCLK": "E-ink display SPI clock pin.",
"PIN_EINK_MOSI": "E-ink display SPI MOSI pin.",
# ---------- I2C/SPI ----------
"I2C_SDA": "Primary I2C data line GPIO pin.",
"I2C_SCL": "Primary I2C clock line GPIO pin.",
"PIN_SPI_MISO": "Primary SPI MISO (data from peripheral) GPIO pin.",
"PIN_SPI_MOSI": "Primary SPI MOSI (data to peripheral) GPIO pin.",
"PIN_SPI_SCK": "Primary SPI clock GPIO pin.",
"SPI_INTERFACES_COUNT": "Number of hardware SPI buses available on this board.",
"WIRE_INTERFACES_COUNT": "Number of hardware I2C buses available on this board.",
"PIN_SPI1_MISO": "Secondary SPI bus MISO pin.",
"PIN_SPI1_MOSI": "Secondary SPI bus MOSI pin.",
"PIN_SPI1_SCK": "Secondary SPI bus clock pin.",
"SPI_FREQUENCY": "Default SPI clock frequency in Hz for this board.",
"SPI_READ_FREQUENCY": "Reduced SPI clock rate used for read transactions.",
"SPI_SCK": "SPI clock pin alias used in older board files.",
"SPI_MOSI": "SPI MOSI pin alias used in older board files.",
"SPI_MISO": "SPI MISO pin alias used in older board files.",
# ---------- Power ----------
"BATTERY_PIN": "ADC input GPIO connected to the battery voltage divider.",
"ADC_MULTIPLIER": "Floating-point scale factor to convert raw ADC reading to battery voltage.",
"ADC_CHANNEL": "ADC channel enum or number for the battery sense input.",
"BATTERY_SENSE_RESOLUTION_BITS": "ADC resolution in bits used for battery voltage sampling.",
"ADC_RESOLUTION": "Board-level ADC resolution definition, referenced by other power macros.",
"BATTERY_SENSE_RESOLUTION": "Alias for the effective ADC resolution for battery sense.",
"ADC_CTRL": "GPIO that enables or gates the ADC voltage-divider circuit.",
"ADC_CTRL_ENABLED": "Logic level (HIGH or LOW) that turns on the ADC control switch.",
"ADC_ATTENUATION": "ESP32 ADC input attenuation setting; controls measurable voltage range.",
"BATTERY_SENSE_SAMPLES": "Number of ADC samples to average for a stable battery reading.",
"EXT_NOTIFY_OUT": "GPIO output used to signal an external LED or buzzer for notifications.",
"USE_POWERSAVE": "Enables aggressive power-save mode (deep sleep, reduced poll intervals).",
"SLEEP_TIME": "Default light-sleep duration in milliseconds between wakeups.",
"PIN_POWER_EN": "GPIO to assert to enable a board power rail or load switch.",
"HAS_PPM": "Set to 1 if the board has an IP5306 or similar PPM power path IC.",
# ---------- Connectivity/Other ----------
"HAS_TOUCHSCREEN": "Set to 1 for boards with a capacitive or resistive touch panel.",
"HAS_NEOPIXEL": "Set to 1 if the board has addressable RGB LEDs (WS2812 / NeoPixel).",
"PCF8563_RTC": "I2C address of the PCF8563 real-time clock IC.",
"PCF85063_RTC": "I2C address of the PCF85063 real-time clock IC.",
"HAS_ETHERNET": "Set to 1 for boards with a wired Ethernet interface.",
"HAS_I2S": "Set to 1 if I2S audio output is present.",
"NFC_INT": "Interrupt pin from the NFC controller IC.",
"NFC_CS": "SPI chip-select for the NFC controller.",
"USE_XL9555": "Enables the XL9555 16-bit I2C GPIO expander driver.",
"EXPANDS_DRV_EN": "GPIO expander pin used to enable the haptic driver.",
"EXPANDS_AMP_EN": "GPIO expander pin used to power-on the audio amplifier.",
"EXPANDS_KB_RST": "GPIO expander pin used to reset the keyboard controller.",
"EXPANDS_LORA_EN": "GPIO expander pin used to power-gate the LoRa radio.",
"EXPANDS_GPS_EN": "GPIO expander pin used to power-gate the GPS module.",
"EXPANDS_NFC_EN": "GPIO expander pin used to power-gate the NFC controller.",
# ---------- Other ----------
"LED_POWER": "GPIO for the status LED, defines the LED pin number.",
"LED_STATE_ON": "Logic level (HIGH or LOW) that turns the status LED on.",
"PIN_SERIAL1_RX": "Secondary UART RX pin (used for accessories, GPS on some boards).",
"PIN_SERIAL1_TX": "Secondary UART TX pin.",
"PIN_WIRE_SDA": "Arduino-framework I2C SDA pin alias (nRF52 / RP2040 style).",
"PIN_WIRE_SCL": "Arduino-framework I2C SCL pin alias.",
"PIN_LED1": "First LED GPIO pin in the nRF52 Arduino BSP pin table.",
"VARIANT_MCK": "Crystal oscillator frequency in Hz for nRF52 variant clock configuration.",
"PINS_COUNT": "Total number of GPIO pins defined in the Arduino BSP variant table.",
"NUM_DIGITAL_PINS": "Count of digital-capable pins in the BSP variant table.",
"NUM_ANALOG_INPUTS": "Count of analog-input pins in the BSP variant table.",
"NUM_ANALOG_OUTPUTS": "Count of analog-output (DAC) pins in the BSP variant table.",
"LED_BLUE": "GPIO number of the blue status LED (typical on nRF52 and RP2040 boards).",
"USE_LFXO": "Instructs the nRF52 BSP to use the low-frequency crystal oscillator.",
"BUTTON_NEED_PULLUP": "Enables internal pull-up on the button GPIO (duplicate entry for clarity).",
}
def shorten(value: str, limit: int = 60) -> str:
compact = " ".join(value.split())
return compact if len(compact) <= limit else compact[: limit - 3] + "..."
# Architecture families known to rely heavily on BSP or base-environment defaults rather
# than declaring every field locally. Used in the Inherited Defaults Note section.
_BSP_DEFAULT_FAMILIES: dict[str, list[str]] = {
"nrf52840": [
"VARIANT_MCK — nRF52 BSP clock constant (e.g., 64000000ul); always inherited from BSP unless overridden.",
"USE_LFXO — low-frequency crystal oscillator selection; declared locally only when the board uses LFXO rather than the RC oscillator.",
"PIN_SPI_* / PIN_SPI1_* — SPI bus pin numbers come from the BSP variant table; boards override only when the LoRa radio or display uses non-default SPI routing.",
"WIRE_INTERFACES_COUNT / SPI_INTERFACES_COUNT — bus count comes from BSP; explicitly set only when the board deviates.",
"LED_BLUE / PIN_LED1 / PINS_COUNT / NUM_DIGITAL_PINS — standard BSP pin-table entries inherited from the nRF52 Arduino core.",
],
"rp2040": [
"PIN_SPI_* — primary SPI pins come from the RP2040 Arduino BSP; most boards declare them explicitly, but the defaults align with the Pico pin assignments.",
"NUM_DIGITAL_PINS / NUM_ANALOG_INPUTS — Arduino BSP counts; rarely overridden locally.",
],
"stm32": [
"Radio and pin assignments for STM32WL targets are largely internal to the WL SoC and declared via STM32 HAL/BSP headers; variant.h files are minimal.",
"USE_STM32WLx is typically the only explicit define; all other radio config comes from the BSP.",
],
"native": [
"The native/Portduino target uses runtime configuration rather than compile-time pin defines; variant.h only sets display and GPS stubs.",
],
}
def collect_inventory() -> dict[str, object]:
architectures: defaultdict[str, list[dict[str, object]]] = defaultdict(list)
category_frequency: defaultdict[str, Counter[str]] = defaultdict(Counter)
total_variant_dirs = 0
total_envs = 0
no_variant_h: defaultdict[str, int] = defaultdict(int) # arch -> count of dirs with no variant.h
has_metadata: int = 0 # env count that has at least one custom_meshtastic_* key
for platformio_path in sorted(VARIANTS_DIR.glob("**/platformio.ini")):
variant_dir = platformio_path.parent
total_variant_dirs += 1
variant_path = variant_dir / "variant.h"
board_level_arch_raw = variant_dir.parts[-2] if len(variant_dir.parts) >= 2 else "unknown"
if not variant_path.exists():
no_variant_h[board_level_arch_raw] += 1
continue
envs = parse_platformio_envs(platformio_path)
defines, category_counts = parse_variant_macros(variant_path)
total_envs += len(envs)
board_level_arch = board_level_arch_raw
for env in envs:
section = str(env["section"])
if not section.startswith("env:"):
continue
metadata = dict(env["metadata"])
if metadata:
has_metadata += 1
architecture = str(metadata.get("custom_meshtastic_architecture") or board_level_arch)
if architecture == "esp32s3":
architecture = "esp32-s3"
if architecture == "esp32c3":
architecture = "esp32-c3"
if architecture == "esp32c6":
architecture = "esp32-c6"
entry = {
"environment": section.split(":", 1)[1],
"variant_dir": str(variant_dir.relative_to(ROOT)),
"metadata": metadata,
"defines": defines,
"category_counts": category_counts,
}
architectures[architecture].append(entry)
for name in defines:
category = classify_macro(name)
category_frequency[category][name] += 1
return {
"architectures": architectures,
"category_frequency": category_frequency,
"total_variant_dirs": total_variant_dirs,
"total_envs": total_envs,
"no_variant_h": dict(no_variant_h),
"has_metadata": has_metadata,
}
def render_markdown(inventory: dict[str, object]) -> str:
architectures = inventory["architectures"]
category_frequency = inventory["category_frequency"]
lines: list[str] = []
lines.append("# Hardware Support Context")
lines.append("")
lines.append("This document inventories the board-support inputs and common target-definition fields")
lines.append("currently used in the Meshtastic firmware repository. It is intended as reusable context")
lines.append("for adding new hardware support without re-discovering naming patterns, metadata keys, and")
lines.append("frequently used pin or capability macros from scratch.")
lines.append("")
lines.append("## Scope")
lines.append("")
lines.append(f"- Variant directories scanned: {inventory['total_variant_dirs']}")
lines.append(f"- PlatformIO environments summarized: {inventory['total_envs']}")
lines.append("- Sources: `variants/**/platformio.ini` and `variants/**/variant.h`")
lines.append("- Notes: This inventory reflects explicit per-variant declarations. Some boards also inherit")
lines.append(" defaults from architecture headers or shared base environments, which must still be checked")
lines.append(" before creating new hardware support.")
lines.append("")
lines.append("## Repository Metadata Inputs")
lines.append("")
lines.append("The following `custom_meshtastic_*` metadata keys are already used across board environments:")
lines.append("")
for key in PLATFORMIO_METADATA_KEYS:
lines.append(f"- `{key}`")
lines.append("")
lines.append("## Common Target-Definition Categories")
lines.append("")
for category in ["Input", "Radio", "GPS", "Display", "I2C/SPI", "Power", "Connectivity/Other", "Other"]:
counter = category_frequency.get(category, Counter())
if not counter:
continue
lines.append(f"### {category}")
lines.append("")
lines.append("| Macro | Used in | Description |")
lines.append("| --- | --- | --- |")
for name, count in counter.most_common(15):
description = MACRO_DESCRIPTIONS.get(name, "")
lines.append(f"| `{name}` | {count} variants | {description} |")
lines.append("")
lines.append("## Architecture and Environment Inventory")
lines.append("")
for architecture in sorted(architectures):
entries = sorted(architectures[architecture], key=lambda item: item["environment"])
lines.append(f"### {architecture}")
lines.append("")
lines.append("| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |")
lines.append("| --- | --- | --- | --- | --- | --- |")
for entry in entries:
metadata = entry["metadata"]
category_counts = entry["category_counts"]
categories = []
for category in ["Display", "Radio", "Input", "GPS", "Power", "Connectivity/Other"]:
count = category_counts.get(category, 0)
if count:
categories.append(f"{category}:{count}")
category_summary = ", ".join(categories) if categories else "none"
lines.append(
"| {environment} | {display_name} | {hw_model} | {hw_slug} | {variant_dir} | {categories} |".format(
environment=entry["environment"],
display_name=metadata.get("custom_meshtastic_display_name", ""),
hw_model=metadata.get("custom_meshtastic_hw_model", ""),
hw_slug=metadata.get("custom_meshtastic_hw_model_slug", ""),
variant_dir=entry["variant_dir"],
categories=category_summary,
)
)
lines.append("")
lines.append("## Representative Board Examples")
lines.append("")
for architecture in sorted(architectures):
entries = sorted(architectures[architecture], key=lambda item: item["environment"])
if not entries:
continue
sample = entries[0]
lines.append(f"### {architecture}: `{sample['environment']}`")
lines.append("")
lines.append(f"- Variant directory: `{sample['variant_dir']}`")
metadata = sample["metadata"]
for key in PLATFORMIO_METADATA_KEYS:
value = metadata.get(key)
if value:
lines.append(f"- `{key}`: `{value}`")
defines = sample["defines"]
for category in ["Input", "Radio", "GPS", "Display", "I2C/SPI", "Power", "Connectivity/Other"]:
selected = [name for name in defines if classify_macro(name) == category][:8]
if not selected:
continue
lines.append(f"- {category} examples:")
for name in selected:
lines.append(f" - `{name}` = `{shorten(defines[name])}`")
lines.append("")
lines.append("## Intake Guidance For New Hardware")
lines.append("")
lines.append("When using this context to add a new board, collect these inputs before generating files:")
lines.append("")
lines.append("- PlatformIO environment name")
lines.append("- `custom_meshtastic_hw_model` and `custom_meshtastic_hw_model_slug`")
lines.append("- Display name and architecture")
lines.append("- Whether the board is actively supported and its support level")
lines.append("- Partition scheme, DFU requirement, and image/tag metadata if applicable")
lines.append("- Radio chip family and complete radio pin group")
lines.append("- Input/button/rotary/keyboard pins")
lines.append("- Display interface pins and driver-related macros")
lines.append("- GPS, power-management, I2C, SPI, storage, and auxiliary peripheral definitions")
lines.append("- Any board-specific initialization that requires `variant.cpp` or extra variant hooks")
lines.append("")
lines.append("## Inherited Defaults Note")
lines.append("")
lines.append(
"Some architecture families rely on BSP (Board Support Package) or base-environment defaults "
"rather than declaring every pin or capability macro explicitly in `variant.h`. "
"When adding a new board for one of these families, check the relevant BSP headers before "
"assuming a missing define means a feature is absent."
)
lines.append("")
no_variant_h = inventory.get("no_variant_h", {})
if no_variant_h:
lines.append(
f"Directories scanned that had no `variant.h` "
f"(relying entirely on BSP/base-environment): "
+ ", ".join(f"{arch}: {count}" for arch, count in sorted(no_variant_h.items()))
)
lines.append("")
for family, notes in _BSP_DEFAULT_FAMILIES.items():
lines.append(f"### {family}")
lines.append("")
for note in notes:
lines.append(f"- {note}")
lines.append("")
lines.append("## Cautions")
lines.append("")
lines.append("- Some boards rely on architecture defaults rather than declaring every field locally.")
lines.append("- Some board families expose multiple environments or display variants that share one hardware model.")
lines.append("- Source materials such as schematics still need human verification before new pin mappings are trusted.")
lines.append("- This document is a starting context artifact, not proof that a new board definition is safe to merge.")
lines.append("")
return "\n".join(lines)
def print_validate_summary(inventory: dict[str, object]) -> None:
"""Print a validation summary to stdout without writing the output file."""
total_dirs = inventory["total_variant_dirs"]
total_envs = inventory["total_envs"]
has_metadata = inventory["has_metadata"]
no_variant_h = inventory.get("no_variant_h", {})
no_variant_h_total = sum(no_variant_h.values())
architectures = inventory["architectures"]
arch_count = len(architectures)
print("Hardware Support Context — Validation Summary")
print("=" * 48)
print(f"Variant directories scanned : {total_dirs}")
print(f"Directories with no variant.h (BSP-only) : {no_variant_h_total}")
if no_variant_h:
for arch, count in sorted(no_variant_h.items()):
print(f" {arch}: {count}")
print(f"PlatformIO environments found : {total_envs}")
print(f"Environments with custom_meshtastic_* metadata : {has_metadata}")
print(f"Architecture families represented : {arch_count}")
print("")
print("Architecture families:")
for arch in sorted(architectures):
entries = architectures[arch]
print(f" {arch}: {len(entries)} environment(s)")
print("")
print("BSP-default families with special notes:")
for family in _BSP_DEFAULT_FAMILIES:
marker = "YES" if family in {a.split("-")[0] for a in architectures} else "not in scan"
print(f" {family}: {marker}")
def main() -> None:
parser = argparse.ArgumentParser(description="Generate hardware support context markdown")
parser.add_argument("--output", default=str(OUTPUT_PATH), help="Output markdown path")
parser.add_argument(
"--validate",
action="store_true",
help="Print a validation summary (variant counts, BSP-only dirs, metadata coverage) without writing output",
)
args = parser.parse_args()
inventory = collect_inventory()
if args.validate:
print_validate_summary(inventory)
return
markdown = render_markdown(inventory)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(markdown + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
@@ -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>
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Regenerate the fake-NodeDB fixtures: produces 250 / 500 / 1000 / 2000-node
# JSONL seed files + their compiled v25 protobufs.
#
# Layout:
# test/fixtures/nodedb/seed_v25_<N>.jsonl — COMMITTED, hand-editable.
# build/fixtures/nodedb/nodes_v25_<N>.proto — .gitignored, build artifact.
# Drop into /prefs/nodes.proto.
#
# Daily use: ./bin/regen-fake-nodedbs.sh
# - Recompiles protos from committed seeds (fresh wall-clock timestamps).
# Intentional seed bump: REGEN_SEEDS=yes ./bin/regen-fake-nodedbs.sh
# - Overwrites the committed JSONL files with freshly-seeded data.
set -euo pipefail
cd "$(dirname "$0")/.."
# 1) Make sure the Python protobuf bindings exist (in-tree generation; .gitignored).
if [[ ! -d bin/_generated/meshtastic ]]; then
echo "regenerating Python protobuf bindings (one-time)..."
./bin/regen-py-protos.sh
fi
# 2) Pick a Python interpreter that has the meshtastic deps installed.
# Prefer the mcp-server venv (most likely to be set up by the operator).
PY="python3"
for cand in mcp-server/.venv/bin/python3 .venv/bin/python3; do
if [[ -x "$cand" ]]; then
PY="$cand"
break
fi
done
# 3) Pinned seeds per size — bump only when you intentionally want different
# structural data committed. Parallel arrays so the script works on
# macOS bash 3.2 (no `declare -A`).
SIZES=(250 500 1000 2000)
SEEDS=(20260511 20260512 20260513 20260514)
REGEN_SEEDS="${REGEN_SEEDS:-no}"
mkdir -p build/fixtures/nodedb test/fixtures/nodedb
for i in 0 1 2 3; do
n="${SIZES[$i]}"
seed="${SEEDS[$i]}"
jsonl=$(printf "test/fixtures/nodedb/seed_v25_%04d.jsonl" "$n")
proto=$(printf "build/fixtures/nodedb/nodes_v25_%04d.proto" "$n")
if [[ "$REGEN_SEEDS" == "yes" || ! -f "$jsonl" ]]; then
$PY bin/gen-fake-nodedb-seed.py \
--count "$n" \
--seed "$seed" \
--out "$jsonl" \
--centroid 33.1284,-107.2528 \
--spread-km 60 \
--position-coverage 0.85 \
--telemetry-coverage 0.70 \
--environment-coverage 0.25 \
--status-coverage 0.40
echo " seed: $jsonl ($(wc -c < "$jsonl") bytes)"
fi
$PY bin/seed-json-to-proto.py --in "$jsonl" --out "$proto"
echo " proto: $proto ($(wc -c < "$proto") bytes)"
done
echo ""
echo "Done. To load on Portduino native:"
echo " cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto"
echo ""
echo "To push to a hardware device:"
echo " Use the mcp-server tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Regenerate Python protobuf bindings from the in-tree `protobufs/` submodule
# into `bin/_generated/`. Called by bin/regen-fake-nodedbs.sh; also useful as
# a standalone refresh after any change to a .proto file.
#
# Output is .gitignored — bindings are a build artifact.
#
# Namespace rewrite:
# The .proto files declare `package meshtastic;`, which makes protoc emit
# imports like `from meshtastic import mesh_pb2`. That conflicts with the
# PyPI `meshtastic` package (which the mcp-server relies on for its
# SerialInterface/BLEInterface transport). We post-process the generated
# files to live under `meshtastic_v25` instead — both the directory layout
# and all internal imports — so they coexist cleanly with the PyPI package.
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v protoc >/dev/null 2>&1; then
echo "ERROR: protoc not found in PATH." >&2
echo " macOS: brew install protobuf" >&2
echo " Ubuntu/Debian: apt install protobuf-compiler" >&2
exit 1
fi
OUT=bin/_generated
LOCAL_NS=meshtastic_v25
rm -rf "$OUT"
mkdir -p "$OUT"
# 1) Generate from the in-tree protos. nanopb.proto first so its descriptor
# is available for the [(nanopb).*] options on other messages.
protoc \
--proto_path=protobufs \
--python_out="$OUT" \
protobufs/nanopb.proto \
protobufs/meshtastic/*.proto
# 2) Move the generated `meshtastic/` directory to `meshtastic_v25/`.
mv "$OUT/meshtastic" "$OUT/$LOCAL_NS"
# 3) Rewrite internal imports: any reference to `meshtastic.X_pb2` or
# `from meshtastic import X_pb2` becomes `meshtastic_v25.*`.
python3 bin/_rewrite_proto_namespace.py "$OUT/$LOCAL_NS" "$LOCAL_NS"
# 4) Make the package importable.
touch "$OUT/__init__.py"
touch "$OUT/$LOCAL_NS/__init__.py"
echo "regenerated Python protobuf bindings -> $OUT/$LOCAL_NS/ (namespace: $LOCAL_NS)" >&2
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""Compile a committed seed JSONL into a binary meshtastic_NodeDatabase v25 proto.
The input is produced by `bin/gen-fake-nodedb-seed.py`. Timestamps in the JSONL
are stored as `*_offset_sec` (seconds before "now"); this script resolves them
to absolute epochs using `--now-epoch` (default: current wall clock).
Output is a raw `pb_encode`-compatible binary that can be dropped at
`/prefs/nodes.proto` on the device (Portduino prefs dir or hardware via
XModem) and loaded by `NodeDB::loadFromDisk` at boot.
Wire format reference:
protobufs/meshtastic/deviceonly.proto (NodeDatabase, NodeInfoLite, sat entries)
src/mesh/NodeDB.h:467-484 (bitfield bit positions)
src/mesh/NodeDB.cpp:1523-1524 (pb_decode entry point)
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
import time
from typing import Any
# Prefer the in-tree generated Python protobuf bindings (bin/_generated/meshtastic_v25/)
# because the firmware branch's protos (v25 NodeDatabase satellite arrays, slim
# NodeInfoLite) are typically newer than what the PyPI `meshtastic` package
# ships. Run `bin/regen-py-protos.sh` to (re)generate.
#
# Namespace note: the local bindings live under `meshtastic_v25` (NOT `meshtastic`)
# to avoid shadowing the PyPI `meshtastic` package — bin/regen-py-protos.sh
# post-processes the protoc output to rename the package.
_HERE = pathlib.Path(__file__).resolve().parent
_LOCAL_PROTO_DIR = _HERE / "_generated"
if _LOCAL_PROTO_DIR.is_dir():
sys.path.insert(0, str(_LOCAL_PROTO_DIR))
try:
from meshtastic_v25.deviceonly_pb2 import ( # type: ignore[import-not-found]
NodeDatabase,
NodeInfoLite,
NodePositionEntry,
NodeTelemetryEntry,
NodeEnvironmentEntry,
NodeStatusEntry,
PositionLite,
)
from meshtastic_v25.mesh_pb2 import HardwareModel, Position, StatusMessage # type: ignore[import-not-found]
from meshtastic_v25.config_pb2 import Config # type: ignore[import-not-found]
from meshtastic_v25.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics # type: ignore[import-not-found]
except ImportError as local_err:
# Fall back to the PyPI package if in-tree bindings haven't been generated.
# Will fail the v25 assertion below if the PyPI package predates the
# satellite-DB schema, but at least gives a clear "run regen-py-protos.sh"
# error message instead of an opaque ImportError.
try:
from meshtastic.protobuf.deviceonly_pb2 import (
NodeDatabase,
NodeInfoLite,
NodePositionEntry,
NodeTelemetryEntry,
NodeEnvironmentEntry,
NodeStatusEntry,
PositionLite,
)
from meshtastic.protobuf.mesh_pb2 import HardwareModel, Position, StatusMessage
from meshtastic.protobuf.config_pb2 import Config
from meshtastic.protobuf.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics
except ImportError as pypi_err:
print(
"ERROR: could not import meshtastic protobuf bindings.\n"
" In-tree generation: run `bin/regen-py-protos.sh` (requires protoc).\n"
" PyPI fallback: `pip install meshtastic` (may lag firmware branch).\n"
f" local error (meshtastic_v25): {local_err}\n"
f" pypi error (meshtastic.protobuf): {pypi_err}",
file=sys.stderr,
)
sys.exit(1)
# Fail loudly if bindings predate v25 (no satellite arrays).
assert (
hasattr(NodeDatabase, "DESCRIPTOR")
and "positions" in NodeDatabase.DESCRIPTOR.fields_by_name
), (
"Loaded meshtastic bindings are older than v25 (NodeDatabase.positions missing). "
"Run `bin/regen-py-protos.sh` against the in-tree protobufs/ submodule."
)
# ---------------------------------------------------------------------------
# Bitfield bit positions (mirror src/mesh/NodeDB.h:467-484).
# ---------------------------------------------------------------------------
BIT_IS_KEY_MANUALLY_VERIFIED = 0
BIT_IS_MUTED = 1
BIT_VIA_MQTT = 2
BIT_IS_FAVORITE = 3
BIT_IS_IGNORED = 4
BIT_HAS_USER = 5
BIT_IS_LICENSED = 6
BIT_IS_UNMESSAGABLE = 7
BIT_HAS_IS_UNMESSAGABLE = 8
BITFIELD_LAYOUT = (
# JSON key bit position
("is_key_manually_verified", BIT_IS_KEY_MANUALLY_VERIFIED),
("is_muted", BIT_IS_MUTED),
("via_mqtt", BIT_VIA_MQTT),
("is_favorite", BIT_IS_FAVORITE),
("is_ignored", BIT_IS_IGNORED),
("has_user", BIT_HAS_USER),
("is_licensed", BIT_IS_LICENSED),
("is_unmessagable", BIT_IS_UNMESSAGABLE),
("has_is_unmessagable", BIT_HAS_IS_UNMESSAGABLE),
)
def _pack_bitfield(bf: dict[str, bool]) -> int:
out = 0
for key, shift in BITFIELD_LAYOUT:
if bf.get(key, False):
out |= (1 << shift)
return out
def _validate_node(node: dict[str, Any]) -> None:
"""Friendly errors so hand-editors get clear feedback."""
if "num" not in node or not isinstance(node["num"], str):
raise ValueError(f"node missing/invalid 'num' (must be hex string): {node!r}")
if "long_name" not in node:
raise ValueError(f"node {node['num']}: missing 'long_name'")
if len(node["long_name"]) > 24:
raise ValueError(
f"node {node['num']}: long_name {node['long_name']!r} is "
f"{len(node['long_name'])} chars; max 24 (nanopb max_size:25 minus NUL)"
)
if "short_name" in node:
# short_name max_size:5 (incl. NUL) → 4 bytes of content.
# Char count is irrelevant — emojis with variation selectors (e.g. ❄️ = 6 B)
# would slip past a `len(str) > 4` check. Always measure bytes.
b = node["short_name"].encode("utf-8")
if len(b) > 4:
raise ValueError(
f"node {node['num']}: short_name {node['short_name']!r} is "
f"{len(b)} bytes UTF-8; max 4 (nanopb max_size:5 minus NUL)"
)
pk = node.get("public_key_hex", "")
if pk and len(pk) != 64:
raise ValueError(
f"node {node['num']}: public_key_hex must be 64 hex chars or empty; "
f"got {len(pk)} chars"
)
if pk:
try:
bytes.fromhex(pk)
except ValueError as e:
raise ValueError(f"node {node['num']}: public_key_hex is not valid hex: {e}")
def _resolve_time(
node: dict[str, Any],
field_absolute: str,
field_offset: str,
now_epoch: int,
) -> int:
"""If `field_absolute` is set, use it; else compute `now_epoch - offset`."""
if field_absolute in node and node[field_absolute] is not None:
return int(node[field_absolute])
offset = node.get(field_offset, 0)
return max(0, int(now_epoch) - int(offset))
def _build_node_info_lite(node: dict[str, Any], now_epoch: int) -> NodeInfoLite:
_validate_node(node)
info = NodeInfoLite()
info.num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
info.long_name = node.get("long_name", "")
info.short_name = node.get("short_name", "")
# Enum lookups will raise ValueError on unknown names — that's exactly what we want.
info.hw_model = HardwareModel.Value(node.get("hw_model", "UNSET"))
info.role = Config.DeviceConfig.Role.Value(node.get("role", "CLIENT"))
pk_hex = node.get("public_key_hex", "")
if pk_hex:
info.public_key = bytes.fromhex(pk_hex)
info.snr = float(node.get("snr", 0.0))
info.channel = int(node.get("channel", 0))
if "hops_away" in node:
# `optional uint32 hops_away = 9;` — in Python protobuf, assigning the
# field implicitly sets HasField("hops_away") to True. No has_hops_away
# setter exists (unlike the C++ nanopb-generated header).
info.hops_away = int(node["hops_away"])
info.next_hop = int(node.get("next_hop", 0))
info.last_heard = _resolve_time(node, "last_heard", "last_heard_offset_sec", now_epoch)
info.bitfield = _pack_bitfield(node.get("bitfield", {}))
return info
def _build_position_entry(num: int, pos: dict[str, Any], now_epoch: int) -> NodePositionEntry:
entry = NodePositionEntry()
entry.num = num
pl = PositionLite()
# Firmware stores lat/long as int32 in 1e-7 degrees.
pl.latitude_i = int(round(float(pos["latitude"]) * 1e7))
pl.longitude_i = int(round(float(pos["longitude"]) * 1e7))
pl.altitude = int(pos.get("altitude", 0))
pl.time = _resolve_time(pos, "time", "time_offset_sec", now_epoch)
pl.location_source = Position.LocSource.Value(pos.get("location_source", "LOC_UNSET"))
entry.position.CopyFrom(pl)
return entry
def _build_telemetry_entry(num: int, tel: dict[str, Any]) -> NodeTelemetryEntry:
entry = NodeTelemetryEntry()
entry.num = num
dm = DeviceMetrics()
if "battery_level" in tel:
dm.battery_level = int(tel["battery_level"])
if "voltage" in tel:
dm.voltage = float(tel["voltage"])
if "channel_utilization" in tel:
dm.channel_utilization = float(tel["channel_utilization"])
if "air_util_tx" in tel:
dm.air_util_tx = float(tel["air_util_tx"])
if "uptime_seconds" in tel:
dm.uptime_seconds = int(tel["uptime_seconds"])
entry.device_metrics.CopyFrom(dm)
return entry
def _build_environment_entry(num: int, env: dict[str, Any]) -> NodeEnvironmentEntry:
entry = NodeEnvironmentEntry()
entry.num = num
em = EnvironmentMetrics()
if "temperature" in env:
em.temperature = float(env["temperature"])
if "relative_humidity" in env:
em.relative_humidity = float(env["relative_humidity"])
if "barometric_pressure" in env:
em.barometric_pressure = float(env["barometric_pressure"])
if "iaq" in env:
em.iaq = int(env["iaq"])
entry.environment_metrics.CopyFrom(em)
return entry
def _build_status_entry(num: int, status: dict[str, Any]) -> NodeStatusEntry:
# `StatusMessage` (mesh.proto:1445) has a single `string status` field.
entry = NodeStatusEntry()
entry.num = num
sm = StatusMessage()
if "status" in status:
sm.status = str(status["status"])
entry.status.CopyFrom(sm)
return entry
def compile_jsonl_to_proto(jsonl_path: pathlib.Path, now_epoch: int) -> bytes:
"""Read a seed JSONL and return the encoded NodeDatabase bytes."""
lines = jsonl_path.read_text(encoding="utf-8").splitlines()
if not lines:
raise ValueError(f"{jsonl_path} is empty")
meta_line = lines[0]
meta_obj = json.loads(meta_line)
meta = meta_obj.get("_meta", {})
version = meta.get("version")
if version != 25:
raise ValueError(
f"{jsonl_path}: meta version is {version!r}; this compiler "
f"requires version=25. Regenerate the seed with the matching tooling."
)
db = NodeDatabase()
db.version = 25
for ln, raw in enumerate(lines[1:], start=2):
raw = raw.strip()
if not raw:
continue
try:
node = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"{jsonl_path}:{ln} JSON parse error: {e}")
num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
# Header
info = _build_node_info_lite(node, now_epoch)
db.nodes.append(info)
# Satellites (nullable)
if node.get("position"):
db.positions.append(_build_position_entry(num, node["position"], now_epoch))
if node.get("telemetry"):
db.telemetry.append(_build_telemetry_entry(num, node["telemetry"]))
if node.get("environment"):
db.environment.append(_build_environment_entry(num, node["environment"]))
if node.get("status"):
db.status.append(_build_status_entry(num, node["status"]))
return db.SerializeToString()
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(
description="Compile a seed JSONL into a binary v25 NodeDatabase proto.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--in", dest="in_path", required=True, help="Input seed JSONL.")
p.add_argument("--out", required=True, help="Output binary .proto path.")
p.add_argument(
"--now-epoch",
type=int,
default=None,
help="Pin 'now' to this Unix epoch (for byte-identical CI). Default: time.time().",
)
args = p.parse_args(argv)
in_path = pathlib.Path(args.in_path)
if not in_path.is_file():
print(f"input not found: {in_path}", file=sys.stderr)
return 2
now_epoch = args.now_epoch if args.now_epoch is not None else int(time.time())
try:
encoded = compile_jsonl_to_proto(in_path, now_epoch)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 3
out_path = pathlib.Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(encoded)
print(
f"compiled {in_path} -> {out_path} ({len(encoded)} bytes, now_epoch={now_epoch})",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+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"
}
+41
View File
@@ -0,0 +1,41 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"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=0"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "station-g3"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "BQ Station G3",
"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": "",
"vendor": "BQ Consulting"
}
+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
-742
View File
@@ -1,742 +0,0 @@
# Hardware Support Context
This document inventories the board-support inputs and common target-definition fields
currently used in the Meshtastic firmware repository. It is intended as reusable context
for adding new hardware support without re-discovering naming patterns, metadata keys, and
frequently used pin or capability macros from scratch.
## Scope
- Variant directories scanned: 166
- PlatformIO environments summarized: 212
- Sources: `variants/**/platformio.ini` and `variants/**/variant.h`
- Notes: This inventory reflects explicit per-variant declarations. Some boards also inherit
defaults from architecture headers or shared base environments, which must still be checked
before creating new hardware support.
## Repository Metadata Inputs
The following `custom_meshtastic_*` metadata keys are already used across board environments:
- `custom_meshtastic_hw_model`
- `custom_meshtastic_hw_model_slug`
- `custom_meshtastic_architecture`
- `custom_meshtastic_actively_supported`
- `custom_meshtastic_support_level`
- `custom_meshtastic_display_name`
- `custom_meshtastic_images`
- `custom_meshtastic_tags`
- `custom_meshtastic_requires_dfu`
- `custom_meshtastic_partition_scheme`
## Common Target-Definition Categories
### Input
| Macro | Used in | Description |
| ---------------------------------- | ------------ | --------------------------------------------------------------------------- |
| `BUTTON_PIN` | 127 variants | Primary user button GPIO pin number. |
| `ALT_BUTTON_PIN` | 12 variants | Secondary / alternate button pin, used on boards with two buttons. |
| `CANCEL_BUTTON_PIN` | 8 variants | Button wired to cancel/back actions (e.g., T-Deck cancel key). |
| `KB_BL_PIN` | 5 variants | Keyboard backlight control pin (e.g., T-LoRa Pager keyboard). |
| `KB_INT` | 4 variants | Keyboard interrupt input pin; signals a keypress to the MCU. |
| `INPUTDRIVER_ENCODER_TYPE` | 3 variants | Selects the rotary encoder driver variant (0 = none, 1+ = specific type). |
| `BUTTON_PIN_ALT` | 2 variants | Alternative spelling used by some older board families for a second button. |
| `INPUTDRIVER_TWO_WAY_ROCKER` | 2 variants | Enables the two-way rocker input driver. |
| `INPUTDRIVER_TWO_WAY_ROCKER_RIGHT` | 2 variants | GPIO pin for the rightward rocker direction. |
| `INPUTDRIVER_TWO_WAY_ROCKER_LEFT` | 2 variants | GPIO pin for the leftward rocker direction. |
| `INPUTDRIVER_TWO_WAY_ROCKER_BTN` | 2 variants | GPIO pin for the rocker click/press direction. |
| `KB_POWERON` | 2 variants | Pin used to power-on or enable the keyboard peripheral. |
| `KB_SLAVE_ADDRESS` | 2 variants | I2C address of the keyboard controller IC. |
| `ROTARY_A` | 2 variants | First encoder channel pin (clock / A signal). |
| `ROTARY_B` | 2 variants | Second encoder channel pin (data / B signal). |
### Radio
| Macro | Used in | Description |
| -------------------------- | ------------ | ----------------------------------------------------------------------------- |
| `SX126X_CS` | 150 variants | SX126x chip-select pin (often same as LORA_CS). |
| `SX126X_RESET` | 150 variants | SX126x reset pin (often same as LORA_RESET). |
| `SX126X_BUSY` | 150 variants | SX126x BUSY pin; must be polled low before issuing SPI commands. |
| `SX126X_DIO1` | 150 variants | SX126x DIO1 interrupt output used for all IRQ events. |
| `USE_SX1262` | 145 variants | Select the SX1262 sub-GHz LoRa chip driver. |
| `SX126X_DIO3_TCXO_VOLTAGE` | 140 variants | Drives the TCXO regulator via DIO3; set to the supply voltage (e.g., 1.8). |
| `LORA_CS` | 137 variants | LoRa radio SPI chip-select GPIO pin. |
| `LORA_RESET` | 135 variants | LoRa radio hardware-reset GPIO pin (active low). |
| `SX126X_DIO2_AS_RF_SWITCH` | 135 variants | Set to 1 so the driver controls the TX/RX RF switch via DIO2. |
| `LORA_DIO1` | 130 variants | LoRa radio DIO1 interrupt pin; on SX126x this is the primary IRQ line. |
| `LORA_SCK` | 125 variants | LoRa radio SPI clock pin. |
| `LORA_MISO` | 125 variants | LoRa radio SPI MISO pin. |
| `LORA_MOSI` | 125 variants | LoRa radio SPI MOSI pin. |
| `LORA_DIO2` | 110 variants | LoRa radio DIO2 pin; on RF95 used for FSK interrupt; on SX126x tx/rx control. |
| `LORA_DIO0` | 98 variants | LoRa radio DIO0 interrupt pin (RF95/SX1276 done/RxDone). |
### GPS
| Macro | Used in | Description |
| ------------------------- | ------------ | ------------------------------------------------------------------------------ |
| `GPS_RX_PIN` | 114 variants | UART RX pin connected to the GPS module TX output. |
| `GPS_TX_PIN` | 111 variants | UART TX pin connected to the GPS module RX input. |
| `HAS_GPS` | 69 variants | Set to 1 if the board has an on-board GPS receiver; 0 to disable GPS entirely. |
| `PIN_GPS_PPS` | 43 variants | GPS pulse-per-second input pin for timing synchronisation. |
| `PIN_GPS_EN` | 30 variants | GPIO to power-enable or power-gate the GPS module. |
| `PIN_GPS_STANDBY` | 29 variants | Places the GPS into standby/low-power mode when driven. |
| `GPS_THREAD_INTERVAL` | 28 variants | Millisecond poll interval for the GPS background thread. |
| `GPS_L76K` | 27 variants | Selects the Quectel L76K GPS driver and protocol. |
| `GPS_BAUDRATE` | 27 variants | UART baud rate for GPS serial communication. |
| `GPS_EN_ACTIVE` | 17 variants | Logic level (HIGH or LOW) that enables the GPS power pin. |
| `PIN_GPS_RESET` | 16 variants | Hardware-reset line to the GPS module. |
| `GPS_DEFAULT_NOT_PRESENT` | 16 variants | Compiled-in default assuming no GPS; overridden at runtime if detected. |
| `GPS_RESET_MODE` | 13 variants | Defines the reset signal polarity or protocol for the GPS chip. |
| `GPS_UBLOX` | 8 variants | Selects the u-blox GPS driver and UBX protocol. |
| `PIN_GPS_REINIT` | 7 variants | Pin used to trigger GPS re-initialisation sequences. |
### Display
| Macro | Used in | Description |
| ----------------------------- | ----------- | ------------------------------------------------------------------------ |
| `PIN_EINK_CS` | 51 variants | E-ink display SPI chip-select pin. |
| `PIN_EINK_BUSY` | 51 variants | E-ink display BUSY output; high when a page update is in progress. |
| `PIN_EINK_DC` | 51 variants | E-ink display data/command select pin. |
| `PIN_EINK_RES` | 51 variants | E-ink display hardware-reset pin. |
| `PIN_EINK_SCLK` | 49 variants | E-ink display SPI clock pin. |
| `PIN_EINK_MOSI` | 49 variants | E-ink display SPI MOSI pin. |
| `HAS_SCREEN` | 34 variants | Set to 1 if the board has any display hardware. |
| `USE_TFTDISPLAY` | 27 variants | Selects the TFT LCD driver path. |
| `TFT_HEIGHT` | 25 variants | Vertical pixel resolution of the TFT display. |
| `TFT_WIDTH` | 25 variants | Horizontal pixel resolution of the TFT display. |
| `SCREEN_TRANSITION_FRAMERATE` | 23 variants | Target framerate for UI animations and transitions. |
| `TFT_OFFSET_X` | 22 variants | Horizontal pixel offset for display alignment correction. |
| `TFT_OFFSET_Y` | 22 variants | Vertical pixel offset for display alignment correction. |
| `SCREEN_ROTATE` | 18 variants | Non-zero value rotates the display 180° for mounted-upside-down screens. |
| `TFT_BL` | 15 variants | Backlight control PWM or GPIO pin for the TFT panel. |
### I2C/SPI
| Macro | Used in | Description |
| ----------------------- | ------------ | ----------------------------------------------------- |
| `I2C_SDA` | 103 variants | Primary I2C data line GPIO pin. |
| `I2C_SCL` | 103 variants | Primary I2C clock line GPIO pin. |
| `PIN_SPI_MISO` | 72 variants | Primary SPI MISO (data from peripheral) GPIO pin. |
| `PIN_SPI_MOSI` | 72 variants | Primary SPI MOSI (data to peripheral) GPIO pin. |
| `PIN_SPI_SCK` | 72 variants | Primary SPI clock GPIO pin. |
| `SPI_INTERFACES_COUNT` | 66 variants | Number of hardware SPI buses available on this board. |
| `WIRE_INTERFACES_COUNT` | 64 variants | Number of hardware I2C buses available on this board. |
| `PIN_SPI1_MISO` | 35 variants | Secondary SPI bus MISO pin. |
| `PIN_SPI1_MOSI` | 35 variants | Secondary SPI bus MOSI pin. |
| `PIN_SPI1_SCK` | 35 variants | Secondary SPI bus clock pin. |
| `SPI_FREQUENCY` | 24 variants | Default SPI clock frequency in Hz for this board. |
| `SPI_READ_FREQUENCY` | 21 variants | Reduced SPI clock rate used for read transactions. |
| `SPI_SCK` | 19 variants | SPI clock pin alias used in older board files. |
| `SPI_MOSI` | 19 variants | SPI MOSI pin alias used in older board files. |
| `SPI_MISO` | 19 variants | SPI MISO pin alias used in older board files. |
### Power
| Macro | Used in | Description |
| ------------------------------- | ------------ | -------------------------------------------------------------------------- |
| `BATTERY_PIN` | 128 variants | ADC input GPIO connected to the battery voltage divider. |
| `ADC_MULTIPLIER` | 122 variants | Floating-point scale factor to convert raw ADC reading to battery voltage. |
| `ADC_CHANNEL` | 71 variants | ADC channel enum or number for the battery sense input. |
| `BATTERY_SENSE_RESOLUTION_BITS` | 64 variants | ADC resolution in bits used for battery voltage sampling. |
| `ADC_RESOLUTION` | 49 variants | Board-level ADC resolution definition, referenced by other power macros. |
| `BATTERY_SENSE_RESOLUTION` | 44 variants | Alias for the effective ADC resolution for battery sense. |
| `ADC_CTRL` | 29 variants | GPIO that enables or gates the ADC voltage-divider circuit. |
| `ADC_CTRL_ENABLED` | 27 variants | Logic level (HIGH or LOW) that turns on the ADC control switch. |
| `EXT_NOTIFY_OUT` | 23 variants | GPIO output used to signal an external LED or buzzer for notifications. |
| `USE_POWERSAVE` | 21 variants | Enables aggressive power-save mode (deep sleep, reduced poll intervals). |
| `SLEEP_TIME` | 21 variants | Default light-sleep duration in milliseconds between wakeups. |
| `ADC_ATTENUATION` | 20 variants | ESP32 ADC input attenuation setting; controls measurable voltage range. |
| `PIN_POWER_EN` | 18 variants | GPIO to assert to enable a board power rail or load switch. |
| `BATTERY_SENSE_SAMPLES` | 11 variants | Number of ADC samples to average for a stable battery reading. |
| `HAS_PPM` | 4 variants | Set to 1 if the board has an IP5306 or similar PPM power path IC. |
### Connectivity/Other
| Macro | Used in | Description |
| ----------------- | ----------- | ------------------------------------------------------------------- |
| `HAS_TOUCHSCREEN` | 19 variants | Set to 1 for boards with a capacitive or resistive touch panel. |
| `HAS_NEOPIXEL` | 14 variants | Set to 1 if the board has addressable RGB LEDs (WS2812 / NeoPixel). |
| `PCF8563_RTC` | 11 variants | I2C address of the PCF8563 real-time clock IC. |
| `HAS_ETHERNET` | 9 variants | Set to 1 for boards with a wired Ethernet interface. |
| `HAS_I2S` | 7 variants | Set to 1 if I2S audio output is present. |
| `PCF85063_RTC` | 3 variants | I2C address of the PCF85063 real-time clock IC. |
| `NFC_INT` | 2 variants | Interrupt pin from the NFC controller IC. |
| `NFC_CS` | 2 variants | SPI chip-select for the NFC controller. |
| `USE_XL9555` | 2 variants | Enables the XL9555 16-bit I2C GPIO expander driver. |
| `EXPANDS_DRV_EN` | 2 variants | GPIO expander pin used to enable the haptic driver. |
| `EXPANDS_AMP_EN` | 2 variants | GPIO expander pin used to power-on the audio amplifier. |
| `EXPANDS_KB_RST` | 2 variants | GPIO expander pin used to reset the keyboard controller. |
| `EXPANDS_LORA_EN` | 2 variants | GPIO expander pin used to power-gate the LoRa radio. |
| `EXPANDS_GPS_EN` | 2 variants | GPIO expander pin used to power-gate the GPS module. |
| `EXPANDS_NFC_EN` | 2 variants | GPIO expander pin used to power-gate the NFC controller. |
### Other
| Macro | Used in | Description |
| -------------------- | ----------- | -------------------------------------------------------------------------- |
| `LED_POWER` | 94 variants | GPIO for the status LED, defines the LED pin number. |
| `LED_STATE_ON` | 88 variants | Logic level (HIGH or LOW) that turns the status LED on. |
| `PIN_SERIAL1_RX` | 67 variants | Secondary UART RX pin (used for accessories, GPS on some boards). |
| `PIN_SERIAL1_TX` | 67 variants | Secondary UART TX pin. |
| `PIN_WIRE_SDA` | 64 variants | Arduino-framework I2C SDA pin alias (nRF52 / RP2040 style). |
| `PIN_WIRE_SCL` | 64 variants | Arduino-framework I2C SCL pin alias. |
| `PIN_LED1` | 63 variants | First LED GPIO pin in the nRF52 Arduino BSP pin table. |
| `VARIANT_MCK` | 63 variants | Crystal oscillator frequency in Hz for nRF52 variant clock configuration. |
| `PINS_COUNT` | 63 variants | Total number of GPIO pins defined in the Arduino BSP variant table. |
| `NUM_DIGITAL_PINS` | 63 variants | Count of digital-capable pins in the BSP variant table. |
| `NUM_ANALOG_INPUTS` | 63 variants | Count of analog-input pins in the BSP variant table. |
| `NUM_ANALOG_OUTPUTS` | 63 variants | Count of analog-output (DAC) pins in the BSP variant table. |
| `LED_BLUE` | 62 variants | GPIO number of the blue status LED (typical on nRF52 and RP2040 boards). |
| `USE_LFXO` | 58 variants | Instructs the nRF52 BSP to use the low-frequency crystal oscillator. |
| `BUTTON_NEED_PULLUP` | 54 variants | Enables internal pull-up on the button GPIO (duplicate entry for clarity). |
## Architecture and Environment Inventory
### diy
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| ------------------------- | ------------ | -------- | ------- | --------------------------------------------- | --------------------------------------------------------- |
| 9m2ibr_aprs_lora_tracker | | | | variants/esp32/diy/9m2ibr_aprs_lora_tracker | Display:1, Radio:24, Input:1, GPS:2, Power:4 |
| esp32c3_super_mini | | | | variants/esp32c3/diy/esp32c3_super_mini | Display:1, Radio:18, Input:1, GPS:2 |
| meshtastic-diy-v1_1 | | | | variants/esp32/diy/v1_1 | Radio:24, Input:1, GPS:1, Power:1 |
| my-esp32s3-diy-eink | | | | variants/esp32s3/diy/my_esp32s3_diy_eink | Display:6, Radio:17, Input:1, GPS:1, Connectivity/Other:1 |
| my-esp32s3-diy-oled | | | | variants/esp32s3/diy/my_esp32s3_diy_oled | Display:1, Radio:17, Input:1, GPS:1, Connectivity/Other:1 |
| nrf52_promicro_diy-inkhud | | | | variants/nrf52840/diy/nrf52_promicro_diy_tcxo | Display:4, Radio:29, Input:1, GPS:4, Power:6 |
| t-energy-s3_e22 | | | | variants/esp32s3/diy/t-energy-s3_e22 | Display:1, Radio:18, Input:1, GPS:3, Power:3 |
### esp32
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| --------------------------- | --------------------------- | -------- | --------------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
| betafpv_2400_tx_micro | | | | variants/esp32/betafpv_2400_tx_micro | Radio:14, Input:1, Connectivity/Other:1 |
| betafpv_900_tx_nano | | | | variants/esp32/betafpv_900_tx_nano | Display:1, Radio:10, Input:1 |
| chatter2 | | | | variants/esp32/chatter2 | Display:12, Radio:16, Input:3, GPS:3, Power:4 |
| hackerboxes-esp32-io | | | | variants/esp32/hackerboxes_esp32_io | Display:1, Radio:16, Input:1, GPS:1 |
| heltec-v1 | Heltec V1 | 11 | HELTEC_V1 | variants/esp32/heltec_v1 | Radio:5, Input:1, GPS:2, Power:3 |
| heltec-v2_0 | Heltec V2.0 | 5 | HELTEC_V2_0 | variants/esp32/heltec_v2 | Radio:5, Input:1, GPS:2, Power:3 |
| heltec-v2_1 | Heltec V2.1 | 10 | HELTEC_V2_1 | variants/esp32/heltec_v2.1 | Radio:5, Input:1, GPS:3, Power:4 |
| heltec-wireless-bridge | | | | variants/esp32/heltec_wireless_bridge | Radio:9, GPS:1 |
| heltec-wsl-v2_1 | | | | variants/esp32/heltec_wsl_v2.1 | Radio:9, Input:1, Power:5 |
| hydra | Hydra | 39 | HYDRA | variants/esp32/diy/hydra | Radio:22, Input:1, GPS:3, Power:4 |
| m5stack-core | M5 Stack | 42 | M5STACK | variants/esp32/m5stack_core | Display:7, Radio:9, Input:1, GPS:2 |
| m5stack-coreink | | | | variants/esp32/m5stack_coreink | Display:7, Radio:34, Input:1, GPS:2, Power:3, Connectivity/Other:1 |
| meshtastic-diy-v1 | DIY V1 | 39 | DIY_V1 | variants/esp32/diy/v1 | Radio:23, Input:1, GPS:3, Power:4 |
| meshtastic-dr-dev | DR-DEV | 41 | DR_DEV | variants/esp32/diy/dr-dev | Display:1, Radio:26, Input:1, GPS:2, Power:3 |
| nano-g1 | Nano G1 | 14 | NANO_G1 | variants/esp32/nano-g1 | Display:1, Radio:13, Input:1, GPS:2, Power:1 |
| nano-g1-explorer | Nano G1 Explorer | 17 | NANO_G1_EXPLORER | variants/esp32/nano-g1-explorer | Display:1, Radio:13, Input:1, GPS:2, Power:5 |
| radiomaster_900_bandit | | | | variants/esp32/radiomaster_900_bandit | Radio:19, Input:1, GPS:1, Connectivity/Other:1 |
| radiomaster_900_bandit_nano | RadioMaster 900 Bandit Nano | 64 | RADIOMASTER_900_BANDIT_NANO | variants/esp32/radiomaster_900_bandit_nano | Display:1, Radio:19 |
| rak11200 | RAK WisBlock 11200 | 13 | RAK11200 | variants/esp32/rak11200 | Radio:18, GPS:2, Power:2 |
| station-g1 | Station G1 | 25 | STATION_G1 | variants/esp32/station-g1 | Display:1, Radio:13, Input:1, GPS:2, Power:5 |
| sugarcube | | | | variants/esp32/tlora_v2_1_16 | Radio:6, Power:4 |
| tbeam | LILYGO T-Beam | 4 | TBEAM | variants/esp32/tbeam | Display:4, Radio:14, Input:1, GPS:3, Power:2, Connectivity/Other:1 |
| tbeam-displayshield | | | | variants/esp32/tbeam | Display:4, Radio:14, Input:1, GPS:3, Power:2, Connectivity/Other:1 |
| tbeam0_7 | LILYGO T-Beam V0.7 | 6 | TBEAM_V0P7 | variants/esp32/tbeam_v07 | Radio:5, Input:1, GPS:3, Power:3 |
| tlora-v1 | LILYGO T-LoRa V1 | 2 | TLORA_V1 | variants/esp32/tlora_v1 | Radio:5, Input:1, Power:1 |
| tlora-v2 | LILYGO T-LoRa V2 | 1 | TLORA_V2 | variants/esp32/tlora_v2 | Radio:5, Input:1, Power:2 |
| tlora-v2-1-1_6 | LILYGO T-LoRa V2.1-1.6 | 3 | TLORA_V2_1_1P6 | variants/esp32/tlora_v2_1_16 | Radio:6, Power:4 |
| tlora-v2-1-1_8 | LILYGO T-LoRa V2.1-1.8 | 15 | TLORA_V2_1_1P8 | variants/esp32/tlora_v2_1_18 | Radio:6, Input:1, Power:3 |
| tlora_v1_3 | | | | variants/esp32/tlora_v1_3 | Radio:5, Input:1, Power:2 |
| trackerd | | | | variants/esp32/trackerd | Display:1, Radio:5, Input:1, GPS:9, Power:4 |
| wiphone | | | | variants/esp32/wiphone | Display:9, Radio:9, GPS:1 |
### esp32-c3
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| -------------------------- | ------------ | -------- | ----------- | ----------------------------------------- | ------------------------------------------------------------------ |
| ai-c3 | | | | variants/esp32c3/ai-c3 | Radio:15, Input:1, GPS:1 |
| hackerboxes-esp32c3-oled | | | | variants/esp32c3/hackerboxes_esp32c3_oled | Display:1, Radio:16, Input:1, GPS:1 |
| heltec-hru-3601 | | | | variants/esp32c3/heltec_hru_3601 | Display:1, Radio:16, Input:1, GPS:1, Power:1, Connectivity/Other:1 |
| heltec-ht62-esp32c3-sx1262 | Heltec HT62 | 53 | HELTEC_HT62 | variants/esp32c3/heltec_esp32c3 | Display:1, Radio:16, Input:1, GPS:1 |
| m5stack-stamp-c3 | | | | variants/esp32c3/m5stack-stamp-c3 | Radio:9, Input:1, GPS:1 |
### esp32-c6
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| --------------- | ---------------- | -------- | ----------- | -------------------------------- | ------------------------------------------------ |
| m5stack-unitc6l | M5Stack Unit C6L | 111 | M5STACK_C6L | variants/esp32c6/m5stack_unitc6l | Display:1, Radio:14, GPS:3, Connectivity/Other:1 |
| tlora-c6 | | | | variants/esp32c6/tlora_c6 | Radio:15 |
### esp32-s3
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| -------------------------------- | ----------------------------- | -------- | ---------------------------- | --------------------------------------------- | -------------------------------------------------------------------- |
| CDEBYTE_EoRa-Hub | | | | variants/esp32s3/CDEBYTE_EoRa-Hub | Display:2, Radio:14, Input:1, Power:6 |
| CDEBYTE_EoRa-S3 | EBYTE EoRa-S3 | 61 | CDEBYTE_EORA_S3 | variants/esp32s3/CDEBYTE_EoRa-S3 | Display:2, Radio:13, Input:1, Power:3 |
| EBYTE_ESP32-S3 | | | | variants/esp32s3/EBYTE_ESP32-S3 | Display:1, Radio:26, Input:1, GPS:5, Power:1 |
| ESP32-S3-Pico | | | | variants/esp32s3/esp32-s3-pico | Display:6, Radio:18, Input:2, GPS:1, Power:4, Connectivity/Other:1 |
| bpi_picow_esp32_s3 | | | | variants/esp32s3/bpi_picow_esp32_s3 | Display:1, Radio:25, Input:1, GPS:1 |
| crowpanel-esp32s3-2-epaper | | | | variants/esp32s3/crowpanel-esp32s3-5-epaper | Display:6, Radio:21, Input:1, GPS:1, Power:1 |
| crowpanel-esp32s3-4-epaper | | | | variants/esp32s3/crowpanel-esp32s3-5-epaper | Display:6, Radio:21, Input:1, GPS:1, Power:1 |
| crowpanel-esp32s3-5-epaper | | | | variants/esp32s3/crowpanel-esp32s3-5-epaper | Display:6, Radio:21, Input:1, GPS:1, Power:1 |
| dreamcatcher-2206 | | | | variants/esp32s3/dreamcatcher | Radio:16, Input:1, Power:1, Connectivity/Other:1 |
| elecrow-adv-24-28-tft | Crowpanel Adv 2.4/2.8 TFT | 97 | CROWPANEL | variants/esp32s3/elecrow_panel | Display:1, Radio:21, GPS:6, Power:2 |
| elecrow-adv-35-tft | Crowpanel Adv 3.5 TFT | 97 | CROWPANEL | variants/esp32s3/elecrow_panel | Display:1, Radio:21, GPS:6, Power:2 |
| elecrow-adv1-43-50-70-tft | Crowpanel Adv 4.3/5.0/7.0 TFT | 97 | CROWPANEL | variants/esp32s3/elecrow_panel | Display:1, Radio:21, GPS:6, Power:2 |
| hackaday-communicator | | | | variants/esp32s3/hackaday-communicator | Display:12, Radio:14, Input:1, GPS:1, Power:2 |
| heltec-v3 | Heltec V3 | 43 | HELTEC_V3 | variants/esp32s3/heltec_v3 | Display:1, Radio:16, Input:1, Power:6 |
| heltec-v4 | Heltec V4 | 110 | HELTEC_V4 | variants/esp32s3/heltec_v4 | Display:1, Radio:21, Input:1, GPS:10, Power:6 |
| heltec-v4-tft | Heltec V4 TFT | 110 | HELTEC_V4 | variants/esp32s3/heltec_v4 | Display:1, Radio:21, Input:1, GPS:10, Power:6 |
| heltec-vision-master-e213 | Heltec Vision Master E213 | 67 | HELTEC_VISION_MASTER_E213 | variants/esp32s3/heltec_vision_master_e213 | Display:6, Radio:15, Input:2, Power:6 |
| heltec-vision-master-e213-inkhud | | | | variants/esp32s3/heltec_vision_master_e213 | Display:6, Radio:15, Input:2, Power:6 |
| heltec-vision-master-e290 | Heltec Vision Master E290 | 68 | HELTEC_VISION_MASTER_E290 | variants/esp32s3/heltec_vision_master_e290 | Display:6, Radio:15, Input:2, Power:6 |
| heltec-vision-master-e290-inkhud | | | | variants/esp32s3/heltec_vision_master_e290 | Display:6, Radio:15, Input:2, Power:6 |
| heltec-vision-master-t190 | Heltec Vision Master T190 | 66 | HELTEC_VISION_MASTER_T190 | variants/esp32s3/heltec_vision_master_t190 | Display:6, Radio:16, Input:2, Power:6 |
| heltec-wireless-paper | Heltec Wireless Paper | 49 | HELTEC_WIRELESS_PAPER | variants/esp32s3/heltec_wireless_paper | Display:6, Radio:15, Input:1, Power:6 |
| heltec-wireless-paper-inkhud | | | | variants/esp32s3/heltec_wireless_paper | Display:6, Radio:15, Input:1, Power:6 |
| heltec-wireless-paper-v1_0 | Heltec Wireless Paper V1.0 | 57 | HELTEC_WIRELESS_PAPER_V1_0 | variants/esp32s3/heltec_wireless_paper_v1 | Display:6, Radio:15, Input:1, Power:6 |
| heltec-wireless-tracker | Heltec Wireless Tracker V1.1 | 48 | HELTEC_WIRELESS_TRACKER | variants/esp32s3/heltec_wireless_tracker | Display:9, Radio:16, Input:1, GPS:7, Power:6 |
| heltec-wireless-tracker-V1-0 | Heltec Wireless Tracker V1.0 | 58 | HELTEC_WIRELESS_TRACKER_V1_0 | variants/esp32s3/heltec_wireless_tracker_V1_0 | Display:9, Radio:16, Input:1, GPS:9, Power:4 |
| heltec-wireless-tracker-v2 | Heltec Wireless Tracker V2 | 113 | HELTEC_WIRELESS_TRACKER_V2 | variants/esp32s3/heltec_wireless_tracker_v2 | Display:10, Radio:19, Input:1, GPS:7, Power:6 |
| heltec-wsl-v3 | Heltec Wireless Stick Lite V3 | 44 | HELTEC_WSL_V3 | variants/esp32s3/heltec_wsl_v3 | Radio:16, Input:1, Power:6 |
| heltec_capsule_sensor_v3 | | | | variants/esp32s3/heltec_capsule_sensor_v3 | Display:1, Radio:16, Input:1, GPS:7, Power:6 |
| heltec_sensor_hub | | | | variants/esp32s3/heltec_sensor_hub | Display:1, Radio:15, Input:1, Power:6, Connectivity/Other:1 |
| icarus | | | | variants/esp32s3/icarus | Display:1, Radio:12, Input:1 |
| link32-s3-v1 | | | | variants/esp32s3/link32_s3_v1 | Display:1, Radio:16, Input:2, Power:4, Connectivity/Other:1 |
| m5stack-cardputer-adv | | | | variants/esp32s3/m5stack_cardputer_adv | Display:5, Radio:17, Input:2, GPS:4, Power:3, Connectivity/Other:2 |
| m5stack-cores3 | | | | variants/esp32s3/m5stack_cores3 | Radio:11, Power:1 |
| mesh-tab-3-2-IPS-capacitive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
| mesh-tab-3-2-IPS-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
| mesh-tab-3-2-TN-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
| mesh-tab-3-5-IPS-capacitive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
| mesh-tab-3-5-IPS-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
| mesh-tab-3-5-TN-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
| mesh-tab-4-0-IPS-capacitive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
| mini-epaper-s3 | LILYGO Mini ePaper S3 E-Ink | | MINI_EPAPER_S3 | variants/esp32s3/mini-epaper-s3 | Display:8, Radio:12, Input:5, GPS:1, Power:3, Connectivity/Other:1 |
| mini-epaper-s3-inkhud | | | | variants/esp32s3/mini-epaper-s3 | Display:8, Radio:12, Input:5, GPS:1, Power:3, Connectivity/Other:1 |
| nibble-esp32 | | | | variants/esp32s3/nibble_esp32 | Radio:9, Input:1 |
| nugget-s3-lora | | | | variants/esp32s3/nugget_s3_lora | Display:2, Radio:9, Input:1, Connectivity/Other:1 |
| picomputer-s3 | Pi Computer S3 | 52 | PICOMPUTER_S3 | variants/esp32s3/picomputer-s3 | Display:10, Radio:9, Input:1, Power:3 |
| picomputer-s3-tft | | | | variants/esp32s3/picomputer-s3 | Display:10, Radio:9, Input:1, Power:3 |
| rak3112 | | | | variants/esp32s3/rak3312 | Radio:13, GPS:3, Power:5 |
| rak3312 | RAK3312 | 106 | RAK3312 | variants/esp32s3/rak3312 | Radio:13, GPS:3, Power:5 |
| rak_wismesh_tap_v2 | RAK WisMesh Tap V2 | 116 | WISMESH_TAP_V2 | variants/esp32s3/rak_wismesh_tap_v2 | Radio:13, Input:1, GPS:3, Power:4 |
| rak_wismesh_tap_v2-tft | | | | variants/esp32s3/rak_wismesh_tap_v2 | Radio:13, Input:1, GPS:3, Power:4 |
| seeed-sensecap-indicator | Seeed SenseCAP Indicator | 70 | SENSECAP_INDICATOR | variants/esp32s3/seeed-sensecap-indicator | Display:12, Radio:17, Input:1, GPS:4, Connectivity/Other:1 |
| seeed-sensecap-indicator-tft | | | | variants/esp32s3/seeed-sensecap-indicator | Display:12, Radio:17, Input:1, GPS:4, Connectivity/Other:1 |
| seeed-xiao-s3 | Seeed Xiao ESP32-S3 | 81 | SEEED_XIAO_S3 | variants/esp32s3/seeed_xiao_s3 | Radio:16, Input:1, GPS:6, Power:3 |
| station-g2 | Station G2 | 31 | STATION_G2 | variants/esp32s3/station-g2 | Display:1, Radio:14, Input:1, GPS:2, Power:4 |
| t-beam-1w | LILYGO T-Beam 1W | 122 | TBEAM_1_WATT | variants/esp32s3/t-beam-1w | Display:3, Radio:19, Input:2, GPS:6, Power:5 |
| t-deck | LILYGO T-Deck | 50 | T_DECK | variants/esp32s3/t-deck | Display:11, Radio:17, Input:3, GPS:3, Power:5, Connectivity/Other:2 |
| t-deck-pro | LILYGO T-Deck Pro | 102 | T_DECK_PRO | variants/esp32s3/t-deck-pro | Display:6, Radio:18, Input:2, GPS:7, Power:5, Connectivity/Other:1 |
| t-deck-tft | | | | variants/esp32s3/t-deck | Display:11, Radio:17, Input:3, GPS:3, Power:5, Connectivity/Other:2 |
| t-eth-elite | | | | variants/esp32s3/t-eth-elite | Display:1, Radio:34, Input:1, GPS:5, Connectivity/Other:1 |
| t-watch-s3 | LILYGO T-Watch S3 | 51 | T_WATCH_S3 | variants/esp32s3/t-watch-s3 | Display:12, Radio:17, Input:1, GPS:5, Power:3, Connectivity/Other:3 |
| t5s3_epaper_inkhud | | | | variants/esp32s3/t5s3_epaper | Radio:21, Input:3, GPS:2, Power:5, Connectivity/Other:3 |
| tbeam-s3-core | LILYGO T-Beam Supreme | 12 | LILYGO_TBEAM_S3_CORE | variants/esp32s3/tbeam-s3-core | Display:1, Radio:25, Input:1, GPS:4, Power:1, Connectivity/Other:2 |
| thinknode_m2 | ThinkNode M2 | 90 | THINKNODE_M2 | variants/esp32s3/ELECROW-ThinkNode-M2 | Display:2, Radio:14, Input:2, GPS:1, Power:6 |
| thinknode_m5 | ThinkNode M5 | 107 | THINKNODE_M5 | variants/esp32s3/ELECROW-ThinkNode-M5 | Display:6, Radio:14, Input:2, GPS:8, Power:4, Connectivity/Other:1 |
| tlora-pager | LILYGO T-LoRa Pager | 103 | T_LORA_PAGER | variants/esp32s3/tlora-pager | Display:10, Radio:30, Input:6, GPS:5, Power:5, Connectivity/Other:17 |
| tlora-pager-tft | | | | variants/esp32s3/tlora-pager | Display:10, Radio:30, Input:6, GPS:5, Power:5, Connectivity/Other:17 |
| tlora-t3s3-epaper | LILYGO T-LoRa T3-S3 E-Ink | 16 | TLORA_T3_S3 | variants/esp32s3/tlora_t3s3_epaper | Display:6, Radio:28, Input:1, GPS:3, Power:3 |
| tlora-t3s3-epaper-inkhud | | | | variants/esp32s3/tlora_t3s3_epaper | Display:6, Radio:28, Input:1, GPS:3, Power:3 |
| tlora-t3s3-v1 | LILYGO T-LoRa T3-S3 | 16 | TLORA_T3_S3 | variants/esp32s3/tlora_t3s3_v1 | Display:1, Radio:36, Input:1, Power:3 |
| unphone | unPhone | 59 | UNPHONE | variants/esp32s3/unphone | Display:9, Radio:9, Input:3, GPS:1, Power:2, Connectivity/Other:1 |
| unphone-tft | | | | variants/esp32s3/unphone | Display:9, Radio:9, Input:3, GPS:1, Power:2, Connectivity/Other:1 |
### esp32s2
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| -------------- | ------------ | -------- | ------- | ------------------------------- | -------------------------------------- |
| nugget-s2-lora | | | | variants/esp32s2/nugget_s2_lora | Radio:9, Input:1, Connectivity/Other:1 |
### native
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| ---------------- | ------------ | -------- | ------- | ----------------------------------- | ----------------- |
| buildroot | | | | variants/native/portduino-buildroot | Display:2, GPS:1 |
| coverage | | | | variants/native/portduino | Display:2, GPS:1 |
| native | | | | variants/native/portduino | Display:2, GPS:1 |
| native-fb | | | | variants/native/portduino | Display:2, GPS:1 |
| native-tft | | | | variants/native/portduino | Display:2, GPS:1 |
| native-tft-debug | | | | variants/native/portduino | Display:2, GPS:1 |
### nrf52840
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| -------------------------------- | -------------------------- | -------- | ------------------------- | ---------------------------------------------- | --------------------------------------------------------- |
| ME25LS01-4Y10TD | | | | variants/nrf52840/ME25LS01-4Y10TD | Radio:8, Input:1, GPS:8, Power:4 |
| ME25LS01-4Y10TD_e-ink | | | | variants/nrf52840/ME25LS01-4Y10TD_e-ink | Display:6, Radio:8, Input:1, GPS:8, Power:4 |
| TWC_mesh_v4 | | | | variants/nrf52840/TWC_mesh_v4 | Display:1, Radio:7, GPS:4, Power:5 |
| canaryone | Canary One | 29 | CANARYONE | variants/nrf52840/canaryone | Radio:7, GPS:8, Power:5 |
| feather_diy | | | | variants/nrf52840/feather_diy | Radio:17, Input:1 |
| gat562_mesh_trial_tracker | | | | variants/nrf52840/gat562_mesh_trial_tracker | Display:2, Radio:8, GPS:4, Power:5 |
| heltec-mesh-node-t096 | Heltec Mesh Node 096 | 127 | HELTEC_MESH_NODE_T096 | variants/nrf52840/heltec_mesh_node_t096 | Display:8, Radio:11, GPS:10, Power:9 |
| heltec-mesh-node-t114 | Heltec Mesh Node T114 | 69 | HELTEC_MESH_NODE_T114 | variants/nrf52840/heltec_mesh_node_t114 | Display:7, Radio:8, GPS:7, Power:8, Connectivity/Other:1 |
| heltec-mesh-node-t114-inkhud | | | | variants/nrf52840/heltec_mesh_node_t114-inkhud | Display:6, Radio:8, GPS:7, Power:7, Connectivity/Other:1 |
| heltec-mesh-pocket-10000 | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
| heltec-mesh-pocket-10000-inkhud | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
| heltec-mesh-pocket-5000 | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
| heltec-mesh-pocket-5000-inkhud | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
| heltec-mesh-solar | Heltec MeshSolar | 108 | HELTEC_MESH_SOLAR | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
| heltec-mesh-solar-eink | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
| heltec-mesh-solar-inkhud | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
| heltec-mesh-solar-oled | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
| heltec-mesh-solar-tft | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
| makerpython_nrf52840_sx1280_eink | | | | variants/nrf52840/MakePython_nRF52840_eink | Display:6, Radio:6, GPS:4, Power:5 |
| makerpython_nrf52840_sx1280_oled | | | | variants/nrf52840/MakePython_nRF52840_oled | Radio:6, GPS:4, Power:5 |
| meshlink | | | | variants/nrf52840/meshlink | Display:6, Radio:7, Input:1, GPS:5, Power:5 |
| meshlink_eink | | | | variants/nrf52840/meshlink | Display:6, Radio:7, Input:1, GPS:5, Power:5 |
| meshtiny | | | | variants/nrf52840/meshtiny | Display:2, Radio:8, Input:5, Power:5 |
| minimesh_lite | | | | variants/nrf52840/dls_Minimesh_Lite | Radio:15, Input:1, GPS:4, Power:6 |
| monteops_hw1 | | | | variants/nrf52840/monteops_hw1 | Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
| ms24sf1 | | | | variants/nrf52840/MS24SF1 | Radio:8, Input:1, GPS:8, Power:4 |
| muzi-base | muzi BASE | 93 | MUZI_BASE | variants/nrf52840/muzi_base | Display:3, Radio:19, Input:1, GPS:4, Power:5 |
| nano-g2-ultra | Nano G2 Ultra | 18 | NANO_G2_ULTRA | variants/nrf52840/nano-g2-ultra | Display:1, Radio:7, GPS:4, Power:6, Connectivity/Other:1 |
| nrf52_promicro_diy_tcxo | NRF52 Pro-micro DIY | 63 | NRF52_PROMICRO_DIY | variants/nrf52840/diy/nrf52_promicro_diy_tcxo | Display:4, Radio:29, Input:1, GPS:4, Power:6 |
| pca10059_diy_eink | | | | variants/nrf52840/Dongle_nRF52840-pca10059-v1 | Display:7, Radio:8, GPS:4, Power:5 |
| r1-neo | muzi R1 Neo | 101 | MUZI_R1_NEO | variants/nrf52840/r1-neo | Display:1, Radio:8, GPS:4, Power:5 |
| rak2560 | RAK WisMesh Repeater | 22 | WISMESH_HUB | variants/nrf52840/rak2560 | Display:6, Radio:8, GPS:2, Power:5 |
| rak3401-1watt | RAK3401 1W | 117 | RAK3401 | variants/nrf52840/rak3401_1watt | Display:6, Radio:12, GPS:3, Power:5 |
| rak4631 | RAK WisBlock 4631 | 9 | RAK4631 | variants/nrf52840/rak4631 | Display:6, Radio:8, GPS:3, Power:7, Connectivity/Other:1 |
| rak4631_dbg | | | | variants/nrf52840/rak4631 | Display:6, Radio:8, GPS:3, Power:7, Connectivity/Other:1 |
| rak4631_eink | | | | variants/nrf52840/rak4631_epaper | Display:6, Radio:8, GPS:4, Power:5 |
| rak4631_eink_onrxtx | | | | variants/nrf52840/rak4631_epaper_onrxtx | Display:6, Radio:8, Power:1 |
| rak4631_eth_gw | | | | variants/nrf52840/rak4631_eth_gw | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
| rak4631_eth_gw_dbg | | | | variants/nrf52840/rak4631_eth_gw | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
| rak4631_nomadstar_meteor_pro | NomadStar Meteor Pro | 96 | NOMADSTAR_METEOR_PRO | variants/nrf52840/rak4631_nomadstar_meteor_pro | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
| rak4631_nomadstar_meteor_pro_dbg | | | | variants/nrf52840/rak4631_nomadstar_meteor_pro | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
| rak_wismeshtag | RAK WisMesh Tag | 105 | WISMESH_TAG | variants/nrf52840/rak_wismeshtag | Display:7, Radio:8, GPS:4, Power:5 |
| rak_wismeshtap | RAK WisMesh Tap | 84 | WISMESH_TAP | variants/nrf52840/rak_wismeshtap | Display:21, Radio:8, GPS:3, Power:7, Connectivity/Other:1 |
| seeed_solar_node | Seeed SenseCAP Solar Node | 95 | SEEED_SOLAR_NODE | variants/nrf52840/seeed_solar_node | Radio:9, Input:2, GPS:8, Power:3 |
| seeed_wio_tracker_L1 | Seeed Wio Tracker L1 | 99 | SEEED_WIO_TRACKER_L1 | variants/nrf52840/seeed_wio_tracker_L1 | Display:2, Radio:9, Input:1, GPS:7, Power:3 |
| seeed_wio_tracker_L1_eink | Seeed Wio Tracker L1 E-Ink | 100 | SEEED_WIO_TRACKER_L1_EINK | variants/nrf52840/seeed_wio_tracker_L1_eink | Display:7, Radio:9, Input:1, GPS:7, Power:3 |
| seeed_wio_tracker_L1_eink-inkhud | | | | variants/nrf52840/seeed_wio_tracker_L1_eink | Display:7, Radio:9, Input:1, GPS:7, Power:3 |
| seeed_xiao_nrf52840_kit | Seeed Xiao NRF52840 Kit | 88 | XIAO_NRF52_KIT | variants/nrf52840/seeed_xiao_nrf52840_kit | Radio:19, Input:2, GPS:9, Power:6 |
| seeed_xiao_nrf52840_kit_i2c | | | | variants/nrf52840/seeed_xiao_nrf52840_kit | Radio:19, Input:2, GPS:9, Power:6 |
| t-echo | LILYGO T-Echo | 7 | T_ECHO | variants/nrf52840/t-echo | Display:7, Radio:8, GPS:7, Power:6, Connectivity/Other:1 |
| t-echo-inkhud | | | | variants/nrf52840/t-echo | Display:7, Radio:8, GPS:7, Power:6, Connectivity/Other:1 |
| t-echo-lite | LILYGO T-Echo Lite | 109 | T_ECHO_LITE | variants/nrf52840/t-echo-lite | Display:6, Radio:9, GPS:10, Power:8 |
| t-echo-plus | | | | variants/nrf52840/t-echo-plus | Display:8, Radio:8, GPS:7, Power:6 |
| thinknode_m1 | ThinkNode M1 | 89 | THINKNODE_M1 | variants/nrf52840/ELECROW-ThinkNode-M1 | Display:7, Radio:8, Input:1, GPS:8, Power:8 |
| thinknode_m1-inkhud | | | | variants/nrf52840/ELECROW-ThinkNode-M1 | Display:7, Radio:8, Input:1, GPS:8, Power:8 |
| thinknode_m3 | Elecrow ThinkNode M3 | 115 | THINKNODE_M3 | variants/nrf52840/ELECROW-ThinkNode-M3 | Radio:1, Input:2, GPS:8, Power:7, Connectivity/Other:1 |
| thinknode_m4 | | | | variants/nrf52840/ELECROW-ThinkNode-M4 | Radio:8, GPS:12, Power:7 |
| thinknode_m6 | ThinkNode M6 | 120 | THINKNODE_M6 | variants/nrf52840/ELECROW-ThinkNode-M6 | Radio:7, GPS:9, Power:9, Connectivity/Other:1 |
| tracker-t1000-e | Seeed SenseCAP T1000-E | 71 | TRACKER_T1000_E | variants/nrf52840/tracker-t1000-e | Display:1, Radio:8, Input:1, GPS:13, Power:5 |
| wio-sdk-wm1110 | | | | variants/nrf52840/wio-sdk-wm1110 | Radio:8, Input:1 |
| wio-t1000-s | | | | variants/nrf52840/wio-t1000-s | Radio:8, Input:1, GPS:12, Power:4 |
| wio-tracker-wm1110 | Seeed Wio WM1110 Tracker | 21 | WIO_WM1110 | variants/nrf52840/wio-tracker-wm1110 | Radio:8, Input:1, GPS:3 |
### rp2040
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| -------------------- | ------------------- | -------- | ----------- | ------------------------------------ | ------------------------------------------------ |
| catsniffer | | | | variants/rp2040/ec_catsniffer | Display:1, Radio:17, GPS:1 |
| challenger_2040_lora | | | | variants/rp2040/challenger_2040_lora | Radio:17, Input:1, Power:1 |
| feather_rp2040_rfm95 | | | | variants/rp2040/feather_rp2040_rfm95 | Radio:17, Input:1, Power:1 |
| nibble-rp2040 | | | | variants/rp2040/nibble_rp2040 | Radio:9, Input:1 |
| pico | Raspberry Pi Pico | 47 | RPI_PICO | variants/rp2040/rpipico | Radio:16, Input:1, Power:4 |
| pico_slowclock | | | | variants/rp2040/rpipico-slowclock | Display:2, Radio:16, Input:1, GPS:5, Power:4 |
| picow | Raspberry Pi Pico W | 47 | RPI_PICO | variants/rp2040/rpipicow | Radio:16, Input:1, Power:4, Connectivity/Other:1 |
| rak11310 | RAK WisBlock 11310 | 26 | RAK11310 | variants/rp2040/rak11310 | Radio:17, Input:1, Power:3, Connectivity/Other:1 |
| rp2040-lora | RP2040 LoRa | 30 | RP2040_LORA | variants/rp2040/rp2040-lora | Radio:18, Input:1, Power:1 |
| senselora_rp2040 | | | | variants/rp2040/senselora_rp2040 | Display:1, Radio:9, Input:1, Power:1 |
### rp2350
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| ----------- | ------------ | -------- | ------- | ------------------------- | ------------------------------------------------ |
| pico2 | | | | variants/rp2350/rpipico2 | Radio:16, Input:1, Power:4 |
| pico2w | | | | variants/rp2350/rpipico2w | Radio:16, Input:1, Power:4, Connectivity/Other:1 |
### stm32
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
| --------------- | ------------ | -------- | ------- | ------------------------------ | ---------------------------------- |
| CDEBYTE_E77-MBL | | | | variants/stm32/CDEBYTE_E77-MBL | Display:1 |
| milesight_gs301 | | | | variants/stm32/milesight_gs301 | Display:1, Radio:1, Input:1 |
| rak3172 | | | | variants/stm32/rak3172 | Display:1 |
| russell | | | | variants/stm32/russell | Display:1, Radio:1, Input:1, GPS:4 |
| wio-e5 | | | | variants/stm32/wio-e5 | Display:1 |
## Representative Board Examples
### diy: `9m2ibr_aprs_lora_tracker`
- Variant directory: `variants/esp32/diy/9m2ibr_aprs_lora_tracker`
- Input examples:
- `BUTTON_PIN` = `15`
- Radio examples:
- `LORA_SCK` = `18`
- `LORA_MISO` = `19`
- `LORA_MOSI` = `23`
- `LORA_CS` = `5`
- `LORA_DIO0` = `26`
- `LORA_RESET` = `27`
- `LORA_DIO1` = `12`
- `LORA_DIO2` = `RADIOLIB_NC`
- GPS examples:
- `GPS_RX_PIN` = `16`
- `GPS_TX_PIN` = `17`
- Display examples:
- `HAS_SCREEN` = `1`
- I2C/SPI examples:
- `I2C_SDA` = `21`
- `I2C_SCL` = `22`
- Power examples:
- `BATTERY_PIN` = `35`
- `ADC_MULTIPLIER` = `2.01`
- `ADC_CHANNEL` = `ADC1_GPIO35_CHANNEL`
- `BATTERY_SENSE_RESOLUTION_BITS` = `ADC_RESOLUTION`
### esp32: `betafpv_2400_tx_micro`
- Variant directory: `variants/esp32/betafpv_2400_tx_micro`
- Input examples:
- `BUTTON_PIN` = `25`
- Radio examples:
- `LORA_SCK` = `18`
- `LORA_MISO` = `19`
- `LORA_MOSI` = `23`
- `LORA_CS` = `5`
- `RF95_FAN_EN` = `17`
- `USE_SX1280` = `1`
- `LORA_RESET` = `14`
- `SX128X_CS` = `5`
- I2C/SPI examples:
- `I2C_SDA` = `22`
- `I2C_SCL` = `32`
- Connectivity/Other examples:
- `HAS_NEOPIXEL` = `1`
### esp32-c3: `ai-c3`
- Variant directory: `variants/esp32c3/ai-c3`
- Input examples:
- `BUTTON_PIN` = `9`
- Radio examples:
- `USE_RF95` = `1`
- `LORA_SCK` = `4`
- `LORA_MISO` = `5`
- `LORA_MOSI` = `6`
- `LORA_CS` = `7`
- `LORA_DIO0` = `10`
- `LORA_DIO1` = `3`
- `LORA_RESET` = `2`
- GPS examples:
- `HAS_GPS` = `0`
- I2C/SPI examples:
- `I2C_SDA` = `SDA`
- `I2C_SCL` = `SCL`
### esp32-c6: `m5stack-unitc6l`
- Variant directory: `variants/esp32c6/m5stack_unitc6l`
- `custom_meshtastic_hw_model`: `111`
- `custom_meshtastic_hw_model_slug`: `M5STACK_C6L`
- `custom_meshtastic_architecture`: `esp32-c6`
- `custom_meshtastic_actively_supported`: `true`
- `custom_meshtastic_support_level`: `1`
- `custom_meshtastic_display_name`: `M5Stack Unit C6L`
- `custom_meshtastic_images`: `m5_c6l.svg`
- `custom_meshtastic_tags`: `M5Stack`
- Radio examples:
- `USE_SX1262` = `1`
- `LORA_MISO` = `22`
- `LORA_SCK` = `20`
- `LORA_MOSI` = `21`
- `LORA_CS` = `23`
- `LORA_RESET` = `RADIOLIB_NC`
- `LORA_DIO1` = `7`
- `LORA_BUSY` = `19`
- GPS examples:
- `HAS_GPS` = `1`
- `GPS_RX_PIN` = `4`
- `GPS_TX_PIN` = `5`
- Display examples:
- `SCREEN_TRANSITION_FRAMERATE` = `10`
- I2C/SPI examples:
- `I2C_SDA` = `10`
- `I2C_SCL` = `8`
- Connectivity/Other examples:
- `HAS_NEOPIXEL` = `1`
### esp32-s3: `CDEBYTE_EoRa-Hub`
- Variant directory: `variants/esp32s3/CDEBYTE_EoRa-Hub`
- Input examples:
- `BUTTON_PIN` = `0`
- Radio examples:
- `USE_LR1121` = `1`
- `LORA_SCK` = `9`
- `LORA_MOSI` = `10`
- `LORA_MISO` = `11`
- `LORA_RESET` = `12`
- `LORA_CS` = `8`
- `LORA_DIO9` = `13`
- `LR1121_IRQ_PIN` = `14`
- Display examples:
- `HAS_SCREEN` = `1`
- `USE_SSD1306` = `1`
- I2C/SPI examples:
- `I2C_SCL` = `17`
- `I2C_SDA` = `18`
- `I2C_SCL1` = `21`
- `I2C_SDA1` = `10`
- Power examples:
- `BATTERY_PIN` = `1`
- `ADC_CHANNEL` = `ADC1_GPIO1_CHANNEL`
- `ADC_MULTIPLIER` = `103.0`
- `ADC_ATTENUATION` = `ADC_ATTEN_DB_0`
- `ADC_CTRL` = `37`
- `ADC_CTRL_ENABLED` = `LOW`
### esp32s2: `nugget-s2-lora`
- Variant directory: `variants/esp32s2/nugget_s2_lora`
- Input examples:
- `BUTTON_PIN` = `0`
- Radio examples:
- `USE_RF95` = `1`
- `LORA_SCK` = `6`
- `LORA_MISO` = `8`
- `LORA_MOSI` = `10`
- `LORA_CS` = `13`
- `LORA_DIO0` = `16`
- `LORA_RESET` = `5`
- `LORA_DIO1` = `RADIOLIB_NC`
- I2C/SPI examples:
- `I2C_SDA` = `34`
- `I2C_SCL` = `36`
- Connectivity/Other examples:
- `HAS_NEOPIXEL` = `1`
### native: `buildroot`
- Variant directory: `variants/native/portduino-buildroot`
- GPS examples:
- `HAS_GPS` = `1`
- Display examples:
- `HAS_SCREEN` = `1`
- `USE_TFTDISPLAY` = `1`
### nrf52840: `ME25LS01-4Y10TD`
- Variant directory: `variants/nrf52840/ME25LS01-4Y10TD`
- Input examples:
- `BUTTON_PIN` = `(0 + 27)`
- Radio examples:
- `LORA_RESET` = `(32 + 11)`
- `LORA_DIO1` = `(32 + 12)`
- `LORA_DIO2` = `(32 + 10)`
- `LORA_SCK` = `PIN_SPI_SCK`
- `LORA_MISO` = `PIN_SPI_MISO`
- `LORA_MOSI` = `PIN_SPI_MOSI`
- `LORA_CS` = `PIN_SPI_NSS`
- `USE_LR1110` = `1`
- GPS examples:
- `HAS_GPS` = `0`
- `PIN_GPS_EN` = `-1`
- `GPS_EN_ACTIVE` = `HIGH`
- `PIN_GPS_RESET` = `-1`
- `GPS_VRTC_EN` = `-1`
- `GPS_SLEEP_INT` = `-1`
- `GPS_RTC_INT` = `-1`
- `GPS_RESETB_OUT` = `-1`
- I2C/SPI examples:
- `WIRE_INTERFACES_COUNT` = `1`
- `SPI_INTERFACES_COUNT` = `1`
- `PIN_SPI_MISO` = `(0 + 29)`
- `PIN_SPI_MOSI` = `(0 + 2)`
- `PIN_SPI_SCK` = `(32 + 15)`
- `PIN_SPI_NSS` = `(32 + 13)`
- Power examples:
- `BATTERY_PIN` = `-1`
- `ADC_MULTIPLIER` = `(2.0F)`
- `ADC_RESOLUTION` = `14`
- `BATTERY_SENSE_RESOLUTION_BITS` = `12`
### rp2040: `catsniffer`
- Variant directory: `variants/rp2040/ec_catsniffer`
- Radio examples:
- `USE_SX1262` = `1`
- `LORA_SCK` = `18`
- `LORA_MISO` = `16`
- `LORA_MOSI` = `19`
- `LORA_CS` = `17`
- `LORA_DIO0` = `5`
- `LORA_RESET` = `24`
- `LORA_DIO1` = `4`
- GPS examples:
- `HAS_GPS` = `0`
- Display examples:
- `HAS_SCREEN` = `0`
### rp2350: `pico2`
- Variant directory: `variants/rp2350/rpipico2`
- Input examples:
- `BUTTON_PIN` = `17`
- Radio examples:
- `USE_SX1262` = `1`
- `LORA_SCK` = `10`
- `LORA_MISO` = `12`
- `LORA_MOSI` = `11`
- `LORA_CS` = `3`
- `LORA_DIO0` = `RADIOLIB_NC`
- `LORA_RESET` = `15`
- `LORA_DIO1` = `20`
- Power examples:
- `EXT_NOTIFY_OUT` = `22`
- `BATTERY_PIN` = `26`
- `ADC_MULTIPLIER` = `3.1`
- `BATTERY_SENSE_RESOLUTION_BITS` = `ADC_RESOLUTION`
### stm32: `CDEBYTE_E77-MBL`
- Variant directory: `variants/stm32/CDEBYTE_E77-MBL`
- Display examples:
- `USE_STM32WLx` = `1`
## Intake Guidance For New Hardware
When using this context to add a new board, collect these inputs before generating files:
- PlatformIO environment name
- `custom_meshtastic_hw_model` and `custom_meshtastic_hw_model_slug`
- Display name and architecture
- Whether the board is actively supported and its support level
- Partition scheme, DFU requirement, and image/tag metadata if applicable
- Radio chip family and complete radio pin group
- Input/button/rotary/keyboard pins
- Display interface pins and driver-related macros
- GPS, power-management, I2C, SPI, storage, and auxiliary peripheral definitions
- Any board-specific initialization that requires `variant.cpp` or extra variant hooks
## Inherited Defaults Note
Some architecture families rely on BSP (Board Support Package) or base-environment defaults rather than declaring every pin or capability macro explicitly in `variant.h`. When adding a new board for one of these families, check the relevant BSP headers before assuming a missing define means a feature is absent.
Directories scanned that had no `variant.h` (relying entirely on BSP/base-environment): diy: 4, esp32: 3, esp32s3: 1
### nrf52840
- VARIANT_MCK — nRF52 BSP clock constant (e.g., 64000000ul); always inherited from BSP unless overridden.
- USE_LFXO — low-frequency crystal oscillator selection; declared locally only when the board uses LFXO rather than the RC oscillator.
- PIN*SPI*_ / PIN*SPI1*_ — SPI bus pin numbers come from the BSP variant table; boards override only when the LoRa radio or display uses non-default SPI routing.
- WIRE_INTERFACES_COUNT / SPI_INTERFACES_COUNT — bus count comes from BSP; explicitly set only when the board deviates.
- LED_BLUE / PIN_LED1 / PINS_COUNT / NUM_DIGITAL_PINS — standard BSP pin-table entries inherited from the nRF52 Arduino core.
### rp2040
- PIN*SPI*\* — primary SPI pins come from the RP2040 Arduino BSP; most boards declare them explicitly, but the defaults align with the Pico pin assignments.
- NUM_DIGITAL_PINS / NUM_ANALOG_INPUTS — Arduino BSP counts; rarely overridden locally.
### stm32
- Radio and pin assignments for STM32WL targets are largely internal to the WL SoC and declared via STM32 HAL/BSP headers; variant.h files are minimal.
- USE_STM32WLx is typically the only explicit define; all other radio config comes from the BSP.
### native
- The native/Portduino target uses runtime configuration rather than compile-time pin defines; variant.h only sets display and GPS stubs.
## Cautions
- Some boards rely on architecture defaults rather than declaring every field locally.
- Some board families expose multiple environments or display variants that share one hardware model.
- Source materials such as schematics still need human verification before new pin mappings are trusted.
- This document is a starting context artifact, not proof that a new board definition is safe to merge.
+6
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
+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())
+382
View File
@@ -0,0 +1,382 @@
"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload.
The fixture pipeline is two-stage:
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
fake-but-realistic peers. Committed under `test/fixtures/nodedb/`.
2. `bin/seed-json-to-proto.py` compiles JSONL → binary v25 NodeDatabase
protobuf with fresh wall-clock timestamps.
This module exposes `push_fake_nodedb(...)`, the MCP tool that:
- target="portduino": compiles the JSONL into the device's prefs dir on
the local filesystem (`~/.portduino/<config>/prefs/nodes.proto`).
- target="hardware": compiles to a temp file, then streams it over the
XModem protocol (via the meshtastic SerialInterface/BLEInterface +
`meshtastic.xmodempacket` pubsub topic) to `/prefs/nodes.proto` on the
device. Triggers a reboot so the firmware loads the new state on next
boot.
XModem wire details (mirrors firmware impl at src/xmodem.cpp:115-260):
* 128-byte chunks; final chunk padded to 128 B with 0x1A (SUB) bytes.
* CRC16-CCITT (poly 0x1021, init 0x0000).
* SOH/seq=0 carries the destination filename in `buffer.bytes`. ACK if
`FSCom.open(filename, FILE_O_WRITE)` succeeds; NAK otherwise.
* SOH/seq≥1 carries a 128-byte chunk. ACK = advance; NAK = retransmit.
* EOT after the last chunk flushes + closes the file on-device.
Hardware push requires `confirm=True` (mirrors factory_reset / erase_and_flash
in the .github/copilot-instructions.md "never do these without asking" list).
"""
from __future__ import annotations
import dataclasses
import hashlib
import pathlib
import queue
import shutil
import subprocess
import sys
import tempfile
import time
from typing import Any, Literal
from .connection import connect, is_tcp_port
# Resolve repo root so the tool works regardless of mcp-server cwd.
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
_SEED_DIR = _REPO_ROOT / "test" / "fixtures" / "nodedb"
_COMPILE_SCRIPT = _REPO_ROOT / "bin" / "seed-json-to-proto.py"
_DEFAULT_NODES_FILENAME = "/prefs/nodes.proto"
_XMODEM_CHUNK = 128
_XMODEM_SUB = 0x1A
_ACK_TIMEOUT_INIT_S = 5.0
_ACK_TIMEOUT_CHUNK_S = 2.0
_MAX_CHUNK_RETRIES = 5
_VALID_SIZES = (250, 500, 1000, 2000)
class FixtureError(RuntimeError):
"""Raised for any fixture-push failure (compile, transport, ack timeout, …)."""
# ---------------------------------------------------------------------------
# CRC16-CCITT (poly 0x1021, init 0x0000). Matches the firmware's `crc16_ccitt`.
# Hand-rolled to avoid the optional `crcmod` dep.
# ---------------------------------------------------------------------------
def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
crc = init
for b in data:
crc ^= b << 8
for _ in range(8):
if crc & 0x8000:
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
else:
crc = (crc << 1) & 0xFFFF
return crc
# ---------------------------------------------------------------------------
# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module
# doesn't have to duplicate the proto-encoding logic.
# ---------------------------------------------------------------------------
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
if not _COMPILE_SCRIPT.is_file():
raise FixtureError(f"compile script missing at {_COMPILE_SCRIPT}")
cmd = [
sys.executable,
str(_COMPILE_SCRIPT),
"--in",
str(jsonl_path),
"--out",
str(out_path),
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
raise FixtureError(
f"seed-json-to-proto.py failed (exit {exc.returncode}):\n"
f" stdout: {exc.stdout}\n stderr: {exc.stderr}"
) from exc
def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
if custom is not None:
p = pathlib.Path(custom).expanduser().resolve()
if not p.is_file():
raise FixtureError(f"custom_seed_jsonl not found: {p}")
return p
p = _SEED_DIR / f"seed_v25_{size:04d}.jsonl"
if not p.is_file():
raise FixtureError(
f"missing committed seed at {p}. "
f"Run `./bin/regen-fake-nodedbs.sh` to generate it."
)
return p
# ---------------------------------------------------------------------------
# Portduino push — file copy into ~/.portduino/<config>/prefs/
# ---------------------------------------------------------------------------
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
home = pathlib.Path.home()
return home / ".portduino" / config_name / "prefs"
def _push_portduino(
size: int,
jsonl: pathlib.Path,
portduino_config: str,
backup_existing: bool,
) -> dict[str, Any]:
prefs = _portduino_prefs_dir(portduino_config)
prefs.mkdir(parents=True, exist_ok=True)
target = prefs / "nodes.proto"
backed_up_to: str | None = None
if backup_existing and target.is_file():
ts = int(time.time())
backup = prefs / f"nodes.proto.bak.{ts}"
shutil.move(str(target), str(backup))
backed_up_to = str(backup)
_compile_proto(jsonl, target)
raw = target.read_bytes()
return {
"transport": "portduino",
"path": str(target),
"bytes": len(raw),
"sha256": hashlib.sha256(raw).hexdigest(),
"jsonl_source": str(jsonl),
"backed_up_to": backed_up_to,
}
# ---------------------------------------------------------------------------
# Hardware push — XModem over BLE/serial via the meshtastic Python interface.
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class _AckEvent:
control: int
seq: int
def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEvent:
try:
return q.get(timeout=timeout_s)
except queue.Empty as exc:
raise FixtureError(
f"XModem response timeout after {timeout_s:.1f}s — device not responding"
) from exc
def _push_hardware(
size: int,
jsonl: pathlib.Path,
port: str | None,
reboot_after: bool,
) -> dict[str, Any]:
# Lazy imports so the module loads even when the meshtastic deps aren't
# available (e.g. CI in a Python env without the package installed).
try:
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
from pubsub import pub
except ImportError as exc: # pragma: no cover — dep missing
raise FixtureError(
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
) from exc
if is_tcp_port(port):
raise FixtureError(
"hardware push over TCP/portduino is not supported — use "
"target='portduino' to drop the fixture directly into the prefs dir."
)
# Compile the fixture to a temp file with fresh timestamps.
with tempfile.NamedTemporaryFile(suffix=".proto", delete=False) as tf:
proto_path = pathlib.Path(tf.name)
try:
_compile_proto(jsonl, proto_path)
payload = proto_path.read_bytes()
finally:
proto_path.unlink(missing_ok=True)
sha256 = hashlib.sha256(payload).hexdigest()
total_bytes = len(payload)
# Subscribe to XModem responses BEFORE we open the interface, so we don't
# race the first ACK that arrives during the SOH/seq=0 handshake.
#
# NB: the signature MUST declare every kwarg pypubsub will see for this
# topic, or pubsub locks the topic spec to a smaller set (whichever
# subscribe arrives first) and then *rejects* the meshtastic library's
# publish call with `SenderUnknownMsgDataError: unknown ... interface`.
# The meshtastic lib publishes both `packet=` and `interface=`
# (mesh_interface.py:1389-1395), so both must appear here.
response_q: "queue.Queue[_AckEvent]" = queue.Queue()
def _on_xmodem(packet: Any = None, interface: Any = None, **_kw: Any) -> None:
if packet is None:
return
response_q.put(_AckEvent(control=int(packet.control), seq=int(packet.seq)))
pub.subscribe(_on_xmodem, "meshtastic.xmodempacket")
chunks_sent = 0
retried = 0
rebooted = False
XMC = xmodem_pb2.XModem.Control
try:
with connect(port=port) as iface:
# 1) Send the filename (SOH, seq=0).
init_pkt = xmodem_pb2.XModem(
control=XMC.Value("SOH"),
seq=0,
buffer=_DEFAULT_NODES_FILENAME.encode("utf-8"),
)
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=init_pkt))
ack = _wait_for_response(response_q, _ACK_TIMEOUT_INIT_S)
if ack.control != XMC.Value("ACK"):
raise FixtureError(
f"device refused filename {_DEFAULT_NODES_FILENAME!r} "
f"(got control={ack.control}, expected ACK). "
f"Filesystem full or permissions issue?"
)
# 2) Stream the payload in 128 B chunks.
for offset in range(0, total_bytes, _XMODEM_CHUNK):
chunk = payload[offset : offset + _XMODEM_CHUNK]
if len(chunk) < _XMODEM_CHUNK:
# Pad final chunk to 128 B with SUB. The trailing 0x1A bytes
# become part of the file on-device, but nanopb ignores
# bytes past the end of the top-level message.
chunk = chunk + bytes([_XMODEM_SUB] * (_XMODEM_CHUNK - len(chunk)))
seq = ((offset // _XMODEM_CHUNK) + 1) % 256
# Retry loop on NAK / timeout.
attempts = 0
while True:
pkt = xmodem_pb2.XModem(
control=XMC.Value("SOH"),
seq=seq,
buffer=chunk,
crc16=_crc16_ccitt(chunk),
)
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=pkt))
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
if ack.control == XMC.Value("ACK"):
chunks_sent += 1
break
if ack.control == XMC.Value("NAK"):
attempts += 1
retried += 1
if attempts >= _MAX_CHUNK_RETRIES:
# Abort: send CAN so the firmware removes the half-
# written file via FSCom.remove(filename).
iface._sendToRadio(
mesh_pb2.ToRadio(
xmodemPacket=xmodem_pb2.XModem(
control=XMC.Value("CAN")
)
)
)
raise FixtureError(
f"chunk seq={seq} NAK'd {attempts} times; "
f"aborted transfer (file removed on-device)."
)
continue # retry the same chunk
raise FixtureError(
f"unexpected XModem control={ack.control} on seq={seq}"
)
# 3) Tell the device we're done.
iface._sendToRadio(
mesh_pb2.ToRadio(
xmodemPacket=xmodem_pb2.XModem(control=XMC.Value("EOT"))
)
)
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
if ack.control != XMC.Value("ACK"):
raise FixtureError(f"EOT not ACKed (got control={ack.control})")
# 4) Reboot so loadFromDisk picks up the new file.
if reboot_after:
iface.localNode.reboot(secs=1)
rebooted = True
finally:
try:
pub.unsubscribe(_on_xmodem, "meshtastic.xmodempacket")
except Exception:
pass
return {
"transport": "hardware",
"port": port,
"filename_on_device": _DEFAULT_NODES_FILENAME,
"bytes": total_bytes,
"chunks_sent": chunks_sent,
"retried": retried,
"sha256": sha256,
"jsonl_source": str(jsonl),
"rebooted": rebooted,
}
# ---------------------------------------------------------------------------
# Public entry point — registered as an MCP tool in server.py.
# ---------------------------------------------------------------------------
def push_fake_nodedb(
size: int,
target: Literal["portduino", "hardware"] = "portduino",
*,
port: str | None = None,
portduino_config: str = "default",
backup_existing: bool = True,
confirm: bool = False,
reboot_after: bool = True,
custom_seed_jsonl: str | None = None,
) -> dict[str, Any]:
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
Args:
size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use.
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
or BLE identifier. TCP endpoints are rejected — use target="portduino"
instead.
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
if present, so you can roll back.
confirm: required True for target="hardware" (writes flash + reboots).
reboot_after: hardware only. If True, send a 1-second reboot after the
final ACK so loadFromDisk picks up the new file at next boot.
custom_seed_jsonl: override the committed JSONL. Use to push a hand-edited
test scenario.
Returns:
dict with transport, bytes, sha256, etc. — depends on target.
"""
if size not in _VALID_SIZES:
raise FixtureError(
f"size must be one of {_VALID_SIZES}; got {size!r}. "
f"Add a new committed seed if you need a different cardinality."
)
jsonl = _resolve_seed_jsonl(size, custom_seed_jsonl)
if target == "portduino":
return _push_portduino(size, jsonl, portduino_config, backup_existing)
if target == "hardware":
if not confirm:
raise FixtureError(
"hardware push writes flash and triggers a reboot — pass confirm=True."
)
if not port:
raise FixtureError(
"target='hardware' requires a port (e.g. /dev/cu.usbmodemXXXX)."
)
return _push_hardware(size, jsonl, port, reboot_after)
raise FixtureError(f"unknown target {target!r}; expected 'portduino' or 'hardware'")
+44 -1
View File
@@ -108,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)],
@@ -127,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)
@@ -146,20 +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,
@@ -167,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,
}
+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
+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:
+274 -2
View File
@@ -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
@@ -14,17 +15,38 @@ from . import (
admin,
boards,
devices,
fixtures,
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 +97,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 +109,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 +141,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 +151,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()
@@ -734,3 +782,227 @@ 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,
)
# ---------- Fixture / test-data push --------------------------------------
@app.tool()
def push_fake_nodedb(
size: int,
target: str = "portduino",
port: str | None = None,
portduino_config: str = "default",
backup_existing: bool = True,
confirm: bool = False,
reboot_after: bool = True,
custom_seed_jsonl: str | None = None,
) -> dict[str, Any]:
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
Two transports:
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
Fast, no device connection needed.
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
Requires `port` + `confirm=True`. Triggers a reboot
so loadFromDisk picks up the new file at next boot.
Compiles a fresh-timestamp proto from the committed JSONL seed under
test/fixtures/nodedb/seed_v25_<N>.jsonl each invocation, so the loaded
NodeDB always looks "recent" to the connecting phone. Structural data
(names, IDs, positions, telemetries) is deterministic per the seed.
Override the JSONL via `custom_seed_jsonl` to push a hand-edited scenario.
"""
return fixtures.push_fake_nodedb(
size=size,
target=target, # type: ignore[arg-type]
port=port,
portduino_config=portduino_config,
backup_existing=backup_existing,
confirm=confirm,
reboot_after=reboot_after,
custom_seed_jsonl=custom_seed_jsonl,
)
+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,364 @@
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
Lives under tests/unit/ because none of these touch real hardware — they
shell out to the bin/ scripts and decode the resulting protobufs in-process.
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
import time
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
SEED_GEN = REPO_ROOT / "bin" / "gen-fake-nodedb-seed.py"
COMPILE = REPO_ROOT / "bin" / "seed-json-to-proto.py"
FIXTURES_DIR = REPO_ROOT / "test" / "fixtures" / "nodedb"
# Ensure the locally-generated Python protobuf bindings are importable.
# These live under `meshtastic_v25` (not `meshtastic`) so they don't shadow
# the PyPI `meshtastic` package that the rest of the mcp-server depends on.
_BINDINGS_DIR = REPO_ROOT / "bin" / "_generated"
if _BINDINGS_DIR.is_dir() and str(_BINDINGS_DIR) not in sys.path:
sys.path.insert(0, str(_BINDINGS_DIR))
try:
from meshtastic_v25.deviceonly_pb2 import (
NodeDatabase, # type: ignore[import-not-found]
)
except ImportError:
NodeDatabase = None # type: ignore[assignment]
def _require_v25_bindings() -> None:
if NodeDatabase is None:
pytest.skip(
"v25 Python protobuf bindings missing; run `./bin/regen-py-protos.sh`."
)
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
pytest.skip(
"Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`."
)
def _run(cmd: list[str]) -> None:
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
# ---------------------------------------------------------------------------
# Seed generator: deterministic for given --seed (no wall-clock dependence).
# ---------------------------------------------------------------------------
def test_seed_generator_is_deterministic(tmp_path: pathlib.Path) -> None:
a = tmp_path / "a.jsonl"
b = tmp_path / "b.jsonl"
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"100",
"--seed",
"42",
"--out",
str(a),
]
)
# Sleep so any sneaky wall-clock leak in the generator would surface as
# a byte diff between the two runs.
time.sleep(0.8)
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"100",
"--seed",
"42",
"--out",
str(b),
]
)
assert a.read_bytes() == b.read_bytes()
def test_seed_generator_meta_line(tmp_path: pathlib.Path) -> None:
out = tmp_path / "seed.jsonl"
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"50",
"--seed",
"1",
"--out",
str(out),
]
)
lines = out.read_text(encoding="utf-8").splitlines()
assert len(lines) == 51 # 1 meta + 50 nodes
meta = json.loads(lines[0])
assert "_meta" in meta
assert meta["_meta"]["version"] == 25
assert meta["_meta"]["count"] == 50
assert meta["_meta"]["seed"] == 1
def test_seed_only_uses_active_hardware_and_roles(tmp_path: pathlib.Path) -> None:
"""Confirm no deprecated roles + no off-list HW models leak through."""
out = tmp_path / "seed.jsonl"
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"500",
"--seed",
"7",
"--out",
str(out),
]
)
forbidden_roles = {"ROUTER_CLIENT", "REPEATER"}
forbidden_hw = {
"TLORA_V1",
"TLORA_V2",
"TLORA_V1_1P3",
"TLORA_V2_1_1P6",
"TLORA_V2_1_1P8",
"HELTEC_V1",
"HELTEC_V2_0",
"HELTEC_V2_1",
"TBEAM",
"TBEAM_V0P7",
"NANO_G1",
"NANO_G1_EXPLORER",
"NANO_G2_ULTRA",
"STATION_G1",
"STATION_G2",
"PORTDUINO",
"ANDROID_SIM",
"DIY_V1",
"LORA_RELAY_V1",
"NRF52840_PCA10059",
"NRF52_UNKNOWN",
"DR_DEV",
"GENIEBLOCKS",
"M5STACK",
"RP2040_LORA",
"PPR",
}
for raw in out.read_text(encoding="utf-8").splitlines()[1:]:
node = json.loads(raw)
assert node["role"] not in forbidden_roles, f"deprecated role: {node['role']}"
assert (
node["hw_model"] not in forbidden_hw
), f"non-tier-1 HW: {node['hw_model']}"
# ---------------------------------------------------------------------------
# Compile step + committed seeds.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("size", [250, 500, 1000, 2000])
def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path) -> None:
_require_v25_bindings()
proto = tmp_path / "out.proto"
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
if not jsonl.is_file():
pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh")
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
db = NodeDatabase()
db.ParseFromString(proto.read_bytes())
assert db.version == 25
assert len(db.nodes) == size
nums = {n.num for n in db.nodes}
assert len(nums) == size, "node numbers must be unique"
assert all(n.long_name and n.short_name for n in db.nodes)
assert all(len(n.long_name) <= 24 for n in db.nodes) # max_size:25 - NUL
# Coverage sanity (±10pp tolerance for binomial fluctuation).
def in_range(actual: int, expected_ratio: float, tol_pp: float = 0.10) -> bool:
lo = max(0, int((expected_ratio - tol_pp) * size))
hi = min(size, int((expected_ratio + tol_pp) * size))
return lo <= actual <= hi
assert in_range(len(db.positions), 0.85)
assert in_range(len(db.telemetry), 0.70)
assert in_range(len(db.environment), 0.25)
assert in_range(len(db.status), 0.40)
def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
"""Same JSONL compiled twice → identical structure, different timestamps."""
_require_v25_bindings()
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not jsonl.is_file():
pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh")
a = tmp_path / "a.proto"
b = tmp_path / "b.proto"
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
time.sleep(1.2)
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(b)])
da = NodeDatabase()
db_ = NodeDatabase()
da.ParseFromString(a.read_bytes())
db_.ParseFromString(b.read_bytes())
# Zero out timestamp fields and confirm everything else is byte-identical.
for d in (da, db_):
for n in d.nodes:
n.last_heard = 0
for p in d.positions:
p.position.time = 0
assert da.SerializeToString() == db_.SerializeToString()
# Re-load fresh copies to confirm timestamps actually moved.
aa = NodeDatabase()
bb = NodeDatabase()
aa.ParseFromString(a.read_bytes())
bb.ParseFromString(b.read_bytes())
aa_max = max(n.last_heard for n in aa.nodes if n.last_heard)
bb_max = max(n.last_heard for n in bb.nodes if n.last_heard)
assert bb_max >= aa_max
assert bb_max - aa_max < 5 # within a few seconds
def test_compile_pinned_now_epoch_is_byte_identical(tmp_path: pathlib.Path) -> None:
"""With --now-epoch pinned, two compiles produce identical bytes."""
_require_v25_bindings()
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not jsonl.is_file():
pytest.skip("250-node seed not present")
a = tmp_path / "a.proto"
b = tmp_path / "b.proto"
for o in (a, b):
_run(
[
sys.executable,
str(COMPILE),
"--in",
str(jsonl),
"--now-epoch",
"1700000000",
"--out",
str(o),
]
)
assert a.read_bytes() == b.read_bytes()
def test_compile_timestamps_are_recent(tmp_path: pathlib.Path) -> None:
_require_v25_bindings()
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not jsonl.is_file():
pytest.skip("250-node seed not present")
out = tmp_path / "out.proto"
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(out)])
db = NodeDatabase()
db.ParseFromString(out.read_bytes())
now = int(time.time())
# No timestamp older than 7 days, none in the future.
for n in db.nodes:
if n.last_heard:
assert now - 7 * 86400 <= n.last_heard <= now
# At least half should be within the last hour
# (matches expovariate(mean=3600s)).
recent = sum(1 for n in db.nodes if n.last_heard and n.last_heard >= now - 3600)
assert recent >= 0.4 * len(db.nodes)
def test_compile_hand_edit_round_trip(tmp_path: pathlib.Path) -> None:
"""Edit one JSONL line, recompile, confirm edit appears in the proto."""
_require_v25_bindings()
src = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not src.is_file():
pytest.skip("250-node seed not present")
dst = tmp_path / "edited.jsonl"
lines = src.read_text(encoding="utf-8").splitlines()
# Find a node that already has telemetry so the index relationship is
# easy to assert on the other side.
edit_idx = None
for i, raw in enumerate(lines[1:], start=1):
node = json.loads(raw)
if node.get("telemetry") is not None:
edit_idx = i
break
assert edit_idx is not None, "expected at least one node with telemetry"
node = json.loads(lines[edit_idx])
target_num = int(node["num"], 16)
node["long_name"] = "Hand Edited Node"
node["telemetry"] = {
"battery_level": 42,
"voltage": 3.71,
"channel_utilization": 0.0,
"air_util_tx": 0.0,
"uptime_seconds": 1,
}
lines[edit_idx] = json.dumps(node, ensure_ascii=False, sort_keys=True)
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
out = tmp_path / "out.proto"
_run([sys.executable, str(COMPILE), "--in", str(dst), "--out", str(out)])
db = NodeDatabase()
db.ParseFromString(out.read_bytes())
edited = next((n for n in db.nodes if n.num == target_num), None)
assert edited is not None
assert edited.long_name == "Hand Edited Node"
tel = next((t for t in db.telemetry if t.num == target_num), None)
assert tel is not None
assert tel.device_metrics.battery_level == 42
# ---------------------------------------------------------------------------
# Misc smoke checks on the module surface.
# ---------------------------------------------------------------------------
def test_crc16_ccitt_matches_known_vectors() -> None:
"""Sanity-check the hand-rolled CRC16-CCITT matches well-known vectors.
Test vectors from the XModem-CRC spec (init=0, poly=0x1021):
crc16("123456789") = 0x31C3
crc16("") = 0x0000
"""
from meshtastic_mcp.fixtures import _crc16_ccitt
assert _crc16_ccitt(b"") == 0x0000
assert _crc16_ccitt(b"123456789") == 0x31C3
def test_push_fake_nodedb_rejects_invalid_size() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="size must be one of"):
push_fake_nodedb(size=999, target="portduino") # type: ignore[arg-type]
def test_push_fake_nodedb_hardware_requires_confirm() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="confirm=True"):
push_fake_nodedb(size=250, target="hardware", port="/dev/cu.fake")
def test_push_fake_nodedb_hardware_requires_port() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="requires a port"):
push_fake_nodedb(size=250, target="hardware", confirm=True)
def test_push_fake_nodedb_hardware_rejects_tcp_port() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="not supported"):
push_fake_nodedb(
size=250, target="hardware", confirm=True, port="tcp://localhost:4403"
)
+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
)
+7 -5
View File
@@ -49,6 +49,7 @@ build_flags = -Wno-missing-field-initializers
-DRADIOLIB_EXCLUDE_PAGER=1
-DRADIOLIB_EXCLUDE_FSK4=1
-DRADIOLIB_EXCLUDE_APRS=1
-DRADIOLIB_EXCLUDE_ADSB=1
-DRADIOLIB_EXCLUDE_LORAWAN=1
-DMESHTASTIC_EXCLUDE_DROPZONE=1
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
@@ -57,7 +58,6 @@ build_flags = -Wno-missing-field-initializers
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
-DMESHTASTIC_EXCLUDE_POWERMON=1
-DMESHTASTIC_EXCLUDE_STATUS=1
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
#-D OLED_PL=1
@@ -121,7 +121,7 @@ lib_deps =
[radiolib_base]
lib_deps =
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
[device-ui_base]
lib_deps =
@@ -138,7 +138,7 @@ lib_deps =
# renovate: datasource=github-tags depName=Adafruit GFX packageName=adafruit/Adafruit-GFX-Library
https://github.com/adafruit/Adafruit-GFX-Library/archive/refs/tags/1.12.6.zip
# renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel
https://github.com/adafruit/Adafruit_NeoPixel/archive/refs/tags/1.15.4.zip
https://github.com/adafruit/Adafruit_NeoPixel/archive/1.15.5.zip
# renovate: datasource=github-tags depName=Adafruit SSD1306 packageName=adafruit/Adafruit_SSD1306
https://github.com/adafruit/Adafruit_SSD1306/archive/refs/tags/2.5.16.zip
# renovate: datasource=github-tags depName=Adafruit BMP280 packageName=adafruit/Adafruit_BMP280_Library
@@ -171,8 +171,8 @@ lib_deps =
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
@@ -227,6 +227,8 @@ lib_deps =
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
[environmental_extra]
@@ -1,35 +0,0 @@
# Specification Quality Checklist: Hardware Support Agent
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-25
**Feature**: [spec.md](/Users/benmeadors/Documents/GitHub/firmware/specs/129-hardware-support-agent/spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Specification validated against the repository constitution on 2026-03-25.
- Initial scope is intentionally bounded to reusable hardware context plus safe board-intake and scaffolding workflow preparation.
@@ -1,58 +0,0 @@
# Contract: Board Intake Request And Assessment
## Purpose
This contract defines the minimum maintainer input and the expected workflow output for the hardware-support-agent feature.
## Input Contract
### Required Fields
- `environment_name`: Proposed PlatformIO environment name for the new board.
- `hardware_model`: Meshtastic hardware model identifier to assign.
- `display_name`: Human-readable board name.
- `architecture`: Target architecture family such as `esp32`, `esp32-s3`, `nrf52840`, `rp2040`, or `stm32`.
### Recommended Fields
- `hardware_model_slug`: Repository-style uppercase slug if already known.
- `actively_supported`: Whether the board is intended to be actively supported.
- `support_level`: Intended `custom_meshtastic_support_level` value.
- `source_materials`: Links, file paths, or notes for schematics, pinouts, datasheets, or vendor pages.
- `board_notes`: Freeform notes about revisions, peripherals, or known uncertainty.
## Validation Rules
- The workflow must reject or pause on missing required fields.
- The workflow must detect conflicts with existing environment names or existing hardware identifiers where discoverable.
- The workflow must not invent radio, power, display, GPS, input, or auxiliary pin mappings.
- The workflow must identify when architecture defaults may be relevant and flag them for human review.
## Output Contract
### Required Assessment Sections
- `expected_artifacts`: What board-support artifacts are likely required.
- `required_metadata`: What `custom_meshtastic_*` metadata and related fields must be decided.
- `matched_patterns`: Existing repository examples that are closest to the request.
- `evidence_gaps`: Missing or conflicting facts that block safe scaffolding.
- `risk_flags`: Conditions that require special maintainer attention.
- `next_actions`: Concrete steps to move the request toward scaffold readiness.
- `scaffold_ready`: Boolean decision indicating whether draft file generation is safe.
## Artifact Expectations
Depending on the board and architecture, the assessment should consider whether the request will need:
- a new or reused variant directory under `variants/<architecture>/...`
- `variant.h`
- optional `variant.cpp`
- one or more PlatformIO environments with `custom_meshtastic_*` metadata
- board images, tags, partition-scheme metadata, or DFU metadata
- any architecture-specific board files or notes for inherited defaults
## Non-Goals For Phase 1
- Direct parsing of PDF schematics.
- Automatic merging of new board files into the repository.
- Any change to firmware runtime behavior, protocol behavior, or shared board defaults.
@@ -1,124 +0,0 @@
# Data Model: Hardware Support Agent
## Hardware Support Context
Purpose: Repository-backed summary of current target-definition patterns across supported board variants.
Fields:
- source_paths: list of repository glob roots used to build the context
- generated_at: timestamp or generation marker
- variant_count: integer count of scanned variant directories
- environment_count: integer count of summarized PlatformIO environments
- metadata*keys: list of observed `custom_meshtastic*\*` keys
- category_counts: mapping of capability category to common macros and frequencies
- architecture_inventory: list of architecture groups and their environments
- representative_examples: list of example boards with extracted metadata and macro samples
- cautions: list of caveats about inherited defaults, multi-environment boards, and verification limits
Validation rules:
- Must be derived from repository state rather than hand-maintained guesses.
- Must clearly separate explicit declarations from inherited/default behavior where known.
- Must remain read-only context and not imply that any new board is validated for merge.
Relationships:
- Used by Board Intake Request as the canonical repository pattern source.
## Board Intake Request
Purpose: Maintainer-supplied description of a proposed new board or board revision.
Fields:
- environment_name: proposed PlatformIO environment name
- hardware_model: numeric or repository-convention hardware model identifier
- hardware_model_slug: uppercase slug when known
- display_name: human-readable board name
- architecture: target family such as `esp32-s3` or `nrf52840`
- source_materials: optional list of schematic, pinout, datasheet, or board-page references
- support_level: optional intended support metadata
- actively_supported: optional boolean intent
- board_notes: optional maintainer notes about revisions, optional peripherals, or known gaps
Validation rules:
- `environment_name`, `hardware_model`, and `display_name` are required for phase 1 intake.
- Architecture is required before any artifact expectation can be considered complete.
- Source materials are optional for submission but required for moving unresolved hardware fields toward scaffold generation.
- Conflicts with existing environment names or hardware identifiers must be surfaced.
Relationships:
- Produces one Intake Assessment.
- May eventually lead to one or more Board Support Scaffolds.
## Intake Assessment
Purpose: Structured result of evaluating a Board Intake Request against repository patterns and evidence sufficiency.
Fields:
- expected_artifacts: list of files or sections likely needed, such as `variant.h`, optional `variant.cpp`, PlatformIO environment entries, board metadata, images, or tags
- required*metadata: list of mandatory `custom_meshtastic*\*` values and board-definition fields
- matched_patterns: list of related repository examples by architecture or board family
- evidence_gaps: list of unresolved or conflicting hardware facts
- risk_flags: list of issues such as ambiguous board revisions, unsupported peripherals, or inherited-default uncertainty
- next_actions: ordered maintainer actions needed before safe scaffold generation
- scaffold_ready: boolean indicating whether evidence is sufficient for a later scaffold phase
Validation rules:
- Must never infer unsupported pin mappings silently.
- Must describe missing information in maintainer-actionable language.
- Must remain architecture- and variant-scoped.
Relationships:
- Derived from Board Intake Request and Hardware Support Context.
- Blocks or permits creation of Board Support Scaffold.
## Evidence Gap
Purpose: Specific missing, conflicting, or ambiguous fact that prevents safe draft generation.
Fields:
- category: metadata, radio, display, input, GPS, power, storage, connectivity, or revision-scope
- description: maintainer-readable explanation of what is missing or conflicting
- affected_artifact: target file or configuration area impacted
- required_evidence: type of source needed to resolve the gap
- blocking: boolean indicating whether the gap prevents scaffold generation
Validation rules:
- Must be traceable to a missing repository pattern or missing board truth.
- Must not be collapsed into generic “needs more info” language when the specific blocker is knowable.
Relationships:
- Belongs to an Intake Assessment.
## Board Support Scaffold
Purpose: Draft board-support content for a new target once evidence is sufficient.
Fields:
- target_variant_dir: proposed variant directory path
- variant_h_content: draft content or structured sections for `variant.h`
- variant_cpp_content: optional draft for `variant.cpp`
- platformio_env_content: draft PlatformIO environment metadata and extends chain
- unresolved_annotations: inline markers for any remaining non-blocking maintainer review items
- source_basis: references to intake data and repository patterns used to draft content
Validation rules:
- Must only include fields backed by evidence and repository conventions.
- Must preserve variant-scoped truth and never modify unrelated board definitions.
- May only be produced when Intake Assessment marks `scaffold_ready` true.
Relationships:
- Produced from Intake Assessment after gaps are resolved.
-97
View File
@@ -1,97 +0,0 @@
# Implementation Plan: Hardware Support Agent
**Branch**: `[129-hardware-support-agent]` | **Date**: 2026-03-25 | **Spec**: /Users/benmeadors/Documents/GitHub/firmware/specs/129-hardware-support-agent/spec.md
**Input**: Feature specification from `/Users/benmeadors/Documents/GitHub/firmware/specs/129-hardware-support-agent/spec.md`
**Note**: This plan covers Phase 0 and Phase 1 outputs for a constitution-safe first increment. The first delivery scope centers on repository-backed hardware context plus intake validation and report generation; scaffold generation remains designed but gated behind sufficient evidence.
## Summary
Build a Copilot-oriented hardware support workflow for Meshtastic that starts from repository-derived board-definition context, accepts a constrained board intake request, and produces a structured readiness report before any new board files are drafted. The technical approach uses lightweight Python tooling and repository-local markdown/JSON contract artifacts to inventory existing `variant.h` and `platformio.ini` patterns, normalize maintainer inputs, identify evidence gaps, and prepare a later scaffolding phase without changing live firmware behavior.
## Technical Context
**Language/Version**: Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets
**Primary Dependencies**: Python standard library for inventory/intake tooling, existing Spec Kit artifacts, repository-local Copilot prompt/agent files, PlatformIO environment metadata conventions already in `variants/**/platformio.ini`
**Storage**: Repository-local files under `docs/`, `specs/129-hardware-support-agent/`, optional future intake examples under repo docs or fixtures
**Testing**: Targeted script execution, generated artifact review, `python3 bin/generate_hardware_support_context.py`, `trunk fmt` where applicable for markdown/templates, and targeted follow-up validation against representative variant files
**Target Platform**: Maintainer workflow inside the Meshtastic firmware repository on macOS/Linux development environments; outputs describe supported firmware architectures including ESP32, ESP32-S3, ESP32-C3, ESP32-C6, nRF52, RP2040/RP2350, STM32, and native patterns
**Project Type**: Repository tooling and Copilot workflow support
**Performance Goals**: Generate context and intake reports quickly enough for interactive maintainer use on a local checkout; avoid repository-wide processing that would materially slow a normal Copilot session
**Constraints**: No changes to mesh protocol behavior or shared device defaults; no guessing of pin mappings; architecture-specific truth must stay variant-scoped; keep implementation dependency-light and reviewable
**Scale/Scope**: Inventory currently spans 166 variant directories and 212 PlatformIO environments; phase 1 scope covers context generation, intake contract, evidence-gap reporting, and custom-agent planning, not autonomous end-to-end board enablement
## Constitution Check
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
- Safety-critical mesh impact: Pass. This feature is workflow/documentation/tooling only and does not modify routing, airtime, MQTT, channel, packet-path, or public default behavior.
- Variant and platform scope: Pass. The workflow is explicitly scoped to board-definition artifacts and requires variant-specific evidence before any scaffold output is permitted.
- Validation evidence: Pass with explicit plan. Validation for this phase is `python3 bin/generate_hardware_support_context.py`, manual spot-check against representative variants, and review of generated documentation/contracts. Later implementation phases should add targeted intake fixture checks.
- Resource, power, memory, dependency impact: Pass. The feature adds lightweight local tooling and markdown contracts only, with no runtime firmware impact and no new external runtime dependency requirement.
- Constitutional violations or gaps: No active violations for Phase 0/1 planning. The only open product choice is whether future phase 2 includes scaffold generation immediately or remains report-only until more intake validation exists.
### Post-Design Re-Check
- Safety-critical mesh impact remains unchanged after design: no firmware runtime path is altered.
- Variant-scoped hardware truth is reinforced by the intake contract and evidence-gap model.
- Validation evidence remains sufficient for design artifacts, with implementation tasks needing targeted script-level checks.
- Resource and dependency impact remains minimal and repository-local.
- No justification entries are required in Complexity Tracking for this plan.
## Project Structure
### Documentation (this feature)
```text
specs/129-hardware-support-agent/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── board-intake-contract.md
└── tasks.md
```
### Source Code (repository root)
```text
.github/
├── agents/
└── prompts/
bin/
├── generate_hardware_support_context.py
├── board_intake.py
└── board_scaffold.py
docs/
└── hardware-support-context.md
variants/
├── esp32/
├── esp32c3/
├── esp32c6/
├── esp32s2/
├── esp32s3/
├── native/
├── nrf52840/
├── rp2040/
├── rp2350/
└── stm32/
```
**Structure Decision**: Use the existing single-repository tooling structure. Planning artifacts live under `specs/129-hardware-support-agent/`, reusable generated context stays in `docs/`, implementation scripts live in `bin/`, and any future custom Copilot workflow files belong in `.github/prompts/` and `.github/agents/`. No new top-level application structure is needed.
## Complexity Tracking
No constitutional violations or complexity exceptions are currently required.
## Review Notes
- **SC-004** is a post-launch business metric and is not a blocking acceptance gate before merge. A timed baseline comparison should be collected after the first real board intake using the completed workflow.
- Validation completed so far: `python3 bin/generate_hardware_support_context.py --validate`, `python3 bin/board_intake.py bin/fixtures/intake_minimal.json --validate`, `python3 bin/board_intake.py bin/fixtures/intake_full.json`, `python3 bin/board_intake.py bin/fixtures/intake_multi_env.json`, `python3 bin/board_scaffold.py bin/fixtures/intake_full.json`, and `python3 bin/board_scaffold.py bin/fixtures/intake_multi_env.json`.
- `bin/board_scaffold.py` intentionally emits placeholder pin and metadata values plus `// TODO: verify — ...` annotations. Generated scaffold output is draft-only and not suitable for direct merge without maintainer review against schematics and existing board patterns.
- Scaffold generation currently writes to `generated/hardware-support/` rather than directly into `variants/` to keep the workflow reviewable and constitution-safe.
- Skipped validations: no targeted `pio run`, native test, or simulator run was executed because this feature adds repository tooling and generated draft artifacts only; no firmware runtime files under `src/` were changed.
@@ -1,111 +0,0 @@
# Quickstart: Hardware Support Agent
## Goal
Use the repository-backed hardware support workflow to understand current board-definition patterns and evaluate whether a new board request is ready for safe scaffolding.
## Prerequisites
- Work from the repository root.
- Ensure Python 3 is available.
- Have the new board’s minimum intake information ready:
- proposed PlatformIO environment name
- hardware model identifier
- display name
- target architecture
- any available schematic, pinout, or datasheet references
## Step 1: Regenerate the repository context artifact
Run:
```bash
python3 bin/generate_hardware_support_context.py
```
Review:
- `docs/hardware-support-context.md`
Confirm that the architectures, representative examples, and metadata keys still reflect the current repository state.
## Step 2: Prepare the board intake request
Capture the request using the contract in:
- `specs/129-hardware-support-agent/contracts/board-intake-contract.md`
At minimum, fill:
- environment name
- hardware model
- display name
- architecture
Add source materials for radio, display, power, GPS, input, and auxiliary peripherals when available.
## Step 3: Evaluate readiness
Run:
```bash
python3 bin/board_intake.py path/to/intake.json
```
The intake report now includes:
- request summary
- expected artifacts
- required metadata
- matched repository patterns
- blocking and non-blocking evidence gaps
- risk flags
- next actions
- a `scaffold_ready` decision
For gate-style validation, run:
```bash
python3 bin/board_intake.py path/to/intake.json --validate
```
This exits non-zero when blocking gaps remain.
## Step 4: Generate scaffold output when ready
If Step 3 reports `scaffold_ready: true`, run:
```bash
python3 bin/board_scaffold.py path/to/intake.json --output-dir generated/hardware-support
```
Expected outputs:
- `generated/hardware-support/variants/<arch>/<variant-dir>/variant.h`
- `generated/hardware-support/variants/<arch>/<variant-dir>/platformio.ini`
- optional `variant.cpp` for ESP32-family targets
If the intake is not scaffold-ready, the scaffold command prints the assessment and exits non-zero instead of generating files.
## Step 5: Validate the workflow artifacts
Run:
```bash
python3 bin/generate_hardware_support_context.py
python3 bin/board_intake.py bin/fixtures/intake_full.json
python3 bin/board_scaffold.py bin/fixtures/intake_full.json --output-dir generated/hardware-support
```
Then manually spot-check representative targets such as:
- `variants/esp32/tbeam`
- `variants/esp32s3/tlora-pager`
- `variants/nrf52840/t-echo`
- `variants/rp2040/rak11310`
Ensure the generated context, intake assessment, and scaffold output remain consistent with repository truth.
## Next Step
Use [hardware-support.prompt.md](.github/prompts/hardware-support.prompt.md) and [hardware-support.agent.md](.github/agents/hardware-support.agent.md) to invoke the workflow directly from Copilot.
@@ -1,46 +0,0 @@
# Research: Hardware Support Agent
## Decision: Use repository-local Python tooling and markdown artifacts as the first implementation slice
Rationale: The repository already uses lightweight scripts under `bin/` and maintains board truth primarily in `variants/**/platformio.ini` and `variants/**/variant.h`. A Python script plus markdown outputs fits existing repo patterns, keeps dependencies minimal, and provides immediate value without touching firmware runtime code.
Alternatives considered:
- Implement the first increment directly as a full custom Copilot agent that generates new board files. Rejected because the workflow still needs a safer evidence-validation layer before scaffolding hardware definitions.
- Build a standalone service or extension-backed parser. Rejected because it would add unnecessary complexity, operational overhead, and dependencies for a repo-local maintainer workflow.
## Decision: Treat intake validation and readiness reporting as the first generation boundary
Rationale: The constitution requires verified, variant-scoped hardware truth and forbids guessing pins or capabilities. A report-first boundary lets maintainers capture missing evidence, expected artifacts, and metadata requirements before draft files are produced.
Alternatives considered:
- Generate `variant.h` and `platformio.ini` scaffolding immediately from minimum inputs. Rejected because environment name, `hw_model`, and display name are not enough to guarantee correct radio, power, display, and peripheral mappings.
- Block all workflow progress until every future scaffold field is known. Rejected because maintainers still need a useful way to understand what is missing and what repository patterns apply.
## Decision: Reuse the existing hardware inventory document as the canonical context input for planning
Rationale: `docs/hardware-support-context.md` already inventories metadata keys, common macro categories, and representative board examples across the repository. That artifact can serve as the phase 1 foundation for both maintainers and future agent prompts.
Alternatives considered:
- Generate a new per-architecture context file for each family. Rejected for now because a single canonical context artifact is easier to review and sufficient for the first intake/report workflow.
- Depend on maintainers manually browsing variant files during intake. Rejected because it defeats the feature goal of reducing rediscovery work.
## Decision: Represent the maintainer workflow as a small set of explicit entities and a human-readable contract
Rationale: The feature is primarily a repository workflow, not a networked API. A markdown contract describing required fields, validations, and outputs is sufficient for Spec Kit design and future agent/prompt implementation.
Alternatives considered:
- Define a JSON Schema or OpenAPI contract immediately. Rejected for phase 1 because no external service boundary exists yet and the workflow is still evolving.
- Keep the contract implicit in prompt text only. Rejected because it would be harder to review, test, and keep aligned with the constitution.
## Decision: Keep scaffold generation as a designed later phase gated by evidence sufficiency
Rationale: The user wants the end state to include new board-support scaffolding, but constitutional safety requires a stronger intake and validation model first. Planning the later phase now preserves momentum without collapsing safe and unsafe scopes together.
Alternatives considered:
- Remove scaffolding from the feature entirely. Rejected because it is core to the requested long-term outcome.
- Merge report generation and scaffolding into a single undifferentiated phase. Rejected because it weakens reviewability and blurs the safety boundary.
-170
View File
@@ -1,170 +0,0 @@
# Feature Specification: Hardware Support Agent
**Feature Branch**: `[129-hardware-support-agent]`
**Created**: 2026-03-25
**Status**: Draft
**Input**: User description: "Implement the feature specification based on the updated constitution. I want to build an agent inside copilot to add new hardware support, including variant.h/cpp, any platformio ini environments will all of our custom metadata, and all of the pinmappings. I would start this process by just giving the board environment name, hw_model, display name, and perhaps some source materials illustrating the pin mappings in a PDF schematic for instance. I think we should start by creating a context for you in the form of a markdown file documenting all of the current input, button, radio, gpio, and other common pins we use in the meshtastic firmware at a device target definition level, so that we reuse instead of re-invent."
## User Scenarios & Testing _(mandatory)_
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Build Reusable Hardware Context (Priority: P1)
As a firmware maintainer adding support for a new device, I want a single repository-backed reference
that summarizes the common board-definition inputs already used across Meshtastic targets so I can
start from verified patterns instead of re-deriving pins, feature flags, and board metadata from scratch.
**Why this priority**: Without an authoritative context source, any later agent workflow will repeat the
same manual discovery work and risks copying incorrect or incomplete target definitions.
**Independent Test**: Can be fully tested by generating the hardware context artifact from the current
repository and confirming it captures existing target-definition fields, common pins, and board-scoped
capability patterns for a representative set of boards.
**Acceptance Scenarios**:
1. **Given** an existing firmware checkout with multiple board variants, **When** a maintainer requests
the hardware support context, **Then** the system produces a markdown artifact that documents current
board-definition inputs, common pin categories, and recurring variant-level capabilities from the repo.
2. **Given** a maintainer reviewing an existing board, **When** they inspect the context artifact,
**Then** they can identify the board environment, hardware model identifiers, display-related fields,
radio pin definitions, input pins, and other commonly reused target-definition elements without
manually scanning many variant files.
---
### User Story 2 - Define New Board Intake (Priority: P2)
As a firmware maintainer, I want to provide a small set of board inputs such as environment name,
hardware model, display name, and source materials so the Copilot workflow can determine what new
hardware support artifacts need to be created or filled in.
**Why this priority**: A constrained intake contract is required before the workflow can safely generate
variant files and PlatformIO environments for new hardware support.
**Independent Test**: Can be tested independently by supplying the declared board inputs for a hypothetical
new target and confirming the workflow identifies required artifacts, missing evidence, and board-definition
fields that must be resolved before code generation proceeds.
**Acceptance Scenarios**:
1. **Given** a maintainer provides an environment name, hardware model, display name, and source references,
**When** the intake workflow runs, **Then** it identifies the expected target-definition artifacts,
required metadata fields, and unresolved board details that still need confirmation.
2. **Given** the supplied materials do not establish enough hardware truth for safe generation,
**When** the workflow evaluates the request, **Then** it explicitly flags the missing pin mappings,
peripheral capabilities, or board metadata instead of guessing silently.
---
### User Story 3 - Generate Board Support Scaffolding (Priority: P3)
As a firmware maintainer, I want the Copilot workflow to use the validated intake and reusable context to
draft board-support artifacts such as `variant.h`, optional `variant.cpp`, and PlatformIO environment content
with repository-specific metadata so I can add new hardware support consistently and with less manual setup.
**Why this priority**: This delivers the actual acceleration benefit, but it depends on verified context and
safe intake rules to avoid generating incorrect hardware support.
**Independent Test**: Can be tested independently by running the workflow for a new board request and
confirming it produces scaffold content or structured instructions that align with repository patterns and
does not invent unsupported hardware details.
**Acceptance Scenarios**:
1. **Given** validated board inputs and sufficient source evidence, **When** the maintainer requests new
hardware support scaffolding, **Then** the workflow drafts the required board-support files and metadata
using existing repository conventions.
2. **Given** the request would affect hardware flags, pin mappings, or build metadata beyond the available
evidence, **When** scaffolding is attempted, **Then** the workflow limits output to supported fields and
clearly marks unresolved items for human confirmation.
---
### Edge Cases
- The requested board environment name conflicts with an existing PlatformIO environment or target directory.
- The same hardware model appears under multiple existing naming conventions and the correct repository form is ambiguous.
- A schematic or PDF source omits some pins or names them differently than the repository’s existing macros.
- A target uses architecture defaults today, so the context artifact must distinguish explicitly declared pins from inherited defaults.
- A board has multiple display or radio options, optional peripherals, or revision-specific pinouts that cannot be collapsed into one truth.
- The request includes peripherals that exist in source material but are not currently supported by repository patterns.
- A generated board definition would require changing shared defaults or introducing unverified power, timing, or RF assumptions.
## Requirements _(mandatory)_
### Functional Requirements
- **FR-001**: The system MUST produce a repository-local hardware context artifact that documents the current
target-definition patterns used for board support in this firmware repository.
- **FR-002**: The hardware context artifact MUST describe, at minimum, the board environment name,
hardware model identifier, display-related identifiers, radio pin group, input/button-related pins,
and other commonly reused pin or capability categories present at the device-target-definition level.
- **FR-003**: The hardware context artifact MUST distinguish board-specific declarations from architecture-level
defaults or inherited behavior when that distinction affects new board support work.
- **FR-004**: Users MUST be able to initiate the workflow by providing a minimal set of board inputs that includes
board environment name, hardware model, display name, and target architecture, with optional supporting source materials.
Architecture is required because expected artifacts and matched patterns cannot be determined without it.
- **FR-005**: The intake workflow MUST identify which board-support artifacts are expected for the request,
including variant files and PlatformIO environment content where applicable.
- **FR-006**: The intake workflow MUST surface missing or conflicting hardware evidence instead of inventing
unresolved pins, capabilities, metadata, or power assumptions.
- **FR-007**: The workflow MUST preserve variant-scoped hardware truth by keeping generated or suggested values
scoped to the intended target architecture, board, and board revision when known.
- **FR-008**: The workflow MUST support repository-specific metadata required for new PlatformIO environments,
including custom support metadata already used in this codebase.
- **FR-009**: The workflow MUST be able to draft scaffold content for `variant.h`, optional `variant.cpp`, and
related target files only when the provided evidence is sufficient to do so safely.
- **FR-010**: The workflow MUST record unresolved questions in a form the maintainer can act on before using any
scaffolded board support in the repository.
- **FR-011**: The workflow MUST be applicable to current Meshtastic hardware target definitions across supported
architectures, while allowing architecture-specific details to remain architecture-scoped.
- **FR-012**: The workflow MUST not change public protocol behavior, shared radio safety defaults, or unrelated
board definitions as part of preparing new hardware support context.
Where relevant, requirements MUST also state:
- affected architectures, boards, or modules: ESP32, ESP32-S3, ESP32-C3, nRF52, RP2040/RP2350, STM32WL, and Portduino-style target-definition patterns where present in repo conventions
- whether behavior changes public defaults, protocol compatibility, or generated artifacts: this feature must not alter public mesh defaults or protocol compatibility; it may create or update documentation artifacts for board-support workflow context
- any required validation evidence for high-risk mesh, hardware, or power behavior: targeted verification of generated context against representative variant files and naming patterns is required before relying on it for scaffolding
### Key Entities _(include if feature involves data)_
- **Hardware Support Context**: A repository-backed reference artifact that summarizes the current board-support
fields, common pin categories, variant capability macros, and target-definition conventions used across the firmware.
- **Board Intake Request**: The maintainer-provided input set for a proposed new board, including environment name,
hardware model, display name, target architecture, and optional source materials such as schematics.
- **Target Definition Pattern**: A reusable repository convention describing how a board is represented through
`variant.h`, optional companion files, PlatformIO environments, and board-specific metadata.
- **Evidence Gap**: A missing, ambiguous, or conflicting hardware fact that blocks safe generation of new board support.
- **Board Support Scaffold**: The draft output for new hardware support artifacts, limited to fields supported by
verified evidence and current repository conventions.
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: Maintainers can locate the common target-definition inputs and pin categories for an existing board in one artifact within 5 minutes, without manually reading multiple variant directories.
- **SC-002**: For a representative set of existing boards, the context artifact correctly captures the board environment name, key capability categories, and primary pin groups with no unresolved mismatches after maintainer review.
- **SC-003**: A maintainer can submit a new board intake request using the declared minimum inputs and receive a complete list of required board-support artifacts and unresolved evidence gaps in a single workflow pass.
- **SC-004**: _(Post-launch metric — not a pre-merge acceptance gate)_ For new board requests with sufficient source evidence, the workflow reduces manual setup time for initial board-support scaffolding by at least 50% compared with manually assembling variant and PlatformIO definitions from scratch. A baseline timed comparison should be conducted after the first production intake.
## Assumptions
- The initial increment focuses on repository context and intake/scaffolding workflow support, not full autonomous end-to-end board enablement.
- Maintainers using this workflow already have access to the repository, Copilot, and any board source materials they want to reference.
- The first implementation may rely on repository-readable sources and manually supplied board details rather than automated PDF parsing.
- Existing Meshtastic variant files and PlatformIO environments provide enough representative patterns to build a useful reusable context artifact.
- The workflow will be allowed to stop and request clarification when hardware truth is incomplete instead of forcing a guessed output.
-170
View File
@@ -1,170 +0,0 @@
# Tasks: Hardware Support Agent
**Branch**: `129-hardware-support-agent`
**Input**: Design documents from `specs/129-hardware-support-agent/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/board-intake-contract.md ✅
**Note on existing work**: The Phase 3 (US1) inventory script and generated context artifact already exist.
Tasks T001–T004 treat them as in-scope for validation and hardening rather than creation from scratch.
---
## Phase 1: Setup
**Purpose**: Confirm repository structure and tooling baseline for this feature.
- [x] T001 Confirm `bin/generate_hardware_support_context.py` executes without errors from the repo root via `python3 bin/generate_hardware_support_context.py`
- [x] T002 [P] Confirm `docs/hardware-support-context.md` exists and is committed or tracked in the working tree
- [x] T003 [P] Clean up the duplicated `"Where relevant, requirements MUST also state:"` block in `specs/129-hardware-support-agent/spec.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared utilities and data structures that US2 and US3 both depend on.
**⚠️ CRITICAL**: US2 and US3 cannot begin until this phase is complete.
- [x] T004 Add a `BoardIntakeRequest` dataclass (or typed dict) capturing the fields from `specs/129-hardware-support-agent/data-model.md` in `bin/board_intake.py`
- [x] T005 Add a `EvidenceGap` dataclass in `bin/board_intake.py` matching the data model
- [x] T006 Add an `IntakeAssessment` dataclass in `bin/board_intake.py` matching the data model
- [x] T007 Implement a `load_hardware_context(path)` helper in `bin/board_intake.py` that reads `docs/hardware-support-context.md` and returns architecture family names and metadata key list for use by the assessment logic
**Checkpoint**: Shared data structures and context loader in place — US2 and US3 implementation can begin.
---
## Phase 3: User Story 1 — Build Reusable Hardware Context (Priority: P1) 🎯 MVP
**Goal**: A single repository-backed markdown document accurately inventories current board-support patterns across all supported architectures so maintainers can start a new board definition from verified patterns.
**Independent Test**: Run `python3 bin/generate_hardware_support_context.py` and manually spot-check output against four representative variants from different architectures.
### Validation for User Story 1
- [x] T008 [P] [US1] Spot-check generated `docs/hardware-support-context.md` against `variants/esp32/tbeam` — confirm radio pins, metadata keys, and category counts match the variant.h and platformio.ini declarations
- [x] T009 [P] [US1] Spot-check against `variants/esp32s3/tlora-pager` — confirm all 17 `custom_meshtastic_*` metadata keys and high Connectivity/Other category macro count are reflected
- [x] T010 [P] [US1] Spot-check against `variants/nrf52840/t-echo` — confirm nRF52-specific macros (`USE_LFXO`, `VARIANT_MCK`, nRF52-style SPI pins) appear correctly
- [x] T011 [P] [US1] Spot-check against `variants/rp2040/rak11310` — confirm RP2040 architecture entry is present and radio pins match
- [x] T012 [US1] Run `trunk fmt bin/generate_hardware_support_context.py` and fix any formatting issues
### Implementation for User Story 1
- [x] T013 [P] [US1] Add `--validate` CLI flag to `bin/generate_hardware_support_context.py` that prints a summary of how many variants were scanned, how many had metadata keys, and how many had no `variant.h` (for inherited-defaults audit)
- [x] T014 [US1] Add a `## Inherited Defaults Note` section to the generated `docs/hardware-support-context.md` that lists architecture families known to rely on BSP/base-environment defaults rather than locally declared macros (informed by validate output)
**Checkpoint**: `docs/hardware-support-context.md` is validated, formatted, and includes inherited-defaults guidance. US1 fully testable and deliverable independently.
---
## Phase 4: User Story 2 — Define New Board Intake (Priority: P2)
**Goal**: A maintainer can provide an environment name, hardware model, display name, and architecture and receive back a structured assessment listing expected artifacts, required metadata, closest matching patterns, and any evidence gaps.
**Independent Test**: Run the intake workflow against a sample hypothetical board (e.g., a new ESP32-S3 board with only minimum inputs) and confirm it returns a complete assessment with at least one evidence gap identified.
### Validation for User Story 2
- [x] T015 [P] [US2] Create `bin/fixtures/intake_minimal.json` with just the required fields for a hypothetical new ESP32-S3 board and confirm `bin/board_intake.py` parses it without error
- [x] T016 [P] [US2] Create `bin/fixtures/intake_full.json` with all recommended fields and source materials and confirm the workflow marks `scaffold_ready: true`
- [x] T016b [P] [US2] Create `bin/fixtures/intake_multi_env.json` representing a board with two display variants sharing one hardware model (e.g., a TFT and an e-ink variant) and confirm `assess_intake` flags `revision-scope` ambiguity as a blocking Evidence Gap rather than collapsing the options silently
- [x] T017 [US2] Confirm `bin/board_intake.py --validate bin/fixtures/intake_minimal.json` correctly flags missing radio, display, and power evidence as blocking gaps
### Implementation for User Story 2
- [x] T018 [P] [US2] Implement `validate_intake(request: BoardIntakeRequest) -> list[str]` in `bin/board_intake.py` that checks required fields and detects environment-name conflicts against the existing architecture inventory in `docs/hardware-support-context.md`
- [x] T019 [P] [US2] Implement `find_matched_patterns(request: BoardIntakeRequest, context) -> list[dict]` that returns the three closest existing board examples by architecture from the context document
- [x] T020 [US2] Implement `build_evidence_gaps(request: BoardIntakeRequest) -> list[EvidenceGap]` that identifies missing pin group evidence (radio, display, GPS, power, input) based on declared source materials
- [x] T021 [US2] Implement `assess_intake(request: BoardIntakeRequest, context) -> IntakeAssessment` combining T018–T020 to produce a full structured assessment
- [x] T022 [US2] Implement `render_assessment_markdown(assessment: IntakeAssessment) -> str` that formats the assessment as a maintainer-readable markdown report
- [x] T023 [US2] Add a CLI entry point `bin/board_intake.py <intake.json>` that prints the assessment markdown to stdout or an output file
- [x] T024 [US2] Run `trunk fmt bin/board_intake.py` and fix any formatting issues
**Checkpoint**: `bin/board_intake.py` fully processes a new board request and prints an assessment. US2 independently testable.
---
## Phase 5: User Story 3 — Generate Board Support Scaffolding (Priority: P3)
**Goal**: When intake assessment marks `scaffold_ready: true`, the workflow drafts `variant.h`, optional `variant.cpp`, and PlatformIO environment content using repository conventions, with inline annotations for unresolved items.
**Independent Test**: Run the scaffold generator for a fully-specified test case and confirm the output files follow existing repository patterns and include `// TODO:` markers for any fields not backed by supplied evidence.
### Validation for User Story 3
- [x] T025 [P] [US3] Run scaffold generator for `bin/fixtures/intake_full.json` and confirm `variant.h` output contains required radio pin group, capability macros, and metadata section
- [x] T026 [P] [US3] Confirm scaffold generator emits `// TODO: verify —` annotations for any recommended fields absent from the intake fixture
- [x] T027 [US3] Confirm generated PlatformIO env block includes all `custom_meshtastic_*` metadata keys from the contract and uses `extends` to reference the correct base environment for the declared architecture
### Implementation for User Story 3
- [x] T028 [P] [US3] Create `bin/board_scaffold.py` with a `generate_variant_h(assessment: IntakeAssessment, context) -> str` function that drafts a `variant.h` file using architecture-appropriate macro order from the context document
- [x] T029 [P] [US3] Implement `generate_platformio_env(assessment: IntakeAssessment) -> str` in `bin/board_scaffold.py` that emits the PlatformIO environment block with `custom_meshtastic_*` fields
- [x] T030 [US3] Implement `annotate_unresolved(content: str, gaps: list[EvidenceGap]) -> str` that inserts `// TODO: verify — {gap.description}` comments next to lines that correspond to unresolved evidence gaps
- [x] T031 [US3] Implement `scaffold_board(assessment: IntakeAssessment, context, output_dir: Path)` which orchestrates T028–T030 and writes files to the proposed variant directory path
- [x] T032 [US3] Add CLI entry point `bin/board_scaffold.py <intake.json> [--output-dir <path>]` that reads an intake file, runs the full intake + scaffold pipeline, and writes output
- [x] T033 [US3] Guard scaffold entry: if `assess_intake` returns `scaffold_ready: false`, print the assessment report and exit with a non-zero code rather than generating files
- [x] T034 [US3] Run `trunk fmt bin/board_scaffold.py` and fix any formatting issues
**Checkpoint**: All three user stories independently functional. Hardware context, intake assessment, and scaffold generation each work as standalone deliverables.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Custom Copilot agent integration, documentation, and final review.
- [x] T035 [P] Create `.github/prompts/hardware-support.prompt.md` that routes the hardware support workflow to the custom agent
- [x] T036 [P] Create `.github/agents/hardware-support.agent.md` with step-by-step instructions for the Copilot hardware-support workflow: context regeneration → intake → assessment → optional scaffold
- [x] T037 [US1] Update `specs/129-hardware-support-agent/quickstart.md` Step 3 and Step 4 to reference the completed `bin/board_intake.py` CLI and describe the expected output shape
- [x] T038 [P] Confirm that no firmware runtime files under `src/`, `variants/`, or `protobufs/` were modified as part of this feature (constitution compliance check)
- [x] T039 Run `python3 bin/generate_hardware_support_context.py` one final time to confirm the regenerated artifact is up to date and passes spot-checks from T008–T011
- [x] T040 Summarize any skipped validations, open `// TODO:` items in scaffold output, and any known limitations in `specs/129-hardware-support-agent/plan.md` under a `## Review Notes` section
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — start immediately
- **Phase 2 (Foundational)**: Depends on Phase 1 — blocks US2 and US3
- **Phase 3 (US1)**: Can start after Phase 1; does not depend on Phase 2
- **Phase 4 (US2)**: Depends on Phase 2 (shared data structures)
- **Phase 5 (US3)**: Depends on Phase 2 and Phase 4 (uses IntakeAssessment output)
- **Phase 6 (Polish)**: Depends on all story phases
### User Story Dependencies
- **US1 (P1)**: Depends on Phase 1 only — fully independent MVP
- **US2 (P2)**: Depends on Phase 2; integrates with US1 context document but not the script directly
- **US3 (P3)**: Depends on Phase 2 and US2 `assess_intake` result
### Parallel Opportunities
**Within Phase 2**:
- T004, T005, T006 can be written in parallel (same file, separate dataclasses — serialize to avoid conflicts)
**Within Phase 3 (US1)**:
- T008, T009, T010, T011 (spot-checks) are fully independent and can run in parallel
- T013 [P] is independent of validation tasks
**Within Phase 4 (US2)**:
- T015, T016 fixture creation can run in parallel
- T018 and T019 are independent of each other and can be implemented in parallel
**Within Phase 5 (US3)**:
- T028 and T029 are independent scaffold generators — can be written in parallel
- T025 and T026 validation tasks are independent
**Within Phase 6**:
- T035, T036, T038 are independent files — can all be done in parallel
### MVP Scope
To deliver US1 as a standalone MVP: complete Phases 1 and 3 (T001–T003, T008–T014). This verifies the hardware context artifact is accurate and useful without requiring any intake or scaffold tooling.
+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
+2 -1
View File
@@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
bool usePreset)
{
// If use_preset is false, always return "Custom"
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
// rely on this being a stable literal for channel-name hashing and default-channel detection.
if (!usePreset) {
return "Custom";
}
+64 -94
View File
@@ -79,28 +79,46 @@ bool copyFile(const char *from, const char *to)
bool renameFile(const char *pathFrom, const char *pathTo)
{
#ifdef FSCom
#ifdef ARCH_ESP32
// take SPI Lock
spiLock->lock();
// rename was fixed for ESP32 IDF LittleFS in April
bool result = FSCom.rename(pathFrom, pathTo);
spiLock->unlock();
return result;
#else
// copyFile does its own locking.
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
return true;
} else {
return false;
}
#endif
return false;
#endif
}
#include <vector>
/**
* @brief Platform-agnostic filesystem format / wipe.
*
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
* native FSCom.format() which erases and reinitialises the LittleFS
* partition.
*
* On Portduino the fs::FS backend has no format() method. We instead
* delete /prefs (the only meshtastic data directory written at runtime)
* and return. rmDir("/prefs") is already called unconditionally by
* factoryReset() so this is a proven primitive on Portduino.
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
*
* @return true on success, false on failure or if no filesystem is configured.
*/
bool fsFormat()
{
#ifdef FSCom
#if defined(ARCH_PORTDUINO)
rmDir("/prefs");
return FSBegin();
#else
return FSCom.format();
#endif
#else
return false;
#endif
}
/**
* @brief Get the list of files in a directory.
*
@@ -123,23 +141,21 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
File file = root.openNextFile();
while (file) {
#ifdef ARCH_ESP32
const char *filepath = file.path();
#else
const char *filepath = file.name();
#endif
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
if (levels) {
#ifdef ARCH_ESP32
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
#else
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
#endif
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
file.close();
}
} else {
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
#ifdef ARCH_ESP32
strcpy(fileInfo.file_name, file.path());
#else
strcpy(fileInfo.file_name, file.name());
#endif
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
if (!String(fileInfo.file_name).endsWith(".")) {
filenames.push_back(fileInfo);
}
@@ -163,98 +179,59 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
void listDir(const char *dirname, uint8_t levels, bool del)
{
#ifdef FSCom
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
char buffer[255];
#endif
File root = FSCom.open(dirname, FILE_O_READ);
if (!root) {
if (!root || !root.isDirectory())
return;
}
if (!root.isDirectory()) {
return;
}
File file = root.openNextFile();
while (
file &&
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
#ifdef ARCH_ESP32
const char *filepath = file.path();
#else
const char *filepath = file.name();
#endif
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
if (levels) {
#ifdef ARCH_ESP32
listDir(file.path(), levels - 1, del);
listDir(filepath, levels - 1, del);
if (del) {
LOG_DEBUG("Remove %s", file.path());
strncpy(buffer, file.path(), sizeof(buffer));
LOG_DEBUG("Remove %s", filepath);
strncpy(buffer, filepath, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
file.close();
FSCom.rmdir(buffer);
} else {
file.close();
}
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
listDir(file.name(), levels - 1, del);
if (del) {
LOG_DEBUG("Remove %s", file.name());
strncpy(buffer, file.name(), sizeof(buffer));
file.close();
FSCom.rmdir(buffer);
} else {
file.close();
}
#else
LOG_DEBUG(" %s (directory)", file.name());
listDir(file.name(), levels - 1, del);
file.close();
#endif
}
} else {
#ifdef ARCH_ESP32
if (del) {
LOG_DEBUG("Delete %s", file.path());
strncpy(buffer, file.path(), sizeof(buffer));
LOG_DEBUG("Delete %s", filepath);
strncpy(buffer, filepath, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
file.close();
FSCom.remove(buffer);
} else {
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
file.close();
}
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
if (del) {
LOG_DEBUG("Delete %s", file.name());
strncpy(buffer, file.name(), sizeof(buffer));
file.close();
FSCom.remove(buffer);
} else {
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
file.close();
}
#else
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
file.close();
#endif
}
file = root.openNextFile();
}
#ifdef ARCH_ESP32
if (del) {
LOG_DEBUG("Remove %s", root.path());
strncpy(buffer, root.path(), sizeof(buffer));
root.close();
FSCom.rmdir(buffer);
} else {
root.close();
}
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
if (del) {
LOG_DEBUG("Remove %s", root.name());
strncpy(buffer, root.name(), sizeof(buffer));
root.close();
FSCom.rmdir(buffer);
} else {
root.close();
}
const char *rootpath = root.path();
#else
root.close();
const char *rootpath = root.name();
#endif
if (del) {
LOG_DEBUG("Remove %s", rootpath);
strncpy(buffer, rootpath, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
root.close();
FSCom.rmdir(buffer);
} else {
root.close();
}
#endif
}
@@ -268,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del)
void rmDir(const char *dirname)
{
#ifdef FSCom
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
listDir(dirname, 10, true);
#elif defined(ARCH_NRF52)
// nRF52 implementation of LittleFS has a recursive delete function
FSCom.rmdir_r(dirname);
#endif
#endif
}
+1
View File
@@ -52,6 +52,7 @@ void fsInit();
void fsListFiles();
bool copyFile(const char *from, const char *to);
bool renameFile(const char *pathFrom, const char *pathTo);
bool fsFormat();
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
void listDir(const char *dirname, uint8_t levels, bool del = false);
void rmDir(const char *dirname);
+3 -6
View File
@@ -53,8 +53,7 @@ class GPSStatus : public Status
int32_t getLatitude() const
{
if (config.position.fixed_position) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
return node->position.latitude_i;
return localPosition.latitude_i;
} else {
return p.latitude_i;
}
@@ -63,8 +62,7 @@ class GPSStatus : public Status
int32_t getLongitude() const
{
if (config.position.fixed_position) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
return node->position.longitude_i;
return localPosition.longitude_i;
} else {
return p.longitude_i;
}
@@ -73,8 +71,7 @@ class GPSStatus : public Status
int32_t getAltitude() const
{
if (config.position.fixed_position) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
return node->position.altitude;
return localPosition.altitude;
} else {
return p.altitude;
}
+26 -31
View File
@@ -6,7 +6,6 @@
#include "SPILock.h"
#include "SafeFile.h"
#include "gps/RTC.h"
#include "graphics/draw/MessageRenderer.h"
#include <cstring> // memcpy
#ifndef MESSAGE_TEXT_POOL_SIZE
@@ -181,13 +180,8 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
bool isDM = (sm.dest != 0 && sm.dest != NODENUM_BROADCAST);
if (packet.from == 0) {
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
sm.ackStatus = AckStatus::NONE;
} else {
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
sm.ackStatus = AckStatus::ACKED;
}
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
sm.ackStatus = (packet.from == 0) ? AckStatus::NONE : AckStatus::ACKED;
addLiveMessage(sm);
@@ -372,26 +366,25 @@ void MessageStore::clearAllMessages()
#endif
}
// Internal helper: erase first or last message matching a predicate
template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deque, Predicate pred, bool fromBack = false)
// Internal helpers for targeted erasure.
template <typename Predicate> static bool eraseFirstMatch(std::deque<StoredMessage> &deque, Predicate pred)
{
if (fromBack) {
// Iterate from the back and erase all matches from the end
for (auto it = deque.rbegin(); it != deque.rend();) {
if (pred(*it)) {
it = std::deque<StoredMessage>::reverse_iterator(deque.erase(std::next(it).base()));
} else {
++it;
}
for (auto it = deque.begin(); it != deque.end(); ++it) {
if (pred(*it)) {
deque.erase(it);
return true;
}
} else {
// Manual forward search to erase all matches
for (auto it = deque.begin(); it != deque.end();) {
if (pred(*it)) {
it = deque.erase(it);
} else {
++it;
}
}
return false;
}
template <typename Predicate> static void eraseAllMatches(std::deque<StoredMessage> &deque, Predicate pred)
{
for (auto it = deque.begin(); it != deque.end();) {
if (pred(*it)) {
it = deque.erase(it);
} else {
++it;
}
}
}
@@ -399,7 +392,9 @@ template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deq
// Delete oldest message (RAM + persisted queue)
void MessageStore::deleteOldestMessage()
{
eraseIf(liveMessages, [](StoredMessage &) { return true; });
if (!liveMessages.empty()) {
liveMessages.pop_front();
}
saveToFlash();
}
@@ -407,14 +402,14 @@ void MessageStore::deleteOldestMessage()
void MessageStore::deleteOldestMessageInChannel(uint8_t channel)
{
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
eraseIf(liveMessages, pred);
eraseFirstMatch(liveMessages, pred);
saveToFlash();
}
void MessageStore::deleteAllMessagesInChannel(uint8_t channel)
{
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
eraseIf(liveMessages, pred, false /* delete ALL, not just first */);
eraseAllMatches(liveMessages, pred);
saveToFlash();
}
@@ -427,7 +422,7 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer)
uint32_t other = (m.sender == local) ? m.dest : m.sender;
return other == peer;
};
eraseIf(liveMessages, pred, false);
eraseAllMatches(liveMessages, pred);
saveToFlash();
}
@@ -440,7 +435,7 @@ void MessageStore::deleteOldestMessageWithPeer(uint32_t peer)
uint32_t other = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
return other == peer;
};
eraseIf(liveMessages, pred);
eraseFirstMatch(liveMessages, pred);
saveToFlash();
}
-3
View File
@@ -124,9 +124,6 @@ class MessageStore
// Allocate text into pool (used by sender-side code)
static uint16_t storeText(const char *src, size_t len);
// Used when loading from flash to rebuild the text pool
static uint16_t rebuildTextFromFlash(const char *src, size_t len);
private:
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
std::string filename; // Flash filename for persistence

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