@@ -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 ? (
+
+
+
+
+ | Name |
+ On-air hash |
+ Key |
+
+
+
+ {channelList.map((ch) => (
+
+ | {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 ? (
+
+
+
+
+ | Index |
+ Name |
+ Role |
+
+
+
+ {channels.map((ch) => (
+
+ | {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]: