diff --git a/work/dashboard-frontend/src/App.tsx b/work/dashboard-frontend/src/App.tsx index f66e17a..026d499 100644 --- a/work/dashboard-frontend/src/App.tsx +++ b/work/dashboard-frontend/src/App.tsx @@ -10,6 +10,12 @@ import Reference from './pages/Reference' import AdapterConfig from './pages/AdapterConfig' import GaugeSites from './pages/GaugeSites' import TownAnchors from './pages/TownAnchors' +import MeshCoreRouting from './pages/MeshCoreRouting' +import MeshCoreConnection from './pages/MeshCoreConnection' +import MeshCoreContacts from './pages/MeshCoreContacts' +import MeshCoreCompanion from './pages/MeshCoreCompanion' +import MeshtasticConnection from './pages/MeshtasticConnection' +import MeshtasticSources from './pages/MeshtasticSources' import { ToastProvider } from './components/ToastProvider' function App() { @@ -27,6 +33,12 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/work/dashboard-frontend/src/components/Layout.tsx b/work/dashboard-frontend/src/components/Layout.tsx index 07fa702..2434c8c 100644 --- a/work/dashboard-frontend/src/components/Layout.tsx +++ b/work/dashboard-frontend/src/components/Layout.tsx @@ -4,13 +4,19 @@ import { LayoutDashboard, Radio, Cloud, - Settings, Bell, BellRing, BookOpen, Sliders, Droplets, MapPin, + Wifi, + Layers, + Network, + Users, + Bot, + Settings, + type LucideIcon, } from 'lucide-react' import { fetchStatus, type SystemStatus } from '@/lib/api' import { useWebSocket } from '@/hooks/useWebSocket' @@ -21,19 +27,58 @@ interface LayoutProps { children: ReactNode } -const navItems = [ +interface NavItem { + path: string + label: string + icon: LucideIcon +} + +interface NavGroup { + header: string + items: NavItem[] +} + +// Top-level, ungrouped items (no header). +const topNavItems: NavItem[] = [ { path: '/', label: 'Dashboard', icon: LayoutDashboard }, - { path: '/mesh', label: 'Mesh', icon: Radio }, { path: '/environment', label: 'Environment', icon: Cloud }, - { path: '/config', label: 'Config', icon: Settings }, { path: '/alerts', label: 'Alerts', icon: Bell }, - { path: '/notifications', label: 'Notifications', icon: BellRing }, { path: '/reference', label: 'Reference', icon: BookOpen }, { path: '/adapter-config', label: 'Adapter Config', icon: Sliders }, + { path: '/config', label: 'Config', icon: Settings }, { path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets }, { path: '/town-anchors', label: 'Town Anchors', icon: MapPin }, ] +// Grouped sections with labeled headers. Meshtastic "Connection" and "Sources" +// are focused standalone pages (/meshtastic/connection, /meshtastic/sources). +const navGroups: NavGroup[] = [ + { + header: 'Meshtastic', + items: [ + { path: '/meshtastic/connection', label: 'Connection', icon: Wifi }, + { path: '/notifications', label: 'Routing', icon: BellRing }, + { path: '/mesh', label: 'Mesh', icon: Radio }, + { path: '/meshtastic/sources', label: 'Sources', icon: Layers }, + ], + }, + { + header: 'MeshCore', + items: [ + { path: '/meshcore/connection', label: 'Connection', icon: Network }, + { path: '/meshcore/routing', label: 'Routing', icon: BellRing }, + { path: '/meshcore/contacts', label: 'Contacts', icon: Users }, + { path: '/meshcore/companion', label: 'Companion', icon: Bot }, + ], + }, +] + +// Flattened view of every nav item (top + all groups) for title lookup. +const allNavItems: NavItem[] = [ + ...topNavItems, + ...navGroups.flatMap((g) => g.items), +] + function formatUptime(seconds: number): string { const days = Math.floor(seconds / 86400) const hours = Math.floor((seconds % 86400) / 3600) @@ -44,9 +89,40 @@ function formatUptime(seconds: number): string { return `${mins}m` } -function getPageTitle(pathname: string): string { - const item = navItems.find((i) => i.path === pathname) - return item?.label || 'Dashboard' +// Renders a single nav . Items whose path carries a ?section= query are +// matched against pathname+search so only the matching deep-link highlights. +function renderNavItem(item: NavItem, pathname: string, search: string) { + const isActive = item.path.includes('?') + ? `${pathname}${search}` === item.path + : pathname === item.path + const Icon = item.icon + return ( + + {isActive && ( +
+ )} + + {item.label} + + ) +} + +function getPageTitle(fullPath: string): string { + // Exact match first (honors any ?section= query on deep-linked items). + const exact = allNavItems.find((i) => i.path === fullPath) + if (exact) return exact.label + // Fallback: match by pathname only, ignoring query strings. + const base = fullPath.split('?')[0] + const byPath = allNavItems.find((i) => i.path.split('?')[0] === base) + return byPath?.label || 'Dashboard' } export default function Layout({ children }: LayoutProps) { @@ -106,27 +182,15 @@ export default function Layout({ children }: LayoutProps) { {/* Navigation */} {/* Connection status */} @@ -155,7 +219,7 @@ export default function Layout({ children }: LayoutProps) { {/* Header */}

- {getPageTitle(location.pathname)} + {getPageTitle(location.pathname + location.search)}

{/* Live indicator */} diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index b8da989..83b2f8d 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useCallback, useRef } from 'react' +import { Link, useSearchParams } from 'react-router-dom' import { notifyRestartRequired } from '@/components/RestartBanner' import NodePicker from '@/components/NodePicker' import ChannelPicker from '@/components/ChannelPicker' import { - Settings, Bot, Wifi, MessageSquare, Database, Brain, Eye, - Terminal, Cpu, Cloud, Radio, BookOpen, Layers, Activity, + Settings, Bot, MessageSquare, Database, Brain, Eye, + Terminal, Cpu, Cloud, BookOpen, Activity, LayoutDashboard, Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, AlertTriangle, Check, X, Eye as EyeIcon, EyeOff, ExternalLink @@ -18,7 +19,7 @@ interface BotConfig { filter_bbs_protocols: boolean } -interface ConnectionConfig { +export interface ConnectionConfig { type: string serial_port: string tcp_host: string @@ -86,7 +87,7 @@ interface WeatherConfig { wttr: { url: string } } -interface MeshMonitorConfig { +export interface MeshMonitorConfig { enabled: boolean url: string inject_into_prompt: boolean @@ -109,7 +110,7 @@ interface KnowledgeConfig { top_k: number } -interface MeshSourceConfig { +export interface MeshSourceConfig { name: string type: string url: string @@ -225,7 +226,6 @@ type SectionKey = keyof FullConfig const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [ { key: 'bot', label: 'Bot', icon: Bot }, - { key: 'connection', label: 'Connection', icon: Wifi }, { key: 'response', label: 'Response', icon: MessageSquare }, { key: 'history', label: 'History', icon: Database }, { key: 'memory', label: 'Memory', icon: Brain }, @@ -233,9 +233,7 @@ const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [ { key: 'commands', label: 'Commands', icon: Terminal }, { key: 'llm', label: 'LLM', icon: Cpu }, { key: 'weather', label: 'Weather', icon: Cloud }, - { key: 'meshmonitor', label: 'MeshMonitor', icon: Radio }, { key: 'knowledge', label: 'Knowledge', icon: BookOpen }, - { key: 'mesh_sources', label: 'Mesh Sources', icon: Layers }, { key: 'mesh_intelligence', label: 'Intelligence', icon: Activity }, { key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, ] @@ -709,24 +707,10 @@ function BotSection({ data, onChange }: { data: BotConfig; onChange: (d: BotConf ) } -function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) { - const transport = data.transport ?? 'meshtastic' - const showMeshCore = transport === 'meshcore' || transport === 'both' +export function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) { return (
- onChange({ ...data, transport: v })} - options={[ - { value: 'meshtastic', label: 'Meshtastic' }, - { value: 'meshcore', label: 'MeshCore' }, - { value: 'both', label: 'Both' }, - ]} - helper="Which radio transport(s) MeshAI uses" - info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation." - />
)} - {showMeshCore && ( -
-
MeshCore Connection
-
- onChange({ ...data, meshcore_host: v })} - placeholder="192.168.1.100" - helper="IP or hostname of the MeshCore node" - info="Address of the MeshCore node to connect to." - /> - onChange({ ...data, meshcore_port: v })} - min={1} - max={65535} - helper="MeshCore TCP port (default 5525)" - /> -
-
- )} + {/* MeshCore transport + host/port live on their own first-class page + (/meshcore/connection). Subtle cross-link only — no editable fields here. */} +
+ + → MeshCore transport & connection + +
) } @@ -1185,7 +1156,7 @@ function WeatherSection({ data, onChange }: { data: WeatherConfig; onChange: (d: ) } -function MeshMonitorSection({ data, onChange }: { data: MeshMonitorConfig; onChange: (d: MeshMonitorConfig) => void }) { +export function MeshMonitorSection({ data, onChange }: { data: MeshMonitorConfig; onChange: (d: MeshMonitorConfig) => void }) { return (
@@ -1402,7 +1373,7 @@ function MeshSourceCard({ source, onChange, onDelete }: { ) } -function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[]; onChange: (d: MeshSourceConfig[]) => void }) { +export function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[]; onChange: (d: MeshSourceConfig[]) => void }) { const addSource = () => { onChange([...data, { name: 'New Source', @@ -1831,6 +1802,16 @@ export default function Config() { const [config, setConfig] = useState(null) const [originalConfig, setOriginalConfig] = useState(null) const [activeSection, setActiveSection] = useState('bot') + const [searchParams] = useSearchParams() + + // Deep-link support: nav items like /config?section=connection pre-select a + // section. Runs on mount and whenever the query param changes. + useEffect(() => { + const section = searchParams.get('section') + if (section && SECTIONS.some((s) => s.key === section)) { + setActiveSection(section as SectionKey) + } + }, [searchParams]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -1945,7 +1926,6 @@ export default function Config() { const renderSection = () => { switch (activeSection) { case 'bot': return updateSection('bot', d)} /> - case 'connection': return updateSection('connection', d)} /> case 'response': return updateSection('response', d)} /> case 'history': return updateSection('history', d)} /> case 'memory': return updateSection('memory', d)} /> @@ -1953,9 +1933,7 @@ export default function Config() { case 'commands': return updateSection('commands', d)} /> case 'llm': return updateSection('llm', d)} /> case 'weather': return updateSection('weather', d)} /> - case 'meshmonitor': return updateSection('meshmonitor', d)} /> case 'knowledge': return updateSection('knowledge', d)} /> - case 'mesh_sources': return updateSection('mesh_sources', d)} /> case 'mesh_intelligence': return updateSection('mesh_intelligence', d)} /> case 'dashboard': return updateSection('dashboard', d)} /> default: return null diff --git a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx new file mode 100644 index 0000000..ec3c4f7 --- /dev/null +++ b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react' +import { Bot } from 'lucide-react' + +export default function MeshCoreCompanion() { + useEffect(() => { + document.title = 'Companion & Channels - MeshAI' + }, []) + + return ( +
+
+
+
+ +
+
+
+

Companion & Channels

+ + Coming soon + +
+

+ This page will show live status for the AIDA MeshCore companion — its connection + health and the list of channels it is currently joined to. Once the companion status + API is available, you'll be able to monitor the companion here and see which channels + are reachable for broadcast delivery. +

+
+
+
+
+ ) +} diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx new file mode 100644 index 0000000..63414c7 --- /dev/null +++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' +import { TextInput, NumberInput, SelectInput } from './Config' +import { notifyRestartRequired } from '@/components/RestartBanner' +import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' + +// Only the fields this page edits are typed explicitly; the rest of the +// connection config (Meshtastic type / serial / tcp) is preserved untouched on +// save via object spread. +interface ConnectionConfig { + type?: string + serial_port?: string + tcp_host?: string + tcp_port?: number + transport?: string + meshcore_host?: string + meshcore_port?: number + [key: string]: unknown +} + +export default function MeshCoreConnection() { + const [config, setConfig] = useState(null) + const [originalConfig, setOriginalConfig] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [hasChanges, setHasChanges] = useState(false) + + const fetchConfig = useCallback(async () => { + setLoading(true) + try { + const data = (await apiFetchConfig('connection')) as ConnectionConfig + setConfig(data) + setOriginalConfig(JSON.parse(JSON.stringify(data))) + setHasChanges(false) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + document.title = 'MeshCore Connection - MeshAI' + fetchConfig() + }, [fetchConfig]) + + useEffect(() => { + if (config && originalConfig) { + setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) + } + }, [config, originalConfig]) + + const upd = (patch: Partial) => + setConfig((c) => (c ? { ...c, ...patch } : c)) + + const saveConfig = async () => { + if (!config) return + setSaving(true) + setError(null) + setSuccess(null) + try { + // PUT the whole connection object so Meshtastic fields are preserved. + const result = await apiUpdateConfig('connection', config) + setOriginalConfig(JSON.parse(JSON.stringify(config))) + setHasChanges(false) + setSuccess('MeshCore connection saved successfully') + if (result.restart_required) { + notifyRestartRequired([]) + } + setTimeout(() => setSuccess(null), 3000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const discardChanges = () => { + if (originalConfig) { + setConfig(JSON.parse(JSON.stringify(originalConfig))) + setHasChanges(false) + } + } + + if (loading) { + return ( +
+
Loading MeshCore connection...
+
+ ) + } + + if (!config) { + return ( +
+
Failed to load connection config
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ Transport mode and MeshCore node connection. +

+
+
+ + + +
+
+ + {/* Status messages */} + {error && ( +
{error}
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* Form */} +
+ upd({ transport: v })} + options={[ + { value: 'meshtastic', label: 'Meshtastic' }, + { value: 'meshcore', label: 'MeshCore' }, + { value: 'both', label: 'Both' }, + ]} + helper="Which radio transport(s) MeshAI uses" + info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation." + /> +
+
MeshCore Connection
+
+ upd({ meshcore_host: v })} + placeholder="192.168.1.100" + helper="IP or hostname of the MeshCore node" + info="Address of the MeshCore node to connect to." + /> + upd({ meshcore_port: v })} + min={1} + max={65535} + helper="MeshCore TCP port (default 5525)" + /> +
+
+
+ + → Meshtastic connection + +
+
+
+ ) +} diff --git a/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx b/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx new file mode 100644 index 0000000..59bdae1 --- /dev/null +++ b/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react' +import { Users } from 'lucide-react' + +export default function MeshCoreContacts() { + useEffect(() => { + document.title = 'MeshCore Contacts - MeshAI' + }, []) + + return ( +
+
+
+
+ +
+
+
+

MeshCore Contacts

+ + Coming soon + +
+

+ This page will show the MeshCore companion's contact roster — the names, public + keys, last-heard timestamps, and positions of the nodes your companion knows about. + It becomes available once the companion data API is wired up, at which point contacts + can be browsed here and referenced directly when configuring MeshCore DM delivery. +

+
+
+
+
+ ) +} diff --git a/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx new file mode 100644 index 0000000..95874a1 --- /dev/null +++ b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx @@ -0,0 +1,275 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink } from 'lucide-react' +import { + SeverityChannelMatrix, + ListInput, + InfoButton, + TOGGLE_FAMILY_META, + MC_CHANNELS, + type NotificationToggle, + type NotificationsConfig, +} from './Notifications' + +// Merge only the MeshCore-owned fields of `mine` into `fresh`, preserving every +// other (Meshtastic / Other-channels / general) field on the family. The +// severity matrix stores all channels in one dict per severity, so we keep the +// non-meshcore_* entries from the freshly-fetched config and overlay only the +// meshcore_* entries edited on this page. +function mergeMeshcoreFields( + fresh: NotificationToggle | undefined, + mine: NotificationToggle, + key: string, +): NotificationToggle { + const base: NotificationToggle = fresh ? { ...fresh } : { ...mine, name: key } + base.name = base.name || key + + const freshSC = fresh?.severity_channels || {} + const mineSC = mine.severity_channels || {} + const severities = new Set([...Object.keys(freshSC), ...Object.keys(mineSC)]) + const mergedSC: Record = {} + severities.forEach((sev) => { + const nonMeshcore = (freshSC[sev] || []).filter((c) => !c.startsWith('meshcore_')) + const meshcore = (mineSC[sev] || []).filter((c) => c.startsWith('meshcore_')) + mergedSC[sev] = [...nonMeshcore, ...meshcore] + }) + base.severity_channels = mergedSC + base.meshcore_channel = mine.meshcore_channel ?? null + base.meshcore_dm_contacts = mine.meshcore_dm_contacts || [] + return base +} + +export default function MeshCoreRouting() { + const [config, setConfig] = useState(null) + const [originalConfig, setOriginalConfig] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [hasChanges, setHasChanges] = useState(false) + + const fetchConfig = useCallback(async () => { + try { + const res = await fetch('/api/config/notifications') + if (!res.ok) throw new Error('Failed to fetch notifications config') + const data: NotificationsConfig = await res.json() + setConfig(data) + setOriginalConfig(JSON.parse(JSON.stringify(data))) + setHasChanges(false) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + document.title = 'MeshCore Routing - MeshAI' + fetchConfig() + }, [fetchConfig]) + + useEffect(() => { + if (config && originalConfig) { + setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) + } + }, [config, originalConfig]) + + const upd = (fam: string, patch: Partial) => { + if (!config) return + const toggles = config.toggles || {} + setConfig({ + ...config, + toggles: { + ...toggles, + [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle, + }, + }) + } + + const saveConfig = async () => { + if (!config) return + setSaving(true) + setError(null) + setSuccess(null) + try { + // Re-fetch the live config and merge ONLY the MeshCore fields so we never + // clobber concurrent edits made on the Meshtastic Routing page. + const freshRes = await fetch('/api/config/notifications') + if (!freshRes.ok) throw new Error('Failed to re-fetch notifications config') + const fresh: NotificationsConfig = await freshRes.json() + + const merged: NotificationsConfig = { ...fresh, toggles: { ...(fresh.toggles || {}) } } + const myToggles = config.toggles || {} + for (const { key } of TOGGLE_FAMILY_META) { + const mine = myToggles[key] + if (!mine) continue + merged.toggles![key] = mergeMeshcoreFields((fresh.toggles || {})[key], mine, key) + } + + const res = await fetch('/api/config/notifications', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(merged), + }) + const result = await res.json() + if (!res.ok) throw new Error(result.detail || 'Save failed') + + setConfig(merged) + setOriginalConfig(JSON.parse(JSON.stringify(merged))) + setHasChanges(false) + setSuccess('MeshCore routing saved successfully') + setTimeout(() => setSuccess(null), 3000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const discardChanges = () => { + if (originalConfig) { + setConfig(JSON.parse(JSON.stringify(originalConfig))) + setHasChanges(false) + } + } + + if (loading) { + return ( +
+
Loading MeshCore routing...
+
+ ) + } + + if (!config) { + return ( +
+
Failed to load notifications config
+
+ ) + } + + const toggles = config.toggles || {} + + return ( +
+ {/* Header */} +
+
+

+ Per-family MeshCore delivery. Choose which channels fire at each severity, the + MeshCore channel name, and DM contacts. +

+
+
+ + + +
+
+ + {/* Cross-link note: shared family settings live on the Meshtastic Routing page */} +
+ +
+ Shared per-family settings (enable, severity threshold, regions, freshness/cooldown, and + Meshtastic / email / webhook delivery) live on the{' '} + + Meshtastic Routing + {' '} + page. This page edits only the MeshCore delivery for each family. +
+
+ + {/* Status messages */} + {error && ( +
{error}
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* Per-family MeshCore delivery */} +
+
+ MeshCore Delivery + +
+
+ {TOGGLE_FAMILY_META.map(({ key, label, Icon }) => { + const t = toggles[key] || ({} as NotificationToggle) + return ( +
+
+ {label} +
+ +
+
+ + MeshCore +
+ upd(key, { severity_channels: sc })} + /> +
+ + + upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value }) + } + placeholder="AIDA" + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" + /> +

+ Channel name on your MeshCore companion (e.g. AIDA). Blank = not broadcast on + MeshCore. +

+
+ upd(key, { meshcore_dm_contacts: v })} + placeholder="contact name or pubkey" + helper="MeshCore DM recipients (names or pubkeys)" + info="Contact names or pubkeys on the MeshCore companion. Used when meshcore_dm is enabled for a severity." + /> +
+
+ ) + })} +
+
+
+ ) +} diff --git a/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx new file mode 100644 index 0000000..f52e8ab --- /dev/null +++ b/work/dashboard-frontend/src/pages/MeshtasticConnection.tsx @@ -0,0 +1,140 @@ +import { useState, useEffect, useCallback } from 'react' +import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' +import { ConnectionSection, type ConnectionConfig } from './Config' +import { notifyRestartRequired } from '@/components/RestartBanner' +import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' + +export default function MeshtasticConnection() { + const [config, setConfig] = useState(null) + const [originalConfig, setOriginalConfig] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [hasChanges, setHasChanges] = useState(false) + + const fetchConfig = useCallback(async () => { + setLoading(true) + try { + const data = (await apiFetchConfig('connection')) as ConnectionConfig + setConfig(data) + setOriginalConfig(JSON.parse(JSON.stringify(data))) + setHasChanges(false) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + document.title = 'Meshtastic Connection - MeshAI' + fetchConfig() + }, [fetchConfig]) + + useEffect(() => { + if (config && originalConfig) { + setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) + } + }, [config, originalConfig]) + + const saveConfig = async () => { + if (!config) return + setSaving(true) + setError(null) + setSuccess(null) + try { + // PUT the whole connection object so MeshCore fields aren't clobbered. + const result = await apiUpdateConfig('connection', config) + setOriginalConfig(JSON.parse(JSON.stringify(config))) + setHasChanges(false) + setSuccess('Meshtastic connection saved successfully') + if (result.restart_required) { + notifyRestartRequired([]) + } + setTimeout(() => setSuccess(null), 3000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const discardChanges = () => { + if (originalConfig) { + setConfig(JSON.parse(JSON.stringify(originalConfig))) + setHasChanges(false) + } + } + + if (loading) { + return ( +
+
Loading Meshtastic connection...
+
+ ) + } + + if (!config) { + return ( +
+
Failed to load connection config
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ Connection to your Meshtastic radio (serial or TCP). +

+
+
+ + + +
+
+ + {/* Status messages */} + {error && ( +
{error}
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* Form */} +
+ +
+
+ ) +} diff --git a/work/dashboard-frontend/src/pages/MeshtasticSources.tsx b/work/dashboard-frontend/src/pages/MeshtasticSources.tsx new file mode 100644 index 0000000..3583a49 --- /dev/null +++ b/work/dashboard-frontend/src/pages/MeshtasticSources.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect, useCallback } from 'react' +import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' +import { + MeshMonitorSection, + MeshSourcesSection, + type MeshMonitorConfig, + type MeshSourceConfig, +} from './Config' +import { notifyRestartRequired } from '@/components/RestartBanner' +import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' + +export default function MeshtasticSources() { + const [meshmonitor, setMeshmonitor] = useState(null) + const [originalMeshmonitor, setOriginalMeshmonitor] = useState(null) + const [meshSources, setMeshSources] = useState(null) + const [originalMeshSources, setOriginalMeshSources] = useState(null) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [hasChanges, setHasChanges] = useState(false) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const [mm, ms] = await Promise.all([ + apiFetchConfig('meshmonitor') as Promise, + apiFetchConfig('mesh_sources') as Promise, + ]) + setMeshmonitor(mm) + setOriginalMeshmonitor(JSON.parse(JSON.stringify(mm))) + setMeshSources(ms) + setOriginalMeshSources(JSON.parse(JSON.stringify(ms))) + setHasChanges(false) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + document.title = 'Meshtastic Sources - MeshAI' + fetchData() + }, [fetchData]) + + useEffect(() => { + if (meshmonitor && originalMeshmonitor && meshSources && originalMeshSources) { + const mmChanged = JSON.stringify(meshmonitor) !== JSON.stringify(originalMeshmonitor) + const msChanged = JSON.stringify(meshSources) !== JSON.stringify(originalMeshSources) + setHasChanges(mmChanged || msChanged) + } + }, [meshmonitor, originalMeshmonitor, meshSources, originalMeshSources]) + + const saveConfig = async () => { + if (!meshmonitor || !meshSources) return + setSaving(true) + setError(null) + setSuccess(null) + try { + const [mmResult, msResult] = await Promise.all([ + apiUpdateConfig('meshmonitor', meshmonitor), + apiUpdateConfig('mesh_sources', meshSources), + ]) + setOriginalMeshmonitor(JSON.parse(JSON.stringify(meshmonitor))) + setOriginalMeshSources(JSON.parse(JSON.stringify(meshSources))) + setHasChanges(false) + setSuccess('Meshtastic sources saved successfully') + if (mmResult.restart_required || msResult.restart_required) { + notifyRestartRequired([]) + } + setTimeout(() => setSuccess(null), 3000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const discardChanges = () => { + if (originalMeshmonitor) setMeshmonitor(JSON.parse(JSON.stringify(originalMeshmonitor))) + if (originalMeshSources) setMeshSources(JSON.parse(JSON.stringify(originalMeshSources))) + setHasChanges(false) + } + + if (loading) { + return ( +
+
Loading Meshtastic sources...
+
+ ) + } + + if (!meshmonitor || !meshSources) { + return ( +
+
Failed to load sources config
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ MeshMonitor integration and mesh awareness data sources. +

+
+
+ + + +
+
+ + {/* Status messages */} + {error && ( +
{error}
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* MeshMonitor card */} +
+ +
+ + {/* Mesh Sources card */} +
+ +
+
+ ) +} diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index e5fe133..7bdab66 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -39,15 +39,18 @@ interface NotificationRuleConfig { region_scope: string[] } -interface NotificationToggle { +export interface NotificationToggle { name: string enabled: boolean min_severity: string regions: string[] severity_channels: Record + freshness_seconds?: number + cooldown_seconds?: number broadcast_channel: number | null meshcore_channel?: string | null node_ids: string[] + meshcore_dm_contacts: string[] smtp_host: string smtp_port: number smtp_user: string @@ -59,7 +62,7 @@ interface NotificationToggle { webhook_headers: Record } -interface NotificationsConfig { +export interface NotificationsConfig { enabled: boolean cold_start_grace_seconds?: number band_conditions_enabled?: boolean @@ -335,7 +338,7 @@ function formatRelativeTime(timestamp: number | null): string { } // InfoButton component -function InfoButton({ info }: { info: string }) { +export function InfoButton({ info }: { info: string }) { const [open, setOpen] = useState(false) return ( @@ -489,7 +492,7 @@ function TimeInput({ label, value, onChange, helper = '', info = '' }: { ) } -function ListInput({ label, value, onChange, placeholder = 'Add item...', helper = '', info = '' }: { +export function ListInput({ label, value, onChange, placeholder = 'Add item...', helper = '', info = '' }: { label: string value: string[] onChange: (v: string[]) => void @@ -1417,7 +1420,7 @@ function NotificationRuleCard({ } // Main Notifications Page Component -const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[] = [ +export const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[] = [ { key: 'mesh_health', label: 'Mesh Health', Icon: Activity }, { key: 'weather', label: 'Weather', Icon: Cloud }, { key: 'fire', label: 'Fire', Icon: Flame }, @@ -1541,9 +1544,70 @@ function GroupedCategoryPicker({
) } -const TOGGLE_CHANNELS = ['digest', 'mesh_broadcast', 'mesh_dm', 'email', 'webhook'] +// Per-mesh channel groups for the split severity matrix +const MT_CHANNELS = ['mesh_broadcast', 'mesh_dm'] as const +export const MC_CHANNELS = ['meshcore_broadcast', 'meshcore_dm'] as const +const OTHER_CHANNELS = ['digest', 'email', 'webhook'] as const const TOGGLE_SEVERITIES = ['routine', 'priority', 'immediate'] +// Reusable severity × channel matrix. +// Toggling a single channel only touches that exact string in the severity row — +// all other channels (including those owned by the other mesh section) are preserved. +// This makes every checkbox merge-safe: Meshtastic toggles never clobber MeshCore +// entries and vice-versa. +export function SeverityChannelMatrix({ + channels, + severityChannels, + onChange, +}: { + channels: readonly string[] + severityChannels: Record + onChange: (updated: Record) => void +}) { + const colLabel = (c: string) => + c.replace('meshcore_', 'mc_').replace('mesh_', '').replace(/_/g, ' ') + return ( + + + + + {channels.map((c) => ( + + ))} + + + + {TOGGLE_SEVERITIES.map((sev) => ( + + + {channels.map((ch) => { + const on = (severityChannels[sev] || []).includes(ch) + return ( + + ) + })} + + ))} + +
severity{colLabel(c)}
{sev} + { + // Shallow-copy the dict so we don't mutate state, then + // only modify the specific channel being toggled. + const cur: Record = { ...severityChannels } + const arr = new Set(cur[sev] || []) + if (e.target.checked) arr.add(ch) + else arr.delete(ch) + cur[sev] = Array.from(arr) + onChange(cur) + }} + /> +
+ ) +} + function MasterToggles({ toggles, onChange }: { toggles: Record onChange: (t: Record) => void @@ -1581,53 +1645,111 @@ function MasterToggles({ toggles, onChange }: {
)} {isOpen && ( -
- upd(key, { min_severity: v })} /> -
Severity → channels
- - - {TOGGLE_CHANNELS.map((c) => )} - - - {TOGGLE_SEVERITIES.map((sev) => ( - - - {TOGGLE_CHANNELS.map((ch) => { - const on = (t.severity_channels?.[sev] || []).includes(ch) - return ( - - ) - })} - - ))} - -
{c.replace('_', ' ')}
{sev} - { - const cur: Record = { ...(t.severity_channels || {}) } - const arr = new Set(cur[sev] || []) - if (e.target.checked) arr.add(ch); else arr.delete(ch) - cur[sev] = Array.from(arr) - upd(key, { severity_channels: cur }) - }} /> -
- upd(key, { regions: v })} placeholder="Add region..." />
Channel config
- upd(key, { broadcast_channel: v })} /> -
- - upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })} - placeholder="" - className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" +
+ + {/* ── General ──────────────────────────────────────────────── */} +
+
General
+ upd(key, { min_severity: v })} /> + upd(key, { regions: v })} + placeholder="Add region..." /> -

MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.

+
+ upd(key, { freshness_seconds: v })} + min={0} + helper="Drop events older than this" + info="Events older than this window (seconds) are discarded at dispatcher entrance. 600 = 10 min." + /> + upd(key, { cooldown_seconds: v })} + min={0} + helper="0 = no throttle" + info="Per (family, category, region) throttle window. Prevents repeat sends within this window." + /> +
- upd(key, { node_ids: v })} placeholder="!nodeid" /> - upd(key, { recipients: v })} placeholder="ops@example.com" /> - upd(key, { smtp_host: v })} placeholder="smtp.example.com" /> - upd(key, { smtp_port: v })} /> - upd(key, { webhook_url: v })} placeholder="https://..." /> + + {/* ── Meshtastic ───────────────────────────────────────────── */} +
+
+ + Meshtastic +
+ upd(key, { severity_channels: sc })} + /> + upd(key, { broadcast_channel: v })} + min={0} + helper="Meshtastic channel index (0 = LongFast primary)" + info="The Meshtastic channel index used for mesh_broadcast delivery. 0 = primary channel." + /> + upd(key, { node_ids: v })} + placeholder="!hex_id" + helper="Meshtastic DM recipients (hex node IDs)" + info="Hex node IDs for mesh_dm delivery (e.g. !a1b2c3d4). Used when mesh_dm is enabled for a severity." + /> +
+ + {/* MeshCore delivery controls moved to the dedicated + MeshCore -> Routing page (/meshcore/routing). Shared + family settings (enable/severity/regions) stay here. */} + + {/* ── Other channels ───────────────────────────────────────── */} +
+
+ + Other channels +
+ upd(key, { severity_channels: sc })} + /> + upd(key, { recipients: v })} + placeholder="ops@example.com" + /> +
+ + + SMTP settings + +
+ upd(key, { smtp_host: v })} placeholder="smtp.example.com" /> + upd(key, { smtp_port: v })} /> + upd(key, { smtp_user: v })} /> + upd(key, { smtp_password: v })} type="password" /> + upd(key, { smtp_tls: v })} /> + upd(key, { from_address: v })} placeholder="alerts@example.com" /> +
+
+ upd(key, { webhook_url: v })} + placeholder="https://..." + helper="POST alert as JSON" + /> +
+
)}
diff --git a/work/dashboard-frontend/vite.config.ts b/work/dashboard-frontend/vite.config.ts index 275917b..cfe50ef 100644 --- a/work/dashboard-frontend/vite.config.ts +++ b/work/dashboard-frontend/vite.config.ts @@ -16,11 +16,11 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:8080', + target: 'http://localhost:8082', changeOrigin: true, }, '/ws': { - target: 'ws://localhost:8080', + target: 'ws://localhost:8082', ws: true, }, }, diff --git a/work/meshai/config.py b/work/meshai/config.py index 6c496bd..5866029 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -550,7 +550,7 @@ class NotificationRuleConfig: custom_message: str = "" # Delivery type - delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook + delivery_type: str = "" # mesh_broadcast, mesh_dm, meshcore_broadcast, meshcore_dm, email, webhook # Mesh broadcast fields broadcast_channel: int = 0 @@ -559,6 +559,8 @@ class NotificationRuleConfig: # Mesh DM fields node_ids: list = field(default_factory=list) + # MeshCore DM target contacts (names or pubkeys). Parallel to node_ids for Meshtastic. + meshcore_dm_contacts: list = field(default_factory=list) # Email fields smtp_host: str = "" @@ -600,6 +602,8 @@ class NotificationToggle: # Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore. meshcore_channel: Optional[str] = None node_ids: list = field(default_factory=list) + # MeshCore DM target contacts (names or pubkeys). Parallel to node_ids for Meshtastic. + meshcore_dm_contacts: list = field(default_factory=list) smtp_host: str = "" smtp_port: int = 587 smtp_user: str = "" @@ -693,7 +697,8 @@ _DZ_VALID_ROLES = frozenset({ }) _DZ_VALID_DELIVERY = frozenset({ - "mesh_broadcast", "mesh_dm", "email", "webhook", "none", + "mesh_broadcast", "mesh_dm", "meshcore_broadcast", "meshcore_dm", + "email", "webhook", "none", }) # Hazard families that map onto categories.VALID_TOGGLES. snow is a sub-gate of # weather and flood a sub-gate of seismic (resolved in the correlator), so they diff --git a/work/meshai/dashboard/api/notification_routes.py b/work/meshai/dashboard/api/notification_routes.py index a3f0e55..4dbbf47 100644 --- a/work/meshai/dashboard/api/notification_routes.py +++ b/work/meshai/dashboard/api/notification_routes.py @@ -15,11 +15,15 @@ class TestRequest(BaseModel): class ChannelTestRequest(BaseModel): """Request body for channel connectivity test.""" - type: str # mesh_broadcast, mesh_dm, email, webhook + type: str # mesh_broadcast, mesh_dm, meshcore_broadcast, meshcore_dm, email, webhook # Mesh broadcast channel_index: Optional[int] = 0 # Mesh DM node_ids: Optional[List[str]] = [] + # MeshCore broadcast + meshcore_channel: Optional[str] = None + # MeshCore DM + meshcore_dm_contacts: Optional[List[str]] = [] # Email smtp_host: Optional[str] = "" smtp_port: Optional[int] = 587 @@ -117,6 +121,10 @@ async def test_channel(request: Request, body: ChannelTestRequest): channel_config["channel_index"] = body.channel_index or 0 elif body.type == "mesh_dm": channel_config["node_ids"] = body.node_ids or [] + elif body.type == "meshcore_broadcast": + channel_config["meshcore_channel"] = body.meshcore_channel or "" + elif body.type == "meshcore_dm": + channel_config["meshcore_dm_contacts"] = body.meshcore_dm_contacts or [] elif body.type == "email": channel_config.update({ "smtp_host": body.smtp_host or "", diff --git a/work/meshai/notifications/channels.py b/work/meshai/notifications/channels.py index 555d5d3..e99ba76 100644 --- a/work/meshai/notifications/channels.py +++ b/work/meshai/notifications/channels.py @@ -56,22 +56,23 @@ class NotificationChannel(ABC): class MeshBroadcastChannel(NotificationChannel): - """Post alert to mesh channel.""" + """Post alert to Meshtastic channel (explicit Meshtastic-only delivery).""" channel_type = "mesh_broadcast" def __init__(self, connector: "MeshConnector", channel_index: int = 0, - meshcore_channel: Optional[str] = None): + transport: Optional[str] = "meshtastic"): self._connector = connector self._channel = channel_index - # Per-family MeshCore channel NAME (None = MeshCore child skipped - # downstream). Ignored by Meshtastic; behavior-preserving there. - self._meshcore_channel = meshcore_channel + # Transport hint: "meshtastic" for mesh_broadcast; passed to CompositeTransport + # so it routes only to the Meshtastic child. Single-transport implementations + # accept and ignore this kwarg, so behavior is unchanged there. + self._transport = transport _mc = getattr(connector, "max_chars", 200) self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200) async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool: - """Send alert to mesh channel.""" + """Send alert to Meshtastic channel.""" if not self._connector: logger.warning("No mesh connector available") return False @@ -83,7 +84,7 @@ class MeshBroadcastChannel(NotificationChannel): text=alert.message or "", destination=None, channel=self._channel, - meshcore_channel=self._meshcore_channel, + transport=self._transport, ) logger.info("Broadcast pre-chunked alert to channel %d", self._channel) return True @@ -95,7 +96,7 @@ class MeshBroadcastChannel(NotificationChannel): text=chunk, destination=None, channel=self._channel, - meshcore_channel=self._meshcore_channel, + transport=self._transport, ) logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel) return True @@ -173,19 +174,127 @@ class MeshBroadcastChannel(NotificationChannel): return False, f"Mesh broadcast failed: {e}" +class MeshCoreBroadcastChannel(NotificationChannel): + """Post alert to a MeshCore channel (explicit MeshCore-only delivery).""" + + channel_type = "meshcore_broadcast" + + def __init__(self, connector: "MeshConnector", meshcore_channel: Optional[str] = None): + self._connector = connector + # Channel NAME on the MeshCore companion (resolved to a slot at send time). + self._meshcore_channel = meshcore_channel + _mc = getattr(connector, "max_chars", 200) + self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200) + + def _has_meshcore_capability(self) -> bool: + """Return True if the connector can reach a MeshCore transport.""" + # CompositeTransport: check for a child named "meshcore". + by_name = getattr(self._connector, "_by_name", None) + if by_name is not None: + return "meshcore" in by_name + # Single-transport: check for an explicit transport_name tag. + return getattr(self._connector, "transport_name", None) == "meshcore" + + async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool: + """Send alert to MeshCore channel.""" + if not self._connector: + logger.warning("No mesh connector available for meshcore_broadcast") + return False + + if not self._meshcore_channel: + logger.debug("meshcore_broadcast: meshcore_channel not set; skipping") + return False + + if not self._has_meshcore_capability(): + logger.debug( + "meshcore_broadcast: connector has no MeshCore transport; skipping" + ) + return False + + try: + # If payload already has chunk metadata (from digest), use message directly + if alert.chunk_index is not None: + self._connector.send_message( + text=alert.message or "", + destination=None, + meshcore_channel=self._meshcore_channel, + transport="meshcore", + ) + logger.info( + "MeshCore broadcast pre-chunked alert to channel %r", + self._meshcore_channel, + ) + return True + + # Render to chunks for single-event delivery + chunks = self._renderer.render(alert) + for chunk in chunks: + self._connector.send_message( + text=chunk, + destination=None, + meshcore_channel=self._meshcore_channel, + transport="meshcore", + ) + logger.info( + "MeshCore broadcast %d chunk(s) to channel %r", + len(chunks), self._meshcore_channel, + ) + return True + except Exception as e: + logger.error("Failed to MeshCore broadcast alert: %s", e) + return False + + async def test_connection(self) -> dict: + """Test MeshCore channel connectivity.""" + if not self._has_meshcore_capability(): + return { + "success": False, + "message": "No MeshCore transport available", + "error": "Set connection.transport to 'meshcore' or 'both'", + "details": {"meshcore_channel": self._meshcore_channel}, + } + return { + "success": True, + "message": f"MeshCore channel: {self._meshcore_channel}", + "error": "", + "details": {"meshcore_channel": self._meshcore_channel}, + } + + async def deliver_test(self, message: str) -> tuple[bool, str]: + """Deliver a specific test message to the MeshCore channel.""" + if not self._connector: + return False, "Not connected" + if not self._meshcore_channel: + return False, "No MeshCore channel configured" + try: + self._connector.send_message( + text=message, + destination=None, + meshcore_channel=self._meshcore_channel, + transport="meshcore", + ) + return True, f"Sent to MeshCore channel {self._meshcore_channel!r}" + except Exception as e: + return False, f"MeshCore broadcast failed: {e}" + + class MeshDMChannel(NotificationChannel): - """DM alert to specific node IDs.""" + """DM alert to specific Meshtastic node IDs.""" channel_type = "mesh_dm" - def __init__(self, connector: "MeshConnector", node_ids: list[str]): + def __init__(self, connector: "MeshConnector", node_ids: list[str], + transport_hint: Optional[str] = "meshtastic"): self._connector = connector self._node_ids = node_ids + # Explicit transport hint so CompositeTransport routes only to the + # Meshtastic child. Single-transport impls ignore this kwarg. + self._transport_hint = transport_hint _mc = getattr(connector, "max_chars", 200) self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200) async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool: - """Send alert via DM to configured nodes.""" + """Send alert via DM to configured Meshtastic nodes.""" if not self._connector: return False @@ -201,7 +310,12 @@ class MeshDMChannel(NotificationChannel): for message in messages: try: node_id = str(node_id) - self._connector.send_message(text=message, destination=node_id, channel=0) + self._connector.send_message( + text=message, + destination=node_id, + channel=0, + transport=self._transport_hint, + ) except Exception as e: logger.error("Failed to DM %s: %s", node_id, e) success = False @@ -295,6 +409,109 @@ class MeshDMChannel(NotificationChannel): return False, f"All DMs failed: {'; '.join(errors)}" +class MeshCoreDMChannel(NotificationChannel): + """DM alert to specific MeshCore contacts (names or pubkeys).""" + + channel_type = "meshcore_dm" + + def __init__(self, connector: "MeshConnector", contacts: list): + self._connector = connector + self._contacts = list(contacts) + _mc = getattr(connector, "max_chars", 200) + self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200) + + def _has_meshcore_capability(self) -> bool: + """Return True if the connector can reach a MeshCore transport.""" + by_name = getattr(self._connector, "_by_name", None) + if by_name is not None: + return "meshcore" in by_name + return getattr(self._connector, "transport_name", None) == "meshcore" + + async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool: + """Send alert via DM to configured MeshCore contacts.""" + if not self._connector: + return False + + if not self._contacts: + logger.debug("meshcore_dm: no contacts configured; skipping") + return False + + if not self._has_meshcore_capability(): + logger.debug( + "meshcore_dm: connector has no MeshCore transport; skipping" + ) + return False + + # If payload already has chunk metadata (from digest), use message directly + if alert.chunk_index is not None: + messages = [alert.message or ""] + else: + messages = self._renderer.render(alert) + + success = True + for contact in self._contacts: + for message in messages: + try: + self._connector.send_message( + text=message, + destination=str(contact), + transport="meshcore", + ) + except Exception as e: + logger.error("Failed to MeshCore DM %s: %s", contact, e) + success = False + + return success + + async def test_connection(self) -> dict: + """Test MeshCore DM connectivity.""" + if not self._has_meshcore_capability(): + return { + "success": False, + "message": "No MeshCore transport available", + "error": "Set connection.transport to 'meshcore' or 'both'", + "details": {"contacts": self._contacts}, + } + if not self._contacts: + return { + "success": False, + "message": "No MeshCore DM contacts configured", + "error": "Add at least one contact to meshcore_dm_contacts", + "details": {"contacts": []}, + } + return { + "success": True, + "message": f"MeshCore DM to {len(self._contacts)} contact(s)", + "error": "", + "details": {"contacts": self._contacts}, + } + + async def deliver_test(self, message: str) -> tuple[bool, str]: + """Deliver a specific test message via MeshCore DM.""" + if not self._connector: + return False, "Not connected" + if not self._contacts: + return False, "No MeshCore DM contacts configured" + success_count = 0 + errors = [] + for contact in self._contacts: + try: + self._connector.send_message( + text=message, + destination=str(contact), + transport="meshcore", + ) + success_count += 1 + except Exception as e: + errors.append(f"{contact}: {e}") + if success_count == len(self._contacts): + return True, f"Sent MeshCore DM to {success_count} contact(s)" + elif success_count > 0: + return True, f"Sent to {success_count}/{len(self._contacts)} contacts. Errors: {'; '.join(errors)}" + else: + return False, f"All MeshCore DMs failed: {'; '.join(errors)}" + + class EmailChannel(NotificationChannel): """Send alert via SMTP email.""" @@ -772,26 +989,51 @@ class WebhookChannel(NotificationChannel): def create_channel(rule: "NotificationRuleConfig", connector=None) -> NotificationChannel: """Create a channel instance from a NotificationRuleConfig. - + + Delivery types and their per-mesh routing: + mesh_broadcast -> Meshtastic ONLY (broadcast_channel; transport="meshtastic") + meshcore_broadcast-> MeshCore ONLY (meshcore_channel NAME; transport="meshcore") + mesh_dm -> Meshtastic DM (node_ids; transport="meshtastic") + meshcore_dm -> MeshCore DM (meshcore_dm_contacts; transport="meshcore") + email -> SMTP email + webhook -> HTTP POST + Args: rule: NotificationRuleConfig with delivery_type and channel settings connector: MeshConnector instance (required for mesh channels) - + Returns: NotificationChannel instance """ delivery_type = rule.delivery_type if delivery_type == "mesh_broadcast": + # Meshtastic-only broadcast: explicit transport hint so CompositeTransport + # routes only to the Meshtastic child and skips MeshCore. return MeshBroadcastChannel( connector=connector, channel_index=rule.broadcast_channel, + transport="meshtastic", + ) + elif delivery_type == "meshcore_broadcast": + # MeshCore-only broadcast: routes to MeshCore child by channel NAME. + return MeshCoreBroadcastChannel( + connector=connector, meshcore_channel=getattr(rule, "meshcore_channel", None), ) elif delivery_type == "mesh_dm": + # Meshtastic-only DM: explicit transport hint so CompositeTransport + # routes only to the Meshtastic child. return MeshDMChannel( connector=connector, node_ids=rule.node_ids, + transport_hint="meshtastic", + ) + elif delivery_type == "meshcore_dm": + # MeshCore-only DM: routes to MeshCore child via contact name/pubkey. + return MeshCoreDMChannel( + connector=connector, + contacts=list(getattr(rule, "meshcore_dm_contacts", []) or []), ) elif delivery_type == "email": return EmailChannel( @@ -814,21 +1056,23 @@ def create_channel(rule: "NotificationRuleConfig", connector=None) -> Notificati def create_channel_from_dict(config: dict, connector=None) -> NotificationChannel: """Create a channel instance from a dict config (legacy interface). - + Used by old router.py and test_channel API. Will be removed in Phase 2.7. """ channel_type = config.get("type", "") if channel_type == "mesh_broadcast": + # Legacy dict configs are Meshtastic-only; no auto-fan. return MeshBroadcastChannel( connector=connector, channel_index=config.get("channel_index", 0), - meshcore_channel=config.get("meshcore_channel"), + transport="meshtastic", ) elif channel_type == "mesh_dm": return MeshDMChannel( connector=connector, node_ids=config.get("node_ids", []), + transport_hint="meshtastic", ) elif channel_type == "email": return EmailChannel( diff --git a/work/meshai/notifications/pipeline/dispatcher.py b/work/meshai/notifications/pipeline/dispatcher.py index a93bfa1..3569087 100644 --- a/work/meshai/notifications/pipeline/dispatcher.py +++ b/work/meshai/notifications/pipeline/dispatcher.py @@ -448,7 +448,9 @@ class Dispatcher: try: rule = self._toggle_to_rule(tog, ch_type, event) channel = self._channel_factory(rule, self._connector) - if friendly is not None and ch_type in ("mesh_broadcast", "mesh_dm"): + if friendly is not None and ch_type in ( + "mesh_broadcast", "mesh_dm", "meshcore_broadcast", "meshcore_dm" + ): payload = make_payload_from_event(event, message=friendly) else: payload = make_payload_from_event(event) @@ -549,15 +551,33 @@ class Dispatcher: source_event_table, source_event_pk) return False - # Route through rf_propagation toggle\'s broadcast_channel. + # Route through rf_propagation toggle\'s configured channels. toggles = getattr(self._config.notifications, "toggles", None) or {} rf = toggles.get("rf_propagation") if isinstance(toggles, dict) else None - if rf is None or not getattr(rf, "broadcast_channel", None): + if rf is None: self._logger.info( - "scheduled-broadcast: rf_propagation channel not " - "configured; dropping") + "scheduled-broadcast: rf_propagation toggle not found; dropping") return False + # Resolve broadcast channel types from the toggle\'s severity_channels for + # "priority" (band-conditions are priority-class RF propagation info). + # Falls back to ["mesh_broadcast"] for old configs without severity_channels. + sev_channels = getattr(rf, "severity_channels", {}) or {} + ch_types = [ + c for c in sev_channels.get("priority", ["mesh_broadcast"]) + if c in ("mesh_broadcast", "meshcore_broadcast") + ] + if not ch_types: + # Backward compat: if severity_channels has no broadcast types, + # use mesh_broadcast when broadcast_channel is configured. + if getattr(rf, "broadcast_channel", None) is not None: + ch_types = ["mesh_broadcast"] + else: + self._logger.info( + "scheduled-broadcast: rf_propagation channel not " + "configured; dropping") + return False + # Build a synthetic Event purely to reuse _toggle_to_rule + the # NotificationPayload constructor. Severity \'priority\' keeps it # out of quiet-hours suppression unless explicitly overridden. @@ -570,35 +590,39 @@ class Dispatcher: severity="priority", title=text, ) ev.data["_meshai_precomposed"] = True - rule = self._toggle_to_rule(rf, "mesh_broadcast", ev) - try: - channel = self._channel_factory(rule, self._connector) - payload = make_payload_from_event(ev, message=text) - success = await channel.deliver(payload, rule) - except Exception: - self._logger.exception( - "scheduled-broadcast: delivery raised; treating as failed") - return False - if success: - # Audit row -- mirrors _post_broadcast_commit for scheduled. + delivered_any = False + for ch_type in ch_types: + rule = self._toggle_to_rule(rf, ch_type, ev) try: - from meshai.persistence import get_db - conn = get_db() - bytes_sent = len(text.encode("utf-8")) if text else 0 - conn.execute( - "INSERT INTO mesh_broadcasts_out(sent_at, recipient, " - "channel, text, source_event_table, source_event_pk, " - "bytes_sent, ack_received) VALUES (?,?,?,?,?,?,?,?)", - (int(time.time()), "broadcast", - rf.broadcast_channel, text, - source_event_table, str(source_event_pk), - bytes_sent, 0), - ) + channel = self._channel_factory(rule, self._connector) + payload = make_payload_from_event(ev, message=text) + success = await channel.deliver(payload, rule) except Exception: self._logger.exception( - "scheduled-broadcast: audit row insert failed") - return bool(success) + "scheduled-broadcast: delivery raised for %s; skipping", ch_type) + continue + + if success: + delivered_any = True + # Audit row -- mirrors _post_broadcast_commit for scheduled. + try: + from meshai.persistence import get_db + conn = get_db() + bytes_sent = len(text.encode("utf-8")) if text else 0 + conn.execute( + "INSERT INTO mesh_broadcasts_out(sent_at, recipient, " + "channel, text, source_event_table, source_event_pk, " + "bytes_sent, ack_received) VALUES (?,?,?,?,?,?,?,?)", + (int(time.time()), "broadcast", + rf.broadcast_channel, text, + source_event_table, str(source_event_pk), + bytes_sent, 0), + ) + except Exception: + self._logger.exception( + "scheduled-broadcast: audit row insert failed for %s", ch_type) + return delivered_any def _post_broadcast_commit(self, event, payload, rule, ch_type: str) -> None: """Persistence side-effects of an actually-successful broadcast. @@ -625,6 +649,9 @@ class Dispatcher: if ch_type == "mesh_dm": node_ids = list(getattr(rule, "node_ids", []) or []) recipient = ",".join(map(str, node_ids)) or "dm" + elif ch_type == "meshcore_dm": + contacts = list(getattr(rule, "meshcore_dm_contacts", []) or []) + recipient = ",".join(map(str, contacts)) or "meshcore_dm" else: recipient = "broadcast" channel = getattr(rule, "broadcast_channel", None) @@ -662,6 +689,7 @@ class Dispatcher: broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0), meshcore_channel=getattr(tog, "meshcore_channel", None), node_ids=list(getattr(tog, "node_ids", []) or []), + meshcore_dm_contacts=list(getattr(tog, "meshcore_dm_contacts", []) or []), smtp_host=getattr(tog, "smtp_host", ""), smtp_port=getattr(tog, "smtp_port", 587), smtp_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""), smtp_tls=getattr(tog, "smtp_tls", True), from_address=getattr(tog, "from_address", ""), diff --git a/work/meshai/notifications/pipeline/scheduler.py b/work/meshai/notifications/pipeline/scheduler.py index 66f2512..6835710 100644 --- a/work/meshai/notifications/pipeline/scheduler.py +++ b/work/meshai/notifications/pipeline/scheduler.py @@ -126,7 +126,7 @@ class DigestScheduler: channel = self._channel_factory(rule, self._connector) delivery_type = rule.delivery_type - if delivery_type in ("mesh_broadcast", "mesh_dm"): + if delivery_type in ("mesh_broadcast", "mesh_dm", "meshcore_broadcast", "meshcore_dm"): # One deliver call per chunk chunks = digest.mesh_chunks total = len(chunks) diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index 572b95e..e2779b2 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -224,7 +224,8 @@ class CompositeTransport(MeshTransport): """ if destination is None: # --- Rule 1: broadcast --- - return self._broadcast(text, channel, meshcore_channel=meshcore_channel) + return self._broadcast(text, channel, meshcore_channel=meshcore_channel, + transport=transport) if transport is not None: # --- Rule 2: hinted DM --- @@ -233,18 +234,52 @@ class CompositeTransport(MeshTransport): # --- Rule 3: unhinted DM --- return self._send_unhinted(text, destination, channel) - def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[str] = None) -> bool: + def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[str] = None, + transport: Optional[str] = None) -> bool: """Fan text out to connected children with per-transport channel routing. + If ``transport`` is given, send ONLY to the child whose name matches — + this is the explicit per-mesh delivery path (mesh_broadcast → "meshtastic", + meshcore_broadcast → "meshcore"). If ``transport`` is None, keep the + legacy fan-out: all connected children with per-transport channel routing. + For the Meshtastic child, ``channel`` (Meshtastic channel index) is used. For the MeshCore child: - - ``meshcore_channel`` set → route that channel NAME to MeshCore, - which resolves it to a companion slot at send time. + - ``meshcore_channel`` set → route that channel NAME to MeshCore. - ``meshcore_channel`` is None → skip the MeshCore child entirely (family not configured for MeshCore; no fallback to a default). Returns True if at least one child succeeded. """ + if transport is not None: + # Hinted broadcast: send ONLY to the named child. + child = self._by_name.get(transport) + if child is None: + logger.debug( + "CompositeTransport: broadcast hint %r not found; known: %s", + transport, list(self._by_name), + ) + return False + name = _child_name(child) + if not child.connected: + logger.debug( + "CompositeTransport: hinted broadcast child %r not connected", name + ) + return False + try: + if name == "meshcore": + return child.send_message( + text, destination=None, meshcore_channel=meshcore_channel + ) + else: + return child.send_message(text, destination=None, channel=channel) + except Exception as exc: + logger.error( + "CompositeTransport: hinted broadcast via %r raised: %s", name, exc + ) + return False + + # No hint: fan to all connected children (backward-compat, no-hint path). any_ok = False for child in self._children: name = _child_name(child) diff --git a/work/tests/test_channel_rendering.py b/work/tests/test_channel_rendering.py index 945127d..20aeac4 100644 --- a/work/tests/test_channel_rendering.py +++ b/work/tests/test_channel_rendering.py @@ -216,15 +216,15 @@ def test_webhook_channel_uses_webhook_renderer(): # ============================================================ # PER-FAMILY MESHCORE ROUTING — end-to-end threading guard -# (regression guard for the broadcast send-path gap) +# Updated for the explicit-per-mesh model (meshcore_broadcast/mesh_broadcast) # ============================================================ -def test_broadcast_threads_meshcore_channel_through_factory(): - """create_channel(rule) -> MeshBroadcastChannel.deliver must pass BOTH - channel= AND meshcore_channel= to send_message. +def test_mesh_broadcast_routes_to_meshtastic_only(): + """mesh_broadcast passes transport='meshtastic' and channel index to + send_message. meshcore_channel is NOT passed (auto-fan removed). - This is the regression guard for the gap where the rule's - meshcore_channel never reached connector.send_message. + Regression guard: before this model, mesh_broadcast also threaded + meshcore_channel through; now it is Meshtastic-only. """ from meshai.config import NotificationRuleConfig from meshai.notifications.channels import create_channel @@ -234,7 +234,7 @@ def test_broadcast_threads_meshcore_channel_through_factory(): name="toggle:fire", delivery_type="mesh_broadcast", broadcast_channel=1, - meshcore_channel="AIDA", + meshcore_channel="AIDA", # present in config but must NOT flow to send_message ) channel = create_channel(rule, mock_connector) @@ -254,54 +254,63 @@ def test_broadcast_threads_meshcore_channel_through_factory(): mock_connector.send_message.assert_called_once() kwargs = mock_connector.send_message.call_args.kwargs assert kwargs.get("channel") == 1 - # The load-bearing assertion: the name was NOT dropped. - assert kwargs.get("meshcore_channel") == "AIDA" + assert kwargs.get("transport") == "meshtastic" + # meshcore_channel must NOT be present (no auto-fan). + assert "meshcore_channel" not in kwargs or kwargs.get("meshcore_channel") is None -def test_broadcast_meshcore_channel_none_passed_through(): - """meshcore_channel=None (family not on MeshCore) => send_message still - receives meshcore_channel=None (MeshCore child skipped downstream).""" +def test_meshcore_broadcast_routes_to_meshcore_only(): + """meshcore_broadcast passes meshcore_channel=name and transport='meshcore' + to send_message. This is the explicit MeshCore-only delivery path.""" from meshai.config import NotificationRuleConfig - from meshai.notifications.channels import create_channel + from meshai.notifications.channels import MeshCoreBroadcastChannel, create_channel + # Simulate a CompositeTransport connector with a meshcore child. mock_connector = MagicMock() + mock_connector._by_name = {"meshcore": MagicMock(), "meshtastic": MagicMock()} + mock_connector.send_message.return_value = True + rule = NotificationRuleConfig( - name="toggle:weather", - delivery_type="mesh_broadcast", - broadcast_channel=0, - meshcore_channel=None, + name="toggle:fire", + delivery_type="meshcore_broadcast", + meshcore_channel="AIDA", ) channel = create_channel(rule, mock_connector) + assert isinstance(channel, MeshCoreBroadcastChannel) payload = NotificationPayload( - message="weather alert", - category="weather_warning", - severity="priority", + message="fire alert", + category="fire", + severity="immediate", timestamp=time.time(), - event_type="weather_warning", + event_type="fire", chunk_index=0, ) assert asyncio.run(channel.deliver(payload, rule)) is True + mock_connector.send_message.assert_called_once() kwargs = mock_connector.send_message.call_args.kwargs - assert kwargs.get("channel") == 0 - assert "meshcore_channel" in kwargs - assert kwargs.get("meshcore_channel") is None + assert kwargs.get("meshcore_channel") == "AIDA" + assert kwargs.get("transport") == "meshcore" + assert kwargs.get("destination") is None def test_broadcast_render_loop_threads_meshcore_channel(): - """Non-prechunked path (renderer loop) also threads meshcore_channel - on every chunk send.""" + """Non-prechunked path (renderer loop) for meshcore_broadcast threads + meshcore_channel on every chunk send.""" from meshai.config import NotificationRuleConfig from meshai.notifications.channels import create_channel mock_connector = MagicMock() + # Connector has a meshcore child so the no-op guard passes. + mock_connector._by_name = {"meshcore": MagicMock(), "meshtastic": MagicMock()} + mock_connector.send_message.return_value = True + rule = NotificationRuleConfig( name="toggle:fire", - delivery_type="mesh_broadcast", - broadcast_channel=2, + delivery_type="meshcore_broadcast", meshcore_channel="AIDA", ) channel = create_channel(rule, mock_connector) @@ -318,5 +327,5 @@ def test_broadcast_render_loop_threads_meshcore_channel(): assert asyncio.run(channel.deliver(payload, rule)) is True assert mock_connector.send_message.call_count >= 2 for call in mock_connector.send_message.call_args_list: - assert call.kwargs.get("channel") == 2 assert call.kwargs.get("meshcore_channel") == "AIDA" + assert call.kwargs.get("transport") == "meshcore" diff --git a/work/tests/test_notification_toggles.py b/work/tests/test_notification_toggles.py index 0a8b407..8554255 100644 --- a/work/tests/test_notification_toggles.py +++ b/work/tests/test_notification_toggles.py @@ -1,10 +1,16 @@ -"""v0.5 Section 1: NotificationToggle dispatch routing tests.""" +"""v0.5 Section 1: NotificationToggle dispatch routing tests. + +Also covers the per-mesh delivery type routing introduced in +feat/meshcore-first-class-delivery (meshcore_broadcast, meshcore_dm). +""" import asyncio +from unittest.mock import MagicMock -from meshai.config import Config +from meshai.config import Config, NotificationToggle from meshai.notifications.pipeline.dispatcher import Dispatcher from meshai.notifications.events import make_event +from meshai.notifications.channels import create_channel class RecChannel: @@ -134,3 +140,300 @@ def test_rules_and_toggles_both_fire(): rec = _dispatch(cfg, _ev(severity="priority")) names = {r["name"] for r in rec} assert "legacy" in names and "toggle:weather" in names # parallel paths both fire + + +# ============================================================ +# Per-mesh delivery type routing tests (feat/meshcore-first-class-delivery) +# ============================================================ + +def _wipe_db(): + """Wipe dispatcher persistence so each test is independent.""" + try: + from meshai.persistence import get_db + conn = get_db() + conn.execute("DELETE FROM dispatcher_dedup") + conn.execute("DELETE FROM dispatcher_cooldowns") + conn.execute( + "UPDATE dispatcher_state SET cold_start_anchor=NULL, " + "stale_dropped=0, cooldown_dropped=0, dedup_dropped=0, " + "cold_start_dropped=0 WHERE id=1" + ) + except Exception: + pass + + +def _dispatch_with_connector(cfg, event, connector): + """Dispatch event, using a real connector so send_message calls are captured.""" + _wipe_db() + delivered_rules = [] + + def _factory(rule, conn): + ch = create_channel(rule, connector) + # Wrap to record rule metadata too. + original_deliver = ch.deliver + + async def _record_deliver(payload, r): + result = await original_deliver(payload, r) + delivered_rules.append({ + "delivery_type": r.delivery_type, + "meshcore_channel": getattr(r, "meshcore_channel", None), + "meshcore_dm_contacts": list(getattr(r, "meshcore_dm_contacts", []) or []), + "node_ids": list(getattr(r, "node_ids", []) or []), + }) + return result + + ch.deliver = _record_deliver + return ch + + d = Dispatcher(cfg, _factory, connector=connector) + asyncio.run(d.dispatch(event)) + return delivered_rules + + +def test_meshcore_broadcast_routes_to_meshcore_child_only(): + """meshcore_broadcast in severity_channels → send_message called with + transport='meshcore' and the family's meshcore_channel name. + The Meshtastic child must NOT be called for this type.""" + meshtastic_child = MagicMock() + meshtastic_child.connected = True + meshtastic_child.transport_name = "meshtastic" + meshtastic_child.send_message.return_value = True + + meshcore_child = MagicMock() + meshcore_child.connected = True + meshcore_child.transport_name = "meshcore" + meshcore_child.send_message.return_value = True + + from meshai.transport.composite_transport import CompositeTransport + connector = CompositeTransport([meshtastic_child, meshcore_child]) + # Simulate that the connector has a meshcore child (for capability check in channel). + connector._by_name = {"meshtastic": meshtastic_child, "meshcore": meshcore_child} + + cfg = Config() + cfg.notifications.rules = [] + cfg.notifications.cold_start_grace_seconds = 0 + t = cfg.notifications.toggles["fire"] + t.enabled = True + t.min_severity = "immediate" + t.severity_channels = {"immediate": ["meshcore_broadcast"]} + t.broadcast_channel = 0 + t.meshcore_channel = "AIDA" + + event = make_event( + source="wfigs", category="fire_perimeter", + severity="immediate", title="fire alert", + ) + + rules = _dispatch_with_connector(cfg, event, connector) + assert len(rules) == 1 + assert rules[0]["delivery_type"] == "meshcore_broadcast" + assert rules[0]["meshcore_channel"] == "AIDA" + + # MeshCore child received the call with the channel NAME on the correct kwarg. + assert meshcore_child.send_message.called + mc_kwargs = meshcore_child.send_message.call_args.kwargs + assert mc_kwargs.get("destination") is None + # Regression guard for DEFECT 1: channel NAME must be routed via meshcore_channel=. + assert mc_kwargs.get("meshcore_channel") == "AIDA" + # The old broken code passed AIDA via channel=; that must NOT be the routing mechanism. + assert mc_kwargs.get("channel") != "AIDA" + + # Meshtastic child must NOT have been called. + meshtastic_child.send_message.assert_not_called() + + +def test_mesh_broadcast_routes_to_meshtastic_child_only(): + """mesh_broadcast → send_message with transport='meshtastic' and + the Meshtastic channel index. MeshCore child must NOT be called.""" + meshtastic_child = MagicMock() + meshtastic_child.connected = True + meshtastic_child.transport_name = "meshtastic" + meshtastic_child.send_message.return_value = True + + meshcore_child = MagicMock() + meshcore_child.connected = True + meshcore_child.transport_name = "meshcore" + meshcore_child.send_message.return_value = True + + from meshai.transport.composite_transport import CompositeTransport + connector = CompositeTransport([meshtastic_child, meshcore_child]) + connector._by_name = {"meshtastic": meshtastic_child, "meshcore": meshcore_child} + + cfg = Config() + cfg.notifications.rules = [] + cfg.notifications.cold_start_grace_seconds = 0 + t = cfg.notifications.toggles["weather"] + t.enabled = True + t.min_severity = "priority" + t.severity_channels = {"priority": ["mesh_broadcast"]} + t.broadcast_channel = 3 + + event = make_event( + source="nws", category="weather_warning", + severity="priority", title="weather alert", + ) + + rules = _dispatch_with_connector(cfg, event, connector) + assert len(rules) == 1 + assert rules[0]["delivery_type"] == "mesh_broadcast" + + # Meshtastic child received the call. + assert meshtastic_child.send_message.called + mt_kwargs = meshtastic_child.send_message.call_args.kwargs + assert mt_kwargs.get("destination") is None + assert mt_kwargs.get("channel") == 3 + + # MeshCore child must NOT have been called. + meshcore_child.send_message.assert_not_called() + + +def test_meshcore_dm_routes_to_meshcore_contacts(): + """meshcore_dm → connector.send_message called per meshcore_dm_contacts + entry with transport='meshcore'.""" + from meshai.notifications.channels import MeshCoreDMChannel + + mock_connector = MagicMock() + mock_connector._by_name = {"meshcore": MagicMock(), "meshtastic": MagicMock()} + mock_connector.send_message.return_value = True + + from meshai.config import NotificationRuleConfig + import time as _time + from meshai.notifications.events import NotificationPayload + + rule = NotificationRuleConfig( + name="toggle:mesh_health", + delivery_type="meshcore_dm", + meshcore_dm_contacts=["alice", "bob"], + ) + + channel = create_channel(rule, mock_connector) + assert isinstance(channel, MeshCoreDMChannel) + + payload = NotificationPayload( + message="dm alert", + category="mesh_health", + severity="immediate", + timestamp=_time.time(), + chunk_index=0, + ) + + result = asyncio.run(channel.deliver(payload, rule)) + assert result is True + + # One send_message call per contact. + assert mock_connector.send_message.call_count == 2 + destinations = [ + call.kwargs.get("destination") + for call in mock_connector.send_message.call_args_list + ] + assert set(destinations) == {"alice", "bob"} + for call in mock_connector.send_message.call_args_list: + assert call.kwargs.get("transport") == "meshcore" + + +def test_meshcore_broadcast_noop_when_no_meshcore_transport(): + """meshcore_broadcast with transport=meshtastic (no MeshCore child) → + deliver returns False, no exception raised.""" + from meshai.notifications.channels import MeshCoreBroadcastChannel + from meshai.config import NotificationRuleConfig + import time as _time + from meshai.notifications.events import NotificationPayload + + # Connector has NO meshcore child (transport=meshtastic scenario). + mock_connector = MagicMock() + # _by_name exists but has only meshtastic. + mock_connector._by_name = {"meshtastic": MagicMock()} + + rule = NotificationRuleConfig( + name="toggle:fire", + delivery_type="meshcore_broadcast", + meshcore_channel="AIDA", + ) + + channel = create_channel(rule, mock_connector) + assert isinstance(channel, MeshCoreBroadcastChannel) + + payload = NotificationPayload( + message="fire alert", + category="fire", + severity="immediate", + timestamp=_time.time(), + chunk_index=0, + ) + + # Must not raise; returns False (no-op). + result = asyncio.run(channel.deliver(payload, rule)) + assert result is False + # send_message must NOT have been called (no accidental Meshtastic send). + mock_connector.send_message.assert_not_called() + + +def test_config_round_trip_meshcore_fields(): + """NotificationToggle with meshcore types in severity_channels and + meshcore_dm_contacts survives _dataclass_to_dict / _dict_to_dataclass.""" + from meshai.config import _dataclass_to_dict, _dict_to_dataclass, NotificationToggle + + tog = NotificationToggle( + name="fire", + enabled=True, + min_severity="immediate", + severity_channels={ + "priority": ["meshcore_broadcast"], + "immediate": ["mesh_broadcast", "meshcore_broadcast", "meshcore_dm"], + }, + broadcast_channel=1, + meshcore_channel="AIDA", + meshcore_dm_contacts=["alice", "bob"], + node_ids=["!deadbeef"], + ) + + d = _dataclass_to_dict(tog) + assert d["meshcore_dm_contacts"] == ["alice", "bob"] + assert d["meshcore_channel"] == "AIDA" + assert "meshcore_broadcast" in d["severity_channels"]["priority"] + assert "meshcore_dm" in d["severity_channels"]["immediate"] + + restored = _dict_to_dataclass(NotificationToggle, d) + assert restored.meshcore_dm_contacts == ["alice", "bob"] + assert restored.meshcore_channel == "AIDA" + assert "meshcore_broadcast" in restored.severity_channels["priority"] + assert "meshcore_dm" in restored.severity_channels["immediate"] + assert restored.node_ids == ["!deadbeef"] + + +def test_meshtastic_only_config_unchanged(): + """Existing configs with only mesh_broadcast/mesh_dm and + transport=meshtastic behave identically to pre-MeshCore behavior.""" + mock_connector = MagicMock() + # Simulate a plain MeshtasticTransport (no _by_name, transport_name=meshtastic). + mock_connector.transport_name = "meshtastic" + mock_connector.send_message.return_value = True + # No _by_name attribute (not a CompositeTransport). + del mock_connector._by_name + + cfg = Config() + cfg.notifications.rules = [] + cfg.notifications.cold_start_grace_seconds = 0 + t = cfg.notifications.toggles["weather"] + t.enabled = True + t.min_severity = "priority" + t.severity_channels = { + "priority": ["mesh_broadcast"], + "immediate": ["mesh_broadcast", "mesh_dm"], + } + t.broadcast_channel = 0 + t.node_ids = ["!deadbeef"] + + event = make_event( + source="nws", category="weather_warning", + severity="priority", title="weather alert", + ) + rules = _dispatch_with_connector(cfg, event, mock_connector) + assert len(rules) == 1 + assert rules[0]["delivery_type"] == "mesh_broadcast" + + # send_message called with Meshtastic channel and transport hint. + assert mock_connector.send_message.called + kwargs = mock_connector.send_message.call_args.kwargs + assert kwargs.get("transport") == "meshtastic" + assert kwargs.get("channel") == 0