diff --git a/talkingq-url/banban/service/location.py b/talkingq-url/banban/service/location.py index ffae038..4957c01 100644 --- a/talkingq-url/banban/service/location.py +++ b/talkingq-url/banban/service/location.py @@ -130,6 +130,19 @@ class LocationService(DatabaseServiceBase): ) return device_identity, None + payload = self._normalized_location_payload( + coord_type=self._get_explicit_coord_type(payload), + latitude=payload.lat, + longitude=payload.lng, + accuracy_m=payload.accuracy_m, + altitude_m=payload.altitude_m, + speed_mps=payload.speed_mps, + heading_deg=payload.heading_deg, + source=payload.source, + battery_pct=payload.battery_pct, + device_time=payload.device_time, + ) + db_session = await self.get_session() try: dao = LocationDAO(db_session) @@ -191,6 +204,60 @@ class LocationService(DatabaseServiceBase): finally: await db_session.close() + @dataclass(frozen=True) + class _LocationPayload: + coord_type: str + lat: float + lng: float + accuracy_m: int | None + altitude_m: float | None + speed_mps: float | None + heading_deg: int | None + source: int + battery_pct: int | None + device_time: datetime + + def _normalized_location_payload( + self, + *, + coord_type: str | None, + latitude: float, + longitude: float, + accuracy_m: int | None, + altitude_m: float | None, + speed_mps: float | None, + heading_deg: int | None, + source: int, + battery_pct: int | None, + device_time: datetime, + ) -> _LocationPayload: + normalized_lat, normalized_lng, normalized_coord_type = normalize_location_coordinate( + latitude=float(latitude), + longitude=float(longitude), + coord_type=coord_type, + ) + return self._LocationPayload( + coord_type=normalized_coord_type, + lat=normalized_lat, + lng=normalized_lng, + accuracy_m=accuracy_m, + altitude_m=altitude_m, + speed_mps=speed_mps, + heading_deg=heading_deg, + source=source, + battery_pct=battery_pct, + device_time=device_time, + ) + + @staticmethod + def _get_explicit_coord_type(payload: DeviceLocationReportRequest) -> str | None: + fields_set = getattr(payload, "model_fields_set", None) + if fields_set is None: + fields_set = getattr(payload, "__fields_set__", set()) + if "coord_type" not in fields_set: + return None + return payload.coord_type + async def report_mqtt_device_location( self, *, @@ -215,19 +282,6 @@ class LocationService(DatabaseServiceBase): ) return None - @dataclass(frozen=True) - class _MQTTLocationPayload: - coord_type: str - lat: float - lng: float - accuracy_m: int | None - altitude_m: float | None - speed_mps: float | None - heading_deg: int | None - source: int - battery_pct: int | None - device_time: datetime - source_value = self._normalize_location_source(source) db_session = await self.get_session() @@ -237,16 +291,10 @@ class LocationService(DatabaseServiceBase): if not binding_row or binding_row["child_id"] is None: return None - normalized_lat, normalized_lng, normalized_coord_type = normalize_location_coordinate( + payload = self._normalized_location_payload( + coord_type=coord_type, latitude=float(latitude), longitude=float(longitude), - coord_type=coord_type, - ) - - payload = _MQTTLocationPayload( - coord_type=normalized_coord_type, - lat=normalized_lat, - lng=normalized_lng, accuracy_m=accuracy_m, altitude_m=altitude_m, speed_mps=speed_mps, diff --git a/talkingq-url/tests/test_location_coordinate_normalization.py b/talkingq-url/tests/test_location_coordinate_normalization.py index 0d899b7..6af1695 100644 --- a/talkingq-url/tests/test_location_coordinate_normalization.py +++ b/talkingq-url/tests/test_location_coordinate_normalization.py @@ -1,5 +1,6 @@ import pytest +from banban.schemas.location import DeviceLocationReportRequest from banban.service.location import ( LocationService, normalize_location_coordinate, @@ -49,3 +50,68 @@ async def test_mqtt_location_report_ignores_zero_zero_before_normalization(monke assert row is None assert sessions == [] + + +@pytest.mark.asyncio +async def test_http_location_report_without_coord_type_converts_before_persist(monkeypatch): + service = LocationService() + captured = {} + + class FakeDeviceIdentity: + child_id = 9 + child_name = "child" + + class FakeSession: + async def close(self): + captured["closed"] = True + + class FakeLocationDAO: + def __init__(self, session): + captured["session"] = session + + async def report_device_location(self, *, device_id, child_id, payload): + captured["device_id"] = device_id + captured["child_id"] = child_id + captured["payload"] = payload + return { + "child_id": child_id, + "device_id": device_id, + "coord_type": payload.coord_type, + "lat": payload.lat, + "lng": payload.lng, + "should_resolve_address": False, + } + + async def fake_authenticate_device_identity(*, device_id, serial_number): + captured["auth"] = (device_id, serial_number) + return FakeDeviceIdentity() + + async def fake_get_session(): + return FakeSession() + + monkeypatch.setattr( + "banban.service.location.im_service.authenticate_device_identity", + fake_authenticate_device_identity, + ) + monkeypatch.setattr("banban.service.location.LocationDAO", FakeLocationDAO) + monkeypatch.setattr(service, "get_session", fake_get_session) + + _, row = await service.report_device_location( + device_id="TalkingQ_device001", + serial_number="SN001", + payload=DeviceLocationReportRequest( + lat=30.223122166666666, + lng=120.25837883333334, + source=0, + device_time="2026-06-29T00:00:00", + ), + ) + + persisted_payload = captured["payload"] + assert captured["auth"] == ("TalkingQ_device001", "SN001") + assert captured["child_id"] == 9 + assert captured["closed"] is True + assert row["coord_type"] == "gcj02" + assert persisted_payload.coord_type == "gcj02" + assert persisted_payload.lat == pytest.approx(30.22066658053894) + assert persisted_payload.lng == pytest.approx(120.26284572518867)