From 94987796089c7ef82f9815d0f7ddf1e763016d2a Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Sat, 11 Jul 2026 03:55:36 +0000 Subject: [PATCH] feat(meshcore): self-service add/remove channel provisioning from dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- work/dashboard-frontend/src/lib/api.ts | 32 ++++ .../src/pages/MeshCoreConnection.tsx | 145 +++++++++++++++--- work/meshai/dashboard/api/mesh_send_routes.py | 81 +++++++++- work/meshai/transport/meshcore_transport.py | 113 ++++++++++++++ 4 files changed, 348 insertions(+), 23 deletions(-) diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index 83c3bfe..8323e42 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -606,6 +606,38 @@ export async function getMeshcoreChannelsDetail(): Promise('/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 { + 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 { + 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:`` (vs a bare channel name for channel targets). // ``active:false`` / [] when MeshCore is not connected. diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx index 48c9a7b..a2054e7 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx @@ -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(null) + const [channelError, setChannelError] = useState(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 ( - + + + ) })} {channels.length === 0 && ( @@ -379,6 +445,41 @@ export default function MeshCoreConnection() { )}

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.

+ + {/* Add a new channel (name + PSK) to the companion's channel table */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+ {channelError &&

{channelError}

} 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: