obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but that tree was gitignored, so a clone of this repository could not build the firmware it ships. It is now committed here as ordinary files in its own folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP bridge's signature verification (--trust) used on the bench. Nothing is fetched from or pushed to the colleague's repository; this repository and its remotes carry everything. The folder's own .gitignore keeps build output, downloaded components and private key material out, as it did there; the committed file set is identical to that repository's tracked files. The ESP32-C5 is still flashed from obu-firmware/, which only takes vanetza-idf from microbu-esp32c5/, so the two stay separate folders. FLASHING.md says how to take a newer version of the colleague's tree (copy it over the folder, rebuild, test, commit).
826 lines
32 KiB
Python
826 lines
32 KiB
Python
"""
|
|
ITS-G5 Receiver USB-to-MQTT bridge (V2X2MAP).
|
|
|
|
Reads framed packets from an ESP32-C5 connected via USB-Serial-JTAG and
|
|
republishes them to one or more MQTT brokers.
|
|
|
|
Frame format on the wire (little-endian, no padding):
|
|
magic[4] = b"ITS5"
|
|
sec u32 epoch seconds (from rx_ctrl)
|
|
usec u32 microseconds
|
|
len u16 payload length in bytes
|
|
payload <len> raw 802.11 MAC frame
|
|
|
|
Run:
|
|
python its_g5_bridge.py --node-id V2X2MAP:d2cf13ed6293
|
|
python its_g5_bridge.py --node-id V2X2MAP:d2cf13ed6293 --broker mqtts://broker1:8883 --broker mqtt://broker2:1883
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import logging
|
|
import os
|
|
import re
|
|
import ssl
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from urllib.parse import urlparse
|
|
|
|
import paho.mqtt.client as mqtt
|
|
import serial
|
|
import serial.tools.list_ports
|
|
|
|
import dashboard_server
|
|
from vam import decode_vam
|
|
from secured import unwrap_signed
|
|
|
|
# Signature verification (verify.py), set by --trust. None: signatures are not checked at all.
|
|
TRUST = None
|
|
|
|
MAGIC = b"ITS5"
|
|
HEADER_FMT = "<4sIIH"
|
|
HEADER_LEN = struct.calcsize(HEADER_FMT) # 14
|
|
|
|
DEFAULT_BROKER = "mqtts://cits1.opentrafficmap.org:8883"
|
|
|
|
ESP_VID = 0x303A # Espressif Systems
|
|
FALLBACK_VIDS = {0x10C4, 0x1A86} # CP210x, CH340
|
|
|
|
HDR_LENGTHS = (24, 26, 30, 32)
|
|
|
|
MSG_TYPE_FROM_BTP = {
|
|
2001: "CAM",
|
|
2002: "DENM",
|
|
2003: "MAPEM",
|
|
2004: "SPATEM",
|
|
2006: "IVIM",
|
|
2007: "SREM",
|
|
2008: "SSEM",
|
|
2010: "TLM",
|
|
2012: "RTCMEM",
|
|
2018: "VAM",
|
|
}
|
|
|
|
STATION_TYPES = {
|
|
0: "unknown", 1: "pedestrian", 2: "cyclist", 3: "moped",
|
|
4: "motorcycle", 5: "passenger car", 6: "bus", 7: "light truck",
|
|
8: "heavy truck", 9: "trailer", 10: "special vehicle", 11: "tram", 15: "RSU",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frame decoding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sniff_ether_type(payload: bytes) -> int | None:
|
|
for hdr_len in HDR_LENGTHS:
|
|
if len(payload) < hdr_len + 8:
|
|
continue
|
|
snap = payload[hdr_len:hdr_len + 8]
|
|
if snap[:6] == b"\xaa\xaa\x03\x00\x00\x00":
|
|
return (snap[6] << 8) | snap[7]
|
|
return None
|
|
|
|
|
|
def decode_itsg5_full(payload: bytes) -> dict:
|
|
"""
|
|
Full ITS-G5 decode — mirrors Android ItsG5Decoder.decodeFull().
|
|
Returns dict with ether_type, msg_type, station_id (hex str),
|
|
gn_addr, station_type[_label], secured, and optionally
|
|
lat/lon/speed_mps/heading_deg.
|
|
"""
|
|
et = sniff_ether_type(payload)
|
|
result: dict = {"ether_type": et, "msg_type": "UNKNOWN"}
|
|
if et != 0x8947:
|
|
return result
|
|
|
|
for hdr_len in HDR_LENGTHS:
|
|
if len(payload) < hdr_len + 8 + 4 + 8:
|
|
continue
|
|
if payload[hdr_len:hdr_len + 6] != b"\xaa\xaa\x03\x00\x00\x00":
|
|
continue
|
|
|
|
basic_off = hdr_len + 8
|
|
common_off = basic_off + 4
|
|
if len(payload) < common_off + 8:
|
|
continue
|
|
|
|
security = None
|
|
if (payload[basic_off] & 0x0F) == 2:
|
|
# Secured packet (TS 103 097): the Common Header and everything after it are the
|
|
# unsecuredData inside the Ieee1609Dot2Data. Take the payload out of the envelope
|
|
# and continue on a buffer that carries the basic header followed by that payload.
|
|
# The signature is NOT verified here: fields are what the sender claims.
|
|
envelope = payload[common_off:]
|
|
try:
|
|
security = unwrap_signed(envelope)
|
|
except ValueError as exc:
|
|
result.update(secured=True, decode_status=f"secured GN envelope: {exc}")
|
|
return result
|
|
payload = payload[:common_off] + security["payload"]
|
|
if len(payload) < common_off + 8:
|
|
result.update(secured=True, decode_status="secured GN envelope: payload too short")
|
|
return result
|
|
|
|
ht_hst = payload[common_off + 1]
|
|
ht = (ht_hst >> 4) & 0x0F
|
|
hst = ht_hst & 0x0F
|
|
|
|
ext_hdr_len = {1: 4, 2: 48, 3: 56, 4: 44, 5: 28, 6: 36}.get(ht, 28)
|
|
src_in_ext = 0 if (ht == 5 and hst == 0) else 4
|
|
src_off = common_off + 8 + src_in_ext
|
|
btp_off = common_off + 8 + ext_hdr_len
|
|
|
|
if len(payload) < btp_off + 4 or len(payload) < src_off + 24:
|
|
continue
|
|
|
|
gn_addr = payload[src_off:src_off + 8]
|
|
st = (gn_addr[0] >> 2) & 0x1F
|
|
mac = gn_addr[2:8]
|
|
station_id = int.from_bytes(gn_addr, "big")
|
|
|
|
lat_raw = int.from_bytes(payload[src_off + 12:src_off + 16], "big", signed=True)
|
|
lon_raw = int.from_bytes(payload[src_off + 16:src_off + 20], "big", signed=True)
|
|
psh_off = src_off + 20
|
|
|
|
if not (-90.0 <= lat_raw / 1e7 <= 90.0) and len(payload) >= src_off + 28:
|
|
lat2 = int.from_bytes(payload[src_off + 16:src_off + 20], "big", signed=True)
|
|
lon2 = int.from_bytes(payload[src_off + 20:src_off + 24], "big", signed=True)
|
|
if -90.0 <= lat2 / 1e7 <= 90.0 and -180.0 <= lon2 / 1e7 <= 180.0:
|
|
lat_raw, lon_raw, psh_off = lat2, lon2, src_off + 24
|
|
|
|
psh_raw = int.from_bytes(payload[psh_off:psh_off + 4], "big")
|
|
speed_raw = (psh_raw >> 16) & 0x7FFF
|
|
if speed_raw >= 0x4000:
|
|
speed_raw -= 0x8000
|
|
heading_raw = psh_raw & 0xFFFF
|
|
|
|
lat = lat_raw / 1e7
|
|
lon = lon_raw / 1e7
|
|
dst_port = (payload[btp_off] << 8) | payload[btp_off + 1]
|
|
msg_type = MSG_TYPE_FROM_BTP.get(dst_port, "UNKNOWN")
|
|
secured = security is not None
|
|
|
|
result.update({
|
|
"msg_type": msg_type,
|
|
"station_id": format(station_id, "016x"),
|
|
"gn_addr": ":".join(f"{b:02x}" for b in mac),
|
|
"station_type": st,
|
|
"station_type_label": STATION_TYPES.get(st, f"type {st}"),
|
|
"secured": secured,
|
|
"btp_dst_port": dst_port,
|
|
})
|
|
if secured:
|
|
result["security"] = {
|
|
"psid": security["psid"],
|
|
"hash": security["hash_id"],
|
|
"signer": security["signer"],
|
|
"signer_id": security["signer_id"],
|
|
"generation_time_ms": security["generation_time_ms"],
|
|
# True: signature valid under a --trust chain; False: it fails; None: not checked
|
|
# (no --trust, or the signer does not chain to anything configured).
|
|
"verified": None,
|
|
}
|
|
if TRUST is not None:
|
|
verdict = TRUST.verify(envelope)
|
|
result["security"]["verified"] = verdict["verified"]
|
|
result["security"]["verification"] = verdict["reason"]
|
|
|
|
if -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0 and not (lat == 0.0 and lon == 0.0):
|
|
result["lat"] = lat
|
|
result["lon"] = lon
|
|
result["speed_mps"] = speed_raw * 0.01
|
|
if heading_raw != 0xFFFF:
|
|
result["heading_deg"] = heading_raw * 0.1
|
|
|
|
if msg_type == "VAM":
|
|
# Never use GN values as substitutes for missing/invalid VAM data.
|
|
for key in ("lat", "lon", "speed_mps", "heading_deg"):
|
|
result.pop(key, None)
|
|
length = int.from_bytes(payload[common_off + 4:common_off + 6], "big")
|
|
nh = payload[common_off] >> 4
|
|
try:
|
|
if nh not in (1, 2):
|
|
raise ValueError("non-BTP VAM transport unsupported")
|
|
if length < 4 or btp_off + length > len(payload):
|
|
raise ValueError("truncated GN payload")
|
|
vam = decode_vam(payload[btp_off + 4:btp_off + length])
|
|
result["vam"] = vam
|
|
result["vam_status"] = "decoded"
|
|
result["station_type"] = vam["station_type"]
|
|
result["station_type_label"] = STATION_TYPES.get(vam["station_type"], str(vam["station_type"]))
|
|
for key in ("lat", "lon", "speed_mps", "heading_deg"):
|
|
if key in vam:
|
|
result[key] = vam[key]
|
|
except ValueError as exc:
|
|
result["vam_status"] = str(exc)
|
|
return result
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PCAP writer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PcapWriter:
|
|
"""Minimal libpcap writer — DLT_IEEE802_11 (105), open in Wireshark directly."""
|
|
_GLOBAL = struct.pack("<IHHiIII", 0xA1B2C3D4, 2, 4, 0, 0, 65535, 105)
|
|
|
|
def __init__(self, path: str):
|
|
self._f = open(path, "wb")
|
|
self._f.write(self._GLOBAL)
|
|
self._f.flush()
|
|
|
|
def write(self, sec: int, usec: int, data: bytes):
|
|
n = len(data)
|
|
self._f.write(struct.pack("<IIII", sec, usec, n, n))
|
|
self._f.write(data)
|
|
self._f.flush()
|
|
|
|
def close(self):
|
|
try: self._f.close()
|
|
except Exception: pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frame parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FrameReader:
|
|
"""Stateful resync parser: scans a byte stream for ITS5 frames."""
|
|
def __init__(self):
|
|
self._buf = bytearray()
|
|
|
|
def feed(self, chunk: bytes):
|
|
self._buf.extend(chunk)
|
|
while True:
|
|
idx = self._buf.find(MAGIC)
|
|
if idx < 0:
|
|
if len(self._buf) > 3:
|
|
del self._buf[: len(self._buf) - 3]
|
|
return
|
|
if idx > 0:
|
|
del self._buf[:idx]
|
|
if len(self._buf) < HEADER_LEN:
|
|
return
|
|
_, sec, usec, plen = struct.unpack_from(HEADER_FMT, self._buf, 0)
|
|
if plen > 4096:
|
|
del self._buf[:4]
|
|
continue
|
|
if len(self._buf) < HEADER_LEN + plen:
|
|
return
|
|
payload = bytes(self._buf[HEADER_LEN: HEADER_LEN + plen])
|
|
del self._buf[: HEADER_LEN + plen]
|
|
yield sec, usec, payload
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serial helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _find_esp_port() -> str | None:
|
|
for p in serial.tools.list_ports.comports():
|
|
if p.vid == ESP_VID:
|
|
return p.device
|
|
for p in serial.tools.list_ports.comports():
|
|
if p.vid in FALLBACK_VIDS:
|
|
return p.device
|
|
return None
|
|
|
|
|
|
def _open_serial(port: str, baud: int) -> serial.Serial:
|
|
ser = serial.Serial(port, baud, timeout=0.5)
|
|
ser.dtr = False
|
|
ser.rts = False
|
|
time.sleep(0.05)
|
|
return ser
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MQTT helpers — multiple brokers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_broker(url: str) -> tuple[str, int, bool]:
|
|
"""Return (host, port, use_tls) from a broker URL."""
|
|
url = url.strip()
|
|
if "://" not in url:
|
|
url = "mqtts://" + url
|
|
parsed = urlparse(url)
|
|
tls = parsed.scheme in ("mqtts", "ssl")
|
|
host = parsed.hostname or "localhost"
|
|
port = parsed.port or (8883 if tls else 1883)
|
|
return host, port, tls
|
|
|
|
|
|
def build_mqtt_clients(node_id: str, broker_urls: list[str], status_topic: str,
|
|
connected_count: list, count_lock: threading.Lock):
|
|
"""
|
|
Build one Paho client per broker URL.
|
|
connected_count is a shared mutable [int] list; count_lock guards it.
|
|
Returns the list of clients (already started with loop_start).
|
|
"""
|
|
clients = []
|
|
for url in broker_urls:
|
|
host, port, use_tls = _parse_broker(url)
|
|
safe_id = re.sub(r"[^a-zA-Z0-9-]", "-", node_id)[:20]
|
|
client = mqtt.Client(
|
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
|
client_id=f"its-g5-{safe_id}-{host[:12]}",
|
|
protocol=mqtt.MQTTv311,
|
|
)
|
|
client.will_set(status_topic, payload=b"offline", qos=1, retain=True)
|
|
if use_tls:
|
|
client.tls_set_context(ssl.create_default_context())
|
|
|
|
def on_connect(c, userdata, flags, rc, props=None,
|
|
_host=host, _port=port, _st=status_topic):
|
|
if rc == 0:
|
|
logging.info("MQTT connected to %s:%d", _host, _port)
|
|
c.publish(_st, b"online", qos=1, retain=True)
|
|
with count_lock:
|
|
connected_count[0] += 1
|
|
dashboard_server.set_mqtt_state(available=connected_count[0] > 0)
|
|
dashboard_server.update_broker_status(_host, _port, True)
|
|
else:
|
|
logging.error("MQTT connect failed %s:%d rc=%s", _host, _port, rc)
|
|
dashboard_server.update_broker_status(_host, _port, False)
|
|
|
|
def on_disconnect(c, userdata, flags, rc, props=None,
|
|
_host=host, _port=port):
|
|
logging.warning("MQTT disconnected %s:%d rc=%s", _host, _port, rc)
|
|
with count_lock:
|
|
connected_count[0] = max(0, connected_count[0] - 1)
|
|
dashboard_server.set_mqtt_state(available=connected_count[0] > 0)
|
|
dashboard_server.update_broker_status(_host, _port, False)
|
|
|
|
client.on_connect = on_connect
|
|
client.on_disconnect = on_disconnect
|
|
client.connect_async(host, port, keepalive=60)
|
|
client.loop_start()
|
|
clients.append(client)
|
|
logging.info("MQTT broker configured: %s:%d tls=%s", host, port, use_tls)
|
|
return clients
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths — config/ and recordings/ next to the executable
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _base_dir() -> str:
|
|
"""
|
|
Windows frozen (.exe) : directory containing the executable
|
|
Windows dev : directory containing this script
|
|
Linux / macOS : ~/.config/v2x2map (XDG)
|
|
"""
|
|
if getattr(sys, "frozen", False):
|
|
return os.path.dirname(sys.executable)
|
|
if sys.platform == "win32":
|
|
return os.path.dirname(os.path.abspath(__file__))
|
|
xdg = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
|
return os.path.join(xdg, "v2x2map")
|
|
|
|
|
|
def _config_path() -> str:
|
|
d = _base_dir()
|
|
if sys.platform == "win32" or getattr(sys, "frozen", False):
|
|
d = os.path.join(d, "config")
|
|
os.makedirs(d, exist_ok=True)
|
|
return os.path.join(d, "v2x2map.cfg")
|
|
|
|
|
|
def _recordings_dir() -> str:
|
|
if getattr(sys, "frozen", False) or sys.platform == "win32":
|
|
d = os.path.join(_base_dir(), "recordings")
|
|
else:
|
|
d = os.path.join(os.path.expanduser("~"), "v2x2map", "recordings")
|
|
os.makedirs(d, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def _load_config() -> dict:
|
|
try:
|
|
with open(_config_path(), encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_config(cfg: dict) -> None:
|
|
try:
|
|
with open(_config_path(), "w", encoding="utf-8") as f:
|
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _pause_exit(code: int) -> int:
|
|
"""On frozen exe, pause before exit so the user can read error messages."""
|
|
if getattr(sys, "frozen", False):
|
|
input("\nPress Enter to quit…")
|
|
return code
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legal disclaimer (shown once on first run, acceptance stored in config)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_DISCLAIMER = """\
|
|
[English]
|
|
This software captures IEEE 802.11p / ITS-G5 radio frames in promiscuous mode
|
|
and may optionally forward data to an MQTT server.
|
|
|
|
IMPORTANT:
|
|
- Operation is only permitted in environments for which the user holds the
|
|
necessary authorisation.
|
|
- Intercepting radio signals without permission may violate national law
|
|
(e.g. wiretapping statutes, §89 TKG in Germany).
|
|
- This software is intended solely for research and development purposes.
|
|
Use on public roads or in regulated frequency bands without a regulatory
|
|
permit is not allowed.
|
|
- Any use is entirely at the user's own risk. The developers accept no
|
|
liability for damages or legal violations.
|
|
|
|
By continuing you confirm that you are aware of and will comply with all
|
|
applicable laws.
|
|
|
|
[Deutsch]
|
|
Diese Software erfasst IEEE 802.11p / ITS-G5 Funksignale im Promiscuous-Modus
|
|
und kann Daten optional an einen MQTT-Server übertragen.
|
|
|
|
WICHTIG:
|
|
- Der Betrieb ist ausschließlich in Umgebungen gestattet, für die der
|
|
Anwender die erforderliche Berechtigung besitzt.
|
|
- Das unbefugte Abhören von Funksignalen kann gegen nationales Recht
|
|
verstoßen (z. B. § 89 TKG in Deutschland).
|
|
- Die Software dient ausschließlich Forschungs- und Entwicklungszwecken.
|
|
Eine Nutzung ohne behördliche Genehmigung ist unzulässig.
|
|
- Jegliche Nutzung liegt in der alleinigen Verantwortung des Anwenders.
|
|
Die Entwickler übernehmen keine Haftung für Schäden oder Verstöße.
|
|
|
|
Mit der Fortsetzung bestätigen Sie, alle geltenden Gesetze zu kennen
|
|
und einzuhalten.\
|
|
"""
|
|
|
|
|
|
def _check_disclaimer(cfg: dict) -> bool:
|
|
"""Return True if disclaimer is accepted. Shows it on first run."""
|
|
if cfg.get("legal_accepted"):
|
|
return True
|
|
|
|
sep = "=" * 66
|
|
print(f"\n{sep}")
|
|
print(" V2X2MAP ITS-G5 Bridge — Legal Notice / Rechtlicher Hinweis")
|
|
print(sep)
|
|
print()
|
|
print(_DISCLAIMER)
|
|
print()
|
|
print(sep)
|
|
print(" Press ENTER to accept and continue.")
|
|
print(" Any other input + ENTER, or Ctrl+C, to quit.")
|
|
print(" ENTER druecken zum Akzeptieren, sonstige Eingabe zum Beenden.")
|
|
print(sep)
|
|
try:
|
|
ans = input(" > ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
ans = None
|
|
|
|
if ans == "": # bare Enter = accept
|
|
cfg["legal_accepted"] = True
|
|
_save_config(cfg)
|
|
return True
|
|
|
|
print("\n Not accepted — exiting. / Nicht akzeptiert — Beenden.")
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(
|
|
description="ITS-G5 USB->MQTT bridge (V2X2MAP)\n"
|
|
f"Config: {_config_path()}",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
p.add_argument("--port", default=None,
|
|
help="serial port (e.g. COM7); omit to auto-detect or use config")
|
|
p.add_argument("--list-ports", action="store_true",
|
|
help="print available serial ports with VID/PID and exit")
|
|
p.add_argument("--baud", type=int, default=115200)
|
|
p.add_argument("--node-id", default=None,
|
|
help="device ID, e.g. V2X2MAP:d2cf13ed6293 (read from config if omitted)")
|
|
p.add_argument("--broker", action="append", dest="brokers", default=[],
|
|
metavar="URL",
|
|
help="MQTT broker URL (mqtts://host:port or mqtt://host:port). "
|
|
"Can be specified multiple times. "
|
|
f"Default from config or {DEFAULT_BROKER}")
|
|
p.add_argument("--no-mqtt", action="store_true",
|
|
help="parse only, don't publish (for testing)")
|
|
p.add_argument("--reset-on-start", action="store_true",
|
|
help="pulse RTS to reboot the device on first connect")
|
|
p.add_argument("--reconnect-delay", type=float, default=3.0,
|
|
help="seconds to wait before reconnecting after USB disconnect (0 = exit)")
|
|
p.add_argument("--exit-after", type=float, default=0,
|
|
help="exit after N seconds (0 = run forever)")
|
|
p.add_argument("--dashboard-port", type=int, default=8080,
|
|
help="local HTTP port for the live dashboard (0 to disable)")
|
|
# When launched as frozen exe (double-click), open browser automatically
|
|
_frozen = getattr(sys, "frozen", False)
|
|
p.add_argument("--open-browser", action="store_true", default=_frozen,
|
|
help="open the dashboard URL in the default browser on start "
|
|
"(default: on when running as .exe)")
|
|
p.add_argument("--no-browser", action="store_true",
|
|
help="suppress automatic browser opening")
|
|
p.add_argument("--pcap-out", default="",
|
|
help="write all captured frames to a .pcap file (DLT_IEEE802_11)")
|
|
p.add_argument("--trust", action="append", default=[], metavar="FILE",
|
|
help="verify signed packets against this chain: a VCR1 credential bundle "
|
|
"or a COER certificate (.oer); repeatable. Default: signatures not checked")
|
|
p.add_argument("--verbose", "-v", action="store_true")
|
|
args = p.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
|
|
if args.list_ports:
|
|
for pt in serial.tools.list_ports.comports():
|
|
vid_pid = f" [{pt.vid:04X}:{pt.pid:04X}]" if pt.vid else ""
|
|
print(f"{pt.device:<12} {pt.description}{vid_pid}")
|
|
return 0
|
|
|
|
# ── Config file + CLI merge (CLI wins) ───────────────────────────────
|
|
if args.trust:
|
|
global TRUST
|
|
from verify import TrustStore
|
|
TRUST = TrustStore(args.trust)
|
|
cfg = _load_config()
|
|
if cfg:
|
|
logging.info("Config loaded: %s", _config_path())
|
|
|
|
# Legal disclaimer — show once, store acceptance in config
|
|
if not _check_disclaimer(cfg):
|
|
return _pause_exit(0)
|
|
|
|
node_id = args.node_id or cfg.get("node_id", "")
|
|
port = args.port or cfg.get("port")
|
|
broker_urls = args.brokers or cfg.get("brokers", [DEFAULT_BROKER])
|
|
|
|
if not node_id:
|
|
print("\n" + "=" * 60)
|
|
print(" Error: No node-id configured.")
|
|
print(" Run its-g5-setup.exe OR pass")
|
|
print(" --node-id V2X2MAP:xxxxxxxxxxxx")
|
|
print("=" * 60)
|
|
return _pause_exit(1)
|
|
|
|
if not port:
|
|
port = _find_esp_port()
|
|
if port:
|
|
logging.info("Port auto-detected: %s", port)
|
|
else:
|
|
print("\n" + "=" * 60)
|
|
print(" Error: No serial port found.")
|
|
print(" Connect the ESP32 or pass --port.")
|
|
print("=" * 60)
|
|
return _pause_exit(1)
|
|
|
|
logging.info("Node-ID : %s", node_id)
|
|
logging.info("Port : %s", port)
|
|
logging.info("Config : %s", _config_path())
|
|
logging.info("Recordings: %s", _recordings_dir())
|
|
|
|
packet_topic = f"its/{node_id}/packet"
|
|
status_topic = f"its/{node_id}/status"
|
|
|
|
# MQTT publishing starts disabled — user activates from dashboard
|
|
dashboard_server.set_mqtt_state(enabled=False, available=False)
|
|
|
|
connected_count = [0]
|
|
count_lock = threading.Lock()
|
|
clients: list = []
|
|
|
|
if not args.no_mqtt:
|
|
dashboard_server.register_brokers(broker_urls)
|
|
clients = build_mqtt_clients(
|
|
node_id, broker_urls, status_topic, connected_count, count_lock
|
|
)
|
|
|
|
# ── Dashboard-controlled PCAP recording ─────────────────────────────────
|
|
rec = {"pcap": None, "file": "", "frames": 0}
|
|
rec_lock = threading.Lock()
|
|
|
|
def on_record(action: str, filename: str = "") -> dict:
|
|
with rec_lock:
|
|
if action == "start":
|
|
if rec["pcap"]:
|
|
rec["pcap"].close()
|
|
fn = filename or os.path.join(
|
|
_recordings_dir(), f"its5_{time.strftime('%Y%m%d_%H%M%S')}.pcap"
|
|
)
|
|
try:
|
|
rec["pcap"] = PcapWriter(fn)
|
|
rec["file"] = fn
|
|
rec["frames"] = 0
|
|
st = {"recording": True, "file": fn, "frames": 0}
|
|
except Exception as exc:
|
|
rec["pcap"] = None
|
|
st = {"recording": False, "file": "", "frames": 0, "error": str(exc)}
|
|
elif action == "stop":
|
|
if rec["pcap"]:
|
|
rec["pcap"].close()
|
|
rec["pcap"] = None
|
|
st = {"recording": False, "file": rec["file"], "frames": rec["frames"]}
|
|
else:
|
|
st = {"recording": rec["pcap"] is not None,
|
|
"file": rec["file"], "frames": rec["frames"]}
|
|
dashboard_server.update_record_state(st)
|
|
return st
|
|
|
|
dashboard_server.set_record_callback(on_record)
|
|
|
|
if args.dashboard_port:
|
|
dashboard_server.start(args.dashboard_port, node_id=node_id)
|
|
url = f"http://127.0.0.1:{args.dashboard_port}"
|
|
logging.info("Dashboard: %s", url)
|
|
if args.open_browser and not args.no_browser:
|
|
try:
|
|
import webbrowser
|
|
webbrowser.open(url)
|
|
except Exception as exc:
|
|
logging.warning("Could not open browser: %s", exc)
|
|
|
|
# ── Static PCAP output (--pcap-out, always-on) ───────────────────────────
|
|
pcap: PcapWriter | None = None
|
|
if args.pcap_out:
|
|
pcap = PcapWriter(args.pcap_out)
|
|
logging.info("PCAP output: %s", args.pcap_out)
|
|
|
|
n_frames = 0
|
|
n_bytes = 0
|
|
start_time = time.monotonic()
|
|
last_stats = start_time
|
|
rate_window: deque = deque()
|
|
interval_window: deque = deque(maxlen=200)
|
|
last_frame_mono: float | None = None
|
|
deadline = time.monotonic() + args.exit_after if args.exit_after > 0 else None
|
|
first_connect = True
|
|
ser: serial.Serial | None = None
|
|
done = False
|
|
|
|
try:
|
|
while not done:
|
|
if deadline and time.monotonic() >= deadline:
|
|
logging.info("exit-after deadline reached")
|
|
break
|
|
|
|
try:
|
|
logging.info("Opening %s @ %d baud", port, args.baud)
|
|
ser = _open_serial(port, args.baud)
|
|
except serial.SerialException as exc:
|
|
logging.warning("Cannot open %s: %s", port, exc)
|
|
dashboard_server.broadcast_serial_state(connected=False, port=port)
|
|
if args.reconnect_delay <= 0:
|
|
break
|
|
time.sleep(args.reconnect_delay)
|
|
continue
|
|
|
|
if args.reset_on_start and first_connect:
|
|
logging.info("Pulsing RTS to reset device")
|
|
ser.rts = True
|
|
time.sleep(0.1)
|
|
ser.rts = False
|
|
time.sleep(0.05)
|
|
ser.reset_input_buffer()
|
|
first_connect = False
|
|
dashboard_server.broadcast_serial_state(connected=True, port=port)
|
|
reader = FrameReader()
|
|
|
|
try:
|
|
while not done:
|
|
if deadline and time.monotonic() >= deadline:
|
|
done = True; break
|
|
|
|
chunk = ser.read(4096)
|
|
if chunk:
|
|
n_bytes += len(chunk)
|
|
for sec, usec, payload in reader.feed(chunk):
|
|
n_frames += 1
|
|
now_mono = time.monotonic()
|
|
if last_frame_mono is not None:
|
|
dt_ms = (now_mono - last_frame_mono) * 1000.0
|
|
interval_window.append(dt_ms)
|
|
last_frame_mono = now_mono
|
|
|
|
rate_window.append(now_mono)
|
|
while rate_window and rate_window[0] < now_mono - 60.0:
|
|
rate_window.popleft()
|
|
|
|
decoded = decode_itsg5_full(payload)
|
|
|
|
if args.dashboard_port:
|
|
evt = {"n": n_frames, "sec": sec, "usec": usec,
|
|
"len": len(payload), "hex": payload[:48].hex()}
|
|
evt.update(decoded)
|
|
dashboard_server.broadcast_frame(evt)
|
|
|
|
if clients and dashboard_server.is_mqtt_enabled():
|
|
for c in clients:
|
|
try:
|
|
c.publish(packet_topic, payload, qos=0, retain=False)
|
|
except Exception:
|
|
pass
|
|
|
|
if pcap:
|
|
pcap.write(sec, usec, payload)
|
|
|
|
with rec_lock:
|
|
if rec["pcap"]:
|
|
rec["pcap"].write(sec, usec, payload)
|
|
rec["frames"] += 1
|
|
if rec["frames"] % 50 == 0:
|
|
dashboard_server.update_record_state({
|
|
"recording": True,
|
|
"file": rec["file"],
|
|
"frames": rec["frames"],
|
|
})
|
|
|
|
now_mono = time.monotonic()
|
|
if now_mono - last_stats >= 5.0:
|
|
rate = len(rate_window)
|
|
uptime = int(now_mono - start_time)
|
|
|
|
# Performance statistics (frequency, jitter, min/max inter-arrival interval)
|
|
freq_hz = round(rate / 60.0, 2)
|
|
mean_interval_ms = 0.0
|
|
jitter_ms = 0.0
|
|
min_interval_ms = 0.0
|
|
max_interval_ms = 0.0
|
|
if interval_window:
|
|
mean_interval_ms = round(sum(interval_window) / len(interval_window), 2)
|
|
var = sum((x - mean_interval_ms) ** 2 for x in interval_window) / len(interval_window)
|
|
jitter_ms = round(math.sqrt(var), 2)
|
|
min_interval_ms = round(min(interval_window), 2)
|
|
max_interval_ms = round(max(interval_window), 2)
|
|
if mean_interval_ms > 0:
|
|
freq_hz = round(1000.0 / mean_interval_ms, 2)
|
|
|
|
logging.info("frames=%d rate=%d/min (%.2f Hz) mean_dt=%.1fms (jitter=%.2fms) bytes=%d uptime=%ds",
|
|
n_frames, rate, freq_hz, mean_interval_ms, jitter_ms, n_bytes, uptime)
|
|
if args.dashboard_port:
|
|
dashboard_server.broadcast_stats({
|
|
"frames": n_frames,
|
|
"rate": rate,
|
|
"freq_hz": freq_hz,
|
|
"mean_interval_ms": mean_interval_ms,
|
|
"jitter_ms": jitter_ms,
|
|
"min_interval_ms": min_interval_ms,
|
|
"max_interval_ms": max_interval_ms,
|
|
"bytes": n_bytes,
|
|
"uptime_s": uptime,
|
|
})
|
|
last_stats = now_mono
|
|
|
|
except serial.SerialException as exc:
|
|
logging.warning("Serial error: %s", exc)
|
|
dashboard_server.broadcast_serial_state(connected=False, port=port)
|
|
try: ser.close()
|
|
except Exception: pass
|
|
ser = None
|
|
if args.reconnect_delay <= 0:
|
|
break
|
|
logging.info("Reconnecting in %.1f s…", args.reconnect_delay) # noqa: RUF001
|
|
time.sleep(args.reconnect_delay)
|
|
|
|
except KeyboardInterrupt:
|
|
logging.info("Stopping (frames=%d)", n_frames)
|
|
finally:
|
|
for c in clients:
|
|
try:
|
|
c.publish(status_topic, b"offline", qos=1, retain=True)
|
|
c.loop_stop()
|
|
c.disconnect()
|
|
except Exception:
|
|
pass
|
|
if ser is not None:
|
|
try: ser.close()
|
|
except Exception: pass
|
|
if pcap:
|
|
pcap.close()
|
|
with rec_lock:
|
|
if rec["pcap"]:
|
|
rec["pcap"].close()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|