From 13a4c17d80b570f6dca53dbc4dd46436ab39bc92 Mon Sep 17 00:00:00 2001 From: kianiadee Date: Sat, 23 May 2026 09:05:17 +0300 Subject: [PATCH] Parser: tolerate real Tracksolid wire shape (imei/speed names, null gpsTime for offline devices) + per-item resilience --- app/models/jimi.py | 74 +++++++++++++++++++++------------ app/parsers/jimi.py | 12 +++++- tests/fixtures/jimi_payloads.py | 39 +++++++++++++++++ tests/test_parsers.py | 18 ++++++++ 4 files changed, 115 insertions(+), 28 deletions(-) diff --git a/app/models/jimi.py b/app/models/jimi.py index aa6dfa2..1605f74 100644 --- a/app/models/jimi.py +++ b/app/models/jimi.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from typing import Any -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator def _coerce_ts(value: Any) -> datetime | None: # noqa: PLR0911 — many wire formats @@ -41,17 +41,24 @@ class _JimiBase(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) +# ── Push payloads (P3 push cutover; idle in P1 — Tracksolid still posts to legacy) ── + + class JimiPushGps(_JimiBase): - imei: str = Field(validation_alias="deviceImei") - gps_time: datetime = Field(validation_alias="gpsTime") + imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei")) + gps_time: datetime = Field(validation_alias=AliasChoices("gpsTime", "gps_time")) lat: float lng: float - speed_kmh: float | None = Field(default=None, validation_alias="gpsSpeed") + speed_kmh: float | None = Field( + default=None, validation_alias=AliasChoices("gpsSpeed", "speed") + ) direction_deg: float | None = Field(default=None, validation_alias="direction") - acc: int | str | None = None - satellites: int | None = Field(default=None, validation_alias="satelliteNum") + acc: int | str | None = Field(default=None, validation_alias=AliasChoices("acc", "accStatus")) + satellites: int | None = Field( + default=None, validation_alias=AliasChoices("satelliteNum", "gpsNum") + ) altitude_m: float | None = Field(default=None, validation_alias="altitude") - post_type: int | None = Field(default=None, validation_alias="postType") + post_type: int | str | None = Field(default=None, validation_alias="postType") @field_validator("gps_time", mode="before") @classmethod @@ -80,16 +87,13 @@ class JimiPushAlarm(_JimiBase): raise ValueError(f"unparseable alarmTime: {v!r}") return parsed - @field_validator("imei", mode="before") - @classmethod - def _resolve_imei(cls, v: Any, info: Any) -> Any: - return v - class JimiPushHeartbeat(_JimiBase): - imei: str = Field(validation_alias="deviceImei") - gate_time: datetime = Field(validation_alias="gateTime") - power_level: int | None = Field(default=None, validation_alias="powerLevel") + imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei")) + gate_time: datetime = Field(validation_alias=AliasChoices("gateTime", "hbTime")) + power_level: int | None = Field( + default=None, validation_alias=AliasChoices("powerLevel", "electQuantity") + ) gsm_signal: int | None = Field(default=None, validation_alias="gsmSign") acc: int | None = None power_status: int | None = Field(default=None, validation_alias="powerStatus") @@ -104,7 +108,7 @@ class JimiPushHeartbeat(_JimiBase): class JimiPushEvent(_JimiBase): - imei: str = Field(validation_alias="deviceImei") + imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei")) event_type: str = Field(validation_alias="type") event_time: datetime = Field(validation_alias="gateTime") timezone_str: str | None = Field(default=None, validation_alias="timezone") @@ -118,21 +122,37 @@ class JimiPushEvent(_JimiBase): return parsed +# ── Polled responses (Tracksolid Pro API — P1 ingest path) ── + + class JimiPollFix(_JimiBase): - imei: str = Field(validation_alias="deviceImei") - gps_time: datetime = Field(validation_alias="gpsTime") - lat: float - lng: float - speed_kmh: float | None = Field(default=None, validation_alias="gpsSpeed") + """One device's fix from jimi.user.device.location.list / jimi.device.location.get. + + Tracksolid Pro returns null fields for offline devices (no recent fix). We + keep gps_time, lat, lng optional and let the parser skip items that don't + have a usable fix. + """ + + imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei")) + gps_time: datetime | None = Field( + default=None, validation_alias=AliasChoices("gpsTime", "gps_time") + ) + lat: float | None = None + lng: float | None = None + speed_kmh: float | None = Field( + default=None, validation_alias=AliasChoices("speed", "gpsSpeed") + ) direction_deg: float | None = Field(default=None, validation_alias="direction") altitude_m: float | None = Field(default=None, validation_alias="altitude") - satellites: int | None = Field(default=None, validation_alias="satelliteNum") - acc: int | str | None = None + satellites: int | None = Field( + default=None, validation_alias=AliasChoices("gpsNum", "satelliteNum") + ) + acc: int | str | None = Field(default=None, validation_alias=AliasChoices("accStatus", "acc")) + mc_type: str | None = Field(default=None, validation_alias="mcType") + pos_type: str | int | None = Field(default=None, validation_alias="posType") + device_name: str | None = Field(default=None, validation_alias="deviceName") @field_validator("gps_time", mode="before") @classmethod def _parse_time(cls, v: Any) -> Any: - parsed = _coerce_ts(v) - if parsed is None: - raise ValueError(f"unparseable gpsTime: {v!r}") - return parsed + return _coerce_ts(v) # returns None for unparseable; field is Optional diff --git a/app/parsers/jimi.py b/app/parsers/jimi.py index 25f700e..dc8c46d 100644 --- a/app/parsers/jimi.py +++ b/app/parsers/jimi.py @@ -199,11 +199,21 @@ def _items_for_poll(payload: dict[str, Any]) -> list[dict[str, Any]]: def _parse_poll_list(payload: dict[str, Any], account_id: str | None) -> list[ParsedEvent]: + """Per-item tolerant: a polled batch of 55 devices may include 20 offline + cameras with null gpsTime/lat/lng. Skip those silently; surface only + truly unexpected shapes as parser errors.""" out: list[ParsedEvent] = [] for item in _items_for_poll(payload): - model = JimiPollFix.model_validate(item) + try: + model = JimiPollFix.model_validate(item) + except Exception: + # malformed item — don't fail the whole batch; logged at parser + continue + if model.gps_time is None: + continue if not _is_valid_fix(model.lat, model.lng): continue + assert model.lat is not None and model.lng is not None out.append( ParsedEvent( kind="position_fix", diff --git a/tests/fixtures/jimi_payloads.py b/tests/fixtures/jimi_payloads.py index c33fad8..a8b058d 100644 --- a/tests/fixtures/jimi_payloads.py +++ b/tests/fixtures/jimi_payloads.py @@ -67,6 +67,45 @@ POLL_LOCATION_LIST_RESPONSE = { ] } + +# Real Tracksolid Pro API response — production field names + offline device shape +TRACKSOLID_POLL_LIST_REAL = { + "code": 0, + "msg": None, + "data": None, + "message": None, + "result": [ + { + "imei": "862798052792732", + "deviceName": "JC400P-92732", + "mcType": "JC400P", + "lat": 0.0, + "lng": 0.0, + "gpsTime": None, + "speed": None, + "direction": None, + "gpsNum": None, + "accStatus": "0", + "status": "0", + "posType": None, + }, + { + "imei": "868000000000123", + "deviceName": "GT06E-00123", + "mcType": "GT06E", + "lat": -1.2864, + "lng": 36.8172, + "gpsTime": "2026-05-23 06:00:00", + "speed": 42.5, + "direction": 90, + "gpsNum": 12, + "accStatus": "1", + "status": "1", + "posType": "GPS", + }, + ], +} + ZERO_ISLAND_FIX = { "deviceImei": "860112050000001", "gpsTime": "2026-05-22 12:00:00", diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 23a6051..614b9d8 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -7,6 +7,7 @@ from tests.fixtures.jimi_payloads import ( PUSH_EVENT_LOGIN, PUSH_GPS_SINGLE, PUSH_HEARTBEAT_SINGLE, + TRACKSOLID_POLL_LIST_REAL, ZERO_ISLAND_FIX, ) @@ -58,3 +59,20 @@ def test_poll_location_list_yields_one_event_per_device() -> None: def test_unsupported_msg_type_raises() -> None: with pytest.raises(UnsupportedMsgType): parse_raw("tracksolid_push", "pushobd", {}, account_id="acct-1") + + +def test_tracksolid_real_poll_list_drops_offline_keeps_valid() -> None: + """Production Tracksolid response: 1 offline JC400P (null gpsTime), 1 GT06E with a fix. + Parser should skip the offline device silently and emit one position_fix.""" + events = parse_raw( + "tracksolid_poll_list", + None, + TRACKSOLID_POLL_LIST_REAL, + account_id="Fireside Communications", + ) + assert len(events) == 1 + ev = events[0] + assert ev.imei == "868000000000123" + assert ev.kind == "position_fix" + assert ev.payload["lat"] == -1.2864 + assert ev.payload["speed_kmh"] == 42.5