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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 meter’s 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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user