From 6e1831b60622012fe67781b63e0f053ac1071ad2 Mon Sep 17 00:00:00 2001 From: malice Date: Thu, 9 Jul 2026 10:19:40 -0600 Subject: [PATCH] refactor(channels): move channel info onto the connection pages + expose MeshCore channel key (#104) Remove the standalone Channels page and its nav entry (avoids menu bloat) and relocate the channel listings onto the existing connection pages: MeshCore channels (name, on-air hash, and the channel key/PSK with a copy button) on the MeshCore companion page, and Meshtastic channels (index, name, role) on the Meshtastic connection page. The MeshCore transport now retains the channel_secret it already fetches at enumeration and /api/meshcore/channels/detail returns it as `key` (hex PSK) so operators can provision companion radios. known_channels() and /api/meshcore/channels are unchanged. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/dashboard-frontend/src/App.tsx | 2 - .../src/components/Layout.tsx | 1 - work/dashboard-frontend/src/lib/api.ts | 4 +- .../dashboard-frontend/src/pages/Channels.tsx | 153 ------------------ .../src/pages/MeshCoreCompanion.tsx | 72 +++++++-- .../src/pages/MeshtasticConnection.tsx | 42 ++++- work/meshai/dashboard/api/mesh_send_routes.py | 2 +- work/meshai/transport/meshcore_transport.py | 24 ++- 8 files changed, 122 insertions(+), 178 deletions(-) delete mode 100644 work/dashboard-frontend/src/pages/Channels.tsx diff --git a/work/dashboard-frontend/src/App.tsx b/work/dashboard-frontend/src/App.tsx index 739a0f0..2fbde71 100644 --- a/work/dashboard-frontend/src/App.tsx +++ b/work/dashboard-frontend/src/App.tsx @@ -22,7 +22,6 @@ import ScheduledBroadcasts from './pages/ScheduledBroadcasts' import MeshtasticDangerZones from './pages/MeshtasticDangerZones' import MeshCoreDangerZones from './pages/MeshCoreDangerZones' import Coverage from './pages/Coverage' -import Channels from './pages/Channels' import { ToastProvider } from './components/ToastProvider' import { DirtyProvider } from './context/DirtyContext' @@ -47,7 +46,6 @@ function App() { {/* New aggregated pages */} } /> } /> - } /> {/* Custom sources folded into Data Feeds; keep old bookmark working */} } /> diff --git a/work/dashboard-frontend/src/components/Layout.tsx b/work/dashboard-frontend/src/components/Layout.tsx index 83f9618..626e896 100644 --- a/work/dashboard-frontend/src/components/Layout.tsx +++ b/work/dashboard-frontend/src/components/Layout.tsx @@ -53,7 +53,6 @@ const navGroups: NavGroup[] = [ { path: '/activity', label: 'Activity Log', icon: Activity }, { path: '/places', label: 'Places', icon: MapPin }, { path: '/coverage', label: 'Coverage', icon: Map }, - { path: '/channels', label: 'Channels', icon: Radio }, ], }, { diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index 39225dc..cd55c6d 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -595,8 +595,8 @@ export async function getMeshcoreChannels(): Promise { return fetchJson('/api/meshcore/channels') } -// MeshCore channels with on-air hash (routes by channel NAME, no slot/index). -export interface MeshcoreChannelDetail { name: string; hash: string | null } +// MeshCore channels with on-air hash + PSK key (routes by channel NAME, no slot/index). +export interface MeshcoreChannelDetail { name: string; hash: string | null; key: string | null } export interface MeshcoreChannelsDetail { active: boolean channels: MeshcoreChannelDetail[] diff --git a/work/dashboard-frontend/src/pages/Channels.tsx b/work/dashboard-frontend/src/pages/Channels.tsx deleted file mode 100644 index 132f79c..0000000 --- a/work/dashboard-frontend/src/pages/Channels.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useState, useEffect } from 'react' -import { Radio } from 'lucide-react' -import { - getChannels, - getMeshcoreChannelsDetail, - type MeshtasticChannel, - type MeshcoreChannelsDetail, -} from '../lib/api' - -/** - * Read-only Channels overview. - * - * Two independent sections, one per mesh family: - * - Meshtastic channels (routes by channel index) - * - MeshCore channels (routes by channel name; no index/slot) - * - * Nothing here transmits or mutates — it is a status view only. - */ -export default function Channels() { - const [mt, setMt] = useState(null) - const [mc, setMc] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - document.title = 'Channels - MeshAI' - }, []) - - useEffect(() => { - let cancelled = false - ;(async () => { - setLoading(true) - setError(null) - try { - const [mtData, mcData] = await Promise.all([ - getChannels(), - getMeshcoreChannelsDetail(), - ]) - if (cancelled) return - setMt(mtData) - setMc(mcData) - } catch (err) { - if (cancelled) return - setError(err instanceof Error ? err.message : 'Failed to load channels') - } finally { - if (!cancelled) setLoading(false) - } - })() - return () => { - cancelled = true - } - }, []) - - const mtChannels = mt ?? [] - const mcChannels = mc?.active ? mc.channels : [] - - return ( -
- {/* Header */} -
-
- -
-
-

Channels

-

- Channels configured on each connected radio. Read-only. -

-
-
- - {loading ? ( -
-
Loading...
-
- ) : error ? ( -
- {error} -
- ) : ( - <> - {/* Meshtastic channels */} -
-
-

Meshtastic Channels

-

Routes by channel index.

-
- {mtChannels.length > 0 ? ( -
- - - - - - - - - - {mtChannels.map((ch) => ( - - - - - - ))} - -
IndexNameRole
{ch.index}{ch.name}{ch.role}
-
- ) : ( -
- Node offline — channels unavailable -
- )} -
- - {/* MeshCore channels */} -
-
-

MeshCore Channels

-

Routes by channel name.

-
- {mcChannels.length > 0 ? ( -
- - - - - - - - - {mcChannels.map((ch) => ( - - - - - ))} - -
NameOn-air hash
{ch.name} - {ch.hash != null ? `0x${ch.hash}` : '—'} -
-
- ) : ( -
- MeshCore not connected -
- )} -
- - )} -
- ) -} diff --git a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx index 52eeb00..f404457 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx @@ -2,11 +2,11 @@ import { useState, useEffect, useCallback } from 'react' import { Bot, Radio } from 'lucide-react' import { fetchMeshcoreSelf, - getMeshcoreChannels, + getMeshcoreChannelsDetail, sendMeshcoreAdvert, updateConfig, type MeshcoreSelf, - type MeshcoreChannels, + type MeshcoreChannelsDetail, type TestSendResult, } from '../lib/api' @@ -24,9 +24,10 @@ function relativeTime(epochSec: number): string { export default function MeshCoreCompanion() { const [self, setSelf] = useState(null) - const [channels, setChannels] = useState(null) + const [channels, setChannels] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [copiedKey, setCopiedKey] = useState(null) // Send Advert state const [advertSending, setAdvertSending] = useState(false) @@ -50,7 +51,7 @@ export default function MeshCoreCompanion() { try { const [selfData, channelData] = await Promise.all([ fetchMeshcoreSelf(), - getMeshcoreChannels(), + getMeshcoreChannelsDetail(), ]) if (cancelled) return setSelf(selfData) @@ -125,8 +126,18 @@ export default function MeshCoreCompanion() { } }, [advertIntervalHours]) + const handleCopyKey = useCallback(async (key: string) => { + try { + await navigator.clipboard.writeText(key) + setCopiedKey(key) + setTimeout(() => setCopiedKey((cur) => (cur === key ? null : cur)), 1500) + } catch { + // clipboard unavailable (e.g. non-secure context) — no-op + } + }, []) + const connected = self?.connected === true - const channelNames = channels?.active ? channels.channels : [] + const channelList = channels?.active ? channels.channels : [] return (
@@ -268,19 +279,52 @@ export default function MeshCoreCompanion() {
- {/* Channel list */} + {/* Channel list — Name · Hash · Key (read-only) */}

Channels

+

+ Key = the channel PSK; enter it (or the # name) on a companion radio to join. +

- {channelNames.length > 0 ? ( -
    - {channelNames.map((name) => ( -
  • - {name} -
  • - ))} -
+ {channelList.length > 0 ? ( +
+ + + + + + + + + + {channelList.map((ch) => ( + + + + + + ))} + +
NameOn-air hashKey
{ch.name} + {ch.hash != null ? `0x${ch.hash}` : '—'} + + {ch.key != null ? ( +
+ {ch.key} + +
+ ) : ( + + )} +
+
) : (
No channels
)} diff --git a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx index b969661..303eae3 100644 --- a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx @@ -4,7 +4,7 @@ import { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfi import ChannelPicker from '@/components/ChannelPicker' import NodePicker from '@/components/NodePicker' import { notifyRestartRequired } from '@/components/RestartBanner' -import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api' +import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage, getChannels, type MeshtasticChannel } from '@/lib/api' import { useDirty } from '@/context/DirtyContext' // Only the fields the "Bot behavior" section edits are typed explicitly; the @@ -37,6 +37,9 @@ export default function MeshtasticConnection() { const [success, setSuccess] = useState(null) const [hasChanges, setHasChanges] = useState(false) + // Read-only channel listing (index ↔ name ↔ role), fetched from the radio. + const [channels, setChannels] = useState([]) + // Test send state const [testChannel, setTestChannel] = useState(0) const [testText, setTestText] = useState('') @@ -86,6 +89,9 @@ export default function MeshtasticConnection() { useEffect(() => { document.title = 'Meshtastic Connection - MeshAI' fetchConfig() + getChannels() + .then(setChannels) + .catch(() => setChannels([])) }, [fetchConfig]) useEffect(() => { @@ -290,6 +296,40 @@ export default function MeshtasticConnection() {
)} + {/* Channels card — read-only Index · Name · Role */} +
+
+

Channels

+

+ Channels configured on the radio. Meshtastic routes by channel index. Read-only. +

+
+ {channels.length > 0 ? ( +
+ + + + + + + + + + {channels.map((ch) => ( + + + + + + ))} + +
IndexNameRole
{ch.index}{ch.name}{ch.role}
+
+ ) : ( +
Node offline — channels unavailable
+ )} +
+ {/* Send test message card */}
Send Test Message
diff --git a/work/meshai/dashboard/api/mesh_send_routes.py b/work/meshai/dashboard/api/mesh_send_routes.py index 321699d..bd44a1d 100644 --- a/work/meshai/dashboard/api/mesh_send_routes.py +++ b/work/meshai/dashboard/api/mesh_send_routes.py @@ -43,7 +43,7 @@ async def meshcore_channels(request: Request): async def meshcore_channels_detail(request: Request): """Enumerated MeshCore channels with on-air hash, if connected. - Returns {"active": bool, "channels": [{"name": str, "hash": str|null}]}. + Returns {"active": bool, "channels": [{"name": str, "hash": str|null, "key": str|null}]}. Routes by channel NAME (no slot/index), so no index is exposed here. """ connector = getattr(request.app.state, "connector", None) diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 604702c..867f274 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -793,8 +793,23 @@ class MeshCoreTransport(MeshTransport): # null-truncated / utf-8-decoded — do NOT trim or lowercase). self._chan_name_to_idx[name] = slot # Additive read-only detail: preserve order, capture on-air hash. + # Additive read-only detail: preserve order, capture on-air + # hash + the channel KEY (PSK) as a hex string so operators + # can provision the channel on companion radios. The lib + # emits channel_secret as raw 16-byte PSK; hex-encode it. + _secret = payload.get("channel_secret") + if isinstance(_secret, (bytes, bytearray)): + _key = _secret.hex() + elif isinstance(_secret, str) and _secret: + _key = _secret + else: + _key = None self._chan_details.append( - {"name": name, "hash": payload.get("channel_hash")} + { + "name": name, + "hash": payload.get("channel_hash"), + "key": _key, + } ) empty_run = 0 except Exception as exc: @@ -809,9 +824,10 @@ class MeshCoreTransport(MeshTransport): return list(self._chan_name_to_idx.keys()) def channel_details(self) -> list[dict]: - """Ordered [{name, hash}] for each enumerated MeshCore channel (incl. - Public); ``hash`` is the on-air channel hash or None. Read-only view - for the dashboard; captured alongside _chan_name_to_idx at connect.""" + """Ordered [{name, hash, key}] for each enumerated MeshCore channel + (incl. Public); ``hash`` is the on-air channel hash or None, ``key`` + is the channel PSK as a hex string (or None). Read-only view for the + dashboard; captured alongside _chan_name_to_idx at connect.""" return list(self._chan_details) def get_contacts(self) -> list[dict]: