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.
This commit is contained in:
parent
22037c9a23
commit
9498779608
4 changed files with 347 additions and 22 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
const refreshChannels = useCallback(async () => {
|
||||
try {
|
||||
const res = await getMeshcoreChannels()
|
||||
setChannelsActive(res.active)
|
||||
setChannels(res.channels)
|
||||
if (res.channels.length > 0) setSelectedChannel(res.channels[0])
|
||||
})
|
||||
.catch(() => {
|
||||
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,10 +410,13 @@ export default function MeshCoreConnection() {
|
|||
{channels.map((ch) => {
|
||||
const selected = (mcContext.observe_channels ?? []).includes(ch)
|
||||
return (
|
||||
<label
|
||||
<div
|
||||
key={ch}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17]"
|
||||
>
|
||||
<label
|
||||
onClick={() => toggleObserveChannel(ch)}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
|
||||
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'
|
||||
|
|
@ -370,6 +425,17 @@ export default function MeshCoreConnection() {
|
|||
</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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue