diff --git a/talkingq-url/banban/service/location.py b/talkingq-url/banban/service/location.py index 2ca064f..ffae038 100644 --- a/talkingq-url/banban/service/location.py +++ b/talkingq-url/banban/service/location.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime +import math from typing import Any from services.database_service_base import DatabaseServiceBase @@ -23,6 +24,10 @@ ADDRESS_RESOLVE_PENDING = 0 ADDRESS_RESOLVE_SUCCESS = 1 ADDRESS_RESOLVE_FAILED = 2 INVALID_COORDINATE_EPSILON = 0.0000001 +GCJ02_COORD_TYPE = "gcj02" +WGS84_COORD_TYPES = {"", "wgs84", "gps"} +WGS84_TO_GCJ02_A = 6378245.0 +WGS84_TO_GCJ02_EE = 0.00669342162296594323 def is_valid_coordinate_pair(latitude: float | None, longitude: float | None) -> bool: @@ -38,6 +43,62 @@ def is_valid_coordinate_pair(latitude: float | None, longitude: float | None) -> return not (abs(lat) < INVALID_COORDINATE_EPSILON and abs(lng) < INVALID_COORDINATE_EPSILON) +def normalize_location_coordinate( + *, + latitude: float, + longitude: float, + coord_type: str | None, +) -> tuple[float, float, str]: + normalized_type = str(coord_type or "").strip().lower() + if normalized_type == GCJ02_COORD_TYPE: + return float(latitude), float(longitude), GCJ02_COORD_TYPE + if normalized_type in WGS84_COORD_TYPES: + lng, lat = wgs84_to_gcj02(float(longitude), float(latitude)) + return lat, lng, GCJ02_COORD_TYPE + return float(latitude), float(longitude), normalized_type + + +def wgs84_to_gcj02(longitude: float, latitude: float) -> tuple[float, float]: + if _is_out_of_china(longitude, latitude): + return longitude, latitude + + dlat = _transform_lat(longitude - 105.0, latitude - 35.0) + dlng = _transform_lng(longitude - 105.0, latitude - 35.0) + radlat = latitude / 180.0 * math.pi + magic = math.sin(radlat) + magic = 1 - WGS84_TO_GCJ02_EE * magic * magic + sqrtmagic = math.sqrt(magic) + dlat = (dlat * 180.0) / ( + (WGS84_TO_GCJ02_A * (1 - WGS84_TO_GCJ02_EE)) / (magic * sqrtmagic) * math.pi + ) + dlng = (dlng * 180.0) / ( + WGS84_TO_GCJ02_A / sqrtmagic * math.cos(radlat) * math.pi + ) + return longitude + dlng, latitude + dlat + + +def _is_out_of_china(longitude: float, latitude: float) -> bool: + return not (72.004 <= longitude <= 137.8347 and 0.8293 <= latitude <= 55.8271) + + +def _transform_lat(x: float, y: float) -> float: + ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + ret += 0.2 * math.sqrt(abs(x)) + ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 + ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0 + ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0 + return ret + + +def _transform_lng(x: float, y: float) -> float: + ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + ret += 0.1 * math.sqrt(abs(x)) + ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 + ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0 + ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0 + return ret + + class LocationService(DatabaseServiceBase): def __init__(self): super().__init__(service_name="location_service") @@ -176,10 +237,16 @@ 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( + latitude=float(latitude), + longitude=float(longitude), + coord_type=coord_type, + ) + payload = _MQTTLocationPayload( - coord_type=(coord_type or "gcj02").strip() or "gcj02", - lat=float(latitude), - lng=float(longitude), + 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 new file mode 100644 index 0000000..0d899b7 --- /dev/null +++ b/talkingq-url/tests/test_location_coordinate_normalization.py @@ -0,0 +1,51 @@ +import pytest + +from banban.service.location import ( + LocationService, + normalize_location_coordinate, +) + + +def test_normalize_default_device_location_from_wgs84_to_gcj02(): + lat, lng, coord_type = normalize_location_coordinate( + latitude=30.223122166666666, + longitude=120.25837883333334, + coord_type=None, + ) + + assert coord_type == "gcj02" + assert lat == pytest.approx(30.22066658053894) + assert lng == pytest.approx(120.26284572518867) + + +def test_normalize_explicit_gcj02_does_not_convert_again(): + lat, lng, coord_type = normalize_location_coordinate( + latitude=30.223122166666666, + longitude=120.25837883333334, + coord_type="gcj02", + ) + + assert coord_type == "gcj02" + assert lat == pytest.approx(30.223122166666666) + assert lng == pytest.approx(120.25837883333334) + + +@pytest.mark.asyncio +async def test_mqtt_location_report_ignores_zero_zero_before_normalization(monkeypatch): + service = LocationService() + sessions = [] + + async def fail_get_session(): + sessions.append("called") + raise AssertionError("invalid location should not open a database session") + + monkeypatch.setattr(service, "get_session", fail_get_session) + + row = await service.report_mqtt_device_location( + device_id="TalkingQ_device001", + latitude=0, + longitude=0, + ) + + assert row is None + assert sessions == []