Compare commits

...
8 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
78 changed files with 9641 additions and 1947 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
}
]
}
+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
}
}
]
}
+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)
+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,
},
});