test: add gb32960 e2e smoke verifier
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
lingniu
2026-06-29 18:25:56 +08:00
parent 9dc667f8da
commit 5c1c61f29b
2 changed files with 343 additions and 0 deletions

262
tools/gb32960_e2e_smoke.py Normal file
View File

@@ -0,0 +1,262 @@
#!/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()