Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions backend/routers/mesh.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime, timezone, timedelta
import json
import logging

from fastapi import APIRouter, Depends, Query
Expand All @@ -7,6 +8,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from deps import get_db
from redis_bus import get_redis

router = APIRouter(tags=["mesh"])
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -69,6 +71,7 @@ async def list_mesh_links(
@router.get("/mesh/topology")
async def mesh_topology(
stale_minutes: int = Query(30, ge=1, le=1440),
include_coords: bool = Query(False, description="Enrich each node with lat/lon from Redis entity cache"),
db: AsyncSession = Depends(get_db),
):
"""Return nodes and links as a graph for visualization."""
Expand All @@ -80,11 +83,33 @@ async def mesh_topology(
).bindparams(cutoff=cutoff)
)
links = [dict(r) for r in result.mappings().all()]
node_ids = set()
node_ids: set[str] = set()
for lnk in links:
node_ids.add(lnk["node_a"])
node_ids.add(lnk["node_b"])
return {"nodes": list(node_ids), "links": links}

if not include_coords:
return {"nodes": list(node_ids), "links": links}

# Enrich nodes with coordinates from the Redis entity cache
node_list = list(node_ids)
keys = [f"entity:{nid}" for nid in node_list]
r = get_redis()
values = await r.mget(*keys)

nodes_with_coords = []
for nid, raw in zip(node_list, values):
entry: dict = {"id": nid, "lat": None, "lon": None}
if raw:
try:
data = json.loads(raw)
entry["lat"] = data.get("lat")
entry["lon"] = data.get("lon")
except Exception:
pass
nodes_with_coords.append(entry)

return {"nodes": nodes_with_coords, "links": links}


@router.get("/mesh/messages")
Expand Down
14 changes: 14 additions & 0 deletions db/init/10_mesh_alert_config.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- SNR threshold alert configuration, one row per MeshCore source URL.
-- Rows are created on demand by the admin UI; the poller falls back to
-- DEFAULT_THRESHOLD (-90 dBm) when no matching row exists.
CREATE TABLE IF NOT EXISTS mesh_alert_configs (
id SERIAL PRIMARY KEY,
source_url TEXT NOT NULL,
snr_threshold REAL NOT NULL DEFAULT -90.0,
cooldown_secs INTEGER NOT NULL DEFAULT 300,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX IF NOT EXISTS ux_mesh_alert_configs_url
ON mesh_alert_configs (source_url);
49 changes: 43 additions & 6 deletions frontend/src/components/panels/EntityDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,14 @@ export function EntityDetail() {
// Tab state
const [activeTab, setActiveTab] = useState<'overview' | 'weather' | 'tags'>('overview')

// Reset tab to overview if switching from aircraft (which has weather) to non-aircraft
// Reset tab to overview when switching to an entity type that has no weather tab
useEffect(() => {
if (activeTab === 'weather' && entity?.entity_type !== 'aircraft') {
const isAprsWeather = entity?.entity_type === 'aprs'
&& (entity?.identity as Record<string, unknown> | undefined)?.station_type === 'weather'
if (activeTab === 'weather' && entity?.entity_type !== 'aircraft' && !isAprsWeather) {
setActiveTab('overview')
}
}, [entity?.entity_type, activeTab])
}, [entity?.entity_type, (entity?.identity as Record<string, unknown> | undefined)?.station_type, activeTab])

const handleAddTag = async () => {
if (!selectedEntityId || !tagInput.trim()) return
Expand Down Expand Up @@ -202,7 +204,11 @@ export function EntityDetail() {
{/* Tabs */}
<div className="flex gap-4 border-b border-white/10">
{(['overview', 'weather', 'tags'] as const).map(tab => {
if (tab === 'weather' && entity.entity_type !== 'aircraft') return null
if (tab === 'weather') {
const isAprsWeather = entity.entity_type === 'aprs'
&& (identity as Record<string, unknown>).station_type === 'weather'
if (entity.entity_type !== 'aircraft' && !isAprsWeather) return null
}
return (
<button
key={tab}
Expand Down Expand Up @@ -313,7 +319,38 @@ export function EntityDetail() {
</>
)}

{activeTab === 'weather' && (
{activeTab === 'weather' && entity.entity_type === 'aprs' && (() => {
const wx = (identity as Record<string, unknown>).wx as Record<string, unknown> | undefined
if (!wx) return (
<div className="text-center p-4">
<span className="ms text-[24px] text-on-surface-variant/50 mb-2 block">cloud_off</span>
<p className="text-[11px] text-on-surface-variant uppercase tracking-widest">No weather data</p>
</div>
)
const rows: [string, string][] = []
if (typeof wx.temp_f === 'number') rows.push(['Temperature', `${wx.temp_f}°F`])
if (typeof wx.humidity === 'number') rows.push(['Humidity', `${wx.humidity}%`])
if (typeof wx.pressure_mb === 'number') rows.push(['Pressure', `${(wx.pressure_mb as number).toFixed(1)} mb`])
if (typeof wx.wind_mph === 'number') {
const dir = typeof wx.wind_dir_deg === 'number' ? `${wx.wind_dir_deg}° ` : ''
rows.push(['Wind', `${dir}${wx.wind_mph} mph`])
}
if (typeof wx.gust_mph === 'number') rows.push(['Gust', `${wx.gust_mph} mph`])
if (typeof wx.rain_in === 'number') rows.push(['Rain (1h)', `${(wx.rain_in as number).toFixed(2)}"`])
return (
<div className="space-y-1">
<span className="label-caps text-[11px] text-sky-400/80 mb-2 block">Station Conditions</span>
{rows.map(([label, val]) => (
<div key={label} className="flex justify-between items-baseline gap-2">
<span className="text-[11px] text-on-surface-variant shrink-0">{label}</span>
<span className="font-mono text-[11px] text-sky-300">{val}</span>
</div>
))}
</div>
)
})()}

{activeTab === 'weather' && entity.entity_type === 'aircraft' && (
<div className="space-y-4">
{!originWx && !destinationWx ? (
<div className="text-center p-4">
Expand All @@ -333,7 +370,7 @@ export function EntityDetail() {
</div>
</div>
)}

{destinationWx && (
<div>
<div className="flex items-center gap-1.5 mb-2 text-amber-gold">
Expand Down
86 changes: 84 additions & 2 deletions frontend/src/components/panels/entity/AprsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,25 @@ const STATION_TYPE_COLORS: Record<string, string> = {
fixed: 'text-green-400 bg-green-400/10',
}

function WindArrow({ deg }: { deg: number }) {
return (
<span
className="ms text-[14px] inline-block"
style={{ transform: `rotate(${deg}deg)`, display: 'inline-block' }}
>
arrow_upward
</span>
)
}

export function AprsOverview({ entity, getIdentity }: OverviewProps) {
const stationType = getIdentity('station_type')
const symDesc = getIdentity('symbol_desc')

const wx = stationType === 'weather'
? (entity.identity as Record<string, unknown>)?.wx as Record<string, unknown> | undefined
: undefined

const identityRows: [string, string | undefined][] = [
['Type', entity.entity_type],
['Source', entity.source],
Expand All @@ -37,6 +52,73 @@ export function AprsOverview({ entity, getIdentity }: OverviewProps) {
)}
</div>
)}

{/* Weather data card — only for weather stations with wx data */}
{wx && (
<div className="bg-white/5 border border-white/10 rounded-sm p-2 mb-3 space-y-2">
<span className="label-caps text-[11px] text-sky-400/80 block">Live Conditions</span>
<div className="grid grid-cols-2 gap-2">
{typeof wx.temp_f === 'number' && (
<div>
<div className="flex items-center gap-1 text-on-surface-variant mb-0.5">
<span className="ms text-[12px]">thermometer</span>
<span className="label-caps text-[10px]">Temp</span>
</div>
<span className="font-mono text-[14px] text-sky-300">
{wx.temp_f}°F
</span>
</div>
)}
{typeof wx.humidity === 'number' && (
<div>
<div className="flex items-center gap-1 text-on-surface-variant mb-0.5">
<span className="ms text-[12px]">water_drop</span>
<span className="label-caps text-[10px]">Humidity</span>
</div>
<span className="font-mono text-[14px] text-sky-300">
{wx.humidity}%
</span>
</div>
)}
{typeof wx.wind_mph === 'number' && (
<div>
<div className="flex items-center gap-1 text-on-surface-variant mb-0.5">
<span className="ms text-[12px]">air</span>
<span className="label-caps text-[10px]">Wind</span>
</div>
<span className="font-mono text-[14px] text-sky-300 flex items-center gap-1">
{typeof wx.wind_dir_deg === 'number' && <WindArrow deg={wx.wind_dir_deg as number} />}
{wx.wind_mph} mph
{typeof wx.gust_mph === 'number' && (
<span className="text-[11px] text-on-surface-variant">g{wx.gust_mph}</span>
)}
</span>
</div>
)}
{typeof wx.pressure_mb === 'number' && (
<div>
<div className="flex items-center gap-1 text-on-surface-variant mb-0.5">
<span className="ms text-[12px]">compress</span>
<span className="label-caps text-[10px]">Pressure</span>
</div>
<span className="font-mono text-[14px] text-sky-300">
{(wx.pressure_mb as number).toFixed(1)} mb
</span>
</div>
)}
</div>
{typeof wx.rain_in === 'number' && (
<div className="flex items-center justify-between pt-1 border-t border-white/10">
<div className="flex items-center gap-1 text-on-surface-variant">
<span className="ms text-[12px]">rainy</span>
<span className="label-caps text-[10px]">Rain (1h)</span>
</div>
<span className="font-mono text-[11px] text-sky-300">{(wx.rain_in as number).toFixed(2)}"</span>
</div>
)}
</div>
)}

<div className="grid grid-cols-2 gap-2">
<div className="bg-white/5 border border-white/10 p-2 rounded-sm relative overflow-hidden">
<div className="flex items-center gap-1.5 mb-1 text-on-surface-variant relative z-10">
Expand All @@ -47,7 +129,7 @@ export function AprsOverview({ entity, getIdentity }: OverviewProps) {
{entity.altitude != null ? `${Math.round(entity.altitude).toLocaleString()} ft` : '--'}
</div>
</div>

<div className="bg-white/5 border border-white/10 p-2 rounded-sm relative overflow-hidden">
<div className="flex items-center gap-1.5 mb-1 text-on-surface-variant relative z-10">
<span className="ms text-[12px]">speed</span>
Expand Down Expand Up @@ -91,7 +173,7 @@ export function AprsOverview({ entity, getIdentity }: OverviewProps) {
))}
</div>
</div>

<div className="flex justify-between items-center pt-2 border-t border-white/10">
<div className="flex items-center gap-1 text-on-surface-variant">
<span className="ms text-[11px]">schedule</span>
Expand Down
65 changes: 65 additions & 0 deletions poller/enrichment/aprs_weather.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
APRS weather comment parser.

Handles Ultimeter/Davis-style weather data embedded in APRS position comments:
050/010g015t072r000p000P000h50b10254
wind 050°/010kts, gust 015kts, temp 72°F, rain 0.00", humidity 50%, pressure 1025.4mb
"""
import re
from typing import Optional

# Matches the standard positional Ultimeter/Davis WX comment block.
# Fields are individually optional and may appear in any order after the
# wind block. All numeric groups are ASCII digits; temp may be signed.
_WX_RE = re.compile(
r"(?:(\d{3})/(\d{3}))?" # wind_dir / wind_speed_kts
r"(?:g(\d{3}))?" # gust_kts
r"(?:t(-?\d{3}))?" # temp_F (signed)
r"(?:r(\d{3}))?" # rain_1h (0.01 in)
r"(?:p(\d{3}))?" # rain_24h (0.01 in)
r"(?:P(\d{3}))?" # rain_midnight (0.01 in)
r"(?:h(\d{2,3}))?" # humidity pct (00 → 100%)
r"(?:b(\d{4,5}))?", # pressure 0.1 mbar
re.IGNORECASE,
)


def parse_wx_comment(comment: str) -> Optional[dict]:
"""Parse an Ultimeter/Davis-style APRS weather comment.

Returns a dict with whichever fields are present, or None when the
comment contains no recognisable WX data.
"""
if not comment:
return None

m = _WX_RE.search(comment)
if not m or not any(m.groups()):
return None

wind_dir, wind_spd, gust, temp, rain_1h, rain_24h, rain_mid, humidity, pressure = m.groups()

result: dict = {}

if temp is not None:
result["temp_f"] = int(temp)

if humidity is not None:
h = int(humidity)
# APRS encodes 100 % as "00"
result["humidity"] = 100 if h == 0 else min(h, 100)

if pressure is not None:
result["pressure_mb"] = int(pressure) / 10.0

if wind_dir is not None and wind_spd is not None:
result["wind_dir_deg"] = int(wind_dir)
result["wind_mph"] = round(int(wind_spd) * 1.15078, 1) # kts → mph

if gust is not None:
result["gust_mph"] = round(int(gust) * 1.15078, 1)

if rain_1h is not None:
result["rain_in"] = int(rain_1h) / 100.0

return result if result else None
2 changes: 2 additions & 0 deletions poller/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pollers.utilities import UtilityPoller
from pollers.p25 import P25Poller
from pollers.meshcore import MeshCorePoller
from pollers.meshcore_pymc import MeshCorePymcPoller
from pollers.summary import AISummaryPoller
from pollers.seismic import SeismicPoller
from pollers.fire import FirePoller
Expand Down Expand Up @@ -74,6 +75,7 @@ async def main():
UtilityPoller(),
P25Poller(),
MeshCorePoller(),
MeshCorePymcPoller(),
AISummaryPoller(),
SeismicPoller(),
FirePoller(),
Expand Down
Loading
Loading