diff --git a/work/dashboard-frontend/src/App.tsx b/work/dashboard-frontend/src/App.tsx index 2fbde71..739a0f0 100644 --- a/work/dashboard-frontend/src/App.tsx +++ b/work/dashboard-frontend/src/App.tsx @@ -22,6 +22,7 @@ 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' @@ -46,6 +47,7 @@ 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 626e896..83f9618 100644 --- a/work/dashboard-frontend/src/components/Layout.tsx +++ b/work/dashboard-frontend/src/components/Layout.tsx @@ -53,6 +53,7 @@ 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 059c228..39225dc 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -595,6 +595,29 @@ 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 } +export interface MeshcoreChannelsDetail { + active: boolean + channels: MeshcoreChannelDetail[] +} + +export async function getMeshcoreChannelsDetail(): Promise { + return fetchJson('/api/meshcore/channels/detail') +} + +// Meshtastic radio channels (routes by channel index). +export interface MeshtasticChannel { + index: number + name: string + role: string + enabled: boolean +} + +export async function getChannels(): Promise { + return fetchJson('/api/channels') +} + export interface MeshcoreContact { name: string | null pubkey: string diff --git a/work/dashboard-frontend/src/pages/Channels.tsx b/work/dashboard-frontend/src/pages/Channels.tsx new file mode 100644 index 0000000..132f79c --- /dev/null +++ b/work/dashboard-frontend/src/pages/Channels.tsx @@ -0,0 +1,153 @@ +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/meshai/dashboard/api/mesh_send_routes.py b/work/meshai/dashboard/api/mesh_send_routes.py index 73e234b..321699d 100644 --- a/work/meshai/dashboard/api/mesh_send_routes.py +++ b/work/meshai/dashboard/api/mesh_send_routes.py @@ -39,6 +39,24 @@ async def meshcore_channels(request: Request): return {"active": False, "channels": []} +@router.get("/meshcore/channels/detail") +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}]}. + Routes by channel NAME (no slot/index), so no index is exposed here. + """ + connector = getattr(request.app.state, "connector", None) + mc = _find_child(connector, "meshcore") + if mc is not None and getattr(mc, "connected", False): + try: + channels = list(mc.channel_details()) + except Exception: + channels = [] + return {"active": True, "channels": channels} + return {"active": False, "channels": []} + + @router.get("/meshcore/contacts") async def meshcore_contacts(request: Request): """Roster of known MeshCore contacts if a meshcore transport is connected.""" diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 5527011..604702c 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -117,6 +117,10 @@ class MeshCoreTransport(MeshTransport): # Companion channel table: channel NAME -> slot index, built at # connect time by _enumerate_channels(). Empty until connected. self._chan_name_to_idx: dict[str, int] = {} + # Ordered [{name, hash}] detail for each enumerated channel (incl. + # Public), captured alongside _chan_name_to_idx at connect. Read-only + # view for the dashboard Channels page. Empty until connected. + self._chan_details: list[dict] = [] # Self-advertisement tracking. self._last_advert_sent: Optional[float] = None # epoch seconds or None # asyncio.Task handle for the periodic advert loop; None when inactive. @@ -758,6 +762,7 @@ class MeshCoreTransport(MeshTransport): with a hard cap of 40 slots. """ self._chan_name_to_idx = {} + self._chan_details = [] try: empty_run = 0 for idx in range(40): @@ -787,6 +792,10 @@ class MeshCoreTransport(MeshTransport): # Named slot: exact, case-sensitive (firmware name is already # 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. + self._chan_details.append( + {"name": name, "hash": payload.get("channel_hash")} + ) empty_run = 0 except Exception as exc: logger.warning("MeshCore: channel enumeration error: %s", exc) @@ -799,6 +808,12 @@ class MeshCoreTransport(MeshTransport): """Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect).""" 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.""" + return list(self._chan_details) + def get_contacts(self) -> list[dict]: """Roster of known MeshCore contacts. [] if not connected.""" if self._mc is None or not self._connected: