diff --git a/include/Configuration.h b/include/Configuration.h index e13b558aa..634537546 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -82,6 +82,13 @@ struct CONFIG_T { double Latitude; uint8_t SunsetType; } Ntp; + struct { + bool TCPEnabled; + uint32_t Port; + uint32_t IDDTUPro; + uint32_t IDTotal; + uint32_t IDMeter; + } Modbus; struct { bool Enabled; diff --git a/include/ModbusDtu.h b/include/ModbusDtu.h new file mode 100644 index 000000000..a3c4e3463 --- /dev/null +++ b/include/ModbusDtu.h @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include + +#include + +// eModbus +#include "ModbusMessage.h" +#include "ModbusServerTCPasync.h" + +class ModbusDTUMessage : public ModbusMessage { +private: + // Value cache, mostly for conversion + union Value { + float val_float; + uint16_t val_u16; + int32_t val_i32; + uint32_t val_u32; + uint64_t val_u64; + uint32_t val_ip; + } value; + + // Conversion cache + union Conversion { + // fixed point converted to u32 + uint32_t fixed_point_u32; + // uint64 converted to hex string + char u64_hex_str[sizeof(uint64_t) * 8 + 1]; + // uint64 converted to 12 decimal digits (6 registers) in big endian + std::array u64_dec_digits; + // ip address converted to String + char ip_str[12]; + } conv; + +public: + // Default empty message Constructor - optionally takes expected size of MM_data + explicit ModbusDTUMessage(uint16_t dataLen); + + // Special message Constructor - takes a std::vector + explicit ModbusDTUMessage(std::vector s); + + // Add float to Modbus register + void addFloat32(const float_t &val, const size_t reg_offset); + + // Add float as decimal fixed point to Modbus register + void addFloat32AsDecimalFixedPoint(const float_t &val, const float &precision, const size_t reg_offset); + + // Add string to Modbus register + void addString(const char * const str, const size_t length, const size_t reg_offset); + + // Add string to Modbus register + void addString(const String &str, const size_t reg_offset); + + // Add uint32 to Modbus register + void addUInt32(const uint32_t val, const size_t reg_offset); + + // Add uint64 to Modbus register + void addUInt64(const uint64_t val, const size_t reg_offset); + + // Convert uint64 to hex string and add to Modbus register + void addUInt64AsHexString(const uint64_t val, const size_t reg_offset); + + // Convert uint64 to 12 decimal digits (big endian) and add to Modbus register + void addUInt64AsDecimalDigits(const uint64_t val, const size_t reg_offset); + + // Convert IP address to string and add to Modbus register + void addIPAddressAsString(const IPAddress val, const size_t reg_offset); +}; + +ModbusMessage DTUPro(ModbusMessage request); +ModbusMessage OpenDTUTotal(ModbusMessage request); +ModbusMessage OpenDTUMeter(ModbusMessage request); + +extern ModbusServerTCPasync ModbusTCPServer; diff --git a/include/ModbusSettings.h b/include/ModbusSettings.h new file mode 100644 index 000000000..d559a957d --- /dev/null +++ b/include/ModbusSettings.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +class ModbusSettingsClass { +public: + ModbusSettingsClass(); + void init(); + + void performConfig(); + +private: + void startTCP(); + + void stopTCP(); +}; + +extern ModbusSettingsClass ModbusSettings; diff --git a/include/WebApi.h b/include/WebApi.h index b6fdbd089..dca7dbdcc 100644 --- a/include/WebApi.h +++ b/include/WebApi.h @@ -12,6 +12,7 @@ #include "WebApi_inverter.h" #include "WebApi_limit.h" #include "WebApi_maintenance.h" +#include "WebApi_modbus.h" #include "WebApi_mqtt.h" #include "WebApi_network.h" #include "WebApi_ntp.h" @@ -55,6 +56,7 @@ class WebApiClass { WebApiInverterClass _webApiInverter; WebApiLimitClass _webApiLimit; WebApiMaintenanceClass _webApiMaintenance; + WebApiModbusClass _webApiModbus; WebApiMqttClass _webApiMqtt; WebApiNetworkClass _webApiNetwork; WebApiNtpClass _webApiNtp; diff --git a/include/WebApi_modbus.h b/include/WebApi_modbus.h new file mode 100644 index 000000000..673272662 --- /dev/null +++ b/include/WebApi_modbus.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include + +class WebApiModbusClass { +public: + void init(AsyncWebServer& server, Scheduler& scheduler); + +private: + void onModbusStatus(AsyncWebServerRequest* request); + void onModbusAdminGet(AsyncWebServerRequest* request); + void onModbusAdminPost(AsyncWebServerRequest* request); +}; diff --git a/include/defaults.h b/include/defaults.h index fd41a3d0b..2ee90306b 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -30,6 +30,12 @@ #define NTP_LATITUDE 51.1657f #define NTP_SUNSETTYPE 1U +#define MODBUS_TCP_ENABLED false +#define MODBUS_PORT 502 +#define MODBUS_ID_DTUPRO 1 +#define MODBUS_ID_TOTAL 125 +#define MODBUS_ID_METER 243 + #define MQTT_ENABLED false #define MQTT_HOST "" #define MQTT_PORT 1883U diff --git a/platformio.ini b/platformio.ini index 48d9cf97b..ee48e43eb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -36,10 +36,17 @@ build_flags = build_unflags = -std=gnu++11 +; Ignore dependencies of eModbus as they are fulfilled by other library variants +lib_ignore = + AsyncTCP + ESPAsyncTCP + custom-Ethernet + lib_deps = mathieucarbou/ESP Async WebServer @ 2.9.0 bblanchon/ArduinoJson @ ^7.0.4 https://github.com/bertmelis/espMqttClient.git#v1.6.0 + https://github.com/eModbus/eModbus.git nrf24/RF24 @ ^1.4.8 olikraus/U8g2 @ ^2.35.15 buelowp/sunset @ ^1.1.7 diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 8e8030745..5783c574d 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -54,6 +54,13 @@ bool ConfigurationClass::write() ntp["longitude"] = config.Ntp.Longitude; ntp["sunsettype"] = config.Ntp.SunsetType; + JsonObject modbus = doc["modbus"].to(); + modbus["tcp_enabled"] = config.Modbus.TCPEnabled; + modbus["port"] = config.Modbus.Port; + modbus["id_dtupro"] = config.Modbus.IDDTUPro; + modbus["id_total"] = config.Modbus.IDTotal; + modbus["id_meter"] = config.Modbus.IDMeter; + JsonObject mqtt = doc["mqtt"].to(); mqtt["enabled"] = config.Mqtt.Enabled; mqtt["hostname"] = config.Mqtt.Hostname; @@ -227,6 +234,13 @@ bool ConfigurationClass::read() config.Ntp.Longitude = ntp["longitude"] | NTP_LONGITUDE; config.Ntp.SunsetType = ntp["sunsettype"] | NTP_SUNSETTYPE; + JsonObject modbus = doc["modbus"]; + config.Modbus.TCPEnabled = modbus["tcp_enabled"] | MODBUS_TCP_ENABLED; + config.Modbus.Port = modbus["port"] | MODBUS_PORT; + config.Modbus.IDDTUPro = modbus["id_dtupro"] | MODBUS_ID_DTUPRO; + config.Modbus.IDTotal = modbus["id_total"] | MODBUS_ID_TOTAL; + config.Modbus.IDMeter = modbus["id_meter"] | MODBUS_ID_METER; + JsonObject mqtt = doc["mqtt"]; config.Mqtt.Enabled = mqtt["enabled"] | MQTT_ENABLED; strlcpy(config.Mqtt.Hostname, mqtt["hostname"] | MQTT_HOST, sizeof(config.Mqtt.Hostname)); diff --git a/src/ModbusDtu.cpp b/src/ModbusDtu.cpp new file mode 100644 index 000000000..5bb4bb33a --- /dev/null +++ b/src/ModbusDtu.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Bobby Noelte + */ +#include +#include +#include + +// OpenDTU +#include "ModbusDtu.h" + + +ModbusDTUMessage::ModbusDTUMessage(uint16_t dataLen = 0) : ModbusMessage(dataLen) { + value.val_float = NAN; +} + +ModbusDTUMessage::ModbusDTUMessage(std::vector s) : ModbusMessage(s) { + value.val_float = NAN; +} + +void ModbusDTUMessage::addFloat32(const float_t &val, const size_t reg_offset) { + // Use union to convert from float to uint32 + value.val_float = val; + + addUInt32(value.val_u32, reg_offset); +} + +void ModbusDTUMessage::addFloat32AsDecimalFixedPoint(const float_t &val, const float &precision, const size_t reg_offset) { + // Check if value is already converted to fixed point + if (value.val_float != val) { + // Multiply by 10^precision to shift the decimal point + // Round the scaled value to the nearest integer + // Use union to convert from fixed point to uint32 + value.val_i32 = round(val * std::pow(10, precision)); + // remember converted value + conv.fixed_point_u32 = value.val_u32; + // mark conversion + value.val_float = val; + } + + addUInt32(conv.fixed_point_u32, reg_offset); +} + +void ModbusDTUMessage::addString(const char * const str, const size_t length, const size_t reg_offset) { + // Check if the position is within the bounds of the string + size_t offset = reg_offset * sizeof(uint16_t); + if (offset + sizeof(uint16_t) <= length) { + // Reinterpret the memory at position 'offset' as uint16_t + std::memcpy(&value.val_u16, str + offset, sizeof(uint16_t)); + } else { + value.val_u16 = 0; + } + + add(value.val_u16); +} + +void ModbusDTUMessage::addString(const String &str, const size_t reg_offset) { + addString(str.c_str(), str.length(), reg_offset); +} + +void ModbusDTUMessage::addUInt32(const uint32_t val, const size_t reg_offset) { + if (reg_offset <= 1) { + add((uint16_t)(val >> 16 * (1 - reg_offset))); + } else { + add((uint16_t)0); + } +} + +void ModbusDTUMessage::addUInt64(const uint64_t val, const size_t reg_offset) { + if (reg_offset <= 3) { + add((uint16_t)(val >> 16 * (3 - reg_offset))); + } else { + add((uint16_t)0); + } +} + +void ModbusDTUMessage::addUInt64AsHexString(const uint64_t val, const size_t reg_offset) { + // Check if value is already converted to hex string + if (val != value.val_u64) { + snprintf(&conv.u64_hex_str[0], sizeof(conv.u64_hex_str), "%0x%08x", + ((uint32_t)((val >> 32) & 0xFFFFFFFF)), + ((uint32_t)(val & 0xFFFFFFFF))); + // mark conversion + value.val_u64 = val; + } + + addString(&conv.u64_hex_str[0], sizeof(conv.u64_hex_str), reg_offset); +} + +void ModbusDTUMessage::addUInt64AsDecimalDigits(const uint64_t val, const size_t reg_offset) { + if (val != value.val_u64) { + value.val_u64 = val; + // Extract digits from the number + for (int i = 6 - 1; i >= 0; i--) { + conv.u64_dec_digits[i] = value.val_u64 % 10; // Extract the least significant digit + value.val_u64 /= 10; // Remove the least significant digit + conv.u64_dec_digits[i] += (value.val_u64 % 10) << 8; // Extract the least significant digit + value.val_u64 /= 10; // Remove the least significant digit + } + // mark conversion + value.val_u64 = val; + } + + if (reg_offset < 6) { + add(conv.u64_dec_digits[reg_offset]); + } else { + add((uint16_t)0); + } +} + +void ModbusDTUMessage::addIPAddressAsString(const IPAddress val, const size_t reg_offset) { + // Check if value is already converted to hex string + if (val != value.val_ip) { + String str(val.toString()); + std::memcpy(&conv.ip_str, str.c_str(), std::min(sizeof(conv.ip_str), str.length())); + // mark conversion + value.val_ip = val; + } + + addString(&conv.ip_str[0], sizeof(conv.ip_str), reg_offset); +} + +// Create server(s) +ModbusServerTCPasync ModbusTCPServer; diff --git a/src/ModbusDtuMeter.cpp b/src/ModbusDtuMeter.cpp new file mode 100644 index 000000000..058482987 --- /dev/null +++ b/src/ModbusDtuMeter.cpp @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Bobby Noelte + */ +#include +#include + +// OpenDTU +#include "Hoymiles.h" +#include "Configuration.h" +#include "Datastore.h" +#include "ModbusDtu.h" +#include "ModbusSettings.h" +#include "NetworkSettings.h" + +// eModbus +#include "Logging.h" + +// OpenDTU single phase (AN or AB) meter +// - FC 0x03 requests (read holding registers) +ModbusMessage OpenDTUMeter(ModbusMessage request) { + uint16_t addr = 0; // Start address + uint16_t words = 0; // # of words requested + + const CONFIG_T& config = Configuration.get(); + + // read address from request + request.get(2, addr); + // read # of words from request + request.get(4, words); + + LOG_D("Request FC03 0x%04x:%d\n", (int)addr, (int)words); + + uint16_t response_size = words * 2 + 6; + ModbusDTUMessage response(response_size); // The Modbus message we are going to give back + + LOG_D("Response initialized to size %d bytes\n", (int)response_size); + + if (addr >= 40000) { + // SunSpec - OpenDTU Meter + + // Set up response + response.add(request.getServerID(), request.getFunctionCode(), (uint8_t)(words * 2)); + + // Complete response + for (uint16_t reg = addr; reg < (addr + words); reg++) { + if (reg < 40070) { + // Model 1 - SunSpec Common Registers + uint8_t reg_idx = reg - 40000; + + switch (reg_idx) { + case 0 ... 1: + // SunS + response.addString("SunS", reg_idx - 0); + break; + case 2: + // Model ID + response.add((uint16_t)1); + break; + case 3: + // SunSpec model register count (length without header (4)) + response.add((uint16_t)66); + break; + case 4 ... 19: + // Manufacturer - string + response.addString("OpenDTU", reg_idx - 4); + break; + case 20 ... 35: + // Model - string + response.addString("OpenDTU Meter", reg_idx - 20); + break; + case 36 ... 43: + // Options - string + response.addString(config.Dev_PinMapping, reg_idx - 36); + break; + case 44 ... 51: + // Version - string + response.addString(AUTO_GIT_HASH, reg_idx - 44); + break; + case 52 ... 67: + // Serial Number - string + response.addUInt64AsHexString(config.Dtu.Serial, reg_idx - 52); + break; + case 68: + // Device Address - uint16 + response.add((uint16_t)request.getServerID()); + break; + default: + // Pad + response.add((uint16_t)0x8000); + break; + } + } else if (reg < 40198) { // >= 40070 + // Model 211 - Single Phase (AN or AB) Meter FLOAT Model + // The Meter acts as a virtual meter that combines the individual + // measured values of the inverters, if useful. + uint8_t reg_idx = reg - 40070; + + switch (reg_idx) { + case 0: + // Model ID + response.add((uint16_t)211); + break; + case 1: + // SunSpec model register count (length without model header (2)) + response.add((uint16_t)124); + break; + case 28 ... 29: + // Watts (W), Total Real Power + response.addFloat32(Datastore.getTotalAcPowerEnabled() * -1, reg_idx - 28); + break; + case 60 ... 61: + // Total Watt-hours Exported (Wh), Total Real Energy Exported + response.addFloat32(Datastore.getTotalAcYieldTotalEnabled() * 1000, reg_idx - 60); + break; + case 124 ... 125: + // bitfield32 + response.add((uint16_t)0); + break; + default: + // float32 - Not a Number + response.addFloat32(NAN, reg_idx & 0x01); + break; + } + } else if (reg < 40200) { // >= 40198 + // Mark end of models + response.add((uint16_t)0); + } else { + goto address_error; + } + } + } else { +address_error: + // No valid regs - send respective error response + LOG_W("Illegal data address 0x%04x:%d\n", (int)addr, (int)words); + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + } + +respond: + HEXDUMP_D("Response FC03", response.data(), response.size()); + + // Send response back + return response; +} diff --git a/src/ModbusDtuPro.cpp b/src/ModbusDtuPro.cpp new file mode 100644 index 000000000..ff429223a --- /dev/null +++ b/src/ModbusDtuPro.cpp @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Bobby Noelte + */ +#include +#include +#include + +// OpenDTU +#include "Hoymiles.h" +#include "Configuration.h" +#include "ModbusDtu.h" +#include "ModbusSettings.h" + +// eModbus +#include "Logging.h" + +// DTUPro - Number of registers per inverter +#define DTUPRO_INV_DATA_REGISTER_COUNT 40 + +// DTUPro - Number of registers for serial number of inverter +#define DTUPRO_INV_SERIAL_REGISTER_COUNT 6 + +// DTUPro - Data type of a inverter register bank +#define DTUPRO_INV_DATA_TYPE_DEFAULT 0x3C + +typedef struct { + std::shared_ptr inv; // inverter + ChannelNum_t chan; // dc channel 0 - 5 of inverter + uint8_t chan_total; // total # of dc channels of inverter +} ModbusInvChannel_t; + +// 3-Gen DTU-Pro +// - FC 0x03 requests (read holding registers) +ModbusMessage DTUPro(ModbusMessage request) { + uint16_t addr = 0; // Start address + uint16_t words = 0; // # of words requested + + uint8_t num_inverters = Hoymiles.getNumInverters(); + + // read address from request + request.get(2, addr); + // read # of words from request + request.get(4, words); + + LOG_D("Request FC03 0x%04x:%d\n", (int)addr, (int)words); + + uint16_t response_size = words * 2 + 6; + ModbusDTUMessage response(response_size); // The Modbus message we are going to give back + + LOG_D("Response initialized to size %d bytes\n", (int)response_size); + + if (addr >= 0x2000) { + // Holding registers for serial numbers + const CONFIG_T& config = Configuration.get(); + + if ((addr + words) > (0x2003 + num_inverters * (DTUPRO_INV_SERIAL_REGISTER_COUNT + 1))) { + // No valid regs - send respective error response + LOG_W("Illegal data address 0x%04x:%d\n", (int)addr, (int)words); + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + return response; + } + + // Set up response + response.add(request.getServerID(), request.getFunctionCode(), (uint8_t)(words * 2)); + + // Complete response, 12 digit decimal serial number big endian, per 6 registers, final 0 + for (uint16_t reg = addr; reg < (addr + words); reg++) { + if (reg >= (0x2006 + num_inverters * DTUPRO_INV_SERIAL_REGISTER_COUNT)) { + // No more inverters, add 0 for end of inverters + response.add((uint16_t)0); + } else if (reg >= 0x2006) { + // Inverter serial number + uint8_t inv_idx = (reg - 0x2003) / DTUPRO_INV_SERIAL_REGISTER_COUNT; + auto inv = Hoymiles.getInverterByPos(inv_idx); + + uint8_t reg_idx = reg - (0x2003 + inv_idx * DTUPRO_INV_SERIAL_REGISTER_COUNT); + uint64_t inv_serial = 0; + if (inv != nullptr) { + inv_serial = inv->serial(); + } + response.addUInt64AsDecimalDigits(inv_serial, reg_idx); + } else { + // DTU serial number, reg 0x2000, 0x2001, 0x2002, ... 0x2005 + uint8_t reg_idx = reg - 0x2000; + response.addUInt64AsDecimalDigits(config.Dtu.Serial, reg_idx); + } + } + } else if (addr >= 0x1000) { + // Loop all inverters and channels + std::vector channels; + for (uint8_t inv_idx = 0; inv_idx < num_inverters; inv_idx++) { + auto inv = Hoymiles.getInverterByPos(inv_idx); + if (inv == nullptr) { + continue; + } + if (!inv->isReachable()) { + // Inverter not reachable - send respective error response + LOG_W("Inverter#%d not reachable\n", (int)(inv_idx + 1)); + response.setError(request.getServerID(), request.getFunctionCode(), GATEWAY_TARGET_NO_RESP); + goto respond; + } + auto inv_channels = inv->Statistics()->getChannelsByType(TYPE_DC); + for (auto& inv_chan : inv_channels) { + ModbusInvChannel_t channel; + channel.inv = inv; + channel.chan = inv_chan; + channel.chan_total = inv_channels.size(); + channels.push_back(channel); + + // Debugging DC channel values + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(inv_chan + 1), "TYPE_DC.FLD_UDC", + inv->Statistics()->getChannelFieldValue(TYPE_DC, inv_chan, FLD_UDC)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(inv_chan + 1), "TYPE_DC.FLD_IDC", + inv->Statistics()->getChannelFieldValue(TYPE_DC, inv_chan, FLD_IDC)); + } + // Debugging AC channel values + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_AC.FLD_UAC_1N", + inv->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_UAC_1N)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_AC.FLD_UAC_2N", + inv->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_UAC_2N)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_AC.FLD_UAC_3N", + inv->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_UAC_3N)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_AC.FLD_UAC", + inv->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_UAC)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_AC.FLD_PAC", + inv->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_AC.FLD_F", + inv->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_F)); + // Debugging inverter channel values + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_INV.FLD_YD", + inv->Statistics()->getChannelFieldValue(TYPE_INV, CH0, FLD_YD)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_INV.FLD_YT", + inv->Statistics()->getChannelFieldValue(TYPE_INV, CH0, FLD_YT)); + LOG_D("Modbus inv#%d chan#%d %s: %f\n", (int)(inv_idx + 1), (int)(CH0 + 1), "TYPE_INV.FLD_T", + inv->Statistics()->getChannelFieldValue(TYPE_INV, CH0, FLD_T)); + } + + if ((addr + words) > (0x1000 + channels.size() * DTUPRO_INV_DATA_REGISTER_COUNT)) { + // No valid regs - send respective error response + LOG_W("Illegal data address 0x%04x:%d\n", (int)addr, (int)words); + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + goto respond; + } + + // Set up response + response.add(request.getServerID(), request.getFunctionCode(), (uint8_t)(words * 2)); + + // Complete response + for (uint16_t reg = addr; reg < (addr + words); reg++) { + uint8_t chan_idx = (reg - 0x1000) / DTUPRO_INV_DATA_REGISTER_COUNT; + uint8_t reg_idx = reg - (0x1000 + chan_idx * DTUPRO_INV_DATA_REGISTER_COUNT); + auto statistics = channels[chan_idx].inv->Statistics(); + + switch (reg_idx) { + case 0: + // Start of dataset + response.add((uint16_t)DTUPRO_INV_DATA_TYPE_DEFAULT); + break; + case 1 ... 6: + // Microinverter SN + response.addUInt64AsDecimalDigits(channels[chan_idx].inv->serial(), reg_idx - 1); + break; + case 7: + // Port Number - uint16, starts at 1 + response.add((uint16_t)(channels[chan_idx].chan + 1)); + break; + case 8 ... 9: + // PV Voltage (V) - decimal fixed point, precision 1 + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_DC, channels[chan_idx].chan, FLD_UDC), 1, + reg_idx - 8); + break; + case 10 ... 11: + // PV Current (A) - decimal fixed point, precision 2 + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_DC, channels[chan_idx].chan, FLD_IDC), 2, + reg_idx - 10); + break; + case 12 ... 13: + // Grid Voltage (V) - decimal fixed point, precision 1 + if (statistics->hasChannelFieldValue(TYPE_AC, CH0, FLD_UAC_1N)) { + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_AC, CH0, FLD_UAC_1N), 1, + reg_idx - 12); + } else { + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_AC, CH0, FLD_UAC), 1, + reg_idx - 12); + } + break; + case 14 ... 15: + // Grid Frequency (Hz) - decimal fixed point, precision 2 + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_AC, CH0, FLD_F), 2, + reg_idx - 14); + break; + case 16 ... 17: + // PV Power (W) - decimal fixed point, precision 1 + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC), 1, + reg_idx - 16); + break; + case 18 ... 19: + // Today Production (Wh) - uint32 + response.addUInt32( + statistics->getChannelFieldValue(TYPE_INV, CH0, FLD_YD) * 1, + reg_idx - 18); + break; + case 20 ... 23: + // Total Production (Wh) - uint64 + response.addUInt64( + statistics->getChannelFieldValue(TYPE_INV, CH0, FLD_YT) * 1000, + reg_idx - 20); + break; + case 24 ... 25: + // Temperature (°C) - decimal fixed point, precision 1 + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_INV, CH0, FLD_T), 1, + reg_idx - 24); + break; + case 26 ... 32: + // Todo + response.add((uint16_t)0x8000); + break; + case 33: + // Fixed + response.add((uint16_t)0x07); + break; + case 34 ... 39: + default: + // Reserved + response.add((uint16_t)0x8000); + break; + } + } + } else { + // No valid regs - send respective error response + LOG_W("Illegal data address 0x%04x:%d\n", (int)addr, (int)words); + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + return response; + } + +respond: + HEXDUMP_D("Response FC03", response.data(), response.size()); + + // Send response back + return response; +} diff --git a/src/ModbusDtuTotal.cpp b/src/ModbusDtuTotal.cpp new file mode 100644 index 000000000..090d6d3c4 --- /dev/null +++ b/src/ModbusDtuTotal.cpp @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Bobby Noelte + */ +#include +#include + +// OpenDTU +#include "Hoymiles.h" +#include "Configuration.h" +#include "Datastore.h" +#include "ModbusDtu.h" +#include "ModbusSettings.h" +#include "NetworkSettings.h" + +// eModbus +#include "Logging.h" + +// OpenDTU Total inverter +// - FC 0x03 requests (read holding registers) +ModbusMessage OpenDTUTotal(ModbusMessage request) { + uint16_t addr = 0; // Start address + uint16_t words = 0; // # of words requested + + const CONFIG_T& config = Configuration.get(); + + // read address from request + request.get(2, addr); + // read # of words from request + request.get(4, words); + + LOG_D("Request FC03 0x%04x:%d\n", (int)addr, (int)words); + + uint16_t response_size = words * 2 + 6; + ModbusDTUMessage response(response_size); // The Modbus message we are going to give back + + LOG_D("Response initialized to size %d bytes\n", (int)response_size); + + if (addr >= 40000) { + // SunSpec - OpenDTU Total inverter + + // Set up response + response.add(request.getServerID(), request.getFunctionCode(), (uint8_t)(words * 2)); + + // Complete response + for (uint16_t reg = addr; reg < (addr + words); reg++) { + if (reg < 40070) { + // Model 1 - SunSpec Common Registers + uint8_t reg_idx = reg - 40000; + + switch (reg_idx) { + case 0 ... 1: + // SunS + response.addString("SunS", reg_idx - 0); + break; + case 2: + // Model ID + response.add((uint16_t)1); + break; + case 3: + // SunSpec model register count (length without header (4)) + response.add((uint16_t)66); + break; + case 4 ... 19: + // Manufacturer - string + response.addString("OpenDTU", reg_idx - 4); + break; + case 20 ... 35: + // Model - string + response.addString("OpenDTU Total", reg_idx - 20); + break; + case 36 ... 43: + // Options - string + response.addString(config.Dev_PinMapping, reg_idx - 36); + break; + case 44 ... 51: + // Version - string + response.addString(AUTO_GIT_HASH, reg_idx - 44); + break; + case 52 ... 67: + // Serial Number - string + response.addUInt64AsHexString(config.Dtu.Serial, reg_idx - 52); + break; + case 68: + // Device Address - uint16 + response.add((uint16_t)request.getServerID()); + break; + default: + // Pad + response.add((uint16_t)0x8000); + break; + } + } else if (reg < 40170) { // >= 40070 + // Model 12 - IPv4 Model + uint8_t reg_idx = reg - 40170; + + switch (reg_idx) { + case 0: + // Model ID + response.add((uint16_t)12); + break; + case 1: + // SunSpec model register count (length without model header (2)) + response.add((uint16_t)98); + break; + case 2 ... 4: + // Interface name + response.addString(NetworkSettings.macAddress(), reg_idx - 2); + break; + case 11 ... 18: + // IP - string + response.addIPAddressAsString(NetworkSettings.localIP(), reg_idx - 11); + break; + case 19 ... 26: + // Netmask - string + response.addIPAddressAsString(NetworkSettings.subnetMask(), reg_idx - 19); + break; + case 27 ... 34: + // Gateway - string + response.addIPAddressAsString(NetworkSettings.gatewayIP(), reg_idx - 27); + break; + case 35 ... 42: + // DNS1 - string + response.addIPAddressAsString(NetworkSettings.dnsIP(0), reg_idx - 35); + break; + case 43 ... 50: + // DNS2 - string + response.addIPAddressAsString(NetworkSettings.dnsIP(1), reg_idx - 43); + break; + case 51 ... 62: + // NTP1 - string + response.add((uint16_t)0); + break; + case 63 ... 74: + // NTP2 - string + response.add((uint16_t)0); + break; + case 75 ... 86: + // Domain name - string + response.add((uint16_t)0); + break; + case 87 ... 98: + // Host name - string + response.addString(NetworkSettings.getHostname(), reg_idx - 87); + break; + default: + // Points set to NaN + response.add((uint16_t)0x8000); + break; + } + } else if (reg < 40232) { // >= 40170 + // Model 113 - Inverter (Three Phase) FLOAT Model + // The Inverter Manager acts as a virtual inverter that combines the individual + // measured values of the inverters, if useful. + uint8_t reg_idx = reg - 40170; + + switch (reg_idx) { + case 0: + // Model ID + response.add((uint16_t)113); + break; + case 1: + // SunSpec model register count (length without model header (2)) + response.add((uint16_t)60); + break; + case 22 ... 23: + // AC Power (W) + response.addFloat32(Datastore.getTotalAcPowerEnabled(), reg_idx - 22); + break; + case 32 ... 33: + // AC Energy (Wh) + response.addFloat32(Datastore.getTotalAcYieldTotalEnabled() * 1000, reg_idx - 32); + break; + case 38 ... 39: + // DC Power (W) + response.addFloat32(Datastore.getTotalDcPowerEnabled(), reg_idx - 38); + break; + case 48 ... 49: + // enum16 + response.add((uint16_t)0); + break; + case 50 ... 61: + // bitfield32 + response.add((uint16_t)0); + break; + default: + // float32 - Not a Number + response.addFloat32(NAN, reg_idx & 0x01); + break; + } + } else if (reg < 40234) { // >= 40232 + // Mark end of models + response.add((uint16_t)0); + } else { + goto address_error; + } + } + } else { +address_error: + // No valid regs - send respective error response + LOG_W("Illegal data address 0x%04x:%d\n", (int)addr, (int)words); + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + } + +respond: + HEXDUMP_D("Response FC03", response.data(), response.size()); + + // Send response back + return response; +} diff --git a/src/ModbusSettings.cpp b/src/ModbusSettings.cpp new file mode 100644 index 000000000..7ef1a6593 --- /dev/null +++ b/src/ModbusSettings.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Bobby Noelte + */ + +// OpenDTU +#include "Configuration.h" +#include "MessageOutput.h" +#include "ModbusDtu.h" +#include "ModbusSettings.h" + +// eModbus +#include "Logging.h" + +ModbusSettingsClass::ModbusSettingsClass() +{ +} + +void ModbusSettingsClass::init() +{ + // Set Modbus logging to OpenDTU MessageOutput + LOGDEVICE = &MessageOutput; + + // Start server(s) if enabled + performConfig(); +} + +// Start server(s) +void ModbusSettingsClass::startTCP() +{ + const CONFIG_T& config = Configuration.get(); + + if (!ModbusTCPServer.isRunning()) { + // Define server(s) + ModbusTCPServer.registerWorker(config.Modbus.IDDTUPro, READ_HOLD_REGISTER, &DTUPro); + ModbusTCPServer.registerWorker(config.Modbus.IDTotal, READ_HOLD_REGISTER, &OpenDTUTotal); + ModbusTCPServer.registerWorker(config.Modbus.IDMeter, READ_HOLD_REGISTER, &OpenDTUMeter); + + // Start + ModbusTCPServer.start(Configuration.get().Modbus.Port, 1, 20000); + } +} + +// Stop servers(s) +void ModbusSettingsClass::stopTCP() +{ + const CONFIG_T& config = Configuration.get(); + + if (ModbusTCPServer.isRunning()) { + ModbusTCPServer.stop(); + } + + ModbusTCPServer.unregisterWorker(config.Modbus.IDDTUPro); + ModbusTCPServer.unregisterWorker(config.Modbus.IDTotal); + ModbusTCPServer.unregisterWorker(config.Modbus.IDMeter); +} + +void ModbusSettingsClass::performConfig() +{ + // Force stop of all servers + stopTCP(); + + // (Re-)start servers if enabled + if (Configuration.get().Modbus.TCPEnabled) { + startTCP(); + } +} + +ModbusSettingsClass ModbusSettings; diff --git a/src/WebApi.cpp b/src/WebApi.cpp index 1a5b28709..b87e23ada 100644 --- a/src/WebApi.cpp +++ b/src/WebApi.cpp @@ -25,6 +25,7 @@ void WebApiClass::init(Scheduler& scheduler) _webApiInverter.init(_server, scheduler); _webApiLimit.init(_server, scheduler); _webApiMaintenance.init(_server, scheduler); + _webApiModbus.init(_server, scheduler); _webApiMqtt.init(_server, scheduler); _webApiNetwork.init(_server, scheduler); _webApiNtp.init(_server, scheduler); diff --git a/src/WebApi_modbus.cpp b/src/WebApi_modbus.cpp new file mode 100644 index 000000000..cc0ba6b59 --- /dev/null +++ b/src/WebApi_modbus.cpp @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Bobby Noelte + */ +#include "WebApi_modbus.h" +#include "NetworkSettings.h" +#include "ModbusSettings.h" +#include "Configuration.h" +#include "WebApi.h" +#include "WebApi_errors.h" +#include "helper.h" +#include + +void WebApiModbusClass::init(AsyncWebServer& server, Scheduler& scheduler) +{ + using std::placeholders::_1; + + server.on("/api/modbus/status", HTTP_GET, std::bind(&WebApiModbusClass::onModbusStatus, this, _1)); + server.on("/api/modbus/config", HTTP_GET, std::bind(&WebApiModbusClass::onModbusAdminGet, this, _1)); + server.on("/api/modbus/config", HTTP_POST, std::bind(&WebApiModbusClass::onModbusAdminPost, this, _1)); +} + +void WebApiModbusClass::onModbusStatus(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentialsReadonly(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + auto& root = response->getRoot(); + const CONFIG_T& config = Configuration.get(); + + root["modbus_tcp_enabled"] = config.Modbus.TCPEnabled; + root["modbus_hostname"] = NetworkSettings.getHostname(); + root["modbus_ip"] = NetworkSettings.localIP().toString(); + root["modbus_port"] = config.Modbus.Port; + root["modbus_id_dtupro"] = config.Modbus.IDDTUPro; + root["modbus_id_total"] = config.Modbus.IDTotal; + root["modbus_id_meter"] = config.Modbus.IDMeter; + + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); +} + +void WebApiModbusClass::onModbusAdminGet(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + auto& root = response->getRoot(); + const CONFIG_T& config = Configuration.get(); + + root["modbus_tcp_enabled"] = config.Modbus.TCPEnabled; + + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); +} + +void WebApiModbusClass::onModbusAdminPost(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + JsonDocument root; + if (!WebApi.parseRequestData(request, response, root)) { + return; + } + + auto& retMsg = response->getRoot(); + + if (!root.containsKey("modbus_tcp_enabled")) { + retMsg["message"] = "Values are missing!"; + retMsg["code"] = WebApiError::GenericValueMissing; + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); + return; + } + + if (root["modbus_tcp_enabled"].as()) { + // Provision for further modbus settings + } + + CONFIG_T& config = Configuration.get(); + config.Modbus.TCPEnabled = root["modbus_tcp_enabled"].as(); + + WebApi.writeConfig(retMsg); + + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); + + ModbusSettings.performConfig(); +} diff --git a/src/main.cpp b/src/main.cpp index 433619e1f..342264204 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,8 @@ #include "InverterSettings.h" #include "Led_Single.h" #include "MessageOutput.h" +#include "ModbusDtu.h" +#include "ModbusSettings.h" #include "MqttHandleDtu.h" #include "MqttHandleHass.h" #include "MqttHandleInverter.h" @@ -101,6 +103,11 @@ void setup() SunPosition.init(scheduler); MessageOutput.println("done"); + // Initialize Modbus + MessageOutput.print("Initialize Modbus... "); + ModbusSettings.init(); + MessageOutput.println("done"); + // Initialize MqTT MessageOutput.print("Initialize MqTT... "); MqttSettings.init();