diff --git a/.gitignore b/.gitignore index e69de29..e120b36 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,22 @@ +# Secrets +.env +.env.* + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ + +# uv +.uv/ + +# OS +.DS_Store +Thumbs.db + +# Backups +bak_* + +# Logs +*.log diff --git a/02_tracksolid_full_schema_rev.sql b/02_tracksolid_full_schema_rev.sql index 62eaf5c..3d7a294 100644 --- a/02_tracksolid_full_schema_rev.sql +++ b/02_tracksolid_full_schema_rev.sql @@ -186,13 +186,27 @@ CREATE TABLE IF NOT EXISTS tracksolid.parking_events ( -- 3.08 Alarms, OBD, Fault Codes CREATE TABLE IF NOT EXISTS tracksolid.alarms ( - id BIGSERIAL PRIMARY KEY, imei TEXT REFERENCES tracksolid.devices(imei), - alarm_type TEXT, alarm_time TIMESTAMPTZ, geom geometry(Point, 4326), speed NUMERIC(7,2), updated_at TIMESTAMPTZ DEFAULT NOW() + id BIGSERIAL PRIMARY KEY, + imei TEXT REFERENCES tracksolid.devices(imei), + alarm_type TEXT, + alarm_time TIMESTAMPTZ, + geom geometry(Point, 4326), + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + speed NUMERIC(7,2), + acc_status TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT alarms_dedup UNIQUE (imei, alarm_type, alarm_time) ); CREATE TABLE IF NOT EXISTS tracksolid.obd_readings ( - id BIGSERIAL PRIMARY KEY, imei TEXT REFERENCES tracksolid.devices(imei), - reading_time TIMESTAMPTZ, engine_rpm INTEGER, fuel_level_pct NUMERIC(5,2), updated_at TIMESTAMPTZ DEFAULT NOW() + id BIGSERIAL PRIMARY KEY, + imei TEXT REFERENCES tracksolid.devices(imei), + reading_time TIMESTAMPTZ, + engine_rpm INTEGER, + fuel_level_pct NUMERIC(5,2), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT obd_readings_dedup UNIQUE (imei, reading_time) ); -- ============================================================================= @@ -237,11 +251,16 @@ CREATE TABLE dwh_gold.fact_daily_fleet_metrics ( CREATE OR REPLACE FUNCTION tracksolid.set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; --- Apply trigger to tables +-- Apply trigger to tables with updated_at column DO $$ DECLARE t TEXT; BEGIN - FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'tracksolid' AND has_column('tracksolid', tablename, 'updated_at') + FOR t IN + SELECT pt.tablename + FROM pg_tables pt + JOIN information_schema.columns c + ON c.table_schema = pt.schemaname AND c.table_name = pt.tablename + WHERE pt.schemaname = 'tracksolid' AND c.column_name = 'updated_at' LOOP EXECUTE format('CREATE TRIGGER trg_upd_%I BEFORE UPDATE ON tracksolid.%I FOR EACH ROW EXECUTE FUNCTION tracksolid.set_updated_at()', t, t); END LOOP; diff --git a/03_webhook_schema_migration.sql b/03_webhook_schema_migration.sql new file mode 100644 index 0000000..5f0a8bb --- /dev/null +++ b/03_webhook_schema_migration.sql @@ -0,0 +1,128 @@ +-- ============================================================================= +-- Fireside Communications — Tracksolid Pro Fleet Telemetry +-- Schema Migration: Webhook Receiver Support +-- ============================================================================= +-- Adds tables and columns required by the FastAPI webhook receiver service +-- that ingests push data from Jimi's Data Push API. +-- +-- All statements are idempotent (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS). +-- Run against production with: psql $DATABASE_URL -f 03_webhook_schema_migration.sql +-- ============================================================================= + +-- ============================================================================= +-- 1. Expand obd_readings for push data +-- ============================================================================= +-- The Jimi /pushobd webhook sends a rich obdJson payload with device-model- +-- specific dataID fields. We store the full JSON as JSONB for flexibility, +-- plus commonly-needed columns for direct querying. + +ALTER TABLE tracksolid.obd_readings + ADD COLUMN IF NOT EXISTS car_type SMALLINT, + ADD COLUMN IF NOT EXISTS acc_state SMALLINT, + ADD COLUMN IF NOT EXISTS status_flags INTEGER, + ADD COLUMN IF NOT EXISTS lat DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS lng DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS geom geometry(Point, 4326), + ADD COLUMN IF NOT EXISTS obd_data JSONB; + +COMMENT ON COLUMN tracksolid.obd_readings.obd_data IS + 'Raw obdJson from Jimi push. Contains dataID1..N fields (engine RPM, coolant temp, fuel level, etc.)'; + +-- ============================================================================= +-- 2. Create fault_codes table +-- ============================================================================= +-- Stores DTC fault codes from /pushfaultinfo webhook. +-- One row per fault code per report for queryability. + +CREATE TABLE IF NOT EXISTS tracksolid.fault_codes ( + id BIGSERIAL PRIMARY KEY, + imei TEXT NOT NULL REFERENCES tracksolid.devices(imei), + reported_at TIMESTAMPTZ NOT NULL, + fault_code TEXT NOT NULL, + status_flags INTEGER, + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + geom geometry(Point, 4326), + event_time TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fault_codes_dedup UNIQUE (imei, reported_at, fault_code) +); + +CREATE INDEX IF NOT EXISTS idx_fault_codes_imei_time + ON tracksolid.fault_codes (imei, reported_at DESC); + +CREATE INDEX IF NOT EXISTS idx_fault_codes_code + ON tracksolid.fault_codes (fault_code); + +-- ============================================================================= +-- 3. Create heartbeats table (hypertable) +-- ============================================================================= +-- Stores device heartbeat data from /pushhb webhook. +-- High volume, low long-term value — retained for 30 days. + +CREATE TABLE IF NOT EXISTS tracksolid.heartbeats ( + imei TEXT NOT NULL REFERENCES tracksolid.devices(imei), + gate_time TIMESTAMPTZ NOT NULL, + power_level SMALLINT, + gsm_signal SMALLINT, + acc_status SMALLINT, + power_status SMALLINT, + fortify SMALLINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (imei, gate_time) +); + +SELECT create_hypertable('tracksolid.heartbeats', 'gate_time', + chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE); + +SELECT add_retention_policy('tracksolid.heartbeats', INTERVAL '30 days', + if_not_exists => TRUE); + +-- ============================================================================= +-- 4. Expand trips for push data +-- ============================================================================= +-- The /pushtripreport webhook provides fuel consumption, idle time, and +-- trip sequence numbers not available from the polling API. + +ALTER TABLE tracksolid.trips + ADD COLUMN IF NOT EXISTS fuel_consumed_l NUMERIC(8,2), + ADD COLUMN IF NOT EXISTS idle_time_s INTEGER, + ADD COLUMN IF NOT EXISTS driving_time_s INTEGER, + ADD COLUMN IF NOT EXISTS trip_seq INTEGER, + ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'poll'; + +COMMENT ON COLUMN tracksolid.trips.driving_time_s IS 'runTimeSecond from API: total driving time in seconds'; + +COMMENT ON COLUMN tracksolid.trips.source IS 'poll = from API polling, push = from webhook push'; + +-- ============================================================================= +-- 5. Expand position_history for push data +-- ============================================================================= +-- The /pushgps webhook provides altitude and positioning type not available +-- from the polling API. + +ALTER TABLE tracksolid.position_history + ADD COLUMN IF NOT EXISTS altitude NUMERIC(8,2), + ADD COLUMN IF NOT EXISTS post_type SMALLINT, + ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'poll'; + +-- ============================================================================= +-- 6. Expand alarms for push data +-- ============================================================================= + +ALTER TABLE tracksolid.alarms + ADD COLUMN IF NOT EXISTS alarm_name TEXT, + ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'poll'; + +-- ============================================================================= +-- 7. Permissions +-- ============================================================================= + +GRANT ALL ON tracksolid.fault_codes TO tracksolid_owner; +GRANT ALL ON tracksolid.heartbeats TO tracksolid_owner; +GRANT SELECT ON tracksolid.fault_codes TO grafana_ro; +GRANT SELECT ON tracksolid.heartbeats TO grafana_ro; + +-- Grant sequence permissions for BIGSERIAL columns +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tracksolid TO tracksolid_owner; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tracksolid TO grafana_ro; diff --git a/docker-compose.yaml b/docker-compose.yaml index 199f63d..8dcdb4c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,9 +17,10 @@ services: retries: 5 ingest_movement: - build: + build: context: . dockerfile: Dockerfile + command: python ingest_movement_rev.py restart: always depends_on: timescale_db: @@ -27,13 +28,33 @@ services: env_file: .env # Coolify will inject variables here ingest_events: - build: + build: context: . dockerfile: Dockerfile + command: python ingest_events_rev.py restart: always depends_on: timescale_db: condition: service_healthy + env_file: .env + + webhook_receiver: + build: + context: . + dockerfile: Dockerfile + command: uvicorn webhook_receiver_rev:app --host 0.0.0.0 --port 8000 --workers 2 + restart: always + depends_on: + timescale_db: + condition: service_healthy + env_file: .env + ports: + - "8000:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 grafana: image: grafana/grafana:11.0.0 diff --git a/ingest_events_rev.py b/ingest_events_rev.py index 3ef0b20..6d64de7 100644 --- a/ingest_events_rev.py +++ b/ingest_events_rev.py @@ -1,26 +1,26 @@ """ ingest_events_rev.py — Fireside Communications · Tracksolid Events Pipeline ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -RESPONSIBILITY: Alarms, Geofences, and OBD engine diagnostics. +RESPONSIBILITY: Alarm event polling (catch-up/fallback for webhook push data). + +OBD diagnostics are received via the webhook_receiver_rev.py push service — +jimi.device.obd.list does not exist in the Tracksolid Pro API. REVISIONS (QA-Verified): [FIX-E01] Batching: Polls 50 IMEIs per call to stay within API limits. - [FIX-E02] JSONB: Stores raw payloads in alarms/obd for future flexibility. [FIX-E03] Atomic Logging: One log row per batch per endpoint. [FIX-E04] Signal Handling: Clean pool closure on SIGTERM/SIGINT. + [FIX-E05] Removed poll_obd: OBD data is push-only via /pushobd webhook. + [FIX-11] Uses shared safe_task/setup_shutdown from ts_shared_rev (DRY). ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ """ -import signal -import sys import time import schedule -import json from datetime import datetime, timezone, timedelta from ts_shared_rev import ( api_post, - close_pool, get_active_imeis, get_conn, get_token, @@ -30,28 +30,12 @@ from ts_shared_rev import ( clean_int, clean_ts, get_logger, + safe_task, + setup_shutdown, ) log = get_logger("events") - -# ── Graceful Shutdown ───────────────────────────────────────────────────────── - -def _shutdown(signum, frame): - log.info("Signal %s received. Closing DB pool...", signum) - close_pool() - sys.exit(0) - -signal.signal(signal.SIGTERM, _shutdown) -signal.signal(signal.SIGINT, _shutdown) - -def _safe(fn): - def wrapper(): - try: - fn() - except Exception: - log.exception("Task %s failed. Scheduler continuing...", fn.__name__) - wrapper.__name__ = fn.__name__ - return wrapper +setup_shutdown(log) # ── 1. Alarms & Geofence Events (Every 5m) ──────────────────────────────────── @@ -83,15 +67,15 @@ def poll_alarms(): cur.execute(""" INSERT INTO tracksolid.alarms ( - imei, alarm_type, alarm_time, geom, lat, lng, + imei, alarm_type, alarm_time, geom, lat, lng, speed, acc_status, updated_at ) VALUES ( - %s, %s, %s, - CASE WHEN %s IS NOT NULL AND %s IS NOT NULL - THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) - ELSE NULL END, + %s, %s, %s, + CASE WHEN %s IS NOT NULL AND %s IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) + ELSE NULL END, %s, %s, %s, %s, NOW() - ) ON CONFLICT DO NOTHING + ) ON CONFLICT (imei, alarm_type, alarm_time) DO NOTHING """, ( a.get("imei"), clean(a.get("alarmType")), clean_ts(a.get("alarmTime")), lng, lat, lng, lat, lat, lng, @@ -104,58 +88,17 @@ def poll_alarms(): log.info("Alarms: %d new events inserted.", inserted) -# ── 2. OBD engine diagnostics (Every 10m) ──────────────────────────────────── - -def poll_obd(): - log.info("Polling OBD telemetry...") - t0, token, imeis = time.time(), get_token(), get_active_imeis() - if not token or not imeis: return - - inserted = 0 - # OBD API often requires per-device polling or specific time ranges - for imei in imeis: - resp = api_post("jimi.device.obd.list", { - "imei": imei, - "page_size": 20 - }, token) - - readings = resp.get("result", []) - if not readings: continue - - with get_conn() as conn: - with conn.cursor() as cur: - for r in readings: - cur.execute(""" - INSERT INTO tracksolid.obd_readings ( - imei, reading_time, engine_rpm, fuel_level_pct, updated_at - ) VALUES (%s, %s, %s, %s, NOW()) - ON CONFLICT (imei, reading_time) DO NOTHING - """, ( - imei, clean_ts(r.get("readTime")), - clean_int(r.get("engineRpm")), clean_num(r.get("fuelLevel")) - )) - inserted += 1 - conn.commit() - - # Log summary of OBD poll - with get_conn() as conn: - with conn.cursor() as cur: - log_ingestion(cur, "jimi.device.obd.list", len(imeis), 0, inserted, int((time.time()-t0)*1000), True) - conn.commit() - log.info("OBD: %d readings processed.", inserted) - # ── Main Loop ───────────────────────────────────────────────────────────────── def main(): - log.info("Starting EVENTS PIPELINE (v2.0)...") - + log.info("Starting EVENTS PIPELINE (v2.1)...") + # OBD removed: Data arrives via webhook push (/pushobd), not polling. + # Startup catch-up - _safe(poll_alarms)() - _safe(poll_obd)() + safe_task(poll_alarms, log)() # Schedule - schedule.every(5).minutes.do(_safe(poll_alarms)) - schedule.every(10).minutes.do(_safe(poll_obd)) + schedule.every(5).minutes.do(safe_task(poll_alarms, log)) while True: schedule.run_pending() diff --git a/ingest_movement_rev.py b/ingest_movement_rev.py index f396f27..36b6828 100644 --- a/ingest_movement_rev.py +++ b/ingest_movement_rev.py @@ -10,11 +10,12 @@ REVISIONS (QA-Verified): [FIX-M07] Signal Handling: Clean DB pool closure on SIGTERM/SIGINT. [FIX-M08] Atomic Logging: log_ingestion happens within the data transaction. [FIX-QA-01] Distance: Explicit km to meters conversion (* 1000). + [FIX-11] Uses shared safe_task/setup_shutdown from ts_shared_rev (DRY). + [FIX-M09] Trips: Captures runTimeSecond and maxSpeed from API. + [FIX-M10] Parking: New poll_parking via jimi.open.platform.report.parking. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ """ -import signal -import sys import time import schedule from datetime import datetime, timezone, timedelta @@ -22,7 +23,6 @@ from datetime import datetime, timezone, timedelta from ts_shared_rev import ( TARGET_ACCOUNT, api_post, - close_pool, get_active_imeis, get_conn, get_token, @@ -33,29 +33,12 @@ from ts_shared_rev import ( clean_int, clean_ts, get_logger, + safe_task, + setup_shutdown, ) log = get_logger("movement") - -# ── Graceful Shutdown ───────────────────────────────────────────────────────── - -def _shutdown(signum, frame): - log.info("Signal %s received. Closing DB pool...", signum) - close_pool() - sys.exit(0) - -signal.signal(signal.SIGTERM, _shutdown) -signal.signal(signal.SIGINT, _shutdown) - -def _safe(fn): - """Decorator to prevent scheduler death on single function failure.""" - def wrapper(): - try: - fn() - except Exception: - log.exception("Task %s failed. Scheduler continuing...", fn.__name__) - wrapper.__name__ = fn.__name__ - return wrapper +setup_shutdown(log) # ── 1. Device Registry Sync (Daily) ────────────────────────────────────────── @@ -194,27 +177,90 @@ def poll_trips(): dist_km = clean_num(t.get("distance")) dist_m = dist_km * 1000 if dist_km is not None else 0 # [QA-01] Conversion cur.execute(""" - INSERT INTO tracksolid.trips (imei, start_time, end_time, distance_m, avg_speed_kmh) - VALUES (%s, %s, %s, %s, %s) ON CONFLICT (imei, start_time) DO NOTHING - """, (t.get("imei"), clean_ts(t.get("startTime")), clean_ts(t.get("endTime")), dist_m, clean_num(t.get("avgSpeed")))) + INSERT INTO tracksolid.trips ( + imei, start_time, end_time, distance_m, + avg_speed_kmh, max_speed_kmh, driving_time_s, source + ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'poll') + ON CONFLICT (imei, start_time) DO UPDATE SET + end_time = EXCLUDED.end_time, + distance_m = EXCLUDED.distance_m, + max_speed_kmh = COALESCE(EXCLUDED.max_speed_kmh, tracksolid.trips.max_speed_kmh), + driving_time_s = COALESCE(EXCLUDED.driving_time_s, tracksolid.trips.driving_time_s) + """, ( + t.get("imei"), clean_ts(t.get("startTime")), clean_ts(t.get("endTime")), + dist_m, clean_num(t.get("avgSpeed")), + clean_num(t.get("maxSpeed")), clean_int(t.get("runTimeSecond")) + )) inserted += 1 conn.commit() log.info("Trips: %d records processed.", inserted) +# ── 4. Parking Events (Every 15m) ───────────────────────────────────────────── + +def poll_parking(): + t0 = time.time() + token, imeis = get_token(), get_active_imeis() + if not token or not imeis: return + + end_ts = datetime.now(timezone.utc) + start_ts = end_ts - timedelta(hours=1) + inserted = 0 + + for i in range(0, len(imeis), 50): + batch = imeis[i:i+50] + resp = api_post("jimi.open.platform.report.parking", { + "imeis": ",".join(batch), + "begin_time": start_ts.strftime("%Y-%m-%d %H:%M:%S"), + "end_time": end_ts.strftime("%Y-%m-%d %H:%M:%S"), + }, token) + + events = resp.get("result", []) + with get_conn() as conn: + with conn.cursor() as cur: + for p in events: + imei = p.get("imei") + start_time = clean_ts(p.get("startTime")) + if not imei or not start_time: + continue + lat, lng = clean_num(p.get("lat")), clean_num(p.get("lng")) + cur.execute(""" + INSERT INTO tracksolid.parking_events ( + imei, event_type, start_time, end_time, + duration_seconds, geom, address + ) VALUES ( + %s, 'parking', %s, %s, %s, + CASE WHEN %s IS NOT NULL AND %s IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) + ELSE NULL END, + %s + ) ON CONFLICT (imei, start_time, event_type) DO NOTHING + """, ( + imei, start_time, clean_ts(p.get("endTime")), + clean_int(p.get("seconds")), + lng, lat, lng, lat, + clean(p.get("address")) + )) + inserted += 1 + log_ingestion(cur, "jimi.open.platform.report.parking", len(batch), 0, inserted, + int((time.time() - t0) * 1000), True) + log.info("Parking: %d events processed.", inserted) + # ── Main Loop ───────────────────────────────────────────────────────────────── def main(): - log.info("Starting MOVEMENT PIPELINE (v2.0)...") - + log.info("Starting MOVEMENT PIPELINE (v2.1)...") + # Startup catch-up - _safe(sync_devices)() - _safe(poll_live_positions)() - _safe(poll_trips)() + safe_task(sync_devices, log)() + safe_task(poll_live_positions, log)() + safe_task(poll_trips, log)() + safe_task(poll_parking, log)() # Schedule - schedule.every(60).seconds.do(_safe(poll_live_positions)) - schedule.every(15).minutes.do(_safe(poll_trips)) - schedule.every().day.at("02:00").do(_safe(sync_devices)) + schedule.every(60).seconds.do(safe_task(poll_live_positions, log)) + schedule.every(15).minutes.do(safe_task(poll_trips, log)) + schedule.every(15).minutes.do(safe_task(poll_parking, log)) + schedule.every().day.at("02:00").do(safe_task(sync_devices, log)) while True: schedule.run_pending() diff --git a/pyproject.toml b/pyproject.toml index d934f6d..bf0b6a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "requests>=2.32.3", # API requests "schedule>=1.2.2", # Polling loops/scheduler "urllib3>=2.2.2", # HTTP connection pooling/retries + "fastapi>=0.115.0", # Webhook receiver framework + "uvicorn[standard]>=0.30.0", # ASGI server for FastAPI + "python-multipart>=0.0.9", # Required for FastAPI Form() parsing ] [build-system] diff --git a/tracksolidApiDocumentation.md b/tracksolidApiDocumentation.md new file mode 100644 index 0000000..35d5e71 --- /dev/null +++ b/tracksolidApiDocumentation.md @@ -0,0 +1,1664 @@ +# Tracksolid Pro API Documentation + +> Consolidated from official Jimi IoT documentation at [tracksolidprodocs.jimicloud.com](https://tracksolidprodocs.jimicloud.com/) and [docs.jimicloud.com](https://docs.jimicloud.com/integration/integration.html). +> +> API Spec Version: 2.7.7 | Last updated: 2026-04-08 + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Authentication & Signing](#2-authentication--signing) +3. [Access Control](#3-access-control) +4. [Device Management](#4-device-management) +5. [Tracking & Telemetry](#5-tracking--telemetry) +6. [Alarms](#6-alarms) +7. [Media](#7-media) +8. [Command Management](#8-command-management) +9. [Geofencing](#9-geofencing) +10. [Data Push API (Webhooks)](#10-data-push-api-webhooks) + +--- + +## 1. Overview + +### Architecture + +Your application server connects to the Jimi API server — client apps should never call the API directly. You must apply for an `appKey` and `appSecret` from Jimi (providing your account), then use your server to obtain an `access_token` and call other interfaces. + +### Conventions + +- Encoding: UTF-8 +- Format: JSON (`application/json charset=utf-8`) +- Timezone: UTC (GMT+0) +- Timestamp format: `yyyy-MM-dd HH:mm:ss` +- All requests use POST to a single base URL with the `method` parameter to differentiate endpoints + +### Regional Base URLs + +| Region | Base URL | +|---|---| +| TrackSolid (TS) | `http://open.10000track.com/route/rest` | +| TSP Hong Kong / Singapore | `https://hk-open.tracksolidpro.com/route/rest` | +| TSP Europe | `https://eu-open.tracksolidpro.com/route/rest` | +| TSP United States | `https://us-open.tracksolidpro.com/route/rest` | + +### Common Request Parameters + +Every API call includes these base parameters: + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `method` | string | Yes | API method identifier (e.g. `jimi.oauth.token.get`) | +| `app_key` | string | Yes | Application key assigned by Jimi | +| `timestamp` | string | Yes | Current UTC time (`yyyy-MM-dd HH:mm:ss`) | +| `sign_method` | string | Yes | Always `md5` | +| `sign` | string | Yes | MD5 signature (see [Signing](#2-authentication--signing)) | +| `v` | string | Yes | API version, always `1.0` | +| `format` | string | Yes | Always `json` | +| `access_token` | string | Conditional | Required for all methods except token acquisition | + +### Common Response Structure + +```json +{ + "code": 0, + "message": "success", + "result": { } +} +``` + +| Code | Meaning | +|---|---| +| `0` | Success | +| `1004` | Token expired — refresh or re-authenticate | +| `1006` | Rate limit exceeded — back off and retry | +| `-1` | General error | + +### Rate Limits + +- Token acquisition (`jimi.oauth.token.get`) can be called **at most once per minute** +- `jimi.device.alarm.list` — time range limited to **1 month**, max **1000 rows** returned +- Batch endpoints accept up to **50 IMEIs** per call + +--- + +## 2. Authentication & Signing + +### MD5 Signature Algorithm + +Every API call requires a signature to prevent tampering. The algorithm: + +1. Collect all request parameters (excluding `sign` and any with `null` values) +2. Sort parameters alphabetically by key +3. Concatenate as: `{appSecret}{key1}{value1}{key2}{value2}...{appSecret}` +4. Compute MD5 hash of the UTF-8 encoded string +5. Convert to uppercase hex + +Example: +``` +Input: SECRET + "app_keyABC123methodjimi.oauth.token.gettimestamp2025-01-01 00:00:00" + SECRET +Output: MD5 → uppercase hex string +``` + +--- + +## 3. Access Control + +### 3.1 Get Access Token + +Obtain an authentication token for API access. + +| | | +|---|---| +| **Method** | `jimi.oauth.token.get` | +| **Auth required** | No (this obtains the token) | +| **Rate limit** | Max 1 call per minute | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `user_id` | string | Yes | Account user ID | +| `user_pwd_md5` | string | Yes | MD5 hash of user password | +| `expires_in` | int | Yes | Token lifetime in seconds (e.g. `7200` for 2 hours) | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `accessToken` | string | The access token for subsequent API calls | +| `expiresIn` | int | Token lifetime in seconds | +| `account` | string | Account identifier | +| `appKey` | string | Application key | +| `refreshToken` | string | Token used to refresh before expiry | +| `time` | string | Server time | + +--- + +### 3.2 Refresh Access Token + +Update token before it expires without full re-authentication. + +| | | +|---|---| +| **Method** | `jimi.oauth.token.refresh` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `access_token` | string | Yes | Current access token | +| `refresh_token` | string | Yes | Refresh token from initial auth | +| `expires_in` | int | Yes | New token lifetime in seconds | + +**Response:** Same as `jimi.oauth.token.get`. + +--- + +## 4. Device Management + +### 4.1 List All Devices + +Retrieve all devices belonging to a specified account. + +| | | +|---|---| +| **Method** | `jimi.user.device.list` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `target` | string | Yes | Target account ID | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `deviceName` | string | Device display name | +| `mcType` | string | Device model type | +| `sim` | string | SIM card number | +| `expiration` | string | Service expiration date | +| `activationTime` | string | Device activation date | +| `vehicleName` | string | Assigned vehicle name | +| `vehicleNumber` | string | License plate / vehicle number | +| `driverName` | string | Assigned driver name | +| `enabledFlag` | int | 1 = enabled, 0 = disabled | + +--- + +### 4.2 Get Device Detail + +Retrieve comprehensive information for a specific IMEI. + +| | | +|---|---| +| **Method** | `jimi.track.device.detail` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `deviceName` | string | Device name | +| `account` | string | Owner account | +| `mcType` | string | Model type | +| `sim` | string | SIM number | +| `expiration` | string | Service expiration | +| `vehicleName` | string | Vehicle name | +| `currentMileage` | number | Odometer reading | +| `deviceGroup` | string | Group name | + +--- + +### 4.3 Update Device Expiration + +Modify device expiration dates. + +| | | +|---|---| +| **Method** | `jimi.user.device.expiration.update` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei_list` | string | Yes | Comma-separated IMEIs | +| `new_expiration` | string | Yes | New expiration date | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `update_result` | string | Result status | +| `update_msg` | string | Result message | + +--- + +### 4.4 Update Vehicle Information by IMEI + +Update vehicle details and device settings. + +| | | +|---|---| +| **Method** | `jimi.open.device.update` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `device_name` | string | No | Device display name | +| `vehicle_name` | string | No | Vehicle name | +| `vehicle_icon` | string | No | Vehicle icon identifier | +| `vehicle_number` | string | No | License plate | +| `driver_name` | string | No | Driver name | +| `driver_phone` | string | No | Driver phone | +| `device_status` | string | No | Device status | +| `sim` | string | No | SIM card number | +| `remarks` | string | No | Notes | +| `mileage` | number | No | Current mileage | + +**Response:** Success confirmation (code 0). + +--- + +### 4.5 Move Devices Between Accounts + +Transfer devices between sub-accounts. + +| | | +|---|---| +| **Method** | `jimi.open.device.move` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `src_account` | string | Yes | Source account | +| `dest_account` | string | Yes | Destination account | +| `imeis` | string | Yes | Comma-separated IMEIs | +| `cleanBindFlag` | boolean | No | Clear existing bindings | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `result` | array | Array of moved device IMEIs | + +--- + +### 4.6 Bind App User + +Associate device with a mobile application user. + +| | | +|---|---| +| **Method** | `jimi.open.device.bind` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `user_id` | string | Yes | App user ID | + +--- + +### 4.7 Unbind App User + +Remove device association from a mobile application user. + +| | | +|---|---| +| **Method** | `jimi.open.device.unbind` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `user_id` | string | Yes | App user ID | + +--- + +### 4.8 Create Device Group + +| | | +|---|---| +| **Method** | `jimi.device.group.create` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `account` | string | Yes | Account ID | +| `group_name` | string | Yes | Group name | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `group_id` | string | Created group ID | +| `group_name` | string | Group name | + +--- + +### 4.9 Edit Device Group + +| | | +|---|---| +| **Method** | `jimi.device.group.update` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `group_id` | string | Yes | Group ID to edit | +| `group_name` | string | Yes | New group name | + +--- + +### 4.10 Delete Device Group + +| | | +|---|---| +| **Method** | `jimi.device.group.delete` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `group_id` | string | Yes | Group ID to delete | + +--- + +### 4.11 List Device Groups + +| | | +|---|---| +| **Method** | `jimi.device.group.list` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `account` | string | Yes | Account ID | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `group_id` | string | Group identifier | +| `group_name` | string | Group display name | + +--- + +## 5. Tracking & Telemetry + +### 5.1 Get Location of Devices by Account + +Retrieve latest positions for all devices under an account. Single API call for entire fleet. + +| | | +|---|---| +| **Method** | `jimi.user.device.location.list` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `target` | string | Yes | Target account ID | +| `map_type` | string | No | Coordinate system (e.g. `GOOGLE`) | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `deviceName` | string | Device name | +| `status` | string | Device status | +| `lat` | double | Latitude | +| `lng` | double | Longitude | +| `speed` | number | Speed (km/h) | +| `gpsTime` | string | GPS fix timestamp | +| `accStatus` | string | ACC ignition status | +| `currentMileage` | number | Odometer reading | +| `expireFlag` | string | Expiration flag | + +--- + +### 5.2 Get Location of Specific Device(s) + +Fetch current position for one or multiple specific devices. + +| | | +|---|---| +| **Method** | `jimi.device.location.get` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imeis` | string | Yes | Comma-separated IMEI list | +| `map_type` | string | No | Coordinate system | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `deviceName` | string | Device name | +| `lat` | double | Latitude | +| `lng` | double | Longitude | +| `status` | string | Device status | +| `speed` | number | Speed (km/h) | +| `gpsTime` | string | GPS fix timestamp | +| `accStatus` | string | ACC ignition status | +| `currentMileage` | number | Odometer | + +--- + +### 5.3 Get Sharing Location URL + +Generate a shareable map link for device location display. + +| | | +|---|---| +| **Method** | `jimi.device.location.URL.share` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `URL` | string | Shareable map URL | + +--- + +### 5.4 Get Mileage / Trip Data + +Extract trip and distance records within a time period. Supports batching up to 50 IMEIs. + +| | | +|---|---| +| **Method** | `jimi.device.track.mileage` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imeis` | string | Yes | Comma-separated IMEI list (max 50) | +| `begin_time` | string | Yes | Start time (`yyyy-MM-dd HH:mm:ss`) | +| `end_time` | string | Yes | End time (`yyyy-MM-dd HH:mm:ss`) | +| `start_row` | int | No | Pagination offset | +| `page_size` | int | No | Page size | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `startTime` | string | Trip start time | +| `endTime` | string | Trip end time | +| `distance` | number | Distance in kilometers | +| `avgSpeed` | number | Average speed (km/h) | +| `runTimeSecond` | int | Trip duration in seconds | + +--- + +### 5.5 Get Track Data (GPS Trail) + +Retrieve detailed positional waypoints for a specified timeframe. **Per-device** endpoint. + +| | | +|---|---| +| **Method** | `jimi.device.track.list` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Single device IMEI | +| `begin_time` | string | Yes | Start time (`yyyy-MM-dd HH:mm:ss`) | +| `end_time` | string | Yes | End time (`yyyy-MM-dd HH:mm:ss`) | +| `map_type` | string | No | Coordinate system | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `lat` | double | Latitude | +| `lng` | double | Longitude | +| `gpsTime` | string | Timestamp of GPS fix | +| `gpsSpeed` | int | Speed (km/h) | +| `direction` | int | Heading (0-360 degrees) | +| `ignition` | string | Ignition state | +| `accStatus` | string | ACC status | +| `confidence` | int | Position confidence | + +--- + +### 5.6 Wi-Fi / Base Station Locating + +Determine position using cellular tower and wireless network signals (fallback when GPS unavailable). + +| | | +|---|---| +| **Method** | `jimi.lbs.address.get` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `lbs` | string | No | LBS data | +| `wifi` | string | No | WiFi data | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `lat` | double | Latitude | +| `lng` | double | Longitude | +| `accuracy` | number | Position accuracy (meters) | + +--- + +### 5.7 Get Parking / Idling Data + +Analyze stationary and engine-running periods. + +| | | +|---|---| +| **Method** | `jimi.open.platform.report.parking` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `account` | string | Yes | Account ID | +| `imeis` | string | Yes | Comma-separated IMEI list | +| `start_time` | string | Yes | Start time | +| `end_time` | string | Yes | End time | +| `start_row` | int | Yes | Pagination offset | +| `page_size` | int | Yes | Page size | +| `acc_type` | string | Yes | ACC filter type | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `startTime` | string | Parking/idle start | +| `endTime` | string | Parking/idle end | +| `durSecond` | int | Total duration (seconds) | +| `addr` | string | Address / location | +| `deviceName` | string | Device name | +| `stopSecond` | int | Stationary duration (seconds) | + +--- + +### 5.8 Get Location of TAG Device + +Query latest beacon/TAG positioning data. + +| | | +|---|---| +| **Method** | `jimi.device.location.getTagMsg` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | TAG device IMEI | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `lng` | double | Longitude | +| `lat` | double | Latitude | +| `gpsTime` | string | GPS timestamp | +| `gpsSpeed` | int | Speed | +| `positionType` | string | Position method | +| `gpsNum` | int | Satellite count | + +--- + +## 6. Alarms + +### 6.1 Get Device Alarm List + +Retrieve alarm events for devices within a time range. + +| | | +|---|---| +| **Method** | `jimi.device.alarm.list` | +| **Auth required** | Yes | +| **Constraints** | Time range max **1 month**, max **1000 rows** returned | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imeis` | string | Yes | Comma-separated IMEI list | +| `begin_time` | string | Yes | Start time (`yyyy-MM-dd HH:mm:ss`) | +| `end_time` | string | Yes | End time (`yyyy-MM-dd HH:mm:ss`) | +| `page_size` | int | No | Results per page (max 1000) | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `deviceName` | string | Device name | +| `imei` | string | Device IMEI | +| `model` | string | Device model | +| `account` | string | Account | +| `alertTypeId` | string | Alarm type identifier | +| `alarmTypeName` | string | Alarm type display name | +| `alertTime` | string | Alarm trigger time | +| `positioningTime` | string | GPS fix time at alarm | +| `lat` | double | Latitude | +| `lng` | double | Longitude | +| `speed` | number | Speed at alarm time | +| `geoid` | string | Geo-fence ID (if geofence alarm) | + +> **Note:** The documented response field names (`alertTypeId`, `alertTime`) may differ from what some code examples use (`alarmType`, `alarmTime`). Always verify against actual API responses. + +--- + +## 7. Media + +### 7.1 Get Device Live Streaming Page URL + +Provides an embeddable URL for real-time video broadcast. + +| | | +|---|---| +| **Method** | `jimi.device.live.page.url` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `type` | string | No | Stream type | +| `voice` | string | No | Audio toggle | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `lat` | double | Device latitude | +| `lng` | double | Device longitude | +| `gpsTime` | string | GPS time | +| `VIN` | string | Vehicle VIN | +| `plateNo` | string | Plate number | +| `UrlCamera` | string | Embeddable streaming page URL | + +--- + +### 7.2 Send Media Instruction (Capture Photo/Video) + +Command a DVR device to capture video or still images on demand. + +| | | +|---|---| +| **Method** | `jimi.device.meida.cmd.send` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `camera` | int | Yes | Camera channel index | +| `mediaType` | string | Yes | `photo` or `video` | +| `shootTime` | int | No | Video recording duration (seconds) | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `code` | int | Result code | +| `msg` | string | Result message | +| `cmdSeqNo` | string | Command sequence number for tracking | + +--- + +### 7.3 Send History Video Instruction + +Command device to upload recorded video files from its storage. + +| | | +|---|---| +| **Method** | `jimi.device.history.cmd.send` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `type` | string | Yes | Upload type | +| `camera` | int | Yes | Camera channel | +| `fileName` | string | No | Specific file name | +| `time` | string | No | Time filter | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `code` | int | Result code | +| `msg` | string | Result message | +| `cmdSeqNo` | string | Command sequence number | + +--- + +### 7.4 Get Device Photo/Video URL + +Retrieve URLs for captured photos and video recordings. + +| | | +|---|---| +| **Method** | `jimi.device.jimi.media.URL` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `camera` | int | Yes | Camera channel | +| `media_type` | string | Yes | `photo` or `video` | +| `start_time` | string | No | Filter start time | +| `end_time` | string | No | Filter end time | +| `token` | string | No | Pagination token | +| `page_no` | int | No | Page number | +| `page_size` | int | No | Results per page | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `thumb_URL` | string | Thumbnail URL | +| `file_URL` | string | Full file download URL | +| `create_time` | string | Capture timestamp | +| `mime_type` | string | MIME type (`image/jpeg`, `video/mp4`, etc.) | +| `camera` | int | Camera channel | +| `file_size` | int | File size in bytes | + +--- + +### 7.5 Get Device Media Event URL + +Retrieve media files triggered by alarm events (hard braking, collision, etc.). + +| | | +|---|---| +| **Method** | `jimi.device.media.event.URL` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imeis` | string | Yes | Comma-separated IMEI list | +| `media_type` | string | Yes | `photo` or `video` | +| `start_time` | string | Yes | Filter start time | +| `end_time` | string | Yes | Filter end time | +| `page_no` | int | Yes | Page number | +| `page_size` | int | Yes | Results per page | +| `event_type` | string | No | Filter by event type | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `event_type` | string | Alarm/event type | +| `lat` | double | Latitude at event | +| `lng` | double | Longitude at event | +| `alarm_time` | string | Event timestamp | +| `fileList` | array | Nested array of file objects (URLs, MIME types, sizes) | + +--- + +### 7.6 Get Device Live Streaming Address + +Obtain a streaming protocol address (RTSP/HLS) for real-time video. + +| | | +|---|---| +| **Method** | `jimi.device.media.live.stream` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `channel` | int | Yes | Camera channel (1-5) | +| `appId` | string | Yes | Application identifier | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `result` | string | Streaming URL | + +--- + +### 7.7 Send Command to Query Historical Video List + +Two-step process: first send this command to prompt the device to compile its stored video inventory. + +| | | +|---|---| +| **Method** | `jimi.device.media.history.list.cmd` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `channel` | int | Yes | Camera channel | +| `dateTime` | string | Yes | Target date/time | +| `instructionId` | string | Yes | Unique instruction identifier | +| `appId` | string | Yes | Application identifier | + +**Response:** Acknowledgment only. + +--- + +### 7.8 Query Historical Video List + +Second step: retrieve the compiled video file metadata (after sending 7.7). + +| | | +|---|---| +| **Method** | `jimi.device.media.history.list.get` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `instructionId` | string | Yes | Same ID used in 7.7 | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `channel` | int | Camera channel | +| `beginTime` | string | Recording start time | +| `endTime` | string | Recording end time | +| `fileSize` | int | File size | +| `fileName` | string | File name | +| `codeType` | int | Encoding type (1=Main, 2=Sub) | + +--- + +### 7.9 Get Historical Video Streaming Address + +Generate a playback URL for recorded video segments. + +| | | +|---|---| +| **Method** | `jimi.device.media.history.stream` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `channel` | int | Yes | Camera channel | +| `appId` | string | Yes | Application identifier | +| `beginTime` | string | No | Playback start time | +| `endTime` | string | No | Playback end time | +| `fileNameList` | string | No | Specific file names | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `result` | string | Playback streaming URL | + +--- + +### 7.10 Close Streaming + +Terminate an active video streaming session to release resources. + +| | | +|---|---| +| **Method** | `jimi.device.media.close.stream` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `channel` | int | Yes | Camera channel | +| `type` | string | Yes | Stream type to close | +| `appId` | string | Yes | Application identifier | + +--- + +## 8. Command Management + +### 8.1 Get Command List Supported by Device + +Retrieve available command templates for a target device model. + +| | | +|---|---| +| **Method** | `jimi.open.instruction.list` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `id` | string | Command template ID | +| `orderName` | string | Command name | +| `orderContent` | string | Command template | +| `orderExplain` | string | Description | +| `isOffLine` | boolean | Supports offline delivery | + +--- + +### 8.2 Send Command to Device + +Transmit a formatted instruction with substituted parameters. + +| | | +|---|---| +| **Method** | `jimi.open.instruction.send` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `inst_param_json` | string | Yes | JSON-encoded command parameters | + +--- + +### 8.3 Get Results of Command Execution + +Retrieve execution status and device response for a sent command. + +| | | +|---|---| +| **Method** | `jimi.open.instruction.result` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | + +**Response (array):** + +| Field | Type | Description | +|---|---|---| +| `codeId` | string | Command ID | +| `code` | string | Command code | +| `content` | string | Device response content | +| `isExecute` | boolean | Whether executed | +| `sendTime` | string | Send timestamp | +| `sender` | string | Who sent | +| `receiveDevice` | string | Target device | + +--- + +### 8.4 Send Raw Command Data + +Transmit hexadecimal command directly to device (advanced usage). + +| | | +|---|---| +| **Method** | `jimi.open.instruction.raw.send` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `raw_cmd` | string | Yes | Hex-encoded command data | + +--- + +## 9. Geofencing + +### 9.1 Create Device Geo-fence + +Establish a circular boundary alarm directly on the device. + +| | | +|---|---| +| **Method** | `jimi.open.device.fence.create` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `fence_name` | string | Yes | Fence display name | +| `alarm_type` | string | Yes | Alarm trigger type | +| `report_mode` | string | Yes | Reporting mode | +| `alarm_switch` | string | Yes | Enable/disable | +| `lng` | double | Yes | Center longitude | +| `lat` | double | Yes | Center latitude | +| `radius` | int | Yes | Radius in meters | +| `zoom_level` | int | Yes | Map zoom level | +| `map_type` | string | Yes | Map coordinate system | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `result` | string | Fence serial number | + +--- + +### 9.2 Delete Device Geo-fence + +| | | +|---|---| +| **Method** | `jimi.open.device.fence.delete` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `imei` | string | Yes | Device IMEI | +| `instruct_no` | string | Yes | Fence serial number | + +--- + +### 9.3 Create Platform Geo-fence + +Establish a server-side boundary definition (supports polygons, circles, etc.). + +| | | +|---|---| +| **Method** | `jimi.open.platform.fence.create` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `account` | string | Yes | Account ID | +| `fence_name` | string | Yes | Fence name | +| `fence_type` | string | Yes | Geometry type | +| `geom` | string | Yes | Geometry definition (GeoJSON or WKT) | +| `fence_color` | string | No | Display color | +| `radius` | int | No | Radius (for circle type) | +| `description` | string | No | Description | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `data` | string | Created fence ID | + +--- + +### 9.4 Delete Platform Geo-fence + +| | | +|---|---| +| **Method** | `jimi.open.platform.fence.delete` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `account` | string | Yes | Account ID | +| `fence_id` | string | Yes | Fence ID to delete | + +--- + +### 9.5 Bind Devices to Platform Geo-fence + +Associate devices with a server boundary and configure alert triggers. + +| | | +|---|---| +| **Method** | `jimi.open.platform.fence.bind` | +| **Auth required** | Yes | + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `fence_id` | string | Yes | Fence ID | +| `imeis` | string | No | Comma-separated IMEIs | +| `alert_type` | string | No | Alert trigger (enter/exit/both) | +| `stay_time_in` | int | No | Dwell time threshold for entry (seconds) | +| `stay_time_out` | int | No | Dwell time threshold for exit (seconds) | + +**Response:** + +| Field | Type | Description | +|---|---|---| +| `data` | int | Count of related devices | + +--- + +## 10. Data Push API (Webhooks) + +The Data Push API is a **server-to-server push architecture** where Jimi's servers POST data to your HTTP endpoints. This is the primary mechanism for receiving real-time telemetry and the **only way** to receive certain data types (OBD, fault codes, heartbeat). + +You must configure your callback URL in the Tracksolid Pro platform. All push endpoints use: +- **Method:** POST +- **Content-Type:** `application/x-www-form-urlencoded` +- **Common parameters:** `token` (string) and `data_list` (JSON string array, max 50 items per request) +- **Expected response:** `{"code": 0, "msg": "success"}` + +--- + +### 10.1 Login/Logout Events + +| Endpoint | `{YourURL}/pushevent` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `type` | string | `LOGIN` or `LOGOUT` | +| `gateTime` | string | Event time (`yyyy-MM-dd HH:mm:ss`) | +| `timezone` | string | Device timezone (e.g. `GMT+08:00`) | + +--- + +### 10.2 Heartbeat Data + +| Endpoint | `{YourURL}/pushhb` | +|---|---| + +**`data_list` fields (max 50 per request):** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Heartbeat time | +| `powerLevel` | int | Battery percentage | +| `gsmSign` | int | GSM signal strength | +| `acc` | int | ACC status (0=OFF, 1=ON) | +| `powerStatus` | int | 0=Not charging, 1=Charging | +| `fortify` | int | Defense/fortify status | + +--- + +### 10.3 GPS Data + +| Endpoint | `{YourURL}/pushgps` | +|---|---| + +**`data_list` fields (max 50 per request):** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `gpsTime` | string | GPS fix time | +| `gateTime` | string | Server receive time | +| `lng` | double | Longitude | +| `lat` | double | Latitude | +| `satelliteNum` | int | Satellites used | +| `gpsSpeed` | int | Speed (km/h) | +| `direction` | int | Heading (0-360 degrees) | +| `acc` | int | ACC status | +| `postType` | int | 1=GPS, 2=LBS, 3=WiFi | +| `postMethod` | int | Upload mode (0x00-0x0F) | +| `status` | int | Position status flags | +| `altitude` | int | Altitude (meters) | +| `distance` | int | Mileage (meters) | + +--- + +### 10.4 Alarm Data + +| Endpoint | `{YourURL}/pushalarm` | +|---|---| + +**`data_list` fields (max 50 per request):** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `alarmType` | string | Alarm type | +| `alarmName` | string | Alarm display name | +| `gateTime` | string | Event time | +| *(additional fields vary by alarm type)* | | | + +--- + +### 10.5 RFID Data + +| Endpoint | `{YourURL}/rfid` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `moduleType` | string | RFID module type | +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Scan time | +| `postTime` | string | Post time | +| `value` | string | Encoded RFID value | +| `gpsTime` | string | Optional GPS time | +| `lng`, `lat` | double | Optional GPS position | + +--- + +### 10.6 Plug-In Module Data + +| Endpoint | `{YourURL}/wgtc` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `hexContent` | string | Hex-formatted module data | +| `postTime` | string | Optional timestamp | +| `gpsTime` | string | Optional GPS time | +| `lng`, `lat` | double | Optional GPS position | +| `category` | string | Protocol (0x9B, 0xF2) | +| `type` | int | Message type | + +--- + +### 10.7 Fuel / Oil Sensor Data + +| Endpoint | `{YourURL}/pushoil` | +|---|---| + +**`data_list` fields (max 50 per request):** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `path` | int | Sensor ID | +| `gateTime` | string | Reading time | +| `value` | double | Oil level (divide by 100 for actual value) | +| `unit` | int | 1=cm, 2=%, 3=V, 4=L | +| `gpsTime` | string | Optional GPS time | +| `lng`, `lat` | double | Optional GPS position | + +--- + +### 10.8 File Upload Notification + +| Endpoint | `{YourURL}/pushfileupload` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `fileName` | string | File list (semicolon-separated) | +| `gateTime` | string | Upload time | +| `result` | string | `SUCCESS` or `FAILURE` | + +--- + +### 10.9 Temperature & Humidity + +| Endpoint | `{YourURL}/pushtem` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `tem` | double | Temperature value | +| `hum` | double | Humidity percentage | +| `gateTime` | string | Reading time | +| `postTime` | string | Post time | + +--- + +### 10.10 LBS / Cell Tower Data + +| Endpoint | `{YourURL}/pushlbs` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `postType` | string | `WIFI` or `LBS` | +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Reading time | +| `lbsJson` | string | JSON with MCC, MNC, cellList (LAC, CI, RSSI) | + +--- + +### 10.11 Resource List (Video Inventory) + +| Endpoint | `{YourURL}/pushresourcelist` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `imei` | string | Device IMEI | +| `totalNum` | string | Number of resources | +| `instructionID` | string | Unique instruction ID | +| `resourceList` | array | Objects with: `channel`, `beginTime`, `endTime`, `fileSize`, `resourceType`, `codeType`, `storageType`, `alarmFlag` | + +--- + +### 10.12 FTP Upload Result + +| Endpoint | `{YourURL}/pushftpfileupload` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `result` | int | 0=Success, 1=Failure | +| `deviceImei` | string | Device IMEI | +| `instructionID` | string | Instruction reference | +| `gateTime` | string | Completion time | + +--- + +### 10.13 IoTHub Events + +| Endpoint | `{YourURL}/pushIothubEvent` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Event time | +| `eventType` | string | One of: `UploadAlarmFileList`, `UploadAlarmFileBegin`, `UploadAlarmFileEnd`, `UploadMediaFileBegin`, `UploadMediaFileEnd` | +| `eventContent` | string | Event-specific JSON payload | + +--- + +### 10.14 PassThrough Data + +| Endpoint | `{YourURL}/pushPassThroughData` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Receive time | +| `type` | int | Message type (0, 11, 65, 66, 240-255) | +| `category` | string | Protocol (0x0900, 0x9C, 0x94) | +| `content` | string | Base64-encoded message body | + +--- + +### 10.15 Extension Data + +| Endpoint | `{YourURL}/pushTerminalTransInfo` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `postTime` | string | Post time | +| `deviceImei` | string | Device IMEI | +| `gpsTime` | string | Optional GPS time | +| `lng`, `lat` | double | Optional GPS position | +| `extensionId` | int | 8197=device status, 8199=serial port data | +| `content` | string | Extension-specific JSON | + +--- + +### 10.16 Offline Command Response + +| Endpoint | `{YourURL}/pushInstructResponse` | +|---|---| + +**Fields:** + +| Field | Type | Description | +|---|---|---| +| `token` | string | Auth token | +| `msgType` | int | 1=Asynchronous, 2=Offline commands | +| `data_list` | string | JSON with `_code`, `_msg`, `_imei`, `_serverFlagId`, `_content` | + +--- + +### 10.17 Facial ID List + +| Endpoint | `{YourURL}/pushFileContent` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `instructionId` | string | Instruction reference | +| `postTime` | string | Post time | +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Event time | +| `content` | array | ID list strings | + +--- + +### 10.18 Extended Data (JIMI KKS) + +| Endpoint | `{YourURL}/pushextendedkks` | +|---|---| + +Pushes extended device data with sub-message types identified by `subMessagesId`: +- GPS data, KC208 events, power info, Bluetooth peripherals, positioning method, distance, ACC, LBS, pass-through data, positional accuracy + +--- + +### 10.19 DVR Upload Callback + +| Endpoint | `{YourURL}/uploadCallback` | +|---|---| + +Called when a device finishes uploading media (photo, video, event clip) to Jimi's cloud. + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `businessType` | string | `regularPicture`, `remotePictureOrVideo`, `eventAttachment`, `historyVideo`, `facePicture`, etc. | +| `imei` | string | Device IMEI | +| `camera` | int | Camera channel index | +| `shootType` | int | 1=Photo, 2=Video | +| `shootTime` | int | Video duration (seconds) | +| `alarmTime` | long | Unix timestamp of alarm trigger | +| `lat`, `lng` | string | GPS coordinates | +| `mimeType` | string | `image/jpeg`, `video/mp4`, etc. | +| `localFileName` | string | Original device filename | +| `filename` | string | Cloud storage filename | +| `timezone` | string | Optional timezone | +| `instructionId` | string | Optional instruction reference | + +--- + +### 10.20 OBD Data + +| Endpoint | `{YourURL}/pushobd` | +|---|---| + +> **Important:** This is the only documented method for receiving OBD data. There is no polling/pull endpoint for OBD. + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `obdJson` | object | Contains all OBD readings | +| `obdJson.car_type` | int | 1=Commercial, 2=Passenger | +| `obdJson.push_time` | string | Push timestamp | +| `obdJson.event_time` | string | Event timestamp | +| `obdJson.AccState` | int | 0=OFF, 1=ON | +| `obdJson.statusFlags` | int | Status bit flags | +| `obdJson.lng` | double | Longitude | +| `obdJson.lat` | double | Latitude | +| `obdJson.dataID1..N` | int | Vehicle parameter values (engine RPM, coolant temp, fuel level, etc.) | + +--- + +### 10.21 DTC Fault Codes + +| Endpoint | `{YourURL}/pushfaultinfo` | +|---|---| + +> **Important:** This is the only documented method for receiving DTC fault codes. + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Report time (`yyyy-MM-dd HH:mm:ss`) | +| `faultCodeList` | array | DTC codes (e.g. `["P0301", "P0420"]`) | +| `faultNum` | int | Number of fault codes | +| `statusFlags` | int | OBD status bit flags | +| `lng` | double | GPS longitude | +| `lat` | double | GPS latitude | +| `eventTime` | long | Fault event timestamp (Unix) | + +--- + +### 10.22 Trip Report + +| Endpoint | `{YourURL}/pushtripreport` | +|---|---| + +**`data_list` fields:** + +| Field | Type | Description | +|---|---|---| +| `deviceImei` | string | Device IMEI | +| `gateTime` | string | Report time | +| `miles` | double | Distance (km) | +| `oils` | double | Fuel consumption (L) | +| `idleTimes` | int | Accumulated idle time (seconds) | +| `tripSeq` | int | Trip sequence number | +| `beginTime` | string | Trip start (BCD format) | +| `endTime` | string | Trip end (BCD format) | +| `beginLat`, `beginLng` | double | Start coordinates | +| `endLat`, `endLng` | double | End coordinates | +| `properties` | int | 1=Start, 2=End | + +--- + +## Appendix A: Instruction API (sendInstruct) + +For advanced device control, the instruction API uses a separate endpoint: + +**Endpoint:** `{InsAddress}/api/device/sendInstruct` (POST) + +Common parameters: `deviceImei`, `proNo` (protocol number), `cmdContent` (JSON), `token` + +| proNo | Function | Key cmdContent Fields | +|---|---|---| +| `128` | Universal text command | `cmdContent` (command string), `sync`, `offlineFlag`, `timeOut` | +| `37121` | Start real-time A/V stream | `dataType`, `codeStreamType`, `channel`, `videoIP`, `videoTCPPort` | +| `37122` | Control A/V stream | `channel`, `cmd` (0=Off, 1=Switch, 2=Pause, 3=Resume), `dataType` | +| `37381` | Query on-device video list | `channel`, `beginTime`, `endTime`, `alarmFlag`, `resourceType`, `instructionID` | +| `37377` | Start video playback | `serverAddress`, `tcpPort`, `channel`, `beginTime`, `endTime`, `playMethod`, `instructionID` | +| `33283` | Acknowledge alarm | `alarmSerialNo` | + +**Response codes (`data._code`):** + +| Code | Meaning | +|---|---| +| `100` | Success | +| `200` | Invalid parameter | +| `300` | Device offline | +| `600` | Timeout | + +--- + +## Appendix B: API Coverage in This Codebase + +| API Method | Pipeline | Status | +|---|---|---| +| `jimi.oauth.token.get` | `ts_shared_rev.py` | In use | +| `jimi.oauth.token.refresh` | `ts_shared_rev.py` | In use | +| `jimi.user.device.list` | `ingest_movement_rev.py` | In use | +| `jimi.track.device.detail` | `ingest_movement_rev.py` | In use | +| `jimi.user.device.location.list` | `ingest_movement_rev.py` | In use | +| `jimi.device.track.mileage` | `ingest_movement_rev.py` | In use | +| `jimi.device.alarm.list` | `ingest_events_rev.py` | In use (verify field names) | +| `jimi.device.obd.list` | `ingest_events_rev.py` | **Not a real endpoint** — OBD is push-only | +| `jimi.device.track.list` | — | Not used (high-res GPS trails) | +| `jimi.open.platform.report.parking` | — | Not used (parking_events table exists) | +| `jimi.device.jimi.media.URL` | — | Not used (media catalog) | +| `jimi.device.media.event.URL` | — | Not used (alarm-triggered media) | +| All Data Push endpoints | — | Not used (webhook receiver needed) | diff --git a/ts_shared_rev.py b/ts_shared_rev.py index 2fbf39b..18589c4 100644 --- a/ts_shared_rev.py +++ b/ts_shared_rev.py @@ -2,7 +2,7 @@ ts_shared_rev.py — Fireside Communications · Tracksolid Pro Ingestion Stack ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Shared utilities: config, signing, HTTP, DB pool, token cache, clean helpers. -Imported by ingest_movement_rev.py and ingest_events_rev.py. +Imported by ingest_movement_rev.py, ingest_events_rev.py, and webhook_receiver_rev.py. REVISIONS (QA-Verified): [FIX-01] Secrets exclusively from env (Security). @@ -12,6 +12,8 @@ REVISIONS (QA-Verified): [FIX-05] API rate-limit (1006) back-off + re-sign (Resiliency). [FIX-QA-01] clean_num/clean_int return None on non-numeric (Data Integrity). [FIX-QA-02] api_post catches all RequestExceptions for retry (Robustness). + [FIX-09] get_conn auto-commits on success (Data Integrity). + [FIX-11] Consolidated safe_task/setup_shutdown (DRY). ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ """ @@ -20,6 +22,8 @@ from __future__ import annotations import hashlib import logging import os +import signal +import sys import time from contextlib import contextmanager from datetime import datetime, timezone, timedelta @@ -85,12 +89,13 @@ def _get_pool() -> psycopg2.pool.ThreadedConnectionPool: @contextmanager def get_conn(): - """Thread-safe DB connection context manager.""" + """Thread-safe DB connection context manager. Auto-commits on success, rolls back on error.""" pool = _get_pool() conn = pool.getconn() try: conn.autocommit = False yield conn + conn.commit() except Exception: conn.rollback() raise @@ -103,6 +108,29 @@ def close_pool(): _pool.closeall() _log.info("DB Pool closed.") +# ── Scheduler / Signal Utilities ───────────────────────────────────────────── + +def safe_task(fn, logger=None): + """Decorator to prevent scheduler death on single function failure.""" + _logger = logger or _log + def wrapper(): + try: + fn() + except Exception: + _logger.exception("Task %s failed. Scheduler continuing...", fn.__name__) + wrapper.__name__ = fn.__name__ + return wrapper + +def setup_shutdown(logger=None): + """Register SIGTERM/SIGINT handlers for clean DB pool closure.""" + _logger = logger or _log + def _handler(signum, frame): + _logger.info("Signal %s received. Closing DB pool...", signum) + close_pool() + sys.exit(0) + signal.signal(signal.SIGTERM, _handler) + signal.signal(signal.SIGINT, _handler) + # ── Value Cleaning (QA Fixes) ───────────────────────────────────────────────── def clean(v: Any) -> Optional[str]: @@ -127,6 +155,17 @@ def clean_int(v: Any) -> Optional[int]: except (ValueError, TypeError): return None +def clean_ts(v: Any) -> Optional[str]: + """Clean timestamp string for PostgreSQL insertion.""" + s = clean(v) + if s is None: + return None + try: + datetime.fromisoformat(s.replace("Z", "+00:00")) + return s + except (ValueError, TypeError): + return None + def is_valid_fix(lat: Any, lng: Any) -> bool: """Filters out 0,0 'Zero Island' markers and null positions.""" flat, flng = clean_num(lat), clean_num(lng) @@ -166,7 +205,7 @@ def api_post(method: str, extra: dict, access_token: Optional[str] = None, _retr r = _session.post(API_BASE_URL, data=params, timeout=25) r.raise_for_status() data = r.json() - except Exception as e: + except (requests.RequestException, ValueError) as e: if _retry_count < 3: time.sleep(2 ** _retry_count) return api_post(method, extra, access_token, _retry_count + 1) diff --git a/webhook_receiver_rev.py b/webhook_receiver_rev.py new file mode 100644 index 0000000..9420fdb --- /dev/null +++ b/webhook_receiver_rev.py @@ -0,0 +1,458 @@ +""" +webhook_receiver_rev.py — Fireside Communications · Tracksolid Webhook Receiver +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +RESPONSIBILITY: Receives real-time push data from Jimi Tracksolid Pro servers. + +Jimi's Data Push API POSTs telemetry to these endpoints as it arrives from +devices, providing real-time ingestion without polling. This is the ONLY way +to receive OBD diagnostics and DTC fault codes — those data types have no +polling endpoint. + +ENDPOINTS: + /pushobd — OBD CAN bus diagnostics (Priority 1) + /pushfaultinfo — DTC fault codes (Priority 1) + /pushalarm — Alarm events (Priority 2) + /pushgps — GPS positions (Priority 2) + /pushhb — Device heartbeats (Priority 2) + /pushtripreport — Trip reports (Priority 2) + /health — Healthcheck for Docker/monitoring +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +from __future__ import annotations + +import json +import os +import time +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Optional + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import JSONResponse + +from ts_shared_rev import ( + close_pool, + get_conn, + log_ingestion, + clean, + clean_num, + clean_int, + clean_ts, + is_valid_fix, + get_logger, +) + +log = get_logger("webhook") + +# ── Configuration ───────────────────────────────────────────────────────────── + +WEBHOOK_TOKEN = os.getenv("JIMI_WEBHOOK_TOKEN", "") + +# ── Lifespan ────────────────────────────────────────────────────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + log.info("Webhook receiver starting (v1.0)...") + yield + log.info("Webhook receiver shutting down...") + close_pool() + +app = FastAPI(title="Tracksolid Webhook Receiver", lifespan=lifespan) + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +SUCCESS = {"code": 0, "msg": "success"} + + +def _validate_token(token: str) -> None: + """Raise 403 if token is invalid. Skips validation if JIMI_WEBHOOK_TOKEN is empty.""" + if WEBHOOK_TOKEN and token != WEBHOOK_TOKEN: + raise HTTPException(status_code=403, detail="Invalid token") + + +def _parse_data_list(raw: str) -> list[dict]: + """Parse the JSON string from Jimi's data_list form field.""" + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return parsed + return [parsed] + except (json.JSONDecodeError, TypeError): + log.warning("Failed to parse data_list: %.200s", raw) + return [] + + +def unix_to_ts(v) -> Optional[str]: + """Convert Unix timestamp (seconds or milliseconds) to ISO string.""" + if v is None: + return None + try: + ts = int(v) + if ts > 1e12: + ts = ts // 1000 + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError, OSError): + return None + + +def _make_geom_params(lat, lng): + """Return (lng, lat, lng, lat) tuple for the CASE WHEN ST_MakePoint pattern.""" + return (lng, lat, lng, lat) + +# ── Health Check ────────────────────────────────────────────────────────────── + +@app.get("/health") +def health(): + return {"status": "ok"} + +# ── 1. OBD Diagnostics (Priority 1) ────────────────────────────────────────── + +@app.post("/pushobd") +def push_obd(token: str = Form(""), data_list: str = Form("")): + _validate_token(token) + items = _parse_data_list(data_list) + if not items: + return JSONResponse(content=SUCCESS) + + t0 = time.time() + inserted = 0 + + with get_conn() as conn: + with conn.cursor() as cur: + for item in items: + try: + imei = clean(item.get("deviceImei")) + obd = item.get("obdJson", {}) + if isinstance(obd, str): + try: + obd = json.loads(obd) + except json.JSONDecodeError: + obd = {} + + event_time = clean_ts(obd.get("event_time")) + if not imei or not event_time: + continue + + lat = clean_num(obd.get("lat")) + lng = clean_num(obd.get("lng")) + + cur.execute(""" + INSERT INTO tracksolid.obd_readings ( + imei, reading_time, car_type, acc_state, status_flags, + lat, lng, geom, obd_data, updated_at + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, + CASE WHEN %s IS NOT NULL AND %s IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) + ELSE NULL END, + %s, NOW() + ) ON CONFLICT (imei, reading_time) DO UPDATE SET + obd_data = EXCLUDED.obd_data, + updated_at = NOW() + """, ( + imei, event_time, + clean_int(obd.get("car_type")), + clean_int(obd.get("AccState")), + clean_int(obd.get("statusFlags")), + lat, lng, + *_make_geom_params(lat, lng), + json.dumps(obd), + )) + inserted += 1 + except Exception: + log.warning("Failed to process OBD item for %s", item.get("deviceImei"), exc_info=True) + + log_ingestion(cur, "webhook/pushobd", len(items), 0, inserted, + int((time.time() - t0) * 1000), True) + conn.commit() + + log.info("pushobd: %d/%d items processed.", inserted, len(items)) + return JSONResponse(content=SUCCESS) + +# ── 2. DTC Fault Codes (Priority 1) ────────────────────────────────────────── + +@app.post("/pushfaultinfo") +def push_fault_info(token: str = Form(""), data_list: str = Form("")): + _validate_token(token) + items = _parse_data_list(data_list) + if not items: + return JSONResponse(content=SUCCESS) + + t0 = time.time() + inserted = 0 + + with get_conn() as conn: + with conn.cursor() as cur: + for item in items: + try: + imei = clean(item.get("deviceImei")) + gate_time = clean_ts(item.get("gateTime")) + if not imei or not gate_time: + continue + + fault_codes = item.get("faultCodeList", []) + if isinstance(fault_codes, str): + try: + fault_codes = json.loads(fault_codes) + except json.JSONDecodeError: + fault_codes = [] + + lat = clean_num(item.get("lat")) + lng = clean_num(item.get("lng")) + evt_time = unix_to_ts(item.get("eventTime")) or clean_ts(item.get("eventTime")) + + for code in fault_codes: + cur.execute(""" + INSERT INTO tracksolid.fault_codes ( + imei, reported_at, fault_code, status_flags, + lat, lng, geom, event_time + ) VALUES ( + %s, %s, %s, %s, %s, %s, + CASE WHEN %s IS NOT NULL AND %s IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) + ELSE NULL END, + %s + ) ON CONFLICT (imei, reported_at, fault_code) DO NOTHING + """, ( + imei, gate_time, clean(code), + clean_int(item.get("statusFlags")), + lat, lng, + *_make_geom_params(lat, lng), + evt_time, + )) + inserted += 1 + except Exception: + log.warning("Failed to process fault item for %s", item.get("deviceImei"), exc_info=True) + + log_ingestion(cur, "webhook/pushfaultinfo", len(items), 0, inserted, + int((time.time() - t0) * 1000), True) + conn.commit() + + log.info("pushfaultinfo: %d fault codes from %d items.", inserted, len(items)) + return JSONResponse(content=SUCCESS) + +# ── 3. Alarm Events (Priority 2) ───────────────────────────────────────────── + +@app.post("/pushalarm") +def push_alarm(token: str = Form(""), data_list: str = Form("")): + _validate_token(token) + items = _parse_data_list(data_list) + if not items: + return JSONResponse(content=SUCCESS) + + t0 = time.time() + inserted = 0 + + with get_conn() as conn: + with conn.cursor() as cur: + for item in items: + try: + imei = clean(item.get("deviceImei")) + alarm_type = clean(item.get("alarmType")) + alarm_time = clean_ts(item.get("gateTime")) + if not imei or not alarm_time: + continue + + lat = clean_num(item.get("lat")) + lng = clean_num(item.get("lng")) + + cur.execute(""" + INSERT INTO tracksolid.alarms ( + imei, alarm_type, alarm_name, alarm_time, geom, + lat, lng, speed, source, updated_at + ) VALUES ( + %s, %s, %s, %s, + CASE WHEN %s IS NOT NULL AND %s IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) + ELSE NULL END, + %s, %s, %s, 'push', NOW() + ) ON CONFLICT (imei, alarm_type, alarm_time) DO NOTHING + """, ( + imei, alarm_type, clean(item.get("alarmName")), alarm_time, + *_make_geom_params(lat, lng), + lat, lng, + clean_num(item.get("speed")), + )) + inserted += 1 + except Exception: + log.warning("Failed to process alarm for %s", item.get("deviceImei"), exc_info=True) + + log_ingestion(cur, "webhook/pushalarm", len(items), 0, inserted, + int((time.time() - t0) * 1000), True) + conn.commit() + + log.info("pushalarm: %d/%d items processed.", inserted, len(items)) + return JSONResponse(content=SUCCESS) + +# ── 4. GPS Positions (Priority 2) ──────────────────────────────────────────── + +@app.post("/pushgps") +def push_gps(token: str = Form(""), data_list: str = Form("")): + _validate_token(token) + items = _parse_data_list(data_list) + if not items: + return JSONResponse(content=SUCCESS) + + t0 = time.time() + inserted = 0 + + with get_conn() as conn: + with conn.cursor() as cur: + for item in items: + try: + imei = clean(item.get("deviceImei")) + gps_time = clean_ts(item.get("gpsTime")) + lat = clean_num(item.get("lat")) + lng = clean_num(item.get("lng")) + + if not imei or not gps_time or not is_valid_fix(lat, lng): + continue + + cur.execute(""" + INSERT INTO tracksolid.position_history ( + imei, gps_time, geom, lat, lng, speed, direction, + acc_status, satellite, current_mileage, + altitude, post_type, source + ) VALUES ( + %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), + %s, %s, %s, %s, %s, %s, %s, %s, %s, 'push' + ) ON CONFLICT (imei, gps_time) DO NOTHING + """, ( + imei, gps_time, lng, lat, + lat, lng, + clean_num(item.get("gpsSpeed")), + clean_num(item.get("direction")), + str(item.get("acc")) if item.get("acc") is not None else None, + clean_int(item.get("satelliteNum")), + clean_num(item.get("distance")), + clean_num(item.get("altitude")), + clean_int(item.get("postType")), + )) + inserted += 1 + except Exception: + log.warning("Failed to process GPS for %s", item.get("deviceImei"), exc_info=True) + + log_ingestion(cur, "webhook/pushgps", len(items), 0, inserted, + int((time.time() - t0) * 1000), True) + conn.commit() + + log.info("pushgps: %d/%d items processed.", inserted, len(items)) + return JSONResponse(content=SUCCESS) + +# ── 5. Device Heartbeats (Priority 2) ──────────────────────────────────────── + +@app.post("/pushhb") +def push_heartbeat(token: str = Form(""), data_list: str = Form("")): + _validate_token(token) + items = _parse_data_list(data_list) + if not items: + return JSONResponse(content=SUCCESS) + + t0 = time.time() + inserted = 0 + + with get_conn() as conn: + with conn.cursor() as cur: + for item in items: + try: + imei = clean(item.get("deviceImei")) + gate_time = clean_ts(item.get("gateTime")) + if not imei or not gate_time: + continue + + cur.execute(""" + INSERT INTO tracksolid.heartbeats ( + imei, gate_time, power_level, gsm_signal, + acc_status, power_status, fortify + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (imei, gate_time) DO NOTHING + """, ( + imei, gate_time, + clean_int(item.get("powerLevel")), + clean_int(item.get("gsmSign")), + clean_int(item.get("acc")), + clean_int(item.get("powerStatus")), + clean_int(item.get("fortify")), + )) + inserted += 1 + except Exception: + log.warning("Failed to process heartbeat for %s", item.get("deviceImei"), exc_info=True) + + log_ingestion(cur, "webhook/pushhb", len(items), 0, inserted, + int((time.time() - t0) * 1000), True) + conn.commit() + + log.info("pushhb: %d/%d items processed.", inserted, len(items)) + return JSONResponse(content=SUCCESS) + +# ── 6. Trip Reports (Priority 2) ───────────────────────────────────────────── + +@app.post("/pushtripreport") +def push_trip_report(token: str = Form(""), data_list: str = Form("")): + _validate_token(token) + items = _parse_data_list(data_list) + if not items: + return JSONResponse(content=SUCCESS) + + t0 = time.time() + inserted = 0 + + with get_conn() as conn: + with conn.cursor() as cur: + for item in items: + try: + imei = clean(item.get("deviceImei")) + begin_time = clean_ts(item.get("beginTime")) + end_time = clean_ts(item.get("endTime")) + if not imei or not begin_time: + continue + + miles_km = clean_num(item.get("miles")) + distance_m = miles_km * 1000 if miles_km is not None else None + + begin_lat = clean_num(item.get("beginLat")) + begin_lng = clean_num(item.get("beginLng")) + end_lat = clean_num(item.get("endLat")) + end_lng = clean_num(item.get("endLng")) + + cur.execute(""" + INSERT INTO tracksolid.trips ( + imei, start_time, end_time, distance_m, + start_geom, end_geom, + fuel_consumed_l, idle_time_s, trip_seq, source, + updated_at + ) VALUES ( + %s, %s, %s, %s, + CASE WHEN %s IS NOT NULL AND %s IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) + ELSE NULL END, + CASE WHEN %s IS NOT NULL AND %s IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(%s, %s), 4326) + ELSE NULL END, + %s, %s, %s, 'push', NOW() + ) ON CONFLICT (imei, start_time) DO UPDATE SET + end_time = EXCLUDED.end_time, + distance_m = EXCLUDED.distance_m, + end_geom = EXCLUDED.end_geom, + fuel_consumed_l = EXCLUDED.fuel_consumed_l, + idle_time_s = EXCLUDED.idle_time_s, + updated_at = NOW() + """, ( + imei, begin_time, end_time, distance_m, + begin_lng, begin_lat, begin_lng, begin_lat, + end_lng, end_lat, end_lng, end_lat, + clean_num(item.get("oils")), + clean_int(item.get("idleTimes")), + clean_int(item.get("tripSeq")), + )) + inserted += 1 + except Exception: + log.warning("Failed to process trip for %s", item.get("deviceImei"), exc_info=True) + + log_ingestion(cur, "webhook/pushtripreport", len(items), 0, inserted, + int((time.time() - t0) * 1000), True) + conn.commit() + + log.info("pushtripreport: %d/%d items processed.", inserted, len(items)) + return JSONResponse(content=SUCCESS)