mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
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:
parent
fe6589e0e5
commit
ae884b9651
6 changed files with 265 additions and 157 deletions
|
|
@ -67,6 +67,11 @@ interface NwsConfig {
|
||||||
duplicate_allowed_after_seconds: number
|
duplicate_allowed_after_seconds: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avalanche adapter config shape
|
||||||
|
interface AvalancheConfig {
|
||||||
|
min_danger_level: number
|
||||||
|
}
|
||||||
|
|
||||||
interface TomtomConfig {
|
interface TomtomConfig {
|
||||||
min_magnitude: number
|
min_magnitude: number
|
||||||
drop_non_present: boolean
|
drop_non_present: boolean
|
||||||
|
|
@ -279,6 +284,10 @@ export default function Environment() {
|
||||||
duplicate_allowed_after_seconds: 3600,
|
duplicate_allowed_after_seconds: 3600,
|
||||||
})
|
})
|
||||||
const [nwsOriginal, setNwsOriginal] = useState<string>("")
|
const [nwsOriginal, setNwsOriginal] = useState<string>("")
|
||||||
|
const [avalancheConfig, setAvalancheConfig] = useState<AvalancheConfig>({
|
||||||
|
min_danger_level: 3,
|
||||||
|
})
|
||||||
|
const [avalancheOriginal, setAvalancheOriginal] = useState<string>("")
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -381,6 +390,19 @@ export default function Environment() {
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} 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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load config')
|
setError(e instanceof Error ? e.message : 'Failed to load config')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -408,7 +430,8 @@ export default function Environment() {
|
||||||
const hasRoads511Changes = JSON.stringify(roads511Config) !== roads511Original
|
const hasRoads511Changes = JSON.stringify(roads511Config) !== roads511Original
|
||||||
const hasWzdxChanges = JSON.stringify(wzdxConfig) !== wzdxOriginal
|
const hasWzdxChanges = JSON.stringify(wzdxConfig) !== wzdxOriginal
|
||||||
const hasNwsChanges = JSON.stringify(nwsConfig) !== nwsOriginal
|
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) => {
|
const saveAdapterConfig = async (adapterName: string, key: string, value: unknown) => {
|
||||||
|
|
@ -533,6 +556,15 @@ const save = async () => {
|
||||||
setNwsOriginal(JSON.stringify(nwsConfig))
|
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')
|
setSuccess('Config saved')
|
||||||
setTimeout(() => setSuccess(null), 3000)
|
setTimeout(() => setSuccess(null), 3000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -550,6 +582,7 @@ const save = async () => {
|
||||||
setRoads511Config(JSON.parse(roads511Original || JSON.stringify(roads511Config)))
|
setRoads511Config(JSON.parse(roads511Original || JSON.stringify(roads511Config)))
|
||||||
setWzdxConfig(JSON.parse(wzdxOriginal || JSON.stringify(wzdxConfig)))
|
setWzdxConfig(JSON.parse(wzdxOriginal || JSON.stringify(wzdxConfig)))
|
||||||
setNwsConfig(JSON.parse(nwsOriginal || JSON.stringify(nwsConfig)))
|
setNwsConfig(JSON.parse(nwsOriginal || JSON.stringify(nwsConfig)))
|
||||||
|
setAvalancheConfig(JSON.parse(avalancheOriginal || JSON.stringify(avalancheConfig)))
|
||||||
}
|
}
|
||||||
const restart = async () => {
|
const restart = async () => {
|
||||||
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
|
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
|
||||||
|
|
@ -656,11 +689,38 @@ const save = async () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'avalanche': return (<>
|
case 'avalanche': return (
|
||||||
<NumberInput label="Tick Seconds" value={env.avalanche.tick_seconds} onChange={(v) => up({ avalanche: { ...env.avalanche, tick_seconds: v } })} min={60} />
|
<div className="space-y-6">
|
||||||
<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/" />
|
{env.avalanche.feed_source !== 'central' && (
|
||||||
<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 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 (<>
|
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." />
|
<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" />
|
<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" />
|
||||||
|
|
|
||||||
|
|
@ -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).",
|
"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).",
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dp9XCfH-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
47
meshai/env/avalanche.py
vendored
47
meshai/env/avalanche.py
vendored
|
|
@ -237,6 +237,14 @@ class AvalancheAdapter:
|
||||||
NOT emitted (returns None). High/Extreme (4-5) -> avalanche_warning;
|
NOT emitted (returns None). High/Extreme (4-5) -> avalanche_warning;
|
||||||
Considerable (3) -> avalanche_watch.
|
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:
|
Args:
|
||||||
evt: Internal event dict from get_events()
|
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.
|
is missing its centroid or event_id, or the danger is not elevated.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from meshai.adapter_config import adapter_config
|
||||||
|
|
||||||
lat = evt.get("lat")
|
lat = evt.get("lat")
|
||||||
lon = evt.get("lon")
|
lon = evt.get("lon")
|
||||||
if lat is None or lon is None:
|
if lat is None or lon is None:
|
||||||
|
|
@ -258,6 +268,11 @@ class AvalancheAdapter:
|
||||||
if danger_level is None:
|
if danger_level is None:
|
||||||
return 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,
|
# Category from danger level: High/Extreme (4-5) is a warning,
|
||||||
# Considerable (3) is a watch, anything below is not actionable.
|
# Considerable (3) is a watch, anything below is not actionable.
|
||||||
if danger_level >= 4:
|
if danger_level >= 4:
|
||||||
|
|
@ -265,20 +280,30 @@ class AvalancheAdapter:
|
||||||
elif danger_level == 3:
|
elif danger_level == 3:
|
||||||
category = "avalanche_watch"
|
category = "avalanche_watch"
|
||||||
else:
|
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")
|
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.
|
# New/Update prefix from store's danger-level-rise detection
|
||||||
summary_parts = [title]
|
is_update = bool(evt.get("_is_update", False))
|
||||||
danger_name = evt.get("danger_name")
|
prefix = "Update:" if is_update else "New:"
|
||||||
if danger_name:
|
|
||||||
summary_parts.append(f"Danger: {danger_name}")
|
# Line 1: emoji + prefix + zone + danger level
|
||||||
travel = evt.get("travel_advice")
|
emoji = "\u26f7"
|
||||||
if travel:
|
level_name = evt.get("danger_name", "Unknown")
|
||||||
summary_parts.append(str(travel))
|
zone = evt.get("zone_name", "Unknown Zone")
|
||||||
summary = " | ".join(summary_parts)[:300]
|
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
|
# 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
|
# of the same zone coalesce on this group_key; using it as the sole
|
||||||
|
|
|
||||||
14
meshai/env/store.py
vendored
14
meshai/env/store.py
vendored
|
|
@ -115,6 +115,20 @@ class EnvironmentalStore:
|
||||||
self._events[key] = evt
|
self._events[key] = evt
|
||||||
if is_new and self._event_bus and hasattr(adapter, "to_event"):
|
if is_new and self._event_bus and hasattr(adapter, "to_event"):
|
||||||
self._emit_event(adapter, evt)
|
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:
|
else:
|
||||||
for evt in adapter.get_events():
|
for evt in adapter.get_events():
|
||||||
key = (evt["source"], evt["event_id"])
|
key = (evt["source"], evt["event_id"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue