fleet-platform/app/tracksolid/client.py

134 lines
4.8 KiB
Python
Raw Normal View History

"""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)})