]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp-server): enhance OCPP 2.0.1 mock server for comprehensive E2E testing ...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 25 Mar 2026 11:38:55 +0000 (12:38 +0100)
committerGitHub <noreply@github.com>
Wed, 25 Mar 2026 11:38:55 +0000 (12:38 +0100)
* 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
tests/ocpp-server/server.py
tests/ocpp-server/test_server.py
tests/ocpp2-e2e-test-plan.md [deleted file]

index 85ae9581618f02e1b88345a9c346ebdfaf859ab2..496c7431d2ffc731aa5994486f8d3aa5f825456c 100644 (file)
@@ -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 <HOST> --port <PORT>
-```
+- `--host <HOST>`: Bind address (default: `127.0.0.1`)
+- `--port <PORT>`: Listening port (default: `9000`)
+
+### Boot Behavior
 
-- `--host <HOST>`: Server bind address (default: `127.0.0.1`)
-- `--port <PORT>`: Server port (default: `9000`)
+- `--boot-status <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 <SEQ>`: 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 <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 <STATUS> --total-cost <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 <STATUS>`: BootNotification response status (`accepted`, `pending`, `rejected`; default: `accepted`)
-- `--total-cost <COST>`: Total cost returned in TransactionEvent.Updated responses (default: `10.0`)
+### Authorization
 
-**Examples:**
+- `--auth-mode <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 <ID>`: Include `groupIdToken` in Authorize and TransactionEvent.Started responses
+- `--auth-cache-expiry <SEC>`: 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 <MODE> [--whitelist TOKEN1 TOKEN2 ...] [--blacklist TOKEN1 TOKEN2 ...] [--offline]
-```
+#### Single command
 
-- `--auth-mode <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 <NAME>`: OCPP command to send (see supported commands below)
+- `--delay <SEC>`: One-shot delay before sending (mutually exclusive with `--period`)
+- `--period <SEC>`: 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 <SEQ>`: Comma-separated `CMD:DELAY` pairs, executed sequentially (e.g., `RequestStartTransaction:5,RequestStopTransaction:30`)
+
+Mutually exclusive with `--command`.
 
 ```shell
-poetry run python server.py --command <COMMAND_NAME> --period <SECONDS>
-poetry run python server.py --command <COMMAND_NAME> --delay <SECONDS>
+poetry run python server.py --commands "RequestStartTransaction:5,RequestStopTransaction:30"
 ```
 
-- `--command <COMMAND_NAME>`: The OCPP command to send (see supported commands below)
-- `--period <SECONDS>`: Interval in seconds between repeated command sends (mutually exclusive with `--delay`)
-- `--delay <SECONDS>`: 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 <TYPE>`: TriggerMessage requested message type (default: `StatusNotification`)
+  - `StatusNotification`, `BootNotification`, `Heartbeat`, `MeterValues`, `FirmwareStatusNotification`, `LogStatusNotification`, `SignCertificate`
+- `--reset-type <TYPE>`: Reset type (default: `Immediate`)
+  - `Immediate` — Reset now
+  - `OnIdle` — Reset when no transaction is active
+- `--availability-status <STATUS>`: ChangeAvailability operational status (default: `Operative`)
+  - `Operative` — Connector available
+  - `Inoperative` — Connector unavailable
+- `--set-variables <SPECS>`: SetVariables data as `Component.Variable=Value,...` (values must not contain commas)
+- `--get-variables <SPECS>`: 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
index 13ceb4d6b625355d9ab732c8dcc6973ffdb493de..0e510ef9ef95477a57ddd00d1f873f8ac802db59 100644 (file)
@@ -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(
index 907de6f1d9a82dbcd3ef26f6b4e1365c2b336e8e..ecf8c177fb69be832e39bcdea007020b285a46df 100644 (file)
@@ -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 (file)
index 8b273b9..0000000
+++ /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 `<ISO8601>` 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": "<ISO8601>",
-     "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": "<ISO8601>",
-         "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": "<ISO8601>"
-   }
-   ```
-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": "<ISO8601>",
-     "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": "<ISO8601>",
-     "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 <Name> --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 <CommandName> --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": "<ISO8601>",
-     "triggerReason": "Authorized",
-     "seqNo": 0,
-     "transactionInfo": {
-       "transactionId": "mcp-direct-test-001"
-     },
-     "idToken": {
-       "idToken": "test_token",
-       "type": "ISO14443"
-     }
-   }
-   ```
-2. `transactionEvent` with ocpp20Payload:
-   ```json
-   {
-     "eventType": "Updated",
-     "timestamp": "<ISO8601>",
-     "triggerReason": "MeterValuePeriodic",
-     "seqNo": 1,
-     "transactionInfo": {
-       "transactionId": "mcp-direct-test-001"
-     },
-     "meterValue": [
-       {
-         "timestamp": "<ISO8601>",
-         "sampledValue": [{ "value": 1500.0, "measurand": "Power.Active.Import" }]
-       }
-     ]
-   }
-   ```
-3. `transactionEvent` with ocpp20Payload:
-   ```json
-   {
-     "eventType": "Ended",
-     "timestamp": "<ISO8601>",
-     "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 = <status>
-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": "<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 <X> --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)