mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(dashboard): send-test-message + MeshCore channel list
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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
47b56adab5
commit
2bee7dc453
8 changed files with 425 additions and 3 deletions
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
work/meshai/dashboard/api/mesh_send_routes.py
Normal file
85
work/meshai/dashboard/api/mesh_send_routes.py
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
159
work/tests/test_mesh_send_api.py
Normal file
159
work/tests/test_mesh_send_api.py
Normal 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": []}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue