134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
|
|
"""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)})
|