#!/usr/bin/env python3 """JT808 TCP ingest to TDengine history smoke and light-load verifier.""" from __future__ import annotations import argparse import concurrent.futures import datetime as dt import json import os import pathlib import socket import time import urllib.parse import urllib.request from dataclasses import dataclass from typing import Any try: from tools import tdengine_smoke except ModuleNotFoundError: import tdengine_smoke def bcd(value: str) -> bytes: digits = "".join(ch for ch in value if ch.isdigit()) if len(digits) % 2: digits = "0" + digits return bytes((int(digits[i]) << 4) | int(digits[i + 1]) for i in range(0, len(digits), 2)) def write_u16(value: int) -> bytes: return bytes([(value >> 8) & 0xFF, value & 0xFF]) def write_u32(value: int) -> bytes: return bytes([ (value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF, ]) def ascii_fixed(value: str, length: int) -> bytes: raw = value.encode("ascii", errors="ignore")[:length] return raw + b" " * (length - len(raw)) def bcc(data: bytes) -> int: result = 0 for byte in data: result ^= byte return result def escape_payload(data: bytes) -> bytes: out = bytearray() for byte in data: if byte == 0x7E: out += b"\x7D\x02" elif byte == 0x7D: out += b"\x7D\x01" else: out.append(byte) return bytes(out) def unescape_payload(data: bytes) -> bytes: out = bytearray() index = 0 while index < len(data): byte = data[index] if byte == 0x7D and index + 1 < len(data): marker = data[index + 1] if marker == 0x02: out.append(0x7E) index += 2 continue if marker == 0x01: out.append(0x7D) index += 2 continue out.append(byte) index += 1 return bytes(out) def build_frame(message_id: int, phone: str, serial: int, body: bytes) -> bytes: payload = ( write_u16(message_id) + write_u16(len(body) & 0x03FF) + bcd(phone)[-6:] + write_u16(serial & 0xFFFF) + body ) payload += bytes([bcc(payload)]) return b"\x7E" + escape_payload(payload) + b"\x7E" def build_register_body(device_id: str, plate: str) -> bytes: return ( write_u16(44) + write_u16(4401) + ascii_fixed("MAKER", 5) + ascii_fixed("TYPE-A", 20) + ascii_fixed(device_id, 7) + bytes([1]) + plate.encode("ascii", errors="ignore") ) def build_location_body(event_time: dt.datetime, sequence: int = 0) -> bytes: timestamp = event_time.strftime("%y%m%d%H%M%S") return ( write_u32(0) + write_u32(0x00030000) + write_u32(39916527 + sequence) + write_u32(116397128 + sequence) + write_u16(50) + write_u16(523) + write_u16(90) + bcd(timestamp) ) def parse_event_time(value: str) -> dt.datetime: parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if parsed.tzinfo is not None: parsed = parsed.astimezone(dt.timezone.utc).replace(tzinfo=None) return parsed def send_frame(host: str, port: int, frame: bytes, timeout: float, read_response: bool, linger_ms: int = 0) -> bytes: with socket.create_connection((host, port), timeout=timeout) as sock: sock.settimeout(timeout) sock.sendall(frame) if not read_response: if linger_ms > 0: time.sleep(linger_ms / 1000) return b"" try: return sock.recv(1024) except socket.timeout: return b"" def send_register(host: str, port: int, phone: str, serial: int, timeout: float) -> bytes: device_id = "D" + phone[-6:] plate = "B" + phone[-5:] return send_frame( host, port, build_frame(0x0100, phone, serial, build_register_body(device_id, plate)), timeout, True, ) def send_location(host: str, port: int, phone: str, serial: int, event_time: dt.datetime, timeout: float, linger_ms: int) -> bytes: return send_frame( host, port, build_frame(0x0200, phone, serial, build_location_body(event_time, serial)), timeout, False, linger_ms, ) def recv_optional(sock: socket.socket, timeout: float) -> bytes: previous_timeout = sock.gettimeout() sock.settimeout(timeout) try: return sock.recv(1024) except socket.timeout: return b"" finally: sock.settimeout(previous_timeout) def send_phone_sequence( host: str, port: int, phone: str, phone_index: int, frames: int, event_time: dt.datetime, timeout: float, rate: float, linger_ms: int, post_register_ms: int, ) -> tuple[bytes, int]: with socket.create_connection((host, port), timeout=timeout) as sock: sock.settimeout(timeout) device_id = "D" + phone[-6:] plate = "B" + phone[-5:] sock.sendall(build_frame(0x0100, phone, 100 + phone_index, build_register_body(device_id, plate))) ack = recv_optional(sock, timeout) if post_register_ms > 0: time.sleep(post_register_ms / 1000) sent = 0 for frame_index in range(frames): serial = 1000 + phone_index * frames + frame_index sock.sendall(build_frame( 0x0200, phone, serial, build_location_body(event_time + dt.timedelta(seconds=frame_index), serial), )) sent += 1 if rate > 0: time.sleep(1 / rate) if linger_ms > 0: time.sleep(linger_ms / 1000) return ack, sent @dataclass(frozen=True) class SendWorkloadResult: sent_registers: int sent_locations: int elapsed_seconds: float def send_one_phone(args: argparse.Namespace, phone: str, phone_index: int, event_time: dt.datetime) -> tuple[bytes, int]: if args.connection_mode == "session": return send_phone_sequence( args.tcp_host, args.tcp_port, phone, phone_index, args.frames, event_time, args.tcp_timeout, args.rate, args.linger_ms, args.post_register_ms, ) ack = send_register(args.tcp_host, args.tcp_port, phone, 100 + phone_index, args.tcp_timeout) if args.post_register_ms > 0: time.sleep(args.post_register_ms / 1000) sent = 0 for frame_index in range(args.frames): serial = 1000 + phone_index * args.frames + frame_index send_location( args.tcp_host, args.tcp_port, phone, serial, event_time + dt.timedelta(seconds=frame_index), args.tcp_timeout, args.linger_ms, ) sent += 1 if args.rate > 0: time.sleep(1 / args.rate) return ack, sent def send_phone_workload(args: argparse.Namespace, phones: list[str], event_time: dt.datetime) -> SendWorkloadResult: sent_locations = 0 start = time.monotonic() workers = max(1, int(getattr(args, "workers", 1))) def run_one(index_and_phone: tuple[int, str]) -> tuple[bytes, int]: phone_index, phone = index_and_phone return send_one_phone(args, phone, phone_index, event_time) indexed_phones = list(enumerate(phones)) if workers == 1 or len(indexed_phones) <= 1: results = [run_one(item) for item in indexed_phones] else: with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: results = list(executor.map(run_one, indexed_phones)) for phone, (ack, sent) in zip(phones, results): if args.require_register_ack and not ack: raise RuntimeError(f"register ack timeout for {phone}") sent_locations += sent elapsed = max(time.monotonic() - start, 0.001) return SendWorkloadResult( sent_registers=len(phones), sent_locations=sent_locations, elapsed_seconds=elapsed, ) def query_json(base_url: str, path: str, params: dict[str, Any], timeout: float) -> dict[str, Any]: url = base_url.rstrip("/") + path + "?" + urllib.parse.urlencode(params) request = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) def location_params(phone: str, date_from: str, date_to: str, limit: int) -> dict[str, Any]: return { "phone": phone, "dateFrom": date_from, "dateTo": date_to, "limit": limit, "order": "ASC", } def next_page_params(params: dict[str, Any], page: dict[str, Any]) -> dict[str, Any]: cursor = page.get("nextCursor") or {} next_params = dict(params) if cursor: next_params["cursorTs"] = cursor["ts"] next_params["cursorId"] = cursor["id"] return next_params def archive_path(root: pathlib.Path, uri: str) -> pathlib.Path: prefix = "archive://" if not uri.startswith(prefix): raise ValueError(f"unsupported archive uri: {uri}") return root / uri[len(prefix):] def raw_frame_count_sql(phone: str, items: list[dict[str, Any]]) -> str: sqls = raw_frame_count_sqls(phone, items) if not sqls: raise ValueError("no raw uri found in jt808 location items") return sqls[0] def raw_frame_count_sqls(phone: str, items: list[dict[str, Any]]) -> list[str]: seen: set[str] = set() sqls: list[str] = [] for item in items: raw_uri = item.get("rawUri") or "" if not raw_uri or raw_uri in seen: continue seen.add(raw_uri) sqls.append(tdengine_smoke.raw_frame_count_sql( protocol="JT808", vehicle_key=item.get("vehicleKey") or ("jt808:" + phone), phone=phone, raw_uri=raw_uri, )) return sqls def verify_tdengine_raw_frames( rest_url: str, username: str, password: str, sqls: list[str], timeout_seconds: float, request_timeout: float, ) -> int: if not sqls: return 0 verified = 0 for sql in sqls: count = wait_for_tdengine_raw_frames( rest_url, username, password, sql, 1, timeout_seconds, request_timeout) if count < 1: raise RuntimeError(f"tdengine raw_frames row not visible for query: {sql}") verified += 1 return verified def wait_for_tdengine_raw_frames( rest_url: str, username: str, password: str, sql: str, minimum: int, timeout_seconds: float, request_timeout: float, ) -> int: deadline = time.monotonic() + timeout_seconds last_count = 0 while time.monotonic() < deadline: last_count = tdengine_smoke.query_count(rest_url, username, password, sql, request_timeout) if last_count >= minimum: return last_count time.sleep(1) return last_count def wait_for_locations( history_base_url: str, phone: str, date_from: str, date_to: str, minimum: int, timeout_seconds: float, request_timeout: float, ) -> dict[str, Any]: deadline = time.monotonic() + timeout_seconds params = location_params(phone, date_from, date_to, max(minimum, 10)) last_page: dict[str, Any] = {} while time.monotonic() < deadline: last_page = query_json(history_base_url, "/api/event-history/jt808/locations", params, request_timeout) if len(last_page.get("items") or []) >= minimum: return last_page time.sleep(1) return last_page def verify_pagination(history_base_url: str, phone: str, date_from: str, date_to: str, request_timeout: float) -> bool: first_params = location_params(phone, date_from, date_to, 1) first = query_json(history_base_url, "/api/event-history/jt808/locations", first_params, request_timeout) if not first.get("items") or not first.get("nextCursor"): return False second = query_json( history_base_url, "/api/event-history/jt808/locations", next_page_params(first_params, first), request_timeout, ) return bool(second.get("items")) def generated_phone(start_phone: str, offset: int) -> str: return str(int(start_phone) + offset) def expected_history_count(args: argparse.Namespace) -> int: minimum = min(args.frames, args.expect_history_count) if args.verify_pagination and args.frames > 1: minimum = max(minimum, 2) return minimum def run(args: argparse.Namespace) -> dict[str, Any]: event_time = parse_event_time(args.event_time) date_from = (event_time - dt.timedelta(minutes=5)).isoformat() date_to = (event_time + dt.timedelta(minutes=args.frames + 5)).isoformat() phones = [generated_phone(args.start_phone, index) for index in range(args.phones)] workload = send_phone_workload(args, phones, event_time) first_phone = phones[0] page = wait_for_locations( args.history_base_url, first_phone, date_from, date_to, expected_history_count(args), args.history_timeout, args.http_timeout, ) items = page.get("items") or [] if len(items) < expected_history_count(args): raise RuntimeError(f"history rows not visible for {first_phone}: got {len(items)}") archive_checked = 0 if args.archive_root: root = pathlib.Path(args.archive_root) for item in items[: args.archive_check_limit]: path = archive_path(root, item["rawUri"]) if not path.exists(): raise RuntimeError(f"raw archive missing: {path}") archive_checked += 1 tdengine_raw_frames = None if args.tdengine_rest_url: tdengine_raw_frames = verify_tdengine_raw_frames( args.tdengine_rest_url, args.tdengine_username, args.tdengine_password, raw_frame_count_sqls(first_phone, items), args.history_timeout, args.http_timeout, ) pagination_ok = None if args.verify_pagination and args.frames > 1: pagination_ok = verify_pagination(args.history_base_url, first_phone, date_from, date_to, args.http_timeout) if not pagination_ok: raise RuntimeError("cursor pagination verification failed") return { "tcpHost": args.tcp_host, "tcpPort": args.tcp_port, "connectionMode": args.connection_mode, "workers": max(1, int(args.workers)), "phones": phones, "sentRegisters": workload.sent_registers, "sentLocations": workload.sent_locations, "sendElapsedSeconds": round(workload.elapsed_seconds, 3), "sendLocationsPerSecond": round(workload.sent_locations / workload.elapsed_seconds, 2), "historyRowsForFirstPhone": len(items), "tdengineRawFrames": tdengine_raw_frames, "archiveChecked": archive_checked, "paginationOk": pagination_ok, "firstFrameId": items[0].get("frameId") if items else None, "firstRawUri": items[0].get("rawUri") if items else None, } def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--tcp-host", default="127.0.0.1") p.add_argument("--tcp-port", type=int, default=808) p.add_argument("--history-base-url", default="http://127.0.0.1:20200") p.add_argument("--connection-mode", choices=["per-frame", "session"], default="per-frame") p.add_argument("--start-phone", default="13079962000") p.add_argument("--phones", type=int, default=1) p.add_argument("--workers", type=int, default=1, help="parallel phone sessions; 1 keeps legacy serial behavior") p.add_argument("--frames", type=int, default=3, help="location frames per phone") p.add_argument("--rate", type=float, default=0, help="location send rate per second; 0 means unlimited") p.add_argument("--linger-ms", type=int, default=2500, help="time to keep each terminal connection open after writes") p.add_argument("--post-register-ms", type=int, default=1000, help="pause after register ack before location frames") p.add_argument("--event-time", default="2024-01-02T03:04:05") p.add_argument("--expect-history-count", type=int, default=1) p.add_argument("--verify-pagination", action="store_true") p.add_argument("--archive-root", default="") p.add_argument("--archive-check-limit", type=int, default=3) p.add_argument("--tdengine-rest-url", default=os.environ.get("TDENGINE_REST_URL", "")) p.add_argument("--tdengine-username", default=os.environ.get("TDENGINE_USERNAME", "root")) p.add_argument("--tdengine-password", default=os.environ.get("TDENGINE_PASSWORD", "taosdata")) p.add_argument("--require-register-ack", action="store_true", default=True) p.add_argument("--tcp-timeout", type=float, default=2) p.add_argument("--http-timeout", type=float, default=5) p.add_argument("--history-timeout", type=float, default=30) return p def main() -> None: args = parser().parse_args() summary = run(args) print(json.dumps(summary, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()