diff --git a/src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx b/src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx new file mode 100644 index 000000000..0ad497246 --- /dev/null +++ b/src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx @@ -0,0 +1,241 @@ +/** + * @vitest-environment jsdom + * + * Tests for MeshCoreChannelsConfigSection (phase 3). + * - Renders the list of channels fetched from /api/channels/all. + * - "Add channel" appears and seeds the next free index + a generated secret. + * - Save sends a PUT to /api/channels/:idx with base64-encoded PSK + + * sourceId, and re-fetches afterwards. + * - Delete sends a DELETE to /api/channels/:idx?sourceId=… and re-fetches. + * - The secret input is hidden by default and toggles on "Show". + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record, vars?: Record) => { + if (typeof fallback === 'string') { + if (vars && typeof vars === 'object') { + return fallback.replace(/\{\{(\w+)\}\}/g, (_m, k) => String((vars as any)[k] ?? '')); + } + return fallback; + } + return key; + }, + }), +})); + +vi.mock('../../contexts/AuthContext', () => ({ + useAuth: () => ({ hasPermission: () => true }), +})); + +vi.mock('../ToastContainer', () => ({ + useToast: () => ({ showToast: vi.fn() }), +})); + +const csrfFetchMock = vi.fn(); +vi.mock('../../hooks/useCsrfFetch', () => ({ + useCsrfFetch: () => csrfFetchMock, +})); + +import { MeshCoreChannelsConfigSection } from './MeshCoreChannelsConfigSection'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +beforeEach(() => { + csrfFetchMock.mockReset(); + // Make crypto.getRandomValues deterministic so we can match the seeded secret. + vi.spyOn(crypto, 'getRandomValues').mockImplementation((arr: any) => { + if (arr && typeof arr.length === 'number') { + for (let i = 0; i < arr.length; i++) arr[i] = (i + 1) & 0xff; + } + return arr; + }); +}); + +describe('MeshCoreChannelsConfigSection — list rendering', () => { + it('renders each channel returned by /api/channels/all', async () => { + csrfFetchMock.mockResolvedValueOnce( + jsonResponse([ + { id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }, + { id: 1, name: 'Town', psk: 'EBESExQVFhcYGRobHB0eHw==' }, + { id: 2, name: '', psk: 'ICEiIyQlJicoKSorLC0uLw==' }, + ]), + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('# Public')).toBeTruthy(); + expect(screen.getByText('# Town')).toBeTruthy(); + // Unnamed slot falls back to "Channel N". + expect(screen.getByText('# Channel 2')).toBeTruthy(); + }); + + const calledUrl = csrfFetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain('/api/channels/all?sourceId=src-a'); + }); + + it('shows the empty-state when the API returns no channels', async () => { + csrfFetchMock.mockResolvedValueOnce(jsonResponse([])); + render( + , + ); + await waitFor(() => + expect(screen.getByText('No channels reported by the device yet.')).toBeTruthy(), + ); + }); + + it('disables Edit/Delete when canWrite=false', async () => { + csrfFetchMock.mockResolvedValueOnce( + jsonResponse([{ id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }]), + ); + render( + , + ); + await waitFor(() => screen.getByText('# Public')); + expect((screen.getByText('Edit') as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByText('Delete') as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByText('+ Add channel') as HTMLButtonElement).disabled).toBe(true); + }); +}); + +describe('MeshCoreChannelsConfigSection — add channel', () => { + it('seeds the editor with the next free idx and a generated secret', async () => { + csrfFetchMock.mockResolvedValueOnce( + jsonResponse([ + { id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }, + // Note: idx 1 is missing → "next free" should be 1. + { id: 2, name: 'Other', psk: 'EBESExQVFhcYGRobHB0eHw==' }, + ]), + ); + + render( + , + ); + await waitFor(() => screen.getByText('# Public')); + + fireEvent.click(screen.getByText('+ Add channel')); + + expect(screen.getByText('Adding channel 1')).toBeTruthy(); + + // crypto.getRandomValues mock fills bytes with [1,2,...,16]. + // Hex: 0102030405060708090a0b0c0d0e0f10 + const secretInput = screen.getByLabelText('Secret (hex, 32 chars)') as HTMLInputElement; + expect(secretInput.value).toBe('0102030405060708090a0b0c0d0e0f10'); + }); + + it('Save sends PUT to /api/channels/ with base64 PSK + sourceId, then re-fetches', async () => { + csrfFetchMock + // initial list + .mockResolvedValueOnce(jsonResponse([ + { id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }, + ])) + // PUT response + .mockResolvedValueOnce(jsonResponse({ success: true })) + // re-fetch list after save + .mockResolvedValueOnce(jsonResponse([ + { id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }, + { id: 1, name: 'NewChan', psk: 'AQIDBAUGBwgJCgsMDQ4PEA==' }, + ])); + + render( + , + ); + await waitFor(() => screen.getByText('# Public')); + + fireEvent.click(screen.getByText('+ Add channel')); + const nameInput = screen.getByLabelText('Name') as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'NewChan' } }); + + await act(async () => { + fireEvent.click(screen.getByText('Save')); + }); + + // Find the PUT call. + const putCall = csrfFetchMock.mock.calls.find( + c => typeof c[1]?.method === 'string' && c[1].method === 'PUT', + ); + expect(putCall).toBeDefined(); + expect(putCall![0]).toBe('/api/channels/1'); + const body = JSON.parse(putCall![1].body); + expect(body.name).toBe('NewChan'); + expect(body.sourceId).toBe('src-a'); + // PSK is the base64 of the 16-byte deterministic secret. + expect(body.psk).toBe('AQIDBAUGBwgJCgsMDQ4PEA=='); + + // Re-fetch happened (third csrfFetch call is a GET). + await waitFor(() => screen.getByText('# NewChan')); + }); +}); + +describe('MeshCoreChannelsConfigSection — delete + secret-visibility', () => { + it('Delete sends DELETE to /api/channels/?sourceId= and refetches', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + csrfFetchMock + // initial list + .mockResolvedValueOnce(jsonResponse([ + { id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }, + { id: 1, name: 'GoneSoon', psk: 'EBESExQVFhcYGRobHB0eHw==' }, + ])) + // DELETE response + .mockResolvedValueOnce(jsonResponse({ success: true })) + // re-fetch + .mockResolvedValueOnce(jsonResponse([ + { id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }, + ])); + + render( + , + ); + await waitFor(() => screen.getByText('# GoneSoon')); + + // Find the row with GoneSoon and click its Delete button (the second one). + const deleteButtons = screen.getAllByText('Delete') as HTMLButtonElement[]; + expect(deleteButtons.length).toBe(2); + await act(async () => { + fireEvent.click(deleteButtons[1]); + }); + + const deleteCall = csrfFetchMock.mock.calls.find( + c => typeof c[1]?.method === 'string' && c[1].method === 'DELETE', + ); + expect(deleteCall).toBeDefined(); + expect(deleteCall![0]).toBe('/api/channels/1?sourceId=src-a'); + + await waitFor(() => { + expect(screen.queryByText('# GoneSoon')).toBeNull(); + }); + + confirmSpy.mockRestore(); + }); + + it('Secret input is type=password by default and switches to text when Show is clicked', async () => { + csrfFetchMock.mockResolvedValueOnce( + jsonResponse([{ id: 0, name: 'Public', psk: 'AAECAwQFBgcICQoLDA0ODw==' }]), + ); + + render( + , + ); + await waitFor(() => screen.getByText('# Public')); + + fireEvent.click(screen.getByText('Edit')); + const secretInput = screen.getByLabelText('Secret (hex, 32 chars)') as HTMLInputElement; + expect(secretInput.type).toBe('password'); + + fireEvent.click(screen.getByText('Show')); + expect(secretInput.type).toBe('text'); + // Hex of the base64 'AAECAwQFBgcICQoLDA0ODw==' is 000102030405060708090a0b0c0d0e0f. + expect(secretInput.value).toBe('000102030405060708090a0b0c0d0e0f'); + }); +}); diff --git a/src/components/MeshCore/MeshCoreChannelsConfigSection.tsx b/src/components/MeshCore/MeshCoreChannelsConfigSection.tsx new file mode 100644 index 000000000..6676718c4 --- /dev/null +++ b/src/components/MeshCore/MeshCoreChannelsConfigSection.tsx @@ -0,0 +1,489 @@ +/** + * MeshCoreChannelsConfigSection — MeshCore-specific channel configuration UI. + * + * Why a separate component instead of extending `ChannelsConfigSection`: + * the Meshtastic config section is 900+ lines built around drag-reorder of + * a fixed 8-slot grid, plus role/uplink/downlink/positionPrecision semantics + * that simply don't exist for MeshCore. MeshCore channels are + * `{ channelIdx, name, secret(16B AES-128) }` and the count is device- + * dependent. Forking keeps both flows readable; phase 1 already softened + * the shared backend (`cleanupInvalidChannels`, the PUT/DELETE routes) to + * branch by source type. + * + * Capabilities: + * - List the channels synced by `MeshCoreManager.syncChannelsFromDevice`. + * - Add a new channel (auto-assigns the lowest free index). + * - Rename, regenerate-secret (16-byte `crypto.getRandomValues`), or + * delete an existing channel. + * - Show the secret in hex with show/copy toggles (same masked-by-default + * UX as the Meshtastic PSK field). + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCsrfFetch } from '../../hooks/useCsrfFetch'; +import { useAuth } from '../../contexts/AuthContext'; +import { useToast } from '../ToastContainer'; +import { logger } from '../../utils/logger'; + +interface MeshCoreChannelsConfigSectionProps { + baseUrl: string; + sourceId: string; + /** When false (device not connected), the section is read-only with a notice. */ + canWrite: boolean; +} + +interface ChannelRow { + id: number; + name: string; + /** Raw base64 PSK from the server; only present when the caller has write + * permission to this channel (issue #2951). For MeshCore the underlying + * bytes are exactly 16. */ + psk?: string | null; +} + +const SECRET_BYTES = 16; +const MAX_NAME_BYTES = 31; + +// --- helpers --------------------------------------------------------------- + +function base64ToHex(b64: string | null | undefined): string { + if (!b64) return ''; + try { + const bin = atob(b64); + let out = ''; + for (let i = 0; i < bin.length; i++) out += bin.charCodeAt(i).toString(16).padStart(2, '0'); + return out; + } catch { + return ''; + } +} + +function hexToBase64(hex: string): string { + const clean = hex.replace(/\s+/g, '').toLowerCase(); + if (clean.length % 2 !== 0) throw new Error('odd hex length'); + const bytes = new Uint8Array(clean.length / 2); + for (let i = 0; i < clean.length; i += 2) { + const v = parseInt(clean.substring(i, i + 2), 16); + if (Number.isNaN(v)) throw new Error('invalid hex'); + bytes[i / 2] = v; + } + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function generateSecretHex(): string { + const bytes = new Uint8Array(SECRET_BYTES); + crypto.getRandomValues(bytes); + let hex = ''; + for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0'); + return hex; +} + +function isValid16ByteHex(hex: string): boolean { + const clean = hex.replace(/\s+/g, ''); + return /^[0-9a-fA-F]{32}$/.test(clean); +} + +// --- component ------------------------------------------------------------- + +export const MeshCoreChannelsConfigSection: React.FC = ({ + baseUrl, + sourceId, + canWrite, +}) => { + const { t } = useTranslation(); + const csrfFetch = useCsrfFetch(); + const { hasPermission } = useAuth(); + const canConfigure = canWrite && hasPermission('configuration', 'write'); + + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(false); + const [editingIdx, setEditingIdx] = useState(null); + const [editName, setEditName] = useState(''); + const [editSecretHex, setEditSecretHex] = useState(''); + const [showSecret, setShowSecret] = useState(false); + const [saving, setSaving] = useState(false); + const [reloadTick, setReloadTick] = useState(0); + const { showToast } = useToast(); + + const reload = useCallback(() => setReloadTick(v => v + 1), []); + + useEffect(() => { + if (!sourceId) return; + let cancelled = false; + setLoading(true); + (async () => { + try { + const url = `${baseUrl}/api/channels/all?sourceId=${encodeURIComponent(sourceId)}`; + const response = await csrfFetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = await response.json(); + const rows: ChannelRow[] = Array.isArray(raw) + ? raw + .filter((c: any) => typeof c?.id === 'number') + .map((c: any) => ({ + id: c.id as number, + name: String(c.name ?? ''), + psk: typeof c.psk === 'string' ? c.psk : null, + })) + .sort((a, b) => a.id - b.id) + : []; + if (!cancelled) setChannels(rows); + } catch (err) { + if (!cancelled) logger.error('Failed to fetch MeshCore channels for config:', err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [baseUrl, sourceId, csrfFetch, reloadTick]); + + // Find the smallest free channel index (starting at 0). Used by "Add channel". + const nextFreeIdx = useMemo(() => { + const used = new Set(channels.map(c => c.id)); + for (let i = 0; i < 256; i++) if (!used.has(i)) return i; + return 255; + }, [channels]); + + const startEdit = useCallback((row: ChannelRow) => { + setEditingIdx(row.id); + setEditName(row.name); + setEditSecretHex(base64ToHex(row.psk)); + setShowSecret(false); + }, []); + + const startAdd = useCallback(() => { + setEditingIdx(nextFreeIdx); + setEditName(''); + setEditSecretHex(generateSecretHex()); + setShowSecret(false); + }, [nextFreeIdx]); + + const cancelEdit = useCallback(() => { + setEditingIdx(null); + setEditName(''); + setEditSecretHex(''); + setShowSecret(false); + }, []); + + const handleRegenerate = useCallback(() => { + setEditSecretHex(generateSecretHex()); + }, []); + + const handleCopySecret = useCallback(async () => { + if (!editSecretHex) return; + try { + await navigator.clipboard.writeText(editSecretHex); + showToast(t('meshcore.channels.secret_copied', 'Secret copied to clipboard'), 'success'); + } catch { + showToast(t('meshcore.channels.secret_copy_failed', 'Failed to copy secret'), 'error'); + } + }, [editSecretHex, showToast, t]); + + const handleSave = useCallback(async () => { + if (editingIdx === null) return; + const trimmedName = editName.trim(); + if (new TextEncoder().encode(trimmedName).length > MAX_NAME_BYTES) { + showToast(t('meshcore.channels.name_too_long', 'Channel name must be {{max}} bytes or less', { max: MAX_NAME_BYTES }), 'error'); + return; + } + if (!isValid16ByteHex(editSecretHex)) { + showToast(t('meshcore.channels.invalid_secret', 'Secret must be exactly 32 hex characters (16 bytes)'), 'error'); + return; + } + + setSaving(true); + try { + const pskBase64 = hexToBase64(editSecretHex); + const response = await csrfFetch(`${baseUrl}/api/channels/${editingIdx}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: trimmedName, + psk: pskBase64, + sourceId, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body?.success === false) { + const msg = body?.error || `HTTP ${response.status}`; + showToast(t('meshcore.channels.save_failed', 'Failed to save channel: {{msg}}', { msg }), 'error'); + return; + } + showToast(t('meshcore.channels.saved', 'Channel {{idx}} saved', { idx: editingIdx }), 'success'); + cancelEdit(); + reload(); + } catch (err) { + logger.error('MeshCore channel save error:', err); + showToast(t('meshcore.channels.save_failed_generic', 'Failed to save channel'), 'error'); + } finally { + setSaving(false); + } + }, [editingIdx, editName, editSecretHex, baseUrl, sourceId, csrfFetch, showToast, t, cancelEdit, reload]); + + const handleDelete = useCallback(async (idx: number) => { + if (!confirm(t('meshcore.channels.confirm_delete', 'Delete channel {{idx}}? This will remove it from the device.', { idx }))) { + return; + } + try { + const response = await csrfFetch(`${baseUrl}/api/channels/${idx}?sourceId=${encodeURIComponent(sourceId)}`, { + method: 'DELETE', + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body?.success === false) { + const msg = body?.error || `HTTP ${response.status}`; + showToast(t('meshcore.channels.delete_failed', 'Failed to delete channel: {{msg}}', { msg }), 'error'); + return; + } + showToast(t('meshcore.channels.deleted', 'Channel {{idx}} deleted', { idx }), 'success'); + reload(); + } catch (err) { + logger.error('MeshCore channel delete error:', err); + showToast(t('meshcore.channels.delete_failed_generic', 'Failed to delete channel'), 'error'); + } + }, [baseUrl, sourceId, csrfFetch, showToast, t, reload]); + + return ( +
+

{t('meshcore.channels.title', 'Channels')}

+

+ {t( + 'meshcore.channels.hint', + 'Channels on this MeshCore device. Each channel is a name plus a 16-byte (AES-128) shared secret.', + )} +

+ + {loading && channels.length === 0 && ( +
+ {t('meshcore.channels.loading_list', 'Loading channels…')} +
+ )} + + {!loading && channels.length === 0 && ( +
+ {t('meshcore.channels.empty', 'No channels reported by the device yet.')} +
+ )} + + {channels.length > 0 && ( +
    + {channels.map(row => { + const isEditing = editingIdx === row.id; + return ( +
  • + {!isEditing && ( +
    +
    +
    + # {row.name || t('meshcore.channels.unnamed', 'Channel {{idx}}', { idx: row.id })} +
    +
    + {t('meshcore.channels.idx_label', 'Index')}: {row.id} +
    +
    + + +
    + )} + + {isEditing && ( + setShowSecret(v => !v)} + onRegenerate={handleRegenerate} + onCopy={handleCopySecret} + onSave={handleSave} + onCancel={cancelEdit} + saving={saving} + /> + )} +
  • + ); + })} +
+ )} + + {/* Inline "Add channel" editor (used when starting fresh without an existing row). */} + {editingIdx !== null && !channels.some(c => c.id === editingIdx) && ( +
+
+ {t('meshcore.channels.adding', 'Adding channel {{idx}}', { idx: editingIdx })} +
+ setShowSecret(v => !v)} + onRegenerate={handleRegenerate} + onCopy={handleCopySecret} + onSave={handleSave} + onCancel={cancelEdit} + saving={saving} + /> +
+ )} + +
+ +
+
+ ); +}; + +// --- editor sub-component (used inline for both edit + add) --------------- + +interface ChannelEditorProps { + idx: number; + name: string; + onNameChange: (v: string) => void; + secretHex: string; + onSecretChange: (v: string) => void; + showSecret: boolean; + onToggleShowSecret: () => void; + onRegenerate: () => void; + onCopy: () => void; + onSave: () => void; + onCancel: () => void; + saving: boolean; +} + +const ChannelEditor: React.FC = ({ + idx, + name, + onNameChange, + secretHex, + onSecretChange, + showSecret, + onToggleShowSecret, + onRegenerate, + onCopy, + onSave, + onCancel, + saving, +}) => { + const { t } = useTranslation(); + return ( +
+
+ + onNameChange(e.target.value)} + maxLength={MAX_NAME_BYTES} + disabled={saving} + style={{ width: '100%' }} + /> +
+
+ +
+ onSecretChange(e.target.value)} + spellCheck={false} + autoComplete="off" + disabled={saving} + style={{ flex: 1, fontFamily: 'monospace' }} + /> + + + +
+
+
+ + +
+
+ ); +}; + +export default MeshCoreChannelsConfigSection; diff --git a/src/components/MeshCore/MeshCoreConfigurationView.tsx b/src/components/MeshCore/MeshCoreConfigurationView.tsx index 04ba74ca7..06630f439 100644 --- a/src/components/MeshCore/MeshCoreConfigurationView.tsx +++ b/src/components/MeshCore/MeshCoreConfigurationView.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { ConnectionStatus, MeshCoreActions, TelemetryMode } from './hooks/useMeshCore'; import { RADIO_PRESETS, findPresetId } from './radioPresets'; import { useAuth } from '../../contexts/AuthContext'; +import { MeshCoreChannelsConfigSection } from './MeshCoreChannelsConfigSection'; const TELEMETRY_MODE_OPTIONS: TelemetryMode[] = ['always', 'device', 'never']; // MeshCore device types: COMPANION=1, REPEATER=2, ROOM_SERVER=3. @@ -11,11 +12,18 @@ const COMPANION_ONLY_DEVICES = new Set([2, 3]); interface MeshCoreConfigurationViewProps { status: ConnectionStatus | null; actions: MeshCoreActions; + /** Frontend basename (typically `''` or `'/meshmonitor'`). Optional so legacy + * single-source callers that don't manage channels still compile. */ + baseUrl?: string; + /** Source UUID — required for the channels sub-section's API calls. */ + sourceId?: string; } export const MeshCoreConfigurationView: React.FC = ({ status, actions, + baseUrl, + sourceId, }) => { const { t } = useTranslation(); const { hasPermission } = useAuth(); @@ -440,6 +448,16 @@ export const MeshCoreConfigurationView: React.FC )} + + {/* Channels — Companion-only and only when the per-source addressing + props are available (sourceId/baseUrl come from the MeshCorePage). */} + {baseUrl !== undefined && sourceId && !COMPANION_ONLY_DEVICES.has(local?.advType ?? 1) && ( + + )} ); }; diff --git a/src/components/MeshCore/MeshCorePage.tsx b/src/components/MeshCore/MeshCorePage.tsx index be9285ec0..9e6641fcc 100644 --- a/src/components/MeshCore/MeshCorePage.tsx +++ b/src/components/MeshCore/MeshCorePage.tsx @@ -100,7 +100,12 @@ export const MeshCorePage: React.FC = ({ baseUrl, sourceId, e )} {view === 'configuration' && ( - + )} {view === 'settings' && ( { try { const channelId = parseInt(req.params.id); - if (isNaN(channelId) || channelId < 0 || channelId > 7) { + if (isNaN(channelId) || channelId < 0) { + return res.status(400).json({ error: 'Invalid channel ID' }); + } + + // The 0-7 slot cap is a Meshtastic-only convention; MeshCore devices + // expose a device-dependent number of channels (see phase-1 plan). + // Resolve the source type early so we can gate the cap accordingly. + const { sourceId: chanSourceId } = req.body; + const sourceRowForType = (typeof chanSourceId === 'string' && chanSourceId.length > 0) + ? await databaseService.sources.getSource(chanSourceId) + : null; + const sourceType = sourceRowForType?.type ?? 'meshtastic_tcp'; + + if (sourceType !== 'meshcore' && channelId > 7) { return res.status(400).json({ error: 'Invalid channel ID. Must be between 0-7' }); } @@ -2818,15 +2831,17 @@ apiRouter.put('/channels/:id', requireAuth(), async (req, res) => { }); } - const { name, psk, role, uplinkEnabled, downlinkEnabled, positionPrecision, sourceId: chanSourceId } = req.body; + const { name, psk, role, uplinkEnabled, downlinkEnabled, positionPrecision } = req.body; - // Validate name if provided (allow empty names for unnamed channels) + // Validate name if provided (allow empty names for unnamed channels). + // Meshtastic caps channel names at 11 chars; MeshCore allows up to 31. if (name !== undefined && name !== null) { if (typeof name !== 'string') { return res.status(400).json({ error: 'Channel name must be a string' }); } - if (name.length > 11) { - return res.status(400).json({ error: 'Channel name must be 11 characters or less' }); + const maxLen = sourceType === 'meshcore' ? 31 : 11; + if (name.length > maxLen) { + return res.status(400).json({ error: `Channel name must be ${maxLen} characters or less` }); } } @@ -2872,6 +2887,45 @@ apiRouter.put('/channels/:id', requireAuth(), async (req, res) => { : existingChannel.positionPrecision, }; + if (sourceType === 'meshcore') { + // MeshCore write path: push the channel to the device first, then + // re-sync the DB from the device (the manager's setChannel handles + // both — including base64↔hex secret conversion). + const mcManager = resolveSourceManager(chanSourceId) as unknown as { + setChannel: (idx: number, name: string, secretHex: string) => Promise; + }; + // Convert the base64 PSK to hex for the meshcore.js wire format. + // Reject anything that doesn't decode to exactly 16 bytes (AES-128). + const incomingPskBase64 = updatedChannelData.psk; + let secretHex: string; + try { + const bytes = Buffer.from(incomingPskBase64 ?? '', 'base64'); + if (bytes.length !== 16) { + return res.status(400).json({ + error: `MeshCore channel secret must decode to exactly 16 bytes (got ${bytes.length})`, + }); + } + secretHex = bytes.toString('hex'); + } catch { + return res.status(400).json({ error: 'Invalid MeshCore channel secret (expected base64 of 16 bytes)' }); + } + + try { + await mcManager.setChannel(channelId, updatedChannelData.name, secretHex); + logger.info(`✅ MeshCore: pushed channel ${channelId} to device + re-synced DB`); + } catch (deviceError) { + logger.error(`⚠️ MeshCore: failed to push channel ${channelId} to device:`, deviceError); + return res.status(502).json({ + error: 'Failed to write channel to MeshCore device', + message: deviceError instanceof Error ? deviceError.message : String(deviceError), + }); + } + + const updatedChannel = await databaseService.channels.getChannelById(channelId, chanSourceId); + return res.json({ success: true, channel: updatedChannel }); + } + + // Meshtastic write path. // Update channel in database. Scope to the requesting source so each // source's channel row is independent. `allowBlankName: true` lets the // user clear a stored channel name — without it, the ingest-protection @@ -2915,10 +2969,24 @@ apiRouter.put('/channels/:id', requireAuth(), async (req, res) => { apiRouter.delete('/channels/:id', requireAuth(), async (req, res) => { try { const channelId = parseInt(req.params.id); - if (isNaN(channelId) || channelId < 0 || channelId > 7) { + if (isNaN(channelId) || channelId < 0) { + return res.status(400).json({ error: 'Invalid channel ID' }); + } + + // sourceId is required so the channel and its messages are removed from a single source + const rawSourceId = (req.body && req.body.sourceId) ?? (req.query && req.query.sourceId); + if (rawSourceId === undefined || rawSourceId === null || rawSourceId === '' || typeof rawSourceId !== 'string') { + return res.status(400).json({ error: 'sourceId is required' }); + } + const deleteChannelSourceId: string = rawSourceId; + + // Same 0-7 cap softening as the PUT route — MeshCore allows higher idx. + const sourceRowForType = await databaseService.sources.getSource(deleteChannelSourceId); + const sourceType = sourceRowForType?.type ?? 'meshtastic_tcp'; + if (sourceType !== 'meshcore' && channelId > 7) { return res.status(400).json({ error: 'Invalid channel ID (0-7)' }); } - if (channelId === 0) { + if (sourceType !== 'meshcore' && channelId === 0) { return res.status(400).json({ error: 'Cannot delete primary channel' }); } @@ -2932,13 +3000,26 @@ apiRouter.delete('/channels/:id', requireAuth(), async (req, res) => { }); } - // sourceId is required so the channel and its messages are removed from a single source - const rawSourceId = (req.body && req.body.sourceId) ?? (req.query && req.query.sourceId); - if (rawSourceId === undefined || rawSourceId === null || rawSourceId === '' || typeof rawSourceId !== 'string') { - return res.status(400).json({ error: 'sourceId is required' }); + if (sourceType === 'meshcore') { + // MeshCore: push delete to the device first, then re-sync the DB. + // (The manager.deleteChannel does both.) Also purge stored messages. + const mcManager = resolveSourceManager(deleteChannelSourceId) as unknown as { + deleteChannel: (idx: number) => Promise; + }; + try { + await mcManager.deleteChannel(channelId); + } catch (deviceError) { + logger.error(`⚠️ MeshCore: failed to delete channel ${channelId} on device:`, deviceError); + return res.status(502).json({ + error: 'Failed to delete channel on MeshCore device', + message: deviceError instanceof Error ? deviceError.message : String(deviceError), + }); + } + logger.info(`🗑️ MeshCore: deleted channel ${channelId} on device + re-synced DB (source=${deleteChannelSourceId})`); + return res.json({ success: true, message: `Channel ${channelId} deleted`, sourceId: deleteChannelSourceId }); } - const deleteChannelSourceId: string = rawSourceId; + // Meshtastic path. // Purge messages for this channel (scoped to the chosen source) const deletedCount = await databaseService.messages.purgeChannelMessages(channelId, deleteChannelSourceId); // Delete the channel record (scoped to the chosen source)