263 lines
8.5 KiB
Python
263 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""GB32960 TCP ingest to history/archive smoke verifier."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import pathlib
|
|
import socket
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
|
|
SHANGHAI = dt.timezone(dt.timedelta(hours=8))
|
|
DEFAULT_SAMPLE = (
|
|
"modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex"
|
|
)
|
|
DEFAULT_FIELD_KEYS = "speed_kmh,total_mileage_km,longitude,latitude"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FrameInfo:
|
|
vin: str
|
|
command: int
|
|
event_time: dt.datetime | None
|
|
|
|
|
|
def read_hex_frame(path: pathlib.Path) -> bytes:
|
|
text = path.read_text(encoding="utf-8")
|
|
return bytes.fromhex("".join(text.split()))
|
|
|
|
|
|
def parse_frame_info(frame: bytes) -> FrameInfo:
|
|
if len(frame) < 25:
|
|
raise ValueError(f"gb32960 frame too short: {len(frame)}")
|
|
if frame[:2] not in (b"##", b"$$"):
|
|
raise ValueError("gb32960 frame must start with ## or $$")
|
|
data_len = int.from_bytes(frame[22:24], "big")
|
|
expected_len = 24 + data_len + 1
|
|
if len(frame) < expected_len:
|
|
raise ValueError(f"gb32960 frame incomplete: len={len(frame)} expected={expected_len}")
|
|
command = frame[2]
|
|
vin = frame[4:21].decode("ascii", errors="ignore").strip()
|
|
event_time = None
|
|
if command in (0x01, 0x02, 0x03, 0x04, 0x05) and data_len >= 6:
|
|
event_time = parse_collect_time(frame[24:30])
|
|
return FrameInfo(vin=vin, command=command, event_time=event_time)
|
|
|
|
|
|
def parse_collect_time(raw: bytes) -> dt.datetime:
|
|
if len(raw) != 6:
|
|
raise ValueError("gb32960 collect time must be 6 bytes")
|
|
year, month, day, hour, minute, second = raw
|
|
return dt.datetime(2000 + year, month, day, hour, minute, second, tzinfo=SHANGHAI)
|
|
|
|
|
|
def query_range(event_time: dt.datetime | None, minutes: int) -> tuple[str, str]:
|
|
center = event_time or dt.datetime.now(tz=SHANGHAI)
|
|
center = center.astimezone(SHANGHAI)
|
|
start = center - dt.timedelta(minutes=minutes)
|
|
end = center + dt.timedelta(minutes=minutes)
|
|
return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")
|
|
|
|
|
|
def send_frame(host: str, port: int, frame: bytes, timeout: float) -> bytes:
|
|
with socket.create_connection((host, port), timeout=timeout) as sock:
|
|
sock.settimeout(timeout)
|
|
sock.sendall(frame)
|
|
try:
|
|
return sock.recv(4096)
|
|
except socket.timeout:
|
|
return b""
|
|
|
|
|
|
def valid_ack(ack: bytes) -> bool:
|
|
return len(ack) >= 2 and ack[:2] in (b"##", b"$$")
|
|
|
|
|
|
def query_json(base_url: str, path: str, params: dict[str, Any], timeout: float) -> 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 wait_for_records(
|
|
history_base_url: str,
|
|
vin: str,
|
|
date_from: str,
|
|
date_to: str,
|
|
minimum: int,
|
|
timeout_seconds: float,
|
|
request_timeout: float,
|
|
) -> list[dict[str, Any]]:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
params = {
|
|
"protocol": "GB32960",
|
|
"vin": vin,
|
|
"dateFrom": date_from,
|
|
"dateTo": date_to,
|
|
"limit": max(minimum, 10),
|
|
"order": "ASC",
|
|
}
|
|
last_items: list[dict[str, Any]] = []
|
|
while time.monotonic() < deadline:
|
|
response = query_json(history_base_url, "/api/event-history/records", params, request_timeout)
|
|
last_items = response if isinstance(response, list) else []
|
|
if len(last_items) >= minimum:
|
|
return last_items
|
|
time.sleep(1)
|
|
return last_items
|
|
|
|
|
|
def wait_for_fields(
|
|
history_base_url: str,
|
|
vin: str,
|
|
field_key: str,
|
|
date_from: str,
|
|
date_to: str,
|
|
timeout_seconds: float,
|
|
request_timeout: float,
|
|
) -> list[dict[str, Any]]:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
params = {
|
|
"protocol": "GB32960",
|
|
"vin": vin,
|
|
"fieldKey": field_key,
|
|
"dateFrom": date_from,
|
|
"dateTo": date_to,
|
|
"limit": 10,
|
|
"order": "ASC",
|
|
}
|
|
last_items: list[dict[str, Any]] = []
|
|
while time.monotonic() < deadline:
|
|
response = query_json(history_base_url, "/api/event-history/telemetry/fields", params, request_timeout)
|
|
last_items = response.get("items") if isinstance(response, dict) else []
|
|
if last_items:
|
|
return last_items
|
|
time.sleep(1)
|
|
return last_items
|
|
|
|
|
|
def parse_field_keys(value: str) -> list[str]:
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
|
|
|
|
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 check_archive(root: pathlib.Path, records: list[dict[str, Any]], limit: int) -> int:
|
|
checked = 0
|
|
seen: set[str] = set()
|
|
for record in records:
|
|
uri = record.get("rawArchiveUri") or ""
|
|
if not uri or uri in seen:
|
|
continue
|
|
seen.add(uri)
|
|
path = archive_path(root, uri)
|
|
if not path.exists():
|
|
raise RuntimeError(f"raw archive missing: {path}")
|
|
checked += 1
|
|
if checked >= limit:
|
|
return checked
|
|
return checked
|
|
|
|
|
|
def run(args: argparse.Namespace) -> dict[str, Any]:
|
|
frame = read_hex_frame(pathlib.Path(args.frame_hex_file))
|
|
info = parse_frame_info(frame)
|
|
date_from, date_to = query_range(info.event_time, args.window_minutes)
|
|
|
|
ack = send_frame(args.tcp_host, args.tcp_port, frame, args.tcp_timeout)
|
|
if args.require_ack and not valid_ack(ack):
|
|
raise RuntimeError(f"gb32960 ack missing or invalid: {ack.hex()}")
|
|
|
|
records = wait_for_records(
|
|
args.history_base_url,
|
|
info.vin,
|
|
date_from,
|
|
date_to,
|
|
args.expect_record_count,
|
|
args.history_timeout,
|
|
args.http_timeout,
|
|
)
|
|
if len(records) < args.expect_record_count:
|
|
raise RuntimeError(f"gb32960 records not visible for {info.vin}: got {len(records)}")
|
|
|
|
field_counts: dict[str, int] = {}
|
|
for field_key in parse_field_keys(args.field_keys):
|
|
field_items = wait_for_fields(
|
|
args.history_base_url,
|
|
info.vin,
|
|
field_key,
|
|
date_from,
|
|
date_to,
|
|
args.history_timeout,
|
|
args.http_timeout,
|
|
)
|
|
field_counts[field_key] = len(field_items)
|
|
if not field_items:
|
|
raise RuntimeError(f"gb32960 telemetry field not visible for {info.vin}: {field_key}")
|
|
|
|
archive_checked = 0
|
|
if args.archive_root:
|
|
archive_checked = check_archive(pathlib.Path(args.archive_root), records, args.archive_check_limit)
|
|
|
|
return {
|
|
"tcpHost": args.tcp_host,
|
|
"tcpPort": args.tcp_port,
|
|
"vin": info.vin,
|
|
"command": f"0x{info.command:02x}",
|
|
"eventTime": info.event_time.isoformat() if info.event_time else None,
|
|
"ackBytesHex": ack.hex(),
|
|
"records": len(records),
|
|
"fieldCounts": field_counts,
|
|
"archiveChecked": archive_checked,
|
|
"firstRawUri": first_raw_uri(records),
|
|
}
|
|
|
|
|
|
def first_raw_uri(records: list[dict[str, Any]]) -> str | None:
|
|
for record in records:
|
|
uri = record.get("rawArchiveUri")
|
|
if uri:
|
|
return uri
|
|
return 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=32960)
|
|
p.add_argument("--history-base-url", default="http://127.0.0.1:20200")
|
|
p.add_argument("--frame-hex-file", default=DEFAULT_SAMPLE)
|
|
p.add_argument("--field-keys", default=DEFAULT_FIELD_KEYS)
|
|
p.add_argument("--window-minutes", type=int, default=10)
|
|
p.add_argument("--expect-record-count", type=int, default=1)
|
|
p.add_argument("--archive-root", default="")
|
|
p.add_argument("--archive-check-limit", type=int, default=1)
|
|
p.add_argument("--require-ack", action="store_true", default=True)
|
|
p.add_argument("--tcp-timeout", type=float, default=5)
|
|
p.add_argument("--http-timeout", type=float, default=5)
|
|
p.add_argument("--history-timeout", type=float, default=60)
|
|
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()
|