Compare commits

..

8 Commits

14 changed files with 1958 additions and 1038 deletions

View File

@@ -102,3 +102,99 @@ 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
### Session: 2026-02-28 12:26
**Modified files:**
- .claude/CLAUDE.md
- examples/Receiver/src/main.cpp
- examples/Repeater/src/main.cpp

500
REVIEW.md Normal file
View File

@@ -0,0 +1,500 @@
# VictronBLE Code Review
## Part 1: Bug Fixes, Efficiency & Simplification
### Bugs
**1. Missing virtual destructor on `VictronDeviceData` (CRITICAL)**
`VictronBLE.h:123` - The base struct has no virtual destructor, but derived objects (`SolarChargerData`, etc.) are deleted through base pointers at `VictronBLE.cpp:287` (`delete data`). This is **undefined behavior** in C++. The derived destructors (which must clean up the `String` members they inherit) may never run.
Fix: Add `virtual ~VictronDeviceData() {}` — or better, eliminate the inheritance (see simplification below).
**2. `nullPad` field in `victronManufacturerData` is wrong**
`VictronBLE.h:68` - Comment says "extra byte because toCharArray() adds a \0 byte" but the code uses `std::string::copy()` which does NOT null-terminate. This makes the struct 1 byte too large, which is harmless but misleading. If the BLE stack ever returns exactly `sizeof(victronManufacturerData)` bytes, the copy would read past the source buffer.
Fix: Remove the `nullPad` field.
**3. `panelVoltage` calculation is unreliable**
`VictronBLE.cpp:371-376` - PV voltage is computed as `panelPower / batteryCurrent`. On an MPPT charger, battery current and PV current are different (that's the whole point of MPPT). This gives a meaningless number. The BLE protocol doesn't transmit PV voltage for solar chargers.
Fix: Remove `panelVoltage` from `SolarChargerData`. It's not in the protocol and the calculation is wrong.
**4. Aux data voltage/temperature heuristic is fragile**
`VictronBLE.cpp:410` - `if (payload->auxData < 3000)` is used to distinguish voltage from temperature. The Victron protocol actually uses a bit flag (bit 15 of the aux field, or the record subtype) to indicate which type of aux input is connected. The magic number 3000 will misclassify edge cases.
Fix: Use the proper protocol flag if available, or document this as a known limitation.
### Efficiency Improvements
**5. `hexStringToBytes` allocates 16 String objects**
`VictronBLE.cpp:610-611` - For each byte, `hex.substring()` creates a new heap-allocated `String`. On ESP32, this fragments the heap unnecessarily.
Fix: Direct char-to-nibble conversion:
```cpp
bool hexStringToBytes(const char* hex, uint8_t* bytes, size_t len) {
for (size_t i = 0; i < len; i++) {
uint8_t hi = hex[i*2], lo = hex[i*2+1];
hi = (hi >= 'a') ? hi - 'a' + 10 : (hi >= 'A') ? hi - 'A' + 10 : hi - '0';
lo = (lo >= 'a') ? lo - 'a' + 10 : (lo >= 'A') ? lo - 'A' + 10 : lo - '0';
if (hi > 15 || lo > 15) return false;
bytes[i] = (hi << 4) | lo;
}
return true;
}
```
**6. MAC normalization on every lookup is wasteful**
`normalizeMAC()` is called in `processDevice()` for every BLE advertisement received (could be hundreds per scan), plus in every `getSolarChargerData()` / `getBatteryMonitorData()` call. Each call creates a new String and does 3 replace operations.
Fix: Normalize once at `addDevice()` time and store as a fixed `char[13]` (12 hex chars + null). Use `memcmp` or `strcmp` for comparison.
**7. `std::map<String, DeviceInfo*>` is heavy**
A typical setup monitors 1-4 devices. `std::map` has significant overhead (red-black tree, heap allocations for nodes). A simple fixed-size array with linear search would be faster and use less memory.
Fix: Replace with `DeviceInfo devices[MAX_DEVICES]` (where MAX_DEVICES = 8 or similar) and a `uint8_t deviceCount`.
**8. `loop()` blocks for entire scan duration**
`VictronBLE.cpp:140` - `pBLEScan->start(scanDuration, false)` is blocking. With the default 5-second scan duration, `loop()` blocks for 5 seconds every call.
Fix: Use `pBLEScan->start(0)` for continuous non-blocking scan, or use the async scan API. Data arrives via callbacks anyway.
### Simplification — Things to Remove
**9. Remove `VictronDeviceConfig` struct**
Only used as a parameter to `addDevice`. The convenience overload `addDevice(name, mac, key, type)` is what all examples use. The config struct adds an unnecessary layer.
**10. Remove `lastError` / `getLastError()`**
Uses heap-allocated String. If `debugEnabled` is true, errors already go to Serial. If debug is off, nobody calls `getLastError()` — none of the examples use it. Remove entirely.
**11. Remove `getDevicesByType()`**
No examples use it. Returns `std::vector<String>` which heap-allocates. Users already know their device MACs since they registered them.
**12. Remove `removeDevice()`**
No examples use it. In a typical embedded deployment, devices are configured once at startup and never removed.
**13. Remove the per-type getter methods**
`getSolarChargerData()`, `getBatteryMonitorData()`, etc. are polling-style accessors. All examples use the callback pattern instead. The getters copy the entire data struct (including Strings) which is expensive. If needed, a single `getData(mac, type)` returning a pointer would suffice.
**14. Flatten the inheritance hierarchy**
`VictronDeviceData``SolarChargerData` etc. uses inheritance + dynamic allocation + virtual dispatch (needed once we add virtual destructor). Since each device type is always accessed through its specific type, a tagged union or flat struct per type would be simpler:
```cpp
struct VictronDevice {
char mac[13];
char name[32];
uint8_t deviceType;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
union {
struct { /* solar fields */ } solar;
struct { /* battery fields */ } battery;
struct { /* inverter fields */ } inverter;
struct { /* dcdc fields */ } dcdc;
};
};
```
This eliminates heap allocation, virtual dispatch, and the `createDeviceData` factory.
**15. Replace virtual callback class with function pointer**
`VictronDeviceCallback` with 4 virtual methods → a single function pointer:
```cpp
typedef void (*VictronCallback)(const VictronDevice* device);
```
The callback receives the device and can switch on `deviceType`. Simpler, no vtable overhead, compatible with C.
**16. Remove `String` usage throughout**
Arduino `String` uses heap allocation and causes fragmentation. MAC addresses are always 12 hex chars. Device names can use fixed `char[]`. This is the single biggest simplification and memory improvement.
### Summary of Simplified API
After removing the above, the public API would be approximately:
```cpp
void victron_init(uint32_t scanDuration);
bool victron_add_device(const char* name, const char* mac, const char* hexKey, uint8_t type);
void victron_set_callback(VictronCallback cb);
void victron_loop();
```
~4 functions instead of ~15 methods.
---
## Part 2: Multi-Platform BLE Support
### Current BLE Dependencies
All ESP32-specific BLE code is confined to:
1. **Headers** (`VictronBLE.h`):
- `#include <BLEDevice.h>`, `<BLEAdvertisedDevice.h>`, `<BLEScan.h>`
- `BLEScan*` member
- `VictronBLEAdvertisedDeviceCallbacks` class inheriting `BLEAdvertisedDeviceCallbacks`
- `BLEAddress` type in `macAddressToString()`
2. **Implementation** (`VictronBLE.cpp`):
- `BLEDevice::init()` — line 42
- `BLEDevice::getScan()` — line 43
- `pBLEScan->setAdvertisedDeviceCallbacks()` — line 52
- `pBLEScan->setActiveScan/setInterval/setWindow` — lines 53-55
- `pBLEScan->start()` / `pBLEScan->clearResults()` — lines 140-141
- `BLEAdvertisedDevice` methods in `processDevice()` — lines 152-213
3. **Non-BLE dependencies**:
- `mbedtls/aes.h` — available on ESP32, STM32 (via Mbed), and many others
- `Arduino.h` — available on all Arduino-compatible platforms
### What is NOT platform-specific
The bulk of the code — packet structures, enums, decryption, payload parsing — is pure data processing with no BLE dependency. This is ~70% of the code.
### Recommended Approach: BLE Transport Abstraction
Instead of a full HAL with virtual interfaces (which adds complexity), use a **push-based architecture** where the platform-specific code feeds raw manufacturer data into the parser:
```
Platform BLE Code (user provides) → victron_process_advertisement() → Callback
```
#### Step 1: Extract parser into standalone module
Create `victron_parser.h/.c` containing:
- All packed structs (manufacturer data, payloads)
- All enums (device types, charger states)
- `victron_decrypt()` — AES-CTR decryption
- `victron_parse_advertisement()` — takes raw manufacturer bytes, returns parsed data
- Device registry (add device, lookup by MAC)
This module has **zero BLE dependency**. It needs only `<stdint.h>`, `<string.h>`, and an AES-CTR implementation.
#### Step 2: Platform-specific BLE adapter (thin)
For ESP32 Arduino, provide `VictronBLE_ESP32.h` — a thin wrapper that:
- Sets up BLE scanning
- In the scan callback, extracts MAC + manufacturer data bytes
- Calls `victron_process_advertisement(mac, mfg_data, len, rssi)`
For STM32 (using STM32 BLE stack, or a BLE module like HM-10):
- User writes their own scan callback
- Calls the same `victron_process_advertisement()` function
For NRF52 (using Arduino BLE or nRF SDK):
- Same pattern
#### Step 3: AES portability
`mbedtls` is widely available but not universal. Allow the AES implementation to be swapped:
```c
// User can override before including victron_parser.h
#ifndef VICTRON_AES_CTR_DECRYPT
#define VICTRON_AES_CTR_DECRYPT victron_aes_ctr_mbedtls
#endif
```
Or simply provide a function pointer:
```c
typedef bool (*victron_aes_fn)(const uint8_t* key, const uint8_t* iv,
const uint8_t* in, uint8_t* out, size_t len);
void victron_set_aes_impl(victron_aes_fn fn);
```
### Result
- **Parser**: Works on any CPU (ESP32, STM32, NRF52, Linux, etc.)
- **BLE adapter**: ~30 lines of platform-specific glue code
- **AES**: Pluggable, defaults to mbedtls
This approach is simpler than a virtual HAL interface and puts the user in control of their BLE stack.
---
## Part 3: C Core with C++ Wrapper
### Rationale
The "knowledge" in this library is:
1. Victron BLE advertisement packet format (struct layouts)
2. Field encoding (scaling factors, bit packing, sign extension)
3. AES-CTR decryption with nonce construction
4. Device type identification
All of this is pure data processing — no C++ features needed. Moving it to C enables:
- Use in ESP-IDF (C-based) without Arduino
- Use on bare-metal STM32, NRF, PIC, etc.
- Use from other languages via FFI (Python ctypes, Rust FFI, etc.)
- Smaller binary, no RTTI/vtable overhead
### Proposed File Structure
```
src/
victron_ble_parser.h # C header — all public types and functions
victron_ble_parser.c # C implementation — parsing, decryption, device registry
VictronBLE.h # C++ wrapper (Arduino/ESP32 convenience class)
VictronBLE.cpp # C++ wrapper implementation
```
### `victron_ble_parser.h` — C API
```c
#ifndef VICTRON_BLE_PARSER_H
#define VICTRON_BLE_PARSER_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <stdbool.h>
/* ---- Constants ---- */
#define VICTRON_MANUFACTURER_ID 0x02E1
#define VICTRON_MAX_DEVICES 8
#define VICTRON_ENCRYPTION_KEY_LEN 16
#define VICTRON_MAX_ENCRYPTED_LEN 21
#define VICTRON_MAC_STR_LEN 13 /* 12 hex chars + null */
#define VICTRON_NAME_MAX_LEN 32
/* ---- Enums ---- */
typedef enum {
VICTRON_DEVICE_UNKNOWN = 0x00,
VICTRON_DEVICE_SOLAR_CHARGER = 0x01,
VICTRON_DEVICE_BATTERY_MONITOR = 0x02,
VICTRON_DEVICE_INVERTER = 0x03,
VICTRON_DEVICE_DCDC_CONVERTER = 0x04,
VICTRON_DEVICE_SMART_LITHIUM = 0x05,
VICTRON_DEVICE_INVERTER_RS = 0x06,
/* ... etc ... */
} victron_device_type_t;
typedef enum {
VICTRON_CHARGER_OFF = 0,
VICTRON_CHARGER_BULK = 3,
VICTRON_CHARGER_ABSORPTION = 4,
VICTRON_CHARGER_FLOAT = 5,
/* ... etc ... */
} victron_charger_state_t;
/* ---- Wire-format structures (packed) ---- */
typedef struct {
uint16_t vendor_id;
uint8_t beacon_type;
uint8_t unknown[3];
uint8_t record_type;
uint16_t nonce;
uint8_t key_check;
uint8_t encrypted_data[VICTRON_MAX_ENCRYPTED_LEN];
} __attribute__((packed)) victron_mfg_data_t;
typedef struct {
uint8_t device_state;
uint8_t error_code;
int16_t battery_voltage_10mv;
int16_t battery_current_10ma;
uint16_t yield_today_10wh;
uint16_t input_power_w;
uint16_t load_current_10ma;
uint8_t reserved[2];
} __attribute__((packed)) victron_solar_raw_t;
/* ... similar for battery_monitor, inverter, dcdc ... */
/* ---- Parsed result structures ---- */
typedef struct {
victron_charger_state_t charge_state;
float battery_voltage; /* V */
float battery_current; /* A */
float panel_power; /* W */
uint16_t yield_today_wh;
float load_current; /* A */
uint8_t error_code;
} victron_solar_data_t;
typedef struct {
float voltage; /* V */
float current; /* A */
float temperature; /* °C */
float aux_voltage; /* V */
uint16_t remaining_mins;
float consumed_ah;
float soc; /* % */
uint8_t alarms; /* raw alarm bits */
} victron_battery_data_t;
/* ... similar for inverter, dcdc ... */
/* Tagged union for any device */
typedef struct {
char mac[VICTRON_MAC_STR_LEN];
char name[VICTRON_NAME_MAX_LEN];
victron_device_type_t device_type;
int8_t rssi;
uint32_t last_update_ms;
bool data_valid;
union {
victron_solar_data_t solar;
victron_battery_data_t battery;
/* victron_inverter_data_t inverter; */
/* victron_dcdc_data_t dcdc; */
};
} victron_device_t;
/* ---- AES function signature (user can provide their own) ---- */
typedef bool (*victron_aes_ctr_fn)(
const uint8_t key[16], const uint8_t iv[16],
const uint8_t* input, uint8_t* output, size_t len);
/* ---- Core API ---- */
/* Initialize the parser context. Provide AES implementation (NULL = use default mbedtls). */
void victron_init(victron_aes_ctr_fn aes_fn);
/* Register a device to monitor. hex_key is 32-char hex string. Returns device index or -1. */
int victron_add_device(const char* name, const char* mac_hex,
const char* hex_key, victron_device_type_t type);
/* Process a raw BLE manufacturer data buffer. Called from your BLE scan callback.
Returns pointer to updated device, or NULL if not a monitored device. */
const victron_device_t* victron_process(const char* mac_hex, int8_t rssi,
const uint8_t* mfg_data, size_t mfg_len,
uint32_t timestamp_ms);
/* Get a device by index */
const victron_device_t* victron_get_device(int index);
/* Get device count */
int victron_get_device_count(void);
/* Optional callback — called when a device is updated */
typedef void (*victron_update_callback_t)(const victron_device_t* device);
void victron_set_callback(victron_update_callback_t cb);
#ifdef __cplusplus
}
#endif
#endif /* VICTRON_BLE_PARSER_H */
```
### `victron_ble_parser.c` — Implementation Sketch
```c
#include "victron_ble_parser.h"
#include <string.h>
/* ---- Internal state ---- */
static victron_device_t s_devices[VICTRON_MAX_DEVICES];
static uint8_t s_keys[VICTRON_MAX_DEVICES][16];
static int s_device_count = 0;
static victron_aes_ctr_fn s_aes_fn = NULL;
static victron_update_callback_t s_callback = NULL;
/* ---- Default AES (mbedtls) ---- */
#ifdef VICTRON_USE_MBEDTLS /* or auto-detect */
#include "mbedtls/aes.h"
static bool default_aes_ctr(const uint8_t key[16], const uint8_t iv[16],
const uint8_t* in, uint8_t* out, size_t len) {
mbedtls_aes_context aes;
mbedtls_aes_init(&aes);
if (mbedtls_aes_setkey_enc(&aes, key, 128) != 0) {
mbedtls_aes_free(&aes);
return false;
}
size_t nc_off = 0;
uint8_t nonce[16], stream[16];
memcpy(nonce, iv, 16);
memset(stream, 0, 16);
int ret = mbedtls_aes_crypt_ctr(&aes, len, &nc_off, nonce, stream, in, out);
mbedtls_aes_free(&aes);
return ret == 0;
}
#endif
void victron_init(victron_aes_ctr_fn aes_fn) {
s_device_count = 0;
memset(s_devices, 0, sizeof(s_devices));
s_aes_fn = aes_fn;
#ifdef VICTRON_USE_MBEDTLS
if (!s_aes_fn) s_aes_fn = default_aes_ctr;
#endif
}
/* hex_to_bytes, normalize_mac, parse_solar, parse_battery, etc. — all pure C */
const victron_device_t* victron_process(const char* mac_hex, int8_t rssi,
const uint8_t* mfg_data, size_t mfg_len,
uint32_t timestamp_ms) {
/* 1. Check vendor ID */
/* 2. Normalize MAC, find in s_devices[] */
/* 3. Build IV from nonce, decrypt */
/* 4. Parse based on record_type */
/* 5. Update device struct, call callback */
/* 6. Return pointer to device */
return NULL; /* placeholder */
}
```
### `VictronBLE.h` — C++ Arduino Wrapper (thin)
```cpp
#ifndef VICTRON_BLE_H
#define VICTRON_BLE_H
#include <Arduino.h>
#include "victron_ble_parser.h"
#if defined(ESP32)
#include <BLEDevice.h>
#include <BLEScan.h>
#endif
class VictronBLE {
public:
bool begin(uint32_t scanDuration = 5);
bool addDevice(const char* name, const char* mac,
const char* key, victron_device_type_t type);
void setCallback(victron_update_callback_t cb);
void loop();
private:
#if defined(ESP32)
BLEScan* scan = nullptr;
uint32_t scanDuration = 5;
static void onScanResult(BLEAdvertisedDevice dev);
#endif
};
#endif
```
### What Goes Where
| Content | File | Language |
|---|---|---|
| Packet structs (wire format) | `victron_ble_parser.h` | C |
| Device type / state enums | `victron_ble_parser.h` | C |
| Parsed data structs | `victron_ble_parser.h` | C |
| AES-CTR decryption | `victron_ble_parser.c` | C |
| Payload parsing (bit twiddling) | `victron_ble_parser.c` | C |
| Device registry | `victron_ble_parser.c` | C |
| Hex string conversion | `victron_ble_parser.c` | C |
| MAC normalization | `victron_ble_parser.c` | C |
| ESP32 BLE scanning | `VictronBLE.cpp` | C++ |
| Arduino convenience class | `VictronBLE.h/.cpp` | C++ |
### Migration Steps
1. Create `victron_ble_parser.h` with all C types and function declarations
2. Create `victron_ble_parser.c` — move parsing functions, convert String→char*, convert class methods→free functions
3. Slim down `VictronBLE.h` to just the ESP32 BLE scanning wrapper that calls the C API
4. Slim down `VictronBLE.cpp` to just `begin()`, `loop()`, and the scan callback glue
5. Update examples (minimal changes — API stays similar)
6. Test on ESP32 first, then try compiling the C core on a different target
### Estimated Code Sizes After Split
- `victron_ble_parser.h`: ~150 lines (types + API)
- `victron_ble_parser.c`: ~300 lines (all the protocol knowledge)
- `VictronBLE.h`: ~30 lines (ESP32 wrapper)
- `VictronBLE.cpp`: ~50 lines (ESP32 BLE glue)
vs. current: `VictronBLE.h` ~330 lines + `VictronBLE.cpp` ~640 lines = 970 lines total
After: ~530 lines total, with better separation of concerns

106
UPGRADE_V0.4.md Normal file
View File

@@ -0,0 +1,106 @@
# Upgrading to VictronBLE v0.4
v0.4 is a breaking API change that simplifies the library significantly.
## Summary of Changes
- **Callback**: Virtual class → function pointer
- **Data access**: Inheritance → tagged union (`VictronDevice` with `solar`, `battery`, `inverter`, `dcdc` members)
- **Strings**: Arduino `String` → fixed `char[]` arrays
- **Memory**: `std::map` + heap allocation → fixed array, zero dynamic allocation
- **Removed**: `getLastError()`, `removeDevice()`, `getDevicesByType()`, per-type getter methods, `VictronDeviceConfig` struct, `VictronDeviceCallback` class
- **Removed field**: `panelVoltage` (was unreliably derived from `panelPower / batteryCurrent`)
## Migration Guide
### 1. Callback: class → function pointer
**Before (v0.3):**
```cpp
class MyCallback : public VictronDeviceCallback {
void onSolarChargerData(const SolarChargerData& data) override {
Serial.println(data.deviceName + ": " + String(data.panelPower) + "W");
}
void onBatteryMonitorData(const BatteryMonitorData& data) override {
Serial.println("SOC: " + String(data.soc) + "%");
}
};
MyCallback callback;
victron.setCallback(&callback);
```
**After (v0.4):**
```cpp
void onVictronData(const VictronDevice* dev) {
switch (dev->deviceType) {
case DEVICE_TYPE_SOLAR_CHARGER:
Serial.printf("%s: %.0fW\n", dev->name, dev->solar.panelPower);
break;
case DEVICE_TYPE_BATTERY_MONITOR:
Serial.printf("SOC: %.1f%%\n", dev->battery.soc);
break;
}
}
victron.setCallback(onVictronData);
```
### 2. Data field access
Fields moved from flat `SolarChargerData` etc. into the `VictronDevice` tagged union:
| Old (v0.3) | New (v0.4) |
|---|---|
| `data.deviceName` | `dev->name` (char[32]) |
| `data.macAddress` | `dev->mac` (char[13]) |
| `data.rssi` | `dev->rssi` |
| `data.lastUpdate` | `dev->lastUpdate` |
| `data.batteryVoltage` | `dev->solar.batteryVoltage` |
| `data.batteryCurrent` | `dev->solar.batteryCurrent` |
| `data.panelPower` | `dev->solar.panelPower` |
| `data.yieldToday` | `dev->solar.yieldToday` |
| `data.loadCurrent` | `dev->solar.loadCurrent` |
| `data.chargeState` | `dev->solar.chargeState` (uint8_t, was enum) |
| `data.panelVoltage` | **Removed** - see below |
### 3. panelVoltage removed
`panelVoltage` was a derived value (`panelPower / batteryCurrent`) that was unreliable (division by zero when no current, inaccurate due to MPPT conversion). It has been removed.
If you need an estimate:
```cpp
float panelVoltage = (dev->solar.batteryCurrent > 0.1f)
? dev->solar.panelPower / dev->solar.batteryCurrent
: 0.0f;
```
### 4. getLastError() removed
Debug output now goes directly to Serial when `setDebug(true)` is enabled. Remove any `getLastError()` calls.
**Before:**
```cpp
if (!victron.begin(2)) {
Serial.println(victron.getLastError());
}
```
**After:**
```cpp
if (!victron.begin(2)) {
Serial.println("Failed to initialize VictronBLE!");
}
```
### 5. String types
Device name and MAC are now `char[]` instead of Arduino `String`. Use `Serial.printf()` or `String(dev->name)` if you need a String object.
### 6. addDevice() parameters
Parameters changed from `String` to `const char*`. Existing string literals work unchanged. `VictronDeviceConfig` struct is no longer needed.
```cpp
// Both v0.3 and v0.4 - string literals work the same
victron.addDevice("MySolar", "f69dfcce55eb",
"bf25c098c156afd6a180157b8a3ab1fb", DEVICE_TYPE_SOLAR_CHARGER);
```

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,100 @@
/**
* 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 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.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

@@ -3,8 +3,7 @@
* *
* Demonstrates change-detection logging for Solar Charger data. * Demonstrates change-detection logging for Solar Charger data.
* Only logs to serial when a value changes (ignoring RSSI), or once * Only logs to serial when a value changes (ignoring RSSI), or once
* per minute if nothing has changed. This keeps serial output quiet * per minute if nothing has changed.
* and is useful for long-running monitoring / data logging.
* *
* Setup: * Setup:
* 1. Get your device encryption keys from the VictronConnect app * 1. Get your device encryption keys from the VictronConnect app
@@ -16,13 +15,11 @@
VictronBLE victron; VictronBLE victron;
// Tracks last-logged values per device for change detection
struct SolarChargerSnapshot { struct SolarChargerSnapshot {
bool valid = false; bool valid = false;
SolarChargerState chargeState; uint8_t chargeState;
float batteryVoltage; float batteryVoltage;
float batteryCurrent; float batteryCurrent;
float panelVoltage;
float panelPower; float panelPower;
uint16_t yieldToday; uint16_t yieldToday;
float loadCurrent; float loadCurrent;
@@ -30,26 +27,26 @@ struct SolarChargerSnapshot {
uint32_t packetsSinceLastLog = 0; uint32_t packetsSinceLastLog = 0;
}; };
// Store a snapshot per device (index by MAC string)
static const int MAX_DEVICES = 4; static const int MAX_DEVICES = 4;
static String deviceMACs[MAX_DEVICES]; static char deviceMACs[MAX_DEVICES][VICTRON_MAC_LEN];
static SolarChargerSnapshot snapshots[MAX_DEVICES]; static SolarChargerSnapshot snapshots[MAX_DEVICES];
static int deviceCount = 0; static int deviceCount = 0;
static const unsigned long LOG_INTERVAL_MS = 60000; // 1 minute static const unsigned long LOG_INTERVAL_MS = 60000;
static int findOrAddDevice(const String& mac) { static int findOrAddDevice(const char* mac) {
for (int i = 0; i < deviceCount; i++) { for (int i = 0; i < deviceCount; i++) {
if (deviceMACs[i] == mac) return i; if (strcmp(deviceMACs[i], mac) == 0) return i;
} }
if (deviceCount < MAX_DEVICES) { if (deviceCount < MAX_DEVICES) {
deviceMACs[deviceCount] = mac; strncpy(deviceMACs[deviceCount], mac, VICTRON_MAC_LEN - 1);
deviceMACs[deviceCount][VICTRON_MAC_LEN - 1] = '\0';
return deviceCount++; return deviceCount++;
} }
return -1; return -1;
} }
static String chargeStateName(SolarChargerState state) { static const char* chargeStateName(uint8_t state) {
switch (state) { switch (state) {
case CHARGER_OFF: return "Off"; case CHARGER_OFF: return "Off";
case CHARGER_LOW_POWER: return "Low Power"; case CHARGER_LOW_POWER: return "Low Power";
@@ -66,66 +63,58 @@ static String chargeStateName(SolarChargerState state) {
} }
} }
static void logData(const SolarChargerData& data, const char* reason, uint32_t packets) { static void logData(const VictronDevice* dev, const VictronSolarData& s,
Serial.println("[" + data.deviceName + "] " + reason + const char* reason, uint32_t packets) {
" pkts:" + String(packets) + Serial.printf("[%s] %s pkts:%lu | State:%s Batt:%.2fV %.2fA PV:%.0fW Yield:%uWh",
" | State:" + chargeStateName(data.chargeState) + dev->name, reason, packets,
" Batt:" + String(data.batteryVoltage, 2) + "V" + chargeStateName(s.chargeState),
" " + String(data.batteryCurrent, 2) + "A" + s.batteryVoltage, s.batteryCurrent,
" PV:" + String(data.panelVoltage, 1) + "V" + s.panelPower, s.yieldToday);
" " + String(data.panelPower, 0) + "W" + if (s.loadCurrent > 0)
" Yield:" + String(data.yieldToday) + "Wh" + Serial.printf(" Load:%.2fA", s.loadCurrent);
(data.loadCurrent > 0 ? " Load:" + String(data.loadCurrent, 2) + "A" : "")); Serial.println();
} }
class LoggerCallback : public VictronDeviceCallback { void onVictronData(const VictronDevice* dev) {
public: if (dev->deviceType != DEVICE_TYPE_SOLAR_CHARGER) return;
void onSolarChargerData(const SolarChargerData& data) override { const auto& s = dev->solar;
int idx = findOrAddDevice(data.macAddress);
if (idx < 0) return;
SolarChargerSnapshot& prev = snapshots[idx]; int idx = findOrAddDevice(dev->mac);
unsigned long now = millis(); if (idx < 0) return;
prev.packetsSinceLastLog++;
if (!prev.valid) { SolarChargerSnapshot& prev = snapshots[idx];
// First reading - always log unsigned long now = millis();
logData(data, "INIT", prev.packetsSinceLastLog); prev.packetsSinceLastLog++;
if (!prev.valid) {
logData(dev, s, "INIT", prev.packetsSinceLastLog);
} else {
bool changed = (prev.chargeState != s.chargeState) ||
(prev.batteryVoltage != s.batteryVoltage) ||
(prev.batteryCurrent != s.batteryCurrent) ||
(prev.panelPower != s.panelPower) ||
(prev.yieldToday != s.yieldToday) ||
(prev.loadCurrent != s.loadCurrent);
if (changed) {
logData(dev, s, "CHG", prev.packetsSinceLastLog);
} else if (now - prev.lastLogTime >= LOG_INTERVAL_MS) {
logData(dev, s, "HEARTBEAT", prev.packetsSinceLastLog);
} else { } else {
// Check for changes (everything except RSSI) return;
bool changed = false;
if (prev.chargeState != data.chargeState) changed = true;
if (prev.batteryVoltage != data.batteryVoltage) changed = true;
if (prev.batteryCurrent != data.batteryCurrent) changed = true;
if (prev.panelVoltage != data.panelVoltage) changed = true;
if (prev.panelPower != data.panelPower) changed = true;
if (prev.yieldToday != data.yieldToday) changed = true;
if (prev.loadCurrent != data.loadCurrent) changed = true;
if (changed) {
logData(data, "CHG", prev.packetsSinceLastLog);
} else if (now - prev.lastLogTime >= LOG_INTERVAL_MS) {
logData(data, "HEARTBEAT", prev.packetsSinceLastLog);
} else {
return; // Nothing to log
}
} }
// Update snapshot
prev.packetsSinceLastLog = 0;
prev.valid = true;
prev.chargeState = data.chargeState;
prev.batteryVoltage = data.batteryVoltage;
prev.batteryCurrent = data.batteryCurrent;
prev.panelVoltage = data.panelVoltage;
prev.panelPower = data.panelPower;
prev.yieldToday = data.yieldToday;
prev.loadCurrent = data.loadCurrent;
prev.lastLogTime = now;
} }
};
LoggerCallback callback; prev.packetsSinceLastLog = 0;
prev.valid = true;
prev.chargeState = s.chargeState;
prev.batteryVoltage = s.batteryVoltage;
prev.batteryCurrent = s.batteryCurrent;
prev.panelPower = s.panelPower;
prev.yieldToday = s.yieldToday;
prev.loadCurrent = s.loadCurrent;
prev.lastLogTime = now;
}
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
@@ -135,14 +124,12 @@ void setup() {
if (!victron.begin(5)) { if (!victron.begin(5)) {
Serial.println("ERROR: Failed to initialize VictronBLE!"); Serial.println("ERROR: Failed to initialize VictronBLE!");
Serial.println(victron.getLastError());
while (1) delay(1000); while (1) delay(1000);
} }
victron.setDebug(false); victron.setDebug(false);
victron.setCallback(&callback); victron.setCallback(onVictronData);
// Add your devices here
victron.addDevice( victron.addDevice(
"Rainbow48V", "Rainbow48V",
"E4:05:42:34:14:F3", "E4:05:42:34:14:F3",
@@ -157,11 +144,10 @@ void setup() {
DEVICE_TYPE_SOLAR_CHARGER DEVICE_TYPE_SOLAR_CHARGER
); );
Serial.println("Configured " + String(victron.getDeviceCount()) + " devices"); Serial.printf("Configured %d devices\n", (int)victron.getDeviceCount());
Serial.println("Logging on change, or every 60s heartbeat\n"); Serial.println("Logging on change, or every 60s heartbeat\n");
} }
void loop() { void loop() {
victron.loop(); victron.loop();
delay(100);
} }

View File

@@ -1,154 +1,129 @@
/** /**
* VictronBLE Example * VictronBLE Multi-Device Example
* *
* This example demonstrates how to use the VictronBLE library to read data * Demonstrates reading data from multiple Victron device types via BLE.
* from multiple Victron devices simultaneously.
*
* Hardware Requirements:
* - ESP32 board
* - Victron devices with BLE (SmartSolar, SmartShunt, etc.)
* *
* Setup: * Setup:
* 1. Get your device encryption keys from the VictronConnect app: * 1. Get your device encryption keys from VictronConnect app
* - Open VictronConnect * (Settings > Product Info > Instant readout via Bluetooth > Show)
* - Connect to your device * 2. Update the device configurations below with your MAC and key
* - Go to Settings > Product Info
* - Enable "Instant readout via Bluetooth"
* - Click "Show" next to "Instant readout details"
* - Copy the encryption key (32 hex characters)
*
* 2. Update the device configurations below with your devices' MAC addresses
* and encryption keys
*/ */
#include <Arduino.h> #include <Arduino.h>
#include "VictronBLE.h" #include "VictronBLE.h"
// Create VictronBLE instance
VictronBLE victron; VictronBLE victron;
// Device callback class - gets called when new data arrives static uint32_t solarChargerCount = 0;
class MyVictronCallback : public VictronDeviceCallback { static uint32_t batteryMonitorCount = 0;
public: static uint32_t inverterCount = 0;
uint32_t solarChargerCount = 0; static uint32_t dcdcConverterCount = 0;
uint32_t batteryMonitorCount = 0;
uint32_t inverterCount = 0;
uint32_t dcdcConverterCount = 0;
void onSolarChargerData(const SolarChargerData& data) override { static const char* chargeStateName(uint8_t state) {
solarChargerCount++; switch (state) {
Serial.println("\n=== Solar Charger: " + data.deviceName + " (#" + String(solarChargerCount) + ") ==="); case CHARGER_OFF: return "Off";
Serial.println("MAC: " + data.macAddress); case CHARGER_LOW_POWER: return "Low Power";
Serial.println("RSSI: " + String(data.rssi) + " dBm"); case CHARGER_FAULT: return "Fault";
Serial.println("State: " + getChargeStateName(data.chargeState)); case CHARGER_BULK: return "Bulk";
Serial.println("Battery: " + String(data.batteryVoltage, 2) + " V"); case CHARGER_ABSORPTION: return "Absorption";
Serial.println("Current: " + String(data.batteryCurrent, 2) + " A"); case CHARGER_FLOAT: return "Float";
Serial.println("Panel Voltage: " + String(data.panelVoltage, 1) + " V"); case CHARGER_STORAGE: return "Storage";
Serial.println("Panel Power: " + String(data.panelPower) + " W"); case CHARGER_EQUALIZE: return "Equalize";
Serial.println("Yield Today: " + String(data.yieldToday) + " Wh"); case CHARGER_INVERTING: return "Inverting";
if (data.loadCurrent > 0) { case CHARGER_POWER_SUPPLY: return "Power Supply";
Serial.println("Load Current: " + String(data.loadCurrent, 2) + " A"); case CHARGER_EXTERNAL_CONTROL: return "External Control";
} default: return "Unknown";
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago");
} }
}
void onBatteryMonitorData(const BatteryMonitorData& data) override { void onVictronData(const VictronDevice* dev) {
batteryMonitorCount++; switch (dev->deviceType) {
Serial.println("\n=== Battery Monitor: " + data.deviceName + " (#" + String(batteryMonitorCount) + ") ==="); case DEVICE_TYPE_SOLAR_CHARGER: {
Serial.println("MAC: " + data.macAddress); const auto& s = dev->solar;
Serial.println("RSSI: " + String(data.rssi) + " dBm"); solarChargerCount++;
Serial.println("Voltage: " + String(data.voltage, 2) + " V"); Serial.printf("\n=== Solar Charger: %s (#%lu) ===\n", dev->name, solarChargerCount);
Serial.println("Current: " + String(data.current, 2) + " A"); Serial.printf("MAC: %s\n", dev->mac);
Serial.println("SOC: " + String(data.soc, 1) + " %"); Serial.printf("RSSI: %d dBm\n", dev->rssi);
Serial.println("Consumed: " + String(data.consumedAh, 2) + " Ah"); Serial.printf("State: %s\n", chargeStateName(s.chargeState));
Serial.printf("Battery: %.2f V\n", s.batteryVoltage);
if (data.remainingMinutes < 65535) { Serial.printf("Current: %.2f A\n", s.batteryCurrent);
int hours = data.remainingMinutes / 60; Serial.printf("Panel Power: %.0f W\n", s.panelPower);
int mins = data.remainingMinutes % 60; Serial.printf("Yield Today: %u Wh\n", s.yieldToday);
Serial.println("Time Remaining: " + String(hours) + "h " + String(mins) + "m"); if (s.loadCurrent > 0)
Serial.printf("Load Current: %.2f A\n", s.loadCurrent);
Serial.printf("Last Update: %lus ago\n", (millis() - dev->lastUpdate) / 1000);
break;
} }
case DEVICE_TYPE_BATTERY_MONITOR: {
if (data.temperature > 0) { const auto& b = dev->battery;
Serial.println("Temperature: " + String(data.temperature, 1) + " °C"); batteryMonitorCount++;
Serial.printf("\n=== Battery Monitor: %s (#%lu) ===\n", dev->name, batteryMonitorCount);
Serial.printf("MAC: %s\n", dev->mac);
Serial.printf("RSSI: %d dBm\n", dev->rssi);
Serial.printf("Voltage: %.2f V\n", b.voltage);
Serial.printf("Current: %.2f A\n", b.current);
Serial.printf("SOC: %.1f %%\n", b.soc);
Serial.printf("Consumed: %.2f Ah\n", b.consumedAh);
if (b.remainingMinutes < 65535)
Serial.printf("Time Remaining: %dh %dm\n", b.remainingMinutes / 60, b.remainingMinutes % 60);
if (b.temperature > 0)
Serial.printf("Temperature: %.1f C\n", b.temperature);
if (b.auxVoltage > 0)
Serial.printf("Aux Voltage: %.2f V\n", b.auxVoltage);
if (b.alarmLowVoltage || b.alarmHighVoltage || b.alarmLowSOC ||
b.alarmLowTemperature || b.alarmHighTemperature) {
Serial.print("ALARMS:");
if (b.alarmLowVoltage) Serial.print(" LOW-V");
if (b.alarmHighVoltage) Serial.print(" HIGH-V");
if (b.alarmLowSOC) Serial.print(" LOW-SOC");
if (b.alarmLowTemperature) Serial.print(" LOW-TEMP");
if (b.alarmHighTemperature) Serial.print(" HIGH-TEMP");
Serial.println();
}
Serial.printf("Last Update: %lus ago\n", (millis() - dev->lastUpdate) / 1000);
break;
} }
if (data.auxVoltage > 0) { case DEVICE_TYPE_INVERTER: {
Serial.println("Aux Voltage: " + String(data.auxVoltage, 2) + " V"); const auto& inv = dev->inverter;
inverterCount++;
Serial.printf("\n=== Inverter/Charger: %s (#%lu) ===\n", dev->name, inverterCount);
Serial.printf("MAC: %s\n", dev->mac);
Serial.printf("RSSI: %d dBm\n", dev->rssi);
Serial.printf("Battery: %.2f V\n", inv.batteryVoltage);
Serial.printf("Current: %.2f A\n", inv.batteryCurrent);
Serial.printf("AC Power: %.0f W\n", inv.acPower);
Serial.printf("State: %d\n", inv.state);
if (inv.alarmLowVoltage || inv.alarmHighVoltage ||
inv.alarmHighTemperature || inv.alarmOverload) {
Serial.print("ALARMS:");
if (inv.alarmLowVoltage) Serial.print(" LOW-V");
if (inv.alarmHighVoltage) Serial.print(" HIGH-V");
if (inv.alarmHighTemperature) Serial.print(" TEMP");
if (inv.alarmOverload) Serial.print(" OVERLOAD");
Serial.println();
}
Serial.printf("Last Update: %lus ago\n", (millis() - dev->lastUpdate) / 1000);
break;
} }
case DEVICE_TYPE_DCDC_CONVERTER: {
// Print alarms const auto& dc = dev->dcdc;
if (data.alarmLowVoltage || data.alarmHighVoltage || data.alarmLowSOC || dcdcConverterCount++;
data.alarmLowTemperature || data.alarmHighTemperature) { Serial.printf("\n=== DC-DC Converter: %s (#%lu) ===\n", dev->name, dcdcConverterCount);
Serial.print("ALARMS: "); Serial.printf("MAC: %s\n", dev->mac);
if (data.alarmLowVoltage) Serial.print("LOW-V "); Serial.printf("RSSI: %d dBm\n", dev->rssi);
if (data.alarmHighVoltage) Serial.print("HIGH-V "); Serial.printf("Input: %.2f V\n", dc.inputVoltage);
if (data.alarmLowSOC) Serial.print("LOW-SOC "); Serial.printf("Output: %.2f V\n", dc.outputVoltage);
if (data.alarmLowTemperature) Serial.print("LOW-TEMP "); Serial.printf("Current: %.2f A\n", dc.outputCurrent);
if (data.alarmHighTemperature) Serial.print("HIGH-TEMP "); Serial.printf("State: %d\n", dc.chargeState);
Serial.println(); if (dc.errorCode != 0)
Serial.printf("Error Code: %d\n", dc.errorCode);
Serial.printf("Last Update: %lus ago\n", (millis() - dev->lastUpdate) / 1000);
break;
} }
default:
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago"); break;
} }
}
void onInverterData(const InverterData& data) override {
inverterCount++;
Serial.println("\n=== Inverter/Charger: " + data.deviceName + " (#" + String(inverterCount) + ") ===");
Serial.println("MAC: " + data.macAddress);
Serial.println("RSSI: " + String(data.rssi) + " dBm");
Serial.println("Battery: " + String(data.batteryVoltage, 2) + " V");
Serial.println("Current: " + String(data.batteryCurrent, 2) + " A");
Serial.println("AC Power: " + String(data.acPower) + " W");
Serial.println("State: " + String(data.state));
// Print alarms
if (data.alarmLowVoltage || data.alarmHighVoltage ||
data.alarmHighTemperature || data.alarmOverload) {
Serial.print("ALARMS: ");
if (data.alarmLowVoltage) Serial.print("LOW-V ");
if (data.alarmHighVoltage) Serial.print("HIGH-V ");
if (data.alarmHighTemperature) Serial.print("TEMP ");
if (data.alarmOverload) Serial.print("OVERLOAD ");
Serial.println();
}
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago");
}
void onDCDCConverterData(const DCDCConverterData& data) override {
dcdcConverterCount++;
Serial.println("\n=== DC-DC Converter: " + data.deviceName + " (#" + String(dcdcConverterCount) + ") ===");
Serial.println("MAC: " + data.macAddress);
Serial.println("RSSI: " + String(data.rssi) + " dBm");
Serial.println("Input: " + String(data.inputVoltage, 2) + " V");
Serial.println("Output: " + String(data.outputVoltage, 2) + " V");
Serial.println("Current: " + String(data.outputCurrent, 2) + " A");
Serial.println("State: " + String(data.chargeState));
if (data.errorCode != 0) {
Serial.println("Error Code: " + String(data.errorCode));
}
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago");
}
private:
String getChargeStateName(SolarChargerState state) {
switch (state) {
case CHARGER_OFF: return "Off";
case CHARGER_LOW_POWER: return "Low Power";
case CHARGER_FAULT: return "Fault";
case CHARGER_BULK: return "Bulk";
case CHARGER_ABSORPTION: return "Absorption";
case CHARGER_FLOAT: return "Float";
case CHARGER_STORAGE: return "Storage";
case CHARGER_EQUALIZE: return "Equalize";
case CHARGER_INVERTING: return "Inverting";
case CHARGER_POWER_SUPPLY: return "Power Supply";
case CHARGER_EXTERNAL_CONTROL: return "External Control";
default: return "Unknown";
}
}
};
MyVictronCallback callback;
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
@@ -158,102 +133,43 @@ void setup() {
Serial.println("VictronBLE Multi-Device Example"); Serial.println("VictronBLE Multi-Device Example");
Serial.println("=================================\n"); Serial.println("=================================\n");
// Initialize VictronBLE with 5 second scan duration
if (!victron.begin(5)) { if (!victron.begin(5)) {
Serial.println("ERROR: Failed to initialize VictronBLE!"); Serial.println("ERROR: Failed to initialize VictronBLE!");
Serial.println(victron.getLastError());
while (1) delay(1000); while (1) delay(1000);
} }
// Enable debug output (optional)
victron.setDebug(false); victron.setDebug(false);
victron.setCallback(onVictronData);
// Set callback for data updates
victron.setCallback(&callback);
// Add your devices here
// Replace with your actual MAC addresses and encryption keys
// CORRECT in Alternative
// Rainbow48V at MAC e4:05:42:34:14:f3
// Temporary - Scott Example
victron.addDevice(
"Rainbow48V", // Device name
"E4:05:42:34:14:F3", // MAC address
"0ec3adf7433dd61793ff2f3b8ad32ed8", // Encryption key (32 hex chars)
DEVICE_TYPE_SOLAR_CHARGER // Device type
);
victron.addDevice( victron.addDevice(
"ScottTrailer", // Device name "Rainbow48V",
"e64559783cfb", "E4:05:42:34:14:F3",
"3fa658aded4f309b9bc17a2318cb1f56", "0ec3adf7433dd61793ff2f3b8ad32ed8",
DEVICE_TYPE_SOLAR_CHARGER // Device type
);
// Example: Solar Charger #1
/*
victron.addDevice(
"MPPT 100/30", // Device name
"E7:48:D4:28:B7:9C", // MAC address
"0df4d0395b7d1a876c0c33ecb9e70dcd", // Encryption key (32 hex chars)
DEVICE_TYPE_SOLAR_CHARGER // Device type
);
*/
// Example: Solar Charger #2
/*
victron.addDevice(
"MPPT 75/15",
"AA:BB:CC:DD:EE:FF",
"1234567890abcdef1234567890abcdef",
DEVICE_TYPE_SOLAR_CHARGER DEVICE_TYPE_SOLAR_CHARGER
); );
*/
// Example: Battery Monitor (SmartShunt)
/*
victron.addDevice( victron.addDevice(
"SmartShunt", "ScottTrailer",
"11:22:33:44:55:66", "e64559783cfb",
"fedcba0987654321fedcba0987654321", "3fa658aded4f309b9bc17a2318cb1f56",
DEVICE_TYPE_BATTERY_MONITOR DEVICE_TYPE_SOLAR_CHARGER
); );
*/
// Example: Inverter/Charger Serial.printf("Configured %d devices\n", (int)victron.getDeviceCount());
/*
victron.addDevice(
"MultiPlus",
"99:88:77:66:55:44",
"abcdefabcdefabcdefabcdefabcdefab",
DEVICE_TYPE_INVERTER
);
*/
Serial.println("Configured " + String(victron.getDeviceCount()) + " devices");
Serial.println("\nStarting BLE scan...\n"); Serial.println("\nStarting BLE scan...\n");
} }
static uint32_t loopCount = 0;
static uint32_t lastReport = 0;
void loop() { void loop() {
// Process BLE scanning and data updates victron.loop(); // Non-blocking: returns immediately if scan is running
victron.loop(); loopCount++;
// Optional: You can also manually query device data uint32_t now = millis();
// This is useful if you're not using callbacks if (now - lastReport >= 10000) {
/* Serial.printf("Loop iterations in last 10s: %lu\n", loopCount);
SolarChargerData solarData; loopCount = 0;
if (victron.getSolarChargerData("E7:48:D4:28:B7:9C", solarData)) { lastReport = now;
// Do something with solarData
} }
BatteryMonitorData batteryData;
if (victron.getBatteryMonitorData("11:22:33:44:55:66", batteryData)) {
// Do something with batteryData
}
*/
// Add a small delay to avoid overwhelming the serial output
delay(100);
} }

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,219 @@
/**
* 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 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:%.0fW Yield:%uWh",
recvCount,
name,
chargeStateName(pkt->chargeState),
pkt->batteryVoltage,
pkt->batteryCurrent,
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: %.0fW\n", 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,174 @@
/**
* VictronBLE Repeater Example
*
* Collects Solar Charger data via BLE and transmits the latest
* readings over ESPNow broadcast every 5 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 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 const unsigned long SEND_INTERVAL_MS = 5000;
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;
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;
}
void onVictronData(const VictronDevice* dev) {
if (dev->deviceType != DEVICE_TYPE_SOLAR_CHARGER) return;
blePacketCount++;
const auto& s = dev->solar;
SolarChargerPacket pkt;
pkt.chargeState = s.chargeState;
pkt.batteryVoltage = s.batteryVoltage;
pkt.batteryCurrent = s.batteryCurrent;
pkt.panelPower = s.panelPower;
pkt.yieldToday = s.yieldToday;
pkt.loadCurrent = s.loadCurrent;
pkt.rssi = dev->rssi;
memset(pkt.deviceName, 0, sizeof(pkt.deviceName));
strncpy(pkt.deviceName, dev->name, sizeof(pkt.deviceName) - 1);
int idx = findOrAddCached(pkt.deviceName);
if (idx >= 0) {
cachedPackets[idx] = pkt;
cachedValid[idx] = true;
}
}
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.print("MAC: ");
Serial.println(WiFi.macAddress());
// Init ESPNow
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, broadcasting on all channels");
// Init VictronBLE
if (!victron.begin(5)) {
Serial.println("ERROR: Failed to initialize VictronBLE!");
while (1) delay(1000);
}
victron.setDebug(false);
victron.setCallback(onVictronData);
victron.addDevice(
"Rainbow48V",
"E4:05:42:34:14:F3",
"0ec3adf7433dd61793ff2f3b8ad32ed8",
DEVICE_TYPE_SOLAR_CHARGER
);
victron.addDevice(
"ScottTrailer",
"e64559783cfb",
"3fa658aded4f309b9bc17a2318cb1f56",
DEVICE_TYPE_SOLAR_CHARGER
);
Serial.printf("Configured %d BLE devices\n", (int)victron.getDeviceCount());
Serial.printf("Packet size: %d bytes\n\n", (int)sizeof(SolarChargerPacket));
}
void loop() {
victron.loop(); // Blocks for scanDuration 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 %.0fW State:%d\n",
cachedPackets[i].deviceName,
cachedPackets[i].batteryVoltage,
cachedPackets[i].batteryCurrent,
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);
}
}

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

@@ -7,659 +7,361 @@
*/ */
#include "VictronBLE.h" #include "VictronBLE.h"
#include <string.h>
// Constructor
VictronBLE::VictronBLE() VictronBLE::VictronBLE()
: pBLEScan(nullptr), callback(nullptr), debugEnabled(false), : deviceCount(0), pBLEScan(nullptr), scanCallbackObj(nullptr),
scanDuration(5), initialized(false) { callback(nullptr), debugEnabled(false), scanDuration(5),
minIntervalMs(1000), initialized(false) {
memset(devices, 0, sizeof(devices));
} }
// Destructor
VictronBLE::~VictronBLE() {
for (auto& pair : devices) {
delete pair.second;
}
devices.clear();
if (pBLEScan) {
pBLEScan->stop();
}
}
// Initialize BLE
bool VictronBLE::begin(uint32_t scanDuration) { bool VictronBLE::begin(uint32_t scanDuration) {
if (initialized) { if (initialized) return true;
debugPrint("VictronBLE already initialized");
return true;
}
this->scanDuration = scanDuration; this->scanDuration = scanDuration;
debugPrint("Initializing VictronBLE...");
BLEDevice::init("VictronBLE"); BLEDevice::init("VictronBLE");
pBLEScan = BLEDevice::getScan(); pBLEScan = BLEDevice::getScan();
if (!pBLEScan) return false;
if (!pBLEScan) { scanCallbackObj = new VictronBLEAdvertisedDeviceCallbacks(this);
lastError = "Failed to create BLE scanner"; pBLEScan->setAdvertisedDeviceCallbacks(scanCallbackObj, true);
debugPrint(lastError); pBLEScan->setActiveScan(false);
return false;
}
pBLEScan->setAdvertisedDeviceCallbacks(new VictronBLEAdvertisedDeviceCallbacks(this), true);
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) Serial.println("[VictronBLE] Initialized");
return true; return true;
} }
// Add a device to monitor bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
bool VictronBLE::addDevice(const VictronDeviceConfig& config) { VictronDeviceType type) {
if (config.macAddress.length() == 0) { if (deviceCount >= VICTRON_MAX_DEVICES) return false;
lastError = "MAC address cannot be empty"; if (!hexKey || strlen(hexKey) != 32) return false;
debugPrint(lastError); if (!mac || strlen(mac) == 0) return false;
return false;
}
if (config.encryptionKey.length() != 32) { char normalizedMAC[VICTRON_MAC_LEN];
lastError = "Encryption key must be 32 hex characters"; normalizeMAC(mac, normalizedMAC);
debugPrint(lastError);
return false;
}
String normalizedMAC = normalizeMAC(config.macAddress); // Check for duplicate
if (findDevice(normalizedMAC)) return false;
// Check if device already exists DeviceEntry* entry = &devices[deviceCount];
if (devices.find(normalizedMAC) != devices.end()) { memset(entry, 0, sizeof(DeviceEntry));
debugPrint("Device " + normalizedMAC + " already exists, updating config"); entry->active = true;
delete devices[normalizedMAC];
}
DeviceInfo* info = new DeviceInfo(); strncpy(entry->device.name, name ? name : "", VICTRON_NAME_LEN - 1);
info->config = config; entry->device.name[VICTRON_NAME_LEN - 1] = '\0';
info->config.macAddress = normalizedMAC; memcpy(entry->device.mac, normalizedMAC, VICTRON_MAC_LEN);
entry->device.deviceType = type;
entry->device.rssi = -100;
// Convert encryption key from hex string to bytes if (!hexToBytes(hexKey, entry->key, 16)) return false;
if (!hexStringToBytes(config.encryptionKey, info->encryptionKeyBytes, 16)) {
lastError = "Invalid encryption key format";
debugPrint(lastError);
delete info;
return false;
}
// Create appropriate data structure based on device type deviceCount++;
info->data = createDeviceData(config.expectedType);
if (info->data) {
info->data->macAddress = normalizedMAC;
info->data->deviceName = config.name;
}
devices[normalizedMAC] = info;
debugPrint("Added device: " + config.name + " (MAC: " + normalizedMAC + ")");
if (debugEnabled) {
debugPrint(" Original MAC input: " + config.macAddress);
debugPrint(" Stored normalized: " + normalizedMAC);
}
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
return true; return true;
} }
bool VictronBLE::addDevice(String name, String macAddress, String encryptionKey, // Scan complete callback — sets flag so loop() restarts
VictronDeviceType expectedType) { static bool s_scanning = false;
VictronDeviceConfig config(name, macAddress, encryptionKey, expectedType); static void onScanDone(BLEScanResults results) {
return addDevice(config); s_scanning = false;
} }
// Remove a device
void VictronBLE::removeDevice(String macAddress) {
String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC);
if (it != devices.end()) {
delete it->second;
devices.erase(it);
debugPrint("Removed device: " + normalizedMAC);
}
}
// Main loop function
void VictronBLE::loop() { void VictronBLE::loop() {
if (!initialized) { if (!initialized) return;
return; if (!s_scanning) {
pBLEScan->clearResults();
s_scanning = pBLEScan->start(scanDuration, onScanDone, false);
} }
// Start a scan
BLEScanResults scanResults = pBLEScan->start(scanDuration, false);
pBLEScan->clearResults();
} }
// BLE callback implementation // BLE scan callback
void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) { void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) {
if (victronBLE) { if (victronBLE) victronBLE->processDevice(advertisedDevice);
victronBLE->processDevice(advertisedDevice);
}
} }
// Process advertised device void VictronBLE::processDevice(BLEAdvertisedDevice& advertisedDevice) {
void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) { if (!advertisedDevice.haveManufacturerData()) return;
// Get MAC address from the advertised device
String mac = macAddressToString(advertisedDevice.getAddress());
String normalizedMAC = normalizeMAC(mac);
if (debugEnabled) { std::string raw = advertisedDevice.getManufacturerData();
debugPrint("Raw MAC: " + mac + " -> Normalized: " + normalizedMAC); if (raw.length() < 10) return;
}
// TODO: Consider skipping with no manufacturer data? // Quick vendor ID check before any other work
memset(&manufacturerData, 0, sizeof(manufacturerData)); uint16_t vendorID = (uint8_t)raw[0] | ((uint8_t)raw[1] << 8);
if (advertisedDevice.haveManufacturerData()) { if (vendorID != VICTRON_MANUFACTURER_ID) return;
std::string mfgData = advertisedDevice.getManufacturerData();
// XXX Storing it this way is not thread safe - is that issue on this ESP32?
debugPrint("Getting manufacturer data: Size=" + String(mfgData.length()));
mfgData.copy((char*)&manufacturerData, (mfgData.length() > sizeof(manufacturerData) ? sizeof(manufacturerData) : mfgData.length()));
}
// Pointer? XXX // Parse manufacturer data
victronManufacturerData mfgData;
memset(&mfgData, 0, sizeof(mfgData));
size_t copyLen = raw.length() > sizeof(mfgData) ? sizeof(mfgData) : raw.length();
raw.copy(reinterpret_cast<char*>(&mfgData), copyLen);
// Debug: Log all discovered BLE devices // Normalize MAC and find device
if (debugEnabled) { char normalizedMAC[VICTRON_MAC_LEN];
String debugMsg = ""; normalizeMAC(advertisedDevice.getAddress().toString().c_str(), normalizedMAC);
debugMsg += "BLE Device: " + mac; DeviceEntry* entry = findDevice(normalizedMAC);
debugMsg += ", RSSI: " + String(advertisedDevice.getRSSI()) + " dBm"; if (!entry) {
if (advertisedDevice.haveName()) if (debugEnabled) Serial.printf("[VictronBLE] Unmonitored Victron: %s\n", normalizedMAC);
debugMsg += ", Name: " + String(advertisedDevice.getName().c_str());
debugMsg += ", Mfg ID: 0x" + String(manufacturerData.vendorID, HEX);
if (manufacturerData.vendorID == VICTRON_MANUFACTURER_ID) {
debugMsg += " (Victron)";
}
debugPrint(debugMsg);
}
// Check if this is one of our configured devices
auto it = devices.find(normalizedMAC);
if (it == devices.end()) {
// XXX Check if the device is a Victron device
// This needs lots of improvemet and only do in debug
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
}
DeviceInfo* deviceInfo = it->second;
// Check if it's Victron (manufacturer ID 0x02E1)
if (manufacturerData.vendorID != VICTRON_MANUFACTURER_ID) {
debugPrint("Skipping non VICTRON");
return; return;
} }
debugPrint("Processing data from: " + deviceInfo->config.name); // Skip if nonce unchanged (data hasn't changed on the device)
if (entry->device.dataValid && mfgData.nonceDataCounter == entry->lastNonce) {
// Still update RSSI since we got a packet
entry->device.rssi = advertisedDevice.getRSSI();
return;
}
// Parse the advertisement // Skip if minimum interval hasn't elapsed
if (parseAdvertisement(normalizedMAC)) { uint32_t now = millis();
// Update RSSI if (entry->device.dataValid && (now - entry->device.lastUpdate) < minIntervalMs) {
if (deviceInfo->data) { return;
deviceInfo->data->rssi = advertisedDevice.getRSSI(); }
deviceInfo->data->lastUpdate = millis();
} if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
entry->device.name, mfgData.nonceDataCounter);
if (parseAdvertisement(entry, mfgData)) {
entry->lastNonce = mfgData.nonceDataCounter;
entry->device.rssi = advertisedDevice.getRSSI();
entry->device.lastUpdate = now;
} }
} }
// Parse advertisement data bool VictronBLE::parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg) {
bool VictronBLE::parseAdvertisement(const String& macAddress) {
// 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)); Serial.printf("[VictronBLE] Beacon:0x%02X Record:0x%02X Nonce:0x%04X\n",
debugPrint("Beacon Type: 0x" + String(manufacturerData.beaconType, HEX)); mfg.beaconType, mfg.victronRecordType, mfg.nonceDataCounter);
debugPrint("Record Type: 0x" + String(manufacturerData.victronRecordType, HEX));
debugPrint("Nonce: 0x" + String(manufacturerData.nonceDataCounter, HEX));
} }
// Build IV (initialization vector) from nonce // Quick key check before expensive decryption
// IV is 16 bytes: nonce (2 bytes little-endian) + zeros (14 bytes) if (mfg.encryptKeyMatch != entry->key[0]) {
uint8_t iv[16] = {0}; if (debugEnabled) Serial.println("[VictronBLE] Key byte mismatch");
iv[0] = manufacturerData.nonceDataCounter & 0xFF; // Low byte
iv[1] = (manufacturerData.nonceDataCounter >> 8) & 0xFF; // High byte
// Remaining bytes stay zero
// Decrypt the data
uint8_t decrypted[32]; // Max expected size
if (!decryptAdvertisement(manufacturerData.victronEncryptedData,
sizeof(manufacturerData.victronEncryptedData),
deviceInfo->encryptionKeyBytes, iv, decrypted)) {
lastError = "Decryption failed";
debugPrint(lastError);
return false; return false;
} }
// Parse based on device type // Build IV from nonce (2 bytes little-endian + 14 zero bytes)
bool parseOk = false; uint8_t iv[16] = {0};
iv[0] = mfg.nonceDataCounter & 0xFF;
iv[1] = (mfg.nonceDataCounter >> 8) & 0xFF;
switch (manufacturerData.victronRecordType) { // Decrypt
uint8_t decrypted[VICTRON_ENCRYPTED_LEN];
if (!decryptData(mfg.victronEncryptedData, VICTRON_ENCRYPTED_LEN,
entry->key, iv, decrypted)) {
if (debugEnabled) Serial.println("[VictronBLE] Decryption failed");
return false;
}
// Parse based on record type (auto-detects device type)
bool ok = false;
switch (mfg.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER: case DEVICE_TYPE_SOLAR_CHARGER:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_SOLAR_CHARGER) { entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
parseOk = parseSolarCharger(decrypted, sizeof(decrypted), ok = parseSolarCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.solar);
*(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) { entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
parseOk = parseBatteryMonitor(decrypted, sizeof(decrypted), ok = parseBatteryMonitor(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.battery);
*(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:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_INVERTER) { entry->device.deviceType = DEVICE_TYPE_INVERTER;
parseOk = parseInverter(decrypted, sizeof(decrypted), ok = parseInverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.inverter);
*(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) { entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
parseOk = parseDCDCConverter(decrypted, sizeof(decrypted), ok = parseDCDCConverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.dcdc);
*(DCDCConverterData*)deviceInfo->data);
}
break; break;
default: default:
debugPrint("Unknown device type: 0x" + String(manufacturerData.victronRecordType, HEX)); if (debugEnabled) Serial.printf("[VictronBLE] Unknown type: 0x%02X\n", mfg.victronRecordType);
return false; return false;
} }
if (parseOk && deviceInfo->data) { if (ok) {
deviceInfo->data->dataValid = true; entry->device.dataValid = true;
if (callback) callback(&entry->device);
// Call appropriate callback
if (callback) {
switch (manufacturerData.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER:
callback->onSolarChargerData(*(SolarChargerData*)deviceInfo->data);
break;
case DEVICE_TYPE_BATTERY_MONITOR:
callback->onBatteryMonitorData(*(BatteryMonitorData*)deviceInfo->data);
break;
case DEVICE_TYPE_INVERTER:
case DEVICE_TYPE_INVERTER_RS:
case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS:
callback->onInverterData(*(InverterData*)deviceInfo->data);
break;
case DEVICE_TYPE_DCDC_CONVERTER:
callback->onDCDCConverterData(*(DCDCConverterData*)deviceInfo->data);
break;
}
}
} }
return parseOk; return ok;
} }
// Decrypt advertisement using AES-128-CTR bool VictronBLE::decryptData(const uint8_t* encrypted, size_t len,
bool VictronBLE::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) {
mbedtls_aes_context aes; mbedtls_aes_context aes;
mbedtls_aes_init(&aes); mbedtls_aes_init(&aes);
// Set encryption key if (mbedtls_aes_setkey_enc(&aes, key, 128) != 0) {
int ret = mbedtls_aes_setkey_enc(&aes, key, 128);
if (ret != 0) {
mbedtls_aes_free(&aes); mbedtls_aes_free(&aes);
return false; return false;
} }
// AES-CTR decryption
size_t nc_off = 0; size_t nc_off = 0;
uint8_t nonce_counter[16]; uint8_t nonce_counter[16];
uint8_t stream_block[16]; uint8_t stream_block[16];
memcpy(nonce_counter, iv, 16); memcpy(nonce_counter, iv, 16);
memset(stream_block, 0, 16); memset(stream_block, 0, 16);
ret = mbedtls_aes_crypt_ctr(&aes, encLen, &nc_off, nonce_counter, int ret = mbedtls_aes_crypt_ctr(&aes, len, &nc_off, nonce_counter,
stream_block, encrypted, decrypted); stream_block, encrypted, decrypted);
mbedtls_aes_free(&aes); mbedtls_aes_free(&aes);
return (ret == 0); return (ret == 0);
} }
// Parse Solar Charger data bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result) {
bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result) { if (len < sizeof(victronSolarChargerPayload)) return false;
if (len < sizeof(victronSolarChargerPayload)) { const auto* p = reinterpret_cast<const victronSolarChargerPayload*>(data);
debugPrint("Solar charger data too short: " + String(len) + " bytes");
return false; result.chargeState = p->deviceState;
result.errorCode = p->errorCode;
result.batteryVoltage = p->batteryVoltage * 0.01f;
result.batteryCurrent = p->batteryCurrent * 0.01f;
result.yieldToday = p->yieldToday * 10;
result.panelPower = p->inputPower;
result.loadCurrent = (p->loadCurrent != 0xFFFF) ? p->loadCurrent * 0.01f : 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
result.batteryVoltage, result.batteryCurrent,
(int)result.panelPower, result.chargeState);
} }
// Cast decrypted data to struct for easy access
const victronSolarChargerPayload* payload = (const victronSolarChargerPayload*)data;
// Parse charge state
result.chargeState = (SolarChargerState)payload->deviceState;
// Parse battery voltage (10 mV units -> volts)
result.batteryVoltage = payload->batteryVoltage * 0.01f;
// Parse battery current (10 mA units, signed -> amps)
result.batteryCurrent = payload->batteryCurrent * 0.01f;
// Parse yield today (10 Wh units -> Wh)
result.yieldToday = payload->yieldToday * 10;
// Parse PV power (1 W units)
result.panelPower = payload->inputPower;
// Parse load current (10 mA units -> amps, 0xFFFF = no load)
if (payload->loadCurrent != 0xFFFF) {
result.loadCurrent = payload->loadCurrent * 0.01f;
} else {
result.loadCurrent = 0;
}
// Calculate PV voltage from power and current (if current > 0)
if (result.batteryCurrent > 0.1f) {
result.panelVoltage = result.panelPower / result.batteryCurrent;
} else {
result.panelVoltage = 0;
}
debugPrint("Solar Charger: " + String(result.batteryVoltage, 2) + "V, " +
String(result.batteryCurrent, 2) + "A, " +
String(result.panelPower) + "W, State: " + String(result.chargeState));
return true; return true;
} }
// Parse Battery Monitor data bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result) {
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMonitorData& result) { if (len < sizeof(victronBatteryMonitorPayload)) return false;
if (len < sizeof(victronBatteryMonitorPayload)) { const auto* p = reinterpret_cast<const victronBatteryMonitorPayload*>(data);
debugPrint("Battery monitor data too short: " + String(len) + " bytes");
return false;
}
// Cast decrypted data to struct for easy access result.remainingMinutes = p->remainingMins;
const victronBatteryMonitorPayload* payload = (const victronBatteryMonitorPayload*)data; result.voltage = p->batteryVoltage * 0.01f;
// Parse remaining time (1 minute units) // Alarm bits
result.remainingMinutes = payload->remainingMins; 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;
// Parse battery voltage (10 mV units -> volts) // Aux data: voltage or temperature (heuristic: < 30V = voltage)
result.voltage = payload->batteryVoltage * 0.01f; // NOTE: Victron protocol uses a flag bit for this, but it's not exposed
// in the BLE advertisement. This heuristic may misclassify edge cases.
// Parse alarm bits if (p->auxData < 3000) {
result.alarmLowVoltage = (payload->alarms & 0x01) != 0; result.auxVoltage = p->auxData * 0.01f;
result.alarmHighVoltage = (payload->alarms & 0x02) != 0;
result.alarmLowSOC = (payload->alarms & 0x04) != 0;
result.alarmLowTemperature = (payload->alarms & 0x10) != 0;
result.alarmHighTemperature = (payload->alarms & 0x20) != 0;
// Parse aux data: voltage (10 mV units) or temperature (0.01K units)
if (payload->auxData < 3000) { // If < 30V, it's voltage
result.auxVoltage = payload->auxData * 0.01f;
result.temperature = 0; result.temperature = 0;
} else { // Otherwise temperature in 0.01 Kelvin } else {
result.temperature = (payload->auxData * 0.01f) - 273.15f; result.temperature = (p->auxData * 0.01f) - 273.15f;
result.auxVoltage = 0; result.auxVoltage = 0;
} }
// Parse battery current (22-bit signed, 1 mA units) // 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 = p->currentLow |
int32_t current = payload->currentLow | (p->currentMid << 8) |
(payload->currentMid << 8) | ((p->currentHigh_consumedLow & 0x3F) << 16);
((payload->currentHigh_consumedLow & 0x3F) << 16); if (current & 0x200000) current |= 0xFFC00000; // Sign extend
// Sign extend from 22 bits to 32 bits result.current = current * 0.001f;
if (current & 0x200000) {
current |= 0xFFC00000; // 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;
// SOC (10-bit, 0.1% units)
result.soc = (p->soc & 0x3FF) * 0.1f;
if (debugEnabled) {
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
result.voltage, result.current, result.soc);
} }
result.current = current * 0.001f; // Convert mA to A
// 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) |
(payload->consumedMid << 2) |
(payload->consumedHigh << 10);
// Sign extend from 18 bits to 32 bits
if (consumedAh & 0x20000) {
consumedAh |= 0xFFFC0000;
}
result.consumedAh = consumedAh * 0.01f; // Convert 10mAh to Ah
// Parse SOC (10-bit value, 10 = 1.0%)
result.soc = (payload->soc & 0x3FF) * 0.1f;
debugPrint("Battery Monitor: " + String(result.voltage, 2) + "V, " +
String(result.current, 2) + "A, SOC: " + String(result.soc, 1) + "%");
return true; return true;
} }
// Parse Inverter data bool VictronBLE::parseInverter(const uint8_t* data, size_t len, VictronInverterData& result) {
bool VictronBLE::parseInverter(const uint8_t* data, size_t len, InverterData& result) { if (len < sizeof(victronInverterPayload)) return false;
if (len < sizeof(victronInverterPayload)) { const auto* p = reinterpret_cast<const victronInverterPayload*>(data);
debugPrint("Inverter data too short: " + String(len) + " bytes");
return false;
}
// Cast decrypted data to struct for easy access result.state = p->deviceState;
const victronInverterPayload* payload = (const victronInverterPayload*)data; result.batteryVoltage = p->batteryVoltage * 0.01f;
result.batteryCurrent = p->batteryCurrent * 0.01f;
// Parse device state // AC Power (signed 24-bit)
result.state = payload->deviceState; int32_t acPower = p->acPowerLow | (p->acPowerMid << 8) | (p->acPowerHigh << 16);
if (acPower & 0x800000) acPower |= 0xFF000000; // Sign extend
// Parse battery voltage (10 mV units -> volts)
result.batteryVoltage = payload->batteryVoltage * 0.01f;
// Parse battery current (10 mA units, signed -> amps)
result.batteryCurrent = payload->batteryCurrent * 0.01f;
// Parse AC Power (signed 24-bit, 1 W units)
int32_t acPower = payload->acPowerLow |
(payload->acPowerMid << 8) |
(payload->acPowerHigh << 16);
// Sign extend from 24 bits to 32 bits
if (acPower & 0x800000) {
acPower |= 0xFF000000;
}
result.acPower = acPower; result.acPower = acPower;
// Parse alarm bits // Alarm bits
result.alarmLowVoltage = (payload->alarms & 0x01) != 0; result.alarmLowVoltage = (p->alarms & 0x01) != 0;
result.alarmHighVoltage = (payload->alarms & 0x02) != 0; result.alarmHighVoltage = (p->alarms & 0x02) != 0;
result.alarmHighTemperature = (payload->alarms & 0x04) != 0; result.alarmHighTemperature = (p->alarms & 0x04) != 0;
result.alarmOverload = (payload->alarms & 0x08) != 0; result.alarmOverload = (p->alarms & 0x08) != 0;
debugPrint("Inverter: " + String(result.batteryVoltage, 2) + "V, " +
String(result.acPower) + "W, State: " + String(result.state));
if (debugEnabled) {
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
result.batteryVoltage, (int)result.acPower, result.state);
}
return true; return true;
} }
// Parse DC-DC Converter data bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result) {
bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, DCDCConverterData& result) { if (len < sizeof(victronDCDCConverterPayload)) return false;
if (len < sizeof(victronDCDCConverterPayload)) { const auto* p = reinterpret_cast<const victronDCDCConverterPayload*>(data);
debugPrint("DC-DC converter data too short: " + String(len) + " bytes");
return false; result.chargeState = p->chargeState;
result.errorCode = p->errorCode;
result.inputVoltage = p->inputVoltage * 0.01f;
result.outputVoltage = p->outputVoltage * 0.01f;
result.outputCurrent = p->outputCurrent * 0.01f;
if (debugEnabled) {
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
result.inputVoltage, result.outputVoltage, result.outputCurrent);
} }
// Cast decrypted data to struct for easy access
const victronDCDCConverterPayload* payload = (const victronDCDCConverterPayload*)data;
// Parse charge state
result.chargeState = payload->chargeState;
// Parse error code
result.errorCode = payload->errorCode;
// Parse input voltage (10 mV units -> volts)
result.inputVoltage = payload->inputVoltage * 0.01f;
// Parse output voltage (10 mV units -> volts)
result.outputVoltage = payload->outputVoltage * 0.01f;
// Parse output current (10 mA units -> amps)
result.outputCurrent = payload->outputCurrent * 0.01f;
debugPrint("DC-DC Converter: In=" + String(result.inputVoltage, 2) + "V, Out=" +
String(result.outputVoltage, 2) + "V, " + String(result.outputCurrent, 2) + "A");
return true; return true;
} }
// Get data methods // --- Helpers ---
bool VictronBLE::getSolarChargerData(String macAddress, SolarChargerData& data) {
String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_SOLAR_CHARGER) {
data = *(SolarChargerData*)it->second->data;
return data.dataValid;
}
return false;
}
bool VictronBLE::getBatteryMonitorData(String macAddress, BatteryMonitorData& data) {
String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_BATTERY_MONITOR) {
data = *(BatteryMonitorData*)it->second->data;
return data.dataValid;
}
return false;
}
bool VictronBLE::getInverterData(String macAddress, InverterData& data) {
String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_INVERTER) {
data = *(InverterData*)it->second->data;
return data.dataValid;
}
return false;
}
bool VictronBLE::getDCDCConverterData(String macAddress, DCDCConverterData& data) {
String normalizedMAC = normalizeMAC(macAddress);
auto it = devices.find(normalizedMAC);
if (it != devices.end() && it->second->data &&
it->second->data->deviceType == DEVICE_TYPE_DCDC_CONVERTER) {
data = *(DCDCConverterData*)it->second->data;
return data.dataValid;
}
return false;
}
// Get devices by type
std::vector<String> VictronBLE::getDevicesByType(VictronDeviceType type) {
std::vector<String> result;
for (const auto& pair : devices) {
if (pair.second->data && pair.second->data->deviceType == type) {
result.push_back(pair.first);
}
}
return result;
}
// Helper: Create device data structure
VictronDeviceData* VictronBLE::createDeviceData(VictronDeviceType type) {
switch (type) {
case DEVICE_TYPE_SOLAR_CHARGER:
return new SolarChargerData();
case DEVICE_TYPE_BATTERY_MONITOR:
return new BatteryMonitorData();
case DEVICE_TYPE_INVERTER:
case DEVICE_TYPE_INVERTER_RS:
case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS:
return new InverterData();
case DEVICE_TYPE_DCDC_CONVERTER:
return new DCDCConverterData();
default:
return new VictronDeviceData();
}
}
// Helper: Convert hex string to bytes
bool VictronBLE::hexStringToBytes(const String& hex, uint8_t* bytes, size_t len) {
if (hex.length() != len * 2) {
return false;
}
bool VictronBLE::hexToBytes(const char* hex, uint8_t* out, size_t len) {
if (strlen(hex) != len * 2) return false;
for (size_t i = 0; i < len; i++) { for (size_t i = 0; i < len; i++) {
String byteStr = hex.substring(i * 2, i * 2 + 2); uint8_t hi = hex[i * 2], lo = hex[i * 2 + 1];
char* endPtr; if (hi >= '0' && hi <= '9') hi -= '0';
bytes[i] = strtoul(byteStr.c_str(), &endPtr, 16); else if (hi >= 'a' && hi <= 'f') hi = hi - 'a' + 10;
if (*endPtr != '\0') { else if (hi >= 'A' && hi <= 'F') hi = hi - 'A' + 10;
return false; else return false;
} if (lo >= '0' && lo <= '9') lo -= '0';
else if (lo >= 'a' && lo <= 'f') lo = lo - 'a' + 10;
else if (lo >= 'A' && lo <= 'F') lo = lo - 'A' + 10;
else return false;
out[i] = (hi << 4) | lo;
} }
return true; return true;
} }
// Helper: MAC address to string void VictronBLE::normalizeMAC(const char* input, char* output) {
String VictronBLE::macAddressToString(BLEAddress address) { int j = 0;
// Use the BLEAddress toString() method which provides consistent formatting for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) {
return String(address.toString().c_str()); char c = input[i];
if (c == ':' || c == '-') continue;
output[j++] = (c >= 'A' && c <= 'F') ? (c + 32) : c;
}
output[j] = '\0';
} }
// Helper: Normalize MAC address format VictronBLE::DeviceEntry* VictronBLE::findDevice(const char* normalizedMAC) {
String VictronBLE::normalizeMAC(String mac) { for (size_t i = 0; i < deviceCount; i++) {
String normalized = mac; if (devices[i].active && strcmp(devices[i].device.mac, normalizedMAC) == 0) {
normalized.toLowerCase(); return &devices[i];
// XXX - is this right, was - to : but not consistent location of pairs or not }
normalized.replace("-", ""); }
normalized.replace(":", ""); return nullptr;
return normalized;
}
// Debug helpers
void VictronBLE::debugPrint(const String& message) {
if (debugEnabled)
Serial.println("[VictronBLE] " + message);
} }

View File

@@ -15,14 +15,16 @@
#include <BLEDevice.h> #include <BLEDevice.h>
#include <BLEAdvertisedDevice.h> #include <BLEAdvertisedDevice.h>
#include <BLEScan.h> #include <BLEScan.h>
#include <map>
#include <vector>
#include "mbedtls/aes.h" #include "mbedtls/aes.h"
// Victron manufacturer ID // --- Constants ---
#define VICTRON_MANUFACTURER_ID 0x02E1 static constexpr uint16_t VICTRON_MANUFACTURER_ID = 0x02E1;
static constexpr int VICTRON_MAX_DEVICES = 8;
static constexpr int VICTRON_MAC_LEN = 13; // 12 hex chars + null
static constexpr int VICTRON_NAME_LEN = 32;
static constexpr int VICTRON_ENCRYPTED_LEN = 21;
// Device type IDs from Victron protocol // --- Device type IDs from Victron protocol ---
enum VictronDeviceType { enum VictronDeviceType {
DEVICE_TYPE_UNKNOWN = 0x00, DEVICE_TYPE_UNKNOWN = 0x00,
DEVICE_TYPE_SOLAR_CHARGER = 0x01, DEVICE_TYPE_SOLAR_CHARGER = 0x01,
@@ -38,7 +40,7 @@ enum VictronDeviceType {
DEVICE_TYPE_DC_ENERGY_METER = 0x0B DEVICE_TYPE_DC_ENERGY_METER = 0x0B
}; };
// Device state for Solar Charger // --- Device state for Solar Charger ---
enum SolarChargerState { enum SolarChargerState {
CHARGER_OFF = 0, CHARGER_OFF = 0,
CHARGER_LOW_POWER = 1, CHARGER_LOW_POWER = 1,
@@ -53,162 +55,219 @@ enum SolarChargerState {
CHARGER_EXTERNAL_CONTROL = 252 CHARGER_EXTERNAL_CONTROL = 252
}; };
// Binary data structures for decoding BLE advertisements // ============================================================
// Must use __attribute__((packed)) to prevent compiler padding // Wire-format packed structures for decoding BLE advertisements
// ============================================================
// Manufacturer data structure (outer envelope) struct victronManufacturerData {
typedef struct { uint16_t vendorID;
uint16_t vendorID; // vendor ID uint8_t beaconType; // 0x10 = Product Advertisement
uint8_t beaconType; // Should be 0x10 (Product Advertisement) for the packets we want uint8_t unknownData1[3];
uint8_t unknownData1[3]; // Unknown data uint8_t victronRecordType; // Device type (see VictronDeviceType)
uint8_t victronRecordType; // Should be 0x01 (Solar Charger) for the packets we want uint16_t nonceDataCounter;
uint16_t nonceDataCounter; // Nonce uint8_t encryptKeyMatch; // Should match encryption key byte 0
uint8_t encryptKeyMatch; // Should match pre-shared encryption key byte 0 uint8_t victronEncryptedData[VICTRON_ENCRYPTED_LEN];
uint8_t victronEncryptedData[21]; // (31 bytes max per BLE spec - size of previous elements) } __attribute__((packed));
uint8_t nullPad; // extra byte because toCharArray() adds a \0 byte.
} __attribute__((packed)) victronManufacturerData;
// Decrypted payload structures for each device type
// Solar Charger decrypted payload struct victronSolarChargerPayload {
typedef struct { uint8_t deviceState;
uint8_t deviceState; // Charge state (SolarChargerState enum) uint8_t errorCode;
uint8_t errorCode; // Error code int16_t batteryVoltage; // 10mV units
int16_t batteryVoltage; // Battery voltage in 10mV units int16_t batteryCurrent; // 10mA units (signed)
int16_t batteryCurrent; // Battery current in 10mA units (signed) uint16_t yieldToday; // 10Wh units
uint16_t yieldToday; // Yield today in 10Wh units uint16_t inputPower; // 1W units
uint16_t inputPower; // PV power in 1W units uint16_t loadCurrent; // 10mA units (0xFFFF = no load)
uint16_t loadCurrent; // Load current in 10mA units (0xFFFF = no load) uint8_t reserved[2];
uint8_t reserved[2]; // Reserved bytes } __attribute__((packed));
} __attribute__((packed)) victronSolarChargerPayload;
// Battery Monitor decrypted payload struct victronBatteryMonitorPayload {
typedef struct { uint16_t remainingMins;
uint16_t remainingMins; // Time remaining in minutes uint16_t batteryVoltage; // 10mV units
uint16_t batteryVoltage; // Battery voltage in 10mV units uint8_t alarms;
uint8_t alarms; // Alarm bits uint16_t auxData; // 10mV (voltage) or 0.01K (temperature)
uint16_t auxData; // Aux voltage (10mV) or temperature (0.01K) uint8_t currentLow;
uint8_t currentLow; // Battery current bits 0-7 uint8_t currentMid;
uint8_t currentMid; // Battery current bits 8-15 uint8_t currentHigh_consumedLow; // Current bits 16-21 (low 6), consumed bits 0-1 (high 2)
uint8_t currentHigh_consumedLow; // Current bits 16-21 (low 6 bits), consumed bits 0-1 (high 2 bits) uint8_t consumedMid;
uint8_t consumedMid; // Consumed Ah bits 2-9 uint8_t consumedHigh;
uint8_t consumedHigh; // Consumed Ah bits 10-17 uint16_t soc; // 0.1% units (10-bit)
uint16_t soc; // State of charge in 0.1% units (10-bit value) uint8_t reserved[2];
uint8_t reserved[2]; // Reserved bytes } __attribute__((packed));
} __attribute__((packed)) victronBatteryMonitorPayload;
// Inverter decrypted payload struct victronInverterPayload {
typedef struct { uint8_t deviceState;
uint8_t deviceState; // Device state uint8_t errorCode;
uint8_t errorCode; // Error code uint16_t batteryVoltage; // 10mV units
uint16_t batteryVoltage; // Battery voltage in 10mV units int16_t batteryCurrent; // 10mA units (signed)
int16_t batteryCurrent; // Battery current in 10mA units (signed) uint8_t acPowerLow;
uint8_t acPowerLow; // AC Power bits 0-7 uint8_t acPowerMid;
uint8_t acPowerMid; // AC Power bits 8-15 uint8_t acPowerHigh; // Signed 24-bit
uint8_t acPowerHigh; // AC Power bits 16-23 (signed 24-bit) uint8_t alarms;
uint8_t alarms; // Alarm bits uint8_t reserved[4];
uint8_t reserved[4]; // Reserved bytes } __attribute__((packed));
} __attribute__((packed)) victronInverterPayload;
// DC-DC Converter decrypted payload struct victronDCDCConverterPayload {
typedef struct { uint8_t chargeState;
uint8_t chargeState; // Charge state uint8_t errorCode;
uint8_t errorCode; // Error code uint16_t inputVoltage; // 10mV units
uint16_t inputVoltage; // Input voltage in 10mV units uint16_t outputVoltage; // 10mV units
uint16_t outputVoltage; // Output voltage in 10mV units uint16_t outputCurrent; // 10mA units
uint16_t outputCurrent; // Output current in 10mA units uint8_t reserved[6];
uint8_t reserved[6]; // Reserved bytes } __attribute__((packed));
} __attribute__((packed)) victronDCDCConverterPayload;
// Base structure for all device data // ============================================================
struct VictronDeviceData { // Parsed data structures (flat, no inheritance)
String deviceName; // ============================================================
String macAddress;
VictronDeviceType deviceType;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
VictronDeviceData() : deviceType(DEVICE_TYPE_UNKNOWN), rssi(-100), struct VictronSolarData {
lastUpdate(0), dataValid(false) {} uint8_t chargeState; // SolarChargerState enum
}; uint8_t errorCode;
// Solar Charger specific data
struct SolarChargerData : public VictronDeviceData {
SolarChargerState chargeState;
float batteryVoltage; // V float batteryVoltage; // V
float batteryCurrent; // A float batteryCurrent; // A
float panelVoltage; // V (PV voltage)
float panelPower; // W float panelPower; // W
uint16_t yieldToday; // Wh uint16_t yieldToday; // Wh
float loadCurrent; // A float loadCurrent; // A
SolarChargerData() : chargeState(CHARGER_OFF), batteryVoltage(0),
batteryCurrent(0), panelVoltage(0), panelPower(0),
yieldToday(0), loadCurrent(0) {
deviceType = DEVICE_TYPE_SOLAR_CHARGER;
}
}; };
// Battery Monitor/SmartShunt specific data struct VictronBatteryData {
struct BatteryMonitorData : public VictronDeviceData {
float voltage; // V float voltage; // V
float current; // A (positive = charging, negative = discharging) float current; // A
float temperature; // °C float temperature; // C (0 if aux is voltage)
float auxVoltage; // V (starter battery or midpoint) float auxVoltage; // V (0 if aux is temperature)
uint16_t remainingMinutes; // Minutes uint16_t remainingMinutes;
float consumedAh; // Ah float consumedAh; // Ah
float soc; // State of Charge % float soc; // %
bool alarmLowVoltage; bool alarmLowVoltage;
bool alarmHighVoltage; bool alarmHighVoltage;
bool alarmLowSOC; bool alarmLowSOC;
bool alarmLowTemperature; bool alarmLowTemperature;
bool alarmHighTemperature; bool alarmHighTemperature;
BatteryMonitorData() : voltage(0), current(0), temperature(0),
auxVoltage(0), remainingMinutes(0), consumedAh(0),
soc(0), alarmLowVoltage(false), alarmHighVoltage(false),
alarmLowSOC(false), alarmLowTemperature(false),
alarmHighTemperature(false) {
deviceType = DEVICE_TYPE_BATTERY_MONITOR;
}
}; };
// Inverter specific data struct VictronInverterData {
struct InverterData : public VictronDeviceData {
float batteryVoltage; // V float batteryVoltage; // V
float batteryCurrent; // A float batteryCurrent; // A
float acPower; // W float acPower; // W
uint8_t state; // Inverter state uint8_t state;
bool alarmHighVoltage;
bool alarmLowVoltage; bool alarmLowVoltage;
bool alarmHighVoltage;
bool alarmHighTemperature; bool alarmHighTemperature;
bool alarmOverload; bool alarmOverload;
InverterData() : batteryVoltage(0), batteryCurrent(0), acPower(0),
state(0), alarmHighVoltage(false), alarmLowVoltage(false),
alarmHighTemperature(false), alarmOverload(false) {
deviceType = DEVICE_TYPE_INVERTER;
}
}; };
// DC-DC Converter specific data struct VictronDCDCData {
struct DCDCConverterData : public VictronDeviceData {
float inputVoltage; // V float inputVoltage; // V
float outputVoltage; // V float outputVoltage; // V
float outputCurrent; // A float outputCurrent; // A
uint8_t chargeState; uint8_t chargeState;
uint8_t errorCode; uint8_t errorCode;
DCDCConverterData() : inputVoltage(0), outputVoltage(0), outputCurrent(0),
chargeState(0), errorCode(0) {
deviceType = DEVICE_TYPE_DCDC_CONVERTER;
}
}; };
// Forward declaration // ============================================================
class VictronBLE; // Main device struct with tagged union
// ============================================================
// Callback interface for device data updates struct VictronDevice {
char name[VICTRON_NAME_LEN];
char mac[VICTRON_MAC_LEN];
VictronDeviceType deviceType;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
union {
VictronSolarData solar;
VictronBatteryData battery;
VictronInverterData inverter;
VictronDCDCData dcdc;
};
};
// ============================================================
// Callback — simple function pointer
// ============================================================
typedef void (*VictronCallback)(const VictronDevice* device);
// Forward declaration
class VictronBLEAdvertisedDeviceCallbacks;
// ============================================================
// Main VictronBLE class
// ============================================================
class VictronBLE {
public:
VictronBLE();
bool begin(uint32_t scanDuration = 5);
bool addDevice(const char* name, const char* mac, const char* hexKey,
VictronDeviceType type = DEVICE_TYPE_UNKNOWN);
void setCallback(VictronCallback cb) { callback = cb; }
void setDebug(bool enable) { debugEnabled = enable; }
void setMinInterval(uint32_t ms) { minIntervalMs = ms; }
size_t getDeviceCount() const { return deviceCount; }
void loop();
private:
friend class VictronBLEAdvertisedDeviceCallbacks;
struct DeviceEntry {
VictronDevice device;
uint8_t key[16];
uint16_t lastNonce;
bool active;
};
DeviceEntry devices[VICTRON_MAX_DEVICES];
size_t deviceCount;
BLEScan* pBLEScan;
VictronBLEAdvertisedDeviceCallbacks* scanCallbackObj;
VictronCallback callback;
bool debugEnabled;
uint32_t scanDuration;
uint32_t minIntervalMs;
bool initialized;
static bool hexToBytes(const char* hex, uint8_t* out, size_t len);
static void normalizeMAC(const char* input, char* output);
DeviceEntry* findDevice(const char* normalizedMAC);
bool decryptData(const uint8_t* encrypted, size_t len,
const uint8_t* key, const uint8_t* iv, uint8_t* decrypted);
void processDevice(BLEAdvertisedDevice& dev);
bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg);
bool parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& 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);
};
// BLE scan callback (required by ESP32 BLE API)
class VictronBLEAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
public:
VictronBLEAdvertisedDeviceCallbacks(VictronBLE* parent) : victronBLE(parent) {}
void onResult(BLEAdvertisedDevice advertisedDevice) override;
private:
VictronBLE* victronBLE;
};
// ============================================================
// Commented-out features — kept for reference / future use
// ============================================================
#if 0
// --- VictronDeviceConfig (use addDevice(name, mac, key, type) directly) ---
struct VictronDeviceConfig {
String name;
String macAddress;
String encryptionKey;
VictronDeviceType expectedType;
VictronDeviceConfig() : expectedType(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) {}
};
// --- Virtual callback interface (replaced by function pointer VictronCallback) ---
class VictronDeviceCallback { class VictronDeviceCallback {
public: public:
virtual ~VictronDeviceCallback() {} virtual ~VictronDeviceCallback() {}
@@ -218,114 +277,17 @@ public:
virtual void onDCDCConverterData(const DCDCConverterData& data) {} virtual void onDCDCConverterData(const DCDCConverterData& data) {}
}; };
// Device configuration structure // --- Per-type getter methods (use callback instead) ---
struct VictronDeviceConfig { bool getSolarChargerData(const String& macAddress, SolarChargerData& data);
String name; bool getBatteryMonitorData(const String& macAddress, BatteryMonitorData& data);
String macAddress; bool getInverterData(const String& macAddress, InverterData& data);
String encryptionKey; // 32 character hex string bool getDCDCConverterData(const String& macAddress, DCDCConverterData& data);
VictronDeviceType expectedType;
VictronDeviceConfig() : expectedType(DEVICE_TYPE_UNKNOWN) {} // --- Other removed methods ---
VictronDeviceConfig(String n, String mac, String key, VictronDeviceType type = DEVICE_TYPE_UNKNOWN) void removeDevice(const String& macAddress);
: name(n), macAddress(mac), encryptionKey(key), expectedType(type) {} std::vector<String> getDevicesByType(VictronDeviceType type);
}; String getLastError() const;
// Main VictronBLE class #endif // commented-out features
class VictronBLE {
public:
VictronBLE();
~VictronBLE();
// Initialize BLE and start scanning
bool begin(uint32_t scanDuration = 5);
// Add a device to monitor
bool addDevice(const VictronDeviceConfig& config);
bool addDevice(String name, String macAddress, String encryptionKey,
VictronDeviceType expectedType = DEVICE_TYPE_UNKNOWN);
// Remove a device
void removeDevice(String macAddress);
// Get device count
size_t getDeviceCount() const { return devices.size(); }
// Set callback for data updates
void setCallback(VictronDeviceCallback* cb) { callback = cb; }
// Process scanning (call in loop())
void loop();
// Get latest data for a device
bool getSolarChargerData(String macAddress, SolarChargerData& data);
bool getBatteryMonitorData(String macAddress, BatteryMonitorData& data);
bool getInverterData(String macAddress, InverterData& data);
bool getDCDCConverterData(String macAddress, DCDCConverterData& data);
// Get all devices of a specific type
std::vector<String> getDevicesByType(VictronDeviceType type);
// Enable/disable debug output
void setDebug(bool enable) { debugEnabled = enable; }
// Get last error message
String getLastError() const { return lastError; }
private:
friend class VictronBLEAdvertisedDeviceCallbacks;
struct DeviceInfo {
VictronDeviceConfig config;
VictronDeviceData* data;
uint8_t encryptionKeyBytes[16];
DeviceInfo() : data(nullptr) {
memset(encryptionKeyBytes, 0, 16);
}
~DeviceInfo() {
if (data) delete data;
}
};
std::map<String, DeviceInfo*> devices;
BLEScan* pBLEScan;
VictronDeviceCallback* callback;
bool debugEnabled;
String lastError;
uint32_t scanDuration;
bool initialized;
// XXX Experiment with actual victron data
victronManufacturerData manufacturerData;
// Internal methods
bool hexStringToBytes(const String& hex, uint8_t* bytes, size_t len);
bool decryptAdvertisement(const uint8_t* encrypted, size_t encLen,
const uint8_t* key, const uint8_t* iv,
uint8_t* decrypted);
bool parseAdvertisement(const String& macAddress);
void processDevice(BLEAdvertisedDevice advertisedDevice);
VictronDeviceData* createDeviceData(VictronDeviceType type);
bool parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result);
bool parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMonitorData& result);
bool parseInverter(const uint8_t* data, size_t len, InverterData& result);
bool parseDCDCConverter(const uint8_t* data, size_t len, DCDCConverterData& result);
void debugPrint(const String& message);
String macAddressToString(BLEAddress address);
String normalizeMAC(String mac);
};
// BLE scan callback class
class VictronBLEAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
public:
VictronBLEAdvertisedDeviceCallbacks(VictronBLE* parent) : victronBLE(parent) {}
void onResult(BLEAdvertisedDevice advertisedDevice) override;
private:
VictronBLE* victronBLE;
};
#endif // VICTRON_BLE_H #endif // VICTRON_BLE_H