From bcc15b489e8080b6f98205662f362e9e08f71683 Mon Sep 17 00:00:00 2001 From: tkeairns Date: Mon, 9 Nov 2026 21:20:30 -0800 Subject: [PATCH] testing: the v2g suite, EVerest DC sessions over ISO 15118-2 and DIN 70121 --- README.md | 2 +- justfile | 33 +++ testing/suites/v2g/Dockerfile | 12 + testing/suites/v2g/README.md | 60 +++++ .../suites/v2g/captures/din-dc.messages.csv | 65 +++++ testing/suites/v2g/captures/din-dc.pcapng | Bin 0 -> 19960 bytes .../suites/v2g/captures/iso2-dc.messages.csv | 69 +++++ testing/suites/v2g/captures/iso2-dc.pcapng | Bin 0 -> 21376 bytes testing/suites/v2g/conftest.py | 190 ++++++++++++++ testing/suites/v2g/everest.yaml | 150 +++++++++++ testing/suites/v2g/test_session.py | 242 ++++++++++++++++++ 11 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 testing/suites/v2g/Dockerfile create mode 100644 testing/suites/v2g/README.md create mode 100644 testing/suites/v2g/captures/din-dc.messages.csv create mode 100644 testing/suites/v2g/captures/din-dc.pcapng create mode 100644 testing/suites/v2g/captures/iso2-dc.messages.csv create mode 100644 testing/suites/v2g/captures/iso2-dc.pcapng create mode 100644 testing/suites/v2g/conftest.py create mode 100644 testing/suites/v2g/everest.yaml create mode 100644 testing/suites/v2g/test_session.py diff --git a/README.md b/README.md index f4ec9c9..74abede 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ rather than copying them. | `dbc/` | Wire formats of the shared nodes: `bench.dbc` (classic), `genset.dbc` (J1939), and `full.dbc`, both merged, the one file tools load; `lib/codec_/` and `full.dbc` are generated by `just dbc` | | `arxml/` | `can-full.arxml`, the can-full bench as an AUTOSAR R19-11 System Template; the DC-DC's AUTOSAR tables are generated from it (`just arxml`) | | `toolchain/` | The firmware build image | -| `testing/` | Host-side pytest suites and fixtures, run against any SocketCAN channel | +| `testing/` | Host-side pytest suites and fixtures, run against any SocketCAN channel; `suites/v2g` brings its own EVerest SIL (`just test-v2g`) | | `bench/` | A Renode bench for any set of nodes, and the agent image; each bench builds on it, and `just test-bench` runs a suite on it | | `tools/` | Host scripts: filling a node's A2L with one build's addresses (the build runs it), generating the DC-DC's AUTOSAR tables from `arxml/` (`just arxml`) | | `justfile` | Shared recipes: building nodes, `twister`, `test-bench`, and `hil` for a flashed board | diff --git a/justfile b/justfile index 1bda86d..409dd28 100644 --- a/justfile +++ b/justfile @@ -16,6 +16,12 @@ TOOLCHAIN := "zelos-examples-toolchain:" + ZEPHYR + "-" + `shasum -a 256 toolcha BOARD := "nucleo_h753zi" # The part on that board, for probe-rs. CHIP := "STM32H753ZITx" +# The V2G SIL image: EVerest pinned by digest, plus the suite's tools. Tagged by +# its Dockerfile, as TOOLCHAIN is. +V2G_SIL := "zelos-examples-v2g-sil:" + `shasum -a 256 testing/suites/v2g/Dockerfile | cut -c1-12` +# The SIL's container: the suite mounted, the capabilities its link needs, and +# IPv6, which V2G runs on. +V2G_RUN := "--cap-add NET_ADMIN --cap-add SYS_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0 -v \"$PWD/testing/suites/v2g:/suite:ro\"" # The Docker volume holding the west workspace, or an absolute host path to # bind instead. Override to build two trees at once. WORKSPACE := env("ZELOS_WEST_VOLUME", "zxws") @@ -140,6 +146,28 @@ hil ELF CHANNEL +SUITES: # --durations: the slowest tests, timed, after the results. cd testing && uv run --frozen pytest "${suites[@]}" --channel {{ CHANNEL }} --a2l "${elf%.elf}.a2l" --durations=10 +# The SIL needs CAP_NET_ADMIN for the veth and CAP_SYS_ADMIN for the EV's +# network namespace. ARGS go to pytest, e.g. `-k din`. +# +# Run the V2G suite: EVerest's EVSE and EV charge over a veth pair, captured. +test-v2g *ARGS: _v2g_sil + docker run --rm {{ V2G_RUN }} {{ V2G_SIL }} /suite {{ ARGS }} + +# Record the V2G demo captures and their message sidecars again, ISO 15118-2 and DIN 70121. +v2g-record: _v2g_sil + #!/usr/bin/env bash + set -euo pipefail + out=$(mktemp -d) + trap 'rm -rf "$out"' EXIT + docker run --rm {{ V2G_RUN }} -v "$out:/out" --entrypoint bash {{ V2G_SIL }} -c \ + "python3 -m pytest -p no:cacheprovider -q /suite/test_session.py::test_session --record /out; \ + status=\$?; chown -R $(id -u):$(id -g) /out; exit \$status" + for p in iso2 din; do + cp "$out/test_session[$p]/$p.pcapng" "testing/suites/v2g/captures/$p-dc.pcapng" + cp "$out/test_session[$p]/$p.messages.csv" "testing/suites/v2g/captures/$p-dc.messages.csv" + done + ls -l testing/suites/v2g/captures/ + # Remove the cached Zephyr workspace. Frees about 2 GB. clean-workspace: docker volume rm {{ WORKSPACE }} @@ -161,6 +189,11 @@ _fresh ELFS +SOURCES: exit 1 fi +# Build the V2G SIL image. Cached by Docker after the first run. +_v2g_sil: + docker image inspect {{ V2G_SIL }} > /dev/null 2>&1 || \ + docker build -q -t {{ V2G_SIL }} testing/suites/v2g > /dev/null + # Build the firmware toolchain image. Cached by Docker after the first run. _toolchain: docker image inspect {{ TOOLCHAIN }} > /dev/null 2>&1 || \ diff --git a/testing/suites/v2g/Dockerfile b/testing/suites/v2g/Dockerfile new file mode 100644 index 0000000..9894fd6 --- /dev/null +++ b/testing/suites/v2g/Dockerfile @@ -0,0 +1,12 @@ +# The V2G SIL: EVerest's demo image, pinned, plus the broker, the capture +# tools and pytest. EVerest 2025.10.0 (everest-demo tag 2025.10.0-single-phase). +FROM ghcr.io/everest/everest-demo/manager@sha256:9165d0937323ae61ee2e496f68f3c4a465ed9099740e489df1f0161265492756 + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + mosquitto mosquitto-clients iproute2 tshark \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir --break-system-packages pytest==9.1.1 + +WORKDIR /suite +ENTRYPOINT ["python3", "-m", "pytest", "-p", "no:cacheprovider"] diff --git a/testing/suites/v2g/README.md b/testing/suites/v2g/README.md new file mode 100644 index 0000000..8dac565 --- /dev/null +++ b/testing/suites/v2g/README.md @@ -0,0 +1,60 @@ +# V2G suite + +DC charging sessions between an EVerest EVSE and an EVerest EV, ISO 15118-2 and +DIN 70121, over a veth pair, each captured on the link. Software in the loop: +no modems, no CP line. + +```bash +just test-v2g # from the repository root; Linux Docker +just test-v2g -k din # pytest arguments pass through +just v2g-record # record captures/ again +``` + +## The SIL + +| Piece | What it is | +|---|---| +| Image | `everest-demo/manager` 2025.10.0-single-phase, pinned by digest (`Dockerfile`), plus Mosquitto, tshark and pytest | +| EVSE | `EvseV2G` on `v2g-evse`, no TLS, with EVerest's simulated DC supply, isolation monitor and board | +| EV | `PyEvJosev` (Josev, EXIficient codec) driven by `EvManager`, standalone in its own network namespace on `v2g-ev` | +| SLAC | `SlacSimulator`, in process: no HomePlug frames on the link | +| Broker | Mosquitto on a Unix socket, reachable from both namespaces | +| Capture | dumpcap on `v2g-evse`: SDP, the V2G TCP connection, neighbour discovery | + +The container needs `CAP_NET_ADMIN` (the veth) and `CAP_SYS_ADMIN` (the EV's +namespace), which `just test-v2g` grants. + +## The checks + +Truth is the link. Each capture is walked at the V2GTP layer: SDP request and +response, the response's address and port the ones the EV connected to, then +each direction of the one TCP connection split into V2GTP frames with nothing +left over, requests and responses alternating. EXI is not decoded. Frames are +named by the EVSE's record of them (`v2g_messages`), matched on bytes. + +| Test | EV does | Asserts | +|---|---|---| +| `test_session[iso2]`, `[din]` | charges 5 s, then stops | protocol selected; SessionSetup first, CableCheck, PreCharge, PowerDelivery, a CurrentDemand loop, PowerDelivery, WeldingDetection, SessionStop last; FIN, no RST | +| `test_ev_unplug_mid_current_demand` | unplugs in CurrentDemand | ends in CurrentDemand: no PowerDelivery stop, no WeldingDetection, no SessionStop | +| `test_isolation_fault` | charges; the isolation monitor reads 10 kOhm | CableCheck, then no PreCharge and no CurrentDemand | +| `test_capture_fixture` | - | the committed captures pass the same link checks, and each sidecar row points at a V2GTP frame's start, in order and direction; needs only tshark | + +## Captures + +`captures/iso2-dc.pcapng` and `captures/din-dc.pcapng`: one full session each, +from `test_session`, about 20 KB. Link-local addresses from random MACs. + +Each has a sidecar, `.messages.csv`: one row per V2GTP frame, `frame` +(the capture's frame number of the segment holding its first byte), +`direction` (`ev` or `evse`) and `message` (e.g. `CurrentDemandReq`), named +from the EVSE's record matched to the wire. No EXI bytes: the record's +responses are not the wire's (below). `just v2g-record` writes both. + +## Known EVerest behaviour + +- EvseV2G 2025.10 records each response in its request's buffer: the request's + V2GTP header and length, the response's EXI cut or padded to it. Requests are + recorded exactly. The suite matches responses on their common prefix. +- Real SLAC (`EvseSlac`, `EvSlac`) over the veth does not match: without a + modem no `CM_SET_KEY.CNF` comes back, so `EvseSlac` stays in reset and + `EvSlac` fails. The HomePlug AV frames do cross the link. diff --git a/testing/suites/v2g/captures/din-dc.messages.csv b/testing/suites/v2g/captures/din-dc.messages.csv new file mode 100644 index 0000000..cd7ffda --- /dev/null +++ b/testing/suites/v2g/captures/din-dc.messages.csv @@ -0,0 +1,65 @@ +frame,direction,message +11,ev,SupportedAppProtocolReq +13,evse,SupportedAppProtocolRes +15,ev,SessionSetupReq +17,evse,SessionSetupRes +19,ev,ServiceDiscoveryReq +21,evse,ServiceDiscoveryRes +23,ev,ServicePaymentSelectionReq +25,evse,ServicePaymentSelectionRes +27,ev,ContractAuthenticationReq +30,evse,ContractAuthenticationRes +32,ev,ContractAuthenticationReq +34,evse,ContractAuthenticationRes +37,ev,ChargeParameterDiscoveryReq +39,evse,ChargeParameterDiscoveryRes +41,ev,CableCheckReq +43,evse,CableCheckRes +45,ev,CableCheckReq +47,evse,CableCheckRes +49,ev,CableCheckReq +51,evse,CableCheckRes +53,ev,CableCheckReq +55,evse,CableCheckRes +57,ev,CableCheckReq +59,evse,CableCheckRes +61,ev,CableCheckReq +63,evse,CableCheckRes +65,ev,CableCheckReq +67,evse,CableCheckRes +69,ev,PreChargeReq +71,evse,PreChargeRes +73,ev,PreChargeReq +75,evse,PreChargeRes +77,ev,PowerDeliveryReq +79,evse,PowerDeliveryRes +81,ev,CurrentDemandReq +83,evse,CurrentDemandRes +85,ev,CurrentDemandReq +87,evse,CurrentDemandRes +89,ev,CurrentDemandReq +91,evse,CurrentDemandRes +93,ev,CurrentDemandReq +95,evse,CurrentDemandRes +97,ev,CurrentDemandReq +99,evse,CurrentDemandRes +101,ev,CurrentDemandReq +103,evse,CurrentDemandRes +105,ev,CurrentDemandReq +107,evse,CurrentDemandRes +109,ev,CurrentDemandReq +111,evse,CurrentDemandRes +113,ev,CurrentDemandReq +115,evse,CurrentDemandRes +117,ev,PowerDeliveryReq +119,evse,PowerDeliveryRes +121,ev,WeldingDetectionReq +123,evse,WeldingDetectionRes +126,ev,WeldingDetectionReq +128,evse,WeldingDetectionRes +130,ev,WeldingDetectionReq +132,evse,WeldingDetectionRes +134,ev,WeldingDetectionReq +136,evse,WeldingDetectionRes +138,ev,SessionStopReq +140,evse,SessionStopRes diff --git a/testing/suites/v2g/captures/din-dc.pcapng b/testing/suites/v2g/captures/din-dc.pcapng new file mode 100644 index 0000000000000000000000000000000000000000..eed47aac0f10eb1b25ad05346bb6146a33d04d0d GIT binary patch literal 19960 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 Z`6#!WYZJ45;r2#+A~^iuHhbW>`F{jpfA;_Y literal 0 HcmV?d00001 diff --git a/testing/suites/v2g/conftest.py b/testing/suites/v2g/conftest.py new file mode 100644 index 0000000..c5c63c3 --- /dev/null +++ b/testing/suites/v2g/conftest.py @@ -0,0 +1,190 @@ +"""An EVerest EVSE and EV charging over a veth pair, one session per run. + +The EVSE side (EvseV2G and the simulated hardware) runs under the EVerest +manager in the container's namespace on v2g-evse. The EV (PyEvJosev, which +encodes with its own EXI codec) runs standalone in a namespace of its own on +the peer, v2g-ev, so SDP and the V2G TCP connection cross the link. The two +reach one Mosquitto over a Unix socket, which no namespace hides. SLAC is +simulated in process: no HomePlug frames on the link. + +Runs in the SIL image, which carries EVerest, Mosquitto and dumpcap and has +CAP_NET_ADMIN and CAP_SYS_ADMIN. From the repository root: + + just test-v2g +""" + +import contextlib +import json +import os +import shutil +import signal +import subprocess +import time +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +import yaml + +HERE = Path(__file__).parent +PREFIX = Path("/ext/dist") +SOCKET = "/run/mqtt.sock" +EVSE_IF, EV_IF = "v2g-evse", "v2g-ev" +# EvManager: matched, start DC, wait for the EVSE's power, close the contactors. +START = "iso_wait_slac_matched;iso_start_v2g_session DC 86400 0;iso_wait_pwr_ready;iso_dc_power_on" +# Boot to a finished session, with every module's start-up. +SESSION_S = 90.0 + + +def pytest_addoption(parser): + parser.addoption("--record", type=Path, help="Keep each session's pcapng, MQTT record and logs in this directory") + + +@dataclass +class Session: + """One run's evidence: the link's capture and the modules' MQTT variables.""" + + pcap: Path + # (topic, payload) of every module variable, in the order published. + vars: list[tuple[str, dict]] = field(default_factory=list) + + def var(self, module: str, name: str) -> list: + """Every value module published for variable name.""" + return [p.get("data") for t, p in self.vars if t.startswith(f"everest/modules/{module}/") and p.get("name") == name] + + +def run(*cmd: str) -> None: + subprocess.run(cmd, check=True) + + +def wait(done: Callable[[], bool], timeout_s: float, what: str) -> None: + end = time.monotonic() + timeout_s + while not done(): + assert time.monotonic() < end, f"no {what} within {timeout_s} s" + time.sleep(0.1) + + +def link_ready(iface: str) -> bool: + """A link-local address, past duplicate address detection.""" + out = subprocess.run(["ip", "-6", "-o", "addr", "show", "dev", iface, "scope", "link"], capture_output=True, text=True).stdout + return "inet6" in out and "tentative" not in out + + +@contextlib.contextmanager +def proc(cmd: list[str], log: Path, **kw) -> Iterator[subprocess.Popen]: + """A process for the block, its output in log; TERM, then KILL after 3 s.""" + with log.open("wb") as out: + p = subprocess.Popen(cmd, stdout=out, stderr=subprocess.STDOUT, start_new_session=True, **kw) + try: + yield p + finally: + with contextlib.suppress(ProcessLookupError): + os.killpg(p.pid, signal.SIGTERM) + try: + p.wait(3) + except subprocess.TimeoutExpired: + os.killpg(p.pid, signal.SIGKILL) + p.wait() + + +@pytest.fixture(scope="session") +def broker(tmp_path_factory) -> Iterator[None]: + if shutil.which("mosquitto") is None or not (PREFIX / "bin/manager").exists(): + pytest.skip("runs in the SIL image: just test-v2g") + conf = tmp_path_factory.mktemp("mqtt") / "mosquitto.conf" + conf.write_text(f"listener 0 {SOCKET}\nallow_anonymous true\nuser root\n") + with proc(["mosquitto", "-c", str(conf)], conf.with_suffix(".log")): + wait(lambda: Path(SOCKET).exists(), 5, "broker socket") + yield + + +def config(protocol: str, commands: str, imd: dict | None) -> dict: + cfg = yaml.safe_load((HERE / "everest.yaml").read_text()) + mods = cfg["active_modules"] + ev = mods["iso15118_car"]["config_module"] + ev["supported_DIN70121"], ev["supported_ISO15118_2"] = protocol == "din", protocol == "iso2" + mods["ev_manager"]["config_module"]["auto_exec_commands"] = commands + mods["imd"]["config_implementation"]["main"].update(imd or {}) + return cfg + + +@pytest.fixture +def charge(broker, request, tmp_path) -> Callable[..., Session]: + """charge(protocol, commands, done, imd=None): one session, until done(session) holds. + + protocol: "iso2" or "din", the one the EV offers. commands: EvManager's + script after START. done: the end the test expects, on MQTT variables. + """ + record = request.config.getoption("record") + out = record / request.node.name if record else tmp_path + out.mkdir(parents=True, exist_ok=True) + + def charge(protocol: str, commands: str, done: Callable[[Session], bool], imd: dict | None = None) -> Session: + (out / "everest.yaml").write_text(yaml.safe_dump(config(protocol, f"{START};{commands}", imd))) + session = Session(out / f"{protocol}.pcapng") + run("ip", "link", "add", EVSE_IF, "type", "veth", "peer", "name", EV_IF) + try: + with contextlib.ExitStack() as stack: + # The EV's namespace lives as long as this shell. It starts the + # module once its link is up and the manager can configure it. + pidfile, go = out / "ev.pid", out / "ev.go" + pidfile.unlink(missing_ok=True) + go.unlink(missing_ok=True) + ev_env = { + **os.environ, + "PYTHONPATH": str(PREFIX / "lib/everest/everestpy"), + "EV_MODULE": "iso15118_car", + "EV_PREFIX": str(PREFIX), + "EV_LOG_CONF_FILE": str(PREFIX / "etc/everest/default_logging.cfg"), + "EV_MQTT_EVEREST_PREFIX": "everest/", + "EV_MQTT_EXTERNAL_PREFIX": "", + "EV_MQTT_BROKER_SOCKET_PATH": SOCKET, + } + script = ( + f"echo $$ > {pidfile}; until ip link show {EV_IF} >/dev/null 2>&1; do sleep 0.1; done; " + f"ip link set lo up && ip link set {EV_IF} up && " + f"until ip -6 -o addr show dev {EV_IF} scope link | grep -v tentative | grep -q inet6; do sleep 0.1; done; " + f"until [ -e {go} ]; do sleep 0.1; done; exec python3 {PREFIX}/libexec/everest/modules/PyEvJosev/module.py" + ) + ev = stack.enter_context(proc(["unshare", "-n", "bash", "-c", script], out / "ev.log", env=ev_env)) + wait(lambda: pidfile.exists() and pidfile.read_text().strip(), 5, "EV namespace") + run("ip", "link", "set", EV_IF, "netns", pidfile.read_text().strip()) + run("ip", "link", "set", EVSE_IF, "up") + wait(lambda: link_ready(EVSE_IF), 10, f"{EVSE_IF} address") + + mqtt = out / "vars.txt" + stack.enter_context(proc(["mosquitto_sub", "--unix", SOCKET, "-v", "-t", "everest/modules/+/impl/+/var"], mqtt)) + stack.enter_context(proc(["dumpcap", "-q", "-i", EVSE_IF, "-w", str(session.pcap)], out / "dumpcap.log")) + wait(lambda: session.pcap.exists(), 5, "capture") + env = {**os.environ, "LD_LIBRARY_PATH": str(PREFIX / "lib")} + log = out / "manager.log" + manager = stack.enter_context(proc([str(PREFIX / "bin/manager"), "--prefix", str(PREFIX), "--conf", str(out / "everest.yaml")], log, env=env)) + wait(lambda: b"Not starting standalone module" in log.read_bytes(), 30, "manager up") + go.touch() + + def over() -> bool: + assert manager.poll() is None, f"manager exited {manager.returncode}: {out / 'manager.log'}" + assert ev.poll() is None, f"EV exited {ev.returncode}: {out / 'ev.log'}" + session.vars = read_vars(mqtt) + return done(session) + + wait(over, SESSION_S, "session end") + # The connection's last segments. + time.sleep(1.0) + finally: + subprocess.run(["ip", "link", "del", EVSE_IF], stderr=subprocess.DEVNULL) + session.vars = read_vars(out / "vars.txt") + return session + + return charge + + +def read_vars(path: Path) -> list[tuple[str, dict]]: + """mosquitto_sub -v lines: topic, a space, the JSON payload. A line still being written is left.""" + got = [] + for line in path.read_text().splitlines(): + topic, _, payload = line.partition(" ") + with contextlib.suppress(json.JSONDecodeError): + got.append((topic, json.loads(payload))) + return got diff --git a/testing/suites/v2g/everest.yaml b/testing/suites/v2g/everest.yaml new file mode 100644 index 0000000..2fc0d4a --- /dev/null +++ b/testing/suites/v2g/everest.yaml @@ -0,0 +1,150 @@ +# EVerest 2025.10.0: a simulated DC EVSE and EV, from the image's +# config-sil-dc.yaml. conftest.py sets, per run, the protocol the EV offers, +# EvManager's script and the isolation monitor's reading. +# +# The broker is a Unix socket, so the EV reaches it from its own network +# namespace. The EV (iso15118_car) is standalone: conftest.py starts it in that +# namespace on v2g-ev. The EVSE listens on v2g-evse, the other end of the veth. +settings: + mqtt_broker_socket_path: /run/mqtt.sock +active_modules: + iso15118_charger: + module: EvseV2G + config_module: + device: v2g-evse + # Plain TCP: the capture's EXI is readable. + tls_security: prohibit + connections: + security: + - module_id: evse_security + implementation_id: main + iso15118_car: + module: PyEvJosev + standalone: true + config_module: + device: v2g-ev + supported_DIN70121: false + supported_ISO15118_2: true + tls_active: false + evse_manager: + module: EvseManager + config_module: + connector_id: 1 + evse_id: DE*PNX*E12345*1 + evse_id_din: 49A80737A45678 + session_logging: true + session_logging_xml: false + session_logging_path: /tmp/everest-logs + charge_mode: DC + connections: + bsp: + - module_id: yeti_driver + implementation_id: board_support + powermeter_car_side: + - module_id: powersupply_dc + implementation_id: powermeter + slac: + - module_id: slac + implementation_id: evse + hlc: + - module_id: iso15118_charger + implementation_id: charger + powersupply_DC: + - module_id: powersupply_dc + implementation_id: main + imd: + - module_id: imd + implementation_id: main + over_voltage_monitor: + - module_id: ovm + implementation_id: main + powersupply_dc: + module: DCSupplySimulator + yeti_driver: + module: YetiSimulator + config_module: + connector_id: 1 + # SLAC in process: no HomePlug frames on the link. + slac: + module: SlacSimulator + imd: + module: IMDSimulator + config_implementation: + main: + selftest_success: true + resistance_F_Ohm: 900000 + ovm: + module: OVMSimulator + ev_manager: + module: EvManager + config_module: + connector_id: 1 + auto_enable: true + auto_exec: true + auto_exec_commands: set by conftest.py + dc_target_current: 20 + dc_target_voltage: 400 + connections: + ev_board_support: + - module_id: yeti_driver + implementation_id: ev_board_support + ev: + - module_id: iso15118_car + implementation_id: ev + slac: + - module_id: slac + implementation_id: ev + auth: + module: Auth + config_module: + connection_timeout: 10 + selection_algorithm: FindFirst + connections: + token_provider: + - module_id: token_provider + implementation_id: main + token_validator: + - module_id: token_validator + implementation_id: main + evse_manager: + - module_id: evse_manager + implementation_id: evse + token_provider: + module: DummyTokenProvider + config_implementation: + main: + token: TOKEN1 + connections: + evse: + - module_id: evse_manager + implementation_id: evse + token_validator: + module: DummyTokenValidator + config_implementation: + main: + validation_result: Accepted + validation_reason: Token seems valid + sleep: 0.25 + evse_security: + module: EvseSecurity + config_module: + private_key_password: "123456" + energy_manager: + module: EnergyManager + connections: + energy_trunk: + - module_id: grid_connection_point + implementation_id: energy_grid + grid_connection_point: + module: EnergyNode + config_module: + fuse_limit_A: 40.0 + phase_count: 3 + connections: + price_information: [] + energy_consumer: + - module_id: evse_manager + implementation_id: energy_grid + powermeter: + - module_id: yeti_driver + implementation_id: powermeter diff --git a/testing/suites/v2g/test_session.py b/testing/suites/v2g/test_session.py new file mode 100644 index 0000000..974a378 --- /dev/null +++ b/testing/suites/v2g/test_session.py @@ -0,0 +1,242 @@ +"""DC charging sessions between EVerest's EVSE and EV, ISO 15118-2 and DIN 70121, +checked on the link they crossed. + +The capture is walked at the V2GTP layer (ISO 15118-2 clause 7.8): SDP on UDP +15118, then each direction of the TCP connection split into V2GTP frames with +nothing left over. EXI is not decoded here. Each frame is named by the EVSE's +own record of it (EvseV2G's v2g_messages variable), accepted only where that +record carries the frame's bytes; the EV encodes with another codec +(EXIficient, in Josev), so a session that completes is two codecs agreeing. + + just test-v2g # every test + just v2g-record # re-record captures/ from the sessions +""" + +import csv +import itertools +import shutil +import struct +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +V2G_PORT = 15118 +SDP_REQ, SDP_RES, EXI = 0x9000, 0x9001, 0x8001 +# What the EV's script does after START (conftest), and the end each test waits for. +STOP_AFTER_S = 5 +EV_STOP = f"sleep {STOP_AFTER_S};iso_stop_charging;iso_wait_v2g_session_stopped;unplug" +PROTOCOLS = {"iso2": "ISO15118-2-2013", "din": "DIN70121"} + +SETUP = ["SupportedAppProtocol", "SessionSetup", "ServiceDiscovery"] +CHARGE = ["CableCheck", "PreCharge", "PowerDelivery", "CurrentDemand"] +STOP = ["PowerDelivery", "WeldingDetection", "SessionStop"] + + +@dataclass +class Frame: + t: float + # The capture's frame number of the segment holding the V2GTP frame's first byte. + number: int + from_ev: bool + type: int + payload: bytes + raw: bytes + + +def fields(pcap: Path, *names: str) -> list[list[str]]: + out = subprocess.run( + ["tshark", "-r", str(pcap), "-T", "fields", "-E", "separator=|", *itertools.chain.from_iterable(("-e", n) for n in names)], + check=True, + capture_output=True, + text=True, + ).stdout + return [line.split("|") for line in out.splitlines()] + + +def v2gtp(data: bytes) -> list[tuple[int, bytes, bytes]]: + """(payload type, payload, frame) of each V2GTP frame in data, which they must fill exactly.""" + got, i = [], 0 + while i < len(data): + assert len(data) - i >= 8, f"{len(data) - i} bytes left over" + version, inverse, ptype, n = struct.unpack_from("!BBHI", data, i) + assert (version, inverse) == (0x01, 0xFE), data[i : i + 8].hex() + assert i + 8 + n <= len(data), f"frame of {n} bytes, {len(data) - i - 8} left" + got.append((ptype, data[i + 8 : i + 8 + n], data[i : i + 8 + n])) + i += 8 + n + return got + + +def sdp(pcap: Path) -> list[tuple[str, int, int, bytes]]: + """(source, source port, payload type, payload) of each SDP datagram.""" + rows = fields(pcap, "ipv6.src", "udp.srcport", "udp.dstport", "udp.payload") + got = [] + for src, sport, dport, payload in rows: + if V2G_PORT in (int(sport or 0), int(dport or 0)): + ((ptype, body, _),) = v2gtp(bytes.fromhex(payload)) + got.append((src, int(sport), ptype, body)) + return got + + +def tcp(pcap: Path) -> tuple[tuple[str, int], list[Frame], set[str]]: + """The one connection's server (address, port), its V2GTP frames in time order, and its flags seen.""" + rows = fields(pcap, "frame.number", "frame.time_epoch", "ipv6.src", "ipv6.dst", "tcp.srcport", "tcp.dstport", "tcp.seq_raw", "tcp.len", "tcp.flags.str", "tcp.payload") + rows = [r[1:] + r[:1] for r in rows if r[4]] + syn = [r for r in rows if "S" in r[7] and "A" not in r[7].replace("ยท", "")] + assert len(syn) == 1, f"{len(syn)} connections" + ev, server = (syn[0][1], int(syn[0][3])), (syn[0][2], int(syn[0][4])) + flags = set() + streams: dict[bool, list[tuple[float, int, bytes, int]]] = {True: [], False: []} + for t, src, _, sport, _, seq, n, f, payload, number in rows: + from_ev = (src, int(sport)) == ev + assert from_ev or (src, int(sport)) == server, f"a second peer {src}:{sport}" + flags.update(c for c in f if c.isalpha()) + if int(n): + streams[from_ev].append((float(t), int(seq), bytes.fromhex(payload), int(number))) + frames = [] + for from_ev, segs in streams.items(): + # A veth loses and reorders nothing: each segment follows the last. + for (_, a, pa, _), (_, b, _, _) in itertools.pairwise(segs): + assert b == (a + len(pa)) % 2**32, "a gap or retransmission in the stream" + # A frame starts in the segment holding its first byte. + starts, data = [], b"" + for t, _, p, number in segs: + starts.append((len(data), t, number)) + data += p + at = 0 + for ptype, body, raw in v2gtp(data): + _, t, number = max(s for s in starts if s[0] <= at) + frames.append(Frame(t, number, from_ev, ptype, body, raw)) + at += len(raw) + frames.sort(key=lambda f: f.t) + return server, frames, flags + + +def names(session, frames: list[Frame]) -> list[str]: + """Each frame's message name, from the EVSE's record of the same bytes. + + A request the EVSE never read, sent as the link went, is the one frame it + may lack: the last, named "?". + """ + record = session.var("iso15118_charger", "v2g_messages") + unread = len(frames) - len(record) + assert unread == 0 or (unread == 1 and frames[-1].from_ev), f"{len(record)} recorded, {len(frames)} on the wire" + got = [] + for f, r in zip(frames, record, strict=False): + exi = bytes.fromhex(r["exi"]) + assert r["id"].endswith("Req") == f.from_ev, r["id"] + if f.from_ev: + assert exi == f.raw, f"{r['id']}: recorded {exi.hex()}, wire {f.raw.hex()}" + else: + # EvseV2G 2025.10 records a response in its request's frame: the + # request's header and length, the response's EXI cut or padded to it. + n = min(len(exi) - 8, len(f.payload)) + assert exi[8 : 8 + n] == f.payload[:n], f"{r['id']}: recorded {exi.hex()}, wire {f.raw.hex()}" + got.append(r["id"]) + return got + ["?"] * unread + + +def exchanges(msgs: list[str]) -> list[str]: + """Request/response pairs, as one name each; asserts each request is answered by its response. + + An unanswered last request is left out. + """ + got = [] + for req, res in zip(msgs[::2], msgs[1::2], strict=False): + assert req.endswith("Req") and res == req[:-3] + "Res", (req, res) + got.append(req[:-3]) + return got + + +def runs(seq: list[str]) -> list[str]: + return [k for k, _ in itertools.groupby(seq)] + + +def check_link(pcap: Path) -> tuple[list[Frame], set[str]]: + """SDP found the EVSE that served the connection; every frame is EXI.""" + found = sdp(pcap) + reqs = [(s, b) for s, _, t, b in found if t == SDP_REQ] + res = [(s, b) for s, _, t, b in found if t == SDP_RES] + assert reqs and res, found + # Request: security, transport (0x10 no TLS, 0x00 TCP). Response: the + # EVSE's address and port, then the same two. + assert all(b == bytes([0x10, 0x00]) for _, b in reqs), reqs + server, frames, flags = tcp(pcap) + for src, b in res: + assert len(b) == 20 and b[18:] == bytes([0x10, 0x00]), b.hex() + assert (src, struct.unpack_from("!H", b, 16)[0]) == server + assert all(f.type == EXI for f in frames) + # Request, response, request, ... + assert [f.from_ev for f in frames] == [i % 2 == 0 for i in range(len(frames))] + return frames, flags + + +def write_messages(path: Path, frames: list[Frame], msgs: list[str]) -> None: + """The capture's sidecar: each V2GTP frame's frame number, direction and message name.""" + with path.open("w", newline="") as f: + w = csv.writer(f) + w.writerow(["frame", "direction", "message"]) + w.writerows((fr.number, "ev" if fr.from_ev else "evse", m) for fr, m in zip(frames, msgs, strict=True)) + + +def ended(s) -> bool: + """The EV's V2G session is over, however it ended.""" + return bool(s.var("iso15118_car", "v2g_session_finished")) + + +def unplugged(s) -> bool: + return any(e.get("event") == "SessionFinished" for e in s.var("evse_manager", "session_event")) + + +@pytest.mark.parametrize("protocol", PROTOCOLS) +def test_session(charge, protocol): + """The EV charges STOP_AFTER_S in CurrentDemand, then ends the session itself.""" + s = charge(protocol, EV_STOP, ended) + frames, flags = check_link(s.pcap) + msgs = names(s, frames) + write_messages(s.pcap.with_suffix(".messages.csv"), frames, msgs) + seq = exchanges(msgs) + assert s.var("iso15118_charger", "selected_protocol") == [PROTOCOLS[protocol]] + assert runs(seq)[:3] == SETUP, seq + assert runs(seq)[-3:] == STOP, seq + # The DC sequence, in order, with the loop between. + it = iter(runs(seq)) + assert all(m in it for m in CHARGE), seq + # Some of the loop, not a single exchange: Josev sends about two a second. + assert seq.count("CurrentDemand") >= STOP_AFTER_S, seq + assert "F" in flags and "R" not in flags, flags + assert s.var("iso15118_charger", "current_demand_started") and s.var("iso15118_charger", "current_demand_finished") + + +def test_ev_unplug_mid_current_demand(charge): + """Unplugged in CurrentDemand: no PowerDelivery stop, no SessionStop, and the link ends.""" + s = charge("iso2", "sleep 3;unplug", unplugged) + frames, _ = check_link(s.pcap) + seq = runs(exchanges(names(s, frames))) + assert seq[-1] == "CurrentDemand", seq + assert "SessionStop" not in seq and "WeldingDetection" not in seq + + +def test_isolation_fault(charge): + """The isolation monitor reads a fault: no PreCharge, so no power.""" + s = charge("iso2", EV_STOP, ended, imd={"resistance_F_Ohm": 10000}) + frames, _ = check_link(s.pcap) + seq = runs(exchanges(names(s, frames))) + assert "CableCheck" in seq and "PreCharge" not in seq and "CurrentDemand" not in seq, seq + + +@pytest.mark.parametrize("protocol", PROTOCOLS) +def test_capture_fixture(protocol): + """The committed demo capture holds the session test_session checks: SDP, then one + connection of whole frames, each named in its sidecar at the frame it starts in.""" + if shutil.which("tshark") is None: + pytest.skip("needs tshark") + frames, flags = check_link(HERE / "captures" / f"{protocol}-dc.pcapng") + assert len(frames) >= 2 * (len(SETUP) + len(CHARGE) + len(STOP)) and "F" in flags + with (HERE / "captures" / f"{protocol}-dc.messages.csv").open(newline="") as f: + rows = list(csv.DictReader(f)) + assert [(int(r["frame"]), r["direction"]) for r in rows] == [(fr.number, "ev" if fr.from_ev else "evse") for fr in frames] + seq = exchanges([r["message"] for r in rows]) + assert runs(seq)[:3] == SETUP and runs(seq)[-3:] == STOP, seq