Created
January 18, 2026 16:27
-
-
Save arduinka55055/38a4f7d846d67f93bd25ff202a4df454 to your computer and use it in GitHub Desktop.
Dyness (pylon mode) RS485 battery communication parser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include <Arduino.h> | |
| #include <SPI.h> | |
| #include <Adafruit_GFX.h> | |
| #include <Adafruit_ST7735.h> | |
| #include <ESP8266WiFi.h> | |
| #include <ESP8266mDNS.h> | |
| #include <WiFiUdp.h> | |
| #include <ArduinoOTA.h> | |
| // --- WIFI CONFIGURATION --- | |
| const char* ssid = "ssid"; // <--- CHANGE THIS | |
| const char* password = "password"; // <--- CHANGE THIS | |
| const char* udpAddress = "192.168.0.100"; | |
| const int udpPort = 8323; | |
| WiFiUDP udp; | |
| // --- RS485 Configuration --- | |
| // Using Hardware Serial (Serial) for RS485 RX-only | |
| // Connect: RS485 TX (from module) -> ESP8266 RX (D0/GPIO3) | |
| // No TX pin needed since we're only receiving | |
| // No enable pin needed for receive-only | |
| // ST7735 Display Pins | |
| // ST7735 Display Pins | |
| #define TFT_CS 15//20 | |
| #define TFT_RST 2//21 | |
| #define TFT_DC 16//22 | |
| #define TFT_MOSI 13//23 | |
| #define TFT_SCLK 14//15 | |
| #define RS485_EN 4 | |
| // Initialize Display | |
| Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST); | |
| // No SoftwareSerial needed - using Hardware Serial | |
| // --- DATA STRUCTURES --- | |
| struct PylonStats { | |
| float voltage = 0.0; | |
| float current = 0.0; | |
| int soc = 0; | |
| int soh = 0; // Leave as zero (not provided in protocol) | |
| float temp = 0.0; | |
| bool updated = false; | |
| }; | |
| // Packed struct for UDP transmission | |
| struct __attribute__((packed)) PylonPacket { | |
| float voltage; | |
| float current; | |
| int32_t soc; | |
| int32_t soh; | |
| float temp; | |
| bool updated; | |
| }; | |
| PylonStats batteryData; | |
| unsigned long lastUpdate = 0; | |
| unsigned long lastRS485Activity = 0; | |
| String rxBuffer = ""; | |
| // Function Prototypes | |
| bool parseRS485Message(String message); | |
| bool isAllZeros(const uint8_t *data, uint8_t len); | |
| void updateDisplay(); | |
| void updateHeader(); | |
| void drawLabels(); | |
| void sendToUDP(); | |
| // Helper function to convert hex string to int (signed) | |
| int16_t hexToInt(String hexStr, bool signedVal = true) { | |
| if (hexStr.length() == 0) return 0; | |
| uint16_t val = (uint16_t)strtol(hexStr.c_str(), NULL, 16); | |
| if (signedVal && val > 0x7FFF) { | |
| val -= 0x10000; | |
| } | |
| return (int16_t)val; | |
| } | |
| uint16_t hexStringToUInt16(String hexStr) { | |
| uint16_t result = 0; | |
| for (int i = 0; i < hexStr.length(); i++) { | |
| char c = hexStr.charAt(i); | |
| result <<= 4; | |
| if (c >= '0' && c <= '9') { | |
| result |= (c - '0'); | |
| } else if (c >= 'A' && c <= 'F') { | |
| result |= (c - 'A' + 10); | |
| } else if (c >= 'a' && c <= 'f') { | |
| result |= (c - 'a' + 10); | |
| } | |
| } | |
| return result; | |
| } | |
| void setup() { | |
| // IMPORTANT: For hardware serial receive-only setup: | |
| // 1. Serial.begin(115200) for debugging (uses GPIO1/TX, GPIO3/RX) | |
| // 2. But we want to use GPIO3/RX for RS485 | |
| // 3. We can't use Serial for both debugging AND RS485 at the same time | |
| // Option 1: Use Serial for debugging during development | |
| // Then switch to RS485 for production | |
| // Option 2: Use Serial for RS485 and SoftwareSerial for debugging | |
| // Let's use Serial for debugging first to test WiFi/display | |
| Serial.begin(9600); | |
| delay(1000); | |
| Serial.println("\n\nESP8266 Battery Monitor Starting..."); | |
| Serial.println("Using Hardware Serial for RS485 (RX-only)"); | |
| // 0. Setup WiFi | |
| WiFi.hostname("esp8266batt"); | |
| ArduinoOTA.setHostname("esp8266batt"); | |
| ArduinoOTA.setPassword("esp-flashing"); | |
| Serial.print("Connecting to WiFi"); | |
| WiFi.begin(ssid, password); | |
| int retry = 0; | |
| while (WiFi.status() != WL_CONNECTED && retry < 20) { | |
| delay(250); | |
| Serial.print("."); | |
| retry++; | |
| } | |
| if (WiFi.status() == WL_CONNECTED) { | |
| Serial.println("\nWiFi Connected!"); | |
| Serial.print("IP: "); Serial.println(WiFi.localIP()); | |
| } else { | |
| Serial.println("\nWiFi Failed (continuing offline)"); | |
| } | |
| ArduinoOTA.onStart([]() { | |
| String type; | |
| if (ArduinoOTA.getCommand() == U_FLASH) { | |
| type = "sketch"; | |
| } else { // U_FS | |
| type = "filesystem"; | |
| } | |
| // NOTE: if updating FS this would be the place to unmount FS using FS.end() | |
| Serial.println("Start updating " + type); | |
| }); | |
| ArduinoOTA.onEnd([]() { | |
| Serial.println("\nEnd"); | |
| }); | |
| ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { | |
| Serial.printf("Progress: %u%%\r", (progress / (total / 100))); | |
| }); | |
| ArduinoOTA.onError([](ota_error_t error) { | |
| Serial.printf("Error[%u]: ", error); | |
| if (error == OTA_AUTH_ERROR) { | |
| Serial.println("Auth Failed"); | |
| } else if (error == OTA_BEGIN_ERROR) { | |
| Serial.println("Begin Failed"); | |
| } else if (error == OTA_CONNECT_ERROR) { | |
| Serial.println("Connect Failed"); | |
| } else if (error == OTA_RECEIVE_ERROR) { | |
| Serial.println("Receive Failed"); | |
| } else if (error == OTA_END_ERROR) { | |
| Serial.println("End Failed"); | |
| } | |
| }); | |
| ArduinoOTA.begin(); | |
| // 1. Initialize Display | |
| Serial.println("Initializing Display..."); | |
| SPI.begin(); | |
| tft.initR(INITR_144GREENTAB); | |
| tft.setRotation(1); | |
| tft.fillScreen(ST7735_BLACK); | |
| drawLabels(); | |
| updateHeader(); | |
| // 2. Setup Hardware Serial for RS485 | |
| // IMPORTANT: We need to reconfigure Serial for RS485 | |
| // Serial already began at 115200 for debugging | |
| // Reinitialize Serial at 9600 for RS485 | |
| pinMode(RS485_EN, OUTPUT); | |
| digitalWrite(RS485_EN, 0); | |
| Serial.flush(); | |
| delay(100); | |
| // Switch Serial to RS485 mode (9600 baud, RX on GPIO3) | |
| //Serial.begin(9600, SERIAL_8N1, SERIAL_RX_ONLY); | |
| // Note: SERIAL_RX_ONLY disables TX, freeing GPIO1 for other uses | |
| Serial.println("RS485 Initialized at 9600 baud (RX-only)"); | |
| Serial.println("Serial monitor won't work now - use another method for debugging"); | |
| // Alternative: If you want to keep Serial for debugging, | |
| // use SoftwareSerial for debugging instead: | |
| // SoftwareSerial debugSerial(14, 12); // RX, TX (D5, D6) | |
| // debugSerial.begin(115200); | |
| // Then use debugSerial.println() instead of Serial.println() | |
| // Clear receive buffer | |
| while (Serial.available()) { | |
| char c = Serial.read(); | |
| Serial.print("Startup Read: "); | |
| Serial.println(c, HEX); | |
| } | |
| Serial.println("Setup Complete!"); | |
| } | |
| void loop() { | |
| ArduinoOTA.handle(); | |
| // Read RS485 messages from Hardware Serial | |
| if (Serial.available()) { | |
| lastRS485Activity = millis(); | |
| char c = Serial.read(); | |
| // Echo to debug if needed (but Serial is now RS485) | |
| // For debugging, you'd need SoftwareSerial | |
| // Check for start of frame | |
| if (c == '~') { | |
| rxBuffer = "~"; | |
| } | |
| // Check for end of frame (carriage return) | |
| else if (c == '\r') { | |
| if (rxBuffer.length() > 0 && rxBuffer.startsWith("~")) { | |
| // Parse the message | |
| if (parseRS485Message(rxBuffer)) { | |
| batteryData.updated = true; | |
| } | |
| } | |
| rxBuffer = ""; | |
| } | |
| // Accumulate characters | |
| else if (rxBuffer.length() > 0 && isprint(c)) { | |
| rxBuffer += c; | |
| } | |
| } | |
| // Debug: Show RS485 activity (would need SoftwareSerial for output) | |
| if (millis() - lastRS485Activity > 5000 && lastRS485Activity > 0) { | |
| // Can't Serial.println here since Serial is RS485 | |
| lastRS485Activity = millis(); | |
| } | |
| // Update Screen and Send UDP every 1 second | |
| if (millis() - lastUpdate >= 1000) { | |
| updateHeader(); | |
| if (batteryData.updated) { | |
| updateDisplay(); | |
| sendToUDP(); | |
| batteryData.updated = false; | |
| } | |
| lastUpdate = millis(); | |
| } | |
| } | |
| void sendToUDP() { | |
| if (WiFi.status() == WL_CONNECTED) { | |
| PylonPacket packet; | |
| packet.voltage = batteryData.voltage; | |
| packet.current = batteryData.current; | |
| packet.soc = (int32_t)batteryData.soc; | |
| packet.soh = (int32_t)batteryData.soh; // Will be 0 | |
| packet.temp = batteryData.temp; | |
| udp.beginPacket(udpAddress, udpPort); | |
| udp.write((uint8_t*)&packet, sizeof(packet)); | |
| udp.endPacket(); | |
| } | |
| } | |
| void updateHeader() { | |
| tft.fillRect(0, 0, 128, 9, ST7735_BLACK); | |
| tft.setTextSize(1); | |
| tft.setCursor(2, 0); | |
| if (WiFi.status() == WL_CONNECTED) { | |
| tft.setTextColor(ST7735_GREEN); | |
| tft.print(WiFi.localIP()); | |
| } else { | |
| tft.setTextColor(ST7735_RED); | |
| tft.print("OFFLINE"); | |
| } | |
| } | |
| void drawLabels() { | |
| tft.setTextSize(1); | |
| tft.setTextColor(ST7735_CYAN); | |
| tft.drawLine(0, 10, 128, 10, ST7735_BLUE); | |
| tft.setTextColor(ST7735_WHITE); | |
| int x_label = 0; | |
| int y_start = 20; | |
| int y_step = 12; | |
| tft.setCursor(x_label, y_start); tft.print("VOLT:"); | |
| tft.setCursor(x_label, y_start + y_step); tft.print("AMP :"); | |
| tft.setCursor(x_label, y_start + y_step * 2); tft.print("TEMP:"); | |
| tft.setCursor(x_label, y_start + y_step * 3); tft.print("SOH :"); | |
| tft.drawLine(0, 75, 128, 75, ST7735_BLUE); | |
| } | |
| void updateDisplay() { | |
| int x_val = 40; | |
| int y_start = 20; | |
| int y_step = 12; | |
| // 1. Voltage | |
| tft.setTextSize(1); | |
| tft.setCursor(x_val, y_start); | |
| tft.setTextColor(ST7735_GREEN, ST7735_BLACK); | |
| tft.print(batteryData.voltage, 2); | |
| tft.print("V"); | |
| // 2. Current | |
| tft.setCursor(x_val, y_start + y_step); | |
| if (batteryData.current < 0) tft.setTextColor(ST7735_RED, ST7735_BLACK); | |
| else tft.setTextColor(ST7735_GREEN, ST7735_BLACK); | |
| tft.print(batteryData.current, 1); | |
| tft.print("A "); | |
| // 3. Temperature | |
| tft.setCursor(x_val, y_start + y_step * 2); | |
| tft.setTextColor(ST7735_ORANGE, ST7735_BLACK); | |
| tft.print(batteryData.temp, 1); | |
| tft.print("C"); | |
| // 4. SOH (will be 0) | |
| tft.setCursor(x_val, y_start + y_step * 3); | |
| tft.setTextColor(ST7735_MAGENTA, ST7735_BLACK); | |
| tft.print(batteryData.soh); | |
| tft.print("% "); | |
| // --- BOTTOM SECTION (SOC & WATTS) --- | |
| float power = batteryData.voltage * batteryData.current; | |
| // A. SOC (Big Font) | |
| tft.setTextSize(2); | |
| tft.setCursor(1, 85); | |
| if (batteryData.soc < 20) tft.setTextColor(ST7735_RED, ST7735_BLACK); | |
| else if (batteryData.soc < 50) tft.setTextColor(ST7735_YELLOW, ST7735_BLACK); | |
| else tft.setTextColor(ST7735_GREEN, ST7735_BLACK); | |
| if (batteryData.soc < 100) tft.print(" "); | |
| if (batteryData.soc < 10) tft.print(" "); | |
| tft.print(batteryData.soc); | |
| tft.setTextSize(1); | |
| tft.print("%"); | |
| // B. Watts | |
| tft.setCursor(60, 85); | |
| tft.setTextSize(1); | |
| tft.setTextColor(ST7735_WHITE, ST7735_BLACK); | |
| tft.print("POWER:"); | |
| tft.setTextSize(2); | |
| tft.setCursor(60, 98); | |
| if (power < 0) tft.setTextColor(ST7735_RED, ST7735_BLACK); | |
| else tft.setTextColor(ST7735_GREEN, ST7735_BLACK); | |
| tft.print((int)power); | |
| tft.print("W "); | |
| } | |
| bool parseRS485Message(String message) { | |
| if (message.length() < 17) { | |
| return false; | |
| } | |
| // Check for valid start | |
| if (message.charAt(0) != '~') { | |
| return false; | |
| } | |
| // Get CID2 (bytes 7-8, 0-indexed) | |
| String cid2 = message.substring(7, 9); | |
| // Get LENGTH field (last 3 chars of 4-char field at position 10-13) | |
| String lengthStr = message.substring(10, 13); | |
| int infoLength = 0; | |
| // Convert hex length to decimal | |
| char hexStr[4]; | |
| lengthStr.toCharArray(hexStr, 4); | |
| infoLength = (int)strtol(hexStr, NULL, 16); | |
| // Check if we have enough data for analog info (needs at least 44 chars) | |
| if (infoLength < 44 || message.length() < (13 + infoLength + 4)) { | |
| return false; | |
| } | |
| // Only parse if CID2 is "00" (Response Frame) | |
| if (cid2 != "00") { | |
| return false; | |
| } | |
| // Get INFO payload | |
| String infoPayload = message.substring(13, 13 + infoLength); | |
| try { | |
| // 1. Total Average Voltage (4 chars) -> Unit: 0.001V | |
| String voltageHex = infoPayload.substring(0, 4); | |
| uint16_t voltageRaw = hexStringToUInt16(voltageHex); | |
| batteryData.voltage = voltageRaw / 1000.0; | |
| // 2. Total Current (4 chars) -> Unit: 0.01A | |
| String currentHex = infoPayload.substring(4, 8); | |
| batteryData.current = hexToInt(currentHex, true) / 100.0; | |
| // 3. SOC (2 chars) -> Unit: 1% | |
| String socHex = infoPayload.substring(8, 10); | |
| batteryData.soc = hexToInt(socHex, false); | |
| // 12. Average Temperature (4 chars) - offset 40-44 | |
| if (infoPayload.length() >= 44) { | |
| String tempHex = infoPayload.substring(40, 44); | |
| // Swap bytes as in Python code | |
| String swappedTemp = tempHex.substring(2, 4) + tempHex.substring(0, 2); | |
| int16_t tempRaw = hexToInt(swappedTemp, false); | |
| // Convert from Kelvin (as per Python code) | |
| if (tempRaw > 0) { | |
| batteryData.temp = (tempRaw - 2731) / 10.0; | |
| } else { | |
| batteryData.temp = 0.0; | |
| } | |
| } else { | |
| batteryData.temp = 0.0; | |
| } | |
| // SOH is not provided in this protocol, leave as 0 | |
| batteryData.soh = 0; | |
| return true; | |
| } catch (...) { | |
| return false; | |
| } | |
| } | |
| bool isAllZeros(const uint8_t *data, uint8_t len) { | |
| for (uint8_t i = 0; i < len; i++) { | |
| if (data[i] != 0) return false; | |
| } | |
| return true; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment