Parser: tolerate real Tracksolid wire shape (imei/speed names, null gpsTime for offline devices) + per-item resilience
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions

This commit is contained in:
kianiadee 2026-05-23 09:05:17 +03:00
parent 4924552c7f
commit 13a4c17d80
4 changed files with 115 additions and 28 deletions

View file

@ -1,7 +1,7 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any 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 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) 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): class JimiPushGps(_JimiBase):
imei: str = Field(validation_alias="deviceImei") imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei"))
gps_time: datetime = Field(validation_alias="gpsTime") gps_time: datetime = Field(validation_alias=AliasChoices("gpsTime", "gps_time"))
lat: float lat: float
lng: 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") direction_deg: float | None = Field(default=None, validation_alias="direction")
acc: int | str | None = None acc: int | str | None = Field(default=None, validation_alias=AliasChoices("acc", "accStatus"))
satellites: int | None = Field(default=None, validation_alias="satelliteNum") satellites: int | None = Field(
default=None, validation_alias=AliasChoices("satelliteNum", "gpsNum")
)
altitude_m: float | None = Field(default=None, validation_alias="altitude") 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") @field_validator("gps_time", mode="before")
@classmethod @classmethod
@ -80,16 +87,13 @@ class JimiPushAlarm(_JimiBase):
raise ValueError(f"unparseable alarmTime: {v!r}") raise ValueError(f"unparseable alarmTime: {v!r}")
return parsed return parsed
@field_validator("imei", mode="before")
@classmethod
def _resolve_imei(cls, v: Any, info: Any) -> Any:
return v
class JimiPushHeartbeat(_JimiBase): class JimiPushHeartbeat(_JimiBase):
imei: str = Field(validation_alias="deviceImei") imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei"))
gate_time: datetime = Field(validation_alias="gateTime") gate_time: datetime = Field(validation_alias=AliasChoices("gateTime", "hbTime"))
power_level: int | None = Field(default=None, validation_alias="powerLevel") power_level: int | None = Field(
default=None, validation_alias=AliasChoices("powerLevel", "electQuantity")
)
gsm_signal: int | None = Field(default=None, validation_alias="gsmSign") gsm_signal: int | None = Field(default=None, validation_alias="gsmSign")
acc: int | None = None acc: int | None = None
power_status: int | None = Field(default=None, validation_alias="powerStatus") power_status: int | None = Field(default=None, validation_alias="powerStatus")
@ -104,7 +108,7 @@ class JimiPushHeartbeat(_JimiBase):
class JimiPushEvent(_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_type: str = Field(validation_alias="type")
event_time: datetime = Field(validation_alias="gateTime") event_time: datetime = Field(validation_alias="gateTime")
timezone_str: str | None = Field(default=None, validation_alias="timezone") timezone_str: str | None = Field(default=None, validation_alias="timezone")
@ -118,21 +122,37 @@ class JimiPushEvent(_JimiBase):
return parsed return parsed
# ── Polled responses (Tracksolid Pro API — P1 ingest path) ──
class JimiPollFix(_JimiBase): class JimiPollFix(_JimiBase):
imei: str = Field(validation_alias="deviceImei") """One device's fix from jimi.user.device.location.list / jimi.device.location.get.
gps_time: datetime = Field(validation_alias="gpsTime")
lat: float Tracksolid Pro returns null fields for offline devices (no recent fix). We
lng: float keep gps_time, lat, lng optional and let the parser skip items that don't
speed_kmh: float | None = Field(default=None, validation_alias="gpsSpeed") 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") direction_deg: float | None = Field(default=None, validation_alias="direction")
altitude_m: float | None = Field(default=None, validation_alias="altitude") altitude_m: float | None = Field(default=None, validation_alias="altitude")
satellites: int | None = Field(default=None, validation_alias="satelliteNum") satellites: int | None = Field(
acc: int | str | None = None 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") @field_validator("gps_time", mode="before")
@classmethod @classmethod
def _parse_time(cls, v: Any) -> Any: def _parse_time(cls, v: Any) -> Any:
parsed = _coerce_ts(v) return _coerce_ts(v) # returns None for unparseable; field is Optional
if parsed is None:
raise ValueError(f"unparseable gpsTime: {v!r}")
return parsed

View file

@ -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]: 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] = [] out: list[ParsedEvent] = []
for item in _items_for_poll(payload): for item in _items_for_poll(payload):
try:
model = JimiPollFix.model_validate(item) 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): if not _is_valid_fix(model.lat, model.lng):
continue continue
assert model.lat is not None and model.lng is not None
out.append( out.append(
ParsedEvent( ParsedEvent(
kind="position_fix", kind="position_fix",

View file

@ -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 = { ZERO_ISLAND_FIX = {
"deviceImei": "860112050000001", "deviceImei": "860112050000001",
"gpsTime": "2026-05-22 12:00:00", "gpsTime": "2026-05-22 12:00:00",

View file

@ -7,6 +7,7 @@ from tests.fixtures.jimi_payloads import (
PUSH_EVENT_LOGIN, PUSH_EVENT_LOGIN,
PUSH_GPS_SINGLE, PUSH_GPS_SINGLE,
PUSH_HEARTBEAT_SINGLE, PUSH_HEARTBEAT_SINGLE,
TRACKSOLID_POLL_LIST_REAL,
ZERO_ISLAND_FIX, 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: def test_unsupported_msg_type_raises() -> None:
with pytest.raises(UnsupportedMsgType): with pytest.raises(UnsupportedMsgType):
parse_raw("tracksolid_push", "pushobd", {}, account_id="acct-1") 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