From bf44efbfd969bbbb094c76e54926ee0a8d332daf Mon Sep 17 00:00:00 2001 From: Mitchell Moss Date: Wed, 29 Apr 2026 09:18:50 -0400 Subject: [PATCH 1/3] feat: update repeater location from GPS fix --- config.yaml.example | 6 ++ repeater/config.py | 2 + repeater/data_acquisition/gps_service.py | 129 +++++++++++++++++++++++ repeater/main.py | 54 +++++++++- repeater/web/api_endpoints.py | 10 ++ repeater/web/openapi.yaml | 24 +++++ tests/test_gps_service.py | 61 +++++++++++ 7 files changed, 285 insertions(+), 1 deletion(-) diff --git a/config.yaml.example b/config.yaml.example index 1140299..67012e2 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -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 diff --git a/repeater/config.py b/repeater/config.py index 01c3a3e..87a9666 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -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 diff --git a/repeater/data_acquisition/gps_service.py b/repeater/data_acquisition/gps_service.py index 5db4302..6c20913 100644 --- a/repeater/data_acquisition/gps_service.py +++ b/repeater/data_acquisition/gps_service.py @@ -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")) diff --git a/repeater/main.py b/repeater/main.py index 4cc8b5f..2b8b46c 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -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: diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 86bc3b8..e1da5da 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -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": [], diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 1dff299..444e751 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -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: diff --git a/tests/test_gps_service.py b/tests/test_gps_service.py index c110bcc..90cc7e6 100644 --- a/tests/test_gps_service.py +++ b/tests/test_gps_service.py @@ -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" From da8f83964a631aeefc4a5f0334c34a0756c9f646 Mon Sep 17 00:00:00 2001 From: Mitchell Moss Date: Wed, 29 Apr 2026 09:54:22 -0400 Subject: [PATCH 2/3] Fix GPS location updates and status reporting --- repeater/data_acquisition/gps_service.py | 10 ++++- tests/test_gps_service.py | 53 +++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/repeater/data_acquisition/gps_service.py b/repeater/data_acquisition/gps_service.py index 6c20913..0720d73 100644 --- a/repeater/data_acquisition/gps_service.py +++ b/repeater/data_acquisition/gps_service.py @@ -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: @@ -615,7 +619,10 @@ class GPSService: 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) + gps_config.get( + "update_repeater_location_from_fix", + gps_config.get("use_gps_for_repeater_location", True), + ) ) self.location_update_interval_seconds = max( 1.0, float(gps_config.get("location_update_interval_seconds", 600.0)) @@ -1073,6 +1080,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(), diff --git a/tests/test_gps_service.py b/tests/test_gps_service.py index 90cc7e6..c08d2fb 100644 --- a/tests/test_gps_service.py +++ b/tests/test_gps_service.py @@ -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( { @@ -484,3 +514,24 @@ def test_gps_service_does_not_update_repeater_location_without_valid_fix(): assert location_updates == [] assert service.get_snapshot()["location_update"]["state"] == "waiting_for_fix" + + +def test_gps_service_honors_legacy_use_gps_for_repeater_location_flag(): + location_updates = [] + service = GPSService( + { + "gps": { + "enabled": True, + "time_sync_enabled": False, + "use_gps_for_repeater_location": 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" From d83cb61fe7cc10fbbc0b47935e5dd290fa9a7da8 Mon Sep 17 00:00:00 2001 From: Mitchell Moss Date: Wed, 29 Apr 2026 10:45:53 -0400 Subject: [PATCH 3/3] Make GPS location persistence opt-in and fuzzed --- config.yaml.example | 10 +++++--- repeater/config.py | 2 +- repeater/data_acquisition/gps_service.py | 25 ++++++++++-------- tests/test_gps_service.py | 32 ++++++++++++++++++++++-- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/config.yaml.example b/config.yaml.example index 67012e2..397d67d 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -176,10 +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 + # 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 diff --git a/repeater/config.py b/repeater/config.py index 87a9666..bbdec1a 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -98,7 +98,7 @@ 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, + "update_repeater_location_from_fix": False, "location_update_interval_seconds": 600.0, } diff --git a/repeater/data_acquisition/gps_service.py b/repeater/data_acquisition/gps_service.py index 0720d73..e53355b 100644 --- a/repeater/data_acquisition/gps_service.py +++ b/repeater/data_acquisition/gps_service.py @@ -619,10 +619,7 @@ class GPSService: 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", - gps_config.get("use_gps_for_repeater_location", True), - ) + 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)) @@ -879,22 +876,30 @@ class GPSService: 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": latitude, - "longitude": longitude, + "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=latitude, - longitude=longitude, + latitude=effective_latitude, + longitude=effective_longitude, error=f"{type(exc).__name__}: {exc}", ) logger.warning("GPS repeater location update failed: %s", exc) @@ -902,8 +907,8 @@ class GPSService: self._record_location_update_status( state="updated" if updated else "skipped", - latitude=latitude, - longitude=longitude, + latitude=effective_latitude, + longitude=effective_longitude, success=updated, ) diff --git a/tests/test_gps_service.py b/tests/test_gps_service.py index c08d2fb..12049b5 100644 --- a/tests/test_gps_service.py +++ b/tests/test_gps_service.py @@ -464,6 +464,7 @@ def test_gps_service_updates_repeater_location_from_valid_fix(monkeypatch): "gps": { "enabled": True, "time_sync_enabled": False, + "update_repeater_location_from_fix": True, "location_update_interval_seconds": 600.0, } }, @@ -503,6 +504,7 @@ def test_gps_service_does_not_update_repeater_location_without_valid_fix(): "gps": { "enabled": True, "time_sync_enabled": False, + "update_repeater_location_from_fix": True, } }, location_update_callback=lambda payload: location_updates.append(payload) or True, @@ -516,14 +518,13 @@ def test_gps_service_does_not_update_repeater_location_without_valid_fix(): assert service.get_snapshot()["location_update"]["state"] == "waiting_for_fix" -def test_gps_service_honors_legacy_use_gps_for_repeater_location_flag(): +def test_gps_service_location_update_is_opt_in_by_default(): location_updates = [] service = GPSService( { "gps": { "enabled": True, "time_sync_enabled": False, - "use_gps_for_repeater_location": False, } }, location_update_callback=lambda payload: location_updates.append(payload) or True, @@ -535,3 +536,30 @@ def test_gps_service_honors_legacy_use_gps_for_repeater_location_flag(): 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