mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
Add a read-only Channels page listing both transports' channels with the correct identifier per transport: Meshtastic by channel index (index, name, role) from /api/channels, and MeshCore by channel name plus the on-air hash from a new /api/meshcore/channels/detail endpoint. The MeshCore transport now retains the channel_hash it already fetches at enumeration (previously discarded); known_channels() and the existing /api/meshcore/channels endpoint are unchanged. Adds a Channels nav entry and /channels route. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
8.1 KiB
Python
205 lines
8.1 KiB
Python
"""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": []}
|
|
|
|
|
|
@router.get("/meshcore/channels/detail")
|
|
async def meshcore_channels_detail(request: Request):
|
|
"""Enumerated MeshCore channels with on-air hash, if connected.
|
|
|
|
Returns {"active": bool, "channels": [{"name": str, "hash": str|null}]}.
|
|
Routes by channel NAME (no slot/index), so no index is exposed here.
|
|
"""
|
|
connector = getattr(request.app.state, "connector", None)
|
|
mc = _find_child(connector, "meshcore")
|
|
if mc is not None and getattr(mc, "connected", False):
|
|
try:
|
|
channels = list(mc.channel_details())
|
|
except Exception:
|
|
channels = []
|
|
return {"active": True, "channels": channels}
|
|
return {"active": False, "channels": []}
|
|
|
|
|
|
@router.get("/meshcore/contacts")
|
|
async def meshcore_contacts(request: Request):
|
|
"""Roster of known MeshCore contacts 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:
|
|
contacts = list(mc.get_contacts())
|
|
except Exception:
|
|
contacts = []
|
|
return {"active": True, "contacts": contacts}
|
|
return {"active": False, "contacts": []}
|
|
|
|
|
|
@router.get("/meshcore/self")
|
|
async def meshcore_self(request: Request):
|
|
"""Companion self/connection status 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:
|
|
return mc.self_info()
|
|
except Exception:
|
|
return {"connected": False}
|
|
return {"connected": False}
|
|
|
|
|
|
@router.post("/meshcore/advert")
|
|
async def meshcore_send_advert(request: Request):
|
|
"""Broadcast a signed self-advertisement (flood=True) via MeshCore.
|
|
|
|
Returns {sent: bool, detail: str}. Returns {sent: false} when MeshCore
|
|
is not connected.
|
|
"""
|
|
connector = getattr(request.app.state, "connector", None)
|
|
mc = _find_child(connector, "meshcore")
|
|
if mc is None or not getattr(mc, "connected", False):
|
|
return {"sent": False, "detail": "MeshCore not connected"}
|
|
try:
|
|
send_fn = getattr(mc, "send_advert_async", None)
|
|
if send_fn is not None:
|
|
ok = bool(await send_fn())
|
|
else:
|
|
ok = bool(mc.send_advert())
|
|
detail = "Self-advert sent" if ok else "send_advert returned False"
|
|
logger.info("dashboard: meshcore manual advert sent=%s", ok)
|
|
return {"sent": ok, "detail": detail}
|
|
except Exception as exc:
|
|
logger.error("dashboard: meshcore advert error: %s", exc)
|
|
return {"sent": False, "detail": str(exc)}
|
|
|
|
|
|
@router.get("/meshcore/telemetry")
|
|
async def meshcore_telemetry(request: Request):
|
|
"""Cached telemetry readings for auto-polled MeshCore contacts.
|
|
|
|
Returns {active: bool, entries: list}. entries is [] (and active False)
|
|
when MeshCore is not connected.
|
|
"""
|
|
connector = getattr(request.app.state, "connector", None)
|
|
mc = _find_child(connector, "meshcore")
|
|
if mc is not None and getattr(mc, "connected", False):
|
|
try:
|
|
entries = list(mc.get_telemetry_cache())
|
|
except Exception:
|
|
entries = []
|
|
return {"active": True, "entries": entries}
|
|
return {"active": False, "entries": []}
|
|
|
|
|
|
@router.post("/meshcore/telemetry/poll")
|
|
async def meshcore_telemetry_poll(request: Request):
|
|
"""On-demand ('Poll now') telemetry request for a single MeshCore contact.
|
|
|
|
Body: {"contact": "<name-or-pubkey>"}. Returns {available, contact, data}
|
|
on success, or {available: False, detail: ...} when unavailable/inactive.
|
|
"""
|
|
connector = getattr(request.app.state, "connector", None)
|
|
mc = _find_child(connector, "meshcore")
|
|
if mc is None or not getattr(mc, "connected", False):
|
|
return {"available": False, "detail": "MeshCore not connected"}
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
contact = (body or {}).get("contact")
|
|
if not contact:
|
|
return {"available": False, "detail": "Missing 'contact'"}
|
|
try:
|
|
poll_fn = getattr(mc, "req_telemetry_async", None)
|
|
if poll_fn is not None:
|
|
data = await poll_fn(contact)
|
|
else:
|
|
data = mc.req_telemetry(contact)
|
|
if data is None:
|
|
return {"available": False, "contact": contact, "detail": "No telemetry response"}
|
|
return {"available": True, "contact": contact, "data": data}
|
|
except Exception as exc:
|
|
logger.error("dashboard: meshcore telemetry poll error: %s", exc)
|
|
return {"available": False, "contact": contact, "detail": str(exc)}
|
|
|
|
|
|
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(await connector.send_message_async(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(await connector.send_message_async(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
|