#include #include #include // --- CONFIGURATION --- const char* shunt_mac = ""; const char* encryption_key = ""; #define TOUCH_CS 33 #define TOUCH_IRQ 36 // Maximum plausible change between BLE updates (~1 second apart) // Tune these if you get false rejections under genuine rapid change #define MAX_SOC_JUMP 2.0f // % — battery can't charge/discharge 2% in 1 second #define MAX_VOLTAGE_JUMP 3.0f // V #define MAX_CURRENT_JUMP 200.0f // A — allows for large load switching #define MAX_STARTER_JUMP 1.0f // V #define MAX_CAH_JUMP 5.0f // Ah XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ); TFT_eSPI tft = TFT_eSPI(); TFT_eSprite sprite = TFT_eSprite(&tft); VictronBLE victron; bool spriteReady = false; uint8_t brightness = 2; uint32_t lastTouchMs = 0; #define BL_PIN 21 const uint8_t BL_LEVELS[] = { 20, 120, 255 }; // Last known good values — all start as "not yet received" struct ShuntState { float soc = -1.f; float houseV = -1.f; float starterV = -1.f; float amps = -999.f; float watts = -999.f; float consumedAh = -999.f; int remainMins = -1; bool valid = false; // true once we have one full good packet } state; uint32_t rejectedPackets = 0; // counter shown in status bar void setBrightness(uint8_t level) { brightness = level % 3; analogWrite(BL_PIN, BL_LEVELS[brightness]); } // ------------------------------------------------------- // Packet validator // Returns true if all values are plausible vs last good state // ------------------------------------------------------- bool packetPlausible(float soc, float houseV, float starterV, float amps, float consumedAh) { // Basic range checks — catch obvious garbage first if (soc < 0.f || soc > 100.f) return false; if (houseV < 8.f || houseV > 20.f) return false; if (starterV > 0.f && // only check if aux present (starterV < 8.f || starterV > 20.f)) return false; if (fabsf(amps) > 500.f) return false; // If we have a previous good state, check for implausible jumps if (state.valid) { if (fabsf(soc - state.soc) > MAX_SOC_JUMP) return false; if (fabsf(houseV - state.houseV) > MAX_VOLTAGE_JUMP) return false; if (fabsf(amps - state.amps) > MAX_CURRENT_JUMP) return false; if (state.starterV > 0.f && starterV > 0.f && fabsf(starterV - state.starterV) > MAX_STARTER_JUMP) return false; if (fabsf(consumedAh - state.consumedAh) > MAX_CAH_JUMP) return false; } return true; } // ------------------------------------------------------- // Draw one 320x120 half // ------------------------------------------------------- void drawHalf(int yBase) { char buf[40]; if (spriteReady) sprite.fillSprite(TFT_BLACK); else if (yBase == 0) tft.fillScreen(TFT_BLACK); #define C (spriteReady ? (TFT_eSPI&)sprite : tft) #define Y(abs) ((abs) - yBase) #define IN(abs) ((abs) >= yBase && (abs) < yBase + 120) // ── TOP HALF ──────────────────────────────────────── // SOC (y=2) if (IN(2)) { uint16_t socColor = !state.valid ? TFT_DARKGREY : state.soc > 50 ? TFT_GREEN : state.soc > 20 ? TFT_YELLOW : TFT_RED; C.setTextSize(5); C.setTextColor(socColor); C.setCursor(0, Y(2)); if (state.valid) C.printf("%.1f%%", state.soc); else C.print("--.-%%"); C.setTextSize(1); C.setTextColor(TFT_DARKGREY); C.setCursor(240, Y(10)); C.print(state.valid ? "SOC" : "wait"); } // Divider (y=50) if (IN(50)) C.drawFastHLine(0, Y(50), 320, TFT_DARKGREY); // House (y=58) if (IN(58)) { C.setTextSize(2); C.setCursor(0, Y(58)); C.setTextColor(TFT_DARKGREY); C.print("House: "); C.setTextColor(TFT_WHITE); if (state.valid) snprintf(buf, sizeof(buf), "%.2fV", state.houseV); else snprintf(buf, sizeof(buf), "--"); C.print(buf); } // Starter (y=80) if (IN(80)) { C.setTextSize(2); C.setCursor(0, Y(80)); C.setTextColor(TFT_DARKGREY); C.print("Starter: "); if (state.valid && state.starterV > 0.1f) { uint16_t sc = state.starterV > 12.4f ? TFT_GREEN : state.starterV > 11.8f ? TFT_YELLOW : TFT_RED; C.setTextColor(sc); snprintf(buf, sizeof(buf), "%.2fV", state.starterV); C.print(buf); } else { C.setTextColor(TFT_DARKGREY); C.print(state.valid ? "--" : "--"); } } // Divider (y=104) if (IN(104)) C.drawFastHLine(0, Y(104), 320, TFT_DARKGREY); // ── BOTTOM HALF ────────────────────────────────────── uint16_t aColor = state.amps >= 0 ? TFT_CYAN : TFT_ORANGE; // Current (y=122) if (IN(122)) { C.setTextSize(2); C.setCursor(0, Y(122)); C.setTextColor(TFT_DARKGREY); C.print("Current: "); C.setTextColor(state.valid ? aColor : TFT_DARKGREY); if (state.valid) snprintf(buf, sizeof(buf), "%+.2fA", state.amps); else snprintf(buf, sizeof(buf), "--"); C.print(buf); } // Power (y=144) if (IN(144)) { C.setTextSize(2); C.setCursor(0, Y(144)); C.setTextColor(TFT_DARKGREY); C.print("Power: "); C.setTextColor(state.valid ? aColor : TFT_DARKGREY); if (state.valid) snprintf(buf, sizeof(buf), "%+.1fW", state.watts); else snprintf(buf, sizeof(buf), "--"); C.print(buf); } // Divider (y=168) if (IN(168)) C.drawFastHLine(0, Y(168), 320, TFT_DARKGREY); // Consumed Ah (y=176) if (IN(176)) { C.setTextSize(2); C.setCursor(0, Y(176)); C.setTextColor(TFT_DARKGREY); C.print("Used: "); C.setTextColor(TFT_WHITE); if (state.valid) snprintf(buf, sizeof(buf), "%.1fAh", state.consumedAh); else snprintf(buf, sizeof(buf), "--"); C.print(buf); } // Time remaining (y=198) if (IN(198)) { C.setTextSize(2); C.setCursor(0, Y(198)); C.setTextColor(TFT_DARKGREY); C.print("Time: "); C.setTextColor(TFT_WHITE); if (!state.valid || state.remainMins <= 0 || state.remainMins > 40000) { C.print("--"); } else { int d = state.remainMins / 1440; int h = (state.remainMins % 1440) / 60; int m = state.remainMins % 60; if (d > 0) snprintf(buf, sizeof(buf), "%dd %dh %dm", d, h, m); else if (h > 0) snprintf(buf, sizeof(buf), "%dh %dm", h, m); else snprintf(buf, sizeof(buf), "%dm", m); C.print(buf); } } // Divider (y=222) if (IN(222)) C.drawFastHLine(0, Y(222), 320, TFT_DARKGREY); // Status bar (y=228) if (IN(228)) { C.setTextSize(1); C.setTextColor(TFT_DARKGREY); C.setCursor(0, Y(228)); // Show rejected packet count so you can see filtering is working if (rejectedPackets > 0) C.printf("RV Monitor bad pkts: %lu", rejectedPackets); else C.print("RV Battery Monitor"); const char* dimStr = brightness == 0 ? "LOW" : brightness == 1 ? "MED" : "FULL"; C.setTextColor(brightness == 2 ? TFT_WHITE : brightness == 1 ? TFT_YELLOW : TFT_DARKGREY); C.setCursor(272, Y(228)); C.print(dimStr); static bool dot = false; dot = !dot; C.fillCircle(314, Y(236), 3, dot ? TFT_BLUE : TFT_DARKGREY); } if (spriteReady) sprite.pushSprite(0, yBase); #undef C #undef Y #undef IN } // ------------------------------------------------------- // BLE Callback // ------------------------------------------------------- void onVictronData(const VictronDevice* device) { float amps = device->battery.current * -1.0f; float houseV = device->battery.voltage; float watts = houseV * amps; float soc = device->battery.soc; float starterV = device->battery.auxVoltage; float consumedAh = device->battery.consumedAh; int remainMins = device->battery.remainingMinutes; // Validate entire packet before accepting any of it if (!packetPlausible(soc, houseV, starterV, amps, consumedAh)) { rejectedPackets++; Serial.printf("Rejected packet #%lu: soc=%.1f V=%.2f I=%.2f aux=%.2f Ah=%.1f\n", rejectedPackets, soc, houseV, amps, starterV, consumedAh); // Keep displaying last good state — do NOT update anything drawHalf(0); drawHalf(120); return; } // Good packet — update stored state state.soc = soc; state.houseV = houseV; state.starterV = starterV; state.amps = amps; state.watts = watts; state.consumedAh = consumedAh; state.remainMins = remainMins; state.valid = true; drawHalf(0); drawHalf(120); } // ------------------------------------------------------- // Setup // ------------------------------------------------------- void setup() { Serial.begin(115200); pinMode(BL_PIN, OUTPUT); setBrightness(2); tft.init(); tft.setRotation(3); tft.invertDisplay(true); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE); tft.setTextSize(2); tft.println("RV Battery Monitor"); SPI.begin(25, 39, 32, 33); touch.begin(); touch.setRotation(1); tft.println("Touch: OK"); void* ptr = sprite.createSprite(320, 120); if (ptr != nullptr) { spriteReady = true; tft.println("Display: smooth"); } else { spriteReady = false; tft.setTextColor(TFT_YELLOW); tft.println("Display: basic"); } tft.setTextColor(TFT_WHITE); tft.println("Scanning for Shunt..."); victron.begin(); victron.setCallback(onVictronData); victron.addDevice("House Shunt", shunt_mac, encryption_key); } // ------------------------------------------------------- // Loop // ------------------------------------------------------- void loop() { victron.loop(); if (touch.touched()) { uint32_t now = millis(); if (now - lastTouchMs > 600) { lastTouchMs = now; setBrightness(brightness + 1); } } }