From aee410a1a6f7819b33ae4ff4f2ed27ceb548202e Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 25 Mar 2026 12:38:55 +0100 Subject: [PATCH] feat(ocpp-server): enhance OCPP 2.0.1 mock server for comprehensive E2E testing (#1752) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(ocpp-server): add boot status sequence for Pending→Accepted transition testing * feat(ocpp-server): add configurable TriggerMessage type * feat(ocpp-server): add configurable Reset type and ChangeAvailability status * feat(ocpp-server): add command sequencing for multi-step CSMS workflows * feat(ocpp-server): add groupIdToken and cacheExpiry to Authorize response * feat(ocpp-server): add multi-variable SetVariables/GetVariables CLI support * fix(ocpp-server): share boot_index across reconnections so sequence advances correctly * fix(ocpp-server): schedule send_commands as background task so it runs after cp.start() * fix(ocpp-server): correct CLI help text casing and validate boot-status-sequence * chore: remove E2E test plan from PR branch * refactor(ocpp-server): fix misplaced tests and harmonize style * docs(ocpp-server): document all new CLI flags and transaction tracking * fix(ocpp-server): harden input validation and connection cleanup * fix(ocpp-server): clean up auth-mode choices and CLI edge cases * fix(ocpp-server): validate cache-expiry, skip trailing commas, document yield semantics * fix(ocpp-server): harmonize empty-entry handling and wrap CLI parsers in error handler --- tests/ocpp-server/README.md | 198 ++-- tests/ocpp-server/server.py | 358 ++++++- tests/ocpp-server/test_server.py | 434 +++++++- tests/ocpp2-e2e-test-plan.md | 1600 ------------------------------ 4 files changed, 856 insertions(+), 1734 deletions(-) delete mode 100644 tests/ocpp2-e2e-test-plan.md diff --git a/tests/ocpp-server/README.md b/tests/ocpp-server/README.md index 85ae9581..496c7431 100644 --- a/tests/ocpp-server/README.md +++ b/tests/ocpp-server/README.md @@ -1,19 +1,17 @@ # OCPP2 Mock Server -This project includes an Open Charge Point Protocol (OCPP) version 2.0.1 mock server implemented in Python. +An OCPP 2.0.1 mock CSMS (Central System Management System) for end-to-end testing of charging station simulators. ## Prerequisites -This project requires Python 3.12+ (see `pyproject.toml`) and requires [poetry](https://python-poetry.org/) 2+ to install the required packages. +This project requires Python 3.12+ (see `pyproject.toml`) and [Poetry](https://python-poetry.org/) 2+. -Install poetry: +Install Poetry: ```shell pipx install poetry ``` -or by using your OS packages manager. - Then install dependencies: ```shell @@ -22,135 +20,153 @@ poetry install --no-root ## Running the Server -To start the server, run the `server.py` script: - -```shell -poetry run task server -``` - -Or - ```shell poetry run python server.py ``` -The server will start listening for connections on `127.0.0.1:9000` by default. +The server listens on `127.0.0.1:9000` by default. ## Configuration ### Server -```shell -poetry run python server.py --host --port -``` +- `--host `: Bind address (default: `127.0.0.1`) +- `--port `: Listening port (default: `9000`) + +### Boot Behavior -- `--host `: Server bind address (default: `127.0.0.1`) -- `--port `: Server port (default: `9000`) +- `--boot-status `: Fixed BootNotification response status (default: `Accepted`) + - `Accepted` — Station registered + - `Pending` — Station not yet registered, must retry + - `Rejected` — Station rejected, must retry +- `--boot-status-sequence `: Comma-separated status sequence (e.g., `Pending,Pending,Accepted`). Returns the next status on each BootNotification, stays on the last value once exhausted. +- `--total-cost `: Total cost in TransactionEvent.Updated responses (default: `10.0`) -### Charging Station Behavior +`--boot-status` and `--boot-status-sequence` are mutually exclusive. `--boot-status X` is shorthand for `--boot-status-sequence X`. ```shell -poetry run python server.py --boot-status --total-cost +poetry run python server.py --boot-status Rejected +poetry run python server.py --boot-status-sequence Pending,Pending,Accepted +poetry run python server.py --total-cost 25.50 ``` -- `--boot-status `: BootNotification response status (`accepted`, `pending`, `rejected`; default: `accepted`) -- `--total-cost `: Total cost returned in TransactionEvent.Updated responses (default: `10.0`) +### Authorization -**Examples:** +- `--auth-mode `: Authorization mode (default: `normal`) + - `normal` — Accept all tokens + - `whitelist` — Only accept tokens in the whitelist + - `blacklist` — Block tokens in the blacklist, accept all others + - `rate_limit` — Reject all with `NotAtThisTime` +- `--whitelist TOKEN ...`: Authorized tokens (default: `valid_token test_token authorized_user`) +- `--blacklist TOKEN ...`: Blocked tokens (default: `blocked_token invalid_user`) +- `--offline`: Simulate network failure (raises `InternalError` on Authorize) +- `--auth-group-id `: Include `groupIdToken` in Authorize and TransactionEvent.Started responses +- `--auth-cache-expiry `: Include `cacheExpiryDateTime` (now + N seconds) in Authorize and TransactionEvent.Started responses ```shell -poetry run python server.py --boot-status rejected -poetry run python server.py --total-cost 25.50 +poetry run python server.py --auth-mode whitelist --whitelist valid_token test_token +poetry run python server.py --auth-mode blacklist --blacklist blocked_token +poetry run python server.py --auth-mode rate_limit +poetry run python server.py --offline +poetry run python server.py --auth-group-id MyGroup --auth-cache-expiry 3600 ``` -### Authorization Modes +### OCPP Commands -The server supports configurable authorization behavior for testing OCPP 2.0.1 authentication scenarios: +Send CSMS-initiated commands to connected charging stations. -```shell -poetry run python server.py --auth-mode [--whitelist TOKEN1 TOKEN2 ...] [--blacklist TOKEN1 TOKEN2 ...] [--offline] -``` +#### Single command -- `--auth-mode `: Authorization mode (default: `normal`) - - `normal` - Accept all authorization requests (default) - - `whitelist` - Only accept tokens in the whitelist - - `blacklist` - Block tokens in the blacklist, accept all others - - `rate_limit` - Reject all requests with `NotAtThisTime` (simulates rate limiting) - - `offline` - Not used directly (use `--offline` flag instead) -- `--whitelist TOKEN1 TOKEN2 ...`: Space-separated list of authorized tokens (default: `valid_token test_token authorized_user`) -- `--blacklist TOKEN1 TOKEN2 ...`: Space-separated list of blocked tokens (default: `blocked_token invalid_user`) -- `--offline`: Simulate network failure (raises InternalError on Authorize requests) +- `--command `: OCPP command to send (see supported commands below) +- `--delay `: One-shot delay before sending (mutually exclusive with `--period`) +- `--period `: Repeat interval in seconds (mutually exclusive with `--delay`) -**Examples:** +`--delay` or `--period` is required when `--command` is specified. ```shell -poetry run python server.py --auth-mode whitelist --whitelist valid_token test_token -poetry run python server.py --auth-mode blacklist --blacklist blocked_token invalid_user -poetry run python server.py --offline -poetry run python server.py --auth-mode rate_limit +poetry run python server.py --command Reset --delay 5 +poetry run python server.py --command GetBaseReport --period 10 ``` -### OCPP Command Sending +#### Command sequence -The server can periodically send outgoing OCPP commands to connected charging stations: +- `--commands `: Comma-separated `CMD:DELAY` pairs, executed sequentially (e.g., `RequestStartTransaction:5,RequestStopTransaction:30`) + +Mutually exclusive with `--command`. ```shell -poetry run python server.py --command --period -poetry run python server.py --command --delay +poetry run python server.py --commands "RequestStartTransaction:5,RequestStopTransaction:30" ``` -- `--command `: The OCPP command to send (see supported commands below) -- `--period `: Interval in seconds between repeated command sends (mutually exclusive with `--delay`) -- `--delay `: One-shot delay in seconds before sending the command (mutually exclusive with `--period`) +#### Command-specific options + +These flags customize the payload of specific commands: -**Example:** +- `--trigger-message `: TriggerMessage requested message type (default: `StatusNotification`) + - `StatusNotification`, `BootNotification`, `Heartbeat`, `MeterValues`, `FirmwareStatusNotification`, `LogStatusNotification`, `SignCertificate` +- `--reset-type `: Reset type (default: `Immediate`) + - `Immediate` — Reset now + - `OnIdle` — Reset when no transaction is active +- `--availability-status `: ChangeAvailability operational status (default: `Operative`) + - `Operative` — Connector available + - `Inoperative` — Connector unavailable +- `--set-variables `: SetVariables data as `Component.Variable=Value,...` (values must not contain commas) +- `--get-variables `: GetVariables data as `Component.Variable,...` ```shell -poetry run python server.py --command GetBaseReport --period 5 +poetry run python server.py --command TriggerMessage --trigger-message BootNotification --delay 5 +poetry run python server.py --command Reset --reset-type OnIdle --delay 5 +poetry run python server.py --command ChangeAvailability --availability-status Inoperative --delay 5 +poetry run python server.py --command SetVariables --delay 5 \ + --set-variables "OCPPCommCtrlr.HeartbeatInterval=30,TxCtrlr.EVConnectionTimeOut=60" ``` -## Supported OCPP Messages +## Supported OCPP 2.0.1 Messages ### Outgoing Commands (CSMS → CS) -- `CertificateSigned` - Send a signed certificate to the charging station -- `ChangeAvailability` - Change connector availability -- `ClearCache` - Clear the charging station cache -- `CustomerInformation` - Request customer information from the charging station -- `DataTransfer` - Send custom data -- `DeleteCertificate` - Delete a certificate on the charging station -- `GetBaseReport` - Request a base configuration report -- `GetInstalledCertificateIds` - Get installed certificate IDs from the charging station -- `GetLog` - Request log upload from the charging station -- `GetTransactionStatus` - Get the status of a transaction -- `GetVariables` - Get variable values from the charging station -- `InstallCertificate` - Install a certificate on the charging station -- `RequestStartTransaction` - Request to start a transaction -- `RequestStopTransaction` - Request to stop a transaction -- `Reset` - Reset the charging station -- `SetNetworkProfile` - Set the network connection profile -- `SetVariables` - Set variable values on the charging station -- `TriggerMessage` - Trigger a specific message -- `UnlockConnector` - Unlock a specific connector -- `UpdateFirmware` - Request firmware update on the charging station +- `CertificateSigned` — Send a signed certificate to the charging station +- `ChangeAvailability` — Change connector availability +- `ClearCache` — Clear the charging station cache +- `CustomerInformation` — Request customer information +- `DataTransfer` — Send custom vendor-specific data +- `DeleteCertificate` — Delete a certificate on the charging station +- `GetBaseReport` — Request a full device model report +- `GetInstalledCertificateIds` — List installed certificate IDs +- `GetLog` — Request log upload +- `GetTransactionStatus` — Get status of a transaction +- `GetVariables` — Get variable values +- `InstallCertificate` — Install a CA certificate +- `RequestStartTransaction` — Remote start a transaction +- `RequestStopTransaction` — Remote stop a transaction +- `Reset` — Reset the charging station +- `SetNetworkProfile` — Set the network connection profile +- `SetVariables` — Set variable values +- `TriggerMessage` — Trigger a specific message from the station +- `UnlockConnector` — Unlock a connector +- `UpdateFirmware` — Request firmware update ### Incoming Handlers (CS → CSMS) -- `Authorize` - Handle authorization requests (with configurable auth modes) -- `BootNotification` - Handle boot notification from charging station -- `DataTransfer` - Handle vendor-specific data transfer -- `FirmwareStatusNotification` - Handle firmware update status -- `Get15118EVCertificate` - Handle ISO 15118 EV certificate requests -- `GetCertificateStatus` - Handle OCSP certificate status requests -- `Heartbeat` - Handle heartbeat messages -- `LogStatusNotification` - Handle log upload status -- `MeterValues` - Handle meter value reports -- `NotifyCustomerInformation` - Handle customer information reports -- `NotifyReport` - Handle device model report notifications -- `SecurityEventNotification` - Handle security events -- `SignCertificate` - Handle CSR signing requests -- `StatusNotification` - Handle status notifications -- `TransactionEvent` - Handle transaction events (Started/Updated/Ended) +- `Authorize` — Handle authorization requests (configurable auth modes) +- `BootNotification` — Handle boot notification (configurable status sequence) +- `DataTransfer` — Handle vendor-specific data transfer +- `FirmwareStatusNotification` — Handle firmware update status +- `Get15118EVCertificate` — Handle ISO 15118 EV certificate requests +- `GetCertificateStatus` — Handle OCSP certificate status requests +- `Heartbeat` — Handle heartbeat messages +- `LogStatusNotification` — Handle log upload status +- `MeterValues` — Handle meter value reports +- `NotifyCustomerInformation` — Handle customer information reports +- `NotifyReport` — Handle device model report notifications +- `SecurityEventNotification` — Handle security events +- `SignCertificate` — Handle CSR signing requests +- `StatusNotification` — Handle connector status notifications +- `TransactionEvent` — Handle transaction lifecycle (Started/Updated/Ended) + +### Transaction Tracking + +The server tracks active transaction IDs from `TransactionEvent.Started` and uses real IDs in `RequestStopTransaction` and `GetTransactionStatus`. Falls back to a test ID when no transaction is active. ## Development @@ -186,4 +202,4 @@ poetry run task test_coverage ## Reference -- [mobilityhouse/ocpp](https://github.com/mobilityhouse/ocpp) - Python OCPP library +- [mobilityhouse/ocpp](https://github.com/mobilityhouse/ocpp) — Python OCPP library diff --git a/tests/ocpp-server/server.py b/tests/ocpp-server/server.py index 13ceb4d6..0e510ef9 100644 --- a/tests/ocpp-server/server.py +++ b/tests/ocpp-server/server.py @@ -6,8 +6,8 @@ import logging import math import signal import sys -from dataclasses import dataclass -from datetime import datetime, timezone +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone from enum import StrEnum from functools import partial from random import randint @@ -91,6 +91,8 @@ class AuthConfig: blacklist: tuple[str, ...] offline: bool default_status: AuthorizationStatusEnumType + auth_group_id: str | None = None + auth_cache_expiry: float | None = None @dataclass(frozen=True) @@ -101,28 +103,57 @@ class ServerConfig: delay: float | None period: float | None auth_config: AuthConfig - boot_status: RegistrationStatusEnumType + boot_sequence: tuple[RegistrationStatusEnumType, ...] total_cost: float # Intentionally mutable despite frozen dataclass charge_points: set["ChargePoint"] + # Shared mutable counter so boot_sequence advances across reconnections + boot_index: list[int] = field(default_factory=lambda: [0]) + commands: list[tuple[Action, float]] | None = None + trigger_message_type: MessageTriggerEnumType = ( + MessageTriggerEnumType.status_notification + ) + reset_type: ResetEnumType = ResetEnumType.immediate + availability_status: OperationalStatusEnumType = OperationalStatusEnumType.operative + set_variables_data: list[dict] | None = None + get_variables_data: list[dict] | None = None class ChargePoint(ocpp.v201.ChargePoint): """OCPP 2.0.1 charge point handler with configurable behavior for testing.""" _command_timer: Timer | None + _commands_task: asyncio.Task[None] | None _auth_config: AuthConfig - _boot_status: RegistrationStatusEnumType + _boot_sequence: tuple[RegistrationStatusEnumType, ...] + _boot_index: list[int] _total_cost: float + _trigger_message_type: MessageTriggerEnumType + _reset_type: ResetEnumType + _availability_status: OperationalStatusEnumType _charge_points: set["ChargePoint"] + _set_variables_data: list[dict] | None + _get_variables_data: list[dict] | None def __init__( self, connection, auth_config: AuthConfig | None = None, - boot_status: RegistrationStatusEnumType = RegistrationStatusEnumType.accepted, + boot_sequence: tuple[RegistrationStatusEnumType, ...] = ( + RegistrationStatusEnumType.accepted, + ), + boot_index: list[int] | None = None, total_cost: float = DEFAULT_TOTAL_COST, + trigger_message_type: MessageTriggerEnumType = ( + MessageTriggerEnumType.status_notification + ), + reset_type: ResetEnumType = ResetEnumType.immediate, + availability_status: OperationalStatusEnumType = ( + OperationalStatusEnumType.operative + ), charge_points: set["ChargePoint"] | None = None, + set_variables_data: list[dict] | None = None, + get_variables_data: list[dict] | None = None, ): # Extract CP ID from last URL segment (OCPP 2.0.1 Part 4) cp_id = connection.request.path.strip("/").split("/")[-1] @@ -133,9 +164,19 @@ class ChargePoint(ocpp.v201.ChargePoint): super().__init__(cp_id, connection) self._charge_points = charge_points if charge_points is not None else set() self._command_timer = None - self._boot_status = boot_status + self._commands_task = None + self._boot_sequence = boot_sequence + if not self._boot_sequence: + raise ValueError("boot_sequence must contain at least one status") + self._boot_index = boot_index if boot_index is not None else [0] self._total_cost = total_cost + self._trigger_message_type = trigger_message_type + self._reset_type = reset_type + self._availability_status = availability_status + self._set_variables_data = set_variables_data + self._get_variables_data = get_variables_data self._charge_points.add(self) + self._active_transactions: dict[str, int] = {} if auth_config is None: self._auth_config = AuthConfig( mode=AuthMode.normal, @@ -167,15 +208,34 @@ class ChargePoint(ocpp.v201.ChargePoint): case _: return self._auth_config.default_status + def _build_id_token_info(self, token_id: str) -> dict: + """Build id_token_info dict with optional groupIdToken and cacheExpiry.""" + status = self._resolve_auth_status(token_id) + id_token_info: dict = {"status": status} + if self._auth_config.auth_group_id is not None: + id_token_info["group_id_token"] = { + "id_token": self._auth_config.auth_group_id, + "type": "Central", + } + if self._auth_config.auth_cache_expiry is not None: + expiry = datetime.now(timezone.utc) + timedelta( + seconds=self._auth_config.auth_cache_expiry + ) + id_token_info["cache_expiry_date_time"] = expiry.isoformat() + return id_token_info + # --- Incoming message handlers (CS → CSMS) --- @on(Action.boot_notification) async def on_boot_notification(self, charging_station, reason, **kwargs): logger.info("Received %s", Action.boot_notification) + idx = self._boot_index[0] + status = self._boot_sequence[min(idx, len(self._boot_sequence) - 1)] + self._boot_index[0] = idx + 1 return ocpp.v201.call_result.BootNotification( current_time=datetime.now(timezone.utc).isoformat(), interval=DEFAULT_HEARTBEAT_INTERVAL, - status=self._boot_status, + status=status, ) @on(Action.heartbeat) @@ -203,10 +263,12 @@ class ChargePoint(ocpp.v201.ChargePoint): raise InternalError(description="Simulated network failure") token_id = id_token.get("id_token", "") - status = self._resolve_auth_status(token_id) + id_token_info = self._build_id_token_info(token_id) - logger.info("Authorization status for %s: %s", token_id, status) - return ocpp.v201.call_result.Authorize(id_token_info={"status": status}) + logger.info( + "Authorization status for %s: %s", token_id, id_token_info["status"] + ) + return ocpp.v201.call_result.Authorize(id_token_info=id_token_info) @on(Action.transaction_event) async def on_transaction_event( @@ -222,15 +284,24 @@ class ChargePoint(ocpp.v201.ChargePoint): case TransactionEventEnumType.started: logger.info("Received %s Started", Action.transaction_event) + transaction_id = transaction_info.get("transaction_id", "") + evse_id = kwargs.get("evse", {}).get("id", 0) + if transaction_id: + self._active_transactions[transaction_id] = evse_id + else: + logger.warning("TransactionEvent.Started with empty transaction_id") + id_token = kwargs.get("id_token", {}) token_id = id_token.get("id_token", "") - status = self._resolve_auth_status(token_id) + id_token_info = self._build_id_token_info(token_id) logger.info( - "Transaction start auth status for %s: %s", token_id, status + "Transaction start auth status for %s: %s", + token_id, + id_token_info["status"], ) return ocpp.v201.call_result.TransactionEvent( - id_token_info={"status": status} + id_token_info=id_token_info ) case TransactionEventEnumType.updated: logger.info("Received %s Updated", Action.transaction_event) @@ -239,6 +310,8 @@ class ChargePoint(ocpp.v201.ChargePoint): ) case TransactionEventEnumType.ended: logger.info("Received %s Ended", Action.transaction_event) + transaction_id = transaction_info.get("transaction_id", "") + self._active_transactions.pop(transaction_id, None) return ocpp.v201.call_result.TransactionEvent() case _: logger.warning("Unknown transaction event type: %s", event_type) @@ -335,20 +408,25 @@ class ChargePoint(ocpp.v201.ChargePoint): ) async def _send_get_variables(self): - request = ocpp.v201.call.GetVariables( - get_variable_data=[ + data = ( + self._get_variables_data + if self._get_variables_data is not None + else [ { "component": {"name": "ChargingStation"}, "variable": {"name": "AvailabilityState"}, } ] ) + request = ocpp.v201.call.GetVariables(get_variable_data=data) await self.call(request, suppress=False) logger.info("%s response received", Action.get_variables) async def _send_set_variables(self): - request = ocpp.v201.call.SetVariables( - set_variable_data=[ + data = ( + self._set_variables_data + if self._set_variables_data is not None + else [ { "component": {"name": "OCPPCommCtrlr"}, "variable": {"name": "HeartbeatInterval"}, @@ -356,6 +434,7 @@ class ChargePoint(ocpp.v201.ChargePoint): } ] ) + request = ocpp.v201.call.SetVariables(set_variable_data=data) await self.call(request, suppress=False) logger.info("%s response received", Action.set_variables) @@ -369,14 +448,16 @@ class ChargePoint(ocpp.v201.ChargePoint): logger.info("%s response received", Action.request_start_transaction) async def _send_request_stop_transaction(self): - request = ocpp.v201.call.RequestStopTransaction( - transaction_id="test_transaction_123" - ) + transaction_id = next(iter(self._active_transactions), "") + if not transaction_id: + logger.warning("No active transaction found, using fallback ID") + transaction_id = "test_transaction_123" + request = ocpp.v201.call.RequestStopTransaction(transaction_id=transaction_id) await self.call(request, suppress=False) logger.info("%s response received", Action.request_stop_transaction) async def _send_reset(self): - request = ocpp.v201.call.Reset(type=ResetEnumType.immediate) + request = ocpp.v201.call.Reset(type=self._reset_type) await self._call_and_log(request, Action.reset, ResetStatusEnumType.accepted) async def _send_unlock_connector(self): @@ -387,7 +468,7 @@ class ChargePoint(ocpp.v201.ChargePoint): async def _send_change_availability(self): request = ocpp.v201.call.ChangeAvailability( - operational_status=OperationalStatusEnumType.operative + operational_status=self._availability_status ) await self._call_and_log( request, @@ -397,7 +478,7 @@ class ChargePoint(ocpp.v201.ChargePoint): async def _send_trigger_message(self): request = ocpp.v201.call.TriggerMessage( - requested_message=MessageTriggerEnumType.status_notification + requested_message=self._trigger_message_type ) await self._call_and_log( request, Action.trigger_message, TriggerMessageStatusEnumType.accepted @@ -473,8 +554,12 @@ class ChargePoint(ocpp.v201.ChargePoint): await self._call_and_log(request, Action.get_log, LogStatusEnumType.accepted) async def _send_get_transaction_status(self): + transaction_id = next(iter(self._active_transactions), "") + if not transaction_id: + logger.warning("No active transaction found, using fallback ID") + transaction_id = "test_transaction_123" request = ocpp.v201.call.GetTransactionStatus( - transaction_id="test_transaction_123", + transaction_id=transaction_id, ) response = await self.call(request, suppress=False) logger.info( @@ -612,10 +697,17 @@ class ChargePoint(ocpp.v201.ChargePoint): except ConnectionClosed: self.handle_connection_closed() + async def send_commands(self, commands: list[tuple[Action, float]]) -> None: + for command_name, delay in commands: + await asyncio.sleep(delay) + await self._send_command(command_name) + def handle_connection_closed(self): logger.info("ChargePoint %s closed connection", self.id) if self._command_timer: self._command_timer.cancel() + if self._commands_task: + self._commands_task.cancel() self._charge_points.discard(self) logger.debug("Connected ChargePoint(s): %d", len(self._charge_points)) @@ -646,12 +738,22 @@ async def on_connect( cp = ChargePoint( websocket, auth_config=config.auth_config, - boot_status=config.boot_status, + boot_sequence=config.boot_sequence, + boot_index=config.boot_index, total_cost=config.total_cost, + trigger_message_type=config.trigger_message_type, + reset_type=config.reset_type, + availability_status=config.availability_status, charge_points=charge_points, + set_variables_data=config.set_variables_data, + get_variables_data=config.get_variables_data, ) if config.command_name: await cp.send_command(config.command_name, config.delay, config.period) + elif config.commands: + # send_commands() begins with asyncio.sleep(delay) which yields to + # cp.start() below. All delays are validated > 0 by _parse_commands. + cp._commands_task = asyncio.create_task(cp.send_commands(config.commands)) try: await cp.start() @@ -671,9 +773,92 @@ def check_positive_number(value): return value +def _parse_commands(commands_str: str) -> list[tuple[Action, float]]: + result: list[tuple[Action, float]] = [] + for entry in commands_str.split(","): + entry = entry.strip() + if not entry: + continue + if ":" not in entry: + raise argparse.ArgumentTypeError( + f"Invalid command entry '{entry}': expected 'CMD:DELAY' format" + ) + cmd_str, delay_str = entry.split(":", 1) + try: + cmd = Action(cmd_str.strip()) + except ValueError: + raise argparse.ArgumentTypeError( + f"Unknown action: '{cmd_str.strip()}'" + ) from None + try: + delay = float(delay_str.strip()) + except ValueError: + raise argparse.ArgumentTypeError( + f"Invalid delay '{delay_str.strip()}': must be a number" + ) from None + if not math.isfinite(delay) or delay <= 0: + raise argparse.ArgumentTypeError( + f"Delay must be a finite positive number, got {delay}" + ) + result.append((cmd, delay)) + return result + + +def _parse_set_variable_specs(specs_str: str) -> list[dict]: + result = [] + for entry in specs_str.split(","): + entry = entry.strip() + if not entry: + continue + if "=" not in entry or "." not in entry.split("=")[0]: + raise argparse.ArgumentTypeError( + f"Invalid variable spec '{entry}': expected 'Component.Variable=Value'" + ) + component_var, value = entry.split("=", 1) + component, variable = component_var.strip().split(".", 1) + result.append( + { + "component": {"name": component.strip()}, + "variable": {"name": variable.strip()}, + "attribute_value": value.strip(), + } + ) + return result + + +def _parse_get_variable_specs(specs_str: str) -> list[dict]: + result = [] + for entry in specs_str.split(","): + entry = entry.strip() + if not entry: + continue + if "." not in entry: + raise argparse.ArgumentTypeError( + f"Invalid variable spec '{entry}': expected 'Component.Variable'" + ) + component, variable = entry.split(".", 1) + result.append( + { + "component": {"name": component.strip()}, + "variable": {"name": variable.strip()}, + } + ) + return result + + async def main(): parser = argparse.ArgumentParser(description="OCPP2 Server") - parser.add_argument("-c", "--command", type=Action, help="command name") + command_group = parser.add_mutually_exclusive_group() + command_group.add_argument("-c", "--command", type=Action, help="command name") + command_group.add_argument( + "--commands", + type=str, + default=None, + help=( + 'comma-separated command sequence: "CMD1:DELAY1,CMD2:DELAY2,..."' + ' (e.g., "RequestStartTransaction:5,RequestStopTransaction:30")' + ), + ) group = parser.add_mutually_exclusive_group() group.add_argument( "-d", @@ -703,11 +888,24 @@ async def main(): ) # Charging configuration - parser.add_argument( + boot_group = parser.add_mutually_exclusive_group() + boot_group.add_argument( "--boot-status", type=RegistrationStatusEnumType, - default=RegistrationStatusEnumType.accepted, - help="boot notification response status (default: accepted)", + default=None, + help=( + "boot notification response status" + " (Accepted, Pending, Rejected; default: Accepted)" + ), + ) + boot_group.add_argument( + "--boot-status-sequence", + type=str, + default=None, + help=( + "comma-separated boot notification status sequence" + " (e.g. Pending,Pending,Accepted)" + ), ) parser.add_argument( "--total-cost", @@ -716,11 +914,30 @@ async def main(): help=f"TransactionEvent.Updated total cost (default: {DEFAULT_TOTAL_COST})", ) + parser.add_argument( + "--trigger-message", + type=MessageTriggerEnumType, + default=MessageTriggerEnumType.status_notification, + help="TriggerMessage requested_message type (default: StatusNotification)", + ) + parser.add_argument( + "--reset-type", + type=ResetEnumType, + default=ResetEnumType.immediate, + help="Reset type: Immediate, OnIdle (default: Immediate)", + ) + parser.add_argument( + "--availability-status", + type=OperationalStatusEnumType, + default=OperationalStatusEnumType.operative, + help="ChangeAvailability status: Operative, Inoperative (default: Operative)", + ) + # Auth configuration parser.add_argument( "--auth-mode", type=str, - choices=["normal", "offline", "whitelist", "blacklist", "rate_limit"], + choices=["normal", "whitelist", "blacklist", "rate_limit"], default="normal", help="Authorization mode (default: normal)", ) @@ -743,18 +960,90 @@ async def main(): action="store_true", help="Simulate offline/network failure mode", ) + parser.add_argument( + "--auth-group-id", + type=str, + default=None, + help="groupIdToken id_token value to include in Authorize response", + ) + parser.add_argument( + "--auth-cache-expiry", + type=check_positive_number, + default=None, + help="cacheExpiryDateTime offset in seconds from now (e.g., 3600)", + ) + + parser.add_argument( + "--set-variables", + type=str, + default=None, + help=( + 'SetVariables data: "Component.Variable=Value,..." ' + '(e.g., "OCPPCommCtrlr.HeartbeatInterval=30"). ' + "Values must not contain commas." + ), + ) + parser.add_argument( + "--get-variables", + type=str, + default=None, + help=( + 'GetVariables data: "Component.Variable,..." ' + '(e.g., "ChargingStation.AvailabilityState")' + ), + ) args, _ = parser.parse_known_args() group.required = args.command is not None args = parser.parse_args() + try: + parsed_commands = _parse_commands(args.commands) if args.commands else None + if parsed_commands is not None and not parsed_commands: + parser.error("--commands must contain at least one CMD:DELAY entry") + parsed_set_variables = ( + _parse_set_variable_specs(args.set_variables) + if args.set_variables + else None + ) + parsed_get_variables = ( + _parse_get_variable_specs(args.get_variables) + if args.get_variables + else None + ) + except argparse.ArgumentTypeError as e: + parser.error(str(e)) + + if args.boot_status_sequence is not None: + boot_sequence_items: list[RegistrationStatusEnumType] = [] + for raw_value in args.boot_status_sequence.split(","): + value = raw_value.strip() + try: + status = RegistrationStatusEnumType(value) + except ValueError: + valid = ", ".join(e.value for e in RegistrationStatusEnumType) + parser.error( + f"invalid value for --boot-status-sequence: {value!r}." + f" Valid values are: {valid}" + ) + boot_sequence_items.append(status) + boot_sequence = tuple(boot_sequence_items) + if not boot_sequence: + parser.error("--boot-status-sequence must contain at least one status") + elif args.boot_status is not None: + boot_sequence = (args.boot_status,) + else: + boot_sequence = (RegistrationStatusEnumType.accepted,) + auth_config = AuthConfig( mode=AuthMode(args.auth_mode), whitelist=tuple(args.whitelist), blacklist=tuple(args.blacklist), offline=args.offline, default_status=AuthorizationStatusEnumType.accepted, + auth_group_id=args.auth_group_id, + auth_cache_expiry=args.auth_cache_expiry, ) config = ServerConfig( @@ -762,9 +1051,16 @@ async def main(): delay=args.delay, period=args.period, auth_config=auth_config, - boot_status=args.boot_status, + boot_sequence=boot_sequence, + boot_index=[0], total_cost=args.total_cost, charge_points=set(), + commands=parsed_commands, + trigger_message_type=args.trigger_message, + reset_type=args.reset_type, + availability_status=args.availability_status, + set_variables_data=parsed_set_variables, + get_variables_data=parsed_get_variables, ) logger.info( diff --git a/tests/ocpp-server/test_server.py b/tests/ocpp-server/test_server.py index 907de6f1..ecf8c177 100644 --- a/tests/ocpp-server/test_server.py +++ b/tests/ocpp-server/test_server.py @@ -4,7 +4,7 @@ import argparse import contextlib import logging import signal -from typing import ClassVar +from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch import ocpp.v201.call @@ -50,6 +50,9 @@ from server import ( AuthMode, ChargePoint, ServerConfig, + _parse_commands, + _parse_get_variable_specs, + _parse_set_variable_specs, _random_request_id, check_positive_number, main, @@ -191,16 +194,25 @@ def main_mocks(): def _patch_main(mock_loop, mock_server, mock_event, extra_patches=None): args = argparse.Namespace( command=None, + commands=None, delay=None, period=None, host="127.0.0.1", port=9000, - boot_status=RegistrationStatusEnumType.accepted, + boot_status=None, + boot_status_sequence=None, total_cost=10.0, auth_mode="normal", whitelist=["valid_token"], blacklist=["blocked_token"], offline=False, + auth_group_id=None, + auth_cache_expiry=None, + trigger_message=MessageTriggerEnumType.status_notification, + reset_type=ResetEnumType.immediate, + availability_status=OperationalStatusEnumType.operative, + set_variables=None, + get_variables=None, ) mock_serve_cm = AsyncMock() mock_serve_cm.__aenter__ = AsyncMock(return_value=mock_server) @@ -308,6 +320,8 @@ class TestResolveAuthStatus: status = blacklist_charge_point._resolve_auth_status("") assert status == AuthorizationStatusEnumType.accepted + +class TestHandlerCoverage: """Tests verifying all expected OCPP 2.0.1 handlers and commands are implemented.""" EXPECTED_INCOMING_HANDLERS: ClassVar[list[str]] = [ @@ -390,14 +404,15 @@ class TestChargePointDefaultConfig: def test_command_timer_initially_none(self, charge_point): assert charge_point._command_timer is None - def test_default_boot_status(self, charge_point): - assert charge_point._boot_status == RegistrationStatusEnumType.accepted + def test_default_boot_sequence(self, charge_point): + assert charge_point._boot_sequence == (RegistrationStatusEnumType.accepted,) - def test_custom_boot_status(self, mock_connection): + def test_custom_boot_sequence(self, mock_connection): cp = ChargePoint( - mock_connection, boot_status=RegistrationStatusEnumType.rejected + mock_connection, + boot_sequence=(RegistrationStatusEnumType.rejected,), ) - assert cp._boot_status == RegistrationStatusEnumType.rejected + assert cp._boot_sequence == (RegistrationStatusEnumType.rejected,) def test_default_total_cost(self, charge_point): assert charge_point._total_cost == DEFAULT_TOTAL_COST @@ -406,6 +421,10 @@ class TestChargePointDefaultConfig: cp = ChargePoint(mock_connection, total_cost=25.50) assert cp._total_cost == 25.50 + def test_empty_boot_sequence_raises(self, mock_connection): + with pytest.raises(ValueError, match="at least one status"): + ChargePoint(mock_connection, boot_sequence=()) + # --- Async handler tests --- @@ -425,7 +444,8 @@ class TestBootNotificationHandler: async def test_configurable_boot_status(self, mock_connection): cp = ChargePoint( - mock_connection, boot_status=RegistrationStatusEnumType.rejected + mock_connection, + boot_sequence=(RegistrationStatusEnumType.rejected,), ) response = await cp.on_boot_notification( charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, @@ -435,7 +455,8 @@ class TestBootNotificationHandler: async def test_pending_boot_status(self, mock_connection): cp = ChargePoint( - mock_connection, boot_status=RegistrationStatusEnumType.pending + mock_connection, + boot_sequence=(RegistrationStatusEnumType.pending,), ) response = await cp.on_boot_notification( charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, @@ -443,6 +464,87 @@ class TestBootNotificationHandler: ) assert response.status == RegistrationStatusEnumType.pending + async def test_boot_notification_single_status_compat(self, mock_connection): + cp = ChargePoint( + mock_connection, + boot_sequence=(RegistrationStatusEnumType.accepted,), + ) + for _ in range(3): + response = await cp.on_boot_notification( + charging_station={ + "model": TEST_MODEL, + "vendor_name": TEST_VENDOR_NAME, + }, + reason="PowerUp", + ) + assert response.status == RegistrationStatusEnumType.accepted + + async def test_boot_notification_sequence_iterates(self, mock_connection): + cp = ChargePoint( + mock_connection, + boot_sequence=( + RegistrationStatusEnumType.pending, + RegistrationStatusEnumType.pending, + RegistrationStatusEnumType.accepted, + ), + ) + r1 = await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + assert r1.status == RegistrationStatusEnumType.pending + + r2 = await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + assert r2.status == RegistrationStatusEnumType.pending + + r3 = await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + assert r3.status == RegistrationStatusEnumType.accepted + + async def test_boot_notification_sequence_clamps_to_last(self, mock_connection): + cp = ChargePoint( + mock_connection, + boot_sequence=( + RegistrationStatusEnumType.pending, + RegistrationStatusEnumType.accepted, + ), + ) + await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + r3 = await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + r4 = await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + assert r3.status == RegistrationStatusEnumType.accepted + assert r4.status == RegistrationStatusEnumType.accepted + + async def test_boot_status_sequence_backwards_compat(self, mock_connection): + cp = ChargePoint( + mock_connection, + boot_sequence=(RegistrationStatusEnumType.accepted,), + ) + response = await cp.on_boot_notification( + charging_station={"model": TEST_MODEL, "vendor_name": TEST_VENDOR_NAME}, + reason="PowerUp", + ) + assert response.status == RegistrationStatusEnumType.accepted + assert response.interval == DEFAULT_HEARTBEAT_INTERVAL + class TestHeartbeatHandler: """Tests for the Heartbeat incoming handler.""" @@ -517,6 +619,54 @@ class TestAuthorizeHandler: ) +class TestRicherAuthorizeResponse: + """Tests for richer Authorize response with groupIdToken and cacheExpiry.""" + + async def test_authorize_includes_group_id_token(self, mock_connection): + cp = ChargePoint( + mock_connection, + auth_config=AuthConfig( + mode=AuthMode.normal, + whitelist=(), + blacklist=(), + offline=False, + default_status=AuthorizationStatusEnumType.accepted, + auth_group_id="MyGroup", + ), + ) + result = await cp.on_authorize( + id_token={"id_token": "test_token", "type": "ISO14443"} + ) + assert result.id_token_info["group_id_token"]["id_token"] == "MyGroup" # noqa: S105 + assert result.id_token_info["group_id_token"]["type"] == "Central" + + async def test_authorize_includes_cache_expiry(self, mock_connection): + cp = ChargePoint( + mock_connection, + auth_config=AuthConfig( + mode=AuthMode.normal, + whitelist=(), + blacklist=(), + offline=False, + default_status=AuthorizationStatusEnumType.accepted, + auth_cache_expiry=3600, + ), + ) + result = await cp.on_authorize( + id_token={"id_token": "test_token", "type": "ISO14443"} + ) + assert "cache_expiry_date_time" in result.id_token_info + expiry = result.id_token_info["cache_expiry_date_time"] + assert isinstance(expiry, str) and "T" in expiry + + async def test_authorize_no_enrichment_by_default(self, charge_point): + result = await charge_point.on_authorize( + id_token={"id_token": "test_token", "type": "ISO14443"} + ) + assert "group_id_token" not in result.id_token_info + assert "cache_expiry_date_time" not in result.id_token_info + + class TestTransactionEventHandler: """Tests for the TransactionEvent incoming handler.""" @@ -574,6 +724,8 @@ class TestTransactionEventHandler: assert response.total_cost is None assert response.id_token_info is None + +class TestDataTransferHandler: """Tests for the DataTransfer incoming handler.""" async def test_returns_accepted(self, charge_point): @@ -581,6 +733,81 @@ class TestTransactionEventHandler: assert response.status == DataTransferStatusEnumType.accepted +class TestTransactionTracking: + """Tests for active transaction tracking in ChargePoint.""" + + async def test_transaction_event_started_stores_transaction(self, charge_point): + await charge_point.on_transaction_event( + event_type=TransactionEventEnumType.started, + timestamp=TEST_TIMESTAMP, + trigger_reason="Authorized", + seq_no=0, + transaction_info={"transaction_id": TEST_TRANSACTION_ID}, + id_token={"id_token": TEST_TOKEN, "type": "ISO14443"}, + evse={"id": TEST_EVSE_ID}, + ) + assert TEST_TRANSACTION_ID in charge_point._active_transactions + assert charge_point._active_transactions[TEST_TRANSACTION_ID] == TEST_EVSE_ID + + async def test_transaction_event_ended_removes_transaction(self, charge_point): + charge_point._active_transactions[TEST_TRANSACTION_ID] = TEST_EVSE_ID + await charge_point.on_transaction_event( + event_type=TransactionEventEnumType.ended, + timestamp=TEST_TIMESTAMP, + trigger_reason="StopAuthorized", + seq_no=2, + transaction_info={"transaction_id": TEST_TRANSACTION_ID}, + ) + assert TEST_TRANSACTION_ID not in charge_point._active_transactions + + async def test_send_request_stop_uses_active_transaction_id( + self, command_charge_point + ): + command_charge_point._active_transactions["real-txn-999"] = TEST_EVSE_ID + command_charge_point.call.return_value = ( + ocpp.v201.call_result.RequestStopTransaction( + status=RequestStartStopStatusEnumType.accepted + ) + ) + await command_charge_point._send_request_stop_transaction() + request = command_charge_point.call.call_args[0][0] + assert request.transaction_id == "real-txn-999" + + async def test_send_get_transaction_status_uses_active_transaction_id( + self, command_charge_point + ): + command_charge_point._active_transactions["real-txn-999"] = TEST_EVSE_ID + command_charge_point.call.return_value = ( + ocpp.v201.call_result.GetTransactionStatus(messages_in_queue=False) + ) + await command_charge_point._send_get_transaction_status() + request = command_charge_point.call.call_args[0][0] + assert request.transaction_id == "real-txn-999" + + async def test_send_request_stop_fallback_when_no_transaction( + self, command_charge_point + ): + command_charge_point.call.return_value = ( + ocpp.v201.call_result.RequestStopTransaction( + status=RequestStartStopStatusEnumType.accepted + ) + ) + await command_charge_point._send_request_stop_transaction() + request = command_charge_point.call.call_args[0][0] + assert request.transaction_id == "test_transaction_123" + + async def test_empty_transaction_id_not_stored(self, charge_point): + await charge_point.on_transaction_event( + event_type=TransactionEventEnumType.started, + timestamp=TEST_TIMESTAMP, + trigger_reason="Authorized", + seq_no=0, + transaction_info={"transaction_id": ""}, + id_token={"id_token": TEST_TOKEN, "type": "ISO14443"}, + ) + assert "" not in charge_point._active_transactions + + class TestCertificateHandlers: """Tests for certificate-related incoming handlers.""" @@ -1040,8 +1267,7 @@ class TestOnConnect: @staticmethod def _make_config(**overrides): - """Create a minimal ServerConfig for testing.""" - defaults = { + defaults: dict[str, Any] = { "command_name": None, "delay": None, "period": None, @@ -1052,7 +1278,7 @@ class TestOnConnect: offline=False, default_status=AuthorizationStatusEnumType.accepted, ), - "boot_status": RegistrationStatusEnumType.accepted, + "boot_sequence": (RegistrationStatusEnumType.accepted,), "total_cost": 0.0, "charge_points": set(), } @@ -1126,6 +1352,12 @@ class TestHandleConnectionClosed: charge_point.handle_connection_closed() mock_timer.cancel.assert_called_once() + def test_commands_task_cancelled_on_close(self, charge_point): + mock_task = MagicMock() + charge_point._commands_task = mock_task + charge_point.handle_connection_closed() + mock_task.cancel.assert_called_once() + def test_timer_none_no_error(self, charge_point): charge_point._command_timer = None charge_point.handle_connection_closed() @@ -1271,3 +1503,181 @@ class TestMainGracefulShutdown: assert callable(sigint_handler) sigint_handler(signal.SIGINT.value, None) mock_loop.call_soon_threadsafe.assert_called_once() + + +class TestTriggerMessageType: + """Tests for configurable TriggerMessage type.""" + + async def test_send_trigger_message_default_status_notification( + self, command_charge_point + ): + command_charge_point.call = AsyncMock( + return_value=ocpp.v201.call_result.TriggerMessage( + status=TriggerMessageStatusEnumType.accepted + ) + ) + await command_charge_point._send_trigger_message() + call_args = command_charge_point.call.call_args + request = call_args[0][0] + assert request.requested_message == MessageTriggerEnumType.status_notification + + async def test_send_trigger_message_custom_boot_notification(self, mock_connection): + cp = ChargePoint( + mock_connection, + trigger_message_type=MessageTriggerEnumType.boot_notification, + ) + cp.call = AsyncMock( + return_value=ocpp.v201.call_result.TriggerMessage( + status=TriggerMessageStatusEnumType.accepted + ) + ) + await cp._send_trigger_message() + call_args = cp.call.call_args + request = call_args[0][0] + assert request.requested_message == MessageTriggerEnumType.boot_notification + + +class TestResetType: + """Tests for configurable Reset type.""" + + async def test_send_reset_default_immediate(self, command_charge_point): + command_charge_point.call = AsyncMock( + return_value=ocpp.v201.call_result.Reset( + status=ResetStatusEnumType.accepted + ) + ) + await command_charge_point._send_reset() + call_args = command_charge_point.call.call_args + request = call_args[0][0] + assert request.type == ResetEnumType.immediate + + async def test_send_reset_on_idle(self, mock_connection): + cp = ChargePoint(mock_connection, reset_type=ResetEnumType.on_idle) + cp.call = AsyncMock( + return_value=ocpp.v201.call_result.Reset( + status=ResetStatusEnumType.accepted + ) + ) + await cp._send_reset() + call_args = cp.call.call_args + request = call_args[0][0] + assert request.type == ResetEnumType.on_idle + + +class TestChangeAvailabilityStatus: + """Tests for configurable ChangeAvailability status.""" + + async def test_send_change_availability_default_operative( + self, command_charge_point + ): + command_charge_point.call = AsyncMock( + return_value=ocpp.v201.call_result.ChangeAvailability( + status=ChangeAvailabilityStatusEnumType.accepted + ) + ) + await command_charge_point._send_change_availability() + call_args = command_charge_point.call.call_args + request = call_args[0][0] + assert request.operational_status == OperationalStatusEnumType.operative + + async def test_send_change_availability_inoperative(self, mock_connection): + cp = ChargePoint( + mock_connection, + availability_status=OperationalStatusEnumType.inoperative, + ) + cp.call = AsyncMock( + return_value=ocpp.v201.call_result.ChangeAvailability( + status=ChangeAvailabilityStatusEnumType.accepted + ) + ) + await cp._send_change_availability() + call_args = cp.call.call_args + request = call_args[0][0] + assert request.operational_status == OperationalStatusEnumType.inoperative + + +class TestCommandSequencing: + """Tests for command sequencing (send_commands and _parse_commands).""" + + async def test_send_commands_executes_in_order(self, mock_connection): + cp = ChargePoint(mock_connection) + mock_send = AsyncMock() + with patch.object(cp, "_send_command", mock_send): + commands = [(Action.heartbeat, 0.001), (Action.clear_cache, 0.001)] + await cp.send_commands(commands) + assert mock_send.call_count == 2 + assert mock_send.call_args_list[0][0][0] == Action.heartbeat + assert mock_send.call_args_list[1][0][0] == Action.clear_cache + + def test_parse_commands_valid(self): + result = _parse_commands("Reset:5,ClearCache:10") + assert result == [(Action.reset, 5.0), (Action.clear_cache, 10.0)] + + def test_parse_commands_invalid_format(self): + with pytest.raises(argparse.ArgumentTypeError, match="expected 'CMD:DELAY'"): + _parse_commands("ResetOnly") + + def test_parse_commands_unknown_action(self): + with pytest.raises(argparse.ArgumentTypeError, match="Unknown action"): + _parse_commands("UnknownAction:5") + + def test_parse_commands_case_sensitive(self): + with pytest.raises(argparse.ArgumentTypeError, match="Unknown action"): + _parse_commands("reset:5") + + def test_parse_commands_infinite_delay(self): + with pytest.raises(argparse.ArgumentTypeError, match="finite positive"): + _parse_commands("Reset:inf") + + def test_parse_commands_nan_delay(self): + with pytest.raises(argparse.ArgumentTypeError, match="finite positive"): + _parse_commands("Reset:nan") + + +class TestMultiVariableCommands: + """Tests for multi-variable SetVariables/GetVariables CLI support.""" + + def test_parse_set_variable_specs_valid(self): + result = _parse_set_variable_specs( + "OCPPCommCtrlr.HeartbeatInterval=30,TxCtrlr.EVConnectionTimeOut=60" + ) + assert len(result) == 2 + assert result[0]["component"]["name"] == "OCPPCommCtrlr" + assert result[0]["variable"]["name"] == "HeartbeatInterval" + assert result[0]["attribute_value"] == "30" + assert result[1]["component"]["name"] == "TxCtrlr" + assert result[1]["variable"]["name"] == "EVConnectionTimeOut" + assert result[1]["attribute_value"] == "60" + + def test_parse_get_variable_specs_valid(self): + result = _parse_get_variable_specs( + "ChargingStation.AvailabilityState,OCPPCommCtrlr.HeartbeatInterval" + ) + assert len(result) == 2 + assert result[0]["component"]["name"] == "ChargingStation" + assert result[0]["variable"]["name"] == "AvailabilityState" + assert result[1]["component"]["name"] == "OCPPCommCtrlr" + assert result[1]["variable"]["name"] == "HeartbeatInterval" + + def test_parse_set_variable_specs_invalid_no_dot(self): + with pytest.raises( + argparse.ArgumentTypeError, + match=r"expected 'Component\.Variable=Value'", + ): + _parse_set_variable_specs("NoComponentVariable=30") + + async def test_send_set_variables_uses_custom_data(self, command_charge_point): + custom_data = [ + { + "component": {"name": "TestComp"}, + "variable": {"name": "TestVar"}, + "attribute_value": "42", + } + ] + command_charge_point._set_variables_data = custom_data + command_charge_point.call = AsyncMock( + return_value=MagicMock(set_variable_result=[]) + ) + await command_charge_point._send_set_variables() + call_args = command_charge_point.call.call_args[0][0] + assert call_args.set_variable_data == custom_data diff --git a/tests/ocpp2-e2e-test-plan.md b/tests/ocpp2-e2e-test-plan.md deleted file mode 100644 index 8b273b97..00000000 --- a/tests/ocpp2-e2e-test-plan.md +++ /dev/null @@ -1,1600 +0,0 @@ -# OCPP 2.0.1 End-to-End Test Plan - -Comprehensive test scenarios for validating the e-mobility charging station simulator's OCPP 2.0.1 stack. -Executed via MCP tools against the Python mock OCPP server (`tests/ocpp-server/server.py`). - -## Conventions - -- **Mock server**: `tests/ocpp-server/server.py` — managed by the tester (start/stop/restart with options) -- **Simulator**: Already running with 1 OCPP 2.0.1 station — **not managed** by the tester -- **Station template**: `keba-ocpp2.station-template.json` -- **Station ID**: `CS-KEBA-OCPP2-00001` -- **Station hashId**: `e9041c294a82a2d6aa194a801c3ba39d6b24d1cb16c0a0b3db4e37c9fe4e80cbb0808843f66a320b3f58df0288c8e98a` -- **EVSE ID**: 1, **Connector ID**: 1 -- **Default supervision URL**: `ws://localhost:9000` -- **Mock server startup**: `cd tests/ocpp-server && poetry run python server.py [OPTIONS]` -- **Reconnect**: Station auto-reconnects when server restarts (~30s). Wait for `BootNotification` Accepted before proceeding. - -### Server Lifecycle Pattern - -``` -1. Kill previous server instance (Ctrl+C or SIGTERM) -2. Start server with new options: poetry run python server.py [OPTIONS] -3. Wait for station reconnection (check via listChargingStations → bootNotificationResponse.status) -4. Execute test cases -5. Verify results (logs, station state, MCP responses) -``` - -### Verification Methods - -- **MCP state**: `listChargingStations` → inspect station data, connector status, EVSE status -- **MCP logs**: `readCombinedLog` → check OCPP message exchange -- **MCP error logs**: `readErrorLog` → check for unexpected errors -- **MCP response**: Direct tool response (status: success/failure, responsesFailed) - -### IMPORTANT: Enum Casing - -The mock server uses Python `ocpp` library enums which are **Title-Case**: - -- `--boot-status Accepted` (NOT `accepted`) -- `--boot-status Rejected` (NOT `rejected`) -- `--boot-status Pending` (NOT `pending`) - -### IMPORTANT: Do NOT Touch the Simulator - -The OCPP mock server is the only component managed during testing. -**NEVER** call `stopChargingStation`, `startChargingStation`, `addChargingStations`, or `deleteChargingStations`. -The simulator auto-reconnects when the mock server restarts (~30s backoff). - -### Pass/Fail Criteria - -A test case **passes** when ALL of the following are true: - -1. MCP tool response contains `"status": "success"` (no `responsesFailed`) -2. `readCombinedLog` contains the expected OCPP message names in the correct order -3. `listChargingStations` shows the expected station/connector state after the test -4. `readErrorLog` contains no unexpected errors caused by the test - -A test case **fails** if any expected result is not met. - -### Timestamp Convention - -All `` placeholders must be replaced with the current UTC timestamp at execution time, -e.g., `2026-03-24T17:30:00.000Z`. The MCP tools may auto-generate timestamps where applicable. - ---- - -## Test Group 1 — Provisioning: Boot Accepted (Normal Flow) - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -### TC-B01: Cold Boot — Accepted - -**OCPP Use Case**: B01 — Cold Boot Charging Station - -**Pre-conditions**: Server running with `--boot-status accepted` - -**Steps**: - -1. `stopChargingStation` (hashIds: [station]) -2. Wait 5s -3. `startChargingStation` (hashIds: [station]) -4. Wait 10s for boot cycle -5. `listChargingStations` - -**Expected Results**: - -- `bootNotificationResponse.status` = `Accepted` -- `bootNotificationResponse.interval` = 60 -- `bootNotificationResponse.currentTime` is a valid ISO 8601 timestamp -- EVSE 1 / Connector 1 status = `Available` -- `readCombinedLog` shows: BootNotificationRequest sent, BootNotificationResponse received with Accepted -- `readCombinedLog` shows: StatusNotification sent for connector with Available status - ---- - -## Test Group 2 — Provisioning: Boot Rejected - -### Server Setup - -```bash -poetry run python server.py --boot-status rejected -``` - -Wait for station reconnection attempt. - -### TC-B03: Cold Boot — Rejected - -**OCPP Use Case**: B03 — Cold Boot Charging Station - Rejected - -**Pre-conditions**: Server running with `--boot-status rejected` - -**Steps**: - -1. `stopChargingStation` (hashIds: [station]) -2. Wait 5s -3. `startChargingStation` (hashIds: [station]) -4. Wait 15s (station will retry) -5. `listChargingStations` -6. `readCombinedLog` - -**Expected Results**: - -- `bootNotificationResponse.status` = `Rejected` -- Station keeps retrying BootNotification at configured interval -- No StatusNotification sent (station not accepted) -- Log shows repeated BootNotification attempts - ---- - -## Test Group 3 — Provisioning: Boot Pending - -### Server Setup - -```bash -poetry run python server.py --boot-status pending -``` - -### TC-B02: Cold Boot — Pending - -**OCPP Use Case**: B02 — Cold Boot Charging Station - Pending - -**Pre-conditions**: Server running with `--boot-status pending` - -**Steps**: - -1. `stopChargingStation` (hashIds: [station]) -2. Wait 5s -3. `startChargingStation` (hashIds: [station]) -4. Wait 15s -5. `listChargingStations` -6. `readCombinedLog` - -**Expected Results**: - -- `bootNotificationResponse.status` = `Pending` -- Station retries BootNotification at the interval specified in response -- Station should not send messages other than BootNotification while Pending -- Log shows repeated BootNotification attempts with Pending responses - ---- - -## Test Group 4 — Core Messaging (Normal Mode) - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -Wait for station reconnection and Accepted boot. - -### TC-G02: Heartbeat - -**OCPP Use Case**: G02 — Heartbeat - -**Steps**: - -1. `heartbeat` (hashIds: [station]) -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows HeartbeatRequest sent -- Log shows HeartbeatResponse received with `currentTime` timestamp - -### TC-G01: StatusNotification - -**OCPP Use Case**: G01 — Status Notification - -**Steps**: - -1. `statusNotification` with ocpp20Payload: - ```json - { - "timestamp": "", - "connectorStatus": "Available", - "evseId": 1, - "connectorId": 1 - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows StatusNotificationRequest sent with connector status -- Log shows StatusNotificationResponse received (empty) - -### TC-B06-MCP: BootNotification (Manual Trigger) - -**OCPP Use Case**: B01 — BootNotification - -**Steps**: - -1. `bootNotification` with ocpp20Payload: - ```json - { - "reason": "PowerUp", - "chargingStation": { - "model": "KC-P30-ESS400C2-E0R", - "vendorName": "Keba AG" - } - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows BootNotificationRequest sent -- Response contains status = Accepted, interval = 60 - -### TC-J01: MeterValues (Non-Transaction) - -**OCPP Use Case**: J01 — Sending Meter Values not related to a transaction - -**Steps**: - -1. `meterValues` with ocpp20Payload: - ```json - { - "evseId": 1, - "meterValue": [ - { - "timestamp": "", - "sampledValue": [ - { - "value": 230.0, - "measurand": "Voltage", - "unitOfMeasure": { "unit": "V" } - } - ] - } - ] - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows MeterValuesRequest sent -- Log shows MeterValuesResponse received (empty) - -### TC-P01: DataTransfer (CP → CSMS) - -**OCPP Use Case**: P01 — DataTransfer - -**Steps**: - -1. `dataTransfer` with ocpp20Payload: - ```json - { - "vendorId": "TestVendor", - "messageId": "TestMessage", - "data": "test_payload" - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows DataTransferRequest sent with vendorId -- Log shows DataTransferResponse with status = Accepted - -### TC-A04: SecurityEventNotification - -**OCPP Use Case**: A04 — Security Event Notification - -**Steps**: - -1. `securityEventNotification` with ocpp20Payload: - ```json - { - "type": "FirmwareUpdated", - "timestamp": "" - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows SecurityEventNotificationRequest sent -- Log shows SecurityEventNotificationResponse received - -### TC-L01-FW: FirmwareStatusNotification - -**OCPP Use Case**: L01 — Firmware Status Notification - -**Steps**: - -1. `firmwareStatusNotification` with ocpp20Payload: - ```json - { - "status": "Installed", - "requestId": 1 - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows FirmwareStatusNotificationRequest sent with status Installed - -### TC-N01-LOG: LogStatusNotification - -**OCPP Use Case**: N01 — Log Status Notification - -**Steps**: - -1. `logStatusNotification` with ocpp20Payload: - ```json - { - "status": "Uploaded", - "requestId": 1 - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows LogStatusNotificationRequest sent - -### TC-B07: NotifyReport - -**OCPP Use Case**: B07 — Get Base Report / Notify Report - -**Steps**: - -1. `notifyReport` with ocpp20Payload: - ```json - { - "requestId": 1, - "generatedAt": "", - "seqNo": 0, - "tbc": false - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows NotifyReportRequest sent - -### TC-N02: NotifyCustomerInformation - -**OCPP Use Case**: N02 — Customer Information Notification - -**Steps**: - -1. `notifyCustomerInformation` with ocpp20Payload: - ```json - { - "data": "Customer information payload", - "seqNo": 0, - "generatedAt": "", - "requestId": 1 - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows NotifyCustomerInformationRequest sent - -### TC-M01: Get15118EVCertificate - -**OCPP Use Case**: M01 — Certificate installation EV - -**Steps**: - -1. `get15118EVCertificate` with ocpp20Payload: - ```json - { - "iso15118SchemaVersion": "urn:iso:15118:2:2013:MsgDef", - "action": "Install", - "exiRequest": "bW9ja19leGlfcmVxdWVzdA==" - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows Get15118EVCertificateRequest sent -- Response contains status = Accepted - -### TC-M06: GetCertificateStatus - -**OCPP Use Case**: M06 — Get V2G Charging Station Certificate status - -**Steps**: - -1. `getCertificateStatus` with ocpp20Payload: - ```json - { - "ocspRequestData": { - "hashAlgorithm": "SHA256", - "issuerNameHash": "mock_issuer_name_hash", - "issuerKeyHash": "mock_issuer_key_hash", - "serialNumber": "mock_serial", - "responderURL": "https://ocsp.example.com" - } - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows GetCertificateStatusRequest sent -- Response status = Accepted - -### TC-A03: SignCertificate - -**OCPP Use Case**: A03 — Update Charging Station Certificate initiated by CS - -**Steps**: - -1. `signCertificate` with ocpp20Payload: - ```json - { - "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBkTCB+wIBADBFMQswCQYD\n-----END CERTIFICATE REQUEST-----", - "certificateType": "ChargingStationCertificate" - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = `success` -- Log shows SignCertificateRequest sent -- Response status = Accepted - ---- - -## Test Group 5 — CSMS-Initiated Commands (Server → CS) - -For each command, the server is started with `--command --delay 3`. -The station must be booted and connected first. - -### Server Setup Pattern (per sub-test) - -```bash -# Kill previous server, then: -poetry run python server.py --boot-status accepted --command --delay 5 -``` - -Wait for station boot + 5s delay for command. - -### TC-F06-TRIGGER: TriggerMessage - -**OCPP Use Case**: F06 — Trigger Message - -**Server**: `--command TriggerMessage --delay 5` - -**Steps**: - -1. Start server, wait for boot + command delivery (~15s) -2. `readCombinedLog` - -**Expected Results**: - -- Log shows TriggerMessage received from CSMS requesting StatusNotification -- Log shows TriggerMessageResponse sent with status = Accepted -- Log shows StatusNotificationRequest sent (triggered) - -### TC-C01-CACHE: ClearCache - -**OCPP Use Case**: C11 — Clear Authorization Data in Authorization Cache - -**Server**: `--command ClearCache --delay 5` - -**Steps**: - -1. Start server, wait for boot + command delivery (~15s) -2. `readCombinedLog` - -**Expected Results**: - -- Log shows ClearCache received from CSMS -- Log shows ClearCacheResponse sent with status = Accepted - -### TC-B07-GBR: GetBaseReport (Server-Initiated) - -**OCPP Use Case**: B07 — Get Base Report - -**Server**: `--command GetBaseReport --delay 5` - -**Steps**: - -1. Start server, wait for boot + command delivery (~15s) -2. `readCombinedLog` -3. `listChargingStations` (check if report data available) - -**Expected Results**: - -- Log shows GetBaseReport received requesting FullInventory -- Log shows GetBaseReportResponse sent with status = Accepted -- Log shows one or more NotifyReportRequests sent with device model data - -### TC-B06-GV: GetVariables (Server-Initiated) - -**OCPP Use Case**: B06 — Get Variables - -**Server**: `--command GetVariables --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows GetVariables received for ChargingStation.AvailabilityState -- Log shows GetVariablesResponse sent with variable result data - -### TC-B05-SV: SetVariables (Server-Initiated) - -**OCPP Use Case**: B05 — Set Variables - -**Server**: `--command SetVariables --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` -3. `listChargingStations` (check HeartbeatInterval changed to 30) - -**Expected Results**: - -- Log shows SetVariables received for HeartbeatInterval = 30 -- Log shows SetVariablesResponse sent with result status -- Station configuration updated (HeartbeatInterval = 30) - -### TC-G03: ChangeAvailability (Server-Initiated) - -**OCPP Use Case**: G03 — Change Availability EVSE/Connector - -**Server**: `--command ChangeAvailability --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` -3. `listChargingStations` - -**Expected Results**: - -- Log shows ChangeAvailability received with operationalStatus = Operative -- Log shows ChangeAvailabilityResponse sent with status = Accepted - -### TC-B11: Reset (Server-Initiated, Without Ongoing Transaction) - -**OCPP Use Case**: B11 — Reset Without Ongoing Transaction - -**Server**: `--command Reset --delay 5` - -**Steps**: - -1. Start server, wait ~15s (station resets) -2. Wait additional 30s (resetTime config = 30000ms) -3. `listChargingStations` -4. `readCombinedLog` - -**Expected Results**: - -- Log shows Reset received with type = Immediate -- Log shows ResetResponse sent with status = Accepted -- Station reboots and sends new BootNotification -- Station returns to Available after reset - -### TC-F05: UnlockConnector (Server-Initiated) - -**OCPP Use Case**: F05 — Remotely Unlock Connector - -**Server**: `--command UnlockConnector --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows UnlockConnector received for evseId=1, connectorId=1 -- Log shows UnlockConnectorResponse sent with status = Unlocked - -### TC-P02: DataTransfer (CSMS → CS) - -**OCPP Use Case**: P01 — DataTransfer (CSMS-initiated) - -**Server**: `--command DataTransfer --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows DataTransfer received from CSMS (vendorId=TestVendor) -- Log shows DataTransferResponse sent with status = Accepted - -### TC-B09: SetNetworkProfile (Server-Initiated) - -**OCPP Use Case**: B09 — Setting a new NetworkConnectionProfile - -**Server**: `--command SetNetworkProfile --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows SetNetworkProfile received -- Log shows SetNetworkProfileResponse sent with status = Accepted -- Note: Per README, SetNetworkProfile validates and accepts but does NOT persist (B09.FR.01) - -### TC-N01-GL: GetLog (Server-Initiated) - -**OCPP Use Case**: N01 — Retrieve Log Information - -**Server**: `--command GetLog --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows GetLog received for DiagnosticsLog -- Log shows GetLogResponse sent with status = Accepted -- Log shows subsequent LogStatusNotification sent (Idle or uploading) - -### TC-L01-UF: UpdateFirmware (Server-Initiated) - -**OCPP Use Case**: L01 — Secure Firmware Update - -**Server**: `--command UpdateFirmware --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` (check firmware update lifecycle) - -**Expected Results**: - -- Log shows UpdateFirmware received -- Log shows UpdateFirmwareResponse sent with status = Accepted -- Log shows FirmwareStatusNotification sequence: Downloading → Downloaded → Installing → Installed -- Note: Per README, signature verification always succeeds (SignatureVerified) - -### TC-A02: CertificateSigned (Server-Initiated) - -**OCPP Use Case**: A02 — Update Charging Station Certificate by request of CSMS - -**Server**: `--command CertificateSigned --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows CertificateSigned received with certificate chain -- Log shows CertificateSignedResponse sent (Accepted or Rejected depending on certificate validity) - -### TC-N02-CI: CustomerInformation (Server-Initiated) - -**OCPP Use Case**: N02 — Customer Information - -**Server**: `--command CustomerInformation --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows CustomerInformation received with report=true, clear=false -- Log shows CustomerInformationResponse sent with status = Accepted -- Log shows subsequent NotifyCustomerInformation sent with customer data - -### TC-M04: DeleteCertificate (Server-Initiated) - -**OCPP Use Case**: M04 — Delete a specific certificate from a Charging Station - -**Server**: `--command DeleteCertificate --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows DeleteCertificate received with certificate hash data -- Log shows DeleteCertificateResponse sent (Accepted or NotFound) - -### TC-M03: GetInstalledCertificateIds (Server-Initiated) - -**OCPP Use Case**: M03 — Retrieve list of available certificates - -**Server**: `--command GetInstalledCertificateIds --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows GetInstalledCertificateIds received for CSMSRootCertificate -- Log shows GetInstalledCertificateIdsResponse sent with list (or empty) - -### TC-M05: InstallCertificate (Server-Initiated) - -**OCPP Use Case**: M05 — Install CA certificate in a Charging Station - -**Server**: `--command InstallCertificate --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows InstallCertificate received for CSMSRootCertificate -- Log shows InstallCertificateResponse sent (Accepted or Rejected) - -### TC-E14: GetTransactionStatus (Server-Initiated, No Transaction) - -**OCPP Use Case**: E14 — Check transaction status - -**Server**: `--command GetTransactionStatus --delay 5` - -**Steps**: - -1. Start server, wait ~15s -2. `readCombinedLog` - -**Expected Results**: - -- Log shows GetTransactionStatus received for transaction_id=test_transaction_123 -- Log shows GetTransactionStatusResponse sent with messagesInQueue=false (no matching transaction) - ---- - -## Test Group 6 — Remote Transaction Lifecycle (Normal Auth) - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -Wait for station reconnection and Accepted boot. - -### TC-F01: Remote Start Transaction - -**OCPP Use Case**: F01/F02 — Remote Start Transaction - -**Pre-conditions**: Station booted, connector Available, no active transaction - -**Steps**: - -1. Verify state: `listChargingStations` → connector status = Available, transactionStarted = false -2. Start server with: `--command RequestStartTransaction --delay 5` -3. Wait ~15s for server to send RequestStartTransaction -4. `listChargingStations` -5. `readCombinedLog` - -**Expected Results**: - -- Log shows RequestStartTransaction received (idToken=test_token, type=ISO14443, evseId=1) -- Log shows RequestStartTransactionResponse sent with status = Accepted -- Log shows TransactionEvent.Started sent (seqNo=0, triggerReason=RemoteStart) -- Connector status changes to Occupied -- `transactionStarted` = true -- `transactionId` is a non-empty UUID string - -### TC-F03: Remote Stop Transaction - -**OCPP Use Case**: F03 — Remote Stop Transaction - -**Pre-conditions**: Server running with `--boot-status accepted`, connector Available - -> **Note**: This test is self-contained. It starts its own transaction via ATG, then triggers -> a remote stop from the server. The mock server's `RequestStopTransaction` uses a hardcoded -> `transaction_id="test_transaction_123"` which will NOT match the ATG-generated UUID. -> The expected behavior is that the station rejects the stop request (transaction not found). -> To test a _successful_ remote stop, use the MCP `stopAutomaticTransactionGenerator` tool instead. - -**Steps**: - -1. Verify no active transaction: `listChargingStations` → transactionStarted = false -2. Start ATG: `startAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -3. Wait 10s for transaction to start -4. `listChargingStations` → verify transactionStarted = true, note transactionId -5. `stopAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -6. Wait 10s for transaction to end -7. `listChargingStations` → verify transactionStarted = false -8. `readCombinedLog` - -**Expected Results**: - -- TransactionEvent.Started sent with triggerReason (seqNo=0) -- During transaction: periodic TransactionEvent.Updated with MeterValues -- After ATG stop: TransactionEvent.Ended sent with stoppedReason = Local -- Connector status returns to Available -- transactionStarted = false - -### TC-F03b: Remote Stop — Transaction Not Found - -**Pre-conditions**: Server configured to send RequestStopTransaction, no active transaction - -**Server**: Kill and restart with `--command RequestStopTransaction --delay 5` - -**Steps**: - -1. Wait for boot + command delivery (~15s) -2. `readCombinedLog` - -**Expected Results**: - -- Log shows RequestStopTransaction received with transaction_id="test_transaction_123" -- Station responds with status = Rejected (no matching transaction found) -- No TransactionEvent.Ended sent - -### TC-E01-ATG: Transaction via Automatic Transaction Generator - -**OCPP Use Case**: E01-E06 — Transaction lifecycle - -**Pre-conditions**: Station booted, connector Available, server running with normal auth - -**Steps**: - -1. `startAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -2. Wait 30s for transaction to start and run -3. `listChargingStations` (check transaction state) -4. `readCombinedLog` (check TransactionEvent sequence) -5. Wait for ATG transaction to complete (configured duration) -6. `stopAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -7. `listChargingStations` -8. `readCombinedLog` - -**Expected Results**: - -- Log shows Authorize request sent (if requireAuthorize=true) -- Log shows TransactionEvent.Started (seqNo=0) -- Log shows periodic TransactionEvent.Updated with MeterValues (seqNo=1,2,...) -- Log shows TransactionEvent.Ended (final seqNo) -- During transaction: connector status = Occupied, transactionStarted = true -- After transaction: connector status = Available, transactionStarted = false - -### TC-J02: MeterValues During Transaction - -**OCPP Use Case**: J02 — Sending transaction related Meter Values - -**Pre-conditions**: Active transaction (started via ATG or remote start) - -**Steps**: - -1. Start ATG: `startAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -2. Wait 30s -3. `readCombinedLog` — inspect MeterValues - -**Expected Results**: - -- TransactionEvent.Started contains meter values with context Transaction.Begin -- TransactionEvent.Updated contains periodic meter values (Power.Active.Import, Current.Import, Voltage, Energy.Active.Import.Register) -- TransactionEvent.Ended contains meter values with context Transaction.End -- seqNo values are sequential (0, 1, 2, ...) - -### TC-I01: Total Cost in TransactionEvent.Updated Response - -**OCPP Use Case**: I02 — Show EV Driver Running Total Cost During Charging - -**Pre-conditions**: Server running with `--total-cost 25.50` - -**Server**: Kill and restart with `--boot-status accepted --total-cost 25.50` - -**Steps**: - -1. Wait for station reconnection and Accepted boot -2. Start ATG: `startAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -3. Wait 30s (enough for periodic TransactionEvent.Updated exchanges) -4. `readCombinedLog` — look for TransactionEvent.Updated response containing totalCost -5. `stopAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) - -**Expected Results**: - -- TransactionEvent.Updated responses from CSMS include `totalCost: 25.5` -- Station logs the received total cost value - -### TC-E01-DIRECT: TransactionEvent via MCP Tool (Direct Send) - -**OCPP Use Case**: E01 — Start Transaction options (direct MCP control) - -**Pre-conditions**: Server running with `--boot-status accepted`, connector Available - -**Steps**: - -1. `transactionEvent` with ocpp20Payload: - ```json - { - "eventType": "Started", - "timestamp": "", - "triggerReason": "Authorized", - "seqNo": 0, - "transactionInfo": { - "transactionId": "mcp-direct-test-001" - }, - "idToken": { - "idToken": "test_token", - "type": "ISO14443" - } - } - ``` -2. `transactionEvent` with ocpp20Payload: - ```json - { - "eventType": "Updated", - "timestamp": "", - "triggerReason": "MeterValuePeriodic", - "seqNo": 1, - "transactionInfo": { - "transactionId": "mcp-direct-test-001" - }, - "meterValue": [ - { - "timestamp": "", - "sampledValue": [{ "value": 1500.0, "measurand": "Power.Active.Import" }] - } - ] - } - ``` -3. `transactionEvent` with ocpp20Payload: - ```json - { - "eventType": "Ended", - "timestamp": "", - "triggerReason": "StopAuthorized", - "seqNo": 2, - "transactionInfo": { - "transactionId": "mcp-direct-test-001", - "stoppedReason": "Local" - } - } - ``` -4. `readCombinedLog` - -**Expected Results**: - -- All 3 TransactionEvent requests sent successfully (MCP status = success) -- Server responds to each: Started (with idTokenInfo), Updated (with totalCost), Ended (empty) -- Sequence: Started (seqNo=0) → Updated (seqNo=1) → Ended (seqNo=2) - ---- - -## Test Group 7 — Authorization: Whitelist Mode - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted --auth-mode whitelist --whitelist valid_token test_token -``` - -### TC-C01-WL-OK: Authorize with Whitelisted Token - -**OCPP Use Case**: C01 — EV Driver Authorization using RFID - -**Steps**: - -1. `authorize` with ocpp20Payload: - ```json - { - "idToken": { - "idToken": "test_token", - "type": "ISO14443" - } - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- Log shows AuthorizeRequest sent with idToken=test_token -- Log shows AuthorizeResponse received with status = Accepted - -### TC-C01-WL-REJECT: Authorize with Non-Whitelisted Token - -**Steps**: - -1. `authorize` with ocpp20Payload: - ```json - { - "idToken": { - "idToken": "unknown_token", - "type": "ISO14443" - } - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- Log shows AuthorizeRequest sent with idToken=unknown_token -- Log shows AuthorizeResponse received with status = Blocked - -### TC-E05-WL: Start Transaction — Unauthorized Token (Whitelist) - -**OCPP Use Case**: E05 — Start Transaction - Id not Accepted - -**Pre-conditions**: Server with whitelist that does NOT include the token used in RequestStartTransaction - -> **Note**: The mock server's `_send_request_start_transaction()` sends `idToken=test_token`. -> The whitelist is set to `valid_token` only, so `test_token` is NOT whitelisted. -> When the station processes the RemoteStart, it sends Authorize or TransactionEvent.Started. -> The server resolves auth status based on whitelist → returns Blocked. - -**Server**: `--boot-status accepted --auth-mode whitelist --whitelist valid_token --command RequestStartTransaction --delay 5` - -**Steps**: - -1. Wait for boot + command delivery (~15s) -2. `readCombinedLog` -3. `listChargingStations` - -**Expected Results**: - -- Station receives RequestStartTransaction from CSMS with idToken=test_token -- Station accepts the CSMS command (RequestStartTransactionResponse status = Accepted) -- Station sends Authorize or TransactionEvent.Started to CSMS -- Server responds with idTokenInfo.status = Blocked (test_token not in whitelist) -- Station aborts the transaction due to authorization failure -- Connector returns to Available -- No active transaction remains - ---- - -## Test Group 8 — Authorization: Blacklist Mode - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted --auth-mode blacklist --blacklist blocked_token invalid_user -``` - -### TC-C01-BL-OK: Authorize with Non-Blacklisted Token - -**Steps**: - -1. `authorize` with ocpp20Payload: - ```json - { - "idToken": { - "idToken": "any_valid_token", - "type": "ISO14443" - } - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- AuthorizeResponse status = Accepted - -### TC-C01-BL-REJECT: Authorize with Blacklisted Token - -**Steps**: - -1. `authorize` with ocpp20Payload: - ```json - { - "idToken": { - "idToken": "blocked_token", - "type": "ISO14443" - } - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- AuthorizeResponse status = Blocked - ---- - -## Test Group 9 — Authorization: Rate Limit Mode - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted --auth-mode rate_limit -``` - -### TC-C01-RL: Authorize Rejected — Rate Limit - -**Steps**: - -1. `authorize` with ocpp20Payload: - ```json - { - "idToken": { - "idToken": "any_token", - "type": "ISO14443" - } - } - ``` -2. `readCombinedLog` - -**Expected Results**: - -- AuthorizeResponse status = NotAtThisTime -- No transaction should start - ---- - -## Test Group 10 — Authorization: Offline / Network Failure - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted --offline -``` - -### TC-C01-OFFLINE: Authorize — Network Failure - -**OCPP Use Case**: B04 — Offline Behavior Idle Charging Station - -**Steps**: - -1. `authorize` with ocpp20Payload: - ```json - { - "idToken": { - "idToken": "any_token", - "type": "ISO14443" - } - } - ``` -2. `readCombinedLog` -3. `readErrorLog` - -**Expected Results**: - -- Server raises InternalError on Authorize request -- Station receives OCPP error response -- Error log shows authorization failure -- Transaction should not start - ---- - -## Test Group 11 — Connection Management - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -### TC-CONN-01: Close and Reopen Connection - -**Steps**: - -1. `listChargingStations` → verify connected -2. `closeConnection` (hashIds: [station]) -3. Wait 5s -4. `listChargingStations` → verify disconnected/reconnecting -5. `openConnection` (hashIds: [station]) -6. Wait 15s (reconnect + boot) -7. `listChargingStations` → verify reconnected and Accepted - -**Expected Results**: - -- After close: station shows disconnected state -- After open: station reconnects, sends BootNotification, gets Accepted -- Connector returns to Available - -### TC-CONN-02: Set Supervision URL - -**Steps**: - -1. Note current supervision URL via `listChargingStations` → supervisionUrl -2. `setSupervisionUrl` (hashIds: [station], url: "ws://127.0.0.1:9000") - (Use 127.0.0.1 instead of localhost to force a reconnection to a different resolved URL) -3. Wait 15s -4. `listChargingStations` → verify reconnected and booted -5. `setSupervisionUrl` (hashIds: [station], url: "ws://localhost:9000") - (Restore original URL) -6. Wait 15s -7. `listChargingStations` - -**Expected Results**: - -- Station disconnects from old URL and reconnects to new URL -- BootNotification sent and Accepted after each URL change -- Connector returns to Available - ---- - -## Test Group 12 — Station Lifecycle Management - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -### TC-SIM-01: Stop and Start Charging Station - -**Steps**: - -1. `listChargingStations` → verify running -2. `stopChargingStation` (hashIds: [station]) -3. Wait 5s -4. `listChargingStations` → verify stopped -5. `startChargingStation` (hashIds: [station]) -6. Wait 15s -7. `listChargingStations` → verify running, booted, Available - -**Expected Results**: - -- Stop: station marked as stopped, WebSocket disconnected -- Start: station reconnects, BootNotification Accepted, connector Available - -### TC-SIM-02: Add and Delete Charging Station - -**Steps**: - -1. `addChargingStations` (template: "keba-ocpp2.station-template", numberOfStations: 1, options: { autoStart: true, autoRegister: true, supervisionUrls: "ws://localhost:9000" }) -2. Wait 15s -3. `listChargingStations` → verify 2 stations -4. Note hashId of new station -5. `deleteChargingStations` (hashIds: [new station hashId]) -6. `listChargingStations` → verify 1 station - -**Expected Results**: - -- New station added, connects, sends BootNotification, gets Accepted -- Delete removes the station cleanly - ---- - -## Test Group 13 — CSMS-Initiated Commands with Periodic Execution - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted --command GetBaseReport --period 10 -``` - -### TC-PERIODIC: Periodic GetBaseReport - -**Steps**: - -1. Start server with periodic GetBaseReport every 10s -2. Wait 45s (expect ~3-4 GetBaseReport cycles) -3. `readCombinedLog` - -**Expected Results**: - -- Log shows multiple GetBaseReport received at ~10s intervals -- Each GetBaseReport triggers GetBaseReportResponse + NotifyReport sequence -- Station handles repeated requests without errors - ---- - -## Test Group 14 — Reset With Active Transaction - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -### TC-B12: Reset With Ongoing Transaction - -**OCPP Use Case**: B12 — Reset With Ongoing Transaction - -**Steps**: - -1. Start ATG: `startAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -2. Wait 10s (transaction should be active) -3. `listChargingStations` → verify transactionStarted = true -4. Restart server: `--command Reset --delay 3` -5. Wait 15s -6. `readCombinedLog` -7. Wait 30s (resetTime) -8. `listChargingStations` - -**Expected Results**: - -- Log shows Reset received while transaction active -- If `stopTransactionsOnStopped` = true (default): TransactionEvent.Ended sent before reset -- Station resets and sends new BootNotification -- After reset: connector Available, no active transaction - ---- - -## Test Group 15 — Error and Edge Cases - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -### TC-ERR-01: StatusNotification with All Statuses - -**Steps**: -For each status in [Available, Occupied, Reserved, Unavailable, Faulted]: - -1. `statusNotification` with connectorStatus = -2. Verify MCP response = success - -### TC-ERR-02: Heartbeat Rapid Fire - -**Steps**: - -1. Send 5 `heartbeat` calls in rapid succession -2. `readCombinedLog` - -**Expected Results**: - -- All 5 heartbeats sent and responded to -- No errors in error log - -### TC-ERR-03: MeterValues with Multiple Measurands - -**Steps**: - -1. `meterValues` with payload containing multiple sampledValues: - - Voltage (V), Power.Active.Import (W), Current.Import (A), Energy.Active.Import.Register (Wh) -2. Verify all accepted - -### TC-ERR-04: FirmwareStatusNotification — All Statuses - -**Steps**: -For each status in [Downloaded, DownloadFailed, Downloading, DownloadScheduled, DownloadPaused, Idle, InstallationFailed, Installing, Installed, InstallRebooting, InstallScheduled, InstallVerificationFailed, InvalidSignature, SignatureVerified]: - -1. `firmwareStatusNotification` with ocpp20Payload: { "status": "", "requestId": 1 } -2. Verify MCP response = success - ---- - -## Test Group 16 — Full Offline / Reconnection Behavior - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -### TC-B04-OFFLINE: Offline Behavior — Server Down and Reconnect - -**OCPP Use Case**: B04 — Offline Behavior Idle Charging Station - -**Pre-conditions**: Station booted and connected (Accepted), connector Available - -**Steps**: - -1. `listChargingStations` → verify connected, bootNotificationResponse.status = Accepted -2. Kill mock server (Ctrl+C / SIGTERM) -3. Wait 10s -4. `listChargingStations` → observe station state (should show disconnected/reconnecting) -5. `readCombinedLog` → verify reconnection attempts in logs -6. Restart mock server: `poetry run python server.py --boot-status accepted` -7. Wait 30s (auto-reconnect + boot cycle) -8. `listChargingStations` → verify reconnected, bootNotificationResponse.status = Accepted -9. `readCombinedLog` → verify full reconnection sequence - -**Expected Results**: - -- After server kill: station enters reconnection loop with exponential backoff -- Log shows WebSocket connection lost / reconnection attempts -- After server restart: station reconnects automatically -- New BootNotification sent and Accepted -- StatusNotification sent for all connectors -- Connector returns to Available -- No errors in `readErrorLog` (reconnection is expected behavior) - -### TC-B04-OFFLINE-TXN: Offline During Active Transaction - -**OCPP Use Case**: B04 + E04 — Offline Behavior + Transaction started while CS offline - -**Pre-conditions**: Station booted and connected, server running - -**Steps**: - -1. Start ATG: `startAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -2. Wait 10s → `listChargingStations` → verify transactionStarted = true -3. Kill mock server (Ctrl+C / SIGTERM) -4. Wait 15s (station loses connection during active transaction) -5. `listChargingStations` → check transaction state -6. `readCombinedLog` → check transaction event queueing -7. Restart server: `poetry run python server.py --boot-status accepted` -8. Wait 30s -9. `stopAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -10. Wait 10s -11. `listChargingStations` -12. `readCombinedLog` → check queued event delivery - -**Expected Results**: - -- Transaction events generated during offline period are queued (offline=true flag per E04.FR.03) -- After reconnection: station re-boots, then delivers queued TransactionEvent messages -- Or: `stopTransactionsOnStopped=true` causes TransactionEvent.Ended before disconnect -- Connector eventually returns to Available after transaction cleanup - ---- - -## Test Group 17 — Advanced Edge Cases and Negative Tests - -### Server Setup - -```bash -poetry run python server.py --boot-status accepted -``` - -### TC-E-DOUBLE-START: Duplicate RequestStartTransaction During Active Transaction - -**OCPP Use Case**: E02 — Concurrent transaction blocking - -**Pre-conditions**: Station booted, connector Available - -**Steps**: - -1. Start ATG: `startAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) -2. Wait 10s → `listChargingStations` → verify transactionStarted = true -3. Kill server, restart: `--boot-status accepted --command RequestStartTransaction --delay 3` -4. Wait 15s (server sends RequestStartTransaction while transaction already active) -5. `readCombinedLog` -6. `stopAutomaticTransactionGenerator` (hashIds: [station], connectorIds: [1]) - -**Expected Results**: - -- Station rejects the second RequestStartTransaction (connector already occupied / transactionPending) -- Log shows RequestStartTransactionResponse with status = Rejected -- Original transaction continues unaffected - -### TC-E-STOP-NONE: RequestStopTransaction When No Transaction Active - -**OCPP Use Case**: E06 — Stop Transaction edge case - -**Pre-conditions**: Station booted, connector Available, NO active transaction - -**Server**: Restart with `--command RequestStopTransaction --delay 5` - -**Steps**: - -1. `listChargingStations` → verify transactionStarted = false -2. Wait for server to send RequestStopTransaction (~15s) -3. `readCombinedLog` - -**Expected Results**: - -- Log shows RequestStopTransaction received for transaction_id="test_transaction_123" -- Station responds with status = Rejected (no matching transaction) -- No TransactionEvent.Ended sent -- Connector stays Available - -### TC-B11-PENDING: Reset While Station in Pending Boot State - -**Pre-conditions**: Server configured for Pending boot - -**Server**: Restart with `--boot-status pending --command Reset --delay 8` - -**Steps**: - -1. Station connects and gets BootNotification Pending -2. Server sends Reset after 8s delay (while station in Pending state) -3. Wait 30s -4. `readCombinedLog` - -**Expected Results**: - -- Station receives Reset while in Pending registration state -- Station should either accept the reset or ignore it (implementation-dependent) -- Log shows the Reset request and response -- Station reboots and retries BootNotification - -### TC-ERR-05: LogStatusNotification Without Prior GetLog - -**Steps**: - -1. `logStatusNotification` with ocpp20Payload: { "status": "Idle", "requestId": 999 } -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = success (server accepts notification regardless) -- No errors — the station sends the notification, server acknowledges - -### TC-ERR-06: FirmwareStatusNotification Without Prior UpdateFirmware - -**Steps**: - -1. `firmwareStatusNotification` with ocpp20Payload: { "status": "Idle", "requestId": 999 } -2. `readCombinedLog` - -**Expected Results**: - -- MCP response status = success -- No errors — orphaned notification handled gracefully - ---- - -## Summary: Test Coverage Matrix - -| OCPP Functional Block | Use Cases Covered | Test Cases | Server Config Required | -| ------------------------- | ------------------------------ | ---------- | ------------------------------------------------ | -| **A. Security** | A02, A03, A04 | 4 | Normal, CertificateSigned command | -| **B. Provisioning** | B01-B04, B05-B07, B09, B11-B12 | 12 | Normal, Rejected, Pending, various commands | -| **C. Authorization** | C01, C05, C11 | 7 | Normal, Whitelist, Blacklist, RateLimit, Offline | -| **E. Transactions** | E01-E06, E14 | 8 | Normal, Whitelist, Direct MCP | -| **F. Remote Control** | F01-F03, F05, F06 | 6 | Normal + commands | -| **G. Availability** | G01-G03 | 4 | Normal + ChangeAvailability command | -| **I. Tariff/Cost** | I02 | 1 | Normal + --total-cost | -| **J. MeterValues** | J01, J02 | 2 | Normal | -| **L. FirmwareManagement** | L01 | 3 | Normal + UpdateFirmware command | -| **M. ISO15118 Certs** | M01, M03-M06 | 5 | Normal + cert commands | -| **N. Diagnostics** | N01, N02 | 4 | Normal + GetLog/CustomerInfo commands | -| **P. DataTransfer** | P01 | 2 | Normal + DataTransfer command | -| **Connection Mgmt** | — | 2 | Normal | -| **Station Lifecycle** | — | 2 | Normal | -| **Offline/Reconnect** | B04, E04 | 2 | Normal (server kill/restart) | -| **Periodic Commands** | — | 1 | Normal + periodic command | -| **Edge/Negative Cases** | — | 10 | Normal, Pending + Reset | -| **TOTAL** | | **~75** | **10 distinct server configs** | - -### Server Configurations Required (ordered execution) - -| # | Config | Tests Covered | -| --- | ------------------------------------------------------------------------------------- | ------------------------------- | -| 1 | `--boot-status accepted` | Groups 4, 6, 11, 12, 15, 16, 17 | -| 2 | `--boot-status rejected` | Group 2 | -| 3 | `--boot-status pending` | Group 3 | -| 4 | `--boot-status accepted --auth-mode whitelist --whitelist valid_token test_token` | Group 7 | -| 5 | `--boot-status accepted --auth-mode blacklist --blacklist blocked_token invalid_user` | Group 8 | -| 6 | `--boot-status accepted --auth-mode rate_limit` | Group 9 | -| 7 | `--boot-status accepted --offline` | Group 10 | -| 8 | `--boot-status accepted --command --delay 5` (multiple restarts) | Group 5 | -| 9 | `--boot-status accepted --command GetBaseReport --period 10` | Group 13 | -| 10 | `--boot-status accepted --total-cost 25.50` | TC-I01 | -| 11 | `--boot-status pending --command Reset --delay 8` | TC-B11-PENDING | - -### Review Status - -- **Reviewed by**: Momus (Plan Critic) — cross-validated against all 7 criteria -- **Verdict**: APPROVED WITH CHANGES -- **Changes applied**: - - ✅ Fixed TC-F03 (was broken: hardcoded transaction_id + server restart killed transaction) - - ✅ Added TC-F03b (Remote Stop — Transaction Not Found) - - ✅ Added TC-I01 (--total-cost verification) - - ✅ Added TC-E01-DIRECT (direct transactionEvent MCP tool test) - - ✅ Added Test Group 16 (full offline/reconnection: TC-B04-OFFLINE, TC-B04-OFFLINE-TXN) - - ✅ Added Test Group 17 (advanced edge cases: double start, stop-none, reset-pending, orphaned notifications) - - ✅ Fixed TC-CONN-02 (was trivial: same URL → now uses different host) - - ✅ Added pass/fail criteria and timestamp convention - - ✅ Updated summary matrix with new test count (~75 test cases) -- 2.43.0