4 Commits
v0.3.1 ... main

Author SHA1 Message Date
977641b093 Forwarding code 2026-02-17 09:27:48 +11:00
24712c206a Work on receiver and sender 2026-02-15 19:20:03 +11:00
8a2402cb63 Repeater and Test code for ESP Now 2026-02-15 19:10:19 +11:00
a64fef899b New version with smaller memory footprint etc 2026-02-12 18:33:56 +11:00
10 changed files with 884 additions and 148 deletions

View File

@@ -102,3 +102,92 @@ Arduino/ESP32 library for reading Victron Energy devices via Bluetooth Low Energ
- library.json - library.json
- library.properties - library.properties
### Session: 2026-02-12 18:23
**Commits:**
```
a843eb9 Keep v0.3.1
5a210fb Experimenting with a claude file and created new logging example
```
**Modified files:**
- .claude/CLAUDE.md
- README.md
- VERSIONS
- library.json
- library.properties
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-12 18:35
**Commits:**
```
a64fef8 New version with smaller memory footprint etc
a843eb9 Keep v0.3.1
5a210fb Experimenting with a claude file and created new logging example
```
**Modified files:**
- .claude/CLAUDE.md
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-13 11:02
**Modified files:**
- .claude/CLAUDE.md
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-15 18:59
**Modified files:**
- .claude/CLAUDE.md
- library.json
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-15 19:06
**Modified files:**
- .claude/CLAUDE.md
- library.json
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-15 19:10
**Modified files:**
- .claude/CLAUDE.md
- library.json
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-15 19:18
**Commits:**
```
8a2402c Repeater and Test code for ESP Now
```
**Modified files:**
- .claude/CLAUDE.md
- examples/FakeRepeater/platformio.ini
- examples/FakeRepeater/src/main.cpp
- examples/Receiver/platformio.ini
- examples/Receiver/src/main.cpp
- examples/Repeater/platformio.ini
- examples/Repeater/src/main.cpp
- library.json
### Session: 2026-02-15 19:20
**Commits:**
```
24712c2 Work on receiver and sender
8a2402c Repeater and Test code for ESP Now
```
**Modified files:**
- .claude/CLAUDE.md
- examples/Receiver/platformio.ini
- examples/Receiver/src/main.cpp
- examples/Repeater/src/main.cpp

View File

@@ -0,0 +1,48 @@
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
[env:esp32-s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
[env:esp32-c3]
platform = espressif32
framework = arduino
board = esp32-c3-devkitm-1
board_build.mcu = esp32c3
board_build.f_cpu = 160000000L
board_build.flash_mode = dio
board_build.partitions = default.csv
monitor_speed = 115200
monitor_filters = time, default, esp32_exception_decoder
upload_speed = 921600
build_flags =
-Os
-D ARDUINO_ESP32C3_DEV
-D CONFIG_IDF_TARGET_ESP32C3
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
[env:m5stick]
platform = espressif32
board = m5stick-c
framework = arduino
board_build.mcu = esp32
board_build.f_cpu = 240000000L
board_build.partitions = no_ota.csv
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags =
-Os
lib_deps =
M5StickC

View File

@@ -0,0 +1,102 @@
/**
* VictronBLE FakeRepeater Example
*
* Sends fake Solar Charger data over ESPNow every 10 seconds.
* Use with the Receiver example to test ESPNow without needing
* a real Victron device or the VictronBLE library.
*
* No VictronBLE dependency - just WiFi + ESPNow.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <esp_now.h>
// ESPNow packet structure - must match Receiver
struct __attribute__((packed)) SolarChargerPacket {
uint8_t chargeState;
float batteryVoltage; // V
float batteryCurrent; // A
float panelVoltage; // V
float panelPower; // W
uint16_t yieldToday; // Wh
float loadCurrent; // A
int8_t rssi; // BLE RSSI
char deviceName[16]; // Null-terminated, truncated
};
static const uint8_t BROADCAST_ADDR[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
static uint32_t sendCount = 0;
static unsigned long lastSendTime = 0;
static const unsigned long SEND_INTERVAL_MS = 10000;
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=== VictronBLE FakeRepeater ===\n");
WiFi.mode(WIFI_STA);
WiFi.disconnect();
Serial.println("MAC: " + WiFi.macAddress());
if (esp_now_init() != ESP_OK) {
Serial.println("ERROR: ESPNow init failed!");
while (1) delay(1000);
}
esp_now_peer_info_t peerInfo = {};
memcpy(peerInfo.peer_addr, BROADCAST_ADDR, 6);
peerInfo.channel = 0;
peerInfo.encrypt = false;
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
Serial.println("ERROR: Failed to add broadcast peer!");
while (1) delay(1000);
}
Serial.println("ESPNow initialized, sending fake data every 10s");
Serial.println("Packet size: " + String(sizeof(SolarChargerPacket)) + " bytes\n");
}
void loop() {
unsigned long now = millis();
if (now - lastSendTime < SEND_INTERVAL_MS) {
delay(100);
return;
}
lastSendTime = now;
sendCount++;
// Generate varying fake data
SolarChargerPacket pkt;
pkt.chargeState = (sendCount % 4) + 3; // Cycle through Bulk(3), Absorption(4), Float(5), Storage(6)
pkt.batteryVoltage = 51.0f + (sendCount % 20) * 0.15f;
pkt.batteryCurrent = 2.0f + (sendCount % 10) * 0.5f;
pkt.panelVoltage = 65.0f + (sendCount % 15) * 0.8f;
pkt.panelPower = pkt.batteryCurrent * pkt.batteryVoltage;
pkt.yieldToday = 100 + sendCount * 10;
pkt.loadCurrent = 0;
pkt.rssi = -60 - (sendCount % 30);
memset(pkt.deviceName, 0, sizeof(pkt.deviceName));
strncpy(pkt.deviceName, "FakeMPPT", sizeof(pkt.deviceName) - 1);
esp_err_t result = esp_now_send(BROADCAST_ADDR,
reinterpret_cast<const uint8_t*>(&pkt),
sizeof(pkt));
if (result != ESP_OK) {
Serial.println("[TX FAIL] " + String(esp_err_to_name(result)));
} else {
Serial.printf("[TX #%lu] %s Batt:%.2fV %.2fA PV:%.0fW Yield:%uWh RSSI:%d\n",
sendCount,
pkt.deviceName,
pkt.batteryVoltage,
pkt.batteryCurrent,
pkt.panelPower,
pkt.yieldToday,
pkt.rssi);
}
}

View File

@@ -0,0 +1,49 @@
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
[env:esp32-s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
[env:m5stick]
platform = espressif32
board = m5stick-c
framework = arduino
board_build.mcu = esp32
board_build.f_cpu = 240000000L
board_build.partitions = no_ota.csv
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags =
-Os
-D USE_M5STICK
lib_deps =
M5StickC
[env:esp32-c3]
platform = espressif32
framework = arduino
board = esp32-c3-devkitm-1
board_build.mcu = esp32c3
board_build.f_cpu = 160000000L
board_build.flash_mode = dio
board_build.partitions = default.csv
monitor_speed = 115200
monitor_filters = time, default, esp32_exception_decoder
upload_speed = 921600
build_flags =
-Os
-D ARDUINO_ESP32C3_DEV
-D CONFIG_IDF_TARGET_ESP32C3
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1

View File

@@ -0,0 +1,221 @@
/**
* VictronBLE ESPNow Receiver
*
* Standalone receiver for data sent by the Repeater example.
* Does NOT depend on VictronBLE library - just ESPNow.
*
* Flash this on a second ESP32 and it will print Solar Charger
* data received over ESPNow from the Repeater.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <esp_now.h>
#ifdef USE_M5STICK
#include <M5StickC.h>
#endif
// ESPNow packet structure - must match Repeater
struct __attribute__((packed)) SolarChargerPacket {
uint8_t chargeState;
float batteryVoltage; // V
float batteryCurrent; // A
float panelVoltage; // V
float panelPower; // W
uint16_t yieldToday; // Wh
float loadCurrent; // A
int8_t rssi; // BLE RSSI
char deviceName[16]; // Null-terminated, truncated
};
static uint32_t recvCount = 0;
#ifdef USE_M5STICK
// Display: cache latest packet per device for screen rotation
static const int MAX_DISPLAY_DEVICES = 4;
static SolarChargerPacket displayPackets[MAX_DISPLAY_DEVICES];
static bool displayValid[MAX_DISPLAY_DEVICES] = {};
static int displayCount = 0;
static int displayPage = 0; // Which device to show
static bool displayDirty = true;
static unsigned long lastPageSwitch = 0;
static const unsigned long PAGE_SWITCH_MS = 5000; // Rotate pages every 5s
static int findOrAddDisplay(const char* name) {
for (int i = 0; i < displayCount; i++) {
if (strncmp(displayPackets[i].deviceName, name, 16) == 0) return i;
}
if (displayCount < MAX_DISPLAY_DEVICES) return displayCount++;
return -1;
}
#endif
static const char* chargeStateName(uint8_t state) {
switch (state) {
case 0: return "Off";
case 1: return "Low Power";
case 2: return "Fault";
case 3: return "Bulk";
case 4: return "Absorption";
case 5: return "Float";
case 6: return "Storage";
case 7: return "Equalize";
case 9: return "Inverting";
case 11: return "Power Supply";
case 252: return "External Control";
default: return "Unknown";
}
}
void onDataRecv(const uint8_t* senderMac, const uint8_t* data, int len) {
if (len != sizeof(SolarChargerPacket)) {
Serial.println("Unexpected packet size: " + String(len));
return;
}
const auto* pkt = reinterpret_cast<const SolarChargerPacket*>(data);
recvCount++;
// Ensure device name is null-terminated even if corrupted
char name[17];
memcpy(name, pkt->deviceName, 16);
name[16] = '\0';
Serial.printf("[RX #%lu] %s | State:%s Batt:%.2fV %.2fA PV:%.1fV %.0fW Yield:%uWh",
recvCount,
name,
chargeStateName(pkt->chargeState),
pkt->batteryVoltage,
pkt->batteryCurrent,
pkt->panelVoltage,
pkt->panelPower,
pkt->yieldToday);
if (pkt->loadCurrent > 0) {
Serial.printf(" Load:%.2fA", pkt->loadCurrent);
}
Serial.printf(" RSSI:%ddBm From:%02X:%02X:%02X:%02X:%02X:%02X\n",
pkt->rssi,
senderMac[0], senderMac[1], senderMac[2],
senderMac[3], senderMac[4], senderMac[5]);
#ifdef USE_M5STICK
int idx = findOrAddDisplay(name);
if (idx >= 0) {
displayPackets[idx] = *pkt;
displayValid[idx] = true;
displayDirty = true;
}
#endif
}
void setup() {
#ifdef USE_M5STICK
M5.begin();
M5.Lcd.setRotation(3); // Landscape, USB on right
M5.Lcd.fillScreen(BLACK);
M5.Lcd.setTextColor(WHITE, BLACK);
M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(0, 0);
M5.Lcd.println("ESPNow Receiver");
M5.Lcd.println("Waiting...");
#endif
Serial.begin(115200);
delay(1000);
Serial.println("\n=== VictronBLE ESPNow Receiver ===\n");
// Init WiFi in STA mode (required for ESPNow)
WiFi.mode(WIFI_STA);
WiFi.disconnect();
Serial.println("MAC: " + WiFi.macAddress());
// Init ESPNow
if (esp_now_init() != ESP_OK) {
Serial.println("ERROR: ESPNow init failed!");
while (1) delay(1000);
}
esp_now_register_recv_cb(onDataRecv);
Serial.println("ESPNow initialized, waiting for packets...");
Serial.println("Expecting " + String(sizeof(SolarChargerPacket)) + " byte packets\n");
}
void loop() {
#ifdef USE_M5STICK
M5.update();
// Button A (front): manually cycle to next device
if (M5.BtnA.wasPressed()) {
if (displayCount > 0) {
displayPage = (displayPage + 1) % displayCount;
displayDirty = true;
lastPageSwitch = millis();
}
}
// Auto-rotate pages every 5 seconds if multiple devices
if (displayCount > 1) {
unsigned long now = millis();
if (now - lastPageSwitch >= PAGE_SWITCH_MS) {
lastPageSwitch = now;
displayPage = (displayPage + 1) % displayCount;
displayDirty = true;
}
}
// Redraw screen when data changes or page switches
if (displayDirty && displayCount > 0) {
displayDirty = false;
int p = displayPage % displayCount;
if (!displayValid[p]) { delay(100); return; }
const auto& pkt = displayPackets[p];
M5.Lcd.fillScreen(BLACK);
M5.Lcd.setCursor(0, 0);
// Row 0: device name + page indicator
M5.Lcd.setTextColor(CYAN, BLACK);
M5.Lcd.printf("%s", pkt.deviceName);
if (displayCount > 1) {
M5.Lcd.printf(" [%d/%d]", p + 1, displayCount);
}
M5.Lcd.println();
// Row 1: charge state
M5.Lcd.setTextColor(YELLOW, BLACK);
M5.Lcd.printf("State: %s\n", chargeStateName(pkt.chargeState));
// Row 2: battery voltage + current (large-ish)
M5.Lcd.setTextColor(GREEN, BLACK);
M5.Lcd.setTextSize(2);
M5.Lcd.printf("%.2fV\n", pkt.batteryVoltage);
M5.Lcd.setTextSize(1);
M5.Lcd.setTextColor(WHITE, BLACK);
M5.Lcd.printf("Batt: %.2fA\n", pkt.batteryCurrent);
// Row 3: PV
M5.Lcd.printf("PV: %.1fV %.0fW\n", pkt.panelVoltage, pkt.panelPower);
// Row 4: yield + load
M5.Lcd.printf("Yield: %uWh", pkt.yieldToday);
if (pkt.loadCurrent > 0) {
M5.Lcd.printf(" Ld:%.1fA", pkt.loadCurrent);
}
M5.Lcd.println();
// Row 5: stats
M5.Lcd.setTextColor(DARKGREY, BLACK);
M5.Lcd.printf("RSSI:%d RX:%lu", pkt.rssi, recvCount);
}
#endif
delay(100);
}

View File

@@ -0,0 +1,57 @@
[env]
lib_extra_dirs = ../..
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags =
-DCORE_DEBUG_LEVEL=3
[env:esp32-s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
[env:esp32-c3]
platform = espressif32
framework = arduino
board = esp32-c3-devkitm-1
board_build.mcu = esp32c3
board_build.f_cpu = 160000000L
board_build.flash_mode = dio
board_build.partitions = huge_app.csv
monitor_speed = 115200
monitor_filters = time, default, esp32_exception_decoder
upload_speed = 921600
build_flags =
-Os
-I src
-D ARDUINO_ESP32C3_DEV
-D CONFIG_IDF_TARGET_ESP32C3
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
elapsedMillis
[env:m5stick]
platform = espressif32
board = m5stick-c
framework = arduino
board_build.mcu = esp32
board_build.f_cpu = 240000000L
board_build.partitions = no_ota.csv
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags =
-Os
lib_deps =
M5StickC
elapsedMillis

View File

@@ -0,0 +1,189 @@
/**
* VictronBLE Repeater Example
*
* Collects Solar Charger data via BLE and transmits the latest
* readings over ESPNow broadcast every 30 seconds. Place this ESP32
* near Victron devices and use a separate Receiver ESP32 at a distance.
*
* ESPNow range is typically much greater than BLE (~200m+ line of sight).
*
* Setup:
* 1. Get your device encryption keys from the VictronConnect app
* 2. Update the device configurations below with your MAC and key
* 3. Flash the Receiver example on a second ESP32
*/
#include <Arduino.h>
#include <WiFi.h>
#include <esp_now.h>
#include "VictronBLE.h"
// ESPNow packet structure - must match Receiver
struct __attribute__((packed)) SolarChargerPacket {
uint8_t chargeState;
float batteryVoltage; // V
float batteryCurrent; // A
float panelVoltage; // V
float panelPower; // W
uint16_t yieldToday; // Wh
float loadCurrent; // A
int8_t rssi; // BLE RSSI
char deviceName[16]; // Null-terminated, truncated
};
// Broadcast address
static const uint8_t BROADCAST_ADDR[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
static const unsigned long SEND_INTERVAL_MS = 5000; // 30 seconds
static uint32_t sendCount = 0;
static uint32_t sendFailCount = 0;
static uint32_t blePacketCount = 0;
// Cache latest packet per device
static const int MAX_DEVICES = 4;
static SolarChargerPacket cachedPackets[MAX_DEVICES];
static bool cachedValid[MAX_DEVICES] = {};
static int cachedCount = 0;
static unsigned long lastSendTime = 0;
VictronBLE victron;
// Find cached slot by device name, or allocate a new one
static int findOrAddCached(const char* name) {
for (int i = 0; i < cachedCount; i++) {
if (strncmp(cachedPackets[i].deviceName, name, sizeof(cachedPackets[i].deviceName)) == 0)
return i;
}
if (cachedCount < MAX_DEVICES) return cachedCount++;
return -1;
}
class RepeaterCallback : public VictronDeviceCallback {
public:
void onSolarChargerData(const SolarChargerData& data) override {
blePacketCount++;
// Build packet
SolarChargerPacket pkt;
pkt.chargeState = static_cast<uint8_t>(data.chargeState);
pkt.batteryVoltage = data.batteryVoltage;
pkt.batteryCurrent = data.batteryCurrent;
pkt.panelVoltage = data.panelVoltage;
pkt.panelPower = data.panelPower;
pkt.yieldToday = data.yieldToday;
pkt.loadCurrent = data.loadCurrent;
pkt.rssi = data.rssi;
memset(pkt.deviceName, 0, sizeof(pkt.deviceName));
strncpy(pkt.deviceName, data.deviceName.c_str(), sizeof(pkt.deviceName) - 1);
// Cache it
int idx = findOrAddCached(pkt.deviceName);
if (idx >= 0) {
cachedPackets[idx] = pkt;
cachedValid[idx] = true;
}
}
};
RepeaterCallback callback;
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=== VictronBLE ESPNow Repeater ===\n");
// Init WiFi in STA mode (required for ESPNow)
WiFi.mode(WIFI_STA);
WiFi.disconnect();
Serial.println("MAC: " + WiFi.macAddress());
// Init ESPNow
if (esp_now_init() != ESP_OK) {
Serial.println("ERROR: ESPNow init failed!");
while (1) delay(1000);
}
// Add broadcast peer
esp_now_peer_info_t peerInfo = {};
memcpy(peerInfo.peer_addr, BROADCAST_ADDR, 6);
peerInfo.channel = 0; // Use current channel
peerInfo.encrypt = false;
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
Serial.println("ERROR: Failed to add broadcast peer!");
while (1) delay(1000);
}
Serial.println("ESPNow initialized, broadcasting on all channels");
// Init VictronBLE
if (!victron.begin(5)) {
Serial.println("ERROR: Failed to initialize VictronBLE!");
Serial.println(victron.getLastError());
while (1) delay(1000);
}
victron.setDebug(false);
victron.setCallback(&callback);
// Add your devices here
victron.addDevice(
"Rainbow48V",
"E4:05:42:34:14:F3",
"0ec3adf7433dd61793ff2f3b8ad32ed8",
DEVICE_TYPE_SOLAR_CHARGER
);
victron.addDevice(
"ScottTrailer",
"e64559783cfb",
"3fa658aded4f309b9bc17a2318cb1f56",
DEVICE_TYPE_SOLAR_CHARGER
);
Serial.println("Configured " + String(victron.getDeviceCount()) + " BLE devices");
Serial.println("Packet size: " + String(sizeof(SolarChargerPacket)) + " bytes\n");
}
void loop() {
victron.loop();
// Send cached packets every 30 seconds
unsigned long now = millis();
if (now - lastSendTime >= SEND_INTERVAL_MS) {
lastSendTime = now;
int sent = 0;
for (int i = 0; i < cachedCount; i++) {
if (!cachedValid[i]) continue;
esp_err_t result = esp_now_send(BROADCAST_ADDR,
reinterpret_cast<const uint8_t*>(&cachedPackets[i]),
sizeof(SolarChargerPacket));
if (result == ESP_OK) {
sendCount++;
sent++;
Serial.printf("[ESPNow] Sent %s: %.2fV %.1fA PV:%.1fV %.0fW State:%d\n",
cachedPackets[i].deviceName,
cachedPackets[i].batteryVoltage,
cachedPackets[i].batteryCurrent,
cachedPackets[i].panelVoltage,
cachedPackets[i].panelPower,
cachedPackets[i].chargeState);
} else {
sendFailCount++;
Serial.printf("[ESPNow] FAIL sending %s (err=%d)\n",
cachedPackets[i].deviceName, result);
}
}
Serial.printf("[Stats] BLE pkts:%lu ESPNow sent:%lu fail:%lu devices:%d\n",
blePacketCount, sendCount, sendFailCount, cachedCount);
}
delay(100);
}

View File

@@ -31,6 +31,11 @@
"name": "Logger", "name": "Logger",
"base": "examples/Logger", "base": "examples/Logger",
"files": ["src/main.cpp"] "files": ["src/main.cpp"]
},
{
"name": "Repeater",
"base": "examples/Repeater",
"files": ["src/main.cpp"]
} }
], ],
"export": { "export": {

View File

@@ -10,8 +10,8 @@
// Constructor // Constructor
VictronBLE::VictronBLE() VictronBLE::VictronBLE()
: pBLEScan(nullptr), callback(nullptr), debugEnabled(false), : pBLEScan(nullptr), scanCallback(nullptr), callback(nullptr),
scanDuration(5), initialized(false) { debugEnabled(false), scanDuration(5), initialized(false) {
} }
// Destructor // Destructor
@@ -24,35 +24,38 @@ VictronBLE::~VictronBLE() {
if (pBLEScan) { if (pBLEScan) {
pBLEScan->stop(); pBLEScan->stop();
} }
delete scanCallback;
} }
// Initialize BLE // Initialize BLE
bool VictronBLE::begin(uint32_t scanDuration) { bool VictronBLE::begin(uint32_t scanDuration) {
if (initialized) { if (initialized) {
debugPrint("VictronBLE already initialized"); if (debugEnabled) debugPrint("VictronBLE already initialized");
return true; return true;
} }
this->scanDuration = scanDuration; this->scanDuration = scanDuration;
debugPrint("Initializing VictronBLE..."); if (debugEnabled) debugPrint("Initializing VictronBLE...");
BLEDevice::init("VictronBLE"); BLEDevice::init("VictronBLE");
pBLEScan = BLEDevice::getScan(); pBLEScan = BLEDevice::getScan();
if (!pBLEScan) { if (!pBLEScan) {
lastError = "Failed to create BLE scanner"; lastError = "Failed to create BLE scanner";
debugPrint(lastError); if (debugEnabled) debugPrint(lastError);
return false; return false;
} }
pBLEScan->setAdvertisedDeviceCallbacks(new VictronBLEAdvertisedDeviceCallbacks(this), true); scanCallback = new VictronBLEAdvertisedDeviceCallbacks(this);
pBLEScan->setAdvertisedDeviceCallbacks(scanCallback, true);
pBLEScan->setActiveScan(false); // Passive scan - lower power pBLEScan->setActiveScan(false); // Passive scan - lower power
pBLEScan->setInterval(100); pBLEScan->setInterval(100);
pBLEScan->setWindow(99); pBLEScan->setWindow(99);
initialized = true; initialized = true;
debugPrint("VictronBLE initialized successfully"); if (debugEnabled) debugPrint("VictronBLE initialized successfully");
return true; return true;
} }
@@ -61,13 +64,13 @@ bool VictronBLE::begin(uint32_t scanDuration) {
bool VictronBLE::addDevice(const VictronDeviceConfig& config) { bool VictronBLE::addDevice(const VictronDeviceConfig& config) {
if (config.macAddress.length() == 0) { if (config.macAddress.length() == 0) {
lastError = "MAC address cannot be empty"; lastError = "MAC address cannot be empty";
debugPrint(lastError); if (debugEnabled) debugPrint(lastError);
return false; return false;
} }
if (config.encryptionKey.length() != 32) { if (config.encryptionKey.length() != 32) {
lastError = "Encryption key must be 32 hex characters"; lastError = "Encryption key must be 32 hex characters";
debugPrint(lastError); if (debugEnabled) debugPrint(lastError);
return false; return false;
} }
@@ -75,7 +78,7 @@ bool VictronBLE::addDevice(const VictronDeviceConfig& config) {
// Check if device already exists // Check if device already exists
if (devices.find(normalizedMAC) != devices.end()) { if (devices.find(normalizedMAC) != devices.end()) {
debugPrint("Device " + normalizedMAC + " already exists, updating config"); if (debugEnabled) debugPrint("Device " + normalizedMAC + " already exists, updating config");
delete devices[normalizedMAC]; delete devices[normalizedMAC];
} }
@@ -86,7 +89,7 @@ bool VictronBLE::addDevice(const VictronDeviceConfig& config) {
// Convert encryption key from hex string to bytes // Convert encryption key from hex string to bytes
if (!hexStringToBytes(config.encryptionKey, info->encryptionKeyBytes, 16)) { if (!hexStringToBytes(config.encryptionKey, info->encryptionKeyBytes, 16)) {
lastError = "Invalid encryption key format"; lastError = "Invalid encryption key format";
debugPrint(lastError); if (debugEnabled) debugPrint(lastError);
delete info; delete info;
return false; return false;
} }
@@ -100,8 +103,8 @@ bool VictronBLE::addDevice(const VictronDeviceConfig& config) {
devices[normalizedMAC] = info; devices[normalizedMAC] = info;
debugPrint("Added device: " + config.name + " (MAC: " + normalizedMAC + ")");
if (debugEnabled) { if (debugEnabled) {
debugPrint("Added device: " + config.name + " (MAC: " + normalizedMAC + ")");
debugPrint(" Original MAC input: " + config.macAddress); debugPrint(" Original MAC input: " + config.macAddress);
debugPrint(" Stored normalized: " + normalizedMAC); debugPrint(" Stored normalized: " + normalizedMAC);
} }
@@ -109,21 +112,21 @@ bool VictronBLE::addDevice(const VictronDeviceConfig& config) {
return true; return true;
} }
bool VictronBLE::addDevice(String name, String macAddress, String encryptionKey, bool VictronBLE::addDevice(const String& name, const String& macAddress, const String& encryptionKey,
VictronDeviceType expectedType) { VictronDeviceType expectedType) {
VictronDeviceConfig config(name, macAddress, encryptionKey, expectedType); VictronDeviceConfig config(name, macAddress, encryptionKey, expectedType);
return addDevice(config); return addDevice(config);
} }
// Remove a device // Remove a device
void VictronBLE::removeDevice(String macAddress) { void VictronBLE::removeDevice(const String& macAddress) {
String normalizedMAC = normalizeMAC(macAddress); String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC); auto it = devices.find(normalizedMAC);
if (it != devices.end()) { if (it != devices.end()) {
delete it->second; delete it->second;
devices.erase(it); devices.erase(it);
debugPrint("Removed device: " + normalizedMAC); if (debugEnabled) debugPrint("Removed device: " + normalizedMAC);
} }
} }
@@ -146,7 +149,7 @@ void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertise
} }
// Process advertised device // Process advertised device
void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) { void VictronBLE::processDevice(BLEAdvertisedDevice& advertisedDevice) {
// Get MAC address from the advertised device // Get MAC address from the advertised device
String mac = macAddressToString(advertisedDevice.getAddress()); String mac = macAddressToString(advertisedDevice.getAddress());
String normalizedMAC = normalizeMAC(mac); String normalizedMAC = normalizeMAC(mac);
@@ -155,28 +158,25 @@ void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) {
debugPrint("Raw MAC: " + mac + " -> Normalized: " + normalizedMAC); debugPrint("Raw MAC: " + mac + " -> Normalized: " + normalizedMAC);
} }
// TODO: Consider skipping with no manufacturer data? // Parse manufacturer data into local struct
memset(&manufacturerData, 0, sizeof(manufacturerData)); victronManufacturerData mfgData;
memset(&mfgData, 0, sizeof(mfgData));
if (advertisedDevice.haveManufacturerData()) { if (advertisedDevice.haveManufacturerData()) {
std::string mfgData = advertisedDevice.getManufacturerData(); std::string rawMfgData = advertisedDevice.getManufacturerData();
// XXX Storing it this way is not thread safe - is that issue on this ESP32? if (debugEnabled) debugPrint("Getting manufacturer data: Size=" + String(rawMfgData.length()));
debugPrint("Getting manufacturer data: Size=" + String(mfgData.length())); rawMfgData.copy(reinterpret_cast<char*>(&mfgData),
mfgData.copy((char*)&manufacturerData, (mfgData.length() > sizeof(manufacturerData) ? sizeof(manufacturerData) : mfgData.length())); (rawMfgData.length() > sizeof(mfgData) ? sizeof(mfgData) : rawMfgData.length()));
} }
// Pointer? XXX
// Debug: Log all discovered BLE devices // Debug: Log all discovered BLE devices
if (debugEnabled) { if (debugEnabled) {
String debugMsg = ""; String debugMsg = "BLE Device: " + mac;
debugMsg += "BLE Device: " + mac;
debugMsg += ", RSSI: " + String(advertisedDevice.getRSSI()) + " dBm"; debugMsg += ", RSSI: " + String(advertisedDevice.getRSSI()) + " dBm";
if (advertisedDevice.haveName()) if (advertisedDevice.haveName())
debugMsg += ", Name: " + String(advertisedDevice.getName().c_str()); debugMsg += ", Name: " + String(advertisedDevice.getName().c_str());
debugMsg += ", Mfg ID: 0x" + String(manufacturerData.vendorID, HEX); debugMsg += ", Mfg ID: 0x" + String(mfgData.vendorID, HEX);
if (manufacturerData.vendorID == VICTRON_MANUFACTURER_ID) { if (mfgData.vendorID == VICTRON_MANUFACTURER_ID) {
debugMsg += " (Victron)"; debugMsg += " (Victron)";
} }
@@ -186,25 +186,8 @@ void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) {
// Check if this is one of our configured devices // Check if this is one of our configured devices
auto it = devices.find(normalizedMAC); auto it = devices.find(normalizedMAC);
if (it == devices.end()) { if (it == devices.end()) {
// XXX Check if the device is a Victron device if (debugEnabled && mfgData.vendorID == VICTRON_MANUFACTURER_ID) {
// This needs lots of improvemet and only do in debug debugPrint("Found unmonitored Victron Device: " + normalizedMAC);
if (manufacturerData.vendorID == VICTRON_MANUFACTURER_ID) {
debugPrint("Found unmonitored Victron Device: " + normalizeMAC(mac));
// DeviceInfo* deviceInfo = new DeviceInfo(mac, advertisedDevice.getName());
// devices.insert({normalizedMAC, deviceInfo});
// XXX What type of Victron device is it?
// Check if it's a Victron Energy device
/*
if (advertisedDevice.haveServiceData()) {
std::string serviceData = advertisedDevice.getServiceData();
if (serviceData.length() >= 2) {
uint16_t serviceId = (uint8_t)serviceData[1] << 8 | (uint8_t)serviceData[0];
if (serviceId == VICTRON_ENERGY_SERVICE_ID) {
debugPrint("Found Victron Energy Device: " + mac);
}
}
}
*/
} }
return; // Not a device we're monitoring return; // Not a device we're monitoring
} }
@@ -212,15 +195,15 @@ void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) {
DeviceInfo* deviceInfo = it->second; DeviceInfo* deviceInfo = it->second;
// Check if it's Victron (manufacturer ID 0x02E1) // Check if it's Victron (manufacturer ID 0x02E1)
if (manufacturerData.vendorID != VICTRON_MANUFACTURER_ID) { if (mfgData.vendorID != VICTRON_MANUFACTURER_ID) {
debugPrint("Skipping non VICTRON"); if (debugEnabled) debugPrint("Skipping non VICTRON");
return; return;
} }
debugPrint("Processing data from: " + deviceInfo->config.name); if (debugEnabled) debugPrint("Processing data from: " + deviceInfo->config.name);
// Parse the advertisement // Parse the advertisement
if (parseAdvertisement(normalizedMAC)) { if (parseAdvertisement(deviceInfo, mfgData)) {
// Update RSSI // Update RSSI
if (deviceInfo->data) { if (deviceInfo->data) {
deviceInfo->data->rssi = advertisedDevice.getRSSI(); deviceInfo->data->rssi = advertisedDevice.getRSSI();
@@ -230,55 +213,47 @@ void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) {
} }
// Parse advertisement data // Parse advertisement data
bool VictronBLE::parseAdvertisement(const String& macAddress) { bool VictronBLE::parseAdvertisement(DeviceInfo* deviceInfo, const victronManufacturerData& mfgData) {
// XXX We already searched above - try not to again?
auto it = devices.find(macAddress);
if (it == devices.end()) {
debugPrint("parseAdvertisement: Device not found");
return false;
}
DeviceInfo* deviceInfo = it->second;
if (debugEnabled) { if (debugEnabled) {
debugPrint("Vendor ID: 0x" + String(manufacturerData.vendorID, HEX)); debugPrint("Vendor ID: 0x" + String(mfgData.vendorID, HEX));
debugPrint("Beacon Type: 0x" + String(manufacturerData.beaconType, HEX)); debugPrint("Beacon Type: 0x" + String(mfgData.beaconType, HEX));
debugPrint("Record Type: 0x" + String(manufacturerData.victronRecordType, HEX)); debugPrint("Record Type: 0x" + String(mfgData.victronRecordType, HEX));
debugPrint("Nonce: 0x" + String(manufacturerData.nonceDataCounter, HEX)); debugPrint("Nonce: 0x" + String(mfgData.nonceDataCounter, HEX));
} }
// Build IV (initialization vector) from nonce // Build IV (initialization vector) from nonce
// IV is 16 bytes: nonce (2 bytes little-endian) + zeros (14 bytes) // IV is 16 bytes: nonce (2 bytes little-endian) + zeros (14 bytes)
uint8_t iv[16] = {0}; uint8_t iv[16] = {0};
iv[0] = manufacturerData.nonceDataCounter & 0xFF; // Low byte iv[0] = mfgData.nonceDataCounter & 0xFF; // Low byte
iv[1] = (manufacturerData.nonceDataCounter >> 8) & 0xFF; // High byte iv[1] = (mfgData.nonceDataCounter >> 8) & 0xFF; // High byte
// Remaining bytes stay zero // Remaining bytes stay zero
// Decrypt the data // Decrypt the data
uint8_t decrypted[32]; // Max expected size const size_t encryptedLen = sizeof(mfgData.victronEncryptedData);
if (!decryptAdvertisement(manufacturerData.victronEncryptedData, uint8_t decrypted[encryptedLen];
sizeof(manufacturerData.victronEncryptedData), if (!decryptAdvertisement(mfgData.victronEncryptedData,
encryptedLen,
deviceInfo->encryptionKeyBytes, iv, decrypted)) { deviceInfo->encryptionKeyBytes, iv, decrypted)) {
lastError = "Decryption failed"; lastError = "Decryption failed";
debugPrint(lastError); if (debugEnabled) debugPrint(lastError);
return false; return false;
} }
// Parse based on device type // Parse based on device type
bool parseOk = false; bool parseOk = false;
switch (manufacturerData.victronRecordType) { switch (mfgData.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER: case DEVICE_TYPE_SOLAR_CHARGER:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_SOLAR_CHARGER) { if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_SOLAR_CHARGER) {
parseOk = parseSolarCharger(decrypted, sizeof(decrypted), parseOk = parseSolarCharger(decrypted, encryptedLen,
*(SolarChargerData*)deviceInfo->data); *static_cast<SolarChargerData*>(deviceInfo->data));
} }
break; break;
case DEVICE_TYPE_BATTERY_MONITOR: case DEVICE_TYPE_BATTERY_MONITOR:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_BATTERY_MONITOR) { if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_BATTERY_MONITOR) {
parseOk = parseBatteryMonitor(decrypted, sizeof(decrypted), parseOk = parseBatteryMonitor(decrypted, encryptedLen,
*(BatteryMonitorData*)deviceInfo->data); *static_cast<BatteryMonitorData*>(deviceInfo->data));
} }
break; break;
@@ -287,20 +262,20 @@ bool VictronBLE::parseAdvertisement(const String& macAddress) {
case DEVICE_TYPE_MULTI_RS: case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS: case DEVICE_TYPE_VE_BUS:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_INVERTER) { if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_INVERTER) {
parseOk = parseInverter(decrypted, sizeof(decrypted), parseOk = parseInverter(decrypted, encryptedLen,
*(InverterData*)deviceInfo->data); *static_cast<InverterData*>(deviceInfo->data));
} }
break; break;
case DEVICE_TYPE_DCDC_CONVERTER: case DEVICE_TYPE_DCDC_CONVERTER:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_DCDC_CONVERTER) { if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_DCDC_CONVERTER) {
parseOk = parseDCDCConverter(decrypted, sizeof(decrypted), parseOk = parseDCDCConverter(decrypted, encryptedLen,
*(DCDCConverterData*)deviceInfo->data); *static_cast<DCDCConverterData*>(deviceInfo->data));
} }
break; break;
default: default:
debugPrint("Unknown device type: 0x" + String(manufacturerData.victronRecordType, HEX)); if (debugEnabled) debugPrint("Unknown device type: 0x" + String(mfgData.victronRecordType, HEX));
return false; return false;
} }
@@ -309,21 +284,21 @@ bool VictronBLE::parseAdvertisement(const String& macAddress) {
// Call appropriate callback // Call appropriate callback
if (callback) { if (callback) {
switch (manufacturerData.victronRecordType) { switch (mfgData.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER: case DEVICE_TYPE_SOLAR_CHARGER:
callback->onSolarChargerData(*(SolarChargerData*)deviceInfo->data); callback->onSolarChargerData(*static_cast<SolarChargerData*>(deviceInfo->data));
break; break;
case DEVICE_TYPE_BATTERY_MONITOR: case DEVICE_TYPE_BATTERY_MONITOR:
callback->onBatteryMonitorData(*(BatteryMonitorData*)deviceInfo->data); callback->onBatteryMonitorData(*static_cast<BatteryMonitorData*>(deviceInfo->data));
break; break;
case DEVICE_TYPE_INVERTER: case DEVICE_TYPE_INVERTER:
case DEVICE_TYPE_INVERTER_RS: case DEVICE_TYPE_INVERTER_RS:
case DEVICE_TYPE_MULTI_RS: case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS: case DEVICE_TYPE_VE_BUS:
callback->onInverterData(*(InverterData*)deviceInfo->data); callback->onInverterData(*static_cast<InverterData*>(deviceInfo->data));
break; break;
case DEVICE_TYPE_DCDC_CONVERTER: case DEVICE_TYPE_DCDC_CONVERTER:
callback->onDCDCConverterData(*(DCDCConverterData*)deviceInfo->data); callback->onDCDCConverterData(*static_cast<DCDCConverterData*>(deviceInfo->data));
break; break;
} }
} }
@@ -365,15 +340,14 @@ bool VictronBLE::decryptAdvertisement(const uint8_t* encrypted, size_t encLen,
// Parse Solar Charger data // Parse Solar Charger data
bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result) { bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result) {
if (len < sizeof(victronSolarChargerPayload)) { if (len < sizeof(victronSolarChargerPayload)) {
debugPrint("Solar charger data too short: " + String(len) + " bytes"); if (debugEnabled) debugPrint("Solar charger data too short: " + String(len) + " bytes");
return false; return false;
} }
// Cast decrypted data to struct for easy access const auto* payload = reinterpret_cast<const victronSolarChargerPayload*>(data);
const victronSolarChargerPayload* payload = (const victronSolarChargerPayload*)data;
// Parse charge state // Parse charge state
result.chargeState = (SolarChargerState)payload->deviceState; result.chargeState = static_cast<SolarChargerState>(payload->deviceState);
// Parse battery voltage (10 mV units -> volts) // Parse battery voltage (10 mV units -> volts)
result.batteryVoltage = payload->batteryVoltage * 0.01f; result.batteryVoltage = payload->batteryVoltage * 0.01f;
@@ -401,9 +375,11 @@ bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, SolarCharger
result.panelVoltage = 0; result.panelVoltage = 0;
} }
if (debugEnabled) {
debugPrint("Solar Charger: " + String(result.batteryVoltage, 2) + "V, " + debugPrint("Solar Charger: " + String(result.batteryVoltage, 2) + "V, " +
String(result.batteryCurrent, 2) + "A, " + String(result.batteryCurrent, 2) + "A, " +
String(result.panelPower) + "W, State: " + String(result.chargeState)); String(result.panelPower) + "W, State: " + String(result.chargeState));
}
return true; return true;
} }
@@ -411,12 +387,11 @@ bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, SolarCharger
// Parse Battery Monitor data // Parse Battery Monitor data
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMonitorData& result) { bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMonitorData& result) {
if (len < sizeof(victronBatteryMonitorPayload)) { if (len < sizeof(victronBatteryMonitorPayload)) {
debugPrint("Battery monitor data too short: " + String(len) + " bytes"); if (debugEnabled) debugPrint("Battery monitor data too short: " + String(len) + " bytes");
return false; return false;
} }
// Cast decrypted data to struct for easy access const auto* payload = reinterpret_cast<const victronBatteryMonitorPayload*>(data);
const victronBatteryMonitorPayload* payload = (const victronBatteryMonitorPayload*)data;
// Parse remaining time (1 minute units) // Parse remaining time (1 minute units)
result.remainingMinutes = payload->remainingMins; result.remainingMinutes = payload->remainingMins;
@@ -441,7 +416,6 @@ bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMon
} }
// Parse battery current (22-bit signed, 1 mA units) // Parse battery current (22-bit signed, 1 mA units)
// Bits 0-7: currentLow, Bits 8-15: currentMid, Bits 16-21: low 6 bits of currentHigh_consumedLow
int32_t current = payload->currentLow | int32_t current = payload->currentLow |
(payload->currentMid << 8) | (payload->currentMid << 8) |
((payload->currentHigh_consumedLow & 0x3F) << 16); ((payload->currentHigh_consumedLow & 0x3F) << 16);
@@ -452,7 +426,6 @@ bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMon
result.current = current * 0.001f; // Convert mA to A result.current = current * 0.001f; // Convert mA to A
// Parse consumed Ah (18-bit signed, 10 mAh units) // Parse consumed Ah (18-bit signed, 10 mAh units)
// Bits 0-1: high 2 bits of currentHigh_consumedLow, Bits 2-9: consumedMid, Bits 10-17: consumedHigh
int32_t consumedAh = ((payload->currentHigh_consumedLow & 0xC0) >> 6) | int32_t consumedAh = ((payload->currentHigh_consumedLow & 0xC0) >> 6) |
(payload->consumedMid << 2) | (payload->consumedMid << 2) |
(payload->consumedHigh << 10); (payload->consumedHigh << 10);
@@ -465,8 +438,10 @@ bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMon
// Parse SOC (10-bit value, 10 = 1.0%) // Parse SOC (10-bit value, 10 = 1.0%)
result.soc = (payload->soc & 0x3FF) * 0.1f; result.soc = (payload->soc & 0x3FF) * 0.1f;
if (debugEnabled) {
debugPrint("Battery Monitor: " + String(result.voltage, 2) + "V, " + debugPrint("Battery Monitor: " + String(result.voltage, 2) + "V, " +
String(result.current, 2) + "A, SOC: " + String(result.soc, 1) + "%"); String(result.current, 2) + "A, SOC: " + String(result.soc, 1) + "%");
}
return true; return true;
} }
@@ -474,12 +449,11 @@ bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMon
// Parse Inverter data // Parse Inverter data
bool VictronBLE::parseInverter(const uint8_t* data, size_t len, InverterData& result) { bool VictronBLE::parseInverter(const uint8_t* data, size_t len, InverterData& result) {
if (len < sizeof(victronInverterPayload)) { if (len < sizeof(victronInverterPayload)) {
debugPrint("Inverter data too short: " + String(len) + " bytes"); if (debugEnabled) debugPrint("Inverter data too short: " + String(len) + " bytes");
return false; return false;
} }
// Cast decrypted data to struct for easy access const auto* payload = reinterpret_cast<const victronInverterPayload*>(data);
const victronInverterPayload* payload = (const victronInverterPayload*)data;
// Parse device state // Parse device state
result.state = payload->deviceState; result.state = payload->deviceState;
@@ -506,8 +480,10 @@ bool VictronBLE::parseInverter(const uint8_t* data, size_t len, InverterData& re
result.alarmHighTemperature = (payload->alarms & 0x04) != 0; result.alarmHighTemperature = (payload->alarms & 0x04) != 0;
result.alarmOverload = (payload->alarms & 0x08) != 0; result.alarmOverload = (payload->alarms & 0x08) != 0;
if (debugEnabled) {
debugPrint("Inverter: " + String(result.batteryVoltage, 2) + "V, " + debugPrint("Inverter: " + String(result.batteryVoltage, 2) + "V, " +
String(result.acPower) + "W, State: " + String(result.state)); String(result.acPower) + "W, State: " + String(result.state));
}
return true; return true;
} }
@@ -515,12 +491,11 @@ bool VictronBLE::parseInverter(const uint8_t* data, size_t len, InverterData& re
// Parse DC-DC Converter data // Parse DC-DC Converter data
bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, DCDCConverterData& result) { bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, DCDCConverterData& result) {
if (len < sizeof(victronDCDCConverterPayload)) { if (len < sizeof(victronDCDCConverterPayload)) {
debugPrint("DC-DC converter data too short: " + String(len) + " bytes"); if (debugEnabled) debugPrint("DC-DC converter data too short: " + String(len) + " bytes");
return false; return false;
} }
// Cast decrypted data to struct for easy access const auto* payload = reinterpret_cast<const victronDCDCConverterPayload*>(data);
const victronDCDCConverterPayload* payload = (const victronDCDCConverterPayload*)data;
// Parse charge state // Parse charge state
result.chargeState = payload->chargeState; result.chargeState = payload->chargeState;
@@ -537,56 +512,58 @@ bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, DCDCConvert
// Parse output current (10 mA units -> amps) // Parse output current (10 mA units -> amps)
result.outputCurrent = payload->outputCurrent * 0.01f; result.outputCurrent = payload->outputCurrent * 0.01f;
if (debugEnabled) {
debugPrint("DC-DC Converter: In=" + String(result.inputVoltage, 2) + "V, Out=" + debugPrint("DC-DC Converter: In=" + String(result.inputVoltage, 2) + "V, Out=" +
String(result.outputVoltage, 2) + "V, " + String(result.outputCurrent, 2) + "A"); String(result.outputVoltage, 2) + "V, " + String(result.outputCurrent, 2) + "A");
}
return true; return true;
} }
// Get data methods // Get data methods
bool VictronBLE::getSolarChargerData(String macAddress, SolarChargerData& data) { bool VictronBLE::getSolarChargerData(const String& macAddress, SolarChargerData& data) {
String normalizedMAC = normalizeMAC(macAddress); String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC); auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data && if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_SOLAR_CHARGER) { it->second->data->deviceType == DEVICE_TYPE_SOLAR_CHARGER) {
data = *(SolarChargerData*)it->second->data; data = *static_cast<SolarChargerData*>(it->second->data);
return data.dataValid; return data.dataValid;
} }
return false; return false;
} }
bool VictronBLE::getBatteryMonitorData(String macAddress, BatteryMonitorData& data) { bool VictronBLE::getBatteryMonitorData(const String& macAddress, BatteryMonitorData& data) {
String normalizedMAC = normalizeMAC(macAddress); String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC); auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data && if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_BATTERY_MONITOR) { it->second->data->deviceType == DEVICE_TYPE_BATTERY_MONITOR) {
data = *(BatteryMonitorData*)it->second->data; data = *static_cast<BatteryMonitorData*>(it->second->data);
return data.dataValid; return data.dataValid;
} }
return false; return false;
} }
bool VictronBLE::getInverterData(String macAddress, InverterData& data) { bool VictronBLE::getInverterData(const String& macAddress, InverterData& data) {
String normalizedMAC = normalizeMAC(macAddress); String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC); auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data && if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_INVERTER) { it->second->data->deviceType == DEVICE_TYPE_INVERTER) {
data = *(InverterData*)it->second->data; data = *static_cast<InverterData*>(it->second->data);
return data.dataValid; return data.dataValid;
} }
return false; return false;
} }
bool VictronBLE::getDCDCConverterData(String macAddress, DCDCConverterData& data) { bool VictronBLE::getDCDCConverterData(const String& macAddress, DCDCConverterData& data) {
String normalizedMAC = normalizeMAC(macAddress); String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC); auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data && if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_DCDC_CONVERTER) { it->second->data->deviceType == DEVICE_TYPE_DCDC_CONVERTER) {
data = *(DCDCConverterData*)it->second->data; data = *static_cast<DCDCConverterData*>(it->second->data);
return data.dataValid; return data.dataValid;
} }
return false; return false;
@@ -644,21 +621,19 @@ bool VictronBLE::hexStringToBytes(const String& hex, uint8_t* bytes, size_t len)
// Helper: MAC address to string // Helper: MAC address to string
String VictronBLE::macAddressToString(BLEAddress address) { String VictronBLE::macAddressToString(BLEAddress address) {
// Use the BLEAddress toString() method which provides consistent formatting
return String(address.toString().c_str()); return String(address.toString().c_str());
} }
// Helper: Normalize MAC address format // Helper: Normalize MAC address format
String VictronBLE::normalizeMAC(String mac) { String VictronBLE::normalizeMAC(const String& mac) {
String normalized = mac; String normalized = mac;
normalized.toLowerCase(); normalized.toLowerCase();
// XXX - is this right, was - to : but not consistent location of pairs or not
normalized.replace("-", ""); normalized.replace("-", "");
normalized.replace(":", ""); normalized.replace(":", "");
return normalized; return normalized;
} }
// Debug helpers // Debug helper
void VictronBLE::debugPrint(const String& message) { void VictronBLE::debugPrint(const String& message) {
if (debugEnabled) if (debugEnabled)
Serial.println("[VictronBLE] " + message); Serial.println("[VictronBLE] " + message);

View File

@@ -20,7 +20,7 @@
#include "mbedtls/aes.h" #include "mbedtls/aes.h"
// Victron manufacturer ID // Victron manufacturer ID
#define VICTRON_MANUFACTURER_ID 0x02E1 static constexpr uint16_t VICTRON_MANUFACTURER_ID = 0x02E1;
// Device type IDs from Victron protocol // Device type IDs from Victron protocol
enum VictronDeviceType { enum VictronDeviceType {
@@ -57,7 +57,7 @@ enum SolarChargerState {
// Must use __attribute__((packed)) to prevent compiler padding // Must use __attribute__((packed)) to prevent compiler padding
// Manufacturer data structure (outer envelope) // Manufacturer data structure (outer envelope)
typedef struct { struct victronManufacturerData {
uint16_t vendorID; // vendor ID uint16_t vendorID; // vendor ID
uint8_t beaconType; // Should be 0x10 (Product Advertisement) for the packets we want uint8_t beaconType; // Should be 0x10 (Product Advertisement) for the packets we want
uint8_t unknownData1[3]; // Unknown data uint8_t unknownData1[3]; // Unknown data
@@ -66,11 +66,11 @@ typedef struct {
uint8_t encryptKeyMatch; // Should match pre-shared encryption key byte 0 uint8_t encryptKeyMatch; // Should match pre-shared encryption key byte 0
uint8_t victronEncryptedData[21]; // (31 bytes max per BLE spec - size of previous elements) uint8_t victronEncryptedData[21]; // (31 bytes max per BLE spec - size of previous elements)
uint8_t nullPad; // extra byte because toCharArray() adds a \0 byte. uint8_t nullPad; // extra byte because toCharArray() adds a \0 byte.
} __attribute__((packed)) victronManufacturerData; } __attribute__((packed));
// Decrypted payload structures for each device type // Decrypted payload structures for each device type
// Solar Charger decrypted payload // Solar Charger decrypted payload
typedef struct { struct victronSolarChargerPayload {
uint8_t deviceState; // Charge state (SolarChargerState enum) uint8_t deviceState; // Charge state (SolarChargerState enum)
uint8_t errorCode; // Error code uint8_t errorCode; // Error code
int16_t batteryVoltage; // Battery voltage in 10mV units int16_t batteryVoltage; // Battery voltage in 10mV units
@@ -79,10 +79,10 @@ typedef struct {
uint16_t inputPower; // PV power in 1W units uint16_t inputPower; // PV power in 1W units
uint16_t loadCurrent; // Load current in 10mA units (0xFFFF = no load) uint16_t loadCurrent; // Load current in 10mA units (0xFFFF = no load)
uint8_t reserved[2]; // Reserved bytes uint8_t reserved[2]; // Reserved bytes
} __attribute__((packed)) victronSolarChargerPayload; } __attribute__((packed));
// Battery Monitor decrypted payload // Battery Monitor decrypted payload
typedef struct { struct victronBatteryMonitorPayload {
uint16_t remainingMins; // Time remaining in minutes uint16_t remainingMins; // Time remaining in minutes
uint16_t batteryVoltage; // Battery voltage in 10mV units uint16_t batteryVoltage; // Battery voltage in 10mV units
uint8_t alarms; // Alarm bits uint8_t alarms; // Alarm bits
@@ -94,10 +94,10 @@ typedef struct {
uint8_t consumedHigh; // Consumed Ah bits 10-17 uint8_t consumedHigh; // Consumed Ah bits 10-17
uint16_t soc; // State of charge in 0.1% units (10-bit value) uint16_t soc; // State of charge in 0.1% units (10-bit value)
uint8_t reserved[2]; // Reserved bytes uint8_t reserved[2]; // Reserved bytes
} __attribute__((packed)) victronBatteryMonitorPayload; } __attribute__((packed));
// Inverter decrypted payload // Inverter decrypted payload
typedef struct { struct victronInverterPayload {
uint8_t deviceState; // Device state uint8_t deviceState; // Device state
uint8_t errorCode; // Error code uint8_t errorCode; // Error code
uint16_t batteryVoltage; // Battery voltage in 10mV units uint16_t batteryVoltage; // Battery voltage in 10mV units
@@ -107,17 +107,17 @@ typedef struct {
uint8_t acPowerHigh; // AC Power bits 16-23 (signed 24-bit) uint8_t acPowerHigh; // AC Power bits 16-23 (signed 24-bit)
uint8_t alarms; // Alarm bits uint8_t alarms; // Alarm bits
uint8_t reserved[4]; // Reserved bytes uint8_t reserved[4]; // Reserved bytes
} __attribute__((packed)) victronInverterPayload; } __attribute__((packed));
// DC-DC Converter decrypted payload // DC-DC Converter decrypted payload
typedef struct { struct victronDCDCConverterPayload {
uint8_t chargeState; // Charge state uint8_t chargeState; // Charge state
uint8_t errorCode; // Error code uint8_t errorCode; // Error code
uint16_t inputVoltage; // Input voltage in 10mV units uint16_t inputVoltage; // Input voltage in 10mV units
uint16_t outputVoltage; // Output voltage in 10mV units uint16_t outputVoltage; // Output voltage in 10mV units
uint16_t outputCurrent; // Output current in 10mA units uint16_t outputCurrent; // Output current in 10mA units
uint8_t reserved[6]; // Reserved bytes uint8_t reserved[6]; // Reserved bytes
} __attribute__((packed)) victronDCDCConverterPayload; } __attribute__((packed));
// Base structure for all device data // Base structure for all device data
struct VictronDeviceData { struct VictronDeviceData {
@@ -205,8 +205,9 @@ struct DCDCConverterData : public VictronDeviceData {
} }
}; };
// Forward declaration // Forward declarations
class VictronBLE; class VictronBLE;
class VictronBLEAdvertisedDeviceCallbacks;
// Callback interface for device data updates // Callback interface for device data updates
class VictronDeviceCallback { class VictronDeviceCallback {
@@ -226,7 +227,7 @@ struct VictronDeviceConfig {
VictronDeviceType expectedType; VictronDeviceType expectedType;
VictronDeviceConfig() : expectedType(DEVICE_TYPE_UNKNOWN) {} VictronDeviceConfig() : expectedType(DEVICE_TYPE_UNKNOWN) {}
VictronDeviceConfig(String n, String mac, String key, VictronDeviceType type = DEVICE_TYPE_UNKNOWN) VictronDeviceConfig(const String& n, const String& mac, const String& key, VictronDeviceType type = DEVICE_TYPE_UNKNOWN)
: name(n), macAddress(mac), encryptionKey(key), expectedType(type) {} : name(n), macAddress(mac), encryptionKey(key), expectedType(type) {}
}; };
@@ -241,11 +242,11 @@ public:
// Add a device to monitor // Add a device to monitor
bool addDevice(const VictronDeviceConfig& config); bool addDevice(const VictronDeviceConfig& config);
bool addDevice(String name, String macAddress, String encryptionKey, bool addDevice(const String& name, const String& macAddress, const String& encryptionKey,
VictronDeviceType expectedType = DEVICE_TYPE_UNKNOWN); VictronDeviceType expectedType = DEVICE_TYPE_UNKNOWN);
// Remove a device // Remove a device
void removeDevice(String macAddress); void removeDevice(const String& macAddress);
// Get device count // Get device count
size_t getDeviceCount() const { return devices.size(); } size_t getDeviceCount() const { return devices.size(); }
@@ -257,10 +258,10 @@ public:
void loop(); void loop();
// Get latest data for a device // Get latest data for a device
bool getSolarChargerData(String macAddress, SolarChargerData& data); bool getSolarChargerData(const String& macAddress, SolarChargerData& data);
bool getBatteryMonitorData(String macAddress, BatteryMonitorData& data); bool getBatteryMonitorData(const String& macAddress, BatteryMonitorData& data);
bool getInverterData(String macAddress, InverterData& data); bool getInverterData(const String& macAddress, InverterData& data);
bool getDCDCConverterData(String macAddress, DCDCConverterData& data); bool getDCDCConverterData(const String& macAddress, DCDCConverterData& data);
// Get all devices of a specific type // Get all devices of a specific type
std::vector<String> getDevicesByType(VictronDeviceType type); std::vector<String> getDevicesByType(VictronDeviceType type);
@@ -285,26 +286,26 @@ private:
~DeviceInfo() { ~DeviceInfo() {
if (data) delete data; if (data) delete data;
} }
DeviceInfo(const DeviceInfo&) = delete;
DeviceInfo& operator=(const DeviceInfo&) = delete;
}; };
std::map<String, DeviceInfo*> devices; std::map<String, DeviceInfo*> devices;
BLEScan* pBLEScan; BLEScan* pBLEScan;
VictronBLEAdvertisedDeviceCallbacks* scanCallback;
VictronDeviceCallback* callback; VictronDeviceCallback* callback;
bool debugEnabled; bool debugEnabled;
String lastError; String lastError;
uint32_t scanDuration; uint32_t scanDuration;
bool initialized; bool initialized;
// XXX Experiment with actual victron data
victronManufacturerData manufacturerData;
// Internal methods // Internal methods
bool hexStringToBytes(const String& hex, uint8_t* bytes, size_t len); bool hexStringToBytes(const String& hex, uint8_t* bytes, size_t len);
bool decryptAdvertisement(const uint8_t* encrypted, size_t encLen, bool decryptAdvertisement(const uint8_t* encrypted, size_t encLen,
const uint8_t* key, const uint8_t* iv, const uint8_t* key, const uint8_t* iv,
uint8_t* decrypted); uint8_t* decrypted);
bool parseAdvertisement(const String& macAddress); bool parseAdvertisement(DeviceInfo* deviceInfo, const victronManufacturerData& mfgData);
void processDevice(BLEAdvertisedDevice advertisedDevice); void processDevice(BLEAdvertisedDevice& advertisedDevice);
VictronDeviceData* createDeviceData(VictronDeviceType type); VictronDeviceData* createDeviceData(VictronDeviceType type);
bool parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result); bool parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result);
@@ -315,7 +316,7 @@ private:
void debugPrint(const String& message); void debugPrint(const String& message);
String macAddressToString(BLEAddress address); String macAddressToString(BLEAddress address);
String normalizeMAC(String mac); String normalizeMAC(const String& mac);
}; };
// BLE scan callback class // BLE scan callback class