fleet-platform/app/models/jimi.py

159 lines
6 KiB
Python
Raw Permalink Normal View History

from datetime import UTC, datetime
from typing import Any
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
def _coerce_ts(value: Any) -> datetime | None: # noqa: PLR0911 — many wire formats
"""Tracksolid uses both unix epoch (sec/ms) and ISO/BCD strings.
Returns a UTC datetime, or None when the value cannot be interpreted.
"""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
n = int(value)
if n > 10**12:
n = n // 1000
try:
return datetime.fromtimestamp(n, tz=UTC)
except (OverflowError, OSError, ValueError):
return None
if isinstance(value, str):
s = value.strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
try:
return datetime.strptime(s, fmt).replace(tzinfo=UTC)
except ValueError:
continue
if s.isdigit() and len(s) in (12, 14):
fmt = "%y%m%d%H%M%S" if len(s) == 12 else "%Y%m%d%H%M%S"
try:
return datetime.strptime(s, fmt).replace(tzinfo=UTC)
except ValueError:
return None
return None
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=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=AliasChoices("gpsSpeed", "speed")
)
direction_deg: float | None = Field(default=None, validation_alias="direction")
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 | str | None = Field(default=None, validation_alias="postType")
@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
class JimiPushAlarm(_JimiBase):
imei: str
alarm_type: str = Field(validation_alias="alarmType")
alarm_name: str | None = Field(default=None, validation_alias="alarmName")
alarm_time: datetime = Field(validation_alias="alarmTime")
lat: float | None = None
lng: float | None = None
speed_kmh: float | None = Field(default=None, validation_alias="speed")
device_name: str | None = Field(default=None, validation_alias="deviceName")
@field_validator("alarm_time", mode="before")
@classmethod
def _parse_time(cls, v: Any) -> Any:
parsed = _coerce_ts(v)
if parsed is None:
raise ValueError(f"unparseable alarmTime: {v!r}")
return parsed
class JimiPushHeartbeat(_JimiBase):
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")
@field_validator("gate_time", mode="before")
@classmethod
def _parse_time(cls, v: Any) -> Any:
parsed = _coerce_ts(v)
if parsed is None:
raise ValueError(f"unparseable gateTime: {v!r}")
return parsed
class JimiPushEvent(_JimiBase):
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")
@field_validator("event_time", mode="before")
@classmethod
def _parse_time(cls, v: Any) -> Any:
parsed = _coerce_ts(v)
if parsed is None:
raise ValueError(f"unparseable gateTime: {v!r}")
return parsed
# ── Polled responses (Tracksolid Pro API — P1 ingest path) ──
class JimiPollFix(_JimiBase):
"""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=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:
return _coerce_ts(v) # returns None for unparseable; field is Optional