feat(routing): MeshCore as first-class delivery types (meshcore_broadcast/dm) (#11)

* feat(routing): MeshCore as first-class delivery types (meshcore_broadcast/dm)

Replace the composite auto-fan with explicit per-mesh delivery types so
each family independently controls broadcast/DM per severity on Meshtastic
AND MeshCore. mesh_broadcast->Meshtastic only, meshcore_broadcast->MeshCore
(by channel name), mesh_dm/meshcore_dm likewise; routing via the existing
transport hint. Adds meshcore_dm_contacts. Meshtastic-only configs
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routing): deliver meshcore_broadcast via meshcore_channel through CompositeTransport

The hinted _broadcast path passed the channel NAME on the `channel` kwarg,
which MeshCoreTransport ignores (it reads meshcore_channel), so
meshcore_broadcast silently no-op'd on transport=both configs. Route the
meshcore child via meshcore_channel and the meshtastic child via channel.
Fix the test that asserted the broken kwarg layout. Add the new delivery
types to the remaining enumeration/validation sites (channel-test endpoint,
scheduler digest chunking, danger-zone valid set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): split Notifications into Meshtastic and MeshCore sections

Delineate per-mesh routing: each family configures Meshtastic delivery
(mesh_broadcast/mesh_dm, channel index, node IDs) and MeshCore delivery
(meshcore_broadcast/meshcore_dm, channel name, contacts) in separate
sections; shared settings (enable/severity/regions/email/webhook) once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): first-class MeshCore nav section + dedicated pages

Group the sidebar into Meshtastic and MeshCore sections. Promote MeshCore
routing and connection to their own pages; move MeshCore routing out of
Notifications (which stays Meshtastic + shared family settings). Add
placeholder Contacts and Companion pages for the follow-on companion data
API. No backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): parallel MT/MC nav order + symmetric connection links

Order both nav groups Connection/Routing/Mesh(Contacts)/Sources(Companion).
Replace the prominent MeshCore block on the Meshtastic Connection page with
a single subtle cross-link, mirrored on the MeshCore Connection page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboard): focused Meshtastic Connection/Sources pages for MT/MC parity

Meshtastic Connection and Sources are now their own focused pages
(mirroring MeshCore), instead of deep-linking into the full Config page.
Global settings move to a restored top-level Config item. No duplicate
editors; connection cross-links are mirror-image between the two pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-02 22:42:14 -06:00 committed by GitHub
commit de1e58aa71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1870 additions and 219 deletions

View file

@ -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() {
<Route path="/adapter-config" element={<AdapterConfig />} />
<Route path="/gauge-sites" element={<GaugeSites />} />
<Route path="/town-anchors" element={<TownAnchors />} />
<Route path="/meshcore/routing" element={<MeshCoreRouting />} />
<Route path="/meshcore/connection" element={<MeshCoreConnection />} />
<Route path="/meshcore/contacts" element={<MeshCoreContacts />} />
<Route path="/meshcore/companion" element={<MeshCoreCompanion />} />
<Route path="/meshtastic/connection" element={<MeshtasticConnection />} />
<Route path="/meshtastic/sources" element={<MeshtasticSources />} />
</Routes>
</Layout>
</ToastProvider>

View file

@ -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 <Link>. 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 (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive
? 'text-white bg-transparent'
: 'text-[#777] hover:text-white hover:bg-bg-hover'
}`}
>
{isActive && (
<div className="absolute right-0 top-0 bottom-0 w-[2px] bg-[#f59e0b]" />
)}
<Icon size={16} />
{item.label}
</Link>
)
}
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 */}
<nav className="flex-1 py-4">
{navItems.map((item) => {
const isActive = location.pathname === item.path
const Icon = item.icon
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive
? 'text-white bg-transparent'
: 'text-[#777] hover:text-white hover:bg-bg-hover'
}`}
>
{isActive && (
<div className="absolute right-0 top-0 bottom-0 w-[2px] bg-[#f59e0b]" />
)}
<Icon size={16} />
{item.label}
</Link>
)
})}
{topNavItems.map((item) => renderNavItem(item, location.pathname, location.search))}
{navGroups.map((group) => (
<div key={group.header} className="mt-4">
<div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">
{group.header}
</div>
{group.items.map((item) => renderNavItem(item, location.pathname, location.search))}
</div>
))}
</nav>
{/* Connection status */}
@ -155,7 +219,7 @@ export default function Layout({ children }: LayoutProps) {
{/* Header */}
<header className="h-14 flex-shrink-0 border-b border-border bg-bg-card flex items-center justify-between px-6">
<h1 className="text-lg font-sans font-semibold text-white">
{getPageTitle(location.pathname)}
{getPageTitle(location.pathname + location.search)}
</h1>
<div className="flex items-center gap-6">
{/* Live indicator */}

View file

@ -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 (
<div className="space-y-4">
<SectionDescription text={SECTION_DESCRIPTIONS.connection} />
<SelectInput
label="Transport Mode"
value={transport}
onChange={(v) => 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."
/>
<SelectInput
label="Connection Type"
value={data.type}
@ -766,30 +750,17 @@ function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChang
/>
</div>
)}
{showMeshCore && (
<div className="space-y-4 pt-2 border-t border-[#1e2a3a]">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="MeshCore Host"
value={data.meshcore_host ?? ''}
onChange={(v) => 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."
/>
<NumberInput
label="MeshCore Port"
value={data.meshcore_port ?? 5525}
onChange={(v) => 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. */}
<div className="pt-2">
<Link
to="/meshcore/connection"
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-accent transition-colors"
>
&rarr; MeshCore transport &amp; connection
</Link>
</div>
</div>
)}
</div>
)
}
@ -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 (
<div className="space-y-4">
<SectionDescription text={SECTION_DESCRIPTIONS.meshmonitor} />
@ -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<FullConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<FullConfig | null>(null)
const [activeSection, setActiveSection] = useState<SectionKey>('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<string | null>(null)
@ -1945,7 +1926,6 @@ export default function Config() {
const renderSection = () => {
switch (activeSection) {
case 'bot': return <BotSection data={config.bot} onChange={(d) => updateSection('bot', d)} />
case 'connection': return <ConnectionSection data={config.connection} onChange={(d) => updateSection('connection', d)} />
case 'response': return <ResponseSection data={config.response} onChange={(d) => updateSection('response', d)} />
case 'history': return <HistorySection data={config.history} onChange={(d) => updateSection('history', d)} />
case 'memory': return <MemorySection data={config.memory} onChange={(d) => updateSection('memory', d)} />
@ -1953,9 +1933,7 @@ export default function Config() {
case 'commands': return <CommandsSection data={config.commands} onChange={(d) => updateSection('commands', d)} />
case 'llm': return <LLMSection data={config.llm} onChange={(d) => updateSection('llm', d)} />
case 'weather': return <WeatherSection data={config.weather} onChange={(d) => updateSection('weather', d)} />
case 'meshmonitor': return <MeshMonitorSection data={config.meshmonitor} onChange={(d) => updateSection('meshmonitor', d)} />
case 'knowledge': return <KnowledgeSection data={config.knowledge} onChange={(d) => updateSection('knowledge', d)} />
case 'mesh_sources': return <MeshSourcesSection data={config.mesh_sources} onChange={(d) => updateSection('mesh_sources', d)} />
case 'mesh_intelligence': return <MeshIntelligenceSection data={config.mesh_intelligence} onChange={(d) => updateSection('mesh_intelligence', d)} />
case 'dashboard': return <DashboardSection data={config.dashboard} onChange={(d) => updateSection('dashboard', d)} />
default: return null

View file

@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { Bot } from 'lucide-react'
export default function MeshCoreCompanion() {
useEffect(() => {
document.title = 'Companion & Channels - MeshAI'
}, [])
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Bot size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">Companion &amp; Channels</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show live status for the AIDA MeshCore companion &mdash; 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.
</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -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<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(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<ConnectionConfig>) =>
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 (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading MeshCore connection...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load connection config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Transport mode and MeshCore node connection.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* Form */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<SelectInput
label="Transport Mode"
value={config.transport ?? 'meshtastic'}
onChange={(v) => 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."
/>
<div className="pt-2 border-t border-[#1e2a3a] space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="MeshCore Host"
value={config.meshcore_host ?? ''}
onChange={(v) => 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."
/>
<NumberInput
label="MeshCore Port"
value={config.meshcore_port ?? 5525}
onChange={(v) => upd({ meshcore_port: v })}
min={1}
max={65535}
helper="MeshCore TCP port (default 5525)"
/>
</div>
</div>
<div className="pt-2">
<Link
to="/meshtastic/connection"
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-accent transition-colors"
>
&rarr; Meshtastic connection
</Link>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { Users } from 'lucide-react'
export default function MeshCoreContacts() {
useEffect(() => {
document.title = 'MeshCore Contacts - MeshAI'
}, [])
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Users size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show the MeshCore companion's contact roster &mdash; 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.
</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -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<string, string[]> = {}
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<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(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<NotificationToggle>) => {
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 (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading MeshCore routing...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load notifications config</div>
</div>
)
}
const toggles = config.toggles || {}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Per-family MeshCore delivery. Choose which channels fire at each severity, the
MeshCore channel name, and DM contacts.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Cross-link note: shared family settings live on the Meshtastic Routing page */}
<div className="flex items-start gap-2 p-3 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-400">
<ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
<div>
Shared per-family settings (enable, severity threshold, regions, freshness/cooldown, and
Meshtastic / email / webhook delivery) live on the{' '}
<Link to="/notifications" className="text-accent hover:underline">
Meshtastic Routing
</Link>{' '}
page. This page edits only the MeshCore delivery for each family.
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* Per-family MeshCore delivery */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
MeshCore Delivery
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are set on the Meshtastic Routing page." />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
const t = toggles[key] || ({} as NotificationToggle)
return (
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
<div className="flex items-center gap-2 text-sm text-slate-200">
<Icon size={15} /> {label}
</div>
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<MessageSquare size={13} />
MeshCore
</div>
<SeverityChannelMatrix
channels={MC_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">
MeshCore channel name
</label>
<input
type="text"
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
onChange={(e) =>
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"
/>
<p className="text-xs text-slate-600">
Channel name on your MeshCore companion (e.g. AIDA). Blank = not broadcast on
MeshCore.
</p>
</div>
<ListInput
label="MeshCore DM contacts"
value={t.meshcore_dm_contacts || []}
onChange={(v) => 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."
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}

View file

@ -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<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(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 (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading Meshtastic connection...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load connection config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Connection to your Meshtastic radio (serial or TCP).
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* Form */}
<div className="bg-bg-card border border-border p-6">
<ConnectionSection data={config} onChange={setConfig} />
</div>
</div>
)
}

View file

@ -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<MeshMonitorConfig | null>(null)
const [originalMeshmonitor, setOriginalMeshmonitor] = useState<MeshMonitorConfig | null>(null)
const [meshSources, setMeshSources] = useState<MeshSourceConfig[] | null>(null)
const [originalMeshSources, setOriginalMeshSources] = useState<MeshSourceConfig[] | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [mm, ms] = await Promise.all([
apiFetchConfig('meshmonitor') as Promise<MeshMonitorConfig>,
apiFetchConfig('mesh_sources') as Promise<MeshSourceConfig[]>,
])
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 (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading Meshtastic sources...</div>
</div>
)
}
if (!meshmonitor || !meshSources) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load sources config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
MeshMonitor integration and mesh awareness data sources.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchData}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* MeshMonitor card */}
<div className="bg-bg-card border border-border p-6">
<MeshMonitorSection data={meshmonitor} onChange={setMeshmonitor} />
</div>
{/* Mesh Sources card */}
<div className="bg-bg-card border border-border p-6">
<MeshSourcesSection data={meshSources} onChange={setMeshSources} />
</div>
</div>
)
}

View file

@ -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<string, string[]>
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<string, string>
}
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({
</div>
)
}
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<string, string[]>
onChange: (updated: Record<string, string[]>) => void
}) {
const colLabel = (c: string) =>
c.replace('meshcore_', 'mc_').replace('mesh_', '').replace(/_/g, ' ')
return (
<table className="text-xs w-full">
<thead>
<tr>
<th className="text-left text-slate-600 font-normal w-20">severity</th>
{channels.map((c) => (
<th key={c} className="text-slate-500 font-normal px-1 whitespace-nowrap">{colLabel(c)}</th>
))}
</tr>
</thead>
<tbody>
{TOGGLE_SEVERITIES.map((sev) => (
<tr key={sev}>
<td className="text-slate-400 pr-2 whitespace-nowrap">{sev}</td>
{channels.map((ch) => {
const on = (severityChannels[sev] || []).includes(ch)
return (
<td key={ch} className="text-center">
<input
type="checkbox"
checked={on}
onChange={(e) => {
// Shallow-copy the dict so we don't mutate state, then
// only modify the specific channel being toggled.
const cur: Record<string, string[]> = { ...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)
}}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
)
}
function MasterToggles({ toggles, onChange }: {
toggles: Record<string, NotificationToggle>
onChange: (t: Record<string, NotificationToggle>) => void
@ -1581,53 +1645,111 @@ function MasterToggles({ toggles, onChange }: {
</div>
)}
{isOpen && (
<div className={`mt-3 space-y-3 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
<div className={`mt-3 space-y-4 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
{/* ── General ──────────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="text-xs text-slate-500 uppercase tracking-wide">General</div>
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
<div className="text-xs text-slate-500">Severity &rarr; channels</div>
<table className="text-xs w-full">
<thead>
<tr><th></th>{TOGGLE_CHANNELS.map((c) => <th key={c} className="text-slate-500 font-normal px-1">{c.replace('_', ' ')}</th>)}</tr>
</thead>
<tbody>
{TOGGLE_SEVERITIES.map((sev) => (
<tr key={sev}>
<td className="text-slate-400 pr-2">{sev}</td>
{TOGGLE_CHANNELS.map((ch) => {
const on = (t.severity_channels?.[sev] || []).includes(ch)
return (
<td key={ch} className="text-center">
<input type="checkbox" checked={on} onChange={(e) => {
const cur: Record<string, string[]> = { ...(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 })
}} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." /> <div className="text-xs text-slate-500 pt-1">Channel config</div>
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">MeshCore channel</label>
<input
type="text"
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
onChange={(e) => 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"
<ListInput
label="Regions (empty = all)"
value={t.regions || []}
onChange={(v) => upd(key, { regions: v })}
placeholder="Add region..."
/>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Freshness (sec)"
value={t.freshness_seconds ?? 600}
onChange={(v) => 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."
/>
<NumberInput
label="Cooldown (sec)"
value={t.cooldown_seconds ?? 0}
onChange={(v) => upd(key, { cooldown_seconds: v })}
min={0}
helper="0 = no throttle"
info="Per (family, category, region) throttle window. Prevents repeat sends within this window."
/>
<p className="text-xs text-slate-600">MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.</p>
</div>
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
</div>
{/* ── Meshtastic ───────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<Radio size={13} />
Meshtastic
</div>
<SeverityChannelMatrix
channels={MT_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<NumberInput
label="Broadcast channel"
value={t.broadcast_channel ?? 0}
onChange={(v) => 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."
/>
<ListInput
label="DM node IDs"
value={t.node_ids || []}
onChange={(v) => 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."
/>
</div>
{/* MeshCore delivery controls moved to the dedicated
MeshCore -> Routing page (/meshcore/routing). Shared
family settings (enable/severity/regions) stay here. */}
{/* ── Other channels ───────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<Mail size={13} />
Other channels
</div>
<SeverityChannelMatrix
channels={OTHER_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<ListInput
label="Email recipients"
value={t.recipients || []}
onChange={(v) => upd(key, { recipients: v })}
placeholder="ops@example.com"
/>
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-xs text-slate-400 hover:text-slate-200">
<ChevronRight size={12} className="group-open:rotate-90 transition-transform" />
SMTP settings
</summary>
<div className="mt-2 space-y-2 pl-4 border-l border-[#1e2a3a]">
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
<TextInput label="Webhook URL" value={t.webhook_url || ''} onChange={(v) => upd(key, { webhook_url: v })} placeholder="https://..." />
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
</div>
</details>
<TextInput
label="Webhook URL"
value={t.webhook_url || ''}
onChange={(v) => upd(key, { webhook_url: v })}
placeholder="https://..."
helper="POST alert as JSON"
/>
</div>
</div>
)}
</div>

View file

@ -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,
},
},

View file

@ -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

View file

@ -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 "",

View file

@ -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."""
@ -773,6 +990,14 @@ 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)
@ -783,15 +1008,32 @@ def create_channel(rule: "NotificationRuleConfig", connector=None) -> Notificati
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(
@ -820,15 +1062,17 @@ def create_channel_from_dict(config: dict, connector=None) -> NotificationChanne
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(

View file

@ -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,10 +551,28 @@ 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 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")
@ -570,17 +590,21 @@ class Dispatcher:
severity="priority", title=text,
)
ev.data["_meshai_precomposed"] = True
rule = self._toggle_to_rule(rf, "mesh_broadcast", ev)
delivered_any = False
for ch_type in ch_types:
rule = self._toggle_to_rule(rf, ch_type, 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
"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
@ -597,8 +621,8 @@ class Dispatcher:
)
except Exception:
self._logger.exception(
"scheduled-broadcast: audit row insert failed")
return bool(success)
"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", ""),

View file

@ -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)

View file

@ -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)

View file

@ -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=<broadcast_channel> AND meshcore_channel=<name> 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"

View file

@ -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