Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter

- CitrineOS core extracted (CSMS OCPP 2.0.1)
- OpenOCPP extracted (firmware OCPP 1.6J/2.0.1)
- ShapeShifter library installed (pip install -e)
- ShapeShifter specification extracted
- EVerest extracted

TODO updated with progress
This commit is contained in:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -0,0 +1,29 @@
#
# AUTO GENERATED - MARKED REGIONS WILL BE KEPT
# template version 3
#
# module setup:
# - ${MODULE_NAME}: module name
ev_setup_cpp_module()
# ev@bcc62523-e22b-41d7-ba2f-825b493a3c97:v1
# insert your custom targets and additional config variables here
# ev@bcc62523-e22b-41d7-ba2f-825b493a3c97:v1
target_sources(${MODULE_NAME}
PRIVATE
"main/powermeterImpl.cpp"
)
# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1
# insert other things like install cmds etc here
target_sources(${MODULE_NAME}
PRIVATE
"main/transport.cpp"
)
if(EVEREST_CORE_BUILD_TESTING)
add_subdirectory(tests)
endif()
# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1

View File

@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest
#include "CarloGavazzi_EM580.hpp"
namespace module {
void CarloGavazzi_EM580::init() {
invoke_init(*p_main);
}
void CarloGavazzi_EM580::ready() {
invoke_ready(*p_main);
}
} // namespace module

View File

@@ -0,0 +1,63 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2026 Pionix GmbH and Contributors to EVerest
#ifndef CARLO_GAVAZZI_EM580_HPP
#define CARLO_GAVAZZI_EM580_HPP
//
// AUTO GENERATED - MARKED REGIONS WILL BE KEPT
// template version 2
//
#include "ld-ev.hpp"
// headers for provided interface implementations
#include <generated/interfaces/powermeter/Implementation.hpp>
// headers for required interface implementations
#include <generated/interfaces/serial_communication_hub/Interface.hpp>
// ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1
// insert your custom include headers here
// ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1
namespace module {
struct Conf {};
class CarloGavazzi_EM580 : public Everest::ModuleBase {
public:
CarloGavazzi_EM580() = delete;
CarloGavazzi_EM580(const ModuleInfo& info, std::unique_ptr<powermeterImplBase> p_main,
std::unique_ptr<serial_communication_hubIntf> r_modbus, Conf& config) :
ModuleBase(info), p_main(std::move(p_main)), r_modbus(std::move(r_modbus)), config(config){};
const std::unique_ptr<powermeterImplBase> p_main;
const std::unique_ptr<serial_communication_hubIntf> r_modbus;
const Conf& config;
// ev@1fce4c5e-0ab8-41bb-90f7-14277703d2ac:v1
// insert your public definitions here
// ev@1fce4c5e-0ab8-41bb-90f7-14277703d2ac:v1
protected:
// ev@4714b2ab-a24f-4b95-ab81-36439e1478de:v1
// insert your protected definitions here
// ev@4714b2ab-a24f-4b95-ab81-36439e1478de:v1
private:
friend class LdEverest;
void init();
void ready();
// ev@211cfdbe-f69a-4cd6-a4ec-f8aaa3d1b6c8:v1
// insert your private definitions here
// ev@211cfdbe-f69a-4cd6-a4ec-f8aaa3d1b6c8:v1
};
// ev@087e516b-124c-48df-94fb-109508c7cda9:v1
// insert other definitions here
// ev@087e516b-124c-48df-94fb-109508c7cda9:v1
} // namespace module
#endif // CARLO_GAVAZZI_EM580_HPP

View File

@@ -0,0 +1,243 @@
# Carlo Gavazzi EM580 Power Meter Driver
Driver module for the **Carlo Gavazzi EM580** power meter using Modbus via EVerest's `serial_communication_hub` interface.
It implements the standardized EVerest `powermeter` interface and supports **OCMF/Eichrecht** transaction flows.
## Overview
This is an **EVerest Hardware Driver** module that:
- **Implements**: `powermeter` interface
- **Communicates**: Modbus RTU (through `SerialCommHub`)
- **Provides**: Live meter values, OCMF transaction start/stop handling, public key publishing
## Features
- **Live measurements**: Publishes `powermeter` readings periodically (`live_measurement_interval_ms`)
- **OCMF transactions**:
- `start_transaction`: writes OCMF identification fields + tariff text (TT) + start command
- `stop_transaction`: ends transaction, waits for READY, reads OCMF file, confirms file read
- **Modbus protocol compliance**: transport splits writes into chunks (max 123 registers per request)
- **Resilience / retries**:
- Separate initial connection retry settings vs. normal operation retry settings
- Communication-fault raise/clear hooks
- **Device state monitoring**: periodic read of device state bitfield (`device_state_read_interval_ms`)
- **Signature key readout**: reads signature type and public keys; publishes public key (hex) via `public_key_ocmf`
## Hardware requirements & compatibility
### Supported devices
- **Carlo Gavazzi EM580** with **Modbus RTU** enabled/available.
- **OCMF/Eichrecht flow**: requires a meter variant/firmware that supports the OCMF register set used by this driver.
If you are unsure which EM580 variant you have, check the device documentation/ordering code and confirm:
- Modbus RTU via RS-485 is supported and enabled
- The meter is configured for a known Modbus **unit id** (device address)
- **Carlo Gavazzi EM300** with **Modbus RTU** enabled/available.
These models do not support OCMF/Eichrecht and can only be used as usual power meter.
All transaction related configuration etc. does not apply for such devices.
### Bus / physical layer
- **RS-485 (2-wire, half duplex)**: correct A/B wiring is essential.
- **Termination**: enable 120Ω termination at the ends of the RS-485 bus (and only at the ends).
- **Biasing**: ensure the bus has proper bias resistors (often provided by the adapter/master or by dedicated biasing).
### Host requirements
- A Linux host running EVerest with access to a serial device (e.g. `/dev/ttyUSB0`).
- A **USB-to-RS485** adapter (or equivalent RS-485 interface) supported by the OS.
- Permissions to access the serial device node (group membership / udev rules).
## Configuration
### Required connections
The module requires a `serial_communication_hub` implementation (typically `SerialCommHub`) via its `modbus` requirement.
`SerialCommHub` encapsulates the serial port settings (port, baudrate, parity, timeouts). The EM580 module only needs the
hub connection plus its Modbus unit id (`powermeter_device_id`).
### Example configuration (bringup)
See `config/bringup/config-bringup-CGEM580.yaml`:
```yaml
active_modules:
cgem580:
module: CarloGavazzi_EM580
config_implementation:
main:
powermeter_device_id: 1
communication_retry_count: 3
communication_retry_delay_ms: 500
communication_error_pause_delay_s: 10
initial_connection_retry_count: 10
initial_connection_retry_delay_ms: 2000
timezone_offset_minutes: 60
live_measurement_interval_ms: 1000
device_state_read_interval_ms: 10000
connections:
modbus:
- module_id: comm_hub
implementation_id: main
```
### Multiple EM580 devices on one RS-485 bus
You can run multiple EM580 devices on the same RS-485 line by:
- Creating multiple `CarloGavazzi_EM580` module instances
- Pointing them all to the same `SerialCommHub`
- Giving each instance a unique `powermeter_device_id` (Modbus unit id)
### Configuration parameters
All parameters are defined in `modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/manifest.yaml`:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `powermeter_device_id` | integer | `1` | Modbus device ID on the bus |
| `communication_retry_count` | integer | `3` | Retries for regular Modbus operations |
| `communication_retry_delay_ms` | integer | `500` | Delay between regular retries |
| `communication_error_pause_delay_s` | integer | `10` | Pause after a communication failure in the live measurement thread before retrying (also applies to initial communication) |
| `initial_connection_retry_count` | integer | `10` (0 = infinite) | Retries during initial device setup/signature config reads |
| `initial_connection_retry_delay_ms` | integer | `2000` | Delay between initialization retries |
| `timezone_offset_minutes` | integer | `0` | Timezone offset from UTC (minutes) |
| `live_measurement_interval_ms` | integer | `1000` | Interval for reading/publishing live measurements |
| `device_state_read_interval_ms` | integer | `10000` | Interval for reading device-state bitfield (VendorError reporting) |
| `public_key_format` | enum | `binary` | The key format to use for the public key.
### Parameter tuning notes
- **`initial_connection_*`**:
- Used during module startup for device setup / signature config reads.
- `initial_connection_retry_count: 0` means **retry forever**.
- `initial_connection_retry_delay_ms` has a **minimum of 100ms** (see `manifest.yaml`).
- **`communication_*`**:
- Used for regular Modbus operations during runtime.
- `communication_retry_delay_ms` has a **minimum of 10ms** (see `manifest.yaml`).
- **`communication_error_pause_delay_s`**:
- After a communication exception in the live thread, the module waits this long before retrying.
- If the line is physically broken (wrong wiring / adapter unplugged), increasing this value reduces log spam.
- **`live_measurement_interval_ms` / `device_state_read_interval_ms`**:
- Keep live measurements reasonable for your bus speed and number of devices.
- For multi-drop RS-485, consider increasing intervals if you see bus contention/timeouts.
## Interfaces
### Provides
- `main`: `powermeter`
### Requires
- `modbus`: `serial_communication_hub`
## Transaction flow (OCMF)
### `start_transaction`
High-level flow:
1. Read OCMF state register and ensure it is `NOT_READY` before starting.
2. Write OCMF transaction registers:
- Identification status/level/flags/type
- Identification data (ID)
- Charging point identifier type + value (EVSE ID)
- Tariff text (TT) as `tariff_text + "<=>" + transaction_id`
- Written as **0-terminated** and only the **used** portion (no full padding).
3. Write session modality (charging vehicle).
4. Write the start command (`'B'`).
### `stop_transaction`
High-level flow:
1. Write end command (`'E'`) if stopping the currently tracked transaction.
2. Wait for OCMF state `READY`.
3. Read OCMF file (size + content).
4. Confirm file read by writing `NOT_READY` to OCMF state.
5. Return OCMF report in `signed_meter_value` (with `public_key` attached).
## Signature validation (recommended)
For troubleshooting and integration testing, this module ships a small signature validation tool under:
`modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/`.
It can validate EM580 OCMF signatures (`ECDSA-brainpoolP384r1-SHA256`) against the public key read from the meter.
See `ocmf_validation/README.md` for usage details.
## Troubleshooting
### No communication / timeouts
Common causes and checks:
- **Wrong unit id**:
- Verify `powermeter_device_id` matches the meters Modbus address.
- If you have multiple meters, ensure each has a unique id (1..247 are typical).
- **Wrong serial settings**:
- Ensure `SerialCommHub` settings match the meter configuration (baudrate, parity).
- If you are unsure, start with conservative settings and increase once stable.
- **RS-485 wiring / termination**:
- Swap A/B if you see only timeouts.
- Ensure termination is correct (only at bus ends).
- Keep cables short / twisted pair; avoid star topologies where possible.
- **Adapter / permissions**:
- Confirm the serial device path exists and is stable (`/dev/ttyUSB0` can change between boots).
- Ensure the EVerest process has permissions to open the device node.
### Repeated `CommunicationFault` raises
The module raises a communication fault when Modbus operations fail and clears it once communication is restored.
If you see frequent toggling:
- Reduce bus load (increase `live_measurement_interval_ms`, especially with multiple meters)
- Increase `communication_retry_count` modestly (and keep `communication_retry_delay_ms` ≥ 10ms)
- Consider increasing `communication_error_pause_delay_s` to reduce retry storms on hard faults
### OCMF transaction does not complete / stuck waiting for READY
- Check that the meter is in the expected OCMF state and is not holding a previous transaction open.
- Ensure the transaction ids passed to `start_transaction` / `stop_transaction` match your intended flow.
- Inspect logs around OCMF state transitions and file readout to see which step fails.
### Tariff text (TT) is truncated
The EM580 TT field is limited to `CHAR[252]`. The driver logs a warning and truncates overlong strings.
Shorten `tariff_text` and/or the appended data (e.g. transaction id formatting).
### Signature verification fails
Use the validation tool in `ocmf_validation/` to validate the produced OCMF string against the published public key.
If it fails:
- Ensure public key and OCMF data come from the **same device** and **same transaction**
- Ensure the OCMF data is not modified (OCMF signatures are over compact JSON)
## Notes / Limitations
- **Write-multiple-registers limit**: the Modbus transport enforces the protocol limit by chunking into max 123 registers.
- **Tariff text length**: TT is a `CHAR[252]` field (126 words). The driver logs a warning and truncates if needed.
## References
- Module docs: `modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/docs/index.rst`
- OCMF spec: see [SAFE-eV OCMF specification](https://github.com/SAFE-eV/OCMF-Open-Charge-Metering-Format)
## Unit tests
Unit tests live under `modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/tests/` and include:
- Helper-level tests (`helper.hpp`)
- `powermeterImpl` behavior tests using a fake Modbus transport and small test hooks
Build/run example (target name may vary by build system settings):
```bash
ninja -C build everest-core_carlo_gavazzi_em580_helper_tests
./build/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/tests/everest-core_carlo_gavazzi_em580_helper_tests
```

View File

@@ -0,0 +1,74 @@
.. _everest_modules_handwritten_CarloGavazzi_EM580:
.. *******************
.. Carlo Gavazzi EM580
.. *******************
Module implementing the Carlo Gavazzi EM580 power meter driver adapter via Modbus RTU (through SerialCommHub).
This module also supports models without OCMF/Eichrecht support (e.g. EM300 series).
Description
===========
The module consists of a single ``main`` implementation that serves the ``powermeter`` interface. Modbus access is done
via the required ``serial_communication_hub`` interface.
Features
========
- Live meter reads and ``powermeter`` publishing (interval configurable)
- Resilient Modbus transport with retries and protocol-compliant write chunking
If supported by meter:
- OCMF/Eichrecht transaction start/stop logic
- Public key reading and publishing (hex)
Module Configuration
====================
The module configuration parameters are defined in ``manifest.yaml``. A complete example configuration can be found at
``config/bringup/config-bringup-CGEM580.yaml``.
Transaction flow (OCMF)
=======================
Start transaction
-----------------
At transaction start the module:
1. Ensures OCMF state is ``NOT_READY``
2. Writes OCMF identification data, EVSE ID and tariff text (TT) (0-terminated, used bytes only)
3. Writes session modality
4. Sends the start command (``'B'``)
Stop transaction
----------------
At transaction stop the module:
1. Sends the end command (``'E'``) for the tracked transaction
2. Waits for OCMF state ``READY``
3. Reads the OCMF file (size + content)
4. Confirms the file read by setting state back to ``NOT_READY``
Notes / Limitations
===================
- Modbus ``Write Multiple Registers`` requests are chunked to max 123 registers per request.
- TT is a ``CHAR[252]`` field (126 words); overlong strings are warned and truncated.
Device identification code (register ``300012`` / ``000Bh``)
----------------------------------------------------------
At startup the driver reads the Carlo Gavazzi **Controls identification code** from Modbus register ``300012``
(``000Bh``) to decide whether OCMF transactions are exposed.
The following identification codes are **explicitly supported** as **EM300/ET300 series** (live metering only;
``start_transaction`` / ``stop_transaction`` return ``NOT_SUPPORTED``): **331**, **332**, **335**, **336**, **340**,
**341**, **345**, **346**, **355**.
Any **other** identification code is treated as an OCMF-capable device (e.g. EM580 class): the full transaction flow
applies. If a new meter without OCMF uses a code not listed above, the driver should be updated to recognise it;
otherwise it may incorrectly attempt the OCMF path.

View File

@@ -0,0 +1,557 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2026 Pionix GmbH and Contributors to EVerest
#ifndef CARLO_GAVAZZI_EM580_HELPER_HPP
#define CARLO_GAVAZZI_EM580_HELPER_HPP
#include <array>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <iomanip>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#include <everest/logging.hpp>
#include "transport.hpp"
#include <generated/interfaces/powermeter/Implementation.hpp>
namespace em580 {
namespace registers {
constexpr std::int32_t MODBUS_BASE_ADDRESS = 300001;
// the following identification register is only accessible/visible when a direct single access is used
constexpr std::int32_t MODBUS_IDENTIFICATION_CODE_ADDRESS = 300012; // 000Bh: Carlo Gavazzi Controls identification code
constexpr std::int32_t MODBUS_SIGNATURE_TYPE_ADDRESS = 309472; // 24FFh: Signature type (UINT16)
constexpr std::int32_t MODBUS_PUBLIC_KEY_ADDRESS = 309473; // 2500h: Public key (UINT16[130])
// DER formatted public key (Table 4.20/4.21), mandatory to read whole block
// from 2600h.
constexpr std::int32_t MODBUS_PUBLIC_KEY_DER_ADDRESS = 309729; // 2600h: Public key DER (read-only)
constexpr std::uint16_t MODBUS_PUBLIC_KEY_DER_WORD_COUNT_256 = 46; // 2600h..262Dh (92 bytes, DER length 0x5A + 2)
constexpr std::uint16_t MODBUS_PUBLIC_KEY_DER_WORD_COUNT_384 = 62; // 2600h..263Dh (124 bytes, DER length 0x7A + 2)
constexpr std::int32_t MODBUS_SIGNED_MAP_ADDRESS = 302049;
constexpr std::uint16_t MODBUS_SIGNED_MAP_WORD_COUNT_256 = 93; // 61 words signed Data + 32 words signature
constexpr std::uint16_t MODBUS_SIGNED_MAP_WORD_COUNT_384 = 109; // 61 words signed Data + 48 words signature
constexpr std::int32_t MODBUS_REAL_TIME_VALUES_ADDRESS = 300001;
// We only need instantaneous values up to 300052 (frequency) for the live polling loop.
// Energy totals are read from 301281+ (INT64, Wh) and signed values from 302049+.
constexpr std::uint16_t MODBUS_REAL_TIME_VALUES_COUNT = 52; // Registers 300001-300052 (52 words)
constexpr std::int32_t MODBUS_REAL_TIME_ENERGY_ADDRESS_EM300_SERIES = 301025;
constexpr std::uint16_t MODBUS_REAL_TIME_ENERGY_COUNT_EM300_SERIES = 12; // Table 2.5-1: 301025-301036 (12 words)
constexpr std::int32_t MODBUS_REAL_TIME_ENERGY_ADDRESS = 301281;
constexpr std::uint16_t MODBUS_REAL_TIME_ENERGY_COUNT = 32; // Registers 301281-301312 (32 words)
constexpr std::int32_t MODBUS_TEMPERATURE_ADDRESS = 300776; // Internal Temperature
constexpr std::int32_t MODBUS_FIRMWARE_MEASURE_MODULE_ADDRESS = 300771; // Measure module firmware version/revision
constexpr std::int32_t MODBUS_FIRMWARE_COMMUNICATION_MODULE_ADDRESS =
300772; // Communication module firmware version/revision
constexpr std::int32_t MODBUS_SERIAL_NUMBER_START_ADDRESS = 320481; // Serial number (7 registers: 320481-320487)
constexpr std::uint16_t MODBUS_SERIAL_NUMBER_REGISTER_COUNT = 7; // 7 UINT16 registers = 14 bytes
constexpr std::int32_t MODBUS_PRODUCTION_YEAR_ADDRESS = 320488; // Production year (1 UINT16 register)
// same register for older series, but with following note in datasheet:
// This register is available only in EM330 and EM340 manufactured from
// October 1st 2018 (from serial number YR2018 274xxxS and following)
constexpr std::int32_t MODBUS_PRODUCTION_YEAR_ADDRESS_EM300_SERIES = 320497;
// Device state register (Table 4.30, Section 4.3.6)
constexpr std::int32_t MODBUS_DEVICE_STATE_ADDRESS = 320499; // 5012h: Device state (UINT16 bitfield)
// Time synchronization registers
constexpr std::int32_t MODBUS_UTC_TIMESTAMP_ADDRESS = 328723; // UTC Timestamp for synchronization (INT64, 4 words)
constexpr std::int32_t MODBUS_TIMEZONE_OFFSET_ADDRESS = 328722; // Local time delta in minutes (INT16, 1 word)
// OCMF Transaction registers (Table 4.34)
constexpr std::int32_t MODBUS_OCMF_IDENTIFICATION_STATUS_ADDRESS = 328673; // 7000h: OCMF Ident. Status (UINT16)
constexpr std::int32_t MODBUS_OCMF_IDENTIFICATION_LEVEL_ADDRESS = 328674; // 7001h: OCMF Ident. Level (UINT16)
constexpr std::int32_t MODBUS_OCMF_IDENTIFICATION_FLAGS_START_ADDRESS =
328675; // 7002h: OCMF Ident. Flags 1-4 (4 UINT16)
constexpr std::uint16_t MODBUS_OCMF_IDENTIFICATION_FLAGS_COUNT = 4; // 4 flags
constexpr std::int32_t MODBUS_OCMF_IDENTIFICATION_TYPE_ADDRESS = 328679; // 7006h: OCMF Ident. Type (UINT16)
constexpr std::int32_t MODBUS_OCMF_IDENTIFICATION_DATA_START_ADDRESS =
328680; // 7007h: OCMF Ident. Data (CHAR[40] = 20 words)
constexpr std::uint16_t MODBUS_OCMF_IDENTIFICATION_DATA_WORD_COUNT = 20; // 40 bytes = 20 words
constexpr std::int32_t MODBUS_OCMF_CHARGING_POINT_ID_TYPE_ADDRESS =
328700; // 701Bh: OCMF Charging point identifier type (UINT16)
constexpr std::int32_t MODBUS_OCMF_CHARGING_POINT_ID_START_ADDRESS = 328701; // 701Ch: OCMF CPI (CHAR[40] = 20 words)
constexpr std::uint16_t MODBUS_OCMF_CHARGING_POINT_ID_WORD_COUNT = 20; // 40 bytes = 20 words
constexpr std::int32_t MODBUS_OCMF_SESSION_MODALITY_ADDRESS = 328727; // 7036h: OCMF Session Modality (UINT16)
constexpr std::uint16_t MODBUS_OCMF_SESSION_MODALITY_CHARGING_VEHICLE = 0; // Charging vehicle
constexpr std::uint16_t MODBUS_OCMF_SESSION_MODALITY_VEHICLE_TO_GRID = 1; // Vehicle to grid
constexpr std::uint16_t MODBUS_OCMF_SESSION_MODALITY_BIDIRECTIONAL = 2; // Bidirectional
// Tariff text register (Table 4.32)
// 326881 (6900h): Tariff text (CHAR[252] = 126 words)
constexpr std::int32_t MODBUS_OCMF_TARIFF_TEXT_ADDRESS = 326881; // 6900h: Tariff text (CHAR[252] = 126 words)
constexpr std::uint16_t MODBUS_OCMF_TARIFF_TEXT_WORD_COUNT = 126; // 252 bytes = 126 words (CHAR[252])
constexpr std::int32_t MODBUS_OCMF_TRANSACTION_ID_GENERATION_ADDRESS =
328417; // 6F00h: OCMF Transaction ID Generation (UINT16)
// Tariff update register (Table 4.33)
constexpr std::int32_t MODBUS_OCMF_TARIFF_UPDATE_ADDRESS = 327085; // 69CCh: Tariff update (UINT16)
// OCMF Command register (Table 4.35)
// The register is UINT16 containing the ASCII code (e.g. 'B', 'E', 'A').
constexpr std::int32_t MODBUS_OCMF_COMMAND_ADDRESS = 328737; // 7040h: OCMF Command Data (UINT16)
constexpr std::uint16_t MODBUS_OCMF_COMMAND_START = 0x42; // Start transaction ('B')
constexpr std::uint16_t MODBUS_OCMF_COMMAND_END = 0x45; // End transaction ('E')
constexpr std::uint16_t MODBUS_OCMF_COMMAND_ABORT = 0x41; // Abort transaction ('A')
// OCMF State / status registers (Table 4.39 and related)
constexpr std::int32_t MODBUS_OCMF_STATE_ADDRESS = 328929; // 7100h: OCMF State (UINT16)
constexpr std::int32_t MODBUS_OCMF_TRANSACTION_ID_ADDRESS = 328931; // 7102h: OCMF Transaction ID (UINT32)
constexpr std::uint16_t MODBUS_OCMF_STATE_NOT_READY = 0; // Not ready
constexpr std::uint16_t MODBUS_OCMF_STATE_RUNNING = 1; // Running
constexpr std::uint16_t MODBUS_OCMF_STATE_READY = 2; // Ready
constexpr std::uint16_t MODBUS_OCMF_STATE_CORRUPTED = 3; // Corrupted
constexpr std::int32_t MODBUS_OCMF_STATE_SIZE_ADDRESS = 328930; // 7101h: OCMF Size (UINT16)
constexpr std::int32_t MODBUS_OCMF_STATE_FILE_ADDRESS = 328945; // 7110h: OCMF File (max theoretically 2031 words)
constexpr std::uint16_t MODBUS_OCMF_STATE_FILE_WORD_COUNT = 2031; // 2031 words = 4062 bytes
constexpr std::int32_t MODBUS_OCMF_CHARGING_STATUS_ADDRESS = 328742; // 7045h: Charging status (UINT16)
constexpr std::int32_t MODBUS_OCMF_LAST_TRANSACTION_ID_ADDRESS = 328762; // 7059h: Last transaction id (CHAR[])
constexpr std::uint16_t MODBUS_OCMF_LAST_TRANSACTION_ID_WORD_COUNT = 7; // 14 bytes = 7 words
constexpr std::int32_t MODBUS_OCMF_TIME_SYNC_STATUS_ADDRESS = 328769; // 7060h: Time synchronization status (UINT16)
} // namespace registers
} // namespace em580
namespace modbus_utils {
inline void check_bounds_or_throw(const transport::DataVector& data, transport::DataVector::size_type offset,
transport::DataVector::size_type needed_bytes, const char* what) {
if (offset > data.size() || needed_bytes > (data.size() - offset)) {
throw std::out_of_range(std::string(what) + ": offset/length out of range (offset=" + std::to_string(offset) +
", needed=" + std::to_string(needed_bytes) + ", size=" + std::to_string(data.size()) +
")");
}
}
// Strong type wrappers to prevent parameter swapping
struct ByteOffset {
explicit ByteOffset(transport::DataVector::size_type val) : value(val) {
}
explicit operator transport::DataVector::size_type() const {
return value;
}
private:
transport::DataVector::size_type value;
};
struct ByteLength {
explicit ByteLength(transport::DataVector::size_type val) : value(val) {
}
explicit operator transport::DataVector::size_type() const {
return value;
}
private:
transport::DataVector::size_type value;
};
inline std::uint32_t to_uint32(const transport::DataVector& data, ByteOffset offset) {
const auto off = static_cast<transport::DataVector::size_type>(offset);
check_bounds_or_throw(data, off, 4, "to_uint32");
return static_cast<std::uint32_t>(data[off] << 24 | data[off + 1] << 16 | data[off + 2] << 8 | data[off + 3]);
}
inline std::int32_t to_int32(const transport::DataVector& data, ByteOffset offset) {
const auto off = static_cast<transport::DataVector::size_type>(offset);
check_bounds_or_throw(data, off, 4, "to_int32");
return static_cast<std::int32_t>(data[off + 2] << 24 | data[off + 3] << 16 | data[off] << 8 | data[off + 1]);
}
inline std::int64_t to_int64(const transport::DataVector& data, ByteOffset offset) {
const auto off = static_cast<transport::DataVector::size_type>(offset);
check_bounds_or_throw(data, off, 8, "to_int64");
// EM580 Modbus spec:
// - Byte order inside a word is MSB -> LSB.
// - Word order for INT64/UINT64 is LSW -> MSW.
const std::uint64_t w0 = (static_cast<std::uint64_t>(data[off]) << 8) | static_cast<std::uint64_t>(data[off + 1]);
const std::uint64_t w1 =
(static_cast<std::uint64_t>(data[off + 2]) << 8) | static_cast<std::uint64_t>(data[off + 3]);
const std::uint64_t w2 =
(static_cast<std::uint64_t>(data[off + 4]) << 8) | static_cast<std::uint64_t>(data[off + 5]);
const std::uint64_t w3 =
(static_cast<std::uint64_t>(data[off + 6]) << 8) | static_cast<std::uint64_t>(data[off + 7]);
const std::uint64_t u = (w0) | (w1 << 16) | (w2 << 32) | (w3 << 48);
return static_cast<std::int64_t>(u);
}
inline std::uint16_t to_uint16(const transport::DataVector& data, ByteOffset offset) {
const auto off = static_cast<transport::DataVector::size_type>(offset);
check_bounds_or_throw(data, off, 2, "to_uint16");
return static_cast<std::uint16_t>(data[off] << 8 | data[off + 1]);
}
inline std::int16_t to_int16(const transport::DataVector& data, ByteOffset offset) {
std::uint16_t raw = to_uint16(data, offset);
return static_cast<std::int16_t>(raw);
}
inline std::string to_hex_string(const transport::DataVector& data, ByteOffset offset, ByteLength length) {
const auto off = static_cast<transport::DataVector::size_type>(offset);
const auto len = static_cast<transport::DataVector::size_type>(length);
check_bounds_or_throw(data, off, len, "to_hex_string");
std::stringstream ss;
for (std::size_t index = 0; index < len; ++index) {
ss << std::uppercase << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(data[off + index]);
}
return ss.str();
}
inline std::size_t max_payload_bytes_for_words(std::size_t max_words) {
const std::size_t capacity_bytes = max_words * 2;
return capacity_bytes > 0 ? capacity_bytes - 1 : 0; // reserve NUL
}
inline void log_truncation_warning_if_needed(const char* field_name, const std::string& value, std::size_t max_words) {
const std::size_t capacity_bytes = max_words * 2;
const std::size_t max_payload_bytes = max_payload_bytes_for_words(max_words);
if (value.size() > max_payload_bytes) {
EVLOG_warning << field_name << " too long (" << value.size() << " bytes). Max is " << max_payload_bytes
<< " bytes (" << max_words << " words / " << capacity_bytes << " bytes incl. NUL). "
<< "It will be truncated.";
}
}
/// Converts a string to a big-endian Modbus CHAR array (vector of UINT16 words)
/// that is **0-terminated** and contains only the **used** part (i.e. no full
/// fixed-length padding).
///
/// - Max capacity is `max_words * 2` bytes.
/// - Ensures a terminating `\\0` byte is present within the returned data.
/// - If `str` is too long, it is truncated to fit `max_words * 2 - 1` bytes (+
/// 1 byte terminator).
inline std::vector<std::uint16_t> string_to_modbus_char_array(const std::string& str, std::size_t max_words) {
const std::size_t max_bytes = max_words * 2;
if (max_bytes == 0) {
return {};
}
const std::size_t used_len = std::min(str.size(), max_bytes - 1); // leave space for terminator
const std::size_t bytes_to_write = used_len + 1; // include terminator byte
const std::size_t words_to_write = (bytes_to_write + 1) / 2; // ceil(bytes/2)
std::vector<std::uint16_t> data(words_to_write, 0);
for (std::size_t i = 0; i < used_len; ++i) {
const std::size_t word_idx = i / 2;
if ((i % 2) == 0) {
data[word_idx] = static_cast<std::uint8_t>(str[i]) << 8;
} else {
data[word_idx] |= static_cast<std::uint8_t>(str[i]);
}
}
return data;
}
} // namespace modbus_utils
namespace ocmf {
/// Confirm OCMF file read by writing NOT_READY (0) into the OCMF state
/// register.
inline void confirm_file_read(transport::AbstractModbusTransport& modbus_transport) {
std::vector<std::uint16_t> ocmf_confirmation_data = {em580::registers::MODBUS_OCMF_STATE_NOT_READY};
modbus_transport.write_multiple_registers(em580::registers::MODBUS_OCMF_STATE_ADDRESS, ocmf_confirmation_data);
}
/// Wait until OCMF state becomes READY (2).
/// @return true if READY, false on CORRUPTED or timeout.
inline bool wait_for_ready(transport::AbstractModbusTransport& modbus_transport,
std::chrono::milliseconds poll_interval = std::chrono::milliseconds{100},
int max_retries = 10) {
std::uint16_t state = em580::registers::MODBUS_OCMF_STATE_NOT_READY;
transport::DataVector state_data;
int retries = 0;
while (state != em580::registers::MODBUS_OCMF_STATE_READY) {
state_data = modbus_transport.fetch(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1);
state = modbus_utils::to_uint16(state_data, modbus_utils::ByteOffset{0});
if (state == em580::registers::MODBUS_OCMF_STATE_CORRUPTED) {
return false;
}
if (state != em580::registers::MODBUS_OCMF_STATE_READY) {
EVLOG_info << "OCMF state: " << state;
std::this_thread::sleep_for(poll_interval);
retries++;
if (retries > max_retries) {
return false;
}
}
}
return true;
}
inline bool is_uuid36(const std::string& s) {
if (s.size() != 36) {
return false;
}
for (std::size_t i = 0; i < s.size(); ++i) {
const char c = s[i];
if (i == 8 || i == 13 || i == 18 || i == 23) {
if (c != '-') {
return false;
}
continue;
}
if (!std::isxdigit(static_cast<unsigned char>(c))) {
return false;
}
}
return true;
}
inline std::optional<std::string> extract_transaction_id_from_ocmf_record(const std::string& ocmf_record) {
const std::string key = "\"TT\"";
std::size_t key_pos = ocmf_record.find(key);
if (key_pos == std::string::npos) {
return std::nullopt;
}
std::size_t colon_pos = ocmf_record.find(':', key_pos + key.size());
if (colon_pos == std::string::npos) {
return std::nullopt;
}
std::size_t value_start = ocmf_record.find('"', colon_pos + 1);
if (value_start == std::string::npos) {
return std::nullopt;
}
++value_start;
std::string tt_value;
tt_value.reserve(128);
bool escaped = false;
for (std::size_t i = value_start; i < ocmf_record.size(); ++i) {
const char c = ocmf_record[i];
if (escaped) {
tt_value.push_back(c);
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
if (c == '"') {
break;
}
tt_value.push_back(c);
}
const std::string marker = "<=>";
const std::size_t marker_pos = tt_value.rfind(marker);
if (marker_pos == std::string::npos) {
return std::nullopt;
}
std::string tail = tt_value.substr(marker_pos + marker.size());
while (!tail.empty() && std::isspace(static_cast<unsigned char>(tail.front()))) {
tail.erase(tail.begin());
}
while (!tail.empty() && std::isspace(static_cast<unsigned char>(tail.back()))) {
tail.pop_back();
}
std::optional<std::string> last_uuid;
if (tail.size() >= 36) {
for (std::size_t i = 0; i + 36 <= tail.size(); ++i) {
const std::string candidate = tail.substr(i, 36);
if (is_uuid36(candidate)) {
last_uuid = candidate;
}
}
}
return last_uuid;
}
/// Extract transaction id (UUID) from a tariff text string.
///
/// Driver convention: tariff text is written as "<user text><=><transaction_id>".
/// Returns the last UUID found after the "<=>" marker.
inline std::optional<std::string> extract_transaction_id_from_tariff_text(const std::string& tariff_text,
std::string_view marker) {
const std::size_t marker_pos = tariff_text.rfind(marker);
if (marker_pos == std::string::npos) {
return std::nullopt;
}
std::string tail = tariff_text.substr(marker_pos + marker.size());
while (!tail.empty() && std::isspace(static_cast<unsigned char>(tail.front()))) {
tail.erase(tail.begin());
}
while (!tail.empty() && std::isspace(static_cast<unsigned char>(tail.back()))) {
tail.pop_back();
}
// The transaction id is appended at the end, so search from the back.
if (tail.size() < 36) {
return std::nullopt;
}
for (std::size_t i = tail.size() - 36 + 1; i-- > 0;) {
const std::string candidate = tail.substr(i, 36);
if (is_uuid36(candidate)) {
return candidate;
}
}
return std::nullopt;
}
inline std::uint16_t flag_to_value(types::powermeter::OCMFIdentificationFlags flag) {
switch (flag) {
case types::powermeter::OCMFIdentificationFlags::RFID_NONE:
return 0;
case types::powermeter::OCMFIdentificationFlags::RFID_PLAIN:
return 1;
case types::powermeter::OCMFIdentificationFlags::RFID_RELATED:
return 2;
case types::powermeter::OCMFIdentificationFlags::RFID_PSK:
return 3;
case types::powermeter::OCMFIdentificationFlags::OCPP_NONE:
return 0;
case types::powermeter::OCMFIdentificationFlags::OCPP_RS:
return 1;
case types::powermeter::OCMFIdentificationFlags::OCPP_AUTH:
return 2;
case types::powermeter::OCMFIdentificationFlags::OCPP_RS_TLS:
return 3;
case types::powermeter::OCMFIdentificationFlags::OCPP_AUTH_TLS:
return 4;
case types::powermeter::OCMFIdentificationFlags::OCPP_CACHE:
return 5;
case types::powermeter::OCMFIdentificationFlags::OCPP_WHITELIST:
return 6;
case types::powermeter::OCMFIdentificationFlags::OCPP_CERTIFIED:
return 7;
case types::powermeter::OCMFIdentificationFlags::ISO15118_NONE:
return 0;
case types::powermeter::OCMFIdentificationFlags::ISO15118_PNC:
return 1;
case types::powermeter::OCMFIdentificationFlags::PLMN_NONE:
return 0;
case types::powermeter::OCMFIdentificationFlags::PLMN_RING:
return 1;
case types::powermeter::OCMFIdentificationFlags::PLMN_SMS:
return 2;
}
return 0;
}
inline std::uint16_t level_to_value(types::powermeter::OCMFIdentificationLevel level) {
switch (level) {
case types::powermeter::OCMFIdentificationLevel::NONE:
return 0;
case types::powermeter::OCMFIdentificationLevel::HEARSAY:
return 1;
case types::powermeter::OCMFIdentificationLevel::TRUSTED:
return 2;
case types::powermeter::OCMFIdentificationLevel::VERIFIED:
return 3;
case types::powermeter::OCMFIdentificationLevel::CERTIFIED:
return 4;
case types::powermeter::OCMFIdentificationLevel::SECURE:
return 5;
case types::powermeter::OCMFIdentificationLevel::MISMATCH:
return 6;
case types::powermeter::OCMFIdentificationLevel::INVALID:
return 7;
case types::powermeter::OCMFIdentificationLevel::OUTDATED:
return 8;
case types::powermeter::OCMFIdentificationLevel::UNKNOWN:
return 9;
}
return 0;
}
inline std::uint16_t type_to_value(types::powermeter::OCMFIdentificationType type) {
switch (type) {
case types::powermeter::OCMFIdentificationType::NONE:
return 0;
case types::powermeter::OCMFIdentificationType::DENIED:
return 1;
case types::powermeter::OCMFIdentificationType::UNDEFINED:
return 2;
case types::powermeter::OCMFIdentificationType::ISO14443:
return 10;
case types::powermeter::OCMFIdentificationType::ISO15693:
return 11;
case types::powermeter::OCMFIdentificationType::EMAID:
return 20;
case types::powermeter::OCMFIdentificationType::EVCCID:
return 21;
case types::powermeter::OCMFIdentificationType::EVCOID:
return 30;
case types::powermeter::OCMFIdentificationType::ISO7812:
return 40;
case types::powermeter::OCMFIdentificationType::CARD_TXN_NR:
return 50;
case types::powermeter::OCMFIdentificationType::CENTRAL:
return 60;
case types::powermeter::OCMFIdentificationType::CENTRAL_1:
return 61;
case types::powermeter::OCMFIdentificationType::CENTRAL_2:
return 62;
case types::powermeter::OCMFIdentificationType::LOCAL:
return 70;
case types::powermeter::OCMFIdentificationType::LOCAL_1:
return 71;
case types::powermeter::OCMFIdentificationType::LOCAL_2:
return 72;
case types::powermeter::OCMFIdentificationType::PHONE_NUMBER:
return 80;
case types::powermeter::OCMFIdentificationType::KEY_CODE:
return 90;
}
return 0;
}
} // namespace ocmf
namespace device_state_utils {
inline std::vector<std::string> decode_device_state_errors(std::uint16_t device_state) {
struct BitError {
const char* message;
std::uint16_t bit;
};
static constexpr std::array<BitError, 12> errors = {{
{"V1N over maximum range", 0U},
{"V2N over maximum range", 1U},
{"V3N over maximum range", 2U},
{"V12 over maximum range", 3U},
{"V23 over maximum range", 4U},
{"V31 over maximum range", 5U},
{"I1 over maximum range", 6U},
{"I2 over maximum range", 7U},
{"I3 over maximum range", 8U},
{"Frequency outside validity range", 9U},
{"EVCS module internal fault", 12U},
{"Measure module internal fault", 13U},
}};
std::vector<std::string> out;
for (const auto& err : errors) {
if ((device_state & static_cast<std::uint16_t>(1U << err.bit)) != 0U) {
out.emplace_back(err.message);
}
}
return out;
}
} // namespace device_state_utils
#endif // CARLO_GAVAZZI_EM580_HELPER_HPP

View File

@@ -0,0 +1,163 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2026 Pionix GmbH and Contributors to EVerest
#ifndef MAIN_POWERMETER_IMPL_HPP
#define MAIN_POWERMETER_IMPL_HPP
//
// AUTO GENERATED - MARKED REGIONS WILL BE KEPT
// template version 3
//
#include <generated/interfaces/powermeter/Implementation.hpp>
#include "../CarloGavazzi_EM580.hpp"
// ev@75ac1216-19eb-4182-a85c-820f1fc2c091:v1
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
#include "transport.hpp"
// ev@75ac1216-19eb-4182-a85c-820f1fc2c091:v1
namespace module {
namespace main {
struct Conf {
int powermeter_device_id;
int communication_retry_count;
int communication_retry_delay_ms;
int communication_error_pause_delay_s;
int initial_connection_retry_count;
int initial_connection_retry_delay_ms;
int timezone_offset_minutes;
int live_measurement_interval_ms;
int device_state_read_interval_ms;
std::string public_key_format;
};
class powermeterImpl : public powermeterImplBase {
public:
powermeterImpl() = delete;
powermeterImpl(Everest::ModuleAdapter* ev, const Everest::PtrContainer<CarloGavazzi_EM580>& mod, Conf& config) :
powermeterImplBase(ev, "main"), mod(mod), config(config){};
// Marker used to append the transaction id to the tariff text (TT field).
// Format: "<tariff_text><=><transaction_id>"
static constexpr std::string_view TARIFF_TEXT_TRANSACTION_ID_MARKER = "<=>";
// ev@8ea32d28-373f-4c90-ae5e-b4fcc74e2a61:v1
// insert your public definitions here
~powermeterImpl() override;
// Test-only access helpers (used by unit tests to avoid spinning up the full
// EVerest runtime). These are intentionally narrow: inject transport + tweak
// minimal internal state + invoke handlers.
struct TestAccess {
static void set_modbus_transport(powermeterImpl& self,
std::unique_ptr<transport::AbstractModbusTransport> transport) {
self.p_modbus_transport = std::move(transport);
}
static void set_pending_closed_transaction(powermeterImpl& self, bool pending) {
self.m_pending_closed_transaction = pending;
}
static void set_transaction_id(powermeterImpl& self, std::string transaction_id) {
self.m_transaction_id = std::move(transaction_id);
self.m_transaction_active.store(true);
}
static void set_public_key_hex(powermeterImpl& self, std::string public_key_hex) {
self.m_public_key_hex = std::move(public_key_hex);
}
static void set_signed_map_word_count(powermeterImpl& self, std::uint16_t signed_map_word_count) {
self.m_signed_map_word_count = signed_map_word_count;
}
static types::powermeter::TransactionStartResponse start_transaction(powermeterImpl& self,
types::powermeter::TransactionReq& req) {
return self.handle_start_transaction(req);
}
static types::powermeter::TransactionStopResponse stop_transaction(powermeterImpl& self,
std::string& transaction_id) {
return self.handle_stop_transaction(transaction_id);
}
};
// ev@8ea32d28-373f-4c90-ae5e-b4fcc74e2a61:v1
protected:
// command handler functions (virtual)
virtual types::powermeter::TransactionStartResponse
handle_start_transaction(types::powermeter::TransactionReq& value) override;
virtual types::powermeter::TransactionStopResponse handle_stop_transaction(std::string& transaction_id) override;
// ev@d2d1847a-7b88-41dd-ad07-92785f06f5c4:v1
// insert your protected definitions here
// ev@d2d1847a-7b88-41dd-ad07-92785f06f5c4:v1
private:
const Everest::PtrContainer<CarloGavazzi_EM580>& mod;
const Conf& config;
virtual void init() override;
virtual void ready() override;
// ev@3370e4dd-95f4-47a9-aaec-ea76f34a66c9:v1
std::unique_ptr<transport::AbstractModbusTransport> p_modbus_transport;
std::optional<types::units_signed::SignedMeterValue> m_start_signed_meter_value;
std::uint16_t m_public_key_length_in_bits;
std::string m_public_key_hex;
std::string m_transaction_id;
std::string m_measure_module_firmware_version;
std::string m_communication_module_firmware_version;
std::string m_serial_number;
std::string m_signature_method_string;
std::uint16_t m_signed_map_word_count{0};
std::atomic_bool m_transaction_active{false};
std::atomic_bool m_pending_time_sync{false};
bool m_pending_closed_transaction{false};
// Background threads (started in ready(), joined on destruction)
std::atomic_bool stop_requested_{false};
std::mutex stop_mutex_;
std::condition_variable stop_cv_;
std::thread live_measure_thread_;
std::thread time_sync_thread_;
// flag whether transactions are supported
bool m_transaction_support{true};
void configure_device();
void read_signature_config();
types::units_signed::SignedMeterValue read_signed_meter_value();
void read_powermeter_values();
void dump_device_state(void);
void read_identification();
void read_firmware_versions();
void read_serial_number();
void read_transaction_state_and_id();
std::string read_ocmf_file();
void synchronize_time();
void set_timezone(int offset_minutes);
void time_sync_thread();
[[nodiscard]] bool is_transaction_active() const;
void clear_transaction_states();
void write_transaction_registers(const types::powermeter::TransactionReq& transaction_req);
void read_device_state();
// ev@3370e4dd-95f4-47a9-aaec-ea76f34a66c9:v1
};
// ev@3d7da0ad-02c2-493d-9920-0bbbd56b9876:v1
// insert other definitions here
// ev@3d7da0ad-02c2-493d-9920-0bbbd56b9876:v1
} // namespace main
} // namespace module
#endif // MAIN_POWERMETER_IMPL_HPP

View File

@@ -0,0 +1,114 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2026 Pionix GmbH and Contributors to EVerest
#include "transport.hpp"
#include <string>
// Modbus protocol limits:
// - Read Input Registers (0x04): typically max 125 registers per request
// - Write Multiple Registers (0x10): max 123 registers per request (PDU size
// limit)
constexpr std::uint16_t MAX_READ_REGISTERS_PER_MESSAGE = 125;
constexpr std::uint16_t MAX_WRITE_REGISTERS_PER_MESSAGE = 123;
namespace transport {
transport::DataVector SerialCommHubTransport::fetch(std::int32_t address, std::uint16_t register_count) {
return retry_with_config([this, address, register_count]() {
transport::DataVector response;
response.reserve(static_cast<std::size_t>(register_count) * 2U); // this is a uint8_t vector
std::uint16_t remaining_register_to_read{register_count};
std::int32_t read_address{address - m_base_address};
while (remaining_register_to_read > 0) {
const std::uint16_t register_to_read = remaining_register_to_read > MAX_READ_REGISTERS_PER_MESSAGE
? MAX_READ_REGISTERS_PER_MESSAGE
: remaining_register_to_read;
types::serial_comm_hub_requests::Result serial_com_hub_result =
m_serial_hub.call_modbus_read_input_registers(static_cast<int>(m_device_id),
static_cast<int>(read_address), register_to_read);
// Check for communication errors
if (serial_com_hub_result.status_code == types::serial_comm_hub_requests::StatusCodeEnum::Timeout) {
throw transport::ModbusTimeoutException("Modbus read timeout: Packet receive timeout");
} else if (serial_com_hub_result.status_code != types::serial_comm_hub_requests::StatusCodeEnum::Success) {
std::string error_msg =
"Modbus read failed with status: " +
types::serial_comm_hub_requests::status_code_enum_to_string(serial_com_hub_result.status_code);
throw std::runtime_error(error_msg);
}
if (not serial_com_hub_result.value.has_value())
throw std::runtime_error("no result from serial com hub!");
// make sure that returned vector is a int32 vector
static_assert(
std::is_same_v<std::int32_t,
decltype(types::serial_comm_hub_requests::Result::value)::value_type::value_type>);
union {
std::int32_t val_32;
struct {
std::uint8_t v3;
std::uint8_t v2;
std::uint8_t v1;
std::uint8_t v0;
} val_8;
} swapit;
static_assert(sizeof(swapit.val_32) == sizeof(swapit.val_8));
transport::DataVector tmp{};
for (auto item : serial_com_hub_result.value.value()) {
swapit.val_32 = item;
tmp.push_back(swapit.val_8.v2);
tmp.push_back(swapit.val_8.v3);
}
response.insert(response.end(), tmp.begin(), tmp.end());
read_address += register_to_read;
remaining_register_to_read -= register_to_read;
}
return response;
});
}
void SerialCommHubTransport::write_multiple_registers(std::int32_t address, const std::vector<std::uint16_t>& data) {
retry_with_config_void([this, address, &data]() {
std::int32_t write_address = address - m_base_address;
std::size_t offset = 0;
while (offset < data.size()) {
const std::size_t remaining = data.size() - offset;
const std::size_t chunk_size = remaining > static_cast<std::size_t>(MAX_WRITE_REGISTERS_PER_MESSAGE)
? static_cast<std::size_t>(MAX_WRITE_REGISTERS_PER_MESSAGE)
: remaining;
types::serial_comm_hub_requests::VectorUint16 data_raw;
data_raw.data.reserve(chunk_size);
for (std::size_t i = 0; i < chunk_size; ++i) {
data_raw.data.push_back(data[offset + i]);
}
types::serial_comm_hub_requests::StatusCodeEnum status = m_serial_hub.call_modbus_write_multiple_registers(
static_cast<int>(m_device_id), static_cast<int>(write_address + static_cast<std::int32_t>(offset)),
data_raw);
if (status == types::serial_comm_hub_requests::StatusCodeEnum::Timeout) {
throw transport::ModbusTimeoutException("Modbus write timeout: Packet receive timeout");
} else if (status != types::serial_comm_hub_requests::StatusCodeEnum::Success) {
std::string error_msg = "Failed to write Modbus registers: " +
types::serial_comm_hub_requests::status_code_enum_to_string(status);
throw std::runtime_error(error_msg);
}
offset += chunk_size;
}
});
}
} // namespace transport

View File

@@ -0,0 +1,249 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2026 Pionix GmbH and Contributors to EVerest
#ifndef POWERMETER_TRANSPORT_HPP
#define POWERMETER_TRANSPORT_HPP
/**
* Baseclass for transport classes.
*
* Transports are:
* - direct connection via modbus
* - connection via SerialComHub
*/
#include <atomic>
#include <chrono>
#include <cstdint>
#include <everest/logging.hpp>
#include <functional>
#include <generated/interfaces/serial_communication_hub/Interface.hpp>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace transport {
using DataVector = std::vector<std::uint8_t>;
// Custom exception to distinguish timeout errors from other Modbus errors
class ModbusTimeoutException : public std::runtime_error {
public:
explicit ModbusTimeoutException(const std::string& message) : std::runtime_error(message) {
}
};
// Error handler callback type: void(error_message)
using ErrorHandler = std::function<void(const std::string&)>;
// Clear error callback type: void()
using ClearErrorHandler = std::function<void()>;
class AbstractModbusTransport {
public:
AbstractModbusTransport() = default;
virtual ~AbstractModbusTransport() = default;
AbstractModbusTransport(const AbstractModbusTransport&) = delete;
AbstractModbusTransport& operator=(const AbstractModbusTransport&) = delete;
AbstractModbusTransport(AbstractModbusTransport&&) = delete;
AbstractModbusTransport& operator=(AbstractModbusTransport&&) = delete;
virtual transport::DataVector fetch(std::int32_t address, std::uint16_t register_count) = 0;
virtual void write_multiple_registers(std::int32_t address, const std::vector<std::uint16_t>& data) = 0;
};
/**
* data transport via SerialComHub
*/
class SerialCommHubTransport : public AbstractModbusTransport {
private:
serial_communication_hubIntf& m_serial_hub;
std::int32_t m_device_id;
std::int32_t m_base_address;
// Retry configuration
std::int32_t m_initial_retry_count;
std::int32_t m_initial_retry_delay_ms;
std::int32_t m_normal_retry_count;
std::int32_t m_normal_retry_delay_ms;
// State tracking
std::atomic_bool m_initial_connection_mode{true};
// Error handling callbacks (optional)
ErrorHandler m_error_handler;
ClearErrorHandler m_clear_error_handler;
// Internal retry helper for functions that return a value
template <typename Func> auto retry_with_config(Func&& func) -> decltype(std::forward<Func>(func)()) {
const bool is_initial = m_initial_connection_mode.load();
const int max_retries = is_initial ? m_initial_retry_count : m_normal_retry_count;
const int delay_ms = is_initial ? m_initial_retry_delay_ms : m_normal_retry_delay_ms;
const bool infinite_retries = is_initial && (m_initial_retry_count == 0);
// For initial connection, 0 means infinite retries
int attempt = 1;
while (infinite_retries || attempt <= max_retries) {
try {
auto result = std::forward<Func>(func)();
// First successful call - switch to normal mode
bool was_initial = m_initial_connection_mode.exchange(false);
// Clear CommunicationFault error if communication is restored
// Only clear if we're not in initial connection mode (i.e., we've had
// at least one successful operation)
if (m_clear_error_handler && !was_initial) {
m_clear_error_handler();
}
return result;
} catch (const ModbusTimeoutException& e) {
// Timeout errors should raise CommunicationFault
const bool should_retry = infinite_retries ? true : attempt < max_retries;
if (should_retry) {
if (infinite_retries) {
EVLOG_warning << "Modbus operation failed (attempt " << attempt
<< ", infinite retries): " << e.what() << ". Retrying in " << delay_ms << "ms...";
} else {
EVLOG_warning << "Modbus operation failed (attempt " << attempt << "/" << max_retries
<< "): " << e.what() << ". Retrying in " << delay_ms << "ms...";
}
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
} else {
EVLOG_error << "Modbus operation failed after " << attempt << " attempts: " << e.what();
// Raise CommunicationFault error for timeout errors
if (m_error_handler) {
m_error_handler("Modbus communication error: " + std::string(e.what()));
}
rethrow_exception(std::current_exception());
}
attempt++;
} catch (const std::exception& e) {
// Other errors (non-timeout) should not raise CommunicationFault
const bool should_retry = infinite_retries ? true : attempt < max_retries;
if (should_retry) {
if (infinite_retries) {
EVLOG_warning << "Modbus operation failed (attempt " << attempt
<< ", infinite retries): " << e.what() << ". Retrying in " << delay_ms << "ms...";
} else {
EVLOG_warning << "Modbus operation failed (attempt " << attempt << "/" << max_retries
<< "): " << e.what() << ". Retrying in " << delay_ms << "ms...";
}
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
} else {
EVLOG_error << "Modbus operation failed after " << attempt << " attempts: " << e.what();
// Don't raise CommunicationFault for non-timeout errors
rethrow_exception(std::current_exception());
}
attempt++;
}
}
// This should never be reached, but needed to satisfy compiler
throw std::runtime_error("Retry loop exited unexpectedly");
}
// Internal retry helper for void functions
template <typename Func> void retry_with_config_void(Func&& func) {
const bool is_initial = m_initial_connection_mode.load();
const int max_retries = is_initial ? m_initial_retry_count : m_normal_retry_count;
const int delay_ms = is_initial ? m_initial_retry_delay_ms : m_normal_retry_delay_ms;
const bool infinite_retries = is_initial && (m_initial_retry_count == 0);
// For initial connection, 0 means infinite retries
int attempt = 1;
while (infinite_retries || attempt <= max_retries) {
try {
std::forward<Func>(func)();
// First successful call - switch to normal mode
bool was_initial = m_initial_connection_mode.exchange(false);
// Clear CommunicationFault error if communication is restored
// Only clear if we're not in initial connection mode (i.e., we've had
// at least one successful operation)
if (m_clear_error_handler && !was_initial) {
m_clear_error_handler();
}
return;
} catch (const ModbusTimeoutException& e) {
// Timeout errors should raise CommunicationFault
const bool should_retry = infinite_retries ? true : attempt < max_retries;
if (should_retry) {
if (infinite_retries) {
EVLOG_warning << "Modbus operation failed (attempt " << attempt
<< ", infinite retries): " << e.what() << ". Retrying in " << delay_ms << "ms...";
} else {
EVLOG_warning << "Modbus operation failed (attempt " << attempt << "/" << max_retries
<< "): " << e.what() << ". Retrying in " << delay_ms << "ms...";
}
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
} else {
EVLOG_error << "Modbus operation failed after " << attempt << " attempts: " << e.what();
// Raise CommunicationFault error for timeout errors
if (m_error_handler) {
m_error_handler("Modbus communication error: " + std::string(e.what()));
}
rethrow_exception(std::current_exception());
}
attempt++;
} catch (const std::exception& e) {
// Other errors (non-timeout) should not raise CommunicationFault
const bool should_retry = infinite_retries ? true : attempt < max_retries;
if (should_retry) {
if (infinite_retries) {
EVLOG_warning << "Modbus operation failed (attempt " << attempt
<< ", infinite retries): " << e.what() << ". Retrying in " << delay_ms << "ms...";
} else {
EVLOG_warning << "Modbus operation failed (attempt " << attempt << "/" << max_retries
<< "): " << e.what() << ". Retrying in " << delay_ms << "ms...";
}
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
} else {
EVLOG_error << "Modbus operation failed after " << attempt << " attempts: " << e.what();
// Don't raise CommunicationFault for non-timeout errors
rethrow_exception(std::current_exception());
}
attempt++;
}
}
}
public:
struct RetryConfig {
std::int32_t initial_retry_count;
std::int32_t initial_retry_delay_ms;
std::int32_t normal_retry_count;
std::int32_t normal_retry_delay_ms;
};
struct TransportConfig {
std::int32_t device_id;
std::int32_t base_address;
RetryConfig retry;
};
SerialCommHubTransport(serial_communication_hubIntf& serial_hub, TransportConfig config) :
SerialCommHubTransport(serial_hub, config, nullptr, nullptr) {
}
SerialCommHubTransport(serial_communication_hubIntf& serial_hub, TransportConfig config, ErrorHandler error_handler,
ClearErrorHandler clear_error_handler) :
m_serial_hub(serial_hub),
m_device_id(config.device_id),
m_base_address(config.base_address),
m_initial_retry_count(config.retry.initial_retry_count),
m_initial_retry_delay_ms(config.retry.initial_retry_delay_ms),
m_normal_retry_count(config.retry.normal_retry_count),
m_normal_retry_delay_ms(config.retry.normal_retry_delay_ms),
m_error_handler(std::move(error_handler)),
m_clear_error_handler(std::move(clear_error_handler)) {
}
transport::DataVector fetch(std::int32_t address, std::uint16_t register_count) override;
void write_multiple_registers(std::int32_t address, const std::vector<std::uint16_t>& data) override;
};
} // namespace transport
#endif // POWERMETER_TRANSPORT_HPP

View File

@@ -0,0 +1,76 @@
description: Carlo Gavazzi EM580 powermeter
provides:
main:
description: Implementation of the driver functionality
interface: powermeter
config:
powermeter_device_id:
description: The powermeter's address on the serial bus
type: integer
minimum: 0
maximum: 255
default: 1
communication_retry_count:
description: Number of retries for communication operations before giving up.
type: integer
minimum: 1
maximum: 100
default: 3
communication_retry_delay_ms:
description: Delay in milliseconds between retry attempts.
type: integer
minimum: 10
maximum: 10000
default: 500
communication_error_pause_delay_s:
description: Delay in seconds before retrying communication in the live measurement thread after a failure. Default 10 seconds. Applies to initial communication too.
type: integer
minimum: 1
maximum: 600
default: 10
initial_connection_retry_count:
description: Number of retries for initial connection/signature config read during module initialization. 0 means infinite retries.
type: integer
minimum: 0
maximum: 100
default: 10
initial_connection_retry_delay_ms:
description: Delay in milliseconds between retry attempts during initialization.
type: integer
minimum: 100
maximum: 60000
default: 2000
timezone_offset_minutes:
description: Timezone offset from UTC in minutes (e.g., 60 for UTC+1, -300 for UTC-5). Range -1440 to +1440 minutes. Default is 0 (UTC).
type: integer
minimum: -1440
maximum: 1440
default: 0
live_measurement_interval_ms:
description: Interval in milliseconds between live powermeter reads and publishes. Default 1000 ms (once per second). Allowed range 500-60000 ms (twice per second to once per minute).
type: integer
minimum: 500
maximum: 60000
default: 1000
device_state_read_interval_ms:
description: Interval in milliseconds between reading the device state bitfield (used for VendorError reporting). Default 10000 ms (once per 10 seconds). Allowed range 500-60000 ms.
type: integer
minimum: 500
maximum: 60000
default: 10000
public_key_format:
description: The key format to use for the public key.
type: string
enum:
- binary
- der
default: binary
requires:
modbus:
interface: serial_communication_hub
metadata:
license: https://opensource.org/licenses/Apache-2.0
authors:
- florin.mihut@pionix.com
enable_external_mqtt: false

View File

@@ -0,0 +1,179 @@
# OCMF Signature Validation
This directory contains tools for validating OCMF (Open Charge Metering Format) signatures from the Carlo Gavazzi EM580 powermeter.
## Overview
The EM580 device signs OCMF transaction data using **ECDSA-brainpoolP384r1-SHA256**. This validation tool verifies the authenticity of OCMF data by checking the digital signature against the device's public key.
## Prerequisites
### Python Dependencies
Install the required Python library:
```bash
pip install cryptography
```
Or if using Nix:
```bash
nix-shell -p "python3.withPackages (ps: with ps; [ cryptography ])"
```
## Files
- **`validate_ocmf_signature.py`** - Main validation script
- **`test_validation.sh`** - Convenience script for quick testing
- **`README.md`** - This file
## Usage
### Method 1: Using the convenience script
Edit `test_validation.sh` to set your public key and OCMF data, then run:
```bash
./test_validation.sh
```
### Method 2: Using the validation script directly
#### Validate OCMF pipe-separated string format
The EM580 device outputs OCMF data in the format: `OCMF|<data_json>|<signature_json>`
```bash
python3 validate_ocmf_signature.py \
--public-key "04521C09090AB6A2826A613D36483A71F789F6C0D900F9A9106415EA8BE3F6AFEB5926B39E264CB3727647DA49B153370221F18048B343AC0318203F7043F840CD8BB5C9C6734C0DB46B19711AD94A0DB8F1FA854E2D60D25B33D7DDE145F61E6C" \
--ocmf-string 'OCMF|{"FV":"1.2",...}|{"SD":"signature_hex","SA":"ECDSA-brainpoolP384r1-SHA256"}'
```
Or read from a file:
```bash
python3 validate_ocmf_signature.py \
--public-key "04<194_hex_chars>" \
--ocmf-string "$(cat ocmf_data.txt)"
```
#### Validate with separate components
```bash
python3 validate_ocmf_signature.py \
--public-key "04<194_hex_chars>" \
--text "data-to-be-signed" \
--signature "<signature_hex>"
```
#### Validate from file
```bash
python3 validate_ocmf_signature.py \
--public-key "04<194_hex_chars>" \
--file data.json \
--signature "<signature_hex>"
```
## Public Key Format
The public key must be in **uncompressed format**:
- Starts with `0x04`
- Followed by X coordinate (48 bytes = 96 hex chars)
- Followed by Y coordinate (48 bytes = 96 hex chars)
- **Total: 97 bytes = 194 hex characters** for P384
The public key can be read from the EM580 device at Modbus register **309473** (address 2500h). For a 384-bit key, read 49 words (98 bytes), but the last byte is unused, so use only the first 97 bytes.
## Signature Format
The signature can be in two formats:
1. **DER format** (ASN.1 encoded) - most common, typically 102-110 bytes
2. **Raw format**: r || s (each 48 bytes for P384, total 96 bytes = 192 hex chars)
The script automatically detects the format.
## OCMF Data Format
The EM580 device outputs OCMF data in a pipe-separated format:
```
OCMF|<data_json>|<signature_json>
```
Where:
- `<data_json>` - JSON object containing all meter data (FV, GI, GS, RD, etc.)
- `<signature_json>` - JSON object with:
- `SD`: The signature in hex format
- `SA`: The signature algorithm (e.g., "ECDSA-brainpoolP384r1-SHA256")
## JSON Normalization
**Important**: OCMF requires signatures to be computed over **compact JSON** (no spaces). The validation script automatically normalizes JSON to compact format before verification.
Example:
- Original: `{"LI": 99,"LR": 0}`
- Compact: `{"LI":99,"LR":0}`
The script handles this normalization automatically.
## Example Output
```
Loading public key...
✓ Public key loaded (brainpoolP384r1)
✓ Parsed OCMF string format
Data length: 828 characters
Signature length: 204 hex characters
⚠ JSON normalization: Original had 828 chars, compact has 825 chars
Using compact JSON format for signature verification (OCMF requirement)
Original hash: acafca116bd433ed0a8ad1200de600adf977d9bdef966bdecb3ec1c3cda2fdcc
Compact hash: fa3020425aaf1d03f8e2bce13f76e60cb098b3bff1664d1d45503b0d9c6b351b
Verifying signature...
Algorithm: ECDSA-brainpoolP384r1-SHA256
Message length: 825 characters (825 bytes)
Message hash (SHA256): fa3020425aaf1d03f8e2bce13f76e60cb098b3bff1664d1d45503b0d9c6b351b
Message preview (first 100 chars): {"FV":"1.2","GI":"Carlo Gavazzi Controls-EM580DINAV23XS3DET","GS":"KZ1660104001D"...
✓ SIGNATURE VALID - The message is authentic!
```
## Troubleshooting
### Signature verification fails
If signature verification fails, check:
1. **Public key**: Ensure it matches the device's current public key (read from register 309473)
2. **Signature**: Ensure it's from the same transaction as the data
3. **Data format**: The script automatically normalizes JSON, but verify the data hasn't been modified
4. **Key/Signature pair**: The public key and signature must be from the same device and transaction
### Common errors
- **"Expected 97 bytes for uncompressed P384 public key"**: The public key format is incorrect. Ensure it's 194 hex characters (97 bytes) starting with `04`.
- **"Invalid hex string"**: Check that the public key and signature contain only valid hexadecimal characters (0-9, A-F).
- **"Signature format not recognized"**: The signature should be either DER format (starts with 0x30) or raw format (96 bytes).
## Technical Details
### Algorithm
- **Curve**: brainpoolP384r1 (Brainpool P-384)
- **Hash**: SHA-256
- **Signature**: ECDSA
### Data-to-be-signed
The device signs the **compact JSON representation** of the OCMF data (the `<data_json>` part, without the "OCMF|" prefix or signature JSON).
### Byte Order
- Public key: Uncompressed format (0x04 || X || Y), big-endian
- Signature: DER format (ASN.1) or raw (r || s), big-endian
## References
- [OCMF Specification](https://github.com/SAFE-eV/OCMF-Open-Charge-Metering-Format)
- EM580 Modbus Communication Protocol document (Table 4.19, 4.21)

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Quick test script for OCMF validation
#
# Usage:
# 1. Edit this script to set your PUBLIC_KEY and OCMF_DATA_FILE
# 2. Run: ./test_validation.sh
#
# Or set environment variables:
# PUBLIC_KEY="04..." OCMF_DATA_FILE="path/to/ocmf.txt" ./test_validation.sh
# Default values - edit these or set as environment variables
PUBLIC_KEY="${PUBLIC_KEY:-04521C09090AB6A2826A613D36483A71F789F6C0D900F9A9106415EA8BE3F6AFEB5926B39E264CB3727647DA49B153370221F18048B343AC0318203F7043F840CD8BB5C9C6734C0DB46B19711AD94A0DB8F1FA854E2D60D25B33D7DDE145F61E6C}"
OCMF_DATA_FILE="${OCMF_DATA_FILE:-./text.txt}"
# Check if OCMF data file exists
if [ ! -f "$OCMF_DATA_FILE" ]; then
echo "Error: OCMF data file not found: $OCMF_DATA_FILE"
echo "Please set OCMF_DATA_FILE environment variable or edit this script."
exit 1
fi
OCMF_DATA=$(cat "$OCMF_DATA_FILE")
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 "$SCRIPT_DIR/validate_ocmf_signature.py" \
--public-key "$PUBLIC_KEY" \
--ocmf-string "$OCMF_DATA"

View File

@@ -0,0 +1 @@
OCMF|{"FV":"1.2","GI":"Carlo Gavazzi Controls-EM580DINAV23XS3DET","GS":"KZ1660104001D","GV":"M_1.6.3-C_1.6.3","PG":"T23","MV":"Carlo Gavazzi Controls","MM":"EM580DINAV23XS3DET","MS":"KZ1660104001D","MF":"M_1.6.3-C_1.6.3","IS":true,"IL":"NONE","IF":[],"IT":"ISO14443","ID":"A1z */-+.()[]{}$%^&*_+-=[];',","TT":"This-is-just-a-long-string-to-test-the-tariff-text-functionality.No-spaces-are-allowed.The-kWh-price-is-0.30-EUR/kWh-just-joking-it-is-2.30-EUR/kWh<=>12345678-1234-5678-1234-567812345678","CT":"EVSEID","CI":"DE*ENBW*BER001*EVSE01","LC":{"LN":"CABLE_LOSS","LI": 99,"LR": 0,"LU": "mOhm"},"RD":[{"TM":"2025-12-17T12:09:16,000+0100 S","TX":"B","RV":1.637,"RI":"1-b:1.8.0","RU":"kWh","RT":"AC","RM":"","ST":"G"},{"TM":"2025-12-17T12:30:30,000+0100 S","TX":"E","RV":1.643,"RI":"1-b:1.8.0","RU":"kWh","RT":"AC","RM":"","ST":"G"}]}|{"SD":"306402306ECEF6E68BF22926278DF470DEA50E12DACA2DCBC54F6EED7B73276EC22795F9D48795608D03EE4639EE11EC7013BC980230633380379E601677F1C1DC0958FE421722ABA8361E30019B34463B9A038229E5063EB54DBDBC9EA63E3F069384FDB72C","SA":"ECDSA-brainpoolP384r1-SHA256"}

View File

@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""
OCMF Signature Validation Script
Validates ECDSA signatures using brainpoolP384r1 curve and SHA256 hash algorithm.
This script can be used to verify the authenticity of OCMF (Open Charge Metering Format) data.
Usage:
python3 validate_ocmf_signature.py --public-key <hex_key> --text <message> --signature <hex_signature>
python3 validate_ocmf_signature.py --public-key <hex_key> --file <json_file> --signature <hex_signature>
python3 validate_ocmf_signature.py --public-key <hex_key> --ocmf-json <ocmf_json_string>
The signature should be in DER format (hex encoded).
The public key should be in uncompressed format (hex encoded, 97 bytes = 194 hex chars for P384).
"""
import argparse
import json
import sys
from hashlib import sha256
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature, decode_dss_signature
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
except ImportError:
print("Error: cryptography library is required. Install it with: pip install cryptography")
sys.exit(1)
def parse_ocmf_json(ocmf_json_str):
"""
Parse OCMF JSON string and extract the data-to-be-signed and signature.
OCMF format structure:
{
"SD": "data-to-be-signed",
"SA": "signature-algorithm",
"SI": "signature"
}
"""
try:
ocmf_data = json.loads(ocmf_json_str)
if "SD" not in ocmf_data or "SI" not in ocmf_data:
raise ValueError("OCMF JSON must contain 'SD' (data) and 'SI' (signature) fields")
return ocmf_data["SD"], ocmf_data["SI"]
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON format: {e}")
def parse_ocmf_string(ocmf_str):
"""
Parse OCMF pipe-separated string format: OCMF|<data_json>|<signature_json>
The signature_json should contain "SD" field with the signature hex.
"""
parts = ocmf_str.split("|", 2)
if len(parts) != 3 or parts[0] != "OCMF":
raise ValueError("Invalid OCMF string format. Expected: OCMF|<data_json>|<signature_json>")
data_json = parts[1]
signature_json_str = parts[2]
# Parse signature JSON to get SD field
try:
signature_json = json.loads(signature_json_str)
signature_hex = signature_json.get("SD", "")
if not signature_hex:
raise ValueError("Signature JSON must contain 'SD' field")
return data_json, signature_hex
except json.JSONDecodeError as e:
raise ValueError(f"Invalid signature JSON format: {e}")
def hex_to_bytes(hex_str):
"""Convert hex string to bytes, handling both with and without 0x prefix."""
hex_str = hex_str.strip()
if hex_str.startswith("0x") or hex_str.startswith("0X"):
hex_str = hex_str[2:]
# Remove any whitespace or separators
hex_str = hex_str.replace(" ", "").replace(":", "").replace("-", "")
try:
return bytes.fromhex(hex_str)
except ValueError as e:
raise ValueError(f"Invalid hex string: {e}")
def load_public_key_from_hex(public_key_hex):
"""
Load ECDSA public key from hex string.
For brainpoolP384r1:
- Uncompressed format: 0x04 || X || Y (97 bytes = 194 hex chars)
- X and Y are each 48 bytes (96 hex chars)
"""
public_key_bytes = hex_to_bytes(public_key_hex)
# Accept DER-encoded SubjectPublicKeyInfo (starts with 0x30) as well.
# This is the format typically returned by devices in a DER key block.
if len(public_key_bytes) >= 2 and public_key_bytes[0] == 0x30:
try:
key = serialization.load_der_public_key(public_key_bytes, backend=default_backend())
except Exception as e:
raise ValueError(f"Failed to load DER public key: {e}")
if not isinstance(key, ec.EllipticCurvePublicKey):
raise ValueError("DER public key is not an EC public key")
if not isinstance(key.curve, ec.BrainpoolP384R1):
raise ValueError(f"DER public key curve mismatch: expected brainpoolP384r1, got {type(key.curve).__name__}")
return key
# For P384, uncompressed key should be 97 bytes (0x04 + 48 bytes X + 48 bytes Y)
if len(public_key_bytes) != 97:
raise ValueError(f"Expected 97 bytes for uncompressed P384 public key, got {len(public_key_bytes)} bytes")
if public_key_bytes[0] != 0x04:
raise ValueError("Uncompressed public key must start with 0x04")
# Extract X and Y coordinates (each 48 bytes)
x = public_key_bytes[1:49]
y = public_key_bytes[49:97]
# Create public key using brainpoolP384r1 curve
public_numbers = ec.EllipticCurvePublicNumbers(
int.from_bytes(x, byteorder='big'),
int.from_bytes(y, byteorder='big'),
ec.BrainpoolP384R1()
)
return public_numbers.public_key(default_backend())
def decode_signature(signature_hex):
"""
Decode signature from hex string.
The signature can be in two formats:
1. DER encoded (ASN.1 format) - standard for ECDSA
2. Raw format: r || s (each 48 bytes for P384)
"""
signature_bytes = hex_to_bytes(signature_hex)
# Try DER format first (most common)
try:
# For P384, DER signature is typically around 104-110 bytes
# Try to decode as DER
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
r, s = decode_dss_signature(signature_bytes)
return r, s
except Exception:
# If DER fails, try raw format: r || s (each 48 bytes = 96 bytes total for P384)
if len(signature_bytes) == 96:
r = int.from_bytes(signature_bytes[:48], byteorder='big')
s = int.from_bytes(signature_bytes[48:], byteorder='big')
return r, s
else:
raise ValueError(f"Signature format not recognized. Expected DER or 96-byte raw format, got {len(signature_bytes)} bytes")
def verify_signature(public_key, message, signature_hex):
"""
Verify ECDSA signature using brainpoolP384r1 and SHA256.
Args:
public_key: ECDSA public key object
message: The message/text to verify (string or bytes)
signature_hex: Signature in hex format (DER or raw)
Returns:
bool: True if signature is valid, False otherwise
"""
# Convert message to bytes if it's a string
if isinstance(message, str):
message_bytes = message.encode('utf-8')
else:
message_bytes = message
# Decode signature
r, s = decode_signature(signature_hex)
# Verify signature
try:
public_key.verify(
encode_dss_signature(r, s),
message_bytes,
ec.ECDSA(hashes.SHA256())
)
return True
except Exception as e:
print(f"Signature verification failed: {e}")
return False
def verify_signature_prehashed(public_key, message, signature_hex):
"""
Verify signature where the device signs SHA256(message) directly (pre-hashed ECDSA).
"""
# Convert message to bytes if it's a string
if isinstance(message, str):
message_bytes = message.encode('utf-8')
else:
message_bytes = message
digest = sha256(message_bytes).digest()
r, s = decode_signature(signature_hex)
try:
public_key.verify(
encode_dss_signature(r, s),
digest,
ec.ECDSA(Prehashed(hashes.SHA256()))
)
return True
except Exception as e:
print(f"Prehashed signature verification failed: {e}")
return False
def main():
parser = argparse.ArgumentParser(
description="Validate ECDSA-brainpoolP384r1-SHA256 signatures for OCMF data",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Validate with separate components
python3 validate_ocmf_signature.py \\
--public-key "04<194_hex_chars>" \\
--text "data-to-be-signed" \\
--signature "<signature_hex>"
# Validate from OCMF pipe-separated string
python3 validate_ocmf_signature.py \\
--public-key "04<194_hex_chars>" \\
--ocmf-string 'OCMF|{"data":"..."}|{"SD":"signature","SA":"ECDSA-brainpoolP384r1-SHA256"}'
# Validate from OCMF JSON string
python3 validate_ocmf_signature.py \\
--public-key "04<194_hex_chars>" \\
--ocmf-json '{"SD":"data","SA":"ECDSA-brainpoolP384r1-SHA256","SI":"signature"}'
# Validate from file
python3 validate_ocmf_signature.py \\
--public-key "04<194_hex_chars>" \\
--file ocmf_data.json \\
--signature "<signature_hex>"
"""
)
parser.add_argument(
'--public-key',
required=True,
help='Public key in hex format (uncompressed, 194 hex chars for P384)'
)
parser.add_argument(
'--text',
help='The text/message to verify (data-to-be-signed)'
)
parser.add_argument(
'--signature',
help='Signature in hex format (DER or raw r||s format)'
)
parser.add_argument(
'--file',
help='Read text from file (UTF-8)'
)
parser.add_argument(
'--ocmf-json',
help='OCMF JSON string containing SD (data) and SI (signature) fields'
)
parser.add_argument(
'--ocmf-string',
help='OCMF pipe-separated string format: OCMF|<data_json>|<signature_json>'
)
parser.add_argument(
'--dump-candidates',
action='store_true',
help='Dump the exact message candidates that are attempted for verification (also written to /tmp).'
)
args = parser.parse_args()
# Load public key
try:
print("Loading public key...")
public_key = load_public_key_from_hex(args.public_key)
print(f"✓ Public key loaded (brainpoolP384r1)")
except Exception as e:
print(f"✗ Error loading public key: {e}")
sys.exit(1)
# Determine message and signature
message = None
signature = None
if args.ocmf_string:
# Parse OCMF pipe-separated string
try:
message, signature = parse_ocmf_string(args.ocmf_string)
print(f"✓ Parsed OCMF string format")
print(f" Data length: {len(message)} characters")
print(f" Signature length: {len(signature)} hex characters")
except Exception as e:
print(f"✗ Error parsing OCMF string: {e}")
sys.exit(1)
elif args.ocmf_json:
# Parse OCMF JSON
try:
message, signature = parse_ocmf_json(args.ocmf_json)
print(f"✓ Parsed OCMF JSON")
print(f" Data length: {len(message)} characters")
print(f" Signature length: {len(signature)} hex characters")
except Exception as e:
print(f"✗ Error parsing OCMF JSON: {e}")
sys.exit(1)
elif args.file:
# Read from file
if not args.signature:
print("✗ Error: --signature is required when using --file")
sys.exit(1)
try:
with open(args.file, 'r', encoding='utf-8') as f:
message = f.read()
signature = args.signature
print(f"✓ Read message from file: {args.file}")
print(f" Message length: {len(message)} characters")
except Exception as e:
print(f"✗ Error reading file: {e}")
sys.exit(1)
elif args.text and args.signature:
# Direct text and signature
message = args.text
signature = args.signature
print(f"✓ Using provided text and signature")
print(f" Message length: {len(message)} characters")
else:
print("✗ Error: Must provide either --ocmf-string, --ocmf-json, or (--text and --signature), or (--file and --signature)")
sys.exit(1)
# Normalize JSON for OCMF (compact format, no spaces)
# NOTE: Do NOT normalize / compact JSON. We verify exactly the extracted JSON bytes.
# If the device signs compact JSON, the device output must already be compact.
# Verify signature
print("\nVerifying signature...")
print(f" Algorithm: ECDSA-brainpoolP384r1-SHA256")
# Double-check what we're about to hash
message_bytes = message.encode('utf-8') if isinstance(message, str) else message
final_hash = sha256(message_bytes).hexdigest()
print(f" Message length: {len(message)} characters ({len(message_bytes)} bytes)")
print(f" Message hash (SHA256): {final_hash}")
print(f" Message preview (first 100 chars): {message[:100]}...")
try:
# Verify exactly what we received as <data_json> from the OCMF pipe string.
candidates = [("json_exact", message)]
if isinstance(message, str):
candidates.extend(
[
("ocmf_prefix_json_exact", "OCMF|" + message),
("json_exact_nullterm", message + "\x00"),
("ocmf_prefix_json_exact_nullterm", "OCMF|" + message + "\x00"),
]
)
def dump_candidate(label, candidate_value):
if not args.dump_candidates:
return
if isinstance(candidate_value, str):
b = candidate_value.encode("utf-8")
else:
b = candidate_value
print(f"\n--- candidate:{label} ---")
print(f"len(chars)={len(candidate_value) if isinstance(candidate_value, str) else 'n/a'} len(bytes)={len(b)}")
print("sha256(bytes)=", sha256(b).hexdigest())
# Show a safe representation so NUL and other non-printables are visible.
preview = candidate_value if isinstance(candidate_value, str) else b.decode("utf-8", errors="replace")
print("repr=", repr(preview))
out_path = f"/tmp/ocmf_message_candidate_{label}.txt"
with open(out_path, "wb") as f:
f.write(b)
print("written=", out_path)
for name, candidate in candidates:
dump_candidate(name, candidate)
print(f"\nAttempt: {name} (standard ECDSA over message bytes)")
if verify_signature(public_key, candidate, signature):
print("\n✓ SIGNATURE VALID - The message is authentic!")
sys.exit(0)
print(f"Attempt: {name} (prehashed ECDSA over SHA256(message))")
if verify_signature_prehashed(public_key, candidate, signature):
print("\n✓ SIGNATURE VALID - The message is authentic!")
sys.exit(0)
# Some devices sign the ASCII hex digest rather than the raw message (rare, but cheap to test).
if isinstance(candidate, str):
digest_hex = sha256(candidate.encode("utf-8")).hexdigest()
dump_candidate(f"{name}_sha256hex", digest_hex)
print(f"Attempt: {name} (standard ECDSA over sha256(message).hexdigest() bytes)")
if verify_signature(public_key, digest_hex, signature):
print("\n✓ SIGNATURE VALID - The message is authentic!")
sys.exit(0)
print(f"Attempt: {name} (prehashed ECDSA over SHA256(sha256(message).hexdigest()))")
if verify_signature_prehashed(public_key, digest_hex, signature):
print("\n✓ SIGNATURE VALID - The message is authentic!")
sys.exit(0)
print("\n✗ SIGNATURE INVALID - none of the tried variants matched.")
sys.exit(1)
except Exception as e:
print(f"\n✗ Error during verification: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,38 @@
set(TEST_TARGET_NAME ${PROJECT_NAME}_carlo_gavazzi_em580_helper_tests)
set(MODULE_DIR "${PROJECT_SOURCE_DIR}/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580")
add_executable(${TEST_TARGET_NAME}
test_em580_helper.cpp
test_em580_powermeter_impl.cpp
${MODULE_DIR}/main/powermeterImpl.cpp
${MODULE_DIR}/main/transport.cpp
)
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
set(INCLUDE_DIR
"main"
"tests"
"${MODULE_DIR}/main"
"${MODULE_DIR}/tests"
)
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
target_include_directories(${TEST_TARGET_NAME} PRIVATE
tests
${INCLUDE_DIR}
${GENERATED_INCLUDE_DIR}
${CMAKE_BINARY_DIR}/generated/modules/CarloGavazzi_EM580
)
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
GTest::gtest_main
everest::framework
)
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
ev_register_test_target(${TEST_TARGET_NAME})

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
EVEREST_DIR="$(cd -- "${SCRIPT_DIR}/../../../../.." && pwd)"
DIST_DIR="${EVEREST_DIR}/build/dist"
DIST_ETC_DIR="${DIST_DIR}/etc/everest"
MANAGER_BIN="${DIST_DIR}/bin/manager"
BUPOWERMETER_BIN="${DIST_DIR}/libexec/everest/modules/BUPowermeter/BUPowermeter"
if [[ ! -x "${MANAGER_BIN}" ]]; then
echo "ERROR: manager binary not found/executable at: ${MANAGER_BIN}" >&2
echo "Did you build EVerest and generate the dist/ folder?" >&2
exit 1
fi
if [[ ! -x "${BUPOWERMETER_BIN}" ]]; then
echo "ERROR: BUPowermeter binary not found/executable at: ${BUPOWERMETER_BIN}" >&2
echo "Did you build EVerest and generate the dist/ folder?" >&2
exit 1
fi
if [[ ! -d "${DIST_ETC_DIR}" ]]; then
echo "ERROR: dist etc dir not found at: ${DIST_ETC_DIR}" >&2
exit 1
fi
other_pids=()
cleanup() {
for pid in "${other_pids[@]:-}"; do
kill "${pid}" 2>/dev/null || true
done
}
trap cleanup EXIT INT TERM
read -r -p "Start CGEM580 bringup with 1, 6, 7, 12 or 13 devices? [1/6/7/12/13]: " device_count
case "${device_count}" in
1)
config_file="config-bringup-CGEM580.yaml"
;;
6)
config_file="config-bringup-CGEM580-6x.yaml"
;;
7)
config_file="config-bringup-CGEM580-7x.yaml"
;;
12)
config_file="config-bringup-CGEM580-12x.yaml"
;;
13)
config_file="config-bringup-CGEM580-13x.yaml"
;;
*)
echo "Invalid choice: '${device_count}'. Please enter 1, 6, 7, 12 or 13."
exit 2
;;
esac
if [[ ! -f "${DIST_ETC_DIR}/${config_file}" ]]; then
echo "ERROR: config file not found at: ${DIST_ETC_DIR}/${config_file}" >&2
exit 1
fi
# Start manager first, then powermeters. When the manager window closes, all others will be closed as well.
# Important: run with CWD in ${DIST_ETC_DIR} (matches previous scripts and avoids any CWD-sensitive behavior).
xterm -bg black -fg white -geometry 400x150 -e bash -lc "cd \"${DIST_ETC_DIR}\" && \"${MANAGER_BIN}\" --prefix \"${DIST_DIR}\" --conf \"${config_file}\"" &
manager_pid=$!
for i in $(seq 1 "${device_count}"); do
xterm -bg black -fg white -geometry 200x55 -e bash -lc "cd \"${DIST_ETC_DIR}\" && sleep 1 && \"${BUPOWERMETER_BIN}\" --module cli_${i}" &
other_pids+=($!)
done
wait "${manager_pid}"

View File

@@ -0,0 +1,249 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest
#include <gtest/gtest.h>
#include "helper.hpp"
#include <chrono>
#include <deque>
#include <map>
#include <tuple>
namespace {
constexpr std::size_t kFetchCallAlignmentBytes = 8;
constexpr std::size_t kWriteCallAlignmentBytes = 32;
struct alignas(kFetchCallAlignmentBytes) FetchCall {
std::int32_t address;
std::uint16_t register_count;
};
struct alignas(kWriteCallAlignmentBytes) WriteCall {
std::int32_t address;
std::vector<std::uint16_t> data;
};
class FakeModbusTransport : public transport::AbstractModbusTransport {
public:
// Script one fetch response for (address, register_count).
void push_fetch_response(std::int32_t address, std::uint16_t register_count, transport::DataVector response) {
scripted_fetch_[Key{address, register_count}].push_back(std::move(response));
}
const std::vector<FetchCall>& fetch_calls() const {
return fetch_calls_;
}
const std::vector<WriteCall>& write_calls() const {
return write_calls_;
}
transport::DataVector fetch(std::int32_t address, std::uint16_t register_count) override {
fetch_calls_.push_back(FetchCall{address, register_count});
const Key key{address, register_count};
auto iter = scripted_fetch_.find(key);
if (iter == scripted_fetch_.end() || iter->second.empty()) {
throw std::runtime_error("FakeModbusTransport: no scripted fetch response for address/count");
}
transport::DataVector out = std::move(iter->second.front());
iter->second.pop_front();
return out;
}
void write_multiple_registers(std::int32_t address, const std::vector<std::uint16_t>& data) override {
write_calls_.push_back(WriteCall{address, data});
}
private:
using Key = std::tuple<std::int32_t, std::uint16_t>;
std::map<Key, std::deque<transport::DataVector>> scripted_fetch_;
std::vector<FetchCall> fetch_calls_;
std::vector<WriteCall> write_calls_;
};
transport::DataVector u16_be(std::uint16_t value) {
constexpr std::uint32_t kByteBits = 8U;
constexpr std::uint32_t kByteMask = 0xFFU;
const auto high = static_cast<std::uint8_t>((static_cast<std::uint32_t>(value) >> kByteBits) & kByteMask);
const auto low = static_cast<std::uint8_t>(static_cast<std::uint32_t>(value) & kByteMask);
return transport::DataVector{high, low};
}
} // namespace
TEST(EM580Helper, ExtractTransactionIdFromTTHappyPath) {
const std::string uuid = "12345678-1234-5678-1234-567812345678";
const std::string ocmf = R"(OCMF|{"TT":"price-2.30-EUR/kWh<=>)" + uuid + R"(","RD":[]}|
{"SA":"ECDSA-brainpoolP384r1-SHA256","SD":"00"})";
const auto tid = ocmf::extract_transaction_id_from_ocmf_record(ocmf);
ASSERT_TRUE(tid.has_value());
EXPECT_EQ(*tid, uuid);
}
TEST(EM580Helper, ExtractTransactionIdFromTTMissingMarker) {
const std::string ocmf = R"(OCMF|{"TT":"no-marker-here","RD":[]}|
{"SA":"ECDSA-brainpoolP384r1-SHA256","SD":"00"})";
const auto tid = ocmf::extract_transaction_id_from_ocmf_record(ocmf);
EXPECT_FALSE(tid.has_value());
}
TEST(EM580Helper, MaxPayloadBytesForWords) {
EXPECT_EQ(modbus_utils::max_payload_bytes_for_words(0), 0);
EXPECT_EQ(modbus_utils::max_payload_bytes_for_words(1),
1); // 2 bytes total, reserve NUL => 1
EXPECT_EQ(modbus_utils::max_payload_bytes_for_words(126),
251); // 252 bytes total, reserve NUL => 251
}
TEST(EM580Helper, StringToModbusCharArrayZeroTerminatedAndUsedOnly) {
const auto words = modbus_utils::string_to_modbus_char_array("AB", 126);
ASSERT_EQ(words.size(), 2U); // 'A''B''\0' => 3 bytes => 2 words
EXPECT_EQ(words[0], 0x4142);
EXPECT_EQ(words[1], 0x0000);
}
TEST(EM580Helper, StringToModbusCharArrayTruncatesToFitWithNul) {
// 1 word => 2 bytes total => only 1 byte payload + NUL
const auto words = modbus_utils::string_to_modbus_char_array("HELLO", 1);
ASSERT_EQ(words.size(), 1U);
EXPECT_EQ(words[0], 0x4800); // 'H' + '\0'
}
TEST(EM580Helper, OcmfConfirmFileReadWritesNotReadyToStateRegister) {
FakeModbusTransport transport;
ocmf::confirm_file_read(transport);
ASSERT_EQ(transport.write_calls().size(), 1U);
EXPECT_EQ(transport.write_calls()[0].address, em580::registers::MODBUS_OCMF_STATE_ADDRESS);
ASSERT_EQ(transport.write_calls()[0].data.size(), 1U);
EXPECT_EQ(transport.write_calls()[0].data[0], em580::registers::MODBUS_OCMF_STATE_NOT_READY);
}
TEST(EM580Helper, OcmfWaitForReadyReachesReady) {
FakeModbusTransport transport;
transport.push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_NOT_READY));
transport.push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_RUNNING));
transport.push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_READY));
const bool success = ocmf::wait_for_ready(transport, std::chrono::milliseconds{0}, 10);
EXPECT_TRUE(success);
EXPECT_EQ(transport.fetch_calls().size(), 3U);
}
TEST(EM580Helper, OcmfWaitForReadyFailsOnCorrupted) {
FakeModbusTransport transport;
transport.push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_CORRUPTED));
const bool success = ocmf::wait_for_ready(transport, std::chrono::milliseconds{0}, 10);
EXPECT_FALSE(success);
EXPECT_EQ(transport.fetch_calls().size(), 1U);
}
TEST(EM580Helper, IsUuid36ValidAndInvalid) {
EXPECT_TRUE(ocmf::is_uuid36("12345678-1234-5678-1234-567812345678"));
EXPECT_TRUE(ocmf::is_uuid36("ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB"));
EXPECT_FALSE(ocmf::is_uuid36("12345678-1234-5678-1234-56781234567")); // too short
EXPECT_FALSE(ocmf::is_uuid36("123456781234-5678-1234-567812345678")); // missing '-'
EXPECT_FALSE(ocmf::is_uuid36("12345678-1234-5678-1234-56781234567Z")); // non-hex
}
TEST(EM580Helper, ExtractTransactionIdFromRecordMissingTTField) {
const std::string ocmf = R"(OCMF|{"FV":"1.2","RD":[]}|
{"SA":"ECDSA-brainpoolP384r1-SHA256","SD":"00"})";
const auto tid = ocmf::extract_transaction_id_from_ocmf_record(ocmf);
EXPECT_FALSE(tid.has_value());
}
TEST(EM580Helper, ExtractTransactionIdFromRecordInvalidUuidAfterMarker) {
const std::string ocmf = R"(OCMF|{"TT":"foo<=>not-a-uuid","RD":[]}|
{"SA":"ECDSA-brainpoolP384r1-SHA256","SD":"00"})";
const auto tid = ocmf::extract_transaction_id_from_ocmf_record(ocmf);
EXPECT_FALSE(tid.has_value());
}
TEST(EM580Helper, DecodeDeviceStateErrorsReturnsMatchingMessages) {
// Bits 0 and 13 correspond to V1N over-range and Measure module internal
// fault.
const auto state = static_cast<std::uint16_t>((1U << 0U) | (1U << 13U));
const auto errors = device_state_utils::decode_device_state_errors(state);
ASSERT_EQ(errors.size(), 2U);
EXPECT_EQ(errors[0], "V1N over maximum range");
EXPECT_EQ(errors[1], "Measure module internal fault");
}
TEST(EM580Helper, ModbusToUint16AndToUint32ByteOrder) {
const transport::DataVector data = {
0x12, 0x34, // u16 @0 => 0x1234
0xAA, 0xBB, // u16 @2 => 0xAABB
0xDE, 0xAD, 0xBE, 0xEF // u32 @4 => 0xDEADBEEF
};
EXPECT_EQ(modbus_utils::to_uint16(data, modbus_utils::ByteOffset{0}), 0x1234);
EXPECT_EQ(modbus_utils::to_uint16(data, modbus_utils::ByteOffset{2}), 0xAABB);
EXPECT_EQ(modbus_utils::to_uint32(data, modbus_utils::ByteOffset{4}), 0xDEADBEEF);
}
TEST(EM580Helper, StringToModbusCharArrayEmptyStringIsJustTerminator) {
const auto words = modbus_utils::string_to_modbus_char_array("", 126);
ASSERT_EQ(words.size(), 1U);
EXPECT_EQ(words[0], 0x0000);
}
TEST(EM580Helper, StringToModbusCharArrayPacksOddLengthAndAddsNul) {
// "ABC\0" => 4 bytes => 2 words: 0x4142 0x4300
const auto words = modbus_utils::string_to_modbus_char_array("ABC", 126);
ASSERT_EQ(words.size(), 2U);
EXPECT_EQ(words[0], 0x4142);
EXPECT_EQ(words[1], 0x4300);
}
TEST(EM580Helper, StringToModbusCharArrayTruncatesAndStillTerminates) {
// max_words=2 => 4 bytes total => 3 payload bytes + NUL
const auto words = modbus_utils::string_to_modbus_char_array("HELLO", 2);
ASSERT_EQ(words.size(), 2U);
EXPECT_EQ(words[0], 0x4845); // 'H''E'
EXPECT_EQ(words[1], 0x4C00); // 'L''\0' (truncated)
}
TEST(EM580Helper, OcmfWaitForReadyTimesOutAfterMaxRetries) {
FakeModbusTransport transport;
static constexpr int kNonReadyReads = 11;
// kNonReadyReads non-ready reads => retries becomes kNonReadyReads and
// (retries > max_retries(10)) => false
#pragma unroll
for (int i = 0; i < kNonReadyReads; ++i) {
transport.push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_NOT_READY));
}
const bool success = ocmf::wait_for_ready(transport, std::chrono::milliseconds{0}, 10);
EXPECT_FALSE(success);
EXPECT_EQ(transport.fetch_calls().size(), static_cast<std::size_t>(kNonReadyReads));
}
TEST(EM580Helper, DecodeDeviceStateErrorsEmptyIfNoBitsSet) {
const auto errors = device_state_utils::decode_device_state_errors(0U);
EXPECT_TRUE(errors.empty());
}
TEST(EM580Helper, ModbusToHexStringUppercaseNoSeparators) {
const transport::DataVector data = {0x00, 0x2a, 0xAB, 0xCD, 0xEF};
EXPECT_EQ(modbus_utils::to_hex_string(data, modbus_utils::ByteOffset{0}, modbus_utils::ByteLength{data.size()}),
"002AABCDEF");
}
TEST(EM580Helper, ModbusToInt16Sign) {
const transport::DataVector data = {0xFF, 0xFE}; // 0xFFFE => -2
EXPECT_EQ(modbus_utils::to_int16(data, modbus_utils::ByteOffset{0}), -2);
}

View File

@@ -0,0 +1,243 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest
#include <gtest/gtest.h>
#include "helper.hpp"
#include "powermeterImpl.hpp"
#include <deque>
#include <map>
#include <string_view>
#include <tuple>
namespace {
constexpr std::size_t kFetchCallAlignmentBytes = 8;
constexpr std::size_t kWriteCallAlignmentBytes = 32;
constexpr std::size_t kBytesPerRegister = 2;
struct alignas(kFetchCallAlignmentBytes) FetchCall {
std::int32_t address;
std::uint16_t register_count;
};
struct alignas(kWriteCallAlignmentBytes) WriteCall {
std::int32_t address;
std::vector<std::uint16_t> data;
};
class FakeModbusTransport : public transport::AbstractModbusTransport {
public:
void push_fetch_response(std::int32_t address, std::uint16_t register_count, transport::DataVector response) {
scripted_fetch_[Key{address, register_count}].push_back(std::move(response));
}
const std::vector<FetchCall>& fetch_calls() const {
return fetch_calls_;
}
const std::vector<WriteCall>& write_calls() const {
return write_calls_;
}
transport::DataVector fetch(std::int32_t address, std::uint16_t register_count) override {
fetch_calls_.push_back(FetchCall{address, register_count});
const Key key{address, register_count};
auto iter = scripted_fetch_.find(key);
if (iter == scripted_fetch_.end() || iter->second.empty()) {
throw std::runtime_error("FakeModbusTransport: no scripted fetch response for address/count");
}
transport::DataVector out = std::move(iter->second.front());
iter->second.pop_front();
return out;
}
void write_multiple_registers(std::int32_t address, const std::vector<std::uint16_t>& data) override {
write_calls_.push_back(WriteCall{address, data});
}
private:
using Key = std::tuple<std::int32_t, std::uint16_t>;
std::map<Key, std::deque<transport::DataVector>> scripted_fetch_;
std::vector<FetchCall> fetch_calls_;
std::vector<WriteCall> write_calls_;
};
transport::DataVector u16_be(std::uint16_t value) {
constexpr std::uint32_t kByteBits = 8U;
constexpr std::uint32_t kByteMask = 0xFFU;
const auto high = static_cast<std::uint8_t>((static_cast<std::uint32_t>(value) >> kByteBits) & kByteMask);
const auto low = static_cast<std::uint8_t>(static_cast<std::uint32_t>(value) & kByteMask);
return transport::DataVector{high, low};
}
transport::DataVector bytes(std::string_view str) {
return transport::DataVector{str.begin(), str.end()};
}
transport::DataVector zero_bytes_for_words(std::uint16_t words) {
return transport::DataVector(words * kBytesPerRegister, 0U);
}
module::main::Conf make_test_conf() {
constexpr int kDefaultIntervalMs = 1000;
module::main::Conf conf{};
conf.powermeter_device_id = 1;
conf.communication_retry_count = 0;
conf.communication_retry_delay_ms = 0;
conf.initial_connection_retry_count = 0;
conf.initial_connection_retry_delay_ms = 0;
conf.timezone_offset_minutes = 0;
conf.live_measurement_interval_ms = kDefaultIntervalMs;
conf.device_state_read_interval_ms = kDefaultIntervalMs;
conf.communication_error_pause_delay_s = 0;
return conf;
}
} // namespace
TEST(EM580PowermeterImpl, StartTransactionHappyPathCountsWrites) {
static Everest::PtrContainer<module::CarloGavazzi_EM580> dummy_mod;
auto conf = make_test_conf();
module::main::powermeterImpl impl(nullptr, dummy_mod, conf);
auto transport = std::make_unique<FakeModbusTransport>();
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_NOT_READY));
transport->push_fetch_response(em580::registers::MODBUS_SIGNED_MAP_ADDRESS,
em580::registers::MODBUS_SIGNED_MAP_WORD_COUNT_256,
zero_bytes_for_words(em580::registers::MODBUS_SIGNED_MAP_WORD_COUNT_256));
auto* transport_ptr = transport.get();
module::main::powermeterImpl::TestAccess::set_modbus_transport(impl, std::move(transport));
module::main::powermeterImpl::TestAccess::set_signed_map_word_count(
impl, em580::registers::MODBUS_SIGNED_MAP_WORD_COUNT_256);
types::powermeter::TransactionReq req{};
req.evse_id = "DE*TEST*EVSE01";
req.transaction_id = "12345678-1234-5678-1234-567812345678";
req.identification_status = types::powermeter::OCMFUserIdentificationStatus::ASSIGNED;
req.identification_flags = {};
req.identification_type = types::powermeter::OCMFIdentificationType::ISO14443;
req.identification_level = types::powermeter::OCMFIdentificationLevel::NONE;
req.identification_data.emplace("ABC");
req.tariff_text.emplace("TARIFF");
const auto resp = module::main::powermeterImpl::TestAccess::start_transaction(impl, req);
EXPECT_EQ(resp.status, types::powermeter::TransactionRequestStatus::OK);
// Happy-path expectation: 8 writes from write_transaction_registers + 2
// additional writes (session modality + 'B')
EXPECT_EQ(transport_ptr->write_calls().size(), 10U);
}
TEST(EM580PowermeterImpl, StopTransactionPendingClosedTransactionMismatchReturnsError) {
static Everest::PtrContainer<module::CarloGavazzi_EM580> dummy_mod;
auto conf = make_test_conf();
module::main::powermeterImpl impl(nullptr, dummy_mod, conf);
auto transport = std::make_unique<FakeModbusTransport>();
// read_ocmf_file(): size then file bytes
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_SIZE_ADDRESS, 1, u16_be(1));
const std::string other_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const std::string ocmf_file = R"(OCMF|{"TT":"x<=>)" + other_uuid + R"("}|
{"SA":"ECDSA-brainpoolP384r1-SHA256","SD":"00"})";
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_FILE_ADDRESS, 1, bytes(ocmf_file));
auto* transport_ptr = transport.get();
module::main::powermeterImpl::TestAccess::set_modbus_transport(impl, std::move(transport));
module::main::powermeterImpl::TestAccess::set_pending_closed_transaction(impl, true);
std::string requested_uuid = "12345678-1234-5678-1234-567812345678";
const auto resp = module::main::powermeterImpl::TestAccess::stop_transaction(impl, requested_uuid);
EXPECT_EQ(resp.status, types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR);
ASSERT_TRUE(resp.error.has_value());
EXPECT_EQ(*resp.error, "Transaction id mismatch");
EXPECT_EQ(transport_ptr->write_calls().size(), 0U);
}
TEST(EM580PowermeterImpl, StopTransactionEmptyIdWithoutPendingClosedTransactionCleansUpAndReturnsOk) {
static Everest::PtrContainer<module::CarloGavazzi_EM580> dummy_mod;
auto conf = make_test_conf();
module::main::powermeterImpl impl(nullptr, dummy_mod, conf);
auto transport = std::make_unique<FakeModbusTransport>();
// clear_transaction_states() fetches OCMF state once; use NOT_READY to avoid
// extra cleanup behavior.
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_NOT_READY));
auto* transport_ptr = transport.get();
module::main::powermeterImpl::TestAccess::set_modbus_transport(impl, std::move(transport));
module::main::powermeterImpl::TestAccess::set_transaction_id(impl, "12345678-1234-5678-1234-567812345678");
module::main::powermeterImpl::TestAccess::set_pending_closed_transaction(impl, false);
std::string empty_id;
const auto resp = module::main::powermeterImpl::TestAccess::stop_transaction(impl, empty_id);
EXPECT_EQ(resp.status, types::powermeter::TransactionRequestStatus::OK);
// Expect one write ('E' end command) when no pending closed transaction
// exists.
EXPECT_EQ(transport_ptr->write_calls().size(), 1U);
}
TEST(EM580PowermeterImpl, StartTransactionSpuriousReadyStateDoesCleanupAndNoTransactionWrites) {
static Everest::PtrContainer<module::CarloGavazzi_EM580> dummy_mod;
auto conf = make_test_conf();
module::main::powermeterImpl impl(nullptr, dummy_mod, conf);
auto transport = std::make_unique<FakeModbusTransport>();
// handle_start_transaction() first checks OCMF state.
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_READY));
// clear_transaction_states(): reads state again, sees READY, reads file
// (size+file) and confirms NOT_READY.
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_ADDRESS, 1,
u16_be(em580::registers::MODBUS_OCMF_STATE_READY));
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_SIZE_ADDRESS, 1, u16_be(1));
transport->push_fetch_response(em580::registers::MODBUS_OCMF_STATE_FILE_ADDRESS, 1,
bytes("OCMF|{\"TT\":\"x<=>12345678-1234-5678-1234-567812345678\"}|{}"));
auto* transport_ptr = transport.get();
module::main::powermeterImpl::TestAccess::set_modbus_transport(impl, std::move(transport));
types::powermeter::TransactionReq req{};
req.evse_id = "DE*TEST*EVSE01";
req.transaction_id = "12345678-1234-5678-1234-567812345678";
req.identification_status = types::powermeter::OCMFUserIdentificationStatus::ASSIGNED;
req.identification_flags = {};
req.identification_type = types::powermeter::OCMFIdentificationType::ISO14443;
req.identification_level = types::powermeter::OCMFIdentificationLevel::NONE;
req.identification_data.emplace("ABC");
req.tariff_text.emplace("TARIFF");
const auto resp = module::main::powermeterImpl::TestAccess::start_transaction(impl, req);
EXPECT_EQ(resp.status, types::powermeter::TransactionRequestStatus::OK);
// Only the cleanup confirm write should happen here (no transaction register
// writes).
EXPECT_EQ(transport_ptr->write_calls().size(), 1U);
EXPECT_EQ(transport_ptr->write_calls()[0].address, em580::registers::MODBUS_OCMF_STATE_ADDRESS);
}
TEST(EM580PowermeterImpl, StopTransactionUnknownIdReturnsErrorAndNoWrites) {
static Everest::PtrContainer<module::CarloGavazzi_EM580> dummy_mod;
auto conf = make_test_conf();
module::main::powermeterImpl impl(nullptr, dummy_mod, conf);
auto transport = std::make_unique<FakeModbusTransport>();
auto* transport_ptr = transport.get();
module::main::powermeterImpl::TestAccess::set_modbus_transport(impl, std::move(transport));
module::main::powermeterImpl::TestAccess::set_pending_closed_transaction(impl, false);
module::main::powermeterImpl::TestAccess::set_transaction_id(impl, "11111111-1111-1111-1111-111111111111");
std::string requested_uuid = "22222222-2222-2222-2222-222222222222";
const auto resp = module::main::powermeterImpl::TestAccess::stop_transaction(impl, requested_uuid);
EXPECT_EQ(resp.status, types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR);
ASSERT_TRUE(resp.error.has_value());
EXPECT_EQ(*resp.error, "No open transaction or unknown transaction id");
EXPECT_TRUE(transport_ptr->write_calls().empty());
}