Parser: tolerate real Tracksolid wire shape (imei/speed names, null gpsTime for offline devices) + per-item resilience
This commit is contained in:
parent
4924552c7f
commit
13a4c17d80
4 changed files with 115 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
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",
|
||||
|
|
|
|||
39
tests/fixtures/jimi_payloads.py
vendored
39
tests/fixtures/jimi_payloads.py
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue