From 4fda01cf52912c51f6617a838adf98f9230c7326 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 15:27:27 +0100 Subject: [PATCH 1/4] fix(ui): MC message channel display labels without device index Use API display_label and hashtag-aware formatting in Messages channel picker. --- src/lib/message-channels.test.ts | 26 +++++++++++++++++++++++--- src/lib/message-channels.ts | 16 ++++++++++++++-- src/lib/models.ts | 4 +++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/lib/message-channels.test.ts b/src/lib/message-channels.test.ts index 93daf46..6341dd1 100644 --- a/src/lib/message-channels.test.ts +++ b/src/lib/message-channels.test.ts @@ -5,7 +5,15 @@ import type { MessageChannel } from '@/lib/models'; describe('message-channels', () => { const channels: MessageChannel[] = [ { id: 1, name: 'MT Primary', constellation: 1, protocol: 'meshtastic' }, - { id: 2, name: 'MC Public', constellation: 1, protocol: 'meshcore', mc_channel_idx: 0 }, + { + id: 2, + name: 'test', + constellation: 1, + protocol: 'meshcore', + mc_channel_type: 'HASHTAG', + mc_hashtag: 'test', + display_label: '#test', + }, { id: 3, name: 'Legacy', constellation: 1 }, ]; @@ -14,8 +22,20 @@ describe('message-channels', () => { expect(filterChannelsForProtocol(channels, 'meshcore').map((c) => c.id)).toEqual([2]); }); - it('formats MC channel label with index', () => { - expect(formatMessageChannelLabel(channels[1])).toBe('MC Public (#0)'); + it('formats MC channel label without device index', () => { + expect(formatMessageChannelLabel(channels[1])).toBe('#test'); expect(formatMessageChannelLabel(channels[0])).toBe('MT Primary'); }); + + it('derives hashtag label when display_label is absent', () => { + const ch: MessageChannel = { + id: 4, + name: 'galloway', + constellation: 1, + protocol: 'meshcore', + mc_channel_type: 'HASHTAG', + mc_hashtag: 'galloway', + }; + expect(formatMessageChannelLabel(ch)).toBe('#galloway'); + }); }); diff --git a/src/lib/message-channels.ts b/src/lib/message-channels.ts index 6a64efd..3c09809 100644 --- a/src/lib/message-channels.ts +++ b/src/lib/message-channels.ts @@ -35,9 +35,21 @@ export function filterChannelsForProtocol(channels: MessageChannel[], protocol: }); } +function isHashtagChannel(ch: MessageChannel): boolean { + const t = String(ch.mc_channel_type ?? '').toUpperCase(); + return t === 'HASHTAG'; +} + +/** Operator-facing label for Messages / pickers (no device index). */ export function formatMessageChannelLabel(ch: MessageChannel): string { - if (ch.mc_channel_idx != null) { - return `${ch.name} (#${ch.mc_channel_idx})`; + if (ch.display_label?.trim()) { + return ch.display_label.trim(); + } + if (isHashtagChannel(ch)) { + const tag = (ch.mc_hashtag ?? ch.name ?? '').replace(/^#+/, '').trim(); + if (tag) { + return `#${tag}`; + } } return ch.name; } diff --git a/src/lib/models.ts b/src/lib/models.ts index 5c4a514..7d18063 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -649,8 +649,10 @@ export interface MessageChannel { name: string; constellation: number; protocol?: MeshProtocol | 'meshtastic' | 'meshcore' | string; - mc_channel_idx?: number | null; + /** Operator-facing label from API (#hashtag or public name). */ + display_label?: string | null; mc_channel_type?: string | null; + mc_hashtag?: string | null; } export interface Constellation { From 06bc4aa5d58b8bdb2d9de64a2b1e11272d1b56e3 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 15:28:04 +0100 Subject: [PATCH 2/4] feat(ui): MeshCore feeder channel link-or-create settings UX One card per device slot; link existing constellation channel or create new before apply-to-radio. --- src/pages/user/NodeSettings.tsx | 214 +++++++++++++++++++++++--------- 1 file changed, 153 insertions(+), 61 deletions(-) diff --git a/src/pages/user/NodeSettings.tsx b/src/pages/user/NodeSettings.tsx index cd01ebc..ef966de 100644 --- a/src/pages/user/NodeSettings.tsx +++ b/src/pages/user/NodeSettings.tsx @@ -35,6 +35,7 @@ import { import { SetupManagedNode } from '@/components/nodes/SetupManagedNode'; import { apiKeyLinksManagedNode, isObservedNodeManaged, managedNodeStableKey } from '@/lib/managed-node-enrollment'; import { meshProtocolFromManagedNode } from '@/lib/mesh-protocol'; +import { filterChannelsForProtocol, formatMessageChannelLabel } from '@/lib/message-channels'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMeshtasticApi } from '@/hooks/api/useApi'; import { cn } from '@/lib/utils'; @@ -484,15 +485,31 @@ function stripHashtagPrefix(value: string): string { return value.replace(/^#+/, '').trim(); } -function mcChannelsFromNode(node: OwnedManagedNode): McChannelApplyEntry[] { +type McChannelRowState = McChannelApplyEntry & { + linkMode: 'existing' | 'new'; + linkedChannelId?: number; +}; + +function mcChannelRowsFromNode(node: OwnedManagedNode): McChannelRowState[] { return (node.mc_channels ?? []).map((ch) => ({ mc_channel_idx: ch.mc_channel_idx, name: ch.name, mc_channel_type: ch.mc_channel_type, mc_hashtag: ch.mc_channel_type === 'HASHTAG' ? (ch.mc_hashtag ?? stripHashtagPrefix(ch.name)) : ch.mc_hashtag, + linkMode: 'new' as const, + linkedChannelId: ch.id, })); } +function rowToApplyEntry(row: McChannelRowState): McChannelApplyEntry { + return { + mc_channel_idx: row.mc_channel_idx, + name: row.name, + mc_channel_type: row.mc_channel_type, + mc_hashtag: row.mc_hashtag, + }; +} + function applyMcChannelErrorMessage(err: unknown): string { const data = (err as { data?: { detail?: string; code?: string } })?.data as | { detail?: string; code?: string } @@ -513,11 +530,17 @@ function MeshCoreChannelSettings({ node }: { node: OwnedManagedNode }) { const api = useMeshtasticApi(); const queryClient = useQueryClient(); const internalId = node.internal_id; - const [rows, setRows] = useState(() => mcChannelsFromNode(node)); + const constellationId = node.constellation.id; + const { data: constellationChannels = [] } = useConstellationChannels(constellationId); + const mcCatalog = useMemo( + () => filterChannelsForProtocol(constellationChannels, 'meshcore'), + [constellationChannels] + ); + const [rows, setRows] = useState(() => mcChannelRowsFromNode(node)); const [open, setOpen] = useState(false); useEffect(() => { - setRows(mcChannelsFromNode(node)); + setRows(mcChannelRowsFromNode(node)); }, [node]); const applyToRadio = useMutation({ @@ -525,7 +548,7 @@ function MeshCoreChannelSettings({ node }: { node: OwnedManagedNode }) { if (!internalId) { throw new Error('Missing feeder internal_id'); } - return api.applyMcChannelConfig(internalId, rows); + return api.applyMcChannelConfig(internalId, rows.map(rowToApplyEntry)); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['managed-nodes', 'mine'] }); @@ -533,11 +556,34 @@ function MeshCoreChannelSettings({ node }: { node: OwnedManagedNode }) { }, }); - const updateRow = (index: number, patch: Partial) => { + const applyCatalogChannel = (index: number, channelId: number) => { + const ch = mcCatalog.find((c) => c.id === channelId); + if (!ch) return; + const isHashtag = String(ch.mc_channel_type ?? '').toUpperCase() === 'HASHTAG'; + const tag = isHashtag ? stripHashtagPrefix(ch.mc_hashtag ?? ch.name) : ''; + setRows((prev) => + prev.map((r, i) => { + if (i !== index) return r; + return { + ...r, + linkMode: 'existing', + linkedChannelId: ch.id, + mc_channel_type: isHashtag ? 'HASHTAG' : 'PUBLIC', + name: isHashtag ? tag : ch.name, + mc_hashtag: isHashtag ? tag || null : null, + }; + }) + ); + }; + + const updateRow = (index: number, patch: Partial) => { setRows((prev) => prev.map((r, i) => { if (i !== index) return r; const next = { ...r, ...patch }; + if (patch.linkMode === 'new') { + next.linkedChannelId = undefined; + } if (next.mc_channel_type === 'HASHTAG') { const tag = stripHashtagPrefix(next.mc_hashtag ?? next.name); return { ...next, name: tag, mc_hashtag: tag || null }; @@ -553,7 +599,13 @@ function MeshCoreChannelSettings({ node }: { node: OwnedManagedNode }) { while (used.has(idx) && idx < 63) idx += 1; setRows((prev) => [ ...prev, - { mc_channel_idx: idx, name: `Channel ${idx}`, mc_channel_type: 'PUBLIC', mc_hashtag: null }, + { + mc_channel_idx: idx, + name: `Channel ${idx}`, + mc_channel_type: 'PUBLIC', + mc_hashtag: null, + linkMode: 'new', + }, ]); }; @@ -595,75 +647,115 @@ function MeshCoreChannelSettings({ node }: { node: OwnedManagedNode }) { ) : (
{rows.map((row, index) => ( -
-
- -

{row.mc_channel_idx}

+
+
+ + Device slot {row.mc_channel_idx} + +
-
- +
+
- {row.mc_channel_type === 'HASHTAG' ? ( -
- -
- - # - - updateRow(index, { mc_hashtag: e.target.value })} - placeholder="galloway" - aria-label="Hashtag name" - /> -
-

- Stored on the radio as #{row.mc_hashtag || '…'} (name and hashtag are the same). -

+ {row.linkMode === 'existing' ? ( +
+ + + {mcCatalog.length === 0 ? ( +

No MeshCore channels in this constellation yet.

+ ) : null}
) : ( -
- - updateRow(index, { name: e.target.value })} - /> +
+
+ + +
+ {row.mc_channel_type === 'HASHTAG' ? ( +
+ +
+ + # + + updateRow(index, { mc_hashtag: e.target.value })} + placeholder="test" + aria-label="Hashtag name" + /> +
+
+ ) : ( +
+ + updateRow(index, { name: e.target.value })} + /> +
+ )}
)} -
- -
))}
From d6e2816e0327d7a898ef162f7c8926658c6ad0bc Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 15:38:01 +0100 Subject: [PATCH 3/4] feat(ui): split node settings lists by Meshtastic and MeshCore Group claimed and managed nodes under protocol headings on Settings > My Nodes and Managed Nodes tabs. --- src/pages/user/NodeSettings.tsx | 291 ++++++++++++++++++++++---------- 1 file changed, 200 insertions(+), 91 deletions(-) diff --git a/src/pages/user/NodeSettings.tsx b/src/pages/user/NodeSettings.tsx index ef966de..cc7702b 100644 --- a/src/pages/user/NodeSettings.tsx +++ b/src/pages/user/NodeSettings.tsx @@ -34,7 +34,7 @@ import { } from '@/lib/models'; import { SetupManagedNode } from '@/components/nodes/SetupManagedNode'; import { apiKeyLinksManagedNode, isObservedNodeManaged, managedNodeStableKey } from '@/lib/managed-node-enrollment'; -import { meshProtocolFromManagedNode } from '@/lib/mesh-protocol'; +import { meshProtocolFromManagedNode, meshProtocolFromObservedNode } from '@/lib/mesh-protocol'; import { filterChannelsForProtocol, formatMessageChannelLabel } from '@/lib/message-channels'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMeshtasticApi } from '@/hooks/api/useApi'; @@ -90,6 +90,23 @@ function NodeSettingsContent() { const isNodeManaged = (observed: ObservedNode) => isObservedNodeManaged(observed, myManagedNodes); + const meshtasticClaimedNodes = useMemo( + () => myClaimedNodes.filter((n) => meshProtocolFromObservedNode(n) === 1), + [myClaimedNodes] + ); + const meshcoreClaimedNodes = useMemo( + () => myClaimedNodes.filter((n) => meshProtocolFromObservedNode(n) === 2), + [myClaimedNodes] + ); + const meshtasticManagedNodes = useMemo( + () => myManagedNodes.filter((n) => meshProtocolFromManagedNode(n) === 1), + [myManagedNodes] + ); + const meshcoreManagedNodes = useMemo( + () => myManagedNodes.filter((n) => meshProtocolFromManagedNode(n) === 2), + [myManagedNodes] + ); + // Fetch API keys const { data: apiKeys, isLoading: isLoadingApiKeys } = useQuery({ queryKey: ['api-keys'], @@ -121,48 +138,21 @@ function NodeSettingsContent() { {myClaimedNodes.length > 0 ? ( -
- {myClaimedNodes.map((node) => ( -
-
-
-

{node.short_name || node.node_id_str}

-

{node.long_name}

-

Node ID: {node.node_id_str}

-

- Last heard: -

-
-
-
- - View Node - - {!isNodeManaged(node) && ( - - )} - -
-
- ))} +
+ +
) : (
@@ -270,54 +260,28 @@ function NodeSettingsContent() { {myManagedNodes.length > 0 ? ( - - {myManagedNodes.map((node) => { - const nodeApiKeys = apiKeys?.filter((key) => apiKeyLinksManagedNode(key, node)) || []; - const stableKey = managedNodeStableKey(node); - return ( - - -
-
-

{node.short_name || node.node_id_str}

-
- - {node.constellation.name} - - - Last heard: - - {nodeApiKeys.length > 0 && ( - - • {nodeApiKeys.length} API key{nodeApiKeys.length !== 1 ? 's' : ''} - - )} -
-
-
-
- - setUnmanageManagedTarget(node)} - /> - -
- ); - })} -
+
+ + +
) : ( @@ -452,6 +416,151 @@ function NodeSettingsContent() { ); } +function claimedNodeStableKey(node: ObservedNode): string { + return node.internal_id ?? node.node_id_str ?? String(node.meshtastic_node_id ?? ''); +} + +function ClaimedNodesProtocolSection({ + title, + nodes, + isNodeManaged, + onRunAsManaged, + onUnclaim, +}: { + title: string; + nodes: ObservedNode[]; + isNodeManaged: (node: ObservedNode) => boolean; + onRunAsManaged: (node: ObservedNode) => void; + onUnclaim: (node: ObservedNode) => void; +}) { + return ( +
+

{title}

+ {nodes.length > 0 ? ( +
+ {nodes.map((node) => ( +
+
+
+

{node.short_name || node.node_id_str}

+

{node.long_name}

+

Node ID: {node.node_id_str}

+

+ Last heard: +

+
+
+
+ + View Node + + {!isNodeManaged(node) && ( + + )} + +
+
+ ))} +
+ ) : ( +

No {title} nodes claimed.

+ )} +
+ ); +} + +function ManagedNodesProtocolSection({ + title, + nodes, + apiKeys, + config, + isLoadingApiKeys, + handleCopyToClipboard, + onShowSetupInstructions, + onRequestUnmanage, +}: { + title: string; + nodes: OwnedManagedNode[]; + apiKeys: NodeApiKey[] | undefined; + config: ReturnType; + isLoadingApiKeys: boolean; + handleCopyToClipboard: (text: string) => void; + onShowSetupInstructions: ( + params: { + apiKey: string; + nodeShortName: string; + protocol: 'meshtastic' | 'meshcore'; + botDefaults?: { ignorePortnums?: string | null; hopLimit?: number | null }; + } | null + ) => void; + onRequestUnmanage: (node: OwnedManagedNode) => void; +}) { + return ( +
+

{title}

+ {nodes.length > 0 ? ( + + {nodes.map((node) => { + const nodeApiKeys = apiKeys?.filter((key) => apiKeyLinksManagedNode(key, node)) || []; + const stableKey = managedNodeStableKey(node); + return ( + + +
+
+

{node.short_name || node.node_id_str}

+
+ + {node.constellation.name} + + + Last heard: + + {nodeApiKeys.length > 0 && ( + + • {nodeApiKeys.length} API key{nodeApiKeys.length !== 1 ? 's' : ''} + + )} +
+
+
+
+ + onRequestUnmanage(node)} + /> + +
+ ); + })} +
+ ) : ( +

No {title} managed nodes.

+ )} +
+ ); +} + function channelMappingsFromNode(node: OwnedManagedNode) { return { meshtastic_channel_0: node.meshtastic_channel_0?.id ?? null, From f51a4921c7c07ea15b3902db99474e18f1bc0412 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 16:51:03 +0100 Subject: [PATCH 4/4] feat(ui): dual-list MeshCore channel editor with apply confirmation Replace link-or-create cards with Available / On this radio panels, hashtag labels, reorder warning, and confirm-before-apply modal. New channels stay draft until apply. --- .../nodes/MeshCoreChannelEditor.tsx | 366 ++++++++++++++++++ src/lib/mc-channel-editor.test.ts | 85 ++++ src/lib/mc-channel-editor.ts | 185 +++++++++ src/pages/user/NodeSettings.tsx | 312 +-------------- 4 files changed, 640 insertions(+), 308 deletions(-) create mode 100644 src/components/nodes/MeshCoreChannelEditor.tsx create mode 100644 src/lib/mc-channel-editor.test.ts create mode 100644 src/lib/mc-channel-editor.ts diff --git a/src/components/nodes/MeshCoreChannelEditor.tsx b/src/components/nodes/MeshCoreChannelEditor.tsx new file mode 100644 index 0000000..3aaa77d --- /dev/null +++ b/src/components/nodes/MeshCoreChannelEditor.tsx @@ -0,0 +1,366 @@ +import { useEffect, useMemo, useState } from 'react'; +import { ArrowDown, ArrowUp, ChevronDown, Plus, X } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useConstellationChannels } from '@/hooks/api/useConstellations'; +import { useMeshtasticApi } from '@/hooks/api/useApi'; +import { filterChannelsForProtocol, formatMessageChannelLabel } from '@/lib/message-channels'; +import { + assignedFromFeeder, + assignedOrderKey, + assignedToApplyEntries, + formatAssignedMcChannelLabel, + newDraftChannel, + reorderAssigned, + type AssignedMcChannel, +} from '@/lib/mc-channel-editor'; +import type { OwnedManagedNode } from '@/lib/models'; +import { cn } from '@/lib/utils'; + +function applyMcChannelErrorMessage(err: unknown): string { + const data = (err as { data?: { detail?: string; code?: string } })?.data; + if (data?.code === 'feeder_bot_not_connected') { + return data.detail ?? 'Feeder bot is not connected via WebSocket.'; + } + if (data?.code === 'command_dispatch_unavailable') { + return data.detail ?? 'Could not dispatch the command (channel layer unavailable).'; + } + if (data?.detail) { + return String(data.detail); + } + return 'Could not apply channel config to the radio.'; +} + +let nextClientId = 0; +function newClientId(prefix: string): string { + nextClientId += 1; + return `${prefix}-${nextClientId}`; +} + +type MeshCoreChannelEditorProps = { + node: OwnedManagedNode; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChannelEditorProps) { + const api = useMeshtasticApi(); + const queryClient = useQueryClient(); + const internalId = node.internal_id; + const constellationId = node.constellation.id; + const feederSnapshots = node.mc_channels ?? []; + + const { data: constellationChannels = [] } = useConstellationChannels(constellationId); + const catalog = useMemo(() => filterChannelsForProtocol(constellationChannels, 'meshcore'), [constellationChannels]); + + const [assigned, setAssigned] = useState(() => assignedFromFeeder(node)); + const [initialOrderKey, setInitialOrderKey] = useState(() => assignedOrderKey(assignedFromFeeder(node))); + const [showNewForm, setShowNewForm] = useState(false); + const [newType, setNewType] = useState<'PUBLIC' | 'HASHTAG'>('PUBLIC'); + const [newNameInput, setNewNameInput] = useState(''); + const [confirmApplyOpen, setConfirmApplyOpen] = useState(false); + + useEffect(() => { + const next = assignedFromFeeder(node); + setAssigned(next); + setInitialOrderKey(assignedOrderKey(next)); + setShowNewForm(false); + setNewNameInput(''); + }, [node]); + + const assignedCatalogIds = useMemo( + () => new Set(assigned.map((a) => a.catalogId).filter((id): id is number => id != null)), + [assigned] + ); + + const available = useMemo( + () => catalog.filter((ch) => !assignedCatalogIds.has(ch.id)), + [catalog, assignedCatalogIds] + ); + + const orderChanged = assigned.length > 0 && assignedOrderKey(assigned) !== initialOrderKey; + + const applyToRadio = useMutation({ + mutationFn: () => { + if (!internalId) { + throw new Error('Missing feeder internal_id'); + } + return api.applyMcChannelConfig(internalId, assignedToApplyEntries(assigned, catalog, feederSnapshots)); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['managed-nodes', 'mine'] }); + setConfirmApplyOpen(false); + onOpenChange(false); + }, + }); + + const assignFromCatalog = (channelId: number) => { + setAssigned((prev) => [...prev, { clientId: newClientId('catalog'), catalogId: channelId }]); + }; + + const removeAssigned = (index: number) => { + setAssigned((prev) => prev.filter((_, i) => i !== index)); + }; + + const moveAssigned = (index: number, direction: -1 | 1) => { + setAssigned((prev) => reorderAssigned(prev, index, index + direction)); + }; + + const addDraftToAssigned = () => { + const draft = newDraftChannel(newType, newNameInput); + if (draft.mc_channel_type === 'HASHTAG' && !draft.mc_hashtag) { + return; + } + setAssigned((prev) => [...prev, { clientId: newClientId('draft'), draft }]); + setShowNewForm(false); + setNewNameInput(''); + setNewType('PUBLIC'); + }; + + const syncedLabel = node.mc_channels_synced_at + ? `Last synced from radio: ${new Date(node.mc_channels_synced_at).toLocaleString()}` + : 'Not synced yet — start the bot with upload enabled to mirror device channels.'; + + return ( + <> +
+ + + {open ? ( +
+

{syncedLabel}

+

+ Choose channels for this radio, then apply. The feeder bot must be online (WebSocket). New channels are + created when you apply. +

+ + {orderChanged ? ( + + + Changing the order or set of channels rewrites device slot indices. The radio's local channel + database may disagree briefly — messages can map to the wrong channel until sync completes. + + + ) : null} + +
+
+
+

Available

+

{node.constellation.name}

+
+
    + {available.length === 0 ? ( +
  • No more channels to add.
  • + ) : ( + available.map((ch) => ( +
  • + +
  • + )) + )} +
+
+ {showNewForm ? ( +
+
+ + {newType === 'HASHTAG' ? ( +
+ + # + + setNewNameInput(e.target.value)} + placeholder="test" + aria-label="Hashtag" + /> +
+ ) : ( + setNewNameInput(e.target.value)} + placeholder="Channel name" + aria-label="Channel name" + /> + )} +
+
+ + +
+
+ ) : ( + + )} +
+
+ +
+
+

On this radio

+

Top = slot 0

+
+
    + {assigned.length === 0 ? ( +
  • + Tap a channel under Available, or create a new one. +
  • + ) : ( + assigned.map((row, index) => ( +
  • + {index} + + {formatAssignedMcChannelLabel(row, catalog, feederSnapshots)} + {row.draft ? ( + (new) + ) : null} + +
    + + + +
    +
  • + )) + )} +
+
+
+ +
+ + {applyToRadio.isError ? ( +

+ {applyMcChannelErrorMessage(applyToRadio.error)} +

+ ) : null} +
+
+ ) : null} +
+ + + + + Apply channel config to radio? + + This will overwrite your radio's current channel configuration with the list above. Do you want to + continue? + + +
    + {assigned.map((row, index) => ( +
  • + {index} + {formatAssignedMcChannelLabel(row, catalog, feederSnapshots)} +
  • + ))} +
+ + + + +
+
+ + ); +} diff --git a/src/lib/mc-channel-editor.test.ts b/src/lib/mc-channel-editor.test.ts new file mode 100644 index 0000000..2641bc6 --- /dev/null +++ b/src/lib/mc-channel-editor.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { + assignedFromFeeder, + assignedOrderKey, + assignedToApplyEntries, + formatAssignedMcChannelLabel, + formatMcChannelDraftLabel, + newDraftChannel, + reorderAssigned, +} from './mc-channel-editor'; +import type { McChannelSnapshot, MessageChannel, OwnedManagedNode } from '@/lib/models'; + +describe('mc-channel-editor', () => { + const catalog: MessageChannel[] = [ + { + id: 10, + name: 'Scotland', + constellation: 1, + protocol: 'meshcore', + mc_channel_type: 'PUBLIC', + }, + { + id: 11, + name: 'test', + constellation: 1, + protocol: 'meshcore', + mc_channel_type: 'HASHTAG', + mc_hashtag: 'test', + display_label: '#test', + }, + ]; + + const feederSnapshots: McChannelSnapshot[] = [ + { + id: 11, + mc_channel_idx: 0, + name: 'test', + mc_channel_type: 'HASHTAG', + mc_hashtag: 'test', + }, + ]; + + it('builds assigned rows from feeder mirror', () => { + const node = { + mc_channels: feederSnapshots, + } as OwnedManagedNode; + expect(assignedFromFeeder(node)).toEqual([{ clientId: 'catalog-11', catalogId: 11 }]); + }); + + it('formats hashtag labels for catalog and draft rows', () => { + expect( + formatAssignedMcChannelLabel({ clientId: 'a', catalogId: 11 }, catalog, feederSnapshots) + ).toBe('#test'); + expect(formatMcChannelDraftLabel(newDraftChannel('HASHTAG', 'mesh'))).toBe('#mesh'); + expect(formatMcChannelDraftLabel(newDraftChannel('PUBLIC', 'Scotland'))).toBe('Scotland'); + }); + + it('maps assigned order to apply entries with slot indices', () => { + const assigned = [ + { clientId: 'd1', draft: newDraftChannel('HASHTAG', 'newtag') }, + { clientId: 'c1', catalogId: 10 }, + ]; + expect(assignedToApplyEntries(assigned, catalog, feederSnapshots)).toEqual([ + { + mc_channel_idx: 0, + mc_channel_type: 'HASHTAG', + name: 'newtag', + mc_hashtag: 'newtag', + }, + { + mc_channel_idx: 1, + mc_channel_type: 'PUBLIC', + name: 'Scotland', + mc_hashtag: null, + }, + ]); + }); + + it('reorders assigned list', () => { + const a = { clientId: 'a', catalogId: 1 }; + const b = { clientId: 'b', catalogId: 2 }; + expect(reorderAssigned([a, b], 0, 1)).toEqual([b, a]); + expect(assignedOrderKey([a, b])).not.toBe(assignedOrderKey([b, a])); + }); +}); diff --git a/src/lib/mc-channel-editor.ts b/src/lib/mc-channel-editor.ts new file mode 100644 index 0000000..10e8a7c --- /dev/null +++ b/src/lib/mc-channel-editor.ts @@ -0,0 +1,185 @@ +import type { McChannelApplyEntry, McChannelSnapshot, MessageChannel, OwnedManagedNode } from '@/lib/models'; +import { formatMessageChannelLabel } from '@/lib/message-channels'; + +export type McChannelDraft = { + mc_channel_type: 'PUBLIC' | 'HASHTAG'; + name: string; + mc_hashtag: string | null; +}; + +/** Channel assigned to a feeder slot (order = device index). */ +export type AssignedMcChannel = { + clientId: string; + catalogId?: number; + draft?: McChannelDraft; +}; + +export function stripHashtagPrefix(value: string): string { + return value.replace(/^#+/, '').trim(); +} + +export function isHashtagType(type: string | undefined): boolean { + return String(type ?? '').toUpperCase() === 'HASHTAG'; +} + +export function formatMcChannelDraftLabel(draft: McChannelDraft): string { + if (isHashtagType(draft.mc_channel_type)) { + const tag = stripHashtagPrefix(draft.mc_hashtag ?? draft.name); + return tag ? `#${tag}` : 'Hashtag channel'; + } + return (draft.name || 'Public channel').trim(); +} + +export function formatAssignedMcChannelLabel( + assigned: AssignedMcChannel, + catalog: MessageChannel[], + feederSnapshots: McChannelSnapshot[] +): string { + if (assigned.catalogId != null) { + const fromCatalog = catalog.find((c) => c.id === assigned.catalogId); + if (fromCatalog) { + return formatMessageChannelLabel(fromCatalog); + } + const fromFeeder = feederSnapshots.find((c) => c.id === assigned.catalogId); + if (fromFeeder) { + return snapshotToLabel(fromFeeder); + } + } + if (assigned.draft) { + return formatMcChannelDraftLabel(assigned.draft); + } + return 'Channel'; +} + +function snapshotToLabel(ch: McChannelSnapshot): string { + if (isHashtagType(ch.mc_channel_type)) { + const tag = stripHashtagPrefix(ch.mc_hashtag ?? ch.name); + return tag ? `#${tag}` : ch.name; + } + return ch.name; +} + +export function messageChannelToDraft(ch: MessageChannel): McChannelDraft { + const isHashtag = isHashtagType(ch.mc_channel_type ?? undefined); + if (isHashtag) { + const tag = stripHashtagPrefix(ch.mc_hashtag ?? ch.name); + return { + mc_channel_type: 'HASHTAG', + name: tag, + mc_hashtag: tag || null, + }; + } + return { + mc_channel_type: 'PUBLIC', + name: (ch.name || 'Public').trim(), + mc_hashtag: null, + }; +} + +export function assignedFromFeeder(node: OwnedManagedNode): AssignedMcChannel[] { + return (node.mc_channels ?? []).map((ch) => ({ + clientId: `catalog-${ch.id}`, + catalogId: ch.id, + })); +} + +export function assignedOrderKey(assigned: AssignedMcChannel[]): string { + return assigned.map((a) => (a.catalogId != null ? `c:${a.catalogId}` : `d:${a.clientId}`)).join('|'); +} + +function applyEntryFromCatalog(ch: MessageChannel, index: number): McChannelApplyEntry { + const draft = messageChannelToDraft(ch); + return { + mc_channel_idx: index, + mc_channel_type: draft.mc_channel_type, + name: draft.name, + mc_hashtag: draft.mc_hashtag, + }; +} + +function applyEntryFromSnapshot(ch: McChannelSnapshot, index: number): McChannelApplyEntry { + if (isHashtagType(ch.mc_channel_type)) { + const tag = stripHashtagPrefix(ch.mc_hashtag ?? ch.name); + return { + mc_channel_idx: index, + mc_channel_type: 'HASHTAG', + name: tag, + mc_hashtag: tag || null, + }; + } + return { + mc_channel_idx: index, + mc_channel_type: 'PUBLIC', + name: (ch.name || 'Public').trim(), + mc_hashtag: null, + }; +} + +export function assignedToApplyEntries( + assigned: AssignedMcChannel[], + catalog: MessageChannel[], + feederSnapshots: McChannelSnapshot[] +): McChannelApplyEntry[] { + return assigned.map((row, index) => { + if (row.catalogId != null) { + const fromCatalog = catalog.find((c) => c.id === row.catalogId); + if (fromCatalog) { + return applyEntryFromCatalog(fromCatalog, index); + } + const fromFeeder = feederSnapshots.find((c) => c.id === row.catalogId); + if (fromFeeder) { + return applyEntryFromSnapshot(fromFeeder, index); + } + } + if (row.draft) { + if (row.draft.mc_channel_type === 'HASHTAG') { + const tag = stripHashtagPrefix(row.draft.mc_hashtag ?? row.draft.name); + return { + mc_channel_idx: index, + mc_channel_type: 'HASHTAG', + name: tag, + mc_hashtag: tag || null, + }; + } + return { + mc_channel_idx: index, + mc_channel_type: 'PUBLIC', + name: row.draft.name.trim() || 'Public', + mc_hashtag: null, + }; + } + throw new Error('Invalid assigned channel row'); + }); +} + +export function newDraftChannel(type: 'PUBLIC' | 'HASHTAG', nameInput: string): McChannelDraft { + if (type === 'HASHTAG') { + const tag = stripHashtagPrefix(nameInput); + return { mc_channel_type: 'HASHTAG', name: tag, mc_hashtag: tag || null }; + } + return { + mc_channel_type: 'PUBLIC', + name: nameInput.trim() || 'Public', + mc_hashtag: null, + }; +} + +export function reorderAssigned( + assigned: AssignedMcChannel[], + fromIndex: number, + toIndex: number +): AssignedMcChannel[] { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= assigned.length || + toIndex >= assigned.length + ) { + return assigned; + } + const next = [...assigned]; + const [item] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, item); + return next; +} diff --git a/src/pages/user/NodeSettings.tsx b/src/pages/user/NodeSettings.tsx index cc7702b..6cbc776 100644 --- a/src/pages/user/NodeSettings.tsx +++ b/src/pages/user/NodeSettings.tsx @@ -24,18 +24,12 @@ import { import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { - ObservedNode, - type McChannelApplyEntry, - type McChannelSnapshot, - type MessageChannel, - type OwnedManagedNode, - type NodeApiKey, -} from '@/lib/models'; +import { ObservedNode, type MessageChannel, type OwnedManagedNode, type NodeApiKey } from '@/lib/models'; import { SetupManagedNode } from '@/components/nodes/SetupManagedNode'; import { apiKeyLinksManagedNode, isObservedNodeManaged, managedNodeStableKey } from '@/lib/managed-node-enrollment'; import { meshProtocolFromManagedNode, meshProtocolFromObservedNode } from '@/lib/mesh-protocol'; -import { filterChannelsForProtocol, formatMessageChannelLabel } from '@/lib/message-channels'; + +import { MeshCoreChannelEditor } from '@/components/nodes/MeshCoreChannelEditor'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMeshtasticApi } from '@/hooks/api/useApi'; import { cn } from '@/lib/utils'; @@ -590,307 +584,9 @@ function channelMappingsEqual(a: ChannelMappings, b: ChannelMappings): boolean { }); } -function stripHashtagPrefix(value: string): string { - return value.replace(/^#+/, '').trim(); -} - -type McChannelRowState = McChannelApplyEntry & { - linkMode: 'existing' | 'new'; - linkedChannelId?: number; -}; - -function mcChannelRowsFromNode(node: OwnedManagedNode): McChannelRowState[] { - return (node.mc_channels ?? []).map((ch) => ({ - mc_channel_idx: ch.mc_channel_idx, - name: ch.name, - mc_channel_type: ch.mc_channel_type, - mc_hashtag: ch.mc_channel_type === 'HASHTAG' ? (ch.mc_hashtag ?? stripHashtagPrefix(ch.name)) : ch.mc_hashtag, - linkMode: 'new' as const, - linkedChannelId: ch.id, - })); -} - -function rowToApplyEntry(row: McChannelRowState): McChannelApplyEntry { - return { - mc_channel_idx: row.mc_channel_idx, - name: row.name, - mc_channel_type: row.mc_channel_type, - mc_hashtag: row.mc_hashtag, - }; -} - -function applyMcChannelErrorMessage(err: unknown): string { - const data = (err as { data?: { detail?: string; code?: string } })?.data as - | { detail?: string; code?: string } - | undefined; - if (data?.code === 'feeder_bot_not_connected') { - return data.detail ?? 'Feeder bot is not connected via WebSocket.'; - } - if (data?.code === 'command_dispatch_unavailable') { - return data.detail ?? 'Could not dispatch the command (channel layer unavailable).'; - } - if (data?.detail) { - return String(data.detail); - } - return 'Could not apply channel config to the radio.'; -} - function MeshCoreChannelSettings({ node }: { node: OwnedManagedNode }) { - const api = useMeshtasticApi(); - const queryClient = useQueryClient(); - const internalId = node.internal_id; - const constellationId = node.constellation.id; - const { data: constellationChannels = [] } = useConstellationChannels(constellationId); - const mcCatalog = useMemo( - () => filterChannelsForProtocol(constellationChannels, 'meshcore'), - [constellationChannels] - ); - const [rows, setRows] = useState(() => mcChannelRowsFromNode(node)); const [open, setOpen] = useState(false); - - useEffect(() => { - setRows(mcChannelRowsFromNode(node)); - }, [node]); - - const applyToRadio = useMutation({ - mutationFn: () => { - if (!internalId) { - throw new Error('Missing feeder internal_id'); - } - return api.applyMcChannelConfig(internalId, rows.map(rowToApplyEntry)); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['managed-nodes', 'mine'] }); - setOpen(false); - }, - }); - - const applyCatalogChannel = (index: number, channelId: number) => { - const ch = mcCatalog.find((c) => c.id === channelId); - if (!ch) return; - const isHashtag = String(ch.mc_channel_type ?? '').toUpperCase() === 'HASHTAG'; - const tag = isHashtag ? stripHashtagPrefix(ch.mc_hashtag ?? ch.name) : ''; - setRows((prev) => - prev.map((r, i) => { - if (i !== index) return r; - return { - ...r, - linkMode: 'existing', - linkedChannelId: ch.id, - mc_channel_type: isHashtag ? 'HASHTAG' : 'PUBLIC', - name: isHashtag ? tag : ch.name, - mc_hashtag: isHashtag ? tag || null : null, - }; - }) - ); - }; - - const updateRow = (index: number, patch: Partial) => { - setRows((prev) => - prev.map((r, i) => { - if (i !== index) return r; - const next = { ...r, ...patch }; - if (patch.linkMode === 'new') { - next.linkedChannelId = undefined; - } - if (next.mc_channel_type === 'HASHTAG') { - const tag = stripHashtagPrefix(next.mc_hashtag ?? next.name); - return { ...next, name: tag, mc_hashtag: tag || null }; - } - return { ...next, mc_hashtag: null }; - }) - ); - }; - - const addRow = () => { - const used = new Set(rows.map((r) => r.mc_channel_idx)); - let idx = 0; - while (used.has(idx) && idx < 63) idx += 1; - setRows((prev) => [ - ...prev, - { - mc_channel_idx: idx, - name: `Channel ${idx}`, - mc_channel_type: 'PUBLIC', - mc_hashtag: null, - linkMode: 'new', - }, - ]); - }; - - const removeRow = (index: number) => { - setRows((prev) => prev.filter((_, i) => i !== index)); - }; - - const syncedLabel = node.mc_channels_synced_at - ? `Last synced from radio: ${new Date(node.mc_channels_synced_at).toLocaleString()}` - : 'Not synced yet — start the bot with upload enabled to mirror device channels.'; - - return ( -
- - {open ? ( -
-

{syncedLabel}

-

- The radio is the source of truth. Edits here are sent to the device when you apply; the bot re-syncs the API - mirror afterward. The feeder bot must be online (WebSocket). -

- {rows.length === 0 ? ( -

No channels in the API mirror yet.

- ) : ( -
- {rows.map((row, index) => ( -
-
- - Device slot {row.mc_channel_idx} - - -
-
- - -
- {row.linkMode === 'existing' ? ( -
- - - {mcCatalog.length === 0 ? ( -

No MeshCore channels in this constellation yet.

- ) : null} -
- ) : ( -
-
- - -
- {row.mc_channel_type === 'HASHTAG' ? ( -
- -
- - # - - updateRow(index, { mc_hashtag: e.target.value })} - placeholder="test" - aria-label="Hashtag name" - /> -
-
- ) : ( -
- - updateRow(index, { name: e.target.value })} - /> -
- )} -
- )} -
- ))} -
- )} -
- - - {applyToRadio.isError && ( - - {applyMcChannelErrorMessage(applyToRadio.error)} - - )} -
-
- ) : null} -
- ); + return ; } function ManagedNodeSettings({