webui
This commit is contained in:
@@ -17,12 +17,30 @@ from typing import Annotated, Optional
|
||||
import aprslib
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
from aprs_is import run_aprs_is_collector
|
||||
from config import Config
|
||||
from db import init_db, insert_is_frame, insert_rf_frame
|
||||
from maidenhead import latlon_to_subsquare, subsquare_bounds
|
||||
|
||||
import re as _re
|
||||
|
||||
def _extract_digis(path: str) -> str:
|
||||
"""
|
||||
Reduce a raw APRS path to the actual physical digipeaters that handled it.
|
||||
WIDE*, RELAY, TRACE etc are aliases and are stripped.
|
||||
Returns 'DIRECT' if no real digi hops remain.
|
||||
"""
|
||||
if not path or path == 'Direkt':
|
||||
return 'DIRECT'
|
||||
_ALIAS = _re.compile(r'^(WIDE|RELAY|TRACE|ECHO|GATE|TCPIP|TCPXX|NOGATE|RFONLY|qA[A-Z])', _re.IGNORECASE)
|
||||
hops = [h.strip() for h in path.split(',')]
|
||||
digis = [h for h in hops if h and not _ALIAS.match(h)]
|
||||
return ','.join(digis) if digis else 'DIRECT'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,6 +119,7 @@ async def _run_aprs_is() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="aprs-collector", lifespan=lifespan)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
_bearer = HTTPBearer()
|
||||
|
||||
|
||||
@@ -192,3 +211,255 @@ async def ingest_rf(frame: RFFrameIn) -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("ingest_rf error (frame dropped): %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/coverage/squares")
|
||||
async def coverage_squares():
|
||||
"""
|
||||
Return all Maidenhead subsquares with path breakdown per square.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, path, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
GROUP BY lat, lon, path
|
||||
"""
|
||||
)
|
||||
|
||||
# Second query: first_seen per lat/lon
|
||||
async with _pool.acquire() as conn:
|
||||
firstseen_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, MIN(ts) AS first_seen
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
GROUP BY lat, lon
|
||||
"""
|
||||
)
|
||||
|
||||
# Build first_seen per square
|
||||
sq_first_seen: dict[str, str] = {}
|
||||
for row in firstseen_rows:
|
||||
sq = latlon_to_subsquare(row['lat'], row['lon'])
|
||||
ts = row['first_seen'].isoformat()
|
||||
if sq not in sq_first_seen or ts < sq_first_seen[sq]:
|
||||
sq_first_seen[sq] = ts
|
||||
|
||||
# Third query: stations per square
|
||||
async with _pool.acquire() as conn:
|
||||
station_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, src_call, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
GROUP BY lat, lon, src_call
|
||||
"""
|
||||
)
|
||||
|
||||
squares: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
sq = latlon_to_subsquare(row["lat"], row["lon"])
|
||||
if sq not in squares:
|
||||
squares[sq] = {"paths": {}, "stations": {}}
|
||||
p = _extract_digis(row["path"] or "")
|
||||
squares[sq]["paths"][p] = squares[sq]["paths"].get(p, 0) + row["hits"]
|
||||
|
||||
for row in station_rows:
|
||||
sq = latlon_to_subsquare(row["lat"], row["lon"])
|
||||
if sq not in squares:
|
||||
squares[sq] = {"paths": {}, "stations": {}}
|
||||
call = row["src_call"]
|
||||
squares[sq]["stations"][call] = squares[sq]["stations"].get(call, 0) + row["hits"]
|
||||
|
||||
result = []
|
||||
for sq, data in squares.items():
|
||||
bounds = subsquare_bounds(sq)
|
||||
total = sum(data["paths"].values())
|
||||
sorted_paths = sorted(data["paths"].items(), key=lambda x: x[1], reverse=True)
|
||||
sorted_stations = sorted(data["stations"].items(), key=lambda x: x[1], reverse=True)
|
||||
import math
|
||||
sq_lat = (bounds['lat_min'] + bounds['lat_max']) / 2
|
||||
sq_lon = (bounds['lon_min'] + bounds['lon_max']) / 2
|
||||
dlat = math.radians(sq_lat - 58.35)
|
||||
dlon = math.radians(sq_lon - 14.05)
|
||||
a = math.sin(dlat/2)**2 + math.cos(math.radians(58.35)) * math.cos(math.radians(sq_lat)) * math.sin(dlon/2)**2
|
||||
dist_km = round(6371 * 2 * math.asin(math.sqrt(a)), 1)
|
||||
|
||||
result.append({
|
||||
"square": sq,
|
||||
"hits": total,
|
||||
"first_seen": sq_first_seen.get(sq),
|
||||
"dist_km": dist_km,
|
||||
"paths": [{"path": p, "count": c} for p, c in sorted_paths],
|
||||
"stations": [{"call": s, "count": c} for s, c in sorted_stations],
|
||||
**bounds,
|
||||
})
|
||||
|
||||
return {"squares": result}
|
||||
|
||||
|
||||
@app.get("/coverage/points")
|
||||
async def coverage_points():
|
||||
"""
|
||||
Return individual heard-direct observations with position.
|
||||
Used for the zoomed-in map layer.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (src_call, ROUND(lat::numeric,4), ROUND(lon::numeric,4))
|
||||
src_call,
|
||||
ROUND(lat::numeric, 4) AS lat,
|
||||
ROUND(lon::numeric, 4) AS lon,
|
||||
ts
|
||||
FROM rf_frames
|
||||
WHERE heard_direct = TRUE
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
ORDER BY src_call, ROUND(lat::numeric,4), ROUND(lon::numeric,4), ts DESC
|
||||
"""
|
||||
)
|
||||
|
||||
return {
|
||||
"points": [
|
||||
{
|
||||
"call": row["src_call"],
|
||||
"lat": float(row["lat"]),
|
||||
"lon": float(row["lon"]),
|
||||
"ts": row["ts"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/station/{callsign}")
|
||||
async def coverage_station(callsign: str):
|
||||
"""Detailed statistics for a single station."""
|
||||
import math
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
rx_lat = float(_config.station_call and 58.35)
|
||||
rx_lon = 14.05
|
||||
|
||||
async with _pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
COUNT(*) AS total_frames,
|
||||
MIN(ts) AS first_heard,
|
||||
MAX(ts) AS last_heard,
|
||||
COUNT(DISTINCT ROUND(lat::numeric,3) || ',' || ROUND(lon::numeric,3))
|
||||
AS unique_positions,
|
||||
COUNT(DISTINCT path) AS unique_paths,
|
||||
AVG(lat) AS avg_lat,
|
||||
AVG(lon) AS avg_lon
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1
|
||||
AND lat IS NOT NULL
|
||||
""", callsign)
|
||||
|
||||
path_rows = await conn.fetch("""
|
||||
SELECT path, COUNT(*) AS cnt
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1 AND lat IS NOT NULL
|
||||
GROUP BY path
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 5
|
||||
""", callsign)
|
||||
|
||||
square_rows = await conn.fetch("""
|
||||
SELECT lat, lon, COUNT(*) AS cnt
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1 AND lat IS NOT NULL
|
||||
GROUP BY lat, lon
|
||||
ORDER BY cnt DESC
|
||||
""", callsign)
|
||||
|
||||
if not row or row["total_frames"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Station not found")
|
||||
|
||||
# Distance from each unique position to RX
|
||||
distances = []
|
||||
squares_seen = set()
|
||||
for r in square_rows:
|
||||
d = haversine(rx_lat, rx_lon, r["lat"], r["lon"])
|
||||
distances.append(d)
|
||||
squares_seen.add(latlon_to_subsquare(r["lat"], r["lon"]))
|
||||
|
||||
return {
|
||||
"callsign": callsign,
|
||||
"total_frames": row["total_frames"],
|
||||
"first_heard": row["first_heard"].isoformat(),
|
||||
"last_heard": row["last_heard"].isoformat(),
|
||||
"unique_positions": row["unique_positions"],
|
||||
"unique_paths": row["unique_paths"],
|
||||
"squares": sorted(squares_seen),
|
||||
"distance_min_km": round(min(distances), 1) if distances else None,
|
||||
"distance_max_km": round(max(distances), 1) if distances else None,
|
||||
"distance_avg_km": round(sum(distances)/len(distances), 1) if distances else None,
|
||||
"top_paths": [{"path": _extract_digis(r["path"] or ""), "count": r["cnt"]} for r in path_rows],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/digis")
|
||||
async def coverage_digis():
|
||||
"""
|
||||
Return latest known position for every callsign we have heard.
|
||||
Used client-side to resolve digi positions for path drawing.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (src_call)
|
||||
src_call, lat, lon, ts
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
ORDER BY src_call, ts DESC
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"digis": {
|
||||
row["src_call"]: {
|
||||
"lat": row["lat"],
|
||||
"lon": row["lon"],
|
||||
"ts": row["ts"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/rx-stats")
|
||||
async def coverage_rx_stats():
|
||||
"""
|
||||
Aggregate all reduced paths heard by the RX station, sorted by count.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT path, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
GROUP BY path
|
||||
"""
|
||||
)
|
||||
|
||||
paths: dict[str, int] = {}
|
||||
for row in rows:
|
||||
p = _extract_digis(row["path"] or "")
|
||||
paths[p] = paths.get(p, 0) + row["hits"]
|
||||
|
||||
sorted_paths = sorted(paths.items(), key=lambda x: x[1], reverse=True)
|
||||
return {"paths": [{"path": p, "count": c} for p, c in sorted_paths]}
|
||||
|
||||
Reference in New Issue
Block a user