"""Tracksolid Pro API client. Signature scheme (per https://tracksolidprodocs.jimicloud.com/integration/integration.html): sign = MD5(app_secret + ''.join(k+v for k,v in sorted(params)) + app_secret) upper-case hex, 32 chars. `sign` is then added to the params for transport. Token endpoint: jimi.oauth.token.get → returns accessToken + expiresIn (sec). List endpoint: jimi.user.device.location.list (per target account). Get endpoint: jimi.device.location.get (batch of up to 100 IMEIs). """ import asyncio import hashlib from datetime import UTC, datetime, timedelta from typing import Any import httpx import structlog from app.config import Settings log = structlog.get_logger("tracksolid") REFRESH_LEAD_SECONDS = 300 # refresh 5 min before expiry class TracksolidError(Exception): """Tracksolid API returned a non-zero `code`.""" def __init__(self, code: int, msg: str, *, method: str) -> None: super().__init__(f"{method} → code={code} msg={msg!r}") self.code = code self.msg = msg self.method = method def sign_params(params: dict[str, str], secret: str) -> str: body = "".join(f"{k}{v}" for k, v in sorted(params.items())) raw = f"{secret}{body}{secret}".encode() return hashlib.md5(raw).hexdigest().upper() def _utc_timestamp(at: datetime | None = None) -> str: at = at or datetime.now(UTC) return at.strftime("%Y-%m-%d %H:%M:%S") class TracksolidClient: """Thin async client. One instance per account is fine. Holds an access-token cache; refreshes on demand or on 401. """ def __init__(self, settings: Settings, *, http_timeout: float = 15.0) -> None: self._settings = settings self._http = httpx.AsyncClient(timeout=http_timeout) self._token: str | None = None self._token_expires_at: datetime = datetime.now(UTC) self._lock = asyncio.Lock() async def close(self) -> None: await self._http.aclose() @property def base_url(self) -> str: return self._settings.tracksolid_api_base_url def _common(self) -> dict[str, str]: return { "app_key": self._settings.tracksolid_app_key, "sign_method": "md5", "timestamp": _utc_timestamp(), "format": "json", "v": "1.0", } async def _post(self, params: dict[str, str]) -> dict[str, Any]: signed = dict(params) signed["sign"] = sign_params(params, self._settings.tracksolid_app_secret) resp = await self._http.post(self.base_url, data=signed) resp.raise_for_status() body: dict[str, Any] = resp.json() code = int(body.get("code", -1)) if code != 0: raise TracksolidError(code, str(body.get("msg")), method=params.get("method", "?")) return body async def _ensure_token(self) -> str: async with self._lock: if ( self._token is not None and self._token_expires_at - datetime.now(UTC) > timedelta(seconds=REFRESH_LEAD_SECONDS) ): return self._token params = self._common() | { "method": "jimi.oauth.token.get", "user_id": self._settings.tracksolid_user_id, "user_pwd_md5": self._settings.tracksolid_pwd_md5, "expires_in": str(self._settings.tracksolid_token_ttl_sec), } body = await self._post(params) result = body.get("result", {}) token = result.get("accessToken") ttl = int(result.get("expiresIn", self._settings.tracksolid_token_ttl_sec)) if not isinstance(token, str) or not token: raise TracksolidError( 0, f"missing accessToken in {result!r}", method="jimi.oauth.token.get" ) self._token = token self._token_expires_at = datetime.now(UTC) + timedelta(seconds=ttl) log.info( "tracksolid.token_refreshed", expires_at=self._token_expires_at.isoformat(), ttl_sec=ttl, ) return token async def _data_call(self, method: str, extra: dict[str, str]) -> dict[str, Any]: token = await self._ensure_token() params = self._common() | {"method": method, "access_token": token} | extra return await self._post(params) async def location_list(self, target: str) -> dict[str, Any]: return await self._data_call("jimi.user.device.location.list", {"target": target}) async def location_get(self, imeis: list[str]) -> dict[str, Any]: if not imeis: return {"code": 0, "msg": "success", "result": []} if len(imeis) > 100: raise ValueError("location.get supports max 100 imeis per call") return await self._data_call("jimi.device.location.get", {"imeis": ",".join(imeis)})