feat: avalanche multi-line wire format, danger-level re-emit, GUI panel

- store.py: add avalanche-specific elif block with danger_level rise
  detection; re-emit on level increase with _is_update flag
- avalanche.py: rewrite to_event() with multi-line wire format
  (ski emoji + New:/Update: prefix, zone, danger name/level,
  travel advice, center_id), min_danger_level floor from adapter_config
- defaults.py: add (avalanche, min_danger_level) to REGISTRY (default=3)
- Environment.tsx: structured avalanche panel with broadcast settings
  section, min danger level select (3-Considerable/4-High/5-Extreme)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson (via Claude) 2026-06-09 04:00:47 +00:00
commit ae884b9651
6 changed files with 265 additions and 157 deletions

View file

@ -67,6 +67,11 @@ interface NwsConfig {
duplicate_allowed_after_seconds: number
}
// Avalanche adapter config shape
interface AvalancheConfig {
min_danger_level: number
}
interface TomtomConfig {
min_magnitude: number
drop_non_present: boolean
@ -279,6 +284,10 @@ export default function Environment() {
duplicate_allowed_after_seconds: 3600,
})
const [nwsOriginal, setNwsOriginal] = useState<string>("")
const [avalancheConfig, setAvalancheConfig] = useState<AvalancheConfig>({
min_danger_level: 3,
})
const [avalancheOriginal, setAvalancheOriginal] = useState<string>("")
useEffect(() => {
@ -381,6 +390,19 @@ export default function Environment() {
}
} catch { /* adapter-config optional */ }
// Load adapter-config for avalanche
try {
const avyRes = await fetch("/api/adapter-config/avalanche")
if (avyRes.ok) {
const avyData = await avyRes.json()
const cfg: AvalancheConfig = {
min_danger_level: avyData.min_danger_level?.value ?? 3,
}
setAvalancheConfig(cfg)
setAvalancheOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load config')
} finally {
@ -408,7 +430,8 @@ export default function Environment() {
const hasRoads511Changes = JSON.stringify(roads511Config) !== roads511Original
const hasWzdxChanges = JSON.stringify(wzdxConfig) !== wzdxOriginal
const hasNwsChanges = JSON.stringify(nwsConfig) !== nwsOriginal
const hasChanges = hasEnvChanges || hasWfigsChanges || hasFiresChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges
const hasAvalancheChanges = JSON.stringify(avalancheConfig) !== avalancheOriginal
const hasChanges = hasEnvChanges || hasWfigsChanges || hasFiresChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges || hasAvalancheChanges
const saveAdapterConfig = async (adapterName: string, key: string, value: unknown) => {
@ -533,6 +556,15 @@ const save = async () => {
setNwsOriginal(JSON.stringify(nwsConfig))
}
// Save avalanche adapter config changes
if (hasAvalancheChanges) {
const orig = JSON.parse(avalancheOriginal) as AvalancheConfig
if (avalancheConfig.min_danger_level !== orig.min_danger_level) {
await saveAdapterConfig("avalanche", "min_danger_level", avalancheConfig.min_danger_level)
}
setAvalancheOriginal(JSON.stringify(avalancheConfig))
}
setSuccess('Config saved')
setTimeout(() => setSuccess(null), 3000)
} catch (e) {
@ -550,6 +582,7 @@ const save = async () => {
setRoads511Config(JSON.parse(roads511Original || JSON.stringify(roads511Config)))
setWzdxConfig(JSON.parse(wzdxOriginal || JSON.stringify(wzdxConfig)))
setNwsConfig(JSON.parse(nwsOriginal || JSON.stringify(nwsConfig)))
setAvalancheConfig(JSON.parse(avalancheOriginal || JSON.stringify(avalancheConfig)))
}
const restart = async () => {
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
@ -656,11 +689,38 @@ const save = async () => {
</div>
</div>
)
case 'avalanche': return (<>
<NumberInput label="Tick Seconds" value={env.avalanche.tick_seconds} onChange={(v) => up({ avalanche: { ...env.avalanche, tick_seconds: v } })} min={60} />
<ListInput label="Center IDs" value={env.avalanche.center_ids} onChange={(v) => up({ avalanche: { ...env.avalanche, center_ids: v } })} helper="e.g., SNFAC" infoLink="https://avalanche.org/avalanche-centers/" />
<NumberListInput label="Season Months" value={env.avalanche.season_months} onChange={(v) => up({ avalanche: { ...env.avalanche, season_months: v } })} helper="e.g., 12, 1, 2, 3, 4" />
</>)
case 'avalanche': return (
<div className="space-y-6">
{env.avalanche.feed_source !== 'central' && (
<div className="grid grid-cols-2 gap-4">
<NumberInput label="Tick Seconds" value={env.avalanche.tick_seconds}
onChange={(v) => up({ avalanche: { ...env.avalanche, tick_seconds: v } })}
min={60} />
<NumberListInput label="Season Months" value={env.avalanche.season_months}
onChange={(v) => up({ avalanche: { ...env.avalanche, season_months: v } })}
helper="e.g., 12, 1, 2, 3, 4" />
</div>
)}
<ListInput label="Center IDs" value={env.avalanche.center_ids}
onChange={(v) => up({ avalanche: { ...env.avalanche, center_ids: v } })}
helper="e.g., SNFAC" infoLink="https://avalanche.org/avalanche-centers/" />
<div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
Broadcast Settings
</div>
<div className="grid grid-cols-2 gap-4">
<SelectInput label="Min Danger Level" value={String(avalancheConfig.min_danger_level)}
onChange={(v) => setAvalancheConfig({ ...avalancheConfig, min_danger_level: Number(v) })}
options={[
{ value: "3", label: "3 — Considerable" },
{ value: "4", label: "4 — High" },
{ value: "5", label: "5 — Extreme" },
]}
helper="Minimum avalanche danger level to broadcast" />
</div>
</div>
</div>
)
case 'usgs': return (<>
<NumberInput label="Tick Seconds" value={env.usgs.tick_seconds} onChange={(v) => up({ usgs: { ...env.usgs, tick_seconds: v } })} min={900} helper="Minimum 15 min (900s). tick_seconds is the native-mode poll interval; ignored when this adapter is set to feed_source=central." />
<ListInput label="Site IDs" value={env.usgs.sites} onChange={(v) => up({ usgs: { ...env.usgs, sites: v } })} helper="USGS gauge site numbers" infoLink="https://waterdata.usgs.gov/nwis" />

View file

@ -567,6 +567,15 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
"description": "Allow re-broadcast of the same CAP id after this many seconds (the nws_handler relaxes its dedup gate past this point and uses an Active: prefix).",
},
# =================================================================
# AVALANCHE -- 1 setting (min danger level broadcast floor)
# =================================================================
("avalanche", "min_danger_level"): {
"default": 3,
"type": "int",
"description": "Minimum danger level to broadcast (3=Considerable, 4=High, 5=Extreme).",
},
}

View file

@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CkIzxSuY.js"></script>
<script type="module" crossorigin src="/assets/index-C4C12wpB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dp9XCfH-.css">
</head>
<body>

View file

@ -237,6 +237,14 @@ class AvalancheAdapter:
NOT emitted (returns None). High/Extreme (4-5) -> avalanche_warning;
Considerable (3) -> avalanche_watch.
Multi-line wire format (matching Fire/Roads/Quake style):
Line 1: emoji prefix zone danger_name (level)
Line 2: travel_advice (truncated, only if present)
Line 3: center_id · valid today
The _is_update flag is set by EnvironmentalStore when danger_level
rises for an existing zone; New: for first sighting, Update: for rise.
Args:
evt: Internal event dict from get_events()
@ -245,6 +253,8 @@ class AvalancheAdapter:
is missing its centroid or event_id, or the danger is not elevated.
"""
try:
from meshai.adapter_config import adapter_config
lat = evt.get("lat")
lon = evt.get("lon")
if lat is None or lon is None:
@ -258,6 +268,11 @@ class AvalancheAdapter:
if danger_level is None:
return None
# Min danger level floor from adapter_config
min_level = int(adapter_config.avalanche.min_danger_level)
if danger_level < min_level:
return None
# Category from danger level: High/Extreme (4-5) is a warning,
# Considerable (3) is a watch, anything below is not actionable.
if danger_level >= 4:
@ -265,20 +280,30 @@ class AvalancheAdapter:
elif danger_level == 3:
category = "avalanche_watch"
else:
return None # Low/Moderate/No-Rating -- do not emit
return None # Below min_level but still < 3 -- do not emit
severity = evt.get("severity", "routine")
title = evt.get("headline") or evt.get("zone_name") or "Avalanche Advisory"
# Summary: headline plus the danger name and travel advice.
summary_parts = [title]
danger_name = evt.get("danger_name")
if danger_name:
summary_parts.append(f"Danger: {danger_name}")
travel = evt.get("travel_advice")
if travel:
summary_parts.append(str(travel))
summary = " | ".join(summary_parts)[:300]
# New/Update prefix from store's danger-level-rise detection
is_update = bool(evt.get("_is_update", False))
prefix = "Update:" if is_update else "New:"
# Line 1: emoji + prefix + zone + danger level
emoji = "\u26f7"
level_name = evt.get("danger_name", "Unknown")
zone = evt.get("zone_name", "Unknown Zone")
line1 = f"{emoji} {prefix} {zone} \u2014 {level_name} ({danger_level})"
# Line 2: travel advice (truncated to 120 chars, only if present)
travel = evt.get("travel_advice", "")
line2 = travel[:120] if travel else None
# Line 3: center ID + valid date
center_id = evt.get("center_id", "")
line3 = f"{center_id} \u00b7 valid today" if center_id else "valid today"
summary = "\n".join(l for l in [line1, line2, line3] if l)
title = line1 # first line only for title
# event_id is already the stable "avy_{center}_{zone}" key. Re-polls
# of the same zone coalesce on this group_key; using it as the sole

14
meshai/env/store.py vendored
View file

@ -115,6 +115,20 @@ class EnvironmentalStore:
self._events[key] = evt
if is_new and self._event_bus and hasattr(adapter, "to_event"):
self._emit_event(adapter, evt)
elif name == "avalanche":
# Avalanche: re-emit on danger_level rise (Update:) not just new events.
for evt in adapter.get_events():
key = (evt["source"], evt["event_id"])
prior = self._events.get(key)
is_new = prior is None
prior_level = prior.get("danger_level", -1) if prior else -1
level_rose = (not is_new) and (evt.get("danger_level", -1) > prior_level)
if (is_new or level_rose) and self._event_bus and hasattr(adapter, "to_event"):
evt["_is_update"] = level_rose # signal to to_event()
self._emit_event(adapter, evt)
self._events[key] = evt # always update stored state
else:
for evt in adapter.get_events():
key = (evt["source"], evt["event_id"])