diff --git a/README.md b/README.md index 0b0bcf5..203a561 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Currently supporting ESP32 S and C series (tested on older ESP32, ESP32-S3 and E - **Battery Monitors**: SmartShunt, BMV-712 Smart, BMV-700 series - **Inverters**: MultiPlus, Quattro, Phoenix (with VE.Bus BLE dongle) - **DC-DC Converters**: Orion Smart, Orion XS +- **AC Chargers**: Blue Smart IP22 / IP65 / IP67 chargers - **Others**: Smart Battery Protect, Lynx Smart BMS, Smart Lithium batteries ## Hardware Requirements @@ -40,16 +41,23 @@ Currently supporting ESP32 S and C series (tested on older ESP32, ESP32-S3 and E ### PlatformIO -1. Add to `platformio.ini`: +1. Add to `platformio.ini` (recommended — installs from the PlatformIO registry): ```ini -lib_deps = - https://gitea.sh3d.com.au/Sh3d/VictronBLE +lib_deps = + scottp/victronble +``` + + Or install directly from git. Note the **`.git` suffix is required** — the bare + repository URL is not accepted by PlatformIO: +```ini +lib_deps = + https://gitea.sh3d.com.au/Sh3d/VictronBLE.git ``` 2. Or clone into your project's `lib` folder: ```bash cd lib -git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE +git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE.git ``` ### Arduino IDE @@ -262,6 +270,23 @@ struct VictronDCDCData { }; ``` +#### VictronACChargerData + +```cpp +struct VictronACChargerData { + uint8_t chargeState; // SolarChargerState enum (shared charger states) + uint8_t errorCode; + float voltage1; // V (output 1) + float current1; // A (output 1) + float voltage2; // V (output 2, 0 if absent) + float current2; // A (output 2, 0 if absent) + float voltage3; // V (output 3, 0 if absent) + float current3; // A (output 3, 0 if absent) + float temperature; // C (0 if not available) + float acCurrent; // A (0 if not available) +}; +``` + ## Advanced Usage ### Multiple Devices @@ -299,6 +324,11 @@ void onVictronData(const VictronDevice* dev) { Serial.printf("%s: %.2fV -> %.2fV\n", dev->name, dev->dcdc.inputVoltage, dev->dcdc.outputVoltage); break; + case DEVICE_TYPE_AC_CHARGER: + Serial.printf("%s: %.2fV %.2fA %.0fC\n", dev->name, + dev->acCharger.voltage1, dev->acCharger.current1, + dev->acCharger.temperature); + break; default: break; } @@ -397,7 +427,9 @@ See [VERSIONS](VERSIONS) file for detailed changelog and release history. ## Support -- 📫 Report issues on GitHub +- 📫 Report issues: the Gitea instance does not currently allow public sign-ups, so + email with bug reports, device decode problems, or new device + requests (debug log output is very helpful). - 📖 Check the examples directory - 🔧 Enable debug mode for diagnostics - 📚 See [Victron documentation](https://www.victronenergy.com/live/) diff --git a/VERSIONS b/VERSIONS index a95b39e..120afac 100644 --- a/VERSIONS +++ b/VERSIONS @@ -1,5 +1,33 @@ # Version History +## 0.5.0 (2026-06-04) + +Decoding accuracy fixes (thanks to community bug reports from Karsten, Cory, Kevin and Dan) +plus new AC Charger support. Field layouts verified against the keshavdv/victron-ble +reference implementation and the Victron "Extra Manufacturer Data" specification. + +### Bug fixes +- **Battery monitor decoding rewritten.** The `victronBatteryMonitorPayload` struct had + wrong field widths (8-bit alarm instead of 16, no 2-bit aux-mode field) which cascaded + and misaligned current, consumed Ah and SOC. `parseBatteryMonitor()` now decodes the + bit-packed payload directly by bit offset: signed voltage, 16-bit alarm, aux value + + 2-bit aux mode (replacing the unreliable `< 3000` voltage/temperature heuristic), + 22-bit signed current, 20-bit consumed Ah (×-0.1 Ah), 10-bit SOC (×0.1 %). +- **Solar charger battery current scale fixed.** Was multiplied by 0.01 (10× too small); + the field is in 0.1 A units. Load current is now read as the correct 9-bit field. +- **Compile error with newer ESP32 BLE library fixed.** `getManufacturerData()` now returns + an Arduino `String` on recent cores; `processDevice()` handles both `String` and + `std::string` while preserving the binary payload's embedded null bytes. +- **Device type IDs corrected.** The enum was off-by-one from 0x07 onward, mislabelling + AC chargers (0x08) as Lynx Smart BMS. Now matches the Victron protocol: + 0x07 GX Device, 0x08 AC Charger, 0x09 Smart Battery Protect, 0x0A Lynx Smart BMS, + 0x0B Multi RS, 0x0C VE.Bus, 0x0D DC Energy Meter, 0x0F Orion XS. + +### New features +- **AC Charger support** (Blue Smart IP22/IP65/IP67, device type 0x08). New + `VictronACChargerData` struct (three output voltage/current banks, temperature, AC + current) and `DEVICE_TYPE_AC_CHARGER` handling. + ## 0.4.1 (2026-02-28) Major rework of library internals. Breaking API change — not backwards compatible with 0.3.x. diff --git a/library.json b/library.json index 7468985..33f9d7e 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "victronble", - "version": "0.4.1", + "version": "0.5.0", "description": "ESP32 library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Supports SmartSolar MPPT, SmartShunt, BMV, MultiPlus, Orion and other Victron devices.", "keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, iot, energy, monitoring", "repository": { diff --git a/library.properties b/library.properties index a63f3ba..478f1b7 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=VictronBLE -version=0.4.1 +version=0.5.0 author=Scott Penrose maintainer=Scott Penrose sentence=ESP32 library for reading Victron Energy device data via BLE for any ESP32 diff --git a/src/VictronBLE.cpp b/src/VictronBLE.cpp index df3db66..294d5dc 100644 --- a/src/VictronBLE.cpp +++ b/src/VictronBLE.cpp @@ -87,7 +87,11 @@ void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertise void VictronBLE::processDevice(BLEAdvertisedDevice& advertisedDevice) { if (!advertisedDevice.haveManufacturerData()) return; - std::string raw = advertisedDevice.getManufacturerData(); + // getManufacturerData() returns std::string on older ESP32 BLE libraries and + // an Arduino String on newer ones. Both expose c_str()/length(); constructing + // from (ptr, len) preserves the embedded null bytes present in binary data. + auto mfg = advertisedDevice.getManufacturerData(); + std::string raw(mfg.c_str(), mfg.length()); if (raw.length() < 10) return; // Quick vendor ID check before any other work @@ -180,6 +184,10 @@ bool VictronBLE::parseAdvertisement(DeviceEntry* entry, const victronManufacture entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER; ok = parseDCDCConverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.dcdc); break; + case DEVICE_TYPE_AC_CHARGER: + entry->device.deviceType = DEVICE_TYPE_AC_CHARGER; + ok = parseACCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.acCharger); + break; default: if (debugEnabled) Serial.printf("[VictronBLE] Unknown type: 0x%02X\n", mfg.victronRecordType); return false; @@ -222,11 +230,13 @@ bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolar result.chargeState = p->deviceState; result.errorCode = p->errorCode; - result.batteryVoltage = p->batteryVoltage * 0.01f; - result.batteryCurrent = p->batteryCurrent * 0.01f; + result.batteryVoltage = p->batteryVoltage * 0.01f; // 0.01V units + result.batteryCurrent = p->batteryCurrent * 0.1f; // 0.1A units result.yieldToday = p->yieldToday * 10; result.panelPower = p->inputPower; - result.loadCurrent = (p->loadCurrent != 0xFFFF) ? p->loadCurrent * 0.01f : 0; + // Load current is a 9-bit field (0.1A units); 0x1FF = no load output + uint16_t loadRaw = p->loadCurrent & 0x1FF; + result.loadCurrent = (loadRaw != 0x1FF) ? loadRaw * 0.1f : 0; if (debugEnabled) { Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n", @@ -236,47 +246,96 @@ bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolar return true; } +bool VictronBLE::parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result) { + // Payload is bit-packed (10 fields, 104 bits ending in byte 12). Decode LSB-first. + if (len < 13) return false; + + size_t bit = 0; + auto readBits = [&](uint8_t width) -> uint32_t { + uint32_t value = 0; + for (uint8_t i = 0; i < width; i++) { + size_t b = bit + i; + value |= (uint32_t)((data[b >> 3] >> (b & 7)) & 0x01) << i; + } + bit += width; + return value; + }; + + result.chargeState = (uint8_t)readBits(8); + result.errorCode = (uint8_t)readBits(8); + + uint32_t v1 = readBits(13), i1 = readBits(11); + uint32_t v2 = readBits(13), i2 = readBits(11); + uint32_t v3 = readBits(13), i3 = readBits(11); + uint32_t temp = readBits(7); + uint32_t acCur = readBits(9); + + result.voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : 0; + result.current1 = (i1 != 0x7FF) ? i1 * 0.1f : 0; + result.voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : 0; + result.current2 = (i2 != 0x7FF) ? i2 * 0.1f : 0; + result.voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : 0; + result.current3 = (i3 != 0x7FF) ? i3 * 0.1f : 0; + result.temperature = (temp != 0x7F) ? (float)temp - 40.0f : 0; // C offset by -40 + result.acCurrent = (acCur != 0x1FF) ? acCur * 0.1f : 0; + + if (debugEnabled) { + Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n", + result.voltage1, result.current1, result.temperature, result.chargeState); + } + return true; +} + bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result) { - if (len < sizeof(victronBatteryMonitorPayload)) return false; - const auto* p = reinterpret_cast(data); + // The payload is bit-packed and not byte-aligned, so it is decoded by bit + // offset directly rather than via a struct. SOC ends at bit 117 (byte 14). + if (len < 15) return false; - result.remainingMinutes = p->remainingMins; - result.voltage = p->batteryVoltage * 0.01f; + // TTG (bits 0-15), unsigned minutes + result.remainingMinutes = data[0] | ((uint16_t)data[1] << 8); - // Alarm bits - result.alarmLowVoltage = (p->alarms & 0x01) != 0; - result.alarmHighVoltage = (p->alarms & 0x02) != 0; - result.alarmLowSOC = (p->alarms & 0x04) != 0; - result.alarmLowTemperature = (p->alarms & 0x10) != 0; - result.alarmHighTemperature = (p->alarms & 0x20) != 0; + // Voltage (bits 16-31), signed, 0.01V units + result.voltage = (int16_t)(data[2] | ((uint16_t)data[3] << 8)) * 0.01f; - // Aux data: voltage or temperature (heuristic: < 30V = voltage) - // NOTE: Victron protocol uses a flag bit for this, but it's not exposed - // in the BLE advertisement. This heuristic may misclassify edge cases. - if (p->auxData < 3000) { - result.auxVoltage = p->auxData * 0.01f; + // Alarm (bits 32-47), 16-bit bitmask + uint16_t alarm = data[4] | ((uint16_t)data[5] << 8); + result.alarmLowVoltage = (alarm & 0x0001) != 0; + result.alarmHighVoltage = (alarm & 0x0002) != 0; + result.alarmLowSOC = (alarm & 0x0004) != 0; + result.alarmLowTemperature = (alarm & 0x0010) != 0; + result.alarmHighTemperature = (alarm & 0x0020) != 0; + + // Aux value (bits 48-63) interpreted per aux mode (bits 64-65) + uint16_t auxRaw = data[6] | ((uint16_t)data[7] << 8); + uint8_t auxMode = data[8] & 0x03; // 0=aux voltage, 1=midpoint, 2=temperature, 3=none + if (auxMode == 0) { + result.auxVoltage = auxRaw * 0.01f; result.temperature = 0; - } else { - result.temperature = (p->auxData * 0.01f) - 273.15f; + } else if (auxMode == 2) { + result.temperature = auxRaw * 0.01f - 273.15f; // 0.01K -> C result.auxVoltage = 0; + } else { + result.auxVoltage = 0; + result.temperature = 0; } - // Battery current (22-bit signed, 1 mA units) - int32_t current = p->currentLow | - (p->currentMid << 8) | - ((p->currentHigh_consumedLow & 0x3F) << 16); - if (current & 0x200000) current |= 0xFFC00000; // Sign extend + // Battery current (bits 66-87), 22-bit signed, 0.001A units + int32_t current = ((uint32_t)(data[8] >> 2) & 0x3F) + | ((uint32_t)data[9] << 6) + | ((uint32_t)data[10] << 14); + if (current & 0x200000) current |= 0xFFC00000; // Sign extend 22-bit result.current = current * 0.001f; - // Consumed Ah (18-bit signed, 10 mAh units) - int32_t consumedAh = ((p->currentHigh_consumedLow & 0xC0) >> 6) | - (p->consumedMid << 2) | - (p->consumedHigh << 10); - if (consumedAh & 0x20000) consumedAh |= 0xFFFC0000; // Sign extend - result.consumedAh = consumedAh * 0.01f; + // Consumed Ah (bits 88-107), 20-bit, stored as a positive count, 0.1Ah units. + // Reported as a negative value (amp-hours consumed). + uint32_t consumed = (uint32_t)data[11] + | ((uint32_t)data[12] << 8) + | ((uint32_t)(data[13] & 0x0F) << 16); + result.consumedAh = -((float)consumed * 0.1f); - // SOC (10-bit, 0.1% units) - result.soc = (p->soc & 0x3FF) * 0.1f; + // SOC (bits 108-117), 10-bit, 0.1% units + uint16_t soc = ((uint16_t)(data[13] >> 4) | ((uint16_t)data[14] << 4)) & 0x3FF; + result.soc = soc * 0.1f; if (debugEnabled) { Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n", diff --git a/src/VictronBLE.h b/src/VictronBLE.h index 220054f..e39ec86 100644 --- a/src/VictronBLE.h +++ b/src/VictronBLE.h @@ -33,11 +33,14 @@ enum VictronDeviceType { DEVICE_TYPE_DCDC_CONVERTER = 0x04, DEVICE_TYPE_SMART_LITHIUM = 0x05, DEVICE_TYPE_INVERTER_RS = 0x06, - DEVICE_TYPE_SMART_BATTERY_PROTECT = 0x07, - DEVICE_TYPE_LYNX_SMART_BMS = 0x08, - DEVICE_TYPE_MULTI_RS = 0x09, - DEVICE_TYPE_VE_BUS = 0x0A, - DEVICE_TYPE_DC_ENERGY_METER = 0x0B + DEVICE_TYPE_GX_DEVICE = 0x07, + DEVICE_TYPE_AC_CHARGER = 0x08, + DEVICE_TYPE_SMART_BATTERY_PROTECT = 0x09, + DEVICE_TYPE_LYNX_SMART_BMS = 0x0A, + DEVICE_TYPE_MULTI_RS = 0x0B, + DEVICE_TYPE_VE_BUS = 0x0C, + DEVICE_TYPE_DC_ENERGY_METER = 0x0D, + DEVICE_TYPE_ORION_XS = 0x0F }; // --- Device state for Solar Charger --- @@ -72,27 +75,17 @@ struct victronManufacturerData { struct victronSolarChargerPayload { uint8_t deviceState; uint8_t errorCode; - int16_t batteryVoltage; // 10mV units - int16_t batteryCurrent; // 10mA units (signed) - uint16_t yieldToday; // 10Wh units + int16_t batteryVoltage; // 0.01V units (signed) + int16_t batteryCurrent; // 0.1A units (signed) + uint16_t yieldToday; // 0.01kWh (10Wh) units uint16_t inputPower; // 1W units - uint16_t loadCurrent; // 10mA units (0xFFFF = no load) + uint16_t loadCurrent; // 9-bit field, 0.1A units (0x1FF = no load) uint8_t reserved[2]; } __attribute__((packed)); -struct victronBatteryMonitorPayload { - uint16_t remainingMins; - uint16_t batteryVoltage; // 10mV units - uint8_t alarms; - uint16_t auxData; // 10mV (voltage) or 0.01K (temperature) - uint8_t currentLow; - uint8_t currentMid; - uint8_t currentHigh_consumedLow; // Current bits 16-21 (low 6), consumed bits 0-1 (high 2) - uint8_t consumedMid; - uint8_t consumedHigh; - uint16_t soc; // 0.1% units (10-bit) - uint8_t reserved[2]; -} __attribute__((packed)); +// NOTE: The battery monitor payload is bit-packed (16-bit alarm, 2-bit aux mode, +// 22-bit current, 20-bit consumed Ah, 10-bit SOC) and does NOT byte-align, so it +// is decoded by bit offset directly in parseBatteryMonitor() rather than a struct. struct victronInverterPayload { uint8_t deviceState; @@ -129,6 +122,19 @@ struct VictronSolarData { float loadCurrent; // A }; +struct VictronACChargerData { + uint8_t chargeState; // SolarChargerState enum (shared charger states) + uint8_t errorCode; + float voltage1; // V (output 1) + float current1; // A (output 1) + float voltage2; // V (output 2, 0 if absent) + float current2; // A (output 2, 0 if absent) + float voltage3; // V (output 3, 0 if absent) + float current3; // A (output 3, 0 if absent) + float temperature; // C (0 if not available) + float acCurrent; // A (0 if not available) +}; + struct VictronBatteryData { float voltage; // V float current; // A @@ -179,6 +185,7 @@ struct VictronDevice { VictronBatteryData battery; VictronInverterData inverter; VictronDCDCData dcdc; + VictronACChargerData acCharger; }; }; @@ -236,6 +243,7 @@ private: void processDevice(BLEAdvertisedDevice& dev); bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg); bool parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result); + bool parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result); bool parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result); bool parseInverter(const uint8_t* data, size_t len, VictronInverterData& result); bool parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result);