From 6a1f9eb44ff2e34d1deead066ec762e793131fa6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Sep 2025 06:46:41 +0000 Subject: [PATCH] Add Modbus TCP energy meter support --- include/Configuration.h | 30 ++ include/powermeter/Provider.h | 3 +- include/powermeter/modbus/tcp/Provider.h | 45 +++ src/Configuration.cpp | 65 ++++ src/WebApi_powermeter.cpp | 87 +++++ src/powermeter/Controller.cpp | 4 + src/powermeter/modbus/tcp/Provider.cpp | 389 ++++++++++++++++++++ webapp/src/locales/de.json | 29 +- webapp/src/locales/en.json | 29 +- webapp/src/types/PowerMeterConfig.ts | 23 ++ webapp/src/views/PowerMeterAdminView.vue | 433 +++++++++++++++++++++++ 11 files changed, 1134 insertions(+), 3 deletions(-) create mode 100644 include/powermeter/modbus/tcp/Provider.h create mode 100644 src/powermeter/modbus/tcp/Provider.cpp diff --git a/include/Configuration.h b/include/Configuration.h index fe9c7d7ef..d7eb797a8 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -143,6 +143,33 @@ struct POWERMETER_UDP_VICTRON_CONFIG_T { }; using PowerMeterUdpVictronConfig = struct POWERMETER_UDP_VICTRON_CONFIG_T; +struct POWERMETER_MODBUS_REGISTER_CONFIG_T { + uint16_t Address; + float ScalingFactor; + + enum RegisterDataType { INT16 = 0, UINT16 = 1, INT32 = 2, UINT32 = 3, FLOAT = 4 }; + RegisterDataType DataType; +}; + +struct POWERMETER_MODBUS_TCP_CONFIG_T { + uint32_t PollingInterval; + uint8_t IpAddress[4]; + uint16_t Port; + uint8_t DeviceId; + + POWERMETER_MODBUS_REGISTER_CONFIG_T PowerRegister; + POWERMETER_MODBUS_REGISTER_CONFIG_T PowerL1Register; + POWERMETER_MODBUS_REGISTER_CONFIG_T PowerL2Register; + POWERMETER_MODBUS_REGISTER_CONFIG_T PowerL3Register; + POWERMETER_MODBUS_REGISTER_CONFIG_T VoltageL1Register; + POWERMETER_MODBUS_REGISTER_CONFIG_T VoltageL2Register; + POWERMETER_MODBUS_REGISTER_CONFIG_T VoltageL3Register; + POWERMETER_MODBUS_REGISTER_CONFIG_T ImportRegister; + POWERMETER_MODBUS_REGISTER_CONFIG_T ExportRegister; +}; +using PowerMeterModbusTcpRegisterConfig = struct POWERMETER_MODBUS_REGISTER_CONFIG_T; +using PowerMeterModbusTcpConfig = struct POWERMETER_MODBUS_TCP_CONFIG_T; + struct POWERLIMITER_INVERTER_CONFIG_T { uint64_t Serial; bool IsGoverned; @@ -428,6 +455,7 @@ struct CONFIG_T { PowerMeterHttpJsonConfig HttpJson; PowerMeterHttpSmlConfig HttpSml; PowerMeterUdpVictronConfig UdpVictron; + PowerMeterModbusTcpConfig ModbusTcp; } PowerMeter; PowerLimiterConfig PowerLimiter; @@ -483,6 +511,7 @@ class ConfigurationClass { static void serializePowerMeterHttpJsonConfig(PowerMeterHttpJsonConfig const& source, JsonObject& target); static void serializePowerMeterHttpSmlConfig(PowerMeterHttpSmlConfig const& source, JsonObject& target); static void serializePowerMeterUdpVictronConfig(PowerMeterUdpVictronConfig const& source, JsonObject& target); + static void serializePowerMeterModbusTcpConfig(PowerMeterModbusTcpConfig const& source, JsonObject& target); static void serializeBatteryConfig(BatteryConfig const& source, JsonObject& target); static void serializeBatteryZendureConfig(BatteryZendureConfig const& source, JsonObject& target); static void serializeBatteryMqttConfig(BatteryMqttConfig const& source, JsonObject& target); @@ -500,6 +529,7 @@ class ConfigurationClass { static void deserializePowerMeterHttpJsonConfig(JsonObject const& source, PowerMeterHttpJsonConfig& target); static void deserializePowerMeterHttpSmlConfig(JsonObject const& source, PowerMeterHttpSmlConfig& target); static void deserializePowerMeterUdpVictronConfig(JsonObject const& source, PowerMeterUdpVictronConfig& target); + static void deserializePowerMeterModbusTcpConfig(JsonObject const& source, PowerMeterModbusTcpConfig& target); static void deserializeBatteryConfig(JsonObject const& source, BatteryConfig& target); static void deserializeBatteryZendureConfig(JsonObject const& source, BatteryZendureConfig& target); static void deserializeBatteryMqttConfig(JsonObject const& source, BatteryMqttConfig& target); diff --git a/include/powermeter/Provider.h b/include/powermeter/Provider.h index bacc920ef..24f6d37db 100644 --- a/include/powermeter/Provider.h +++ b/include/powermeter/Provider.h @@ -19,7 +19,8 @@ class Provider { SERIAL_SML = 4, SMAHM2 = 5, HTTP_SML = 6, - MODBUS_UDP_VICTRON = 7 + MODBUS_UDP_VICTRON = 7, + MODBUS_TCP = 8 }; // returns true if the provider is ready for use, false otherwise diff --git a/include/powermeter/modbus/tcp/Provider.h b/include/powermeter/modbus/tcp/Provider.h new file mode 100644 index 000000000..a11800276 --- /dev/null +++ b/include/powermeter/modbus/tcp/Provider.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace PowerMeters::Modbus::Tcp { + +class Provider : public ::PowerMeters::Provider { +public: + explicit Provider(PowerMeterModbusTcpConfig const& cfg); + ~Provider(); + + bool init() final; + void loop() final; + bool isDataValid() const final; + +private: + static void pollingLoopHelper(void* context); + bool readModbusRegister(const PowerMeterModbusTcpRegisterConfig& regConfig, float& value); + bool connectToDevice(); + void pollingLoop(); + bool sendModbusRequest(uint8_t deviceId, uint8_t functionCode, uint16_t startRegister, uint16_t numRegisters); + bool receiveModbusResponse(uint16_t* values, uint16_t numRegisters); + + PowerMeterModbusTcpConfig const _cfg; + + uint32_t _lastPoll = 0; + std::atomic _taskDone; + + std::unique_ptr _upWiFiClient = nullptr; + + TaskHandle_t _taskHandle = nullptr; + bool _stopPolling; + mutable std::mutex _pollingMutex; + std::condition_variable _cv; + + uint16_t _transactionId = 0; +}; + +} // namespace PowerMeters::Modbus::Tcp diff --git a/src/Configuration.cpp b/src/Configuration.cpp index d1fb639fe..6cd292a4d 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -126,6 +126,32 @@ void ConfigurationClass::serializePowerMeterUdpVictronConfig(PowerMeterUdpVictro target["ip_address"] = IPAddress(source.IpAddress).toString(); } +void ConfigurationClass::serializePowerMeterModbusTcpConfig(PowerMeterModbusTcpConfig const& source, JsonObject& target) +{ + target["polling_interval"] = source.PollingInterval; + target["ip_address"] = IPAddress(source.IpAddress).toString(); + target["port"] = source.Port; + target["device_id"] = source.DeviceId; + + // Power registers + auto serializeRegisterConfig = [](JsonObject& parent, const char* name, const PowerMeterModbusTcpRegisterConfig& reg) { + JsonObject regObj = parent[name].to(); + regObj["address"] = reg.Address; + regObj["scaling_factor"] = reg.ScalingFactor; + regObj["data_type"] = static_cast(reg.DataType); + }; + + serializeRegisterConfig(target, "power_register", source.PowerRegister); + serializeRegisterConfig(target, "power_l1_register", source.PowerL1Register); + serializeRegisterConfig(target, "power_l2_register", source.PowerL2Register); + serializeRegisterConfig(target, "power_l3_register", source.PowerL3Register); + serializeRegisterConfig(target, "voltage_l1_register", source.VoltageL1Register); + serializeRegisterConfig(target, "voltage_l2_register", source.VoltageL2Register); + serializeRegisterConfig(target, "voltage_l3_register", source.VoltageL3Register); + serializeRegisterConfig(target, "import_register", source.ImportRegister); + serializeRegisterConfig(target, "export_register", source.ExportRegister); +} + void ConfigurationClass::serializeBatteryConfig(BatteryConfig const& source, JsonObject& target) { target["enabled"] = config.Battery.Enabled; @@ -423,6 +449,9 @@ bool ConfigurationClass::write() JsonObject powermeter_udp_victron = powermeter["udp_victron"].to(); serializePowerMeterUdpVictronConfig(config.PowerMeter.UdpVictron, powermeter_udp_victron); + JsonObject powermeter_modbus_tcp = powermeter["modbus_tcp"].to(); + serializePowerMeterModbusTcpConfig(config.PowerMeter.ModbusTcp, powermeter_modbus_tcp); + JsonObject powerlimiter = doc["powerlimiter"].to(); serializePowerLimiterConfig(config.PowerLimiter, powerlimiter); @@ -550,6 +579,40 @@ void ConfigurationClass::deserializePowerMeterUdpVictronConfig(JsonObject const& target.IpAddress[3] = ip[3]; } +void ConfigurationClass::deserializePowerMeterModbusTcpConfig(JsonObject const& source, PowerMeterModbusTcpConfig& target) +{ + target.PollingInterval = source["polling_interval"] | POWERMETER_POLLING_INTERVAL; + target.Port = source["port"] | 502; // Default Modbus TCP port + target.DeviceId = source["device_id"] | 1; + + IPAddress ip; + ip.fromString(source["ip_address"] | ""); + target.IpAddress[0] = ip[0]; + target.IpAddress[1] = ip[1]; + target.IpAddress[2] = ip[2]; + target.IpAddress[3] = ip[3]; + + // Deserialize register configurations + auto deserializeRegisterConfig = [](JsonObject const& parent, const char* name, PowerMeterModbusTcpRegisterConfig& reg) { + JsonObject regObj = parent[name]; + reg.Address = regObj["address"] | 0; + reg.ScalingFactor = regObj["scaling_factor"] | 1.0f; + reg.DataType = static_cast( + regObj["data_type"] | static_cast(PowerMeterModbusTcpRegisterConfig::RegisterDataType::UINT16) + ); + }; + + deserializeRegisterConfig(source, "power_register", target.PowerRegister); + deserializeRegisterConfig(source, "power_l1_register", target.PowerL1Register); + deserializeRegisterConfig(source, "power_l2_register", target.PowerL2Register); + deserializeRegisterConfig(source, "power_l3_register", target.PowerL3Register); + deserializeRegisterConfig(source, "voltage_l1_register", target.VoltageL1Register); + deserializeRegisterConfig(source, "voltage_l2_register", target.VoltageL2Register); + deserializeRegisterConfig(source, "voltage_l3_register", target.VoltageL3Register); + deserializeRegisterConfig(source, "import_register", target.ImportRegister); + deserializeRegisterConfig(source, "export_register", target.ExportRegister); +} + void ConfigurationClass::deserializeBatteryConfig(JsonObject const& source, BatteryConfig& target) { target.Enabled = source["enabled"] | BATTERY_ENABLED; @@ -879,6 +942,8 @@ bool ConfigurationClass::read() deserializePowerMeterUdpVictronConfig(powermeter["udp_victron"], config.PowerMeter.UdpVictron); + deserializePowerMeterModbusTcpConfig(powermeter["modbus_tcp"], config.PowerMeter.ModbusTcp); + deserializePowerLimiterConfig(doc["powerlimiter"], config.PowerLimiter); JsonObject battery = doc["battery"]; diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 675ac5201..5bc8edac9 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -56,6 +56,9 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) auto udpVictron = root["udp_victron"].to(); Configuration.serializePowerMeterUdpVictronConfig(config.PowerMeter.UdpVictron, udpVictron); + auto modbusTcp = root["modbus_tcp"].to(); + Configuration.serializePowerMeterModbusTcpConfig(config.PowerMeter.ModbusTcp, modbusTcp); + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); } @@ -170,6 +173,87 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) } } + if (static_cast<::PowerMeters::Provider::Type>(root["source"].as()) == ::PowerMeters::Provider::Type::MODBUS_TCP) { + JsonObject modbusTcp = root["modbus_tcp"]; + if (!modbusTcp["ip_address"].is() + || modbusTcp["ip_address"].as().length() == 0) { + retMsg["message"] = "IP address must not be empty!"; + response->setLength(); + request->send(response); + return; + } + + if (!modbusTcp["port"].is() + || modbusTcp["port"].as() <= 0 || modbusTcp["port"].as() > 65535) { + retMsg["message"] = "Port must be between 1 and 65535!"; + response->setLength(); + request->send(response); + return; + } + + if (!modbusTcp["device_id"].is() + || modbusTcp["device_id"].as() < 1 || modbusTcp["device_id"].as() > 247) { + retMsg["message"] = "Device ID must be between 1 and 247!"; + response->setLength(); + request->send(response); + return; + } + + if (!modbusTcp["polling_interval"].is() + || modbusTcp["polling_interval"].as() <= 0) { + retMsg["message"] = "Polling interval must be greater than 0 seconds!"; + response->setLength(); + request->send(response); + return; + } + + // Validate register configurations + auto validateRegisterConfig = [&](JsonObject registerObj, const String& name) -> bool { + if (registerObj.isNull()) return true; // Optional + + if (registerObj.containsKey("address")) { + uint32_t address = registerObj["address"].as(); + if (address > 9999) { + retMsg["message"] = name + " register address must be between 0 and 9999!"; + return false; + } + } + + if (registerObj.containsKey("scaling_factor")) { + float scaling = registerObj["scaling_factor"].as(); + if (scaling <= 0.0f) { + retMsg["message"] = name + " scaling factor must be greater than 0!"; + return false; + } + } + + if (registerObj.containsKey("data_type")) { + uint8_t dataType = registerObj["data_type"].as(); + if (dataType > 4) { // 0-4 are valid datatype values (INT16, UINT16, INT32, UINT32, FLOAT) + retMsg["message"] = name + " data type must be between 0 and 4!"; + return false; + } + } + + return true; + }; + + const char* registerNames[] = { + "power_register", "power_l1_register", "power_l2_register", "power_l3_register", + "voltage_l1_register", "voltage_l2_register", "voltage_l3_register", + "current_l1_register", "current_l2_register", "current_l3_register", + "import_register", "export_register" + }; + + for (const char* regName : registerNames) { + if (!validateRegisterConfig(modbusTcp[regName], regName)) { + response->setLength(); + request->send(response); + return; + } + } + } + { auto guard = Configuration.getWriteGuard(); auto& config = guard.getConfig(); @@ -190,6 +274,9 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) Configuration.deserializePowerMeterUdpVictronConfig(root["udp_victron"].as(), config.PowerMeter.UdpVictron); + + Configuration.deserializePowerMeterModbusTcpConfig(root["modbus_tcp"].as(), + config.PowerMeter.ModbusTcp); } WebApi.writeConfig(retMsg); diff --git a/src/powermeter/Controller.cpp b/src/powermeter/Controller.cpp index 71b140480..4f9d161f0 100644 --- a/src/powermeter/Controller.cpp +++ b/src/powermeter/Controller.cpp @@ -8,6 +8,7 @@ #include #include #include +#include PowerMeters::Controller PowerMeter; @@ -60,6 +61,9 @@ void Controller::updateSettings() case Provider::Type::MODBUS_UDP_VICTRON: _upProvider = std::make_unique<::PowerMeters::Modbus::Udp::Victron::Provider>(pmcfg.UdpVictron); break; + case Provider::Type::MODBUS_TCP: + _upProvider = std::make_unique<::PowerMeters::Modbus::Tcp::Provider>(pmcfg.ModbusTcp); + break; } if (!_upProvider->init()) { diff --git a/src/powermeter/modbus/tcp/Provider.cpp b/src/powermeter/modbus/tcp/Provider.cpp new file mode 100644 index 000000000..c9f76d2e2 --- /dev/null +++ b/src/powermeter/modbus/tcp/Provider.cpp @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include // for memcpy + +#undef TAG +static const char* TAG = "powerMeter"; +static const char* SUBTAG = "ModbusTCP"; + +namespace PowerMeters::Modbus::Tcp { + +Provider::Provider(PowerMeterModbusTcpConfig const& cfg) + : _cfg(cfg) +{ +} + +bool Provider::init() +{ + if (WiFi.status() != WL_CONNECTED) { + DTU_LOGE("WiFi not connected, cannot initialize Modbus TCP power meter"); + return false; + } + + IPAddress ipAddr(_cfg.IpAddress[0], _cfg.IpAddress[1], _cfg.IpAddress[2], _cfg.IpAddress[3]); + DTU_LOGI("Initializing Modbus TCP power meter: IP = %s, Port = %d, Device ID = %d", + ipAddr.toString().c_str(), _cfg.Port, _cfg.DeviceId); + + _upWiFiClient = std::make_unique(); + + return true; +} + +Provider::~Provider() +{ + _taskDone = false; + + std::unique_lock lock(_pollingMutex); + _stopPolling = true; + lock.unlock(); + + _cv.notify_all(); + + if (_taskHandle != nullptr) { + while (!_taskDone) { delay(10); } + _taskHandle = nullptr; + } + + if (_upWiFiClient) { + _upWiFiClient->stop(); + _upWiFiClient = nullptr; + } +} + +void Provider::loop() +{ + if (_taskHandle != nullptr) { return; } + + std::unique_lock lock(_pollingMutex); + _stopPolling = false; + lock.unlock(); + + uint32_t constexpr stackSize = 4096; + xTaskCreate(Provider::pollingLoopHelper, "PM:ModbusTCP", + stackSize, this, 1/*prio*/, &_taskHandle); +} + +bool Provider::isDataValid() const +{ + uint32_t age = millis() - getLastUpdate(); + return getLastUpdate() > 0 && (age < (3 * _cfg.PollingInterval * 1000)); +} + +void Provider::pollingLoopHelper(void* context) +{ + auto pInstance = static_cast(context); + pInstance->pollingLoop(); + pInstance->_taskDone = true; + vTaskDelete(nullptr); +} + +bool Provider::connectToDevice() +{ + if (_upWiFiClient->connected()) { + return true; + } + + IPAddress ipAddr(_cfg.IpAddress[0], _cfg.IpAddress[1], _cfg.IpAddress[2], _cfg.IpAddress[3]); + + DTU_LOGD("Connecting to Modbus TCP device at %s:%d", ipAddr.toString().c_str(), _cfg.Port); + + if (!_upWiFiClient->connect(ipAddr, _cfg.Port)) { + DTU_LOGE("Failed to connect to Modbus TCP device"); + return false; + } + + DTU_LOGD("Connected to Modbus TCP device"); + return true; +} + +bool Provider::sendModbusRequest(uint8_t deviceId, uint8_t functionCode, uint16_t startRegister, uint16_t numRegisters) +{ + // Modbus TCP Application Data Unit (ADU) format: + // [Transaction ID: 2 bytes][Protocol ID: 2 bytes][Length: 2 bytes][Unit ID: 1 byte][Function Code: 1 byte][Data: N bytes] + + uint8_t request[12]; + + // MBAP Header + request[0] = (_transactionId >> 8) & 0xFF; // Transaction ID high byte + request[1] = _transactionId & 0xFF; // Transaction ID low byte + request[2] = 0x00; // Protocol ID high byte (0 for Modbus) + request[3] = 0x00; // Protocol ID low byte + request[4] = 0x00; // Length high byte (6 bytes following) + request[5] = 0x06; // Length low byte + + // PDU + request[6] = deviceId; // Unit ID + request[7] = functionCode; // Function code (0x03 = Read Holding Registers, 0x04 = Read Input Registers) + request[8] = (startRegister >> 8) & 0xFF; // Starting address high byte + request[9] = startRegister & 0xFF; // Starting address low byte + request[10] = (numRegisters >> 8) & 0xFF; // Quantity high byte + request[11] = numRegisters & 0xFF; // Quantity low byte + + _transactionId++; + + size_t bytesWritten = _upWiFiClient->write(request, sizeof(request)); + if (bytesWritten != sizeof(request)) { + DTU_LOGE("Failed to send complete Modbus request, sent %d of %d bytes", bytesWritten, sizeof(request)); + return false; + } + + DTU_LOGD("Sent Modbus request: Device ID=%d, Function Code=0x%02X, Start Reg=%d, Num Regs=%d", deviceId, functionCode, startRegister, numRegisters); + return true; +} + +bool Provider::receiveModbusResponse(uint16_t* values, uint16_t numRegisters) +{ + // Wait for response with timeout + uint32_t startTime = millis(); + const uint32_t timeout = 5000; // 5 second timeout + + while (_upWiFiClient->available() < 9 && (millis() - startTime) < timeout) { + delay(10); + } + + if (_upWiFiClient->available() < 9) { + DTU_LOGE("Modbus response timeout"); + return false; + } + + // MBAP header + PDU + data + size_t expectedLength = 9 + (numRegisters * 2); + std::vector buffer(expectedLength); + + // Wait for complete response + startTime = millis(); + while (_upWiFiClient->available() < expectedLength && (millis() - startTime) < timeout) { + delay(10); + } + + if (_upWiFiClient->available() < expectedLength) { + DTU_LOGE("Incomplete Modbus response, expected %d bytes, got %d", expectedLength, _upWiFiClient->available()); + return false; + } + + size_t bytesRead = _upWiFiClient->readBytes(buffer.data(), expectedLength); + if (bytesRead != expectedLength) { + DTU_LOGE("Failed to read complete Modbus response"); + return false; + } + + // Validate response + uint16_t responseTransactionId = (buffer[0] << 8) | buffer[1]; + uint16_t expectedTransactionId = _transactionId - 1; + + if (responseTransactionId != expectedTransactionId) { + DTU_LOGE("Transaction ID mismatch: expected %d, got %d", expectedTransactionId, responseTransactionId); + return false; + } + + uint8_t functionCode = buffer[7]; + if (functionCode != 0x04) { // Function code for input registers + DTU_LOGE("Unexpected function code in response: 0x%02X", functionCode); + return false; + } + + uint8_t byteCount = buffer[8]; + if (byteCount != numRegisters * 2) { + DTU_LOGE("Unexpected byte count in response: %d", byteCount); + return false; + } + + // Extract register values (big-endian) + for (uint16_t i = 0; i < numRegisters; i++) { + values[i] = (buffer[9 + (i * 2)] << 8) | buffer[10 + (i * 2)]; + } + + DTU_LOGD("Received Modbus response successfully, %d registers", numRegisters); + return true; +} + +bool Provider::readModbusRegister(const PowerMeterModbusTcpRegisterConfig& regConfig, float& value) +{ + if (regConfig.Address == 0) { + return false; // Skip if register not configured + } + + if (!connectToDevice()) { + return false; + } + + // Always use input registers (function code 0x04) + uint8_t functionCode = 0x04; // Read Input Registers + + // Determine number of registers to read based on data type + uint16_t numRegisters = 1; + if (regConfig.DataType == PowerMeterModbusTcpRegisterConfig::RegisterDataType::INT32 || + regConfig.DataType == PowerMeterModbusTcpRegisterConfig::RegisterDataType::UINT32 || + regConfig.DataType == PowerMeterModbusTcpRegisterConfig::RegisterDataType::FLOAT) { + numRegisters = 2; // 32-bit types need 2 registers + } + + if (!sendModbusRequest(_cfg.DeviceId, functionCode, regConfig.Address, numRegisters)) { + return false; + } + + uint16_t rawValues[2]; + if (!receiveModbusResponse(rawValues, numRegisters)) { + return false; + } + + // Convert based on data type + float rawValue = 0.0f; + switch (regConfig.DataType) { + case PowerMeterModbusTcpRegisterConfig::RegisterDataType::INT16: + rawValue = static_cast(static_cast(rawValues[0])); + break; + case PowerMeterModbusTcpRegisterConfig::RegisterDataType::UINT16: + rawValue = static_cast(rawValues[0]); + break; + case PowerMeterModbusTcpRegisterConfig::RegisterDataType::INT32: { + // Combine two 16-bit registers into 32-bit (big-endian) + int32_t combined = (static_cast(rawValues[0]) << 16) | rawValues[1]; + rawValue = static_cast(combined); + break; + } + case PowerMeterModbusTcpRegisterConfig::RegisterDataType::UINT32: { + // Combine two 16-bit registers into 32-bit (big-endian) + uint32_t combined = (static_cast(rawValues[0]) << 16) | rawValues[1]; + rawValue = static_cast(combined); + break; + } + case PowerMeterModbusTcpRegisterConfig::RegisterDataType::FLOAT: { + // Combine two 16-bit registers into float (big-endian IEEE 754) + uint32_t combined = (static_cast(rawValues[0]) << 16) | rawValues[1]; + memcpy(&rawValue, &combined, sizeof(float)); + break; + } + default: + rawValue = static_cast(rawValues[0]); + break; + } + + // Apply scaling factor + value = rawValue * regConfig.ScalingFactor; + + DTU_LOGD("Read register %d (type %d): raw=%f, scaling=%f, value=%f", + regConfig.Address, + static_cast(regConfig.DataType), + rawValue, + regConfig.ScalingFactor, + value); + return true; +} + +void Provider::pollingLoop() +{ + std::unique_lock lock(_pollingMutex); + + while (!_stopPolling) { + auto elapsedMillis = millis() - _lastPoll; + auto intervalMillis = _cfg.PollingInterval * 1000; + if (_lastPoll > 0 && elapsedMillis < intervalMillis) { + auto sleepMs = intervalMillis - elapsedMillis; + _cv.wait_for(lock, std::chrono::milliseconds(sleepMs), + [this] { return _stopPolling; }); + continue; + } + + _lastPoll = millis(); + + // Reading takes time, so release the lock during I/O operations + lock.unlock(); + + float powerValue = 0.0; + float powerL1 = 0.0; + float powerL2 = 0.0; + float powerL3 = 0.0; + float voltageL1 = 0.0; + float voltageL2 = 0.0; + float voltageL3 = 0.0; + float importEnergy = 0.0; + float exportEnergy = 0.0; + + bool success = true; + + // Read power register + if (_cfg.PowerRegister.Address > 0) { + success &= readModbusRegister(_cfg.PowerRegister, powerValue); + } + + // Read per-phase power registers + if (_cfg.PowerL1Register.Address > 0) { + success &= readModbusRegister(_cfg.PowerL1Register, powerL1); + } + if (_cfg.PowerL2Register.Address > 0) { + success &= readModbusRegister(_cfg.PowerL2Register, powerL2); + } + if (_cfg.PowerL3Register.Address > 0) { + success &= readModbusRegister(_cfg.PowerL3Register, powerL3); + } + + // Read voltage registers + if (_cfg.VoltageL1Register.Address > 0) { + success &= readModbusRegister(_cfg.VoltageL1Register, voltageL1); + } + if (_cfg.VoltageL2Register.Address > 0) { + success &= readModbusRegister(_cfg.VoltageL2Register, voltageL2); + } + if (_cfg.VoltageL3Register.Address > 0) { + success &= readModbusRegister(_cfg.VoltageL3Register, voltageL3); + } + + // Read energy registers + if (_cfg.ImportRegister.Address > 0) { + success &= readModbusRegister(_cfg.ImportRegister, importEnergy); + } + if (_cfg.ExportRegister.Address > 0) { + success &= readModbusRegister(_cfg.ExportRegister, exportEnergy); + } + + lock.lock(); + + if (!success) { + DTU_LOGE("Failed to read some Modbus registers"); + continue; + } + + // Update data points + { + auto scopedLock = _dataCurrent.lock(); + + if (_cfg.PowerRegister.Address > 0) { + _dataCurrent.add(powerValue); + } + + if (_cfg.PowerL1Register.Address > 0) { + _dataCurrent.add(powerL1); + } + if (_cfg.PowerL2Register.Address > 0) { + _dataCurrent.add(powerL2); + } + if (_cfg.PowerL3Register.Address > 0) { + _dataCurrent.add(powerL3); + } + + if (_cfg.VoltageL1Register.Address > 0) { + _dataCurrent.add(voltageL1); + } + if (_cfg.VoltageL2Register.Address > 0) { + _dataCurrent.add(voltageL2); + } + if (_cfg.VoltageL3Register.Address > 0) { + _dataCurrent.add(voltageL3); + } + + if (_cfg.ImportRegister.Address > 0) { + _dataCurrent.add(importEnergy); + } + if (_cfg.ExportRegister.Address > 0) { + _dataCurrent.add(exportEnergy); + } + } + + DTU_LOGD("TotalPower: %5.2f", getPowerTotal()); + } +} + +} // namespace PowerMeters::Modbus::Tcp diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index b6680e011..e4f58e130 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -647,6 +647,7 @@ "typeSMAHM2": "SMA Homemanager 2.0", "typeHTTP_SML": "HTTP(S) + SML (z.B. Tibber Pulse via Tibber Bridge)", "typeUDP_VICTRON": "Victron VM-3P75CT (Modbus UDP)", + "typeMODBUS_TCP": "Modbus TCP", "MqttValue": "Konfiguration Wert {valueNumber}", "MqttTopic": "MQTT Topic", "mqttJsonPath": "Optional: JSON-Pfad", @@ -670,7 +671,33 @@ "testHttpSmlHeader": "Konfiguration testen", "testHttpSmlRequest": "HTTP(S)-Anfrage senden und Antwort verarbeiten", "HTTP_SML": "HTTP(S) + SML - Konfiguration", - "UDP_VICTRON": "Victron VM-3P75CT (Modbus UDP) - Konfiguration" + "UDP_VICTRON": "Victron VM-3P75CT (Modbus UDP) - Konfiguration", + "MODBUS_TCP": "Modbus TCP - Konfiguration", + "modbusTcpPort": "Port", + "modbusTcpDeviceId": "Geräte-ID", + "modbusTcpRegisterMapping": "Register-Zuordnung", + "modbusTcpRegisterMappingHint": "Konfigurieren Sie Registeradressen (0000-9999), Registertyp und Skalierungsfaktoren für jede Messung. Adresse leer lassen oder 0 setzen, um das Lesen zu deaktivieren.", + "modbusTcpPowerRegister": "Gesamtleistungs-Register", + "modbusTcpPowerL1Register": "Leistung L1 Register", + "modbusTcpPowerL2Register": "Leistung L2 Register", + "modbusTcpPowerL3Register": "Leistung L3 Register", + "modbusTcpVoltageL1Register": "Spannung L1 Register", + "modbusTcpVoltageL2Register": "Spannung L2 Register", + "modbusTcpVoltageL3Register": "Spannung L3 Register", + "modbusTcpImportRegister": "Import-Energie Register", + "modbusTcpExportRegister": "Export-Energie Register", + "modbusTcpRegisterAddress": "Adresse", + "modbusTcpRegisterAddressTooltip": "4-stellige Registeradresse (0000-9999). Leer lassen oder 0 setzen zum Deaktivieren.", + "modbusTcpScalingFactor": "Skalierungsfaktor", + "modbusTcpScalingFactorTooltip": "Multiplizieren Sie den Rohregisterwert mit diesem Faktor, um den endgültigen Messwert zu erhalten.", + "modbusTcpDataType": "Datentyp", + "modbusTcpDataTypeTooltip": "Datentyp des Registers: 16-bit oder 32-bit Ganzzahlen, oder 32-bit Gleitkommazahlen.", + "modbusTcpDataTypeInt16": "INT16 (vorzeichenbehaftet 16-bit)", + "modbusTcpDataTypeUint16": "UINT16 (vorzeichenlos 16-bit)", + "modbusTcpDataTypeInt32": "INT32 (vorzeichenbehaftet 32-bit)", + "modbusTcpDataTypeUint32": "UINT32 (vorzeichenlos 32-bit)", + "modbusTcpDataTypeFloat": "FLOAT (32-bit IEEE 754)", + "modbusTcpRegisterTooltip": "Modbus Holding-Register Adresse (1-basiert). Auf 0 setzen, um das Lesen dieses Wertes zu deaktivieren." }, "httprequestsettings": { "url": "URL", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 44de8cc57..7e54d7776 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -647,6 +647,7 @@ "typeSMAHM2": "SMA Homemanager 2.0", "typeHTTP_SML": "HTTP(S) + SML (e.g. Tibber Pulse via Tibber Bridge)", "typeUDP_VICTRON": "Victron VM-3P75CT (Modbus UDP)", + "typeMODBUS_TCP": "Modbus TCP Energy Meter", "MqttValue": "Value {valueNumber} Configuration", "mqttJsonPath": "Optional: JSON Path", "MqttTopic": "MQTT Topic", @@ -670,7 +671,33 @@ "testHttpSmlHeader": "Test Configuration", "testHttpSmlRequest": "Send HTTP(S) request and process response", "HTTP_SML": "Configuration", - "UDP_VICTRON": "Configuration" + "UDP_VICTRON": "Configuration", + "MODBUS_TCP": "Configuration", + "modbusTcpPort": "Port", + "modbusTcpDeviceId": "Device ID", + "modbusTcpRegisterMapping": "Register Mapping", + "modbusTcpRegisterMappingHint": "Configure register addresses (0000-9999), register type, and scaling factors for each measurement. Leave address empty or 0 to disable reading that value.", + "modbusTcpPowerRegister": "Total Power Register", + "modbusTcpPowerL1Register": "Power L1 Register", + "modbusTcpPowerL2Register": "Power L2 Register", + "modbusTcpPowerL3Register": "Power L3 Register", + "modbusTcpVoltageL1Register": "Voltage L1 Register", + "modbusTcpVoltageL2Register": "Voltage L2 Register", + "modbusTcpVoltageL3Register": "Voltage L3 Register", + "modbusTcpImportRegister": "Import Energy Register", + "modbusTcpExportRegister": "Export Energy Register", + "modbusTcpRegisterAddress": "Address", + "modbusTcpRegisterAddressTooltip": "4-digit register address (0000-9999). Leave empty or 0 to disable.", + "modbusTcpScalingFactor": "Scaling Factor", + "modbusTcpScalingFactorTooltip": "Multiply the raw register value by this factor to get the final measurement value.", + "modbusTcpDataType": "Data Type", + "modbusTcpDataTypeTooltip": "Data type of the register: 16-bit or 32-bit integers, or 32-bit floating point.", + "modbusTcpDataTypeInt16": "INT16 (signed 16-bit)", + "modbusTcpDataTypeUint16": "UINT16 (unsigned 16-bit)", + "modbusTcpDataTypeInt32": "INT32 (signed 32-bit)", + "modbusTcpDataTypeUint32": "UINT32 (unsigned 32-bit)", + "modbusTcpDataTypeFloat": "FLOAT (32-bit IEEE 754)", + "modbusTcpRegisterTooltip": "Modbus holding register address (1-based). Set to 0 to disable reading this value." }, "httprequestsettings": { "url": "URL", diff --git a/webapp/src/types/PowerMeterConfig.ts b/webapp/src/types/PowerMeterConfig.ts index fb28fc898..eb5e630a0 100644 --- a/webapp/src/types/PowerMeterConfig.ts +++ b/webapp/src/types/PowerMeterConfig.ts @@ -40,6 +40,28 @@ export interface PowerMeterUdpVictronConfig { ip_address: string; } +export interface PowerMeterModbusTcpRegisterConfig { + address: number; + scaling_factor: number; + data_type: number; +} + +export interface PowerMeterModbusTcpConfig { + polling_interval: number; + ip_address: string; + port: number; + device_id: number; + power_register: PowerMeterModbusTcpRegisterConfig; + power_l1_register: PowerMeterModbusTcpRegisterConfig; + power_l2_register: PowerMeterModbusTcpRegisterConfig; + power_l3_register: PowerMeterModbusTcpRegisterConfig; + voltage_l1_register: PowerMeterModbusTcpRegisterConfig; + voltage_l2_register: PowerMeterModbusTcpRegisterConfig; + voltage_l3_register: PowerMeterModbusTcpRegisterConfig; + import_register: PowerMeterModbusTcpRegisterConfig; + export_register: PowerMeterModbusTcpRegisterConfig; +} + export interface PowerMeterConfig { enabled: boolean; source: number; @@ -49,4 +71,5 @@ export interface PowerMeterConfig { http_json: PowerMeterHttpJsonConfig; http_sml: PowerMeterHttpSmlConfig; udp_victron: PowerMeterUdpVictronConfig; + modbus_tcp: PowerMeterModbusTcpConfig; } diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index 67ce2d973..606990adc 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -298,6 +298,381 @@ /> + + @@ -338,6 +713,7 @@ export default defineComponent({ { key: 5, value: this.$t('powermeteradmin.typeSMAHM2') }, { key: 6, value: this.$t('powermeteradmin.typeHTTP_SML') }, { key: 7, value: this.$t('powermeteradmin.typeUDP_VICTRON') }, + { key: 8, value: this.$t('powermeteradmin.typeMODBUS_TCP') }, ], unitTypeList: [ { key: 1, value: 'mW' }, @@ -371,6 +747,15 @@ export default defineComponent({ this.powerMeterConfigList.udp_victron.polling_interval_ms = value * 1000; }, }, + dataTypeOptions() { + return [ + { value: 0, text: this.$t('powermeteradmin.modbusTcpDataTypeInt16') }, + { value: 1, text: this.$t('powermeteradmin.modbusTcpDataTypeUint16') }, + { value: 2, text: this.$t('powermeteradmin.modbusTcpDataTypeInt32') }, + { value: 3, text: this.$t('powermeteradmin.modbusTcpDataTypeUint32') }, + { value: 4, text: this.$t('powermeteradmin.modbusTcpDataTypeFloat') }, + ]; + }, }, methods: { getPowerMeterConfig() { @@ -379,9 +764,57 @@ export default defineComponent({ .then((response) => handleResponse(response, this.$emitter, this.$router)) .then((data) => { this.powerMeterConfigList = data; + this.ensureModbusTcpRegisterStructure(); this.dataLoading = false; }); }, + ensureModbusTcpRegisterStructure() { + // Ensure modbus_tcp registers have the new structure for backward compatibility + if (this.powerMeterConfigList.modbus_tcp) { + const modbusTcp = this.powerMeterConfigList.modbus_tcp; + const registerNames = [ + 'power_register', + 'power_l1_register', + 'power_l2_register', + 'power_l3_register', + 'voltage_l1_register', + 'voltage_l2_register', + 'voltage_l3_register', + 'import_register', + 'export_register', + ]; + + registerNames.forEach((regName) => { + const reg = (modbusTcp as any)[regName]; + if (typeof reg === 'number') { + // Old format: convert number to register config object + (modbusTcp as any)[regName] = { + address: reg, + scaling_factor: 1.0, + data_type: 1, // Default to UINT16 + }; + } else if (!reg || typeof reg !== 'object') { + // Missing: create default (but only if truly missing) + (modbusTcp as any)[regName] = { + address: 0, + scaling_factor: 1.0, + data_type: 1, // Default to UINT16 + }; + } else { + // Valid object: ensure required fields exist with defaults + if (!reg.hasOwnProperty('address')) { + reg.address = 0; + } + if (!reg.hasOwnProperty('scaling_factor')) { + reg.scaling_factor = 1.0; + } + if (!reg.hasOwnProperty('data_type')) { + reg.data_type = 1; // Default to UINT16 + } + } + }); + } + }, savePowerMeterConfig(e: Event) { e.preventDefault();