Merge pull request #210 from smittix/fix/wefax-stations-missing-in-docker

fix(wefax): include station JSON in Docker image and handle missing file
This commit is contained in:
Smittix
2026-04-22 16:45:03 +01:00
committed by GitHub
2 changed files with 103 additions and 96 deletions
-1
View File
@@ -42,7 +42,6 @@ tasks/
instance/ instance/
# data/ is a Python package — only exclude non-code files # data/ is a Python package — only exclude non-code files
data/*.json
data/*.csv data/*.csv
data/*.db data/*.db
+103 -95
View File
@@ -3,36 +3,44 @@
Loads and caches station data from data/wefax_stations.json. Provides Loads and caches station data from data/wefax_stations.json. Provides
lookup by callsign and current-broadcast filtering based on UTC time. lookup by callsign and current-broadcast filtering based on UTC time.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone import logging
from pathlib import Path from datetime import datetime, timezone
from pathlib import Path
log = logging.getLogger(__name__)
_stations_cache: list[dict] | None = None _stations_cache: list[dict] | None = None
_stations_by_callsign: dict[str, dict] = {} _stations_by_callsign: dict[str, dict] = {}
_VALID_FREQUENCY_REFERENCES = {'auto', 'carrier', 'dial'} _VALID_FREQUENCY_REFERENCES = {"auto", "carrier", "dial"}
WEFAX_USB_ALIGNMENT_OFFSET_KHZ = 1.9 WEFAX_USB_ALIGNMENT_OFFSET_KHZ = 1.9
_STATIONS_PATH = Path(__file__).resolve().parent.parent / 'data' / 'wefax_stations.json' _STATIONS_PATH = Path(__file__).resolve().parent.parent / "data" / "wefax_stations.json"
def load_stations() -> list[dict]: def load_stations() -> list[dict]:
"""Load all WeFax stations from JSON, caching on first call.""" """Load all WeFax stations from JSON, caching on first call."""
global _stations_cache, _stations_by_callsign global _stations_cache, _stations_by_callsign
if _stations_cache is not None: if _stations_cache is not None:
return _stations_cache return _stations_cache
with open(_STATIONS_PATH) as f: if not _STATIONS_PATH.exists():
data = json.load(f) log.warning("wefax_stations.json not found at %s", _STATIONS_PATH)
_stations_cache = []
_stations_cache = data.get('stations', []) return _stations_cache
_stations_by_callsign = {s['callsign']: s for s in _stations_cache}
return _stations_cache with open(_STATIONS_PATH) as f:
data = json.load(f)
_stations_cache = data.get("stations", [])
_stations_by_callsign = {s["callsign"]: s for s in _stations_cache}
return _stations_cache
def get_station(callsign: str) -> dict | None: def get_station(callsign: str) -> dict | None:
"""Get a single station by callsign.""" """Get a single station by callsign."""
load_stations() load_stations()
@@ -41,38 +49,38 @@ def get_station(callsign: str) -> dict | None:
def _normalize_frequency_reference(value: str | None) -> str: def _normalize_frequency_reference(value: str | None) -> str:
"""Normalize and validate frequency reference token.""" """Normalize and validate frequency reference token."""
reference = str(value or 'auto').strip().lower() reference = str(value or "auto").strip().lower()
if reference not in _VALID_FREQUENCY_REFERENCES: if reference not in _VALID_FREQUENCY_REFERENCES:
choices = ', '.join(sorted(_VALID_FREQUENCY_REFERENCES)) choices = ", ".join(sorted(_VALID_FREQUENCY_REFERENCES))
raise ValueError(f'frequency_reference must be one of: {choices}') raise ValueError(f"frequency_reference must be one of: {choices}")
return reference return reference
def _station_frequency_reference(station: dict, listed_frequency_khz: float) -> str: def _station_frequency_reference(station: dict, listed_frequency_khz: float) -> str:
"""Infer whether a station frequency entry is carrier or already USB dial.""" """Infer whether a station frequency entry is carrier or already USB dial."""
for entry in station.get('frequencies', []): for entry in station.get("frequencies", []):
try: try:
entry_khz = float(entry.get('khz')) entry_khz = float(entry.get("khz"))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if abs(entry_khz - listed_frequency_khz) > 0.001: if abs(entry_khz - listed_frequency_khz) > 0.001:
continue continue
entry_ref = str(entry.get('reference', '')).strip().lower() entry_ref = str(entry.get("reference", "")).strip().lower()
if entry_ref in ('carrier', 'dial'): if entry_ref in ("carrier", "dial"):
return entry_ref return entry_ref
station_ref = str(station.get('frequency_reference', '')).strip().lower() station_ref = str(station.get("frequency_reference", "")).strip().lower()
if station_ref in ('carrier', 'dial'): if station_ref in ("carrier", "dial"):
return station_ref return station_ref
# Most published marine WeFax channel lists are carrier frequencies. # Most published marine WeFax channel lists are carrier frequencies.
return 'carrier' return "carrier"
def resolve_tuning_frequency_khz( def resolve_tuning_frequency_khz(
listed_frequency_khz: float, listed_frequency_khz: float,
station_callsign: str = '', station_callsign: str = "",
frequency_reference: str = 'auto', frequency_reference: str = "auto",
) -> tuple[float, str, bool]: ) -> tuple[float, str, bool]:
"""Resolve listed frequency to the actual USB dial frequency. """Resolve listed frequency to the actual USB dial frequency.
@@ -86,75 +94,75 @@ def resolve_tuning_frequency_khz(
""" """
listed = float(listed_frequency_khz) listed = float(listed_frequency_khz)
if listed <= 0: if listed <= 0:
raise ValueError('frequency_khz must be greater than zero') raise ValueError("frequency_khz must be greater than zero")
requested_ref = _normalize_frequency_reference(frequency_reference) requested_ref = _normalize_frequency_reference(frequency_reference)
resolved_ref = requested_ref resolved_ref = requested_ref
if requested_ref == 'auto': if requested_ref == "auto":
station = get_station(station_callsign) if station_callsign else None station = get_station(station_callsign) if station_callsign else None
if station: if station:
resolved_ref = _station_frequency_reference(station, listed) resolved_ref = _station_frequency_reference(station, listed)
else: else:
# For ad-hoc frequencies (no station metadata), treat input as dial. # For ad-hoc frequencies (no station metadata), treat input as dial.
resolved_ref = 'dial' resolved_ref = "dial"
if resolved_ref == 'carrier': if resolved_ref == "carrier":
tuned = round(listed - WEFAX_USB_ALIGNMENT_OFFSET_KHZ, 3) tuned = round(listed - WEFAX_USB_ALIGNMENT_OFFSET_KHZ, 3)
if tuned <= 0: if tuned <= 0:
raise ValueError('frequency_khz too low after USB alignment offset') raise ValueError("frequency_khz too low after USB alignment offset")
return tuned, resolved_ref, True return tuned, resolved_ref, True
return listed, resolved_ref, False return listed, resolved_ref, False
def get_current_broadcasts(callsign: str) -> list[dict]: def get_current_broadcasts(callsign: str) -> list[dict]:
"""Return schedule entries closest to the current UTC time. """Return schedule entries closest to the current UTC time.
Returns up to 3 entries: the most recent past broadcast and the Returns up to 3 entries: the most recent past broadcast and the
next two upcoming ones, annotated with ``minutes_until`` or next two upcoming ones, annotated with ``minutes_until`` or
``minutes_ago`` relative to now. ``minutes_ago`` relative to now.
""" """
station = get_station(callsign) station = get_station(callsign)
if not station: if not station:
return [] return []
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
current_minutes = now.hour * 60 + now.minute current_minutes = now.hour * 60 + now.minute
schedule = station.get('schedule', []) schedule = station.get("schedule", [])
if not schedule: if not schedule:
return [] return []
# Convert schedule times to minutes-since-midnight for comparison # Convert schedule times to minutes-since-midnight for comparison
entries: list[tuple[int, dict]] = [] entries: list[tuple[int, dict]] = []
for entry in schedule: for entry in schedule:
parts = entry['utc'].split(':') parts = entry["utc"].split(":")
mins = int(parts[0]) * 60 + int(parts[1]) mins = int(parts[0]) * 60 + int(parts[1])
entries.append((mins, entry)) entries.append((mins, entry))
entries.sort(key=lambda x: x[0]) entries.sort(key=lambda x: x[0])
# Find closest entries relative to now # Find closest entries relative to now
results = [] results = []
for mins, entry in entries: for mins, entry in entries:
diff = mins - current_minutes diff = mins - current_minutes
# Wrap around midnight # Wrap around midnight
if diff < -720: if diff < -720:
diff += 1440 diff += 1440
elif diff > 720: elif diff > 720:
diff -= 1440 diff -= 1440
annotated = dict(entry) annotated = dict(entry)
if diff >= 0: if diff >= 0:
annotated['minutes_until'] = diff annotated["minutes_until"] = diff
else: else:
annotated['minutes_ago'] = abs(diff) annotated["minutes_ago"] = abs(diff)
annotated['_sort_key'] = abs(diff) annotated["_sort_key"] = abs(diff)
results.append(annotated) results.append(annotated)
results.sort(key=lambda x: x['_sort_key']) results.sort(key=lambda x: x["_sort_key"])
# Return 3 nearest entries, clean up sort key # Return 3 nearest entries, clean up sort key
for r in results: for r in results:
r.pop('_sort_key', None) r.pop("_sort_key", None)
return results[:3] return results[:3]