Merge pull request #211 from mitchellmoss/feat-gps-location-update

Update repeater location from GPS fix
This commit is contained in:
Lloyd
2026-04-29 16:10:27 +01:00
committed by GitHub
7 changed files with 380 additions and 2 deletions
+8
View File
@@ -176,6 +176,14 @@ gps:
time_sync_min_offset_seconds: 1.0
time_sync_min_valid_year: 2020
# Opt-in: feed a valid GPS fix back into repeater.latitude/repeater.longitude
# so pyMC Repeater adverts and pyMC Console location details follow the
# receiver. repeater_location_precision_digits is applied before saving so
# deployments can fuzz the stored/advertised location. Updates are throttled
# to avoid rewriting config on every NMEA sentence.
update_repeater_location_from_fix: false
location_update_interval_seconds: 600.0
# Mesh Network Configuration
mesh:
# Unscoped flood policy - controls whether the repeater allows or denies unscoped flooding
+2
View File
@@ -98,6 +98,8 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
"time_sync_interval_seconds": 3600.0,
"time_sync_min_offset_seconds": 1.0,
"time_sync_min_valid_year": 2020,
"update_repeater_location_from_fix": False,
"location_update_interval_seconds": 600.0,
}
# Ensure repeater.security exists with defaults for upgrades from older configs
+142
View File
@@ -359,6 +359,8 @@ class NMEAParser:
status = (self._field(fields, 2) or "").upper()
self.fix["status"] = "valid" if status == "A" else "invalid" if status == "V" else status
self.fix["valid"] = status == "A" or bool(self.fix.get("quality"))
if status == "A" and self.fix.get("quality") is None:
self.fix["quality_label"] = "RMC valid"
latitude = _parse_lat_lon(self._field(fields, 3), self._field(fields, 4))
longitude = _parse_lat_lon(self._field(fields, 5), self._field(fields, 6))
@@ -535,6 +537,8 @@ class NMEAParser:
with self._lock:
now = time.time()
age = now - self.last_update if self.last_update else None
if age is not None and age < 0:
age = 0.0
stale = age is None or age > self.stale_after_seconds
fix_valid = bool(self.fix.get("valid")) and not stale
if self.last_error:
@@ -581,6 +585,7 @@ class GPSService:
*,
clock_setter: Optional[Callable[[datetime], None]] = None,
time_provider: Optional[Callable[[], float]] = None,
location_update_callback: Optional[Callable[[Dict[str, Any]], bool]] = None,
):
gps_config = config.get("gps", {}) if isinstance(config, dict) else {}
repeater_config = config.get("repeater", {}) if isinstance(config, dict) else {}
@@ -613,6 +618,25 @@ class GPSService:
self.time_sync_min_valid_year = int(gps_config.get("time_sync_min_valid_year", 2020))
self._clock_setter = clock_setter or _set_system_clock_from_datetime
self._time_provider = time_provider or time.time
self.location_update_enabled = bool(
gps_config.get("update_repeater_location_from_fix", False)
)
self.location_update_interval_seconds = max(
1.0, float(gps_config.get("location_update_interval_seconds", 600.0))
)
self._location_update_callback = location_update_callback
self._location_update_lock = threading.RLock()
self._last_location_update_monotonic: Optional[float] = None
self._location_update_status: Dict[str, Any] = {
"enabled": self.location_update_enabled,
"state": "disabled" if not self.location_update_enabled else "waiting_for_fix",
"last_attempt": None,
"last_success": None,
"last_error": None,
"last_latitude": None,
"last_longitude": None,
"interval_seconds": self.location_update_interval_seconds,
}
self._time_sync_lock = threading.RLock()
self._last_time_sync_monotonic: Optional[float] = None
self._time_sync_status: Dict[str, Any] = {
@@ -663,6 +687,7 @@ class GPSService:
accepted = self.parser.ingest_sentence(sentence)
if accepted:
self._maybe_sync_system_time()
self._maybe_update_repeater_location()
return accepted
def get_summary(self) -> Dict[str, Any]:
@@ -681,6 +706,7 @@ class GPSService:
"gps_position": snapshot.get("gps_position"),
"manual_position": snapshot.get("manual_position"),
"time_sync": snapshot.get("time_sync"),
"location_update": snapshot.get("location_update"),
"satellites": {
"used_count": snapshot["satellites"].get("used_count"),
"in_view_count": snapshot["satellites"].get("in_view_count"),
@@ -760,6 +786,7 @@ class GPSService:
},
"time_sync": self._get_time_sync_status(snapshot),
"repeater_location": self._resolve_repeater_location(snapshot),
"location_update": self._get_location_update_status(snapshot),
}
)
if not self.enabled:
@@ -771,6 +798,120 @@ class GPSService:
snapshot["status"]["state"] = "error"
return snapshot
def _get_location_update_status(self, snapshot: Dict[str, Any]) -> Dict[str, Any]:
with self._location_update_lock:
status = deepcopy(self._location_update_status)
if not self.enabled or not self.location_update_enabled:
status["state"] = "disabled"
return status
if self._location_update_callback is None:
status["state"] = "unconfigured"
return status
if status.get("state") in ("updated", "error", "skipped"):
return status
if not snapshot.get("status", {}).get("fix_valid"):
status["state"] = "waiting_for_fix"
else:
position = snapshot.get("gps_position") or snapshot.get("position") or {}
latitude = _to_float(position.get("latitude"))
longitude = _to_float(position.get("longitude"))
if not _is_valid_latitude(latitude) or not _is_valid_longitude(longitude):
status["state"] = "waiting_for_position"
elif _is_zero_coordinate(latitude, longitude):
status["state"] = "waiting_for_position"
else:
status["state"] = "ready"
return status
def _record_location_update_status(
self,
*,
state: str,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
error: Optional[str] = None,
success: bool = False,
):
timestamp = datetime.now(timezone.utc).isoformat()
with self._location_update_lock:
self._location_update_status.update(
{
"enabled": self.location_update_enabled,
"state": state,
"last_attempt": timestamp,
"last_error": error,
"last_latitude": latitude,
"last_longitude": longitude,
"interval_seconds": self.location_update_interval_seconds,
}
)
if success:
self._location_update_status["last_success"] = timestamp
def _maybe_update_repeater_location(self):
if not self.enabled or not self.location_update_enabled:
return
if self._location_update_callback is None:
return
now_monotonic = time.monotonic()
with self._location_update_lock:
if (
self._last_location_update_monotonic is not None
and now_monotonic - self._last_location_update_monotonic
< self.location_update_interval_seconds
):
return
snapshot = self.parser.snapshot()
if not snapshot.get("status", {}).get("fix_valid"):
return
position = snapshot.get("position") or {}
latitude = _to_float(position.get("latitude"))
longitude = _to_float(position.get("longitude"))
if not _is_valid_latitude(latitude) or not _is_valid_longitude(longitude):
return
if _is_zero_coordinate(latitude, longitude):
return
effective_latitude = self._apply_precision(latitude)
effective_longitude = self._apply_precision(longitude)
if not _is_valid_latitude(effective_latitude) or not _is_valid_longitude(
effective_longitude
):
return
self._last_location_update_monotonic = now_monotonic
payload = {
"latitude": effective_latitude,
"longitude": effective_longitude,
"altitude_m": _to_float(position.get("altitude_m")),
"fix": deepcopy(snapshot.get("fix") or {}),
"status": deepcopy(snapshot.get("status") or {}),
"time": deepcopy(snapshot.get("time") or {}),
"precision_digits": self.repeater_location_precision_digits,
}
try:
updated = bool(self._location_update_callback(payload))
except Exception as exc:
self._record_location_update_status(
state="error",
latitude=effective_latitude,
longitude=effective_longitude,
error=f"{type(exc).__name__}: {exc}",
)
logger.warning("GPS repeater location update failed: %s", exc)
return
self._record_location_update_status(
state="updated" if updated else "skipped",
latitude=effective_latitude,
longitude=effective_longitude,
success=updated,
)
@staticmethod
def _extract_manual_position(repeater_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
latitude = _to_float(repeater_config.get("latitude"))
@@ -944,6 +1085,7 @@ class GPSService:
offset_seconds=offset_seconds,
success=True,
)
self.parser.last_update = self._time_provider()
logger.info(
"System clock synchronized from GPS time %s (offset %.3fs)",
gps_time.isoformat(),
+53 -1
View File
@@ -264,7 +264,10 @@ class RepeaterDaemon:
)
logger.info("Config manager initialized")
self.gps_service = GPSService(self.config)
self.gps_service = GPSService(
self.config,
location_update_callback=self._update_repeater_location_from_gps,
)
self.gps_service.start()
if self.config.get("gps", {}).get("enabled", False):
logger.info("GPS diagnostics initialized")
@@ -1046,6 +1049,55 @@ class RepeaterDaemon:
logger.error(f"Failed to send advert: {e}", exc_info=True)
return False
def _update_repeater_location_from_gps(self, location: dict) -> bool:
"""Persist the latest valid GPS fix as the repeater's advertised location."""
latitude = location.get("latitude")
longitude = location.get("longitude")
if latitude is None or longitude is None:
return False
repeater_config = self.config.setdefault("repeater", {})
current_latitude = repeater_config.get("latitude")
current_longitude = repeater_config.get("longitude")
try:
if (
current_latitude is not None
and current_longitude is not None
and abs(float(current_latitude) - float(latitude)) < 0.000001
and abs(float(current_longitude) - float(longitude)) < 0.000001
):
return False
except (TypeError, ValueError):
pass
updates = {
"repeater": {
"latitude": float(latitude),
"longitude": float(longitude),
}
}
if self.config_manager:
result = self.config_manager.update_and_save(
updates=updates,
live_update=True,
live_update_sections=["repeater"],
)
if not result.get("success"):
logger.warning(
"GPS location fix could not update repeater config: %s",
result.get("error", "unknown error"),
)
return False
else:
repeater_config.update(updates["repeater"])
logger.info(
"Updated repeater location from GPS fix: latitude=%.6f longitude=%.6f",
latitude,
longitude,
)
return True
def _signal_shutdown(self, sig, loop):
"""Handle SIGTERM/SIGINT by scheduling async shutdown."""
if self._shutdown_started:
+10
View File
@@ -676,6 +676,16 @@ class APIEndpoints:
},
"accuracy": {"hdop": None, "pdop": None, "vdop": None},
"time": {"utc_time": None, "date": None, "datetime_utc": None},
"location_update": {
"enabled": False,
"state": "disabled",
"last_attempt": None,
"last_success": None,
"last_error": None,
"last_latitude": None,
"last_longitude": None,
"interval_seconds": None,
},
"satellites": {
"used_count": None,
"used_prns": [],
+24
View File
@@ -404,6 +404,30 @@ paths:
last_offset_seconds:
type: number
nullable: true
location_update:
type: object
description: GPS-fix-to-repeater-location update status
properties:
enabled:
type: boolean
state:
type: string
enum: [disabled, unconfigured, waiting_for_fix, waiting_for_position, ready, updated, skipped, error]
last_attempt:
type: string
nullable: true
last_success:
type: string
nullable: true
last_error:
type: string
nullable: true
last_latitude:
type: number
nullable: true
last_longitude:
type: number
nullable: true
satellites:
type: object
nmea:
+141 -1
View File
@@ -113,6 +113,37 @@ def test_gps_service_file_source_reads_nmea_lines(tmp_path):
assert snapshot["satellites"]["used_count"] == 5
def test_rmc_only_fix_has_non_conflicting_quality_label():
parser = NMEAParser()
assert parser.ingest_sentence(
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
)
snapshot = parser.snapshot()
assert snapshot["status"]["state"] == "valid_fix"
assert snapshot["fix"]["valid"] is True
assert snapshot["fix"]["quality"] is None
assert snapshot["fix"]["quality_label"] == "RMC valid"
assert snapshot["position"]["latitude"] == 42.83538333
assert snapshot["position"]["longitude"] == -71.1076
def test_snapshot_clamps_negative_age_after_system_clock_step():
parser = NMEAParser()
assert parser.ingest_sentence(
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
)
parser.last_update = time.time() + 120
snapshot = parser.snapshot()
assert snapshot["status"]["state"] == "valid_fix"
assert snapshot["status"]["age_seconds"] == 0.0
def test_gps_service_uses_manual_location_until_gps_fix():
service = GPSService(
{
@@ -347,7 +378,6 @@ def test_gps_service_reflects_runtime_manual_location_updates():
assert snapshot["gps_position"]["longitude"] == -71.1076
assert snapshot["position_meta"]["source"] == "manual_config"
def test_repeater_location_uses_config_when_gps_opt_in_disabled():
service = GPSService(
{
@@ -423,3 +453,113 @@ def test_repeater_location_falls_back_to_config_without_valid_gps_fix():
assert location["source"] == "config_fallback_no_valid_gps_fix"
assert location["latitude"] == 42.123456
assert location["longitude"] == -71.654321
def test_gps_service_updates_repeater_location_from_valid_fix(monkeypatch):
monotonic_now = [1000.0]
monkeypatch.setattr(_MODULE.time, "monotonic", lambda: monotonic_now[0])
location_updates = []
service = GPSService(
{
"gps": {
"enabled": True,
"time_sync_enabled": False,
"update_repeater_location_from_fix": True,
"location_update_interval_seconds": 600.0,
}
},
location_update_callback=lambda payload: location_updates.append(payload) or True,
)
assert service.ingest_sentence(
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,")
)
assert len(location_updates) == 1
assert location_updates[0]["latitude"] == 42.83538333
assert location_updates[0]["longitude"] == -71.1076
snapshot = service.get_snapshot()
assert snapshot["location_update"]["state"] == "updated"
assert snapshot["location_update"]["last_latitude"] == 42.83538333
assert snapshot["location_update"]["last_longitude"] == -71.1076
assert service.ingest_sentence(
_sentence("GPGGA,010204,4251.000,N,07107.000,W,1,05,1.4,33.0,M,0.0,M,,")
)
assert len(location_updates) == 1
monotonic_now[0] += 601.0
assert service.ingest_sentence(
_sentence("GPGGA,010205,4251.000,N,07107.000,W,1,05,1.4,33.0,M,0.0,M,,")
)
assert len(location_updates) == 2
assert location_updates[1]["latitude"] == 42.85
assert location_updates[1]["longitude"] == -71.11666667
def test_gps_service_does_not_update_repeater_location_without_valid_fix():
location_updates = []
service = GPSService(
{
"gps": {
"enabled": True,
"time_sync_enabled": False,
"update_repeater_location_from_fix": True,
}
},
location_update_callback=lambda payload: location_updates.append(payload) or True,
)
assert service.ingest_sentence(
_sentence("GPGGA,010203,4250.123,N,07106.456,W,0,00,8.8,32.0,M,0.0,M,,")
)
assert location_updates == []
assert service.get_snapshot()["location_update"]["state"] == "waiting_for_fix"
def test_gps_service_location_update_is_opt_in_by_default():
location_updates = []
service = GPSService(
{
"gps": {
"enabled": True,
"time_sync_enabled": False,
}
},
location_update_callback=lambda payload: location_updates.append(payload) or True,
)
assert service.ingest_sentence(
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
)
assert location_updates == []
assert service.get_snapshot()["location_update"]["state"] == "disabled"
def test_gps_service_fuzzes_persisted_repeater_location():
location_updates = []
service = GPSService(
{
"gps": {
"enabled": True,
"time_sync_enabled": False,
"update_repeater_location_from_fix": True,
"repeater_location_precision_digits": 3,
}
},
location_update_callback=lambda payload: location_updates.append(payload) or True,
)
assert service.ingest_sentence(
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,")
)
assert len(location_updates) == 1
assert location_updates[0]["latitude"] == 42.835
assert location_updates[0]["longitude"] == -71.108
assert location_updates[0]["precision_digits"] == 3
snapshot = service.get_snapshot()
assert snapshot["location_update"]["last_latitude"] == 42.835
assert snapshot["location_update"]["last_longitude"] == -71.108