mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-05-07 05:54:28 +02:00
feat: update repeater location from GPS fix
This commit is contained in:
@@ -176,6 +176,12 @@ gps:
|
||||
time_sync_min_offset_seconds: 1.0
|
||||
time_sync_min_valid_year: 2020
|
||||
|
||||
# Feed a valid GPS fix back into repeater.latitude/repeater.longitude so
|
||||
# pyMC Repeater adverts and pyMC Console location details follow the receiver.
|
||||
# Updates are throttled to avoid rewriting config on every NMEA sentence.
|
||||
update_repeater_location_from_fix: true
|
||||
location_update_interval_seconds: 600.0
|
||||
|
||||
# Mesh Network Configuration
|
||||
mesh:
|
||||
# Unscoped flood policy - controls whether the repeater allows or denies unscoped flooding
|
||||
|
||||
@@ -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": True,
|
||||
"location_update_interval_seconds": 600.0,
|
||||
}
|
||||
|
||||
# Ensure repeater.security exists with defaults for upgrades from older configs
|
||||
|
||||
@@ -581,6 +581,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 +614,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", True)
|
||||
)
|
||||
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 +683,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 +702,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 +782,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 +794,112 @@ 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
|
||||
|
||||
self._last_location_update_monotonic = now_monotonic
|
||||
payload = {
|
||||
"latitude": latitude,
|
||||
"longitude": 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 {}),
|
||||
}
|
||||
try:
|
||||
updated = bool(self._location_update_callback(payload))
|
||||
except Exception as exc:
|
||||
self._record_location_update_status(
|
||||
state="error",
|
||||
latitude=latitude,
|
||||
longitude=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=latitude,
|
||||
longitude=longitude,
|
||||
success=updated,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_manual_position(repeater_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
latitude = _to_float(repeater_config.get("latitude"))
|
||||
|
||||
+53
-1
@@ -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:
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -423,3 +423,64 @@ 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,
|
||||
"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,
|
||||
}
|
||||
},
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user