Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb9a2afedf | ||
|
|
4e45ec962e | ||
|
|
879a5c62c7 | ||
|
|
fcf2a52717 | ||
|
|
ab3f7f3223 | ||
|
|
859a04a61b | ||
|
|
9b19cbac05 | ||
|
|
a4ab067431 |
@@ -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
@@ -1,8 +1,8 @@
|
||||
.pio
|
||||
pio
|
||||
pio.tar
|
||||
web
|
||||
web.tar
|
||||
/.pio
|
||||
/pio
|
||||
/pio.tar
|
||||
/web
|
||||
/web.tar
|
||||
|
||||
# ignore vscode IDE settings files
|
||||
.vscode/*
|
||||
|
||||
Vendored
+41
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
Executable
+77
@@ -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"
|
||||
Executable
+32
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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`.
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+2258
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 left→right -->
|
||||
<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">from→to</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>
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// Backend (FastAPI) dev address. The pywebview/production build serves the
|
||||
// SPA from FastAPI itself, so these proxies only matter under `npm run dev`.
|
||||
const BACKEND = "http://127.0.0.1:8765";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": { target: BACKEND, changeOrigin: true },
|
||||
"/ws": { target: BACKEND.replace("http", "ws"), ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// Built SPA is served by FastAPI from src/meshtastic_mcp/web/static.
|
||||
outDir: "../src/meshtastic_mcp/web/static",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
+1
-1
Submodule protobufs updated: 1df6c11542...3625166717
Reference in New Issue
Block a user