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.
This commit is contained in:
Matt Johnson 2026-07-11 03:55:36 +00:00
commit 9498779608
4 changed files with 347 additions and 22 deletions

View file

@ -606,6 +606,38 @@ export async function getMeshcoreChannelsDetail(): Promise<MeshcoreChannelsDetai
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
// the value ``room:<pubkey>`` (vs a bare channel name for channel targets).
// ``active:false`` / [] when MeshCore is not connected.

View file

@ -1,10 +1,17 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check, ChevronRight } from 'lucide-react'
import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2 } from 'lucide-react'
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
import SerialPortPicker from '@/components/SerialPortPicker'
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,
addMeshcoreChannel,
removeMeshcoreChannel,
sendTestMessage,
} from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
// Only the fields this page edits are typed explicitly; the rest of the
@ -59,6 +66,13 @@ export default function MeshCoreConnection() {
const [testSending, setTestSending] = useState(false)
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 () => {
setLoading(true)
try {
@ -84,18 +98,56 @@ export default function MeshCoreConnection() {
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 refreshChannels = useCallback(async () => {
try {
const res = await getMeshcoreChannels()
setChannelsActive(res.active)
setChannels(res.channels)
setSelectedChannel((prev) => (prev && res.channels.includes(prev) ? prev : res.channels[0] ?? ''))
} catch {
setChannelsActive(false)
}
}, [])
useEffect(() => {
refreshChannels()
}, [refreshChannels])
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 () => {
setTestSending(true)
setTestResult(null)
@ -358,18 +410,32 @@ export default function MeshCoreConnection() {
{channels.map((ch) => {
const selected = (mcContext.observe_channels ?? []).includes(ch)
return (
<label
<div
key={ch}
onClick={() => toggleObserveChannel(ch)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17]"
>
<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>
<label
onClick={() => toggleObserveChannel(ch)}
className="flex items-center gap-2 flex-1 cursor-pointer"
>
<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>
<button
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"
>
<Trash2 size={14} />
</button>
</div>
)
})}
{channels.length === 0 && (
@ -379,6 +445,41 @@ export default function MeshCoreConnection() {
)}
</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>
{/* 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>
<ListInput
label="Ignore MeshCore Contacts"

View file

@ -4,7 +4,7 @@ import logging
from datetime import datetime
from typing import Optional, Union
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from meshai import secrets_store
@ -58,6 +58,85 @@ async def meshcore_channels_detail(request: Request):
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.

View file

@ -852,6 +852,119 @@ class MeshCoreTransport(MeshTransport):
dashboard; captured alongside _chan_name_to_idx at connect."""
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]:
"""Roster of known MeshCore contacts. [] if not connected."""
if self._mc is None or not self._connected: