├── .gitignore ├── GrowattUsbModbus.ino ├── LICENSE ├── README.md ├── config.h ├── configfile.cpp ├── configfile.h ├── connection.h ├── connectionEthernet.cpp ├── connectionEthernet.h ├── connectionWifi.cpp ├── connectionWifi.h ├── debug.cpp ├── debug.h ├── modbus.cpp ├── modbus.h └── noderedflow.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.bin -------------------------------------------------------------------------------- /GrowattUsbModbus.ino: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include "config.h" 9 | #include "configfile.h" 10 | #include "modbus.h" 11 | #include "debug.h" 12 | 13 | #include "connectionEthernet.h" 14 | #include "connectionWifi.h" 15 | 16 | constexpr auto LED = LED_BUILTIN; 17 | constexpr auto ON = LOW; 18 | constexpr auto OFF = HIGH; 19 | 20 | ConfigFile g_config; 21 | 22 | WiFiManager g_wm; 23 | PubSubClient g_mqttClient; 24 | ModBus g_modbus; 25 | ESP8266WebServer g_httpServer(80); 26 | ESP8266HTTPUpdateServer g_httpUpdater; 27 | ConnectionEthernet g_connectionEthernet; 28 | ConnectionWifi g_connectionWifi; 29 | Connection* g_connection = nullptr; 30 | 31 | // runtime config 32 | String g_mqttServer; 33 | uint32_t g_mqttPort; 34 | String g_mqttUser; 35 | String g_mqttPassword; 36 | String g_mqttSubTopic; 37 | String g_mqttPubTopic; 38 | String g_mqttWillTopic; 39 | String g_otaUser; 40 | String g_otaPassword; 41 | 42 | char g_jsonOutBuffer[4096]; 43 | constexpr uint32_t g_modbusBufferSize = 256; 44 | uint16_t g_modbusBuffer[g_modbusBufferSize]; 45 | 46 | bool sendBuffer(const char* buffer, uint32_t len) 47 | { 48 | if(!buffer || !len) 49 | return false; 50 | 51 | g_mqttClient.beginPublish(g_mqttPubTopic.c_str(), (uint32_t)len, false); 52 | auto remaining = len; 53 | const uint8_t* buf = (const uint8_t*)buffer; 54 | while(remaining > 0) 55 | { 56 | auto sendSize = remaining > 128 ? 128 : remaining; 57 | 58 | g_mqttClient.write(buf, sendSize); 59 | 60 | remaining -= sendSize; 61 | buf += sendSize; 62 | } 63 | g_mqttClient.endPublish(); 64 | g_mqttClient.flush(); 65 | return true; 66 | } 67 | 68 | bool sendJson(DynamicJsonDocument& json) 69 | { 70 | json["timestamp"] = millis(); 71 | json["baud"] = g_modbus.getBaudRate(); 72 | json["rssi"] = WiFi.RSSI(); 73 | json["ssid"] = WiFi.SSID(); 74 | json["ip"] = WiFi.localIP().toString(); 75 | 76 | const auto len = serializeJson(json, g_jsonOutBuffer); 77 | 78 | if(len <= 0) 79 | return false; 80 | 81 | return sendBuffer(g_jsonOutBuffer, len); 82 | } 83 | 84 | bool sendError(const StaticJsonDocument<1024>* request, const char* error) 85 | { 86 | DynamicJsonDocument r(2048); 87 | r["status"] = "error"; 88 | if(request) 89 | r["request"] = *request; 90 | r["error"] = error; 91 | return sendJson(r); 92 | } 93 | 94 | bool sendError(const StaticJsonDocument<1024>& request, const char* error) 95 | { 96 | return sendError(&request, error); 97 | } 98 | bool sendError(const char* error) 99 | { 100 | return sendError(nullptr, error); 101 | } 102 | 103 | bool readModBusRegisters(const StaticJsonDocument<1024>& request, bool holdingRegs) 104 | { 105 | const int first = request["index"]; 106 | const int count = request["count"]; 107 | 108 | if(first < 0) 109 | return sendError(request, "Parameter 'index' must be >= 0"); 110 | if(count <= 0) 111 | return sendError(request, "Parameter 'count' must be > 0"); 112 | if(count > g_modbusBufferSize) 113 | return sendError(request, (String("Parameter 'count' must be <= ") + String(g_modbusBufferSize)).c_str()); 114 | 115 | constexpr auto retryCount = 3; 116 | int errorCount = 0; 117 | 118 | while(true) 119 | { 120 | const auto err = holdingRegs ? 121 | g_modbus.readHoldingRegisters(g_modbusBuffer, first, count) : 122 | g_modbus.readInputRegisters(g_modbusBuffer, first, count); 123 | 124 | if(!err) 125 | break; 126 | 127 | ++errorCount; 128 | 129 | if(err != ModbusMaster::ku8MBResponseTimedOut || errorCount == retryCount) 130 | return sendError(request, (String("Failed to read modbus registers, error code ") + String(err) + " - " + g_modbus.errorToString(err)).c_str()); 131 | 132 | delay(100); 133 | } 134 | 135 | DynamicJsonDocument response(5 * 1024); 136 | 137 | response["status"] = "ok"; 138 | response["request"] = request; 139 | response["retryCount"] = errorCount; 140 | 141 | auto arr = response["data"].to(); 142 | 143 | for(int i=0; i& request) 150 | { 151 | const int index = request["index"]; 152 | const int value = request["value"]; 153 | 154 | if(index < 0) return sendError(request, "Parameter 'index' must be >= 0"); 155 | if(index > 65535) return sendError(request, "Parameter 'index' must be <= 65535"); 156 | if(value < 0) return sendError(request, "Parameter 'value' must be >= 0"); 157 | if(value > 65535) return sendError(request, "Parameter 'value' must be <= 65535"); 158 | 159 | const auto err = g_modbus.writeHoldingRegister(index, value); 160 | 161 | if(err) 162 | return sendError(request, (String("Failed to write modbus register, error code ") + String(err) + " - " + g_modbus.errorToString(err)).c_str()); 163 | 164 | DynamicJsonDocument response(1024); 165 | 166 | response.clear(); 167 | 168 | response["status"] = "ok"; 169 | response["request"] = request; 170 | 171 | return sendJson(response); 172 | } 173 | 174 | void resetSettings() 175 | { 176 | g_wm.resetSettings(); 177 | delay(1000); 178 | ESP.restart(); 179 | } 180 | 181 | void handleMqttMessage(const char* topic, const char* payload) 182 | { 183 | StaticJsonDocument<1024> doc; 184 | const auto error = deserializeJson(doc, payload); 185 | 186 | if(error) 187 | { 188 | debug("deserializeJson() failed: "); 189 | debugln(error.f_str()); 190 | return; 191 | } 192 | 193 | String command = doc["command"]; 194 | 195 | if(command.length() == 0) 196 | return; 197 | 198 | command.toLowerCase(); 199 | 200 | if(command == "reboot") 201 | { 202 | ESP.restart(); 203 | return; 204 | } 205 | 206 | if(command == "resetsettings") 207 | { 208 | resetSettings(); 209 | return; 210 | } 211 | 212 | if(command == "readinputregisters") 213 | { 214 | readModBusRegisters(doc, false); 215 | return; 216 | } 217 | if(command == "readholdingregisters") 218 | { 219 | readModBusRegisters(doc, true); 220 | return; 221 | } 222 | if(command == "writeholdingregister") 223 | { 224 | writeModBusHoldingRegister(doc); 225 | return; 226 | } 227 | 228 | sendError(doc, "Unknown request"); 229 | } 230 | 231 | void onMqttMessage(const char* topic, byte* payload, unsigned int length) 232 | { 233 | payload[length] = 0; 234 | 235 | const char* string = (const char*)payload; 236 | 237 | debugln("MQTT message: topic = " + String(topic) + ", payload = " + String(string)); 238 | 239 | digitalWrite(LED, ON); 240 | handleMqttMessage(topic, string); 241 | digitalWrite(LED, OFF); 242 | } 243 | 244 | bool runConfigPortal(bool force) 245 | { 246 | const auto usingEthernet = g_connectionEthernet.isInitialized(); 247 | 248 | bool result = usingEthernet; 249 | 250 | if(usingEthernet || force) 251 | { 252 | g_wm.setConfigPortalBlocking(true); 253 | result |= g_wm.startConfigPortal("GrowattUSB", "growattusb"); 254 | } 255 | else 256 | { 257 | result = g_wm.autoConnect("GrowattUSB", "growattusb"); 258 | } 259 | 260 | return result; 261 | } 262 | 263 | void runConfigAndRestart() 264 | { 265 | runConfigPortal(true); 266 | delay(1000); 267 | ESP.restart(); 268 | } 269 | 270 | bool wifiReconnect() 271 | { 272 | if (g_connectionWifi.isConnected()) 273 | return true; 274 | 275 | digitalWrite(LED, ON); 276 | 277 | if(!runConfigPortal(false)) 278 | return false; 279 | 280 | while (!g_connectionWifi.isConnected()) 281 | { 282 | delay(200); 283 | digitalWrite(LED, !digitalRead(LED)); 284 | } 285 | 286 | debug("Wifi now connected, my ip "); 287 | debugln(WiFi.localIP().toString()); 288 | 289 | digitalWrite(LED, OFF); 290 | 291 | return true; 292 | } 293 | 294 | bool ethernetReconnect() 295 | { 296 | if (g_connectionEthernet.isConnected()) 297 | return true; 298 | 299 | return g_connectionEthernet.connect(); 300 | } 301 | 302 | bool mqttReconnect() 303 | { 304 | if(!g_connection || !g_connection->isConnected()) 305 | return false; 306 | 307 | if (g_mqttServer.length() == 0 || g_mqttPort == 0) 308 | { 309 | runConfigAndRestart(); 310 | return false; 311 | } 312 | 313 | if (g_mqttClient.connected()) 314 | return true; 315 | 316 | debugln((String("MQTT not connected, state = ") + String(g_mqttClient.state())).c_str()); 317 | 318 | const auto t = millis(); 319 | 320 | while(true) 321 | { 322 | g_mqttClient.setClient(*g_connection->getClient()); 323 | g_mqttClient.setServer(g_mqttServer.c_str(), g_mqttPort); 324 | 325 | const auto mqttClientName = String("esp8266_") + String(ESP.getChipId()); 326 | 327 | if(g_mqttClient.connect(mqttClientName.c_str(), g_mqttUser.c_str(), g_mqttPassword.c_str(), g_mqttWillTopic.c_str(), 2, true, "offline")) 328 | { 329 | debugln("MQTT connection established"); 330 | g_mqttClient.setCallback(onMqttMessage); 331 | g_mqttClient.subscribe(g_mqttSubTopic.c_str()); 332 | g_mqttClient.publish(g_mqttWillTopic.c_str(), "online", true); 333 | return true; 334 | } 335 | else 336 | { 337 | debugln("MQTT connect failed"); 338 | if(millis() - t > 20000) 339 | { 340 | runConfigAndRestart(); 341 | break; 342 | } 343 | else 344 | { 345 | digitalWrite(LED, !digitalRead(LED)); 346 | delay(1500); 347 | } 348 | } 349 | } 350 | 351 | return false; 352 | } 353 | 354 | void loopConnections() 355 | { 356 | g_mqttClient.loop(); 357 | g_httpServer.handleClient(); 358 | if (g_connection) 359 | g_connection->loop(); 360 | } 361 | 362 | bool modbusReconnect() 363 | { 364 | if(g_dryRun) 365 | return true; 366 | 367 | if(g_modbus.isValid()) 368 | return true; 369 | 370 | digitalWrite(LED, OFF); 371 | 372 | const auto t = millis(); 373 | 374 | auto myDelay = [&](uint32_t ms) 375 | { 376 | const auto t = millis(); 377 | while((millis() - t) < ms) 378 | { 379 | loopConnections(); 380 | } 381 | }; 382 | 383 | while((millis() - t) < 20000) 384 | { 385 | if(g_modbus.connect()) 386 | return true; 387 | 388 | digitalWrite(LED, ON); 389 | myDelay(500); 390 | 391 | digitalWrite(LED, OFF); 392 | myDelay(500); 393 | } 394 | 395 | sendError("Modbus connection failed"); 396 | 397 | // give us some time to report the modbus connection error via MQTT 398 | { 399 | const auto t = millis(); 400 | while((millis() - t) < 3000) 401 | g_mqttClient.loop(); 402 | } 403 | 404 | return false; 405 | } 406 | 407 | bool reconnectAll() 408 | { 409 | if(!ethernetReconnect()) 410 | { 411 | if(!wifiReconnect()) 412 | return false; 413 | } 414 | 415 | if(!mqttReconnect()) return false; 416 | 417 | if(!modbusReconnect()) return false; 418 | 419 | return true; 420 | } 421 | 422 | void setup() 423 | { 424 | if(g_debug) 425 | Serial.begin(115200); // debug 426 | 427 | pinMode(LED, OUTPUT); 428 | 429 | LittleFS.begin(); 430 | 431 | delay(500); 432 | 433 | debugln("Creating ethernet connection"); 434 | 435 | if(g_connectionEthernet.initialize()) 436 | { 437 | g_connection = &g_connectionEthernet; 438 | } 439 | else if(g_connectionWifi.initialize()) 440 | { 441 | g_connection = &g_connectionWifi; 442 | } 443 | else 444 | { 445 | ESP.restart(); 446 | } 447 | 448 | g_mqttServer = g_config.get("g_mqttServer", DefaultConfig::mqttServer); 449 | g_mqttPort = g_config.get("g_mqttPort", String(DefaultConfig::mqttPort).c_str()).toInt(); 450 | g_mqttUser = g_config.get("g_mqttUser", DefaultConfig::mqttUser); 451 | g_mqttPassword = g_config.get("g_mqttPassword", DefaultConfig::mqttPassword); 452 | g_mqttSubTopic = g_config.get("g_mqttSubTopic", DefaultConfig::mqttSubTopic); 453 | g_mqttPubTopic = g_config.get("g_mqttPubTopic", DefaultConfig::mqttPubTopic); 454 | g_mqttWillTopic = g_config.get("g_mqttWillTopic", DefaultConfig::mqttWillTopic); 455 | g_otaUser = g_config.get("g_otaUser", DefaultConfig::otaUser); 456 | g_otaPassword = g_config.get("g_otaPassword", DefaultConfig::otaPassword); 457 | 458 | WiFi.hostname(g_config.get("hostname", DefaultConfig::hostName)); 459 | WiFi.mode(WIFI_STA); 460 | 461 | delay(2000); 462 | 463 | auto mqttServer = new WiFiManagerParameter("server", "MQTT Server", g_mqttServer.c_str(), 32); 464 | auto mqttPort = new WiFiManagerParameter("port", "MQTT Port", String(g_mqttPort).c_str(), 5); 465 | auto mqttUser = new WiFiManagerParameter("user", "MQTT Username", g_mqttUser.c_str(), 32); 466 | auto mqttPassword = new WiFiManagerParameter("pass", "MQTT Password", g_mqttPassword.c_str(), 32); 467 | auto mqttTopicSub = new WiFiManagerParameter("topicSub", "MQTT Command Topic", g_mqttSubTopic.c_str(), 64); 468 | auto mqttTopicPub = new WiFiManagerParameter("topicPub", "MQTT Publish Topic", g_mqttPubTopic.c_str(), 64); 469 | auto mqttTopicWill = new WiFiManagerParameter("topicWill", "MQTT Will Topic", g_mqttWillTopic.c_str(), 64); 470 | auto otaUser = new WiFiManagerParameter("otaUser", "OTA Update User", g_otaUser.c_str(), 64); 471 | auto otaPassword = new WiFiManagerParameter("otaPassword", "OTA Update Password", g_otaPassword.c_str(), 64); 472 | 473 | g_wm.addParameter(mqttServer); 474 | g_wm.addParameter(mqttPort); 475 | g_wm.addParameter(mqttUser); 476 | g_wm.addParameter(mqttPassword); 477 | g_wm.addParameter(mqttTopicSub); 478 | g_wm.addParameter(mqttTopicPub); 479 | g_wm.addParameter(mqttTopicWill); 480 | g_wm.addParameter(otaUser); 481 | g_wm.addParameter(otaPassword); 482 | 483 | g_wm.setDebugOutput(false); 484 | 485 | g_wm.setBreakAfterConfig(g_connectionEthernet.isInitialized()); 486 | 487 | g_wm.setSaveParamsCallback([&]() 488 | { 489 | g_mqttServer = mqttServer->getValue(); 490 | g_mqttPort = String(mqttPort->getValue()).toInt(); 491 | g_mqttUser = mqttUser->getValue(); 492 | g_mqttPassword = mqttPassword->getValue(); 493 | g_mqttSubTopic = mqttTopicSub->getValue(); 494 | g_mqttPubTopic = mqttTopicPub->getValue(); 495 | g_mqttWillTopic = mqttTopicWill->getValue(); 496 | g_otaUser = otaUser->getValue(); 497 | g_otaPassword = otaPassword->getValue(); 498 | 499 | g_config.set("g_mqttServer", g_mqttServer.c_str()); 500 | g_config.set("g_mqttPort", String(g_mqttPort).c_str()); 501 | g_config.set("g_mqttUser", g_mqttUser.c_str()); 502 | g_config.set("g_mqttPassword", g_mqttPassword.c_str()); 503 | g_config.set("g_mqttSubTopic", g_mqttSubTopic.c_str()); 504 | g_config.set("g_mqttPubTopic", g_mqttPubTopic.c_str()); 505 | g_config.set("g_mqttWillTopic", g_mqttWillTopic.c_str()); 506 | g_config.set("g_otaUser", g_otaUser.c_str()); 507 | g_config.set("g_otaPassword", g_otaPassword.c_str()); 508 | 509 | delay(1000); 510 | }); 511 | 512 | g_wm.setConfigPortalTimeout(60); 513 | 514 | if(!reconnectAll()) 515 | ESP.restart(); 516 | 517 | g_httpUpdater.setup(&g_httpServer, "/update", g_otaUser, g_otaPassword); 518 | g_httpServer.begin(); 519 | 520 | debugln("Boot completed"); 521 | } 522 | 523 | void loop() 524 | { 525 | if(!reconnectAll()) 526 | return; 527 | 528 | loopConnections(); 529 | 530 | auto t = millis(); 531 | 532 | if(g_connectionEthernet.isInitialized()) 533 | t &= 0x3ff; 534 | else 535 | t &= 0x1ff; 536 | 537 | if(t < 40) 538 | digitalWrite(LED, ON); 539 | else 540 | digitalWrite(LED, OFF); 541 | } 542 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lyve1981 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Growatt USB ModBus to MQTT proxy 2 | 3 | # Description 4 | 5 | This project is a custom data logger for Growatt inverters with an USB connector. 6 | 7 | Usually, you connect a ShineWifi-X or ShineLan-X connector (sold by Growatt) to the USB connector of your inverter to monitor your data, but you are restricted to the Growatt Cloud, aka ShineServer. This project replaces the need for a Shine* module by exposing the ModBus interface via MQTT. 8 | 9 | Benefits: 10 | 11 | - Works without internet / no cloud connection needed 12 | - Access more data than is usually exposed via ShineServer 13 | - Faster update speed. If you want, you can even read the data once every second 14 | - Further process your data / integrate into your Smart Home 15 | 16 | # Installation & Usage 17 | 18 | Installation & Usage instructions [can be found in the Wiki](https://github.com/Lyve1981/GrowattUsbModbus/wiki). 19 | -------------------------------------------------------------------------------- /config.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | namespace DefaultConfig 4 | { 5 | constexpr const char* const hostName = "GrowattMB2USB"; 6 | constexpr const char* const wifiPassword = "GrowattMB2USB"; 7 | 8 | // MQTT 9 | constexpr const char* const mqttServer = "192.168.20.10"; 10 | constexpr const int32_t mqttPort = 1883; 11 | constexpr const char* const mqttUser = ""; 12 | constexpr const char* const mqttPassword = ""; 13 | constexpr const char* const mqttSubTopic = "energy/gromodbus/request"; 14 | constexpr const char* const mqttPubTopic = "energy/gromodbus/response"; 15 | constexpr const char* const mqttWillTopic = "energy/gromodbus/status"; 16 | 17 | // Over-the-Air updater 18 | constexpr const char* const otaUser = "ota"; 19 | constexpr const char* const otaPassword = "growatt"; 20 | } 21 | -------------------------------------------------------------------------------- /configfile.cpp: -------------------------------------------------------------------------------- 1 | #include "configfile.h" 2 | 3 | #include "LittleFS.h" 4 | 5 | #include "debug.h" 6 | 7 | String ConfigFile::get(const char* _key, const char* _default) 8 | { 9 | auto f = LittleFS.open(_key, "r"); 10 | if (!f) 11 | { 12 | debugln("Failed to open file " + String(_key)); 13 | return _default; 14 | } 15 | 16 | String result = ""; 17 | 18 | while (f.available()) 19 | result += (char)f.read(); 20 | 21 | f.close(); 22 | 23 | if(result.length() == 0) 24 | return _default; 25 | 26 | return result; 27 | } 28 | 29 | bool ConfigFile::set(const char* _key, const char* _value) 30 | { 31 | if(strlen(_key) == 0) 32 | { 33 | LittleFS.remove(_key); 34 | return true; 35 | } 36 | 37 | File f = LittleFS.open(_key, "w"); 38 | if (!f) 39 | { 40 | debugln("Failed to create file " + String(_key)); 41 | return false; 42 | } 43 | 44 | const auto bytesWritten = f.print(_value); 45 | 46 | f.close(); 47 | 48 | if(!bytesWritten) 49 | { 50 | debugln("Failed to write to file " + String(_key)); 51 | return false; 52 | } 53 | 54 | return bytesWritten > 0; 55 | } 56 | -------------------------------------------------------------------------------- /configfile.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | class String; 4 | 5 | class ConfigFile 6 | { 7 | public: 8 | String get(const char* _key, const char* _default); 9 | bool set(const char* _key, const char* _value); 10 | }; 11 | -------------------------------------------------------------------------------- /connection.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | class Client; 4 | 5 | class Connection 6 | { 7 | public: 8 | virtual bool initialize() 9 | { 10 | m_initialized = true; 11 | return true; 12 | } 13 | virtual bool isInitialized() 14 | { 15 | return m_initialized; 16 | } 17 | 18 | virtual bool connect() = 0; 19 | virtual bool isConnected() = 0; 20 | 21 | virtual Client* getClient() = 0; 22 | 23 | virtual void loop() {} 24 | 25 | private: 26 | bool m_initialized = false; 27 | }; 28 | -------------------------------------------------------------------------------- /connectionEthernet.cpp: -------------------------------------------------------------------------------- 1 | #include "connectionEthernet.h" 2 | 3 | #include "UIPEthernet.h" 4 | 5 | #include "debug.h" 6 | 7 | /* 8 | Wiring for ENC28J60 to NodeMCU Lolin V3 CH340 9 | 10 | ENC ... NodeMCU 11 | 12 | GND ... GND 13 | VCC ... 3V3 14 | RESET ... RST 15 | CS ... D0 (GPIO16, WAKE) 16 | SCK ... D5 (GPIO14, HSPI SCLK) 17 | SO ... D6 (GPIO12, HSPI MISO) 18 | SI ... D7 (GPIO13, HSPI MOSI) 19 | */ 20 | 21 | constexpr int PIN_CS = D0; 22 | 23 | constexpr uint8_t g_ethMac[] = {0x5e,0xc0,0xde,0xba,0x5e,0x01}; 24 | 25 | bool ConnectionEthernet::initialize() 26 | { 27 | debug("Initialize Ethernet, CS Pin = "); 28 | debugln(String(PIN_CS)); 29 | 30 | Ethernet.init(PIN_CS); 31 | if(!Ethernet.begin(g_ethMac)) 32 | { 33 | debugln("Ethernet.begin() failed"); 34 | if(Ethernet.hardwareStatus() == EthernetNoHardware) 35 | { 36 | debugln("No Ethernet hardware found"); 37 | return false; 38 | } 39 | debugln("Failed to get address via DHCP"); 40 | return false; 41 | } 42 | 43 | debug("My IP: "); 44 | debugln(Ethernet.localIP().toString()); 45 | 46 | return Connection::initialize(); 47 | } 48 | 49 | Client* ConnectionEthernet::getClient() 50 | { 51 | return new EthernetClient(); 52 | } 53 | 54 | void ConnectionEthernet::loop() 55 | { 56 | Ethernet.maintain(); 57 | } 58 | -------------------------------------------------------------------------------- /connectionEthernet.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "connection.h" 4 | 5 | class ConnectionEthernet : public Connection 6 | { 7 | public: 8 | ConnectionEthernet() = default; 9 | 10 | virtual bool initialize() override; 11 | 12 | virtual bool connect() override 13 | { 14 | return isConnected(); 15 | } 16 | 17 | virtual bool isConnected() override 18 | { 19 | return isInitialized(); 20 | } 21 | 22 | virtual Client* getClient() override; 23 | 24 | virtual void loop() override; 25 | }; 26 | -------------------------------------------------------------------------------- /connectionWifi.cpp: -------------------------------------------------------------------------------- 1 | #include "connectionWifi.h" 2 | 3 | #include 4 | #include "WifiClient.h" 5 | 6 | bool ConnectionWifi::initialize() 7 | { 8 | return Connection::initialize(); 9 | } 10 | 11 | bool ConnectionWifi::isInitialized() 12 | { 13 | return Connection::isInitialized(); 14 | } 15 | 16 | bool ConnectionWifi::connect() 17 | { 18 | return false; 19 | } 20 | 21 | bool ConnectionWifi::isConnected() 22 | { 23 | return WiFi.status() == WL_CONNECTED; 24 | } 25 | 26 | Client* ConnectionWifi::getClient() 27 | { 28 | return new WiFiClient(); 29 | } 30 | -------------------------------------------------------------------------------- /connectionWifi.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "connection.h" 4 | 5 | class ConnectionWifi : public Connection 6 | { 7 | public: 8 | ConnectionWifi() = default; 9 | 10 | virtual bool initialize() override; 11 | virtual bool isInitialized() override; 12 | 13 | virtual bool connect() override; 14 | virtual bool isConnected() override; 15 | 16 | virtual Client* getClient() override; 17 | }; 18 | -------------------------------------------------------------------------------- /debug.cpp: -------------------------------------------------------------------------------- 1 | #include "debug.h" 2 | #include 3 | 4 | void debugln(const String& msg) 5 | { 6 | if(g_debug) 7 | Serial.println(msg); 8 | } 9 | 10 | void debug(const String& msg) 11 | { 12 | if(g_debug) 13 | Serial.print(msg); 14 | } 15 | -------------------------------------------------------------------------------- /debug.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | static constexpr bool g_dryRun = false; 4 | 5 | static constexpr bool g_debug = g_dryRun; 6 | 7 | class String; 8 | 9 | void debugln(const String& msg); 10 | void debug(const String& msg); 11 | -------------------------------------------------------------------------------- /modbus.cpp: -------------------------------------------------------------------------------- 1 | #include "modbus.h" 2 | #include "debug.h" 3 | 4 | #include 5 | 6 | ModbusMaster g_modBus; 7 | 8 | constexpr auto g_errNotInitialized = 0xff; 9 | 10 | void idleCallback() 11 | { 12 | yield(); 13 | } 14 | 15 | bool ModBus::connect() 16 | { 17 | m_valid = false; 18 | 19 | g_modBus.idle(idleCallback); 20 | 21 | // Try USB first 22 | Serial.begin(115200); 23 | delay(500); 24 | g_modBus.begin(1, Serial); 25 | 26 | auto res = g_modBus.readInputRegisters(55, 2); // dummy read total energy 27 | 28 | if(res == g_modBus.ku8MBSuccess) 29 | { 30 | m_valid = true; 31 | m_baudRate = 115200; 32 | return true; 33 | } 34 | 35 | // Try older serial inverter as a backup if USB failed 36 | delay(500); 37 | Serial.begin(9600); 38 | delay(500); 39 | g_modBus.begin(1, Serial); 40 | res = g_modBus.readInputRegisters(28, 2); // dummy read total energy 41 | 42 | if(res != g_modBus.ku8MBSuccess) 43 | return false; 44 | 45 | m_valid = true; 46 | m_baudRate = 9600; 47 | return true; 48 | } 49 | 50 | int ModBus::readInputRegisters(uint16_t* buffer, uint32_t _address, uint32_t _count) 51 | { 52 | if(!m_valid) 53 | { 54 | if(g_dryRun) 55 | { 56 | for(uint16_t i=0; i<_count; ++i) 57 | buffer[i] = _address + i; 58 | return ModbusMaster::ku8MBSuccess; 59 | } 60 | return g_errNotInitialized; 61 | } 62 | 63 | const auto res = g_modBus.readInputRegisters(_address, _count); 64 | if (res != g_modBus.ku8MBSuccess) 65 | return res; 66 | for(int i=0; i<_count; ++i) 67 | buffer[i] = g_modBus.getResponseBuffer(i); 68 | return res; 69 | } 70 | 71 | int ModBus::readHoldingRegisters(uint16_t* buffer, uint32_t _address, uint32_t _count) 72 | { 73 | if(!m_valid) 74 | { 75 | if(g_dryRun) 76 | { 77 | for(uint16_t i=0; i<_count; ++i) 78 | buffer[i] = _address + i; 79 | return ModbusMaster::ku8MBSuccess; 80 | } 81 | return g_errNotInitialized; 82 | } 83 | 84 | const auto res = g_modBus.readHoldingRegisters(_address, _count); 85 | if (res != g_modBus.ku8MBSuccess) 86 | return res; 87 | for(int i=0; i<_count; ++i) 88 | buffer[i] = g_modBus.getResponseBuffer(i); 89 | return res; 90 | } 91 | 92 | int ModBus::writeHoldingRegister(uint16_t _address, uint16_t _value) 93 | { 94 | if(!m_valid) 95 | return g_dryRun ? ModbusMaster::ku8MBSuccess : g_errNotInitialized; 96 | 97 | return g_modBus.writeSingleRegister(_address, _value); 98 | } 99 | 100 | String ModBus::errorToString(int errorCode) 101 | { 102 | switch(errorCode) 103 | { 104 | case ModbusMaster::ku8MBIllegalFunction: return "Modbus protocol illegal function exception."; 105 | case ModbusMaster::ku8MBIllegalDataAddress: return "Modbus protocol illegal data address exception"; 106 | case ModbusMaster::ku8MBIllegalDataValue: return "Modbus protocol illegal data value exception"; 107 | case ModbusMaster::ku8MBSlaveDeviceFailure: return "Modbus protocol slave device failure exception"; 108 | 109 | case ModbusMaster::ku8MBSuccess: return "ModbusMaster success"; 110 | case ModbusMaster::ku8MBInvalidSlaveID: return "ModbusMaster invalid response slave ID exception"; 111 | case ModbusMaster::ku8MBInvalidFunction: return "ModbusMaster invalid response function exception"; 112 | case ModbusMaster::ku8MBResponseTimedOut: return "ModbusMaster response timed out exception"; 113 | case ModbusMaster::ku8MBInvalidCRC: return "ModbusMaster invalid response CRC exception"; 114 | case g_errNotInitialized: return "Connection failed or not initialized"; 115 | 116 | default: return String("Unknown error code ") + String(errorCode); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /modbus.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | 7 | class ModBus 8 | { 9 | public: 10 | bool connect(); 11 | bool isValid() const { return m_valid; } 12 | 13 | int readInputRegisters (uint16_t* _buffer, uint32_t _address, uint32_t _count); 14 | int readHoldingRegisters(uint16_t* _buffer, uint32_t _address, uint32_t _count); 15 | int writeHoldingRegister(uint16_t _address, uint16_t _value); 16 | 17 | String errorToString(int errorCode); 18 | 19 | uint32_t getBaudRate() const { return m_baudRate; } 20 | 21 | private: 22 | bool m_valid = false; 23 | uint32_t m_baudRate = 0; 24 | }; 25 | -------------------------------------------------------------------------------- /noderedflow.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "5dae21e1b0933241", 4 | "type": "tab", 5 | "label": "GrowattUSB", 6 | "disabled": false, 7 | "info": "", 8 | "env": [] 9 | }, 10 | { 11 | "id": "d35deb7d63e2b781", 12 | "type": "mqtt in", 13 | "z": "5dae21e1b0933241", 14 | "name": "", 15 | "topic": "energy/gromodbus/response", 16 | "qos": "2", 17 | "datatype": "auto-detect", 18 | "broker": "24fa06d723ab4714", 19 | "nl": false, 20 | "rap": true, 21 | "rh": 0, 22 | "inputs": 0, 23 | "x": 140, 24 | "y": 180, 25 | "wires": [ 26 | [ 27 | "9e3c95f2faa643c5" 28 | ] 29 | ] 30 | }, 31 | { 32 | "id": "9e3c95f2faa643c5", 33 | "type": "json", 34 | "z": "5dae21e1b0933241", 35 | "name": "", 36 | "property": "payload", 37 | "action": "obj", 38 | "pretty": false, 39 | "x": 210, 40 | "y": 240, 41 | "wires": [ 42 | [ 43 | "7c06c1c5e68de54d" 44 | ] 45 | ] 46 | }, 47 | { 48 | "id": "083eeedc4d225cf5", 49 | "type": "mqtt out", 50 | "z": "5dae21e1b0933241", 51 | "name": "", 52 | "topic": "energy/gromodbus/request", 53 | "qos": "", 54 | "retain": "", 55 | "respTopic": "", 56 | "contentType": "", 57 | "userProps": "", 58 | "correl": "", 59 | "expiry": "", 60 | "broker": "24fa06d723ab4714", 61 | "x": 940, 62 | "y": 220, 63 | "wires": [] 64 | }, 65 | { 66 | "id": "aadc3af0e7e9bb20", 67 | "type": "json", 68 | "z": "5dae21e1b0933241", 69 | "name": "", 70 | "property": "payload", 71 | "action": "str", 72 | "pretty": false, 73 | "x": 870, 74 | "y": 160, 75 | "wires": [ 76 | [ 77 | "083eeedc4d225cf5" 78 | ] 79 | ] 80 | }, 81 | { 82 | "id": "f230988aec308b5b", 83 | "type": "inject", 84 | "z": "5dae21e1b0933241", 85 | "name": "every minute", 86 | "props": [ 87 | { 88 | "p": "payload" 89 | } 90 | ], 91 | "repeat": "60", 92 | "crontab": "", 93 | "once": false, 94 | "onceDelay": 0.1, 95 | "topic": "", 96 | "payload": "0", 97 | "payloadType": "num", 98 | "x": 300, 99 | "y": 60, 100 | "wires": [ 101 | [ 102 | "e1e4b663db7b6ec1" 103 | ] 104 | ] 105 | }, 106 | { 107 | "id": "a4059454c4b1bc5d", 108 | "type": "function", 109 | "z": "5dae21e1b0933241", 110 | "name": "requestRegs", 111 | "func": "var registerSetIndex = msg.payload;\n\n// As we query various ranges of registers, we send multiple requests\nconst registerSets =\n[\n // inputs\n [0, 64, 0], // Inverter is limited to return at most 64 registers per request\n [64, 61, 0], // So we split this up into two chunks\n [1000, 64, 0],\n [1064, 61, 0],\n\n // holdings\n [0, 64, 1],\n [64, 61, 1],\n [1000, 64, 1],\n [1064, 61, 1]\n];\n\nif(registerSetIndex >= registerSets.length)\n return [null, {reset: true}]; // finished\n\nvar rs = registerSets[registerSetIndex];\n\n// build request object to be sent via MQTT to ModBus2MQTT proxy\nvar index = rs[0];\nvar count = rs[1];\nvar holding = rs[2] != 0 ? true : false;\n\nmsg.payload = {\n \"command\": holding ? \"readholdingregisters\" : \"readinputregisters\",\n \"index\": index,\n \"count\": count,\n \"requestIndex\": registerSetIndex,\n \"requestMax\": registerSets.length,\n \"holding\": holding\n};\n\nreturn [msg, null];", 112 | "outputs": 2, 113 | "noerr": 0, 114 | "initialize": "", 115 | "finalize": "", 116 | "libs": [], 117 | "x": 650, 118 | "y": 200, 119 | "wires": [ 120 | [ 121 | "aadc3af0e7e9bb20" 122 | ], 123 | [ 124 | "626ca8c5bdb5f211", 125 | "e1e4b663db7b6ec1" 126 | ] 127 | ] 128 | }, 129 | { 130 | "id": "7c06c1c5e68de54d", 131 | "type": "function", 132 | "z": "5dae21e1b0933241", 133 | "name": "parseJson", 134 | "func": "var j = msg.payload;\n\nif(j == null)\n return null;\n\nvar status = j.status;\nvar data = j.data;\nvar request = j.request;\n\nif(status != \"ok\")\n{\n node.error(\"Error response from Growatt Proxy: \" + JSON.stringify(j, null, 2), msg);\n return null;\n}\n\nfor (var i = 0; i < count; ++i)\n{\n if(data[i] == 65535)\n {\n node.error(\"Data read failed, register \" + i + \" contains invalid value \" + data[i]);\n return null;\n }\n}\n\n// store the retrieved register values in this flow\n\nvar index = request.index;\nvar count = request.count;\nvar holding = request.holding;\n\nvar key = holding ? \"regHoldings\" : \"regInputs\";\n\nvar currentData = flow.get(key);\n\nif(currentData == null)\n currentData = {};\n\nfor(var i=0; i> 8;\n var c1 = regs[i] & 0xff;\n text += String.fromCharCode(c0, c1);\n }\n return text;\n}\n\nvar serial = readText(regsH, 23, 5);\n\nfields[\"datalogserial\"] = \"ModBus2USB\";\nfields[\"pvserial\"] = serial;\n\nvar json = {};\n\njson[\"time\"] = date;\njson[\"fields\"] = fields;\n\nmsg.payload = json;\n\nreturn msg;", 190 | "outputs": 1, 191 | "noerr": 0, 192 | "initialize": "", 193 | "finalize": "", 194 | "libs": [], 195 | "x": 710, 196 | "y": 400, 197 | "wires": [ 198 | [ 199 | "bf7a825e1150d34d", 200 | "f153242c66325c64", 201 | "6bd69537198740bd" 202 | ] 203 | ] 204 | }, 205 | { 206 | "id": "e1e4b663db7b6ec1", 207 | "type": "trigger", 208 | "z": "5dae21e1b0933241", 209 | "name": "retry", 210 | "op1": "", 211 | "op2": "", 212 | "op1type": "pay", 213 | "op2type": "pay", 214 | "duration": "5", 215 | "extend": false, 216 | "overrideDelay": false, 217 | "units": "s", 218 | "reset": "", 219 | "bytopic": "all", 220 | "topic": "topic", 221 | "outputs": 1, 222 | "x": 570, 223 | "y": 80, 224 | "wires": [ 225 | [ 226 | "48c0b088398b78da" 227 | ] 228 | ] 229 | }, 230 | { 231 | "id": "f153242c66325c64", 232 | "type": "link out", 233 | "z": "5dae21e1b0933241", 234 | "name": "GrowattData", 235 | "mode": "link", 236 | "links": [ 237 | "9718176cb3794c40", 238 | "3dd47dd4f54fa987" 239 | ], 240 | "x": 875, 241 | "y": 460, 242 | "wires": [] 243 | }, 244 | { 245 | "id": "6bd69537198740bd", 246 | "type": "change", 247 | "z": "5dae21e1b0933241", 248 | "name": "", 249 | "rules": [ 250 | { 251 | "t": "set", 252 | "p": "data", 253 | "pt": "global", 254 | "to": "payload", 255 | "tot": "msg" 256 | } 257 | ], 258 | "action": "", 259 | "property": "", 260 | "from": "", 261 | "to": "", 262 | "reg": false, 263 | "x": 940, 264 | "y": 340, 265 | "wires": [ 266 | [] 267 | ] 268 | }, 269 | { 270 | "id": "bfcf1f5f44c4356e", 271 | "type": "delay", 272 | "z": "5dae21e1b0933241", 273 | "name": "Growatt Inverter needs a break", 274 | "pauseType": "delay", 275 | "timeout": "100", 276 | "timeoutUnits": "milliseconds", 277 | "rate": "1", 278 | "nbRateUnits": "1", 279 | "rateUnits": "second", 280 | "randomFirst": "1", 281 | "randomLast": "5", 282 | "randomUnits": "seconds", 283 | "drop": false, 284 | "allowrate": false, 285 | "outputs": 1, 286 | "x": 430, 287 | "y": 300, 288 | "wires": [ 289 | [ 290 | "a4059454c4b1bc5d" 291 | ] 292 | ] 293 | }, 294 | { 295 | "id": "70e12a3d6a454dbb", 296 | "type": "comment", 297 | "z": "5dae21e1b0933241", 298 | "name": "do something with the data", 299 | "info": "", 300 | "x": 1010, 301 | "y": 460, 302 | "wires": [] 303 | }, 304 | { 305 | "id": "24fa06d723ab4714", 306 | "type": "mqtt-broker", 307 | "name": "", 308 | "broker": "192.168.20.10", 309 | "port": "1883", 310 | "clientid": "", 311 | "autoConnect": true, 312 | "usetls": false, 313 | "protocolVersion": "4", 314 | "keepalive": "60", 315 | "cleansession": true, 316 | "birthTopic": "", 317 | "birthQos": "0", 318 | "birthPayload": "", 319 | "birthMsg": {}, 320 | "closeTopic": "", 321 | "closeQos": "0", 322 | "closePayload": "", 323 | "closeMsg": {}, 324 | "willTopic": "", 325 | "willQos": "0", 326 | "willPayload": "", 327 | "willMsg": {}, 328 | "userProps": "", 329 | "sessionExpiry": "" 330 | } 331 | ] --------------------------------------------------------------------------------