From 11bac716d04cb3a0b32f8e373abe190d43dbff51 Mon Sep 17 00:00:00 2001 From: malice Date: Fri, 3 Jul 2026 01:06:31 -0600 Subject: [PATCH] feat(dashboard): send-test-message + MeshCore channel list (#14) Add POST /api/mesh/test-send (fire a labeled test broadcast on a chosen mesh+channel via the live transport) and GET /api/meshcore/channels (surface the companion's enumerated channel names). "Send test message" cards on both Connection pages, with the MeshCore one listing real channels. Lets the operator confirm a mesh's send path on demand. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/dashboard-frontend/src/lib/api.ts | 23 +++ .../src/pages/MeshCoreConnection.tsx | 84 ++++++++- .../src/pages/MeshtasticConnection.tsx | 62 ++++++- work/meshai/dashboard/api/mesh_send_routes.py | 85 ++++++++++ work/meshai/dashboard/server.py | 2 + work/meshai/transport/composite_transport.py | 9 + work/meshai/transport/meshcore_transport.py | 4 + work/tests/test_mesh_send_api.py | 159 ++++++++++++++++++ 8 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 work/meshai/dashboard/api/mesh_send_routes.py create mode 100644 work/tests/test_mesh_send_api.py diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index 631d051..50190e7 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -481,3 +481,26 @@ export async function fetchHotspots(): Promise { export async function fetchRegions(): Promise { return fetchJson('/api/regions') } + +export interface MeshcoreChannels { active: boolean; channels: string[] } +export interface TestSendResult { sent: boolean; detail: string } + +export async function getMeshcoreChannels(): Promise { + return fetchJson('/api/meshcore/channels') +} + +export async function sendTestMessage(body: { + transport: 'meshtastic' | 'meshcore' + channel: string | number + text?: string +}): Promise { + const response = await fetch('/api/mesh/test-send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + return response.json() +} diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx index 859b1f6..a44208f 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' import { TextInput, NumberInput } from './Config' import { notifyRestartRequired } from '@/components/RestartBanner' -import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' +import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api' import { useDirty } from '@/context/DirtyContext' // Only the fields this page edits are typed explicitly; the rest of the @@ -29,6 +29,14 @@ export default function MeshCoreConnection() { const [success, setSuccess] = useState(null) const [hasChanges, setHasChanges] = useState(false) + // Test send state + const [channelsActive, setChannelsActive] = useState(false) + const [channels, setChannels] = useState([]) + const [selectedChannel, setSelectedChannel] = useState('') + const [testText, setTestText] = useState('') + const [testSending, setTestSending] = useState(false) + const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null) + const fetchConfig = useCallback(async () => { setLoading(true) try { @@ -49,6 +57,35 @@ export default function MeshCoreConnection() { fetchConfig() }, [fetchConfig]) + useEffect(() => { + getMeshcoreChannels() + .then((res) => { + setChannelsActive(res.active) + setChannels(res.channels) + if (res.channels.length > 0) setSelectedChannel(res.channels[0]) + }) + .catch(() => { + setChannelsActive(false) + }) + }, []) + + const handleTestSend = async () => { + setTestSending(true) + setTestResult(null) + try { + const result = await sendTestMessage({ + transport: 'meshcore', + channel: selectedChannel, + text: testText.trim() || undefined, + }) + setTestResult(result) + } catch (err) { + setTestResult({ sent: false, detail: err instanceof Error ? err.message : 'Send failed' }) + } finally { + setTestSending(false) + } + } + useEffect(() => { if (config && originalConfig) { setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) @@ -190,6 +227,51 @@ export default function MeshCoreConnection() { + + {/* Send test message card */} +
+
Send Test Message
+ {!channelsActive ? ( +

MeshCore not connected

+ ) : ( + <> +
+ + +
+ + + {testResult && ( + testResult.sent ? ( +
+ {testResult.detail} +
+ ) : ( +
{testResult.detail}
+ ) + )} + + )} +
) } diff --git a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx index 3e54582..7f3d744 100644 --- a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback } from 'react' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' -import { ConnectionSection, type ConnectionConfig } from './Config' +import { ConnectionSection, TextInput, NumberInput, type ConnectionConfig } from './Config' import { notifyRestartRequired } from '@/components/RestartBanner' -import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' +import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api' import { useDirty } from '@/context/DirtyContext' export default function MeshtasticConnection() { @@ -15,6 +15,29 @@ export default function MeshtasticConnection() { const [success, setSuccess] = useState(null) const [hasChanges, setHasChanges] = useState(false) + // Test send state + const [testChannel, setTestChannel] = useState(0) + const [testText, setTestText] = useState('') + const [testSending, setTestSending] = useState(false) + const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null) + + const handleTestSend = async () => { + setTestSending(true) + setTestResult(null) + try { + const result = await sendTestMessage({ + transport: 'meshtastic', + channel: testChannel, + text: testText.trim() || undefined, + }) + setTestResult(result) + } catch (err) { + setTestResult({ sent: false, detail: err instanceof Error ? err.message : 'Send failed' }) + } finally { + setTestSending(false) + } + } + const fetchConfig = useCallback(async () => { setLoading(true) try { @@ -143,6 +166,41 @@ export default function MeshtasticConnection() {
+ + {/* Send test message card */} +
+
Send Test Message
+ + + + {testResult && ( + testResult.sent ? ( +
+ {testResult.detail} +
+ ) : ( +
{testResult.detail}
+ ) + )} +
) } diff --git a/work/meshai/dashboard/api/mesh_send_routes.py b/work/meshai/dashboard/api/mesh_send_routes.py new file mode 100644 index 0000000..953a6af --- /dev/null +++ b/work/meshai/dashboard/api/mesh_send_routes.py @@ -0,0 +1,85 @@ +"""Dashboard 'send test message' API routes (meshtastic + meshcore).""" + +import logging +from datetime import datetime +from typing import Optional, Union + +from fastapi import APIRouter, Request +from pydantic import BaseModel + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["mesh-send"]) + + +def _find_child(connector, name: str): + """Find a child transport by transport_name โ€” handles bare transport or CompositeTransport.""" + if connector is None: + return None + if getattr(connector, "transport_name", None) == name: + return connector + children = getattr(connector, "children", None) + if children: + for c in children: + if getattr(c, "transport_name", None) == name: + return c + return None + + +@router.get("/meshcore/channels") +async def meshcore_channels(request: Request): + """List enumerated MeshCore channel names if a meshcore transport is connected.""" + connector = getattr(request.app.state, "connector", None) + mc = _find_child(connector, "meshcore") + if mc is not None and getattr(mc, "connected", False): + try: + names = list(mc.known_channels()) + except Exception: + names = [] + return {"active": True, "channels": names} + return {"active": False, "channels": []} + + +class TestSendRequest(BaseModel): + transport: str + channel: Union[str, int] + text: Optional[str] = None + + +@router.post("/mesh/test-send") +async def test_send(request: Request, body: TestSendRequest): + """Send a one-off test message over the requested transport/channel.""" + connector = getattr(request.app.state, "connector", None) + text = (body.text or "").strip() or f"๐Ÿงช MeshAI test โ€” {datetime.now().strftime('%H:%M')}" + + if body.transport == "meshtastic": + child = _find_child(connector, "meshtastic") + if child is None or not getattr(child, "connected", False): + result = {"sent": False, "detail": "meshtastic not connected"} + else: + try: + idx = int(body.channel) + except (ValueError, TypeError): + result = {"sent": False, "detail": f"invalid meshtastic channel index: {body.channel!r}"} + else: + ok = bool(connector.send_message(text, destination=None, channel=idx, transport="meshtastic")) + result = {"sent": ok, "detail": f"sent to meshtastic channel {idx}" if ok else "send returned False"} + elif body.transport == "meshcore": + child = _find_child(connector, "meshcore") + if child is None or not getattr(child, "connected", False): + result = {"sent": False, "detail": "meshcore not connected"} + else: + name = str(body.channel) + ok = bool(connector.send_message(text, destination=None, meshcore_channel=name, transport="meshcore")) + if ok: + result = {"sent": True, "detail": f"sent to '{name}'"} + else: + known = list(child.known_channels()) # re-checked after send (lazy re-enum may have run) + if name not in known: + result = {"sent": False, "detail": f"channel '{name}' not on companion โ€” known: {known}"} + else: + result = {"sent": False, "detail": f"send failed for channel '{name}'"} + else: + result = {"sent": False, "detail": f"unknown transport: {body.transport!r}"} + + logger.info("dashboard: test-send transport=%s channel=%s sent=%s", body.transport, body.channel, result["sent"]) + return result diff --git a/work/meshai/dashboard/server.py b/work/meshai/dashboard/server.py index 7139867..66400f5 100644 --- a/work/meshai/dashboard/server.py +++ b/work/meshai/dashboard/server.py @@ -52,6 +52,7 @@ def create_app() -> FastAPI: from .api.system_routes import router as system_router from .api.config_routes import router as config_router from .api.mesh_routes import router as mesh_router + from .api.mesh_send_routes import router as mesh_send_router from .api.env_routes import router as env_router from .api.alert_routes import router as alert_router from .api.notification_routes import router as notification_router @@ -62,6 +63,7 @@ def create_app() -> FastAPI: app.include_router(curation_router, prefix="/api") app.include_router(config_router, prefix="/api") app.include_router(mesh_router, prefix="/api") + app.include_router(mesh_send_router, prefix="/api") app.include_router(env_router, prefix="/api") app.include_router(alert_router, prefix="/api") diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index 2a2295d..c69b667 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -83,6 +83,15 @@ class CompositeTransport(MeshTransport): return c return None + def meshcore_child(self) -> Optional[MeshTransport]: + """Return the MeshCoreTransport child, or None if absent.""" + return self._by_name.get("meshcore") + + def known_channels(self) -> List[str]: + """Passthrough to the MeshCore child's known channels; [] if no meshcore child.""" + child = self.meshcore_child() + return child.known_channels() if child is not None else [] + # ------------------------------------------------------------------ # Routing decision helpers (factored out for unit-test access) # ------------------------------------------------------------------ diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 2d7b66d..adcccd6 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -138,6 +138,10 @@ class MeshCoreTransport(MeshTransport): "MeshCore: enumerated %d named channel(s)", len(self._chan_name_to_idx) ) + def known_channels(self) -> list[str]: + """Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect).""" + return list(self._chan_name_to_idx.keys()) + # ------------------------------------------------------------------ # Internal coroutines (run on the dedicated loop) # ------------------------------------------------------------------ diff --git a/work/tests/test_mesh_send_api.py b/work/tests/test_mesh_send_api.py new file mode 100644 index 0000000..84d290c --- /dev/null +++ b/work/tests/test_mesh_send_api.py @@ -0,0 +1,159 @@ +"""API tests for the dashboard 'send test message' routes. + +Uses a bare FastAPI() + TestClient with a hand-seeded ``app.state.connector`` +(MagicMock-based fakes). The connector fakes mimic a CompositeTransport: +``transport_name=None`` and an explicit iterable ``children`` list. +""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from meshai.dashboard.api.mesh_send_routes import router + + +def _child(transport_name, connected=True, known=None): + """Build a fake child transport (meshtastic/meshcore).""" + c = MagicMock() + c.transport_name = transport_name + c.connected = connected + if known is not None: + c.known_channels.return_value = list(known) + return c + + +def _composite(children, send_result=True): + """Build a fake CompositeTransport connector wrapping *children*. + + A bare MagicMock's auto-attrs are truthy and ``children`` is not + iterable, so set both explicitly. + """ + connector = MagicMock() + connector.transport_name = None + connector.children = list(children) + connector.send_message.return_value = send_result + return connector + + +def _client(connector): + app = FastAPI() + app.include_router(router, prefix="/api") + app.state.connector = connector + return TestClient(app) + + +# ============================================================================ +# POST /api/mesh/test-send โ€” meshcore +# ============================================================================ + + +def test_meshcore_success(): + mc = _child("meshcore", connected=True, known=["aida", "emergency"]) + connector = _composite([mc], send_result=True) + client = _client(connector) + + r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"}) + assert r.status_code == 200 + body = r.json() + assert body["sent"] is True + assert "aida" in body["detail"] + + connector.send_message.assert_called_once() + _, kwargs = connector.send_message.call_args + assert kwargs["meshcore_channel"] == "aida" + assert kwargs["transport"] == "meshcore" + assert kwargs["destination"] is None + + +def test_meshcore_unknown_channel(): + mc = _child("meshcore", connected=True, known=["aida"]) + connector = _composite([mc], send_result=False) + client = _client(connector) + + r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "ghost"}) + assert r.status_code == 200 + body = r.json() + assert body["sent"] is False + assert "not on companion" in body["detail"] + assert "aida" in body["detail"] + + +def test_meshcore_inactive(): + mt = _child("meshtastic", connected=True) + connector = _composite([mt]) + client = _client(connector) + + r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"}) + assert r.status_code == 200 + body = r.json() + assert body["sent"] is False + assert body["detail"] == "meshcore not connected" + + +# ============================================================================ +# POST /api/mesh/test-send โ€” meshtastic +# ============================================================================ + + +def test_meshtastic_success(): + mt = _child("meshtastic", connected=True) + connector = _composite([mt], send_result=True) + client = _client(connector) + + r = client.post("/api/mesh/test-send", json={"transport": "meshtastic", "channel": 0}) + assert r.status_code == 200 + body = r.json() + assert body["sent"] is True + + connector.send_message.assert_called_once() + _, kwargs = connector.send_message.call_args + assert kwargs["channel"] == 0 + assert kwargs["transport"] == "meshtastic" + + +# ============================================================================ +# Default text +# ============================================================================ + + +def test_default_text_when_omitted(): + mc = _child("meshcore", connected=True, known=["aida"]) + connector = _composite([mc], send_result=True) + client = _client(connector) + + r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"}) + assert r.status_code == 200 + assert r.json()["sent"] is True + + args, kwargs = connector.send_message.call_args + sent_text = kwargs["text"] if "text" in kwargs else args[0] + assert isinstance(sent_text, str) + assert sent_text.startswith("๐Ÿงช MeshAI test") + + +# ============================================================================ +# GET /api/meshcore/channels +# ============================================================================ + + +def test_meshcore_channels_active(): + mc = _child("meshcore", connected=True, known=["aida", "emergency"]) + connector = _composite([mc]) + client = _client(connector) + + r = client.get("/api/meshcore/channels") + assert r.status_code == 200 + assert r.json() == {"active": True, "channels": ["aida", "emergency"]} + + +def test_meshcore_channels_no_meshcore(): + mt = _child("meshtastic", connected=True) + connector = _composite([mt]) + client = _client(connector) + + r = client.get("/api/meshcore/channels") + assert r.status_code == 200 + assert r.json() == {"active": False, "channels": []}