diff --git a/include/Configuration.h b/include/Configuration.h index bb0e478f2..dde4f557e 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -84,6 +84,12 @@ struct CONFIG_T { double Latitude; uint8_t SunsetType; } Ntp; + struct { + bool TCPEnabled; + uint32_t Port; + uint32_t IDDTUPro; + uint32_t IDTotal; + } Modbus; struct { bool Enabled; diff --git a/include/ModbusDtu.h b/include/ModbusDtu.h new file mode 100644 index 000000000..43b019ea8 --- /dev/null +++ b/include/ModbusDtu.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include + +// eModbus +#include "ModbusServerTCPasync.h" + +class ModbusDtuClass { +public: + ModbusDtuClass(); + void init(Scheduler& scheduler); + +private: + void loop(); + + Task _loopTask; +}; + +ModbusMessage OpenDTUTotal(ModbusMessage request); +ModbusMessage DTUPro(ModbusMessage request); + +extern ModbusDtuClass ModbusDtu; +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 28ae7d33b..455f06f6a 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" @@ -50,6 +51,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..59708d6b1 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -30,6 +30,11 @@ #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 MQTT_ENABLED false #define MQTT_HOST "" #define MQTT_PORT 1883U diff --git a/platformio.ini b/platformio.ini index 108c12bec..179b21bc1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -35,10 +35,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.8.1 bblanchon/ArduinoJson @ ^6.21.5 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 3b189187c..3f0427da4 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -58,6 +58,12 @@ bool ConfigurationClass::write() ntp["longitude"] = config.Ntp.Longitude; ntp["sunsettype"] = config.Ntp.SunsetType; + JsonObject modbus = doc.createNestedObject("modbus"); + modbus["tcp_enabled"] = config.Modbus.TCPEnabled; + modbus["port"] = config.Modbus.Port; + modbus["id_dtupro"] = config.Modbus.IDDTUPro; + modbus["id_total"] = config.Modbus.IDTotal; + JsonObject mqtt = doc.createNestedObject("mqtt"); mqtt["enabled"] = config.Mqtt.Enabled; mqtt["hostname"] = config.Mqtt.Hostname; @@ -227,6 +233,12 @@ 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; + 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..3543c4caf --- /dev/null +++ b/src/ModbusDtu.cpp @@ -0,0 +1,736 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2024 Bobby Noelte + */ +#include +#include +#include +#include + +// OpenDTU +#include "Hoymiles.h" +#include "Configuration.h" +#include "Datastore.h" +#include "ModbusSettings.h" +#include "NetworkSettings.h" +#include "MessageOutput.h" +#include "ModbusDtu.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 + +// Modbus task execution interval - must also fit to watchdog retrigger +#define MODBUS_TASK_INTERVAL_MS 5000 + +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; + +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 = 0) : ModbusMessage(dataLen) { + value.val_float = NAN; + } + + // Special message Constructor - takes a std::vector + explicit ModbusDTUMessage(std::vector s) : ModbusMessage(s) { + value.val_float = NAN; + } + + // Add float to Modbus register + void 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); + } + + // Add float as decimal fixed point to Modbus register + void 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); + } + + // Add string to Modbus register + void 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); + } + + // Add string to Modbus register + inline void addString(const String &str, const size_t reg_offset) { + addString(str.c_str(), str.length(), reg_offset); + } + + // Add uint32 to Modbus register + void 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); + } + } + + // Add uint64 to Modbus register + void 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); + } + } + + // Convert uint64 to hex string and add to Modbus register + void 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); + } + + // Convert uint64 to 12 decimal digits (big endian) and add to Modbus register + void 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); + } + } + + // Convert IP address to string and add to Modbus register + void 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; + +// 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; + + if (reg_idx < 4) { // >= 0 + switch (reg_idx) { + case 0: + case 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; + } + } else if (reg_idx < 4 + 16) { // >= 4 + // Manufacturer - string + response.addString("OpenDTU", reg_idx - 4); + } else if (reg_idx < 20 + 16) { // >= 20 + // Model - string + response.addString("OpenDTU Total", reg_idx - 20); + } else if (reg_idx < 36 + 8) { // >= 36 + // Options - string + response.addString(config.Dev_PinMapping, reg_idx - 36); + } else if (reg_idx < 44 + 8) { // >= 44 + // Version - string + response.addString(AUTO_GIT_HASH, reg_idx - 44); + } else if (reg_idx < 52 + 16) { // >= 52 + // Serial Number - string + response.addUInt64AsHexString(config.Dtu.Serial, reg_idx - 52); + } else if (reg_idx < 68 + 1) { // >= 68 + // Device Address - uint16 + response.add((uint16_t)request.getServerID()); + } else { + // Pad + response.add((uint16_t)0x8000); + } + } 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: + case 3: + case 4: + case 5: + // Interface name + response.addString(NetworkSettings.macAddress(), reg_idx - 2); + break; + case 11: + case 12: + case 13: + case 14: + case 15: + case 16: + case 17: + case 18: + // IP - string + response.addIPAddressAsString(NetworkSettings.localIP(), reg_idx - 11); + break; + case 19: + case 20: + case 21: + case 22: + case 23: + case 24: + case 25: + case 26: + // Netmask - string + response.addIPAddressAsString(NetworkSettings.subnetMask(), reg_idx - 19); + break; + case 27: + case 28: + case 29: + case 30: + case 31: + case 32: + case 33: + case 34: + // Gateway - string + response.addIPAddressAsString(NetworkSettings.gatewayIP(), reg_idx - 27); + break; + case 35: + case 36: + case 37: + case 38: + case 39: + case 40: + case 41: + case 42: + // DNS1 - string + response.addIPAddressAsString(NetworkSettings.dnsIP(0), reg_idx - 35); + break; + case 43: + case 44: + case 45: + case 46: + case 47: + case 48: + case 49: + case 50: + // DNS2 - string + response.addIPAddressAsString(NetworkSettings.dnsIP(1), reg_idx - 43); + break; + case 51: + case 52: + case 53: + case 54: + case 55: + case 56: + case 57: + case 58: + case 59: + case 60: + case 61: + case 62: + // NTP1 - string + response.add((uint16_t)0); + break; + case 63: + case 64: + case 65: + case 66: + case 67: + case 68: + case 69: + case 70: + case 71: + case 72: + case 73: + case 74: + // NTP2 - string + response.add((uint16_t)0); + break; + case 75: + case 76: + case 77: + case 78: + case 79: + case 80: + case 81: + case 82: + case 83: + case 84: + case 85: + case 86: + // Domain name - string + response.add((uint16_t)0); + break; + case 87: + case 88: + case 89: + case 90: + case 91: + case 92: + case 93: + case 95: + case 96: + case 97: + case 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) { // >= 400170 + // 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: + case 23: + // AC Power (W) + response.addFloat32(Datastore.getTotalAcPowerEnabled(), reg_idx - 22); + break; + case 32: + case 33: + // AC Energy (Wh) + response.addFloat32(Datastore.getTotalAcYieldTotalEnabled() * 1000, reg_idx - 32); + break; + case 38: + case 39: + // DC Power (W) + response.addFloat32(Datastore.getTotalDcPowerEnabled(), reg_idx - 38); + break; + case 48: + case 49: + // enum16 + response.add((uint16_t)0); + break; + case 50: + case 51: + case 52: + case 53: + case 54: + case 55: + case 56: + case 57: + case 58: + case 59: + case 60: + case 61: + // bitfield32 + response.add((uint16_t)0); + break; + default: + // float32 - Not a Number + response.addFloat32(NAN, reg_idx & 0x01); + 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); + } + +respond: + HEXDUMP_D("Response FC03", response.data(), response.size()); + + // Send response back + return response; +} + +// 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: + case 2: + case 3: + case 4: + case 5: + case 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: + case 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: + case 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: + case 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: + case 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: + case 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: + case 19: + // Today Production (Wh) - uint32 + response.addUInt32( + statistics->getChannelFieldValue(TYPE_INV, CH0, FLD_YD) * 1, + reg_idx - 18); + break; + case 20: + case 21: + case 22: + case 23: + // Total Production (Wh) - uint64 + response.addUInt64( + statistics->getChannelFieldValue(TYPE_INV, CH0, FLD_YT) * 1000, + reg_idx - 20); + break; + case 24: + case 25: + // Temperature (°C) - decimal fixed point, precision 1 + response.addFloat32AsDecimalFixedPoint( + statistics->getChannelFieldValue(TYPE_INV, CH0, FLD_T), 1, + reg_idx - 24); + break; + case 26: + case 27: + case 28: + case 29: + case 30: + case 31: + case 32: + // Todo + response.add((uint16_t)0x8000); + break; + case 33: + // Fixed + response.add((uint16_t)0x07); + break; + case 34: + case 35: + case 36: + case 37: + case 38: + case 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; +} + +ModbusDtuClass ModbusDtu; + +ModbusDtuClass::ModbusDtuClass() + : _loopTask(TASK_IMMEDIATE, TASK_FOREVER, std::bind(&ModbusDtuClass::loop, this)) +{ +} + +void ModbusDtuClass::init(Scheduler& scheduler) +{ + + + + // Prepare for Modbus monitoring loop + scheduler.addTask(_loopTask); + _loopTask.setInterval(MODBUS_TASK_INTERVAL_MS); + _loopTask.enable(); +} + +void ModbusDtuClass::loop() +{ + // Provide debug data + LOG_D("heap size: %u\n", ESP.getFreeHeap()); + LOG_D("clients running. %u\n", ModbusTCPServer.activeClients()); +} diff --git a/src/ModbusSettings.cpp b/src/ModbusSettings.cpp new file mode 100644 index 000000000..247598c85 --- /dev/null +++ b/src/ModbusSettings.cpp @@ -0,0 +1,57 @@ +// 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() +{ + const CONFIG_T& config = Configuration.get(); + + // Set Modbus logging to OpenDTU MessageOutput + LOGDEVICE = &MessageOutput; + + // Define server(s) + ModbusTCPServer.registerWorker(config.Modbus.IDDTUPro, READ_HOLD_REGISTER, &DTUPro); + ModbusTCPServer.registerWorker(config.Modbus.IDTotal, READ_HOLD_REGISTER, &OpenDTUTotal); + // Start server(s) if enabled + performConfig(); +} + +// Start server(s) +void ModbusSettingsClass::startTCP() +{ + if (!ModbusTCPServer.isRunning()) { + ModbusTCPServer.start(Configuration.get().Modbus.Port, 1, 20000); + } +} + +// Stop servers(s) +void ModbusSettingsClass::stopTCP() +{ + if (ModbusTCPServer.isRunning()) { + ModbusTCPServer.stop(); + } +} + +void ModbusSettingsClass::performConfig() +{ + stopTCP(); + if (Configuration.get().Modbus.TCPEnabled) { + startTCP(); + } +} + +ModbusSettingsClass ModbusSettings; diff --git a/src/WebApi.cpp b/src/WebApi.cpp index 40809927a..4852281b1 100644 --- a/src/WebApi.cpp +++ b/src/WebApi.cpp @@ -24,6 +24,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..5624afe32 --- /dev/null +++ b/src/WebApi_modbus.cpp @@ -0,0 +1,120 @@ +// 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; + + response->setLength(); + request->send(response); +} + +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; + + response->setLength(); + request->send(response); +} + +void WebApiModbusClass::onModbusAdminPost(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + auto& retMsg = response->getRoot(); + retMsg["type"] = "warning"; + + if (!request->hasParam("data", true)) { + retMsg["message"] = "No values found!"; + retMsg["code"] = WebApiError::GenericNoValueFound; + response->setLength(); + request->send(response); + return; + } + + const String json = request->getParam("data", true)->value(); + + if (json.length() > 1024) { + retMsg["message"] = "Data too large!"; + retMsg["code"] = WebApiError::GenericDataTooLarge; + response->setLength(); + request->send(response); + return; + } + + DynamicJsonDocument root(1024); + const DeserializationError error = deserializeJson(root, json); + + if (error) { + retMsg["message"] = "Failed to parse data!"; + retMsg["code"] = WebApiError::GenericParseError; + response->setLength(); + request->send(response); + return; + } + + if (!root.containsKey("modbus_tcp_enabled")) { + retMsg["message"] = "Values are missing!"; + retMsg["code"] = WebApiError::GenericValueMissing; + response->setLength(); + request->send(response); + 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); + + response->setLength(); + request->send(response); + + ModbusSettings.performConfig(); +} diff --git a/src/main.cpp b/src/main.cpp index 433619e1f..752ed46fe 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,12 @@ void setup() SunPosition.init(scheduler); MessageOutput.println("done"); + // Initialize Modbus + MessageOutput.print("Initialize Modbus... "); + ModbusSettings.init(); + ModbusDtu.init(scheduler); + MessageOutput.println("done"); + // Initialize MqTT MessageOutput.print("Initialize MqTT... "); MqttSettings.init();