feat: swpc multi-line wire + GUI wired to real adapter_config keys

swpc_handler.py: rewrite _render() to multi-line format (emoji + New:
prefix + scale/type — detail line — SWPC · time tag). Extract message
and time fields from envelope for line 2/3.

Environment.tsx: replace empty SWPC panel with broadcast threshold
controls — geomag Kp floor (G1-G5), flare class floor (M1-X10),
proton pfu floor (S1-S4). Full adapter_config save/load/discard wiring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson (via Claude) 2026-06-09 06:29:18 +00:00
commit be9bcfded4
5 changed files with 676 additions and 562 deletions

View file

@ -72,6 +72,13 @@ interface AvalancheConfig {
min_danger_level: number
}
// SWPC adapter config shape
interface SwpcConfig {
geomag_kp_floor: number
flare_class_floor: string
proton_pfu_floor: number
}
interface TomtomConfig {
min_magnitude: number
drop_non_present: boolean
@ -288,6 +295,12 @@ export default function Environment() {
min_danger_level: 3,
})
const [avalancheOriginal, setAvalancheOriginal] = useState<string>("")
const [swpcConfig, setSwpcConfig] = useState<SwpcConfig>({
geomag_kp_floor: 7.0,
flare_class_floor: "X1",
proton_pfu_floor: 10.0,
})
const [swpcOriginal, setSwpcOriginal] = useState<string>("")
useEffect(() => {
@ -403,6 +416,21 @@ export default function Environment() {
}
} catch { /* adapter-config optional */ }
// Load adapter-config for swpc
try {
const swpcRes = await fetch("/api/adapter-config/swpc")
if (swpcRes.ok) {
const swpcData = await swpcRes.json()
const cfg: SwpcConfig = {
geomag_kp_floor: swpcData.geomag_kp_floor?.value ?? 7.0,
flare_class_floor: swpcData.flare_class_floor?.value ?? "X1",
proton_pfu_floor: swpcData.proton_pfu_floor?.value ?? 10.0,
}
setSwpcConfig(cfg)
setSwpcOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load config')
} finally {
@ -431,7 +459,8 @@ export default function Environment() {
const hasWzdxChanges = JSON.stringify(wzdxConfig) !== wzdxOriginal
const hasNwsChanges = JSON.stringify(nwsConfig) !== nwsOriginal
const hasAvalancheChanges = JSON.stringify(avalancheConfig) !== avalancheOriginal
const hasChanges = hasEnvChanges || hasWfigsChanges || hasFiresChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges || hasAvalancheChanges
const hasSwpcChanges = JSON.stringify(swpcConfig) !== swpcOriginal
const hasChanges = hasEnvChanges || hasWfigsChanges || hasFiresChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges || hasAvalancheChanges || hasSwpcChanges
const saveAdapterConfig = async (adapterName: string, key: string, value: unknown) => {
@ -565,6 +594,21 @@ const save = async () => {
setAvalancheOriginal(JSON.stringify(avalancheConfig))
}
// Save swpc adapter config changes
if (hasSwpcChanges) {
const orig = JSON.parse(swpcOriginal) as SwpcConfig
if (swpcConfig.geomag_kp_floor !== orig.geomag_kp_floor) {
await saveAdapterConfig("swpc", "geomag_kp_floor", swpcConfig.geomag_kp_floor)
}
if (swpcConfig.flare_class_floor !== orig.flare_class_floor) {
await saveAdapterConfig("swpc", "flare_class_floor", swpcConfig.flare_class_floor)
}
if (swpcConfig.proton_pfu_floor !== orig.proton_pfu_floor) {
await saveAdapterConfig("swpc", "proton_pfu_floor", swpcConfig.proton_pfu_floor)
}
setSwpcOriginal(JSON.stringify(swpcConfig))
}
setSuccess('Config saved')
setTimeout(() => setSuccess(null), 3000)
} catch (e) {
@ -583,6 +627,7 @@ const save = async () => {
setWzdxConfig(JSON.parse(wzdxOriginal || JSON.stringify(wzdxConfig)))
setNwsConfig(JSON.parse(nwsOriginal || JSON.stringify(nwsConfig)))
setAvalancheConfig(JSON.parse(avalancheOriginal || JSON.stringify(avalancheConfig)))
setSwpcConfig(JSON.parse(swpcOriginal || JSON.stringify(swpcConfig)))
}
const restart = async () => {
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
@ -642,7 +687,45 @@ const save = async () => {
</div>
)}
</>)
case 'swpc': return <div className="text-xs text-slate-500">No additional settings.</div>
case 'swpc': return (
<div className="space-y-6">
<div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
Broadcast Thresholds
</div>
<div className="grid grid-cols-3 gap-4">
<SelectInput label="Geomag Kp Floor" value={String(swpcConfig.geomag_kp_floor)}
onChange={(v) => setSwpcConfig({ ...swpcConfig, geomag_kp_floor: Number(v) })}
options={[
{ value: "5", label: "5 — G1 Minor" },
{ value: "6", label: "6 — G2 Moderate" },
{ value: "7", label: "7 — G3 Strong" },
{ value: "8", label: "8 — G4 Severe" },
{ value: "9", label: "9 — G5 Extreme" },
]}
helper="Kp at or above this triggers geomag broadcast" />
<SelectInput label="Flare Class Floor" value={swpcConfig.flare_class_floor}
onChange={(v) => setSwpcConfig({ ...swpcConfig, flare_class_floor: v })}
options={[
{ value: "M1", label: "M1 — R1 Minor" },
{ value: "M5", label: "M5 — R2 Moderate" },
{ value: "X1", label: "X1 — R3 Strong" },
{ value: "X10", label: "X10 — R4 Severe" },
]}
helper="X-ray flare class floor for broadcast" />
<SelectInput label="Proton pfu Floor" value={String(swpcConfig.proton_pfu_floor)}
onChange={(v) => setSwpcConfig({ ...swpcConfig, proton_pfu_floor: Number(v) })}
options={[
{ value: "10", label: "10 — S1 Minor" },
{ value: "100", label: "100 — S2 Moderate" },
{ value: "1000", label: "1000 — S3 Strong" },
{ value: "10000", label: "10000 — S4 Severe" },
]}
helper="Proton flux (pfu) at ≥10 MeV for broadcast" />
</div>
</div>
</div>
)
case 'ducting': return (
<div className="grid grid-cols-3 gap-4">
<NumberInput label="Tick Seconds" value={env.ducting.tick_seconds} onChange={(v) => up({ ducting: { ...env.ducting, tick_seconds: v } })} min={60} />

View file

@ -14,10 +14,14 @@ Three Central sub-adapters all route here:
swpc_alerts -> parse alert payload (flare class, geomag, proton scale)
swpc_protons -> check >=10 MeV proton flux threshold
Wire format (Matt's approved option C):
🌌 Strong geomagnetic storm (G3/Kp7) -- HF degraded, aurora possible
🔆 Major solar flare (R3/X1.2) -- HF radio fading ~30 min, GPS may glitch
Solar radiation storm (S1) -- polar HF radio affected
Wire format (multi-line, matches Fire/Quake/Avalanche style):
Line 1: {emoji} New: {scale} {type} {key fact}
Line 2: supporting detail (impact summary / message, truncated 120 chars)
Line 3: SWPC · {time tag}
Geomag: 🧲 New: G3 Geomagnetic Storm Kp7
Flare: New: X1.2 Solar Flare R3
Proton: New: S1 Radiation Storm 10 pfu
"""
from __future__ import annotations
from meshai.adapter_config import adapter_config
@ -293,33 +297,60 @@ def handle_swpc(envelope: dict, subject: str,
"SELECT last_broadcast_at FROM swpc_events WHERE event_id=?",
(event_id,)).fetchone()
# Extract optional detail and time tag for multi-line render.
_detail = d.get("message") or d.get("description") or ""
if isinstance(_detail, str):
_detail = _detail.strip()[:120]
else:
_detail = ""
_time_tag = ""
_t_raw = d.get("time") or d.get("issued_at") or d.get("issue_time") or ""
if isinstance(_t_raw, str) and _t_raw:
_time_tag = _t_raw[:16].replace("T", " ")
if row is None:
_upsert_swpc(conn, event_id=event_id, adapter=adapter,
payload_json=payload_json, occurred_at=occurred_at or now,
first_seen_at=now, set_last_broadcast=False)
wire = _render(event_kind, scale_code, label, scalar_str)
wire = _render(event_kind, scale_code, label, scalar_str,
is_update=False, detail=_detail, time_tag=_time_tag)
_attach_commit(data, event_id=event_id, event_log_row_id=log_id)
return wire
if row["last_broadcast_at"] is None:
wire = _render(event_kind, scale_code, label, scalar_str)
wire = _render(event_kind, scale_code, label, scalar_str,
is_update=False, detail=_detail, time_tag=_time_tag)
_attach_commit(data, event_id=event_id, event_log_row_id=log_id)
return wire
# Already broadcast — return None (no Update re-broadcast for SWPC;
# space weather events are point-in-time, not evolving like fires).
return None
def _render(event_kind, scale_code, label, scalar_str) -> str:
def _render(event_kind, scale_code, label, scalar_str,
*, is_update: bool = False, detail: str = "",
time_tag: str = "") -> str:
prefix = "Update:" if is_update else "New:"
if event_kind == "geomag":
return (f"🌌 {label.title()} geomagnetic storm ({scale_code}/{scalar_str}) "
f"-- HF degraded, aurora possible")
if event_kind == "flare":
return (f"🔆 Major solar flare ({scale_code}/{scalar_str}) "
f"-- HF radio fading ~30 min, GPS may glitch")
if event_kind == "proton":
return (f"☢️ Solar radiation storm ({scale_code}/{scalar_str}) "
f"-- polar HF radio affected")
return f"⚠️ Space weather event ({scale_code or '?'})"
line1 = f"🧲 {prefix} {scale_code} Geomagnetic Storm — {scalar_str}"
line2 = detail[:120] if detail else "HF degraded, aurora possible"
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
elif event_kind == "flare":
line1 = f"☀️ {prefix} {scalar_str} Solar Flare — {scale_code}"
line2 = detail[:120] if detail else "HF radio fading, GPS may glitch"
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
elif event_kind == "proton":
line1 = f"☢️ {prefix} {scale_code} Radiation Storm — {scalar_str}"
line2 = detail[:120] if detail else "Polar HF radio affected"
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
else:
line1 = f"⚠️ {prefix} Space Weather Event — {scale_code or '?'}"
line2 = detail[:120] if detail else None
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
return "\n".join(l for l in [line1, line2, line3] if l)
def _upsert_swpc(conn, *, event_id, adapter, payload_json, occurred_at,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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-DzHEJ_0f.js"></script>
<script type="module" crossorigin src="/assets/index-CM6OazXs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dp9XCfH-.css">
</head>
<body>