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 <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-03 01:06:31 -06:00 committed by GitHub
commit 11bac716d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 425 additions and 3 deletions

View file

@ -481,3 +481,26 @@ export async function fetchHotspots(): Promise<HotspotsResponse> {
export async function fetchRegions(): Promise<RegionInfo[]> { export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions') return fetchJson<RegionInfo[]>('/api/regions')
} }
export interface MeshcoreChannels { active: boolean; channels: string[] }
export interface TestSendResult { sent: boolean; detail: string }
export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
return fetchJson<MeshcoreChannels>('/api/meshcore/channels')
}
export async function sendTestMessage(body: {
transport: 'meshtastic' | 'meshcore'
channel: string | number
text?: string
}): Promise<TestSendResult> {
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()
}

View file

@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { TextInput, NumberInput } from './Config' import { TextInput, NumberInput } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner' 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' import { useDirty } from '@/context/DirtyContext'
// Only the fields this page edits are typed explicitly; the rest of the // 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<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false) const [hasChanges, setHasChanges] = useState(false)
// Test send state
const [channelsActive, setChannelsActive] = useState(false)
const [channels, setChannels] = useState<string[]>([])
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 () => { const fetchConfig = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@ -49,6 +57,35 @@ export default function MeshCoreConnection() {
fetchConfig() fetchConfig()
}, [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(() => { useEffect(() => {
if (config && originalConfig) { if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
@ -190,6 +227,51 @@ export default function MeshCoreConnection() {
</Link> </Link>
</div> </div>
</div> </div>
{/* Send test message card */}
<div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}>
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
{!channelsActive ? (
<p className="text-sm text-slate-500">MeshCore not connected</p>
) : (
<>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Channel</label>
<select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{channels.map((ch) => (
<option key={ch} value={ch}>{ch}</option>
))}
</select>
</div>
<TextInput
label="Message (optional)"
value={testText}
onChange={setTestText}
placeholder={`\u{1F9EA} MeshAI test — ${new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}`}
/>
<button
onClick={handleTestSend}
disabled={testSending}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white text-sm transition-colors"
>
{testSending ? 'Sending...' : 'Send test'}
</button>
{testResult && (
testResult.sent ? (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />{testResult.detail}
</div>
) : (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{testResult.detail}</div>
)
)}
</>
)}
</div>
</div> </div>
) )
} }

View file

@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-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 { 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' import { useDirty } from '@/context/DirtyContext'
export default function MeshtasticConnection() { export default function MeshtasticConnection() {
@ -15,6 +15,29 @@ export default function MeshtasticConnection() {
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false) 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 () => { const fetchConfig = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@ -143,6 +166,41 @@ export default function MeshtasticConnection() {
<div className="bg-bg-card border border-border p-6"> <div className="bg-bg-card border border-border p-6">
<ConnectionSection data={config} onChange={setConfig} /> <ConnectionSection data={config} onChange={setConfig} />
</div> </div>
{/* Send test message card */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
<NumberInput
label="Channel Index"
value={testChannel}
onChange={setTestChannel}
min={0}
max={7}
helper="Meshtastic channel number (0 = primary)"
/>
<TextInput
label="Message (optional)"
value={testText}
onChange={setTestText}
placeholder={`\u{1F9EA} MeshAI test — ${new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}`}
/>
<button
onClick={handleTestSend}
disabled={testSending}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white text-sm transition-colors"
>
{testSending ? 'Sending...' : 'Send test'}
</button>
{testResult && (
testResult.sent ? (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />{testResult.detail}
</div>
) : (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{testResult.detail}</div>
)
)}
</div>
</div> </div>
) )
} }

View file

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

View file

@ -52,6 +52,7 @@ def create_app() -> FastAPI:
from .api.system_routes import router as system_router from .api.system_routes import router as system_router
from .api.config_routes import router as config_router from .api.config_routes import router as config_router
from .api.mesh_routes import router as mesh_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.env_routes import router as env_router
from .api.alert_routes import router as alert_router from .api.alert_routes import router as alert_router
from .api.notification_routes import router as notification_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(curation_router, prefix="/api")
app.include_router(config_router, prefix="/api") app.include_router(config_router, prefix="/api")
app.include_router(mesh_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(env_router, prefix="/api")
app.include_router(alert_router, prefix="/api") app.include_router(alert_router, prefix="/api")

View file

@ -83,6 +83,15 @@ class CompositeTransport(MeshTransport):
return c return c
return None 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) # Routing decision helpers (factored out for unit-test access)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -138,6 +138,10 @@ class MeshCoreTransport(MeshTransport):
"MeshCore: enumerated %d named channel(s)", len(self._chan_name_to_idx) "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) # Internal coroutines (run on the dedicated loop)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -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": []}