2026-05-22 21:53:42 +00:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
2026-05-23 06:05:17 +00:00
|
|
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
|
2026-05-22 21:53:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 06:05:17 +00:00
|
|
|
# ── Push payloads (P3 push cutover; idle in P1 — Tracksolid still posts to legacy) ──
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
class JimiPushGps(_JimiBase):
|
2026-05-23 06:05:17 +00:00
|
|
|
imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei"))
|
|
|
|
|
gps_time: datetime = Field(validation_alias=AliasChoices("gpsTime", "gps_time"))
|
2026-05-22 21:53:42 +00:00
|
|
|
lat: float
|
|
|
|
|
lng: float
|
2026-05-23 06:05:17 +00:00
|
|
|
speed_kmh: float | None = Field(
|
|
|
|
|
default=None, validation_alias=AliasChoices("gpsSpeed", "speed")
|
|
|
|
|
)
|
2026-05-22 21:53:42 +00:00
|
|
|
direction_deg: float | None = Field(default=None, validation_alias="direction")
|
2026-05-23 06:05:17 +00:00
|
|
|
acc: int | str | None = Field(default=None, validation_alias=AliasChoices("acc", "accStatus"))
|
|
|
|
|
satellites: int | None = Field(
|
|
|
|
|
default=None, validation_alias=AliasChoices("satelliteNum", "gpsNum")
|
|
|
|
|
)
|
2026-05-22 21:53:42 +00:00
|
|
|
altitude_m: float | None = Field(default=None, validation_alias="altitude")
|
2026-05-23 06:05:17 +00:00
|
|
|
post_type: int | str | None = Field(default=None, validation_alias="postType")
|
2026-05-22 21:53:42 +00:00
|
|
|
|
|
|
|
|
@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):
|
2026-05-23 06:05:17 +00:00
|
|
|
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")
|
|
|
|
|
)
|
2026-05-22 21:53:42 +00:00
|
|
|
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):
|
2026-05-23 06:05:17 +00:00
|
|
|
imei: str = Field(validation_alias=AliasChoices("imei", "deviceImei"))
|
2026-05-22 21:53:42 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 06:05:17 +00:00
|
|
|
# ── Polled responses (Tracksolid Pro API — P1 ingest path) ──
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 21:53:42 +00:00
|
|
|
class JimiPollFix(_JimiBase):
|
2026-05-23 06:05:17 +00:00
|
|
|
"""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")
|
|
|
|
|
)
|
2026-05-22 21:53:42 +00:00
|
|
|
direction_deg: float | None = Field(default=None, validation_alias="direction")
|
|
|
|
|
altitude_m: float | None = Field(default=None, validation_alias="altitude")
|
2026-05-23 06:05:17 +00:00
|
|
|
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")
|
2026-05-22 21:53:42 +00:00
|
|
|
|
|
|
|
|
@field_validator("gps_time", mode="before")
|
|
|
|
|
@classmethod
|
|
|
|
|
def _parse_time(cls, v: Any) -> Any:
|
2026-05-23 06:05:17 +00:00
|
|
|
return _coerce_ts(v) # returns None for unparseable; field is Optional
|