mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
24cb6a31df
commit
de1e58aa71
19 changed files with 1870 additions and 219 deletions
|
|
@ -10,6 +10,12 @@ import Reference from './pages/Reference'
|
||||||
import AdapterConfig from './pages/AdapterConfig'
|
import AdapterConfig from './pages/AdapterConfig'
|
||||||
import GaugeSites from './pages/GaugeSites'
|
import GaugeSites from './pages/GaugeSites'
|
||||||
import TownAnchors from './pages/TownAnchors'
|
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'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -27,6 +33,12 @@ function App() {
|
||||||
<Route path="/adapter-config" element={<AdapterConfig />} />
|
<Route path="/adapter-config" element={<AdapterConfig />} />
|
||||||
<Route path="/gauge-sites" element={<GaugeSites />} />
|
<Route path="/gauge-sites" element={<GaugeSites />} />
|
||||||
<Route path="/town-anchors" element={<TownAnchors />} />
|
<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>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,19 @@ import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Radio,
|
Radio,
|
||||||
Cloud,
|
Cloud,
|
||||||
Settings,
|
|
||||||
Bell,
|
Bell,
|
||||||
BellRing,
|
BellRing,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Sliders,
|
Sliders,
|
||||||
Droplets,
|
Droplets,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Wifi,
|
||||||
|
Layers,
|
||||||
|
Network,
|
||||||
|
Users,
|
||||||
|
Bot,
|
||||||
|
Settings,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
|
|
@ -21,19 +27,58 @@ interface LayoutProps {
|
||||||
children: ReactNode
|
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: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ path: '/mesh', label: 'Mesh', icon: Radio },
|
|
||||||
{ path: '/environment', label: 'Environment', icon: Cloud },
|
{ path: '/environment', label: 'Environment', icon: Cloud },
|
||||||
{ path: '/config', label: 'Config', icon: Settings },
|
|
||||||
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
||||||
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
|
||||||
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
||||||
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
|
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
|
||||||
|
{ path: '/config', label: 'Config', icon: Settings },
|
||||||
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
|
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
|
||||||
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
|
{ 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 {
|
function formatUptime(seconds: number): string {
|
||||||
const days = Math.floor(seconds / 86400)
|
const days = Math.floor(seconds / 86400)
|
||||||
const hours = Math.floor((seconds % 86400) / 3600)
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
|
@ -44,9 +89,40 @@ function formatUptime(seconds: number): string {
|
||||||
return `${mins}m`
|
return `${mins}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPageTitle(pathname: string): string {
|
// Renders a single nav <Link>. Items whose path carries a ?section= query are
|
||||||
const item = navItems.find((i) => i.path === pathname)
|
// matched against pathname+search so only the matching deep-link highlights.
|
||||||
return item?.label || 'Dashboard'
|
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) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
|
@ -106,27 +182,15 @@ export default function Layout({ children }: LayoutProps) {
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 py-4">
|
<nav className="flex-1 py-4">
|
||||||
{navItems.map((item) => {
|
{topNavItems.map((item) => renderNavItem(item, location.pathname, location.search))}
|
||||||
const isActive = location.pathname === item.path
|
{navGroups.map((group) => (
|
||||||
const Icon = item.icon
|
<div key={group.header} className="mt-4">
|
||||||
return (
|
<div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">
|
||||||
<Link
|
{group.header}
|
||||||
key={item.path}
|
</div>
|
||||||
to={item.path}
|
{group.items.map((item) => renderNavItem(item, location.pathname, location.search))}
|
||||||
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
|
</div>
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Connection status */}
|
{/* Connection status */}
|
||||||
|
|
@ -155,7 +219,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="h-14 flex-shrink-0 border-b border-border bg-bg-card flex items-center justify-between px-6">
|
<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">
|
<h1 className="text-lg font-sans font-semibold text-white">
|
||||||
{getPageTitle(location.pathname)}
|
{getPageTitle(location.pathname + location.search)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{/* Live indicator */}
|
{/* Live indicator */}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import {
|
import {
|
||||||
Settings, Bot, Wifi, MessageSquare, Database, Brain, Eye,
|
Settings, Bot, MessageSquare, Database, Brain, Eye,
|
||||||
Terminal, Cpu, Cloud, Radio, BookOpen, Layers, Activity,
|
Terminal, Cpu, Cloud, BookOpen, Activity,
|
||||||
LayoutDashboard, Save, RotateCcw, RefreshCw,
|
LayoutDashboard, Save, RotateCcw, RefreshCw,
|
||||||
Plus, Trash2, ChevronDown, ChevronRight, AlertTriangle,
|
Plus, Trash2, ChevronDown, ChevronRight, AlertTriangle,
|
||||||
Check, X, Eye as EyeIcon, EyeOff, ExternalLink
|
Check, X, Eye as EyeIcon, EyeOff, ExternalLink
|
||||||
|
|
@ -18,7 +19,7 @@ interface BotConfig {
|
||||||
filter_bbs_protocols: boolean
|
filter_bbs_protocols: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
type: string
|
type: string
|
||||||
serial_port: string
|
serial_port: string
|
||||||
tcp_host: string
|
tcp_host: string
|
||||||
|
|
@ -86,7 +87,7 @@ interface WeatherConfig {
|
||||||
wttr: { url: string }
|
wttr: { url: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeshMonitorConfig {
|
export interface MeshMonitorConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
url: string
|
url: string
|
||||||
inject_into_prompt: boolean
|
inject_into_prompt: boolean
|
||||||
|
|
@ -109,7 +110,7 @@ interface KnowledgeConfig {
|
||||||
top_k: number
|
top_k: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeshSourceConfig {
|
export interface MeshSourceConfig {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -225,7 +226,6 @@ type SectionKey = keyof FullConfig
|
||||||
|
|
||||||
const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [
|
const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [
|
||||||
{ key: 'bot', label: 'Bot', icon: Bot },
|
{ key: 'bot', label: 'Bot', icon: Bot },
|
||||||
{ key: 'connection', label: 'Connection', icon: Wifi },
|
|
||||||
{ key: 'response', label: 'Response', icon: MessageSquare },
|
{ key: 'response', label: 'Response', icon: MessageSquare },
|
||||||
{ key: 'history', label: 'History', icon: Database },
|
{ key: 'history', label: 'History', icon: Database },
|
||||||
{ key: 'memory', label: 'Memory', icon: Brain },
|
{ 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: 'commands', label: 'Commands', icon: Terminal },
|
||||||
{ key: 'llm', label: 'LLM', icon: Cpu },
|
{ key: 'llm', label: 'LLM', icon: Cpu },
|
||||||
{ key: 'weather', label: 'Weather', icon: Cloud },
|
{ key: 'weather', label: 'Weather', icon: Cloud },
|
||||||
{ key: 'meshmonitor', label: 'MeshMonitor', icon: Radio },
|
|
||||||
{ key: 'knowledge', label: 'Knowledge', icon: BookOpen },
|
{ key: 'knowledge', label: 'Knowledge', icon: BookOpen },
|
||||||
{ key: 'mesh_sources', label: 'Mesh Sources', icon: Layers },
|
|
||||||
{ key: 'mesh_intelligence', label: 'Intelligence', icon: Activity },
|
{ key: 'mesh_intelligence', label: 'Intelligence', icon: Activity },
|
||||||
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ 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 }) {
|
export function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) {
|
||||||
const transport = data.transport ?? 'meshtastic'
|
|
||||||
const showMeshCore = transport === 'meshcore' || transport === 'both'
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SectionDescription text={SECTION_DESCRIPTIONS.connection} />
|
<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
|
<SelectInput
|
||||||
label="Connection Type"
|
label="Connection Type"
|
||||||
value={data.type}
|
value={data.type}
|
||||||
|
|
@ -766,30 +750,17 @@ function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChang
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showMeshCore && (
|
{/* MeshCore transport + host/port live on their own first-class page
|
||||||
<div className="space-y-4 pt-2 border-t border-[#1e2a3a]">
|
(/meshcore/connection). Subtle cross-link only — no editable fields here. */}
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
|
<div className="pt-2">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Link
|
||||||
<TextInput
|
to="/meshcore/connection"
|
||||||
label="MeshCore Host"
|
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-accent transition-colors"
|
||||||
value={data.meshcore_host ?? ''}
|
>
|
||||||
onChange={(v) => onChange({ ...data, meshcore_host: v })}
|
→ MeshCore transport & connection
|
||||||
placeholder="192.168.1.100"
|
</Link>
|
||||||
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)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SectionDescription text={SECTION_DESCRIPTIONS.meshmonitor} />
|
<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 = () => {
|
const addSource = () => {
|
||||||
onChange([...data, {
|
onChange([...data, {
|
||||||
name: 'New Source',
|
name: 'New Source',
|
||||||
|
|
@ -1831,6 +1802,16 @@ export default function Config() {
|
||||||
const [config, setConfig] = useState<FullConfig | null>(null)
|
const [config, setConfig] = useState<FullConfig | null>(null)
|
||||||
const [originalConfig, setOriginalConfig] = useState<FullConfig | null>(null)
|
const [originalConfig, setOriginalConfig] = useState<FullConfig | null>(null)
|
||||||
const [activeSection, setActiveSection] = useState<SectionKey>('bot')
|
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 [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -1945,7 +1926,6 @@ export default function Config() {
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case 'bot': return <BotSection data={config.bot} onChange={(d) => updateSection('bot', d)} />
|
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 'response': return <ResponseSection data={config.response} onChange={(d) => updateSection('response', d)} />
|
||||||
case 'history': return <HistorySection data={config.history} onChange={(d) => updateSection('history', 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)} />
|
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 'commands': return <CommandsSection data={config.commands} onChange={(d) => updateSection('commands', d)} />
|
||||||
case 'llm': return <LLMSection data={config.llm} onChange={(d) => updateSection('llm', 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 '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 '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 '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)} />
|
case 'dashboard': return <DashboardSection data={config.dashboard} onChange={(d) => updateSection('dashboard', d)} />
|
||||||
default: return null
|
default: return null
|
||||||
|
|
|
||||||
34
work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx
Normal file
34
work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx
Normal 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 & 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 — 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
Normal file
198
work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
→ Meshtastic connection
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
work/dashboard-frontend/src/pages/MeshCoreContacts.tsx
Normal file
34
work/dashboard-frontend/src/pages/MeshCoreContacts.tsx
Normal 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 — 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
work/dashboard-frontend/src/pages/MeshCoreRouting.tsx
Normal file
275
work/dashboard-frontend/src/pages/MeshCoreRouting.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
work/dashboard-frontend/src/pages/MeshtasticConnection.tsx
Normal file
140
work/dashboard-frontend/src/pages/MeshtasticConnection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
work/dashboard-frontend/src/pages/MeshtasticSources.tsx
Normal file
162
work/dashboard-frontend/src/pages/MeshtasticSources.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -39,15 +39,18 @@ interface NotificationRuleConfig {
|
||||||
region_scope: string[]
|
region_scope: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationToggle {
|
export interface NotificationToggle {
|
||||||
name: string
|
name: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
min_severity: string
|
min_severity: string
|
||||||
regions: string[]
|
regions: string[]
|
||||||
severity_channels: Record<string, string[]>
|
severity_channels: Record<string, string[]>
|
||||||
|
freshness_seconds?: number
|
||||||
|
cooldown_seconds?: number
|
||||||
broadcast_channel: number | null
|
broadcast_channel: number | null
|
||||||
meshcore_channel?: string | null
|
meshcore_channel?: string | null
|
||||||
node_ids: string[]
|
node_ids: string[]
|
||||||
|
meshcore_dm_contacts: string[]
|
||||||
smtp_host: string
|
smtp_host: string
|
||||||
smtp_port: number
|
smtp_port: number
|
||||||
smtp_user: string
|
smtp_user: string
|
||||||
|
|
@ -59,7 +62,7 @@ interface NotificationToggle {
|
||||||
webhook_headers: Record<string, string>
|
webhook_headers: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationsConfig {
|
export interface NotificationsConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
cold_start_grace_seconds?: number
|
cold_start_grace_seconds?: number
|
||||||
band_conditions_enabled?: boolean
|
band_conditions_enabled?: boolean
|
||||||
|
|
@ -335,7 +338,7 @@ function formatRelativeTime(timestamp: number | null): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// InfoButton component
|
// InfoButton component
|
||||||
function InfoButton({ info }: { info: string }) {
|
export function InfoButton({ info }: { info: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
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
|
label: string
|
||||||
value: string[]
|
value: string[]
|
||||||
onChange: (v: string[]) => void
|
onChange: (v: string[]) => void
|
||||||
|
|
@ -1417,7 +1420,7 @@ function NotificationRuleCard({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main Notifications Page Component
|
// 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: 'mesh_health', label: 'Mesh Health', Icon: Activity },
|
||||||
{ key: 'weather', label: 'Weather', Icon: Cloud },
|
{ key: 'weather', label: 'Weather', Icon: Cloud },
|
||||||
{ key: 'fire', label: 'Fire', Icon: Flame },
|
{ key: 'fire', label: 'Fire', Icon: Flame },
|
||||||
|
|
@ -1541,9 +1544,70 @@ function GroupedCategoryPicker({
|
||||||
</div>
|
</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']
|
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 }: {
|
function MasterToggles({ toggles, onChange }: {
|
||||||
toggles: Record<string, NotificationToggle>
|
toggles: Record<string, NotificationToggle>
|
||||||
onChange: (t: Record<string, NotificationToggle>) => void
|
onChange: (t: Record<string, NotificationToggle>) => void
|
||||||
|
|
@ -1581,53 +1645,111 @@ function MasterToggles({ toggles, onChange }: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isOpen && (
|
{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 })} />
|
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
|
||||||
<div className="text-xs text-slate-500">Severity → channels</div>
|
<ListInput
|
||||||
<table className="text-xs w-full">
|
label="Regions (empty = all)"
|
||||||
<thead>
|
value={t.regions || []}
|
||||||
<tr><th></th>{TOGGLE_CHANNELS.map((c) => <th key={c} className="text-slate-500 font-normal px-1">{c.replace('_', ' ')}</th>)}</tr>
|
onChange={(v) => upd(key, { regions: v })}
|
||||||
</thead>
|
placeholder="Add region..."
|
||||||
<tbody>
|
/>
|
||||||
{TOGGLE_SEVERITIES.map((sev) => (
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<tr key={sev}>
|
<NumberInput
|
||||||
<td className="text-slate-400 pr-2">{sev}</td>
|
label="Freshness (sec)"
|
||||||
{TOGGLE_CHANNELS.map((ch) => {
|
value={t.freshness_seconds ?? 600}
|
||||||
const on = (t.severity_channels?.[sev] || []).includes(ch)
|
onChange={(v) => upd(key, { freshness_seconds: v })}
|
||||||
return (
|
min={0}
|
||||||
<td key={ch} className="text-center">
|
helper="Drop events older than this"
|
||||||
<input type="checkbox" checked={on} onChange={(e) => {
|
info="Events older than this window (seconds) are discarded at dispatcher entrance. 600 = 10 min."
|
||||||
const cur: Record<string, string[]> = { ...(t.severity_channels || {}) }
|
/>
|
||||||
const arr = new Set(cur[sev] || [])
|
<NumberInput
|
||||||
if (e.target.checked) arr.add(ch); else arr.delete(ch)
|
label="Cooldown (sec)"
|
||||||
cur[sev] = Array.from(arr)
|
value={t.cooldown_seconds ?? 0}
|
||||||
upd(key, { severity_channels: cur })
|
onChange={(v) => upd(key, { cooldown_seconds: v })}
|
||||||
}} />
|
min={0}
|
||||||
</td>
|
helper="0 = no throttle"
|
||||||
)
|
info="Per (family, category, region) throttle window. Prevents repeat sends within this window."
|
||||||
})}
|
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-600">MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
|
</div>
|
||||||
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
|
|
||||||
|
{/* ── 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" />
|
<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 })} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8082',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://localhost:8080',
|
target: 'ws://localhost:8082',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -550,7 +550,7 @@ class NotificationRuleConfig:
|
||||||
custom_message: str = ""
|
custom_message: str = ""
|
||||||
|
|
||||||
# Delivery type
|
# 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
|
# Mesh broadcast fields
|
||||||
broadcast_channel: int = 0
|
broadcast_channel: int = 0
|
||||||
|
|
@ -559,6 +559,8 @@ class NotificationRuleConfig:
|
||||||
|
|
||||||
# Mesh DM fields
|
# Mesh DM fields
|
||||||
node_ids: list = field(default_factory=list)
|
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
|
# Email fields
|
||||||
smtp_host: str = ""
|
smtp_host: str = ""
|
||||||
|
|
@ -600,6 +602,8 @@ class NotificationToggle:
|
||||||
# Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore.
|
# Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore.
|
||||||
meshcore_channel: Optional[str] = None
|
meshcore_channel: Optional[str] = None
|
||||||
node_ids: list = field(default_factory=list)
|
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_host: str = ""
|
||||||
smtp_port: int = 587
|
smtp_port: int = 587
|
||||||
smtp_user: str = ""
|
smtp_user: str = ""
|
||||||
|
|
@ -693,7 +697,8 @@ _DZ_VALID_ROLES = frozenset({
|
||||||
})
|
})
|
||||||
|
|
||||||
_DZ_VALID_DELIVERY = 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
|
# 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
|
# weather and flood a sub-gate of seismic (resolved in the correlator), so they
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,15 @@ class TestRequest(BaseModel):
|
||||||
|
|
||||||
class ChannelTestRequest(BaseModel):
|
class ChannelTestRequest(BaseModel):
|
||||||
"""Request body for channel connectivity test."""
|
"""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
|
# Mesh broadcast
|
||||||
channel_index: Optional[int] = 0
|
channel_index: Optional[int] = 0
|
||||||
# Mesh DM
|
# Mesh DM
|
||||||
node_ids: Optional[List[str]] = []
|
node_ids: Optional[List[str]] = []
|
||||||
|
# MeshCore broadcast
|
||||||
|
meshcore_channel: Optional[str] = None
|
||||||
|
# MeshCore DM
|
||||||
|
meshcore_dm_contacts: Optional[List[str]] = []
|
||||||
# Email
|
# Email
|
||||||
smtp_host: Optional[str] = ""
|
smtp_host: Optional[str] = ""
|
||||||
smtp_port: Optional[int] = 587
|
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
|
channel_config["channel_index"] = body.channel_index or 0
|
||||||
elif body.type == "mesh_dm":
|
elif body.type == "mesh_dm":
|
||||||
channel_config["node_ids"] = body.node_ids or []
|
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":
|
elif body.type == "email":
|
||||||
channel_config.update({
|
channel_config.update({
|
||||||
"smtp_host": body.smtp_host or "",
|
"smtp_host": body.smtp_host or "",
|
||||||
|
|
|
||||||
|
|
@ -56,22 +56,23 @@ class NotificationChannel(ABC):
|
||||||
|
|
||||||
|
|
||||||
class MeshBroadcastChannel(NotificationChannel):
|
class MeshBroadcastChannel(NotificationChannel):
|
||||||
"""Post alert to mesh channel."""
|
"""Post alert to Meshtastic channel (explicit Meshtastic-only delivery)."""
|
||||||
|
|
||||||
channel_type = "mesh_broadcast"
|
channel_type = "mesh_broadcast"
|
||||||
|
|
||||||
def __init__(self, connector: "MeshConnector", channel_index: int = 0,
|
def __init__(self, connector: "MeshConnector", channel_index: int = 0,
|
||||||
meshcore_channel: Optional[str] = None):
|
transport: Optional[str] = "meshtastic"):
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._channel = channel_index
|
self._channel = channel_index
|
||||||
# Per-family MeshCore channel NAME (None = MeshCore child skipped
|
# Transport hint: "meshtastic" for mesh_broadcast; passed to CompositeTransport
|
||||||
# downstream). Ignored by Meshtastic; behavior-preserving there.
|
# so it routes only to the Meshtastic child. Single-transport implementations
|
||||||
self._meshcore_channel = meshcore_channel
|
# accept and ignore this kwarg, so behavior is unchanged there.
|
||||||
|
self._transport = transport
|
||||||
_mc = getattr(connector, "max_chars", 200)
|
_mc = getattr(connector, "max_chars", 200)
|
||||||
self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200)
|
self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200)
|
||||||
|
|
||||||
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
||||||
"""Send alert to mesh channel."""
|
"""Send alert to Meshtastic channel."""
|
||||||
if not self._connector:
|
if not self._connector:
|
||||||
logger.warning("No mesh connector available")
|
logger.warning("No mesh connector available")
|
||||||
return False
|
return False
|
||||||
|
|
@ -83,7 +84,7 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
text=alert.message or "",
|
text=alert.message or "",
|
||||||
destination=None,
|
destination=None,
|
||||||
channel=self._channel,
|
channel=self._channel,
|
||||||
meshcore_channel=self._meshcore_channel,
|
transport=self._transport,
|
||||||
)
|
)
|
||||||
logger.info("Broadcast pre-chunked alert to channel %d", self._channel)
|
logger.info("Broadcast pre-chunked alert to channel %d", self._channel)
|
||||||
return True
|
return True
|
||||||
|
|
@ -95,7 +96,7 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
text=chunk,
|
text=chunk,
|
||||||
destination=None,
|
destination=None,
|
||||||
channel=self._channel,
|
channel=self._channel,
|
||||||
meshcore_channel=self._meshcore_channel,
|
transport=self._transport,
|
||||||
)
|
)
|
||||||
logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel)
|
logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel)
|
||||||
return True
|
return True
|
||||||
|
|
@ -173,19 +174,127 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
return False, f"Mesh broadcast failed: {e}"
|
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):
|
class MeshDMChannel(NotificationChannel):
|
||||||
"""DM alert to specific node IDs."""
|
"""DM alert to specific Meshtastic node IDs."""
|
||||||
|
|
||||||
channel_type = "mesh_dm"
|
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._connector = connector
|
||||||
self._node_ids = node_ids
|
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)
|
_mc = getattr(connector, "max_chars", 200)
|
||||||
self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200)
|
self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200)
|
||||||
|
|
||||||
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
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:
|
if not self._connector:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -201,7 +310,12 @@ class MeshDMChannel(NotificationChannel):
|
||||||
for message in messages:
|
for message in messages:
|
||||||
try:
|
try:
|
||||||
node_id = str(node_id)
|
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:
|
except Exception as e:
|
||||||
logger.error("Failed to DM %s: %s", node_id, e)
|
logger.error("Failed to DM %s: %s", node_id, e)
|
||||||
success = False
|
success = False
|
||||||
|
|
@ -295,6 +409,109 @@ class MeshDMChannel(NotificationChannel):
|
||||||
return False, f"All DMs failed: {'; '.join(errors)}"
|
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):
|
class EmailChannel(NotificationChannel):
|
||||||
"""Send alert via SMTP email."""
|
"""Send alert via SMTP email."""
|
||||||
|
|
||||||
|
|
@ -773,6 +990,14 @@ class WebhookChannel(NotificationChannel):
|
||||||
def create_channel(rule: "NotificationRuleConfig", connector=None) -> NotificationChannel:
|
def create_channel(rule: "NotificationRuleConfig", connector=None) -> NotificationChannel:
|
||||||
"""Create a channel instance from a NotificationRuleConfig.
|
"""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:
|
Args:
|
||||||
rule: NotificationRuleConfig with delivery_type and channel settings
|
rule: NotificationRuleConfig with delivery_type and channel settings
|
||||||
connector: MeshConnector instance (required for mesh channels)
|
connector: MeshConnector instance (required for mesh channels)
|
||||||
|
|
@ -783,15 +1008,32 @@ def create_channel(rule: "NotificationRuleConfig", connector=None) -> Notificati
|
||||||
delivery_type = rule.delivery_type
|
delivery_type = rule.delivery_type
|
||||||
|
|
||||||
if delivery_type == "mesh_broadcast":
|
if delivery_type == "mesh_broadcast":
|
||||||
|
# Meshtastic-only broadcast: explicit transport hint so CompositeTransport
|
||||||
|
# routes only to the Meshtastic child and skips MeshCore.
|
||||||
return MeshBroadcastChannel(
|
return MeshBroadcastChannel(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
channel_index=rule.broadcast_channel,
|
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),
|
meshcore_channel=getattr(rule, "meshcore_channel", None),
|
||||||
)
|
)
|
||||||
elif delivery_type == "mesh_dm":
|
elif delivery_type == "mesh_dm":
|
||||||
|
# Meshtastic-only DM: explicit transport hint so CompositeTransport
|
||||||
|
# routes only to the Meshtastic child.
|
||||||
return MeshDMChannel(
|
return MeshDMChannel(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
node_ids=rule.node_ids,
|
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":
|
elif delivery_type == "email":
|
||||||
return EmailChannel(
|
return EmailChannel(
|
||||||
|
|
@ -820,15 +1062,17 @@ def create_channel_from_dict(config: dict, connector=None) -> NotificationChanne
|
||||||
channel_type = config.get("type", "")
|
channel_type = config.get("type", "")
|
||||||
|
|
||||||
if channel_type == "mesh_broadcast":
|
if channel_type == "mesh_broadcast":
|
||||||
|
# Legacy dict configs are Meshtastic-only; no auto-fan.
|
||||||
return MeshBroadcastChannel(
|
return MeshBroadcastChannel(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
channel_index=config.get("channel_index", 0),
|
channel_index=config.get("channel_index", 0),
|
||||||
meshcore_channel=config.get("meshcore_channel"),
|
transport="meshtastic",
|
||||||
)
|
)
|
||||||
elif channel_type == "mesh_dm":
|
elif channel_type == "mesh_dm":
|
||||||
return MeshDMChannel(
|
return MeshDMChannel(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
node_ids=config.get("node_ids", []),
|
node_ids=config.get("node_ids", []),
|
||||||
|
transport_hint="meshtastic",
|
||||||
)
|
)
|
||||||
elif channel_type == "email":
|
elif channel_type == "email":
|
||||||
return EmailChannel(
|
return EmailChannel(
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,9 @@ class Dispatcher:
|
||||||
try:
|
try:
|
||||||
rule = self._toggle_to_rule(tog, ch_type, event)
|
rule = self._toggle_to_rule(tog, ch_type, event)
|
||||||
channel = self._channel_factory(rule, self._connector)
|
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)
|
payload = make_payload_from_event(event, message=friendly)
|
||||||
else:
|
else:
|
||||||
payload = make_payload_from_event(event)
|
payload = make_payload_from_event(event)
|
||||||
|
|
@ -549,10 +551,28 @@ class Dispatcher:
|
||||||
source_event_table, source_event_pk)
|
source_event_table, source_event_pk)
|
||||||
return False
|
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 {}
|
toggles = getattr(self._config.notifications, "toggles", None) or {}
|
||||||
rf = toggles.get("rf_propagation") if isinstance(toggles, dict) else None
|
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(
|
self._logger.info(
|
||||||
"scheduled-broadcast: rf_propagation channel not "
|
"scheduled-broadcast: rf_propagation channel not "
|
||||||
"configured; dropping")
|
"configured; dropping")
|
||||||
|
|
@ -570,17 +590,21 @@ class Dispatcher:
|
||||||
severity="priority", title=text,
|
severity="priority", title=text,
|
||||||
)
|
)
|
||||||
ev.data["_meshai_precomposed"] = True
|
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:
|
try:
|
||||||
channel = self._channel_factory(rule, self._connector)
|
channel = self._channel_factory(rule, self._connector)
|
||||||
payload = make_payload_from_event(ev, message=text)
|
payload = make_payload_from_event(ev, message=text)
|
||||||
success = await channel.deliver(payload, rule)
|
success = await channel.deliver(payload, rule)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(
|
self._logger.exception(
|
||||||
"scheduled-broadcast: delivery raised; treating as failed")
|
"scheduled-broadcast: delivery raised for %s; skipping", ch_type)
|
||||||
return False
|
continue
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
delivered_any = True
|
||||||
# Audit row -- mirrors _post_broadcast_commit for scheduled.
|
# Audit row -- mirrors _post_broadcast_commit for scheduled.
|
||||||
try:
|
try:
|
||||||
from meshai.persistence import get_db
|
from meshai.persistence import get_db
|
||||||
|
|
@ -597,8 +621,8 @@ class Dispatcher:
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(
|
self._logger.exception(
|
||||||
"scheduled-broadcast: audit row insert failed")
|
"scheduled-broadcast: audit row insert failed for %s", ch_type)
|
||||||
return bool(success)
|
return delivered_any
|
||||||
|
|
||||||
def _post_broadcast_commit(self, event, payload, rule, ch_type: str) -> None:
|
def _post_broadcast_commit(self, event, payload, rule, ch_type: str) -> None:
|
||||||
"""Persistence side-effects of an actually-successful broadcast.
|
"""Persistence side-effects of an actually-successful broadcast.
|
||||||
|
|
@ -625,6 +649,9 @@ class Dispatcher:
|
||||||
if ch_type == "mesh_dm":
|
if ch_type == "mesh_dm":
|
||||||
node_ids = list(getattr(rule, "node_ids", []) or [])
|
node_ids = list(getattr(rule, "node_ids", []) or [])
|
||||||
recipient = ",".join(map(str, node_ids)) or "dm"
|
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:
|
else:
|
||||||
recipient = "broadcast"
|
recipient = "broadcast"
|
||||||
channel = getattr(rule, "broadcast_channel", None)
|
channel = getattr(rule, "broadcast_channel", None)
|
||||||
|
|
@ -662,6 +689,7 @@ class Dispatcher:
|
||||||
broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0),
|
broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0),
|
||||||
meshcore_channel=getattr(tog, "meshcore_channel", None),
|
meshcore_channel=getattr(tog, "meshcore_channel", None),
|
||||||
node_ids=list(getattr(tog, "node_ids", []) or []),
|
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_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_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""),
|
||||||
smtp_tls=getattr(tog, "smtp_tls", True), from_address=getattr(tog, "from_address", ""),
|
smtp_tls=getattr(tog, "smtp_tls", True), from_address=getattr(tog, "from_address", ""),
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ class DigestScheduler:
|
||||||
channel = self._channel_factory(rule, self._connector)
|
channel = self._channel_factory(rule, self._connector)
|
||||||
delivery_type = rule.delivery_type
|
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
|
# One deliver call per chunk
|
||||||
chunks = digest.mesh_chunks
|
chunks = digest.mesh_chunks
|
||||||
total = len(chunks)
|
total = len(chunks)
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,8 @@ class CompositeTransport(MeshTransport):
|
||||||
"""
|
"""
|
||||||
if destination is None:
|
if destination is None:
|
||||||
# --- Rule 1: broadcast ---
|
# --- 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:
|
if transport is not None:
|
||||||
# --- Rule 2: hinted DM ---
|
# --- Rule 2: hinted DM ---
|
||||||
|
|
@ -233,18 +234,52 @@ class CompositeTransport(MeshTransport):
|
||||||
# --- Rule 3: unhinted DM ---
|
# --- Rule 3: unhinted DM ---
|
||||||
return self._send_unhinted(text, destination, channel)
|
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.
|
"""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 Meshtastic child, ``channel`` (Meshtastic channel index) is used.
|
||||||
For the MeshCore child:
|
For the MeshCore child:
|
||||||
- ``meshcore_channel`` set → route that channel NAME to MeshCore,
|
- ``meshcore_channel`` set → route that channel NAME to MeshCore.
|
||||||
which resolves it to a companion slot at send time.
|
|
||||||
- ``meshcore_channel`` is None → skip the MeshCore child entirely
|
- ``meshcore_channel`` is None → skip the MeshCore child entirely
|
||||||
(family not configured for MeshCore; no fallback to a default).
|
(family not configured for MeshCore; no fallback to a default).
|
||||||
|
|
||||||
Returns True if at least one child succeeded.
|
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
|
any_ok = False
|
||||||
for child in self._children:
|
for child in self._children:
|
||||||
name = _child_name(child)
|
name = _child_name(child)
|
||||||
|
|
|
||||||
|
|
@ -216,15 +216,15 @@ def test_webhook_channel_uses_webhook_renderer():
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PER-FAMILY MESHCORE ROUTING — end-to-end threading guard
|
# 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():
|
def test_mesh_broadcast_routes_to_meshtastic_only():
|
||||||
"""create_channel(rule) -> MeshBroadcastChannel.deliver must pass BOTH
|
"""mesh_broadcast passes transport='meshtastic' and channel index to
|
||||||
channel=<broadcast_channel> AND meshcore_channel=<name> to send_message.
|
send_message. meshcore_channel is NOT passed (auto-fan removed).
|
||||||
|
|
||||||
This is the regression guard for the gap where the rule's
|
Regression guard: before this model, mesh_broadcast also threaded
|
||||||
meshcore_channel never reached connector.send_message.
|
meshcore_channel through; now it is Meshtastic-only.
|
||||||
"""
|
"""
|
||||||
from meshai.config import NotificationRuleConfig
|
from meshai.config import NotificationRuleConfig
|
||||||
from meshai.notifications.channels import create_channel
|
from meshai.notifications.channels import create_channel
|
||||||
|
|
@ -234,7 +234,7 @@ def test_broadcast_threads_meshcore_channel_through_factory():
|
||||||
name="toggle:fire",
|
name="toggle:fire",
|
||||||
delivery_type="mesh_broadcast",
|
delivery_type="mesh_broadcast",
|
||||||
broadcast_channel=1,
|
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)
|
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()
|
mock_connector.send_message.assert_called_once()
|
||||||
kwargs = mock_connector.send_message.call_args.kwargs
|
kwargs = mock_connector.send_message.call_args.kwargs
|
||||||
assert kwargs.get("channel") == 1
|
assert kwargs.get("channel") == 1
|
||||||
# The load-bearing assertion: the name was NOT dropped.
|
assert kwargs.get("transport") == "meshtastic"
|
||||||
assert kwargs.get("meshcore_channel") == "AIDA"
|
# 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():
|
def test_meshcore_broadcast_routes_to_meshcore_only():
|
||||||
"""meshcore_channel=None (family not on MeshCore) => send_message still
|
"""meshcore_broadcast passes meshcore_channel=name and transport='meshcore'
|
||||||
receives meshcore_channel=None (MeshCore child skipped downstream)."""
|
to send_message. This is the explicit MeshCore-only delivery path."""
|
||||||
from meshai.config import NotificationRuleConfig
|
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 = MagicMock()
|
||||||
|
mock_connector._by_name = {"meshcore": MagicMock(), "meshtastic": MagicMock()}
|
||||||
|
mock_connector.send_message.return_value = True
|
||||||
|
|
||||||
rule = NotificationRuleConfig(
|
rule = NotificationRuleConfig(
|
||||||
name="toggle:weather",
|
name="toggle:fire",
|
||||||
delivery_type="mesh_broadcast",
|
delivery_type="meshcore_broadcast",
|
||||||
broadcast_channel=0,
|
meshcore_channel="AIDA",
|
||||||
meshcore_channel=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
channel = create_channel(rule, mock_connector)
|
channel = create_channel(rule, mock_connector)
|
||||||
|
assert isinstance(channel, MeshCoreBroadcastChannel)
|
||||||
|
|
||||||
payload = NotificationPayload(
|
payload = NotificationPayload(
|
||||||
message="weather alert",
|
message="fire alert",
|
||||||
category="weather_warning",
|
category="fire",
|
||||||
severity="priority",
|
severity="immediate",
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
event_type="weather_warning",
|
event_type="fire",
|
||||||
chunk_index=0,
|
chunk_index=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert asyncio.run(channel.deliver(payload, rule)) is True
|
assert asyncio.run(channel.deliver(payload, rule)) is True
|
||||||
|
|
||||||
|
mock_connector.send_message.assert_called_once()
|
||||||
kwargs = mock_connector.send_message.call_args.kwargs
|
kwargs = mock_connector.send_message.call_args.kwargs
|
||||||
assert kwargs.get("channel") == 0
|
assert kwargs.get("meshcore_channel") == "AIDA"
|
||||||
assert "meshcore_channel" in kwargs
|
assert kwargs.get("transport") == "meshcore"
|
||||||
assert kwargs.get("meshcore_channel") is None
|
assert kwargs.get("destination") is None
|
||||||
|
|
||||||
|
|
||||||
def test_broadcast_render_loop_threads_meshcore_channel():
|
def test_broadcast_render_loop_threads_meshcore_channel():
|
||||||
"""Non-prechunked path (renderer loop) also threads meshcore_channel
|
"""Non-prechunked path (renderer loop) for meshcore_broadcast threads
|
||||||
on every chunk send."""
|
meshcore_channel on every chunk send."""
|
||||||
from meshai.config import NotificationRuleConfig
|
from meshai.config import NotificationRuleConfig
|
||||||
from meshai.notifications.channels import create_channel
|
from meshai.notifications.channels import create_channel
|
||||||
|
|
||||||
mock_connector = MagicMock()
|
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(
|
rule = NotificationRuleConfig(
|
||||||
name="toggle:fire",
|
name="toggle:fire",
|
||||||
delivery_type="mesh_broadcast",
|
delivery_type="meshcore_broadcast",
|
||||||
broadcast_channel=2,
|
|
||||||
meshcore_channel="AIDA",
|
meshcore_channel="AIDA",
|
||||||
)
|
)
|
||||||
channel = create_channel(rule, mock_connector)
|
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 asyncio.run(channel.deliver(payload, rule)) is True
|
||||||
assert mock_connector.send_message.call_count >= 2
|
assert mock_connector.send_message.call_count >= 2
|
||||||
for call in mock_connector.send_message.call_args_list:
|
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("meshcore_channel") == "AIDA"
|
||||||
|
assert call.kwargs.get("transport") == "meshcore"
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.pipeline.dispatcher import Dispatcher
|
||||||
from meshai.notifications.events import make_event
|
from meshai.notifications.events import make_event
|
||||||
|
from meshai.notifications.channels import create_channel
|
||||||
|
|
||||||
|
|
||||||
class RecChannel:
|
class RecChannel:
|
||||||
|
|
@ -134,3 +140,300 @@ def test_rules_and_toggles_both_fire():
|
||||||
rec = _dispatch(cfg, _ev(severity="priority"))
|
rec = _dispatch(cfg, _ev(severity="priority"))
|
||||||
names = {r["name"] for r in rec}
|
names = {r["name"] for r in rec}
|
||||||
assert "legacy" in names and "toggle:weather" in names # parallel paths both fire
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue