mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04: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
|
||||
}
|
||||
|
||||
// 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" />
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
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.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>
|
||||
|
|
|
|||
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;
|
||||
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
14
meshai/env/store.py
vendored
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue