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 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

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]:
"""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",

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

View file

@ -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