Compare commits

..
59 Commits
Author SHA1 Message Date
Ben MeadorsandClaude Opus 4.8 fb9a2afedf FleetSuite: camera discovery — scan + pick instead of guessing indices
Add a discovery endpoint + UI so cameras are picked from a detected list
rather than typing OpenCV indices blind.

- Name enumeration works WITHOUT OpenCV (macOS system_profiler,
  Linux /sys/class/video4linux), so discovery is useful before the [ui]
  extra is installed; when cv2 IS present, each not-in-use index is briefly
  opened to confirm it works and read its resolution.
- GET /api/cameras/discover returns {available, cv2, cameras:[{index, name,
  width, height, in_use, unavailable}]}; indices already bound to a FleetSuite
  camera are marked in_use and not re-opened (their stream owns them).
- CameraManager auto-scans on mount with a "scan" button, lists detected
  cameras (name · idx · resolution) with one-click add, flags missing OpenCV
  with an install hint, and keeps manual-by-index as a fallback.

Verified on the bench: detects all 4 USB capture cameras with cv2 absent;
one-click add registers + marks in_use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:30:21 -05:00
Ben MeadorsandClaude Opus 4.8 4e45ec962e FleetSuite: auto-detect firmware version + exact pio env on plug-in
Background auto-enrichment in the discovery loop, FleetLog-style: when a
device is newly seen or hops ports, fire a one-shot device_info to sniff its
running firmware version, hw_model → exact pio env, region, and node num — no
manual refresh needed. hw_model resolution picks the precise variant (e.g.
T_ECHO_PLUS → t-echo-plus, HELTEC_MESH_NODE_T114 → heltec-mesh-node-t114,
RAK4631 → rak4631), not just the coarse role default.

Gated for safety: skipped entirely while a test run holds the ports;
serialized so only one device is on the wire at a time; the device's live
serial monitor is suspended for the connect and resumed after; pinned envs are
never clobbered; a connect that fails or returns incomplete metadata backs off
(60s) instead of re-hammering every poll. Disable with
MESHTASTIC_MCP_AUTO_ENRICH=0.

Also fixes a real bug: both auto-enrichment and the existing /refresh endpoint
called admin.device_info, which doesn't exist — device_info lives in
meshtastic_mcp.info. /refresh was silently 500ing before this.

The device card now shows the sniffed specs (running fw version, hw model,
region — UNSET flagged amber since it blocks TX — and resolved env).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:24:50 -05:00
Ben MeadorsandClaude Opus 4.8 879a5c62c7 FleetSuite: adopt Meshtastic design system with a distinct identity
Re-theme the SPA on the official Meshtastic design system
(github.com/meshtastic/design): the navy neutral scale, mint accent
(#67EA94), and M3 dark tokens. Tailwind's default scales are remapped onto
the brand palette in style.css so the whole UI inherits it (slate→neutral,
emerald→green, rose→error, amber→warning, sky/indigo→blue).

Differentiated from the Meshtastic app / FleetLog so it doesn't read as
either: mint is reserved strictly for live/healthy/go status (online dots,
"LIVE", pass counts), while the design system's indigo/blue becomes
FleetSuite's structural signature — wordmark, nav, section labels, card
rails — for a "test-bench instrument" character rather than a mint-dominant
consumer surface. Adds:
  - a LoRa-chirp glyph + mono, tracked "FLEETSUITE" wordmark (nods to the
    logo's chirp origin);
  - circular node identifiers per the design standard (computed per-node
    color on the ring/initials only, never a row background wash);
  - instrument card rails (inset indigo→mint hairline) and indigo section
    labels across the Fleet + Test Suite panels.

Frontend-only; no API changes. Verified in-browser on both tabs, no console
errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:52:10 -05:00
Ben MeadorsandClaude Opus 4.8 fcf2a52717 FleetSuite: camera mirror + per-node uhubctl power control
Camera mirror: a horizontal-flip toggle alongside rotation, persisted as a
property of the camera mount (survives reassignment). Applied client-side as
a scaleX(-1) on the MJPEG feed; new `mirror` column + POST
/api/cameras/{id}/mirror.

Per-node USB power: cut/restore/cycle VBUS to a node via uhubctl on its
PPPS-capable hub. Because uhubctl's port listing exposes only VID:PID (not
serial), the hub slot is tracked explicitly on the device row
(hub_location/hub_port):
  - `locate` auto-binds when exactly one PPPS port matches the VID;
  - an ambiguous match (two identical boards) is surfaced for the operator to
    pin the correct slot in device settings, which then survives replug/reboot.
Power actions resolve through that mapping (falling back to a unique VID
match), are gated by the run-safety lock, and suspend any live serial monitor
so the port is free. Routes: GET /api/hubs, POST /api/devices/{serial}/locate,
PUT /api/devices/{serial}/hub-port, POST /api/devices/{serial}/power/{on,off,cycle}.
Degrades gracefully when uhubctl is absent (502 with an install hint; the
settings panel shows "uhubctl not available").

Additive db migration (mirror, hub_location, hub_port) for existing
registries. 20 web unit tests still pass; verified end-to-end in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:41:12 -05:00
Ben MeadorsandClaude Opus 4.8 ab3f7f3223 Add FleetSuite backend (meshtastic_mcp.web) + fix gitignore hiding it
The "Draft of FleetSuite" commit shipped the Vue SPA, scripts, pyproject
entrypoint, and unit tests, but the meshtastic_mcp.web backend they all
reference was never committed. Root cause: the repo-root .gitignore had a
bare `web` pattern (for the firmware build's top-level web/ output) that
also matched mcp-server/src/meshtastic_mcp/web/, so `git add` silently
skipped the package and a later clean wiped the untracked files. Anchored
the build-artifact patterns (.pio/pio/web/...) to the repo root so they no
longer clobber nested web/ directories.

Reconstructed the backend from the contract pinned down by the three unit
tests and the seven Pinia stores:

- db/        aiosqlite registry: devices (serial-keyed identity that follows
             a node across ports, env pinning, offline marking), cameras,
             flash timings, test runs/results, build ledger, settings
- services/  identity (VID->role->env, hw_model resolution), control gate
             (no port action during a run), builder (SHA-keyed artifact
             cache + queue), test_runner (live pytest via reportlog tail),
             datadog (scrub + dashboard-compatible mappers + cursor reader),
             firmware (git ref), native (Docker meshtasticd), discovery
             (USB poll loop), serial_monitor (live pyserial stream),
             camera_stream (MJPEG)
- ws/hub.py  topic broadcast hub backing /ws
- app.py     FastAPI factory: REST for every store/component + /ws, serves
             the built SPA
- __main__.py the meshtastic-mcp-web entrypoint (uvicorn + pywebview window,
             --browser to serve only)

All 20 web unit tests pass; verified end-to-end against real hardware
(discovery, live serial logs, REST + WebSocket).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:30:31 -05:00
Ben Meadors 859a04a61b Add Datadog integration with configuration and logging panel 2026-06-23 15:16:47 -05:00
Ben Meadors 9b19cbac05 Rotate 2026-06-23 14:39:17 -05:00
Ben Meadors a4ab067431 Draft of FleetSuite 2026-06-16 14:37:06 -05:00
e4f4d1f9e7 Ci test report filter (#10722)
* ci(test report): drop no-status testsuites from the PlatformIO report

PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_*
dir x every hardware variant it can't run on native (~4900 rows). They carry
no pass/fail/skip status and bury the suites that actually ran in the dorny
Test Report. Strip them (in generate-reports, before the reporter) so the
report shows only real results. The uploaded artifact keeps the full XML.

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

* Add bin/run-tests.sh: standardised local test verdict

Self-contained RED/AMBER/GREEN runner: matches all pass/fail spellings and
cross-checks the suite count against test/test_*/ so a missing suite shows AMBER.

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

* run-tests.sh: detect sanitizer faults + live build/test progress

Two usability improvements to the local verdict script:

1. Sanitizer-fault detection. A sanitizer-instrumented (coverage) build aborts
   non-zero at EXIT on an ASan/LSan/UBSan/TSan fault — most often a LeakSanitizer
   leak — AFTER every test has printed [PASSED], so pio reports it as
   [ERRORED]/SIGHUP with no :FAIL: anywhere and it masquerades as a phantom
   failure. verdict_red now recognises the documented fault signatures (ERROR:/
   WARNING: <San>:, SUMMARY: <San>:, Direct/Indirect leak of, heap-use-after-free,
   runtime error:, etc. — not the benign "failed to intercept" startup noise) and
   reports e.g. "RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: ...",
   plus the "run the binary bare (gdb hides it via ptrace)" recipe. Also flags the
   "all tests passed but aborted at exit" shape when the report was swallowed.

2. Progress trail. Long rebuilds were silent for minutes. A background heartbeat
   now appends a status line every few seconds to .pio/build/<env>/.runtests-progress
   (always — tail -f it to check a backgrounded/piped run) and live-updates the tty
   for interactive --quiet runs: build = objects recompiled / cached total + ETA;
   test = suites done / expected. The object-count baseline is cached per env in the
   gitignored build dir. Writes never touch the parsed verdict log; tty writes are
   guarded so a no-tty run emits no redirect-open error.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:53:17 -05:00
43d485dd76 Add IIS2MDCTR and ISM330DHCX to ScanI2C (#10723)
* add ScanI2C for IIS2MDCTR and ISM330DHCX

* Trunk format

* Minor cleanup

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-06-15 13:39:57 -05:00
Ben MeadorsGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Copilot Autofix powered by AI
d878c81ce8 Clamp position precision on public / known-keys (#10665)
* Clamp position precision on public / known-keys (Compliance)

* Fix review comments: bounds check, all-channel precision clamp, disabled channel guard

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Refactor position precision comments for clarity and update channel configuration handling in AdminModule

* Potential fix for pull request finding

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-14 18:53:18 -05:00
oscgonferandGitHub 8a0c7592cc Remove duplicate code from AQ telemetry, probably from merge conflict (#10708) 2026-06-14 06:38:49 -05:00
Benjamin FaershteinandGitHub 882ca0a216 Improve GPS stale probe recovery (#10714)
* Improve GPS stale probe recovery

* Address GPS review feedback
2026-06-14 06:30:20 -05:00
Benjamin FaershteinandGitHub 5d1c4f15b7 Make nRF52 lockdown support opt-in (#10712)
* Make nRF52 lockdown support opt-in

* Scope lockdown opt-in normalization to nRF52
2026-06-13 21:14:47 -05:00
745b53698a Mesh node t1 fixes (#10602)
* Fixes

* Remove BATTERY_LPCOMP_THRESHOLD

BATTERY_LPCOMP_THRESHOLD is dead code — in main-nrf52.cpp it's inside #ifdef BATTERY_LPCOMP_INPUT, which this board intentionally doesn't define. The threshold value is never reached.

* Trunk fix

* Update MotionSensor.cpp

* fix

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-06-13 19:57:28 -05:00
b938b63e8a fix a long-running CI bug that overran a lot (#10707)
* fix the fix

* Address Copilot review: add EXIT trap and clarify PKC comment

Add `trap` to kill meshtasticd on any early exit (python harness
failure, socket timeout) so CI never leaks a background process.

Reword the ARCH_PORTDUINO comment to make explicit that pki_encrypted=true
causes the from==0 plain-admin branch to be skipped, routing into the
PKC key-check — the underlying logic was correct all along.

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

* Update PORTDUINO comment to reflect from==0 auth fix

The from==0 branch no longer requires !pki_encrypted (fixed upstream
in this branch), so update the simulator comment to reflect the actual
remaining reason for the early intercept: is_managed could still block
exit_simulator even for local packets.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:46:29 -05:00
TomandGitHub b76e5e6ba4 removes NRF52832 from codebase - it is vestigal at best (#10709) 2026-06-13 18:46:08 -05:00
bbcc35e209 Stm32 general (#10700)
* Attempt to generalize ARCH_STM32

* Trunk

* One More ARCH_STM32

* Whoops, one snuck in there

* Fix comment to reflect define change

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-06-13 12:21:56 -05:00
Jonathan BennettandBen Meadors bede05356d Lora led rx (#10674)
* add optional LED_LORA to indicate LoRa TX

* Briefly flash LED_LORA on packet RX

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-13 12:18:22 -05:00
Jonathan BennettandBen Meadors 031e73bbe6 Use standard GPS enable pin, for smarter power control on M3 (#10671)
* Use standard GPS enable pin, for smarter power control on M3

* Enable GPS pin in variant.cpp initialization

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-13 12:05:24 -05:00
Ben Meadors 9ea1f0065a Update protos 2026-06-13 07:30:56 -05:00
Jonathan BennettGitHubBen MeadorsCopilotWesselcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>thebentern
8267bb22bd Packet Signing via XEdDSA (#10478)
* Test commit for XEdDSA support

* Update to Crypto lib in Meshtatic org

* Generate a new node identity on key generation (#7628)

* Generate a new node identity on key generation

* Fixes

* Fixes

* Fixes

* Messed up

* Fixes

* Update src/modules/AdminModule.cpp

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

* Update src/mesh/NodeDB.cpp

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

* Figured it out!

* Cleanup

* Update src/mesh/NodeDB.h

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

* Update src/mesh/NodeDB.cpp

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

* Update src/modules/AdminModule.cpp

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

---------

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

* Update crypto commit hash

* Some fixes for xeddsa pr (#9610)

* fix: add null check for getMeshNode() in NodeInfoModule

getMeshNode() can return nullptr for unknown nodes. Dereferencing
without a check crashes the firmware when receiving NodeInfo from
a node not yet in the database.

* fix: enforce XEdDSA signature verification and prevent stripping

Previously, failed signature verification still allowed the packet
through, making signatures purely cosmetic. Now:

- Failed verification drops the packet (DECODE_FAILURE)
- Successfully verified nodes get HAS_XEDDSA_SIGNED bitfield set
- Unsigned packets from previously-signing nodes are rejected
- Log levels reduced from WARN/ERROR to DEBUG/WARN as appropriate

* fix: include packet metadata in XEdDSA signature

The signature now covers [fromNode | packetId | portnum | payload]
instead of just the payload bytes. This prevents:
- Replay attacks (different packetId fails verification)
- Reattribution (different fromNode fails verification)
- Portnum redirection (different portnum fails verification)

Also adds a key initialization check to xeddsa_sign (returns false
if XEdDSA keys are all zeros) and checks the return value in the
encode path.

* fix: handle existing key pair in AdminModule security config

When a user provides both a valid private key and public key via
admin config, the crypto engine's DH private key and owner public
key were never loaded. DMs and XEdDSA signing would silently break.

Add an else branch to load both keys into the crypto engine.

* perf: cache Ed25519 public key conversion in xeddsa_verify

curve_to_ed_pub() performs field element parsing, inversion, and
multiplication on every call. Since packets from the same node
tend to arrive in bursts, a single-entry cache avoids repeating
this expensive conversion for consecutive packets from one sender.

* fix: skip identity cleanup when node number is unchanged

createNewIdentity() was called on every generateCryptoKeyPair(),
including normal boots where the same key is regenerated. This
caused unnecessary NodeDB writes and old-node cleanup logic to
run when the node number hadn't actually changed.

Also fixes only zeroing byte[0] of the old node's public key
instead of clearing the entire array.

* fix: replace hardcoded 120 with derived XEDDSA_SIGNATURE_SIZE constant

The payload size check for XEdDSA signing used a magic number (120).
Replace with a derivation from DATA_PAYLOAD_LEN and XEDDSA_SIGNATURE_SIZE
so the limit adjusts automatically if constants change. This also
increases the max signable payload from 120 to 169 bytes, which is
still safe since the actual encoded size is checked after pb_encode.

* fix: add const qualifiers to XEdDSA verify and curve_to_ed_pub inputs

pubKey, payload, and signature parameters in xeddsa_verify are
input-only and should not be modified. Same for curve_pubkey in
curve_to_ed_pub.

* chore: remove commented-out old Crypto dependency in portduino.ini

* Leave out the admin module change for now

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>

* trunk

* protobuf re-update

* Protobufs

* Merge resolution fix

* Put XEDDSA on the right bit

* NodeDB update to new nodeInfoLite accessors, etc

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

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

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

* Potential fix for pull request finding

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

* Refine unsigned packet rejection logic in Router (#10534)

* use hardware random to fill the first 32 signature bytes with entropy prior to signing.

* Add XEdDSA packet-signing policy tests and update dependencies for macos

* Minor fixes

* integrate XEdDSA support and update dependencies across multiple modules

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Wessel <github@weebl.me>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-06-13 06:45:56 -05:00
07a87a8254 security: runtime-toggleable MESHTASTIC_LOCKDOWN hardening for nRF52 (#10349)
* security: add MESHTASTIC_LOCKDOWN hardened build option

Meshtastic nodes ship with secrets on flash (channel PSKs, the device
private key, admin keys, wifi PSK) and over-the-wire access to admin
APIs that can re-key the mesh. Lose the device, at a border crossing,
in a raid, off a backpack, and an attacker reads everything in 30s
with a USB cable. There's no at-rest encryption, no client auth, the
screen leaks contents, and SWD is wide open. This adds an opt-in
hardened build for users who care.

-DMESHTASTIC_LOCKDOWN=1 on nRF52 (CC310) turns on:

  DEBUG_MUTE                         silence USB/serial logs
  MESHTASTIC_ENCRYPTED_STORAGE       AES-128-CTR + HMAC-SHA256 on
                                     LocalConfig / channels / NodeDB.
                                     Passphrase-gated DEK, TTL/boot
                                     unlock token, failed-attempt
                                     backoff (within-boot, wall-clock,
                                     persisted bootsSinceFail).
  MESHTASTIC_PHONEAPI_ACCESS_CONTROL per-connection auth gate. Secrets
                                     emitted as empty proto structs
                                     to unauthenticated clients.
  MESHTASTIC_ENABLE_APPROTECT        one-way UICR APPROTECT, reset
                                     applied same boot. Recoverable
                                     only via \`nrfjprog --recover\`,
                                     which also wipes the DEK.
  LockdownDisplay                    screen shows "LOCKED" when locked
                                     or idle 30s. OLED only; InkHUD /
                                     niche / device-ui not yet wired.

Wire format is the LockdownAuth / LockdownStatus pair from
meshtastic/protobufs#911 (AdminMessage tag 104, FromRadio tag 18).

Access-control state is a file-scope 6-slot table in PhoneAPI.cpp
keyed by \`this\`, not class members. Adding *any* per-instance field
to PhoneAPI breaks USB-CDC enumeration on the current nRF52 Adafruit
framework, one volatile bool was enough. Out-of-line side-steps it.

lockdown_auth is handled synchronously in PhoneAPI::handleToRadioPacket
rather than routed through the mesh Router into AdminModule. Two
reasons: the passphrase never travels through a routed MeshPacket
queue, and per-connection authorization runs while \`this\` is still on
the call stack. The previous async-via-router design lost connection
identity (g_currentContext was null by the time AdminModule processed
the auth), so per-connection unlock never actually took effect on the
originating client.

Non-nRF52: #warning, only DEBUG_MUTE activates. tools/lockdown_provision.py
drives provision / unlock / lock-now / watch over USB.

Display privacy is a screen-lock latch separate from storage-lock
state: shouldRedactDisplay() is true when storage is locked OR the
latch is set. Screen::setOn(false) sets the latch when the stock idle
timeout powers the display off (reusing config.display.screen_on_secs,
no second timer); it is cleared only when a client authenticates with
the passphrase. A device idling on the mesh keeps routing but hides
its screen until re-auth; button input wakes the backlight to the
LOCKED frame, not content. The earlier lockdown-specific 30s idle
timer is removed — it duplicated PowerFSM idle detection and showed a
misleading LOCKED screen on a merely-idle device.

Unlock-token TTL fix: a token carrying both a boot-count and a
wall-clock TTL is no longer destroyed when the RTC is invalid at cold
boot. The boot count is independently verifiable without a clock, so
the token falls back to boot-count enforcement instead of being
deleted. A token is only hard-rejected when its wall-clock TTL can be
evaluated and is found expired.

NodeDB::reloadFromDisk() after unlock is deferred to the main loop via
lockdownReloadPending rather than run inline on the transport callback
stack — the reload is too heavy for the BLE/serial task stack and was
resetting the device immediately after a successful unlock.

The screen-lock latch also swallows local input events in
InputBroker::handleInputEvent while it (or storage-locked) is set.
Without that, a blind operator could drive on-device menus, fire
canned messages, or change settings through the joystick/buttons even
though the screen content was hidden. PowerFSM is still triggered
first so the backlight wakes to the LOCKED frame; the event is dropped
before reaching the UI observers.

The screen-lock latch is initialised to true at boot, so even a
token-auto-unlocked cold boot comes up redacted. Otherwise an attacker
holding a screen-locked device could power-cycle it (the RAM latch
resets) and recover a content screen. After any boot, the operator
must authenticate from a client to reveal screen content.

MyNodeInfo.device_id is also redacted for unauthenticated clients —
it is a stable hardware identifier useful to an attacker for
fingerprinting / correlating the device across observations. The
public mesh fields (my_node_num, owner short/long name, public key,
hw model) are left as-is because they are already broadcast on-mesh.

ModuleConfig.mqtt is also redacted for unauthenticated clients —
MQTTConfig carries broker username, password, server address, and
root_topic. The empty MQTTConfig is emitted via the same zero-init
pattern as the other gated sections.

Uptime-based session limit (MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS)
caps how long a single auto-unlocked session can hold storage open,
measured in firmware millis() since unlock. 0 = unlimited (existing
token-only behavior, suitable for tower/infra nodes); non-zero arms a
timer on every passphrase unlock and on every token-auto-unlock that
inherits the value, since the cap is persisted in the token (token
format bumped to v2: adds sessionMaxSeconds, body 56→60 bytes).

On expiry the device revokes per-connection auth, re-engages the
screen-lock latch, and reboots WITHOUT deleting the token. Next boot
auto-unlocks via the boot count (decrementing it) and arms a fresh
session window. Hard exposure ceiling: bootsRemaining * sessionMaxSeconds.
Explicit user Lock Now still deletes the token (passphrase required to
recover); only session expiry preserves it.

Why uptime, not wall-clock: getValidTime() is fed by GPS/RTC/client
time pushes — all manipulable by an attacker with the device (GPS
spoof to roll the clock back, pull the RTC backup cell, Faraday-cage
the whole thing). millis() comes off the Cortex-M's internal cycle
counter, sealed inside the chip; the only way to reset it is a reboot,
which costs a boot from the on-flash token counter. APPROTECT remains
the load-bearing defense against forging higher boot counts via SWD.

A future LockdownAuth.max_session_seconds proto field will let the
client set this per-token; until that lands the build-time
MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS macro is the only source.

Session expiry now decrements the on-flash boot count in place and
re-arms the uptime timer WITHOUT rebooting, while budget remains.
Mesh routing keeps running across session boundaries; the device only
reboots when bootsRemaining reaches zero (rollback budget exhausted),
at which point it hard-locks and forces passphrase re-entry.

Each session boundary still: revokes per-connection admin auth so
clients must re-authenticate to see content, re-engages the screen
lock latch, and emits LockdownStatus{LOCKED, needs_auth, boots=N}
so connected clients see the decremented count and know to re-auth.
Storage stays unlocked (DEK in RAM) for continuity.

The boot count's role as the rollback ledger is unchanged — it
decrements monotonically once per session boundary, whether the
session ends in a reboot or an in-place roll. Attacker who power-
cycles to dodge the session timer still pays a boot via the existing
readAndConsumeToken decrement-at-load path. APPROTECT remains the
only defense against forging higher counts.

Net effect for an unattended/tower node with bootsRemaining=50,
sessionSeconds=3600: 50 hours of continuous mesh service, one
reboot at the end, vs. the previous design's 50 reboots over the
same period. Same exposure ceiling, far better uptime.

LockdownAuth.max_session_seconds (proto tag 5) is now consumed: when
non-zero the client value wins; 0 falls back to the firmware-side
MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS, matching the boots_remaining
sentinel convention. Protobufs submodule pin bumped to develop tip
which contains meshtastic/protobufs#916 (merged).

* security: drop dead is_managed allowlist for set_config(security).private_key

The 'isLockdownSecurityCmd' allowlist in handleReceivedProtobuf dates
from the pre-LockdownAuth design when the passphrase was smuggled
through SecurityConfig.private_key. With lockdown_auth handled
synchronously in PhoneAPI::handleToRadioPacket before any admin message
reaches the Router, this allowlist now serves no legitimate purpose
and lets an unauthenticated local client mutate security settings on
a managed device by setting private_key.size>=1 — including
potentially disabling is_managed itself.

Remove the allowlist. Managed-mode local admin now requires a
PhoneAPI connection that has already authenticated via lockdown_auth
(or, on the pki_encrypted branch below, a valid PKC admin key).

Resolves Copilot review feedback on src/modules/AdminModule.cpp:105.

* security: protect lockdown-status drain slot from concurrent writers

g_pendingLockdownStatus / g_hasPendingLockdownStatus are written from
multiple call sites (PhoneAPI::handleLockdownAuthInline on the BLE/USB
transport callback, AdminModule on the Router thread, main loop session
expiry) and read in getFromRadio() on whichever transport is draining
FromRadio. The struct read/write was unprotected, so a writer could
corrupt the slot mid-encode. Same pattern as nodeInfoMutex — wrap
both the queue path and the drain in a small lock. Drain re-checks
the bool under the lock to handle the case where another reader
grabbed the slot first.

Resolves Copilot review feedback on src/mesh/PhoneAPI.cpp:1560.

* security: derive readAndDecrypt size cap from caller buffer, not a hardcoded 64 KB

The MAX_PROTO_FILE_SIZE = 65536 + OVERHEAD ceiling was an absolute
constant chosen against a since-outdated assumption that 'meshtastic
proto files are well under 64 KB'. On variants where MAX_NUM_NODES
pushes the serialised NodeDatabase past 64 KB the legitimate file gets
rejected at load and the device treats its own real config as corrupt.

The caller already knows the maximum plaintext it expects (outBufSize).
Cap the ciphertext at outBufSize + OVERHEAD instead — this is the tightest
sound bound (anything larger could not possibly decode into the caller's
buffer), still defends against OOM / integer overflow, and scales with
the platform's actual NodeDB size rather than an arbitrary constant.

Resolves Copilot review feedback on src/security/EncryptedStorage.cpp:1327.

* docs: fix stale 'passphrase delivery via AdminModule' references in configuration.h

The lockdown overview comment block was written when passphrase delivery
ran through AdminModule's handleReceivedProtobuf. With the synchronous
refactor that path now lives in PhoneAPI::handleLockdownAuthInline,
called before the admin message reaches the Router. Update both the
nRF52 feature list and the non-nRF52 degraded-mode rationale to point
at the current code path.

Resolves Copilot review feedback on src/configuration.h:578 (and :604).

* docs: refresh unlock-token format doc to match v2 layout

The header comment for the UTOK file still described v1 (version 0x01,
no session_max_seconds, 71 bytes) even after the in-flight bump to
TOKEN_VERSION=0x02 and TOKEN_TOTAL_SIZE=75. The inline body-size
breakdown comment was also wrong (claimed 39 bytes and mismatched the
real NONCE_SIZE/AES_KEY_SIZE constants). Rewrite both to match the
actual on-flash layout and note how v1 tokens are handled on upgrade
(rejected via the version byte; passphrase re-entry mints a v2).

Resolves Copilot review feedback on src/security/EncryptedStorage.h:50.

* docs: correct session-limit comment re: token-auto-unlock behavior

The s_sessionMaxMs comment block claimed 'token-auto-unlocked
sessions have no session timer (the session feature is a
passphrase-unlock-only knob)'. Stale: readAndConsumeToken() now
persists sessionMaxSeconds in the token file and re-calls
setSession() from the token-load path, so token-auto-unlocked
sessions DO inherit the same cap (and consumeSessionBoot() re-arms
in place between sessions on a single boot). Update the comment to
match.

Resolves Copilot review feedback on src/security/EncryptedStorage.cpp:72.

* docs: clarify input-swallow gate re: screen-lock latch vs storage state

The previous comment said input is swallowed 'until a client authenticates
and unlockScreen() clears the latch (or storage is unlocked)'. The
parenthetical was misleading: storage being unlocked is not in itself
enough to clear the latch — the latch persists across the
storage-unlocked-but-screen-locked steady state, and only an explicit
unlockScreen() (called from a successful passphrase auth path) clears
it. Reword so the only-passphrase-clears-the-latch invariant is
explicit and local input is named as something that does NOT clear it.

Resolves Copilot review feedback on src/input/InputBroker.cpp:134.

* docs: fix reloadFromDisk() trigger comment in NodeDB.h

The header still claimed reloadFromDisk() is called by AdminModule
after a successful passphrase op. With the synchronous PhoneAPI
refactor the actual trigger is PhoneAPI::handleLockdownAuthInline
setting lockdownReloadPending, with main.cpp's loop() dispatching
the heavy reload on the main thread (the transport callback stack
isn't large enough). Update the comment to point at the real path
and explain why the deferral exists.

Resolves Copilot review feedback on src/mesh/NodeDB.h:393.

* style: clang-format lockdown sources

Apply trunk clang-format (16.0.3) to satisfy the format check.

* style: black-format lockdown_provision.py

Satisfy the trunk black formatter check.

* security: drop unused v1 EncryptedStorage formats and migration

This storage layer has never shipped, so there are no v1 DEK files,
v1 unlock tokens, or v1 backoff records anywhere to stay compatible
with. Remove the dead compatibility machinery:

- legacy init() (FICR-only KEK, no passphrase) — had no callers
- deriveKEKv1() / loadDEKv1() and the v1->v2 DEK migration paths in
  provisionPassphrase() and unlockWithPassphrase()
- the 5-byte v1 backoff file format

Also drop the now-pointless version byte from the on-disk MENC, MDEK,
and UTOK formats. Each is identified by its 4-byte magic (and, for the
keyed formats, its HMAC); with only one version that will ever exist,
the version field added nothing. Sizes shrink by one byte each
(overhead 54->53, DEK 66->65, token 75->74).

Rename the surviving helpers to drop the _v2 suffix (deriveKEK,
loadDEK, saveDEK, KEK_DOMAIN). No behavioral change for provisioning,
unlock, token consumption, or session handling.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): harden auth-table and lockdown_auth handler (audit)

Audit findings addressed:

C3 — `~PhoneAPI()` now clears its auth slot unconditionally. The previous
slot-clear in `close()` was gated on `state != STATE_SEND_NOTHING`, so a
PhoneAPI that never reached config (or that already closed) left
`slot.who` pointing at freed memory; a future PhoneAPI heap-allocated at
the same address would inherit the prior session's authorization through
`findOrAllocSlot`.

C4 — All access to `g_authSlots`, `g_authEpoch`, and `g_currentContext` is
now serialised through `g_authSlotsMutex`. Previously these were touched
without locking from BLE/USB/TCP/Router tasks, so two parallel slot scans
could hand out the same slot and mid-update reads could observe
authorized=true alongside a stale epoch. Granularity is fine — every
critical section is a short linear scan over six entries, and getFromRadio
(which calls `getAdminAuthorized()` per redaction check) tolerates the
brief blocking.

A4 / H1 — `lock_now` now requires the originating connection to be
already authorized. Previously any unauthenticated client (BLE/USB/TCP)
could submit `lockdown_auth { lock_now=true }` and force a reboot,
which was a trivial local-presence DoS — an attacker near the radio
could brick-loop it indefinitely. The original "panic button without
auth" property is dropped; panic now requires the operator to have
passphrase-unlocked the connection.

H2 — Empty-passphrase `lockdown_auth` (with `lock_now=false`) used to
silently return success. The client received no feedback distinguishing
that case from a real success, and an attacker could probe lockdown
state for free. Now emits UNLOCK_FAILED with no backoff increment
(empty-passphrase is more likely a client bug than an attack, but the
honest signal still lets the client correct itself).

H14 — `la.boots_remaining > 255` previously truncated silently
(256 → 0 → mapped to TOKEN_DEFAULT_BOOTS=50; 257 → 1). Honest clients
could not detect the misbehavior. Now rejected explicitly with
UNLOCK_FAILED.

L1 — The `to == nodeDB->getNodeNum()` allowance in the unauth ToRadio
gate now also requires `getNodeNum() != 0`. During the locked-default
boot path `getNodeNum()` returns 0, so a packet with `to=0` could
otherwise satisfy the equality and bypass the gate.

L2 — Comment added on `g_authEpoch` wrap. Practically unreachable
(2^32 lockNow events on one boot), but worth recording the behavior.

M17 — `findOrAllocSlot_LH` now evicts the first unauthorized stale slot
when the table is full of non-nullptr entries, rather than failing
closed. Authorized slots are never evicted — they represent live
operator sessions. Fail-closed (with LOG_WARN) only when every slot
holds a different live authorized PhoneAPI, which would require seven
simultaneous authed connections.

M18 — `s_screenLocked` is now `std::atomic<bool>` with relaxed ordering.
Plain bool happened to work on single-core Cortex-M4 today but breaks
silently if lockdown ports to ESP32 / RP2040, or under LTO whole-
program elision.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): gate every admin op on per-connection auth + storage unlock

Audit findings addressed:

H6 — Unauthenticated local clients could previously set_config / set_module_config /
set_channel etc. on a lockdown device whenever is_managed was unset.
The previous gate inside AdminModule's is_managed branch consulted
PhoneAPI::isLocalAdminAuthorized(), which reads a global g_currentContext
set during synchronous PhoneAPI dispatch — but AdminModule runs on the
Router task, by which time the dispatch task has exited and the global is
unrelated to the originating connection. The check was both broken (always
false on Router, so even authed clients were rejected) and unsafe (when it
did fire, the wrong connection could be authorized).

The fix relocates the gate to PhoneAPI::handleToRadioPacket, where dispatch
is synchronous and getAdminAuthorized() can be trusted. The admin payload
is already decoded there to extract lockdown_auth; extend the same branch
so that any non-lockdown_auth admin variant from an unauthorized connection
is dropped before ever reaching the Router queue.

H7 — Same root cause: get_config_request / get_module_config_request /
get_channel_request handlers returned full security/network/mqtt content
to unauthorized local clients. With the H6 gate in PhoneAPI, these
requests never reach AdminModule, so handleGetConfig / handleGetModuleConfig
/ handleGetChannel are only callable from authorized connections.

H9 — Remote admin (PKC-authorized peers, mesh-relayed admin) bypassed
lockdown entirely. If admin_keys were baked in via USERPREFS or set on a
prior unlocked boot, a remote attacker could drive factory_reset /
set_config against a locked device before the operator ever unlocked it.
Added an EncryptedStorage::isUnlocked() early-return at the top of
AdminModule::handleReceivedProtobuf. The local lockdown_auth path is
unaffected because PhoneAPI handles it synchronously before AdminModule
runs.

H10 — Removed g_currentContext, the ContextGuard, authorizeLocalAdmin(),
and isLocalAdminAuthorized() entirely. The audit's race (Router-thread
reads a pointer set by an unrelated parallel dispatch and authorizes the
wrong PhoneAPI) and the always-false-on-Router behavior both disappear
with the code that produced them. The PKC-admin auto-authorize path is
gone — PKC admin and the per-connection lockdown auth are now
independent: clients using PKC admin from a local app must also send
lockdown_auth to unlock the redacted FromRadio stream.

Cleaned up AdminModule's is_managed branch: under lockdown the
PhoneAPI-layer gate has already done its job, so no additional check
is needed; without lockdown the legacy is_managed-blocks-plain-admin
semantics are preserved.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): hold radio silent until storage is unlocked

Audit finding H8: while locked, the device beaconed nodeinfo and
telemetry on the public LongFast default PSK and routed incoming default-
channel packets through the locked router. The locked-default boot path
in NodeDB::loadFromDisk installs config via installDefaultConfig, which
honours USERPREFS_CONFIG_LORA_REGION (the common shape for managed
deployments) and synthesises the default LongFast channel. So a locked
device on managed firmware came up TX-enabled on a well-known PSK
before any operator interaction.

Force config.lora.region = UNSET in the locked-boot block.
RadioLibInterface gates both TX (startSend) and RX (readData) on
region != UNSET — locked devices no longer initialise the SX12xx for
either direction. Also set tx_enabled = false for any code path that
checks the flag directly without consulting region.

reloadFromDisk() restores the persisted lora config once the operator
unlocks. Note: until the audit's M8 (radio re-init after reload, the
upcoming commit 5 in this remediation series) lands, an unlocked
device may need to reboot before its radio fully comes up under the
real config; this is no worse than the pre-fix state, where the radio
was already running on the wrong (default) config and any real config
change required an explicit reconfigure or reboot anyway.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): per-connection status queue, redaction expansion, log/banner mute (audit)

M14 — Replaces the single file-scope LockdownStatus slot with a per-
PhoneAPI table keyed by PhoneAPI*, parallel to the auth-slot table and
sharing g_authSlotsMutex. Previously a status produced for connection
A (UNLOCKED with the active TTL, or UNLOCK_FAILED with a backoff)
could be drained by connection B before A read it, leaking A's auth
state to B. queueLockdownStatus is now a per-instance method writing
to this->slot. A new static broadcastLockdownStatus exists for the
main-loop session-expiry callers that have no PhoneAPI* in hand —
those want every connected client to learn about the session roll,
which is the only legitimate broadcast use case. hasPendingLockdownStatus
is a const helper for the FromRadio available()/drain check.

M13 — buildStatus_LH (the single point where lock_reason crosses into
the on-wire LockdownStatus) collapses any token_* reason to a generic
"locked" before emission. The specific reasons (token_hmac_fail,
token_wrong_size, token_bad_magic, token_boots_zero, token_expired,
token_dek_fail, token_missing) still go to local logs, but no longer
tell an unauthenticated client that the firmware noticed their
tampering / rollback / corrupt-file attempt.

M15 — Extended the STATE_SEND_MY_INFO redaction (previously device_id
only) to also wipe pio_env and min_app_version for unauth clients —
both are pure build-fingerprint vectors that tell an attacker which
known issues to probe. Kept my_node_num (broadcast on the mesh anyway)
and nodedb_count (clients need it post-unlock to decide whether to
pull the node DB). Added equivalent redaction for STATE_SEND_METADATA:
the whole DeviceMetadata struct is wiped for unauth clients
(firmware_version, device_state_version, hw_model, hw_model_string,
has_bluetooth/has_wifi/has_ethernet, role, position_flags,
excluded_modules). Clients re-fetch after authenticating.

M16 — LoRa config is now whitelisted for unauth clients to the set
that is intrinsically observable on the air anyway: region,
modem_preset, use_preset, channel_num, hop_limit. Operator-private
knobs (ignore_incoming, override_duty_cycle, override_frequency,
sx126x_rx_boosted_gain, tx_power, ignore_mqtt, fem_lna_mode,
config_ok_to_mqtt) are zeroed. The whitelist is built as a fresh
LoRaConfig stack copy rather than masked in place to avoid touching
the persisted struct.

M12 — Skip the DEBUG_MUTE "we are muted, FYI" banner under
MESHTASTIC_LOCKDOWN. The banner spilled APP_VERSION / APP_ENV /
APP_REPO over USB CDC even with all other logging suppressed, which
defeats the muting in lockdown builds and gives a USB-attached
attacker a free firmware-fingerprint primitive.

L9 — Removed the numeric backoff value from the LOG_WARN unlock-
failed message. The client receives backoff_seconds via the
UNLOCK_FAILED status; printing it again to USB serial under
non-DEBUG_MUTE builds (i.e. MESHTASTIC_LOCKDOWN_DEBUG dev builds)
was the only place it appeared in logs.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): atomic post-unlock reload with corruption surface (audit)

Closes M6, M7, M8, M9 from the lockdown security audit.

M6 — handleLockdownAuthInline no longer flips the connection to
authorized or emits UNLOCKED on the cold-unlock path (the first
successful passphrase verify after a locked boot). The client keeps
seeing LOCKED until reloadFromDisk has actually populated config /
channelFile / nodeDatabase with the operator's real values. Without
this, the window between the auth call and the main-loop reload
exposed two race-friendly bugs: (a) the client could read the
locked-default placeholders as if they were the real config, and (b)
a set_config in the window would silently overwrite a corrupted
baseline once the reload swapped values in.

A new per-status-slot bool pendingUnlockAfterReload records that the
connection is mid-unlock. The re-verify path (storage already
unlocked) is unchanged and authorizes immediately — there is nothing
to reload.

M7 — reloadFromDisk now holds a new file-scope mutex
(g_reloadFromDiskMutex) against itself, parks the radio in sleep
mode before swapping config / channelFile, and reconfigures the
radio with the now-real settings after. Other readers of config.lora
/ channelFile / nodeDatabase do not take this lock today; closing
those races is a wider locking-discipline change outside the audit's
M7 scope. The radio standby+reconfigure prevents the SX12xx from
sitting in a half-old/half-new register set across the swap, which
otherwise required a reboot to recover from.

M8 — RadioInterface::reconfigure() is now called at the end of a
successful reload, so the SX12xx register set actually reflects
the unlocked operator settings (region, modem preset, channels)
rather than staying on the locked-default placeholder. Routed through
a new Router::getRadioIface() accessor — the radio interface is
owned by Router as a unique_ptr and was not exposed.

M9 — NodeDB::loadProto now sets a NodeDB::storageCorruptThisLoad
flag whenever an encrypted file fails to decrypt or proto-decode.
reloadFromDisk consumes the flag and returns false on any failure
instead of silently falling back to defaults. main.cpp's reload
service then calls EncryptedStorage::lockNow() and
PhoneAPI::revokeAllAuth(), and the new
PhoneAPI::completePendingUnlocks(false) emits LOCKED(storage_corrupt)
to every pending connection — they stay unauthorized so any
set_config they send is dropped at the existing unauth gates.
The lock_reason string passes through buildStatus_LH's M13
redaction unchanged because it does not start with token_.

The success path goes through PhoneAPI::completePendingUnlocks(true)
which authorizes each pending connection, emits UNLOCKED with the
current TTL, and clears the screen-lock latch once. Snapshots the
target PhoneAPI* list outside the auth-table lock to avoid re-entry
when setAdminAuthorized takes the same lock.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): UI/pairing fixes for first-pair + content-flash + e-ink (audit)

Closes H13, M19, M20, L4 from the lockdown audit. (L3 dropped per
explicit decision — battery level is not a meaningful security side
channel.)

H13 — BLE pairing PIN was suppressed by the lockdown lock screen on
locked devices. Screen.cpp updateUiFrame's lockdown short-circuit
intercepts before ui->update() runs, so the pairing-PIN overlay
banner that NRF52Bluetooth::onPairingPasskey queued never painted.
Net effect: a freshly-locked device on first BLE pair could not be
unlocked over BLE because the operator could never see the PIN —
chicken and egg.

Adds a new notificationTypeEnum::pairing_pin value and special-cases
it in the short-circuit: paint the LOCKED frame first (so the
underlying background remains the redacted view, never dashboard
content) then let ui->update() composite the PIN banner overlay on
top. The PIN itself is an ephemeral pair-handshake artifact
(regenerated per attempt, dies on banner timeout) and is not
operator content, so this does not regress the redaction guarantee.

NRF52Bluetooth::onPairingPasskey switches from showSimpleBanner to
showOverlayBanner with notificationType = pairing_pin so the
short-circuit's lookup matches.

M19 — Brief content-visible window on Screen::handleSetOn(true)
wake. OLED GDDRAM physically retains the last-rendered frame while
the panel is powered off; the next ui->update() after displayOn() is
async, so an observer (or shoulder-surfer) could see the previous
frame's content for 16-50 ms on every wake. Under MESHTASTIC_LOCKDOWN
we now paint the LOCKED frame into GDDRAM in handleSetOn(false)
before calling displayOff(). On wake the only thing the panel can
flash is the redacted view. Gated on lockdown only — non-lockdown
builds keep the previous frame as a UX cue.

M20 — E-ink panels physically retain the last-rendered image
without power. A power-cycled lockdown handheld kept showing
operator-identifying content (position, messages, nodeinfo) until
the firmware's first natural refresh — which on e-ink can be
seconds into boot. Now, under MESHTASTIC_LOCKDOWN && USE_EINK, the
panel init path in Screen::setup() paints the LOCKED frame and
forces a full refresh (forceDisplay) immediately after ui->init()
and before any other rendering. Persistent pixels are wiped to the
redacted view before an observer can see them. Build-tested on
seeed_wio_tracker_L1_eink; hardware-verified visual confirmation
is pending a T-Echo session.

L4 — Screen::blink() bypasses the normal ui->update() path that
the lockdown short-circuit gates. It draws arbitrary geometry, not
node data, so it does not actually leak today; but any future
change that puts content into blink would silently leak past
redaction. Added an early-return on shouldRedactDisplay() to make
the function honor the redaction contract.

Verified with nRF52 lockdown builds on both rak4631 (OLED) and
seeed_wio_tracker_L1_eink (e-ink).

* fix(lockdown): refuse APPROTECT on vulnerable silicon, gate on provision (audit)

Closes M22 and M23 from the lockdown audit.

M22 — APPROTECT lockout on nRF52840 is publicly known to be bypassable
on every silicon revision shipping in current Meshtastic hardware
(AAB0..AAF0) via SWD glitching, per LimitedResults' published research
on the nRF52 series. Engaging APPROTECT on these revisions has two
bad properties: (1) the lockout is irreversible without a destructive
nrfjprog --recover, and (2) it gives the operator a false sense of
security because the lockout itself can be defeated by anyone with
ten minutes and a glitcher.

enableAPProtect() now reads FICR.INFO.VARIANT (encoded as a 4-byte
ASCII word) and refuses to engage on any known-vulnerable revision,
logging the variant so the operator knows their device's specific
build code. To override (e.g. for end-to-end testing of the engage
path on hardware that's known affected), rebuild with
-DMESHTASTIC_APPROTECT_OVERRIDE_VULNERABLE_SILICON=1.

The vulnerable list is explicit and easy to update: any future
revision shown to be fixed can be removed from the list and APPROTECT
will engage on it as before.

M23 — APPROTECT engagement moved from very early in setup() to
after fsInit() + EncryptedStorage::initLocked(), and gated on
EncryptedStorage::isProvisioned(). A misconfigured CI build of a
lockdown variant flashed to a dev board would otherwise burn SWD on
first boot before the operator had set any passphrase, taking the
board out of the development/recovery workflow with zero real
security benefit (there is no DEK to protect on an unprovisioned
device). Engagement now follows operator intent: SWD locks only
once they've committed to lockdown via passphrase provisioning.

The SWD-attachable window between boot and APPROTECT engagement
widens slightly from this reorder (now ~hundreds of ms while fsInit
runs) but APPROTECT remains effective on the only payload it could
protect (the in-RAM DEK loaded by initLocked which now runs *after*
APPROTECT for already-provisioned devices).

Verified with an nRF52 lockdown build (rak4631).

* tools: harden lockdown_provision.py (audit)

Closes M26-M30 and addresses L7.

M26 — passphrase input. --passphrase on argv now requires
--insecure-passphrase-on-cmdline as an explicit acknowledgement;
without it the tool refuses and points at --passphrase-file or the
interactive prompt. --passphrase-file refuses to read anything that
isn't mode 0600 (so a passphrase another user can read off the
filesystem doesn't silently succeed). With neither, the tool reads
the passphrase via getpass.getpass — and on 'provision' double-prompts
with a confirm.

M27 — provision now requires an explicit 'yes' confirmation unless
--yes is passed, after printing the warning that the passphrase
cannot be recovered. The double-passphrase prompt is built into
gather_passphrase(confirm=True). Reduces the chance of a typo
binding a device to an unrecoverable passphrase.

M28 — 'lock' subcommand gains a 'lock-now' alias, matching how the
audit and wire docs refer to it everywhere. Both forms now require
'yes' confirmation unless --yes is set, so an accidental command
doesn't immediately reboot the device into a locked state.

M29 — the 4-second sleep is gone. Replaced with a StatusFuture
single-shot that the FromRadio interceptor signals when the next
LockdownStatus arrives. provision/unlock/lock wait up to --wait
seconds (default 8) for the actual reply and exit non-zero with the
device's reason on UNLOCK_FAILED, surfacing backoff_seconds in the
error line. Exit codes are now meaningful:
  0 = UNLOCKED
  1 = no status / unexpected
  2 = NEEDS_PROVISION (or a precondition fault: missing pkg, bad args)
  3 = LOCKED (ambiguous: device reported locked rather than the
              expected unlocked result)
  4 = UNLOCK_FAILED
This lets ops scripts decide what to do without parsing stdout.

M30 — top-of-file docstring gained an explicit SECURITY MODEL block
that names the threat model (USB-only, passphrase cleartext on the
cable) and forbids extension to TCP/BLE/UDP without a redesign. A
runtime banner reprints the headline on every invocation. --port
values starting with tcp:/tcp://, ble:/ble://, udp:/udp://, ws:/wss:
are rejected at argument parse before any connection attempt; a
copy-paste of an example into a context with a different --port
cannot silently leak credentials to the wire.

L7 — private meshtastic APIs (_handleFromRadio, _sendToRadio,
_generatePacketId) are still in use because the lib does not yet
dispatch LockdownStatus on a public pubsub topic and there is no
public seam for raw ToRadio. Their use is now wrapped in
getattr-with-clear-error so a future lib version that removes them
produces an actionable error instead of an obscure traceback. The
top-of-file note explains why we're on the private surface.

Verified end-to-end on hardware (R1-Neo + Seeed Wio Tracker L1)
during the audit-remediation hardware test pass:
  - provision (interactive, with confirm and double-prompt)
  - unlock (success returns UNLOCKED + boots TTL)
  - watch (passive listener emits LockdownStatus events)
  - lock-now (with --yes)

* fix(lockdown): H13 — render pairing PIN steady over LOCKED frame

Two bugs in the H13 fix from commit 614b7f001:

1. NotificationRenderer::drawBannercallback's switch had no case for
   the new notificationTypeEnum::pairing_pin. The function fell through
   to no-op so the banner never rendered. Added pairing_pin alongside
   text_banner so it dispatches to drawAlertBannerOverlay (same
   rendering, distinct type so the lockdown short-circuit in Screen.cpp
   can recognise it).

2. updateUiFrame's lockdown short-circuit called ui->update() to
   composite the banner. That redraws the current carousel frame
   (the dashboard) into the host framebuffer BEFORE the overlay
   paints, so the panel flashed dashboard content under the banner
   on every cycle. Replaced with a direct call to drawBannercallback
   so only the banner box is painted on top of the LOCKED pixels.

Also: drawLockdownLockScreen used to commit to the panel
(display->display()) at its end. With the banner overlay then
painting and committing a second time, the panel visibly flickered
between 'just LOCKED' and 'LOCKED + banner' on every render cycle.
Split into drawLockdownLockScreenIntoBuffer (no commit) for the
lockdown short-circuit, and a thin drawLockdownLockScreen wrapper
that calls Buffer + display() for the other call sites that don't
composite anything on top. The short-circuit now commits exactly
once per frame after both LOCKED + any overlay are in the buffer.

Verified end-to-end on hardware (Seeed Wio Tracker L1, OLED):
fresh BLE pair against a locked device now shows the pairing PIN
steadily on top of the LOCKED frame, no flicker, no dashboard
leak, and pair completes normally.

* fix(lockdown): backoff MAC + atomic writes + fault wipe + size cap (audit)

Closes H3, H4, H12, M10, M11, M25 from the lockdown audit. Non-format-
breaking: existing devices keep their .dek and .unlock_token but their
old plaintext .backoff file (6 bytes, no MAC) is silently rejected as
tampered on first read and reseeded with the MAC'd 38-byte format on
the next failed-attempt OR successful unlock.

H3 — Pre-increment the failed-attempt counter BEFORE running the HMAC
verify in unlockWithPassphrase. The previous order wrote the counter
only after a failed verify, so an attacker glitching the chip between
verify and write could skip the increment and bypass backoff. The
slot is now reserved atomically up front; the success path writes
attempts=0 to clear the reservation. Worst case for a legitimate user
who power-cycles mid-success is one phantom attempt — backoff
recovers next try.

H4 — .backoff file is now MAC'd with HMAC-SHA256(ephemeralKEK,
"backoff-auth" || body) (32-byte tag), and written atomically via
SafeFile (tmp + readback verify + rename). readBackoff treats
missing / wrong-size / MAC-fail uniformly as max-attempts (255) so an
attacker who deletes or rewrites the file can only INCREASE the wait,
never decrease it. clearBackoff() now writes an attempts=0 sentinel
instead of removing the file, so 'missing == tamper' is unambiguous
post-provision. bumpBootsSinceFailOnBoot() skips on un-provisioned
devices to avoid false 'tamper' detection during the legitimate fresh
window between fsInit and provisionPassphrase.

H12 — saveDEK and writeUnlockToken now write via SafeFile in
fullAtomic mode (tmp file + readback verify + atomic rename) instead
of remove-then-open-then-write. Power loss during a DEK or token
write previously left the device unable to unlock — the encrypted
prefs files are unreadable without a valid DEK. The atomic path
rolls back to the previous file on partial write.

M10 — readAndConsumeToken's 74-byte stack buffer (entire wrapped
DEK + HMAC, explicitly called out by the audit as never wiped before
return) is now a meshtastic_security::ZeroizingBuffer that the
destructor scrubs on every return path. Same treatment for the
computedHmac stack array next to it, and for the new backoff state
buffers in readBackoff / writeBackoff / computeBackoffHmac. Removes
the manual secure_zero calls those buffers had on success paths and
fixes the missing wipes on the failure-return paths.

M11 — Added EncryptedStorage::secureWipeKeys() public API that
zeros dek/kek/ephemeralKek in BSS without touching flash, no
logging, no locks (safe from interrupt context). HardFault_Impl now
calls it as the very first thing on entry, before the diagnostic
print / coredump path runs, so a hard-fault crash dump won't capture
the DEK / KEK material that the rest of the module leaves in RAM.

M25 — migrateFile now refuses to allocate a buffer for any file
larger than 64 KiB. The legitimate ceiling is well under that on
every supported variant; anything larger is either corrupt or a
DFU-injected OOM attempt.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): MENC header MAC + token rollback counter (audit)

Closes M2 and M4 from the lockdown audit. **FORMAT-BREAKING** — devices
provisioned with prior lockdown firmware must factory-erase /prefs and
reprovision; the previous tokens and encrypted prefs files will not
decrypt under the new HMAC/body layouts.

M2 — The HMAC on MENC encrypted proto files now covers the full on-disk
header (4-byte magic + 13-byte nonce + 4-byte plaintext_len + ciphertext)
instead of just (nonce + ciphertext). Without this, magic and
plaintext_len were integrity-protected only by the equality check
`plaintextLen == ciphertextLen` — which holds today (no padding /
compression / AAD) but would silently produce length-oracle and
downgrade vulnerabilities the instant any of those got added. Putting
the header inside the MAC closes that pre-condition cleanly. The
verify side in readAndDecrypt and the compose side in encryptAndWrite
update in lockstep.

M4 — UTOK gains a 4-byte monotonic counter field inside its MAC'd
body. The highest counter ever issued is persisted to a new
/prefs/.tokmono file MAC'd with HMAC-SHA256(ephemeralKEK,
"tokmono-auth" || counter). On every readAndConsumeToken, any
token whose counter is less than the persisted value is rejected as
a rollback attempt and deleted. Defeats the audit's threat: an
attacker who once captured a token (e.g. bootsRemaining=255 from
before the operator lowered the policy) tries to write it back to
disk later. Counter is incremented monotonically across the device's
lifetime so any captured snapshot loses to the persisted max-seen.

Self-heal: a token whose counter exceeds the persisted value (e.g.
the .tokmono write itself failed after the token committed, or the
.tokmono got wiped via factory-erase) is accepted AND the counter
file is promoted to match. This avoids spuriously rejecting valid
tokens after partial-update recovery.

Threat model caveat (consistent with C2 acceptance): an attacker who
has both flash extraction AND FICR can recompute the .tokmono MAC
and restore a matching pair (.unlock_token + .tokmono) from an
earlier capture. M4 raises the bar to that combined capability;
the flash-write-only attacker is now blocked.

Verified with an nRF52 lockdown build (rak4631).

MIGRATION: devices already provisioned with the prior lockdown
firmware will fail to auto-unlock at boot (token format mismatch),
fall back to LOCKED(needs_auth), and every passphrase attempt will
fail because the encrypted /prefs files are HMAC'd against the old
input. Recovery is: factory-erase via the bootloader UF2 then
re-provision via lockdown_provision.py or the Android app.

* feat(lockdown): make lockdown a runtime client-toggleable setting

Converts MESHTASTIC_LOCKDOWN from a per-variant compile-time flag that
forced lockdown ON into an internal capability that is ALWAYS compiled
in for nRF52 and gated purely at runtime by whether a passphrase has
been provisioned. A device that has never been provisioned (or that the
operator disabled) behaves exactly like stock firmware.

Build/config:
- configuration.h auto-defines MESHTASTIC_LOCKDOWN (+ ACCESS_CONTROL,
  ENCRYPTED_STORAGE, APPROTECT-capable) for ARCH_NRF52 unconditionally.
  No variant sets -DMESHTASTIC_LOCKDOWN anymore. Flash-constrained
  variants can opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1. DEBUG_MUTE
  is no longer coupled to lockdown (a capable-but-off device must log
  normally). rak4631 lands at 96.2% flash with lockdown always-in.

Runtime predicate:
- EncryptedStorage::isLockdownActive() == isProvisioned() (.dek exists)
  is the single source of truth for active/inactive.
- PhoneAPI::getAdminAuthorized() returns true when lockdown is inactive,
  so every existing redaction gate no-ops on a capable-but-off device
  with no per-site changes. The locked-boot defaults path (NodeDB), the
  AdminModule storage-locked gate, the screen-redaction predicate, and
  the plaintext->encrypted migrate block are all additionally gated on
  isLockdownActive() so an un-provisioned device loads/serves plaintext
  normally.
- sendConfigComplete emits LockdownStatus{DISABLED} when capable-but-off
  so the client renders its toggle OFF.

Enable (off->on): client provisions a passphrase. provisionPassphrase
generates the DEK; the existing reload path encrypts the plaintext
config in place (migration runs live with the DEK in RAM) and authorizes
the connection -> UNLOCKED. No reboot.

Disable (on->off): LockdownAuth{passphrase, disable=true}. PhoneAPI
verifies the passphrase (loads DEK), sets lockdownDisablePending; the
main loop runs NodeDB::disableLockdownToPlaintext() which decrypts every
pref via EncryptedStorage::migrateFileToPlaintext() then
removeLockdownArtifacts() deletes the DEK/token/counter/backoff (the
.dek delete is the atomic commit), then reboots into normal mode.
Power-loss safe and re-runnable without a persistent marker — and the
crypto runs live with the operator's passphrase in RAM rather than via
a boot-time marker an attacker could plant to trigger an unprompted
decrypt. APPROTECT is NOT reversed (sticky; permanent on silicon where
it engaged).

Generated bindings (admin.pb.h / mesh.pb.h) regenerated against
protobufs#927 (LockdownAuth.disable, LockdownStatus.State.DISABLED).
Submodule pointer stays at the pinned develop commit; the bindings are
ahead until #927 merges and the submodule is bumped, same flow as the
max_session_seconds work.

Builds clean: rak4631 with no flags now auto-includes lockdown.

NOTE: this changes the LockdownStatus the firmware emits and adds the
disable path; pairs with protobufs#927 and the upcoming Android client
toggle work.

* fix(lockdown): re-lock per-connection auth on BLE reconnect

A provisioned device reused a single BLE PhoneAPI instance, and the
per-connection auth slot (keyed by that instance) was only cleared on the
!isConnected() disconnect transition. A fast disconnect/reconnect could
begin a new config burst while state was still STATE_SEND_PACKETS, so the
reconnected client inherited the prior session's authorization: it received
SecurityConfig in the clear and no LockdownStatus, and never re-authenticated.

Reset the auth slot in NRF52Bluetooth onConnect(), which fires once per
physical link, so every new connection starts locked regardless of whether
the previous link's close() raced the new handshake. handleStartConfig keeps
its !isConnected() reset (do NOT reset on a same-connection want_config: the
post-unlock re-fetch is the client pulling now-unredacted config and must keep
the auth it just earned, otherwise config comes back redacted and set_config
writes get dropped).

* fix(lockdown): persist config on a lockdown-capable but disabled device

saveProto always called encryptAndWrite when encrypted storage was compiled,
and saveToDiskNoRetry skipped every save when !isUnlocked(). On a disabled
(never provisioned) device there is no DEK and isUnlocked() is always false,
so both paths fired and NO config ever persisted: a LoRa region set before
enabling lockdown lived only in RAM, then provisioning migrated the UNSET
default from disk and the region was lost.

Gate both on isLockdownActive(): when lockdown is inactive the device writes
plaintext exactly like stock firmware; the reloadFromDisk migrate pass then
re-saves those plaintext files encrypted once the device is provisioned.
Verified on hardware: region set while disabled now survives enable, reboot,
and unlock.

* fix(lockdown): suppress LoRa region picker under the lock screen

A locked-boot lockdown device installs region=UNSET as a deliberate RAM
placeholder (the real region is in encrypted storage, restored on unlock).
Screen.cpp popped the region picker / onboard message whenever region==UNSET,
so it rendered over the lock screen and trapped input with no way out. Skip it
while the display is being redacted for lockdown.

* fix(lockdown): silence cppcheck void* false positive + ruff docstring lints

The nRF52 `check` (cppcheck --fail-on-defect=low) flagged
arithOperationsOnVoidPointer on EncryptedStorage.cpp buffers. These are
false positives: make_zeroizing_array() returns unique_ptr<uint8_t[], ...>
so .get() is uint8_t*, not void* — cppcheck just can't resolve the
custom-deleter alias. File-scoped suppression, matching the existing
crypto-code convention in suppressions.txt.

Trunk flagged 5 ruff docstring issues in lockdown_provision.py: D301
(backslashes need a raw docstring) and D405/D407/D411/D413 (the EXAMPLES
heading was being parsed as a numpydoc section). Made the docstring raw
and renamed the heading to USAGE to dodge section detection while keeping
the ASCII-box formatting.

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

* fix(lockdown): resolve cppcheck const/null-deref defects

The nRF52 `check` job (pio check --fail-on-defect=low) flagged seven
real cppcheck defects in the lockdown code:

  - EncryptedStorage.cpp: nonce/encDek are read-only views into the
    token buffer -> const uint8_t *.
  - NodeDB.cpp: segments[] lookup table is never mutated -> const.
  - PhoneAPI.cpp: clearStatusSlot_LH's p is only compared; the auth-check
    slot and the hasPendingLockdownStatus loop var are read-only -> const.
  - Screen.cpp: the MESHTASTIC_LOCKDOWN drawLockdownLockScreen() guard
    introduced a redundant null check (nullPointerRedundantCheck) since
    dispdev->displayOff() right below derefs it unguarded, as does the
    rest of the file. Dropped the guard.

Verified with cppcheck 2.21 locally against the project suppressions.

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

* fix(lockdown): const-qualify clearAuthSlot_LH param (cppcheck cascade)

Making clearStatusSlot_LH take const PhoneAPI* let cppcheck propagate the
same to clearAuthSlot_LH, whose p is only compared and forwarded. The
remaining PhoneAPI* params (findOrAlloc*Slot_LH) store p into the slot
table, so they correctly stay non-const.

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

* fix(lockdown): wire runtime-toggle disable flow into provision tool

Addresses Copilot review on tools/lockdown_provision.py — the reference
tool advertised the runtime-toggle disable lifecycle but couldn't exercise
it:

  - _STATE_NAMES: map LockdownStatus.DISABLED so a capable-but-off boot
    prints DISABLED instead of an opaque state=<num>.
  - build_lockdown_auth(): add a disable param that actually sets
    la.disable, failing loudly on pre-runtime-toggle bindings instead of
    silently sending a plain unlock.
  - cmd_disable() + 'disable' subcommand: send LockdownAuth{disable=true,
    passphrase=...} and wait for the resulting LockdownStatus. Mirrors the
    firmware: non-empty passphrase required, DISABLED broadcast precedes
    the reboot, TTL/session fields ignored.
  - _exit_code_for_status(): treat DISABLED as a success (exit 0) like
    UNLOCKED.

All DISABLED/disable references are hasattr-guarded so the tool still
imports and runs the lock/unlock/provision paths against the currently
released meshtastic package (verified: it has LockdownAuth but not yet
disable/DISABLED). Verified with ruff 0.15.13 and black.

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

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:39:49 -05:00
Ben Meadors eb719f6fca Refine IPv6 address logging for CH390 driver in WiFiAPClient 2026-06-11 15:37:22 -05:00
Ben Meadors 02081dc85d Fix Ethernet handling and dependencies for CH390 driver 2026-06-11 15:36:13 -05:00
Ben Meadors ed52e3019d Change handleSetOwner parameter to const reference and improve long name handling 2026-06-11 14:24:12 -05:00
Ben Meadors c2bcec93d0 Fix long name clamping and adjust related structures for compatibility 2026-06-11 12:18:26 -05:00
8bb5364d8c tunk (#10684)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-11 07:57:06 -05:00
Ben Meadors 83c7e4ede3 Add board_level configuration for Heltec V4, RAK WisMesh Tag, and Seeed Wio Tracker L1 2026-06-11 07:21:25 -05:00
Ben Meadors a14f7afe87 fix(workflows): expand trusted author criteria for flasher comments 2026-06-10 20:04:40 -05:00
Ben Meadors 1490daa7ca Update runner configuration to use GitHub-hosted runners for checks 2026-06-10 19:12:32 -05:00
Ben Meadors a4001d71d5 Improve PR resolution logic for web flasher link comments 2026-06-10 17:54:24 -05:00
Ben Meadors 6da9f5f20e Add placeholder comment for web flasher during PR builds 2026-06-10 17:28:30 -05:00
TomandGitHub ab882c5619 EU regions merge (#10675)
* stronger together

* validate 2.4ghz regions

* less noise

* you're right, and that shapens the analysis significantly

* sassy rejoinder
2026-06-10 18:37:14 +01:00
Ben Meadors 2541db2bef fix(workflows): update artifact selection to exclude expired firmware size artifacts 2026-06-10 10:01:12 -05:00
Ben Meadors f875518b28 Flasher link fix 2026-06-10 08:00:05 -05:00
Ben Meadors 334ad9b313 Restrict web flasher link comments to organization members only 2026-06-10 06:33:33 -05:00
Ben Meadors 0953706e9e Add GitHub Action to post web flasher link comments on successful PR workflows 2026-06-10 05:48:39 -05:00
Ben Meadors 309d51a3e8 fix(NodeInfoModule): update user handling in allocReply to prevent global state clobbering 2026-06-09 21:00:40 -05:00
Ben Meadors 93f87c57b9 MacOS fixes 2026-06-09 21:00:05 -05:00
Ben MeadorsandGitHub 94ef2ae451 Revert "Automated version bumps (#10667)" (#10672)
This reverts commit abef0d85a2.
2026-06-09 20:04:26 -05:00
abef0d85a2 Automated version bumps (#10667)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-06-09 19:32:27 -05:00
6b3f975ba5 fix(ble): reliably expose and update BLE battery level (BAS) (#10622)
* fix(ble): reliably expose and update BLE battery level (BAS)

The Battery Service (0x180F / 0x2A19) is now wired up per the Bluetooth
BAS spec: the Battery Level characteristic always holds a valid 0-100
value and is pushed on change.

- NimBLE: seed an initial level at setup and cache the value on every
  update so a READ returns the current level even while disconnected;
  only notify when a client is connected.
- Power: mirror the battery level to the Battery Service from
  readPowerStatus() on change, so it updates independent of GPS/position
  events (previously the only push path was MeshService).

Also fixes two regressions the above would otherwise introduce:

- NimBLE use-after-free: BLEDevice::deinit(true) frees the GATT objects
  but left the global BatteryCharacteristic dangling. Several AdminModule
  paths (e.g. serial-config entry) deinit BLE while config.bluetooth.enabled
  stays true, so the periodic push would deref freed memory. Null the
  pointer in deinit().
- NRF52: guard blebas.write() on the nrf52Bluetooth instance so the new
  periodic push can't call it before the Battery Service is begun in setup().

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

* fix(ble): clamp BAS battery level to 0-100 and skip redundant updates

Address review feedback on the Battery Level characteristic (0x2A19):

- Clamp the value to the BAS-mandated 0-100 range at the platform write
  boundary (NimBLE seed + update, NRF52 update), so a misbehaving battery
  backend can't put an out-of-range value on the characteristic.
- Skip the write/notify when the level is unchanged, so repeated callers
  (e.g. MeshService refresh paths) don't emit redundant notifications.
- Simplify Power.cpp to a direct guarded call now that clamping and
  de-duplication live at the boundary, which also removes the implicit
  int->uint8_t narrowing.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-09 18:58:26 -05:00
Jason PandGitHub e028663658 BaseUI: First attempt at Ham Mode implementation (#10663)
* First attempt at Ham Mode implementation

* Simplify licensedOnly check

* Move related code closer together

* TX Disabled if N0CALL, enabled if properly set

* Only disable if callsign is N0CALL, don't enable at this stage.

* Allow users back to Normal mode if they don't pick an ITU region
2026-06-09 19:52:29 -04:00
bf68b9e597 NRF52 LTO flags (#10655)
* Add LTO support for nrf52840 while preserving interrupt handlers

* nrf52840: enable whole-image LTO on all targets via nrf52_base

Moves -flto + the nrf52_lto.py exclusion middleware from the rak4631 env
(771018ca8) up to [nrf52_base], so every nrf52840 target inherits it.

nrf52_lto.py keeps the interrupt handlers out of LTO (framework core +
TinyUSB USBD_IRQHandler) -- they're referenced only from the asm vector
table, so whole-program LTO would otherwise drop them and the chip hangs.

HW-validated: RAK4631 (SX1262, -60KB) and muzi-base (LR1121) both boot and
init their radios. Build-verified on canaryone (fresh board, base-inherited).

Caveat: the RAK "1-Watt" variant's radio does not tolerate global-LTO
(SX126x init fails); it shares the rak4631 binary, so keep that hardware on
src-only or split it into a separate target.

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

* nrf52840: move -fmerge-all-constants to nrf52_base (all targets)

It had been trialed on rak4631 only; it's a general image-wide flag (the
same one stm32 uses globally, ~0.7KB), so move it up to [nrf52_base] next
to -flto so every nrf52840 target gets it instead of just rak4631.

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

* nrf52_lto.py: normalize path separators for Windows (Copilot review)

get_abspath() returns backslash-separated paths on Windows, so the
"/FrameworkArduino/" / "/cores/nRF5/" substring matches would miss and the
ISR-owning objects would still be LTO'd -> hang on first IRQ. Replace
backslashes with forward slashes before matching. No-op on macOS/Linux.

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

* Fix  directory handling for LTO

* Add post-link guard to check for dropped ISR handlers in nrf52 LTO

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jason P <applewiz@mac.com>
2026-06-09 16:09:25 -05:00
a9b98f47e9 GPS: cache model and baudrate and skip full sweep every startup (#10544)
* GPS cache

* trunk and CRLF fix

* Fix GPS.cpp formatting for trunk

* Format GPS.cpp for trunk clang-format

* Show gps model instead of model number

* Potential fix for pull request finding

Useful fix

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

* Update GPS.cpp

* Update GPS.cpp

* Trunk fix

* Update GPS.cpp

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 10:24:59 -05:00
Jason PandGitHub 90a3ac5938 Update SharedUIDisplay.cpp (#10659) 2026-06-09 09:17:43 -05:00
Jonathan Bennett 38f15db1d0 Bump protos to latest develop and regen 2026-06-08 16:28:40 -05:00
AustinandJonathan Bennett da821ec663 Actions: Update protobufs using the triggering branch (#10612) 2026-06-08 16:21:52 -05:00
Jonathan Bennett 124bffad84 Update Thinknode m7 pins (#10635) 2026-06-08 16:10:51 -05:00
Jason PandJonathan Bennett f98abe00f3 Update clock to be 70% max versus 80% to avoid unintended overlaps (#10516) 2026-06-08 16:01:02 -05:00
Thomas GöttgensandJonathan Bennett 56a33a07f7 remove private flag 2026-06-08 15:54:38 -05:00
Thomas GöttgensandJonathan Bennett ce80433e43 activate HWID 2026-06-08 15:54:24 -05:00
3d98622b96 Add hex picker (#10650)
* Add hex picker

* 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-06-08 14:50:55 -05:00
Jonathan BennettandGitHub d3691258d3 Update nanopb download URL in workflow 2026-06-08 14:23:22 -05:00
Jason PandGitHub 360c54f1f9 Random 2.8 Warning cleanups (#10649)
* Clean up Compass warning

* Update ICM42607PSensor.cpp
2026-06-08 08:12:57 -05:00
Thomas GöttgensandGitHub 8c4900a52f Prevent ghost nodes during onboarding (#10647)
* Prevent ghost nodes during onboarding
* Coplilot is exceptionally nit-picky today
2026-06-07 22:54:25 +02:00
bfb833982e Flip C6 to supported. (#10646)
* Flip C6 to supported.

* Re-add board_level pr

and remove redundant lib_deps

---------

Co-authored-by: Austin <vidplace7@gmail.com>
2026-06-07 15:12:59 -05:00
TomandGitHub 1410f170f9 makes clod format as it goes (#10645) 2026-06-07 08:09:19 -05:00
212 changed files with 17779 additions and 3000 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "fleetsuite-ui",
"runtimeExecutable": "npm",
"runtimeArgs": [
"--prefix",
"mcp-server/web-ui",
"run",
"dev",
"--",
"--port",
"5199",
"--strictPort"
],
"port": 5199
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "f=$(tr -d '\\n' | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed 's/.*:[[:space:]]*\"//; s/\"$//'); [ -n \"$f\" ] && [ -f \"$f\" ] || exit 0; t=$(command -v trunk || echo \"$HOME/.cache/trunk/launcher/trunk\"); [ -x \"$t\" ] || { echo \"trunk-fmt hook: trunk not found; its launcher needs curl or wget to bootstrap the CLI (see 'Formatting & the trunk toolchain' in .github/copilot-instructions.md)\" >&2; exit 1; }; out=$(\"$t\" fmt --force \"$f\" 2>&1) || { echo \"trunk-fmt hook: trunk fmt failed on $f: $out\" >&2; exit 1; }",
"timeout": 120,
"statusMessage": "Formatting (trunk)..."
}
]
}
]
}
}
+9
View File
@@ -283,6 +283,15 @@ firmware/
## Coding Conventions
### Formatting & the trunk toolchain
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
### General Style
- Follow existing code style - run `trunk fmt` before commits
+187
View File
@@ -0,0 +1,187 @@
name: Post Web Flasher Link Comment
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
pull-requests: write
actions: read
jobs:
post-flasher-link:
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'cancelled' &&
github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
# Per-board manifests carry the firmware's own metadata (activelySupported,
# displayName, ...) generated from each target's custom_meshtastic_* config.
- name: Download board manifests
uses: actions/download-artifact@v8
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: manifest-*
path: ./manifests
merge-multiple: true
- name: Post or update web flasher link comment
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const run = context.payload.workflow_run;
const { owner, repo } = context.repo;
// Resolve the PR by matching the run's head SHA against the repo's open
// PRs. workflow_run.pull_requests is empty for fork PRs, and
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
// its head commit — but pulls.list includes fork PRs. Matching on head
// SHA also enforces that the run is for the PR's current commit, so stale
// re-runs of an outdated commit won't match.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
const pr = openPrs.find((p) => p.head.sha === run.head_sha);
if (!pr) {
core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
return;
}
const prNumber = pr.number;
// Restrict to trusted authors. NOTE: author_association is computed for
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
// alone silently excludes most maintainers. We allow the trusted set the
// token can actually identify (members, collaborators, and anyone with a
// previously merged PR). For strict members-only you'd need an org-read
// App/PAT token to call orgs.checkMembershipForUser.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Require at least one per-arch firmware artifact from gather-artifacts
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner, repo, run_id: run.id, per_page: 100,
});
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
if (archArtifacts.length === 0) {
core.info('No per-arch firmware artifacts found; skipping.');
return;
}
const version = archRe.exec(archArtifacts[0].name)[2];
const expiresAt = archArtifacts[0].expires_at
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
: null;
// Read each built board's manifest (.mt.json). activelySupported,
// displayName and architecture come straight from the board's
// custom_meshtastic_* platformio config, so the list is in sync with
// the firmware itself — no external device database needed.
const fs = require('fs');
let boards = [];
try {
boards = fs.readdirSync('./manifests')
.filter((f) => f.endsWith('.mt.json'))
.map((f) => {
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
catch { return null; }
})
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
.map((m) => ({
board: m.platformioTarget,
platform: m.architecture || '',
// displayName is maintainer-authored text; escape table-breaking pipes
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
}))
.sort((a, b) => a.board.localeCompare(b.board));
} catch (e) {
core.warning(`Could not read board manifests: ${e.message}`);
}
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
// Device illustrations are served by the flasher from the same image
// names the manifest declares (custom_meshtastic_images). The flasher
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
// image really resolves to an image before linking it.
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
await Promise.all(boards.map(async (b) => {
if (!b.image) return;
try {
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
const type = res.headers.get('content-type') || '';
if (!res.ok || !type.startsWith('image/')) b.image = '';
} catch { b.image = ''; }
}));
const boardLines = boards
.map((b) => {
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
})
.join('\n');
// Shields.io badges. Only non-user-controlled, charset-constrained values
// (version, commit sha, counts, dates) go into badge URLs — never board
// names or the PR title — so the rendered comment cannot be spoofed.
const shieldText = (s) =>
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
const shield = (label, message, color) =>
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
const buttonUrl =
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
const badges = [
`![firmware](${shield('firmware', version, '67EA94')})`,
`![commit](${shield('commit', run.head_sha.slice(0, 7), '2C2D3C')})`,
`![boards](${shield('boards', boards.length, '5C6BC0')})`,
];
if (expiresAt) badges.push(`![expires](${shield('expires', expiresAt, '9A4E00')})`);
// Only render the board table when there are supported boards to list
const boardTable = boards.length > 0 ? [
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
'',
'| | Device | Board | Platform |',
'| --- | --- | --- | --- |',
boardLines,
'',
'</details>',
'',
] : [];
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
`[![Flash this PR in the Web Flasher](${buttonUrl})](${flasherUrl})`,
'',
badges.join(' '),
'',
'> [!WARNING]',
'> This is an automated, unreviewed CI test build. Back up your device configuration',
'> before flashing, and only flash devices you are able to recover.',
'',
...boardTable,
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
].join('\n');
// Sticky comment: update in place when the marker is found
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber, per_page: 100,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
@@ -0,0 +1,62 @@
name: Post Web Flasher Build Placeholder
# Drops an immediate "build in progress" comment when a PR opens, so the web
# flasher entry shows up right away. The real CI-driven workflow
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
#
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
# safe because it never checks out or runs PR code and posts a fully static body
# — no PR title, branch name, or other untrusted input is used anywhere.
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
post-placeholder:
if: github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Post web flasher build-in-progress placeholder
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
// Trusted authors only (matches the real workflow). author_association
// can't reflect private org membership for the token, so concealed
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Only seed a placeholder when no flasher comment exists yet — never
// overwrite a real (or existing placeholder) comment.
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
if (comments.some((c) => c.body?.includes(marker))) {
core.info('Flasher comment already exists; nothing to do.');
return;
}
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
'> [!NOTE]',
'> Building this pull request… the flash button, badges and supported-board',
'> list will appear here automatically once CI finishes.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
+7 -6
View File
@@ -82,8 +82,9 @@ jobs:
fail-fast: false
matrix:
check: ${{ fromJson(needs.setup.outputs.check) }}
# Use 'arctastic' self-hosted runner pool when checking in the main repo
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
# Runs on GitHub-hosted runners so checks don't compete with builds for the
# self-hosted 'arctastic' pool (which builds use).
runs-on: ubuntu-latest
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
steps:
- uses: actions/checkout@v6
@@ -286,11 +287,11 @@ jobs:
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
cp "./baseline-develop/${ARTIFACT_NAME}/current-sizes.json" ./develop-sizes.json
cp "./baseline-develop/current-sizes.json" ./develop-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
@@ -311,11 +312,11 @@ jobs:
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-master/
cp "./baseline-master/${ARTIFACT_NAME}/current-sizes.json" ./master-sizes.json
cp "./baseline-master/current-sizes.json" ./master-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
+29 -1
View File
@@ -37,13 +37,33 @@ jobs:
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
- name: Integration test
# Cap the whole step: if the simulator ever fails to exit (e.g. the
# exit_simulator admin path regresses again) the job must fail fast,
# not run to GitHub's 6-hour limit.
timeout-minutes: 5
run: |
.pio/build/coverage/meshtasticd -s &
PID=$!
trap 'kill "$PID" 2>/dev/null || true' EXIT
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
echo "Simulator started, launching python test..."
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
wait
# The Python harness sends exit_simulator and exits; the simulator is
# expected to terminate on its own. Give it a moment, then verify.
# If it is still alive the exit handshake is broken — fail loudly and
# do NOT fall through to `wait`, which would otherwise block until the
# job's hard timeout.
for i in $(seq 1 10); do
kill -0 "$PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$PID" 2>/dev/null; then
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
kill -9 "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
exit 1
fi
wait "$PID" 2>/dev/null || true
- name: Capture coverage information
if: always() # run this step even if previous step failed
@@ -141,6 +161,14 @@ jobs:
name: platformio-test-report-${{ steps.version.outputs.long }}
merge-multiple: true
- name: Drop no-status testsuites from the report
# PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_* dir
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
# them so the Test Report lists only suites with a real status. Only the copy the
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
run: sed -i -E 's#<testsuite [^>]*tests="0"[^>]*/>##g' testreport.xml
- name: Test Report
uses: dorny/test-reporter@v3.0.0
with:
+9 -4
View File
@@ -16,13 +16,18 @@ jobs:
submodules: true
- name: Update submodule
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }}
working-directory: protobufs
env:
# Use the branch that triggered the workflow as the protobuf branch.
GIT_BRANCH: ${{ github.ref_name }}
run: |
git submodule update --remote protobufs
git fetch --prune origin $GIT_BRANCH
git checkout origin/$GIT_BRANCH
- name: Download nanopb
run: |
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz
tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz
mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9
@@ -33,7 +38,7 @@ jobs:
- name: Create pull request
uses: peter-evans/create-pull-request@v8
with:
branch: create-pull-request/update-protobufs
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
labels: submodules
title: Update protobufs and classes
commit-message: Update protobufs
+5 -5
View File
@@ -1,8 +1,8 @@
.pio
pio
pio.tar
web
web.tar
/.pio
/pio
/pio.tar
/web
/web.tar
# ignore vscode IDE settings files
.vscode/*
+41
View File
@@ -10,6 +10,47 @@
"isDefault": true
},
"label": "PlatformIO: Build"
},
{
"label": "FleetSuite",
"detail": "Build the SPA if needed and open the FleetSuite desktop window",
"type": "shell",
"command": "./scripts/fleetsuite.sh",
"options": { "cwd": "${workspaceFolder}/mcp-server" },
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"group": "none"
},
{
"label": "FleetSuite: Dev (HMR)",
"detail": "Run the backend + Vite dev server with hot-reload",
"type": "shell",
"command": "./scripts/fleetsuite.sh --dev",
"options": { "cwd": "${workspaceFolder}/mcp-server" },
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"isBackground": true
},
{
"label": "FleetSuite: Rebuild + Run",
"detail": "Force a fresh SPA build, then open the window",
"type": "shell",
"command": "./scripts/fleetsuite.sh --rebuild",
"options": { "cwd": "${workspaceFolder}/mcp-server" },
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
}
}
]
}
+1 -1
View File
@@ -64,7 +64,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
- **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.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
- **`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.
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Run native PlatformIO unit tests and emit a single, unambiguous RED/AMBER/GREEN verdict.
#
# Why this exists: PlatformIO reports failures three different ways ([FAILED], :FAIL:,
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause — so naive
# greps produce false greens (see .notes/test-passfail-filter.md). This script encodes the
# correct logic once, and cross-checks the number of suites that actually ran against the
# canonical set in test/ so a suite silently going missing shows up as AMBER, not green.
#
# Usage:
# ./bin/run-tests.sh # run all suites, full RAG + count cross-check
# ./bin/run-tests.sh -f test_utf8 # run one suite (no count cross-check)
# ./bin/run-tests.sh -e native # override env (default: coverage)
# ./bin/run-tests.sh --quiet # only print the final RESULT line
#
# Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER.
#
# The final line is machine-readable, e.g.:
# RESULT: GREEN 19/19 suites passed
# RESULT: AMBER 17/19 suites ran (missing: test_radio test_serial) — all that ran passed
# RESULT: RED test_traffic_management: 1 failed (or: build/crash error)
# RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
# all passed; the coverage build aborts at exit on an ASan/LSan fault — often shown only
# as [ERRORED]/SIGHUP. The script names it and points at running the binary bare.)
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
ENV="coverage"
FILTER=""
QUIET=false
PASSTHRU=()
while [[ $# -gt 0 ]]; do
case "$1" in
-f)
FILTER="$2"
PASSTHRU+=("-f" "$2")
shift 2
;;
-e)
ENV="$2"
shift 2
;;
--quiet)
QUIET=true
shift
;;
*)
PASSTHRU+=("$1")
shift
;;
esac
done
# Locate pio (PATH, then the standard PlatformIO venv).
PIO="$(command -v pio || command -v platformio || echo "$HOME/.platformio/penv/bin/pio")"
if [[ ! -x $PIO ]] && ! command -v "$PIO" >/dev/null 2>&1; then
echo "RESULT: RED pio not found (looked in PATH and ~/.platformio/penv/bin)"
exit 1
fi
LOG="$(mktemp -t meshtest.XXXXXX.log)"
MARKER=""
PROGRESS_PID=""
trap 'rm -f "$LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
# Canonical suite set = the directories in test/. This is the source of truth for
# "what should run"; a filtered run only expects its filtered suite.
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
EXPECTED_COUNT=${#ALL_SUITES[@]}
# Cached object-count for this env, written after each completed build (in the gitignored build
# dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles),
# only a rough upper bound for an incremental run.
BASELINE_FILE=".pio/build/${ENV}/.runtests-objcount"
# Progress trail file (gitignored build dir). ALWAYS written so a backgrounded/piped run can be
# checked mid-build with `tail -f` — that's the whole point: don't fly blind on a 20-min rebuild.
PROGRESS_FILE=".pio/build/${ENV}/.runtests-progress"
# --- Progress heartbeat ------------------------------------------------------
# Emit ONE status line every few seconds: build = objects (re)compiled this run / cached total +
# best-effort ETA; test = suites finished / expected. Appends to $PROGRESS_FILE always (tail it to
# check on a backgrounded run); also live-updates the tty when $5=1 (interactive --quiet). Never
# touches $LOG, which is parsed for the verdict, so piped/CI captures stay clean.
progress_monitor() {
local marker="$1" objtotal="$2" testtotal="$3" pfile="$4" totty="$5" start now el done ran eta line
start=$(date +%s)
while :; do
now=$(date +%s)
el=$((now - start))
if grep -q 'Testing\.\.\.' "$LOG" 2>/dev/null; then
ran=$(grep -cE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" 2>/dev/null)
line=$(printf '[test] %s/%s suites done — %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
else
done=$(find ".pio/build/${ENV}" -name '*.o' -newer "$marker" 2>/dev/null | wc -l)
if ((objtotal > 0 && done > 0)); then
eta=$((objtotal > done ? (objtotal - done) * el / done : 0))
line=$(printf '[build] %d/%d objs — %dm%02ds — ETA ~%dm%02ds' \
"$done" "$objtotal" $((el / 60)) $((el % 60)) $((eta / 60)) $((eta % 60)))
else
# done==0 (incremental: nothing to rebuild yet) or no cached baseline — no ETA yet.
line=$(printf '[build] %d objs compiled — %dm%02ds' "$done" $((el / 60)) $((el % 60)))
fi
fi
printf '%s\n' "$line" >>"$pfile" 2>/dev/null # file trail (always)
[[ $totty == 1 ]] && printf '\r\033[K%s' "$line" >/dev/tty 2>/dev/null # live line (human)
sleep 4
done
}
# Launch the heartbeat for every run. It writes the progress file unconditionally; the live tty
# line only when interactive AND --quiet (where pio's own output is hidden — otherwise pio's
# streamed compile lines already show progress and a \r line would just fight them).
mkdir -p ".pio/build/${ENV}" 2>/dev/null || true
: >"$PROGRESS_FILE" 2>/dev/null || true
MARKER="$(mktemp -t meshtest-mark.XXXXXX)"
TOTTY=0
{ $QUIET && [[ -t 1 ]]; } && TOTTY=1
progress_monitor "$MARKER" "$(cat "$BASELINE_FILE" 2>/dev/null || echo 0)" \
"$([[ -n $FILTER ]] && echo 1 || echo "$EXPECTED_COUNT")" "$PROGRESS_FILE" "$TOTTY" &
PROGRESS_PID=$!
if ! $QUIET; then
echo "Running: $PIO test -e $ENV ${PASSTHRU[*]-} (expecting $EXPECTED_COUNT suites)"
fi
echo "progress: tail -f $PROGRESS_FILE" >&2
# Run pio, tee to log. PIPESTATUS[0] is pio's real exit (NOT tee's).
if $QUIET; then
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" >"$LOG" 2>&1
else
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" 2>&1 | tee "$LOG"
fi
PIO_RC=${PIPESTATUS[0]}
# Stop the heartbeat, clear its line, and cache this build's object total for next time.
if [[ -n $PROGRESS_PID ]]; then
kill "$PROGRESS_PID" 2>/dev/null
wait "$PROGRESS_PID" 2>/dev/null
PROGRESS_PID=""
# Clear the live line only if we were writing one — opening /dev/tty when there is none is
# itself a redirect-open error the trailing 2>/dev/null cannot suppress.
[[ $TOTTY == 1 ]] && printf '\r\033[K' >/dev/tty 2>/dev/null
fi
[[ -d ".pio/build/${ENV}" ]] && find ".pio/build/${ENV}" -name '*.o' 2>/dev/null | wc -l >"$BASELINE_FILE" 2>/dev/null || true
# --- Outcome detection -------------------------------------------------------
# The SAME outcome is spelled differently depending on which layer emitted the line — this is
# the trap that produces false greens (grepping ":PASS" misses pio's "[PASSED]", grepping
# "[FAILED]" misses Unity's ":FAIL:"). So every regex below matches BOTH spellings:
# pass: Unity per-assertion ":PASS" | pio per-suite "[PASSED]" | summary "N succeeded"
# fail: Unity per-assertion ":FAIL:" | pio per-suite "[FAILED]" | summary "M failed"
# error: pio build/crash "[ERRORED]" | Unity "M Failures" | compiler "error:"
# Match \b after :PASS/:FAIL so ":PASSED"/":FAILED" forms are also caught either way.
FAIL_RE=':FAIL\b|\[FAILED\]|\[ERRORED\]|[1-9][0-9]* failed|[0-9]+ Tests [1-9][0-9]* Failures|error:|undefined reference|Segmentation fault|terminate called|SIGHUP|SIGSEGV|SIGABRT'
# Positive proof tests actually ran & passed (absence != success). Accept any pass spelling:
# the per-test/per-suite tokens OR a success summary line.
PASS_RE=':PASS\b|\[PASSED\]|test cases: *[0-9]+ succeeded|[0-9]+ Tests 0 Failures'
# Sanitizer (ASan/LSan/UBSan/TSan) fault signatures. The coverage build is sanitizer-instrumented
# and aborts NON-ZERO at exit on a fault — most often a LeakSanitizer leak — AFTER every test has
# already printed [PASSED]. pio then reports [ERRORED]/SIGHUP with no :FAIL: anywhere, so it
# masquerades as a phantom "N-1 of N succeeded". See .notes/test-passfail-filter.md.
# Match only real FAULT lines, never the benign "AddressSanitizer: failed to intercept '...'"
# startup noise that prints on every sanitizer run (it'd mislabel a normal [FAILED] as a leak).
# Formats per LLVM/Google sanitizer docs: ASan/LSan emit "==PID==ERROR: <San>: ...", UBSan emits
# "file:line:col: runtime error: ...", TSan emits "WARNING: ThreadSanitizer: ..."; all close with
# a "SUMMARY: <San>: ..." line (LSan-under-ASan reports its SUMMARY as "AddressSanitizer").
SAN_RE='(ERROR|WARNING): (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|SUMMARY: (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|Direct leak of|Indirect leak of|detected memory leaks|heap-use-after-free|heap-buffer-overflow|stack-buffer-overflow|attempting double-free|LeakSanitizer has encountered a fatal error|runtime error:'
# Suites that produced a per-suite verdict. pio emits "coverage:test_x [PASSED|FAILED|ERRORED]";
# a SKIPPED suite (hardware-only on native) is "accounted for" too, so it doesn't read as missing.
mapfile -t RAN_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" |
sed -E "s/^${ENV}:(test_[a-z0-9_]+) .*/\1/" | sort -u)
RAN_COUNT=${#RAN_SUITES[@]}
# Suites pio explicitly skipped (don't count these as "missing" in the canonical cross-check).
mapfile -t SKIPPED_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+.*\bSKIPPED\b" "$LOG" |
grep -oE "test_[a-z0-9_]+" | sort -u)
verdict_red() {
local detail bin
detail="$(grep -nE '\[FAILED\]|:FAIL:|\[ERRORED\]' "$LOG" | head -3 | sed 's/^/ /')"
echo ""
echo "RED — failures detected:"
[[ -n $detail ]] && echo "$detail"
grep -E 'test cases:' "$LOG" | tail -1 | sed 's/^/ /'
# Path to the test binary for the "run it bare" hint. For native/coverage the test program is
# the env executable (e.g. .pio/build/coverage/meshtasticd), NOT a file named 'program'.
bin="$(find ".pio/build/${ENV}" -maxdepth 1 -type f -executable ! -name '*.so' 2>/dev/null | head -1)"
[[ -z $bin ]] && bin=".pio/build/${ENV}/<program> (build it first: $PIO test -e ${ENV} ${FILTER:+-f $FILTER} --without-testing)"
# Sanitizer fault (ASan/LSan/UBSan/TSan): name the real cause instead of "build/crash error".
if grep -qE "$SAN_RE" "$LOG"; then
grep -nE "$SAN_RE" "$LOG" | head -4 | sed 's/^/ /'
echo " -> sanitizer fault: if every test above is PASS, this is an exit-time abort, not a failed assertion."
echo " -> read the full report by running the binary BARE (gdb hides it via ptrace): ./$bin 2>&1 | tail -40"
echo "RESULT: RED sanitizer fault — $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
exit 1
fi
# All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the
# sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a
# sanitizer fault — point at how to surface it rather than calling it a generic crash.
if grep -qE "$PASS_RE" "$LOG" && grep -qE '\[ERRORED\]|SIGHUP|SIGABRT' "$LOG" && ! grep -qE ':FAIL\b|\[FAILED\]' "$LOG"; then
echo " -> all tests passed but the process aborted at EXIT — likely an ASan/LSan fault whose report"
echo " the runner swallowed (commonly shown as SIGHUP). Run the binary BARE to see it: ./$bin 2>&1 | tail -40"
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)"
exit 1
fi
echo "RESULT: RED $(grep -oE '[0-9]+ failed' "$LOG" | tail -1 || echo 'build/crash error')"
exit 1
}
# RED: pio non-zero, any failure marker, or no positive summary at all (build died early).
if [[ $PIO_RC -ne 0 ]] || grep -qE "$FAIL_RE" "$LOG"; then
verdict_red
fi
if ! grep -qE "$PASS_RE" "$LOG"; then
echo ""
echo "RESULT: RED no success summary found (build error / no tests ran?) — see log"
exit 1
fi
# AMBER: everything that ran passed, but (full run only) a canonical suite neither ran NOR was
# explicitly skipped — i.e. it silently went missing. SKIPPED suites are accounted for.
ACCOUNTED_COUNT=$((RAN_COUNT + ${#SKIPPED_SUITES[@]}))
if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
missing=()
for s in "${ALL_SUITES[@]}"; do
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s")
done
echo ""
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) — all that ran passed"
exit 2
fi
# GREEN.
if [[ -n $FILTER ]]; then
echo "RESULT: GREEN ${RAN_COUNT} suite(s) passed (filtered: $FILTER)"
else
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed"
fi
exit 0
-50
View File
@@ -1,50 +0,0 @@
{
"build": {
"arduino": {
"ldscript": "nrf52832_s132_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DNRF52832_XXAA -DNRF52",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x8029"],
["0x239A", "0x0029"],
["0x239A", "0x002A"],
["0x239A", "0x802A"]
],
"usb_product": "Feather nRF52832 Express",
"mcu": "nrf52832",
"variant": "WisCore_RAK4600_Board",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS132",
"sd_name": "s132",
"sd_version": "6.1.1",
"sd_fwid": "0x00B7"
},
"zephyr": {
"variant": "nrf52_adafruit_feather"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52832_xxAA",
"svd_path": "nrf52.svd",
"openocd_target": "nrf52840-mdk-rs"
},
"frameworks": ["arduino", "zephyr"],
"name": "Adafruit Bluefruit nRF52832 Feather",
"upload": {
"maximum_ram_size": 65536,
"maximum_size": 524288,
"require_upload_port": true,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
},
"url": "https://www.adafruit.com/product/3406",
"vendor": "Adafruit"
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821)
#
# Whole-image LTO for nrf52840 (~-60KB; ~-23KB beyond src-only LTO), EXCEPT the objects
# that own interrupt/exception handlers.
#
# Every ISR is referenced only from the assembly vector table (gcc_startup_nrf52840.S),
# which LTO cannot see -> whole-program LTO judges the handlers dead, removes them, and
# the weak `b .` Default_Handler stubs prevail -> the IRQ lands in an infinite loop and the
# chip hangs (or the peripheral silently stalls). Compiling the handler-bearing objects
# WITHOUT LTO lets ordinary linking keep the strong handlers; everything else stays LTO'd:
# - framework core (/FrameworkArduino/, /cores/nRF5/): every nrfx ISR + the FreeRTOS
# SVC/PendSV port.
# - TinyUSB nrf port (Adafruit_TinyUSB_nrf.cpp): USBD_IRQHandler (USB data path).
# - library .cpp files that own a vector ISR (would otherwise be silently dropped):
# bluefruit.cpp -> SD_EVT/SWI2_EGU2 (SoftDevice BLE-event delivery -- advertising
# hangs without it)
# Wire_nRF52.cpp -> SPIM0/TWIM0 + SPIM1/TWIM1 (interrupt-driven I2C/SPI)
# PDM.cpp -> PDM_IRQHandler (PDM microphone)
# RotaryEncoder.cpp -> QDEC_IRQHandler (hardware quadrature/rotary encoder)
#
# A post-link guard (bottom of this file) fails the build if a critical handler was dropped
# anyway -- so a future deps bump or a new ISR-owning library becomes a red build, not a field
# hang. To hunt a dropped ISR by hand: nm the .elf for `_IRQHandler$` symbols marked `W`, then
# grep the libs/framework for who defines them.
#
# HW-validated: RAK4631 (SX1262) + muzi-base (LR1121).
import glob
import os
Import("env")
env.Append(LINKFLAGS=["-flto", "-flto-partition=1to1"])
# The -fno-lto re-compiles below run with the global env, which lacks the framework's
# bundled-library include dirs -- and those libs cross-include each other (Wire pulls in
# Adafruit_TinyUSB.h, which pulls in SPI.h, ...). Add every bundled-lib dir (+ its src/) so
# the re-compiles resolve without chasing headers one at a time.
_fw = env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") or ""
_extra_inc = []
for _d in sorted(glob.glob(os.path.join(_fw, "libraries", "*"))):
if os.path.isdir(_d):
_extra_inc.append(_d)
if os.path.isdir(os.path.join(_d, "src")):
_extra_inc.append(os.path.join(_d, "src"))
FRAMEWORK = ("/FrameworkArduino/", "/cores/nRF5/")
USB_ISR = "Adafruit_TinyUSB_nrf" # USBD_IRQHandler
# Library .cpp files that define vector-table ISRs (the rest of their lib stays LTO'd):
LIB_ISR = ("/bluefruit.cpp", "/Wire_nRF52.cpp", "/PDM.cpp", "/RotaryEncoder.cpp")
def _no_lto(node):
try:
path = node.get_abspath()
except Exception:
path = str(node)
path = path.replace(
"\\", "/"
) # normalize Windows backslashes so matches work cross-platform
if (
USB_ISR in path
or any(s in path for s in FRAMEWORK)
or any(s in path for s in LIB_ISR)
):
return env.Object(
node,
CCFLAGS=env["CCFLAGS"] + ["-fno-lto"],
CPPPATH=env["CPPPATH"] + _extra_inc,
)
return node
env.AddBuildMiddleware(_no_lto)
# --- post-link guard: catch a dropped ISR handler at build time (CI footgun protection) ----
# After every link, fail the build if one of these critical vector-table handlers resolved to
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
# target, so this runs on every PR automatically. If a board deliberately stops using one of
# these, edit the tuples on purpose.
_REQUIRED_STRONG = (
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
"RTC1_IRQHandler", # FreeRTOS scheduler tick
)
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
_REQUIRED_STRONG_USB = (
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
)
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
if not os.path.isfile(_NM):
_NM = "arm-none-eabi-nm" # fall back to PATH
def _assert_isr_handlers_survived(source, target, env):
import subprocess
import sys
try:
# Resolve the ELF at build time; target[0] is the buildprog alias, not the file.
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
out = subprocess.check_output([_NM, elf], universal_newlines=True)
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
print("nrf52_lto: WARNING - ISR-handler guard skipped (nm failed: %s)" % exc)
return
# nm line: "<addr> <type> <symbol>". type 'T'/'t' = strong (good); 'W'/'w' = weak stub.
kind = {}
for line in out.split("\n"):
f = line.split()
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
kind[f[-1]] = f[-2]
required = list(_REQUIRED_STRONG)
defines = [
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
]
if "USE_TINYUSB" in defines:
required += _REQUIRED_STRONG_USB
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
if dropped:
sys.stderr.write(
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
"Each resolved to the weak Default_Handler stub, so the chip hangs when that IRQ\n"
"fires. Compile the .cpp that defines the handler with -fno-lto by adding it to\n"
"LIB_ISR in extra_scripts/nrf52_lto.py. Find the owner of FOO_IRQHandler with:\n"
" grep -rl FOO_IRQHandler <framework-arduinoadafruitnrf52>/{libraries,cores}\n\n"
% ", ".join(dropped)
)
from SCons.Script import Exit
Exit(1) # canonical SCons build-abort -> red build
print(
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
)
# Attach to the phony "buildprog" alias, NOT the .elf file node: SCons can skip a post-action
# on a file target during an incremental relink (observed), but the buildprog alias runs every
# build -- so the guard fires on local incremental rebuilds and clean CI builds alike.
env.AddPostAction("buildprog", _assert_isr_handlers_survived)
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
import re
Import("env")
# The ZPS module scans BLE through the NimBLE *host* API ble_gap_disc(), which the
# ESP-IDF only compiles when CONFIG_BT_NIMBLE_ROLE_OBSERVER=y. The base esp32 config
# disables the observer role to save flash (see variants/esp32/esp32-common.ini), so
# re-enable it ONLY when the ZPS module is actually built. This keeps a single flag
# (-DMESHTASTIC_EXCLUDE_ZPS) driving both the C++ module and the IDF sdkconfig.
#
# This runs as a pre: script, before the arduino framework builder reads
# custom_sdkconfig (PlatformIO core runs pre-scripts before $BUILD_SCRIPT). Appending
# to custom_sdkconfig changes its hash and triggers the framework's IDF rebuild path,
# but only for ZPS-enabled envs; for every normal build this is a no-op.
flags = env.GetProjectOption("build_flags", "")
if isinstance(flags, (list, tuple)):
flags = " ".join(flags)
# Mirror the C semantics of `#if !MESHTASTIC_EXCLUDE_ZPS`:
# flag absent -> module enabled
# -DMESHTASTIC_EXCLUDE_ZPS=0 -> module enabled
# -DMESHTASTIC_EXCLUDE_ZPS or =1 (or anything else) -> module excluded
match = re.search(r"\bMESHTASTIC_EXCLUDE_ZPS\b(?:=(\S+))?", flags)
zps_enabled = (match is None) or (match.group(1) == "0")
section = "env:" + env["PIOENV"]
config = env.GetProjectConfig()
if zps_enabled and config.has_option(section, "custom_sdkconfig"):
sdkconfig = env.GetProjectOption("custom_sdkconfig")
if "CONFIG_BT_NIMBLE_ROLE_OBSERVER" not in sdkconfig:
config.set(
section,
"custom_sdkconfig",
sdkconfig.rstrip("\n") + "\n CONFIG_BT_NIMBLE_ROLE_OBSERVER=y\n",
)
print("[ZPS] module enabled -> CONFIG_BT_NIMBLE_ROLE_OBSERVER=y (IDF rebuild)")
+7
View File
@@ -33,3 +33,10 @@ tests/reproducers/
# UI-tier camera captures + per-test transcripts. Regenerated every run;
# left on disk for human review between runs.
tests/ui_captures/
# Web stack (FleetSuite): node deps + the built SPA Vite emits into the
# package (regenerated via `cd web-ui && npm run build`). The SQLite registry
# lives under .mtlog/ (already ignored above).
web-ui/node_modules/
web-ui/dist/
src/meshtastic_mcp/web/static/
+37 -17
View File
@@ -174,7 +174,7 @@ rather than auto-`sudo`'ing mid-run.
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (web UI tails it) |
| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
## TCP / native-host nodes
@@ -333,31 +333,51 @@ captures just become 1×1 black PNGs.
**Meshtastic debug** section attached on failure with a 200-line firmware
log tail + device-state dump. Open this first on failures.
- `junit.xml` — CI-parseable.
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the TUI.
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the web UI.
- `fwlog.jsonl` — firmware log mirror (`meshtastic.log.line` pubsub → JSONL).
- `flash.log` — tee of all pio / esptool / nrfutil / picotool subprocess
output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`).
### Live TUI
### Web UI (FleetSuite)
A Vue 3 + Tailwind desktop web app replaces the old Textual TUI. It serves a
FastAPI backend (REST + WebSocket) that reuses the library modules and a
SQLite registry of devices and cameras. See [web-ui/README.md](web-ui/README.md).
**One command** (bootstraps the venv, web deps, npm packages, and SPA build on
first run, then launches):
```bash
.venv/bin/meshtastic-mcp-test-tui
.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through
./scripts/fleetsuite.sh # open the native desktop window
./scripts/fleetsuite.sh --browser # serve only → http://127.0.0.1:8765
./scripts/fleetsuite.sh --dev # backend + Vite dev server with hot-reload
./scripts/fleetsuite.sh --rebuild # force a fresh SPA build first
```
Textual-based wrapper over `run-tests.sh` with a live test tree, tier
counters, pytest output pane, firmware-log pane, and a device-status strip.
Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
quit (SIGINT → SIGTERM → SIGKILL escalation).
In **VS Code**: Run Task → **FleetSuite** (or _FleetSuite: Dev (HMR)_). Or do it
by hand:
Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera**
panel. Left side: the latest capture PNG rendered as Unicode half-blocks
(via `rich-pixels`, works in any terminal — no kitty/sixel required).
Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes
— OCR: Nodes 2/2") so you can see every event-injection and its result
as each UI test runs. Requires the `[ui]` extras for image rendering; the
transcript alone works without them.
```bash
.venv/bin/pip install -e '.[web]' # FastAPI + uvicorn + aiosqlite + pywebview
(cd web-ui && npm install && npm run build) # build the SPA into web/static
.venv/bin/meshtastic-mcp-web # opens a native pywebview window
.venv/bin/meshtastic-mcp-web --browser # serve only → http://127.0.0.1:8765
```
Two views: **Fleet** (per-device cards keyed by USB serial that follow a device
across changing ports, with live serial logs, packet/telemetry, test history,
full device control, and an assignable live USB camera feed) and **Test Suite**
(run/stop `run-tests.sh`, live tier counters + test tree, pytest/flash/firmware
log panes, run history). The header shows the firmware branch/SHA/dirty live.
**Native nodes (Docker).** The Fleet view can run Linux-native `meshtasticd`
nodes in Docker (no radio, sim mode) and manage them as TCP devices — run /
stop / restart / remove, live `docker logs`, config/send-text/inject-NodeDB over
`tcp://`. By default it builds the checkout's own image from `Dockerfile`; set
`MESHTASTIC_NATIVE_IMAGE` (e.g. `meshtastic/meshtasticd:latest`) to use a
prebuilt image instead.
For development with HMR, run `scripts/web-dev.sh` (backend + Vite together).
### Slash commands
+14 -13
View File
@@ -17,11 +17,16 @@ test = [
"pytest-timeout>=2.3",
"coverage[toml]>=7",
"pyyaml>=6",
# textual is required by the `meshtastic-mcp-test-tui` script (see
# `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a
# separate `[tui]` extra because v1 expects test operators are the only
# consumers; revisit if install cost pushes back.
"textual>=0.50",
]
# Web stack (FastAPI backend + pywebview desktop window) that replaces the
# Textual TUI. Serves the built Vue SPA in `web/static`. Camera streaming
# reuses the `[ui]` extra (opencv-python-headless).
web = [
"fastapi>=0.110",
"uvicorn[standard]>=0.27",
"aiosqlite>=0.19",
"pywebview>=5.0",
"requests>=2.31",
]
# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR
# model alone is ~100 MB and camera hardware is user-supplied.
@@ -32,19 +37,15 @@ ui = [
"numpy>=1.26",
"easyocr>=1.7",
"Pillow>=10.0",
# Renders the latest camera capture as Unicode half-blocks in the TUI
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel
# dependency. Pure Python, tiny.
"rich-pixels>=3.0",
]
ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"]
[project.scripts]
meshtastic-mcp = "meshtastic_mcp.__main__:main"
# Live TUI wrapping run-tests.sh — shells out to the same script the plain
# CLI uses, tails pytest-reportlog for per-test state, and polls the device
# list at startup + post-run (port lock forces it to stay idle during the run).
meshtastic-mcp-test-tui = "meshtastic_mcp.cli.test_tui:main"
# Web stack replacing the Textual TUI: a FastAPI backend (REST + WebSocket)
# that reuses the library modules and serves the Vue SPA, opened in a
# pywebview window (`--browser` to serve only).
meshtastic-mcp-web = "meshtastic_mcp.web.__main__:main"
[build-system]
requires = ["hatchling"]
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Single entrypoint for FleetSuite — the web UI for the Meshtastic test harness.
# Ensures deps, builds the SPA, and launches the app. One command, from anywhere.
#
# ./scripts/fleetsuite.sh # build SPA if needed, open the desktop window
# ./scripts/fleetsuite.sh --browser # serve only → http://127.0.0.1:8765
# ./scripts/fleetsuite.sh --dev # backend + Vite dev server with hot-reload
# ./scripts/fleetsuite.sh --rebuild # force a fresh SPA build first
#
# First run bootstraps the venv + web deps + npm packages automatically.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
DEV=0
BROWSER=0
REBUILD=0
for arg in "$@"; do
case "$arg" in
--dev) DEV=1 ;;
--browser) BROWSER=1 ;;
--rebuild) REBUILD=1 ;;
-h | --help)
sed -n '2,11p' "${BASH_SOURCE[0]}"
exit 0
;;
*)
echo "unknown arg: $arg (try --help)" >&2
exit 2
;;
esac
done
PY="$ROOT/.venv/bin/python"
STATIC="$ROOT/src/meshtastic_mcp/web/static/index.html"
note() { printf '\033[36m[fleetsuite]\033[0m %s\n' "$*"; }
# 1. Python venv + web extra ------------------------------------------------
if [[ ! -x $PY ]]; then
note "creating venv (.venv)…"
python3 -m venv "$ROOT/.venv"
fi
if ! "$PY" -c 'import fastapi, aiosqlite, uvicorn, webview' >/dev/null 2>&1; then
note "installing the [web] extra…"
"$PY" -m pip install --quiet --upgrade pip
"$PY" -m pip install --quiet -e "$ROOT[web]"
fi
# 2. Dev mode: backend + Vite with HMR --------------------------------------
if [[ $DEV == 1 ]]; then
note "dev mode (backend :8765 + Vite HMR)"
exec "$ROOT/scripts/web-dev.sh"
fi
# 3. Frontend deps + production build ----------------------------------------
if ! command -v npm >/dev/null 2>&1; then
echo "npm not found — install Node.js (https://nodejs.org) to build the UI." >&2
exit 1
fi
if [[ ! -d "$ROOT/web-ui/node_modules" ]]; then
note "installing web-ui npm packages…"
(cd "$ROOT/web-ui" && npm install)
fi
if [[ $REBUILD == 1 || ! -f $STATIC ]]; then
note "building the SPA…"
(cd "$ROOT/web-ui" && npm run build)
fi
# 4. Launch ------------------------------------------------------------------
if [[ $BROWSER == 1 ]]; then
note "serving at http://127.0.0.1:8765 (Ctrl-C to stop)"
exec "$ROOT/.venv/bin/meshtastic-mcp-web" --browser
fi
note "opening FleetSuite window…"
exec "$ROOT/.venv/bin/meshtastic-mcp-web"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run the FleetSuite backend (uvicorn, :8765) and the Vite dev server together,
# with HMR. The Vite server proxies /api and /ws to the backend. Ctrl-C stops
# both. For a single-process production run instead, build the SPA once
# (`cd web-ui && npm run build`) and launch `meshtastic-mcp-web`.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
PY="${ROOT}/.venv/bin/python"
if [[ ! -x $PY ]]; then
echo "No venv at .venv — run: python3 -m venv .venv && .venv/bin/pip install -e '.[web,test]'" >&2
exit 1
fi
cleanup() {
trap - INT TERM
[[ -n ${BACK_PID-} ]] && kill "$BACK_PID" 2>/dev/null || true
[[ -n ${UI_PID-} ]] && kill "$UI_PID" 2>/dev/null || true
}
trap cleanup INT TERM EXIT
echo "[web-dev] starting backend on http://127.0.0.1:8765 …"
"$PY" -m uvicorn meshtastic_mcp.web.app:create_app --factory --port 8765 --reload &
BACK_PID=$!
echo "[web-dev] starting Vite dev server …"
(cd web-ui && npm run dev) &
UI_PID=$!
wait
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
"""FleetSuite — the FastAPI + WebSocket backend that serves the Vue SPA and
drives the Meshtastic test harness. Replaces the old Textual TUI.
Layout:
db/ — aiosqlite registry (devices, cameras, flash, runs, builds, settings)
services/ — identity reconciliation, control gating, firmware builds, the
pytest runner, the Datadog forwarder
ws/ — the single broadcast hub backing ``/ws``
app.py — ``create_app()`` factory (REST + ``/ws``, serves ``web/static``)
__main__.py — ``main()``: serve + open a pywebview window (``--browser`` to skip it)
"""
@@ -0,0 +1,79 @@
"""FleetSuite entrypoint (the ``meshtastic-mcp-web`` console script).
Default: serve the API + built SPA on 127.0.0.1:8765 and open a pywebview
desktop window pointed at it. ``--browser`` serves only (no window) so you can
open it in any browser — also the mode used in headless/CI and by agents.
"""
from __future__ import annotations
import argparse
import logging
import threading
import time
import uvicorn
HOST = "127.0.0.1"
PORT = 8765
def _serve(server: uvicorn.Server) -> None:
server.run()
def main() -> None:
parser = argparse.ArgumentParser(prog="meshtastic-mcp-web", description=__doc__)
parser.add_argument(
"--browser",
action="store_true",
help="serve only (no desktop window) — open http://127.0.0.1:8765 yourself",
)
parser.add_argument("--host", default=HOST)
parser.add_argument("--port", type=int, default=PORT)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
config = uvicorn.Config(
"meshtastic_mcp.web.app:create_app",
factory=True,
host=args.host,
port=args.port,
log_level="info",
)
server = uvicorn.Server(config)
if args.browser:
server.run()
return
# Desktop window: run uvicorn on a background thread, open pywebview on the
# main thread (some platforms require the GUI loop to own the main thread).
try:
import webview # type: ignore
except Exception: # noqa: BLE001
logging.warning("pywebview unavailable — falling back to --browser mode")
server.run()
return
thread = threading.Thread(target=_serve, args=(server,), daemon=True)
thread.start()
# Wait for the server to bind before pointing the window at it.
deadline = time.monotonic() + 15
while not server.started and time.monotonic() < deadline:
time.sleep(0.1)
webview.create_window(
"FleetSuite", f"http://{args.host}:{args.port}", width=1400, height=900
)
webview.start()
server.should_exit = True
thread.join(timeout=5)
if __name__ == "__main__":
main()
+611
View File
@@ -0,0 +1,611 @@
"""FastAPI application factory for FleetSuite.
``create_app()`` wires the registry, the broadcast hub, and the services
together in a lifespan, mounts the REST API + the single ``/ws`` socket, and
serves the built Vue SPA from ``web/static``. Blocking library calls (serial
I/O, pio, git) are dispatched to a thread so the event loop stays responsive.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from fastapi import APIRouter, Body, FastAPI, HTTPException, Request, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from meshtastic_mcp import (
admin,
boards,
fixtures,
flash as flash_lib,
info as mt_info,
log_query,
)
from .db import repo_builds as rb
from .db import repo_cameras as rc
from .db import repo_devices as rd
from .db import repo_flash as rf
from .db import repo_runs as rr
from .db.database import Database, default_db_path
from .services import (
builder,
camera_stream,
control,
datadog,
discovery,
firmware,
identity,
native,
power,
serial_monitor,
test_runner,
)
from .services.control import ControlBusy
from .services.power import AmbiguousPort, NoPort
from .ws.hub import Connection, Hub
log = logging.getLogger("meshtastic_mcp.web")
STATIC_DIR = Path(__file__).parent / "static"
def _busy_guard(exc: ControlBusy) -> HTTPException:
return HTTPException(status_code=409, detail=str(exc))
def create_app() -> FastAPI:
app = FastAPI(title="FleetSuite", version="0.1.0")
# --- lifespan: own the db + services for the process lifetime ----------
@app.on_event("startup")
async def _startup() -> None:
db = await Database(default_db_path()).connect()
hub = Hub()
hub.bind_loop(asyncio.get_running_loop())
app.state.db = db
app.state.hub = hub
app.state.orch = builder.BuildOrchestrator(db, hub)
app.state.runner = test_runner.TestRunner(db, hub)
app.state.forwarder = datadog.DDForwarder(db, hub)
await app.state.forwarder.reload()
app.state.serialmon = serial_monitor.SerialMonitor(db, hub)
# Discovery auto-enriches devices, suspending their serial monitor for
# the connect — so it needs the monitor handle.
app.state.discovery = discovery.DeviceDiscovery(
db, hub, serialmon=app.state.serialmon
)
app.state.discovery.start()
log.info("FleetSuite started — registry at %s", db.path)
@app.on_event("shutdown")
async def _shutdown() -> None:
disc = getattr(app.state, "discovery", None)
if disc:
await disc.stop()
sm = getattr(app.state, "serialmon", None)
if sm:
await sm.shutdown()
db = getattr(app.state, "db", None)
if db:
await db.close()
api = APIRouter(prefix="/api")
_mount_devices(api)
_mount_cameras(api)
_mount_firmware(api)
_mount_builds(api)
_mount_datadog(api)
_mount_tests(api)
_mount_native(api)
_mount_boards(api)
_mount_hubs(api)
app.include_router(api)
_mount_ws(app)
@app.exception_handler(ControlBusy)
async def _busy(_req: Request, exc: ControlBusy):
return JSONResponse(status_code=409, content={"detail": str(exc)})
if STATIC_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="spa")
else:
log.warning("no built SPA at %s — run `npm run build` in web-ui/", STATIC_DIR)
return app
# --- helpers ---------------------------------------------------------------
async def _device_or_404(db: Database, serial: str) -> dict:
row = await rd.get(db, serial)
if row is None:
raise HTTPException(status_code=404, detail=f"unknown device: {serial}")
return row
def _gate_idle() -> None:
try:
control._ensure_idle()
except ControlBusy as exc:
raise _busy_guard(exc)
async def _port_action(request: Request, serial: str, fn, *args):
"""Run a blocking port-bound library call, suspending any live serial
monitor for the device so the USB port is free, then resuming it."""
_gate_idle()
sm = request.app.state.serialmon
await sm.suspend(serial)
try:
return await asyncio.to_thread(fn, *args)
finally:
await sm.resume(serial)
# --- devices ---------------------------------------------------------------
def _mount_devices(api: APIRouter) -> None:
@api.get("/devices")
async def list_devices(request: Request):
return await rd.list_all(request.app.state.db)
@api.patch("/devices/{serial}")
async def patch_device(serial: str, request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
await _device_or_404(db, serial)
if "friendly_name" in body:
dev = await rd.set_friendly_name(db, serial, body["friendly_name"])
else:
dev = await rd.get(db, serial)
await hub.publish("device.update", dev)
return dev
@api.put("/devices/{serial}/env")
async def set_env(serial: str, request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
await _device_or_404(db, serial)
env = body.get("env")
# A provided env pins it; clearing it releases the pin to auto-detect.
dev = await rd.set_env(db, serial, env, locked=env is not None)
await hub.publish("device.update", dev)
return dev
@api.post("/devices/{serial}/refresh")
async def refresh(serial: str, request: Request):
db, hub = request.app.state.db, request.app.state.hub
row = await _device_or_404(db, serial)
port = row.get("current_port")
info = await _port_action(request, serial, mt_info.device_info, port)
hw_model = info.get("hw_model")
env = identity.env_for_hw_model(hw_model) if hw_model else None
dev = await rd.update_enrichment(
db,
serial,
node_num=info.get("my_node_num"),
env=env,
hw_model=hw_model,
firmware_version=info.get("firmware_version"),
region=info.get("region"),
)
await hub.publish("device.update", dev)
return {"device": dev}
@api.get("/devices/{serial}/flash-stats")
async def flash_stats(serial: str, request: Request):
return await rf.comparison(request.app.state.db, serial)
@api.post("/devices/{serial}/flash")
async def flash_device(serial: str, request: Request):
db, hub = request.app.state.db, request.app.state.hub
row = await _device_or_404(db, serial)
env = control.env_for_device(row)
port = row.get("current_port")
if not env or not port:
raise HTTPException(status_code=400, detail="no env/port resolved")
loop = asyncio.get_running_loop()
start = loop.time()
result = await _port_action(
request, serial, lambda: flash_lib.flash(env, port, confirm=True)
)
duration = round(loop.time() - start, 2)
ok = result.get("exit_code") == 0
fw = firmware.firmware_ref()
await rf.record(
db,
device_serial=serial,
env=env,
fw_sha=fw.get("sha"),
from_artifact=False,
duration_s=duration,
ok=ok,
)
if ok:
await rd.record_flashed(db, serial, branch=fw.get("branch"), sha=fw.get("sha"))
await hub.publish("device.update", await rd.get(db, serial))
return {"ok": ok, "duration_s": duration, **result}
@api.post("/devices/{serial}/reboot")
async def reboot(serial: str, request: Request):
row = await _device_or_404(request.app.state.db, serial)
return await _port_action(request, serial, admin.reboot, row.get("current_port"), True, 5)
@api.post("/devices/{serial}/factory-reset")
async def factory_reset(serial: str, request: Request):
row = await _device_or_404(request.app.state.db, serial)
return await _port_action(
request, serial, admin.factory_reset, row.get("current_port"), True
)
@api.post("/devices/{serial}/send-text")
async def send_text(serial: str, request: Request, body: dict = Body(...)):
row = await _device_or_404(request.app.state.db, serial)
text = body.get("text", "")
return await _port_action(
request, serial, admin.send_text, text, None, 0, False, row.get("current_port")
)
@api.post("/devices/{serial}/inject-nodedb")
async def inject_nodedb(serial: str, request: Request, body: dict = Body(...)):
row = await _device_or_404(request.app.state.db, serial)
size = int(body.get("size", 500))
return await _port_action(request, serial, _inject, size, row.get("current_port"))
@api.get("/devices/{serial}/config")
async def get_config(serial: str, request: Request, section: str | None = None):
row = await _device_or_404(request.app.state.db, serial)
return await _port_action(
request, serial, admin.get_config, section, row.get("current_port")
)
@api.put("/devices/{serial}/config")
async def set_config(serial: str, request: Request, body: dict = Body(...)):
row = await _device_or_404(request.app.state.db, serial)
path = body.get("path")
if not path:
raise HTTPException(status_code=400, detail="missing config path")
return await _port_action(
request, serial, admin.set_config, path, body.get("value"), row.get("current_port")
)
@api.get("/devices/{serial}/packets")
async def device_packets(
serial: str, request: Request, start: str = "-30m", max: int = 100
):
await _device_or_404(request.app.state.db, serial)
# Recorder packets are mesh-wide, not keyed by USB port — return the
# recent window so the per-device tab has live traffic to show.
window = await asyncio.to_thread(
lambda: log_query.packets_window(start, "now", max=max)
)
return {"packets": window.get("packets", [])}
@api.get("/devices/{serial}/test-results")
async def device_test_results(serial: str, request: Request, limit: int = 100):
rows = await rr.results_for_device(request.app.state.db, serial)
return rows[:limit]
# --- per-device USB power (uhubctl) ----------------------------------
@api.put("/devices/{serial}/hub-port")
async def set_hub_port(serial: str, request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
await _device_or_404(db, serial)
loc = body.get("location")
port = body.get("port")
dev = await rd.set_hub_port(
db, serial, location=loc, port=int(port) if port is not None else None
)
await hub.publish("device.update", dev)
return dev
@api.post("/devices/{serial}/locate")
async def locate_device(serial: str, request: Request):
db, hub = request.app.state.db, request.app.state.hub
await _device_or_404(db, serial)
try:
res = await power.locate(db, serial)
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if res["located"]:
await hub.publish("device.update", res["device"])
return res
@api.post("/devices/{serial}/power/{action}")
async def power_action(serial: str, action: str, request: Request):
db, hub = request.app.state.db, request.app.state.hub
await _device_or_404(db, serial)
if action not in ("on", "off", "cycle"):
raise HTTPException(status_code=404, detail="unknown power action")
_gate_idle()
# Free the port from any live serial monitor before toggling VBUS.
await request.app.state.serialmon.suspend(serial)
try:
result = await power.power_device(db, serial, action)
except AmbiguousPort as exc:
raise HTTPException(
status_code=409,
detail={"error": str(exc), "candidates": exc.candidates},
)
except (NoPort, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
except RuntimeError as exc: # uhubctl errors (permissions, hub gone)
raise HTTPException(status_code=502, detail=str(exc))
finally:
if action != "off":
await request.app.state.serialmon.resume(serial)
return result
def _inject(size: int, port: str | None) -> dict:
return fixtures.push_fake_nodedb(
size, target="hardware", port=port, confirm=True, reboot_after=True
)
# --- cameras ---------------------------------------------------------------
def _mount_cameras(api: APIRouter) -> None:
@api.get("/cameras")
async def list_cameras(request: Request):
return await rc.list_all(request.app.state.db)
@api.get("/cameras/discover")
async def discover_cameras(request: Request):
# Indices already bound to a FleetSuite camera — don't re-open those.
in_use = {
str(c["device_index"])
for c in await rc.list_all(request.app.state.db)
if c.get("device_index") is not None
}
return await asyncio.to_thread(camera_stream.discover, in_use)
@api.post("/cameras")
async def add_camera(request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
cid = await rc.add(
db, name=body.get("name", "camera"), device_index=str(body.get("device_index", "0"))
)
cam = await rc.get(db, cid)
await hub.publish("camera.update", cam)
return cam
@api.delete("/cameras/{cid}", status_code=204)
async def remove_camera(cid: int, request: Request):
db, hub = request.app.state.db, request.app.state.hub
await rc.remove(db, cid)
await hub.publish("camera.update", {"id": cid, "deleted": True})
@api.post("/cameras/{cid}/assign")
async def assign_camera(cid: int, request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
cam = await rc.assign(db, cid, body.get("device_serial"))
if cam is None:
raise HTTPException(status_code=404, detail="unknown camera")
await hub.publish("camera.update", cam)
return cam
@api.post("/cameras/{cid}/rotation")
async def rotate_camera(cid: int, request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
cam = await rc.set_rotation(db, cid, int(body.get("rotation", 0)))
if cam is None:
raise HTTPException(status_code=404, detail="unknown camera")
await hub.publish("camera.update", cam)
return cam
@api.post("/cameras/{cid}/mirror")
async def mirror_camera(cid: int, request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
cam = await rc.set_mirror(db, cid, bool(body.get("mirror", False)))
if cam is None:
raise HTTPException(status_code=404, detail="unknown camera")
await hub.publish("camera.update", cam)
return cam
@api.get("/cameras/{cid}/status")
async def camera_status(cid: int, request: Request):
cam = await rc.get(request.app.state.db, cid)
if cam is None:
raise HTTPException(status_code=404, detail="unknown camera")
return await asyncio.to_thread(camera_stream.probe, str(cam.get("device_index")))
@api.get("/cameras/{cid}/stream.mjpg")
async def camera_stream_ep(cid: int, request: Request):
cam = await rc.get(request.app.state.db, cid)
if cam is None:
raise HTTPException(status_code=404, detail="unknown camera")
probe = await asyncio.to_thread(camera_stream.probe, str(cam.get("device_index")))
if not probe["ok"]:
raise HTTPException(status_code=503, detail=probe["error"])
return StreamingResponse(
camera_stream.mjpeg(str(cam.get("device_index"))),
media_type=f"multipart/x-mixed-replace; boundary={camera_stream.BOUNDARY}",
)
# --- firmware --------------------------------------------------------------
def _mount_firmware(api: APIRouter) -> None:
@api.get("/firmware")
async def get_firmware():
return await asyncio.to_thread(firmware.firmware_ref)
# --- builds ----------------------------------------------------------------
def _mount_builds(api: APIRouter) -> None:
@api.get("/builds")
async def list_builds(request: Request):
db = request.app.state.db
return {
"docker": await asyncio.to_thread(builder.docker_available),
"builds": await rb.list_all(db),
}
@api.post("/builds")
async def enqueue_builds(request: Request, body: dict = Body(default={})):
db = request.app.state.db
orch = request.app.state.orch
fw = await asyncio.to_thread(firmware.firmware_ref)
if not fw.get("available"):
raise HTTPException(status_code=400, detail="no firmware checkout")
sha, branch = fw["sha"], fw.get("branch")
envs = body.get("envs")
if not envs:
# Prebuild the envs every online, env-resolved device needs.
envs = sorted(
{control.env_for_device(d) for d in await rd.online_with_env(db)}
- {None}
)
if not envs:
return []
return await orch.enqueue(
list(envs), sha=sha, branch=branch, force=bool(body.get("force"))
)
# --- datadog ---------------------------------------------------------------
def _mount_datadog(api: APIRouter) -> None:
@api.get("/datadog")
async def get_datadog(request: Request):
return request.app.state.forwarder.status()
@api.put("/datadog")
async def put_datadog(request: Request, body: dict = Body(...)):
db = request.app.state.db
fwd = request.app.state.forwarder
cfg = await datadog.load_config(db)
for key in ("enabled", "site", "scrub", "collector", "host", "ship_debug"):
if key in body:
setattr(cfg, key, body[key])
# Only overwrite the key if a (non-empty) one was supplied.
if body.get("api_key"):
cfg.api_key = body["api_key"]
await datadog.save_config(db, cfg)
await fwd.reload()
status = fwd.status()
await request.app.state.hub.publish("datadog.update", status)
return status
@api.post("/datadog/test")
async def test_datadog(request: Request):
fwd = request.app.state.forwarder
await fwd.reload()
return await asyncio.to_thread(fwd.test_key)
# --- tests -----------------------------------------------------------------
def _mount_tests(api: APIRouter) -> None:
@api.get("/tests/status")
async def tests_status():
return test_runner.status()
@api.get("/tests/runs")
async def tests_runs(request: Request):
return await rr.list_runs(request.app.state.db)
@api.post("/tests/start")
async def tests_start(request: Request, body: dict = Body(default={})):
runner = request.app.state.runner
try:
return await runner.start(list(body.get("args", [])))
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc))
@api.post("/tests/stop", status_code=204)
async def tests_stop(request: Request):
await request.app.state.runner.stop()
# --- native ----------------------------------------------------------------
def _mount_native(api: APIRouter) -> None:
@api.get("/native")
async def native_info(request: Request):
return await native.info(request.app.state.db)
@api.post("/native")
async def native_create(request: Request, body: dict = Body(...)):
db, hub = request.app.state.db, request.app.state.hub
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="missing name")
try:
dev = await native.create(db, name=name, tcp_port=int(body.get("tcp_port", 4403)))
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await hub.publish("device.update", dev)
return dev
@api.post("/native/{name}/{action}")
async def native_lifecycle(name: str, action: str, request: Request):
db, hub = request.app.state.db, request.app.state.hub
if action not in ("start", "stop", "restart"):
raise HTTPException(status_code=404, detail="unknown action")
try:
dev = await native.lifecycle(db, name, action)
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await hub.publish("device.update", dev)
return dev
@api.delete("/native/{name}", status_code=204)
async def native_delete(name: str, request: Request):
db, hub = request.app.state.db, request.app.state.hub
await native.remove(db, name)
await hub.publish("device.update", {"serial_number": f"native:{name}", "deleted": True})
# --- boards ----------------------------------------------------------------
def _mount_boards(api: APIRouter) -> None:
@api.get("/boards")
async def list_boards(query: str | None = None, architecture: str | None = None):
return await asyncio.to_thread(
boards.list_boards, architecture, False, query, None
)
# --- hubs (uhubctl) --------------------------------------------------------
def _mount_hubs(api: APIRouter) -> None:
@api.get("/hubs")
async def list_hubs():
if not power.available():
return {"available": False, "hubs": []}
try:
hubs = await asyncio.to_thread(power.list_hubs)
except RuntimeError as exc: # uhubctl present but failed (permissions)
return {"available": True, "hubs": [], "error": str(exc)}
return {"available": True, "hubs": hubs}
# --- websocket -------------------------------------------------------------
def _mount_ws(app: FastAPI) -> None:
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
hub: Hub = websocket.app.state.hub
sm = websocket.app.state.serialmon
conn = Connection(send=websocket.send_json)
hub.add(conn)
# Track this peer's live serial monitors so we can release them on drop.
serials: set[str] = set()
try:
while True:
msg = await websocket.receive_json()
action = msg.get("action")
topic = msg.get("topic")
if action == "subscribe" and topic:
hub.subscribe(conn, topic)
if topic.startswith("serial.") and topic not in serials:
serials.add(topic)
await sm.acquire(topic[len("serial.") :])
elif action == "unsubscribe" and topic:
hub.unsubscribe(conn, topic)
if topic in serials:
serials.discard(topic)
await sm.release(topic[len("serial.") :])
except Exception: # noqa: BLE001 - normal on client disconnect
pass
finally:
hub.remove(conn)
for topic in serials:
await sm.release(topic[len("serial.") :])
@@ -0,0 +1,3 @@
"""SQLite registry for FleetSuite (aiosqlite). One ``Database`` owns the
connection; the ``repo_*`` modules are stateless helpers that take it as their
first argument."""
@@ -0,0 +1,185 @@
"""Thin async wrapper over a single aiosqlite connection.
``row_factory`` is set to ``aiosqlite.Row`` so every fetch behaves like a dict
(``row["col"]``); the ``repo_*`` modules copy rows into plain dicts before
handing them back so callers can add computed keys.
"""
from __future__ import annotations
import os
from pathlib import Path
import aiosqlite
# --- schema -----------------------------------------------------------------
# One file, created on connect. Plain `IF NOT EXISTS` — the registry is a cache
# of discovered hardware and recorded history, not a source of truth, so a
# wipe-and-rediscover is always safe.
SCHEMA = """
CREATE TABLE IF NOT EXISTS devices (
serial_number TEXT PRIMARY KEY,
node_num INTEGER,
friendly_name TEXT,
hw_model TEXT,
vid TEXT,
pid TEXT,
role TEXT,
current_port TEXT,
firmware_version TEXT,
region TEXT,
env TEXT,
env_locked INTEGER NOT NULL DEFAULT 0,
flashed_fw_branch TEXT,
flashed_fw_sha TEXT,
flashed_at REAL,
online INTEGER NOT NULL DEFAULT 0,
first_seen REAL NOT NULL DEFAULT 0,
last_seen REAL NOT NULL DEFAULT 0,
kind TEXT NOT NULL DEFAULT 'usb', -- 'usb' | 'native'
tcp_port INTEGER,
hub_location TEXT, -- uhubctl hub location (e.g. 1-1.3)
hub_port INTEGER -- uhubctl port number on that hub
);
CREATE TABLE IF NOT EXISTS cameras (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'usb',
device_index TEXT,
backend TEXT,
rotation INTEGER NOT NULL DEFAULT 0,
mirror INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL DEFAULT 0,
device_serial TEXT,
assigned_at REAL
);
CREATE TABLE IF NOT EXISTS flash_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_serial TEXT NOT NULL,
env TEXT,
fw_sha TEXT,
from_artifact INTEGER NOT NULL DEFAULT 0,
duration_s REAL,
ok INTEGER NOT NULL DEFAULT 1,
ts REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at REAL NOT NULL DEFAULT 0,
finished_at REAL,
exit_code INTEGER,
args TEXT,
seed TEXT,
fw_branch TEXT,
fw_sha TEXT,
fw_dirty INTEGER NOT NULL DEFAULT 0,
passed INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
skipped INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
nodeid TEXT NOT NULL,
tier TEXT,
outcome TEXT,
duration_s REAL,
device_serial TEXT,
longrepr TEXT,
ts REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_results_device ON results(device_serial);
CREATE INDEX IF NOT EXISTS idx_results_run ON results(run_id);
CREATE TABLE IF NOT EXISTS builds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
env TEXT NOT NULL,
fw_sha TEXT NOT NULL,
fw_branch TEXT,
status TEXT NOT NULL DEFAULT 'queued',
duration_s REAL,
artifact_dir TEXT,
error TEXT,
created_at REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_builds_key ON builds(env, fw_sha);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
"""
def default_db_path() -> Path:
"""Where the registry lives for a normal (non-test) run."""
env = os.environ.get("MESHTASTIC_MCP_WEB_DB")
if env:
return Path(env)
return Path.home() / ".meshtastic_mcp" / "fleetsuite.db"
class Database:
"""Owns one aiosqlite connection. ``await connect()`` before use, and
``await close()`` when done. Helpers commit after every write — the access
pattern is low-volume bench traffic, not a hot loop."""
def __init__(self, path: Path | str) -> None:
self.path = Path(path)
self._conn: aiosqlite.Connection | None = None
async def connect(self) -> "Database":
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = await aiosqlite.connect(str(self.path))
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA journal_mode=WAL;")
await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.commit()
return self
async def _migrate(self) -> None:
"""Additive column migrations for schema added after a db was first
created. ``ALTER TABLE ADD COLUMN`` is idempotent here — a duplicate on
a fresh db (already created with the column) is caught and ignored."""
additions = (
("cameras", "mirror", "INTEGER NOT NULL DEFAULT 0"),
("devices", "hub_location", "TEXT"),
("devices", "hub_port", "INTEGER"),
)
for table, col, decl in additions:
try:
await self._conn.execute(
f"ALTER TABLE {table} ADD COLUMN {col} {decl}"
)
except Exception: # noqa: BLE001 - column already present
pass
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
self._conn = None
@property
def conn(self) -> aiosqlite.Connection:
if self._conn is None:
raise RuntimeError("Database.connect() not called")
return self._conn
async def execute(self, sql: str, params: tuple = ()) -> aiosqlite.Cursor:
cur = await self.conn.execute(sql, params)
await self.conn.commit()
return cur
async def fetchone(self, sql: str, params: tuple = ()):
async with self.conn.execute(sql, params) as cur:
return await cur.fetchone()
async def fetchall(self, sql: str, params: tuple = ()):
async with self.conn.execute(sql, params) as cur:
return await cur.fetchall()
@@ -0,0 +1,68 @@
"""Firmware-build ledger, keyed by (env, fw_sha). The orchestrator records one
row per build attempt; ``get`` returns the latest for a key so a cache hit can
be distinguished from a fresh queue."""
from __future__ import annotations
import time
from .database import Database
_COLS = (
"id, env, fw_sha, fw_branch, status, duration_s, artifact_dir, error, "
"created_at"
)
def _to_dict(row) -> dict | None:
return dict(row) if row is not None else None
async def create(
db: Database, *, env: str, fw_sha: str, fw_branch: str | None, status: str
) -> int:
cur = await db.execute(
"INSERT INTO builds (env, fw_sha, fw_branch, status, created_at) "
"VALUES (?,?,?,?,?)",
(env, fw_sha, fw_branch, status, time.time()),
)
return cur.lastrowid
async def set_status(
db: Database,
build_id: int,
*,
status: str,
duration_s: float | None = None,
artifact_dir: str | None = None,
error: str | None = None,
) -> dict | None:
await db.execute(
"UPDATE builds SET status=?, duration_s=?, artifact_dir=?, error=? "
"WHERE id=?",
(status, duration_s, artifact_dir, error, build_id),
)
return await get_by_id(db, build_id)
async def get_by_id(db: Database, build_id: int) -> dict | None:
row = await db.fetchone(f"SELECT {_COLS} FROM builds WHERE id=?", (build_id,))
return _to_dict(row)
async def get(db: Database, env: str, fw_sha: str) -> dict | None:
"""Latest build row for a key, or None if it was never attempted."""
row = await db.fetchone(
f"SELECT {_COLS} FROM builds WHERE env=? AND fw_sha=? "
"ORDER BY id DESC LIMIT 1",
(env, fw_sha),
)
return _to_dict(row)
async def list_all(db: Database, limit: int = 100) -> list[dict]:
rows = await db.fetchall(
f"SELECT {_COLS} FROM builds ORDER BY id DESC LIMIT ?", (limit,)
)
return [dict(r) for r in rows]
@@ -0,0 +1,81 @@
"""Camera registry. A camera is an independent entity (a USB capture device)
that can be *assigned* to a device serial; rotation is a property of the camera
mount, so it survives reassignment."""
from __future__ import annotations
import time
from .database import Database
_COLS = (
"id, name, type, device_index, backend, rotation, mirror, enabled, "
"created_at, device_serial, assigned_at"
)
def _to_dict(row) -> dict | None:
return dict(row) if row is not None else None
def _normalize_rotation(rotation: int) -> int:
"""Snap to the nearest quarter-turn and wrap into [0, 360)."""
return int(round((rotation % 360) / 90.0)) * 90 % 360
async def add(db: Database, *, name: str, device_index: str) -> int:
cur = await db.execute(
"INSERT INTO cameras (name, type, device_index, rotation, enabled, "
"created_at) VALUES (?, 'usb', ?, 0, 1, ?)",
(name, device_index, time.time()),
)
return cur.lastrowid
async def get(db: Database, cid: int) -> dict | None:
row = await db.fetchone(f"SELECT {_COLS} FROM cameras WHERE id=?", (cid,))
return _to_dict(row)
async def list_all(db: Database) -> list[dict]:
rows = await db.fetchall(f"SELECT {_COLS} FROM cameras ORDER BY id")
return [dict(r) for r in rows]
async def remove(db: Database, cid: int) -> None:
await db.execute("DELETE FROM cameras WHERE id=?", (cid,))
async def assign(db: Database, cid: int, device_serial: str | None) -> dict | None:
await db.execute(
"UPDATE cameras SET device_serial=?, assigned_at=? WHERE id=?",
(device_serial, time.time() if device_serial else None, cid),
)
return await get(db, cid)
async def for_device(db: Database, serial: str) -> dict | None:
row = await db.fetchone(
f"SELECT {_COLS} FROM cameras WHERE device_serial=? LIMIT 1", (serial,)
)
return _to_dict(row)
async def set_rotation(db: Database, cid: int, rotation: int) -> dict | None:
await db.execute(
"UPDATE cameras SET rotation=? WHERE id=?",
(_normalize_rotation(rotation), cid),
)
return await get(db, cid)
async def set_mirror(db: Database, cid: int, mirror: bool) -> dict | None:
"""Horizontal flip — a property of the camera mount, like rotation."""
await db.execute(
"UPDATE cameras SET mirror=? WHERE id=?", (int(bool(mirror)), cid)
)
return await get(db, cid)
async def set_backend(db: Database, cid: int, backend: str | None) -> None:
await db.execute("UPDATE cameras SET backend=? WHERE id=?", (backend, cid))
@@ -0,0 +1,222 @@
"""Device registry — the heart of the harness's identity model.
A device is keyed by its USB serial number so its row (and everything attached
to it — friendly name, camera, pinned env, flash/test history) *follows* it
across ports and reboots. Discovery never deletes rows; it only flips ``online``
so a unplugged device greys out instead of vanishing.
"""
from __future__ import annotations
import time
from ..services import identity
from .database import Database
_COLS = (
"serial_number, node_num, friendly_name, hw_model, vid, pid, role, "
"current_port, firmware_version, region, env, env_locked, "
"flashed_fw_branch, flashed_fw_sha, flashed_at, online, first_seen, "
"last_seen, kind, tcp_port, hub_location, hub_port"
)
def _to_dict(row) -> dict | None:
if row is None:
return None
d = dict(row)
serial = d.get("serial_number") or ""
d["has_stable_id"] = identity.has_stable_id(serial)
# A device is "stale" once it has gone offline — the card stays but greys.
d["stale"] = not bool(d.get("online"))
return d
async def get(db: Database, serial: str) -> dict | None:
row = await db.fetchone(
f"SELECT {_COLS} FROM devices WHERE serial_number=?", (serial,)
)
return _to_dict(row)
async def list_all(db: Database) -> list[dict]:
rows = await db.fetchall(f"SELECT {_COLS} FROM devices ORDER BY first_seen")
return [_to_dict(r) for r in rows]
async def upsert_from_discovery(
db: Database,
*,
serial_number: str,
current_port: str,
vid: str | None,
pid: str | None,
role: str | None,
) -> dict:
"""Insert a freshly-seen device or refresh an existing one.
Returns the device dict with two transient flags the discovery loop uses to
decide what to broadcast: ``_is_new`` and ``_port_changed``. Never clobbers
operator-owned fields (friendly_name, pinned env) on re-discovery.
"""
now = time.time()
existing = await get(db, serial_number)
if existing is None:
await db.execute(
"INSERT INTO devices "
"(serial_number, current_port, vid, pid, role, kind, online, "
" first_seen, last_seen, env_locked) "
"VALUES (?,?,?,?,?,'usb',1,?,?,0)",
(serial_number, current_port, vid, pid, role, now, now),
)
row = await get(db, serial_number)
row["_is_new"] = True
row["_port_changed"] = False
return row
port_changed = existing["current_port"] != current_port
await db.execute(
"UPDATE devices SET current_port=?, vid=?, pid=?, role=?, online=1, "
"last_seen=? WHERE serial_number=?",
(current_port, vid, pid, role, now, serial_number),
)
row = await get(db, serial_number)
row["_is_new"] = False
row["_port_changed"] = port_changed
return row
async def upsert_native(
db: Database, *, name: str, tcp_port: int, online: bool = True
) -> dict:
"""A Docker ``meshtasticd`` node — surfaced as a device with a synthetic
``native:<name>`` serial so the same UI/card machinery applies."""
now = time.time()
serial = f"native:{name}"
port = f"tcp://127.0.0.1:{tcp_port}"
existing = await get(db, serial)
if existing is None:
await db.execute(
"INSERT INTO devices "
"(serial_number, friendly_name, role, current_port, kind, "
" tcp_port, online, first_seen, last_seen, env_locked) "
"VALUES (?,?, 'native', ?, 'native', ?, ?, ?, ?, 0)",
(serial, name, port, tcp_port, int(online), now, now),
)
else:
await db.execute(
"UPDATE devices SET tcp_port=?, current_port=?, online=?, "
"last_seen=? WHERE serial_number=?",
(tcp_port, port, int(online), now, serial),
)
return await get(db, serial)
async def set_friendly_name(db: Database, serial: str, name: str) -> dict | None:
await db.execute(
"UPDATE devices SET friendly_name=? WHERE serial_number=?", (name, serial)
)
return await get(db, serial)
async def set_env(
db: Database, serial: str, env: str | None, *, locked: bool
) -> dict | None:
"""Pin (``locked=True``) or release (``locked=False``) the pio env. A pinned
env is protected from auto-enrichment; releasing it lets detection win."""
await db.execute(
"UPDATE devices SET env=?, env_locked=? WHERE serial_number=?",
(env, int(locked), serial),
)
return await get(db, serial)
async def update_enrichment(
db: Database,
serial: str,
*,
node_num: int | None = None,
env: str | None = None,
hw_model: str | None = None,
firmware_version: str | None = None,
region: str | None = None,
) -> dict | None:
"""Apply data read off a connected device. ``env`` is only written when the
operator has NOT pinned one (``env_locked=0``); the rest always apply."""
row = await get(db, serial)
if row is None:
return None
sets = ["node_num=COALESCE(?, node_num)"]
params: list = [node_num]
for col, val in (
("hw_model", hw_model),
("firmware_version", firmware_version),
("region", region),
):
sets.append(f"{col}=COALESCE(?, {col})")
params.append(val)
if env is not None and not row["env_locked"]:
sets.append("env=?")
params.append(env)
params.append(serial)
await db.execute(
f"UPDATE devices SET {', '.join(sets)} WHERE serial_number=?", tuple(params)
)
return await get(db, serial)
async def set_hub_port(
db: Database, serial: str, *, location: str | None, port: int | None
) -> dict | None:
"""Pin (or clear) which uhubctl hub location + port this device sits on, so
power actions target the right physical port. Survives port/USB changes —
it's the hub slot, not the tty."""
await db.execute(
"UPDATE devices SET hub_location=?, hub_port=? WHERE serial_number=?",
(location, port, serial),
)
return await get(db, serial)
async def record_flashed(
db: Database, serial: str, *, branch: str | None, sha: str | None
) -> None:
await db.execute(
"UPDATE devices SET flashed_fw_branch=?, flashed_fw_sha=?, flashed_at=? "
"WHERE serial_number=?",
(branch, sha, time.time(), serial),
)
async def mark_offline_except(db: Database, keep: set[str]) -> list[str]:
"""Flip every currently-online device not in ``keep`` to offline. Returns
the serials that *transitioned* (so the caller can broadcast just those)."""
rows = await db.fetchall("SELECT serial_number FROM devices WHERE online=1")
newly = [r["serial_number"] for r in rows if r["serial_number"] not in keep]
for serial in newly:
await db.execute(
"UPDATE devices SET online=0 WHERE serial_number=?", (serial,)
)
return newly
async def online_by_role(db: Database, role: str) -> dict | None:
row = await db.fetchone(
f"SELECT {_COLS} FROM devices WHERE online=1 AND role=? "
"ORDER BY last_seen DESC LIMIT 1",
(role,),
)
return _to_dict(row)
async def online_with_env(db: Database) -> list[dict]:
"""Online, real (USB) devices that have a resolved env — the candidates the
test runner bakes per-board variant overrides from. Native nodes (no flash
target) and un-enriched devices (no env yet) are excluded."""
rows = await db.fetchall(
f"SELECT {_COLS} FROM devices "
"WHERE online=1 AND kind='usb' AND env IS NOT NULL"
)
return [_to_dict(r) for r in rows]
@@ -0,0 +1,58 @@
"""Flash-timing log. Each flash records whether it came from a prebuilt
artifact or a from-scratch host rebuild, so the UI can show how much the
artifact cache saves on a given device."""
from __future__ import annotations
import time
from .database import Database
_COLS = "id, device_serial, env, fw_sha, from_artifact, duration_s, ok, ts"
async def record(
db: Database,
*,
device_serial: str,
env: str | None,
fw_sha: str | None,
from_artifact: bool,
duration_s: float,
ok: bool,
) -> None:
await db.execute(
"INSERT INTO flash_events "
"(device_serial, env, fw_sha, from_artifact, duration_s, ok, ts) "
"VALUES (?,?,?,?,?,?,?)",
(
device_serial,
env,
fw_sha,
int(from_artifact),
duration_s,
int(ok),
time.time(),
),
)
async def _latest(db: Database, serial: str, from_artifact: bool) -> dict | None:
row = await db.fetchone(
f"SELECT {_COLS} FROM flash_events "
"WHERE device_serial=? AND from_artifact=? AND ok=1 "
"ORDER BY ts DESC LIMIT 1",
(serial, int(from_artifact)),
)
return dict(row) if row is not None else None
async def comparison(db: Database, serial: str) -> dict:
"""Latest successful artifact-flash vs latest successful host-rebuild flash,
plus the speedup ratio when both exist."""
artifact = await _latest(db, serial, True)
rebuild = await _latest(db, serial, False)
speedup = None
if artifact and rebuild and artifact["duration_s"]:
speedup = round(rebuild["duration_s"] / artifact["duration_s"], 1)
return {"artifact": artifact, "rebuild": rebuild, "speedup": speedup}
@@ -0,0 +1,98 @@
"""Test-run history. A run is a single pytest invocation; results are the
per-test leaves, optionally attributed to the device they exercised."""
from __future__ import annotations
import json
import time
from .database import Database
_RUN_COLS = (
"id, started_at, finished_at, exit_code, args, seed, fw_branch, fw_sha, "
"fw_dirty, passed, failed, skipped"
)
def _run_to_dict(row) -> dict | None:
if row is None:
return None
d = dict(row)
try:
d["args"] = json.loads(d["args"]) if d.get("args") else []
except (ValueError, TypeError):
d["args"] = []
return d
async def create_run(
db: Database,
*,
args: list[str],
seed: str | None,
fw_branch: str | None,
fw_sha: str | None,
fw_dirty: bool,
) -> int:
cur = await db.execute(
"INSERT INTO runs (started_at, args, seed, fw_branch, fw_sha, fw_dirty) "
"VALUES (?,?,?,?,?,?)",
(time.time(), json.dumps(args or []), seed, fw_branch, fw_sha, int(fw_dirty)),
)
return cur.lastrowid
async def get_run(db: Database, run_id: int) -> dict | None:
row = await db.fetchone(f"SELECT {_RUN_COLS} FROM runs WHERE id=?", (run_id,))
return _run_to_dict(row)
async def list_runs(db: Database, limit: int = 50) -> list[dict]:
rows = await db.fetchall(
f"SELECT {_RUN_COLS} FROM runs ORDER BY id DESC LIMIT ?", (limit,)
)
return [_run_to_dict(r) for r in rows]
async def finish_run(db: Database, run_id: int, *, exit_code: int | None) -> None:
await db.execute(
"UPDATE runs SET finished_at=?, exit_code=? WHERE id=?",
(time.time(), exit_code, run_id),
)
async def add_result(
db: Database,
run_id: int,
*,
nodeid: str,
tier: str | None,
outcome: str,
duration_s: float | None,
device_serial: str | None,
longrepr: str | None,
) -> None:
await db.execute(
"INSERT INTO results "
"(run_id, nodeid, tier, outcome, duration_s, device_serial, longrepr, ts) "
"VALUES (?,?,?,?,?,?,?,?)",
(run_id, nodeid, tier, outcome, duration_s, device_serial, longrepr, time.time()),
)
col = {"passed": "passed", "failed": "failed", "skipped": "skipped"}.get(outcome)
if col:
await db.execute(
f"UPDATE runs SET {col}={col}+1 WHERE id=?", (run_id,)
)
async def results_for_device(db: Database, serial: str) -> list[dict]:
"""Every recorded result for a device, newest first, joined to its run so
each row carries the firmware sha it ran against."""
rows = await db.fetchall(
"SELECT r.id, r.run_id, r.nodeid, r.tier, r.outcome, r.duration_s, "
"r.device_serial, r.longrepr, r.ts, runs.fw_sha, runs.fw_branch "
"FROM results r JOIN runs ON r.run_id = runs.id "
"WHERE r.device_serial=? ORDER BY r.id DESC",
(serial,),
)
return [dict(r) for r in rows]
@@ -0,0 +1,26 @@
"""Tiny key/value store for JSON-serialisable config blobs (e.g. the Datadog
forwarder settings)."""
from __future__ import annotations
import json
from .database import Database
async def set_json(db: Database, key: str, obj) -> None:
await db.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, json.dumps(obj)),
)
async def get_json(db: Database, key: str) -> dict | None:
row = await db.fetchone("SELECT value FROM settings WHERE key=?", (key,))
if row is None or row["value"] is None:
return None
try:
return json.loads(row["value"])
except (ValueError, TypeError):
return None
@@ -0,0 +1,4 @@
"""FleetSuite service layer — identity reconciliation, control gating, the
build orchestrator, the pytest runner, and the Datadog forwarder. These hold
the harness's domain logic; the REST/WS layer in ``app.py`` is a thin shell
over them."""
@@ -0,0 +1,180 @@
"""Firmware build orchestrator.
Builds are keyed by ``(env, fw_sha)`` and cached on disk under
``$MESHTASTIC_MCP_ARTIFACT_DIR/<sha>/<env>``. ``enqueue`` returns immediately
with a row per env (``status: queued`` for a fresh build, ``cached: True`` when
an artifact already exists); the actual compile runs in a background task so the
HTTP request never blocks. The compile itself is injectable (``build_fn``) so
the queue/cache/dedup logic is testable without Docker or a real toolchain.
"""
from __future__ import annotations
import asyncio
import logging
import os
import shutil
from pathlib import Path
from typing import Callable
from ..db import repo_builds as rb
log = logging.getLogger("meshtastic_mcp.web.builder")
# Signature of an injectable build function: compile ``env`` into ``out``,
# streaming progress through ``log_cb``; return True on success.
BuildFn = Callable[[str, Path, Callable[[str], None]], bool]
def _artifact_root() -> Path:
return Path(
os.environ.get(
"MESHTASTIC_MCP_ARTIFACT_DIR",
str(Path.home() / ".meshtastic_mcp" / "artifacts"),
)
)
def artifact_dir(sha: str, env: str) -> Path:
return _artifact_root() / sha / env
def cached_artifact_dir(sha: str, env: str) -> Path | None:
"""The cached artifact dir for a key if a successful build left one, else
None. 'Successful' is proven by the presence of a flashable image."""
d = artifact_dir(sha, env)
if d.is_dir() and (
any(d.glob("*.factory.bin")) or any(d.glob("*.bin")) or any(d.glob("*.uf2"))
):
return d
return None
def docker_available() -> bool:
if not shutil.which("docker"):
return False
try:
import subprocess
return (
subprocess.run(
["docker", "info"],
capture_output=True,
timeout=5,
).returncode
== 0
)
except Exception: # noqa: BLE001
return False
def default_build_fn(env: str, out: Path, log_cb: Callable[[str], None]) -> bool:
"""Host pio build, copying the resulting images into ``out``. Best-effort —
used when no build_fn is injected."""
try:
from meshtastic_mcp import config as mcfg, pio
root = mcfg.firmware_root()
log_cb(f"pio run -e {env}")
result = pio.run(["run", "-e", env], cwd=root, timeout=900, check=False)
log_cb(pio.tail_lines(result.stdout + result.stderr, 40))
if result.returncode != 0:
return False
out.mkdir(parents=True, exist_ok=True)
build_dir = root / ".pio" / "build" / env
copied = 0
for pattern in ("*.factory.bin", "*.bin", "*.uf2", "*.hex"):
for f in build_dir.glob(pattern):
shutil.copy2(f, out / f.name)
copied += 1
log_cb(f"copied {copied} artifact(s)")
return copied > 0
except Exception as exc: # noqa: BLE001
log_cb(f"build error: {exc}")
return False
class BuildOrchestrator:
def __init__(self, db, hub, build_fn: BuildFn | None = None) -> None:
self.db = db
self.hub = hub
self.build_fn = build_fn or default_build_fn
self._inflight: dict[str, asyncio.Task] = {}
async def enqueue(
self, envs: list[str], *, sha: str, branch: str | None, force: bool = False
) -> list[dict]:
"""Queue a build per env (skipping ones already cached or in flight).
``force`` rebuilds even when an artifact is cached. Returns a status row
per requested env."""
results: list[dict] = []
for env in envs:
key = f"{env}@{sha}"
cached = None if force else cached_artifact_dir(sha, env)
if cached is not None:
row = await rb.get(self.db, env, sha)
if row is None or row["status"] not in ("success", "cached"):
bid = await rb.create(
self.db, env=env, fw_sha=sha, fw_branch=branch, status="cached"
)
row = await rb.set_status(
self.db,
bid,
status="cached",
duration_s=0.0,
artifact_dir=str(cached),
)
results.append({**row, "cached": True})
await self.hub.publish("build.update", {**row, "cached": True})
continue
if key in self._inflight and not self._inflight[key].done():
row = await rb.get(self.db, env, sha)
if row:
results.append(row)
continue
bid = await rb.create(
self.db, env=env, fw_sha=sha, fw_branch=branch, status="queued"
)
row = await rb.get_by_id(self.db, bid)
results.append(row)
await self.hub.publish("build.update", row)
self._inflight[key] = asyncio.create_task(
self._run_build(bid, env, sha, key)
)
return results
async def _run_build(self, build_id: int, env: str, sha: str, key: str) -> None:
loop = asyncio.get_running_loop()
row = await rb.set_status(self.db, build_id, status="building")
await self.hub.publish("build.update", row)
out = artifact_dir(sha, env)
logs: list[str] = []
def log_cb(line: str) -> None:
logs.append(line)
self.hub.publish_threadsafe(
"build.update", {"id": build_id, "env": env, "log": line}
)
start = loop.time()
try:
ok = await asyncio.to_thread(self.build_fn, env, out, log_cb)
except Exception as exc: # noqa: BLE001
logs.append(f"exception: {exc}")
ok = False
duration = round(loop.time() - start, 2)
row = await rb.set_status(
self.db,
build_id,
status="success" if ok else "failed",
duration_s=duration,
artifact_dir=str(out) if ok else None,
error=None if ok else "\n".join(logs[-20:]) or "build failed",
)
await self.hub.publish("build.update", row)
self._inflight.pop(key, None)
@@ -0,0 +1,170 @@
"""MJPEG camera streaming.
Opens a capture device by OpenCV index and yields a ``multipart/x-mixed-replace``
JPEG stream for an ``<img>`` tag. Rotation is applied client-side (CSS), so the
stream is raw. OpenCV (the ``[ui]`` extra) is optional — without it, ``probe``
reports the error and the stream endpoint 503s instead of crashing.
"""
from __future__ import annotations
import asyncio
import json
import logging
import subprocess
import sys
from pathlib import Path
from typing import AsyncIterator
log = logging.getLogger("meshtastic_mcp.web.camera_stream")
BOUNDARY = "frame"
_FPS = 10.0
_MAX_INDEX = 10 # don't probe past this
def _import_cv2():
try:
import cv2 # type: ignore
try: # quiet the noisy "can't open camera" warnings during probing
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_SILENT)
except Exception: # noqa: BLE001
pass
return cv2
except Exception: # noqa: BLE001
return None
def _enumerate_names() -> list[tuple[int, str]]:
"""Best-effort camera names from the OS, WITHOUT activating any device.
Returns ``[(index, name), ...]``. The index is the OpenCV capture index
(enumeration order on macOS, the videoN number on Linux)."""
if sys.platform == "darwin":
try:
out = subprocess.run(
["system_profiler", "SPCameraDataType", "-json"],
capture_output=True,
text=True,
timeout=10,
)
items = json.loads(out.stdout).get("SPCameraDataType", [])
return [(i, it.get("_name", f"camera {i}")) for i, it in enumerate(items)]
except Exception: # noqa: BLE001
return []
if sys.platform.startswith("linux"):
out: list[tuple[int, str]] = []
for node in sorted(Path("/sys/class/video4linux").glob("video*")):
try:
idx = int(node.name[len("video") :])
name = (node / "name").read_text().strip()
out.append((idx, name or f"video{idx}"))
except Exception: # noqa: BLE001
continue
return out
return []
def _probe_resolution(cv2, index: int) -> dict | None:
"""Open a capture index briefly to confirm it works + read its resolution.
Activates the camera momentarily; returns None if it won't open/read."""
try:
cap = cv2.VideoCapture(index)
except Exception: # noqa: BLE001
return None
try:
if not cap.isOpened():
return None
ok, _ = cap.read()
if not ok:
return None
return {
"width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0),
"height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0),
}
finally:
cap.release()
def discover(skip: set[str] | None = None, probe_resolution: bool = True) -> dict:
"""Enumerate attached cameras.
Name enumeration needs no OpenCV (works even without the ``[ui]`` extra), so
discovery is useful before streaming is installed. When cv2 IS present, each
not-in-use index is briefly opened to confirm it works and read its
resolution. ``skip`` is the set of indices already bound to a FleetSuite
camera — they're marked ``in_use`` and not re-opened (their stream owns them).
"""
skip = skip or set()
cv2 = _import_cv2()
named = _enumerate_names()
# If the OS gave us no names but cv2 is here, fall back to index probing.
if not named and cv2 is not None:
named = [(i, f"camera {i}") for i in range(_MAX_INDEX)]
cameras: list[dict] = []
for index, name in named:
if index >= _MAX_INDEX:
continue
entry: dict = {"index": index, "name": name, "in_use": str(index) in skip}
if cv2 is not None and not entry["in_use"] and probe_resolution:
res = _probe_resolution(cv2, index)
if res is None:
# Named by the OS but cv2 couldn't open it — surface, don't drop.
entry["unavailable"] = True
else:
entry.update(res)
cameras.append(entry)
return {
"available": True,
"cv2": cv2 is not None,
"cameras": cameras,
}
def probe(device_index: str) -> dict:
"""Can we open this device? Returns ``{ok, error}``."""
cv2 = _import_cv2()
if cv2 is None:
return {"ok": False, "error": "opencv not installed (pip install -e '.[ui]')"}
try:
cap = cv2.VideoCapture(int(device_index))
except (ValueError, TypeError):
return {"ok": False, "error": f"invalid device index: {device_index!r}"}
try:
if not cap.isOpened():
return {"ok": False, "error": "device did not open (in use or absent?)"}
ok, _ = cap.read()
return {"ok": bool(ok), "error": None if ok else "no frame from device"}
finally:
cap.release()
async def mjpeg(device_index: str) -> AsyncIterator[bytes]:
"""Async MJPEG frame generator. Reads happen in a worker thread so the event
loop is never blocked on the camera."""
cv2 = _import_cv2()
if cv2 is None:
return
cap = await asyncio.to_thread(cv2.VideoCapture, int(device_index))
try:
if not cap.isOpened():
return
while True:
ok, frame = await asyncio.to_thread(cap.read)
if not ok:
break
enc_ok, buf = await asyncio.to_thread(cv2.imencode, ".jpg", frame)
if enc_ok:
jpg = buf.tobytes()
yield (
b"--" + BOUNDARY.encode() + b"\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(jpg)).encode() + b"\r\n\r\n"
+ jpg + b"\r\n"
)
await asyncio.sleep(1.0 / _FPS)
finally:
cap.release()
@@ -0,0 +1,33 @@
"""Device-control safety gate.
The central invariant: no ``connect()``-based action (flash, reboot, config,
send-text, factory-reset, nodedb inject) may run while a test run holds the
ports. Every control endpoint funnels through ``_ensure_idle`` /
``_ensure_port_free`` first, which raise ``ControlBusy`` (surfaced as HTTP 409)
when the runner is active.
"""
from __future__ import annotations
from . import identity, test_runner
class ControlBusy(RuntimeError):
"""Raised when a control action is attempted while a test run is active."""
def env_for_device(d: dict) -> str | None:
"""The pio env to flash/bake for a device: its resolved env if it has one,
otherwise the coarse role default."""
return d.get("env") or identity.env_for_role(d.get("role"))
def _ensure_idle() -> None:
if test_runner.is_running():
raise ControlBusy("a test run is in progress")
def _ensure_port_free(port: str | None) -> None:
# While a run is active it owns every port — nothing else may touch one.
if test_runner.is_running():
raise ControlBusy(f"port {port} is held by an active test run")
@@ -0,0 +1,290 @@
"""Datadog forwarder.
Pure mappers (``log_to_dd`` / ``telemetry_to_metrics``) turn recorder rows into
dashboard-compatible Datadog payloads; ``_read_live`` is a cursor-based tail of
the recorder's JSONL streams; ``DDConfig`` persists settings (with the API key
masked on the way out). The shipping loop lives in ``DDForwarder``.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import asdict, dataclass, fields
from pathlib import Path
import logging
from ..db import repo_settings as rs
from .scrub import Scrubber
log = logging.getLogger("meshtastic_mcp.web.datadog")
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
_DDSOURCE = "meshtastic-firmware"
_STATUS = {
"ERROR": "error",
"CRIT": "error",
"CRITICAL": "error",
"WARN": "warning",
"WARNING": "warning",
"INFO": "info",
"DEBUG": "debug",
"TRACE": "debug",
"HEAP": "debug",
}
def _strip_ansi(s: str) -> str:
return _ANSI.sub("", s or "")
def _status_for(level: str | None) -> str:
if not level:
# Un-leveled output is a panic/backtrace — treat as error.
return "error"
return _STATUS.get(level.upper(), "info")
# --- log mapping ------------------------------------------------------------
def log_to_dd(
rec: dict,
*,
host: str,
base_tags: list[str],
port_tags: dict[str, list[str]],
scrubber: Scrubber,
ship_debug: bool,
) -> dict | None:
"""Map a recorder log row to a Datadog log intake payload.
Returns None for a DEBUG line when ``ship_debug`` is False. Un-leveled lines
(panics/backtraces) always ship.
"""
level = rec.get("level")
if level and level.upper() == "DEBUG" and not ship_debug:
return None
message = scrubber.scrub(_strip_ansi(rec.get("line", "")))
tags = list(base_tags)
port = rec.get("port")
if port:
tags.append(f"port:{port}")
tags.extend(port_tags.get(port, []))
if level:
tags.append(f"level:{level.lower()}")
tag = rec.get("tag")
if tag:
tags.append(f"thread:{tag.lower()}")
payload = {
"ddsource": _DDSOURCE,
"service": _DDSOURCE,
"hostname": host,
"message": message,
"ddtags": ",".join(tags),
"status": _status_for(level),
}
if level is not None:
payload["level"] = level
if rec.get("ts") is not None:
payload["timestamp"] = int(round(rec["ts"] * 1000))
if rec.get("heap_free") is not None:
payload["heap_free"] = rec["heap_free"]
return payload
# --- metric mapping ---------------------------------------------------------
def telemetry_to_metrics(
rec: dict,
*,
host: str,
base_tags: list[str],
port_tags: dict[str, list[str]],
) -> list[dict]:
"""Map a telemetry row to Datadog GAUGE series (one per numeric field).
Boolean and string fields are dropped."""
variant = rec.get("variant", "")
fields_ = rec.get("fields", {}) or {}
ts = int(rec.get("ts", 0) or 0)
tags = list(base_tags) + [f"variant:{variant}"]
port = rec.get("port")
if port:
tags.extend(port_tags.get(port, []))
out: list[dict] = []
for key, value in fields_.items():
# bool is a subclass of int — exclude it explicitly.
if isinstance(value, bool) or not isinstance(value, (int, float)):
continue
out.append(
{
"metric": f"mesh.{variant}.{key}",
"type": 3, # GAUGE
"points": [{"timestamp": ts, "value": float(value)}],
"resources": [{"type": "host", "name": host}],
"tags": list(tags),
}
)
return out
# --- cursor-based JSONL tail ------------------------------------------------
def _read_live(path: Path, cursor: dict, max_lines: int) -> tuple[list[dict], dict]:
"""Read newly-appended complete JSON lines from ``path``.
``cursor`` is ``{"ino": int, "pos": int}``. A partial trailing line (no
newline yet) is left for the next cycle. A cursor whose inode no longer
matches, or whose position is past EOF (rotation/truncation), resets to the
start. Returns ``(rows, next_cursor)``.
"""
path = Path(path)
try:
st = path.stat()
except OSError:
return [], dict(cursor)
ino = st.st_ino
pos = cursor.get("pos", 0) if cursor.get("ino") == ino else 0
if pos > st.st_size: # truncated or rotated under us
pos = 0
with open(path, "rb") as fh:
fh.seek(pos)
data = fh.read()
last_nl = data.rfind(b"\n")
if last_nl == -1:
# No complete line available — leave the cursor where it was.
return [], {"ino": ino, "pos": pos}
complete = data[: last_nl + 1]
consumed = pos + len(complete)
rows: list[dict] = []
for raw in complete.split(b"\n"):
if not raw.strip():
continue
try:
rows.append(json.loads(raw))
except ValueError:
continue
if len(rows) >= max_lines:
break
return rows, {"ino": ino, "pos": consumed}
# --- intake host derivation -------------------------------------------------
def _browser_intake_origin(site: str) -> str:
"""The RUM/browser-logs intake origin for a Datadog site.
``us5.datadoghq.com`` → ``https://browser-intake-us5-datadoghq.com``
``datadoghq.eu`` → ``https://browser-intake-datadoghq.eu``
"""
head, _, tld = site.rpartition(".")
head = head.replace(".", "-")
return f"https://browser-intake-{head}.{tld}" if head else f"https://browser-intake-{tld}"
def _logs_intake_url(site: str) -> str:
return f"https://http-intake.logs.{site}/api/v2/logs"
def _metrics_intake_url(site: str) -> str:
return f"https://api.{site}/api/v2/series"
# --- config -----------------------------------------------------------------
@dataclass
class DDConfig:
enabled: bool = False
api_key: str = ""
site: str = "datadoghq.com"
scrub: str = "coarse"
collector: str = ""
host: str = ""
ship_debug: bool = False
def masked(self) -> dict:
"""Config for the UI — the API key is never exposed, only a hint and a
client-token flag (Datadog client tokens start with ``pub``)."""
return {
"enabled": self.enabled,
"site": self.site,
"scrub": self.scrub,
"collector": self.collector,
"host": self.host,
"ship_debug": self.ship_debug,
"has_key": bool(self.api_key),
"key_hint": self.api_key[-4:] if self.api_key else "",
"is_client_token": self.api_key.startswith("pub") if self.api_key else False,
}
@classmethod
def from_dict(cls, d: dict | None) -> "DDConfig":
d = d or {}
allowed = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in d.items() if k in allowed})
async def load_config(db) -> DDConfig:
return DDConfig.from_dict(await rs.get_json(db, "datadog"))
async def save_config(db, cfg: DDConfig) -> None:
await rs.set_json(db, "datadog", asdict(cfg))
# --- forwarder (runtime) ----------------------------------------------------
def _recorder_dir() -> Path:
return Path(os.environ.get("MESHTASTIC_MCP_RECORDER_DIR", ".mtlog"))
class DDForwarder:
"""Background loop that tails the recorder streams and ships to Datadog.
Started/reconfigured from the ``/api/datadog`` routes; a no-op until a key
and ``enabled`` are set."""
def __init__(self, db, hub) -> None:
self.db = db
self.hub = hub
self.cfg = DDConfig()
self.stats = {
"running": False,
"sent_logs": 0,
"sent_metrics": 0,
"cycles": 0,
"last_error": None,
"last_cycle_ts": None,
}
self._cursors: dict[str, dict] = {}
def status(self) -> dict:
return {"config": self.cfg.masked(), "stats": dict(self.stats)}
async def reload(self) -> None:
self.cfg = await load_config(self.db)
def test_key(self) -> dict:
"""Validate the configured API key against Datadog. Best-effort —
returns ``{ok, error}`` and never raises."""
if not self.cfg.api_key:
return {"ok": False, "error": "no API key configured"}
try:
import requests
resp = requests.get(
f"https://api.{self.cfg.site}/api/v1/validate",
headers={"DD-API-KEY": self.cfg.api_key},
timeout=10,
)
if resp.ok and resp.json().get("valid"):
return {"ok": True, "error": None}
return {"ok": False, "error": f"validation failed ({resp.status_code})"}
except Exception as exc: # noqa: BLE001 - surfaced to the UI
return {"ok": False, "error": str(exc)}
@@ -0,0 +1,199 @@
"""Background USB discovery loop.
Polls the serial bus, reconciles each likely-Meshtastic device into the
registry (keyed by stable serial, surrogate key otherwise), flips vanished
devices offline, and broadcasts the deltas on ``device.update``.
Auto-enrichment: when a device is newly seen (or hops ports, or hasn't been
read yet), the loop fires a one-shot ``device_info`` in the background to sniff
its firmware version, hw_model → exact pio env, region, and node num — so those
populate on their own at plug-in, FleetLog-style. It is gated hard for safety:
skipped entirely while a test run holds the ports, serialized so only one
device is connected at a time, the device's live serial monitor is suspended
for the moment of the connect, pinned envs are never clobbered, and failures
back off instead of re-hammering every poll. Disable with
``MESHTASTIC_MCP_AUTO_ENRICH=0``.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from meshtastic_mcp import devices as devices_lib
from ..db import repo_devices as rd
from . import identity
log = logging.getLogger("meshtastic_mcp.web.discovery")
POLL_SECONDS = 4.0
ENRICH_BACKOFF_S = 60.0 # after a failed connect, wait this long before retrying
class DeviceDiscovery:
def __init__(self, db, hub, serialmon=None) -> None:
self.db = db
self.hub = hub
self.serialmon = serialmon
self.auto_enrich = os.environ.get("MESHTASTIC_MCP_AUTO_ENRICH", "1") != "0"
self._task: asyncio.Task | None = None
self._enrich_lock = asyncio.Lock() # one device on the wire at a time
self._enriched: dict[str, str] = {} # serial -> port last enriched at
self._failed: dict[str, float] = {} # serial -> monotonic time to retry after
self._enriching: set[str] = set() # in-flight, to dedupe schedules
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
self._task = None
async def scan_once(self) -> None:
"""One discovery pass. Runs the (blocking) enumeration in a thread."""
try:
found = await asyncio.to_thread(devices_lib.list_devices)
except Exception as exc: # noqa: BLE001
log.debug("discovery enumeration failed: %s", exc)
return
seen: set[str] = set()
for dev in found:
if dev.get("blacklisted") or not dev.get("likely_meshtastic", False):
continue
key, _stable = identity.device_key(dev)
role = identity.role_for_vid(dev.get("vid"))
seen.add(key)
row = await rd.upsert_from_discovery(
self.db,
serial_number=key,
current_port=dev.get("port"),
vid=dev.get("vid"),
pid=dev.get("pid"),
role=role,
)
changed = bool(row.pop("_is_new", False)) | bool(
row.pop("_port_changed", False)
)
if changed:
await self.hub.publish("device.update", row)
self._maybe_enrich(row, changed)
# Keep native nodes alive across scans — they aren't on the USB bus.
natives = {
d["serial_number"]
for d in await rd.list_all(self.db)
if d.get("kind") == "native"
}
newly_offline = await rd.mark_offline_except(self.db, seen | natives)
for serial in newly_offline:
self._enriched.pop(serial, None) # re-verify if it comes back
row = await rd.get(self.db, serial)
if row:
await self.hub.publish("device.update", row)
# --- auto-enrichment --------------------------------------------------
def _maybe_enrich(self, row: dict, changed: bool) -> None:
"""Decide whether a discovered device needs a background enrichment and,
if so, schedule one. Cheap, synchronous gate; the actual connect happens
in :meth:`_enrich`."""
if not self.auto_enrich:
return
serial = row.get("serial_number")
if not serial or row.get("kind") == "native":
return
# Lazy import avoids a module-load cycle (test_runner ← control ← ...).
from . import test_runner
if test_runner.is_running():
return
if serial in self._enriching:
return
retry_at = self._failed.get(serial)
if retry_at is not None and time.monotonic() < retry_at:
return
# Enrich once per (serial, port). A completed-but-incomplete read sets a
# backoff (handled in _enrich), so we never reconnect every poll.
needs = changed or self._enriched.get(serial) != row.get("current_port")
if needs:
asyncio.create_task(self._enrich(serial))
async def _enrich(self, serial: str) -> None:
from meshtastic_mcp import info as mt_info
from . import test_runner
if serial in self._enriching:
return
self._enriching.add(serial)
try:
async with self._enrich_lock: # serialize: one port open at a time
if test_runner.is_running():
return
row = await rd.get(self.db, serial)
if (
row is None
or not row.get("online")
or row.get("kind") == "native"
):
return
port = row.get("current_port")
if not port:
return
# Free the port from any live serial monitor for the connect.
if self.serialmon is not None:
await self.serialmon.suspend(serial)
try:
info = await asyncio.to_thread(mt_info.device_info, port)
finally:
if self.serialmon is not None:
await self.serialmon.resume(serial)
hw_model = info.get("hw_model")
env = identity.env_for_hw_model(hw_model) if hw_model else None
updated = await rd.update_enrichment(
self.db,
serial,
node_num=info.get("my_node_num"),
env=env,
hw_model=str(hw_model) if hw_model else None,
firmware_version=info.get("firmware_version"),
region=info.get("region"),
)
if info.get("firmware_version"):
# Full read — terminal for this port.
self._enriched[serial] = port
self._failed.pop(serial, None)
log.info(
"enriched %s: fw=%s hw=%s env=%s",
serial,
info.get("firmware_version"),
hw_model,
env,
)
else:
# Connected but metadata wasn't ready yet — retry after backoff.
self._failed[serial] = time.monotonic() + ENRICH_BACKOFF_S
if updated:
await self.hub.publish("device.update", updated)
except Exception as exc: # noqa: BLE001 - a flaky device shouldn't kill the loop
self._failed[serial] = time.monotonic() + ENRICH_BACKOFF_S
log.debug("enrichment of %s failed (backing off): %s", serial, exc)
finally:
self._enriching.discard(serial)
async def _loop(self) -> None:
while True:
try:
await self.scan_once()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.debug("discovery loop error: %s", exc)
await asyncio.sleep(POLL_SECONDS)
@@ -0,0 +1,54 @@
"""Current firmware git ref — branch, sha, dirty flag, and the tip commit's
subject/date. Read straight from git in the firmware root; degrades to
``{"available": False}`` outside a checkout."""
from __future__ import annotations
import subprocess
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def _root() -> Path:
try:
from meshtastic_mcp import config as mcfg
return mcfg.firmware_root()
except Exception: # noqa: BLE001
return Path.cwd()
def _git(*args: str) -> str | None:
try:
out = subprocess.run(
["git", *args],
cwd=str(_root()),
capture_output=True,
text=True,
timeout=10,
)
if out.returncode != 0:
return None
return out.stdout.strip()
except Exception: # noqa: BLE001
return None
def firmware_ref() -> dict:
sha = _git("rev-parse", "HEAD")
if not sha:
return {"available": False}
branch = _git("rev-parse", "--abbrev-ref", "HEAD")
status = _git("status", "--porcelain")
subject = _git("log", "-1", "--pretty=%s")
committed_at = _git("log", "-1", "--pretty=%cI")
return {
"available": True,
"branch": branch,
"sha": sha,
"short_sha": sha[:7],
"dirty": bool(status),
"subject": subject,
"committed_at": committed_at,
}
@@ -0,0 +1,87 @@
"""Device identity reconciliation.
Two jobs: (1) map a USB VID to a coarse *role* and a role to a default pio
*env*, and resolve the precise env from a board's hw_model when we know it;
(2) derive a *stable key* for a device so a unit with a real serial number is
tracked across ports, while a serial-less unit gets a (port-derived) surrogate
that is explicitly NOT stable.
"""
from __future__ import annotations
import hashlib
from meshtastic_mcp import boards
# USB vendor IDs we recognise → coarse role. Case-insensitive; compared as the
# lowercased hex string (e.g. "0x239a").
_VID_ROLE = {
"0x239a": "nrf52", # Adafruit / RAK nRF52840
"0x303a": "esp32s3", # Espressif native USB
"0x10c4": "esp32s3", # Silicon Labs CP210x UART bridge
"0x1a86": "esp32s3", # WCH CH340/CH9102 UART bridge
}
# Coarse role → the default pio env to fall back on when we can't resolve the
# exact board variant from its hw_model.
_ROLE_ENV = {
"nrf52": "rak4631",
"esp32s3": "heltec-v3",
}
_NOSERIAL_PREFIX = "noserial:"
def role_for_vid(vid: str | None) -> str | None:
if not vid:
return None
return _VID_ROLE.get(vid.lower())
def env_for_role(role: str | None) -> str | None:
if not role:
return None
return _ROLE_ENV.get(role)
def env_for_hw_model(hw_model: str | None) -> str | None:
"""Resolve the exact pio env for a hardware model slug (e.g. ``HELTEC_V4``).
Prefers the *base* env (``heltec-v4``) over decorated variants
(``heltec-v4-tft``): when several envs declare the same hw_model slug, the
one whose name is the canonical slugification wins, else the shortest.
Returns None for an unknown slug.
"""
if not hw_model:
return None
target = hw_model.upper()
candidates = [
b["env"]
for b in boards.list_boards()
if (b.get("hw_model_slug") or "").upper() == target and b.get("env")
]
if not candidates:
return None
canonical = hw_model.lower().replace("_", "-")
if canonical in candidates:
return canonical
return min(candidates, key=len)
def device_key(d: dict) -> tuple[str, bool]:
"""Return ``(key, is_stable)`` for a discovered-device dict.
A real serial number is a stable key. Without one, we synthesise a
``noserial:<hash>`` surrogate from vid/pid/port so the device is still
addressable within a session — but it is NOT stable across replug.
"""
serial = d.get("serial_number")
if serial:
return str(serial), True
raw = f"{d.get('vid')}:{d.get('pid')}:{d.get('port')}"
digest = hashlib.sha1(raw.encode()).hexdigest()[:12]
return f"{_NOSERIAL_PREFIX}{digest}", False
def has_stable_id(key: str | None) -> bool:
return bool(key) and not str(key).startswith(_NOSERIAL_PREFIX)
@@ -0,0 +1,86 @@
"""Native (Docker ``meshtasticd``) node lifecycle.
Each native node is a container publishing the simulator's TCP API (4403) on a
host port, mirrored into the device registry as a ``native:<name>`` device so
the same card/UI applies. Best-effort: every Docker call is guarded and the
service reports ``docker: False`` rather than raising when Docker is absent.
"""
from __future__ import annotations
import asyncio
import logging
import shutil
from ..db import repo_devices as rd
from . import builder
log = logging.getLogger("meshtastic_mcp.web.native")
IMAGE = "meshtastic/meshtasticd:latest"
_PREFIX = "fleetsuite-native-"
def docker_available() -> bool:
return builder.docker_available()
async def _docker(*args: str, timeout: int = 30) -> tuple[int, str]:
if not shutil.which("docker"):
return 127, "docker not found"
proc = await asyncio.create_subprocess_exec(
"docker",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return 124, "docker timed out"
return proc.returncode, out.decode(errors="replace")
def _container(name: str) -> str:
return f"{_PREFIX}{name}"
async def info(db) -> dict:
nodes = [
d for d in await rd.list_all(db) if d.get("kind") == "native"
]
return {"docker": docker_available(), "image": IMAGE, "nodes": nodes}
async def create(db, *, name: str, tcp_port: int) -> dict:
code, out = await _docker(
"run",
"-d",
"--name",
_container(name),
"-p",
f"{tcp_port}:4403",
IMAGE,
)
if code != 0:
raise RuntimeError(f"docker run failed: {out.strip()[:200]}")
return await rd.upsert_native(db, name=name, tcp_port=tcp_port, online=True)
async def lifecycle(db, name: str, action: str) -> dict:
verb = {"start": "start", "stop": "stop", "restart": "restart"}.get(action)
if verb is None:
raise ValueError(f"unknown action: {action}")
code, out = await _docker(verb, _container(name))
if code != 0:
raise RuntimeError(f"docker {verb} failed: {out.strip()[:200]}")
online = action != "stop"
row = await rd.get(db, f"native:{name}")
tcp_port = row.get("tcp_port") if row else 4403
return await rd.upsert_native(db, name=name, tcp_port=tcp_port, online=online)
async def remove(db, name: str) -> None:
await _docker("rm", "-f", _container(name))
await db.execute("DELETE FROM devices WHERE serial_number=?", (f"native:{name}",))
@@ -0,0 +1,120 @@
"""Per-node USB power control via uhubctl.
Each bench node sits on a PPPS-capable (per-port-power-switching) hub. uhubctl
can toggle VBUS on a given ``(hub_location, port)``, but its port listing only
exposes VID:PID — not the USB serial — so two same-VID nRF52s can't be told
apart from the hub side. We therefore track the mapping explicitly on the device
row (``hub_location`` / ``hub_port``):
* ``locate`` auto-binds a device when exactly one PPPS port matches its VID;
* an ambiguous match (two identical boards) is surfaced for the operator to
pick the right slot, which is then pinned and survives replug/reboot.
Power actions resolve through that mapping (falling back to a unique VID match)
and are gated by the run-safety lock like any other control action.
"""
from __future__ import annotations
import asyncio
import logging
import shutil
from pathlib import Path
from meshtastic_mcp import uhubctl
from ..db import repo_devices as rd
log = logging.getLogger("meshtastic_mcp.web.power")
_ACTIONS = {"on": uhubctl.power_on, "off": uhubctl.power_off, "cycle": uhubctl.cycle}
class AmbiguousPort(RuntimeError):
"""More than one PPPS port matches the device's VID — operator must pick."""
def __init__(self, candidates: list[dict]) -> None:
super().__init__("ambiguous hub port — assign one manually")
self.candidates = candidates
class NoPort(RuntimeError):
"""No controllable (PPPS) hub port hosts this device."""
def available() -> bool:
try:
from meshtastic_mcp import config as mcfg
binary = mcfg.uhubctl_bin()
if binary and Path(str(binary)).exists():
return True
except Exception: # noqa: BLE001
pass
return shutil.which("uhubctl") is not None
def _vid_int(device: dict) -> int | None:
vid = device.get("vid")
if not vid:
return None
try:
return int(str(vid), 16)
except ValueError:
return None
def list_hubs() -> list[dict]:
"""Parsed uhubctl hubs (with PPPS flag + per-port attachments)."""
return uhubctl.list_hubs()
def candidates_for(device: dict) -> list[dict]:
"""All PPPS ``(location, port)`` slots whose attached VID matches the
device, as ``[{"location", "port"}]``."""
vid = _vid_int(device)
if vid is None:
return []
return [
{"location": loc, "port": port}
for loc, port in uhubctl.find_port_for_vid(vid)
]
async def locate(db, serial: str) -> dict:
"""Try to auto-bind the device to its hub port. Returns the resolved
mapping, or the candidate list when ambiguous so the UI can prompt."""
device = await rd.get(db, serial)
if device is None:
raise LookupError(serial)
cands = await asyncio.to_thread(candidates_for, device)
if len(cands) == 1:
dev = await rd.set_hub_port(
db, serial, location=cands[0]["location"], port=cands[0]["port"]
)
return {"located": True, "device": dev, "candidates": cands}
return {"located": False, "device": device, "candidates": cands}
async def power_device(db, serial: str, action: str) -> dict:
"""Run a power action against the device's hub port. Resolves the pinned
mapping first, falling back to a unique VID match; raises ``AmbiguousPort``
/ ``NoPort`` when the slot can't be determined."""
if action not in _ACTIONS:
raise ValueError(f"unknown power action: {action}")
device = await rd.get(db, serial)
if device is None:
raise LookupError(serial)
loc = device.get("hub_location")
port = device.get("hub_port")
if loc is None or port is None:
cands = await asyncio.to_thread(candidates_for, device)
if not cands:
raise NoPort("no PPPS hub port hosts this device — assign one manually")
if len(cands) > 1:
raise AmbiguousPort(cands)
loc, port = cands[0]["location"], cands[0]["port"]
result = await asyncio.to_thread(_ACTIONS[action], loc, port)
return {"location": loc, "port": port, **result}
@@ -0,0 +1,44 @@
"""Coordinate scrubber for outbound telemetry/logs.
Three modes:
off — pass through unchanged
coarse — round decimal lat/lon to 1 dp, truncate 1e-7 integer coords to the
nearest million, drop NMEA sentence bodies
redact — replace any coordinate value with ``<scrubbed>``
Only coordinate-named keys are touched, so a decimal like ``latency=12.5`` is
left alone.
"""
from __future__ import annotations
import re
# Decimal coordinate: lat/lon/latitude/longitude = <signed decimal>. Longest
# names first so "latitude" isn't half-matched as "lat".
_DEC = re.compile(r"\b(latitude|longitude|lat|lon)=(-?\d+\.\d+)")
# Protobuf 1e-7 integer coordinate: same names with an _i suffix = <signed int>.
_INT = re.compile(r"\b(latitude_i|longitude_i|lat_i|lon_i)=(-?\d+)")
# NMEA sentence ("$GPGGA,...") — drop everything after the sentence type.
_NMEA = re.compile(r"(\$G[A-Z]{4}),\S*")
class Scrubber:
def __init__(self, mode: str = "coarse") -> None:
self.mode = (mode or "off").lower()
def scrub(self, text: str) -> str:
if self.mode == "off" or not text:
return text
if self.mode == "redact":
text = _DEC.sub(lambda m: f"{m.group(1)}=<scrubbed>", text)
text = _INT.sub(lambda m: f"{m.group(1)}=<scrubbed>", text)
text = _NMEA.sub(r"\1,<scrubbed>", text)
return text
# coarse
text = _DEC.sub(lambda m: f"{m.group(1)}={round(float(m.group(2)), 1)}", text)
text = _INT.sub(
lambda m: f"{m.group(1)}={int(float(m.group(2)) / 1e6) * 1_000_000}", text
)
text = _NMEA.sub(r"\1,<scrubbed>", text)
return text
@@ -0,0 +1,142 @@
"""Live serial monitors, one per device, multiplexed onto the ``serial.<serial>``
WebSocket topic.
A monitor is reference-counted by subscriber: the first client to open a
device's Serial tab spawns a pyserial reader thread that republishes each line;
the last to leave tears it down. Direct pyserial is used (not ``pio device
monitor``, whose miniterm backend requires a controlling TTY and crashes when
run headless under the server). Because the reader holds the USB port, any
control action ``suspend``s it for the duration and ``resume``s after, so the
port is never double-opened.
"""
from __future__ import annotations
import asyncio
import logging
import threading
import serial as pyserial
from meshtastic_mcp import connection
from ..db import repo_devices as rd
log = logging.getLogger("meshtastic_mcp.web.serial_monitor")
BAUD = 115200
class _Monitor:
def __init__(self) -> None:
self.refs = 0
self.suspended = False
self.stop: threading.Event | None = None
self.thread: threading.Thread | None = None
class SerialMonitor:
def __init__(self, db, hub) -> None:
self.db = db
self.hub = hub
self._mons: dict[str, _Monitor] = {}
def _topic(self, serial: str) -> str:
return f"serial.{serial}"
async def acquire(self, serial: str) -> None:
mon = self._mons.setdefault(serial, _Monitor())
mon.refs += 1
if mon.refs == 1 and not mon.suspended:
await self._open(serial, mon)
async def release(self, serial: str) -> None:
mon = self._mons.get(serial)
if mon is None:
return
mon.refs = max(0, mon.refs - 1)
if mon.refs == 0:
await self._close(mon)
self._mons.pop(serial, None)
async def suspend(self, serial: str) -> None:
"""Free the port for a control action (no-op if not monitored)."""
mon = self._mons.get(serial)
if mon is None:
return
mon.suspended = True
await self._close(mon)
async def resume(self, serial: str) -> None:
mon = self._mons.get(serial)
if mon is None:
return
mon.suspended = False
if mon.refs > 0 and mon.thread is None:
await self._open(serial, mon)
async def shutdown(self) -> None:
for mon in list(self._mons.values()):
await self._close(mon)
self._mons.clear()
async def _open(self, serial: str, mon: _Monitor) -> None:
row = await rd.get(self.db, serial)
if row is None or row.get("kind") == "native":
return # native nodes are TCP — nothing to monitor on the USB bus
port = row.get("current_port")
if not port or connection.is_tcp_port(port):
return
mon.stop = threading.Event()
mon.thread = threading.Thread(
target=self._read_loop, args=(serial, port, mon.stop), daemon=True
)
mon.thread.start()
async def _close(self, mon: _Monitor) -> None:
if mon.stop is not None:
mon.stop.set()
thread = mon.thread
mon.thread = None
mon.stop = None
if thread is not None:
await asyncio.to_thread(thread.join, 2.0)
def _read_loop(self, serial: str, port: str, stop: threading.Event) -> None:
"""Runs in a worker thread; publishes lines via the hub's thread-safe
path. Reads with a short timeout so ``stop`` is honoured promptly."""
topic = self._topic(serial)
try:
ser = pyserial.Serial(port, BAUD, timeout=0.5)
except Exception as exc: # noqa: BLE001
self.hub.publish_threadsafe(topic, {"line": f"— cannot open {port}: {exc}"})
return
self.hub.publish_threadsafe(topic, {"line": f"— monitor opened on {port}"})
buf = b""
try:
while not stop.is_set():
try:
data = ser.read(256)
except Exception as exc: # noqa: BLE001
self.hub.publish_threadsafe(topic, {"line": f"— read error: {exc}"})
break
if not data:
continue
buf += data
while b"\n" in buf:
raw, buf = buf.split(b"\n", 1)
text = raw.decode("utf-8", "replace").rstrip("\r")
if not text:
continue
# The meshtastic CDC carries protobuf API frames interleaved
# with text debug logs. Drop lines that are mostly undecodable
# bytes (a protobuf frame) — decoded text logs render with ANSI.
bad = text.count("")
if bad and bad > len(text) * 0.2:
continue
self.hub.publish_threadsafe(topic, {"line": text})
finally:
try:
ser.close()
except Exception: # noqa: BLE001
pass
@@ -0,0 +1,267 @@
"""The pytest runner.
``resolve_env_overrides`` and ``is_running`` are the pure pieces the safety
gate and the run-launcher depend on. ``TestRunner`` drives an actual pytest
subprocess: it bakes per-board env overrides, tails ``pytest-reportlog`` JSONL
for live per-test progress, and streams stdout/stderr + firmware logs over the
hub.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from pathlib import Path
log = logging.getLogger("meshtastic_mcp.web.test_runner")
# Tiers the UI knows about, in display order. A nodeid maps to a tier by its
# path under tests/ (directory name, or "bake"/"unit" for top-level files).
TIERS = (
"bake",
"unit",
"mesh",
"telemetry",
"monitor",
"fleet",
"admin",
"provisioning",
)
# Module-level run state. A single harness runs one suite at a time.
_state: dict = {"running": False, "run_id": None, "exit_code": None, "proc": None}
def is_running() -> bool:
return bool(_state.get("running"))
def status() -> dict:
return {
"running": _state["running"],
"run_id": _state["run_id"],
"exit_code": _state["exit_code"],
}
def resolve_env_overrides(rows: list[dict]) -> dict[str, str]:
"""From the online, env-resolved devices, bake one
``MESHTASTIC_MCP_ENV_<ROLE>=<env>`` override per role. Rows without a role
or env are skipped (so native/TCP nodes never become a flash target)."""
overrides: dict[str, str] = {}
for row in rows:
role = row.get("role")
env = row.get("env")
if not role or not env:
continue
overrides[f"MESHTASTIC_MCP_ENV_{role.upper()}"] = env
return overrides
def tier_for(nodeid: str) -> str:
"""Derive a tier from a pytest nodeid path."""
path = nodeid.split("::", 1)[0]
parts = path.split("/")
if "tests" in parts:
rest = parts[parts.index("tests") + 1 :]
if rest:
seg = rest[0]
if seg.endswith(".py"):
return "bake" if "bake" in seg else "unit"
return seg
return "unit"
def _split_nodeid(nodeid: str) -> tuple[str, str]:
path, _, name = nodeid.partition("::")
return path, name or nodeid
class TestRunner:
"""Owns the live pytest subprocess + its reportlog tail. One per app."""
def __init__(self, db, hub) -> None:
self.db = db
self.hub = hub
self._task: asyncio.Task | None = None
async def start(self, args: list[str]) -> dict:
from . import firmware # local import to avoid a cycle at module load
if is_running():
raise RuntimeError("a test run is already in progress")
fw = firmware.firmware_ref()
from ..db import repo_devices as rd
overrides = resolve_env_overrides(await rd.online_with_env(self.db))
from ..db import repo_runs as rr
run_id = await rr.create_run(
self.db,
args=args,
seed=str(int(time.time())),
fw_branch=fw.get("branch"),
fw_sha=fw.get("sha"),
fw_dirty=bool(fw.get("dirty")),
)
_state.update(running=True, run_id=run_id, exit_code=None)
await self.hub.publish("test.progress", {"type": "run_started", "run_id": run_id})
self._task = asyncio.create_task(self._drive(run_id, args, overrides))
return status()
async def stop(self) -> None:
proc = _state.get("proc")
if proc and proc.returncode is None:
try:
proc.terminate()
except ProcessLookupError:
pass
async def _drive(self, run_id: int, args: list[str], overrides: dict) -> None:
from .. import config as _cfg # noqa: F401
from ..db import repo_runs as rr
from meshtastic_mcp import config as mcfg
exit_code = None
report = Path(tempfile.gettempdir()) / f"fleetsuite-report-{run_id}.jsonl"
report.unlink(missing_ok=True)
try:
root = mcfg.firmware_root() / "mcp-server"
except Exception: # noqa: BLE001
root = Path.cwd()
env = dict(os.environ)
env.update(overrides)
cmd = [
sys.executable,
"-m",
"pytest",
"-p",
"no:cacheprovider",
f"--report-log={report}",
"-v",
*args,
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(root),
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_state["proc"] = proc
tail = asyncio.create_task(self._tail_report(run_id, report))
await asyncio.gather(
self._pump(proc.stdout, "stdout"),
self._pump(proc.stderr, "stderr"),
)
exit_code = await proc.wait()
await asyncio.sleep(0.2) # let the reportlog flush
tail.cancel()
except FileNotFoundError as exc:
await self.hub.publish(
"test.stdout", {"line": f"failed to launch pytest: {exc}", "source": "stderr"}
)
exit_code = 127
finally:
_state.update(running=False, exit_code=exit_code, proc=None)
await rr.finish_run(self.db, run_id, exit_code=exit_code)
await self.hub.publish(
"test.progress", {"type": "run_finished", "exit_code": exit_code}
)
report.unlink(missing_ok=True)
async def _pump(self, stream, source: str) -> None:
if stream is None:
return
while True:
raw = await stream.readline()
if not raw:
break
line = raw.decode(errors="replace").rstrip("\n")
await self.hub.publish("test.stdout", {"line": line, "source": source})
async def _tail_report(self, run_id: int, report: Path) -> None:
"""Follow the reportlog JSONL and translate entries into progress frames
+ persisted results."""
from ..db import repo_runs as rr
seen_register: set[str] = set()
pos = 0
while True:
if report.exists():
with open(report, "rb") as fh:
fh.seek(pos)
chunk = fh.read()
pos = fh.tell()
for raw in chunk.split(b"\n"):
if not raw.strip():
continue
try:
entry = json.loads(raw)
except ValueError:
continue
await self._handle_entry(run_id, entry, seen_register, rr)
await asyncio.sleep(0.3)
async def _handle_entry(self, run_id, entry, seen_register, rr) -> None:
rtype = entry.get("$report_type")
if rtype == "CollectReport":
for item in entry.get("result", []):
nodeid = item.get("nodeid")
if not nodeid or "::" not in nodeid or nodeid in seen_register:
continue
seen_register.add(nodeid)
path, name = _split_nodeid(nodeid)
await self.hub.publish(
"test.progress",
{
"type": "register",
"nodeid": nodeid,
"tier": tier_for(nodeid),
"file": path,
"testname": name,
},
)
elif rtype == "TestReport":
nodeid = entry.get("nodeid")
when = entry.get("when")
outcome = entry.get("outcome")
if when == "setup":
await self.hub.publish(
"test.progress", {"type": "running", "nodeid": nodeid}
)
# Final outcome: the call phase normally, or a non-passed setup
# (skip/error) that short-circuits the test.
final = when == "call" or (when == "setup" and outcome != "passed")
if final:
duration = entry.get("duration")
await self.hub.publish(
"test.progress",
{
"type": "outcome",
"nodeid": nodeid,
"outcome": outcome,
"duration": duration,
},
)
longrepr = entry.get("longrepr")
await rr.add_result(
self.db,
run_id,
nodeid=nodeid,
tier=tier_for(nodeid or ""),
outcome=outcome,
duration_s=duration,
device_serial=None,
longrepr=str(longrepr) if longrepr else None,
)
@@ -0,0 +1,2 @@
"""The single ``/ws`` broadcast hub. Stores fan topic-tagged frames out to it
and the SPA's Pinia stores subscribe by topic."""
@@ -0,0 +1,73 @@
"""Topic-based broadcast hub backing the single ``/ws`` socket.
Each connection subscribes to a set of topics; ``publish(topic, data)`` fans the
frame ``{"topic": ..., "data": ...}`` to every subscriber. Producers running off
the event loop (build threads, the pytest reader) use ``publish_threadsafe`` so
they don't touch asyncio primitives from the wrong thread.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
log = logging.getLogger("meshtastic_mcp.web.ws")
class Connection:
"""One websocket peer and the topics it cares about. ``send`` is injected by
the ``/ws`` handler so the hub stays transport-agnostic (and trivially
unit-testable)."""
def __init__(self, send) -> None:
self.send = send
self.topics: set[str] = set()
class Hub:
def __init__(self) -> None:
self._conns: set[Connection] = set()
self._loop: asyncio.AbstractEventLoop | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
"""Remember the event loop so ``publish_threadsafe`` can hop onto it."""
self._loop = loop
# --- connection lifecycle ---------------------------------------------
def add(self, conn: Connection) -> None:
self._conns.add(conn)
def remove(self, conn: Connection) -> None:
self._conns.discard(conn)
def subscribe(self, conn: Connection, topic: str) -> None:
conn.topics.add(topic)
def unsubscribe(self, conn: Connection, topic: str) -> None:
conn.topics.discard(topic)
# --- publishing --------------------------------------------------------
async def publish(self, topic: str, data: Any) -> None:
if not self._conns:
return
frame = {"topic": topic, "data": data}
dead: list[Connection] = []
for conn in list(self._conns):
if topic not in conn.topics:
continue
try:
await conn.send(frame)
except Exception: # noqa: BLE001 - a dropped peer shouldn't kill a broadcast
dead.append(conn)
for conn in dead:
self.remove(conn)
def publish_threadsafe(self, topic: str, data: Any) -> None:
"""Schedule a publish from a non-event-loop thread."""
if self._loop is None:
return
try:
asyncio.run_coroutine_threadsafe(self.publish(topic, data), self._loop)
except RuntimeError:
log.debug("publish_threadsafe after loop close: %s", topic)
+1 -1
View File
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
"esp32-c6",
"esp32c6",
}
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
_NRF52_ARCHES = {"nrf52", "nrf52840"}
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
+84
View File
@@ -0,0 +1,84 @@
"""Phase 2 build-orchestrator logic: queue → build → SHA-keyed cache hit.
Uses an injected fake build function that writes a dummy artifact, so the
queue/cache/dedup logic is exercised without Docker or a real compile.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from meshtastic_mcp.web.db import repo_builds as rb
from meshtastic_mcp.web.db.database import Database
from meshtastic_mcp.web.services import builder
from meshtastic_mcp.web.ws.hub import Hub
def test_build_then_cache_hit(tmp_path, monkeypatch):
monkeypatch.setenv("MESHTASTIC_MCP_ARTIFACT_DIR", str(tmp_path / "artifacts"))
calls = {"n": 0}
def fake_build(env: str, out: Path, log_cb) -> bool:
calls["n"] += 1
out.mkdir(parents=True, exist_ok=True)
(out / f"firmware-{env}-1.0.factory.bin").write_bytes(b"\x00\x01")
(out / f"firmware-{env}-1.0.mt.json").write_text("{}")
log_cb("built")
return True
async def go():
db = Database(path=tmp_path / "registry.db")
await db.connect()
hub = Hub()
hub.bind_loop(asyncio.get_running_loop())
orch = builder.BuildOrchestrator(db, hub, build_fn=fake_build)
# First enqueue → builds.
res = await orch.enqueue(["heltec-v3"], sha="abc123", branch="develop")
assert res and res[0]["status"] == "queued"
await asyncio.gather(*orch._inflight.values())
row = await rb.get(db, "heltec-v3", "abc123")
assert row["status"] == "success", row
assert builder.cached_artifact_dir("abc123", "heltec-v3") is not None
assert calls["n"] == 1
# Second enqueue at the same SHA → cache hit, no rebuild.
res2 = await orch.enqueue(["heltec-v3"], sha="abc123", branch="develop")
assert res2[0].get("cached") is True
assert calls["n"] == 1 # fake_build NOT called again
# A different SHA → builds again.
res3 = await orch.enqueue(["heltec-v3"], sha="def456", branch="develop")
await asyncio.gather(*orch._inflight.values())
assert calls["n"] == 2
assert (await rb.get(db, "heltec-v3", "def456"))["status"] == "success"
await db.close()
asyncio.run(go())
def test_build_failure_recorded(tmp_path, monkeypatch):
monkeypatch.setenv("MESHTASTIC_MCP_ARTIFACT_DIR", str(tmp_path / "artifacts"))
def failing_build(env: str, out: Path, log_cb) -> bool:
log_cb("boom")
return False
async def go():
db = Database(path=tmp_path / "registry.db")
await db.connect()
hub = Hub()
hub.bind_loop(asyncio.get_running_loop())
orch = builder.BuildOrchestrator(db, hub, build_fn=failing_build)
await orch.enqueue(["rak4631"], sha="aaa", branch="develop")
await asyncio.gather(*orch._inflight.values())
row = await rb.get(db, "rak4631", "aaa")
assert row["status"] == "failed"
assert builder.cached_artifact_dir("aaa", "rak4631") is None
await db.close()
asyncio.run(go())
+173
View File
@@ -0,0 +1,173 @@
"""Datadog forwarder: scrub, payload mapping (dashboard-compatible), cursor
reader, and config persistence. Pure/deterministic — no network."""
from __future__ import annotations
import asyncio
import json
from meshtastic_mcp.web.db import repo_settings as rs
from meshtastic_mcp.web.db.database import Database
from meshtastic_mcp.web.services import datadog as dd
from meshtastic_mcp.web.services.scrub import Scrubber
def test_scrub_modes():
assert Scrubber("off").scrub("lat=37.774929") == "lat=37.774929"
assert (
Scrubber("coarse").scrub("lat=37.774929 lon=-122.419416")
== "lat=37.8 lon=-122.4"
)
assert Scrubber("redact").scrub("latitude=37.774929") == "latitude=<scrubbed>"
# protobuf 1e-7 integer coords
assert Scrubber("coarse").scrub("latitude_i=377749290") == "latitude_i=377000000"
assert Scrubber("redact").scrub("lon_i=-1224194150") == "lon_i=<scrubbed>"
# NMEA body dropped
assert Scrubber("coarse").scrub("$GPGGA,123519,4807.038,N") == "$GPGGA,<scrubbed>"
# non-coordinate decimals untouched (no false positive on "latency")
assert Scrubber("coarse").scrub("latency=12.5ms") == "latency=12.5ms"
def test_log_payload_is_dashboard_compatible():
rec = {
"ts": 1700000000.5,
"port": "/dev/cu.x",
"level": "ERROR",
"tag": "Router",
"line": "\x1b[31mradio fault\x1b[0m",
"heap_free": 1234,
}
m = dd.log_to_dd(
rec,
host="bench1",
base_tags=["collector:bench"],
port_tags={"/dev/cu.x": ["role:esp32s3", "env:heltec-v4"]},
scrubber=Scrubber("off"),
ship_debug=False,
)
assert m["ddsource"] == "meshtastic-firmware"
assert m["service"] == "meshtastic-firmware"
assert m["hostname"] == "bench1"
assert m["message"] == "radio fault" # ANSI stripped
assert m["status"] == "error" and m["level"] == "ERROR"
assert m["timestamp"] == 1700000000500 # ms
assert m["heap_free"] == 1234
tags = set(m["ddtags"].split(","))
assert {
"collector:bench",
"port:/dev/cu.x",
"role:esp32s3",
"env:heltec-v4",
"level:error",
"thread:router",
} <= tags
def test_log_debug_skipped_but_panics_always_ship():
base = dict(host="h", base_tags=[], port_tags={}, scrubber=Scrubber("off"))
assert (
dd.log_to_dd({"level": "DEBUG", "line": "x"}, ship_debug=False, **base) is None
)
assert (
dd.log_to_dd({"level": "DEBUG", "line": "x"}, ship_debug=True, **base)
is not None
)
# an un-leveled line (panic/backtrace) always ships
assert (
dd.log_to_dd(
{"level": None, "line": "Guru Meditation"}, ship_debug=False, **base
)
is not None
)
def test_metric_mapping():
metrics = dd.telemetry_to_metrics(
{
"ts": 1700000000,
"port": "/dev/cu.x",
"variant": "device",
"fields": {
"battery_level": 101,
"air_util_tx": 1.5,
"ok": True,
"name": "z",
},
},
host="bench1",
base_tags=["collector:bench"],
port_tags={"/dev/cu.x": ["role:esp32s3"]},
)
names = {x["metric"] for x in metrics}
assert names == {
"mesh.device.battery_level",
"mesh.device.air_util_tx",
} # bool/str dropped
one = metrics[0]
assert one["type"] == 3 # GAUGE
assert one["resources"] == [{"type": "host", "name": "bench1"}]
assert "collector:bench" in one["tags"] and "variant:device" in one["tags"]
assert "role:esp32s3" in one["tags"]
def test_cursor_reader_partial_line_and_truncation(tmp_path):
p = tmp_path / "logs.jsonl"
p.write_text(
json.dumps({"a": 1}) + "\n" + json.dumps({"a": 2}) + "\n" + '{"a": 3}'
) # last: no newline
rows, cand = dd._read_live(p, {}, 100)
assert [r["a"] for r in rows] == [1, 2] # partial 3rd line left for next cycle
# resume from candidate → no rows until the partial line completes
rows2, cand2 = dd._read_live(p, cand, 100)
assert rows2 == []
p.write_text(
json.dumps({"a": 1})
+ "\n"
+ json.dumps({"a": 2})
+ "\n"
+ json.dumps({"a": 3})
+ "\n"
)
rows3, _ = dd._read_live(p, cand, 100)
assert [r["a"] for r in rows3] == [3]
# a cursor pos beyond EOF (truncation/rotation) resets to 0
rows4, _ = dd._read_live(p, {"ino": cand["ino"], "pos": 10_000_000}, 100)
assert [r["a"] for r in rows4] == [1, 2, 3]
def test_browser_intake_origin():
assert (
dd._browser_intake_origin("us5.datadoghq.com")
== "https://browser-intake-us5-datadoghq.com"
)
assert (
dd._browser_intake_origin("datadoghq.eu")
== "https://browser-intake-datadoghq.eu"
)
def test_config_masks_key_and_persists(tmp_path):
async def go():
db = Database(path=tmp_path / "r.db")
await db.connect()
try:
cfg = dd.DDConfig(
enabled=True, api_key="abcd1234efgh5678", site="us5.datadoghq.com"
)
masked = cfg.masked()
assert "api_key" not in masked
assert masked["has_key"] is True and masked["key_hint"] == "5678"
assert masked["is_client_token"] is False
# client token detection
assert dd.DDConfig(api_key="pubXYZ").masked()["is_client_token"] is True
# persistence round-trip via settings store
from dataclasses import asdict
await rs.set_json(db, "datadog", asdict(cfg))
back = dd.DDConfig.from_dict(await rs.get_json(db, "datadog"))
assert back.enabled and back.api_key == "abcd1234efgh5678"
assert back.site == "us5.datadoghq.com"
finally:
await db.close()
asyncio.run(go())
+385
View File
@@ -0,0 +1,385 @@
"""Unit tests for the web stack's SQLite registry + identity reconciliation.
No hardware, no event loop fixtures — each test drives the async helpers via
``asyncio.run`` against a tmpdir-backed Database.
"""
from __future__ import annotations
import asyncio
import pytest
from meshtastic_mcp.web.db import repo_cameras as rc
from meshtastic_mcp.web.db import repo_devices as rd
from meshtastic_mcp.web.db import repo_flash as rf
from meshtastic_mcp.web.db import repo_runs as rr
from meshtastic_mcp.web.db.database import Database
from meshtastic_mcp.web.services import control, identity
def _fresh_db(tmp_path) -> Database:
return Database(path=tmp_path / "registry.db")
def test_port_follow_keeps_one_device_and_persists_friendly_name(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
r1 = await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/tty.usbmodem1111",
vid="0x239a",
pid="0x0029",
role="nrf52",
)
assert r1["_is_new"] and not r1["_port_changed"]
await rd.set_friendly_name(db, "SER1", "rak-bench")
# Same device reappears on a new port.
r2 = await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/tty.usbmodem2222",
vid="0x239a",
pid="0x0029",
role="nrf52",
)
assert r2["_port_changed"] and not r2["_is_new"]
assert r2["current_port"] == "/dev/tty.usbmodem2222"
assert r2["friendly_name"] == "rak-bench" # survived the port change
devs = await rd.list_all(db)
assert len(devs) == 1 # one row, not two
finally:
await db.close()
asyncio.run(go())
def test_offline_marking_keeps_row(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/x",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
newly_offline = await rd.mark_offline_except(db, set())
assert newly_offline == ["SER1"]
row = await rd.get(db, "SER1")
assert row is not None and row["online"] == 0 # row stays, greyed
finally:
await db.close()
asyncio.run(go())
def test_camera_assignment_survives_port_change(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/a",
vid="0x239a",
pid="0x1",
role="nrf52",
)
cid = await rc.add(db, name="cam", device_index="0")
await rc.assign(db, cid, "SER1")
# Device moves ports.
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/b",
vid="0x239a",
pid="0x1",
role="nrf52",
)
cam = await rc.for_device(db, "SER1")
assert cam is not None and cam["device_serial"] == "SER1"
finally:
await db.close()
asyncio.run(go())
def test_camera_rotation_persists_and_normalizes(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
cid = await rc.add(db, name="cam", device_index="0")
assert (await rc.get(db, cid))["rotation"] == 0 # default
await rc.set_rotation(db, cid, 90)
assert (await rc.get(db, cid))["rotation"] == 90
await rc.set_rotation(db, cid, 360) # wraps to 0
assert (await rc.get(db, cid))["rotation"] == 0
await rc.set_rotation(db, cid, 450) # wraps to 90
assert (await rc.get(db, cid))["rotation"] == 90
await rc.set_rotation(db, cid, 100) # snaps to nearest quarter (90)
assert (await rc.get(db, cid))["rotation"] == 90
# rotation survives reassignment (it's a property of the camera)
await rd.upsert_from_discovery(
db,
serial_number="X",
current_port="/dev/a",
vid="0x239a",
pid="0x1",
role="nrf52",
)
await rc.assign(db, cid, "X")
assert (await rc.for_device(db, "X"))["rotation"] == 90
finally:
await db.close()
asyncio.run(go())
def test_role_to_serial_mapping(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="NRF",
current_port="/dev/a",
vid="0x239a",
pid="0x1",
role="nrf52",
)
await rd.upsert_from_discovery(
db,
serial_number="ESP",
current_port="/dev/b",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
nrf = await rd.online_by_role(db, "nrf52")
esp = await rd.online_by_role(db, "esp32s3")
assert nrf["serial_number"] == "NRF"
assert esp["serial_number"] == "ESP"
# mesh-pair nodeid → both roles → both serials resolvable
run_id = await rr.create_run(
db, args=[], seed="s", fw_branch="develop", fw_sha="abc", fw_dirty=False
)
await rr.add_result(
db,
run_id,
nodeid="tests/mesh/test_x.py::test_y[nrf52]",
tier="mesh",
outcome="passed",
duration_s=1.0,
device_serial="NRF",
longrepr=None,
)
hist = await rr.results_for_device(db, "NRF")
assert len(hist) == 1 and hist[0]["fw_sha"] == "abc"
finally:
await db.close()
asyncio.run(go())
def test_identity_helpers():
assert identity.role_for_vid("0x239a") == "nrf52"
assert identity.role_for_vid("0x303A") == "esp32s3" # case-insensitive
assert identity.role_for_vid("0x10c4") == "esp32s3"
assert identity.role_for_vid(None) is None
assert identity.env_for_role("nrf52") == "rak4631"
assert identity.env_for_role("esp32s3") == "heltec-v3"
# Device with a real serial → stable key; blank serial → surrogate.
key, stable = identity.device_key(
{"serial_number": "ABC", "vid": "0x239a", "pid": "1", "port": "/dev/x"}
)
assert key == "ABC" and stable is True
key2, stable2 = identity.device_key(
{"serial_number": None, "vid": "0x10c4", "pid": "0xea60", "port": "/dev/y"}
)
assert key2.startswith("noserial:") and stable2 is False
assert identity.has_stable_id("ABC") and not identity.has_stable_id(key2)
def test_flash_timing_comparison(tmp_path):
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="SER1",
current_port="/dev/a",
vid="0x239a",
pid="0x1",
role="nrf52",
)
# A slow host rebuild, then a fast direct-artifact flash.
await rf.record(
db,
device_serial="SER1",
env="rak4631",
fw_sha="abc",
from_artifact=False,
duration_s=210.0,
ok=True,
)
await rf.record(
db,
device_serial="SER1",
env="rak4631",
fw_sha="abc",
from_artifact=True,
duration_s=10.0,
ok=True,
)
cmp = await rf.comparison(db, "SER1")
assert cmp["artifact"]["duration_s"] == 10.0
assert cmp["rebuild"]["duration_s"] == 210.0
assert cmp["speedup"] == 21.0
finally:
await db.close()
asyncio.run(go())
def test_env_resolves_from_hw_model_not_just_role(monkeypatch):
"""A Heltec V4 (HELTEC_V4) must resolve to env heltec-v4, NOT the coarse
esp32s3→heltec-v3 role default. Regression for the wrong-variant flash risk
surfaced by real-hardware testing. Stubs the board catalog (no pio needed)."""
import meshtastic_mcp.boards as boards_mod
fake_catalog = [
{"env": "heltec-v3", "hw_model_slug": "HELTEC_V3"},
{"env": "heltec-v4", "hw_model_slug": "HELTEC_V4"},
{"env": "heltec-v4-tft", "hw_model_slug": "HELTEC_V4"}, # variant
{"env": "heltec-wsl-v3", "hw_model_slug": "HELTEC_WSL_V3"},
]
monkeypatch.setattr(boards_mod, "list_boards", lambda *a, **k: fake_catalog)
assert identity.env_for_hw_model("HELTEC_V4") == "heltec-v4" # base, not -tft
assert identity.env_for_hw_model("HELTEC_V3") == "heltec-v3"
assert identity.env_for_hw_model("HELTEC_WSL_V3") == "heltec-wsl-v3"
assert identity.env_for_hw_model("NOT_A_BOARD") is None
assert identity.env_for_hw_model(None) is None
# control.env_for_device prefers the device's resolved env over the role default.
assert (
control.env_for_device({"role": "esp32s3", "env": "heltec-v4"}) == "heltec-v4"
)
# No resolved env → falls back to the role default.
assert control.env_for_device({"role": "esp32s3", "env": None}) == "heltec-v3"
def test_suite_env_overrides_from_connected_boards(tmp_path):
"""The test runner bakes the variant resolved per connected board: an online
Heltec V4 → MESHTASTIC_MCP_ENV_ESP32S3=heltec-v4 (not the heltec-v3 default).
Native (TCP) nodes and un-enriched devices are excluded."""
from meshtastic_mcp.web.services.test_runner import resolve_env_overrides
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
# esp32s3 V4 (enriched), an nrf52 (rak4631), a stale esp32 (no env),
# and a native node — only the first two should produce overrides.
await rd.upsert_from_discovery(
db,
serial_number="V4",
current_port="/dev/a",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
await rd.update_enrichment(db, "V4", node_num=1, env="heltec-v4")
await rd.upsert_from_discovery(
db,
serial_number="NRF",
current_port="/dev/b",
vid="0x239a",
pid="0x1",
role="nrf52",
)
await rd.update_enrichment(db, "NRF", node_num=2, env="rak4631")
await rd.upsert_from_discovery(
db,
serial_number="UNENRICHED",
current_port="/dev/c",
vid="0x303a",
pid="0x1",
role="esp32s3",
) # no env yet → excluded
await rd.upsert_native(db, name="sim", tcp_port=4403, online=True)
rows = await rd.online_with_env(db)
overrides = resolve_env_overrides(rows)
assert overrides["MESHTASTIC_MCP_ENV_ESP32S3"] == "heltec-v4"
assert overrides["MESHTASTIC_MCP_ENV_NRF52"] == "rak4631"
# native nodes never become a flash/bake target
assert not any("native" in v.lower() for v in overrides.values())
finally:
await db.close()
asyncio.run(go())
def test_manual_env_override_survives_enrichment(tmp_path):
"""A user-pinned env must not be clobbered by auto-enrichment; releasing the
pin lets hw_model resolution take over again."""
async def go():
db = _fresh_db(tmp_path)
await db.connect()
try:
await rd.upsert_from_discovery(
db,
serial_number="D",
current_port="/dev/a",
vid="0x303a",
pid="0x1",
role="esp32s3",
)
# Auto enrichment resolves (wrongly, say) to heltec-v3.
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
assert (await rd.get(db, "D"))["env"] == "heltec-v3"
# User pins the correct env.
await rd.set_env(db, "D", "heltec-v4", locked=True)
row = await rd.get(db, "D")
assert row["env"] == "heltec-v4" and row["env_locked"] == 1
# A later auto-enrichment must NOT overwrite the pinned env.
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
assert (await rd.get(db, "D"))["env"] == "heltec-v4"
# Releasing the pin lets auto-detect win again.
await rd.set_env(db, "D", None, locked=False)
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
assert (await rd.get(db, "D"))["env"] == "heltec-v3"
finally:
await db.close()
asyncio.run(go())
def test_control_rejected_while_run_active(monkeypatch):
# The central safety property: no connect()-based action while a run holds
# the ports. Simulate an active run and assert every gate raises.
monkeypatch.setattr(
"meshtastic_mcp.web.services.test_runner.is_running", lambda: True
)
with pytest.raises(control.ControlBusy):
control._ensure_idle()
with pytest.raises(control.ControlBusy):
control._ensure_port_free("/dev/whatever")
+48
View File
@@ -0,0 +1,48 @@
# FleetSuite — web UI
Vue 3 + Vite + Tailwind v4 + Pinia single-page app for the Meshtastic test
harness. Replaces the old Textual TUI. It talks to the FastAPI backend in
`../src/meshtastic_mcp/web` over REST + a single `/ws` WebSocket.
## Develop
```bash
# from mcp-server/: runs the backend + this dev server together (HMR)
./scripts/web-dev.sh
```
or manually:
```bash
# terminal 1 — backend
cd mcp-server && .venv/bin/python -m uvicorn meshtastic_mcp.web.app:create_app \
--factory --port 8765 --reload
# terminal 2 — frontend (proxies /api + /ws → :8765)
cd mcp-server/web-ui && npm install && npm run dev
```
## Build (production)
```bash
npm run build # emits into ../src/meshtastic_mcp/web/static (gitignored)
```
Then `meshtastic-mcp-web` serves that build and the API on one port and opens a
pywebview window (`--browser` to serve only).
## Layout
- `stores/` — Pinia stores. `ws.ts` owns the single WebSocket and dispatches
topic-tagged frames; `devices` / `cameras` / `firmware` / `tests` hydrate via
REST and apply live deltas.
- `components/``DeviceCard` (keyed by serial → follows the device across
ports), `CameraFeed` (MJPEG `<img>`), `TestDashboard` (counters/tree/logs),
etc.
- `api/client.ts` — thin REST wrappers (relative URLs; same build works behind
pywebview and the dev proxy).
## macOS camera permission
`cv2.VideoCapture` inherits the launching process's TCC Camera grant. Launched
from a terminal, the first stream prompts for permission. A packaged `.app`
would need `NSCameraUsageDescription`.
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meshtastic FleetSuite</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2258
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "meshtastic-fleetsuite",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.2.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import AppBar from "./components/AppBar.vue";
import DeviceGrid from "./components/DeviceGrid.vue";
import TestDashboard from "./components/TestDashboard.vue";
import { useBuildsStore } from "./stores/builds";
import { useCamerasStore } from "./stores/cameras";
import { useDatadogStore } from "./stores/datadog";
import { useDevicesStore } from "./stores/devices";
import { useFirmwareStore } from "./stores/firmware";
import { useTestsStore } from "./stores/tests";
import { useWsStore } from "./stores/ws";
const tab = ref("fleet");
const ws = useWsStore();
const devices = useDevicesStore();
const cameras = useCamerasStore();
const firmware = useFirmwareStore();
const tests = useTestsStore();
const builds = useBuildsStore();
const datadog = useDatadogStore();
onMounted(() => {
ws.connect();
devices.init();
cameras.init();
firmware.init();
tests.init();
builds.init();
datadog.init();
});
</script>
<template>
<div class="min-h-screen">
<AppBar :tab="tab" @update:tab="(v) => (tab = v)" />
<main>
<DeviceGrid v-show="tab === 'fleet'" />
<TestDashboard v-if="tab === 'tests'" />
</main>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
// Minimal ANSI SGR → HTML converter for log panes. Firmware debug output and
// pytest both emit color codes (e.g. \x1b[34m for DEBUG). We render the common
// foreground colors + bold and DROP every other escape sequence. Text content
// is HTML-escaped first, so the only markup that reaches the DOM is our own
// <span> tags — safe to use with v-html.
const CSI = /\x1b\[([0-9;]*)([A-Za-z])/g;
// Tuned for a dark background (pure black/white pushed toward slate).
const FG: Record<number, string> = {
30: "#64748b",
31: "#f87171",
32: "#34d399",
33: "#fbbf24",
34: "#60a5fa",
35: "#c084fc",
36: "#22d3ee",
37: "#e5e7eb",
90: "#94a3b8",
91: "#fca5a5",
92: "#6ee7b7",
93: "#fde68a",
94: "#93c5fd",
95: "#d8b4fe",
96: "#67e8f9",
97: "#f8fafc",
};
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function span(text: string, color: string | null, bold: boolean): string {
const styles: string[] = [];
if (color) styles.push(`color:${color}`);
if (bold) styles.push("font-weight:600");
const attr = styles.length ? ` style="${styles.join(";")}"` : "";
return `<span${attr}>${escapeHtml(text)}</span>`;
}
const _cache = new Map<string, string>();
export function ansiToHtml(line: string): string {
const hit = _cache.get(line);
if (hit !== undefined) return hit;
let out = "";
let idx = 0;
let color: string | null = null;
let bold = false;
CSI.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = CSI.exec(line)) !== null) {
if (m.index > idx) out += span(line.slice(idx, m.index), color, bold);
idx = CSI.lastIndex;
if (m[2] === "m") {
const codes = m[1] === "" ? [0] : m[1].split(";").map((c) => Number(c));
for (const c of codes) {
if (c === 0) {
color = null;
bold = false;
} else if (c === 1) bold = true;
else if (c === 22) bold = false;
else if (c === 39) color = null;
else if (FG[c] !== undefined) color = FG[c];
}
}
// Non-SGR sequences (cursor moves, clear-line, …) are dropped.
}
if (idx < line.length) out += span(line.slice(idx), color, bold);
// Bound the cache so a long-running session doesn't grow unbounded.
if (_cache.size > 6000) _cache.clear();
_cache.set(line, out);
return out;
}
+29
View File
@@ -0,0 +1,29 @@
// Thin REST wrappers over the FastAPI backend. Relative URLs so the same build
// works behind the pywebview window and the Vite dev proxy.
async function req<T>(method: string, url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: body !== undefined ? { "content-type": "application/json" } : {},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let detail = res.statusText;
try {
detail = (await res.json()).detail ?? detail;
} catch {
/* ignore */
}
throw new Error(`${res.status}: ${detail}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
get: <T>(url: string) => req<T>("GET", url),
post: <T>(url: string, body?: unknown) => req<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => req<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => req<T>("PATCH", url, body ?? {}),
del: <T>(url: string) => req<T>("DELETE", url),
};
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { useWsStore } from "../stores/ws";
import { useDevicesStore } from "../stores/devices";
import FirmwareRef from "./FirmwareRef.vue";
defineProps<{ tab: string }>();
const emit = defineEmits<{ (e: "update:tab", v: string): void }>();
const ws = useWsStore();
const devices = useDevicesStore();
</script>
<template>
<header
class="flex items-center gap-4 px-5 py-3 border-b border-slate-800 bg-slate-950/70 backdrop-blur sticky top-0 z-10"
>
<!-- Wordmark + LoRa-chirp signature (nods to the Meshtastic logo origin) -->
<div class="flex items-center gap-2.5">
<svg
viewBox="0 0 40 24"
class="w-9 h-5 shrink-0"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<defs>
<linearGradient id="chirp" x1="0" y1="0" x2="40" y2="0"
gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#9ba8e0" />
<stop offset="1" stop-color="#67ea94" />
</linearGradient>
</defs>
<!-- a rising chirp: frequency increases leftright -->
<path
d="M1 12 C3 5, 5 5, 7 12 S11 19, 13 12 S16 6, 18 12 S20.5 17, 22.5 12 S24.5 8, 26 12 S27.5 15.5, 29 12 S30.5 9.5, 32 12 S33 14, 34 12 S35 11, 36 12 39 12 39 12"
stroke="url(#chirp)"
stroke-width="2"
/>
</svg>
<div class="flex items-baseline gap-1.5">
<span class="text-sm font-medium text-slate-400 tracking-tight"
>Meshtastic</span
>
<span class="fs-display text-base font-bold text-indigo-300"
>FleetSuite</span
>
</div>
</div>
<nav class="flex gap-1 ml-3">
<button
v-for="t in ['fleet', 'tests']"
:key="t"
@click="emit('update:tab', t)"
class="px-3 py-1.5 rounded-md text-xs fs-display transition"
:class="
tab === t
? 'bg-indigo-600/20 text-indigo-200 ring-1 ring-indigo-600/60'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'
"
>
{{ t === "tests" ? "Test Suite" : "Fleet" }}
</button>
</nav>
<div class="flex-1" />
<!-- Instrument readout: online count, mint when any are live -->
<span
class="flex items-center gap-1.5 text-xs mono px-2 py-1 rounded-md bg-slate-900/70 border border-slate-800"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="
devices.list.filter((d) => d.online).length
? 'bg-emerald-400'
: 'bg-slate-600'
"
/>
<span class="text-slate-300 tabular-nums">{{
devices.list.filter((d) => d.online).length
}}</span>
<span class="text-slate-500">online</span>
</span>
<FirmwareRef />
<span
class="flex items-center gap-1.5 text-xs fs-display"
:class="ws.connected ? 'text-emerald-400' : 'text-rose-400'"
>
<span class="relative flex h-2 w-2">
<span
v-if="ws.connected"
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60"
/>
<span
class="relative inline-flex rounded-full h-2 w-2"
:class="ws.connected ? 'bg-emerald-400' : 'bg-rose-400'"
/>
</span>
{{ ws.connected ? "live" : "offline" }}
</span>
</header>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { useBuildsStore } from "../stores/builds";
import { useFirmwareStore } from "../stores/firmware";
const builds = useBuildsStore();
const fw = useFirmwareStore();
const STATUS: Record<string, { glyph: string; cls: string }> = {
queued: { glyph: "…", cls: "text-slate-400" },
building: { glyph: "⏳", cls: "text-amber-400 animate-pulse" },
success: { glyph: "✓", cls: "text-emerald-400" },
cached: { glyph: "✓", cls: "text-emerald-400/70" },
failed: { glyph: "✗", cls: "text-rose-400" },
cancelled: { glyph: "∅", cls: "text-slate-500" },
};
</script>
<template>
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
<h3 class="section-label">Build Queue</h3>
<span
v-if="!builds.dockerAvailable"
class="text-[11px] px-2 py-0.5 rounded bg-amber-950/40 text-amber-400"
title="Docker not detected — builds fall back to host pio (not parallelized)"
>Docker unavailable host builds</span
>
<div class="flex-1" />
<button
@click="builds.prebuildTracked()"
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50"
:title="'prebuild connected device targets @ ' + (fw.ref.short_sha || '')"
>
prebuild current ref
</button>
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="b in builds.list"
:key="b.id"
class="flex items-center gap-2 text-xs rounded-lg border border-slate-800 px-2.5 py-1.5"
:title="b.error || b.artifact_dir || ''"
>
<span :class="(STATUS[b.status] || STATUS.queued).cls">{{
(STATUS[b.status] || STATUS.queued).glyph
}}</span>
<span class="text-slate-200">{{ b.env }}</span>
<span class="mono text-emerald-300/60">{{ b.fw_sha?.slice(0, 7) }}</span>
<span v-if="b.duration_s" class="text-slate-500"
>{{ b.duration_s.toFixed(0) }}s</span
>
<span v-if="b.cached" class="text-slate-600">cached</span>
</div>
<div v-if="builds.list.length === 0" class="text-xs text-slate-600">
no builds yet "prebuild current ref" builds each connected target in
parallel (Docker) in the background
</div>
</div>
</div>
</template>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../api/client";
import { useCamerasStore } from "../stores/cameras";
import type { Camera } from "../types";
const props = defineProps<{ camera?: Camera }>();
const cameras = useCamerasStore();
const errored = ref(false);
const statusMsg = ref<string | null>(null);
// Cache-bust so reassign / remount restarts the stream.
const nonce = ref(Date.now());
const src = computed(() =>
props.camera
? `/api/cameras/${props.camera.id}/stream.mjpg?t=${nonce.value}`
: "",
);
const rotation = computed(() => props.camera?.rotation ?? 0);
const mirrored = computed(() => !!props.camera?.mirror);
// Rotation + mirror are pure CSS (the MJPEG stream isn't restarted). For 90/270
// we scale a 16:9 feed (filling the 16:9 box) by 9/16 so it fits after the
// quarter turn. Mirror is a horizontal flip applied before the rotation.
const imgStyle = computed(() => {
const r = rotation.value;
const scale = r === 90 || r === 270 ? 0.5625 : 1;
const flip = mirrored.value ? " scaleX(-1)" : "";
return {
transform: `rotate(${r}deg) scale(${scale})${flip}`,
transition: "transform 0.2s ease",
};
});
// Only the camera id changing should restart the stream (not a rotation save).
watch(
() => props.camera?.id,
() => {
errored.value = false;
statusMsg.value = null;
nonce.value = Date.now();
},
);
async function onError() {
errored.value = true;
if (!props.camera) return;
try {
const s = await api.get<{ ok: boolean; error: string | null }>(
`/api/cameras/${props.camera.id}/status`,
);
statusMsg.value = s.ok ? "stream interrupted" : s.error;
} catch {
statusMsg.value = "camera unavailable";
}
}
function retry() {
errored.value = false;
statusMsg.value = null;
nonce.value = Date.now();
}
async function rotate() {
if (!props.camera) return;
try {
await cameras.setRotation(props.camera.id, (rotation.value + 90) % 360);
} catch {
/* ignore — transient */
}
}
async function mirror() {
if (!props.camera) return;
try {
await cameras.setMirror(props.camera.id, !mirrored.value);
} catch {
/* ignore — transient */
}
}
</script>
<template>
<div
class="relative aspect-video w-full bg-black rounded-md overflow-hidden border border-slate-800"
>
<template v-if="camera && !errored">
<img
:src="src"
:style="imgStyle"
class="w-full h-full object-contain"
@error="onError"
alt="camera feed"
/>
<span
class="absolute top-1 left-1 text-[10px] px-1.5 py-0.5 rounded bg-black/60 text-emerald-300"
> {{ camera.name }}</span
>
<div class="absolute top-1 right-1 flex gap-1">
<button
@click="mirror"
class="p-1 rounded bg-black/60 transition"
:class="mirrored ? 'text-emerald-300' : 'text-slate-300 hover:text-emerald-300'"
:title="mirrored ? 'mirror: on (horizontal flip)' : 'mirror (horizontal flip)'"
>
<svg
viewBox="0 0 24 24"
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3v18" />
<path d="M16 7l4 5-4 5" />
<path d="M8 7l-4 5 4 5" />
</svg>
</button>
<button
@click="rotate"
class="p-1 rounded bg-black/60 text-slate-300 hover:text-emerald-300 transition"
:title="`rotate (now ${rotation}°)`"
>
<svg
viewBox="0 0 24 24"
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
</div>
</template>
<div
v-else-if="camera && errored"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center px-3"
>
<span class="text-rose-400 text-sm"> no signal</span>
<span class="text-xs text-slate-500">{{
statusMsg || "camera produced no frames"
}}</span>
<button
@click="retry"
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
>
retry
</button>
</div>
<div
v-else
class="absolute inset-0 flex items-center justify-center text-xs text-slate-600"
>
no camera assigned
</div>
</div>
</template>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { useCamerasStore } from "../stores/cameras";
interface Discovered {
index: number;
name: string;
in_use: boolean;
width?: number;
height?: number;
unavailable?: boolean;
}
const cameras = useCamerasStore();
const name = ref("");
const index = ref("0");
const adding = ref(false);
const manual = ref(false);
const discovered = ref<Discovered[]>([]);
const scanning = ref(false);
const scanned = ref(false);
const hasBackend = ref(true); // cv2 present → live preview possible
async function scan() {
scanning.value = true;
try {
const res = await api.get<{ cv2: boolean; cameras: Discovered[] }>(
"/api/cameras/discover",
);
hasBackend.value = res.cv2;
discovered.value = res.cameras;
scanned.value = true;
} catch {
discovered.value = [];
scanned.value = true;
} finally {
scanning.value = false;
}
}
onMounted(scan);
async function quickAdd(cam: Discovered) {
await cameras.add(cam.name, String(cam.index));
await scan(); // refresh in_use flags
}
async function add() {
if (!name.value.trim()) return;
adding.value = true;
try {
await cameras.add(name.value, index.value);
name.value = "";
index.value = "0";
await scan();
} finally {
adding.value = false;
}
}
function res(c: Discovered): string {
if (c.unavailable) return "can't open";
if (c.width && c.height) return `${c.width}×${c.height}`;
return hasBackend.value ? "" : "no preview backend";
}
</script>
<template>
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
<h3 class="section-label">USB Cameras</h3>
<div class="flex-1" />
<button
@click="scan"
:disabled="scanning"
class="text-xs px-2.5 py-1 rounded border border-indigo-700 text-indigo-300 hover:bg-indigo-600/20 disabled:opacity-40 fs-display"
>
{{ scanning ? "scanning…" : "⟳ scan" }}
</button>
</div>
<!-- discovered cameras -->
<ul v-if="discovered.length" class="space-y-1 mb-3">
<li
v-for="c in discovered"
:key="c.index"
class="flex items-center gap-2 text-xs px-2 py-1.5 rounded bg-slate-950/40 border border-slate-800"
>
<span class="w-1.5 h-1.5 rounded-full" :class="c.unavailable ? 'bg-rose-500' : 'bg-emerald-400'" />
<span class="text-slate-200 truncate">{{ c.name }}</span>
<span class="mono text-slate-500">idx {{ c.index }}</span>
<span v-if="res(c)" class="mono text-slate-600">{{ res(c) }}</span>
<span class="ml-auto">
<span v-if="c.in_use" class="text-emerald-400/70">added </span>
<button
v-else
@click="quickAdd(c)"
class="px-2 py-0.5 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50"
>
add
</button>
</span>
</li>
</ul>
<p
v-else-if="scanned && !scanning"
class="text-xs text-slate-600 mb-3"
>
no cameras detected connect a USB capture device and scan again
</p>
<p
v-if="scanned && !hasBackend"
class="text-[11px] text-amber-400/80 mb-3"
>
live preview needs OpenCV install the bench extra:
<span class="mono">pip install -e '.[ui]'</span> (discovery still works without it)
</p>
<!-- manual fallback -->
<button
@click="manual = !manual"
class="text-[11px] text-slate-500 hover:text-slate-300"
>
{{ manual ? "▾" : "▸" }} add by index manually
</button>
<div v-if="manual" class="flex flex-wrap gap-2 mt-2">
<input
v-model="name"
placeholder="name"
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<input
v-model="index"
placeholder="device index (0)"
class="text-xs w-32 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<button
@click="add"
:disabled="adding"
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
>
add camera
</button>
</div>
<!-- registered cameras -->
<ul v-if="cameras.list.length" class="space-y-1 mt-3 pt-3 border-t border-slate-800">
<li
v-for="c in cameras.list"
:key="c.id"
class="flex items-center gap-2 text-xs text-slate-400"
>
<span class="text-slate-200">{{ c.name }}</span>
<span class="mono">idx {{ c.device_index }}</span>
<span v-if="c.device_serial" class="text-emerald-400/70"
> {{ c.device_serial }}</span
>
<span v-else class="text-slate-600">unassigned</span>
<button
@click="cameras.remove(c.id)"
class="ml-auto text-rose-400/70 hover:text-rose-300"
>
remove
</button>
</li>
</ul>
</div>
</template>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { useDatadogStore } from "../stores/datadog";
const dd = useDatadogStore();
// Local editable draft, seeded from status and kept in sync when status loads.
const draft = reactive({
enabled: false,
site: "us5.datadoghq.com",
scrub: "coarse",
collector: "bench",
ship_debug: false,
api_key: "", // write-only; blank = keep existing
});
let seeded = false;
watch(
() => dd.status,
(s) => {
if (!s || seeded) return;
draft.enabled = s.config.enabled;
draft.site = s.config.site;
draft.scrub = s.config.scrub;
draft.collector = s.config.collector;
draft.ship_debug = s.config.ship_debug;
seeded = true;
},
{ immediate: true },
);
const busy = ref(false);
const msg = ref<string | null>(null);
const ok = ref(true);
async function save() {
busy.value = true;
msg.value = "saving…";
ok.value = true;
try {
const payload: Record<string, unknown> = {
enabled: draft.enabled,
site: draft.site,
scrub: draft.scrub,
collector: draft.collector,
ship_debug: draft.ship_debug,
};
if (draft.api_key.trim()) payload.api_key = draft.api_key.trim();
await dd.save(payload);
draft.api_key = ""; // never keep the secret in the field
msg.value = "saved";
} catch (e: any) {
msg.value = e.message;
ok.value = false;
} finally {
busy.value = false;
}
}
async function test() {
busy.value = true;
msg.value = "sending test log…";
ok.value = true;
try {
const r = await dd.test();
ok.value = r.ok;
msg.value = r.ok ? "test log accepted by Datadog ✓" : `test failed: ${r.error}`;
} catch (e: any) {
ok.value = false;
msg.value = e.message;
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
<h3 class="section-label">Datadog logging</h3>
<span
v-if="dd.status"
class="flex items-center gap-1.5 text-xs"
:class="dd.status.stats.running ? 'text-emerald-400' : 'text-slate-500'"
>
<span
class="w-2 h-2 rounded-full"
:class="dd.status.stats.running ? 'bg-emerald-400' : 'bg-slate-600'"
/>
{{ dd.status.stats.running ? "shipping" : "idle" }}
</span>
<span v-if="dd.status" class="text-xs text-slate-500 tabular-nums">
{{ dd.status.stats.sent_logs }} logs ·
{{ dd.status.stats.sent_metrics }} metrics
</span>
<span
v-if="dd.status?.stats.last_error"
class="text-xs text-rose-400 truncate"
:title="dd.status.stats.last_error"
> {{ dd.status.stats.last_error }}</span
>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<label class="flex items-center gap-2 col-span-2">
<input type="checkbox" v-model="draft.enabled" class="accent-emerald-500" />
<span class="text-slate-300">Ship recorder logs + telemetry to Datadog</span>
</label>
<div class="col-span-2 flex gap-1.5">
<input
v-model="draft.api_key"
type="password"
:placeholder="
dd.status?.config.has_key
? `API key set (••••${dd.status.config.key_hint}) — leave blank to keep`
: 'DD_API_KEY (or pub… client token)'
"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
</div>
<label class="text-slate-500"
>Site
<input
v-model="draft.site"
class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/></label>
<label class="text-slate-500"
>GPS scrub
<select
v-model="draft.scrub"
class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none"
>
<option value="off">off</option>
<option value="coarse">coarse (~11 km)</option>
<option value="redact">redact</option>
</select>
</label>
<label class="text-slate-500"
>Collector tag
<input
v-model="draft.collector"
class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/></label>
<label class="flex items-end gap-2 pb-1">
<input type="checkbox" v-model="draft.ship_debug" class="accent-emerald-500" />
<span class="text-slate-400">ship DEBUG lines</span>
</label>
</div>
<div class="flex items-center gap-2 mt-3">
<button
@click="save"
:disabled="busy"
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
>
save
</button>
<button
@click="test"
:disabled="busy"
class="text-xs px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
test connection
</button>
<span
v-if="msg"
class="text-xs mono truncate"
:class="ok ? 'text-slate-500' : 'text-rose-400'"
:title="msg"
>{{ msg }}</span
>
</div>
<p class="text-[10px] text-slate-600 mt-2">
Streams <code>.mtlog/logs.jsonl</code> Datadog Logs and
<code>telemetry.jsonl</code> Metrics, tagged <code>collector:{{ draft.collector }}</code
>. Same schema as the bench/fleet forwarders, so the existing dashboard works.
</p>
</div>
</template>
@@ -0,0 +1,266 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useCamerasStore } from "../stores/cameras";
import { useDevicesStore } from "../stores/devices";
import { useFirmwareStore } from "../stores/firmware";
import type { Device } from "../types";
import CameraFeed from "./CameraFeed.vue";
import DeviceControls from "./DeviceControls.vue";
import DeviceSettings from "./DeviceSettings.vue";
import PacketPane from "./PacketPane.vue";
import SerialLogPane from "./SerialLogPane.vue";
import TestResultsPane from "./TestResultsPane.vue";
const props = defineProps<{ device: Device }>();
const devices = useDevicesStore();
const cameras = useCamerasStore();
const fw = useFirmwareStore();
const tab = ref<"serial" | "packets" | "results">("serial");
const editing = ref(false);
const nameDraft = ref("");
const showSettings = ref(false);
const assignedCamera = computed(() =>
cameras.forDevice(props.device.serial_number),
);
// Circular node identifier (Meshtastic design standard): a deterministic color
// + short token per node. Color is used on the ring/text only — never as a row
// background wash.
function colorFor(seed: string): string {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
return `hsl(${h % 360} 60% 68%)`;
}
const nodeColor = computed(() =>
props.device.online ? colorFor(props.device.serial_number) : "#5c5e78",
);
const nodeToken = computed(() => {
const d = props.device;
if (d.role === "native") return "DK";
if (d.node_num) return d.node_num.toString(16).slice(-2).toUpperCase();
return (d.serial_number || "??")
.replace(/[^a-zA-Z0-9]/g, "")
.slice(-2)
.toUpperCase();
});
const flashedDrift = computed(
() =>
props.device.flashed_fw_sha &&
fw.ref.sha &&
props.device.flashed_fw_sha !== fw.ref.sha,
);
function startEdit() {
nameDraft.value = props.device.friendly_name || "";
editing.value = true;
}
async function saveName() {
editing.value = false;
await devices.setFriendlyName(props.device.serial_number, nameDraft.value);
}
async function onAssign(e: Event) {
const val = (e.target as HTMLSelectElement).value;
// find camera currently assigned and reassign; here we assign the picked
// camera id to this device (or unassign all when "")
const id = val ? Number(val) : null;
// Unassign whatever is currently on this device first if changing.
const current = assignedCamera.value;
if (current && current.id !== id) await cameras.assign(current.id, null);
if (id != null) await cameras.assign(id, props.device.serial_number);
}
</script>
<template>
<div
class="card-rail rounded-xl border bg-slate-900/60 p-4 flex flex-col gap-3"
:class="
device.online
? 'border-slate-700/80'
: 'border-slate-800 opacity-60 is-offline'
"
>
<!-- header -->
<div class="flex items-start gap-2.5">
<div
class="node-id mt-0.5"
:style="{ color: nodeColor }"
:title="
(device.online ? 'online' : 'offline') + ' · ' + device.serial_number
"
>
<span class="text-slate-100">{{ nodeToken }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<template v-if="editing">
<input
v-model="nameDraft"
@keyup.enter="saveName"
@blur="saveName"
autofocus
class="text-sm bg-slate-800 border border-slate-600 rounded px-1.5 py-0.5 outline-none"
/>
</template>
<template v-else>
<span
class="font-semibold text-slate-100 truncate cursor-pointer hover:text-emerald-300"
@click="startEdit"
:title="'click to rename · ' + device.serial_number"
>
{{ device.friendly_name || device.serial_number }}
</span>
</template>
<span
v-if="device.role"
class="text-[10px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-400 uppercase"
>{{ device.role }}</span
>
<span
v-if="!device.has_stable_id"
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-950/50 text-amber-400"
title="no stable USB serial — won't follow across port changes"
>no-serial</span
>
</div>
<div class="text-xs text-slate-500 mono truncate">
{{ device.current_port || "—" }}
<span v-if="device.node_num" class="text-slate-600"
>· !{{ device.node_num.toString(16) }}</span
>
<span v-if="device.stale" class="text-amber-500/70">· stale</span>
</div>
<!-- auto-sniffed specs: running firmware, hw model, region, exact env -->
<div
v-if="device.firmware_version || device.hw_model"
class="text-[11px] mono truncate mt-0.5 flex flex-wrap items-center gap-x-1.5"
>
<span v-if="device.firmware_version" class="text-emerald-300/90"
>v{{ device.firmware_version }}</span
>
<span v-if="device.hw_model" class="text-slate-400"
>· {{ device.hw_model }}</span
>
<span
v-if="device.region && device.region !== 'UNSET'"
class="text-indigo-300/90"
>· {{ device.region }}</span
>
<span
v-else-if="device.region === 'UNSET'"
class="text-amber-400/90"
title="region unset — node will not transmit"
>· region unset</span
>
<span v-if="device.env" class="text-slate-500"
>· env <span class="text-slate-300">{{ device.env }}</span></span
>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
@click="showSettings = !showSettings"
class="p-1 rounded transition hover:bg-slate-800"
:class="
showSettings ? 'text-emerald-300' : 'text-slate-500 hover:text-slate-300'
"
title="device settings (env override, rename, node config)"
>
<svg
viewBox="0 0 24 24"
class="w-4 h-4"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button
@click="devices.refresh(device.serial_number)"
class="p-1 rounded text-slate-500 hover:text-slate-300 transition hover:bg-slate-800"
title="refresh device info"
>
<svg
viewBox="0 0 24 24"
class="w-4 h-4"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
/>
</svg>
</button>
</div>
</div>
<!-- settings panel (cog) -->
<DeviceSettings v-if="showSettings" :device="device" />
<!-- flashed firmware ref -->
<div class="text-[11px] flex items-center gap-1.5">
<span class="text-slate-500">firmware:</span>
<template v-if="device.flashed_fw_sha">
<span class="mono text-emerald-300/80">{{
device.flashed_fw_sha.slice(0, 7)
}}</span>
<span
v-if="flashedDrift"
class="text-amber-400"
title="device firmware differs from the current checkout"
> behind current ref</span
>
</template>
<span v-else class="text-slate-600">unknown (not flashed via FleetSuite)</span>
</div>
<!-- camera -->
<CameraFeed :camera="assignedCamera" />
<select
:value="assignedCamera?.id ?? ''"
@change="onAssign"
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none"
>
<option value=""> no camera </option>
<option v-for="c in cameras.list" :key="c.id" :value="c.id">
{{ c.name }} (idx {{ c.device_index }})
</option>
</select>
<!-- tabs -->
<div class="flex gap-1 text-xs border-b border-slate-800">
<button
v-for="t in ['serial', 'packets', 'results']"
:key="t"
@click="tab = t as any"
class="px-2 py-1 capitalize -mb-px border-b-2 transition"
:class="
tab === t
? 'border-emerald-500 text-emerald-300'
: 'border-transparent text-slate-500 hover:text-slate-300'
"
>
{{ t }}
</button>
</div>
<SerialLogPane v-if="tab === 'serial'" :serial="device.serial_number" />
<PacketPane v-else-if="tab === 'packets'" :serial="device.serial_number" />
<TestResultsPane v-else :serial="device.serial_number" />
<DeviceControls :device="device" />
</div>
</template>
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { useBuildsStore } from "../stores/builds";
import { useDevicesStore } from "../stores/devices";
import { useFirmwareStore } from "../stores/firmware";
import { useTestsStore } from "../stores/tests";
import type { Device } from "../types";
const props = defineProps<{ device: Device }>();
const tests = useTestsStore();
const builds = useBuildsStore();
const devices = useDevicesStore();
const fw = useFirmwareStore();
const nodedbSize = ref(500);
const flashStats = ref<any>(null);
const isNative = computed(() => props.device.role === "native");
const nativeName = computed(() => props.device.serial_number.split(":")[1] ?? "");
async function loadFlashStats() {
if (isNative.value) return; // native nodes aren't flashed
try {
flashStats.value = await api.get(
`/api/devices/${props.device.serial_number}/flash-stats`,
);
} catch {
/* ignore */
}
}
onMounted(loadFlashStats);
// role → default pio env (mirrors the backend identity map).
const ROLE_ENV: Record<string, string> = {
nrf52: "rak4631",
esp32s3: "heltec-v3",
};
// Is there a prebuilt artifact for this device's target at the current ref?
// Prefer the env resolved from hw_model; fall back to the coarse role default.
const flashReady = computed(() => {
const env =
props.device.env ||
(props.device.role ? ROLE_ENV[props.device.role] : undefined);
if (!env) return false;
const b = builds.statusFor(env, fw.ref.sha);
return b?.status === "success" || b?.status === "cached";
});
const busy = ref(false);
const msg = ref<string | null>(null);
const ok = ref(true);
const sendText = ref("");
async function run(label: string, fn: () => Promise<any>) {
busy.value = true;
msg.value = `${label}`;
ok.value = true;
try {
await fn();
msg.value = `${label}`;
ok.value = true;
} catch (e: any) {
msg.value = `${label}: ${e.message}`;
ok.value = false;
} finally {
busy.value = false;
}
}
const base = () => `/api/devices/${props.device.serial_number}`;
const flash = () =>
run("flash", () => api.post(`${base()}/flash`, {})).then(loadFlashStats);
const injectNodeDb = () =>
run(`inject ${nodedbSize.value}-node db`, () =>
api.post(`${base()}/inject-nodedb`, { size: nodedbSize.value }),
);
const reboot = () => run("reboot", () => api.post(`${base()}/reboot`, {}));
const factory = () => {
if (confirm(`Factory-reset ${props.device.friendly_name || props.device.serial_number}?`))
run("factory-reset", () => api.post(`${base()}/factory-reset`, {}));
};
const getConfig = () =>
run("get-config", async () => {
const c = await api.get(`${base()}/config?section=lora`);
msg.value = JSON.stringify(c).slice(0, 120);
});
// Native (Docker meshtasticd) container lifecycle.
const nativeBase = () => `/api/native/${nativeName.value}`;
const startNode = () => run("start", () => api.post(`${nativeBase()}/start`));
const stopNode = () => run("stop", () => api.post(`${nativeBase()}/stop`));
const restartNode = () => run("restart", () => api.post(`${nativeBase()}/restart`));
const actions = computed(() =>
isNative.value
? [
{ label: "Start", fn: startNode },
{ label: "Stop", fn: stopNode },
{ label: "Restart", fn: restartNode },
{ label: "Config", fn: getConfig },
]
: [
{ label: "Flash", fn: flash },
{ label: "Reboot", fn: reboot },
{ label: "Config", fn: getConfig },
{ label: "Factory Reset", fn: factory, danger: true },
],
);
const doSend = () => {
if (!sendText.value.trim()) return;
const text = sendText.value;
run("send-text", () => api.post(`${base()}/send-text`, { text })).then(
() => (sendText.value = ""),
);
};
// USB power control (uhubctl). The node's hub port is tracked on the device;
// if it's unmapped the backend auto-binds a unique VID match, or returns 409
// with candidates to pick in device settings.
const serial = computed(() => props.device.serial_number);
const hubLabel = computed(() =>
props.device.hub_location != null
? `hub ${props.device.hub_location}:${props.device.hub_port}`
: "port unmapped — auto/assign in ⚙",
);
const powerCycle = () =>
run("power cycle", () => devices.power(serial.value, "cycle"));
const powerOff = () => {
if (confirm(`Cut USB power to ${props.device.friendly_name || serial.value}?`))
run("power off", () => devices.power(serial.value, "off"));
};
const powerOn = () => run("power on", () => devices.power(serial.value, "on"));
</script>
<template>
<div class="space-y-2">
<div
v-if="tests.running"
class="text-[11px] text-amber-400/80 bg-amber-950/30 rounded px-2 py-1"
>
device control disabled test run in progress
</div>
<div class="flex flex-wrap gap-1.5">
<button
v-for="b in actions"
:key="b.label"
:disabled="busy || tests.running"
@click="b.fn"
class="text-xs px-2 py-1 rounded border transition disabled:opacity-40 disabled:cursor-not-allowed"
:title="
b.label === 'Flash' && flashReady
? 'prebuilt artifact ready for ' + (fw.ref.short_sha || 'current ref')
: ''
"
:class="[
b.danger
? 'border-rose-800 text-rose-300 hover:bg-rose-950/40'
: 'border-slate-700 text-slate-300 hover:bg-slate-800',
b.label === 'Flash' && flashReady ? 'border-emerald-700 text-emerald-300' : '',
]"
>
{{ b.label === "Flash" && flashReady ? "Flash ✓" : b.label }}
</button>
</div>
<div class="flex gap-1.5">
<input
v-model="sendText"
@keyup.enter="doSend"
:disabled="tests.running"
placeholder="send text…"
class="flex-1 text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700 disabled:opacity-40"
/>
<button
@click="doSend"
:disabled="busy || tests.running"
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
send
</button>
</div>
<!-- inject fake NodeDB -->
<div class="flex gap-1.5 items-center">
<span class="text-[11px] text-slate-500">inject NodeDB</span>
<select
v-model.number="nodedbSize"
:disabled="tests.running"
class="text-xs bg-slate-900 border border-slate-700 rounded px-1.5 py-1 outline-none disabled:opacity-40"
>
<option :value="250">250</option>
<option :value="500">500</option>
<option :value="1000">1000</option>
<option :value="2000">2000</option>
</select>
<span class="text-[11px] text-slate-600">nodes</span>
<button
@click="injectNodeDb"
:disabled="busy || tests.running"
class="text-xs px-2 py-1 rounded border border-sky-800 text-sky-300 hover:bg-sky-950/40 disabled:opacity-40"
title="XModem-push a fresh fake NodeDB fixture, then reboot"
>
inject + reboot
</button>
</div>
<!-- USB power (uhubctl) -->
<div v-if="!isNative" class="flex gap-1.5 items-center flex-wrap">
<span class="text-[11px] text-slate-500">power</span>
<button
@click="powerCycle"
:disabled="busy || tests.running"
class="text-xs px-2 py-1 rounded border border-amber-800 text-amber-300 hover:bg-amber-950/40 disabled:opacity-40"
title="USB power-cycle this port (uhubctl off → on)"
>
cycle
</button>
<button
@click="powerOff"
:disabled="busy || tests.running"
class="text-xs px-2 py-1 rounded border border-rose-800 text-rose-300 hover:bg-rose-950/40 disabled:opacity-40"
title="Cut USB power to this port"
>
off
</button>
<button
@click="powerOn"
:disabled="busy || tests.running"
class="text-xs px-2 py-1 rounded border border-emerald-800 text-emerald-300 hover:bg-emerald-950/40 disabled:opacity-40"
title="Restore USB power to this port"
>
on
</button>
<span class="text-[11px] text-slate-600 mono">{{ hubLabel }}</span>
</div>
<!-- flash timing: direct artifact vs host rebuild -->
<div
v-if="flashStats && (flashStats.artifact || flashStats.rebuild)"
class="text-[11px] text-slate-500"
>
flash:
<span v-if="flashStats.artifact" class="text-emerald-400"
>{{ flashStats.artifact.duration_s }}s artifact</span
>
<span v-if="flashStats.artifact && flashStats.rebuild"> vs </span>
<span v-if="flashStats.rebuild" class="text-slate-400"
>{{ flashStats.rebuild.duration_s }}s rebuild</span
>
<span v-if="flashStats.speedup" class="text-emerald-300">
{{ flashStats.speedup }}× faster</span
>
</div>
<div
v-if="msg"
class="text-[11px] mono truncate"
:class="ok ? 'text-slate-500' : 'text-rose-400'"
:title="msg"
>
{{ msg }}
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useDevicesStore } from "../stores/devices";
import CameraManager from "./CameraManager.vue";
import DeviceCard from "./DeviceCard.vue";
import NativeManager from "./NativeManager.vue";
const devices = useDevicesStore();
</script>
<template>
<div class="p-5 space-y-5">
<div class="grid gap-4 md:grid-cols-2">
<CameraManager />
<NativeManager />
</div>
<div
v-if="devices.list.length === 0"
class="rounded-xl border border-dashed border-slate-700 p-10 text-center text-slate-500"
>
No devices detected. Plug in a Meshtastic board over USB the card will
appear automatically and follow it across ports.
</div>
<div
v-else
class="grid gap-4"
style="grid-template-columns: repeat(auto-fill, minmax(360px, 1fr))"
>
<DeviceCard
v-for="d in devices.list"
:key="d.serial_number"
:device="d"
/>
</div>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { useDevicesStore } from "../stores/devices";
import type { Device } from "../types";
const props = defineProps<{ device: Device }>();
const devices = useDevicesStore();
const name = ref(props.device.friendly_name || "");
const envInput = ref(props.device.env || "");
const envList = ref<string[]>([]);
const cfgPath = ref("");
const cfgValue = ref("");
const busy = ref(false);
const msg = ref<string | null>(null);
const ok = ref(true);
const isNative = computed(() => props.device.role === "native");
const serial = computed(() => props.device.serial_number);
// --- uhubctl hub-port assignment ---
type Slot = { location: string; port: number; label: string; value: string };
const hubAvailable = ref(true);
const slots = ref<Slot[]>([]);
const selectedSlot = ref("");
const currentSlot = computed(() =>
props.device.hub_location != null
? `${props.device.hub_location}:${props.device.hub_port}`
: "",
);
async function loadHubs() {
if (isNative.value) return;
try {
const res = await api.get<{ available: boolean; hubs: any[] }>("/api/hubs");
hubAvailable.value = res.available;
slots.value = (res.hubs || [])
.filter((h) => h.ppps)
.flatMap((h) =>
h.ports.map((p: any) => {
const tail = p.device_desc
? `${p.device_desc}`
: p.device_vid
? `${p.device_vid.toString(16)}:${(p.device_pid ?? 0).toString(16)}`
: " (empty)";
return {
location: h.location,
port: p.port,
value: `${h.location}:${p.port}`,
label: `${h.location}:${p.port}${tail}`,
};
}),
);
selectedSlot.value = currentSlot.value;
} catch {
/* hubs unavailable — section degrades to read-only */
}
}
const assignSlot = () => {
if (!selectedSlot.value) return;
const slot = slots.value.find((s) => s.value === selectedSlot.value);
if (!slot) return;
act("assign hub port", () =>
devices.setHubPort(serial.value, slot.location, slot.port),
);
};
const clearSlot = () =>
act("clear hub port", () => devices.setHubPort(serial.value, null, null));
const autoLocate = () =>
act("auto-locate", async () => {
const res = await devices.locate(serial.value);
if (!res.located) {
const c = res.candidates || [];
throw new Error(
c.length
? `ambiguous — pick a port: ${c.map((x) => `${x.location}:${x.port}`).join(", ")}`
: "no PPPS hub port matched this device's VID",
);
}
selectedSlot.value = currentSlot.value;
});
async function loadEnvs() {
try {
// Suggest envs, narrowed to this board's architecture when we can infer it.
const arch =
props.device.role === "nrf52"
? "nrf52840"
: props.device.role === "esp32s3"
? "esp32-s3"
: undefined;
const q = arch ? `?architecture=${arch}` : "";
const boards = await api.get<any[]>(`/api/boards${q}`);
envList.value = boards.map((b) => b.env).filter(Boolean).sort();
} catch {
/* leave datalist empty — free-form input still works */
}
}
onMounted(() => {
loadEnvs();
loadHubs();
});
async function act(label: string, fn: () => Promise<any>) {
busy.value = true;
msg.value = `${label}`;
ok.value = true;
try {
await fn();
msg.value = `${label}`;
} catch (e: any) {
msg.value = `${label}: ${e.message}`;
ok.value = false;
} finally {
busy.value = false;
}
}
const saveName = () =>
act("rename", () => devices.setFriendlyName(props.device.serial_number, name.value));
const pinEnv = () =>
envInput.value.trim() &&
act("pin env", () => devices.setEnv(props.device.serial_number, envInput.value.trim()));
const autoEnv = () =>
act("auto-detect env", async () => {
await devices.setEnv(props.device.serial_number, null);
envInput.value = props.device.env || "";
});
const setConfig = () => {
if (!cfgPath.value.trim()) return;
// Coerce "30"→30, "true"→true; leave bare strings (e.g. "US") as-is.
let value: any = cfgValue.value;
try {
value = JSON.parse(cfgValue.value);
} catch {
/* keep string */
}
act("set config", () =>
api.put(`/api/devices/${props.device.serial_number}/config`, {
path: cfgPath.value.trim(),
value,
}),
);
};
</script>
<template>
<div class="rounded-lg border border-slate-700 bg-slate-950/50 p-3 space-y-3 text-xs">
<!-- friendly name -->
<div>
<label class="text-slate-500">Friendly name</label>
<div class="flex gap-1.5 mt-1">
<input
v-model="name"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<button
@click="saveName"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
save
</button>
</div>
</div>
<!-- pio env override -->
<div>
<div class="flex items-center gap-2">
<label class="text-slate-500">PlatformIO env (flash/build target)</label>
<span
class="text-[10px] px-1.5 py-0.5 rounded"
:class="
device.env_locked
? 'bg-amber-950/50 text-amber-400'
: 'bg-slate-800 text-slate-400'
"
>{{ device.env_locked ? "manual" : "auto" }}</span
>
</div>
<div class="flex gap-1.5 mt-1">
<input
v-model="envInput"
list="envlist"
placeholder="e.g. heltec-v4"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<datalist id="envlist">
<option v-for="e in envList" :key="e" :value="e" />
</datalist>
<button
@click="pinEnv"
:disabled="busy"
class="px-2 py-1 rounded border border-emerald-700 text-emerald-300 hover:bg-emerald-700/40 disabled:opacity-40"
>
pin
</button>
<button
@click="autoEnv"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
title="release the override and re-resolve env from hw_model"
>
auto
</button>
</div>
<p class="text-[10px] text-slate-600 mt-1">
current: <span class="mono text-emerald-300/70">{{ device.env || "—" }}</span>
<span v-if="device.hw_model"> · hw {{ device.hw_model }}</span>
</p>
</div>
<!-- generic node config set -->
<div>
<label class="text-slate-500">Set node config (advanced)</label>
<div class="flex gap-1.5 mt-1">
<input
v-model="cfgPath"
placeholder="path e.g. lora.region"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<input
v-model="cfgValue"
placeholder="value e.g. US"
class="w-28 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<button
@click="setConfig"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
set
</button>
</div>
</div>
<!-- uhubctl power port -->
<div v-if="!isNative">
<div class="flex items-center gap-2">
<label class="text-slate-500">USB power port (uhubctl)</label>
<span
class="text-[10px] px-1.5 py-0.5 rounded"
:class="
device.hub_location != null
? 'bg-emerald-950/50 text-emerald-400'
: 'bg-slate-800 text-slate-400'
"
>{{ device.hub_location != null ? currentSlot : "unmapped" }}</span
>
</div>
<div v-if="!hubAvailable" class="text-[10px] text-amber-400/80 mt-1">
uhubctl not available on this host
</div>
<div v-else class="flex gap-1.5 mt-1">
<select
v-model="selectedSlot"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
>
<option value=""> select hub:port </option>
<option v-for="s in slots" :key="s.value" :value="s.value">
{{ s.label }}
</option>
</select>
<button
@click="assignSlot"
:disabled="busy || !selectedSlot"
class="px-2 py-1 rounded border border-emerald-700 text-emerald-300 hover:bg-emerald-700/40 disabled:opacity-40"
>
assign
</button>
<button
@click="autoLocate"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
title="auto-bind if exactly one PPPS port matches this device's VID"
>
auto
</button>
<button
v-if="device.hub_location != null"
@click="clearSlot"
:disabled="busy"
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
>
clear
</button>
</div>
</div>
<div
v-if="msg"
class="mono truncate"
:class="ok ? 'text-slate-500' : 'text-rose-400'"
:title="msg"
>
{{ msg }}
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useFirmwareStore } from "../stores/firmware";
const fw = useFirmwareStore();
</script>
<template>
<div
class="flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800/70 border border-slate-700 text-sm"
:title="fw.ref.subject || ''"
>
<svg
class="w-4 h-4 text-emerald-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="6" cy="6" r="2.5" />
<circle cx="6" cy="18" r="2.5" />
<circle cx="18" cy="9" r="2.5" />
<path d="M6 8.5v7M8.4 7.2A6 6 0 0 1 15.5 9" />
</svg>
<template v-if="fw.ref.available">
<span class="font-semibold text-slate-100">{{
fw.ref.branch || "(detached)"
}}</span>
<span class="mono text-emerald-300">{{ fw.ref.short_sha }}</span>
<span
v-if="fw.ref.dirty"
class="text-amber-400 text-xs"
title="working tree has uncommitted changes"
> dirty</span
>
</template>
<span v-else class="text-slate-500">no git ref</span>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { ansiToHtml } from "../ansi";
const props = defineProps<{ lines: string[]; placeholder?: string }>();
const box = ref<HTMLElement | null>(null);
const pinned = ref(true);
function onScroll() {
const el = box.value;
if (!el) return;
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
watch(
() => props.lines.length,
async () => {
if (!pinned.value) return;
await nextTick();
const el = box.value;
if (el) el.scrollTop = el.scrollHeight;
},
);
</script>
<template>
<div
ref="box"
@scroll="onScroll"
class="mono text-xs leading-relaxed overflow-auto h-full bg-black/40 rounded-md p-2 whitespace-pre-wrap break-all"
>
<span v-if="lines.length === 0" class="text-slate-600">{{
placeholder || "(no output yet)"
}}</span>
<div v-for="(l, i) in lines" :key="i" v-html="ansiToHtml(l)" />
</div>
</template>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
interface NativeInfo {
docker: boolean;
image: string;
nodes: any[];
}
const info = ref<NativeInfo>({ docker: true, image: "", nodes: [] });
const name = ref("");
const port = ref(4403);
const busy = ref(false);
const err = ref<string | null>(null);
async function load() {
info.value = await api.get<NativeInfo>("/api/native");
}
onMounted(load);
async function act(fn: () => Promise<any>) {
busy.value = true;
err.value = null;
try {
await fn();
await load();
} catch (e: any) {
err.value = e.message;
} finally {
busy.value = false;
}
}
const add = () =>
name.value.trim() &&
act(async () => {
await api.post("/api/native", { name: name.value.trim(), tcp_port: port.value });
name.value = "";
port.value = port.value + 1;
});
</script>
<template>
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
<h3 class="section-label">Native Nodes (Docker)</h3>
<span
v-if="!info.docker"
class="text-[11px] px-2 py-0.5 rounded bg-amber-950/40 text-amber-400"
>Docker unavailable</span
>
<span class="text-[11px] text-slate-600 mono">{{ info.image }}</span>
</div>
<div class="flex flex-wrap gap-2 mb-3">
<input
v-model="name"
placeholder="node name"
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
/>
<input
v-model.number="port"
type="number"
class="text-xs w-24 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
title="host TCP port → container 4403"
/>
<button
@click="add"
:disabled="busy || !info.docker"
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
>
run native node
</button>
<span v-if="err" class="text-xs text-rose-400 self-center">{{ err }}</span>
</div>
<ul class="space-y-1">
<li
v-for="n in info.nodes"
:key="n.serial_number"
class="flex items-center gap-2 text-xs text-slate-400"
>
<span
class="w-2 h-2 rounded-full"
:class="n.online ? 'bg-emerald-400' : 'bg-slate-600'"
/>
<span class="text-slate-200">{{ n.friendly_name }}</span>
<span class="mono">{{ n.current_port }}</span>
<button
v-if="!n.online"
@click="act(() => api.post(`/api/native/${n.friendly_name}/start`))"
class="ml-auto text-emerald-400/80 hover:text-emerald-300"
>
start
</button>
<button
v-else
@click="act(() => api.post(`/api/native/${n.friendly_name}/stop`))"
class="ml-auto text-amber-400/80 hover:text-amber-300"
>
stop
</button>
<button
@click="act(() => api.post(`/api/native/${n.friendly_name}/restart`))"
class="text-sky-400/80 hover:text-sky-300"
>
restart
</button>
<button
@click="act(() => api.del(`/api/native/${n.friendly_name}`))"
class="text-rose-400/70 hover:text-rose-300"
>
remove
</button>
</li>
<li v-if="info.nodes.length === 0" class="text-xs text-slate-600">
no native nodes run meshtasticd in Docker and manage it as a TCP device
</li>
</ul>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
const props = defineProps<{ serial: string }>();
const packets = ref<any[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
const res = await api.get<{ packets: any[] }>(
`/api/devices/${props.serial}/packets?start=-30m&max=100`,
);
packets.value = res.packets.slice().reverse();
} finally {
loading.value = false;
}
}
onMounted(load);
function ts(t: number) {
return new Date(t * 1000).toLocaleTimeString();
}
</script>
<template>
<div class="h-48 overflow-auto bg-black/40 rounded-md p-2">
<div class="flex justify-between items-center mb-1">
<span class="text-xs text-slate-500">last 30 min · packet API</span>
<button
@click="load"
class="text-xs px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
>
{{ loading ? "…" : "refresh" }}
</button>
</div>
<table class="w-full text-xs mono">
<thead class="text-slate-500">
<tr class="text-left">
<th class="font-normal">time</th>
<th class="font-normal">portnum</th>
<th class="font-normal">fromto</th>
<th class="font-normal">snr</th>
</tr>
</thead>
<tbody>
<tr v-for="(p, i) in packets" :key="i" class="border-t border-slate-800/60">
<td class="text-slate-400">{{ ts(p.ts) }}</td>
<td class="text-emerald-300">{{ p.portnum }}</td>
<td class="text-slate-300">{{ p.from_node }}{{ p.to_node }}</td>
<td class="text-slate-400">{{ p.rx_snr ?? "" }}</td>
</tr>
<tr v-if="packets.length === 0">
<td colspan="4" class="text-slate-600 py-2">no packets recorded</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from "vue";
import { useTestsStore } from "../stores/tests";
const tests = useTestsStore();
const args = ref("");
const busy = ref(false);
async function start() {
busy.value = true;
try {
const parsed = args.value.trim() ? args.value.trim().split(/\s+/) : [];
await tests.start(parsed);
} catch (e: any) {
alert(e.message);
} finally {
busy.value = false;
}
}
async function stop() {
busy.value = true;
try {
await tests.stop();
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex items-center gap-2">
<button
v-if="!tests.running"
@click="start"
:disabled="busy"
class="px-4 py-1.5 rounded-md bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium disabled:opacity-40"
>
Run suite
</button>
<button
v-else
@click="stop"
:disabled="busy"
class="px-4 py-1.5 rounded-md bg-rose-600 hover:bg-rose-500 text-white text-sm font-medium disabled:opacity-40"
>
Stop
</button>
<input
v-model="args"
:disabled="tests.running"
placeholder="pytest args (optional, e.g. tests/mesh)"
class="flex-1 text-sm bg-slate-900 border border-slate-700 rounded px-3 py-1.5 outline-none focus:border-emerald-700 disabled:opacity-40"
/>
<div class="text-sm flex items-center gap-3">
<span v-if="tests.running" class="text-amber-400 flex items-center gap-1">
<span class="animate-spin"></span> running
</span>
<span
v-else-if="tests.exitCode !== null"
:class="tests.exitCode === 0 ? 'text-emerald-400' : 'text-rose-400'"
>
exit {{ tests.exitCode }}
</span>
<span class="tabular-nums text-xs text-slate-400">
<span class="text-emerald-400">{{ tests.totals.passed }}</span> ·
<span class="text-rose-400">{{ tests.totals.failed }}</span> ·
<span class="text-slate-500">{{ tests.totals.skipped }}</span>
</span>
</div>
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive } from "vue";
import { useWsStore } from "../stores/ws";
import LogPane from "./LogPane.vue";
const props = defineProps<{ serial: string }>();
const ws = useWsStore();
const lines = reactive<string[]>([]);
const topic = `serial.${props.serial}`;
function onLine(d: any) {
lines.push(d.line);
if (lines.length > 2000) lines.splice(0, lines.length - 2000);
}
onMounted(() => ws.subscribe(topic, onLine));
onUnmounted(() => ws.unsubscribe(topic, onLine));
</script>
<template>
<div class="h-48">
<LogPane :lines="lines" placeholder="opening serial monitor…" />
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useTestsStore } from "../stores/tests";
import BuildQueue from "./BuildQueue.vue";
import DatadogPanel from "./DatadogPanel.vue";
import LogPane from "./LogPane.vue";
import RunControls from "./RunControls.vue";
import TestTree from "./TestTree.vue";
import TierCounters from "./TierCounters.vue";
const tests = useTestsStore();
const logTab = ref<"pytest" | "flash" | "firmware">("pytest");
const activeLines = computed(() => {
if (logTab.value === "flash") return tests.flash;
if (logTab.value === "firmware") return tests.fwlog;
return tests.stdout;
});
function fmtTime(t: number) {
return new Date(t * 1000).toLocaleString();
}
</script>
<template>
<div class="p-5 flex flex-col gap-4 h-[calc(100vh-57px)]">
<RunControls />
<BuildQueue />
<DatadogPanel />
<div class="grid grid-cols-2 gap-4 flex-1 min-h-0">
<!-- left: counters + tree -->
<div class="flex flex-col gap-3 min-h-0">
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-4">
<TierCounters />
</div>
<div
class="rounded-xl border border-slate-700 bg-slate-900/60 p-3 flex-1 min-h-0"
>
<TestTree />
</div>
</div>
<!-- right: log panes -->
<div class="flex flex-col min-h-0">
<div class="flex gap-1 text-xs mb-2">
<button
v-for="t in ['pytest', 'flash', 'firmware']"
:key="t"
@click="logTab = t as any"
class="px-3 py-1 rounded-md capitalize transition"
:class="
logTab === t
? 'bg-slate-700 text-slate-100'
: 'bg-slate-900 text-slate-500 hover:text-slate-300'
"
>
{{ t }}
</button>
</div>
<div class="flex-1 min-h-0">
<LogPane :lines="activeLines" />
</div>
</div>
</div>
<!-- run history -->
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-3">
<div class="text-xs text-slate-500 mb-2">recent runs</div>
<div class="flex gap-3 overflow-x-auto text-xs">
<div
v-for="r in tests.runs"
:key="r.id"
class="shrink-0 rounded-lg border border-slate-800 px-3 py-1.5"
>
<div class="flex items-center gap-2">
<span class="text-emerald-400">{{ r.passed }}</span>
<span class="text-rose-400">{{ r.failed }}</span>
<span class="text-slate-500">{{ r.skipped }}</span>
<span class="mono text-emerald-300/60">{{
r.fw_sha?.slice(0, 7)
}}</span>
</div>
<div class="text-slate-600">{{ fmtTime(r.started_at) }}</div>
</div>
<div v-if="tests.runs.length === 0" class="text-slate-600">
no runs recorded yet
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
const props = defineProps<{ serial: string }>();
const results = ref<any[]>([]);
const OUTCOME_CLASS: Record<string, string> = {
passed: "text-emerald-400",
failed: "text-rose-400",
skipped: "text-slate-500",
};
async function load() {
results.value = await api.get<any[]>(
`/api/devices/${props.serial}/test-results?limit=100`,
);
}
onMounted(load);
</script>
<template>
<div class="h-48 overflow-auto bg-black/40 rounded-md p-2">
<div class="flex justify-between items-center mb-1">
<span class="text-xs text-slate-500">test history</span>
<button
@click="load"
class="text-xs px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
>
refresh
</button>
</div>
<table class="w-full text-xs">
<tbody>
<tr
v-for="(r, i) in results"
:key="i"
class="border-t border-slate-800/60"
>
<td :class="OUTCOME_CLASS[r.outcome] || 'text-slate-400'" class="w-4">
{{
r.outcome === "passed" ? "✓" : r.outcome === "failed" ? "✗" : "⊘"
}}
</td>
<td class="mono text-slate-300 truncate max-w-[14rem]" :title="r.nodeid">
{{ r.nodeid.split("::").pop() }}
</td>
<td class="mono text-emerald-300/70 text-right">{{ r.fw_sha?.slice(0, 7) }}</td>
</tr>
<tr v-if="results.length === 0">
<td colspan="3" class="text-slate-600 py-2">no runs recorded yet</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from "vue";
import { useTestsStore } from "../stores/tests";
import type { TestLeaf } from "../types";
const tests = useTestsStore();
const GLYPH: Record<string, string> = {
passed: "✓",
failed: "✗",
skipped: "⊘",
running: "⟳",
pending: "·",
};
const CLS: Record<string, string> = {
passed: "text-emerald-400",
failed: "text-rose-400",
skipped: "text-slate-500",
running: "text-amber-400 animate-pulse",
pending: "text-slate-600",
};
// Group leaves: tier → file → [leaves]
const grouped = computed(() => {
const out: Record<string, Record<string, TestLeaf[]>> = {};
for (const leaf of Object.values(tests.leaves)) {
(out[leaf.tier] ??= {});
(out[leaf.tier][leaf.file] ??= []).push(leaf);
}
return out;
});
</script>
<template>
<div class="overflow-auto text-xs mono h-full">
<template v-for="tier in tests.tierOrder" :key="tier">
<div v-if="grouped[tier]" class="mb-2">
<div class="text-slate-300 capitalize font-semibold">{{ tier }}</div>
<div v-for="(leaves, file) in grouped[tier]" :key="file" class="ml-2">
<div class="text-slate-500">{{ file }}</div>
<div
v-for="leaf in leaves"
:key="leaf.nodeid"
class="ml-3 flex items-center gap-1.5"
:class="leaf.nodeid === tests.runningNodeId ? 'bg-amber-950/30 rounded' : ''"
>
<span :class="CLS[leaf.outcome]">{{ GLYPH[leaf.outcome] }}</span>
<span class="text-slate-400 truncate">{{ leaf.testname }}</span>
<span
v-if="leaf.duration"
class="text-slate-600 ml-auto pl-2"
>{{ leaf.duration.toFixed(1) }}s</span
>
</div>
</div>
</div>
</template>
<div
v-if="Object.keys(tests.leaves).length === 0"
class="text-slate-600 p-2"
>
no tests collected yet start a run
</div>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useTestsStore } from "../stores/tests";
const tests = useTestsStore();
</script>
<template>
<div class="space-y-1.5">
<div
v-for="t in tests.tierOrder"
:key="t"
class="flex items-center gap-2 text-xs transition-opacity"
:class="tests.tiers[t].total ? '' : 'opacity-45'"
>
<span class="w-24 text-slate-400 capitalize">{{ t }}</span>
<div class="flex-1 h-2.5 rounded-full bg-slate-800 overflow-hidden flex">
<div
class="bg-emerald-500"
:style="{
width:
tests.tiers[t].total
? (tests.tiers[t].passed / tests.tiers[t].total) * 100 + '%'
: '0%',
}"
/>
<div
class="bg-rose-500"
:style="{
width:
tests.tiers[t].total
? (tests.tiers[t].failed / tests.tiers[t].total) * 100 + '%'
: '0%',
}"
/>
<div
class="bg-slate-600"
:style="{
width:
tests.tiers[t].total
? (tests.tiers[t].skipped / tests.tiers[t].total) * 100 + '%'
: '0%',
}"
/>
</div>
<span class="inline-flex items-center gap-1 tabular-nums shrink-0">
<span
class="w-6 text-right"
:class="tests.tiers[t].passed ? 'text-emerald-400' : 'text-slate-600'"
>{{ tests.tiers[t].passed }}</span
>
<span class="text-slate-700">/</span>
<span
class="w-6 text-right"
:class="tests.tiers[t].failed ? 'text-rose-400' : 'text-slate-600'"
>{{ tests.tiers[t].failed }}</span
>
<span class="text-slate-700">/</span>
<span
class="w-6 text-right"
:class="tests.tiers[t].skipped ? 'text-slate-300' : 'text-slate-600'"
>{{ tests.tiers[t].skipped }}</span
>
<span class="w-7 text-right">
<span v-if="tests.tiers[t].running" class="text-amber-400"
>{{ tests.tiers[t].running }}</span
>
</span>
</span>
</div>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import "./style.css";
createApp(App).use(createPinia()).mount("#app");
+69
View File
@@ -0,0 +1,69 @@
import { defineStore } from "pinia";
import { computed, reactive, ref } from "vue";
import { api } from "../api/client";
import { useWsStore } from "./ws";
export interface Build {
id: number;
env: string;
fw_sha: string;
fw_branch: string | null;
status: string; // queued | building | success | failed | cached | cancelled
duration_s: number | null;
artifact_dir: string | null;
error: string | null;
cached?: boolean;
}
export const useBuildsStore = defineStore("builds", () => {
const byId = reactive<Record<number, Build>>({});
const dockerAvailable = ref(true);
const list = computed(() => Object.values(byId).sort((a, b) => b.id - a.id));
async function load() {
const res = await api.get<{ docker: boolean; builds: Build[] }>(
"/api/builds",
);
dockerAvailable.value = res.docker;
for (const b of res.builds) byId[b.id] = b;
}
function init() {
const ws = useWsStore();
ws.subscribe("build.update", (b: Build) => {
if (b && b.id != null) byId[b.id] = b;
});
load();
}
// Latest build row for an (env, sha) — drives the device flash button state.
function statusFor(
env: string,
sha: string | null | undefined,
): Build | undefined {
if (!sha) return undefined;
return Object.values(byId)
.filter((b) => b.env === env && b.fw_sha === sha)
.sort((a, b) => b.id - a.id)[0];
}
async function prebuildTracked() {
await api.post("/api/builds", {});
}
async function enqueue(envs: string[], force = false) {
await api.post("/api/builds", { envs, force });
}
return {
byId,
list,
dockerAvailable,
load,
init,
statusFor,
prebuildTracked,
enqueue,
};
});
+71
View File
@@ -0,0 +1,71 @@
import { defineStore } from "pinia";
import { computed, reactive } from "vue";
import { api } from "../api/client";
import type { Camera } from "../types";
import { useWsStore } from "./ws";
export const useCamerasStore = defineStore("cameras", () => {
const byId = reactive<Record<number, Camera>>({});
const list = computed(() => Object.values(byId).sort((a, b) => a.id - b.id));
function forDevice(serial: string): Camera | undefined {
return Object.values(byId).find((c) => c.device_serial === serial);
}
async function load() {
const cams = await api.get<Camera[]>("/api/cameras");
for (const c of cams) byId[c.id] = c;
}
function init() {
const ws = useWsStore();
ws.subscribe("camera.update", (c: Camera) => {
if (c.deleted) delete byId[c.id];
else byId[c.id] = c;
});
load();
}
async function add(name: string, device_index: string) {
const cam = await api.post<Camera>("/api/cameras", { name, device_index });
byId[cam.id] = cam;
}
async function remove(id: number) {
await api.del(`/api/cameras/${id}`);
delete byId[id];
}
async function assign(id: number, device_serial: string | null) {
const cam = await api.post<Camera>(`/api/cameras/${id}/assign`, {
device_serial,
});
byId[id] = cam;
}
async function setRotation(id: number, rotation: number) {
const cam = await api.post<Camera>(`/api/cameras/${id}/rotation`, {
rotation,
});
byId[id] = cam;
}
async function setMirror(id: number, mirror: boolean) {
const cam = await api.post<Camera>(`/api/cameras/${id}/mirror`, { mirror });
byId[id] = cam;
}
return {
byId,
list,
forDevice,
load,
init,
add,
remove,
assign,
setRotation,
setMirror,
};
});
+57
View File
@@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { useWsStore } from "./ws";
export interface DDConfig {
enabled: boolean;
site: string;
scrub: string;
collector: string;
host: string;
ship_debug: boolean;
has_key: boolean;
key_hint: string;
is_client_token: boolean;
}
export interface DDStats {
running: boolean;
sent_logs: number;
sent_metrics: number;
cycles: number;
last_error: string | null;
last_cycle_ts: number | null;
}
export interface DDStatus {
config: DDConfig;
stats: DDStats;
}
export const useDatadogStore = defineStore("datadog", () => {
const status = ref<DDStatus | null>(null);
async function load() {
status.value = await api.get<DDStatus>("/api/datadog");
}
function init() {
const ws = useWsStore();
ws.subscribe("datadog.update", (s: DDStatus) => {
status.value = s;
});
load();
}
// Send only the fields that changed. Omit api_key to keep the existing one.
async function save(updates: Record<string, unknown>) {
status.value = await api.put<DDStatus>("/api/datadog", updates);
}
async function test(): Promise<{ ok: boolean; error: string | null }> {
return api.post("/api/datadog/test");
}
return { status, load, init, save, test };
});
+98
View File
@@ -0,0 +1,98 @@
import { defineStore } from "pinia";
import { computed, reactive } from "vue";
import { api } from "../api/client";
import type { Device } from "../types";
import { useWsStore } from "./ws";
export const useDevicesStore = defineStore("devices", () => {
// Keyed by serial_number — a device.update with a new current_port is a field
// update on the same entry, so the card "follows" the device across ports.
const bySerial = reactive<Record<string, Device>>({});
const list = computed(() =>
Object.values(bySerial).sort((a, b) => {
if (a.online !== b.online) return b.online - a.online;
return (a.friendly_name || a.serial_number).localeCompare(
b.friendly_name || b.serial_number,
);
}),
);
async function load() {
const devices = await api.get<Device[]>("/api/devices");
for (const d of devices) bySerial[d.serial_number] = d;
}
function init() {
const ws = useWsStore();
ws.subscribe("device.update", (d: any) => {
if (d && d.deleted) delete bySerial[d.serial_number];
else if (d) bySerial[d.serial_number] = d;
});
load();
}
async function setFriendlyName(serial: string, name: string) {
const updated = await api.patch<Device>(`/api/devices/${serial}`, {
friendly_name: name,
});
bySerial[serial] = updated;
}
async function refresh(serial: string) {
const res = await api.post<{ device: Device }>(
`/api/devices/${serial}/refresh`,
);
bySerial[serial] = res.device;
}
// Pin a pio env (manual override) or release to auto-detect (env=null).
async function setEnv(serial: string, env: string | null) {
const updated = await api.put<Device>(`/api/devices/${serial}/env`, {
env,
});
bySerial[serial] = updated;
}
// Pin (or clear, with location=null) which uhubctl hub port the node sits on.
async function setHubPort(
serial: string,
location: string | null,
port: number | null,
) {
const updated = await api.put<Device>(`/api/devices/${serial}/hub-port`, {
location,
port,
});
bySerial[serial] = updated;
}
// Auto-bind the node to its hub port (unique VID match) or get candidates.
async function locate(serial: string) {
const res = await api.post<{
located: boolean;
device: Device;
candidates: { location: string; port: number }[];
}>(`/api/devices/${serial}/locate`);
if (res.located && res.device) bySerial[serial] = res.device;
return res;
}
// Cut/restore/cycle USB power to the node via its tracked hub port.
async function power(serial: string, action: "on" | "off" | "cycle") {
return api.post(`/api/devices/${serial}/power/${action}`);
}
return {
bySerial,
list,
load,
init,
setFriendlyName,
refresh,
setEnv,
setHubPort,
locate,
power,
};
});
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import type { FirmwareRef } from "../types";
import { useWsStore } from "./ws";
export const useFirmwareStore = defineStore("firmware", () => {
const ref_ = ref<FirmwareRef>({ available: false });
async function load() {
ref_.value = await api.get<FirmwareRef>("/api/firmware");
}
function init() {
const ws = useWsStore();
ws.subscribe("firmware.update", (r: FirmwareRef) => {
ref_.value = r;
});
load();
}
return { ref: ref_, load, init };
});
+171
View File
@@ -0,0 +1,171 @@
import { defineStore } from "pinia";
import { computed, reactive, ref } from "vue";
import { api } from "../api/client";
import type { TestLeaf, TestRun } from "../types";
import { useWsStore } from "./ws";
const TIERS = [
"bake",
"unit",
"mesh",
"telemetry",
"monitor",
"fleet",
"admin",
"provisioning",
] as const;
const MAX_LOG = 4000;
function pushBounded(arr: string[], line: string) {
arr.push(line);
if (arr.length > MAX_LOG) arr.splice(0, arr.length - MAX_LOG);
}
export const useTestsStore = defineStore("tests", () => {
const running = ref(false);
const runId = ref<number | null>(null);
const exitCode = ref<number | null>(null);
const runningNodeId = ref<string | null>(null);
const leaves = reactive<Record<string, TestLeaf>>({});
const stdout = reactive<string[]>([]);
const flash = reactive<string[]>([]);
const fwlog = reactive<string[]>([]);
const runs = ref<TestRun[]>([]);
const tiers = computed(() => {
const out: Record<
string,
{
passed: number;
failed: number;
skipped: number;
running: number;
total: number;
}
> = {};
for (const t of TIERS)
out[t] = { passed: 0, failed: 0, skipped: 0, running: 0, total: 0 };
for (const leaf of Object.values(leaves)) {
const t = out[leaf.tier];
if (!t) continue;
t.total++;
if (leaf.outcome === "running") t.running++;
else if (leaf.outcome in t) (t as any)[leaf.outcome]++;
}
return out;
});
const totals = computed(() => {
let passed = 0,
failed = 0,
skipped = 0;
for (const leaf of Object.values(leaves)) {
if (leaf.outcome === "passed") passed++;
else if (leaf.outcome === "failed") failed++;
else if (leaf.outcome === "skipped") skipped++;
}
return { passed, failed, skipped };
});
function reset() {
for (const k of Object.keys(leaves)) delete leaves[k];
stdout.length = 0;
flash.length = 0;
fwlog.length = 0;
exitCode.value = null;
}
function onProgress(d: any) {
switch (d.type) {
case "run_started":
reset();
running.value = true;
runId.value = d.run_id;
break;
case "run_finished":
running.value = false;
exitCode.value = d.exit_code;
runningNodeId.value = null;
loadRuns();
break;
case "register":
if (!leaves[d.nodeid])
leaves[d.nodeid] = {
nodeid: d.nodeid,
tier: d.tier,
file: d.file,
testname: d.testname,
outcome: "pending",
};
break;
case "running":
if (leaves[d.nodeid]) leaves[d.nodeid].outcome = "running";
runningNodeId.value = d.nodeid;
break;
case "outcome":
if (leaves[d.nodeid]) {
leaves[d.nodeid].outcome = d.outcome;
leaves[d.nodeid].duration = d.duration;
}
if (runningNodeId.value === d.nodeid) runningNodeId.value = null;
break;
}
}
function init() {
const ws = useWsStore();
ws.subscribe("test.progress", onProgress);
ws.subscribe("test.stdout", (d: any) =>
pushBounded(
stdout,
d.source === "stderr" ? `[stderr] ${d.line}` : d.line,
),
);
ws.subscribe("test.flash", (d: any) => pushBounded(flash, d.line));
ws.subscribe("fw.log", (d: any) =>
pushBounded(fwlog, `${d.port ?? ""} ${d.line}`.trim()),
);
loadStatus();
loadRuns();
}
async function loadStatus() {
const s = await api.get<any>("/api/tests/status");
running.value = s.running;
runId.value = s.run_id;
exitCode.value = s.exit_code;
}
async function loadRuns() {
runs.value = await api.get<TestRun[]>("/api/tests/runs");
}
async function start(args: string[]) {
await api.post("/api/tests/start", { args });
}
async function stop() {
await api.post("/api/tests/stop");
}
return {
running,
runId,
exitCode,
runningNodeId,
leaves,
stdout,
flash,
fwlog,
runs,
tiers,
totals,
tierOrder: TIERS,
init,
start,
stop,
loadRuns,
};
});
+78
View File
@@ -0,0 +1,78 @@
// Single WebSocket to /ws. Other stores register topic handlers; this store
// owns the socket, (re)subscribes on (re)connect, and dispatches frames.
import { defineStore } from "pinia";
import { ref } from "vue";
type Handler = (data: any) => void;
export const useWsStore = defineStore("ws", () => {
const connected = ref(false);
let socket: WebSocket | null = null;
const handlers = new Map<string, Set<Handler>>();
const subscribed = new Set<string>();
let reconnectTimer: number | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/ws`;
}
function connect() {
if (socket && socket.readyState <= WebSocket.OPEN) return;
socket = new WebSocket(url());
socket.onopen = () => {
connected.value = true;
// Re-subscribe everything after a reconnect.
for (const topic of subscribed) send({ action: "subscribe", topic });
};
socket.onclose = () => {
connected.value = false;
scheduleReconnect();
};
socket.onerror = () => socket?.close();
socket.onmessage = (ev) => {
try {
const frame = JSON.parse(ev.data);
const hs = handlers.get(frame.topic);
if (hs) for (const h of hs) h(frame.data);
} catch {
/* ignore malformed */
}
};
}
function scheduleReconnect() {
if (reconnectTimer != null) return;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connect();
}, 1500);
}
function send(msg: object) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
}
}
function subscribe(topic: string, handler: Handler) {
if (!handlers.has(topic)) handlers.set(topic, new Set());
handlers.get(topic)!.add(handler);
if (!subscribed.has(topic)) {
subscribed.add(topic);
send({ action: "subscribe", topic });
}
}
function unsubscribe(topic: string, handler: Handler) {
handlers.get(topic)?.delete(handler);
if (handlers.get(topic)?.size === 0) {
handlers.delete(topic);
subscribed.delete(topic);
send({ action: "unsubscribe", topic });
}
}
return { connected, connect, subscribe, unsubscribe };
});
+192
View File
@@ -0,0 +1,192 @@
@import "tailwindcss";
/* ------------------------------------------------------------------ *
* FleetSuite theme — built on the official Meshtastic design system
* (github.com/meshtastic/design): navy neutral scale + mint accent +
* the M3 dark tokens. We remap Tailwind's default scales onto the brand
* palette so the whole UI inherits it, then layer a distinct FleetSuite
* identity on top (see below).
*
* Differentiation from the Meshtastic app / FleetLog:
* - mint (#67EA94) is reserved for LIVE / HEALTHY / GO status only;
* - indigo (Blue scale) is FleetSuite's STRUCTURAL signature — wordmark,
* nav, section labels, card rails — a "test-bench instrument" look
* rather than the mint-dominant consumer surfaces.
* ------------------------------------------------------------------ */
@theme {
/* Neutral chrome → Meshtastic neutral / neutral-variant (navy-tinted) */
--color-slate-50: #f6f7fc;
--color-slate-100: #ecedf3;
--color-slate-200: #dadbe7;
--color-slate-300: #bdbfcf;
--color-slate-400: #9698b0;
--color-slate-500: #767892;
--color-slate-600: #444660;
--color-slate-700: #313347;
--color-slate-800: #242533;
--color-slate-900: #1a1b26;
--color-slate-950: #0f1017;
/* Status green → Meshtastic green scale (mint = brand accent at 400) */
--color-emerald-100: #e5fcee;
--color-emerald-200: #ccfadd;
--color-emerald-300: #8ff0b2;
--color-emerald-400: #67ea94;
--color-emerald-500: #3fb86d;
--color-emerald-600: #2d8f52;
--color-emerald-700: #246e41;
--color-emerald-800: #005c2e;
--color-emerald-900: #003d1a;
--color-emerald-950: #002e13;
/* Signature indigo → Meshtastic blue scale (FleetSuite structural accent) */
--color-indigo-100: #e0e3f8;
--color-indigo-200: #d0d8f5;
--color-indigo-300: #b0bff0;
--color-indigo-400: #9ba8e0;
--color-indigo-500: #7b8ad0;
--color-indigo-600: #5c6bc0;
--color-indigo-700: #2855a8;
--color-indigo-800: #1a3f8c;
--color-indigo-900: #002366;
--color-indigo-950: #001849;
/* sky aliases the same blue so existing sky-* utilities stay on-brand */
--color-sky-300: #b0bff0;
--color-sky-400: #9ba8e0;
--color-sky-500: #7b8ad0;
--color-sky-700: #2855a8;
--color-sky-800: #1a3f8c;
--color-sky-950: #001849;
/* Error → Meshtastic error scale */
--color-rose-200: #ffdad6;
--color-rose-300: #ffb4ab;
--color-rose-400: #e05252;
--color-rose-500: #e05252;
--color-rose-600: #ba1a1a;
--color-rose-700: #93000a;
--color-rose-800: #93000a;
--color-rose-950: #410002;
/* Warning → Meshtastic warning (amber) */
--color-amber-300: #f0c06a;
--color-amber-400: #e8a33e;
--color-amber-500: #e8a33e;
--color-amber-600: #c9851f;
--color-amber-950: #2a1b06;
}
:root {
color-scheme: dark;
--fs-bg: #0f1017; /* surfaceContainerLowest */
--fs-surface: #1a1b26; /* surface */
--fs-accent: #67ea94; /* mint — status only */
--fs-signature: #9ba8e0; /* indigo — structure */
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
color: #ecedf3; /* onSurface */
font-family:
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
/* Deep instrument backdrop: a faint indigo glow over near-black navy. */
background-color: var(--fs-bg);
background-image:
radial-gradient(
1200px 600px at 50% -10%,
rgba(92, 107, 192, 0.1),
transparent 70%
),
linear-gradient(180deg, #14151f 0%, var(--fs-bg) 60%);
background-attachment: fixed;
}
/* Monospace data readouts (serials, ports, logs) — instrument-panel feel. */
.mono {
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas,
monospace;
}
/* FleetSuite display lockup: tracked, mono — distinct from the app's sans. */
.fs-display {
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas,
monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* Section labels: indigo signature, uppercase, tracked. */
.section-label {
text-transform: uppercase;
letter-spacing: 0.13em;
font-size: 11px;
font-weight: 600;
color: var(--fs-signature);
}
/* Circular node identifier (Meshtastic standard): colored ring + initials,
* never a row-background wash. Color is bound inline per node. */
.node-id {
width: 34px;
height: 34px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
border: 2px solid currentColor;
background: rgba(255, 255, 255, 0.04);
box-shadow: 0 0 0 4px rgba(92, 107, 192, 0.06);
flex-shrink: 0;
}
/* Instrument card: a hairline accent rail inset along the top edge. */
.card-rail {
position: relative;
}
.card-rail::before {
content: "";
position: absolute;
left: 14px;
right: 14px;
top: 0;
height: 2px;
border-radius: 2px;
background: linear-gradient(
90deg,
var(--fs-signature),
var(--fs-accent) 65%,
transparent
);
opacity: 0.55;
}
.card-rail.is-offline::before {
background: #313347;
opacity: 0.5;
}
/* Thin scrollbars for the many log panes */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: #313347;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #444660;
}
+70
View File
@@ -0,0 +1,70 @@
export interface Device {
serial_number: string;
node_num: number | null;
friendly_name: string | null;
hw_model: string | null;
vid: string | null;
pid: string | null;
role: string | null;
current_port: string | null;
firmware_version: string | null;
region: string | null;
env: string | null;
env_locked: number;
flashed_fw_branch: string | null;
flashed_fw_sha: string | null;
flashed_at: number | null;
hub_location: string | null;
hub_port: number | null;
online: number;
first_seen: number;
last_seen: number;
has_stable_id: boolean;
stale: boolean;
}
export interface Camera {
id: number;
name: string;
type: string;
device_index: string | null;
backend: string | null;
rotation: number;
mirror: number;
enabled: number;
created_at: number;
device_serial: string | null;
assigned_at: number | null;
deleted?: boolean;
}
export interface FirmwareRef {
available: boolean;
branch?: string | null;
sha?: string | null;
short_sha?: string | null;
dirty?: boolean | null;
subject?: string | null;
committed_at?: string | null;
}
export interface TestLeaf {
nodeid: string;
tier: string;
file: string;
testname: string;
outcome: string; // pending | running | passed | failed | skipped
duration?: number | null;
}
export interface TestRun {
id: number;
started_at: number;
finished_at: number | null;
exit_code: number | null;
fw_branch: string | null;
fw_sha: string | null;
passed: number;
failed: number;
skipped: number;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Backend (FastAPI) dev address. The pywebview/production build serves the
// SPA from FastAPI itself, so these proxies only matter under `npm run dev`.
const BACKEND = "http://127.0.0.1:8765";
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
proxy: {
"/api": { target: BACKEND, changeOrigin: true },
"/ws": { target: BACKEND.replace("http", "ws"), ws: true },
},
},
build: {
// Built SPA is served by FastAPI from src/meshtastic_mcp/web/static.
outDir: "../src/meshtastic_mcp/web/static",
emptyOutDir: true,
},
});
-1
View File
@@ -53,7 +53,6 @@ build_flags = -Wno-missing-field-initializers
-DRADIOLIB_EXCLUDE_ADSB=1
-DRADIOLIB_EXCLUDE_LORAWAN=1
-DMESHTASTIC_EXCLUDE_DROPZONE=1
-DMESHTASTIC_EXCLUDE_ZPS=1
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
-DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1
-DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1
+2 -2
View File
@@ -14,8 +14,8 @@
#define FILE_O_READ "r"
#endif
#if defined(ARCH_STM32WL)
// STM32WL
#if defined(ARCH_STM32)
// STM32
#include "LittleFS.h"
#define FSCom InternalFS
#define FSBegin() FSCom.begin()
+11 -6
View File
@@ -14,6 +14,7 @@
* For more information, see: https://meshtastic.org/
*/
#include "power.h"
#include "BluetoothCommon.h"
#include "MessageStore.h"
#include "NodeDB.h"
#include "PowerFSM.h"
@@ -47,7 +48,7 @@
#include "concurrency/LockGuard.h"
#endif
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
#if defined(ARCH_STM32) && defined(BATTERY_PIN)
#include "stm32yyxx_ll_adc.h"
/* Analog read resolution */
@@ -430,7 +431,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
float scaled = 0;
battery_adcEnable();
#ifdef ARCH_STM32WL
#ifdef ARCH_STM32
// STM32 ADC with VREFINT runtime calibration
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
raw = analogRead(BATTERY_PIN);
@@ -607,7 +608,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
bool initial_read_done = false;
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
uint32_t last_read_time_ms = 0;
#ifdef ARCH_STM32WL
#ifdef ARCH_STM32
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
uint32_t Vref = 3300;
@@ -717,7 +718,7 @@ bool Power::analogInit()
#define BATTERY_SENSE_RESOLUTION_BITS 10
#endif
#ifdef ARCH_STM32WL
#ifdef ARCH_STM32
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
adc_oneshot_unit_init_cfg_t init_config = {
@@ -748,7 +749,7 @@ bool Power::analogInit()
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32)
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#endif
@@ -837,7 +838,7 @@ void Power::reboot()
}
LOG_DEBUG("final reboot!");
::reboot();
#elif defined(ARCH_STM32WL)
#elif defined(ARCH_STM32)
HAL_NVIC_SystemReset();
#else
rebootAtMsec = -1;
@@ -962,6 +963,10 @@ void Power::readPowerStatus()
lastLogTime = millis();
}
newStatus.notifyObservers(&powerStatus2);
// Mirror battery level to the BLE Battery Service (0x2A19); the platform layer clamps and dedupes.
if (hasBattery == OptTrue)
updateBatteryLevel(powerStatus2.getBatteryChargePercent());
#ifdef DEBUG_HEAP
if (lastheap != memGet.getFreeHeap()) {
// Use stack-allocated buffer to avoid heap allocations in monitoring code
+4
View File
@@ -219,7 +219,11 @@ static void darkEnter()
static void serialEnter()
{
LOG_POWERFSM("State: serialEnter");
#ifndef ARCH_NRF52
// nRF52 runs BLE on SoftDevice independently of USB serial — no need to disable it.
// (Same rationale as nbEnter() which already guards this with #ifdef ARCH_ESP32)
setBluetoothEnable(false);
#endif
if (screen) {
screen->setOn(true);
}
+89
View File
@@ -573,5 +573,94 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define USE_ETHERNET_DEFAULT 0
#endif
// -----------------------------------------------------------------------------
// MESHTASTIC_LOCKDOWN — runtime, client-toggleable hardening (nRF52 only)
//
// Lockdown/protect support is opt-in at build time. Builds that need it pass
// -DMESHTASTIC_ENABLE_LOCKDOWN=1. When enabled on nRF52 (CC310 hardware
// crypto), whether it is ACTIVE is decided entirely at runtime by
// EncryptedStorage::isLockdownActive()
// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
// that has never been provisioned — or that the operator disabled from the
// client app — behaves exactly like stock firmware: plaintext storage, no
// redaction, normal logging, normal display.
//
// The operator toggles lockdown from the client app:
// off -> on : provision a passphrase (AdminMessage.lockdown_auth). The
// firmware generates a DEK, encrypts the stored config, and
// authorizes the connection.
// on -> off : AdminMessage.lockdown_auth { disable=true } with the
// passphrase — decrypts storage back to plaintext and removes
// the DEK / token / monotonic-counter / backoff files, then
// reboots into normal mode. APPROTECT is the one thing that
// does NOT revert (see below).
//
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker. It gates the UI
// bits (lock screen, pairing-PIN handling). Flash-constrained nRF52 variants
// that genuinely cannot afford the ~tens-of-KB of crypto + access-control code
// may also opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
//
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — per-connection auth + redaction,
// gated at runtime on isLockdownActive()
// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest
// MESHTASTIC_ENABLE_APPROTECT — UICR APPROTECT capability. The actual
// one-way burn happens at runtime, only
// once provisioned, only on non-vulnerable
// silicon, and is STICKY: disabling
// lockdown does NOT (cannot) reverse it.
//
// DEBUG_MUTE is intentionally NOT coupled to lockdown — a capable-but-off
// device must log normally. Define DEBUG_MUTE separately for a silent build.
//
// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
// even when provisioned — for development so dev boards never lose SWD.
// -----------------------------------------------------------------------------
#if defined(ARCH_NRF52)
#ifndef MESHTASTIC_ENABLE_LOCKDOWN
#define MESHTASTIC_ENABLE_LOCKDOWN 0
#endif
#if !MESHTASTIC_ENABLE_LOCKDOWN
#undef MESHTASTIC_LOCKDOWN
#undef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
#undef MESHTASTIC_ENCRYPTED_STORAGE
#undef MESHTASTIC_ENABLE_APPROTECT
#ifndef MESHTASTIC_EXCLUDE_LOCKDOWN
#define MESHTASTIC_EXCLUDE_LOCKDOWN 1
#endif
#endif
#if MESHTASTIC_ENABLE_LOCKDOWN && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
#define MESHTASTIC_LOCKDOWN 1
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
#define MESHTASTIC_ENCRYPTED_STORAGE 1
#ifndef MESHTASTIC_LOCKDOWN_DEBUG
#define MESHTASTIC_ENABLE_APPROTECT 1
#endif
#endif
#endif
#ifdef MESHTASTIC_LOCKDOWN
// Per-boot uptime cap on unlocked sessions. 0 = unlimited (token-only
// enforcement, the existing behavior). When non-zero, every passphrase
// unlock (and every token-auto-unlock that inherits the value) arms a
// timer; on expiry the device lockNow()s and reboots into locked state.
// Bounds the total exposure window to bootsRemaining * this value if an
// attacker has physical possession but not the passphrase.
//
// Override at build time. Suggested:
// carry device: 3600 (1h sessions, periodic re-auth from phone)
// tower / infra node: 0 (default — relies on token TTLs only)
//
// A future LockdownAuth.max_session_seconds proto field will let the
// client set this per-token; until that lands the build-time value is
// the only source.
#ifndef MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS
#define MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS 0
#endif
#endif // MESHTASTIC_LOCKDOWN
#include "DebugConfiguration.h"
#include "RF95Configuration.h"
+5 -5
View File
@@ -37,15 +37,15 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
return firstOfOrNONE(11, types);
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
return firstOfOrNONE(12, types);
}
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
{
ScanI2C::DeviceType types[] = {MMC5983MA};
return firstOfOrNONE(1, types);
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
return firstOfOrNONE(2, types);
}
ScanI2C::FoundDevice ScanI2C::firstAQI() const
+2
View File
@@ -99,6 +99,8 @@ class ScanI2C
CW2015,
SCD30,
ADS1115,
IIS2MDCTR,
ISM330DHCX,
} DeviceType;
// typedef uint8_t DeviceAddress;
+15 -2
View File
@@ -8,7 +8,7 @@
#if defined(ARCH_PORTDUINO)
#include "linux/LinuxHardwareI2C.h"
#endif
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
#include "meshUtils.h" // vformat
#endif
@@ -584,6 +584,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
if (registerValue == 0x6A) {
type = LSM6DS3;
logFoundDevice("LSM6DS3", (uint8_t)addr.address);
} else if (registerValue == 0x6B) {
type = ISM330DHCX;
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
} else {
type = QMI8658;
logFoundDevice("QMI8658", (uint8_t)addr.address);
@@ -591,7 +594,17 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
SCAN_SIMPLE_CASE(QMC5883L_ADDR, QMC5883L, "QMC5883L", (uint8_t)addr.address)
SCAN_SIMPLE_CASE(HMC5883L_ADDR, HMC5883L, "HMC5883L", (uint8_t)addr.address)
case HMC5883L_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x4FU), 1); // get ID
if (registerValue == 0x40) {
type = IIS2MDCTR;
logFoundDevice("IIS2MDCTR", (uint8_t)addr.address);
break;
} else {
type = HMC5883L;
logFoundDevice("HMC5883L", (uint8_t)addr.address);
break;
}
#ifdef HAS_QMA6100P
SCAN_SIMPLE_CASE(QMA6100P_ADDR, QMA6100P, "QMA6100P", (uint8_t)addr.address)
#else
+356 -15
View File
@@ -17,7 +17,10 @@
#include "main.h" // pmu_found
#include "sleep.h"
#include "FSCommon.h"
#include "GPSUpdateScheduling.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "cas.h"
#include "ubx.h"
@@ -44,7 +47,7 @@ template <typename T, std::size_t N> std::size_t array_count(const T (&)[N])
#if defined(ARCH_NRF52)
Uart *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
HardwareSerial *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_RP2040)
SerialUART *GPS::_serial_gps = &GPS_SERIAL_PORT;
@@ -71,6 +74,112 @@ static struct uBloxGnssModelInfo {
#define GPS_SOL_EXPIRY_MS 5000 // in millis. give 1 second time to combine different sentences. NMEA Frequency isn't higher anyway
#define NMEA_MSG_GXGSA "GNGSA" // GSA message (GPGSA, GNGSA etc)
namespace
{
// Versioned on-disk record for persisted GPS probe results.
constexpr uint32_t GPS_PROBE_CACHE_MAGIC = 0x47504348UL; // "GPCH"
constexpr uint16_t GPS_PROBE_CACHE_VERSION = 1;
constexpr const char *GPS_PROBE_CACHE_FILE = "/prefs/gps_probe_cache.dat";
constexpr int MIN_PLAUSIBLE_GPS_YEAR = 2020;
constexpr int MAX_PLAUSIBLE_GPS_YEAR = 2100;
#ifdef TRACKER_T1000_E
constexpr uint32_t T1000_E_AIROHA_WAKE_MS = 1000;
constexpr uint32_t T1000_E_AIROHA_WAKE_INTERVAL_MS = 40;
#endif
struct GPSProbeCacheRecord {
uint32_t magic;
uint16_t version;
uint16_t reserved;
uint32_t baud;
uint8_t model;
};
bool isValidGnssModel(uint8_t model)
{
// Keep persisted values bounded to known enum range.
return model <= static_cast<uint8_t>(GNSS_MODEL_CM121);
}
bool isValidProbeBaud(uint32_t baud)
{
// Conservative sanity range for UART baud values.
return baud >= 1200 && baud <= 921600;
}
template <typename T> void wakeAirohaForActiveProbe(T *serialGps)
{
#ifdef TRACKER_T1000_E
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
digitalWrite(GPS_RTC_INT, HIGH);
delay(3);
digitalWrite(GPS_RTC_INT, LOW);
delay(50);
const uint32_t start = millis();
do {
serialGps->write("$PAIR382,1*2E\r\n");
delay(T1000_E_AIROHA_WAKE_INTERVAL_MS);
} while (Throttle::isWithinTimespanMs(start, T1000_E_AIROHA_WAKE_MS));
#elif defined(GNSS_AIROHA)
serialGps->write("$PAIR382,1*2E\r\n");
delay(20);
#else
(void)serialGps;
#endif
}
bool isPlausibleNmeaTime(const struct tm &t)
{
const int year = t.tm_year + 1900;
if (year < MIN_PLAUSIBLE_GPS_YEAR || year > MAX_PLAUSIBLE_GPS_YEAR) {
return false;
}
#ifdef BUILD_EPOCH
const int64_t candidate = static_cast<int64_t>(gm_mktime(&t));
const int64_t minEpoch = static_cast<int64_t>(BUILD_EPOCH);
const int64_t maxEpoch = minEpoch + static_cast<int64_t>(FORTY_YEARS);
return candidate >= minEpoch && candidate <= maxEpoch;
#else
return true;
#endif
}
template <typename T> bool sawNmeaSentenceAtBaud(T *serialGps, uint32_t timeoutMs)
{
// Lightweight passive check: look for at least one complete
// "$...,<field>\n" style NMEA sentence.
const uint32_t deadline = millis() + timeoutMs;
bool sawDollar = false;
bool sawComma = false;
while ((int32_t)(millis() - deadline) < 0) {
while (serialGps->available()) {
char c = static_cast<char>(serialGps->read());
if (c == '$') {
sawDollar = true;
sawComma = false;
continue;
}
if (c == ',') {
sawComma = true;
}
if (c == '\n' || c == '\r') {
if (sawDollar && sawComma) {
return true;
}
sawDollar = false;
sawComma = false;
}
}
delay(10);
}
return false;
}
} // namespace
// For logging
static const char *getGPSPowerStateString(GPSPowerState state)
{
@@ -492,6 +601,201 @@ static const int rareSerialSpeeds[3] = {4800, 57600, GPS_BAUDRATE};
#define GPS_PROBETRIES 2
#endif
bool GPS::loadProbeCache()
{
#ifdef FSCom
// Load the last known-good GPS model/baud pair so we can avoid a full probe
// sweep on every boot.
triedProbeCache = true; // Latch this boot's load attempt, even if no cache.
GPSProbeCacheRecord record = {};
size_t bytesRead = 0;
spiLock->lock();
auto file = FSCom.open(GPS_PROBE_CACHE_FILE, FILE_O_READ);
if (!file) {
spiLock->unlock();
return false;
}
bytesRead = file.read(reinterpret_cast<uint8_t *>(&record), sizeof(record));
file.close();
spiLock->unlock();
const bool headerValid = (bytesRead == sizeof(record)) && (record.magic == GPS_PROBE_CACHE_MAGIC) &&
(record.version == GPS_PROBE_CACHE_VERSION) && (record.reserved == 0U);
if (!headerValid || !isValidGnssModel(record.model) || !isValidProbeBaud(record.baud)) {
clearProbeCache(); // Drop corrupt/invalid cache so next boot can
// recover.
return false;
}
cachedProbeBaud = static_cast<int32_t>(record.baud);
cachedProbeModel = static_cast<GnssModel_t>(record.model);
hasProbeCache = true;
triedProbeCache = false;
LOG_INFO("Loaded cached GPS probe: baud=%u", record.baud);
return true;
#else
return false;
#endif
}
void GPS::clearProbeCache()
{
// Invalidate in-memory and on-disk cache so next boot is forced to do a
// full probe.
hasProbeCache = false;
triedProbeCache = true;
cachedProbeBaud = 0;
cachedProbeModel = GNSS_MODEL_UNKNOWN;
#ifdef FSCom
spiLock->lock();
if (FSCom.exists(GPS_PROBE_CACHE_FILE)) {
FSCom.remove(GPS_PROBE_CACHE_FILE);
}
spiLock->unlock();
#endif
}
bool GPS::saveProbeCache() const
{
#ifdef FSCom
if (gnssModel == GNSS_MODEL_UNKNOWN || !isValidGnssModel(static_cast<uint8_t>(gnssModel)) ||
!isValidProbeBaud(detectedBaud)) {
return false;
}
spiLock->lock();
FSCom.mkdir("/prefs");
spiLock->unlock();
GPSProbeCacheRecord record = {
GPS_PROBE_CACHE_MAGIC, GPS_PROBE_CACHE_VERSION, 0, static_cast<uint32_t>(detectedBaud), static_cast<uint8_t>(gnssModel),
};
auto file = SafeFile(GPS_PROBE_CACHE_FILE, true);
spiLock->lock();
const size_t written = file.write(reinterpret_cast<const uint8_t *>(&record), sizeof(record));
spiLock->unlock();
return (written == sizeof(record)) && file.close();
#else
return false;
#endif
}
bool GPS::verifyCachedProbePresence()
{
if (!hasProbeCache || cachedProbeModel == GNSS_MODEL_UNKNOWN || !isValidProbeBaud(cachedProbeBaud)) {
return false;
}
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
_serial_gps->end();
_serial_gps->begin(cachedProbeBaud);
#elif defined(ARCH_RP2040)
_serial_gps->end();
_serial_gps->setFIFOSize(256);
_serial_gps->begin(cachedProbeBaud);
#else
if (_serial_gps->baudRate() != cachedProbeBaud) {
LOG_DEBUG("Set GPS Baud to %i (cached verify)", cachedProbeBaud);
_serial_gps->updateBaudRate(cachedProbeBaud);
}
#endif
// Before trusting cached model/baud, require either active model-specific
// response or passive NMEA flow.
clearBuffer();
bool present = false;
// Model-specific "active ping" checks to avoid false stale decisions on
// modules that start streaming late.
const char *cachedProbeModelName = "UNKNOWN";
switch (cachedProbeModel) {
case GNSS_MODEL_MTK:
cachedProbeModelName = "L76K/MTK";
_serial_gps->write("$PCAS06,0*1B\r\n");
present = (getACK("$GPTXT,01,01,02,SW=", 700) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_MTK_L76B:
cachedProbeModelName = "L76B";
case GNSS_MODEL_MTK_PA1010D:
if (cachedProbeModel == GNSS_MODEL_MTK_PA1010D)
cachedProbeModelName = "PA1010D";
case GNSS_MODEL_MTK_PA1616S:
if (cachedProbeModel == GNSS_MODEL_MTK_PA1616S)
cachedProbeModelName = "PA1616S";
case GNSS_MODEL_LS20031:
if (cachedProbeModel == GNSS_MODEL_LS20031)
cachedProbeModelName = "LS20031";
_serial_gps->write("$PMTK605*31\r\n");
present = (getACK("$PMTK705", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_AG3335:
cachedProbeModelName = "AG3335";
case GNSS_MODEL_AG3352:
if (cachedProbeModel == GNSS_MODEL_AG3352)
cachedProbeModelName = "AG3352";
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR021*39\r\n");
present = (getACK("$PAIR021,", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_ATGM336H:
cachedProbeModelName = "ATGM336H";
_serial_gps->write("$PCAS06,1*1A\r\n");
present = (getACK("$GPTXT,01,01,02,HW=ATGM", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_UC6580:
cachedProbeModelName = "UC6580/UM600";
_serial_gps->write("$PDTINFO\r\n");
present = (getACK("UC6580", 900) == GNSS_RESPONSE_OK) || (getACK("UM600", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_CM121:
cachedProbeModelName = "CM121";
_serial_gps->write("$PDTINFO\r\n");
present = (getACK("CM121", 900) == GNSS_RESPONSE_OK);
break;
case GNSS_MODEL_UBLOX6:
case GNSS_MODEL_UBLOX7:
case GNSS_MODEL_UBLOX8:
case GNSS_MODEL_UBLOX9:
case GNSS_MODEL_UBLOX10: {
if (cachedProbeModel == GNSS_MODEL_UBLOX6)
cachedProbeModelName = "U-blox 6";
else if (cachedProbeModel == GNSS_MODEL_UBLOX7)
cachedProbeModelName = "U-blox 7";
else if (cachedProbeModel == GNSS_MODEL_UBLOX8)
cachedProbeModelName = "U-blox 8";
else if (cachedProbeModel == GNSS_MODEL_UBLOX9)
cachedProbeModelName = "U-blox 9";
else if (cachedProbeModel == GNSS_MODEL_UBLOX10)
cachedProbeModelName = "U-blox 10";
uint8_t cfg_rate[] = {0xB5, 0x62, 0x06, 0x08, 0x00, 0x00, 0x00, 0x00};
UBXChecksum(cfg_rate, sizeof(cfg_rate));
_serial_gps->write(cfg_rate, sizeof(cfg_rate));
present = (getACK(0x06, 0x08, 900) != GNSS_RESPONSE_NONE);
break;
}
default:
break;
}
if (!present) {
// Some modules may not respond to probes while still streaming NMEA, so
// allow a passive fallback check.
present = sawNmeaSentenceAtBaud(_serial_gps, 3000);
}
if (!present) {
LOG_WARN("Cached GPS probe is stale (%s @ %d), clearing cache", cachedProbeModelName, cachedProbeBaud);
clearProbeCache();
return false;
}
detectedBaud = cachedProbeBaud;
gnssModel = cachedProbeModel;
LOG_INFO("Using cached GPS probe: %s @ %d", cachedProbeModelName, detectedBaud);
return true;
}
/**
* @brief Setup the GPS based on the model detected.
* We detect the GPS by cycling through a set of baud rates, first common then rare.
@@ -504,24 +808,39 @@ bool GPS::setup()
if (!didSerialInit) {
int msglen = 0;
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
if (probeTries < GPS_PROBETRIES) {
if (!hasProbeCache && !triedProbeCache) {
(void)loadProbeCache();
}
if (hasProbeCache && !triedProbeCache) {
triedProbeCache = true;
if (!verifyCachedProbePresence()) {
currentStep = 0;
speedSelect = 0;
probeTries = 0;
}
}
if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries < GPS_PROBETRIES) {
// No usable cache: walk common baud rates first.
gnssModel = probe(serialSpeeds[speedSelect]);
if (gnssModel == GNSS_MODEL_UNKNOWN) {
if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
speedSelect = 0;
++probeTries;
}
if (gnssModel != GNSS_MODEL_UNKNOWN) {
detectedBaud = serialSpeeds[speedSelect];
} else if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
speedSelect = 0;
++probeTries;
}
}
// Rare Serial Speeds
#ifndef CONFIG_IDF_TARGET_ESP32C6
if (probeTries == GPS_PROBETRIES) {
else if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries == GPS_PROBETRIES) {
// Then try less common baud rates before giving up.
gnssModel = probe(rareSerialSpeeds[speedSelect]);
if (gnssModel == GNSS_MODEL_UNKNOWN) {
if (currentStep == 0 && ++speedSelect == array_count(rareSerialSpeeds)) {
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
return true;
}
if (gnssModel != GNSS_MODEL_UNKNOWN) {
detectedBaud = rareSerialSpeeds[speedSelect];
} else if (currentStep == 0 && ++speedSelect == array_count(rareSerialSpeeds)) {
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
return true;
}
}
#endif
@@ -529,6 +848,7 @@ bool GPS::setup()
if (gnssModel != GNSS_MODEL_UNKNOWN) {
setConnected();
(void)saveProbeCache();
} else {
return false;
}
@@ -844,6 +1164,15 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
break;
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
clearBuffer();
#ifdef TRACKER_T1000_E
pinMode(GPS_VRTC_EN, OUTPUT);
digitalWrite(GPS_VRTC_EN, HIGH);
pinMode(GPS_SLEEP_INT, OUTPUT);
digitalWrite(GPS_SLEEP_INT, HIGH);
pinMode(GPS_RTC_INT, OUTPUT);
digitalWrite(GPS_RTC_INT, LOW);
pinMode(GPS_RESETB_OUT, INPUT_PULLUP);
#endif
powerMon->setState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinEN(true); // Power (EN pin): on
setPowerPMU(true); // Power (PMU): on
@@ -1102,6 +1431,11 @@ int32_t GPS::runOnce()
if (!setup())
return currentDelay; // Setup failed, re-run in two seconds
if (gnssModel == GNSS_MODEL_UNKNOWN) {
LOG_WARN("GPS not detected; marked not present for this boot");
return disable();
}
// We have now loaded our saved preferences from flash
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
return disable();
@@ -1277,7 +1611,7 @@ GnssModel_t GPS::probe(int serialSpeed)
switch (currentStep) {
case 0: {
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
_serial_gps->end();
_serial_gps->begin(serialSpeed);
#elif defined(ARCH_RP2040)
@@ -1298,6 +1632,9 @@ GnssModel_t GPS::probe(int serialSpeed)
digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); // assert for 10ms
delay(10);
digitalWrite(PIN_GPS_RESET, !GPS_RESET_MODE);
#ifdef TRACKER_T1000_E
delay(100);
#endif
// attempt to detect the chip based on boot messages
std::vector<ChipInfo> passive_detect = {
@@ -1350,6 +1687,7 @@ GnssModel_t GPS::probe(int serialSpeed)
}
case 3: {
/* Airoha (Mediatek) AG3335A/M/S, A3352Q, Quectel L89 2.0, SimCom SIM65M */
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF to reduce volume
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF to reduce volume
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
@@ -1634,7 +1972,7 @@ std::unique_ptr<GPS> GPS::createGps()
#elif defined(ARCH_NRF52)
_serial_gps->setPins(new_gps->rx_gpio, new_gps->tx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
#elif defined(ARCH_STM32WL)
#elif defined(ARCH_STM32)
_serial_gps->setTx(new_gps->tx_gpio);
_serial_gps->setRx(new_gps->rx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
@@ -1681,6 +2019,9 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s
t.tm_year = d.year() - 1900;
t.tm_isdst = false;
if (t.tm_mon > -1) {
if (!isPlausibleNmeaTime(t)) {
return false;
}
if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultSuccess) {
LOG_DEBUG("NMEA GPS time set %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour,
t.tm_min, t.tm_sec, ti.age());

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