From ffe839869e1ca78adab0c290864e0dd583f13df7 Mon Sep 17 00:00:00 2001
From: iceice400
Date: Fri, 12 Jun 2026 03:17:16 -0400
Subject: [PATCH 1/3] feat(dashboard): operator status strips, MQTT broker
health, map hint
Add console-style status footers on Stats, live MQTT runtime card on
Configuration, map overlay GPS hint, and Home Assistant cookbook docs.
Co-authored-by: Cursor
---
docs/HOME-ASSISTANT-COOKBOOK.md | 125 +++++++++++++++++++++++++
docs/plans/v0.7.8-operator-polish.md | 87 +++++++++++++++++
frontend/css/status_strip.css | 105 +++++++++++++++++++++
frontend/index.html | 2 +
frontend/js/components/node_map.js | 30 +++++-
frontend/js/configuration/mqtt_card.js | 80 ++++++++++++++++
frontend/js/stats_tab.js | 34 +++++++
frontend/js/status_strip.js | 65 +++++++++++++
src/api/routes/mqtt_config_routes.py | 55 ++++++++++-
src/api/server.py | 5 +-
src/coordinator.py | 4 +
src/relay/mqtt_publisher.py | 38 ++++++++
tests/test_mqtt_config_routes.py | 43 +++++++++
tests/test_mqtt_publisher.py | 12 +++
14 files changed, 678 insertions(+), 7 deletions(-)
create mode 100644 docs/HOME-ASSISTANT-COOKBOOK.md
create mode 100644 docs/plans/v0.7.8-operator-polish.md
create mode 100644 frontend/css/status_strip.css
create mode 100644 frontend/js/status_strip.js
diff --git a/docs/HOME-ASSISTANT-COOKBOOK.md b/docs/HOME-ASSISTANT-COOKBOOK.md
new file mode 100644
index 00000000..43630a93
--- /dev/null
+++ b/docs/HOME-ASSISTANT-COOKBOOK.md
@@ -0,0 +1,125 @@
+# Home Assistant Cookbook
+
+Copy-paste recipes for Meshpoint LAN automation. Enable the API first
+(see [API-AUTOMATION.md](./API-AUTOMATION.md)), then add these snippets
+to `configuration.yaml` or package files.
+
+All examples assume:
+
+- Meshpoint dashboard: `http://192.168.1.50:8080`
+- Token stored in `secrets.yaml` as `meshpoint_automation_token`
+
+## Node count sensor
+
+```yaml
+rest:
+ - resource: "http://192.168.1.50:8080/api/automation/nodes?limit=500"
+ headers:
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ scan_interval: 60
+ sensor:
+ - name: "Mesh Nodes"
+ unique_id: meshpoint_node_count
+ value_template: "{{ value_json | length }}"
+ unit_of_measurement: "nodes"
+ icon: mdi:access-point-network
+```
+
+## Meshpoint health binary
+
+Alerts when the automation API stops responding (power loss, service crash).
+
+```yaml
+rest:
+ - resource: "http://192.168.1.50:8080/api/automation/status"
+ headers:
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ scan_interval: 30
+ binary_sensor:
+ - name: "Meshpoint Online"
+ unique_id: meshpoint_online
+ value_template: "{{ value_json.uptime_seconds is defined }}"
+ device_class: connectivity
+```
+
+## Relay activity sensor
+
+```yaml
+rest:
+ - resource: "http://192.168.1.50:8080/api/automation/status"
+ headers:
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ scan_interval: 60
+ sensor:
+ - name: "Meshpoint Relayed"
+ unique_id: meshpoint_relayed_total
+ value_template: "{{ value_json.relay.relayed | default(0) }}"
+ unit_of_measurement: "packets"
+ - name: "Meshpoint Relay Rejected"
+ unique_id: meshpoint_relay_rejected
+ value_template: "{{ value_json.relay.rejected | default(0) }}"
+ unit_of_measurement: "packets"
+```
+
+## Broadcast from an automation
+
+```yaml
+rest_command:
+ meshpoint_broadcast:
+ url: "http://192.168.1.50:8080/api/automation/send"
+ method: POST
+ headers:
+ Content-Type: "application/json"
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ payload: '{"text":"{{ message }}","channel":0,"destination":"broadcast"}'
+
+automation:
+ - alias: "Mesh morning check-in"
+ trigger:
+ - platform: time
+ at: "08:00:00"
+ action:
+ - service: rest_command.meshpoint_broadcast
+ data:
+ message: "Good morning from Home Assistant"
+```
+
+## Alert when node count drops
+
+Useful when a backbone node disappears from the mesh.
+
+```yaml
+automation:
+ - alias: "Mesh node count drop"
+ trigger:
+ - platform: numeric_state
+ entity_id: sensor.mesh_nodes
+ below: 5
+ for: "00:05:00"
+ action:
+ - service: notify.mobile_app
+ data:
+ title: "Meshpoint alert"
+ message: "Node count fell below 5 for 5 minutes"
+```
+
+## MQTT + Home Assistant (packet stream)
+
+If `mqtt.enabled` is true, Meshpoint can publish decoded packets to your
+broker. Enable **Home Assistant auto-discovery** under Configuration β
+MQTT, or subscribe manually to `msh//2/json/...` topics.
+
+The Configuration MQTT card shows live broker health: connection state,
+publish count, disconnect count, and topic prefix.
+
+## Node-RED
+
+Use an **http request** node pointed at `/api/automation/status` or
+`/api/automation/nodes` with header `X-Meshpoint-Token`. Poll every 30β60 s
+for dashboards; use inject + POST for one-shot messages.
+
+## Security notes
+
+- Keep `automation.token` at 32+ random characters.
+- Do not expose port 8080 to the internet.
+- MQTT credentials live in `config/local.yaml`; treat that file like a secret.
diff --git a/docs/plans/v0.7.8-operator-polish.md b/docs/plans/v0.7.8-operator-polish.md
new file mode 100644
index 00000000..d6e561f0
--- /dev/null
+++ b/docs/plans/v0.7.8-operator-polish.md
@@ -0,0 +1,87 @@
+# v0.7.8 Operator Polish
+
+**Purpose:** Small, green-tier improvements that help field operators trust
+dashboard data and integrate Meshpoint with LAN automation. Submit **after**
+Tier A (v0.7.7) PRs begin merging; **one PR at a time**.
+
+**Risk:** π’ Frontend + read-only backend surfaces.
+
+---
+
+## Queue
+
+| # | Branch | Title | Depends on | Status |
+|---|--------|-------|------------|--------|
+| 17 | `feat/operator-polish` | Operator status strips + MQTT broker health + map hint | Tier A baseline | β
Ready |
+| 18 | `feat/stats-traffic-heatmap` | SVG traffic heatmap (extends #69 histogram data) | **#69 merged** | β¬ Not started |
+| 19 | `feat/analytics-status-strips` | RF + Unknown RF + Topology tab footers | **#72, #79, #80 merged** | π Partial (#72) |
+| 20 | `docs/home-assistant-cookbook` | Expanded HA recipes (ships with #17 docs) | β | β
In #17 |
+
+---
+
+## PR 17 β Operator polish (green)
+
+| | |
+|---|---|
+| **Branch** | `feat/operator-polish` |
+| **Title** | `feat(dashboard): operator status strips and MQTT broker health` |
+| **Risk** | π’ |
+
+### What ships
+
+1. **Status strip component** (`status_strip.js`, `status_strip.css`)
+ - Console-style footer: `TRAFFIC Β· concentrator Β· 8,247 pkts Β· updated 14s ago`
+ - Wired on Stats tab (more tabs in PR 19 after their parent PRs merge)
+
+2. **MQTT broker health** (`GET /api/config/mqtt/runtime`)
+ - Live connection, publish count, disconnect count, topic prefix
+ - Configuration β MQTT card banner (no restart required to view)
+
+3. **Map topology GPS hint**
+ - Amber banner when Topology Links overlay is on but no lines draw
+ - Points operators to Topology tab for logical graph
+
+4. **Home Assistant cookbook** (`docs/HOME-ASSISTANT-COOKBOOK.md`)
+
+### Test plan
+
+- [ ] Stats tab footer updates on refresh
+- [ ] MQTT card shows connected/disabled/offline states
+- [ ] Map hint appears when links exist without GPS on both ends
+- [ ] `python -m unittest tests.test_mqtt_publisher tests.test_mqtt_config_routes`
+
+---
+
+## PR 18 β Traffic heatmap (green, follow-up to #69)
+
+Do **not** rewrite #69 mid-merge. After #69 lands, add optional SVG
+hour Γ protocol heatmap beside the 24h bar chart using the same SQL.
+
+---
+
+## PR 19 β Analytics tab footers (green)
+
+After #72 (topology), #79 (RF), and #80 (unknown RF) merge, add
+`StatusStrip` footers:
+
+| Tab | Label | Example |
+|-----|-------|---------|
+| Topology | `TOPOLOGY` | `47 nodes Β· 23 links Β· neighborinfo+routing Β· updated 2m ago` |
+| RF Environment | `RF ENV` | `noise -112 dBm Β· live scan Β· 48 samples Β· updated 5s ago` |
+| Unknown RF | `UNKNOWN RF` | `12 frames Β· 3 SF buckets Β· updated 1m ago` |
+
+Rebase each tab's branch and add ~20 lines; or land as one PR on top of
+merged analytics tabs.
+
+---
+
+## Submission order
+
+```
+Tier A merges (v0.7.7)
+ β PR 17 operator-polish
+ β wait for #69 merge β PR 18 heatmap
+ β wait for #72/#79/#80 β PR 19 tab footers
+```
+
+Never open PR 17 and PR 18 simultaneously.
diff --git a/frontend/css/status_strip.css b/frontend/css/status_strip.css
new file mode 100644
index 00000000..aa3dc0c4
--- /dev/null
+++ b/frontend/css/status_strip.css
@@ -0,0 +1,105 @@
+/* Operator status footer β console-style telemetry in panel chrome. */
+
+.status-strip {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-top: 1.25rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(255, 255, 255, 0.04);
+ background: rgba(6, 182, 212, 0.03);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+}
+
+.status-strip__label {
+ color: var(--text-secondary);
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ font-size: 0.62rem;
+}
+
+.status-strip__sep {
+ opacity: 0.35;
+}
+
+.status-strip__items {
+ color: var(--accent-cyan);
+ text-shadow: 0 0 6px rgba(6, 182, 212, 0.12);
+}
+
+.status-strip__updated {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.status-strip--quiet .status-strip__items {
+ color: var(--text-muted);
+ text-shadow: none;
+ font-style: italic;
+}
+
+.map-topology-hint {
+ position: absolute;
+ left: 50%;
+ bottom: 2.75rem;
+ transform: translateX(-50%);
+ z-index: 800;
+ max-width: min(92%, 28rem);
+ padding: 0.45rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(245, 158, 11, 0.35);
+ background: rgba(15, 23, 42, 0.92);
+ color: var(--accent-amber);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.03em;
+ text-align: center;
+ pointer-events: none;
+ box-shadow: var(--glow-amber);
+}
+
+.mqtt-runtime {
+ margin: 0 0 1rem;
+ padding: 0.55rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(0, 229, 160, 0.04);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.03em;
+ color: var(--text-muted);
+}
+
+.mqtt-runtime--offline {
+ background: rgba(239, 68, 68, 0.06);
+ border-color: rgba(239, 68, 68, 0.25);
+}
+
+.mqtt-runtime--disabled {
+ background: rgba(100, 116, 139, 0.08);
+}
+
+.mqtt-runtime__label {
+ color: var(--text-secondary);
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ font-size: 0.62rem;
+ margin-right: 0.35rem;
+}
+
+.mqtt-runtime__state {
+ color: var(--accent-green);
+}
+
+.mqtt-runtime--offline .mqtt-runtime__state {
+ color: var(--accent-red);
+}
+
+.mqtt-runtime--disabled .mqtt-runtime__state {
+ color: var(--text-muted);
+}
diff --git a/frontend/index.html b/frontend/index.html
index 239398cc..69a41a12 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -21,6 +21,7 @@
+
@@ -747,6 +748,7 @@ Service actions
+
diff --git a/frontend/js/components/node_map.js b/frontend/js/components/node_map.js
index c8a88af8..72564281 100644
--- a/frontend/js/components/node_map.js
+++ b/frontend/js/components/node_map.js
@@ -404,12 +404,33 @@ class NodeMap {
}, 200);
}
+ _showTopologyMapHint(show) {
+ let el = document.getElementById('map-topology-hint');
+ if (!el && this._map) {
+ el = document.createElement('div');
+ el.id = 'map-topology-hint';
+ el.className = 'map-topology-hint';
+ el.hidden = true;
+ this._map.getContainer().appendChild(el);
+ }
+ if (!el) return;
+ if (show) {
+ el.textContent =
+ 'Links need GPS on both endpoints. Open the Topology tab for the logical graph.';
+ el.hidden = false;
+ } else {
+ el.hidden = true;
+ }
+ }
+
async _loadTopology() {
try {
- const res = await fetch('/api/analytics/topology');
- const links = await res.json();
+ const res = await fetch('/api/analytics/topology?hours=24');
+ const data = await res.json();
+ const links = Array.isArray(data) ? data : (data.edges || []);
this._topologyLayer.clearLayers();
+ let drawn = 0;
for (const link of links) {
const srcMarker = this._markers[link.source];
const tgtMarker = this._markers[link.target];
@@ -434,9 +455,14 @@ class NodeMap {
line.bindTooltip(tooltip);
this._topologyLayer.addLayer(line);
+ drawn += 1;
}
+ this._showTopologyMapHint(
+ this._topologyVisible && links.length > 0 && drawn === 0,
+ );
} catch (e) {
console.error('Topology load failed:', e);
+ this._showTopologyMapHint(false);
}
}
diff --git a/frontend/js/configuration/mqtt_card.js b/frontend/js/configuration/mqtt_card.js
index 2678d460..c7738e4c 100644
--- a/frontend/js/configuration/mqtt_card.js
+++ b/frontend/js/configuration/mqtt_card.js
@@ -11,6 +11,7 @@ class MqttConfigCard {
this._root = null;
this._passwordDirty = false;
this._unsubDisplayUnits = null;
+ this._runtimeInterval = null;
}
mount(root) {
@@ -25,6 +26,10 @@ class MqttConfigCard {
are published. Undecrypted packets never leave the device.
+
+ BROKER
+ Loadingβ¦
+