diff --git a/.gitignore b/.gitignore index 35d4aefae8..13cdbe8c93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -*build -*build-cross +*build* .cache/ workspace.yaml .vscode/ diff --git a/config/bringup/config-bringup-CGEM580.yaml b/config/bringup/config-bringup-CGEM580.yaml new file mode 100644 index 0000000000..20ce912099 --- /dev/null +++ b/config/bringup/config-bringup-CGEM580.yaml @@ -0,0 +1,37 @@ +settings: + telemetry_enabled: false + mqtt_broker_socket_path: /run/mosquitto/mosquitto.sock +active_modules: + cgem580: + module: CarloGavazzi_EM580 + config_implementation: + main: + powermeter_device_id: 1 + timezone_offset_minutes: 60 + live_measurement_interval_ms: 1000 # once per second + communication_error_pause_delay_s: 10 # pause 10 seconds on communication error before retry + connections: + modbus: # required interface: serial_communication_hub + - module_id: comm_hub + implementation_id: main + + comm_hub: + module: SerialCommHub + config_implementation: + main: + serial_port: /dev/ttyUSB0 # adjust to your device path + baudrate: 115200 + parity: 0 # 0=None,1=Odd,2=Even (match your device) + within_message_timeout_ms: 10 + + cli: + config_module: + evse_id: "DE*ENBW*BER001*EVSE01" + tariff_text: "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" + identification_data: "A1z */-+.()[]{}$%^&*_+-=[];'," + module: BUPowermeter + standalone: true + connections: + powermeter: + - module_id: cgem580 + implementation_id: main diff --git a/config/bringup/run_tmux_helper.sh b/config/bringup/run_tmux_helper.sh index f20149da62..d2538bd253 100755 --- a/config/bringup/run_tmux_helper.sh +++ b/config/bringup/run_tmux_helper.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#! /usr/bin/env bash PREFIX=$2 EVEREST_CONFIG_FILE=$1 diff --git a/modules/BringUp/BUPowermeter/BUPowermeter.cpp b/modules/BringUp/BUPowermeter/BUPowermeter.cpp index 455d34fc14..729c6f0304 100644 --- a/modules/BringUp/BUPowermeter/BUPowermeter.cpp +++ b/modules/BringUp/BUPowermeter/BUPowermeter.cpp @@ -205,6 +205,7 @@ void BUPowermeter::ready() { types::units::Voltage vd = {}; types::units_signed::SignedMeterValue svd = {}; types::units::Current cd = {}; + types::units::Frequency fd = {}; optional_add(table_content, "Transaction start response: status", types::powermeter::transaction_request_status_to_string(tr_start.status)); @@ -236,17 +237,17 @@ void BUPowermeter::ready() { optional_add(table_content, "Powermeter: imported energy in Wh (from grid): L3", powermeter.energy_Wh_import.L3); - optional_add(table_content, "Powermeter: user defined meter ID", powermeter.meter_id); - optional_add(table_content, "Powermeter: 3 phase rotation error (ccw)", powermeter.phase_seq_error); - optional_add(table_content, "Powermeter: exported energy in Wh (to grid), total", - std::to_string(powermeter.energy_Wh_export.value_or(ed).total)); + std::to_string(powermeter.energy_Wh_export.value_or(ed).total)); optional_add(table_content, "Powermeter: exported energy in Wh (to grid): L1", - powermeter.energy_Wh_export.value_or(ed).L1); + powermeter.energy_Wh_export.value_or(ed).L1); optional_add(table_content, "Powermeter: exported energy in Wh (to grid): L2", - powermeter.energy_Wh_export.value_or(ed).L2); + powermeter.energy_Wh_export.value_or(ed).L2); optional_add(table_content, "Powermeter: exported energy in Wh (to grid): L3", - powermeter.energy_Wh_export.value_or(ed).L3); + powermeter.energy_Wh_export.value_or(ed).L3); + + optional_add(table_content, "Powermeter: user defined meter ID", powermeter.meter_id); + optional_add(table_content, "Powermeter: 3 phase rotation error (ccw)", powermeter.phase_seq_error); optional_add(table_content, "Powermeter: voltage in V, DC", powermeter.voltage_V.value_or(vd).DC); optional_add(table_content, "Powermeter: voltage in V: L1", powermeter.voltage_V.value_or(vd).L1); @@ -256,6 +257,9 @@ void BUPowermeter::ready() { optional_add(table_content, "Powermeter: current in A: L1", powermeter.current_A.value_or(cd).L1); optional_add(table_content, "Powermeter: current in A: L2", powermeter.current_A.value_or(cd).L2); optional_add(table_content, "Powermeter: current in A: L3", powermeter.current_A.value_or(cd).L3); + optional_add(table_content, "Powermeter: frequency in Hz: L1", powermeter.frequency_Hz.value_or(fd).L1); + optional_add(table_content, "Powermeter: frequency in Hz: L2", powermeter.frequency_Hz.value_or(fd).L2); + optional_add(table_content, "Powermeter: frequency in Hz: L3", powermeter.frequency_Hz.value_or(fd).L3); optional_add(table_content, "Public key", public_key); size_t max_width = 120; diff --git a/modules/HardwareDrivers/PowerMeters/CMakeLists.txt b/modules/HardwareDrivers/PowerMeters/CMakeLists.txt index 9621d10ace..fb8764963f 100644 --- a/modules/HardwareDrivers/PowerMeters/CMakeLists.txt +++ b/modules/HardwareDrivers/PowerMeters/CMakeLists.txt @@ -4,6 +4,7 @@ ev_add_module(DZG_GSH01) ev_add_module(GenericPowermeter) ev_add_module(IsabellenhuetteIemDcr) ev_add_module(LemDCBM400600) +ev_add_module(CarloGavazzi_EM580) if(${EVEREST_ENABLE_RS_SUPPORT}) ev_add_module(RsIskraMeter) diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CMakeLists.txt b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CMakeLists.txt new file mode 100644 index 0000000000..5c9a822212 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CMakeLists.txt @@ -0,0 +1,22 @@ +# +# 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" + "main/transport.cpp" +) + +# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1 +# insert other things like install cmds etc here +# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1 diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CarloGavazzi_EM580.cpp b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CarloGavazzi_EM580.cpp new file mode 100644 index 0000000000..ccde97b715 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CarloGavazzi_EM580.cpp @@ -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 diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CarloGavazzi_EM580.hpp b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CarloGavazzi_EM580.hpp new file mode 100644 index 0000000000..0f9901c018 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/CarloGavazzi_EM580.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2020 - 2025 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 + +// headers for required interface implementations +#include + +// 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 p_main, + std::unique_ptr r_modbus, Conf& config) : + ModuleBase(info), p_main(std::move(p_main)), r_modbus(std::move(r_modbus)), config(config) {}; + + const std::unique_ptr p_main; + const std::unique_ptr 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 diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/doc.rst b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/doc.rst new file mode 100644 index 0000000000..10053367c5 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/doc.rst @@ -0,0 +1,117 @@ +.. _everest_modules_handwritten_CarloGavazzi_EM580: + +******************************************* +CarloGavazzi_EM580 +******************************************* + +The module ``CarloGavazzi_EM580`` implements powermeter interface to read data from +EM580 device that is connected via Modbus RTU. + + +Testing the module +================== +We use the MQTT Explorer to interact with the CarloGavazzi_EM580 module. + +``start_transaction`` command +----------------------------- +Publish a command to the topic: ``everest/power_meter_1/meter/cmd`` + +``Request JSON:`` + + .. code-block:: JSON + + { + "data": { + "args": { + "value": { + "evse_id": "evse_id", + "transaction_id": "2995e453-6ba4-4d0f-8030-bec4396d8a63", + "client_id": "client_id", + "tariff_id": 0, + "cable_id": 0, + "user_data": "" + } + }, + "id": "00000000-0000-0000-0000-000000000042", + "origin": "manual_test" + }, + "name": "start_transaction", + "type": "call" + } + +``Response JSON:`` + + .. code-block:: JSON + + { + "data": { + "id": "00000000-0000-0000-0000-000000000042", + "origin": "power_meter_1", + "retval": { + "status": "OK" + } + }, + "name": "start_transaction", + "type": "result" + } + +``stop_transaction`` command +----------------------------- + +Publish a command to the topic: ``everest/power_meter_1/meter/cmd`` + +``Request JSON:`` + + .. code-block:: JSON + + { + "data": { + "args": { + "transaction_id": "2995e453-6ba4-4d0f-8030-bec4396d8a63" + }, + "id": "00000000-0000-0000-0000-000000000042", + "origin": "manual_test" + }, + "name": "stop_transaction", + "type": "call" + } + +``Response JSON:`` + + .. code-block:: JSON + + { + "data": { + "id": "00000000-0000-0000-0000-000000000042", + "origin": "power_meter_1", + "retval": { + "signed_meter_value": { + "encoding_method": "", + "public_key": "0464F9F9447A00672486A6D4625AA5FDB2E8D44B5705347316E7975BC8F9B29FAA11BBF44E8E1E82270267C52D1896AB240C7B4000B9BA2152DE5CCE822E3290A0B1376BFAFE4FB3956B1777EC9EE91EE0671A046BC3433F1409E44B229B5C71E9", + "signed_meter_data": "AQABCAD/AAAAHgAAAAAAAAAAAAABAAIIAP8AAAAeAAAAAAAAAAAAAAEAAQcA/wAAABv//wAAAAABAAwHAP8AAAAj//8AAAAAAQALBwD/AAAAIf/9AAAAAAEAAAoC/wAAACb//QAAAABEQ1QxQTMwVjEwTFMzRUMAAAAAAEJZMDUyMDAwMTAwMkwARENUMUEzMFYxMExTM0VDAHUjlewVGaSa37FIO2S4nVls1wH34HXM/VhqjCmVe2Dy8k/GEaa9zuMj2HY9uPlDwQ0bmq3qlIHesNgBbvcIiP7PXx/fJYrIn1/kgh/sLrUN5YkKefVBqQIkBK7vXk8KOw==", + "signing_method": "384 bit ECDSA SHA 384, using curve brainpoolP384r1" + }, + "start_signed_meter_value": { + "encoding_method": "", + "public_key": "0464F9F9447A00672486A6D4625AA5FDB2E8D44B5705347316E7975BC8F9B29FAA11BBF44E8E1E82270267C52D1896AB240C7B4000B9BA2152DE5CCE822E3290A0B1376BFAFE4FB3956B1777EC9EE91EE0671A046BC3433F1409E44B229B5C71E9", + "signed_meter_data": "AQABCAD/AAAAHgAAAAAAAAAAAAABAAIIAP8AAAAeAAAAAAAAAAAAAAEAAQcA/wAAABv//wAAAAABAAwHAP8AAAAj//8AAAAAAQALBwD/AAAAIf/9AAAAAAEAAAoC/wAAACb//QAAAABEQ1QxQTMwVjEwTFMzRUMAAAAAAEJZMDUyMDAwMTAwMkwARENUMUEzMFYxMExTM0VDACiWQqialjeOBZEW8AonMrpBmsBRnAGVsS/dya+GyhYCxq7JWfz8mywhJi9udAvJRHgspjtQYofqmM0y3c3eJSz3TFfC9TsQNOpBZ2BC+CZRJ+C2ewIsE9D1EIdJyAGG1A==", + "signing_method": "384 bit ECDSA SHA 384, using curve brainpoolP384r1" + }, + "status": "OK" + } + }, + "name": "stop_transaction", + "type": "result" + } + +Publish powermeter variables +---------------------------- +The module reads the following powermeter parameters and publishs them to the EVerest system: + +* energy_Wh_import +* energy_Wh_export +* power_W +* voltage_V +* current_A + +Publish the values ​​every second. diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/powermeterImpl.cpp b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/powermeterImpl.cpp new file mode 100644 index 0000000000..1f90df943e --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/powermeterImpl.cpp @@ -0,0 +1,1018 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "everest/logging.hpp" +#include "powermeterImpl.hpp" +#include "utils.hpp" + +const int MODBUS_BASE_ADDRESS = 300001; + +const int MODBUS_SIGNATURE_TYPE_ADDRESS = 309472; +const int MODBUS_PUBLIC_KEY_ADDRESS = 309473; + +const int MODBUS_SIGNED_MAP_ADDRESS = 302049; +const int MODBUS_SIGNED_MAP_SIGNATURE_ADDRESS = 302126; + +const int MODBUS_REAL_TIME_VALUES_ADDRESS = 300001; +const int MODBUS_REAL_TIME_VALUES_COUNT = 80; // Registers 300001-300080 (0x50 = 80 words) +// This range includes: +// - 300001-300052: Real-time values (52 words) +// - 300053-300054: kWh (+) TOT (energy import) - INT32, 2 words, byte offset 104 (52*2) +// - Gap: 300055-300078 (ignored) +// - 300079-300080: kWh (-) TOT (energy export) - INT32, 2 words, byte offset 156 (78*2) + +const int MODBUS_TEMPERATURE_ADDRESS = 300776; // Internal Temperature + +const int MODBUS_FIRMWARE_MEASURE_MODULE_ADDRESS = 300771; // Measure module firmware version/revision +const int MODBUS_FIRMWARE_COMMUNICATION_MODULE_ADDRESS = 300772; // Communication module firmware version/revision +const int MODBUS_SERIAL_NUMBER_START_ADDRESS = 320481; // Serial number (7 registers: 320481-320487) +const int MODBUS_SERIAL_NUMBER_REGISTER_COUNT = 7; // 7 UINT16 registers = 14 bytes +const int MODBUS_PRODUCTION_YEAR_ADDRESS = 320488; // Production year (1 UINT16 register) + +// Device state register (Table 4.30, Section 4.3.6) +const int MODBUS_DEVICE_STATE_ADDRESS = 320499; // 5012h: Device state (UINT16 bitfield) + +// Time synchronization registers +const int MODBUS_UTC_TIMESTAMP_ADDRESS = 328723; // UTC Timestamp for synchronization (INT64, 4 words) +const int MODBUS_TIMEZONE_OFFSET_ADDRESS = 328722; // Local time delta in minutes (INT16, 1 word) + +// OCMF Transaction registers (Table 4.34) +const int MODBUS_OCMF_IDENTIFICATION_STATUS_ADDRESS = 328673; // 7000h: OCMF Ident. Status (UINT16) +const int MODBUS_OCMF_IDENTIFICATION_LEVEL_ADDRESS = 328674; // 7001h: OCMF Ident. Level (UINT16) +const int MODBUS_OCMF_IDENTIFICATION_FLAGS_START_ADDRESS = 328675; // 7002h: OCMF Ident. Flags 1-4 (4 UINT16) +const int MODBUS_OCMF_IDENTIFICATION_FLAGS_COUNT = 4; // 4 flags +const int MODBUS_OCMF_IDENTIFICATION_TYPE_ADDRESS = 328679; // 7006h: OCMF Ident. Type (UINT16) +const int MODBUS_OCMF_IDENTIFICATION_DATA_START_ADDRESS = 328680; // 7007h: OCMF Ident. Data (CHAR[40] = 20 words) +const int MODBUS_OCMF_IDENTIFICATION_DATA_WORD_COUNT = 20; // 40 bytes = 20 words +const int MODBUS_OCMF_CHARGING_POINT_ID_TYPE_ADDRESS = 328700; // 701Bh: OCMF Charging point identifier type (UINT16) +const int MODBUS_OCMF_CHARGING_POINT_ID_START_ADDRESS = 328701; // 701Ch: OCMF CPI (CHAR[40] = 20 words) +const int MODBUS_OCMF_CHARGING_POINT_ID_WORD_COUNT = 20; // 40 bytes = 20 words +const int MODBUS_OCMF_SESSION_MODALITY_ADDRESS = 328727; // 7036h: OCMF Session Modality (UINT16) +const uint16_t MODBUS_OCMF_SESSION_MODALITY_CHARGING_VEHICLE = 0; // Charging vehicle +const uint16_t MODBUS_OCMF_SESSION_MODALITY_VEHICLE_TO_GRID = 1; // Vehicle to grid +const uint16_t MODBUS_OCMF_SESSION_MODALITY_BIDIRECTIONAL = 2; // Bidirectional + +// Tariff text register (Table 4.32) +// 326881 (6900h): Tariff text (CHAR[252] = 126 words) +const int MODBUS_OCMF_TARIFF_TEXT_ADDRESS = 326881; // 6900h: Tariff text (CHAR[252] = 126 words) +const int MODBUS_OCMF_TARIFF_TEXT_WORD_COUNT = 123; // 246 bytes = 123 words +const int MODBUS_OCMF_TRANSACTION_ID_GENERATION_ADDRESS = 328417; // 6F00h: OCMF Transaction ID Generation (UINT16) + +// Tariff update register (Table 4.33) +const int MODBUS_OCMF_TARIFF_UPDATE_ADDRESS = 327085; // 69CCh: Tariff update (UINT16) + +// OCMF Command register (Table 4.35) +// The device uses CHAR semantics for the command register: the ASCII character is stored in the MSB of the UINT16. +// Example: 'B' -> 0x4200. +const int MODBUS_OCMF_COMMAND_ADDRESS = 328737; // 7040h: OCMF Command Data (UINT16) +const uint16_t MODBUS_OCMF_COMMAND_START = 0x42; // Start transaction ('B' in MSB) +const uint16_t MODBUS_OCMF_COMMAND_END = 0x45; // End transaction ('E' in MSB) +const uint16_t MODBUS_OCMF_COMMAND_ABORT = 0x41; // Abort transaction ('A' in MSB) + +// OCMF State / status registers (Table 4.39 and related) +const int MODBUS_OCMF_STATE_ADDRESS = 328929; // 7100h: OCMF State (UINT16) +const uint16_t MODBUS_OCMF_STATE_NOT_READY = 0; // Not ready +const uint16_t MODBUS_OCMF_STATE_RUNNING = 1; // Running +const uint16_t MODBUS_OCMF_STATE_READY = 2; // Ready +const uint16_t MODBUS_OCMF_STATE_CORRUPTED = 3; // Corrupted +const int MODBUS_OCMF_STATE_SIZE_ADDRESS = 328930; // 7101h: OCMF Size (UINT16) +const int MODBUS_OCMF_STATE_FILE_ADDRESS = 328945; // 7110h: OCMF File (max theoretically 2031 words) +const int MODBUS_OCMF_STATE_FILE_WORD_COUNT = 2031; // 2031 words = 4062 bytes +const int MODBUS_OCMF_CHARGING_STATUS_ADDRESS = 328742; // 7045h: Charging status (UINT16) +const int MODBUS_OCMF_LAST_TRANSACTION_ID_ADDRESS = 328762; // 7059h: Last transaction id (CHAR[]) +const int MODBUS_OCMF_LAST_TRANSACTION_ID_WORD_COUNT = 14; // 14 bytes = 7 words +const int MODBUS_OCMF_TIME_SYNC_STATUS_ADDRESS = 328769; // 7060h: Time synchronization status (UINT16) + +// Byte offsets for Modbus register 300001-300055 (physical addresses 0000h-0036h) +// Each INT32 register is 4 bytes, each INT16 register is 2 bytes +namespace Offsets { +// Voltage registers (INT32, 4 bytes each) +constexpr size_t V_L1_N = 0; // 300001 (0000h) +constexpr size_t V_L2_N = 4; // 300003 (0002h) +constexpr size_t V_L3_N = 8; // 300005 (0004h) + +// Current registers (INT32, 4 bytes each) +constexpr size_t A_L1 = 24; // 300013 (000Ch) +constexpr size_t A_L2 = 28; // 300015 (000Eh) +constexpr size_t A_L3 = 32; // 300017 (0010h) + +// Power registers (INT32, 4 bytes each) +constexpr size_t W_L1 = 36; // 300019 (0012h) +constexpr size_t W_L2 = 40; // 300021 (0014h) +constexpr size_t W_L3 = 44; // 300023 (0016h) +constexpr size_t W_SYS = 80; // 300041 (0028h) + +// Reactive power registers (INT32, 4 bytes each) +constexpr size_t VAR_L1 = 60; // 300031 (001Eh) +constexpr size_t VAR_L2 = 64; // 300033 (0020h) +constexpr size_t VAR_L3 = 68; // 300035 (0022h) +constexpr size_t VAR_SYS = 88; // 300045 (002Ch) + +// Phase sequence register (INT16, 2 bytes) +constexpr size_t PHASE_SEQUENCE = 100; // 300051 (0032h) + +// Frequency register (INT16, 2 bytes) +constexpr size_t FREQUENCY = 102; // 300052 (0033h) + +// Energy registers (INT32, 4 bytes each) - within extended read range (300001-300080) +constexpr size_t ENERGY_IMPORT = 104; // 300053 (0034h) - kWh (+) TOT, byte offset 104 (52*2) +constexpr size_t ENERGY_EXPORT = 156; // 300079 (004Eh) - kWh (-) TOT, byte offset 156 (78*2) +} // namespace Offsets + +// Scaling factors from Modbus document +namespace Factors { +constexpr float VOLTAGE = 0.1F; // Value weight: Volt*10 +constexpr float CURRENT = 0.001F; // Value weight: Ampere*1000 +constexpr float POWER = 0.1F; // Value weight: Watt*10 +constexpr float REACTIVE_POWER = 0.1F; // Value weight: var*10 +constexpr float FREQUENCY = 0.1F; // Value weight: Hz*10 +constexpr float ENERGY_KWH_TO_WH = 100.0F; // Value weight: kWh*10, convert to Wh (kWh*10 * 100 = Wh) +constexpr float TEMPERATURE = 0.1F; // Value weight: Temperature*10 +} // namespace Factors + +namespace module::main { + +void powermeterImpl::init() { + // Set up error handler for CommunicationFault + transport::ErrorHandler error_handler = [this](const std::string& error_message) { + // Check if error is already active to avoid duplicate errors + if (!this->error_state_monitor->is_error_active("powermeter/CommunicationFault", "CommunicationError")) { + EVLOG_error << "Raising CommunicationFault: " << error_message; + auto error = this->error_factory->create_error("powermeter/CommunicationFault", "CommunicationError", + error_message, Everest::error::Severity::High); + raise_error(error); + } + }; + + // Set up clear error handler for CommunicationFault + transport::ClearErrorHandler clear_error_handler = [this]() { + // Clear CommunicationFault error if it's active + if (this->error_state_monitor->is_error_active("powermeter/CommunicationFault", "CommunicationError")) { + EVLOG_info << "Clearing CommunicationFault: Communication restored"; + clear_error("powermeter/CommunicationFault", "CommunicationError"); + } + }; + + p_modbus_transport = move(std::make_unique( + *(mod->r_modbus.get()), config.powermeter_device_id, MODBUS_BASE_ADDRESS, config.initial_connection_retry_count, + config.initial_connection_retry_delay_ms, config.communication_retry_count, config.communication_retry_delay_ms, + error_handler, clear_error_handler)); +} + +void powermeterImpl::read_signature_config() { + EVLOG_info << "Read the signature public key..."; + + enum SignatureType { + SIGNATURE_256_BIT, + SIGNATURE_384_BIT, + SIGNATURE_NONE + }; + + auto read_signature_type = [this]() { + transport::DataVector data = p_modbus_transport->fetch(MODBUS_SIGNATURE_TYPE_ADDRESS, 1); + return static_cast(modbus_utils::to_uint16(data, modbus_utils::ByteOffset{0})); + }; + + auto read_public_key = [this](int lengthInBits) { + const transport::DataVector data = + p_modbus_transport->fetch(MODBUS_PUBLIC_KEY_ADDRESS, (lengthInBits >> 3) + 1); + return modbus_utils::to_hex_string(data, modbus_utils::ByteOffset{0}, + modbus_utils::ByteLength{data.size() - 1}); + }; + + const SignatureType signature_type = read_signature_type(); + std::string signature_type_string; + + switch (signature_type) { + case SIGNATURE_256_BIT: + this->m_public_key_length_in_bits = 256; + signature_type_string = "256-bit"; + break; + case SIGNATURE_384_BIT: + this->m_public_key_length_in_bits = 384; + signature_type_string = "384-bit"; + break; + default: + signature_type_string = "none"; + throw std::runtime_error("no signature keys are configured, device is not eichrecht compliant"); + } + EVLOG_info << "Signature type detected: " << signature_type_string; + + this->m_public_key_hex = read_public_key(this->m_public_key_length_in_bits); + EVLOG_info << "Public key: " << this->m_public_key_hex; + this->publish_public_key_ocmf(this->m_public_key_hex); +} + +void powermeterImpl::read_firmware_versions() { + EVLOG_info << "Read the firmware versions..."; + + // Read measure module firmware version/revision (register 300771) + transport::DataVector measure_fw_data = p_modbus_transport->fetch(MODBUS_FIRMWARE_MEASURE_MODULE_ADDRESS, 1); + uint16_t measure_fw_value = modbus_utils::to_uint16(measure_fw_data, modbus_utils::ByteOffset{0}); + + // Parse firmware version: MSB bits 0-3 = Minor, bits 4-7 = Major, LSB = Revision + uint8_t major = (measure_fw_value >> 8) & 0xF0; + major = major >> 4; // Shift right to get actual major version (0-15) + uint8_t minor = (measure_fw_value >> 8) & 0x0F; + uint8_t revision = measure_fw_value & 0xFF; + + m_measure_module_firmware_version = fmt::format("{}.{}.{}", major, minor, revision); + EVLOG_info << "Measure module firmware version: " << m_measure_module_firmware_version; + + // Read communication module firmware version/revision (register 300772) + transport::DataVector comm_fw_data = p_modbus_transport->fetch(MODBUS_FIRMWARE_COMMUNICATION_MODULE_ADDRESS, 1); + uint16_t comm_fw_value = modbus_utils::to_uint16(comm_fw_data, modbus_utils::ByteOffset{0}); + + // Parse firmware version: MSB bits 0-3 = Minor, bits 4-7 = Major, LSB = Revision + major = (comm_fw_value >> 8) & 0xF0; + major = major >> 4; // Shift right to get actual major version (0-15) + minor = (comm_fw_value >> 8) & 0x0F; + revision = comm_fw_value & 0xFF; + + m_communication_module_firmware_version = fmt::format("{}.{}.{}", major, minor, revision); + EVLOG_info << "Communication module firmware version: " << m_communication_module_firmware_version; +} + +void powermeterImpl::read_serial_number() { + EVLOG_info << "Read the serial number..."; + // Read serial number (registers 320481-320487, 7 UINT16 registers = 14 bytes) + transport::DataVector serial_data = + p_modbus_transport->fetch(MODBUS_SERIAL_NUMBER_START_ADDRESS, MODBUS_SERIAL_NUMBER_REGISTER_COUNT); + + // Convert bytes to string (serial number is stored as ASCII) + // Modbus returns data in big-endian format: each UINT16 register is [MSB, LSB] + // So for 7 registers, we get: [reg0_MSB, reg0_LSB, reg1_MSB, reg1_LSB, ...] + // We assume the string contains only printable characters and null terminator is correctly set or at the end + std::string serial_str; + serial_str.reserve(14); + for (const auto& byte : serial_data) { + char byte_char = static_cast(byte); + // Stop at null terminator if present + if (byte_char == '\0') { + break; + } + serial_str += byte_char; + } + + // Read production year (register 320488, 1 UINT16 register) + transport::DataVector year_data = p_modbus_transport->fetch(MODBUS_PRODUCTION_YEAR_ADDRESS, 1); + uint16_t production_year = modbus_utils::to_uint16(year_data, modbus_utils::ByteOffset{0}); + + // Combine serial number and production year with a dot separator + m_serial_number = serial_str + "." + std::to_string(production_year); + EVLOG_info << "Serial number: " << m_serial_number; +} + +void powermeterImpl::configure_device() { + EVLOG_info << "Configure the device..."; + read_firmware_versions(); + read_serial_number(); + read_signature_config(); + // need a delay here because if the device comes from a power outage, the time sync will fail + std::this_thread::sleep_for(std::chrono::seconds(2)); + // Initial time synchronization + synchronize_time(); + // Set timezone offset + set_timezone(config.timezone_offset_minutes); + + // configure the device to use automtic transaction id generation + // write 1 to register 328672 (7000h) + EVLOG_info << "Configuring the device to use automtic transaction id generation"; + std::vector data = {0}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_TRANSACTION_ID_GENERATION_ADDRESS, data); + + EVLOG_info << "Device configured"; + // TODO(fmihut): check how to recover from a power outage +} + +void powermeterImpl::ready() { + // Retry logic is now handled by SerialCommHubTransport + std::thread live_measure_publisher_thread([this] { + std::atomic_bool device_not_configured = true; + while (true) { + const auto measurement_interval = std::chrono::milliseconds{config.live_measurement_interval_ms}; + try { + if (device_not_configured.load()) { + configure_device(); + device_not_configured = false; + } + read_powermeter_values(); + read_device_state(); + } catch (const std::exception& e) { + EVLOG_error << "Failed to communicate with the device, try again in " + << config.communication_error_pause_delay_s << " seconds: " << e.what(); + device_not_configured = true; + std::this_thread::sleep_for(std::chrono::seconds{config.communication_error_pause_delay_s}); + } + std::this_thread::sleep_for(measurement_interval); + } + }); + live_measure_publisher_thread.detach(); + + // Start time synchronization thread + std::thread time_sync_thread_obj([this]() { time_sync_thread(); }); + time_sync_thread_obj.detach(); +} + +std::vector powermeterImpl::string_to_modbus_char_array(const std::string& str, size_t word_count) { + // Convert string to Modbus CHAR array (null-terminated, padded with zeros) + // Modbus stores strings as big-endian words: [MSB, LSB] per word + std::vector data(word_count, 0); + size_t byte_count = word_count * 2; + size_t str_len = std::min(str.length(), byte_count - 1); // Leave space for null terminator + + // Convert string bytes to Modbus words (big-endian: MSB, LSB) + for (size_t i = 0; i < str_len; ++i) { + size_t word_idx = i / 2; + if (i % 2 == 0) { + // MSB of word + data[word_idx] = static_cast(str[i]) << 8; + } else { + // LSB of word + data[word_idx] |= static_cast(str[i]); + } + } + // Null terminator is already handled by initialization (all zeros) + // The first byte after the string is 0, which identifies EOL + return data; +} + +void powermeterImpl::write_transaction_registers(const types::powermeter::TransactionReq& transaction_req) { + + // Helper function to convert OCMFIdentificationFlags enum to numeric value + auto flag_to_value = [](types::powermeter::OCMFIdentificationFlags flag) -> uint16_t { + 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; + }; + + // 1. Write OCMF Identification Status (register 328673, 7000h) + // 0 = NOT_ASSIGNED (False), 1 = ASSIGNED (True) + uint16_t identification_status_value = + (transaction_req.identification_status == types::powermeter::OCMFUserIdentificationStatus::ASSIGNED) ? 1 : 0; + std::vector status_data = {identification_status_value}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_IDENTIFICATION_STATUS_ADDRESS, status_data); + + // 2. Write OCMF Identification Level (register 328674, 7001h) - optional + uint16_t identification_level_value = 0; // Default: NONE + if (transaction_req.identification_level.has_value()) { + switch (transaction_req.identification_level.value()) { + case types::powermeter::OCMFIdentificationLevel::NONE: + identification_level_value = 0; + break; + case types::powermeter::OCMFIdentificationLevel::HEARSAY: + identification_level_value = 1; + break; + case types::powermeter::OCMFIdentificationLevel::TRUSTED: + identification_level_value = 2; + break; + case types::powermeter::OCMFIdentificationLevel::VERIFIED: + identification_level_value = 3; + break; + case types::powermeter::OCMFIdentificationLevel::CERTIFIED: + identification_level_value = 4; + break; + case types::powermeter::OCMFIdentificationLevel::SECURE: + identification_level_value = 5; + break; + case types::powermeter::OCMFIdentificationLevel::MISMATCH: + identification_level_value = 6; + break; + case types::powermeter::OCMFIdentificationLevel::INVALID: + identification_level_value = 7; + break; + case types::powermeter::OCMFIdentificationLevel::OUTDATED: + identification_level_value = 8; + break; + case types::powermeter::OCMFIdentificationLevel::UNKNOWN: + identification_level_value = 9; + break; + } + } + std::vector level_data = {identification_level_value}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_IDENTIFICATION_LEVEL_ADDRESS, level_data); + + // 3. Write OCMF Identification Flags (registers 328675-328678, 7002h-7005h) - up to 4 flags + std::vector flags_data(MODBUS_OCMF_IDENTIFICATION_FLAGS_COUNT, 0); + for (size_t i = 0; i < transaction_req.identification_flags.size() && i < MODBUS_OCMF_IDENTIFICATION_FLAGS_COUNT; + ++i) { + flags_data[i] = flag_to_value(transaction_req.identification_flags[i]); + } + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_IDENTIFICATION_FLAGS_START_ADDRESS, flags_data); + + // 4. Write OCMF Identification Type (register 328679, 7006h) + uint16_t identification_type_value = 0; // Default: NONE + switch (transaction_req.identification_type) { + case types::powermeter::OCMFIdentificationType::NONE: + identification_type_value = 0; + break; + case types::powermeter::OCMFIdentificationType::DENIED: + identification_type_value = 1; + break; + case types::powermeter::OCMFIdentificationType::UNDEFINED: + identification_type_value = 2; + break; + case types::powermeter::OCMFIdentificationType::ISO14443: + identification_type_value = 10; + break; + case types::powermeter::OCMFIdentificationType::ISO15693: + identification_type_value = 11; + break; + case types::powermeter::OCMFIdentificationType::EMAID: + identification_type_value = 20; + break; + case types::powermeter::OCMFIdentificationType::EVCCID: + identification_type_value = 21; + break; + case types::powermeter::OCMFIdentificationType::EVCOID: + identification_type_value = 30; + break; + case types::powermeter::OCMFIdentificationType::ISO7812: + identification_type_value = 40; + break; + case types::powermeter::OCMFIdentificationType::CARD_TXN_NR: + identification_type_value = 50; + break; + case types::powermeter::OCMFIdentificationType::CENTRAL: + identification_type_value = 60; + break; + case types::powermeter::OCMFIdentificationType::CENTRAL_1: + identification_type_value = 61; + break; + case types::powermeter::OCMFIdentificationType::CENTRAL_2: + identification_type_value = 62; + break; + case types::powermeter::OCMFIdentificationType::LOCAL: + identification_type_value = 70; + break; + case types::powermeter::OCMFIdentificationType::LOCAL_1: + identification_type_value = 71; + break; + case types::powermeter::OCMFIdentificationType::LOCAL_2: + identification_type_value = 72; + break; + case types::powermeter::OCMFIdentificationType::PHONE_NUMBER: + identification_type_value = 80; + break; + case types::powermeter::OCMFIdentificationType::KEY_CODE: + identification_type_value = 90; + break; + } + std::vector type_data = {identification_type_value}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_IDENTIFICATION_TYPE_ADDRESS, type_data); + + // 5. Write OCMF Identification Data (registers 328680-328699, 7007h-701Ah) - CHAR[40] = 20 words + // Format: identification_data + ',' + transaction_id + // Max length: 40 characters - the transaction_id is 36 characters max + std::string client_id_str = transaction_req.identification_data.value_or(""); + client_id_str; + std::vector id_data = + string_to_modbus_char_array(client_id_str, MODBUS_OCMF_IDENTIFICATION_DATA_WORD_COUNT); + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_IDENTIFICATION_DATA_START_ADDRESS, id_data); + + // 6. Write OCMF Charging point identifier type (register 328700, 701Bh) + // 0 = EVSEID, 1 = CBIDC (default to EVSEID) + uint16_t charging_point_id_type = 0; // EVSEID + std::vector id_type_data = {charging_point_id_type}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_CHARGING_POINT_ID_TYPE_ADDRESS, id_type_data); + + // 7. Write OCMF Charging point identifier (registers 328701-328720, 701Ch-702Fh) - CHAR[40] = 20 words (evse_id) + transaction_req.evse_id; + std::vector evse_id_data = + string_to_modbus_char_array(transaction_req.evse_id, MODBUS_OCMF_CHARGING_POINT_ID_WORD_COUNT); + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_CHARGING_POINT_ID_START_ADDRESS, evse_id_data); +} + +types::powermeter::TransactionStartResponse +powermeterImpl::handle_start_transaction(types::powermeter::TransactionReq& treq) { + // Helper function to validate strings for EM580 device + // According to EM580 Modbus document (line 2376-2377 and APPENDIX), only specific ASCII characters are allowed + // Exact allowed characters from APPENDIX table: + // 33 (!), 36 ($), 37 (%), 39 ('), 40-47 (()*+,-./), 48-57 (0-9), 58 (:), 60-63 (<=>?), 65-90 (A-Z), 97-122 (a-z), + // 128 (€), 156 (£), 157 (¥) + // NOT allowed: space (32), pipe (124), quote (34), hash (35), ampersand (38), semicolon (59), and other characters + // Returns true if valid, false if invalid characters are found + auto validate_string_for_em580 = [](const std::string& str) -> bool { + // Build set of allowed character codes (exact list from APPENDIX) + static const std::set allowed = []() { + std::set a; + a.insert(33); // ! + a.insert(36); // $ + a.insert(37); // % + for (uint8_t c = 39; c <= 58; ++c) + a.insert(c); // ()*+,-./0-9: + for (uint8_t c = 60; c <= 63; ++c) + a.insert(c); // <=>? + for (uint8_t c = 65; c <= 90; ++c) + a.insert(c); // A-Z + for (uint8_t c = 97; c <= 122; ++c) + a.insert(c); // a-z + a.insert(128); // € + a.insert(156); // £ + a.insert(157); // ¥ + return a; + }(); + + for (size_t i = 0; i < str.length(); ++i) { + uint8_t code = static_cast(str[i]); + if (allowed.find(code) == allowed.end()) { + return false; + } + } + + return true; + }; + + try { + EVLOG_info << "Starting transaction with transaction id: " << treq.transaction_id + << " and evse id: " << treq.evse_id << " and identification status: " << treq.identification_status + << " and identification type: " + << types::powermeter::ocmfidentification_type_to_string(treq.identification_type) + << " and identification level: " + << types::powermeter::ocmfidentification_level_to_string( + treq.identification_level.value_or(types::powermeter::OCMFIdentificationLevel::NONE)) + << " and identification data: " << treq.identification_data.value_or("") + << " and tariff text: " << treq.tariff_text.value_or("none"); + // Write transaction registers first + EVLOG_info << "Write transaction registers..."; + write_transaction_registers(treq); + + // 8. Write tariff text (register 326881, 6900h) - CHAR[252] = 126 words + // The meter expects a null-terminated string; the helper initializes all words to 0, + // + // IMPORTANT: EM580 only allows specific ASCII characters (see APPENDIX). Space and special characters are NOT + // allowed. + // + EVLOG_info << "Write tariff text..."; + std::string tariff_text = treq.tariff_text.value_or("") + "<=>" + treq.transaction_id; + if (!validate_string_for_em580(tariff_text)) { + EVLOG_error << "String contains invalid characters for EM580 device: '" << tariff_text << "'"; + return {types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, + {}, + {}, + "Invalid tariff text (device supports only an subset of ASCII characters)"}; + } else { + std::vector tariff_text_data = + string_to_modbus_char_array(tariff_text, MODBUS_OCMF_TARIFF_TEXT_WORD_COUNT); + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_TARIFF_TEXT_ADDRESS, tariff_text_data); + } + + EVLOG_info << "Write session modality ... to charging vehicle"; + std::vector session_modality_data = {MODBUS_OCMF_SESSION_MODALITY_CHARGING_VEHICLE}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_SESSION_MODALITY_ADDRESS, session_modality_data); + + // Check OCMF state and ensure it's NOT_READY before starting a transaction + // According to the Modbus document, the OCMF state must be NOT_READY (0) to start a new transaction + transport::DataVector state_data = p_modbus_transport->fetch(MODBUS_OCMF_STATE_ADDRESS, 1); + uint16_t ocmf_state = modbus_utils::to_uint16(state_data, modbus_utils::ByteOffset{0}); + EVLOG_info << "OCMF state before starting transaction: " << ocmf_state; + + if (ocmf_state != MODBUS_OCMF_STATE_NOT_READY) { + if (ocmf_state == MODBUS_OCMF_STATE_READY) { + // If state is READY, we need to reset it to NOT_READY to allow a new transaction + // This confirms the reading of the previous OCMF file (if any) and allows a new session + EVLOG_info << "OCMF state is READY, resetting to NOT_READY to allow new transaction"; + std::vector reset_state_data = {MODBUS_OCMF_STATE_NOT_READY}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_STATE_ADDRESS, reset_state_data); + // Wait a bit for the state to update + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } else if (ocmf_state == MODBUS_OCMF_STATE_RUNNING) { + // If a transaction is already running, we cannot start a new one + throw std::runtime_error( + "Cannot start transaction: OCMF state is RUNNING (transaction already active)"); + } else if (ocmf_state == MODBUS_OCMF_STATE_CORRUPTED) { + // If state is CORRUPTED, we should reset it + EVLOG_warning << "OCMF state is CORRUPTED, resetting to NOT_READY"; + std::vector reset_state_data = {MODBUS_OCMF_STATE_NOT_READY}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_STATE_ADDRESS, reset_state_data); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } + + // Write 'B' command to start transaction (Table 4.35, register 328737) + std::vector command_data1 = {MODBUS_OCMF_COMMAND_START}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_COMMAND_ADDRESS, command_data1); + EVLOG_info << "Transaction " << treq.transaction_id << " started"; + + // Track local state (only used internally, not in device dump) + m_transaction_active.store(true); + m_transaction_id = treq.transaction_id; + return {types::powermeter::TransactionRequestStatus::OK}; + } catch (const std::exception& e) { + EVLOG_error << __PRETTY_FUNCTION__ << " Error: " << e.what() << std::endl; + return {types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, {}, {}, "get_signed_meter_value_error"}; + } +} + +types::powermeter::TransactionStopResponse powermeterImpl::handle_stop_transaction(std::string& transaction_id) { + try { + // Write 'E' command to end transaction (Table 4.35, register 328737) + std::vector command_data = {MODBUS_OCMF_COMMAND_END}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_COMMAND_ADDRESS, command_data); + EVLOG_info << "Transaction " << transaction_id << " stopped"; + m_transaction_active.store(false); + + // check if the OCMF state is ready (Table 4.36, register 328742) + uint16_t state = MODBUS_OCMF_STATE_NOT_READY; + transport::DataVector state_data; + int retries = 0; + while (state != MODBUS_OCMF_STATE_READY) { + state_data = p_modbus_transport->fetch(MODBUS_OCMF_STATE_ADDRESS, 1); + state = modbus_utils::to_uint16(state_data, modbus_utils::ByteOffset{0}); + if (state == MODBUS_OCMF_STATE_CORRUPTED || state == MODBUS_OCMF_STATE_RUNNING) { + return {types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, + {}, + {}, + "get_signed_meter_value_error"}; + } + if (state != MODBUS_OCMF_STATE_READY) { + EVLOG_info << "OCMF state: " << state; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + retries++; + if (retries > 10) { + return {types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, + {}, + {}, + "get_signed_meter_value_error"}; + } + } + } + + // read the size of the OCMF file + transport::DataVector size_data = p_modbus_transport->fetch(MODBUS_OCMF_STATE_SIZE_ADDRESS, 1); + uint16_t size = modbus_utils::to_uint16(size_data, modbus_utils::ByteOffset{0}); + if (size == 0) { + throw std::runtime_error("OCMF file size is 0"); + } + + // read the OCMF file + transport::DataVector file_data = p_modbus_transport->fetch(MODBUS_OCMF_STATE_FILE_ADDRESS, size); + std::string ocmf_data{file_data.begin(), file_data.end()}; + EVLOG_info << "OCMF file: " << ocmf_data; + auto signed_meter_value = types::units_signed::SignedMeterValue{ocmf_data, "", "OCMF"}; + signed_meter_value.public_key.emplace(m_public_key_hex); + + // write 0 to the OCMF state to confirm the reading of the OCMF file + std::vector ocmf_confirmation_data = {MODBUS_OCMF_STATE_NOT_READY}; + p_modbus_transport->write_multiple_registers(MODBUS_OCMF_STATE_ADDRESS, ocmf_confirmation_data); + return types::powermeter::TransactionStopResponse{types::powermeter::TransactionRequestStatus::OK, + {}, // Empty start_signed_meter_value + signed_meter_value}; + } catch (const std::exception& e) { + EVLOG_error << __PRETTY_FUNCTION__ << " Error: " << e.what() << std::endl; + return {types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, {}, {}, "get_signed_meter_value_error"}; + } +} + +void powermeterImpl::read_powermeter_values() { + // Read registers 300001-300082 (82 words = 0x50+2) + // This single read includes: + // - 300001-300052: Real-time values (52 words) + // - 300053-300054: kWh (+) TOT (energy import) - INT32, 2 words + // - Gap: 300055-300078 (Modbus will read but we ignore these bytes) + // - 300079-300080: kWh (-) TOT (energy export) - INT32, 2 words + // This optimization reduces Modbus requests from 3 to 2 (removing the separate totals read) + transport::DataVector data = + p_modbus_transport->fetch(MODBUS_REAL_TIME_VALUES_ADDRESS, MODBUS_REAL_TIME_VALUES_COUNT); + + types::powermeter::Powermeter powermeter{}; + powermeter.timestamp = Everest::Date::to_rfc3339(date::utc_clock::now()); + powermeter.meter_id = std::move(std::string(this->mod->info.id)); + + // Voltage values (INT32, weight: Volt*10) + // 300001 (0000h): V L1-N + // 300003 (0002h): V L2-N + // 300005 (0004h): V L3-N + types::units::Voltage voltage_V; + voltage_V.L1 = + Factors::VOLTAGE * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::V_L1_N})); + voltage_V.L2 = + Factors::VOLTAGE * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::V_L2_N})); + voltage_V.L3 = + Factors::VOLTAGE * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::V_L3_N})); + powermeter.voltage_V = voltage_V; + + // Current values (INT32, weight: Ampere*1000) + // Values are already signed: positive = import, negative = export + // 300013 (000Ch): A L1 + // 300015 (000Eh): A L2 + // 300017 (0010h): A L3 + types::units::Current current_A; + current_A.L1 = + Factors::CURRENT * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::A_L1})); + current_A.L2 = + Factors::CURRENT * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::A_L2})); + current_A.L3 = + Factors::CURRENT * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::A_L3})); + powermeter.current_A = current_A; + + // Power values (INT32, weight: Watt*10) + // Values are already signed: positive = import, negative = export + // 300019 (0012h): W L1 + // 300021 (0014h): W L2 + // 300023 (0016h): W L3 + // 300041 (0028h): W sys + types::units::Power power_W; + power_W.L1 = + Factors::POWER * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::W_L1})); + power_W.L2 = + Factors::POWER * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::W_L2})); + power_W.L3 = + Factors::POWER * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::W_L3})); + power_W.total = + Factors::POWER * static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::W_SYS})); + powermeter.power_W = power_W; + + // Reactive power values (INT32, weight: var*10) + // Values are already signed: positive = import, negative = export + // 300031 (001Eh): var L1 + // 300033 (0020h): var L2 + // 300035 (0022h): var L3 + // 300045 (002Ch): var sys + types::units::ReactivePower VAR; + VAR.L1 = Factors::REACTIVE_POWER * + static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::VAR_L1})); + VAR.L2 = Factors::REACTIVE_POWER * + static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::VAR_L2})); + VAR.L3 = Factors::REACTIVE_POWER * + static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::VAR_L3})); + VAR.total = Factors::REACTIVE_POWER * + static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::VAR_SYS})); + powermeter.VAR = VAR; + + // Frequency (INT16, weight: Hz*10) - register 300052 (0033h) + // Note: Frequency is also available at 300273 and 301341 as INT32 with different factors, + // but we use 300052 (INT16) to keep the bulk read compact (300001-300055) + types::units::Frequency frequency_Hz; + frequency_Hz.L1 = Factors::FREQUENCY * + static_cast(modbus_utils::to_int16(data, modbus_utils::ByteOffset{Offsets::FREQUENCY})); + powermeter.frequency_Hz = frequency_Hz; + + // Phase sequence (INT16) - register 300051 (0032h) + // Value -1 = L1-L3-L2 sequence, value 1 = L1-L2-L3 sequence + int16_t phase_sequence = modbus_utils::to_int16(data, modbus_utils::ByteOffset{Offsets::PHASE_SEQUENCE}); + if (phase_sequence == -1) { + powermeter.phase_seq_error = true; // L1-L3-L2 is considered an error (counter-clockwise) + } else if (phase_sequence == 1) { + powermeter.phase_seq_error = false; // L1-L2-L3 is correct (clockwise) + } + + // Energy import: register 300053 (kWh (+) TOT) - INT32, 2 words + // Byte offset in data: 104 (52*2, since 300053 is at offset 52 from 300001) + // Note: energy_Wh_import is a required field, not optional + powermeter.energy_Wh_import.total = + Factors::ENERGY_KWH_TO_WH * + static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::ENERGY_IMPORT})); + + // Energy export: register 300079 (kWh (-) TOT) - INT32, 2 words + // Byte offset in data: 156 (78*2, since 300079 is at offset 78 from 300001) + types::units::Energy energy_Wh_export; + energy_Wh_export.total = + Factors::ENERGY_KWH_TO_WH * + static_cast(modbus_utils::to_int32(data, modbus_utils::ByteOffset{Offsets::ENERGY_EXPORT})); + powermeter.energy_Wh_export = energy_Wh_export; + + // Read internal temperature (INT16, weight: Temperature*10) - register 300776 (0307h) - 1 word + // transport::DataVector temperature_data = p_modbus_transport->fetch(MODBUS_TEMPERATURE_ADDRESS, 1); + // types::temperature::Temperature temperature; + // temperature.temperature = Factors::TEMPERATURE * + // static_cast(modbus_utils::to_int16(temperature_data, + // modbus_utils::ByteOffset{0})); + // temperature.location = "Internal"; + // std::vector temperatures; + // temperatures.push_back(temperature); + // powermeter.temperatures = temperatures; + + this->publish_powermeter(powermeter); +} + +void powermeterImpl::dump_device_state() { + try { + // 1. OCMF state + transport::DataVector state_data = p_modbus_transport->fetch(MODBUS_OCMF_STATE_ADDRESS, 1); + uint16_t state = modbus_utils::to_uint16(state_data, modbus_utils::ByteOffset{0}); + + // 2. Charging status (register 328742 / 7045h) + transport::DataVector charging_status_data = p_modbus_transport->fetch(MODBUS_OCMF_CHARGING_STATUS_ADDRESS, 1); + uint16_t charging_status = modbus_utils::to_uint16(charging_status_data, modbus_utils::ByteOffset{0}); + + // 3. Last transaction id (register 328723 / 7059h, CHAR[]) + // transport::DataVector last_tx_data = p_modbus_transport->fetch(MODBUS_OCMF_LAST_TRANSACTION_ID_ADDRESS, + // MODBUS_OCMF_LAST_TRANSACTION_ID_WORD_COUNT); + // auto null_pos = std::find(last_tx_data.begin(), last_tx_data.end(), 0); + // std::string last_tx_id(last_tx_data.begin(), null_pos); + + // 4. Time synchronization status (register 328769 / 7060h) + transport::DataVector time_sync_status_data = + p_modbus_transport->fetch(MODBUS_OCMF_TIME_SYNC_STATUS_ADDRESS, 1); + uint16_t time_sync_status = modbus_utils::to_uint16(time_sync_status_data, modbus_utils::ByteOffset{0}); + + // 5. OCMF command (last written command value) + transport::DataVector cmd_data = p_modbus_transport->fetch(MODBUS_OCMF_COMMAND_ADDRESS, 1); + uint16_t raw_cmd = modbus_utils::to_uint16(cmd_data, modbus_utils::ByteOffset{0}); + char cmd_char = static_cast((raw_cmd >> 8U) & 0xFFU); + + // 6. Transaction ID definition (OCMF transaction ID generation) + transport::DataVector tx_def_data = p_modbus_transport->fetch(MODBUS_OCMF_TRANSACTION_ID_GENERATION_ADDRESS, 1); + uint16_t tx_def = modbus_utils::to_uint16(tx_def_data, modbus_utils::ByteOffset{0}); + + EVLOG_info << "EM580 device state dump:"; + EVLOG_info << " OCMF state: " << state; + EVLOG_info << " Charging status (device, raw): " << charging_status; + // EVLOG_info << " Last transaction id (device): " << last_tx_id; + EVLOG_info << " Time synchronization status (device, raw): " << time_sync_status; + EVLOG_info << " Last OCMF command (raw): 0x" << std::hex << raw_cmd << " ('" << cmd_char << "')"; + EVLOG_info << " Transaction ID definition (OCMF): 0x" << std::hex << tx_def; + } catch (const std::exception& e) { + EVLOG_error << "Failed to dump EM580 device state: " << e.what(); + } +} + +bool powermeterImpl::is_transaction_active() const { + return m_transaction_active.load(); +} + +void powermeterImpl::synchronize_time() { + // Get current UTC time as seconds since Unix epoch + auto now_utc = date::utc_clock::now(); + // Convert to system_clock for time_t conversion + auto sys_now = std::chrono::system_clock::now(); + auto time_since_epoch = sys_now.time_since_epoch(); + int64_t seconds_since_epoch = std::chrono::duration_cast(time_since_epoch).count(); + + // Convert to UINT64 and split into 4 words + // According to EM580 Modbus spec: for INT64, word order is LSW->MSW (little-endian word order) + // So we write: [LSW, LSW+1, MSW-1, MSW] = [bits 0-15, bits 16-31, bits 32-47, bits 48-63] + uint64_t timestamp = static_cast(seconds_since_epoch); + std::vector data; + data.push_back(static_cast(timestamp & 0xFFFF)); // LSW: bits 0-15 + data.push_back(static_cast((timestamp >> 16) & 0xFFFF)); // bits 16-31 + data.push_back(static_cast((timestamp >> 32) & 0xFFFF)); // bits 32-47 + data.push_back(static_cast((timestamp >> 48) & 0xFFFF)); // MSW: bits 48-63 + + // Write UTC timestamp to register 328723 (4 words for INT64) + p_modbus_transport->write_multiple_registers(MODBUS_UTC_TIMESTAMP_ADDRESS, data); + + EVLOG_info << "Time synchronized: " << Everest::Date::to_rfc3339(now_utc) + << " (Unix timestamp: " << seconds_since_epoch << ")"; +} + +void powermeterImpl::set_timezone(int offset_minutes) { + EVLOG_info << "Try to set the timezone ... "; + + // Convert to INT16 (signed 16-bit integer) + // Timezone offset range: -1440 to +1440 minutes is validated by the manifest. + int16_t offset_int16 = static_cast(offset_minutes); + std::vector data; + data.push_back(static_cast(offset_int16)); + p_modbus_transport->write_multiple_registers(MODBUS_TIMEZONE_OFFSET_ADDRESS, data); + + EVLOG_info << "Timezone set to: " << (offset_minutes >= 0 ? "+" : "") << offset_minutes << " minutes"; +} + +void powermeterImpl::time_sync_thread() { + const auto sync_interval = std::chrono::hours(1); + auto next_sync_time = std::chrono::steady_clock::now() + sync_interval; + + while (true) { + std::this_thread::sleep_until(next_sync_time); + + if (!is_transaction_active()) { + // No active transaction, perform time sync immediately + try { + synchronize_time(); + m_pending_time_sync = false; + } catch (const std::exception& e) { + EVLOG_error << "Time synchronization failed: " << e.what(); + // Mark as pending to retry when transaction ends + m_pending_time_sync = true; + } + } else { + // Transaction is active, mark sync as pending + EVLOG_info << "Time synchronization deferred: charging session in progress"; + m_pending_time_sync = true; + } + + // Schedule next sync attempt in 1 hour + next_sync_time += sync_interval; + } +} + +void powermeterImpl::read_device_state() { + // Read device state register (Table 4.30, Section 4.3.6) + // Register 320499 (5012h): Device state (UINT16 bitfield) + transport::DataVector state_data = p_modbus_transport->fetch(MODBUS_DEVICE_STATE_ADDRESS, 1); + uint16_t device_state = modbus_utils::to_uint16(state_data, modbus_utils::ByteOffset{0}); + + // Check for error bits and raise VendorError if any are set + std::vector error_messages; + + // Voltage over maximum range errors + if ((device_state & (1U << 0U)) != 0U) { + error_messages.emplace_back("V1N over maximum range"); + } + if ((device_state & (1U << 1U)) != 0U) { + error_messages.emplace_back("V2N over maximum range"); + } + if ((device_state & (1U << 2U)) != 0U) { + error_messages.emplace_back("V3N over maximum range"); + } + if ((device_state & (1U << 3U)) != 0U) { + error_messages.emplace_back("V12 over maximum range"); + } + if ((device_state & (1U << 4U)) != 0U) { + error_messages.emplace_back("V23 over maximum range"); + } + if ((device_state & (1U << 5U)) != 0U) { + error_messages.emplace_back("V31 over maximum range"); + } + + // Current over maximum range errors + if ((device_state & (1U << 6U)) != 0U) { + error_messages.emplace_back("I1 over maximum range"); + } + if ((device_state & (1U << 7U)) != 0U) { + error_messages.emplace_back("I2 over maximum range"); + } + if ((device_state & (1U << 8U)) != 0U) { + error_messages.emplace_back("I3 over maximum range"); + } + + // Frequency outside validity range + if ((device_state & (1U << 9U)) != 0U) { + error_messages.emplace_back("Frequency outside validity range"); + } + + // Module internal fault errors + if ((device_state & (1U << 12U)) != 0U) { + error_messages.emplace_back("EVCS module internal fault"); + } + if ((device_state & (1U << 13U)) != 0U) { + error_messages.emplace_back("Measure module internal fault"); + } + + // If any error bits are set, raise VendorError + if (!error_messages.empty()) { + std::string error_description = "Device state errors detected: "; + for (size_t i = 0; i < error_messages.size(); ++i) { + if (i > 0) { + error_description += ", "; + } + error_description += error_messages[i]; + } + error_description += " (device state: 0x" + fmt::format("{:04X}", device_state) + ")"; + + EVLOG_error << "Device state error: " << error_description; + auto error = this->error_factory->create_error("powermeter/VendorError", "DeviceStateError", error_description); + raise_error(error); + } else { + EVLOG_debug << "Device state OK (0x" << fmt::format("{:04X}", device_state) << ")"; + } +} + +} // namespace module::main diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/powermeterImpl.hpp b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/powermeterImpl.hpp new file mode 100644 index 0000000000..a23a5a35a2 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/powermeterImpl.hpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2020 - 2025 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 + +#include "../CarloGavazzi_EM580.hpp" + +// ev@75ac1216-19eb-4182-a85c-820f1fc2c091:v1 +#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 initial_connection_retry_count; + int initial_connection_retry_delay_ms; + int timezone_offset_minutes; + int live_measurement_interval_ms; + int communication_error_pause_delay_s; +}; + +class powermeterImpl : public powermeterImplBase { +public: + powermeterImpl() = delete; + powermeterImpl(Everest::ModuleAdapter* ev, const Everest::PtrContainer& mod, Conf& config) : + powermeterImplBase(ev, "main"), mod(mod), config(config) {}; + + // ev@8ea32d28-373f-4c90-ae5e-b4fcc74e2a61:v1 + // insert your public definitions here + // 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& mod; + const Conf& config; + + std::unique_ptr p_modbus_transport; + + std::optional m_start_signed_meter_value; + + int 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::atomic_bool m_transaction_active{false}; + std::atomic_bool m_pending_time_sync{false}; + + virtual void init() override; + void configure_device(); + virtual void ready() override; + + // ev@3370e4dd-95f4-47a9-aaec-ea76f34a66c9:v1 + void read_signature_config(); + void read_powermeter_values(); + void dump_device_state(void); + void read_firmware_versions(); + void read_serial_number(); + void synchronize_time(); + void set_timezone(int offset_minutes); + void time_sync_thread(); + [[nodiscard]] bool is_transaction_active() const; + void write_transaction_registers(const types::powermeter::TransactionReq& transaction_req); + void read_device_state(); + std::vector string_to_modbus_char_array(const std::string& str, size_t word_count); + // 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 diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/transport.cpp b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/transport.cpp new file mode 100644 index 0000000000..5a6f9d93cf --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/transport.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest + +#include "transport.hpp" +#include + +const int MAX_REGISTER_PER_MESSAGE = 125; + +namespace transport { + +transport::DataVector SerialCommHubTransport::fetch(int address, int register_count) { + return retry_with_config([this, address, register_count]() { + transport::DataVector response; + response.reserve(register_count * 2); // this is a uint8_t vector + + int remaining_register_to_read{register_count}; + int read_address{address - m_base_address}; + + while (remaining_register_to_read > 0) { + std::size_t register_to_read = remaining_register_to_read > MAX_REGISTER_PER_MESSAGE + ? MAX_REGISTER_PER_MESSAGE + : remaining_register_to_read; + + types::serial_comm_hub_requests::Result serial_com_hub_result = + m_serial_hub.call_modbus_read_input_registers(m_device_id, 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); + + union { + int32_t val_32; + struct { + uint8_t v3; + uint8_t v2; + uint8_t v1; + 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(int address, const std::vector& data) { + retry_with_config_void([this, address, &data]() { + int write_address = address - m_base_address; + types::serial_comm_hub_requests::VectorUint16 data_raw; + for (uint16_t value : data) { + data_raw.data.push_back(value); + } + + types::serial_comm_hub_requests::StatusCodeEnum status = + m_serial_hub.call_modbus_write_multiple_registers(m_device_id, write_address, 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); + } + }); +} + +} // namespace transport diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/transport.hpp b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/transport.hpp new file mode 100644 index 0000000000..f693744c41 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/transport.hpp @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2020 - 2025 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 +#include +#include +#include +#include +#include +#include +#include + +namespace transport { + +using DataVector = std::vector; + +// 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; +// Clear error callback type: void() +using ClearErrorHandler = std::function; + +class AbstractModbusTransport { + +public: + virtual transport::DataVector fetch(int address, int register_count) = 0; + virtual void write_multiple_registers(int address, const std::vector& data) = 0; +}; + +/** + * data transport via SerialComHub + */ + +class SerialCommHubTransport : public AbstractModbusTransport { + +protected: + serial_communication_hubIntf& m_serial_hub; + int m_device_id; + int m_base_address; + + // Retry configuration + int m_initial_retry_count; + int m_initial_retry_delay_ms; + int m_normal_retry_count; + int 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 auto retry_with_config(Func&& func) -> decltype(std::forward(func)()) { + bool is_initial = m_initial_connection_mode.load(); + int max_retries = is_initial ? m_initial_retry_count : m_normal_retry_count; + int delay_ms = is_initial ? m_initial_retry_delay_ms : m_normal_retry_delay_ms; + + // For initial connection, 0 means infinite retries + int attempt = 1; + while (m_initial_retry_count == 0 ? true : attempt <= max_retries) { + try { + auto result = std::forward(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 + bool should_retry = is_initial && m_initial_retry_count == 0 ? true : attempt < max_retries; + if (should_retry) { + 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 + bool should_retry = is_initial && m_initial_retry_count == 0 ? true : attempt < max_retries; + if (should_retry) { + 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 void retry_with_config_void(Func&& func) { + bool is_initial = m_initial_connection_mode.load(); + int max_retries = is_initial ? m_initial_retry_count : m_normal_retry_count; + int delay_ms = is_initial ? m_initial_retry_delay_ms : m_normal_retry_delay_ms; + + // For initial connection, 0 means infinite retries + int attempt = 1; + while (m_initial_retry_count == 0 ? true : attempt <= max_retries) { + try { + std::forward(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 + bool should_retry = is_initial && m_initial_retry_count == 0 ? true : attempt < max_retries; + if (should_retry) { + 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 + bool should_retry = is_initial && m_initial_retry_count == 0 ? true : attempt < max_retries; + if (should_retry) { + 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: + SerialCommHubTransport(serial_communication_hubIntf& serial_hub, int device_id, int base_address, + int initial_retry_count, int initial_retry_delay_ms, int normal_retry_count, + int normal_retry_delay_ms, ErrorHandler error_handler = nullptr, + ClearErrorHandler clear_error_handler = nullptr) : + m_serial_hub(serial_hub), + m_device_id(device_id), + m_base_address(base_address), + m_initial_retry_count(initial_retry_count), + m_initial_retry_delay_ms(initial_retry_delay_ms), + m_normal_retry_count(normal_retry_count), + m_normal_retry_delay_ms(normal_retry_delay_ms), + m_error_handler(error_handler), + m_clear_error_handler(clear_error_handler) { + } + + virtual transport::DataVector fetch(int address, int register_count) override; + virtual void write_multiple_registers(int address, const std::vector& data) override; +}; + +} // namespace transport + +#endif // POWERMETER_TRANSPORT_HPP diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/utils.hpp b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/utils.hpp new file mode 100644 index 0000000000..ffc8083a0c --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/main/utils.hpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest + +#ifndef POWERMETER_UTILS_HPP +#define POWERMETER_UTILS_HPP + +#include +#include +#include +#include +#include + +// #include "base64.hpp" +#include "transport.hpp" + +/** + * @brief Utility functions for converting Modbus data to various types + * + * These functions handle byte order conversion from Modbus (big-endian) format + * to native integer types. Modbus transmits data in big-endian format where + * the most significant byte comes first. + */ + +namespace modbus_utils { + +// Strong type wrappers to prevent parameter swapping +struct ByteOffset { + explicit ByteOffset(transport::DataVector::size_type v) : value(v) { + } + operator transport::DataVector::size_type() const { + return value; + } + +private: + transport::DataVector::size_type value; +}; + +struct ByteLength { + explicit ByteLength(transport::DataVector::size_type v) : value(v) { + } + operator transport::DataVector::size_type() const { + return value; + } + +private: + transport::DataVector::size_type value; +}; + +/** + * @brief Convert 4 bytes from Modbus data to int32_t + * @param data The Modbus data vector + * @param offset Byte offset into the data vector + * @return The converted 32-bit signed integer + * @note According to EM580 Modbus spec: byte order within word is MSB->LSB, + * but for INT32/UINT32/UINT64, word order is LSW->MSW. + * So bytes are arranged as: [LSW_MSB, LSW_LSB, MSW_MSB, MSW_LSB] + * which becomes: MSW_MSB MSW_LSB LSW_MSB LSW_LSB + */ +inline int32_t to_int32(const transport::DataVector& data, ByteOffset offset) { + // Original byte order: [byte0, byte1, byte2, byte3] = [LSW_MSB, LSW_LSB, MSW_MSB, MSW_LSB] + // Convert to: MSW_MSB MSW_LSB LSW_MSB LSW_LSB = byte2<<24 | byte3<<16 | byte0<<8 | byte1 + const auto off = static_cast(offset); + return static_cast(data[off + 2] << 24 | data[off + 3] << 16 | data[off] << 8 | data[off + 1]); +} + +/** + * @brief Convert 2 bytes from Modbus data to uint16_t (big-endian) + * @param data The Modbus data vector + * @param offset Byte offset into the data vector + * @return The converted 16-bit unsigned integer + */ +inline uint16_t to_uint16(const transport::DataVector& data, ByteOffset offset) { + const auto off = static_cast(offset); + return static_cast(data[off] << 8 | data[off + 1]); +} + +/** + * @brief Convert 2 bytes from Modbus data to int16_t (big-endian) + * @param data The Modbus data vector + * @param offset Byte offset into the data vector + * @return The converted 16-bit signed integer + */ +inline int16_t to_int16(const transport::DataVector& data, ByteOffset offset) { + uint16_t raw = to_uint16(data, offset); + return static_cast(raw); +} + +/** + * @brief Convert a range of bytes to a hexadecimal string representation + * @param data The Modbus data vector + * @param offset Byte offset into the data vector + * @param length Number of bytes to convert + * @return Hexadecimal string (uppercase, no separators) + */ +inline std::string to_hex_string(const transport::DataVector& data, ByteOffset offset, ByteLength length) { + const auto off = static_cast(offset); + const auto len = static_cast(length); + 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(data[off + index]); + } + return ss.str(); +} + +} // namespace modbus_utils + +#endif // POWERMETER_UTILS_HPP diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/manifest.yaml b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/manifest.yaml new file mode 100644 index 0000000000..2e668e4003 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/manifest.yaml @@ -0,0 +1,63 @@ +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 no 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 +requires: + modbus: + interface: serial_communication_hub +metadata: + license: https://opensource.org/licenses/Apache-2.0 + authors: + - florin.mihut@pionix.com +enable_external_mqtt: false + diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/README.md b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/README.md new file mode 100644 index 0000000000..2bbfba2928 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/README.md @@ -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||` + +```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 "" +``` + +#### Validate from file + +```bash +python3 validate_ocmf_signature.py \ + --public-key "04<194_hex_chars>" \ + --file data.json \ + --signature "" +``` + +## 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|| +``` + +Where: +- `` - JSON object containing all meter data (FV, GI, GS, RD, etc.) +- `` - 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 `` 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) diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/test_validation.sh b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/test_validation.sh new file mode 100755 index 0000000000..dd8aacfdae --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/test_validation.sh @@ -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" diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/text.txt b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/text.txt new file mode 100644 index 0000000000..f6b3deab7e --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/text.txt @@ -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"} \ No newline at end of file diff --git a/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/validate_ocmf_signature.py b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/validate_ocmf_signature.py new file mode 100755 index 0000000000..6e97260559 --- /dev/null +++ b/modules/HardwareDrivers/PowerMeters/CarloGavazzi_EM580/ocmf_validation/validate_ocmf_signature.py @@ -0,0 +1,365 @@ +#!/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 --text --signature + python3 validate_ocmf_signature.py --public-key --file --signature + python3 validate_ocmf_signature.py --public-key --ocmf-json + +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 +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|| + + 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 = 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 (uncompressed format). + + 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) + + # 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 normalize_json_for_ocmf(json_str): + """ + Normalize JSON string to compact format (no spaces) as required by OCMF spec. + OCMF signatures are computed over the compact JSON representation. + """ + try: + parsed = json.loads(json_str) + # Re-serialize with no spaces (compact format) + return json.dumps(parsed, separators=(',', ':'), ensure_ascii=False) + except json.JSONDecodeError: + # If it's not valid JSON, return as-is (might be plain text) + return json_str + + +def verify_signature(public_key, message, signature_hex, normalize_json=True): + """ + 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) + normalize_json: If True, normalize JSON to compact format (OCMF requirement) + + Returns: + bool: True if signature is valid, False otherwise + """ + # Convert message to bytes if it's a string + if isinstance(message, str): + # Normalize JSON if it looks like JSON (starts with { or [) + if normalize_json and (message.strip().startswith('{') or message.strip().startswith('[')): + message = normalize_json_for_ocmf(message) + message_bytes = message.encode('utf-8') + else: + message_bytes = message + + # Hash the message with SHA256 + message_hash = sha256(message_bytes).digest() + + # Decode signature + r, s = decode_signature(signature_hex) + + # Verify signature + try: + public_key.verify( + encode_dss_signature(r, s), + message_hash, + ec.ECDSA(hashes.SHA256()) + ) + return True + except Exception as e: + print(f"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 "" + + # 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 "" + """ + ) + + 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||' + ) + + 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) + # OCMF spec requires signatures to be computed over compact JSON + if isinstance(message, str) and (message.strip().startswith('{') or message.strip().startswith('[')): + try: + normalized_message = normalize_json_for_ocmf(message) + if normalized_message != message: + print(f"\n⚠ JSON normalization: Original had {len(message)} chars, compact has {len(normalized_message)} chars") + print(" Using compact JSON format for signature verification (OCMF requirement)") + message = normalized_message + # Debug: show the hash of normalized message + normalized_hash = sha256(normalized_message.encode('utf-8')).hexdigest() + print(f" Normalized message hash: {normalized_hash}") + except json.JSONDecodeError: + print(" Warning: Could not parse as JSON, using as-is") + + # 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]}...") + + # Also show what verify_signature will hash (should be the same) + try: + is_valid = verify_signature(public_key, message, signature, normalize_json=False) # Already normalized above + + if is_valid: + print("\n✓ SIGNATURE VALID - The message is authentic!") + sys.exit(0) + else: + print("\n✗ SIGNATURE INVALID - The message may have been tampered with!") + sys.exit(1) + except Exception as e: + print(f"\n✗ Error during verification: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() + diff --git a/modules/HardwareDrivers/PowerMeters/RsIskraMeter/src/main.rs b/modules/HardwareDrivers/PowerMeters/RsIskraMeter/src/main.rs index 7b45b4094b..958e5c53c6 100644 --- a/modules/HardwareDrivers/PowerMeters/RsIskraMeter/src/main.rs +++ b/modules/HardwareDrivers/PowerMeters/RsIskraMeter/src/main.rs @@ -160,6 +160,7 @@ impl From for Result<()> { match value { StatusCodeEnum::Success => Ok(()), StatusCodeEnum::Error => anyhow::bail!("StatusCodeEnum::Error"), + StatusCodeEnum::Timeout => anyhow::bail!("StatusCodeEnum::Timeout"), } } } @@ -180,6 +181,7 @@ impl generated::types::serial_comm_hub_requests::Result { } }, StatusCodeEnum::Error => anyhow::bail!("StatusCodeEnum::Error"), + StatusCodeEnum::Timeout => anyhow::bail!("StatusCodeEnum::Timeout"), } } } @@ -205,6 +207,7 @@ impl From fo } }, StatusCodeEnum::Error => anyhow::bail!("StatusCodeEnum::Error"), + StatusCodeEnum::Timeout => anyhow::bail!("StatusCodeEnum::Timeout"), } } } diff --git a/modules/Misc/SerialCommHub/main/serial_communication_hubImpl.cpp b/modules/Misc/SerialCommHub/main/serial_communication_hubImpl.cpp index f035fd9591..3c4c85665f 100644 --- a/modules/Misc/SerialCommHub/main/serial_communication_hubImpl.cpp +++ b/modules/Misc/SerialCommHub/main/serial_communication_hubImpl.cpp @@ -101,6 +101,7 @@ serial_communication_hubImpl::perform_modbus_request(uint8_t device_address, tin types::serial_comm_hub_requests::Result result; std::vector response; auto retry_counter = config.retries + 1; + bool last_error_was_timeout = false; while (retry_counter > 0) { auto current_trial = config.retries + 1 - retry_counter + 1; @@ -109,9 +110,21 @@ serial_communication_hubImpl::perform_modbus_request(uint8_t device_address, tin config.retries + 1, tiny_modbus::FunctionCode_to_string_with_hex(function), device_address, first_register_address, first_register_address, register_quantity); + last_error_was_timeout = false; try { response = modbus.txrx(device_address, function, first_register_address, register_quantity, config.max_packet_size, wait_for_reply, request); + } catch (const tiny_modbus::TimeoutException& e) { + // TimeoutException is a specific type of communication error + last_error_was_timeout = true; + auto logmsg = fmt::format("Modbus call {} for device id {} addr {}({:#06x}) failed: {}", + tiny_modbus::FunctionCode_to_string_with_hex(function), device_address, + first_register_address, first_register_address, e.what()); + + if (retry_counter != 1) + EVLOG_debug << logmsg; + else + EVLOG_warning << logmsg; } catch (const tiny_modbus::TinyModbusException& e) { auto logmsg = fmt::format("Modbus call {} for device id {} addr {}({:#06x}) failed: {}", tiny_modbus::FunctionCode_to_string_with_hex(function), device_address, @@ -144,7 +157,12 @@ serial_communication_hubImpl::perform_modbus_request(uint8_t device_address, tin result.value = vector_to_int(response); system_error_logged = false; // reset after success } else { - result.status_code = types::serial_comm_hub_requests::StatusCodeEnum::Error; + // If the last error was a timeout, return Timeout status, otherwise Error + if (last_error_was_timeout) { + result.status_code = types::serial_comm_hub_requests::StatusCodeEnum::Timeout; + } else { + result.status_code = types::serial_comm_hub_requests::StatusCodeEnum::Error; + } } return result; } diff --git a/types/serial_comm_hub_requests.yaml b/types/serial_comm_hub_requests.yaml index 71d5028909..70145d8057 100644 --- a/types/serial_comm_hub_requests.yaml +++ b/types/serial_comm_hub_requests.yaml @@ -6,6 +6,7 @@ types: enum: - Success - Error + - Timeout Result: description: Return type for IO transfer functions with 16 bit return values type: object