mirror of
https://github.com/smittix/intercept.git
synced 2026-06-10 23:13:31 -07:00
Merge main into misc-fixes and address PR #202 review
Sync with upstream main and fix required items from review: - updateTimelineLabels() now uses InterceptTime API (getTimezone/getIANA) instead of the stale selectedTimezone/TZ_MAP globals that were removed during the earlier InterceptTime refactor — fixes ReferenceError on TZ change and pass refresh. - Remove profiles: [basic] from the intercept service in docker-compose.yml so bare `docker compose up -d` still starts the main service. Profile-gated services (intercept-history, adsb_db) stay as-is.
This commit is contained in:
+841
-784
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -161,7 +161,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with Airspy.
|
||||
@@ -184,6 +186,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'biastee', '1'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
+5
-1
@@ -165,7 +165,9 @@ class CommandBuilder(ABC):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS decoder command for vessel tracking.
|
||||
@@ -175,6 +177,8 @@ class CommandBuilder(ABC):
|
||||
gain: Gain in dB (None for auto)
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
tcp_port: TCP port for JSON output server
|
||||
udp_host: Optional host to forward NMEA 0183 sentences via UDP
|
||||
udp_port: UDP port for NMEA forwarding (required if udp_host set)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
|
||||
+6
-1
@@ -161,7 +161,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with HackRF.
|
||||
@@ -184,6 +186,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'biastee', '1'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
@@ -140,7 +140,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with LimeSDR.
|
||||
@@ -161,6 +163,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-gr', 'tuner', str(int(gain))])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
+35
-1
@@ -75,6 +75,35 @@ def enable_bias_t_via_rtl_biast(device_index: int = 0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def disable_bias_t_via_rtl_biast(device_index: int = 0) -> bool:
|
||||
"""Disable bias-t power using rtl_biast (RTL-SDR Blog drivers).
|
||||
|
||||
Should be called when stopping an SDR mode that had bias-t enabled,
|
||||
since the hardware register persists after the device is closed.
|
||||
|
||||
Returns True if bias-t was disabled successfully.
|
||||
"""
|
||||
rtl_biast_path = get_tool_path('rtl_biast') or 'rtl_biast'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[rtl_biast_path, '-b', '0', '-d', str(device_index)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Bias-t disabled via rtl_biast on device {device_index}")
|
||||
return True
|
||||
logger.warning(f"rtl_biast failed (exit {result.returncode}): {result.stderr.strip()}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.warning("rtl_biast not found — bias-t may remain on after stop")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to disable bias-t via rtl_biast: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None:
|
||||
"""Detect the correct bias-t flag for the installed dump1090 variant.
|
||||
|
||||
@@ -281,7 +310,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking.
|
||||
@@ -308,6 +339,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'BIASTEE', 'on'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
@@ -139,7 +139,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with SDRPlay.
|
||||
@@ -162,6 +164,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'biastee', '1'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
+8
-3
@@ -93,11 +93,16 @@ def validate_rtl_tcp_port(port: Any) -> int:
|
||||
|
||||
|
||||
def validate_gain(gain: Any) -> float:
|
||||
"""Validate and return gain value."""
|
||||
"""Validate and return gain value.
|
||||
|
||||
Accepts 0 (auto/minimum) up to 102 dB to cover multi-stage SDRs
|
||||
(HackRF LNA+VGA = 40+62 = 102 dB max). RTL-SDR caps at 50 dB
|
||||
internally; values above 50 are only meaningful for HackRF/LimeSDR.
|
||||
"""
|
||||
try:
|
||||
gain_float = float(gain)
|
||||
if not 0 <= gain_float <= 50:
|
||||
raise ValueError(f"Gain must be between 0 and 50, got {gain_float}")
|
||||
if not 0 <= gain_float <= 102:
|
||||
raise ValueError(f"Gain must be between 0 and 102, got {gain_float}")
|
||||
return gain_float
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid gain: {gain}") from e
|
||||
|
||||
+103
-95
@@ -3,36 +3,44 @@
|
||||
Loads and caches station data from data/wefax_stations.json. Provides
|
||||
lookup by callsign and current-broadcast filtering based on UTC time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_stations_cache: list[dict] | None = None
|
||||
_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
|
||||
|
||||
_STATIONS_PATH = Path(__file__).resolve().parent.parent / 'data' / 'wefax_stations.json'
|
||||
|
||||
|
||||
def load_stations() -> list[dict]:
|
||||
"""Load all WeFax stations from JSON, caching on first call."""
|
||||
global _stations_cache, _stations_by_callsign
|
||||
|
||||
if _stations_cache is not None:
|
||||
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
|
||||
|
||||
|
||||
_STATIONS_PATH = Path(__file__).resolve().parent.parent / "data" / "wefax_stations.json"
|
||||
|
||||
|
||||
def load_stations() -> list[dict]:
|
||||
"""Load all WeFax stations from JSON, caching on first call."""
|
||||
global _stations_cache, _stations_by_callsign
|
||||
|
||||
if _stations_cache is not None:
|
||||
return _stations_cache
|
||||
|
||||
if not _STATIONS_PATH.exists():
|
||||
log.warning("wefax_stations.json not found at %s", _STATIONS_PATH)
|
||||
_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:
|
||||
"""Get a single station by callsign."""
|
||||
load_stations()
|
||||
@@ -41,38 +49,38 @@ def get_station(callsign: str) -> dict | None:
|
||||
|
||||
def _normalize_frequency_reference(value: str | None) -> str:
|
||||
"""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:
|
||||
choices = ', '.join(sorted(_VALID_FREQUENCY_REFERENCES))
|
||||
raise ValueError(f'frequency_reference must be one of: {choices}')
|
||||
choices = ", ".join(sorted(_VALID_FREQUENCY_REFERENCES))
|
||||
raise ValueError(f"frequency_reference must be one of: {choices}")
|
||||
return reference
|
||||
|
||||
|
||||
def _station_frequency_reference(station: dict, listed_frequency_khz: float) -> str:
|
||||
"""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:
|
||||
entry_khz = float(entry.get('khz'))
|
||||
entry_khz = float(entry.get("khz"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if abs(entry_khz - listed_frequency_khz) > 0.001:
|
||||
continue
|
||||
entry_ref = str(entry.get('reference', '')).strip().lower()
|
||||
if entry_ref in ('carrier', 'dial'):
|
||||
entry_ref = str(entry.get("reference", "")).strip().lower()
|
||||
if entry_ref in ("carrier", "dial"):
|
||||
return entry_ref
|
||||
|
||||
station_ref = str(station.get('frequency_reference', '')).strip().lower()
|
||||
if station_ref in ('carrier', 'dial'):
|
||||
station_ref = str(station.get("frequency_reference", "")).strip().lower()
|
||||
if station_ref in ("carrier", "dial"):
|
||||
return station_ref
|
||||
|
||||
# Most published marine WeFax channel lists are carrier frequencies.
|
||||
return 'carrier'
|
||||
return "carrier"
|
||||
|
||||
|
||||
def resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz: float,
|
||||
station_callsign: str = '',
|
||||
frequency_reference: str = 'auto',
|
||||
station_callsign: str = "",
|
||||
frequency_reference: str = "auto",
|
||||
) -> tuple[float, str, bool]:
|
||||
"""Resolve listed frequency to the actual USB dial frequency.
|
||||
|
||||
@@ -86,75 +94,75 @@ def resolve_tuning_frequency_khz(
|
||||
"""
|
||||
listed = float(listed_frequency_khz)
|
||||
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)
|
||||
resolved_ref = requested_ref
|
||||
|
||||
if requested_ref == 'auto':
|
||||
if requested_ref == "auto":
|
||||
station = get_station(station_callsign) if station_callsign else None
|
||||
if station:
|
||||
resolved_ref = _station_frequency_reference(station, listed)
|
||||
else:
|
||||
# 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)
|
||||
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 listed, resolved_ref, False
|
||||
|
||||
|
||||
def get_current_broadcasts(callsign: str) -> list[dict]:
|
||||
"""Return schedule entries closest to the current UTC time.
|
||||
|
||||
Returns up to 3 entries: the most recent past broadcast and the
|
||||
next two upcoming ones, annotated with ``minutes_until`` or
|
||||
``minutes_ago`` relative to now.
|
||||
"""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
|
||||
schedule = station.get('schedule', [])
|
||||
if not schedule:
|
||||
return []
|
||||
|
||||
# Convert schedule times to minutes-since-midnight for comparison
|
||||
entries: list[tuple[int, dict]] = []
|
||||
for entry in schedule:
|
||||
parts = entry['utc'].split(':')
|
||||
mins = int(parts[0]) * 60 + int(parts[1])
|
||||
entries.append((mins, entry))
|
||||
entries.sort(key=lambda x: x[0])
|
||||
|
||||
# Find closest entries relative to now
|
||||
results = []
|
||||
for mins, entry in entries:
|
||||
diff = mins - current_minutes
|
||||
# Wrap around midnight
|
||||
if diff < -720:
|
||||
diff += 1440
|
||||
elif diff > 720:
|
||||
diff -= 1440
|
||||
|
||||
annotated = dict(entry)
|
||||
if diff >= 0:
|
||||
annotated['minutes_until'] = diff
|
||||
else:
|
||||
annotated['minutes_ago'] = abs(diff)
|
||||
annotated['_sort_key'] = abs(diff)
|
||||
results.append(annotated)
|
||||
|
||||
results.sort(key=lambda x: x['_sort_key'])
|
||||
|
||||
# Return 3 nearest entries, clean up sort key
|
||||
for r in results:
|
||||
r.pop('_sort_key', None)
|
||||
return results[:3]
|
||||
|
||||
|
||||
def get_current_broadcasts(callsign: str) -> list[dict]:
|
||||
"""Return schedule entries closest to the current UTC time.
|
||||
|
||||
Returns up to 3 entries: the most recent past broadcast and the
|
||||
next two upcoming ones, annotated with ``minutes_until`` or
|
||||
``minutes_ago`` relative to now.
|
||||
"""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
|
||||
schedule = station.get("schedule", [])
|
||||
if not schedule:
|
||||
return []
|
||||
|
||||
# Convert schedule times to minutes-since-midnight for comparison
|
||||
entries: list[tuple[int, dict]] = []
|
||||
for entry in schedule:
|
||||
parts = entry["utc"].split(":")
|
||||
mins = int(parts[0]) * 60 + int(parts[1])
|
||||
entries.append((mins, entry))
|
||||
entries.sort(key=lambda x: x[0])
|
||||
|
||||
# Find closest entries relative to now
|
||||
results = []
|
||||
for mins, entry in entries:
|
||||
diff = mins - current_minutes
|
||||
# Wrap around midnight
|
||||
if diff < -720:
|
||||
diff += 1440
|
||||
elif diff > 720:
|
||||
diff -= 1440
|
||||
|
||||
annotated = dict(entry)
|
||||
if diff >= 0:
|
||||
annotated["minutes_until"] = diff
|
||||
else:
|
||||
annotated["minutes_ago"] = abs(diff)
|
||||
annotated["_sort_key"] = abs(diff)
|
||||
results.append(annotated)
|
||||
|
||||
results.sort(key=lambda x: x["_sort_key"])
|
||||
|
||||
# Return 3 nearest entries, clean up sort key
|
||||
for r in results:
|
||||
r.pop("_sort_key", None)
|
||||
return results[:3]
|
||||
|
||||
Reference in New Issue
Block a user