fix(notifications): test shows live data, not just canned examples

- Test always shows current data for the rule's feed categories
- RF rules show live SFI/Kp/R/S/G and ducting conditions
- Weather rules show active NWS alert count and headlines
- Fire rules show active fire/hotspot count
- Stream rules show current gauge readings
- Mesh rules show current health score and infra status
- Send Current Conditions delivers live snapshot through channel
- Send Test Alert delivers example through channel
- Send Live Alert available when real conditions match

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zvx-echo6 2026-05-13 18:08:09 -06:00
commit 72a7a90f4d
13 changed files with 2627 additions and 75 deletions

View file

@ -5,18 +5,25 @@ import Mesh from './pages/Mesh'
import Environment from './pages/Environment' import Environment from './pages/Environment'
import Config from './pages/Config' import Config from './pages/Config'
import Alerts from './pages/Alerts' import Alerts from './pages/Alerts'
import Notifications from './pages/Notifications'
import Reference from './pages/Reference'
import { ToastProvider } from './components/ToastProvider'
function App() { function App() {
return ( return (
<Layout> <ToastProvider>
<Routes> <Layout>
<Route path="/" element={<Dashboard />} /> <Routes>
<Route path="/mesh" element={<Mesh />} /> <Route path="/" element={<Dashboard />} />
<Route path="/environment" element={<Environment />} /> <Route path="/mesh" element={<Mesh />} />
<Route path="/config" element={<Config />} /> <Route path="/environment" element={<Environment />} />
<Route path="/alerts" element={<Alerts />} /> <Route path="/config" element={<Config />} />
</Routes> <Route path="/alerts" element={<Alerts />} />
</Layout> <Route path="/notifications" element={<Notifications />} />
<Route path="/reference" element={<Reference />} />
</Routes>
</Layout>
</ToastProvider>
) )
} }

View file

@ -6,9 +6,12 @@ import {
Cloud, Cloud,
Settings, Settings,
Bell, Bell,
BellRing,
BookOpen,
} 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'
import { useToast } from './ToastProvider'
interface LayoutProps { interface LayoutProps {
children: ReactNode children: ReactNode
@ -20,6 +23,8 @@ const navItems = [
{ path: '/environment', label: 'Environment', icon: Cloud }, { path: '/environment', label: 'Environment', icon: Cloud },
{ path: '/config', label: 'Config', icon: Settings }, { 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 },
] ]
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
@ -39,8 +44,21 @@ function getPageTitle(pathname: string): string {
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const location = useLocation() const location = useLocation()
const { connected } = useWebSocket() const { connected, lastAlert } = useWebSocket()
const { addToast } = useToast()
const [status, setStatus] = useState<SystemStatus | null>(null) const [status, setStatus] = useState<SystemStatus | null>(null)
const [lastAlertId, setLastAlertId] = useState<string | null>(null)
// Trigger toast on new alerts
useEffect(() => {
if (lastAlert) {
const alertId = `${lastAlert.type}-${lastAlert.message}-${lastAlert.timestamp}`
if (alertId !== lastAlertId) {
setLastAlertId(alertId)
addToast(lastAlert)
}
}
}, [lastAlert, lastAlertId, addToast])
const [currentTime, setCurrentTime] = useState(new Date()) const [currentTime, setCurrentTime] = useState(new Date())
useEffect(() => { useEffect(() => {

View file

@ -0,0 +1,972 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import {
Search, Droplets, Flame, Satellite, CloudLightning, Sun,
Radio, Mountain, Car, Construction, Activity, Bell, Terminal,
Code, ExternalLink
} from 'lucide-react'
// Topic definitions
const TOPICS = [
{ id: 'stream-gauges', label: 'Stream Gauges', icon: Droplets },
{ id: 'wildfire', label: 'Wildfire', icon: Flame },
{ id: 'firms', label: 'Satellite Fire Detection (FIRMS)', icon: Satellite },
{ id: 'weather-alerts', label: 'Weather Alerts', icon: CloudLightning },
{ id: 'solar', label: 'Solar & Geomagnetic', icon: Sun },
{ id: 'ducting', label: 'Tropospheric Ducting', icon: Radio },
{ id: 'avalanche', label: 'Avalanche Danger', icon: Mountain },
{ id: 'traffic', label: 'Traffic Flow', icon: Car },
{ id: 'roads-511', label: 'Road Conditions (511)', icon: Construction },
{ id: 'mesh-health', label: 'Mesh Health', icon: Activity },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'commands', label: 'Commands', icon: Terminal },
{ id: 'api', label: 'API Reference', icon: Code },
]
// Status indicator component for colored dots
function StatusDot({ color }: { color: 'green' | 'yellow' | 'orange' | 'red' | 'black' }) {
const colorClasses = {
green: 'bg-green-500',
yellow: 'bg-yellow-500',
orange: 'bg-orange-500',
red: 'bg-red-500',
black: 'bg-slate-800 border border-slate-600',
}
return <span className={`inline-block w-3 h-3 rounded-full ${colorClasses[color]}`} />
}
// Table component styled for dark theme
function RefTable({ headers, rows }: { headers: string[]; rows: (string | React.ReactNode)[][] }) {
return (
<div className="overflow-x-auto my-4">
<table className="w-full text-sm">
<thead>
<tr className="bg-[#1a2332] border-b border-[#2a3a4a]">
{headers.map((h, i) => (
<th key={i} className="px-4 py-2 text-left text-slate-400 font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className={`border-b border-[#1e2a3a] ${i % 2 === 0 ? 'bg-[#0d1219]' : 'bg-[#0a0e17]'}`}>
{row.map((cell, j) => (
<td key={j} className="px-4 py-2 text-slate-300">{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
// External link component
function ExtLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline inline-flex items-center gap-1"
>
{children} <ExternalLink size={12} />
</a>
)
}
// Section header
function SectionHeader({ children }: { children: React.ReactNode }) {
return <h3 className="text-lg font-semibold text-slate-200 mt-6 mb-3">{children}</h3>
}
// Sub-header
function SubHeader({ children }: { children: React.ReactNode }) {
return <h4 className="text-base font-medium text-slate-300 mt-4 mb-2">{children}</h4>
}
// Monospace text
function Mono({ children }: { children: React.ReactNode }) {
return <code className="font-mono text-accent bg-[#1a2332] px-1 rounded">{children}</code>
}
// Topic section wrapper
function TopicSection({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<section id={id} className="mb-12 scroll-mt-6">
<h2 className="text-2xl font-bold text-slate-100 mb-4 pb-2 border-b border-[#2a3a4a]">{title}</h2>
<div className="text-slate-300 leading-relaxed space-y-4">
{children}
</div>
</section>
)
}
export default function Reference() {
const location = useLocation()
const [searchQuery, setSearchQuery] = useState('')
const [activeTopic, setActiveTopic] = useState('stream-gauges')
const contentRef = useRef<HTMLDivElement>(null)
// Handle hash navigation
useEffect(() => {
const hash = location.hash.replace('#', '')
if (hash && TOPICS.find(t => t.id === hash)) {
setActiveTopic(hash)
const element = document.getElementById(hash)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
}, [location.hash])
// Filter topics by search
const filteredTopics = TOPICS.filter(t =>
t.label.toLowerCase().includes(searchQuery.toLowerCase())
)
const scrollToTopic = (topicId: string) => {
setActiveTopic(topicId)
const element = document.getElementById(topicId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
window.history.replaceState(null, '', `#${topicId}`)
}
return (
<div className="flex h-full -m-6">
{/* Topic sidebar */}
<aside className="w-64 flex-shrink-0 bg-bg-card border-r border-border overflow-y-auto">
<div className="p-4 border-b border-border">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search topics..."
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent placeholder-slate-600"
/>
</div>
</div>
<nav className="py-2">
{filteredTopics.map((topic) => {
const Icon = topic.icon
const isActive = activeTopic === topic.id
return (
<button
key={topic.id}
onClick={() => scrollToTopic(topic.id)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors ${
isActive
? 'text-accent bg-accent/10 border-l-2 border-accent'
: 'text-slate-400 hover:text-slate-200 hover:bg-bg-hover border-l-2 border-transparent'
}`}
>
<Icon size={16} />
{topic.label}
</button>
)
})}
</nav>
</aside>
{/* Main content */}
<div ref={contentRef} className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl">
<p className="text-slate-400 mb-8">
Everything you need to understand and configure MeshAI's monitoring and alerting systems.
</p>
{/* Stream Gauges */}
<TopicSection id="stream-gauges" title="Stream Gauges">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
MeshAI watches river and stream levels at gauges you configure. Each gauge reports two things:
</p>
<p>
<strong>Water Level (Gage Height)</strong> how high the water is, measured in feet. Important: this is NOT the depth of the river. It's the height above a fixed measuring point that's different at every gauge. A reading of "10 feet" at one gauge means something completely different than "10 feet" at another. You can only compare readings from the SAME gauge over time.
</p>
<p>
<strong>Flow (Discharge)</strong> how much water is moving past the gauge, in cubic feet per second (CFS). Think of it as the river's "throughput." For scale:
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>A small creek: 50-200 CFS</li>
<li>A mid-size river: 1,000-5,000 CFS</li>
<li>A big river in spring runoff: 10,000+ CFS</li>
</ul>
<SectionHeader>When Does It Flood?</SectionHeader>
<p>
Flood levels are set by the <strong>National Weather Service</strong>, not USGS. NWS looks at each specific gauge location and decides "at what water level does the road flood? At what level do buildings get water?" Those levels are different everywhere.
</p>
<p><strong>Action Stage</strong> water is rising, time to start paying attention. Usually still inside the riverbanks.</p>
<p><strong>Minor Flood</strong> low-lying roads start getting water on them. NWS issues a Flood Advisory.</p>
<p><strong>Moderate Flood</strong> water in buildings near the river. Some people need to evacuate. NWS issues a Flood Warning.</p>
<p><strong>Major Flood</strong> widespread flooding. Many people evacuating. Serious property damage.</p>
<p>
MeshAI automatically looks up the flood levels for your gauge from NWS when you add a site. Some remote gauges don't have flood levels assigned for those, you set them manually if you know what water levels cause problems in your area.
</p>
<SectionHeader>Low Water / Drought</SectionHeader>
<p>
There's no official "drought stage" for most gauges. If you need to monitor low water (irrigation, fish habitat), set a manual low-water threshold based on what you know about your local river.
</p>
<SectionHeader>Setting It Up</SectionHeader>
<ol className="list-decimal list-inside ml-4 space-y-1">
<li>Find your gauge at <ExtLink href="https://waterdata.usgs.gov/nwis">waterdata.usgs.gov/nwis</ExtLink></li>
<li>Copy the site number (like <Mono>13090500</Mono>)</li>
<li>Add it in Config Environmental USGS</li>
<li>MeshAI auto-fills the gauge name and flood levels from NWS</li>
</ol>
<p>If NWS flood levels don't populate, your gauge may not have them. Set manual thresholds if you know your local conditions.</p>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://waterdata.usgs.gov/nwis">USGS Water Data</ExtLink> find gauges near you</li>
<li><ExtLink href="https://water.noaa.gov">NWS Water Prediction Service</ExtLink> flood forecasts and thresholds</li>
<li><ExtLink href="https://www.usgs.gov/special-topics/water-science-school/science/how-streamflow-measured">Understanding Streamflow</ExtLink> USGS explainer</li>
</ul>
</TopicSection>
{/* Wildfire */}
<TopicSection id="wildfire" title="Wildfire">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
MeshAI tracks active wildfire perimeters from the National Interagency Fire Center (NIFC). For each fire, you see the name, size, how much is contained, and how far it is from your mesh nodes.
</p>
<SectionHeader>Fire Size How Big Is It?</SectionHeader>
<RefTable
headers={['Size', 'What That Means']}
rows={[
['10 acres', 'Small fire. Usually handled quickly by initial crews.'],
['100 acres', 'Notable fire. Active firefighting effort.'],
['1,000 acres', 'Large fire. Major resources being deployed.'],
['10,000+ acres', 'Very large fire. Multiple teams, aircraft, heavy equipment.'],
['100,000+ acres', 'Mega-fire. These make the national news.'],
]}
/>
<p>For reference, 1,000 acres is about 1.5 square miles.</p>
<SectionHeader>Containment Is It Under Control?</SectionHeader>
<p>
Containment means the percentage of the fire's edge where firefighters have built a control line (a cleared strip to stop the fire from spreading further). It does NOT mean the fire is out inside that line.
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><strong>0-30%</strong> Essentially uncontrolled. The fire goes where it wants.</li>
<li><strong>50%</strong> Good progress, but half the edge can still grow.</li>
<li><strong>80%+</strong> Well controlled. Major growth unlikely.</li>
<li><strong>100%</strong> The edge is fully controlled. But the fire may STILL be actively burning inside. "100% contained" does NOT mean "out."</li>
</ul>
<SectionHeader>How Far Away Should I Worry?</SectionHeader>
<RefTable
headers={['Distance', 'What To Do']}
rows={[
[<><StatusDot color="red" /> Under 5 km (3 miles)</>, <><strong>Immediate threat.</strong> This is evacuation-order range. Embers can fly this far in wind.</>],
[<><StatusDot color="orange" /> 5-15 km (3-10 miles)</>, <><strong>Prepare.</strong> The fire could reach you in hours under bad conditions. Have a plan.</>],
[<><StatusDot color="yellow" /> 15-30 km (10-20 miles)</>, <><strong>Watch.</strong> Smoke is likely. Wind shifts could change things fast.</>],
[<><StatusDot color="green" /> Over 30 km (20 miles)</>, <><strong>Awareness.</strong> Keep an eye on it, but no immediate threat.</>],
]}
/>
<p>
How fast can a fire travel? In grass with wind: up to 14 mph. In heavy timber: 1-6 mph. A fire 10 miles away could theoretically reach you in 1-2 hours under worst-case conditions, but typical spread is much slower.
</p>
<SectionHeader>Which Matters More Size or Distance?</SectionHeader>
<p>
<strong>Distance is the immediate concern.</strong> A small uncontained fire 10 km away is more dangerous right now than a huge fire 50 km away. But big fires have more energy and can grow fast under wind shifts keep watching them.
</p>
<SectionHeader>Setting It Up</SectionHeader>
<p>
Just configure your state code (like <Mono>US-ID</Mono> for Idaho) in Config Environmental Fires. MeshAI polls NIFC every 10 minutes for active fires in that state and computes the distance to your mesh nodes automatically.
</p>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://inciweb.nwcg.gov">InciWeb</ExtLink> detailed incident information</li>
<li><ExtLink href="https://data-nifc.opendata.arcgis.com">NIFC Fire Map</ExtLink> raw perimeter data</li>
<li><ExtLink href="https://www.ready.gov/wildfires">Ready.gov Wildfires</ExtLink> preparedness guide</li>
</ul>
</TopicSection>
{/* FIRMS */}
<TopicSection id="firms" title="Satellite Fire Detection (FIRMS)">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
NASA's VIIRS satellites orbit the Earth and look for heat signatures on the ground. When they see something hot a fire, a factory, a sunlit building they flag it as a "hotspot." MeshAI checks these detections for your area.
</p>
<p>
<strong>Why this matters</strong>: satellite hotspots show up <strong>hours before</strong> official fire perimeters are mapped. If a new fire starts near your mesh, the satellite might see it before anyone on the ground reports it.
</p>
<SectionHeader>Confidence Is It Really a Fire?</SectionHeader>
<p>Each detection gets a confidence rating:</p>
<RefTable
headers={['Confidence', 'What It Means']}
rows={[
['High', 'Almost certainly a real fire. Strong heat signature.'],
['Nominal', 'Probably a real fire. Most actual fires get this rating.'],
['Low', 'Maybe a fire, maybe not. Could be a hot roof, sun reflecting off water, a factory, or a gas flare. Lots of false alarms.'],
]}
/>
<p>
<strong>Recommendation</strong>: Set the filter to "Nominal + High." If you include "Low" you'll get alerts for every hot parking lot on a summer day.
</p>
<SectionHeader>FRP How Intense Is It?</SectionHeader>
<p>FRP (Fire Radiative Power) measures the heat output in megawatts. Think of it as "how hot is this thing":</p>
<RefTable
headers={['FRP', 'What It Probably Is']}
rows={[
['Under 5 MW', 'Hot surface, small agricultural burn, gas flare, or warm ground'],
['5-50 MW', 'An actual fire — brush fire, grass fire, typical wildfire'],
['50-300 MW', 'Intense fire — trees fully burning, active fire front'],
['Over 300 MW', 'Extreme fire — major wildfire in full force'],
]}
/>
<p>Setting the minimum FRP to 5 MW filters out most industrial and agricultural false alarms.</p>
<SectionHeader>New Ignition Detection</SectionHeader>
<p>
MeshAI cross-references satellite hotspots against known NIFC fire perimeters. If a hotspot is NOT near any known fire, it gets flagged as a <strong>potential new ignition</strong> maybe a new fire just started. These get elevated priority regardless of confidence level.
</p>
<SectionHeader>Timing</SectionHeader>
<p>
Satellite data arrives <strong>1-3 hours</strong> after the satellite passes overhead. Each location gets observed about <strong>6 times per day</strong> across all satellites, so there are multi-hour gaps. This is not real-time it's "pretty recent."
</p>
<SectionHeader>Getting an API Key</SectionHeader>
<ol className="list-decimal list-inside ml-4 space-y-1">
<li>Go to <ExtLink href="https://firms.modaps.eosdis.nasa.gov/api/area/">FIRMS API page</ExtLink></li>
<li>Click "Get MAP_KEY"</li>
<li>Register for a free Earthdata account</li>
<li>Your key arrives by email</li>
<li>Enter it in Config Environmental FIRMS</li>
</ol>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://firms.modaps.eosdis.nasa.gov">FIRMS Fire Map</ExtLink> see hotspots on a map</li>
<li><ExtLink href="https://earthdata.nasa.gov/data/tools/firms/faq">FIRMS FAQ</ExtLink> how it works</li>
</ul>
</TopicSection>
{/* Weather Alerts */}
<TopicSection id="weather-alerts" title="Weather Alerts">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
MeshAI watches for NWS (National Weather Service) alerts affecting your area warnings, watches, and advisories.
</p>
<SectionHeader>Alert Severity How Serious Is It?</SectionHeader>
<RefTable
headers={['Severity', 'What It Means', 'Example']}
rows={[
['Extreme', 'Life-threatening. The most serious events.', 'Tornado Emergency, Hurricane Warning, Tsunami Warning'],
['Severe', 'Dangerous. Take protective action.', 'Tornado Warning, Flash Flood Warning, Blizzard Warning, Red Flag Warning'],
['Moderate', 'Be prepared. Could become dangerous.', 'Winter Weather Advisory, Wind Advisory, Flood Watch, Heat Advisory'],
['Minor', "Good to know. Probably won't hurt anyone.", 'Special Weather Statement, Air Quality Alert'],
]}
/>
<SectionHeader>When Should I Act? (Urgency)</SectionHeader>
<RefTable
headers={['Urgency', 'What It Means']}
rows={[
['Immediate', 'Do something NOW'],
['Expected', 'Do something within the hour'],
['Future', 'Coming in the next several hours'],
["Past", "It's over — NWS is clearing the alert"],
]}
/>
<SectionHeader>How Sure Are They? (Certainty)</SectionHeader>
<RefTable
headers={['Certainty', 'What It Means']}
rows={[
['Observed', "It's happening right now. Verified."],
['Likely', 'More than 50% chance'],
['Possible', 'Could happen, but less than 50%'],
["Unlikely", "Probably won't, but mentioned for awareness"],
]}
/>
<SectionHeader>These Are Separate Scales</SectionHeader>
<p>
A single alert has all three. A hurricane warning for next week is "Severe + Future + Likely." A tornado spotted on the ground is "Extreme + Immediate + Observed." An air quality advisory is "Minor + Expected + Possible."
</p>
<SectionHeader>What Minimum Severity Should I Set?</SectionHeader>
<RefTable
headers={['Setting', 'What You Get', 'What You Miss']}
rows={[
['Minor', 'Everything — high volume', 'Nothing'],
[<><strong>Moderate</strong> </>, 'Watches, Advisories, and Warnings', 'Special Weather Statements'],
['Severe', 'Only Warnings — things happening NOW', 'Watches (which give you hours of advance warning)'],
['Extreme', 'Only the rarest events', 'Most Tornado and Severe Thunderstorm Warnings'],
]}
/>
<p>
<strong>Moderate is recommended.</strong> It catches Watches (advance warning that conditions may worsen) and Advisories (conditions exist but aren't severe) while filtering out the informational stuff.
</p>
<SectionHeader>Finding Your NWS Zone</SectionHeader>
<ol className="list-decimal list-inside ml-4 space-y-1">
<li>Go to <ExtLink href="https://www.weather.gov">weather.gov</ExtLink></li>
<li>Enter your location</li>
<li>Find your zone code at <ExtLink href="https://www.weather.gov/pimar/PubZone">NWS Zone Map</ExtLink></li>
<li>Zone codes look like: <Mono>IDZ016</Mono>, <Mono>UTZ040</Mono>, etc.</li>
</ol>
<SectionHeader>The User-Agent Field</SectionHeader>
<p>
NWS wants to know who's using their API not for approval, just so they can contact you if something breaks. You make it up:
</p>
<p><Mono>(meshai, you@email.com)</Mono></p>
<p>No registration. No waiting. Just type it in.</p>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://alerts.weather.gov">NWS Active Alerts</ExtLink> see current alerts</li>
<li><ExtLink href="https://www.weather.gov/documentation/services-web-api">NWS API Docs</ExtLink> technical details</li>
</ul>
</TopicSection>
{/* Solar & Geomagnetic */}
<TopicSection id="solar" title="Solar & Geomagnetic Conditions">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
MeshAI tracks space weather solar activity and its effects on Earth's magnetic field. This matters for radio operators because the sun directly controls how well HF radio works, and major solar events can affect all radio communications.
</p>
<SectionHeader>Solar Flux Index (SFI)</SectionHeader>
<p>Think of SFI as a "how active is the sun" number. Higher = better for HF radio, but also higher risk of solar flares.</p>
<RefTable
headers={['SFI', 'What It Means for You']}
rows={[
['Below 70', 'Quiet sun. Higher HF bands (10m, 15m) are probably dead. Stick to lower bands.'],
['70-90', 'Getting better. Some openings on 15m and above, but inconsistent.'],
['90-120', 'Good. Most HF bands work. Reliable contacts on 20m and 15m.'],
['120-170', 'Great. All HF bands open. 10m works for worldwide contacts.'],
['Above 170', 'Excellent. Best HF conditions — but watch for flares.'],
]}
/>
<p><strong>Quick rule</strong>: SFI above 90 and Kp below 4 = good day for HF radio.</p>
<SectionHeader>Kp Index</SectionHeader>
<p>Kp measures how disturbed Earth's magnetic field is, on a 0-9 scale. Higher = more disturbance = worse for HF radio but better for aurora viewing.</p>
<RefTable
headers={['Kp', 'What It Means for You']}
rows={[
['0-2', 'Quiet. Best HF conditions.'],
['3', "Slightly unsettled. You probably won't notice."],
['4', "Active. Some noise and fading on HF, especially if you're at higher latitudes."],
[<strong>5</strong>, <><strong>Minor storm (G1).</strong> HF noticeably degraded. Aurora visible at high latitudes (~60°N).</>],
[<strong>6</strong>, <><strong>Moderate storm (G2).</strong> HF getting rough. Aurora moving south (~55°N).</>],
[<strong>7</strong>, <><strong>Strong storm (G3).</strong> HF unreliable for 1-2 days. Aurora at mid-latitudes.</>],
[<strong>8-9</strong>, <><strong>Severe/Extreme storm.</strong> HF may black out completely. Aurora visible at very low latitudes. Power grid stress possible.</>],
]}
/>
<SectionHeader>R / S / G Scales</SectionHeader>
<p>NOAA's shorthand for three types of space weather events:</p>
<SubHeader>R (Radio Blackouts) from solar flares:</SubHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>R1-R2: Brief HF disruption. You might not notice.</li>
<li>R3: HF goes out for about an hour on the sunlit side of Earth.</li>
<li>R4-R5: HF dead for hours. Serious.</li>
</ul>
<SubHeader>S (Solar Radiation Storms) from energetic particles:</SubHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>Mostly affects polar regions and satellites</li>
<li>S3+: Polar HF goes out entirely</li>
</ul>
<SubHeader>G (Geomagnetic Storms) from solar wind disturbances:</SubHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>Same as the Kp scale: G1 = Kp 5, up to G5 = Kp 9</li>
</ul>
<SectionHeader>Bz The Storm Predictor</SectionHeader>
<p>
Bz measures the direction of the solar wind's magnetic field. When it points south (negative values), the solar wind can dump energy into Earth's magnetic field, causing storms.
</p>
<RefTable
headers={['Bz', 'What It Means']}
rows={[
['Positive', 'All good. Solar wind bouncing off.'],
['0 to -5', 'Slight coupling. Nothing dramatic.'],
['-5 to -10', 'Things starting to pick up. Storm possible.'],
['Below -10', 'Storm likely. Kp will start climbing.'],
['Below -20', 'Severe storm probable.'],
]}
/>
<p>Bz can change fast minute to minute. What matters is whether it stays negative for hours, not brief dips.</p>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://www.swpc.noaa.gov">SWPC Space Weather Dashboard</ExtLink> live data</li>
<li><ExtLink href="https://www.swpc.noaa.gov/noaa-scales-explanation">NOAA Space Weather Scales</ExtLink> what R/S/G mean</li>
<li><ExtLink href="https://www.hamqsl.com/solar.html">HamQSL Solar Page</ExtLink> ham-friendly display</li>
<li><ExtLink href="https://www.swpc.noaa.gov/products/planetary-k-index">Planetary K-Index</ExtLink> live Kp</li>
</ul>
</TopicSection>
{/* Tropospheric Ducting */}
<TopicSection id="ducting" title="Tropospheric Ducting">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
Sometimes the atmosphere creates an invisible "pipe" that traps radio signals and carries them much farther than normal. This is called tropospheric ducting. It mostly affects VHF and UHF frequencies.
</p>
<p>
MeshAI watches for these conditions by analyzing weather data (temperature and humidity at different altitudes) over your mesh area.
</p>
<SectionHeader>How Do I Know If Ducting Is Happening?</SectionHeader>
<p>MeshAI reports a "condition" based on the atmospheric profile:</p>
<RefTable
headers={['Condition', 'What It Means']}
rows={[
['Normal', 'Standard propagation. Nothing unusual.'],
['Super-refraction', 'Slightly enhanced range. You might hear a few more distant stations than usual.'],
['Surface Duct', "Radio signals trapped near the ground. You may hear stations hundreds of km away that you've never heard before."],
['Elevated Duct', 'Same effect but the "pipe" is up in the atmosphere. Affects signals passing through that altitude.'],
]}
/>
<SectionHeader>What You'll Actually Notice</SectionHeader>
<p>When ducting happens on your mesh:</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>Distant repeaters you've never heard suddenly come in</li>
<li>Nodes appear from far outside your normal range</li>
<li>You hear FM radio stations from other cities</li>
<li>ADS-B flight tracking range gets much longer</li>
<li>There might be interference from distant stations on your frequency</li>
</ul>
<SectionHeader>The dM/dz Number</SectionHeader>
<p>The dashboard shows a "dM/dz" value in "M-units/km." You don't need to understand the math just know:</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><strong>Around 118</strong> = normal atmosphere</li>
<li><strong>Below 79</strong> = enhanced propagation starting</li>
<li><strong>Below 0 (negative)</strong> = ducting is happening</li>
<li><strong>Below -50</strong> = strong ducting classic VHF/UHF DX event</li>
</ul>
<SectionHeader>When Does Ducting Happen?</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>Under high-pressure weather systems (clear, stable air)</li>
<li>When warm air sits on top of cool air (temperature inversion)</li>
<li>Most common in late summer and early fall</li>
<li>Strongest along coastlines and over water</li>
<li>In mountain valleys: cold air pooling in fall/winter can create surface ducts</li>
</ul>
<SectionHeader>Setting It Up</SectionHeader>
<p>
Just configure the latitude and longitude of the center of your mesh area in Config Environmental Ducting. MeshAI checks the atmospheric conditions there every 3 hours using free weather model data. No API key needed.
</p>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://dxinfocentre.com/tropo.html">Tropo Forecast Maps (Hepburn)</ExtLink> 6-day tropo prediction</li>
<li><ExtLink href="https://dxmaps.com">DX Maps</ExtLink> real-time VHF/UHF propagation reports</li>
<li><ExtLink href="https://en.wikipedia.org/wiki/Tropospheric_propagation">Wikipedia: Tropospheric Propagation</ExtLink> background</li>
</ul>
</TopicSection>
{/* Avalanche Danger */}
<TopicSection id="avalanche" title="Avalanche Danger">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
MeshAI pulls avalanche forecasts from your regional avalanche center during winter months. The danger scale has 5 levels and it's the same across all of North America.
</p>
<SectionHeader>The Danger Scale</SectionHeader>
<RefTable
headers={['Level', 'Name', 'Color', 'What To Do']}
rows={[
['1', 'Low', <StatusDot color="green" />, 'Generally safe. Normal caution in steep terrain.'],
['2', 'Moderate', <StatusDot color="yellow" />, 'Be careful on specific terrain features. Evaluate conditions.'],
['3', 'Considerable', <StatusDot color="orange" />, <><strong>DANGEROUS.</strong> This is where most people die in avalanches they see "3 out of 5" and think it's fine. It's not. Use extreme caution.</>],
['4', 'High', <StatusDot color="red" />, <><strong>Very dangerous.</strong> Stay off anything steep.</>],
['5', 'Extreme', <StatusDot color="black" />, <><strong>Don't go out.</strong> Avalanches are happening on their own.</>],
]}
/>
<SectionHeader>The Most Important Thing to Know</SectionHeader>
<p>
<strong>Level 3 (Considerable) kills more people than any other level.</strong> People look at "3 out of 5" and think "middle of the road, probably okay." In reality, the risk roughly doubles at each step up the scale. Level 3 is where dangerous conditions overlap with people thinking they can handle it.
</p>
<SectionHeader>Seasonal</SectionHeader>
<p>
MeshAI only checks avalanche conditions during winter months (configurable, default December through April). Outside season, it shows "off season" and saves API calls.
</p>
<SectionHeader>Finding Your Avalanche Center</SectionHeader>
<p>
Go to <ExtLink href="https://avalanche.org/avalanche-centers/">avalanche.org/avalanche-centers/</ExtLink> for a map. Common center codes:
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><Mono>SNFAC</Mono> Sawtooth (central Idaho)</li>
<li><Mono>UAC</Mono> Utah</li>
<li><Mono>NWAC</Mono> Cascades/Olympics (WA/OR)</li>
<li><Mono>CAIC</Mono> Colorado</li>
<li><Mono>SAC</Mono> Sierra Nevada (CA)</li>
<li><Mono>GNFAC</Mono> Gallatin (SW Montana)</li>
</ul>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://avalanche.org">Avalanche.org</ExtLink> US forecasts</li>
<li><ExtLink href="https://avalanche.org/avalanche-encyclopedia/human/resources/north-american-public-avalanche-danger-scale/">Avalanche Danger Scale</ExtLink> full scale explanation</li>
<li><ExtLink href="https://kbyg.org">Know Before You Go</ExtLink> avalanche awareness</li>
</ul>
</TopicSection>
{/* Traffic Flow */}
<TopicSection id="traffic" title="Traffic Flow">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
MeshAI monitors traffic speed on road segments you configure, using data from TomTom (real vehicles with navigation apps reporting their speed).
</p>
<SectionHeader>Speed Ratio The Key Number</SectionHeader>
<p>MeshAI compares current speed to "free-flow speed" (what traffic normally does when the road is empty). The ratio tells you how congested it is:</p>
<RefTable
headers={['Ratio', 'What It Means']}
rows={[
[<><StatusDot color="green" /> Above 85%</>, 'Normal. Traffic flowing fine.'],
[<><StatusDot color="yellow" /> 65-85%</>, 'Slow. Heavier than usual but moving.'],
[<><StatusDot color="orange" /> 40-65%</>, 'Congested. Significant delays.'],
[<><StatusDot color="red" /> Below 40%</>, 'Gridlock. Barely moving.'],
]}
/>
<p>
<strong>Note</strong>: "free-flow speed" is NOT the speed limit. It's what traffic actually does on that road when nobody's in the way. Drivers often exceed speed limits on open highways.
</p>
<SectionHeader>Confidence Can You Trust the Data?</SectionHeader>
<p>TomTom's confidence score tells you how much of the reading comes from real vehicles right now vs historical averages:</p>
<RefTable
headers={['Confidence', 'What It Means']}
rows={[
['Above 0.9', 'Very reliable — lots of real-time probe data'],
['0.7-0.9', 'Good — mix of real-time and historical'],
['Below 0.7', <><strong>Unreliable</strong> mostly guessing from historical patterns. Don't alert on this.</>],
]}
/>
<p>Set minimum confidence to 0.7 to avoid false congestion alerts at night or on rural roads where few probe vehicles drive.</p>
<SectionHeader>Setting Up Corridors</SectionHeader>
<p>Each "corridor" is a point on a road you want to monitor. To add one:</p>
<ol className="list-decimal list-inside ml-4 space-y-1">
<li>Go to Google Maps, find the road</li>
<li>Right-click the road "What's here?" copy the coordinates</li>
<li>Add the corridor in Config with a name and those coordinates</li>
<li>TomTom finds the nearest road segment automatically</li>
</ol>
<SectionHeader>Getting an API Key</SectionHeader>
<ol className="list-decimal list-inside ml-4 space-y-1">
<li>Sign up at <ExtLink href="https://developer.tomtom.com">developer.tomtom.com</ExtLink> (free)</li>
<li>Create an app get your API key</li>
<li>Free tier: 2,500 requests/day (plenty for 5-10 corridors)</li>
</ol>
<SectionHeader>Learn More</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><ExtLink href="https://developer.tomtom.com">TomTom Developer Portal</ExtLink> API docs and key signup</li>
<li><ExtLink href="https://www.tomtom.com/traffic-index/">TomTom Traffic Index</ExtLink> city congestion rankings</li>
</ul>
</TopicSection>
{/* 511 Road Conditions */}
<TopicSection id="roads-511" title="Road Conditions (511)">
<SectionHeader>What You're Looking At</SectionHeader>
<p>
511 systems report road closures, construction, weather events, mountain pass conditions, and incidents. Every state runs their own 511 system there is no national API.
</p>
<SectionHeader>Setting It Up</SectionHeader>
<p>
You need to find YOUR state's 511 developer API. MeshAI does not include a default URL because every state is different. Some states have free public APIs, some require registration, and some don't have developer APIs at all.
</p>
<p>Configure in Config Environmental 511:</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><strong>Base URL</strong> your state's API endpoint</li>
<li><strong>API Key</strong> if required by your state</li>
<li><strong>Endpoints</strong> which data feeds to poll (varies by state)</li>
</ul>
<SectionHeader>Learn More</SectionHeader>
<p>Check your state's 511 or DOT website for developer information.</p>
</TopicSection>
{/* Mesh Health */}
<TopicSection id="mesh-health" title="Mesh Health">
<SectionHeader>Health Score</SectionHeader>
<p>MeshAI computes a 0-100 health score for your mesh network by looking at five areas:</p>
<RefTable
headers={['Area', 'Weight', 'What It Checks']}
rows={[
['Infrastructure', '30%', 'Are your routers and repeaters online and healthy?'],
['Utilization', '25%', 'Is the radio channel getting congested?'],
['Coverage', '20%', 'Do nodes have backup paths, or single points of failure?'],
['Behavior', '15%', 'Are nodes behaving normally (packet patterns, responsiveness)?'],
['Power', '10%', 'Battery levels, solar charging, power stability'],
]}
/>
<SectionHeader>Health Tiers</SectionHeader>
<RefTable
headers={['Score', 'Tier', 'What It Means']}
rows={[
['90-100', <><StatusDot color="green" /> Healthy</>, "Everything's working well."],
['75-89', <><StatusDot color="yellow" /> Slight degradation</>, 'Some issues but the mesh is functional.'],
['50-74', <><StatusDot color="orange" /> Unhealthy</>, 'Multiple problems. Reliability is affected.'],
['25-49', <><StatusDot color="red" /> Warning</>, 'Significant issues. The mesh is struggling.'],
['0-24', <><StatusDot color="black" /> Critical</>, 'Major failures. Barely functional.'],
]}
/>
<SectionHeader>Channel Utilization Is the Radio Channel Full?</SectionHeader>
<p>
Meshtastic radios share one LoRa channel. If too many nodes are transmitting too often, they step on each other and messages get lost.
</p>
<RefTable
headers={['Utilization', "What's Happening"]}
rows={[
[<><StatusDot color="green" /> Under 25%</>, 'Healthy. The firmware itself starts throttling above 25% to protect the channel — so under 25% is the target.'],
[<><StatusDot color="yellow" /> 25-40%</>, 'Getting busy. Common on larger meshes. Worth watching.'],
[<><StatusDot color="orange" /> 40-50%</>, 'Congested. The firmware throttles GPS updates above 40%. Messages are colliding and retrying.'],
[<><StatusDot color="red" /> Over 50%</>, 'Serious problem. More time is spent retrying than communicating. Mesh reliability drops fast.'],
[<><StatusDot color="black" /> Over 65%</>, 'Documented failure point on busy LONG_FAST meshes. The mesh becomes unusable.'],
]}
/>
<SectionHeader>Packet Flooding</SectionHeader>
<p className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-200">
<strong> "Packet flooding" means a node sending too many RADIO PACKETS. This has nothing to do with water flooding.</strong>
</p>
<p>
A normal Meshtastic node sends a packet every few minutes (announcing itself, reporting telemetry, updating position). If a node starts blasting packets every few seconds, something is wrong firmware bug, stuck transmitter, or misconfiguration.
</p>
<RefTable
headers={['Packets per Minute', 'What It Means']}
rows={[
['1-5', 'Normal'],
['5-10', 'Elevated — might be someone chatting a lot'],
['10-20', 'Suspicious — worth investigating'],
['Over 30', 'Something is broken. This node is actively hurting the mesh.'],
]}
/>
<SectionHeader>Battery Levels</SectionHeader>
<p>
Most Meshtastic radios (T-Beam, RAK4631, Heltec V3) use a single lithium battery cell. The voltage tells you how much charge is left:
</p>
<RefTable
headers={['Voltage', 'Charge', 'What To Do']}
rows={[
['4.20V', '100%', 'Full'],
['3.80V', '~60%', 'Fine'],
[<strong>3.60V</strong>, <strong>~30%</strong>, <><strong> Warning charge it soon</strong></>],
[<strong>3.50V</strong>, <strong>~15%</strong>, <><strong>🔴 Low charge it now</strong></>],
[<strong>3.40V</strong>, <strong>~7%</strong>, <><strong> About to die</strong></>],
['3.30V', '~3%', 'Device shutting down'],
]}
/>
<p>
<strong>USB-powered nodes</strong> report 100% battery even if there's no battery installed. Battery alerts only matter for nodes actually running on battery power.
</p>
<SectionHeader>Node Offline Detection</SectionHeader>
<p>
MeshAI marks a node as "offline" when it hasn't been heard for a configurable time period. Different node types need different thresholds:
</p>
<RefTable
headers={['Node Type', 'Recommended Threshold', 'Why']}
rows={[
['Fixed infrastructure (wall power)', <strong>2 hours</strong>, 'These should always be transmitting. 2 hours of silence means something is wrong.'],
['Fixed client (wall power)', '2-4 hours', 'Same logic, slightly more lenient.'],
['Mobile / vehicle', '4-8 hours', 'They go behind mountains, into garages, out of range. Normal.'],
['Solar-powered', '12-24 hours', 'May shut down at night when solar stops charging.'],
]}
/>
<p>
<strong>Rule of thumb</strong>: set the threshold to about 4× the node's beacon interval. Too tight and nodes will constantly flap "offline/online" from normal gaps. Too loose and real outages go unnoticed.
</p>
</TopicSection>
{/* Notifications */}
<TopicSection id="notifications" title="Notifications">
<SectionHeader>How It Works</SectionHeader>
<ol className="list-decimal list-inside ml-4 space-y-1">
<li><strong>Something happens</strong> a fire is detected, weather warning issued, node goes offline, etc.</li>
<li><strong>MeshAI checks your rules</strong> does this event match any of your notification rules? Is it severe enough? Are we in quiet hours?</li>
<li><strong>If a rule matches</strong> MeshAI sends the notification through whatever delivery method that rule is configured for.</li>
</ol>
<SectionHeader>Building Rules</SectionHeader>
<p>Each rule answers three questions:</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><strong>WHEN</strong> does it trigger? (which categories, what severity)</li>
<li><strong>WHERE</strong> does it send? (mesh broadcast, email, webhook, etc.)</li>
<li><strong>HOW OFTEN</strong> at most? (cooldown period)</li>
</ul>
<p>
Use "Add from Template" to start with a pre-built rule and customize it, or build from scratch with "Add Rule."
</p>
<SectionHeader>Severity Levels What Should I Set?</SectionHeader>
<RefTable
headers={['Level', 'When It\'s Used', 'Notification Volume']}
rows={[
['Info', 'Routine stuff (ducting detected, new router appeared)', 'High — lots of messages'],
['Advisory', 'Worth knowing (weather advisory, slow traffic, battery declining)', 'Moderate'],
['Watch', 'Pay attention (fire within 50km, weather watch, stream rising)', 'Low-moderate'],
[<><strong>Warning</strong> </>, 'Take action (fire within 15km, severe weather, critical battery)', 'Low — recommended for most rules'],
['Emergency', 'Life safety (extreme weather, fire at infrastructure, total blackout)', 'Very rare'],
]}
/>
<p>
<strong>"Warning" is the sweet spot for most rules.</strong> You get alerted when something actually needs your attention without being overwhelmed by every minor event.
</p>
<SectionHeader>Quiet Hours</SectionHeader>
<p>
When enabled, non-emergency notifications are held during sleeping hours (default 10pm-6am). Emergency alerts and rules marked "Override Quiet Hours" always get through.
</p>
<p>You can turn quiet hours off entirely if you don't want them.</p>
<SectionHeader>Webhook The Swiss Army Knife</SectionHeader>
<p>
A webhook sends your alert as an HTTP POST to any URL. This one delivery method works with:
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><strong>Discord</strong> use a Discord webhook URL</li>
<li><strong>Slack</strong> use a Slack incoming webhook URL</li>
<li><strong>ntfy.sh</strong> POST to <Mono>https://ntfy.sh/your-topic</Mono></li>
<li><strong>Pushover</strong> POST to the Pushover API</li>
<li><strong>Home Assistant</strong> POST to an automation webhook URL</li>
<li>Anything else that accepts HTTP POST</li>
</ul>
<p>
MeshAI doesn't need to know what's on the other end. Give it the URL and it works.
</p>
</TopicSection>
{/* Commands */}
<TopicSection id="commands" title="Commands">
<p>
All commands use the <Mono>!</Mono> prefix (configurable). Send these as a direct message to MeshAI on your mesh.
</p>
<SectionHeader>Basic Commands</SectionHeader>
<RefTable
headers={['Command', 'What It Does']}
rows={[
[<Mono>!help</Mono>, 'Shows all available commands'],
[<Mono>!ping</Mono>, 'Tests if the bot is alive'],
[<Mono>!status</Mono>, 'Quick mesh summary (nodes online, health score)'],
[<Mono>!health</Mono>, 'Detailed health report with pillar scores'],
[<Mono>!weather</Mono>, 'Current weather for your area'],
]}
/>
<SectionHeader>Environmental Commands</SectionHeader>
<RefTable
headers={['Command', 'What It Does']}
rows={[
[<Mono>!alerts</Mono>, 'Active NWS weather alerts for your area'],
[<><Mono>!solar</Mono> (or <Mono>!hf</Mono>)</>, 'Current solar indices and RF conditions'],
[<Mono>!fire</Mono>, 'Active wildfires near your mesh'],
[<Mono>!avy</Mono>, 'Avalanche advisory (seasonal — shows "off season" in summer)'],
[<><Mono>!streams</Mono> (or <Mono>!gauges</Mono>)</>, 'Stream gauge readings'],
[<><Mono>!roads</Mono> (or <Mono>!traffic</Mono>)</>, 'Road conditions and traffic flow'],
[<Mono>!hotspots</Mono>, 'Satellite fire detections'],
]}
/>
<SectionHeader>Subscription Commands</SectionHeader>
<RefTable
headers={['Command', 'What It Does']}
rows={[
[<Mono>!subscribe</Mono>, 'Lists all alert categories you can subscribe to'],
[<Mono>!subscribe fire_proximity</Mono>, 'Subscribe to a specific category'],
[<Mono>!subscribe all</Mono>, 'Subscribe to everything'],
[<Mono>!unsubscribe fire_proximity</Mono>, 'Unsubscribe from a category'],
[<Mono>!subscriptions</Mono>, "Shows what you're currently subscribed to"],
]}
/>
<SectionHeader>Conversational</SectionHeader>
<p>
MeshAI isn't just commands — you can ask it questions in plain English. "How's the mesh doing?" "Is there any ducting?" "What's the fire situation?" "How's traffic on I-84?" It uses the live environmental data and mesh health data to answer.
</p>
</TopicSection>
{/* API Reference */}
<TopicSection id="api" title="API Reference">
<p>
MeshAI's REST API is available at <Mono>http://your-host:8080</Mono>. All endpoints return JSON.
</p>
<SectionHeader>System</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><Mono>GET /api/status</Mono> version, uptime, node count</li>
<li><Mono>GET /api/channels</Mono> radio channel list</li>
<li><Mono>POST /api/restart</Mono> restart the bot</li>
</ul>
<SectionHeader>Mesh Data</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><Mono>GET /api/health</Mono> health score and pillars</li>
<li><Mono>GET /api/nodes</Mono> all nodes with positions and telemetry</li>
<li><Mono>GET /api/edges</Mono> neighbor links with signal quality</li>
<li><Mono>GET /api/regions</Mono> region summaries</li>
<li><Mono>GET /api/sources</Mono> data source health</li>
</ul>
<SectionHeader>Configuration</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><Mono>GET /api/config</Mono> full config</li>
<li><Mono>GET /api/config/{'{section}'}</Mono> one section</li>
<li><Mono>PUT /api/config/{'{section}'}</Mono> update a section</li>
</ul>
<SectionHeader>Environmental</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><Mono>GET /api/env/status</Mono> per-feed health</li>
<li><Mono>GET /api/env/active</Mono> all active events</li>
<li><Mono>GET /api/env/swpc</Mono> solar/geomagnetic data</li>
<li><Mono>GET /api/env/ducting</Mono> atmospheric profile</li>
<li><Mono>GET /api/env/fires</Mono> wildfire perimeters</li>
<li><Mono>GET /api/env/hotspots</Mono> satellite fire detections</li>
</ul>
<SectionHeader>Alerts</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><Mono>GET /api/alerts/active</Mono> current alerts</li>
<li><Mono>GET /api/alerts/history</Mono> past alerts</li>
<li><Mono>GET /api/notifications/categories</Mono> available alert categories</li>
</ul>
<SectionHeader>Real-time</SectionHeader>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><Mono>ws://your-host:8080/ws/live</Mono> — WebSocket for live updates</li>
</ul>
</TopicSection>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,74 @@
"""Road conditions command."""
from .base import CommandContext, CommandHandler
class RoadsCommand(CommandHandler):
"""Show traffic flow and road conditions."""
aliases = ["traffic", "highways"]
def __init__(self, env_store):
self._env_store = env_store
self._name = "roads"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def description(self) -> str:
return "Show traffic flow and road conditions"
@property
def usage(self) -> str:
return "!roads"
async def execute(self, args: str, context: CommandContext) -> str:
if not self._env_store:
return "Environmental feeds not configured."
traffic_events = self._env_store.get_active(source="traffic")
road_events = self._env_store.get_active(source="511")
if not traffic_events and not road_events:
return "No traffic or road data available. Check if sources are configured."
lines = []
# Traffic flow from TomTom
if traffic_events:
lines.append("Traffic Flow:")
for event in traffic_events:
props = event.get("properties", {})
corridor = props.get("corridor", "Unknown")
current = props.get("currentSpeed", 0)
free_flow = props.get("freeFlowSpeed", 0)
ratio = props.get("speedRatio", 1.0)
closure = props.get("roadClosure", False)
if closure:
lines.append(f" {corridor}: CLOSED")
else:
pct = int(ratio * 100)
lines.append(f" {corridor}: {int(current)}mph ({pct}% of {int(free_flow)}mph)")
# 511 road events
if road_events:
if traffic_events:
lines.append("") # Separator
lines.append("Road Events:")
for event in road_events:
event_type = event.get("event_type", "Event")
headline = event.get("headline", "")[:80]
props = event.get("properties", {})
is_closure = props.get("is_closure", False)
icon = "X" if is_closure else "-"
lines.append(f" {icon} {headline}")
return "\n".join(lines) if lines else "No road conditions data."

View file

@ -0,0 +1,73 @@
"""Stream gauge command."""
from .base import CommandContext, CommandHandler
class StreamsCommand(CommandHandler):
"""Show current stream gauge readings."""
aliases = ["gauges", "rivers"]
def __init__(self, env_store):
self._env_store = env_store
self._name = "streams"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def description(self) -> str:
return "Show stream gauge readings"
@property
def usage(self) -> str:
return "!streams"
async def execute(self, args: str, context: CommandContext) -> str:
if not self._env_store:
return "Environmental feeds not configured."
events = self._env_store.get_active(source="usgs")
if not events:
return "No stream gauge data available. Check if USGS sites are configured."
lines = []
# Group by site
sites = {}
for event in events:
props = event.get("properties", {})
site_id = props.get("site_id", "")
site_name = props.get("site_name", "Unknown")
if site_id not in sites:
sites[site_id] = {"name": site_name, "readings": []}
param = props.get("parameter", "")
value = props.get("value", 0)
unit = props.get("unit", "")
sites[site_id]["readings"].append((param, value, unit))
for site_id, data in sites.items():
name = data["name"]
readings = data["readings"]
# Format readings
parts = []
for param, value, unit in readings:
if "flow" in param.lower() or unit == "ft3/s":
parts.append(f"{value:,.0f} {unit}")
else:
parts.append(f"{value:.1f} {unit}")
reading_str = ", ".join(parts)
lines.append(f"{name}: {reading_str}")
return "\n".join(lines) if lines else "No stream gauge readings."

View file

@ -8,6 +8,7 @@ if TYPE_CHECKING:
from ..mesh_data_store import MeshDataStore from ..mesh_data_store import MeshDataStore
from ..mesh_reporter import MeshReporter from ..mesh_reporter import MeshReporter
from ..subscriptions import SubscriptionManager from ..subscriptions import SubscriptionManager
from ..notifications.router import NotificationRouter
class SubCommand(CommandHandler): class SubCommand(CommandHandler):
@ -15,7 +16,7 @@ class SubCommand(CommandHandler):
name = "sub" name = "sub"
description = "Subscribe to reports or alerts" description = "Subscribe to reports or alerts"
usage = "!sub daily|weekly|alerts [time] [day] [scope]" usage = "!sub daily|weekly|alerts|<category> [time] [day] [scope]"
aliases = ["subscribe"] aliases = ["subscribe"]
def __init__( def __init__(
@ -23,23 +24,35 @@ class SubCommand(CommandHandler):
subscription_manager: "SubscriptionManager" = None, subscription_manager: "SubscriptionManager" = None,
mesh_reporter: "MeshReporter" = None, mesh_reporter: "MeshReporter" = None,
data_store: "MeshDataStore" = None, data_store: "MeshDataStore" = None,
notification_router: "NotificationRouter" = None,
): ):
self._sub_manager = subscription_manager self._sub_manager = subscription_manager
self._reporter = mesh_reporter self._reporter = mesh_reporter
self._data_store = data_store self._data_store = data_store
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str: async def execute(self, args: str, context: CommandContext) -> str:
"""Handle subscription command.""" """Handle subscription command."""
if not self._sub_manager:
return "Subscriptions not available."
parts = args.strip().split() parts = args.strip().split()
# No args - show available alert categories
if not parts: if not parts:
return self._usage_help() return self._show_categories()
sub_type = parts[0].lower() sub_type = parts[0].lower()
# Check if it's a category subscription
if self._notification_router:
from ..notifications.categories import ALERT_CATEGORIES
if sub_type in ALERT_CATEGORIES or sub_type == "all":
return self._handle_category_subscription(sub_type, context)
# Legacy subscription types
if sub_type not in ("daily", "weekly", "alerts"): if sub_type not in ("daily", "weekly", "alerts"):
return f"Invalid type '{sub_type}'. Use: daily, weekly, or alerts" return self._show_categories()
if not self._sub_manager:
return "Subscriptions not available."
try: try:
if sub_type == "daily": if sub_type == "daily":
@ -51,15 +64,55 @@ class SubCommand(CommandHandler):
except ValueError as e: except ValueError as e:
return f"Error: {e}" return f"Error: {e}"
def _show_categories(self) -> str:
"""Show available alert categories."""
try:
from ..notifications.categories import ALERT_CATEGORIES
except ImportError:
return self._usage_help()
lines = ["Available alert categories:"]
for cat_id, cat_info in ALERT_CATEGORIES.items():
lines.append(f" {cat_id} - {cat_info['description']}")
lines.append("")
lines.append("Usage:")
lines.append(" !sub <category> - subscribe to a category")
lines.append(" !sub all - subscribe to all alerts")
lines.append(" !sub alerts - legacy mesh-wide alerts")
return "\n".join(lines)
def _handle_category_subscription(self, category: str, context: CommandContext) -> str:
"""Handle category-based alert subscription."""
node_id = self._get_user_id(context)
if category == "all":
categories = [] # Empty = all categories
else:
categories = [category]
# Add subscription via notification router
rule_name = self._notification_router.add_mesh_subscription(
node_id=node_id,
categories=categories,
)
if category == "all":
return "Subscribed to all alert categories. Use !unsub to remove."
else:
from ..notifications.categories import get_category
cat_info = get_category(category)
return f"Subscribed to {cat_info['name']} alerts. Use !unsub {category} to remove."
def _usage_help(self) -> str: def _usage_help(self) -> str:
"""Return usage help.""" """Return usage help."""
return """Usage: return """Usage:
!sub daily 1830 - daily mesh report at 6:30 PM !sub daily 1830 - daily mesh report at 6:30 PM
!sub daily 1830 region SCID - daily region report !sub daily 1830 region SCID - daily region report
!sub daily 1830 node MHR - daily node report
!sub weekly 0800 sun - weekly digest Sunday 8 AM !sub weekly 0800 sun - weekly digest Sunday 8 AM
!sub alerts - mesh-wide alerts !sub alerts - mesh-wide alerts (legacy)
!sub alerts region SCID - alerts for a region""" !sub <category> - subscribe to alert category
!sub all - subscribe to all alerts"""
def _handle_daily(self, args: list, context: CommandContext) -> str: def _handle_daily(self, args: list, context: CommandContext) -> str:
"""Handle daily subscription.""" """Handle daily subscription."""
@ -68,11 +121,9 @@ class SubCommand(CommandHandler):
schedule_time = args[0] schedule_time = args[0]
scope_type, scope_value = self._parse_scope(args[1:]) scope_type, scope_value = self._parse_scope(args[1:])
# Validate scope
scope_value = self._validate_scope(scope_type, scope_value) scope_value = self._validate_scope(scope_type, scope_value)
result = self._sub_manager.add( self._sub_manager.add(
user_id=self._get_user_id(context), user_id=self._get_user_id(context),
sub_type="daily", sub_type="daily",
schedule_time=schedule_time, schedule_time=schedule_time,
@ -92,11 +143,9 @@ class SubCommand(CommandHandler):
schedule_time = args[0] schedule_time = args[0]
schedule_day = args[1].lower() schedule_day = args[1].lower()
scope_type, scope_value = self._parse_scope(args[2:]) scope_type, scope_value = self._parse_scope(args[2:])
# Validate scope
scope_value = self._validate_scope(scope_type, scope_value) scope_value = self._validate_scope(scope_type, scope_value)
result = self._sub_manager.add( self._sub_manager.add(
user_id=self._get_user_id(context), user_id=self._get_user_id(context),
sub_type="weekly", sub_type="weekly",
schedule_time=schedule_time, schedule_time=schedule_time,
@ -111,13 +160,11 @@ class SubCommand(CommandHandler):
return f"Subscribed: weekly {scope_desc}report at {time_fmt} {day_fmt}" return f"Subscribed: weekly {scope_desc}report at {time_fmt} {day_fmt}"
def _handle_alerts(self, args: list, context: CommandContext) -> str: def _handle_alerts(self, args: list, context: CommandContext) -> str:
"""Handle alerts subscription.""" """Handle alerts subscription (legacy)."""
scope_type, scope_value = self._parse_scope(args) scope_type, scope_value = self._parse_scope(args)
# Validate scope
scope_value = self._validate_scope(scope_type, scope_value) scope_value = self._validate_scope(scope_type, scope_value)
result = self._sub_manager.add( self._sub_manager.add(
user_id=self._get_user_id(context), user_id=self._get_user_id(context),
sub_type="alerts", sub_type="alerts",
scope_type=scope_type, scope_type=scope_type,
@ -128,15 +175,10 @@ class SubCommand(CommandHandler):
return f"Subscribed: alerts for {scope_desc.strip() or 'mesh'}" return f"Subscribed: alerts for {scope_desc.strip() or 'mesh'}"
def _parse_scope(self, args: list) -> tuple[str, str]: def _parse_scope(self, args: list) -> tuple[str, str]:
"""Parse scope from remaining args. """Parse scope from remaining args."""
Returns:
(scope_type, scope_value) tuple
"""
if not args: if not args:
return "mesh", None return "mesh", None
# Look for 'region' or 'node' keyword
scope_type = "mesh" scope_type = "mesh"
scope_value = None scope_value = None
@ -144,26 +186,17 @@ class SubCommand(CommandHandler):
arg_lower = arg.lower() arg_lower = arg.lower()
if arg_lower == "region": if arg_lower == "region":
scope_type = "region" scope_type = "region"
# Everything after 'region' is the region name
scope_value = " ".join(args[i + 1:]) if i + 1 < len(args) else None scope_value = " ".join(args[i + 1:]) if i + 1 < len(args) else None
break break
elif arg_lower == "node": elif arg_lower == "node":
scope_type = "node" scope_type = "node"
# Next arg is the node identifier
scope_value = args[i + 1] if i + 1 < len(args) else None scope_value = args[i + 1] if i + 1 < len(args) else None
break break
return scope_type, scope_value return scope_type, scope_value
def _validate_scope(self, scope_type: str, scope_value: str) -> str: def _validate_scope(self, scope_type: str, scope_value: str) -> str:
"""Validate and resolve scope value. """Validate and resolve scope value."""
Returns:
Resolved scope_value (e.g., full region name)
Raises:
ValueError: If scope not found
"""
if scope_type == "mesh": if scope_type == "mesh":
return None return None
@ -172,14 +205,9 @@ class SubCommand(CommandHandler):
if scope_type == "region" and self._reporter: if scope_type == "region" and self._reporter:
region = self._reporter._find_region(scope_value) region = self._reporter._find_region(scope_value)
if not region: if region:
# List available regions return region.name
health = self._reporter.health_engine.mesh_health return scope_value
if health:
available = [r.name for r in health.regions if r.node_ids]
return scope_value # Use as-is, will fail at delivery if invalid
raise ValueError(f"Region '{scope_value}' not found")
return region.name # Return canonical name
if scope_type == "node" and self._reporter: if scope_type == "node" and self._reporter:
node = self._reporter._find_node(scope_value) node = self._reporter._find_node(scope_value)
@ -191,7 +219,6 @@ class SubCommand(CommandHandler):
def _get_user_id(self, context: CommandContext) -> str: def _get_user_id(self, context: CommandContext) -> str:
"""Extract user ID from context.""" """Extract user ID from context."""
# sender_id is like "!abcd1234" - convert to node_num
sender_id = context.sender_id sender_id = context.sender_id
if sender_id.startswith("!"): if sender_id.startswith("!"):
return str(int(sender_id[1:], 16)) return str(int(sender_id[1:], 16))
@ -217,26 +244,40 @@ class UnsubCommand(CommandHandler):
name = "unsub" name = "unsub"
description = "Remove subscription(s)" description = "Remove subscription(s)"
usage = "!unsub daily|weekly|alerts|all" usage = "!unsub daily|weekly|alerts|<category>|all"
aliases = ["unsubscribe"] aliases = ["unsubscribe"]
def __init__(self, subscription_manager: "SubscriptionManager" = None): def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager self._sub_manager = subscription_manager
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str: async def execute(self, args: str, context: CommandContext) -> str:
"""Handle unsubscribe command.""" """Handle unsubscribe command."""
if not self._sub_manager:
return "Subscriptions not available."
sub_type = args.strip().lower() if args else None sub_type = args.strip().lower() if args else None
if not sub_type: if not sub_type:
return "Usage: !unsub daily|weekly|alerts|all" return "Usage: !unsub daily|weekly|alerts|<category>|all"
if sub_type not in ("daily", "weekly", "alerts", "all"):
return f"Invalid type '{sub_type}'. Use: daily, weekly, alerts, or all"
user_id = self._get_user_id(context) user_id = self._get_user_id(context)
# Check if it's a category unsubscription
if self._notification_router:
from ..notifications.categories import ALERT_CATEGORIES
if sub_type in ALERT_CATEGORIES or sub_type == "all":
self._notification_router.remove_mesh_subscription(user_id)
return "Removed alert subscriptions"
# Legacy subscription types
if not self._sub_manager:
return "Subscriptions not available."
if sub_type not in ("daily", "weekly", "alerts", "all"):
return f"Invalid type '{sub_type}'. Use: daily, weekly, alerts, <category>, or all"
removed = self._sub_manager.remove(user_id, sub_type if sub_type != "all" else None) removed = self._sub_manager.remove(user_id, sub_type if sub_type != "all" else None)
if removed == 0: if removed == 0:
@ -260,26 +301,44 @@ class MySubsCommand(CommandHandler):
name = "mysubs" name = "mysubs"
description = "List your subscriptions" description = "List your subscriptions"
usage = "!mysubs" usage = "!mysubs"
aliases = ["subs"] aliases = ["subs", "subscriptions"]
def __init__(self, subscription_manager: "SubscriptionManager" = None): def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager self._sub_manager = subscription_manager
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str: async def execute(self, args: str, context: CommandContext) -> str:
"""List user's subscriptions.""" """List user's subscriptions."""
if not self._sub_manager:
return "Subscriptions not available."
user_id = self._get_user_id(context) user_id = self._get_user_id(context)
subs = self._sub_manager.get_user_subs(user_id) lines = []
if not subs: # Check notification router subscriptions
if self._notification_router:
categories = self._notification_router.get_node_subscriptions(user_id)
if categories:
if categories == ["all"]:
lines.append("Alert subscriptions: all categories")
else:
lines.append(f"Alert subscriptions: {', '.join(categories)}")
# Check legacy subscriptions
if self._sub_manager:
subs = self._sub_manager.get_user_subs(user_id)
if subs:
if not lines:
lines.append("Your subscriptions:")
else:
lines.append("\nScheduled reports:")
for i, sub in enumerate(subs, 1):
lines.append(f" {i}. {self._format_sub(sub)}")
if not lines:
return "No active subscriptions. Use !sub to subscribe." return "No active subscriptions. Use !sub to subscribe."
lines = ["Your subscriptions:"]
for i, sub in enumerate(subs, 1):
lines.append(f" {i}. {self._format_sub(sub)}")
return "\n".join(lines) return "\n".join(lines)
def _format_sub(self, sub: dict) -> str: def _format_sub(self, sub: dict) -> str:
@ -301,7 +360,7 @@ class MySubsCommand(CommandHandler):
time_str = self._format_time(sub.get("schedule_time", "0000")) time_str = self._format_time(sub.get("schedule_time", "0000"))
day_str = (sub.get("schedule_day") or "").capitalize() day_str = (sub.get("schedule_day") or "").capitalize()
return f"Weekly {scope_desc}report at {time_str} {day_str}" return f"Weekly {scope_desc}report at {time_str} {day_str}"
else: # alerts else:
return f"Alerts for {scope_desc.strip() or 'mesh'}" return f"Alerts for {scope_desc.strip() or 'mesh'}"
def _format_time(self, hhmm: str) -> str: def _format_time(self, hhmm: str) -> str:

View file

@ -0,0 +1,113 @@
"""Notification API routes."""
from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel
from typing import Optional
router = APIRouter(prefix="/notifications", tags=["notifications"])
class ChannelCreate(BaseModel):
"""Channel creation request."""
id: str
type: str
enabled: bool = True
channel_index: int = 0
node_ids: list[str] = []
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_tls: bool = True
from_address: str = ""
recipients: list[str] = []
url: str = ""
headers: dict = {}
class RuleCreate(BaseModel):
"""Rule creation request."""
name: str
categories: list[str] = []
min_severity: str = "warning"
channel_ids: list[str] = []
override_quiet: bool = False
class QuietHoursUpdate(BaseModel):
"""Quiet hours update request."""
start: str
end: str
@router.get("/categories")
async def get_categories():
"""Get all alert categories with descriptions."""
try:
from ...notifications.categories import list_categories
return list_categories()
except ImportError:
return []
@router.get("/channels")
async def get_channels(request: Request):
"""Get configured notification channels."""
notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router:
return []
return notification_router.get_channels()
@router.post("/channels")
async def create_channel(request: Request, channel: ChannelCreate):
"""Create a new notification channel."""
# This would require runtime config modification
# For now, return not implemented
raise HTTPException(status_code=501, detail="Channel creation requires config file edit")
@router.post("/channels/{channel_id}/test")
async def test_channel(request: Request, channel_id: str):
"""Send a test alert to a channel."""
notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured")
success, message = await notification_router.test_channel(channel_id)
return {"success": success, "message": message}
@router.get("/rules")
async def get_rules(request: Request):
"""Get configured notification rules."""
notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router:
return []
return notification_router.get_rules()
@router.post("/rules")
async def create_rule(request: Request, rule: RuleCreate):
"""Create a new notification rule."""
# This would require runtime config modification
raise HTTPException(status_code=501, detail="Rule creation requires config file edit")
@router.get("/quiet-hours")
async def get_quiet_hours(request: Request):
"""Get quiet hours configuration."""
config = getattr(request.app.state, "config", None)
if not config or not hasattr(config, "notifications"):
return {"start": "22:00", "end": "06:00"}
return {
"start": config.notifications.quiet_hours_start,
"end": config.notifications.quiet_hours_end,
}
@router.put("/quiet-hours")
async def update_quiet_hours(request: Request, quiet_hours: QuietHoursUpdate):
"""Update quiet hours configuration."""
# This would require runtime config modification
raise HTTPException(status_code=501, detail="Quiet hours update requires config file edit")

View file

@ -0,0 +1,6 @@
"""Notification system for MeshAI alerts."""
from .categories import ALERT_CATEGORIES, get_category, list_categories
from .router import NotificationRouter
__all__ = ["ALERT_CATEGORIES", "get_category", "list_categories", "NotificationRouter"]

View file

@ -0,0 +1,157 @@
"""Alert category registry.
Defines all alertable conditions with human-readable names and descriptions.
"""
ALERT_CATEGORIES = {
# Infrastructure alerts
"infra_offline": {
"name": "Infrastructure Offline",
"description": "An infrastructure node stopped responding",
"default_severity": "warning",
},
"critical_node_down": {
"name": "Critical Node Down",
"description": "A node marked as critical went offline",
"default_severity": "critical",
},
"infra_recovery": {
"name": "Infrastructure Recovery",
"description": "An infrastructure node came back online",
"default_severity": "info",
},
"new_router": {
"name": "New Router",
"description": "A new router appeared on the mesh",
"default_severity": "info",
},
# Power alerts
"battery_warning": {
"name": "Battery Warning",
"description": "Infrastructure node battery below warning threshold",
"default_severity": "warning",
},
"battery_critical": {
"name": "Battery Critical",
"description": "Infrastructure node battery below critical threshold",
"default_severity": "critical",
},
"battery_emergency": {
"name": "Battery Emergency",
"description": "Infrastructure node battery critically low",
"default_severity": "emergency",
},
"battery_trend": {
"name": "Battery Declining",
"description": "Battery showing declining trend over 7 days",
"default_severity": "warning",
},
"power_source_change": {
"name": "Power Source Change",
"description": "Node switched from USB to battery (possible outage)",
"default_severity": "warning",
},
"solar_not_charging": {
"name": "Solar Not Charging",
"description": "Solar panel not charging during daylight hours",
"default_severity": "warning",
},
# Utilization alerts
"sustained_high_util": {
"name": "High Utilization",
"description": "Channel utilization elevated for extended period",
"default_severity": "warning",
},
"packet_flood": {
"name": "Packet Flood",
"description": "Node sending excessive packets",
"default_severity": "warning",
},
# Coverage alerts
"infra_single_gateway": {
"name": "Single Gateway",
"description": "Infrastructure node dropped to single gateway coverage",
"default_severity": "warning",
},
"feeder_offline": {
"name": "Feeder Offline",
"description": "A feeder gateway stopped responding",
"default_severity": "warning",
},
"region_total_blackout": {
"name": "Region Blackout",
"description": "All infrastructure in a region is offline",
"default_severity": "emergency",
},
# Health score alerts
"mesh_score_low": {
"name": "Mesh Health Low",
"description": "Overall mesh health score below threshold",
"default_severity": "warning",
},
"region_score_low": {
"name": "Region Health Low",
"description": "A region's health score below threshold",
"default_severity": "warning",
},
# Environmental alerts
"weather_warning": {
"name": "Severe Weather",
"description": "NWS warning or advisory for mesh area",
"default_severity": "warning",
},
"hf_blackout": {
"name": "HF Radio Blackout",
"description": "R3+ solar event degrading HF propagation",
"default_severity": "warning",
},
"tropospheric_ducting": {
"name": "Tropospheric Ducting",
"description": "Atmospheric conditions extending VHF/UHF range",
"default_severity": "info",
},
"wildfire_proximity": {
"name": "Fire Near Mesh",
"description": "Wildfire detected within configured distance",
"default_severity": "warning",
},
"new_ignition": {
"name": "New Fire Ignition",
"description": "Satellite hotspot not matching any known fire",
"default_severity": "warning",
},
"flood_warning": {
"name": "Flood Warning",
"description": "Stream gauge exceeds flood threshold",
"default_severity": "warning",
},
"road_closure": {
"name": "Road Closure",
"description": "Full road closure on monitored corridor",
"default_severity": "warning",
},
}
def get_category(category_id: str) -> dict:
"""Get category info by ID, with fallback for unknown categories."""
if category_id in ALERT_CATEGORIES:
return ALERT_CATEGORIES[category_id]
return {
"name": category_id.replace("_", " ").title(),
"description": f"Alert type: {category_id}",
"default_severity": "info",
}
def list_categories() -> list[dict]:
"""List all categories with their IDs."""
return [
{"id": cat_id, **cat_info}
for cat_id, cat_info in ALERT_CATEGORIES.items()
]

View file

@ -0,0 +1,308 @@
"""Notification channel implementations."""
import asyncio
import logging
import smtplib
import ssl
import time
from abc import ABC, abstractmethod
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional, TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from ..connector import MeshConnector
logger = logging.getLogger(__name__)
class NotificationChannel(ABC):
"""Base class for notification delivery channels."""
channel_type: str = "base"
@abstractmethod
async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert. Returns True on success."""
raise NotImplementedError
@abstractmethod
async def test(self) -> tuple[bool, str]:
"""Send test message. Returns (success, message)."""
raise NotImplementedError
class MeshBroadcastChannel(NotificationChannel):
"""Post alert to mesh channel."""
channel_type = "mesh_broadcast"
def __init__(self, connector: "MeshConnector", channel_index: int = 0):
self._connector = connector
self._channel = channel_index
async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert to mesh channel."""
if not self._connector:
logger.warning("No mesh connector available")
return False
try:
message = alert.get("message", "")
self._connector.send_message(
text=message,
destination=None,
channel=self._channel,
)
logger.info("Broadcast alert to channel %d", self._channel)
return True
except Exception as e:
logger.error("Failed to broadcast alert: %s", e)
return False
async def test(self) -> tuple[bool, str]:
"""Send test broadcast."""
try:
self._connector.send_message(
text="[TEST] MeshAI notification system test",
destination=None,
channel=self._channel,
)
return True, "Test message sent to channel %d" % self._channel
except Exception as e:
return False, "Failed to send test: %s" % e
class MeshDMChannel(NotificationChannel):
"""DM alert to specific node IDs."""
channel_type = "mesh_dm"
def __init__(self, connector: "MeshConnector", node_ids: list[str]):
self._connector = connector
self._node_ids = node_ids
async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert via DM to configured nodes."""
if not self._connector:
return False
message = alert.get("message", "")
success = True
for node_id in self._node_ids:
try:
dest = int(node_id) if node_id.isdigit() else node_id
self._connector.send_message(text=message, destination=dest, channel=0)
except Exception as e:
logger.error("Failed to DM %s: %s", node_id, e)
success = False
return success
async def test(self) -> tuple[bool, str]:
"""Send test DM to all configured nodes."""
if not self._node_ids:
return False, "No node IDs configured"
try:
for node_id in self._node_ids:
dest = int(node_id) if node_id.isdigit() else node_id
self._connector.send_message(
text="[TEST] MeshAI notification test",
destination=dest,
channel=0,
)
return True, "Test DMs sent to %d nodes" % len(self._node_ids)
except Exception as e:
return False, "Failed to send test DMs: %s" % e
class EmailChannel(NotificationChannel):
"""Send alert via SMTP email."""
channel_type = "email"
def __init__(
self,
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_password: str,
smtp_tls: bool,
from_address: str,
recipients: list[str],
):
self._host = smtp_host
self._port = smtp_port
self._user = smtp_user
self._password = smtp_password
self._tls = smtp_tls
self._from = from_address
self._recipients = recipients
async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert via email."""
if not self._recipients:
return False
alert_type = alert.get("type", "alert")
severity = alert.get("severity", "info").upper()
message = alert.get("message", "")
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
alert_type, severity, time.strftime("%Y-%m-%d %H:%M:%S"), message
)
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._send_email, subject, body)
return True
except Exception as e:
logger.error("Failed to send email: %s", e)
return False
def _send_email(self, subject: str, body: str):
msg = MIMEMultipart()
msg["From"] = self._from
msg["To"] = ", ".join(self._recipients)
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
if self._tls:
context = ssl.create_default_context()
with smtplib.SMTP(self._host, self._port) as server:
server.starttls(context=context)
if self._user and self._password:
server.login(self._user, self._password)
server.sendmail(self._from, self._recipients, msg.as_string())
else:
with smtplib.SMTP(self._host, self._port) as server:
if self._user and self._password:
server.login(self._user, self._password)
server.sendmail(self._from, self._recipients, msg.as_string())
async def test(self) -> tuple[bool, str]:
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
self._send_email,
"[MeshAI TEST] Notification Test",
"Test message from MeshAI.",
)
return True, "Test email sent to %d recipients" % len(self._recipients)
except Exception as e:
return False, "Failed to send test email: %s" % e
class WebhookChannel(NotificationChannel):
"""POST alert JSON to a URL."""
channel_type = "webhook"
def __init__(self, url: str, headers: Optional[dict] = None):
self._url = url
self._headers = headers or {}
async def deliver(self, alert: dict, rule: dict) -> bool:
"""POST alert to webhook URL."""
payload = {
"type": alert.get("type"),
"severity": alert.get("severity", "info"),
"message": alert.get("message", ""),
"timestamp": time.time(),
"node_name": alert.get("node_name"),
"region": alert.get("region"),
}
# Discord/Slack format
if "discord.com" in self._url or "slack.com" in self._url:
severity = alert.get("severity", "info")
color = {
"emergency": 0xFF0000,
"critical": 0xFF4444,
"warning": 0xFFAA00,
"info": 0x0099FF,
}.get(severity, 0x888888)
payload = {
"embeds": [{
"title": "MeshAI: %s" % alert.get("type", "unknown"),
"description": alert.get("message", ""),
"color": color,
}]
}
# ntfy format
elif "ntfy" in self._url:
headers = {
**self._headers,
"Title": "MeshAI: %s" % alert.get("type", "alert"),
"Priority": "3",
}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
self._url,
content=alert.get("message", ""),
headers=headers,
timeout=10,
)
return resp.status_code < 400
except Exception as e:
logger.error("Webhook failed: %s", e)
return False
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
self._url,
json=payload,
headers={"Content-Type": "application/json", **self._headers},
timeout=10,
)
return resp.status_code < 400
except Exception as e:
logger.error("Webhook failed: %s", e)
return False
async def test(self) -> tuple[bool, str]:
test_alert = {"type": "test", "severity": "info", "message": "MeshAI test message"}
success = await self.deliver(test_alert, {})
if success:
return True, "Test sent to %s" % self._url
return False, "Webhook failed"
def create_channel(config: dict, connector=None) -> NotificationChannel:
"""Create a channel instance from config."""
channel_type = config.get("type", "")
if channel_type == "mesh_broadcast":
return MeshBroadcastChannel(
connector=connector,
channel_index=config.get("channel_index", 0),
)
elif channel_type == "mesh_dm":
return MeshDMChannel(
connector=connector,
node_ids=config.get("node_ids", []),
)
elif channel_type == "email":
return EmailChannel(
smtp_host=config.get("smtp_host", ""),
smtp_port=config.get("smtp_port", 587),
smtp_user=config.get("smtp_user", ""),
smtp_password=config.get("smtp_password", ""),
smtp_tls=config.get("smtp_tls", True),
from_address=config.get("from_address", ""),
recipients=config.get("recipients", []),
)
elif channel_type == "webhook":
return WebhookChannel(
url=config.get("url", ""),
headers=config.get("headers", {}),
)
else:
raise ValueError("Unknown channel type: %s" % channel_type)

View file

@ -0,0 +1,266 @@
"""Notification router - matches alerts to rules and delivers via channels."""
import logging
import time
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from .channels import create_channel, NotificationChannel
from .summarizer import MessageSummarizer
if TYPE_CHECKING:
from ..connector import MeshConnector
logger = logging.getLogger(__name__)
# Severity levels in order
SEVERITY_ORDER = ["info", "advisory", "watch", "warning", "critical", "emergency"]
class NotificationRouter:
"""Routes alerts through matching rules to notification channels."""
def __init__(
self,
config,
connector: Optional["MeshConnector"] = None,
llm_backend=None,
timezone: str = "America/Boise",
):
self._channels: dict[str, NotificationChannel] = {}
self._rules: list[dict] = []
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
self._timezone = timezone
self._dedup_window = getattr(config, "dedup_seconds", 600)
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
self._connector = connector
# Create channel instances from config
channels_config = getattr(config, "channels", [])
for ch_config in channels_config:
if hasattr(ch_config, "__dict__"):
ch_dict = {k: v for k, v in ch_config.__dict__.items() if not k.startswith("_")}
else:
ch_dict = ch_config
if not ch_dict.get("enabled", True):
continue
channel_id = ch_dict.get("id", "")
if not channel_id:
continue
try:
channel = create_channel(ch_dict, connector)
self._channels[channel_id] = channel
logger.debug("Created notification channel: %s (%s)", channel_id, ch_dict.get("type"))
except Exception as e:
logger.warning("Failed to create channel %s: %s", channel_id, e)
# Load rules
rules_config = getattr(config, "rules", [])
for rule in rules_config:
if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else:
rule_dict = rule
self._rules.append(rule_dict)
logger.info(
"Notification router initialized: %d channels, %d rules",
len(self._channels),
len(self._rules),
)
async def process_alert(self, alert: dict) -> bool:
"""Route an alert through matching rules to channels.
Returns True if alert was delivered to at least one channel.
"""
category = alert.get("type", "")
severity = alert.get("severity", "info")
delivered = False
for rule in self._rules:
# Check category match
rule_categories = rule.get("categories", [])
if rule_categories and category not in rule_categories:
continue
# Check severity threshold
min_severity = rule.get("min_severity", "info")
if not self._severity_meets(severity, min_severity):
continue
# Check quiet hours (emergencies and criticals override)
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
if not rule.get("override_quiet", False):
continue
# Check dedup
event_id = alert.get("event_id", alert.get("message", "")[:50])
dedup_key = (category, event_id)
now = time.time()
if dedup_key in self._recent:
if now - self._recent[dedup_key] < self._dedup_window:
logger.debug("Skipping duplicate alert: %s", category)
continue
self._recent[dedup_key] = now
# Deliver to each channel in the rule
channel_ids = rule.get("channel_ids", [])
for channel_id in channel_ids:
channel = self._channels.get(channel_id)
if not channel:
continue
try:
# Summarize for mesh channels if over 200 chars
delivery_alert = alert
message = alert.get("message", "")
if channel.channel_type in ("mesh_broadcast", "mesh_dm"):
if len(message) > 200:
if self._summarizer:
summary = await self._summarizer.summarize(message, max_chars=195)
delivery_alert = {**alert, "message": summary}
else:
delivery_alert = {**alert, "message": message[:195] + "..."}
success = await channel.deliver(delivery_alert, rule)
if success:
delivered = True
logger.info(
"Alert delivered via %s: %s",
channel_id,
category,
)
except Exception as e:
logger.warning("Channel %s delivery failed: %s", channel_id, e)
return delivered
def _severity_meets(self, actual: str, required: str) -> bool:
"""Check if actual severity meets or exceeds required severity."""
try:
actual_idx = SEVERITY_ORDER.index(actual.lower())
required_idx = SEVERITY_ORDER.index(required.lower())
return actual_idx >= required_idx
except ValueError:
return True # Unknown severity, allow through
def _in_quiet_hours(self) -> bool:
"""Check if current time is within quiet hours."""
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(self._timezone)
now = datetime.now(tz)
current_time = now.strftime("%H:%M")
start = self._quiet_start
end = self._quiet_end
if start <= end:
# Simple range (e.g., 01:00 to 06:00)
return start <= current_time <= end
else:
# Crosses midnight (e.g., 22:00 to 06:00)
return current_time >= start or current_time <= end
except Exception:
return False
def get_channels(self) -> list[dict]:
"""Get list of configured channels."""
return [
{"id": ch_id, "type": ch.channel_type}
for ch_id, ch in self._channels.items()
]
def get_rules(self) -> list[dict]:
"""Get list of configured rules."""
return self._rules
async def test_channel(self, channel_id: str) -> tuple[bool, str]:
"""Send a test alert to a specific channel."""
channel = self._channels.get(channel_id)
if not channel:
return False, "Channel not found: %s" % channel_id
return await channel.test()
def add_mesh_subscription(
self,
node_id: str,
categories: list[str],
rule_name: Optional[str] = None,
) -> str:
"""Add a mesh DM subscription for a node.
Creates a channel and rule for the node to receive alerts.
Returns the rule name.
"""
# Create channel ID
channel_id = "mesh_dm_%s" % node_id
# Create channel if it doesn't exist
if channel_id not in self._channels:
from .channels import MeshDMChannel
channel = MeshDMChannel(
connector=self._connector,
node_ids=[node_id],
)
self._channels[channel_id] = channel
# Create rule
if not rule_name:
rule_name = "sub_%s" % node_id
# Check if rule already exists
for rule in self._rules:
if rule.get("name") == rule_name:
# Update existing rule
rule["categories"] = categories if categories else []
rule["channel_ids"] = [channel_id]
return rule_name
# Add new rule
self._rules.append({
"name": rule_name,
"categories": categories if categories else [], # Empty = all
"min_severity": "warning",
"channel_ids": [channel_id],
"override_quiet": False,
})
return rule_name
def remove_mesh_subscription(self, node_id: str) -> bool:
"""Remove a mesh subscription for a node."""
channel_id = "mesh_dm_%s" % node_id
rule_name = "sub_%s" % node_id
# Remove channel
if channel_id in self._channels:
del self._channels[channel_id]
# Remove rule
self._rules = [r for r in self._rules if r.get("name") != rule_name]
return True
def get_node_subscriptions(self, node_id: str) -> list[str]:
"""Get categories a node is subscribed to."""
rule_name = "sub_%s" % node_id
for rule in self._rules:
if rule.get("name") == rule_name:
categories = rule.get("categories", [])
return categories if categories else ["all"]
return []
def cleanup_recent(self, max_age: int = 3600):
"""Clean up old entries from recent alerts cache."""
now = time.time()
self._recent = {
k: v for k, v in self._recent.items()
if now - v < max_age
}

View file

@ -0,0 +1,64 @@
"""Message summarizer for mesh delivery."""
import logging
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ..backends import LLMBackend
logger = logging.getLogger(__name__)
class MessageSummarizer:
"""Summarizes long messages for mesh delivery.
Only used when:
- Delivering to mesh channels (broadcast or DM)
- Message exceeds max_chars (default 200)
- LLM backend is available
Email and webhook channels receive full messages.
"""
def __init__(self, llm_backend: Optional["LLMBackend"] = None):
self._llm = llm_backend
async def summarize(self, message: str, max_chars: int = 195) -> str:
"""Summarize a message to fit within max_chars.
Args:
message: Original message text
max_chars: Maximum characters for summary
Returns:
Summarized message, or truncated original if LLM unavailable
"""
if len(message) <= max_chars:
return message
if not self._llm:
return message[:max_chars - 3] + "..."
prompt = (
"Summarize this alert in under %d characters. "
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
% (max_chars, message)
)
try:
# Use the LLM to generate a summary
response = await self._llm.generate(
prompt,
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
max_tokens=100,
)
summary = response.strip()
# Ensure it fits
if len(summary) <= max_chars:
return summary
return summary[:max_chars - 3] + "..."
except Exception as e:
logger.debug("LLM summarization failed: %s", e)
return message[:max_chars - 3] + "..."

View file

@ -0,0 +1,435 @@
"""MQTT source adapter for Meshtastic broker subscriptions.
Push-based source that subscribes to MQTT topics and decodes
ServiceEnvelope-wrapped MeshPackets. Provides live node/packet
data without polling.
"""
import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
# Port number to name mapping (from portnums_pb2)
PORTNUM_NAMES = {
0: "UNKNOWN_APP",
1: "TEXT_MESSAGE_APP",
2: "REMOTE_HARDWARE_APP",
3: "POSITION_APP",
4: "NODEINFO_APP",
5: "ROUTING_APP",
6: "ADMIN_APP",
7: "TEXT_MESSAGE_COMPRESSED_APP",
8: "WAYPOINT_APP",
9: "AUDIO_APP",
10: "DETECTION_SENSOR_APP",
11: "ALERT_APP",
32: "REPLY_APP",
33: "IP_TUNNEL_APP",
34: "PAXCOUNTER_APP",
64: "SERIAL_APP",
65: "STORE_FORWARD_APP",
66: "RANGE_TEST_APP",
67: "TELEMETRY_APP",
68: "ZPS_APP",
69: "SIMULATOR_APP",
70: "TRACEROUTE_APP",
71: "NEIGHBORINFO_APP",
72: "ATAK_PLUGIN",
73: "MAP_REPORT_APP",
74: "POWERSTRESS_APP",
256: "PRIVATE_APP",
257: "ATAK_FORWARDER",
}
@dataclass
class MQTTNodeInfo:
"""Cached node info from MQTT."""
node_num: int
node_id_hex: str = ""
short_name: str = ""
long_name: str = ""
hw_model: str = ""
role: int = 0
latitude: Optional[float] = None
longitude: Optional[float] = None
altitude: Optional[float] = None
last_heard: float = 0.0
battery_percent: Optional[float] = None
voltage: Optional[float] = None
channel_utilization: Optional[float] = None
air_util_tx: Optional[float] = None
snr: Optional[float] = None
rssi: Optional[int] = None
via_mqtt: bool = True
@dataclass
class MQTTPacketInfo:
"""Packet received from MQTT."""
packet_id: int
from_node: int
to_node: int
portnum: int
portnum_name: str
channel: int
timestamp: float
snr: Optional[float] = None
rssi: Optional[int] = None
hop_limit: Optional[int] = None
hop_start: Optional[int] = None
payload_size: int = 0
gateway_id: str = ""
class MQTTSource:
"""MQTT source adapter subscribing to Meshtastic broker topics.
Maintains a subscription loop that processes ServiceEnvelope messages
and updates node/packet caches. Unlike poll-based sources, this is
push-based and receives data as it arrives.
"""
def __init__(
self,
host: str,
port: int = 1883,
username: str = "",
password: str = "",
topic_root: str = "msh/US",
use_tls: bool = False,
name: str = "mqtt",
):
"""Initialize MQTT source.
Args:
host: MQTT broker hostname
port: MQTT broker port (1883 for plain, 8883 for TLS)
username: MQTT username (optional)
password: MQTT password (optional, supports ${ENV_VAR})
topic_root: Topic root to subscribe to (default: msh/US)
use_tls: Enable TLS for connection
name: Source name for logging/attribution
"""
self._host = host
self._port = port
self._username = username
self._password = self._resolve_env(password)
self._topic_root = topic_root.rstrip("/")
self._use_tls = use_tls
self._name = name
# State
self._nodes: dict[int, MQTTNodeInfo] = {}
self._packets: list[MQTTPacketInfo] = []
self._max_packets = 1000 # Ring buffer
self._is_connected: bool = False
self._is_loaded: bool = False
self._last_message: float = 0.0
self._last_error: str = ""
self._message_count: int = 0
self._data_changed: bool = False
# Subscription task
self._task: Optional[asyncio.Task] = None
self._stop_event: Optional[asyncio.Event] = None
# Retry settings
self._retry_delay = 5 # Initial retry delay
self._max_retry_delay = 300 # Max 5 minutes between retries
def _resolve_env(self, value: str) -> str:
"""Resolve ${ENV_VAR} references in value."""
if value and value.startswith("${") and value.endswith("}"):
env_var = value[2:-1]
return os.environ.get(env_var, "")
return value
@property
def nodes(self) -> dict[int, MQTTNodeInfo]:
"""Return cached nodes."""
return self._nodes
@property
def packets(self) -> list[dict]:
"""Return packets as dicts for compatibility."""
return [
{
"packet_id": p.packet_id,
"from_node": p.from_node,
"to_node": p.to_node,
"portnum": p.portnum,
"portnum_name": p.portnum_name,
"channel": p.channel,
"timestamp": p.timestamp,
"snr": p.snr,
"rssi": p.rssi,
"hop_limit": p.hop_limit,
"hop_start": p.hop_start,
"payload_size": p.payload_size,
"gateway_id": p.gateway_id,
}
for p in self._packets
]
@property
def is_loaded(self) -> bool:
"""Return True if we have received any data."""
return self._is_loaded
@property
def data_changed(self) -> bool:
"""Return True if data changed since last check, then reset."""
changed = self._data_changed
self._data_changed = False
return changed
@property
def health_status(self) -> dict:
"""Return health status for dashboard."""
return {
"name": self._name,
"type": "mqtt",
"host": self._host,
"port": self._port,
"topic_root": self._topic_root,
"is_connected": self._is_connected,
"is_loaded": self._is_loaded,
"last_message": self._last_message,
"last_error": self._last_error,
"message_count": self._message_count,
"node_count": len(self._nodes),
"packet_count": len(self._packets),
}
async def start(self) -> None:
"""Start the subscription loop."""
if self._task is not None:
logger.warning(f"MQTT source '{self._name}' already started")
return
self._stop_event = asyncio.Event()
self._task = asyncio.create_task(self._subscription_loop())
logger.info(f"Started MQTT source '{self._name}' -> {self._host}:{self._port}")
async def stop(self) -> None:
"""Stop the subscription loop."""
if self._stop_event:
self._stop_event.set()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._is_connected = False
logger.info(f"Stopped MQTT source '{self._name}'")
async def _subscription_loop(self) -> None:
"""Main subscription loop with reconnection logic."""
try:
import aiomqtt
except ImportError:
logger.error("aiomqtt not installed. Run: pip install aiomqtt")
self._last_error = "aiomqtt not installed"
return
retry_delay = self._retry_delay
while not self._stop_event.is_set():
try:
# Build connection kwargs
kwargs = {
"hostname": self._host,
"port": self._port,
}
if self._username:
kwargs["username"] = self._username
if self._password:
kwargs["password"] = self._password
# TLS setup
if self._use_tls:
import ssl
tls_context = ssl.create_default_context()
kwargs["tls_context"] = tls_context
async with aiomqtt.Client(**kwargs) as client:
self._is_connected = True
self._last_error = ""
retry_delay = self._retry_delay # Reset on successful connect
logger.info(f"MQTT '{self._name}' connected to {self._host}:{self._port}")
# Subscribe to all topics under root
# Meshtastic uses: msh/{region}/{channel}/json/{node_id}
# and: msh/{region}/{channel}/!{node_id}
topic = f"{self._topic_root}/#"
await client.subscribe(topic)
logger.info(f"MQTT '{self._name}' subscribed to {topic}")
async for message in client.messages:
if self._stop_event.is_set():
break
await self._process_message(message)
except asyncio.CancelledError:
break
except Exception as e:
self._is_connected = False
self._last_error = str(e)
logger.warning(f"MQTT '{self._name}' error: {e}. Retrying in {retry_delay}s")
# Exponential backoff
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, self._max_retry_delay)
async def _process_message(self, message) -> None:
"""Process an incoming MQTT message."""
try:
topic = str(message.topic)
payload = message.payload
# Skip JSON topics (we want binary ServiceEnvelope)
if "/json/" in topic:
return
# Skip map reports (stat/ or map/ topics)
if "/stat/" in topic or "/map/" in topic:
return
# Parse ServiceEnvelope
from meshtastic.protobuf import mqtt_pb2
envelope = mqtt_pb2.ServiceEnvelope()
envelope.ParseFromString(payload)
if not envelope.packet:
return
packet = envelope.packet
gateway_id = envelope.gateway_id or ""
channel_id = envelope.channel_id or ""
# Update stats
self._last_message = time.time()
self._message_count += 1
self._is_loaded = True
self._data_changed = True
# Extract packet info
pkt_info = MQTTPacketInfo(
packet_id=packet.id,
from_node=packet.from_,
to_node=packet.to,
portnum=packet.decoded.portnum if packet.HasField("decoded") else 0,
portnum_name=PORTNUM_NAMES.get(
packet.decoded.portnum if packet.HasField("decoded") else 0,
"UNKNOWN"
),
channel=packet.channel,
timestamp=time.time(),
snr=packet.rx_snr if packet.rx_snr else None,
rssi=packet.rx_rssi if packet.rx_rssi else None,
hop_limit=packet.hop_limit if packet.hop_limit else None,
hop_start=packet.hop_start if packet.hop_start else None,
payload_size=len(packet.decoded.payload) if packet.HasField("decoded") else 0,
gateway_id=gateway_id,
)
# Add to packet ring buffer
self._packets.append(pkt_info)
if len(self._packets) > self._max_packets:
self._packets = self._packets[-self._max_packets:]
# Process decoded payload by portnum
if packet.HasField("decoded"):
await self._process_decoded(packet, gateway_id)
except Exception as e:
logger.debug(f"MQTT message parse error: {e}")
async def _process_decoded(self, packet, gateway_id: str) -> None:
"""Process decoded packet payload."""
decoded = packet.decoded
portnum = decoded.portnum
from_node = packet.from_
# Ensure node exists in cache
if from_node not in self._nodes:
self._nodes[from_node] = MQTTNodeInfo(
node_num=from_node,
node_id_hex=f"!{from_node:08x}",
)
node = self._nodes[from_node]
node.last_heard = time.time()
node.snr = packet.rx_snr if packet.rx_snr else node.snr
node.rssi = packet.rx_rssi if packet.rx_rssi else node.rssi
# NODEINFO_APP (4)
if portnum == 4:
from meshtastic.protobuf import mesh_pb2
user = mesh_pb2.User()
try:
user.ParseFromString(decoded.payload)
node.short_name = user.short_name or node.short_name
node.long_name = user.long_name or node.long_name
node.hw_model = mesh_pb2.HardwareModel.Name(user.hw_model) if user.hw_model else ""
node.role = user.role
except Exception:
pass
# POSITION_APP (3)
elif portnum == 3:
from meshtastic.protobuf import mesh_pb2
pos = mesh_pb2.Position()
try:
pos.ParseFromString(decoded.payload)
if pos.latitude_i:
node.latitude = pos.latitude_i * 1e-7
if pos.longitude_i:
node.longitude = pos.longitude_i * 1e-7
if pos.altitude:
node.altitude = pos.altitude
except Exception:
pass
# TELEMETRY_APP (67)
elif portnum == 67:
from meshtastic.protobuf import telemetry_pb2
telem = telemetry_pb2.Telemetry()
try:
telem.ParseFromString(decoded.payload)
if telem.HasField("device_metrics"):
dm = telem.device_metrics
if dm.battery_level and dm.battery_level <= 100:
node.battery_percent = dm.battery_level
if dm.voltage:
node.voltage = dm.voltage
if dm.channel_utilization:
node.channel_utilization = dm.channel_utilization
if dm.air_util_tx:
node.air_util_tx = dm.air_util_tx
except Exception:
pass
# Compatibility methods for MeshDataStore integration
def tick(self) -> Optional[str]:
"""Tick method for compatibility. MQTT is push-based, not polled.
Returns None since we do not poll endpoints.
"""
return None
def maybe_refresh(self) -> bool:
"""Check if data changed (for legacy compatibility)."""
return self.data_changed