meshai/work/meshai/dashboard/api/mesh_send_routes.py
malice 1a8ae710dd
MeshCore: channel provisioning, key sharing, location-based recommendations & transport-aware identity (#113)
* feat(meshcore): self-service add/remove channel provisioning from dashboard

Adds MeshCoreTransport.add_channel()/remove_channel() (meshcore_transport.py),
scanning the full 40-slot companion channel table for a free slot (no early
empty-run cutoff, unlike _enumerate_channels) and writing/clearing slots via
the only available write opcode (set_channel, 0x20) — the lib exposes no
delete-channel opcode, so removal writes the slot back to its empty state
(blank name + all-zero 16-byte secret).

POST /api/meshcore/channels and DELETE /api/meshcore/channels/{name} routes
(mesh_send_routes.py) validate name/hex-key and return the refreshed channel
list, matching the existing secrets_routes.py HTTPException idiom.

Frontend: Add-channel row (name + optional PSK hex) and a per-channel Remove
affordance inside the existing Observe MeshCore Channels block
(MeshCoreConnection.tsx), plus addMeshcoreChannel()/removeMeshcoreChannel()
API client functions (api.ts).

Built + deployed to CT 108 (docker compose build && up -d, container
healthy); self-cleaning smoke test passed — POST/DELETE of a #meshai-test
channel left the companion table exactly as found.

* MeshCore: display per-channel key (PSK) with reveal + copy

The Observe MeshCore Channels list now shows each channel's PSK hex so
operators can share it with people who want to join. Fetches the
/api/meshcore/channels/detail endpoint (name + key) instead of the
names-only list; keys are masked by default with a per-row eye reveal
toggle and a copy-to-clipboard button. Channels with no retrievable key
show a dash. Existing observe (checkbox), remove (trash), and add-row
controls are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(llm): recommend a MeshCore channel by location + flash-lite default

Add a location-aware MeshCore channel-recommendation context block to the
single-shot LLM prompt so "what channel should I join?" gets an answer with
the channel NAME and join KEY.

- router.py: new standalone helper _build_meshcore_channel_block(config,
  channel_details, position) — lists ONLY observed channels
  (meshcore_context.observe_channels), each with its PSK key
  (channel_details) and, where a region_routes cell maps to it, the covered
  region's human geography (local name, cities, centroid) built from a
  channel->regions reverse map over notifications.region_routes.cells and a
  best-effort fuzzy attach to mesh_intelligence regions. Injected inside the
  existing should_inject_mesh gate, right after the region-geography block;
  fetches channel_details() + get_node_position() off self.connector and is
  fully fail-safe (any error skips the block, never breaks the LLM path).
  Empty observe_channels => empty block (LLM cannot invent channels).
- composite_transport.py: add channel_details() passthrough mirroring
  known_channels().
- README.md: bump the example llm model to gemini-2.5-flash-lite so a fresh
  deploy is policy-compliant (flash-lite supports Google Search grounding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(identity): transport-aware bot identity + alert-channel awareness

Fixes the LLM system prompt being transport-blind: MeshCore DMs were
being told they were on the freq51 Meshtastic mesh network and were
physical node !27780c47 (AIDA-N2), and MeshMonitor/node-health text
(Meshtastic-only) leaked into MeshCore prompts too.

- config.py: BotConfig gains mt_mesh_name/mt_node/mc_mesh_name (generic
  OSS defaults empty; name/owner defaults now generic MeshAI/Unknown).
  LLMConfig.system_prompt code default no longer hardcodes freq51/Twin
  Falls.
- router.py: generate_llm_response() computes transport once and
  branches identity: MeshCore gets an AIDA/MeshCore-radio framing with
  no MT node id; Meshtastic keeps the physical-node framing from the
  new config fields. MeshMonitor block and MT node-health/gateway/packet
  reporting (mesh_reporter Tier1/region/node detail + _MESH_AWARENESS_PROMPT
  + region geography) are now gated to transport == meshtastic. New
  _build_alert_channels_line() adds a short identity-block line on both
  transports listing region_routes-derived alert channels (MT channel
  index or MC channel name). MeshCore channel-recommendation block stays
  transport-neutral.
- README.md: dead gemini-2.5-flash-lite example updated to
  gemini-3.1-flash-lite.

Live config (meshai_data volume, not git-tracked): bot.name=AIDA,
bot.owner=K7ZVX (local.yaml identity.owner, which overrides config.yaml
per config_loader.py LOCAL_FIELDS), mt_mesh_name/mt_node set to the
freq51/AIDA-N2 values, mc_mesh_name left empty. llm.yaml system_prompt
freq51 line removed.

Verified via in-container prompt-assembly dry run through the real
generate_llm_response() path (no LLM call) for both transports, and a
live gemini-3.1-flash-lite 3-query behavior test confirming the
MeshCore channel-recommendation feature still works correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:30:57 -06:00

309 lines
12 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, HTTPException, Request
from pydantic import BaseModel
from meshai import secrets_store
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, "key": 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": []}
class AddChannelRequest(BaseModel):
name: str
key: Optional[str] = None
@router.post("/meshcore/channels")
async def meshcore_add_channel(request: Request, body: AddChannelRequest):
"""Provision a new MeshCore channel (name + PSK) onto the companion.
Body: {"name": str, "key"?: str}. ``key`` is a 32-char hex string (16
bytes) — omit it (or leave empty) for a public channel, which requires
``name`` to start with "#" so the companion derives the PSK from the
name. Returns the refreshed channel list on success.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is None or not getattr(mc, "connected", False):
raise HTTPException(status_code=409, detail="MeshCore not connected")
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Channel name must not be empty")
key = (body.key or "").strip()
secret: Optional[bytes] = None
if key:
try:
secret = bytes.fromhex(key)
except ValueError:
raise HTTPException(status_code=400, detail="Channel key must be valid hex")
if len(secret) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 32 hex characters (16 bytes)",
)
elif not name.startswith("#"):
raise HTTPException(
status_code=400,
detail="A channel key is required unless the name starts with '#' (public)",
)
try:
mc.add_channel(name, secret)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc))
except Exception as exc:
logger.error("dashboard: meshcore add_channel error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
logger.info("dashboard: meshcore channel '%s' added", name)
return {"active": True, "channels": list(mc.known_channels())}
@router.delete("/meshcore/channels/{name}")
async def meshcore_remove_channel(request: Request, name: str):
"""Remove a provisioned MeshCore channel from the companion by name.
Returns the refreshed channel list on success; 404 if the name is not
on the companion's channel table.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is None or not getattr(mc, "connected", False):
raise HTTPException(status_code=409, detail="MeshCore not connected")
try:
mc.remove_channel(name)
except RuntimeError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
logger.error("dashboard: meshcore remove_channel error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
logger.info("dashboard: meshcore channel '%s' removed", name)
return {"active": True, "channels": list(mc.known_channels())}
@router.get("/meshcore/rooms")
async def meshcore_rooms(request: Request):
"""List MeshCore room servers if a meshcore transport is connected.
Returns {"active": bool, "rooms": [{"name", "pubkey", "prefix",
"path_established"}]}. Parallels /meshcore/channels — the frontend uses
this to offer room targets for the ``room:<pubkey>`` routing cell.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is not None and getattr(mc, "connected", False):
try:
rooms = list(mc.get_rooms())
except Exception:
rooms = []
for r in rooms:
try:
r["password_set"] = secrets_store.room_password_is_set(r.get("pubkey") or "")
except Exception:
r["password_set"] = False
return {"active": True, "rooms": rooms}
return {"active": False, "rooms": []}
@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