Compare commits

..

4 Commits

Author SHA1 Message Date
root
3a066ea589 Rttningar 2026-05-03 06:39:58 +00:00
root
b5d65c4a52 N/A 2026-05-02 18:37:10 +00:00
root
9171a5134d webui 2026-05-02 18:17:37 +00:00
root
0b394c9bae webui 2026-05-02 18:16:56 +00:00
7 changed files with 1571 additions and 86 deletions

219
README.md
View File

@@ -1,187 +1,236 @@
# heardlog
Samlar APRS-data från två källor och lagrar i TimescaleDB + PostGIS som grund för en täckningskarta.
APRS RF coverage logger and map for amateur radio digipeaters.
Collects every packet heard on RF by a Direwolf digipeater and builds an
interactive coverage map showing which Maidenhead subsquares have been reached,
which paths were used, and which stations have been heard.
Live: [aprs.sa6anw.se](https://aprs.sa6anw.se)
---
## Architecture
```
[Shack-LAN] [DMZ]
Direwolf AGW:8000
agw_forwarder.py ──POST/Bearer──► FastAPI :8085 ──► TimescaleDB + PostGIS
rotate.aprs2.net (APRS-IS, outbound från DMZ)
[Shack LAN] [DMZ]
Direwolf AGW :8000
|
agw_forwarder.py --POST/Bearer--> FastAPI :8085 --> TimescaleDB + PostGIS
|
rotate.aprs2.net| (APRS-IS regional feed, outbound)
|
nginx :80/443
|
index.html (Leaflet map)
```
| Källa | Tabell | Nyckeldata |
| Source | Table | Key data |
|---|---|---|
| Direwolf AGW (RF) | `rf_frames` | `heard_direct` hördes utan mellanliggande digi |
| APRS-IS regionalt | `is_frames` | Stationer inom 200 km som digipeatas till internet |
`heard_direct = TRUE` är grundstenen för täckningskartan ett bevis på att en station nådde vår mottagare direkt.
| Direwolf AGW (RF) | `rf_frames` | Every packet received over the air |
| APRS-IS regional | `is_frames` | Regional feed within 200 km radius |
---
## Krav
## Repository layout
- Docker + Docker Compose (DMZ-server)
- Direwolf med `AGWPORT 8000` i `direwolf.conf` (shack-dator)
- Python 3.10+ och `pip3 install requests` (shack-dator)
```
heardlog/
????????? .env.example
????????? README.md
????????? docker-compose.yml
????????? collector/ # DMZ - runs in Docker
??? ????????? Dockerfile
??? ????????? requirements.txt
??? ????????? config.py
??? ????????? db.py # TimescaleDB schema + insert helpers
??? ????????? maidenhead.py # Maidenhead subsquare calculations
??? ????????? aprs_is.py # APRS-IS client
??? ????????? main.py # FastAPI app + APRS-IS background task
????????? agw-forwarder/ # Shack - runs directly on the Direwolf machine
??? ????????? agw_forwarder.py
??? ????????? requirements.txt
??? ????????? agw-forwarder.service
????????? web/
????????? index.html # Leaflet map (served by nginx)
????????? nginx.conf
```
---
## DMZ snabbstart
## Getting started
### DMZ
```bash
git clone <repo> heardlog
cd heardlog
cp .env.example .env
# Edit .env - set DB_PASSWORD and API_KEY
# Generera API-nyckel
# Generate API key
python3 -c "import secrets; print(secrets.token_hex(32))"
# Klistra in som API_KEY i .env
docker compose up -d
docker compose logs -f collector
```
Förväntad output:
Expected output:
```
INFO db Database schema ready
INFO main Startup complete listening for RF frames
INFO main Startup complete - listening for RF frames
INFO aprs_is APRS-IS login: # logresp SA6ANW-1 unverified, server T2...
```
`unverified` på APRS-IS är förväntat passcode `-1` ger receive-only vilket är tillräckligt.
---
## Shack agw-forwarder
### Shack (agw-forwarder)
```bash
pip3 install requests
# Skapa konfigurationsfil
cat > heardlog/agw-forwarder/.env << 'EOF'
AGW_HOST=localhost
AGW_PORT=8000
COLLECTOR_URL=http://<dmz-ip>:8085
API_KEY=<samma nyckel som i DMZ .env>
API_KEY=<same key as DMZ .env>
STATION_CALL=SA6ANW-1
LOG_LEVEL=INFO
EOF
# Testa manuellt
# Test manually first
cd heardlog/agw-forwarder
python3 agw_forwarder.py
```
### Kör som systemd-tjänster
### Run as systemd services (shack)
```bash
# Kopiera service-filer
sudo cp heardlog/agw-forwarder/direwolf.service /etc/systemd/system/
sudo cp heardlog/agw-forwarder/agw-forwarder.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now direwolf
sudo systemctl enable --now agw-forwarder
# Verifiera
journalctl -u direwolf -u agw-forwarder -f
```
`agw-forwarder` har `Wants=direwolf.service` systemd startar Direwolf automatiskt om den inte kör.
`agw-forwarder` has `Wants=direwolf.service` - systemd starts Direwolf automatically.
---
## Konfiguration
## Configuration
### DMZ (`.env`)
| Variabel | Standard | Beskrivning |
| Variable | Default | Description |
|---|---|---|
| `STATION_CALL` | `SA6ANW-1` | Din anropssignal |
| `DB_PASSWORD` | | Postgres-lösenord |
| `API_KEY` | | Delad hemlighet med forwardern |
| `APRS_IS_PASSCODE` | `-1` | `-1` för receive-only |
| `APRS_IS_FILTER` | `r/58.35/14.05/200` | lat/lon/km runt din QTH |
| `LOG_LEVEL` | `INFO` | `DEBUG` för varje frame |
| `STATION_CALL` | `SA6ANW-1` | Your callsign |
| `DB_PASSWORD` | - | Postgres password |
| `API_KEY` | - | Shared secret with forwarder |
| `APRS_IS_PASSCODE` | `-1` | `-1` for receive-only |
| `APRS_IS_FILTER` | `r/58.35/14.05/200` | lat/lon/km around your QTH |
| `LOG_LEVEL` | `INFO` | `DEBUG` for per-frame logging |
### Shack (`agw-forwarder/.env`)
| Variabel | Standard | Beskrivning |
| Variable | Default | Description |
|---|---|---|
| `AGW_HOST` | `localhost` | Direwolf-host |
| `AGW_PORT` | `8000` | Direwolf AGW-port |
| `COLLECTOR_URL` | | URL till DMZ-API:t |
| `API_KEY` | | Samma som i DMZ `.env` |
| `STATION_CALL` | `SA6ANW-1` | Din anropssignal |
| `AGW_HOST` | `localhost` | Direwolf host |
| `AGW_PORT` | `8000` | Direwolf AGW port |
| `COLLECTOR_URL` | - | DMZ API URL |
| `API_KEY` | - | Same as DMZ `.env` |
| `STATION_CALL` | `SA6ANW-1` | Your callsign |
---
## Implementationsnoteringar
## Map
### AGW monitoring-format
Direwolf skickar `U`-frames i monitoring-mode med ett ledande mellanslag och full monitoring-header före `\r`:
The map has three zoom levels:
| Zoom | Display |
|---|---|
| < 7 | One dot per Maidenhead subsquare |
| 7 - 13 | Transparent subsquare rectangles (~5 x 4.6 km each) |
| >= 14 | Individual observation points |
Clicking a square opens a popup with two tabs:
- **PATHS** - actual digipeater hops heard from that square, sorted by count. Digipeaters without a known position are shown in red.
- **STATIONS** - callsigns heard from that square. Clicking a station shows detailed statistics including distance, timestamps, and top paths.
Hovering over a path row draws the route on the map: square center to each digipeater to RX station. The RX station marker (pulsing yellow dot) shows all paths heard aggregated across all squares.
Clicking **SQUARES** in the header opens a feed of all squares sorted by most recently added, with distance from the RX station.
Popups can be dragged to move them out of the way.
---
## API
| Endpoint | Auth | Description |
|---|---|---|
| `POST /ingest/rf` | Bearer | RF frame from forwarder |
| `GET /health` | - | Liveness check |
| `GET /coverage/squares` | - | Subsquares with path and station breakdown |
| `GET /coverage/points` | - | Individual observation positions |
| `GET /coverage/digis` | - | Latest known position per callsign |
| `GET /coverage/rx-stats` | - | All paths aggregated across all squares |
| `GET /coverage/station/{callsign}` | - | Detailed statistics for one station |
Swagger UI: `http://<host>:8085/docs`
---
## Implementation notes
### AGW monitoring format
Direwolf sends `U`-frames in monitoring mode with a leading space and full
monitoring header before `\r`:
```
1:Fm SM6MJW-9 To UXRT8L Via WIDE1-1,WIDE2-1 <UI pid=F0 Len=17 F=0 >[16:46:13]\r<info>
```
Via-pathen extraheras med regex mellan `Via ` och ` <UI`. `heard_direct` avgörs av om någon hop i pathen har `*` (har-repeaterats-flaggan).
The via path is extracted with a regex between `Via ` and ` <UI`.
### Null-bytes
APRS-frames kan innehålla `0x00`-bytes i info-fältet. Dessa stoppas ut innan DB-insert eftersom Postgres UTF8 inte accepterar null-bytes.
### Path reduction
Raw APRS paths like `SK6WW,WIDE2-1` are reduced to actual physical digipeaters
by stripping alias hops (`WIDE*`, `RELAY`, `TRACE`, `ECHO`, `GATE`). This means
`SK6WW,WIDE1` and `SK6WW,WIDE2-1` both reduce to `SK6WW` and are counted together.
### Null bytes
APRS frames can contain `0x00` bytes in the info field. These are stripped
before database insert since Postgres UTF-8 rejects null bytes.
---
## Nyttiga queries
## Useful queries
```sql
-- Senaste RF-frames
SELECT ts, src_call, lat, lon, heard_direct, path
-- Latest RF frames
SELECT ts, src_call, lat, lon, path
FROM rf_frames
ORDER BY ts DESC
LIMIT 20;
-- Direkt-hörda stationer senaste 7 dagarna
SELECT ts, src_call, lat, lon, path
FROM rf_frames
WHERE heard_direct = TRUE
AND ts > NOW() - INTERVAL '7 days'
ORDER BY ts DESC;
-- Digis/stationer hörda senaste 30 min
-- Stations heard in the last 30 minutes
SELECT src_call, MAX(ts) AS last_seen, COUNT(*) AS frames
FROM rf_frames
WHERE ts > NOW() - INTERVAL '30 minutes'
GROUP BY src_call
ORDER BY last_seen DESC;
-- Täckningspunkter per rutnätscell (0.01°)
-- Coverage grid
SELECT
ROUND(lat::numeric, 2) AS grid_lat,
ROUND(lon::numeric, 2) AS grid_lon,
COUNT(*) AS hits
FROM rf_frames
WHERE heard_direct = TRUE
AND lat IS NOT NULL
WHERE lat IS NOT NULL
GROUP BY grid_lat, grid_lon;
-- APRS-IS senaste timmen
SELECT ts, src_call, lat, lon
FROM is_frames
WHERE ts > NOW() - INTERVAL '1 hour'
ORDER BY ts DESC;
```
---
## API
| Endpoint | Metod | Auth | Beskrivning |
|---|---|---|---|
| `/ingest/rf` | POST | Bearer | RF-frame från forwarder |
| `/health` | GET | | Liveness check |
Swagger UI: `http://<dmz-ip>:8085/docs`
Access the database:
```bash
docker exec -it heardlog-db-1 psql -U aprs -d aprs
```

55
collector/maidenhead.py Normal file
View File

@@ -0,0 +1,55 @@
"""
Maidenhead locator helpers.
latlon_to_subsquare(lat, lon) -> str e.g. "JO78ai"
subsquare_bounds(sq) -> dict {lat_min, lon_min, lat_max, lon_max}
"""
def latlon_to_subsquare(lat: float, lon: float) -> str:
"""Convert WGS84 coordinates to 6-character Maidenhead subsquare."""
lon_n = lon + 180.0
lat_n = lat + 90.0
field_lon = int(lon_n / 20)
field_lat = int(lat_n / 10)
lon_n = lon_n % 20
lat_n = lat_n % 10
sq_lon = int(lon_n / 2)
sq_lat = int(lat_n / 1)
lon_n = (lon_n % 2) * 12
lat_n = (lat_n % 1) * 24
sub_lon = int(lon_n)
sub_lat = int(lat_n)
return (
chr(ord("A") + field_lon)
+ chr(ord("A") + field_lat)
+ str(sq_lon)
+ str(sq_lat)
+ chr(ord("a") + sub_lon)
+ chr(ord("a") + sub_lat)
)
def subsquare_bounds(sq: str) -> dict:
"""
Return bounding box for a 6-character Maidenhead subsquare.
Each subsquare is 5' longitude ?? 2.5' latitude (~10 ?? 4.6 km in Sweden).
"""
lon = (ord(sq[0]) - ord("A")) * 20.0
lat = (ord(sq[1]) - ord("A")) * 10.0
lon += int(sq[2]) * 2.0
lat += int(sq[3]) * 1.0
lon += (ord(sq[4]) - ord("a")) * (1.0 / 12.0)
lat += (ord(sq[5]) - ord("a")) * (1.0 / 24.0)
lon -= 180.0
lat -= 90.0
return {
"lat_min": lat,
"lon_min": lon,
"lat_max": lat + (1.0 / 24.0),
"lon_max": lon + (1.0 / 12.0),
}

View File

@@ -17,12 +17,45 @@ 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 _valid_position(lat, lon) -> bool:
"""Filter out corrupt or irrelevant positions."""
if lat is None or lon is None:
return False
if lat != lat or lon != lon: # NaN check
return False
if abs(lat) < 0.01 and abs(lon) < 0.01: # 0,0 = no GPS fix
return False
if lat < 54 or lat > 72: # outside Scandinavia
return False
if lon < 4 or lon > 32: # outside Scandinavia
return False
return True
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 +134,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()
@@ -168,6 +202,10 @@ async def ingest_rf(frame: RFFrameIn) -> None:
lat, lon, _ = _parse_position(tnc2)
# Nullify invalid positions so they are stored as NULL rather than corrupt data
if not _valid_position(lat, lon):
lat, lon = None, None
logger.info(
"RF %-6s %-9s [%s] lat=%s lon=%s",
"DIRECT" if frame.heard_direct else "VIA",
@@ -192,3 +230,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]}

442
collector/main.py~ Normal file
View File

@@ -0,0 +1,442 @@
"""
APRS collector ??? DMZ service.
POST /ingest/rf ??? agw-forwarder on shack machine pushes RF frames here
GET /health ??? Docker / uptime check
APRS-IS collector runs as a background asyncio task alongside uvicorn.
"""
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
from datetime import datetime, timezone
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__)
# ---------------------------------------------------------------------------
# App state ??? populated during startup
# ---------------------------------------------------------------------------
_pool = None
_config: Optional[Config] = None
# ---------------------------------------------------------------------------
# Startup / shutdown
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global _pool, _config
_config = Config.from_env()
logging.basicConfig(
level=getattr(logging, _config.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
stream=sys.stdout,
)
# Wait for Postgres
for attempt in range(1, 61):
try:
_pool = await init_db(_config.db_url)
break
except Exception as exc:
logger.warning("DB not ready (%d/60): %s", attempt, exc)
await asyncio.sleep(5)
else:
logger.error("Could not connect to database ??? giving up")
sys.exit(1)
# Start APRS-IS collector as background task
is_task = asyncio.create_task(_run_aprs_is())
logger.info("Startup complete ??? listening for RF frames")
yield # ??? app runs here
is_task.cancel()
try:
await is_task
except asyncio.CancelledError:
pass
async def _run_aprs_is() -> None:
async def on_is_frame(*, ts: datetime, raw: str) -> None:
try:
src_call = raw.split(">")[0] if ">" in raw else ""
lat, lon, comment = _parse_position(raw)
path = _extract_path(raw)
await insert_is_frame(_pool, ts, src_call, lat, lon, path, comment, raw)
logger.debug("IS %-9s lat=%s lon=%s", src_call,
f"{lat:.4f}" if lat else "-",
f"{lon:.4f}" if lon else "-")
except Exception as exc:
logger.warning("IS frame error: %s raw=%r", exc, raw[:80])
await run_aprs_is_collector(
host=_config.aprs_is_host,
port=_config.aprs_is_port,
callsign=_config.aprs_is_callsign,
passcode=_config.aprs_is_passcode,
filter_str=_config.aprs_is_filter,
on_frame=on_is_frame,
)
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(title="aprs-collector", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
_bearer = HTTPBearer()
def _require_api_key(
creds: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)],
) -> None:
if creds.credentials != _config.api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key")
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class RFFrameIn(BaseModel):
ts: datetime # UTC timestamp from forwarder
src_call: str
dst_call: str
via_path: str # comma-separated, may contain '*'
info: str # APRS info field
heard_direct: bool
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_position(packet: str) -> tuple[Optional[float], Optional[float], Optional[str]]:
try:
p = aprslib.parse(packet)
return p.get("latitude"), p.get("longitude"), p.get("comment")
except Exception:
return None, None, None
def _extract_path(raw: str) -> str:
try:
header = raw.split(":")[0]
parts = header.split(",")
return ",".join(parts[1:]) if len(parts) > 1 else ""
except Exception:
return ""
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/ingest/rf", status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(_require_api_key)])
async def ingest_rf(frame: RFFrameIn) -> None:
"""Receive a single APRS RF frame from the shack forwarder."""
try:
# Build TNC2 string so aprslib can parse position
tnc2 = f"{frame.src_call}>{frame.dst_call}"
if frame.via_path:
tnc2 += f",{frame.via_path}"
tnc2 += f":{frame.info}"
lat, lon, _ = _parse_position(tnc2)
logger.info(
"RF %-6s %-9s [%s] lat=%s lon=%s",
"DIRECT" if frame.heard_direct else "VIA",
frame.src_call,
frame.via_path,
f"{lat:.4f}" if lat else "-",
f"{lon:.4f}" if lon else "-",
)
await insert_rf_frame(
pool=_pool,
ts=frame.ts,
rx_station=_config.station_call,
src_call=frame.src_call,
dst_call=frame.dst_call,
lat=lat,
lon=lon,
heard_direct=frame.heard_direct,
path=frame.via_path,
info=frame.info,
raw=tnc2,
)
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
}
}

View File

@@ -30,3 +30,13 @@ services:
depends_on:
db:
condition: service_healthy
web:
image: nginx:alpine
ports:
- "8084:80"
volumes:
- ./web/index.html:/usr/share/nginx/html/index.html:ro
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped

624
web/index.html Normal file
View File

@@ -0,0 +1,624 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>heardlog APRS RF Coverage</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Courier New', monospace; background: #0d0900; color: #ffb347; height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #0d0900; border-bottom: 1px solid #3a2200; flex-shrink: 0; gap: 8px; flex-wrap: wrap; }
.logo { font-size: 0.95rem; font-weight: bold; letter-spacing: 0.1em; color: #ff8c00; white-space: nowrap; }
.logo span { color: #ffb347; font-weight: normal; }
.logo em { color: #ffd580; font-style: normal; font-weight: normal; font-size: 0.85em; }
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.stat { font-size: 0.70rem; color: #7a5500; letter-spacing: 0.06em; white-space: nowrap; }
.stat strong { color: #ffb347; }
.btn { font-family: inherit; font-size: 0.70rem; letter-spacing: 0.08em; background: transparent; border: 1px solid #5a3300; color: #ff8c00; padding: 6px 14px; cursor: pointer; transition: all 0.15s; white-space: nowrap; touch-action: manipulation; }
.btn:hover { background: #2a1500; border-color: #ff8c00; }
#map { flex: 1; min-height: 0; }
@media (max-width: 480px) {
.logo em { display: none; }
.stat { font-size: 0.65rem; }
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.9; }
70% { transform: scale(2.2); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
.rx-marker { width: 14px; height: 14px; background: #ffe066; border: 2px solid #fff; border-radius: 50%; position: relative; }
.rx-marker::after { content: ''; position: absolute; top: -2px; left: -2px; width: 14px; height: 14px; border: 2px solid #ffe066; border-radius: 50%; animation: pulse 2s ease-out infinite; }
/* Popup base */
.sq-popup { font-family: 'Courier New', monospace; background: rgba(13,9,0,0.97); border: 1px solid #5a3300; color: #ffb347; width: min(300px, 90vw); }
.sq-popup-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #3a2200; }
.sq-popup-title { font-size: 0.85rem; color: #ff8c00; font-weight: bold; letter-spacing: 0.1em; }
.sq-popup-sub { font-size: 0.70rem; color: #7a5500; }
/* Tabs */
.sq-tabs { display: flex; border-bottom: 1px solid #3a2200; }
.sq-tab { flex: 1; text-align: center; padding: 8px 0; font-size: 0.70rem; letter-spacing: 0.08em; cursor: pointer; color: #7a5500; border-bottom: 2px solid transparent; transition: all 0.1s; touch-action: manipulation; }
.sq-tab.active { color: #ff8c00; border-bottom-color: #ff8c00; }
.sq-panel { display: none; padding: 6px 10px; max-height: min(220px, 40vh); overflow-y: auto; -webkit-overflow-scrolling: touch; }
.sq-panel.active { display: block; }
.sq-panel table { width: 100%; border-collapse: collapse; font-size: 0.70rem; }
.sq-panel td { padding: 4px 0; }
.sq-panel td:first-child { color: #ff8c00; padding-right: 12px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sq-panel td:last-child { text-align: right; color: #ffd580; }
.station-row { cursor: pointer; touch-action: manipulation; }
.station-row:active td { background: rgba(255,140,0,0.12); }
.station-row td:first-child { color: #ffd580; }
/* Station detail view */
.station-view { display: none; }
.station-view.active { display: block; }
.station-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #3a2200; }
.station-title { font-size: 0.85rem; color: #ffd580; font-weight: bold; }
.back-btn { font-family: inherit; font-size: 0.70rem; background: transparent; border: 1px solid #5a3300; color: #ff8c00; padding: 6px 10px; cursor: pointer; touch-action: manipulation; }
.station-body { padding: 6px 10px; max-height: min(260px, 45vh); overflow-y: auto; -webkit-overflow-scrolling: touch; }
.detail-section { color: #ff8c00; font-size: 0.65rem; letter-spacing: 0.1em; margin: 8px 0 3px; }
.detail-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 0.70rem; border-bottom: 1px solid #1a0f00; }
.detail-label { color: #7a5500; }
.detail-value { color: #ffd580; }
.squares-list { font-size: 0.65rem; color: #7a5500; line-height: 1.8; padding-top: 2px; }
.loading-msg { padding: 12px 10px; font-size: 0.70rem; color: #7a5500; }
/* Squares feed panel */
#squares-panel {
display: none;
position: absolute;
top: 47px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(13,9,0,0.97);
border: 1px solid #5a3300;
width: min(340px, 95vw);
max-height: min(420px, 70vh);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
font-family: 'Courier New', monospace;
}
#squares-panel.open { display: block; }
.sq-feed-header {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 12px; border-bottom: 1px solid #3a2200;
font-size: 0.72rem; color: #ff8c00; letter-spacing: 0.1em;
}
.sq-feed-close { cursor: pointer; color: #7a5500; font-size: 1rem; line-height: 1; }
.sq-feed-close:hover { color: #ff8c00; }
.sq-feed-row {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 12px; border-bottom: 1px solid #1a0f00;
cursor: pointer; font-size: 0.72rem;
}
.sq-feed-row:hover { background: rgba(255,140,0,0.1); }
.sq-feed-call { color: #ff8c00; letter-spacing: 0.05em; }
.sq-feed-meta { color: #7a5500; font-size: 0.65rem; text-align: right; }
.sq-feed-dist { color: #ffd580; }
/* Clickable squares count */
#sq-count { cursor: pointer; text-decoration: underline dotted #5a3300; }
#sq-count:hover { color: #ff8c00; }
/* Leaflet overrides */
.leaflet-popup-content-wrapper { background: transparent !important; box-shadow: 0 4px 24px rgba(0,0,0,0.7) !important; border-radius: 0 !important; padding: 0 !important; }
.leaflet-popup-content { margin: 0 !important; }
.leaflet-popup-tip-container { display: none; }
</style>
</head>
<body>
<header>
<div class="logo">heard<span>log</span> &mdash; <em>APRS RF COVERAGE</em></div>
<div class="controls">
<div class="stat">SQUARES <strong id="sq-count" onclick="toggleSquaresPanel()"></strong></div>
<div class="stat">POSITIONS <strong id="pt-count"></strong></div>
<div class="stat">RX <strong>SA6ANW-1</strong></div>
<button class="btn" onclick="refresh()">&#8635; REFRESH</button>
</div>
</header>
<div id="map"></div>
<div id="squares-panel">
<div class="sq-feed-header">
RECENT SQUARES
<span class="sq-feed-close" onclick="toggleSquaresPanel()">&times;</span>
</div>
<div id="squares-feed-list"></div>
</div>
<script>
const API = '';
const ZOOM_SQUARES_MIN = 7;
const ZOOM_POINTS_MIN = 14;
const RX_LAT = 58.35, RX_LON = 14.05, RX_CALL = 'SA6ANW-1';
const map = L.map('map', { zoomControl: true }).setView([RX_LAT, RX_LON], 8);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors', maxZoom: 19,
}).addTo(map);
let squareDotLayer = L.layerGroup().addTo(map);
let squareRectLayer = L.layerGroup().addTo(map);
let pointLayer = L.layerGroup().addTo(map);
let squaresData = [], pointsData = [], digiCatalog = {}, rxMarker = null;
const rxIcon = L.divIcon({ className: '', html: '<div class="rx-marker"></div>', iconSize: [14,14], iconAnchor: [7,7] });
rxMarker = L.marker([RX_LAT, RX_LON], { icon: rxIcon, zIndexOffset: 1000 }).addTo(map);
async function fetchAll() {
const [sqRes, ptRes, dgRes, rxRes] = await Promise.all([
fetch(`${API}/api/coverage/squares`),
fetch(`${API}/api/coverage/points`),
fetch(`${API}/api/coverage/digis`),
fetch(`${API}/api/coverage/rx-stats`),
]);
squaresData = (await sqRes.json()).squares || [];
pointsData = (await ptRes.json()).points || [];
digiCatalog = (await dgRes.json()).digis || {};
const rxPaths = (await rxRes.json()).paths || [];
buildRxPopup(rxPaths);
document.getElementById('sq-count').textContent = squaresData.length;
document.getElementById('pt-count').textContent = pointsData.length;
renderLayers();
}
// ---------------------------------------------------------------------------
// Popup HTML square view + station view both in DOM, toggled with CSS
// ---------------------------------------------------------------------------
function makePopupHTML(sq) {
const pathRows = sq.paths.map(p => {
const raw = p.path || 'DIRECT';
const hops = raw === 'DIRECT' ? ['DIRECT'] : raw.split(',');
const coloredHops = hops.map(hop =>
(hop === 'DIRECT' || digiCatalog[hop.trim()])
? `<span style="color:#ff8c00">${hop}</span>`
: `<span style="color:#ff2200" title="No position known">${hop}</span>`
).join(',');
return `<tr><td title="${raw}">${coloredHops}</td><td>${p.count}</td></tr>`;
}).join('');
const stationRows = (sq.stations||[]).map(s =>
`<tr class="station-row" onclick="showStation('${sq.square}','${s.call}')">
<td>${s.call}</td><td>${s.count}</td>
</tr>`
).join('');
return `<div class="sq-popup">
<!-- Square overview -->
<div id="sq-view-${sq.square}">
<div class="sq-popup-header">
<span class="sq-popup-title">${sq.square}</span>
<span class="sq-popup-sub">${sq.hits} frames</span>
</div>
<div class="sq-tabs">
<div class="sq-tab active" onclick="switchTab('${sq.square}','paths')">PATHS</div>
<div class="sq-tab" onclick="switchTab('${sq.square}','stations')">STATIONS</div>
</div>
<div id="${sq.square}-paths" class="sq-panel active"><table>${pathRows}</table></div>
<div id="${sq.square}-stations" class="sq-panel"><table>${stationRows}</table></div>
</div>
<!-- Station detail (hidden until clicked) -->
<div id="st-view-${sq.square}" class="station-view">
<div class="station-header">
<span class="station-title" id="st-title-${sq.square}"></span>
<button class="back-btn" onclick="backToSquare('${sq.square}')">&larr; BACK</button>
</div>
<div class="station-body" id="st-body-${sq.square}">
<div class="loading-msg">Loading...</div>
</div>
</div>
</div>`;
}
function switchTab(sq, tab) {
['paths','stations'].forEach(t => {
document.getElementById(`${sq}-${t}`)?.classList.toggle('active', t === tab);
});
const tabs = document.querySelectorAll(`#sq-view-${sq} .sq-tab`);
tabs.forEach((t,i) => t.classList.toggle('active', (i===0&&tab==='paths')||(i===1&&tab==='stations')));
}
function backToSquare(sqId) {
document.getElementById(`sq-view-${sqId}`).style.display = '';
document.getElementById(`st-view-${sqId}`).classList.remove('active');
}
async function showStation(sqId, callsign) {
// Switch views
document.getElementById(`sq-view-${sqId}`).style.display = 'none';
const stView = document.getElementById(`st-view-${sqId}`);
stView.classList.add('active');
document.getElementById(`st-title-${sqId}`).textContent = callsign;
const body = document.getElementById(`st-body-${sqId}`);
body.innerHTML = '<div class="loading-msg">Loading...</div>';
try {
const res = await fetch(`${API}/api/coverage/station/${encodeURIComponent(callsign)}`);
const d = await res.json();
const fmt = iso => iso ? new Date(iso).toLocaleString('sv-SE',{dateStyle:'short',timeStyle:'short'}) : '';
const topPaths = (d.top_paths||[]).map(p =>
`<div class="detail-row"><span class="detail-label">${p.path}</span><span class="detail-value">${p.count}</span></div>`
).join('');
body.innerHTML = `
<div class="detail-section">STATISTIK</div>
<div class="detail-row"><span class="detail-label">Frames totalt</span><span class="detail-value">${d.total_frames}</span></div>
<div class="detail-row"><span class="detail-label">Unique positions</span><span class="detail-value">${d.unique_positions}</span></div>
<div class="detail-row"><span class="detail-label">Unique paths</span><span class="detail-value">${d.unique_paths}</span></div>
<div class="detail-section">DISTANCE (LINE OF SIGHT)</div>
<div class="detail-row"><span class="detail-label">Closest</span><span class="detail-value">${d.distance_min_km??''} km</span></div>
<div class="detail-row"><span class="detail-label">Farthest</span><span class="detail-value">${d.distance_max_km??''} km</span></div>
<div class="detail-row"><span class="detail-label">Average</span><span class="detail-value">${d.distance_avg_km??''} km</span></div>
<div class="detail-section">TIMESTAMPS</div>
<div class="detail-row"><span class="detail-label">First heard</span><span class="detail-value">${fmt(d.first_heard)}</span></div>
<div class="detail-row"><span class="detail-label">Last heard</span><span class="detail-value">${fmt(d.last_heard)}</span></div>
<div class="detail-section">TOP PATHS</div>
${topPaths}
<div class="detail-section">SQUARES</div>
<div class="squares-list">${(d.squares||[]).join(' &nbsp;') || ''}</div>`;
} catch(e) {
body.innerHTML = '<div class="loading-msg">Failed to load data.</div>';
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function makeStationPopup(callsign) {
const id = 'pt-' + callsign.replace(/[^a-zA-Z0-9]/g, '_');
const html = `<div class="sq-popup">
<div id="sq-view-${id}" style="display:none"></div>
<div id="st-view-${id}" class="station-view active">
<div class="station-header">
<span class="station-title" id="st-title-${id}">${callsign}</span>
</div>
<div class="station-body" id="st-body-${id}">
<div class="loading-msg">Loading...</div>
</div>
</div>
</div>`;
const popup = L.popup({ maxWidth: 320 }).setContent(html);
popup.on('add', () => {
fetchStationInto(id, callsign);
});
return popup;
}
async function fetchStationInto(id, callsign) {
const body = document.getElementById(`st-body-${id}`);
if (!body) return;
try {
const res = await fetch(`${API}/api/coverage/station/${encodeURIComponent(callsign)}`);
const d = await res.json();
const fmt = iso => iso ? new Date(iso).toLocaleString('sv-SE',{dateStyle:'short',timeStyle:'short'}) : '-';
const topPaths = (d.top_paths||[]).map(p =>
`<div class="detail-row"><span class="detail-label">${p.path}</span><span class="detail-value">${p.count}</span></div>`
).join('');
body.innerHTML = `
<div class="detail-section">STATISTICS</div>
<div class="detail-row"><span class="detail-label">Total frames</span><span class="detail-value">${d.total_frames}</span></div>
<div class="detail-row"><span class="detail-label">Unique positions</span><span class="detail-value">${d.unique_positions}</span></div>
<div class="detail-row"><span class="detail-label">Unique paths</span><span class="detail-value">${d.unique_paths}</span></div>
<div class="detail-section">DISTANCE (LINE OF SIGHT)</div>
<div class="detail-row"><span class="detail-label">Closest</span><span class="detail-value">${d.distance_min_km??'-'} km</span></div>
<div class="detail-row"><span class="detail-label">Farthest</span><span class="detail-value">${d.distance_max_km??'-'} km</span></div>
<div class="detail-row"><span class="detail-label">Average</span><span class="detail-value">${d.distance_avg_km??'-'} km</span></div>
<div class="detail-section">TIMESTAMPS</div>
<div class="detail-row"><span class="detail-label">First heard</span><span class="detail-value">${fmt(d.first_heard)}</span></div>
<div class="detail-row"><span class="detail-label">Last heard</span><span class="detail-value">${fmt(d.last_heard)}</span></div>
<div class="detail-section">TOP PATHS</div>
${topPaths}
<div class="detail-section">SQUARES</div>
<div class="squares-list">${(d.squares||[]).join(' &nbsp;') || '-'}</div>`;
} catch(e) {
body.innerHTML = '<div class="loading-msg">Failed to load data.</div>';
}
}
function bindSquarePopup(layer, sq) {
layer.bindPopup(makePopupHTML(sq), { maxWidth: 320 });
}
function renderLayers() {
squareDotLayer.clearLayers();
squareRectLayer.clearLayers();
pointLayer.clearLayers();
const zoom = map.getZoom();
if (zoom >= ZOOM_POINTS_MIN) {
for (const pt of pointsData) {
const dot = L.circleMarker([pt.lat, pt.lon], {
radius: 4, color: '#ff8c00', weight: 1, fillColor: '#ffb347', fillOpacity: 0.8,
});
dot.bindPopup(makeStationPopup(pt.call), { maxWidth: 320 });
pointLayer.addLayer(dot);
}
} else if (zoom >= ZOOM_SQUARES_MIN) {
for (const sq of squaresData) {
const rect = L.rectangle(
[[sq.lat_min, sq.lon_min], [sq.lat_max, sq.lon_max]],
{ color: '#ff8c00', weight: 1, opacity: 0.9, fillColor: '#ff8c00', fillOpacity: 0.28 }
);
bindSquarePopup(rect, sq);
rect._sq = sq;
rect.on('popupopen', () => { rect.setStyle({ weight: 3, fillOpacity: 0.45 }); setTimeout(() => attachPathHover(sq), 50); });
rect.on('popupclose', () => { rect.setStyle({ weight: 1, fillOpacity: 0.28 }); clearPathLines(); });
squareRectLayer.addLayer(rect);
}
} else {
for (const sq of squaresData) {
const lat = (sq.lat_min + sq.lat_max) / 2;
const lon = (sq.lon_min + sq.lon_max) / 2;
const dot = L.circleMarker([lat, lon], {
radius: 5, color: '#ff8c00', weight: 1, fillColor: '#ffb347', fillOpacity: 0.9,
});
bindSquarePopup(dot, sq);
dot._sq = sq;
dot.on('popupopen', () => setTimeout(() => attachPathHover(sq), 50));
dot.on('popupclose', () => clearPathLines());
squareDotLayer.addLayer(dot);
}
}
}
// ---------------------------------------------------------------------------
// Squares feed panel
// ---------------------------------------------------------------------------
function toggleSquaresPanel() {
const panel = document.getElementById('squares-panel');
panel.classList.toggle('open');
if (panel.classList.contains('open')) renderSquaresFeed();
}
function renderSquaresFeed() {
const list = document.getElementById('squares-feed-list');
const sorted = [...squaresData].sort((a, b) => {
if (!a.first_seen) return 1;
if (!b.first_seen) return -1;
return b.first_seen.localeCompare(a.first_seen);
});
list.innerHTML = sorted.map(sq => {
const ts = sq.first_seen
? new Date(sq.first_seen).toLocaleString('sv-SE', {dateStyle:'short', timeStyle:'short'})
: '-';
return `<div class="sq-feed-row" onclick="focusSquare('${sq.square}')">
<span class="sq-feed-call">${sq.square}</span>
<span class="sq-feed-meta">
<span class="sq-feed-dist">${sq.dist_km} km</span><br/>
${ts}
</span>
</div>`;
}).join('');
}
function focusSquare(sqId) {
document.getElementById('squares-panel').classList.remove('open');
const sq = squaresData.find(s => s.square === sqId);
if (!sq) return;
const lat = (sq.lat_min + sq.lat_max) / 2;
const lon = (sq.lon_min + sq.lon_max) / 2;
// Zoom to square level and center
map.setView([lat, lon], Math.max(map.getZoom(), 10));
// Open popup after layers have rendered
setTimeout(() => {
let found = false;
squareRectLayer.eachLayer(l => {
if (l._sq && l._sq.square === sqId) { l.openPopup(); found = true; }
});
if (!found) {
squareDotLayer.eachLayer(l => {
if (l._sq && l._sq.square === sqId) { l.openPopup(); }
});
}
}, 300);
}
// ---------------------------------------------------------------------------
// RX station popup
// ---------------------------------------------------------------------------
function buildRxPopup(paths) {
const rows = paths.map(p => {
const hops = p.path === 'DIRECT' ? ['DIRECT'] : p.path.split(',');
const coloredHops = hops.map(hop =>
(hop === 'DIRECT' || digiCatalog[hop.trim()])
? `<span style="color:#ff8c00">${hop}</span>`
: `<span style="color:#ff2200" title="No position known">${hop}</span>`
).join(',');
return `<tr class="rx-path-row" data-path="${p.path}">
<td style="padding-right:14px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.path}">${coloredHops}</td>
<td style="text-align:right;color:#ffd580;white-space:nowrap">${p.count}</td>
</tr>`;
}).join('');
const html = `<div class="sq-popup" style="width:280px">
<div class="sq-popup-header">
<span class="sq-popup-title">${RX_CALL}</span>
<span class="sq-popup-sub">RX station</span>
</div>
<div style="padding:4px 10px 2px;font-size:0.63rem;letter-spacing:0.1em;color:#ff8c00;border-bottom:1px solid #3a2200">PATHS HEARD</div>
<div style="padding:6px 10px;max-height:280px;overflow-y:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.68rem">${rows}</table>
</div>
</div>`;
if (rxMarker) rxMarker.unbindPopup();
if (rxMarker) {
const popup = L.popup({ maxWidth: 320 }).setContent(html);
popup.on('add', () => {
document.querySelectorAll('.rx-path-row').forEach(tr => {
const path = tr.dataset.path;
tr.style.cursor = 'default';
tr.addEventListener('mouseenter', () => drawRxPath(path));
tr.addEventListener('touchstart', e => { e.stopPropagation(); drawRxPath(path); }, { passive: true });
tr.addEventListener('mouseleave', clearPathLines);
tr.addEventListener('touchend', () => setTimeout(clearPathLines, 2000), { passive: true });
});
});
rxMarker.bindPopup(popup);
}
}
// ---------------------------------------------------------------------------
// Path drawing on hover
// ---------------------------------------------------------------------------
let pathLines = [];
function drawRxPath(rawPath) {
clearPathLines();
if (!rawPath || rawPath === 'DIRECT') return;
const waypoints = [];
rawPath.split(',').forEach(hop => {
const d = digiCatalog[hop.trim()];
if (d) waypoints.push({ lat: d.lat, lon: d.lon });
});
waypoints.push({ lat: RX_LAT, lon: RX_LON });
if (waypoints.length < 2) return;
const line = L.polyline(waypoints.map(w => [w.lat, w.lon]), {
color: '#000000', weight: 2, opacity: 0.85, dashArray: '6,4',
}).addTo(map);
pathLines.push(line);
waypoints.forEach(w => {
const m = L.circleMarker([w.lat, w.lon], {
radius: 4, color: '#000000', weight: 2,
fillColor: '#0d0900', fillOpacity: 0.6,
}).addTo(map);
pathLines.push(m);
});
}
function clearPathLines() {
pathLines.forEach(l => map.removeLayer(l));
pathLines = [];
}
function drawPath(rawPath, stationLat, stationLon) {
clearPathLines();
// Build waypoints: station → each digi → RX
const waypoints = [{ lat: stationLat, lon: stationLon, label: 'Station' }];
if (rawPath && rawPath !== 'DIRECT') {
rawPath.split(',').forEach(hop => {
const d = digiCatalog[hop.trim()];
if (d) waypoints.push({ lat: d.lat, lon: d.lon, label: hop.trim() });
});
}
waypoints.push({ lat: RX_LAT, lon: RX_LON, label: RX_CALL });
if (waypoints.length < 2) return;
// Draw polyline
const latlngs = waypoints.map(w => [w.lat, w.lon]);
const line = L.polyline(latlngs, {
color: '#000000', weight: 2, opacity: 0.85,
dashArray: '6,4',
}).addTo(map);
pathLines.push(line);
// Draw circle markers at each waypoint
waypoints.forEach((w, i) => {
const isRx = i === waypoints.length - 1;
const isStation = i === 0;
const m = L.circleMarker([w.lat, w.lon], {
radius: isRx || isStation ? 5 : 4,
color: '#000000',
weight: 2,
fillColor: isRx ? '#ffe066' : '#0d0900',
fillOpacity: isRx ? 1 : 0.6,
}).addTo(map);
pathLines.push(m);
});
}
// Attach hover to path rows after popup opens
function attachPathHover(sq) {
const panel = document.getElementById(`${sq.square}-paths`);
if (!panel) return;
const rows = panel.querySelectorAll('tr');
rows.forEach((tr, i) => {
const rawPath = sq.paths[i]?.path || '';
const drawForRow = () => {
const lat = (sq.lat_min + sq.lat_max) / 2;
const lon = (sq.lon_min + sq.lon_max) / 2;
drawPath(rawPath, lat, lon);
};
tr.addEventListener('mouseenter', drawForRow);
tr.addEventListener('touchstart', e => { e.stopPropagation(); drawForRow(); }, { passive: true });
tr.addEventListener('mouseleave', clearPathLines);
tr.addEventListener('touchend', () => setTimeout(clearPathLines, 2000), { passive: true });
});
}
// ---------------------------------------------------------------------------
// Draggable popup
// ---------------------------------------------------------------------------
map.on('popupopen', e => {
const wrapper = e.popup._wrapper;
if (!wrapper) return;
const header = wrapper.querySelector('.sq-popup-header, .station-header');
if (!header) return;
header.style.cursor = 'grab';
let startX, startY, origLeft, origTop;
function startDrag(clientX, clientY) {
header.style.cursor = 'grabbing';
const el = wrapper.parentElement;
startX = clientX;
startY = clientY;
origLeft = el.offsetLeft;
origTop = el.offsetTop;
function onMove(e) {
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
el.style.left = (origLeft + cx - startX) + 'px';
el.style.top = (origTop + cy - startY) + 'px';
}
function onUp() {
header.style.cursor = 'grab';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('touchmove', onMove, { passive: true });
document.addEventListener('touchend', onUp);
}
header.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); startDrag(e.clientX, e.clientY); });
header.addEventListener('touchstart', e => { e.stopPropagation(); startDrag(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true });
});
map.on('zoomend', renderLayers);
async function refresh() {
document.getElementById('sq-count').textContent = '…';
document.getElementById('pt-count').textContent = '…';
await fetchAll();
}
fetchAll();
</script>
</body>
</html>

15
web/nginx.conf Normal file
View File

@@ -0,0 +1,15 @@
server {
listen 80;
charset utf-8;
source_charset utf-8;
location / {
root /usr/share/nginx/html;
index index.html;
add_header Content-Type "text/html; charset=utf-8";
}
location /api/ {
proxy_pass http://collector:8080/;
}
}