mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
* 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>
309 lines
12 KiB
Python
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
|