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>
This commit is contained in:
malice 2026-07-10 23:30:57 -06:00 committed by GitHub
commit 1a8ae710dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 811 additions and 85 deletions

View file

@ -217,7 +217,7 @@ The curated channel chatter your bot observes is used only as short-term *contex
llm: llm:
backend: "google" # google | openai | anthropic backend: "google" # google | openai | anthropic
api_key: "your-api-key" api_key: "your-api-key"
model: "gemini-2.5-flash" model: "gemini-3.1-flash-lite"
``` ```
Any OpenAI-compatible endpoint works for local models — point `base_url` at Ollama (`http://localhost:11434/v1`), LiteLLM (`http://localhost:4000/v1`), or Open WebUI. Any OpenAI-compatible endpoint works for local models — point `base_url` at Ollama (`http://localhost:11434/v1`), LiteLLM (`http://localhost:4000/v1`), or Open WebUI.

View file

@ -606,6 +606,38 @@ export async function getMeshcoreChannelsDetail(): Promise<MeshcoreChannelsDetai
return fetchJson<MeshcoreChannelsDetail>('/api/meshcore/channels/detail') return fetchJson<MeshcoreChannelsDetail>('/api/meshcore/channels/detail')
} }
// Provision a new MeshCore channel on the companion. `key` is a 32-char hex
// PSK (16 bytes); omit it for a public channel (name must start with "#").
// Throws (with the backend's `detail` message) on failure — e.g. duplicate
// name, no free slot, bad key, or MeshCore not connected.
export async function addMeshcoreChannel(
name: string,
key?: string,
): Promise<MeshcoreChannels> {
const response = await fetch('/api/meshcore/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, key: key || undefined }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Remove a provisioned MeshCore channel from the companion by name.
export async function removeMeshcoreChannel(name: string): Promise<MeshcoreChannels> {
const response = await fetch(`/api/meshcore/channels/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// MeshCore room servers (type-3 contacts). A routing cell targets a room with // MeshCore room servers (type-3 contacts). A routing cell targets a room with
// the value ``room:<pubkey>`` (vs a bare channel name for channel targets). // the value ``room:<pubkey>`` (vs a bare channel name for channel targets).
// ``active:false`` / [] when MeshCore is not connected. // ``active:false`` / [] when MeshCore is not connected.

View file

@ -1,10 +1,18 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check, ChevronRight } from 'lucide-react' import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2, Eye, EyeOff, Copy } from 'lucide-react'
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config' import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
import SerialPortPicker from '@/components/SerialPortPicker' import SerialPortPicker from '@/components/SerialPortPicker'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api' import {
fetchConfig as apiFetchConfig,
updateConfig as apiUpdateConfig,
getMeshcoreChannels,
getMeshcoreChannelsDetail,
addMeshcoreChannel,
removeMeshcoreChannel,
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
@ -54,11 +62,23 @@ export default function MeshCoreConnection() {
// Test send state // Test send state
const [channelsActive, setChannelsActive] = useState(false) const [channelsActive, setChannelsActive] = useState(false)
const [channels, setChannels] = useState<string[]>([]) const [channels, setChannels] = useState<string[]>([])
// Per-channel PSK hex (name -> key), captured from the companion so operators
// can share the key with people who want to join. Masked by default.
const [channelKeys, setChannelKeys] = useState<Record<string, string | null>>({})
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set())
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [selectedChannel, setSelectedChannel] = useState('') const [selectedChannel, setSelectedChannel] = useState('')
const [testText, setTestText] = useState('') const [testText, setTestText] = useState('')
const [testSending, setTestSending] = useState(false) const [testSending, setTestSending] = useState(false)
const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null) const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null)
// Add / remove channel state (provisions the companion's channel table)
const [newChannelName, setNewChannelName] = useState('')
const [newChannelKey, setNewChannelKey] = useState('')
const [channelSaving, setChannelSaving] = useState(false)
const [channelRemoving, setChannelRemoving] = useState<string | null>(null)
const [channelError, setChannelError] = useState<string | null>(null)
const fetchConfig = useCallback(async () => { const fetchConfig = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@ -84,18 +104,91 @@ export default function MeshCoreConnection() {
fetchConfig() fetchConfig()
}, [fetchConfig]) }, [fetchConfig])
useEffect(() => { const refreshChannels = useCallback(async () => {
getMeshcoreChannels() try {
.then((res) => { // Prefer the detail endpoint (name + PSK key); it carries everything the
// names-only list does. Fall back to names-only if detail is unavailable.
const detail = await getMeshcoreChannelsDetail()
const names = detail.channels.map((c) => c.name)
const keyMap: Record<string, string | null> = {}
for (const c of detail.channels) keyMap[c.name] = c.key
setChannelsActive(detail.active)
setChannels(names)
setChannelKeys(keyMap)
setSelectedChannel((prev) => (prev && names.includes(prev) ? prev : names[0] ?? ''))
} catch {
try {
const res = await getMeshcoreChannels()
setChannelsActive(res.active) setChannelsActive(res.active)
setChannels(res.channels) setChannels(res.channels)
if (res.channels.length > 0) setSelectedChannel(res.channels[0]) setChannelKeys({})
}) setSelectedChannel((prev) => (prev && res.channels.includes(prev) ? prev : res.channels[0] ?? ''))
.catch(() => { } catch {
setChannelsActive(false) setChannelsActive(false)
}) }
}
}, []) }, [])
useEffect(() => {
refreshChannels()
}, [refreshChannels])
const toggleRevealKey = (name: string) => {
setRevealedKeys((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
const handleCopyKey = async (name: string, key: string) => {
try {
await navigator.clipboard.writeText(key)
setCopiedKey(name)
setTimeout(() => setCopiedKey((prev) => (prev === name ? null : prev)), 1500)
} catch {
// Clipboard API can be blocked (non-secure context); reveal the key so
// the operator can copy it manually instead of failing silently.
setRevealedKeys((prev) => new Set(prev).add(name))
}
}
const handleAddChannel = async () => {
const name = newChannelName.trim()
if (!name) return
setChannelSaving(true)
setChannelError(null)
try {
await addMeshcoreChannel(name, newChannelKey.trim())
setNewChannelName('')
setNewChannelKey('')
await refreshChannels()
} catch (err) {
setChannelError(err instanceof Error ? err.message : 'Failed to add channel')
} finally {
setChannelSaving(false)
}
}
const handleRemoveChannel = async (name: string) => {
setChannelRemoving(name)
setChannelError(null)
try {
await removeMeshcoreChannel(name)
// Drop it from Observe Channels too, if it was selected there.
setMcContext((c) => {
if (!c || !(c.observe_channels ?? []).includes(name)) return c
return { ...c, observe_channels: (c.observe_channels ?? []).filter((n) => n !== name) }
})
await refreshChannels()
} catch (err) {
setChannelError(err instanceof Error ? err.message : 'Failed to remove channel')
} finally {
setChannelRemoving(null)
}
}
const handleTestSend = async () => { const handleTestSend = async () => {
setTestSending(true) setTestSending(true)
setTestResult(null) setTestResult(null)
@ -357,19 +450,69 @@ export default function MeshCoreConnection() {
<div className="border border-[#1e2a3a] p-2 space-y-1"> <div className="border border-[#1e2a3a] p-2 space-y-1">
{channels.map((ch) => { {channels.map((ch) => {
const selected = (mcContext.observe_channels ?? []).includes(ch) const selected = (mcContext.observe_channels ?? []).includes(ch)
const key = channelKeys[ch] ?? null
const revealed = revealedKeys.has(ch)
return ( return (
<label <div
key={ch} key={ch}
onClick={() => toggleObserveChannel(ch)} className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17]"
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
> >
<div className={`w-4 h-4 rounded border flex items-center justify-center ${ <label
selected ? 'bg-accent border-accent' : 'border-slate-600' onClick={() => toggleObserveChannel(ch)}
}`}> className="flex items-center gap-2 cursor-pointer shrink-0"
{selected && <Check size={12} className="text-white" />} >
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
selected ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{selected && <Check size={12} className="text-white" />}
</div>
<span className="text-sm text-slate-200">{ch}</span>
</label>
{/* Channel key (PSK hex) share this to let others join. Masked by
default; reveal per-row with the eye, copy with the copy button. */}
<div className="flex items-center gap-1 flex-1 min-w-0 justify-end">
{key ? (
<>
<code
title={revealed ? key : 'Key hidden — click the eye to reveal'}
className="text-xs font-mono text-slate-400 truncate max-w-[16rem]"
>
{revealed ? key : '••••••••••••••••'}
</code>
<button
type="button"
title={revealed ? 'Hide key' : 'Reveal key'}
aria-label={revealed ? `Hide key for ${ch}` : `Reveal key for ${ch}`}
onClick={() => toggleRevealKey(ch)}
className="p-1 text-slate-600 hover:text-slate-300"
>
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
type="button"
title="Copy key to clipboard"
aria-label={`Copy key for ${ch}`}
onClick={() => handleCopyKey(ch, key)}
className="p-1 text-slate-600 hover:text-accent"
>
{copiedKey === ch ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
</button>
</>
) : (
<span className="text-xs font-mono text-slate-600" title="No retrievable key for this channel"></span>
)}
</div> </div>
<span className="text-sm text-slate-200">{ch}</span> <button
</label> type="button"
title={`Remove channel '${ch}' from the companion`}
aria-label={`Remove channel ${ch}`}
disabled={channelRemoving === ch}
onClick={() => handleRemoveChannel(ch)}
className="p-1 text-slate-600 hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
<Trash2 size={14} />
</button>
</div>
) )
})} })}
{channels.length === 0 && ( {channels.length === 0 && (
@ -378,7 +521,42 @@ export default function MeshCoreConnection() {
</div> </div>
)} )}
</div> </div>
<p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context.</p> <p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched — pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context. Each channel's key (PSK) is shown on the right reveal and copy it to share with people who want to join.</p>
{/* Add a new channel (name + PSK) to the companion's channel table */}
<div className="flex items-end gap-2 pt-2">
<div className="flex-1 space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">Name</label>
<input
type="text"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
placeholder="#channel-name"
disabled={!channelsActive}
className="w-full px-2 py-1.5 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent disabled:opacity-50"
/>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">Key</label>
<input
type="text"
value={newChannelKey}
onChange={(e) => setNewChannelKey(e.target.value)}
placeholder="PSK hex (32 chars) — leave blank for public #channel"
disabled={!channelsActive}
className="w-full px-2 py-1.5 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={handleAddChannel}
disabled={!channelsActive || channelSaving || !newChannelName.trim()}
className="px-3 py-1.5 bg-accent hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed rounded text-sm text-white whitespace-nowrap"
>
{channelSaving ? 'Saving…' : 'Save'}
</button>
</div>
{channelError && <p className="text-xs text-red-400">{channelError}</p>}
</div> </div>
<ListInput <ListInput
label="Ignore MeshCore Contacts" label="Ignore MeshCore Contacts"

View file

@ -13,13 +13,22 @@ _config_logger = logging.getLogger(__name__)
@dataclass @dataclass
class BotConfig: class BotConfig:
"""Bot identity and trigger settings.""" """Bot identity and trigger settings.
name: str = "ai" mt_mesh_name/mt_node/mc_mesh_name: transport-specific identity used ONLY
owner: str = "" when generating the LLM system prompt for that transport (see
router.generate_llm_response). Generic OSS defaults are intentionally
empty/mesh-agnostic -- deployments fill these in via config.
"""
name: str = "MeshAI"
owner: str = "Unknown"
contact_email: str = "" contact_email: str = ""
respond_to_dms: bool = True respond_to_dms: bool = True
filter_bbs_protocols: bool = True filter_bbs_protocols: bool = True
mt_mesh_name: str = "" # e.g. "freq51 Meshtastic mesh" -- Meshtastic-only identity framing
mt_node: str = "" # e.g. "!27780c47 (AIDA-N2)" -- Meshtastic-only physical node id
mc_mesh_name: str = "" # e.g. "the MeshCore mesh" -- MeshCore-only identity framing
@dataclass @dataclass
@ -167,9 +176,7 @@ class LLMConfig:
"observed any yet.\n" "observed any yet.\n"
"- When asked about yourself or commands, answer conversationally based on " "- When asked about yourself or commands, answer conversationally based on "
"the command list provided below. Don't dump lists unless asked.\n" "the command list provided below. Don't dump lists unless asked.\n"
"- You are part of the freq51 mesh.\n"
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n" "- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
"- You are part of the freq51 mesh in the Twin Falls, Idaho area.\n"
"- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n" "- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n"
"- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically." "- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically."
) )

View file

@ -4,7 +4,7 @@ import logging
from datetime import datetime from datetime import datetime
from typing import Optional, Union from typing import Optional, Union
from fastapi import APIRouter, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from meshai import secrets_store from meshai import secrets_store
@ -58,6 +58,85 @@ async def meshcore_channels_detail(request: Request):
return {"active": False, "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") @router.get("/meshcore/rooms")
async def meshcore_rooms(request: Request): async def meshcore_rooms(request: Request):
"""List MeshCore room servers if a meshcore transport is connected. """List MeshCore room servers if a meshcore transport is connected.

View file

@ -256,6 +256,255 @@ def _build_region_abbreviations(region_names: list[str]) -> dict[str, str]:
return abbrevs return abbrevs
def _build_alert_channels_line(config, transport: str) -> str:
"""Build a short identity-block line describing regional alert channels
for the given transport, sourced from notifications.region_routes.
Reads config.notifications.region_routes.cells (family -> region ->
cell_dict). Only includes cells that are enabled (matrix-level
mt_enabled/mc_enabled AND the cell's own "enabled" flag) and that have
a non-empty value in this transport's column ("mt" channel index for
meshtastic, "mc" channel name for meshcore).
Region names are used verbatim from region_routes they are NOT
reconciled with mesh_intelligence region names.
Fail-safe: returns "" on any error so a broken/missing config never
breaks prompt assembly.
Returns:
A 1-3 line string (no leading/trailing blank lines), or "" if no
routed regions are configured/enabled for this transport.
"""
try:
region_routes = getattr(config.notifications, "region_routes", None)
if region_routes is None:
return ""
transport_enabled = (
region_routes.mt_enabled if transport == "meshtastic" else region_routes.mc_enabled
)
if not transport_enabled:
return ""
cells = getattr(region_routes, "cells", {}) or {}
col = "mt" if transport == "meshtastic" else "mc"
# region -> set(family) for families/regions routed on this transport.
families_by_region: dict = {}
for family, region_map in cells.items():
if not isinstance(region_map, dict):
continue
for region_name, cell in region_map.items():
if not isinstance(cell, dict) or not cell.get("enabled", False):
continue
dest = cell.get(col)
if not dest:
continue
families_by_region.setdefault(region_name, set()).add(family)
if not families_by_region:
return ""
# Distinct hazard families across all routed regions, for the intro line.
all_families = sorted({f for fams in families_by_region.values() for f in fams})
_FAMILY_LABELS = {
"weather": "weather",
"fire": "fire",
"roads": "roads",
"avalanche": "avalanche",
"seismic": "seismic",
"power_outage": "power outages",
"satpass": "satellite passes",
}
hazard_words = ", ".join(_FAMILY_LABELS.get(f, f) for f in all_families)
# region -> destination value (mt index or mc name), for the mapping line.
# A region may route different families to different destinations in
# theory; in practice the matrix is uniform per-region, so just take
# the first cell's destination for the mapping display.
dest_by_region = {}
for family, region_map in cells.items():
if not isinstance(region_map, dict):
continue
for region_name, cell in region_map.items():
if region_name not in families_by_region:
continue
if region_name in dest_by_region:
continue
dest = cell.get(col)
if dest:
dest_by_region[region_name] = dest
if transport == "meshtastic":
mapping = ", ".join(
f"channel {dest_by_region[r]} = {r}" for r in dest_by_region
)
else:
mapping = ", ".join(
f"{dest_by_region[r]} = {r}" for r in dest_by_region
)
if not mapping:
return ""
return (
f"You broadcast regional hazard alerts ({hazard_words}). "
f"People can join a channel to receive that region's alerts: {mapping}."
)
except Exception:
logger.exception("alert channels line build failed")
return ""
def _build_meshcore_channel_block(config, channel_details, position=None) -> str:
"""Build the MeshCore channel-recommendation context block.
Single-shot context-stuffing for the LLM (no tools): lists ONLY the
channels the operator has opted to observe (``meshcore_context.observe_channels``),
each annotated with its PSK key (from ``channel_details``) and where a
region routing cell maps to it the covered region's human geography
(local name, cities, centroid) so the model can match a user's town/GPS
to the right channel.
Args:
config: the loaded Config object (reads meshcore_context.observe_channels,
notifications.region_routes, mesh_intelligence.regions).
channel_details: list of {name, hash, key} dicts from
CompositeTransport.channel_details() (key = PSK hex).
position: optional (lat, lon) tuple for the requesting user; when
present a "USER LOCATION" line is emitted so the LLM can auto-match.
Returns:
The block as a string, or "" if no observed channels are configured
(caller should skip injection in that case).
"""
# 1. Observed set — the ONLY channels we may disclose/recommend.
observe = list(getattr(config.meshcore_context, "observe_channels", []) or [])
if not observe:
return ""
observe_set = set(observe)
# 2. key lookup: channel name -> PSK hex (from live channel_details()).
key_by_name = {}
for d in (channel_details or []):
name = d.get("name")
if name:
key_by_name[name] = d.get("key")
# 3. Reverse map: channel name -> set(route region names), scanning every
# family/region in region_routes for a cell whose `mc` names the channel.
regions_by_channel = {}
try:
cells = getattr(config.notifications.region_routes, "cells", {}) or {}
for _family, region_map in cells.items():
if not isinstance(region_map, dict):
continue
for region_name, cell in region_map.items():
if not isinstance(cell, dict):
continue
mc = cell.get("mc")
if mc:
regions_by_channel.setdefault(mc, set()).add(region_name)
except Exception:
logger.exception("region_routes reverse-map build failed")
# 4. Human geography from mesh_intelligence regions (best-effort attach).
# region_routes uses "SW/SC/East Idaho"; mesh_intelligence uses names
# like "South Western ID" — we do a loose direction-word match rather
# than a strict reconcile, and let the LLM reason over the raw metadata.
geo_regions = []
try:
geo_regions = list(getattr(config.mesh_intelligence, "regions", []) or [])
except Exception:
logger.exception("mesh_intelligence regions read failed")
_DIRECTION_TOKENS = {
"sw": "south west", "sc": "south central", "se": "south east",
"nw": "north west", "ne": "north east",
"n": "north", "s": "south", "e": "east", "w": "west", "c": "central",
}
def _direction_words(route_region: str) -> set:
"""Expand the leading direction abbreviation of a route region name
(e.g. "SW Idaho" -> {south, west}; "East Idaho" -> {east})."""
words = set()
for tok in route_region.lower().replace("-", " ").split():
if tok in ("idaho", "id"):
continue
expanded = _DIRECTION_TOKENS.get(tok, tok)
words.update(expanded.split())
return words
def _match_geo(route_region: str):
"""Fuzzy/substring match a route region name to a mesh_intelligence
region; returns the RegionAnchor or None."""
rr_words = _direction_words(route_region)
best = None
best_score = 0
for r in geo_regions:
name = (getattr(r, "name", "") or "").lower()
geo_words = set(name.replace("-", " ").split()) - {"idaho", "id"}
score = len(rr_words & geo_words)
if score > best_score:
best_score, best = score, r
return best if best_score > 0 else None
# 5. Emit one concise line per OBSERVED channel.
lines = ["", "MESHCORE CHANNELS YOU CAN RECOMMEND (name + join key):"]
for name in observe:
if name not in observe_set:
continue
key = key_by_name.get(name)
key_str = key if key else "(key unavailable)"
route_regions = sorted(regions_by_channel.get(name, set()))
region_str = ""
if route_regions:
details = []
for rr in route_regions:
geo = _match_geo(rr)
if geo is not None:
local = getattr(geo, "local_name", "") or ""
cities = getattr(geo, "cities", []) or []
lat = getattr(geo, "lat", None)
lon = getattr(geo, "lon", None)
bits = [rr]
inner = []
if local:
inner.append(local)
if cities:
inner.append(", ".join(cities[:4]))
if inner:
bits.append(f"({'; '.join(inner)}")
if lat is not None and lon is not None:
bits[-1] += f"; ~{lat:.2f},{lon:.2f}"
bits[-1] += ")"
elif lat is not None and lon is not None:
bits.append(f"(~{lat:.2f},{lon:.2f})")
details.append(" ".join(bits))
else:
details.append(rr)
region_str = " — region: " + " / ".join(details)
lines.append(f" {name}{region_str} — key: {key_str}")
# 6. Optional user location for auto-matching.
if position is not None:
try:
lat, lon = position
lines.append(f"USER LOCATION: {lat},{lon}")
except Exception:
pass
# 7. Instruction for the model.
lines.append(
"If the user asks which MeshCore channel to join: determine their area "
"from USER LOCATION if given, otherwise ask which Idaho town/area they're "
"in, then tell them the channel NAME and KEY to join. Only recommend "
"channels listed above; never invent a channel or key."
)
return "\n".join(lines)
class MessageRouter: class MessageRouter:
"""Routes incoming messages to appropriate handlers.""" """Routes incoming messages to appropriate handlers."""
@ -675,20 +924,56 @@ class MessageRouter:
# Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh # Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh
# 1. Dynamic identity from bot config # Transport of the originating message -- drives identity framing and
# gating of transport-specific prompt blocks below.
transport = getattr(message, "transport", "meshtastic")
# 1. Dynamic identity from bot config, branched by transport.
bot_name = self.config.bot.name or "MeshAI" bot_name = self.config.bot.name or "MeshAI"
bot_owner = self.config.bot.owner or "Unknown" bot_owner = self.config.bot.owner or "Unknown"
identity = ( if transport == "meshcore":
f"You are {bot_name}, an LLM-powered assistant on the freq51 Meshtastic mesh network. " mc_mesh_name = self.config.bot.mc_mesh_name or "the MeshCore mesh"
f"Your managing operator is {bot_owner}. " identity = (
f"You are open source at github.com/zvx-echo6/meshai.\n\n" f"You are {bot_name}, an LLM-powered assistant on {mc_mesh_name}, "
f"IDENTITY: Your name is {bot_name}. You ARE a physical node on the mesh — " f"connected via a MeshCore companion radio. "
f"node !27780c47 (AIDA-N2). You have a real location, real GPS coordinates, " f"Your managing operator is {bot_owner}. "
f"and real radio connections. When someone asks how far something is from you, " f"You are open source at github.com/zvx-echo6/meshai.\n\n"
f"check the mesh data for your node's position and calculate. " f"IDENTITY: Your name is {bot_name}. You have a real MeshCore radio "
f"You are NOT just software — you are a node that other nodes can see, hear, and route through.\n\n" f"presence — you send and receive over an actual MeshCore companion "
) f"radio, not just software. You do NOT have a Meshtastic node identity "
f"and you are NOT part of MeshMonitor.\n\n"
)
else:
mt_mesh_name = self.config.bot.mt_mesh_name
mt_node = self.config.bot.mt_node
if mt_mesh_name or mt_node:
mesh_label = mt_mesh_name or "the mesh"
identity = (
f"You are {bot_name}, an LLM-powered assistant on {mesh_label}. "
f"Your managing operator is {bot_owner}. "
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
)
if mt_node:
identity += (
f"IDENTITY: Your name is {bot_name}. You ARE a physical node on "
f"the mesh — node {mt_node}. You have a real location, real GPS "
f"coordinates, and real radio connections. When someone asks how "
f"far something is from you, check the mesh data for your node's "
f"position and calculate. You are NOT just software — you are a "
f"node that other nodes can see, hear, and route through.\n\n"
)
else:
# No transport identity configured -- generic non-MT-specific fallback.
identity = (
f"You are {bot_name}, an LLM-powered assistant on a mesh network. "
f"Your managing operator is {bot_owner}. "
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
)
alert_channels_line = _build_alert_channels_line(self.config, transport)
if alert_channels_line:
identity += alert_channels_line + "\n\n"
# 2. Static system prompt from config # 2. Static system prompt from config
static_prompt = "" static_prompt = ""
@ -724,9 +1009,11 @@ class MessageRouter:
) )
system_prompt += "\n".join(cmd_lines) system_prompt += "\n".join(cmd_lines)
# 3. MeshMonitor info (only when enabled) # 3. MeshMonitor info (only when enabled -- Meshtastic-only, MeshMonitor
# has no MeshCore concept)
if ( if (
self.meshmonitor_sync transport == "meshtastic"
and self.meshmonitor_sync
and self.config.meshmonitor.enabled and self.config.meshmonitor.enabled
and self.config.meshmonitor.inject_into_prompt and self.config.meshmonitor.inject_into_prompt
): ):
@ -837,53 +1124,78 @@ class MessageRouter:
# v0.7-fire-tracker-4: scope already detected above; no # v0.7-fire-tracker-4: scope already detected above; no
# second call needed. # second call needed.
# Always include Tier 1 summary for mesh questions # Meshtastic-only: node-health/gateway/packet reporting. This whole
tier1 = self.mesh_reporter.build_tier1_summary() # sub-block is built from meshtasticd/MeshMonitor-derived node data
system_prompt += "\n\n" + tier1 # (mesh_reporter tracks Meshtastic node IDs, infra/gateway scoring,
# packet cadence, etc.) and has no MeshCore equivalent.
if transport == "meshtastic":
# Always include Tier 1 summary for mesh questions
tier1 = self.mesh_reporter.build_tier1_summary()
system_prompt += "\n\n" + tier1
# Add Tier 2 detail if scoped # Add Tier 2 detail if scoped
if scope_type == "region" and scope_value: if scope_type == "region" and scope_value:
region_detail = self.mesh_reporter.build_region_detail(scope_value) region_detail = self.mesh_reporter.build_region_detail(scope_value)
system_prompt += "\n\n" + region_detail system_prompt += "\n\n" + region_detail
elif scope_type == "node" and scope_value: elif scope_type == "node" and scope_value:
node_detail = self.mesh_reporter.build_node_detail(scope_value) node_detail = self.mesh_reporter.build_node_detail(scope_value)
system_prompt += "\n\n" + node_detail system_prompt += "\n\n" + node_detail
# Always include relevant recommendations # Always include relevant recommendations
recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value) recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value)
if recommendations: if recommendations:
system_prompt += "\n\n" + recommendations system_prompt += "\n\n" + recommendations
# Add mesh awareness instructions with dynamic region name mappings # Add mesh awareness instructions with dynamic region name mappings
region_name_instructions = "" region_name_instructions = ""
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
# Build region name mappings for the prompt # Build region name mappings for the prompt
mappings = [] mappings = []
for region in self.config.mesh_intelligence.regions: for region in self.config.mesh_intelligence.regions:
local = getattr(region, "local_name", "") or "" local = getattr(region, "local_name", "") or ""
if local and local != region.name: if local and local != region.name:
mappings.append(f'say "{local}" not "{region.name}"') mappings.append(f'say "{local}" not "{region.name}"')
if mappings: if mappings:
region_name_instructions = f"- ALWAYS use local region names: {', '.join(mappings)}. The code names mean nothing to users." region_name_instructions = f"- ALWAYS use local region names: {', '.join(mappings)}. The code names mean nothing to users."
system_prompt += _MESH_AWARENESS_PROMPT.format( system_prompt += _MESH_AWARENESS_PROMPT.format(
region_name_instructions=region_name_instructions region_name_instructions=region_name_instructions
) )
# Build region geography from config dynamically # Build region geography from config dynamically
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"] geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"]
for region in self.config.mesh_intelligence.regions: for region in self.config.mesh_intelligence.regions:
local = getattr(region, "local_name", "") or "" local = getattr(region, "local_name", "") or ""
local_str = f' "{local}"' if local else "" local_str = f' "{local}"' if local else ""
desc = getattr(region, "description", "") or "" desc = getattr(region, "description", "") or ""
desc_str = f"{desc}" if desc else "" desc_str = f"{desc}" if desc else ""
aliases = getattr(region, "aliases", []) or [] aliases = getattr(region, "aliases", []) or []
alias_str = "" alias_str = ""
if aliases: if aliases:
alias_str = f'\n People may call this: {", ".join(aliases)}' alias_str = f'\n People may call this: {", ".join(aliases)}'
geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}") geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}")
system_prompt += "\n".join(geo_lines) system_prompt += "\n".join(geo_lines)
# MeshCore channel-recommendation block: for "what channel
# should I join?" style questions. Transport-neutral (works for
# both meshes -- the block itself is scoped to MeshCore channel
# data). Fail-safe — never break the LLM path if transport/config
# access throws.
try:
channel_details = self.connector.channel_details()
position = None
try:
position = self.connector.get_node_position(message.sender_id)
except Exception:
position = None
mc_block = _build_meshcore_channel_block(
self.config, channel_details, position
)
if mc_block:
system_prompt += "\n\n" + mc_block
except Exception:
logger.exception("meshcore channel block injection failed")
# Update mesh context tracking # Update mesh context tracking
self._update_user_mesh_context( self._update_user_mesh_context(

View file

@ -92,6 +92,11 @@ class CompositeTransport(MeshTransport):
child = self.meshcore_child() child = self.meshcore_child()
return child.known_channels() if child is not None else [] return child.known_channels() if child is not None else []
def channel_details(self) -> List[dict]:
"""Passthrough to the MeshCore child's [{name, hash, key}] channel view; [] if no meshcore child."""
child = self.meshcore_child()
return child.channel_details() if child is not None else []
def get_contacts(self) -> List[dict]: def get_contacts(self) -> List[dict]:
"""Passthrough to the MeshCore child's contact roster; [] if no meshcore child.""" """Passthrough to the MeshCore child's contact roster; [] if no meshcore child."""
child = self.meshcore_child() child = self.meshcore_child()

View file

@ -852,6 +852,119 @@ class MeshCoreTransport(MeshTransport):
dashboard; captured alongside _chan_name_to_idx at connect.""" dashboard; captured alongside _chan_name_to_idx at connect."""
return list(self._chan_details) return list(self._chan_details)
# ------------------------------------------------------------------
# Channel provisioning (add / remove) — self-service from the dashboard.
#
# There is no dedicated "delete channel" opcode in the companion
# firmware/lib; ``set_channel`` (opcode 0x20) is the only channel write.
# A slot is considered EMPTY when ``get_channel(idx)`` reports a blank
# ``channel_name`` (see _enumerate_channels). Freeing a slot therefore
# means writing it back to that same empty state: name="" and a fixed
# all-zero 16-byte secret (passed explicitly so the lib's "derive from
# sha256(name)" fallback — which only triggers when secret is None or
# name starts with "#" — never fires for a blank name).
# ------------------------------------------------------------------
# Companion channel table has a hard cap of 40 slots (0-39); see
# _enumerate_channels. Firmware truncates names to 32 bytes.
_MAX_CHANNEL_SLOTS = 40
_MAX_CHANNEL_NAME_BYTES = 32
_EMPTY_SECRET = bytes(16)
def _find_free_slot(self) -> Optional[int]:
"""Scan the full companion channel table for the first EMPTY slot.
Unlike ``_enumerate_channels`` (which stops early after 3 contiguous
empty slots as a fast-path heuristic for the common contiguous-
provisioning case), this scans every slot 0..39 without an early
empty-run cutoff, so a free slot past a gap is never missed. Stops
early only on a transport error/None result (treated as end of a
usable table). Returns None if no empty slot was found.
"""
for idx in range(self._MAX_CHANNEL_SLOTS):
try:
event = self._run_coro(self._mc.commands.get_channel(idx))
except Exception as exc:
logger.debug(
"MeshCore: get_channel(%d) failed while scanning for free slot: %s",
idx, exc,
)
break
if not event:
break
is_err = getattr(event, "is_error", None)
if callable(is_err) and event.is_error():
break
payload = event.payload or {}
name = payload.get("channel_name", "")
if not name:
return idx
return None
def add_channel(self, name: str, secret: Optional[bytes]) -> dict:
"""Provision a new channel (name + PSK) onto the companion.
``secret`` is 16 raw bytes, or None for a public/derived channel
(name should start with "#" in that case the lib derives the PSK
as sha256(name)[:16]). Picks the first free slot, writes it, then
re-enumerates so ``_chan_name_to_idx``/``_chan_details`` reflect the
change immediately. Raises ValueError on bad input, RuntimeError if
not connected or no free slot, and re-raises transport errors.
"""
if self._mc is None or not self._connected:
raise RuntimeError("MeshCore not connected")
name = (name or "").strip()
if not name:
raise ValueError("Channel name must not be empty")
if len(name.encode("utf-8")) > self._MAX_CHANNEL_NAME_BYTES:
raise ValueError(
f"Channel name exceeds {self._MAX_CHANNEL_NAME_BYTES}-byte companion limit"
)
if secret is not None and len(secret) != 16:
raise ValueError("Channel secret must be exactly 16 bytes")
if name in self._chan_name_to_idx:
raise ValueError(f"Channel '{name}' already exists")
free_idx = self._find_free_slot()
if free_idx is None:
raise RuntimeError("No free channel slot on companion")
event = self._run_coro(self._mc.commands.set_channel(free_idx, name, secret))
is_err = getattr(event, "is_error", None)
if callable(is_err) and event.is_error():
reason = (getattr(event, "payload", None) or {}).get("reason", "unknown")
raise RuntimeError(f"set_channel failed: {reason}")
self._enumerate_channels()
logger.info("MeshCore: added channel '%s' at slot %d", name, free_idx)
return {"name": name, "idx": free_idx}
def remove_channel(self, name: str) -> None:
"""Clear a provisioned channel's slot on the companion.
Resolves *name* to its slot via ``_chan_name_to_idx`` and writes the
slot back to its empty state (blank name, all-zero secret) the
companion firmware exposes no dedicated delete opcode, so this is
the only way to free a slot. Re-enumerates on success. Raises
RuntimeError if not connected or the name is unknown.
"""
if self._mc is None or not self._connected:
raise RuntimeError("MeshCore not connected")
idx = self._chan_name_to_idx.get(name)
if idx is None:
raise RuntimeError(f"Channel '{name}' not found on companion")
event = self._run_coro(
self._mc.commands.set_channel(idx, "", self._EMPTY_SECRET)
)
is_err = getattr(event, "is_error", None)
if callable(is_err) and event.is_error():
reason = (getattr(event, "payload", None) or {}).get("reason", "unknown")
raise RuntimeError(f"set_channel (clear) failed: {reason}")
self._enumerate_channels()
logger.info("MeshCore: removed channel '%s' (was slot %d)", name, idx)
def get_contacts(self) -> list[dict]: def get_contacts(self) -> list[dict]:
"""Roster of known MeshCore contacts. [] if not connected.""" """Roster of known MeshCore contacts. [] if not connected."""
if self._mc is None or not self._connected: if self._mc is None or not self._connected: