39 Commits

Author SHA1 Message Date
scottp 53fa6fa0e1 ignore pka file 2026-06-04 23:48:47 +10:00
scottp de6607d870 Branch version ready for testing with nRF52 2026-06-04 23:47:55 +10:00
scottp 105d66b07a Update info 2026-06-04 22:55:40 +10:00
scottp cd3b4626d1 ignore local 2026-06-04 21:21:55 +10:00
scottp 7706f88399 Bug fixes for reported issues 2026-06-04 21:21:18 +10:00
scottp e7024d9983 Plans for part 2 - multiple bluetooth devices 2026-02-28 15:00:45 +11:00
scottp 261cc0d1fe Improve readme ready for v0.4 release 2026-02-28 14:40:41 +11:00
scottp 39a89c816c Versions v0.4 ready for release 2026-02-28 14:38:30 +11:00
scottp 4944757903 Fix to be non blocking without tasks 2026-02-28 14:31:42 +11:00
scottp 31765c7ac8 Update notes 2026-02-28 13:52:16 +11:00
scottp 84d153c9a8 Single callback version - vastly simplified. 2026-02-28 13:34:20 +11:00
scottp 8a4e010324 More planning 2026-02-28 12:31:04 +11:00
scottp 977641b093 Forwarding code 2026-02-17 09:27:48 +11:00
scottp 24712c206a Work on receiver and sender 2026-02-15 19:20:03 +11:00
scottp 8a2402cb63 Repeater and Test code for ESP Now 2026-02-15 19:10:19 +11:00
scottp a64fef899b New version with smaller memory footprint etc 2026-02-12 18:33:56 +11:00
scottp a843eb924b Keep v0.3.1 2026-02-12 18:10:35 +11:00
scottp 5a210fb88f Experimenting with a claude file and created new logging example 2026-02-12 18:04:02 +11:00
scottp 30c93af18b Fix status 2026-01-29 18:45:37 +11:00
scottp d9577be900 Scripts and automatic claude update 2026-01-29 18:42:41 +11:00
scottp f9e72a68fe Examples todo 2025-12-30 20:54:04 +11:00
scottp a8d40ba260 Fix notes on testing 2025-12-29 20:35:47 +11:00
scottp af39db8732 Fix name 2025-12-29 20:31:07 +11:00
scottp 26b0196791 Decoding working for MPPT 2025-12-29 20:22:41 +11:00
scottp 0863f8572c Finally working decode MPPT 2025-12-29 20:00:05 +11:00
scottp d3b1c632db work on better mac address 2025-12-29 19:26:32 +11:00
scottp 03d8da3b7d Cleaning up by using structs and reusing data blocks 2025-12-29 19:12:47 +11:00
scottp 6a517246ea Experimental version 2025-12-29 13:38:47 +11:00
scottp 4bbab345b0 Working serial on S3 too. Interesting... 2025-12-29 11:40:46 +11:00
scottp 1a651b149d Merge branch 'main' of https://gitea.sh3d.com.au/sh3d/VictronBLE 2025-12-29 11:16:43 +11:00
scottp cec45524d3 Add core2 2025-12-29 11:16:41 +11:00
scottp 2bd6094955 Cleanup only 2025-12-29 11:09:33 +11:00
scottp 9f0f2ce8fd Improved structs 2025-12-28 23:35:27 +11:00
scottp 8e5eba47d7 Working C3 build 2025-12-28 23:27:40 +11:00
scottp 95d83b492a Playing with debug 2025-12-19 12:46:28 +11:00
scottp 139c6f961d Work on decoding using structs 2025-12-18 22:27:15 +11:00
scottp 2ccac7b0c8 Experimenting and decoding - seems some structs are wrong, check original code 2025-12-18 21:49:04 +11:00
scottp 97a71ce34c TODO and m5stick and debug 2025-12-18 20:43:10 +11:00
scottp e827dea4e5 Ignore builds 2025-12-18 18:26:27 +11:00
28 changed files with 3973 additions and 1132 deletions
+322
View File
@@ -0,0 +1,322 @@
# VictronBLE Project Context
## Project Overview
Portable Arduino library for reading Victron Energy devices via Bluetooth Low Energy (BLE). Runs on ESP32 (Bluedroid) and nRF52840 (Bluefruit); BLE scanning is the only platform-specific code (src/esp32, src/nrf52), while decoding and AES-128-CTR crypto are common.
## Key Files
- `src/` - Main library source code
- `examples/` - Example sketches
- `experiment/` - Experimental code
- `library.json` / `library.properties` - PlatformIO/Arduino library config
## Build & Test
- This is an Arduino/PlatformIO library
- Test with PlatformIO: `pio run`
## Session Notes
<!-- Add learnings from each session below -->
### Session: 2026-01-29 18:41
**Modified files:**
- TODO
### Session: 2026-02-11 13:51
**Modified files:**
- .claude/CLAUDE.md
- .claude/scripts/update-claude-md.sh
- TODO
- examples/MultiDevice/src/main.cpp
### Session: 2026-02-11 15:57
**Modified files:**
- .claude/CLAUDE.md
- .claude/scripts/update-claude-md.sh
- TODO
- examples/MultiDevice/src/main.cpp
### Session: 2026-02-12 18:02
**Modified files:**
- .claude/CLAUDE.md
- .claude/scripts/update-claude-md.sh
- TODO
- examples/MultiDevice/src/main.cpp
### Session: 2026-02-12 18:02
**Modified files:**
- .claude/CLAUDE.md
- .claude/scripts/update-claude-md.sh
- TODO
- examples/MultiDevice/src/main.cpp
- library.json
### Session: 2026-02-12 18:06
**Commits:**
```
5a210fb Experimenting with a claude file and created new logging example
```
**Modified files:**
- .claude/CLAUDE.md
- TODO
- examples/Logger/platformio.ini
- examples/Logger/src/main.cpp
- examples/MultiDevice/src/main.cpp
- library.json
### Session: 2026-02-12 18:08
**Commits:**
```
5a210fb Experimenting with a claude file and created new logging example
```
**Modified files:**
- .claude/CLAUDE.md
- README.md
- TODO
- VERSIONS
- examples/Logger/platformio.ini
- examples/Logger/src/main.cpp
- examples/MultiDevice/src/main.cpp
- library.json
- library.properties
### Session: 2026-02-12 18:10
**Commits:**
```
5a210fb Experimenting with a claude file and created new logging example
```
**Modified files:**
- .claude/CLAUDE.md
- README.md
- TODO
- VERSIONS
- examples/Logger/platformio.ini
- examples/Logger/src/main.cpp
- examples/MultiDevice/src/main.cpp
- library.json
- 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
### Session: 2026-02-28 14:32
**Commits:**
```
4944757 Fix to be non blocking without tasks
31765c7 Update notes
84d153c Single callback version - vastly simplified.
```
**Modified files:**
- examples/Logger/src/main.cpp
- examples/MultiDevice/src/main.cpp
- examples/Repeater/src/main.cpp
- library.json
- library.properties
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-28 14:33
**Commits:**
```
4944757 Fix to be non blocking without tasks
31765c7 Update notes
84d153c Single callback version - vastly simplified.
```
**Modified files:**
- .claude/CLAUDE.md
- VERSIONS
- examples/Logger/src/main.cpp
- examples/MultiDevice/src/main.cpp
- examples/Repeater/src/main.cpp
- library.json
- library.properties
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-28 14:36
**Commits:**
```
4944757 Fix to be non blocking without tasks
31765c7 Update notes
```
**Modified files:**
- .claude/CLAUDE.md
- VERSIONS
- examples/Logger/src/main.cpp
- examples/MultiDevice/src/main.cpp
- examples/Repeater/src/main.cpp
- library.json
- library.properties
- src/VictronBLE.cpp
- src/VictronBLE.h
### Session: 2026-02-28 14:40
**Commits:**
```
39a89c8 Versions v0.4 ready for release
4944757 Fix to be non blocking without tasks
31765c7 Update notes
```
**Modified files:**
- .claude/CLAUDE.md
- README.md
- VERSIONS
- library.json
- library.properties
### Session: 2026-02-28 14:48
**Commits:**
```
261cc0d Improve readme ready for v0.4 release
39a89c8 Versions v0.4 ready for release
4944757 Fix to be non blocking without tasks
31765c7 Update notes
```
**Modified files:**
- .claude/CLAUDE.md
- README.md
- REVIEW.md
### Session: 2026-06-04 21:23
**Commits:**
```
cd3b462 ignore local
7706f88 Bug fixes for reported issues
```
**Modified files:**
- .gitignore
- README.md
### Session: 2026-06-04 22:59
**Commits:**
```
105d66b Update info
```
**Modified files:**
- .claude/CLAUDE.md
- README.md
### Session: 2026-06-04 23:46
**Commits:**
```
105d66b Update info
```
**Modified files:**
- .claude/CLAUDE.md
- README.md
- VERSIONS
- examples/MultiDevice/platformio.ini
- examples/MultiDevice/src/main.cpp
- library.json
- library.properties
- src/VictronBLE.cpp
- src/VictronBLE.h
- src/crypto/vble_aes.c
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Auto-update CLAUDE.md at end of session
CLAUDE_MD="$(git rev-parse --show-toplevel)/.claude/CLAUDE.md"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M')
# Get recent git activity from this session (last hour)
RECENT_COMMITS=$(git log --oneline --since="1 hour ago" 2>/dev/null | head -5)
MODIFIED_FILES=$(git diff --name-only HEAD~1 2>/dev/null | head -10)
# Append session summary
{
echo ""
echo "### Session: $TIMESTAMP"
if [ -n "$RECENT_COMMITS" ]; then
echo "**Commits:**"
echo "\`\`\`"
echo "$RECENT_COMMITS"
echo "\`\`\`"
fi
if [ -n "$MODIFIED_FILES" ]; then
echo "**Modified files:**"
echo "$MODIFIED_FILES" | sed 's/^/- /'
fi
echo ""
} >> "$CLAUDE_MD"
echo "Updated CLAUDE.md with session summary"
+6
View File
@@ -1,3 +1,5 @@
PKA_SUMMARY.md
# PlatformIO # PlatformIO
.pio .pio
.pioenvs .pioenvs
@@ -12,6 +14,8 @@ compile_commands.json
!.vscode/extensions.json !.vscode/extensions.json
.history/ .history/
bugs
# Build artifacts # Build artifacts
*.o *.o
*.a *.a
@@ -66,3 +70,5 @@ __pycache__/
.Python .Python
venv/ venv/
env/ env/
*.tar.gz
+207 -173
View File
@@ -1,20 +1,24 @@
# VictronBLE # VictronBLE
ESP32 library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. A portable Arduino library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements — runs on both **ESP32** and **nRF52840**.
**⚠️ INITIAL RELEASE - NOT YET TESTED ON HARDWARE** **⚠️ API CHANGE in v0.4 — not backwards compatible with v0.3.x**
This is an initial release (v0.1.1) and has not yet been tested with real Victron devices. Use with caution and please report any issues you encounter. Testing and feedback are greatly appreciated! v0.4 is a major rework of the library internals: new callback API, reduced memory usage, non-blocking scanning. See [VERSIONS](VERSIONS) for full details. A stable **v1.0** release with a consistent, long-term API is coming soon.
--- ---
Why another library? Most of the Victron BLE examples are built into other frameworks (e.g. ESPHome) and I want a library that can be used in all ESP32 systems, including ESPHome or other frameworks. With long term plan to try and move others to this library and improve code with many eyes. Why another library? Most of the Victron BLE examples are built into other frameworks (e.g. ESPHome) or are locked to a single chip. The goal here is one library that works across ESP32 and nRF52 (and is easy to extend to more), usable standalone or inside ESPHome and other frameworks, with a long-term plan to move others onto it and improve the code with many eyes.
Supports **ESP32** (original, S and C series — tested on older ESP32, ESP32-S3 and ESP32-C3) and **nRF52840** (Adafruit/Seeed Bluefruit core, e.g. Seeed XIAO nRF52840). All decoding and decryption is shared; only a thin BLE scanning backend is platform-specific (`src/esp32/`, `src/nrf52/`), so other chipsets can be added by implementing one more backend.
## Features ## Features
-**Multi-Platform**: One API for ESP32 and nRF52840; backend chosen at compile time
-**No External Dependencies**: Bundled AES-128-CTR — no mbedTLS or crypto library needed
-**Multiple Device Support**: Monitor multiple Victron devices simultaneously -**Multiple Device Support**: Monitor multiple Victron devices simultaneously
-**All Device Types**: Solar chargers, battery monitors, inverters, DC-DC converters -**All Device Types**: Solar chargers, battery monitors, inverters, DC-DC converters, AC chargers
-**Framework Agnostic**: Works with Arduino and ESP-IDF -**Framework Friendly**: Works with Arduino (and ESP-IDF on ESP32)
-**Clean API**: Simple, intuitive interface with callback support -**Clean API**: Simple, intuitive interface with callback support
-**No Pairing Required**: Reads BLE advertisement data directly -**No Pairing Required**: Reads BLE advertisement data directly
-**Low Power**: Uses passive BLE scanning -**Low Power**: Uses passive BLE scanning
@@ -27,29 +31,58 @@ Why another library? Most of the Victron BLE examples are built into other frame
- **Battery Monitors**: SmartShunt, BMV-712 Smart, BMV-700 series - **Battery Monitors**: SmartShunt, BMV-712 Smart, BMV-700 series
- **Inverters**: MultiPlus, Quattro, Phoenix (with VE.Bus BLE dongle) - **Inverters**: MultiPlus, Quattro, Phoenix (with VE.Bus BLE dongle)
- **DC-DC Converters**: Orion Smart, Orion XS - **DC-DC Converters**: Orion Smart, Orion XS
- **AC Chargers**: Blue Smart IP22 / IP65 / IP67 chargers
- **Others**: Smart Battery Protect, Lynx Smart BMS, Smart Lithium batteries - **Others**: Smart Battery Protect, Lynx Smart BMS, Smart Lithium batteries
## Hardware Requirements ## Hardware Requirements
- ESP32, ESP32-S3, or ESP32-C3 board - An ESP32 (original / S / C series) **or** an nRF52840 board (Adafruit/Seeed
Bluefruit core — e.g. Seeed XIAO nRF52840)
- Victron devices with BLE "Instant Readout" enabled - Victron devices with BLE "Instant Readout" enabled
The BLE backend is selected automatically at compile time from the board's
architecture — no code changes are needed to switch platforms.
## Installation ## Installation
### PlatformIO ### PlatformIO
1. Add to `platformio.ini`: 1. Add to `platformio.ini` (recommended — installs from the PlatformIO registry):
```ini ```ini
lib_deps = lib_deps =
https://gitea.sh3d.com.au/Sh3d/VictronBLE scottp/victronble
```
Or install directly from git. Note the **`.git` suffix is required** — the bare
repository URL is not accepted by PlatformIO:
```ini
lib_deps =
https://gitea.sh3d.com.au/Sh3d/VictronBLE.git
``` ```
2. Or clone into your project's `lib` folder: 2. Or clone into your project's `lib` folder:
```bash ```bash
cd lib cd lib
git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE.git
``` ```
#### nRF52840 board note
The nRF52 backend uses the **Bluefruit** library from the Adafruit/Seeed nRF52
core, so pick a board that uses that core. For the Seeed XIAO nRF52840, the
board files live in a community platform fork — use the `*_adafruit` variant
(the plain `xiaoble` variant uses the mbed core, which has no Bluefruit):
```ini
[env:xiao_nrf52840]
platform = https://github.com/maxgerhardt/platform-nordicnrf52
board = xiaoble_adafruit ; XIAO nRF52840 Sense: xiaoblesense_adafruit
framework = arduino
lib_deps = scottp/victronble
```
The Adafruit Feather nRF52840 (`board = adafruit_feather_nrf52840`) works out of
the box with the stock PlatformIO `nordicnrf52` platform. The `MultiDevice`
example's `platformio.ini` includes ready-made ESP32 and nRF52 environments.
### Arduino IDE ### Arduino IDE
1. Download or clone this repository 1. Download or clone this repository
@@ -78,38 +111,34 @@ Use the VictronConnect app to get your device's encryption key:
VictronBLE victron; VictronBLE victron;
// Callback for data updates // Callback — receives a VictronDevice*, switch on deviceType
class MyCallback : public VictronDeviceCallback { void onVictronData(const VictronDevice* dev) {
public: if (dev->deviceType == DEVICE_TYPE_SOLAR_CHARGER) {
void onSolarChargerData(const SolarChargerData& data) override { Serial.printf("Solar %s: %.2fV %.2fA %dW\n",
Serial.printf("Solar: %.2fV, %.2fA, %dW\n", dev->name,
data.batteryVoltage, dev->solar.batteryVoltage,
data.batteryCurrent, dev->solar.batteryCurrent,
data.panelPower); (int)dev->solar.panelPower);
}
} }
};
MyCallback callback;
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
// Initialize library
victron.begin(5); // 5 second scan duration victron.begin(5); // 5 second scan duration
victron.setCallback(&callback); victron.setCallback(onVictronData);
// Add your device (replace with your MAC and key) // Add your device (replace with your MAC and key)
victron.addDevice( victron.addDevice(
"My MPPT", // Name "My MPPT", // Name
"AA:BB:CC:DD:EE:FF", // MAC address "AA:BB:CC:DD:EE:FF", // MAC address
"0123456789abcdef0123456789abcdef", // Encryption key "0123456789abcdef0123456789abcdef", // Encryption key
DEVICE_TYPE_SOLAR_CHARGER // Device type DEVICE_TYPE_SOLAR_CHARGER // Device type (optional, auto-detected)
); );
} }
void loop() { void loop() {
victron.loop(); victron.loop(); // Non-blocking, returns immediately
delay(100);
} }
``` ```
@@ -122,130 +151,113 @@ void loop() {
```cpp ```cpp
bool begin(uint32_t scanDuration = 5); bool begin(uint32_t scanDuration = 5);
``` ```
Initialize BLE and start scanning. Returns `true` on success. Initialize BLE scanning. Returns `true` on success.
**Parameters:** **Parameters:**
- `scanDuration`: BLE scan duration in seconds (default: 5) - `scanDuration`: BLE scan window in seconds (default: 5)
#### Device Management #### Device Management
```cpp ```cpp
bool addDevice(String name, String macAddress, String encryptionKey, bool addDevice(const char* name, const char* mac, const char* hexKey,
VictronDeviceType expectedType = DEVICE_TYPE_UNKNOWN); VictronDeviceType type = DEVICE_TYPE_UNKNOWN);
``` ```
Add a device to monitor. Add a device to monitor (max 8 devices).
**Parameters:** **Parameters:**
- `name`: Friendly name for the device - `name`: Friendly name for the device
- `macAddress`: Device MAC address (format: "AA:BB:CC:DD:EE:FF") - `mac`: Device MAC address (format: `"AA:BB:CC:DD:EE:FF"` or `"aabbccddeeff"`)
- `encryptionKey`: 32-character hex encryption key from VictronConnect - `hexKey`: 32-character hex encryption key from VictronConnect
- `expectedType`: Device type (optional, for validation) - `type`: Device type (optional, auto-detected from BLE advertisement)
**Returns:** `true` on success **Returns:** `true` on success
```cpp ```cpp
void removeDevice(String macAddress); size_t getDeviceCount() const;
```
Remove a device from monitoring.
```cpp
size_t getDeviceCount();
``` ```
Get the number of configured devices. Get the number of configured devices.
#### Data Access #### Callback
```cpp ```cpp
bool getSolarChargerData(String macAddress, SolarChargerData& data); void setCallback(VictronCallback cb);
bool getBatteryMonitorData(String macAddress, BatteryMonitorData& data);
bool getInverterData(String macAddress, InverterData& data);
bool getDCDCConverterData(String macAddress, DCDCConverterData& data);
``` ```
Get latest data for a specific device. Returns `true` if data is valid. Set a function pointer callback. Called when new data arrives from a device. The callback receives a `const VictronDevice*` — switch on `deviceType` to access the appropriate data union member.
```cpp ```cpp
std::vector<String> getDevicesByType(VictronDeviceType type); typedef void (*VictronCallback)(const VictronDevice* device);
``` ```
Get MAC addresses of all devices of a specific type.
#### Callbacks #### Configuration
```cpp ```cpp
void setCallback(VictronDeviceCallback* callback); void setMinInterval(uint32_t ms);
``` ```
Set callback object to receive data updates automatically. Set minimum callback interval per device (default: 1000ms). Callbacks are also suppressed when the device nonce hasn't changed (data unchanged).
#### Utilities
```cpp ```cpp
void setDebug(bool enable); void setDebug(bool enable);
``` ```
Enable/disable debug output to Serial. Enable/disable debug output to Serial.
```cpp #### Main Loop
String getLastError();
```
Get last error message.
```cpp ```cpp
void loop(); void loop();
``` ```
Process BLE scanning and data updates. Call this in your main loop. Call in your main loop. Non-blocking — returns immediately if a scan is already running. Scan restarts automatically when it completes.
### Data Structures ### Data Structures
#### SolarChargerData #### VictronDevice (main struct)
All device types share this struct. Access type-specific data via the union member matching `deviceType`.
```cpp ```cpp
struct SolarChargerData { struct VictronDevice {
String deviceName; char name[32];
String macAddress; char mac[13]; // 12 hex chars + null
VictronDeviceType deviceType;
int8_t rssi; // Signal strength (dBm) int8_t rssi; // Signal strength (dBm)
uint32_t lastUpdate; // millis() of last update uint32_t lastUpdate; // millis() of last update
bool dataValid; // Data validity flag bool dataValid;
union {
VictronSolarData solar;
VictronBatteryData battery;
VictronInverterData inverter;
VictronDCDCData dcdc;
};
};
```
SolarChargerState chargeState; // Charging state #### VictronSolarData
```cpp
struct VictronSolarData {
uint8_t chargeState; // SolarChargerState enum
uint8_t errorCode;
float batteryVoltage; // V float batteryVoltage; // V
float batteryCurrent; // A float batteryCurrent; // A
float panelVoltage; // V (calculated)
float panelPower; // W float panelPower; // W
uint16_t yieldToday; // Wh uint16_t yieldToday; // Wh
float loadCurrent; // A (if load output present) float loadCurrent; // A (if load output present)
}; };
``` ```
**Charge States:** **Charge States** (`chargeState` values):
- `CHARGER_OFF` - Off `CHARGER_OFF`, `CHARGER_LOW_POWER`, `CHARGER_FAULT`, `CHARGER_BULK`, `CHARGER_ABSORPTION`, `CHARGER_FLOAT`, `CHARGER_STORAGE`, `CHARGER_EQUALIZE`, `CHARGER_INVERTING`, `CHARGER_POWER_SUPPLY`, `CHARGER_EXTERNAL_CONTROL`
- `CHARGER_LOW_POWER` - Low power
- `CHARGER_FAULT` - Fault
- `CHARGER_BULK` - Bulk charging
- `CHARGER_ABSORPTION` - Absorption
- `CHARGER_FLOAT` - Float
- `CHARGER_STORAGE` - Storage mode
- `CHARGER_EQUALIZE` - Equalize
- `CHARGER_INVERTING` - Inverting (HUB-4)
- `CHARGER_POWER_SUPPLY` - Power supply mode
- `CHARGER_EXTERNAL_CONTROL` - External control
#### BatteryMonitorData #### VictronBatteryData
```cpp ```cpp
struct BatteryMonitorData { struct VictronBatteryData {
String deviceName;
String macAddress;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
float voltage; // V float voltage; // V
float current; // A (+ charging, - discharging) float current; // A (+ charging, - discharging)
float temperature; // °C (if configured) float temperature; // C (0 if aux is voltage)
float auxVoltage; // V (starter battery/midpoint) float auxVoltage; // V (0 if aux is temperature)
uint16_t remainingMinutes; // Time remaining uint16_t remainingMinutes;
float consumedAh; // Ah consumed float consumedAh; // Ah
float soc; // State of charge % float soc; // State of charge %
// Alarms
bool alarmLowVoltage; bool alarmLowVoltage;
bool alarmHighVoltage; bool alarmHighVoltage;
bool alarmLowSOC; bool alarmLowSOC;
@@ -254,39 +266,25 @@ struct BatteryMonitorData {
}; };
``` ```
#### InverterData #### VictronInverterData
```cpp ```cpp
struct InverterData { struct VictronInverterData {
String deviceName;
String macAddress;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
float batteryVoltage; // V float batteryVoltage; // V
float batteryCurrent; // A float batteryCurrent; // A
float acPower; // W (+ inverting, - charging) float acPower; // W (+ inverting, - charging)
uint8_t state; // Inverter state uint8_t state;
// Alarms
bool alarmHighVoltage;
bool alarmLowVoltage; bool alarmLowVoltage;
bool alarmHighVoltage;
bool alarmHighTemperature; bool alarmHighTemperature;
bool alarmOverload; bool alarmOverload;
}; };
``` ```
#### DCDCConverterData #### VictronDCDCData
```cpp ```cpp
struct DCDCConverterData { struct VictronDCDCData {
String deviceName;
String macAddress;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
float inputVoltage; // V float inputVoltage; // V
float outputVoltage; // V float outputVoltage; // V
float outputCurrent; // A float outputCurrent; // A
@@ -295,6 +293,23 @@ struct DCDCConverterData {
}; };
``` ```
#### VictronACChargerData
```cpp
struct VictronACChargerData {
uint8_t chargeState; // SolarChargerState enum (shared charger states)
uint8_t errorCode;
float voltage1; // V (output 1)
float current1; // A (output 1)
float voltage2; // V (output 2, 0 if absent)
float current2; // A (output 2, 0 if absent)
float voltage3; // V (output 3, 0 if absent)
float current3; // A (output 3, 0 if absent)
float temperature; // C (0 if not available)
float acCurrent; // A (0 if not available)
};
```
## Advanced Usage ## Advanced Usage
### Multiple Devices ### Multiple Devices
@@ -302,78 +317,57 @@ struct DCDCConverterData {
```cpp ```cpp
void setup() { void setup() {
victron.begin(5); victron.begin(5);
victron.setCallback(&callback); victron.setCallback(onVictronData);
// Add multiple devices // Add multiple devices (type is auto-detected from BLE advertisements)
victron.addDevice("MPPT 1", "AA:BB:CC:DD:EE:01", "key1...", DEVICE_TYPE_SOLAR_CHARGER); victron.addDevice("MPPT 1", "AA:BB:CC:DD:EE:01", "key1...");
victron.addDevice("MPPT 2", "AA:BB:CC:DD:EE:02", "key2...", DEVICE_TYPE_SOLAR_CHARGER); victron.addDevice("MPPT 2", "AA:BB:CC:DD:EE:02", "key2...");
victron.addDevice("SmartShunt", "AA:BB:CC:DD:EE:03", "key3...", DEVICE_TYPE_BATTERY_MONITOR); victron.addDevice("SmartShunt", "AA:BB:CC:DD:EE:03", "key3...");
victron.addDevice("Inverter", "AA:BB:CC:DD:EE:04", "key4...", DEVICE_TYPE_INVERTER); victron.addDevice("Inverter", "AA:BB:CC:DD:EE:04", "key4...");
} }
``` ```
### Manual Data Polling ### Handling Multiple Device Types
```cpp ```cpp
void loop() { void onVictronData(const VictronDevice* dev) {
victron.loop(); switch (dev->deviceType) {
case DEVICE_TYPE_SOLAR_CHARGER:
// Query specific device Serial.printf("%s: %.2fV %dW\n", dev->name,
SolarChargerData mpptData; dev->solar.batteryVoltage, (int)dev->solar.panelPower);
if (victron.getSolarChargerData("AA:BB:CC:DD:EE:FF", mpptData)) { break;
if (mpptData.dataValid) { case DEVICE_TYPE_BATTERY_MONITOR:
// Use data Serial.printf("%s: %.2fV %.1f%%\n", dev->name,
float power = mpptData.panelPower; dev->battery.voltage, dev->battery.soc);
break;
case DEVICE_TYPE_INVERTER:
Serial.printf("%s: %dW\n", dev->name, (int)dev->inverter.acPower);
break;
case DEVICE_TYPE_DCDC_CONVERTER:
Serial.printf("%s: %.2fV -> %.2fV\n", dev->name,
dev->dcdc.inputVoltage, dev->dcdc.outputVoltage);
break;
case DEVICE_TYPE_AC_CHARGER:
Serial.printf("%s: %.2fV %.2fA %.0fC\n", dev->name,
dev->acCharger.voltage1, dev->acCharger.current1,
dev->acCharger.temperature);
break;
default:
break;
} }
} }
delay(1000);
}
``` ```
### Find All Devices of a Type ### Callback Throttling
```cpp ```cpp
void loop() { void setup() {
victron.loop(); victron.begin(5);
victron.setCallback(onVictronData);
victron.setMinInterval(2000); // Callback at most every 2 seconds per device
// Get all solar chargers // ...
std::vector<String> mppts = victron.getDevicesByType(DEVICE_TYPE_SOLAR_CHARGER);
for (const String& mac : mppts) {
SolarChargerData data;
if (victron.getSolarChargerData(mac, data)) {
Serial.println(data.deviceName + ": " + String(data.panelPower) + "W");
} }
}
delay(5000);
}
```
### Callback Interface
Implement `VictronDeviceCallback` to receive automatic updates:
```cpp
class MyCallback : public VictronDeviceCallback {
public:
void onSolarChargerData(const SolarChargerData& data) override {
// Handle solar charger update
}
void onBatteryMonitorData(const BatteryMonitorData& data) override {
// Handle battery monitor update
}
void onInverterData(const InverterData& data) override {
// Handle inverter update
}
void onDCDCConverterData(const DCDCConverterData& data) override {
// Handle DC-DC converter update
}
};
``` ```
## Troubleshooting ## Troubleshooting
@@ -410,18 +404,55 @@ This library implements the Victron BLE Advertising protocol:
Based on official [Victron BLE documentation](https://www.victronenergy.com/live/vedirect_protocol:faq). Based on official [Victron BLE documentation](https://www.victronenergy.com/live/vedirect_protocol:faq).
## Architecture & Portability
The library keeps everything platform-independent except the BLE radio:
```
src/
├── VictronBLE.{h,cpp} Common API, device management, payload decoding
├── crypto/vble_aes.{h,c} Bundled AES-128-CTR (no external dependency)
├── esp32/ ESP32 backend — Bluedroid BLEScan
└── nrf52/ nRF52 backend — Bluefruit passive scan
```
- **One BLE HAL.** Each backend extracts the manufacturer data, MAC and RSSI
from a scan result and calls the shared `onAdvertisement()`. All decryption and
decoding is common code. The correct backend is selected automatically at
compile time from the board architecture (`ARDUINO_ARCH_ESP32` /
`ARDUINO_ARCH_NRF52`) — there is nothing platform-specific in your sketch.
- **No external crypto.** AES-128-CTR is bundled (a trimmed, NIST-verified
tiny-AES), so the library no longer depends on mbedTLS or any crypto library
and builds identically on every target.
- **Adding a platform** means implementing one more backend (scan → extract →
`onAdvertisement`); the rest is reused unchanged.
> The data callback runs in the BLE event context (the scan task on ESP32, the
> SoftDevice/Bluefruit handler on nRF52). Keep work in the callback light — copy
> what you need and process it from `loop()`.
## Examples ## Examples
See the `examples/` directory for: See the `examples/` directory for:
- **MultiDevice**: Monitor multiple devices with callbacks - **MultiDevice**: Monitor multiple devices with callbacks. One sketch, multiple
- More examples coming soon! PlatformIO environments — builds for ESP32 (`esp32dev`, …) and nRF52840
(`xiao_nrf52840`, `adafruit_feather_nrf52840`).
- **Logger**: Change-detection logging for Solar Charger data
- **Repeater**: Collect BLE data and re-transmit via ESPNow broadcast
- **Receiver**: Receive ESPNow packets from a Repeater and display data
- **FakeRepeater**: Generate test ESPNow packets without real Victron hardware
## Contributing ## Contributing
The primary repository is hosted on [Gitea](https://gitea.sh3d.com.au/Sh3d/VictronBLE),
with a mirror on **GitHub at <https://github.com/SH3D/VictronBLE>**. Since the Gitea
instance does not currently allow public sign-ups, please raise **issues and pull
requests on the GitHub mirror**.
Contributions welcome! Please: Contributions welcome! Please:
1. Fork the repository 1. Fork the [GitHub mirror](https://github.com/SH3D/VictronBLE)
2. Create a feature branch 2. Create a feature branch
3. Test thoroughly on real hardware 3. Test thoroughly on real hardware
4. Submit a pull request 4. Submit a pull request
@@ -453,7 +484,10 @@ See [VERSIONS](VERSIONS) file for detailed changelog and release history.
## Support ## Support
- 📫 Report issues on GitHub - 📫 Report issues on the [GitHub mirror](https://github.com/SH3D/VictronBLE/issues)
(the Gitea instance does not currently allow public sign-ups). Bug reports, device
decode problems and new device requests are all welcome — debug log output is very
helpful.
- 📖 Check the examples directory - 📖 Check the examples directory
- 🔧 Enable debug mode for diagnostics - 🔧 Enable debug mode for diagnostics
- 📚 See [Victron documentation](https://www.victronenergy.com/live/) - 📚 See [Victron documentation](https://www.victronenergy.com/live/)
+524
View File
@@ -0,0 +1,524 @@
# VictronBLE Code Review
## Part 1: Bug Fixes, Efficiency & Simplification ✅ COMPLETE (v0.4.1)
### 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.
All items implemented in v0.4.1. See [VERSIONS](VERSIONS) for full changelog.
---
## Part 2: Multi-Platform BLE Support
### Recommended Test Hardware
Two cheap BLE development boards for testing the platform abstraction:
**1. Seeed XIAO nRF52840 (~$10 USD)**
- Nordic nRF52840 SoC, Bluetooth 5.0, onboard antenna
- Arduino-compatible via Adafruit nRF52 board support package
- Ultra-small (21x17.5mm), USB-C, battery charging built in
- 1MB flash, 256KB RAM, 2MB QSPI flash
- Has mbedtls available via the nRF SDK
- https://www.seeedstudio.com/Seeed-XIAO-BLE-nRF52840-p-5201.html
**2. Raspberry Pi Pico W (~$6 USD)**
- RP2040 + Infineon CYW43439 (WiFi + Bluetooth 5.2 with BLE)
- Arduino-compatible via arduino-pico core (earlephilhower)
- BLE Central role supported (needed for passive scanning)
- Very widely available and cheap
- Different architecture (ARM Cortex-M0+) from ESP32 (Xtensa/RISC-V), good for testing portability
- https://www.raspberrypi.com/products/raspberry-pi-pico/
Both boards are under $15, Arduino-compatible, and have BLE Central support needed for passive scanning of Victron advertisements. They use different BLE stacks (nRF SoftDevice vs CYW43 BTstack) which will validate the transport abstraction layer.
### 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
+31
View File
@@ -0,0 +1,31 @@
# Misc Stuff
* Consider support for upper/lower case MAC address and optionaly ":"
* Scanning - list devices publishing, should be able to get list even without knowing MAC / Encryption key
* Struct vs Manual
* Sh3dNg version and examples uses structs to get data - seems to work
* Example generated uses manually managing a string
* Reconsider what is best and use
# Debugging
Use standard ESP32 debugging stuff. Handler should not be string but more like printf? Consider alternatives
Make sure debugging can be to file or serial etc
# Decrypting
Review 2 methods of decrypting and check it is working correctly
Seems mbedTLS is better choice as auotmatic hardware support even on ESP32 - where as the esp_aes version is not portable.
# Examples
* Multiple threads - scan BLE in the background
* Example scan anything
* With and without Callback
* Platformio.ini files into example data
# Logging and Debugging
* The debugging is very verbose and hard to read - maybe group the messages together for repeats
+106
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);
```
+114
View File
@@ -1,5 +1,119 @@
# Version History # Version History
## 0.6.0 (2026-06-04)
Multi-platform support. The library now runs on **nRF52840** (Adafruit/Seeed
Bluefruit core) in addition to ESP32, sharing all decoding and crypto code.
### Platform abstraction
- The BLE scanning layer is now the only platform-specific code, split into
backends under `src/esp32/` (ESP32 Bluedroid `BLEScan`) and `src/nrf52/`
(Bluefruit passive scan). Both extract manufacturer data + MAC + RSSI and feed
a shared `VictronBLE::onAdvertisement()`. The public API is unchanged.
- The correct backend is auto-selected at compile time; no user configuration
needed beyond picking the board.
### Portable crypto (no external dependency)
- Replaced the ESP32-only mbedTLS AES with a small bundled AES-128-CTR
implementation (`src/crypto/`, trimmed/prefixed tiny-AES, public domain,
NIST SP 800-38A verified). Decryption output is byte-identical to the previous
mbedTLS path; the library now has no external crypto dependency on any target.
### New
- The `MultiDevice` example now builds for both ESP32 and nRF52840 from a single
sketch — its `platformio.ini` adds `xiao_nrf52840` (Seeed XIAO nRF52840, board
`xiaoble_adafruit` via the maxgerhardt nRF52 platform fork) and
`adafruit_feather_nrf52840` environments alongside the ESP32 ones.
- `nrf52` added to the supported architectures / PlatformIO platforms.
## 0.5.0 (2026-06-04)
Decoding accuracy fixes (thanks to community bug reports from Karsten, Cory, Kevin and Dan)
plus new AC Charger support. Field layouts verified against the keshavdv/victron-ble
reference implementation and the Victron "Extra Manufacturer Data" specification.
### Bug fixes
- **Battery monitor decoding rewritten.** The `victronBatteryMonitorPayload` struct had
wrong field widths (8-bit alarm instead of 16, no 2-bit aux-mode field) which cascaded
and misaligned current, consumed Ah and SOC. `parseBatteryMonitor()` now decodes the
bit-packed payload directly by bit offset: signed voltage, 16-bit alarm, aux value +
2-bit aux mode (replacing the unreliable `< 3000` voltage/temperature heuristic),
22-bit signed current, 20-bit consumed Ah (×-0.1 Ah), 10-bit SOC (×0.1 %).
- **Solar charger battery current scale fixed.** Was multiplied by 0.01 (10× too small);
the field is in 0.1 A units. Load current is now read as the correct 9-bit field.
- **Compile error with newer ESP32 BLE library fixed.** `getManufacturerData()` now returns
an Arduino `String` on recent cores; `processDevice()` handles both `String` and
`std::string` while preserving the binary payload's embedded null bytes.
- **Device type IDs corrected.** The enum was off-by-one from 0x07 onward, mislabelling
AC chargers (0x08) as Lynx Smart BMS. Now matches the Victron protocol:
0x07 GX Device, 0x08 AC Charger, 0x09 Smart Battery Protect, 0x0A Lynx Smart BMS,
0x0B Multi RS, 0x0C VE.Bus, 0x0D DC Energy Meter, 0x0F Orion XS.
### New features
- **AC Charger support** (Blue Smart IP22/IP65/IP67, device type 0x08). New
`VictronACChargerData` struct (three output voltage/current banks, temperature, AC
current) and `DEVICE_TYPE_AC_CHARGER` handling.
## 0.4.1 (2026-02-28)
Major rework of library internals. Breaking API change — not backwards compatible with 0.3.x.
### Callback API rewrite
- Replaced virtual callback class (`VictronDeviceCallback` with 4 override methods) with a
single function pointer (`VictronCallback`). Users now provide a plain function instead of
subclassing. The callback receives a `VictronDevice*` and switches on `deviceType` to access
the appropriate data via a tagged union.
### Non-blocking BLE scanning
- `loop()` is now non-blocking — returns immediately if a scan is already running.
Previously it blocked for the entire scan duration (default 5 seconds).
- Scan restarts automatically when it completes.
### Callback throttling
- Nonce-based deduplication: skips decrypt/parse/callback when the device's data hasn't
changed (detected via the nonce field in the BLE advertisement header).
- Configurable minimum interval (`setMinInterval()`, default 1000ms) limits callback
frequency even when data is changing rapidly.
- Encryption key byte check before AES decryption for early rejection of mismatched keys.
### Memory and code reduction
- Replaced `std::map<String, DeviceInfo*>` with a fixed array (max 8 devices, linear search).
Eliminates heap allocation for device storage.
- Replaced Arduino `String` with fixed `char[]` arrays throughout (MAC: 12 chars, name: 32 chars).
Eliminates heap fragmentation from dynamic string operations.
- Replaced inheritance hierarchy (`VictronDeviceData` base + 4 derived classes) with a flat
`VictronDevice` struct using a tagged union. No more `new`/`delete` for device data.
- Removed `std::map` and `std::vector` includes entirely.
- Source reduced from ~970 lines to ~510 lines (48% reduction).
- Flash savings: ~11-14 KB across examples.
### Bug fixes
- Fixed undefined behavior: derived objects were deleted through a base pointer without a
virtual destructor. Now uses flat structs, no polymorphic delete.
- Removed incorrect `panelVoltage` calculation (was dividing PV power by battery current,
which is wrong for MPPT chargers). The BLE protocol does not transmit PV voltage.
- Removed spurious `nullPad` byte from manufacturer data struct.
- Device type is now auto-detected from the BLE advertisement record type. The type
parameter in `addDevice()` is optional.
### Removed features (commented out in header for reference)
- `VictronDeviceConfig` struct — use `addDevice(name, mac, key, type)` directly
- Per-type getter methods (`getSolarChargerData()`, etc.) — use callback instead
- `removeDevice()`, `getDevicesByType()`, `getLastError()`
### Examples updated
- All examples updated for new callback API
- Removed `panelVoltage` from ESPNow packet structs (Repeater, FakeRepeater, Receiver)
- Removed unnecessary `delay(100)` from loop functions
- Added ESPNow Repeater and Receiver examples
## 0.3.1 (2026-02-11)
### Changes
- Added Logger example: change-detection logging for Solar Charger data
- Added message type counters to MultiDevice example
- Tested with MPPT Solar Chargers on ESP32-S3 and ESP32-C3
## 0.1.1 (2025-12-18) ## 0.1.1 (2025-12-18)
Initial release - not yet tested on hardware. Initial release - not yet tested on hardware.
+48
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
+100
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);
}
}
+143
View File
@@ -0,0 +1,143 @@
[env]
lib_extra_dirs = ../..
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
; Serial monitor settings
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
; Build flags
build_flags =
-DCORE_DEBUG_LEVEL=3
; Library dependencies
lib_deps =
; VictronBLE library will be automatically included from parent directory
; Optional: Specify partition scheme if needed
; board_build.partitions = default.csv
[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
# -DCORE_DEBUG_LEVEL=3
[env:esp32-s3-debug]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
#monitor_speed = 115200
#monitor_filters = esp32_exception_decoder
upload_protocol = esp-builtin
; Debug configuration for GDB
debug_tool = esp-builtin
debug_init_break = tbreak setup
debug_speed = 5000
debug_load_mode = always
; Build flags for debugging
build_flags =
-DCORE_DEBUG_LEVEL=5 ; Maximum ESP32 debug level
-O0 ; Disable optimization for debugging
-g3 ; Maximum debug information
build_type = debug
[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
# NOTE: Need these two ARDUIO_USB modes to work with serial
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:esp32-c3-debug]
platform = espressif32
board = esp32-c3-devkitc-02
framework = arduino
monitor_speed = 115200
; Upload configuration
upload_protocol = esp-builtin
; Debug configuration for GDB
debug_tool = esp-builtin
debug_init_break = tbreak setup
debug_speed = 5000
debug_load_mode = always
; Build flags for debugging
build_flags =
-DCORE_DEBUG_LEVEL=5 ; Maximum ESP32 debug level
-O0 ; Disable optimization for debugging
-g3 ; Maximum debug information
build_type = debug
[env:m5stick]
platform = espressif32
board = m5stick-c
framework = arduino
board_build.mcu = esp32
board_build.f_cpu = 240000000L
board_build.partitions = no_ota.csv
#upload_protocol = espota
#upload_port = Button.local
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
#debug_tool = esp-prog ; esp-bridge, esp-prog ; or ftdi, esp-builtin, jlink, etc.
# debug_speed = 5000 ; optional: JTAG speed in kHz
#build_flags =
# -DCORE_DEBUG_LEVEL=5 ; ESP32 debug level
# -O0 ; no optimization
# -g3 ; max debug info
build_flags =
-Os
lib_deps =
M5StickC
elapsedMillis
[env:tough]
board = m5stack-core2
board_build.mcu = esp32
platform = espressif32
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
debug_tool = esp-bridge ; esp-bridge, esp-prog ; or ftdi, esp-builtin, jlink, etc.
# debug_speed = 5000 ; optional: JTAG speed in kHz
build_flags =
-DCORE_DEBUG_LEVEL=5 ; ESP32 debug level
-O0 ; no optimization
-g3 ; max debug info
-DARDUINO_M5STACK_TOUGH
-DDISPLAY_WIDTH=320
-DDISPLAY_HEIGHT=240
-DHAS_TOUCH=1
-DBUFFER_LINES=10
lib_deps =
M5Unified
elapsedMillis
+153
View File
@@ -0,0 +1,153 @@
/**
* VictronBLE Logger Example
*
* Demonstrates change-detection logging for Solar Charger data.
* Only logs to serial when a value changes (ignoring RSSI), or once
* per minute if nothing has changed.
*
* Setup:
* 1. Get your device encryption keys from the VictronConnect app
* 2. Update the device configurations below with your MAC and key
*/
#include <Arduino.h>
#include "VictronBLE.h"
VictronBLE victron;
struct SolarChargerSnapshot {
bool valid = false;
uint8_t chargeState;
float batteryVoltage;
float batteryCurrent;
float panelPower;
uint16_t yieldToday;
float loadCurrent;
unsigned long lastLogTime = 0;
uint32_t packetsSinceLastLog = 0;
};
static const int MAX_DEVICES = 4;
static char deviceMACs[MAX_DEVICES][VICTRON_MAC_LEN];
static SolarChargerSnapshot snapshots[MAX_DEVICES];
static int deviceCount = 0;
static const unsigned long LOG_INTERVAL_MS = 60000;
static int findOrAddDevice(const char* mac) {
for (int i = 0; i < deviceCount; i++) {
if (strcmp(deviceMACs[i], mac) == 0) return i;
}
if (deviceCount < MAX_DEVICES) {
strncpy(deviceMACs[deviceCount], mac, VICTRON_MAC_LEN - 1);
deviceMACs[deviceCount][VICTRON_MAC_LEN - 1] = '\0';
return deviceCount++;
}
return -1;
}
static const char* chargeStateName(uint8_t 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";
}
}
static void logData(const VictronDevice* dev, const VictronSolarData& s,
const char* reason, uint32_t packets) {
Serial.printf("[%s] %s pkts:%lu | State:%s Batt:%.2fV %.2fA PV:%.0fW Yield:%uWh",
dev->name, reason, packets,
chargeStateName(s.chargeState),
s.batteryVoltage, s.batteryCurrent,
s.panelPower, s.yieldToday);
if (s.loadCurrent > 0)
Serial.printf(" Load:%.2fA", s.loadCurrent);
Serial.println();
}
void onVictronData(const VictronDevice* dev) {
if (dev->deviceType != DEVICE_TYPE_SOLAR_CHARGER) return;
const auto& s = dev->solar;
int idx = findOrAddDevice(dev->mac);
if (idx < 0) return;
SolarChargerSnapshot& prev = snapshots[idx];
unsigned long now = millis();
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 {
return;
}
}
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() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=== VictronBLE Logger Example ===\n");
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 devices\n", (int)victron.getDeviceCount());
Serial.println("Logging on change, or every 60s heartbeat\n");
}
void loop() {
victron.loop();
}
+126 -2
View File
@@ -1,6 +1,25 @@
[env] [env]
lib_extra_dirs = ../.. lib_extra_dirs = ../..
; --- nRF52840 targets (Bluefruit backend, selected automatically) ---
; Seeed XIAO nRF52840. Board files come from a community platform fork; use the
; *_adafruit variant (the plain `xiaoble` uses the mbed core, which has no
; Bluefruit). For the XIAO nRF52840 Sense use board = xiaoblesense_adafruit.
[env:xiao_nrf52840]
platform = https://github.com/maxgerhardt/platform-nordicnrf52
board = xiaoble_adafruit
framework = arduino
monitor_speed = 115200
build_flags = -DCFG_DEBUG=0
; Adafruit Feather nRF52840 — available in the stock PlatformIO nordicnrf52 platform.
[env:adafruit_feather_nrf52840]
platform = nordicnrf52
board = adafruit_feather_nrf52840
framework = arduino
monitor_speed = 115200
build_flags = -DCFG_DEBUG=0
[env:esp32dev] [env:esp32dev]
platform = espressif32 platform = espressif32
board = esp32dev board = esp32dev
@@ -26,13 +45,118 @@ platform = espressif32
board = esp32-s3-devkitc-1 board = esp32-s3-devkitc-1
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
monitor_filters = esp32_exception_decoder
build_flags = build_flags =
-DCORE_DEBUG_LEVEL=3 -D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
# -DCORE_DEBUG_LEVEL=3
[env:esp32-s3-debug]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
#monitor_speed = 115200
#monitor_filters = esp32_exception_decoder
upload_protocol = esp-builtin
; Debug configuration for GDB
debug_tool = esp-builtin
debug_init_break = tbreak setup
debug_speed = 5000
debug_load_mode = always
; Build flags for debugging
build_flags =
-DCORE_DEBUG_LEVEL=5 ; Maximum ESP32 debug level
-O0 ; Disable optimization for debugging
-g3 ; Maximum debug information
build_type = debug
[env:esp32-c3] [env:esp32-c3]
platform = espressif32 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
# NOTE: Need these two ARDUIO_USB modes to work with serial
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:esp32-c3-debug]
platform = espressif32
board = esp32-c3-devkitc-02 board = esp32-c3-devkitc-02
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
; Upload configuration
upload_protocol = esp-builtin
; Debug configuration for GDB
debug_tool = esp-builtin
debug_init_break = tbreak setup
debug_speed = 5000
debug_load_mode = always
; Build flags for debugging
build_flags = build_flags =
-DCORE_DEBUG_LEVEL=3 -DCORE_DEBUG_LEVEL=5 ; Maximum ESP32 debug level
-O0 ; Disable optimization for debugging
-g3 ; Maximum debug information
build_type = debug
[env:m5stick]
platform = espressif32
board = m5stick-c
framework = arduino
board_build.mcu = esp32
board_build.f_cpu = 240000000L
board_build.partitions = no_ota.csv
#upload_protocol = espota
#upload_port = Button.local
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
#debug_tool = esp-prog ; esp-bridge, esp-prog ; or ftdi, esp-builtin, jlink, etc.
# debug_speed = 5000 ; optional: JTAG speed in kHz
#build_flags =
# -DCORE_DEBUG_LEVEL=5 ; ESP32 debug level
# -O0 ; no optimization
# -g3 ; max debug info
build_flags =
-Os
lib_deps =
M5StickC
elapsedMillis
[env:tough]
board = m5stack-core2
board_build.mcu = esp32
platform = espressif32
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
debug_tool = esp-bridge ; esp-bridge, esp-prog ; or ftdi, esp-builtin, jlink, etc.
# debug_speed = 5000 ; optional: JTAG speed in kHz
build_flags =
-DCORE_DEBUG_LEVEL=5 ; ESP32 debug level
-O0 ; no optimization
-g3 ; max debug info
-DARDUINO_M5STACK_TOUGH
-DDISPLAY_WIDTH=320
-DDISPLAY_HEIGHT=240
-DHAS_TOUCH=1
-DBUFFER_LINES=10
lib_deps =
M5Unified
elapsedMillis
+141 -167
View File
@@ -1,127 +1,30 @@
/** /**
* 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: * The same sketch runs on both ESP32 and nRF52840 — the BLE backend is selected
* - ESP32 board * automatically at compile time. Pick the target with the PlatformIO
* - Victron devices with BLE (SmartSolar, SmartShunt, etc.) * environment (see platformio.ini): e.g. `esp32dev` or `xiao_nrf52840`.
* *
* Setup: * Setup:
* 1. Get your device encryption keys from the VictronConnect app: * 1. Get your device encryption keys from the 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;
void onSolarChargerData(const SolarChargerData& data) override { static uint32_t dcdcConverterCount = 0;
Serial.println("\n=== Solar Charger: " + data.deviceName + " ==="); static uint32_t acChargerCount = 0;
Serial.println("MAC: " + data.macAddress);
Serial.println("RSSI: " + String(data.rssi) + " dBm");
Serial.println("State: " + getChargeStateName(data.chargeState));
Serial.println("Battery: " + String(data.batteryVoltage, 2) + " V");
Serial.println("Current: " + String(data.batteryCurrent, 2) + " A");
Serial.println("Panel Voltage: " + String(data.panelVoltage, 1) + " V");
Serial.println("Panel Power: " + String(data.panelPower) + " W");
Serial.println("Yield Today: " + String(data.yieldToday) + " Wh");
if (data.loadCurrent > 0) {
Serial.println("Load Current: " + String(data.loadCurrent, 2) + " A");
}
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago");
}
void onBatteryMonitorData(const BatteryMonitorData& data) override { static const char* chargeStateName(uint8_t state) {
Serial.println("\n=== Battery Monitor: " + data.deviceName + " ===");
Serial.println("MAC: " + data.macAddress);
Serial.println("RSSI: " + String(data.rssi) + " dBm");
Serial.println("Voltage: " + String(data.voltage, 2) + " V");
Serial.println("Current: " + String(data.current, 2) + " A");
Serial.println("SOC: " + String(data.soc, 1) + " %");
Serial.println("Consumed: " + String(data.consumedAh, 2) + " Ah");
if (data.remainingMinutes < 65535) {
int hours = data.remainingMinutes / 60;
int mins = data.remainingMinutes % 60;
Serial.println("Time Remaining: " + String(hours) + "h " + String(mins) + "m");
}
if (data.temperature > 0) {
Serial.println("Temperature: " + String(data.temperature, 1) + " °C");
}
if (data.auxVoltage > 0) {
Serial.println("Aux Voltage: " + String(data.auxVoltage, 2) + " V");
}
// Print alarms
if (data.alarmLowVoltage || data.alarmHighVoltage || data.alarmLowSOC ||
data.alarmLowTemperature || data.alarmHighTemperature) {
Serial.print("ALARMS: ");
if (data.alarmLowVoltage) Serial.print("LOW-V ");
if (data.alarmHighVoltage) Serial.print("HIGH-V ");
if (data.alarmLowSOC) Serial.print("LOW-SOC ");
if (data.alarmLowTemperature) Serial.print("LOW-TEMP ");
if (data.alarmHighTemperature) Serial.print("HIGH-TEMP ");
Serial.println();
}
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago");
}
void onInverterData(const InverterData& data) override {
Serial.println("\n=== Inverter/Charger: " + data.deviceName + " ===");
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 {
Serial.println("\n=== DC-DC Converter: " + data.deviceName + " ===");
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) { 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";
@@ -137,88 +40,159 @@ private:
default: return "Unknown"; default: return "Unknown";
} }
} }
};
MyVictronCallback callback; void onVictronData(const VictronDevice* dev) {
switch (dev->deviceType) {
case DEVICE_TYPE_SOLAR_CHARGER: {
const auto& s = dev->solar;
solarChargerCount++;
Serial.printf("\n=== Solar Charger: %s (#%lu) ===\n", dev->name, solarChargerCount);
Serial.printf("MAC: %s\n", dev->mac);
Serial.printf("RSSI: %d dBm\n", dev->rssi);
Serial.printf("State: %s\n", chargeStateName(s.chargeState));
Serial.printf("Battery: %.2f V\n", s.batteryVoltage);
Serial.printf("Current: %.2f A\n", s.batteryCurrent);
Serial.printf("Panel Power: %.0f W\n", s.panelPower);
Serial.printf("Yield Today: %u Wh\n", s.yieldToday);
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: {
const auto& b = dev->battery;
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;
}
case DEVICE_TYPE_INVERTER: {
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: {
const auto& dc = dev->dcdc;
dcdcConverterCount++;
Serial.printf("\n=== DC-DC Converter: %s (#%lu) ===\n", dev->name, dcdcConverterCount);
Serial.printf("MAC: %s\n", dev->mac);
Serial.printf("RSSI: %d dBm\n", dev->rssi);
Serial.printf("Input: %.2f V\n", dc.inputVoltage);
Serial.printf("Output: %.2f V\n", dc.outputVoltage);
Serial.printf("Current: %.2f A\n", dc.outputCurrent);
Serial.printf("State: %d\n", dc.chargeState);
if (dc.errorCode != 0)
Serial.printf("Error Code: %d\n", dc.errorCode);
Serial.printf("Last Update: %lus ago\n", (millis() - dev->lastUpdate) / 1000);
break;
}
case DEVICE_TYPE_AC_CHARGER: {
const auto& ac = dev->acCharger;
acChargerCount++;
Serial.printf("\n=== AC Charger: %s (#%lu) ===\n", dev->name, acChargerCount);
Serial.printf("MAC: %s\n", dev->mac);
Serial.printf("RSSI: %d dBm\n", dev->rssi);
Serial.printf("State: %s\n", chargeStateName(ac.chargeState));
Serial.printf("Output 1: %.2f V %.2f A\n", ac.voltage1, ac.current1);
if (ac.voltage2 > 0) Serial.printf("Output 2: %.2f V %.2f A\n", ac.voltage2, ac.current2);
if (ac.voltage3 > 0) Serial.printf("Output 3: %.2f V %.2f A\n", ac.voltage3, ac.current3);
if (ac.temperature != 0) Serial.printf("Temperature: %.0f C\n", ac.temperature);
if (ac.acCurrent > 0) Serial.printf("AC Current: %.2f A\n", ac.acCurrent);
Serial.printf("Last Update: %lus ago\n", (millis() - dev->lastUpdate) / 1000);
break;
}
default:
break;
}
}
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(1000); // Wait briefly for USB CDC serial (nRF52/native-USB boards); don't block forever
uint32_t start = millis();
while (!Serial && (millis() - start) < 5000) delay(10);
Serial.println("\n\n================================="); Serial.println("\n\n=================================");
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(true); victron.setCallback(onVictronData);
// Set callback for data updates // Replace with your own devices (MAC + 32-char hex key from VictronConnect)
victron.setCallback(&callback);
// Add your devices here
// Replace with your actual MAC addresses and encryption keys
// Example: Solar Charger #1
victron.addDevice( victron.addDevice(
"MPPT 100/30", // Device name "Rainbow48V",
"E7:48:D4:28:B7:9C", // MAC address "E4:05:42:34:14:F3",
"0df4d0395b7d1a876c0c33ecb9e70dcd", // Encryption key (32 hex chars) "0ec3adf7433dd61793ff2f3b8ad32ed8",
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 on both backends
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);
} }
+49
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
+219
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);
}
+57
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
+174
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);
}
}
+35
View File
@@ -0,0 +1,35 @@
[env]
lib_extra_dirs = ..
[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
# -DCORE_DEBUG_LEVEL=3
[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
# NOTE: Need these two ARDUIO_USB modes to work with serial
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
+397
View File
@@ -0,0 +1,397 @@
/*
Scott's original test code - this does work for MPPT chargers - use it as a base
*/
#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEAdvertisedDevice.h>
#include <BLEScan.h>
#include <aes/esp_aes.h> // AES decryption
typedef struct {
char charMacAddr[13]; // 12 character MAC + \0 (initialized as quoted strings below for convenience)
char charKey[33]; // 32 character keys + \0 (initialized as quoted strings below for convenience)
char comment[16]; // 16 character comment (name) for printing during setup()
byte byteMacAddr[6]; // 6 bytes for MAC - initialized by setup() from quoted strings
byte byteKey[16]; // 16 bytes for encryption key - initialized by setup() from quoted strings
char cachedDeviceName[32]; // 31 characters + \0 (filled in as we receive advertisements)
} solarController;
solarController solarControllers[] = {
// { { .charMacAddr = "e64559783cfb" }, { .charKey = "3fa658aded4f309b9bc17a2318cb1f56" }, { .comment = "ScottTrailer" } },
{ { .charMacAddr = "e405423414f3" }, { .charKey = "0ec3adf7433dd61793ff2f3b8ad32ed8" }, { .comment = "Test" } },
};
int knownSolarControllerCount = sizeof(solarControllers) / sizeof(solarControllers[0]);
BLEScan *pBLEScan;
#define AES_KEY_BITS 128
int scanTime = 1; //In seconds
byte hexCharToByte(char hexChar) {
if (hexChar >= '0' && hexChar <='9') { // 0-9
hexChar=hexChar - '0';
}
else if (hexChar >= 'a' && hexChar <= 'f') { // a-f
hexChar=hexChar - 'a' + 10;
}
else if (hexChar >= 'A' && hexChar <= 'F') { // A-F
hexChar=hexChar - 'A' + 10;
}
else {
hexChar=255;
}
return hexChar;
}
// Decryption keys and MAC addresses obtained from the VictronConnect app will be
// a string of hex digits like this:
//
// f4116784732a
// dc73cb155351cf950f9f3a958b5cd96f
//
// Split that up and turn it into an array whose equivalent definition would be like this:
//
// byte key[]={ 0xdc, 0x73, 0xcb, ... 0xd9, 0x6f };
//
void hexCharStrToByteArray(char * hexCharStr, byte * byteArray) {
bool returnVal=false;
int hexCharStrLength=strlen(hexCharStr);
// There are simpler ways of doing this without the fancy nibble-munching,
// but I do it this way so I parse things like colon-separated MAC addresses.
// BUT: be aware that this expects digits in pairs and byte values need to be
// zero-filled. i.e., a MAC address like 8:0:2b:xx:xx:xx won't come out the way
// you want it.
int byteArrayIndex=0;
bool oddByte=true;
byte hiNibble;
for (int i=0; i<hexCharStrLength; i++) {
byte nibble=hexCharToByte(hexCharStr[i]);
if (nibble!=255) {
if (oddByte) {
hiNibble=nibble;
} else {
byteArray[byteArrayIndex]=(hiNibble<<4) | nibble;
byteArrayIndex++;
}
oddByte=!oddByte;
}
}
// do we have a leftover nibble? I guess we'll assume it's a low nibble?
if (! oddByte) {
byteArray[byteArrayIndex]=hiNibble;
}
}
// Victron docs on the manufacturer data in advertisement packets can be found at:
// https://community.victronenergy.com/storage/attachments/48745-extra-manufacturer-data-2022-12-14.pdf
//
// Usage/style note: I use uint16_t in places where I need to force 16-bit unsigned integers
// instead of whatever the compiler/architecture might decide to use. I might not need to do
// the same with byte variables, but I'll do it anyway just to be at least a little consistent.
// Must use the "packed" attribute to make sure the compiler doesn't add any padding to deal with
// word alignment.
typedef struct {
uint8_t deviceState;
uint8_t errorCode;
int16_t batteryVoltage;
int16_t batteryCurrent;
uint16_t todayYield;
uint16_t inputPower;
uint8_t outputCurrentLo; // Low 8 bits of output current (in 0.1 Amp increments)
uint8_t outputCurrentHi; // High 1 bit of ourput current (must mask off unused bits)
uint8_t unused[4];
} __attribute__((packed)) victronPanelData;
typedef struct {
uint16_t vendorID; // vendor ID
uint8_t beaconType; // Should be 0x10 (Product Advertisement) for the packets we want
uint8_t unknownData1[3]; // Unknown data
uint8_t victronRecordType; // Should be 0x01 (Solar Charger) for the packets we want
uint16_t nonceDataCounter; // Nonce
uint8_t encryptKeyMatch; // Should match pre-shared encryption key byte 0
uint8_t victronEncryptedData[21]; // (31 bytes max per BLE spec - size of previous elements)
uint8_t nullPad; // extra byte because toCharArray() adds a \0 byte.
} __attribute__((packed)) victronManufacturerData;
int bestRSSI = -200;
int selectedSolarControllerIndex = -1;
time_t lastLEDBlinkTime=0;
time_t lastTick=0;
int displayRotation=3;
bool packetReceived=false;
char chargeStateNames[][6] = {
" off",
" 1?",
" 2?",
" bulk",
" abs",
"float",
" 6?",
"equal"
};
class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) {
#define manDataSizeMax 31 // BLE specs say no more than 31 bytes, but see comments below!
// See if we have manufacturer data and then look to see if it's coming from a Victron device.
if (advertisedDevice.haveManufacturerData() == true) {
uint8_t manCharBuf[manDataSizeMax+1];
std::string manData = advertisedDevice.getManufacturerData(); // lib code returns std::string
int manDataSize=manData.length(); // This does not include a null at the end.
Serial.printf("Manufacturer data lengt=%d\n", manData.length());
Serial.printf("Struct Size=%d\n", sizeof(victronManufacturerData));
// Limit size just in case we get a malformed packet.
if (manDataSize > manDataSizeMax) {
Serial.printf(" Note: Truncating malformed %2d byte manufacturer data to max %d byte array size\n",manDataSize,manDataSizeMax);
manDataSize=manDataSizeMax;
}
// Now copy the data from the String to a byte array. Must have the +1 so we
// don't lose the last character to the null terminator.
manData.copy((char *)manCharBuf, manDataSize + 1);
// Now let's use a struct to get to the data more cleanly.
victronManufacturerData * vicData=(victronManufacturerData *)manCharBuf;
// ignore this packet if the Vendor ID isn't Victron.
if (vicData->vendorID!=0x02e1) {
return;
}
// ignore this packet if it isn't type 0x01 (Solar Charger).
if (vicData->victronRecordType != 0x01) {
return;
}
// Get the MAC address of the device we're hearing, and then use that to look up the encryption key
// for the device.
//
// We go through a bit of trouble here to turn the String MAC address that we get from the BLE
// code ("08:00:2b:xx:xx:xx") into a byte array. I'm divided on this... I could have just (and still might!)
// left this as a string and just done a strcmp() match. This would have saved me some coding and execution time
// in exchange for having to format the MAC addresses in my solarControllers list using the embedded colons.
char receivedMacStr[18];
strcpy(receivedMacStr,advertisedDevice.getAddress().toString().c_str());
byte receivedMacByte[6];
hexCharStrToByteArray(receivedMacStr,receivedMacByte);
int solarControllerIndex=-1;
for (int trySolarControllerIndex=0; trySolarControllerIndex<knownSolarControllerCount; trySolarControllerIndex++) {
bool matchedMac=true;
for (int i=0; i<6; i++) {
if (receivedMacByte[i] != solarControllers[trySolarControllerIndex].byteMacAddr[i]) {
matchedMac=false;
break;
}
}
if (matchedMac) {
solarControllerIndex=trySolarControllerIndex;
break;
}
}
// Get the device name (if there's one in this packet).
char deviceName[32]; // 31 characters + \0
strcpy(deviceName,"(unknown device name)");
bool deviceNameFound=false;
if (advertisedDevice.haveName()) {
// This works the same whether getName() returns String or std::string.
strcpy(deviceName,advertisedDevice.getName().c_str());
// This is prone to breaking because it's not very sophisticated. It's meant to
// strip off "SmartSolar" if it's at the beginning of the name, but will do
// ugly things if someone has put it elsewhere like "My SmartSolar Charger".
if (strstr(deviceName,"SmartSolar ") == deviceName) {
strcpy(deviceName,deviceName+11);
}
deviceNameFound=true;
}
// We didn't do this test earlier because we want to print out a name - if we got one.
if (solarControllerIndex == -1) {
Serial.printf("Discarding packet from unconfigured Victron SmartSolar %s at MAC %s\n",deviceName,receivedMacStr);
return;
}
// If we found a device name, cache it for later display.
if (deviceNameFound) {
strcpy(solarControllers[solarControllerIndex].cachedDeviceName,deviceName);
}
// The manufacturer data from Victron contains a byte that's supposed to match the first byte
// of the device's encryption key. If they don't match, when we don't have the right key for
// this device and we just have to throw the data away. ALTERNATELY, we can go ahead and decrypt
// the data - incorrectly - and use the crazy values to indicate that we have a problem.
if (vicData->encryptKeyMatch != solarControllers[solarControllerIndex].byteKey[0]) {
Serial.printf("Encryption key mismatch for %s at MAC %s\n",
solarControllers[solarControllerIndex].cachedDeviceName,receivedMacStr);
return;
}
// Get the signal strength (RSSI) of the beacon.
int RSSI=advertisedDevice.getRSSI();
// If we're showing our data on our integrated graphics hardware,
// then show only the SmartSolar device with the strongest signal.
// I debated on whether to do this with "#if defined..." for conditional compilation
// or I should do this with a boolean "using graphics hardware" variable and a regular
// "if". I decided on #if, but I might change my mind later.
// Get the beacon's RSSI (signal strength). If it's stronger than other beacons we've received,
// then lock on to this SmartSolar and don't display beacons from others anymore.
if (selectedSolarControllerIndex==solarControllerIndex) {
if (RSSI > bestRSSI) {
bestRSSI=RSSI;
}
} else {
if (RSSI > bestRSSI) {
selectedSolarControllerIndex=solarControllerIndex;
Serial.printf("Selected Victon SmartSolar %s at MAC %s as preferred device based on RSSI %d\n",
solarControllers[solarControllerIndex].cachedDeviceName,receivedMacStr,RSSI);
} else {
Serial.printf("Discarding RSSI-based non-selected Victon SmartSolar %s at MAC %s\n",
solarControllers[solarControllerIndex].cachedDeviceName,receivedMacStr);
return;
}
}
// Now that the packet received has met all the criteria for being displayed,
// let's decrypt and decode the manufacturer data.
byte inputData[16];
byte outputData[16]={0};
victronPanelData * victronData = (victronPanelData *) outputData;
// The number of encrypted bytes is given by the number of bytes in the manufacturer
// data as a while minus the number of bytes (10) in the header part of the data.
int encrDataSize=manDataSize-10;
for (int i=0; i<encrDataSize; i++) {
inputData[i]=vicData->victronEncryptedData[i]; // copy for our decrypt below while I figure this out.
}
esp_aes_context ctx;
esp_aes_init(&ctx);
auto status = esp_aes_setkey(&ctx, solarControllers[solarControllerIndex].byteKey, AES_KEY_BITS);
if (status != 0) {
Serial.printf(" Error during esp_aes_setkey operation (%i).\n",status);
esp_aes_free(&ctx);
return;
}
byte data_counter_lsb=(vicData->nonceDataCounter) & 0xff;
byte data_counter_msb=((vicData->nonceDataCounter) >> 8) & 0xff;
u_int8_t nonce_counter[16] = {data_counter_lsb, data_counter_msb, 0};
u_int8_t stream_block[16] = {0};
size_t nonce_offset=0;
status = esp_aes_crypt_ctr(&ctx, encrDataSize, &nonce_offset, nonce_counter, stream_block, inputData, outputData);
if (status != 0) {
Serial.printf("Error during esp_aes_crypt_ctr operation (%i).",status);
esp_aes_free(&ctx);
return;
}
esp_aes_free(&ctx);
byte deviceState=victronData->deviceState; // this is really more like "Charger State"
byte errorCode=victronData->errorCode;
float batteryVoltage=float(victronData->batteryVoltage)*0.01;
float batteryCurrent=float(victronData->batteryCurrent)*0.1;
float todayYield=float(victronData->todayYield)*0.01*1000;
uint16_t inputPower=victronData->inputPower; // this is in watts; no conversion needed
// Getting the output current takes some magic.
int integerOutputCurrent=((victronData->outputCurrentHi & 0x01)<<9) | victronData->outputCurrentLo;
float outputCurrent=float(integerOutputCurrent)*0.1;
// I don't know why, but every so often we'll get half-corrupted data from the Victron.
// Towards the goal of filtering out this noise, I've found that I've rarely (if ever) seen
// corrupted data when the 'unused' bits of the outputCurrent MSB equal 0xfe. We'll use this
// as a litmus test here.
byte unusedBits=victronData->outputCurrentHi & 0xfe;
if (unusedBits != 0xfe) {
return;
}
// The Victron docs say Device State but it's really a Charger State.
char chargeStateName[6];
sprintf(chargeStateName,"%4d?",deviceState);
if (deviceState >=0 && deviceState<=7) {
strcpy(chargeStateName,chargeStateNames[deviceState]);
}
Serial.printf("%-31s Battery: %6.2f Volts %6.2f Amps Solar: %6d Watts Yield: %4.0f Wh Load: %5.1f Amps Charger: %-13s Err: %2d RSSI: %d\n",
solarControllers[solarControllerIndex].cachedDeviceName,
batteryVoltage, batteryCurrent,
inputPower, todayYield,
outputCurrent, chargeStateName, errorCode, RSSI
);
char screenDeviceName[14]; // 13 characters plus /0
strncpy(screenDeviceName,solarControllers[solarControllerIndex].cachedDeviceName,13);
screenDeviceName[13]='\0'; // make sure we have a null byte at the end.
/*
sh3dNbDisplay.line1 = String("Name: ") + String(screenDeviceName);
sh3dNbDisplay.line2 = String("Battery: ") + String(batteryVoltage) + String("V");
sh3dNbDisplay.line3 = String("Charge: ") + String(inputPower) + String("W ") + String(todayYield) + String("Wh");
sh3dNb.setMessage(sh3dNb.iso8601());
sh3dNbDisplay.update();
*/
packetReceived=true;
}
}
};
void setup() {
Serial.begin(115200);
Serial.println("Basic test");
// VICTRON BLUETOOTH
for (int i = 0; i < knownSolarControllerCount; i++) {
hexCharStrToByteArray(solarControllers[i].charMacAddr, solarControllers[i].byteMacAddr);
hexCharStrToByteArray(solarControllers[i].charKey, solarControllers[i].byteKey);
strcpy(solarControllers[i].cachedDeviceName, "(unknown)");
}
for (int i = 0; i < knownSolarControllerCount; i++) {
Serial.printf(" %-16s", solarControllers[i].comment);
Serial.printf(" Mac: ");
for (int j = 0; j < 6; j++) {
Serial.printf(" %2.2x", solarControllers[i].byteMacAddr[j]);
}
Serial.printf(" Key: ");
for (int j = 0; j < 16; j++) {
Serial.printf("%2.2x", solarControllers[i].byteKey[j]);
}
}
BLEDevice::init("");
pBLEScan = BLEDevice::getScan(); //create new scan
pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
pBLEScan->setActiveScan(true); //active scan uses more power, but gets results faster
pBLEScan->setInterval(100);
pBLEScan->setWindow(99); // less or equal setInterval value
}
void loop() {
Serial.println("tick");
BLEScanResults foundDevices = pBLEScan->start(scanTime, false);
pBLEScan->clearResults(); // delete results fromBLEScan buffer to release memory
delay(100);
}
+25 -5
View File
@@ -1,8 +1,8 @@
{ {
"name": "VictronBLE", "name": "victronble",
"version": "0.1.2", "version": "0.6.0",
"description": "ESP32 library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Supports SmartSolar MPPT, SmartShunt, BMV, MultiPlus, Orion and other Victron devices.", "description": "Portable Arduino library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Runs on ESP32 and nRF52840. Supports SmartSolar MPPT, SmartShunt, BMV, MultiPlus, Orion, Blue Smart AC chargers and other Victron devices. No external crypto dependency.",
"keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, iot, energy, monitoring", "keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, nrf52, nrf52840, xiao, iot, energy, monitoring",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://gitea.sh3d.com.au/Sh3d/VictronBLE.git" "url": "https://gitea.sh3d.com.au/Sh3d/VictronBLE.git"
@@ -18,7 +18,7 @@
"license": "MIT", "license": "MIT",
"homepage": "https://gitea.sh3d.com.au/Sh3d/VictronBLE", "homepage": "https://gitea.sh3d.com.au/Sh3d/VictronBLE",
"frameworks": ["arduino", "espidf"], "frameworks": ["arduino", "espidf"],
"platforms": ["espressif32"], "platforms": ["espressif32", "nordicnrf52"],
"headers": ["VictronBLE.h"], "headers": ["VictronBLE.h"],
"dependencies": [], "dependencies": [],
"examples": [ "examples": [
@@ -26,6 +26,26 @@
"name": "MultiDevice", "name": "MultiDevice",
"base": "examples/MultiDevice", "base": "examples/MultiDevice",
"files": ["src/main.cpp"] "files": ["src/main.cpp"]
},
{
"name": "Logger",
"base": "examples/Logger",
"files": ["src/main.cpp"]
},
{
"name": "Repeater",
"base": "examples/Repeater",
"files": ["src/main.cpp"]
},
{
"name": "Receiver",
"base": "examples/Receiver",
"files": ["src/main.cpp"]
},
{
"name": "FakeRepeater",
"base": "examples/FakeRepeater",
"files": ["src/main.cpp"]
} }
], ],
"export": { "export": {
+4 -4
View File
@@ -1,11 +1,11 @@
name=VictronBLE name=VictronBLE
version=0.1.1 version=0.6.0
author=Scott Penrose author=Scott Penrose
maintainer=Scott Penrose <scottp@dd.com.au> maintainer=Scott Penrose <scottp@dd.com.au>
sentence=ESP32 library for reading Victron Energy device data via BLE for any ESP32 sentence=Portable library for reading Victron Energy device data via BLE on ESP32 and nRF52840
paragraph=Read data from Victron SmartSolar, SmartShunt, BMV, inverters and other devices using Bluetooth Low Energy advertisements. Supports multiple devices simultaneously with no pairing required. paragraph=Read data from Victron SmartSolar, SmartShunt, BMV, inverters, Blue Smart AC chargers and other devices using Bluetooth Low Energy advertisements. Runs on ESP32 and nRF52840 (Bluefruit) with no external crypto dependency. Supports multiple devices simultaneously with no pairing required.
category=Communication category=Communication
url=https://gitea.sh3d.com.au/Sh3d/VictronBLE url=https://gitea.sh3d.com.au/Sh3d/VictronBLE
architectures=esp32 architectures=esp32,nrf52
depends= depends=
includes=VictronBLE.h includes=VictronBLE.h
+283 -535
View File
@@ -1,630 +1,378 @@
/** /**
* VictronBLE - ESP32 library for Victron Energy BLE devices * VictronBLE - portable library for Victron Energy BLE devices
* Implementation file * Common implementation (platform-independent: decoding + AES-128-CTR decrypt).
* BLE scanning lives in the per-platform backends under src/esp32 and src/nrf52.
* *
* Copyright (c) 2025 Scott Penrose * Copyright (c) 2025 Scott Penrose
* License: MIT * License: MIT
*/ */
#include "VictronBLE.h" #include "VictronBLE.h"
#include "crypto/vble_aes.h"
#include <string.h>
// Constructor
VictronBLE::VictronBLE() VictronBLE::VictronBLE()
: pBLEScan(nullptr), callback(nullptr), debugEnabled(false), : deviceCount(0), callback(nullptr), debugEnabled(false),
scanDuration(5), initialized(false) { scanDuration(5), minIntervalMs(1000), initialized(false)
#if defined(VICTRON_BACKEND_ESP32)
, pBLEScan(nullptr), scanCallbackObj(nullptr)
#endif
{
memset(devices, 0, sizeof(devices));
} }
// Destructor bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
VictronBLE::~VictronBLE() { VictronDeviceType type) {
for (auto& pair : devices) { if (deviceCount >= VICTRON_MAX_DEVICES) return false;
delete pair.second; if (!hexKey || strlen(hexKey) != 32) return false;
} if (!mac || strlen(mac) == 0) return false;
devices.clear();
if (pBLEScan) { char normalizedMAC[VICTRON_MAC_LEN];
pBLEScan->stop(); normalizeMAC(mac, normalizedMAC);
}
}
// Initialize BLE // Check for duplicate
bool VictronBLE::begin(uint32_t scanDuration) { if (findDevice(normalizedMAC)) return false;
if (initialized) {
debugPrint("VictronBLE already initialized"); DeviceEntry* entry = &devices[deviceCount];
memset(entry, 0, sizeof(DeviceEntry));
entry->active = true;
strncpy(entry->device.name, name ? name : "", VICTRON_NAME_LEN - 1);
entry->device.name[VICTRON_NAME_LEN - 1] = '\0';
memcpy(entry->device.mac, normalizedMAC, VICTRON_MAC_LEN);
entry->device.deviceType = type;
entry->device.rssi = -100;
if (!hexToBytes(hexKey, entry->key, 16)) return false;
deviceCount++;
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
return true; return true;
} }
this->scanDuration = scanDuration; // Platform-independent advertisement handler. Each BLE backend extracts the
// manufacturer-data bytes (vendor ID first), MAC string and RSSI from a scan
// result and feeds them here.
void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
const char* macStr, int8_t rssi) {
if (!mfgData || len < 10) return;
debugPrint("Initializing VictronBLE..."); // Quick vendor ID check before any other work
uint16_t vendorID = mfgData[0] | ((uint16_t)mfgData[1] << 8);
if (vendorID != VICTRON_MANUFACTURER_ID) return;
BLEDevice::init("VictronBLE"); // Copy into the wire-format struct
pBLEScan = BLEDevice::getScan(); victronManufacturerData mfg;
memset(&mfg, 0, sizeof(mfg));
size_t copyLen = len > sizeof(mfg) ? sizeof(mfg) : len;
memcpy(&mfg, mfgData, copyLen);
if (!pBLEScan) { // Normalize MAC and find device
lastError = "Failed to create BLE scanner"; char normalizedMAC[VICTRON_MAC_LEN];
return false; normalizeMAC(macStr, normalizedMAC);
}
pBLEScan->setAdvertisedDeviceCallbacks(new VictronBLEAdvertisedDeviceCallbacks(this), true); DeviceEntry* entry = findDevice(normalizedMAC);
pBLEScan->setActiveScan(false); // Passive scan - lower power if (!entry) {
pBLEScan->setInterval(100); if (debugEnabled) Serial.printf("[VictronBLE] Unmonitored Victron: %s\n", normalizedMAC);
pBLEScan->setWindow(99);
initialized = true;
debugPrint("VictronBLE initialized successfully");
return true;
}
// Add a device to monitor
bool VictronBLE::addDevice(const VictronDeviceConfig& config) {
if (config.macAddress.length() == 0) {
lastError = "MAC address cannot be empty";
return false;
}
if (config.encryptionKey.length() != 32) {
lastError = "Encryption key must be 32 hex characters";
return false;
}
String normalizedMAC = normalizeMAC(config.macAddress);
// Check if device already exists
if (devices.find(normalizedMAC) != devices.end()) {
debugPrint("Device " + normalizedMAC + " already exists, updating config");
delete devices[normalizedMAC];
}
DeviceInfo* info = new DeviceInfo();
info->config = config;
info->config.macAddress = normalizedMAC;
// Convert encryption key from hex string to bytes
if (!hexStringToBytes(config.encryptionKey, info->encryptionKeyBytes, 16)) {
lastError = "Invalid encryption key format";
delete info;
return false;
}
// Create appropriate data structure based on device type
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 + " (" + normalizedMAC + ")");
return true;
}
bool VictronBLE::addDevice(String name, String macAddress, String encryptionKey,
VictronDeviceType expectedType) {
VictronDeviceConfig config(name, macAddress, encryptionKey, expectedType);
return addDevice(config);
}
// 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() {
if (!initialized) {
return; return;
} }
// Start a scan // Skip if nonce unchanged (data hasn't changed on the device)
BLEScanResults scanResults = pBLEScan->start(scanDuration, false); if (entry->device.dataValid && mfg.nonceDataCounter == entry->lastNonce) {
pBLEScan->clearResults(); entry->device.rssi = rssi; // still refresh RSSI
}
// BLE callback implementation
void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) {
if (victronBLE) {
victronBLE->processDevice(advertisedDevice);
}
}
// Process advertised device
void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) {
String mac = macAddressToString(advertisedDevice.getAddress());
String normalizedMAC = normalizeMAC(mac);
// Check if this is one of our configured devices
auto it = devices.find(normalizedMAC);
if (it == devices.end()) {
return; // Not a device we're monitoring
}
DeviceInfo* deviceInfo = it->second;
// Check if device has manufacturer data
if (!advertisedDevice.haveManufacturerData()) {
return; return;
} }
std::string mfgData = advertisedDevice.getManufacturerData(); // Skip if minimum interval hasn't elapsed
if (mfgData.length() < 2) { uint32_t now = millis();
if (entry->device.dataValid && (now - entry->device.lastUpdate) < minIntervalMs) {
return; return;
} }
// Check if it's Victron (manufacturer ID 0x02E1) if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
uint16_t mfgId = (uint8_t)mfgData[1] << 8 | (uint8_t)mfgData[0]; entry->device.name, mfg.nonceDataCounter);
if (mfgId != VICTRON_MANUFACTURER_ID) {
return;
}
debugPrint("Processing data from: " + deviceInfo->config.name); if (parseAdvertisement(entry, mfg)) {
entry->lastNonce = mfg.nonceDataCounter;
// Parse the advertisement entry->device.rssi = rssi;
if (parseAdvertisement((const uint8_t*)mfgData.data(), mfgData.length(), normalizedMAC)) { entry->device.lastUpdate = now;
// Update RSSI
if (deviceInfo->data) {
deviceInfo->data->rssi = advertisedDevice.getRSSI();
deviceInfo->data->lastUpdate = millis();
}
} }
} }
// Parse advertisement data bool VictronBLE::parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg) {
bool VictronBLE::parseAdvertisement(const uint8_t* manufacturerData, size_t len, if (debugEnabled) {
const String& macAddress) { Serial.printf("[VictronBLE] Beacon:0x%02X Record:0x%02X Nonce:0x%04X\n",
auto it = devices.find(macAddress); mfg.beaconType, mfg.victronRecordType, mfg.nonceDataCounter);
if (it == devices.end()) { }
// Quick key check before expensive decryption
if (mfg.encryptKeyMatch != entry->key[0]) {
if (debugEnabled) Serial.println("[VictronBLE] Key byte mismatch");
return false; return false;
} }
DeviceInfo* deviceInfo = it->second; // Build IV from nonce (2 bytes little-endian + 14 zero bytes)
if (len < 6) {
debugPrint("Manufacturer data too short");
return false;
}
// Structure: [MfgID(2)] [DeviceType(1)] [IV(2)] [EncryptedData(n)]
uint8_t deviceType = manufacturerData[2];
// Extract IV (initialization vector) - bytes 3-4, little-endian
uint8_t iv[16] = {0}; uint8_t iv[16] = {0};
iv[0] = manufacturerData[3]; iv[0] = mfg.nonceDataCounter & 0xFF;
iv[1] = manufacturerData[4]; iv[1] = (mfg.nonceDataCounter >> 8) & 0xFF;
// Rest of IV is zero-padded
// Encrypted data starts at byte 5 // Decrypt
const uint8_t* encryptedData = manufacturerData + 5; uint8_t decrypted[VICTRON_ENCRYPTED_LEN];
size_t encryptedLen = len - 5; if (!decryptData(mfg.victronEncryptedData, VICTRON_ENCRYPTED_LEN,
entry->key, iv, decrypted)) {
if (debugEnabled) { if (debugEnabled) Serial.println("[VictronBLE] Decryption failed");
debugPrintHex("Encrypted data", encryptedData, encryptedLen);
debugPrintHex("IV", iv, 16);
}
// Decrypt the data
uint8_t decrypted[32]; // Max expected size
if (!decryptAdvertisement(encryptedData, encryptedLen,
deviceInfo->encryptionKeyBytes, iv, decrypted)) {
lastError = "Decryption failed";
return false; return false;
} }
if (debugEnabled) { // Parse based on record type (auto-detects device type)
debugPrintHex("Decrypted data", decrypted, encryptedLen); bool ok = false;
} switch (mfg.victronRecordType) {
// Parse based on device type
bool parseOk = false;
switch (deviceType) {
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, encryptedLen, 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, encryptedLen, 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, encryptedLen, 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, encryptedLen, ok = parseDCDCConverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.dcdc);
*(DCDCConverterData*)deviceInfo->data); break;
} case DEVICE_TYPE_AC_CHARGER:
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
ok = parseACCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.acCharger);
break; break;
default: default:
debugPrint("Unknown device type: 0x" + String(deviceType, 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 (deviceType) {
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; // AES-128-CTR via the bundled portable implementation (was mbedTLS on ESP32).
mbedtls_aes_init(&aes); // CTR is symmetric and operates in place, so copy then XOR the keystream.
struct vble_aes_ctx ctx;
// Set encryption key vble_aes_init_ctx_iv(&ctx, key, iv);
int ret = mbedtls_aes_setkey_enc(&aes, key, 128); memcpy(decrypted, encrypted, len);
if (ret != 0) { vble_aes_ctr_xcrypt(&ctx, decrypted, len);
mbedtls_aes_free(&aes);
return false;
}
// AES-CTR decryption
size_t nc_off = 0;
uint8_t nonce_counter[16];
uint8_t stream_block[16];
memcpy(nonce_counter, iv, 16);
memset(stream_block, 0, 16);
ret = mbedtls_aes_crypt_ctr(&aes, encLen, &nc_off, nonce_counter,
stream_block, encrypted, decrypted);
mbedtls_aes_free(&aes);
return (ret == 0);
}
// Parse Solar Charger data
bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result) {
if (len < 12) {
debugPrint("Solar charger data too short");
return false;
}
// Byte 0: Charge state
result.chargeState = (SolarChargerState)data[0];
// Bytes 1-2: Battery voltage (10 mV units)
uint16_t vBat = data[1] | (data[2] << 8);
result.batteryVoltage = vBat * 0.01f;
// Bytes 3-4: Battery current (10 mA units, signed)
int16_t iBat = (int16_t)(data[3] | (data[4] << 8));
result.batteryCurrent = iBat * 0.01f;
// Bytes 5-6: Yield today (10 Wh units)
uint16_t yield = data[5] | (data[6] << 8);
result.yieldToday = yield * 10;
// Bytes 7-8: PV power (1 W units)
uint16_t pvPower = data[7] | (data[8] << 8);
result.panelPower = pvPower;
// Bytes 9-10: Load current (10 mA units)
uint16_t iLoad = data[9] | (data[10] << 8);
if (iLoad != 0xFFFF) { // 0xFFFF means no load output
result.loadCurrent = iLoad * 0.01f;
}
// Calculate PV voltage from power and current (if current > 0)
if (result.batteryCurrent > 0.1f) {
result.panelVoltage = result.panelPower / result.batteryCurrent;
}
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::parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result) {
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMonitorData& result) { if (len < sizeof(victronSolarChargerPayload)) return false;
if (len < 15) { const auto* p = reinterpret_cast<const victronSolarChargerPayload*>(data);
debugPrint("Battery monitor data too short");
return false; result.chargeState = p->deviceState;
result.errorCode = p->errorCode;
result.batteryVoltage = p->batteryVoltage * 0.01f; // 0.01V units
result.batteryCurrent = p->batteryCurrent * 0.1f; // 0.1A units
result.yieldToday = p->yieldToday * 10;
result.panelPower = p->inputPower;
// Load current is a 9-bit field (0.1A units); 0x1FF = no load output
uint16_t loadRaw = p->loadCurrent & 0x1FF;
result.loadCurrent = (loadRaw != 0x1FF) ? loadRaw * 0.1f : 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
result.batteryVoltage, result.batteryCurrent,
(int)result.panelPower, result.chargeState);
}
return true;
} }
// Bytes 0-1: Remaining time (1 minute units) bool VictronBLE::parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result) {
uint16_t timeRemaining = data[0] | (data[1] << 8); // Payload is bit-packed (10 fields, 104 bits ending in byte 12). Decode LSB-first.
result.remainingMinutes = timeRemaining; if (len < 13) return false;
// Bytes 2-3: Battery voltage (10 mV units) size_t bit = 0;
uint16_t vBat = data[2] | (data[3] << 8); auto readBits = [&](uint8_t width) -> uint32_t {
result.voltage = vBat * 0.01f; uint32_t value = 0;
for (uint8_t i = 0; i < width; i++) {
size_t b = bit + i;
value |= (uint32_t)((data[b >> 3] >> (b & 7)) & 0x01) << i;
}
bit += width;
return value;
};
// Byte 4: Alarms result.chargeState = (uint8_t)readBits(8);
uint8_t alarms = data[4]; result.errorCode = (uint8_t)readBits(8);
result.alarmLowVoltage = (alarms & 0x01) != 0;
result.alarmHighVoltage = (alarms & 0x02) != 0;
result.alarmLowSOC = (alarms & 0x04) != 0;
result.alarmLowTemperature = (alarms & 0x10) != 0;
result.alarmHighTemperature = (alarms & 0x20) != 0;
// Bytes 5-6: Aux voltage/temperature (10 mV or 0.01K units) uint32_t v1 = readBits(13), i1 = readBits(11);
uint16_t aux = data[5] | (data[6] << 8); uint32_t v2 = readBits(13), i2 = readBits(11);
if (aux < 3000) { // If < 30V, it's voltage uint32_t v3 = readBits(13), i3 = readBits(11);
result.auxVoltage = aux * 0.01f; uint32_t temp = readBits(7);
uint32_t acCur = readBits(9);
result.voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : 0;
result.current1 = (i1 != 0x7FF) ? i1 * 0.1f : 0;
result.voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : 0;
result.current2 = (i2 != 0x7FF) ? i2 * 0.1f : 0;
result.voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : 0;
result.current3 = (i3 != 0x7FF) ? i3 * 0.1f : 0;
result.temperature = (temp != 0x7F) ? (float)temp - 40.0f : 0; // C offset by -40
result.acCurrent = (acCur != 0x1FF) ? acCur * 0.1f : 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
result.voltage1, result.current1, result.temperature, result.chargeState);
}
return true;
}
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result) {
// The payload is bit-packed and not byte-aligned, so it is decoded by bit
// offset directly rather than via a struct. SOC ends at bit 117 (byte 14).
if (len < 15) return false;
// TTG (bits 0-15), unsigned minutes
result.remainingMinutes = data[0] | ((uint16_t)data[1] << 8);
// Voltage (bits 16-31), signed, 0.01V units
result.voltage = (int16_t)(data[2] | ((uint16_t)data[3] << 8)) * 0.01f;
// Alarm (bits 32-47), 16-bit bitmask
uint16_t alarm = data[4] | ((uint16_t)data[5] << 8);
result.alarmLowVoltage = (alarm & 0x0001) != 0;
result.alarmHighVoltage = (alarm & 0x0002) != 0;
result.alarmLowSOC = (alarm & 0x0004) != 0;
result.alarmLowTemperature = (alarm & 0x0010) != 0;
result.alarmHighTemperature = (alarm & 0x0020) != 0;
// Aux value (bits 48-63) interpreted per aux mode (bits 64-65)
uint16_t auxRaw = data[6] | ((uint16_t)data[7] << 8);
uint8_t auxMode = data[8] & 0x03; // 0=aux voltage, 1=midpoint, 2=temperature, 3=none
if (auxMode == 0) {
result.auxVoltage = auxRaw * 0.01f;
result.temperature = 0; result.temperature = 0;
} else { // Otherwise temperature in 0.01 Kelvin } else if (auxMode == 2) {
result.temperature = (aux * 0.01f) - 273.15f; result.temperature = auxRaw * 0.01f - 273.15f; // 0.01K -> C
result.auxVoltage = 0; result.auxVoltage = 0;
} else {
result.auxVoltage = 0;
result.temperature = 0;
} }
// Bytes 7-9: Battery current (22-bit signed, 1 mA units) // Battery current (bits 66-87), 22-bit signed, 0.001A units
int32_t current = data[7] | (data[8] << 8) | ((data[9] & 0x3F) << 16); int32_t current = ((uint32_t)(data[8] >> 2) & 0x3F)
if (current & 0x200000) { // Sign extend if negative | ((uint32_t)data[9] << 6)
current |= 0xFFC00000; | ((uint32_t)data[10] << 14);
} if (current & 0x200000) current |= 0xFFC00000; // Sign extend 22-bit
result.current = current * 0.001f; result.current = current * 0.001f;
// Bytes 9-11: Consumed Ah (18-bit signed, 10 mAh units) // Consumed Ah (bits 88-107), 20-bit, stored as a positive count, 0.1Ah units.
int32_t consumedAh = ((data[9] & 0xC0) >> 6) | (data[10] << 2) | ((data[11] & 0xFF) << 10); // Reported as a negative value (amp-hours consumed).
if (consumedAh & 0x20000) { // Sign extend uint32_t consumed = (uint32_t)data[11]
consumedAh |= 0xFFFC0000; | ((uint32_t)data[12] << 8)
} | ((uint32_t)(data[13] & 0x0F) << 16);
result.consumedAh = consumedAh * 0.01f; result.consumedAh = -((float)consumed * 0.1f);
// Bytes 12-13: SOC (10 = 1.0%) // SOC (bits 108-117), 10-bit, 0.1% units
uint16_t soc = data[12] | ((data[13] & 0x03) << 8); uint16_t soc = ((uint16_t)(data[13] >> 4) | ((uint16_t)data[14] << 4)) & 0x3FF;
result.soc = soc * 0.1f; result.soc = soc * 0.1f;
debugPrint("Battery Monitor: " + String(result.voltage, 2) + "V, " + if (debugEnabled) {
String(result.current, 2) + "A, SOC: " + String(result.soc, 1) + "%"); Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
result.voltage, result.current, result.soc);
}
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 < 10) { const auto* p = reinterpret_cast<const victronInverterPayload*>(data);
debugPrint("Inverter data too short");
return false;
}
// Byte 0: Device state result.state = p->deviceState;
result.state = data[0]; result.batteryVoltage = p->batteryVoltage * 0.01f;
result.batteryCurrent = p->batteryCurrent * 0.01f;
// Bytes 1-2: Battery voltage (10 mV units) // AC Power (signed 24-bit)
uint16_t vBat = data[1] | (data[2] << 8); int32_t acPower = p->acPowerLow | (p->acPowerMid << 8) | (p->acPowerHigh << 16);
result.batteryVoltage = vBat * 0.01f; if (acPower & 0x800000) acPower |= 0xFF000000; // Sign extend
// Bytes 3-4: Battery current (10 mA units, signed)
int16_t iBat = (int16_t)(data[3] | (data[4] << 8));
result.batteryCurrent = iBat * 0.01f;
// Bytes 5-7: AC Power (1 W units, signed 24-bit)
int32_t acPower = data[5] | (data[6] << 8) | (data[7] << 16);
if (acPower & 0x800000) { // Sign extend
acPower |= 0xFF000000;
}
result.acPower = acPower; result.acPower = acPower;
// Byte 8: Alarms // Alarm bits
uint8_t alarms = data[8]; result.alarmLowVoltage = (p->alarms & 0x01) != 0;
result.alarmLowVoltage = (alarms & 0x01) != 0; result.alarmHighVoltage = (p->alarms & 0x02) != 0;
result.alarmHighVoltage = (alarms & 0x02) != 0; result.alarmHighTemperature = (p->alarms & 0x04) != 0;
result.alarmHighTemperature = (alarms & 0x04) != 0; result.alarmOverload = (p->alarms & 0x08) != 0;
result.alarmOverload = (alarms & 0x08) != 0;
debugPrint("Inverter: " + String(result.batteryVoltage, 2) + "V, " +
String(result.acPower) + "W, State: " + String(result.state));
return true;
}
// Parse DC-DC Converter data
bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, DCDCConverterData& result) {
if (len < 10) {
debugPrint("DC-DC converter data too short");
return false;
}
// Byte 0: Charge state
result.chargeState = data[0];
// Bytes 1-2: Input voltage (10 mV units)
uint16_t vIn = data[1] | (data[2] << 8);
result.inputVoltage = vIn * 0.01f;
// Bytes 3-4: Output voltage (10 mV units)
uint16_t vOut = data[3] | (data[4] << 8);
result.outputVoltage = vOut * 0.01f;
// Bytes 5-6: Output current (10 mA units)
uint16_t iOut = data[5] | (data[6] << 8);
result.outputCurrent = iOut * 0.01f;
// Byte 7: Error code
result.errorCode = data[7];
debugPrint("DC-DC Converter: In=" + String(result.inputVoltage, 2) + "V, Out=" +
String(result.outputVoltage, 2) + "V, " + String(result.outputCurrent, 2) + "A");
return true;
}
// Get data methods
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;
}
for (size_t i = 0; i < len; i++) {
String byteStr = hex.substring(i * 2, i * 2 + 2);
char* endPtr;
bytes[i] = strtoul(byteStr.c_str(), &endPtr, 16);
if (*endPtr != '\0') {
return false;
}
}
return true;
}
// Helper: MAC address to string
String VictronBLE::macAddressToString(BLEAddress address) {
char macStr[18];
snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
address.getNative()[0], address.getNative()[1],
address.getNative()[2], address.getNative()[3],
address.getNative()[4], address.getNative()[5]);
return String(macStr);
}
// Helper: Normalize MAC address format
String VictronBLE::normalizeMAC(String mac) {
String normalized = mac;
normalized.toLowerCase();
normalized.replace("-", ":");
return normalized;
}
// Debug helpers
void VictronBLE::debugPrint(const String& message) {
if (debugEnabled) { if (debugEnabled) {
Serial.println("[VictronBLE] " + message); Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
result.batteryVoltage, (int)result.acPower, result.state);
} }
return true;
} }
void VictronBLE::debugPrintHex(const char* label, const uint8_t* data, size_t len) { bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result) {
if (!debugEnabled) return; if (len < sizeof(victronDCDCConverterPayload)) return false;
const auto* p = reinterpret_cast<const victronDCDCConverterPayload*>(data);
Serial.print("[VictronBLE] "); result.chargeState = p->chargeState;
Serial.print(label); result.errorCode = p->errorCode;
Serial.print(": "); 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);
}
return true;
}
// --- Helpers ---
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++) {
if (data[i] < 0x10) Serial.print("0"); uint8_t hi = hex[i * 2], lo = hex[i * 2 + 1];
Serial.print(data[i], HEX); if (hi >= '0' && hi <= '9') hi -= '0';
Serial.print(" "); else if (hi >= 'a' && hi <= 'f') hi = hi - 'a' + 10;
else if (hi >= 'A' && hi <= 'F') hi = hi - 'A' + 10;
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;
} }
Serial.println(); return true;
}
void VictronBLE::normalizeMAC(const char* input, char* output) {
int j = 0;
for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) {
char c = input[i];
if (c == ':' || c == '-') continue;
output[j++] = (c >= 'A' && c <= 'F') ? (c + 32) : c;
}
output[j] = '\0';
}
VictronBLE::DeviceEntry* VictronBLE::findDevice(const char* normalizedMAC) {
for (size_t i = 0; i < deviceCount; i++) {
if (devices[i].active && strcmp(devices[i].device.mac, normalizedMAC) == 0) {
return &devices[i];
}
}
return nullptr;
} }
+240 -174
View File
@@ -1,5 +1,9 @@
/** /**
* VictronBLE - ESP32 library for Victron Energy BLE devices * VictronBLE - portable library for Victron Energy BLE devices
*
* Runs on ESP32 (Bluedroid) and nRF52840 (Bluefruit); the BLE scanning backend
* is the only platform-specific code (see src/esp32 and src/nrf52). Decoding and
* AES-128-CTR decryption are common to all targets.
* *
* Based on Victron's official BLE Advertising protocol documentation * Based on Victron's official BLE Advertising protocol documentation
* Inspired by hoberman's examples and keshavdv's Python library * Inspired by hoberman's examples and keshavdv's Python library
@@ -12,17 +16,30 @@
#define VICTRON_BLE_H #define VICTRON_BLE_H
#include <Arduino.h> #include <Arduino.h>
// --- Platform BLE backend selection ---
// The BLE scanning layer is the only platform-specific part of the library.
// Decoding and crypto are common to all targets.
#if defined(ARDUINO_ARCH_ESP32)
#include <BLEDevice.h> #include <BLEDevice.h>
#include <BLEAdvertisedDevice.h> #include <BLEAdvertisedDevice.h>
#include <BLEScan.h> #include <BLEScan.h>
#include <map> #define VICTRON_BACKEND_ESP32 1
#include <vector> #elif defined(ARDUINO_ARCH_NRF52) || defined(NRF52840_XXAA) || defined(NRF52832_XXAA)
#include "mbedtls/aes.h" #include <bluefruit.h>
#define VICTRON_BACKEND_NRF52 1
#else
#error "VictronBLE: unsupported platform (need ESP32 Arduino or Adafruit/Seeed nRF52 core)"
#endif
// 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,
@@ -31,14 +48,17 @@ enum VictronDeviceType {
DEVICE_TYPE_DCDC_CONVERTER = 0x04, DEVICE_TYPE_DCDC_CONVERTER = 0x04,
DEVICE_TYPE_SMART_LITHIUM = 0x05, DEVICE_TYPE_SMART_LITHIUM = 0x05,
DEVICE_TYPE_INVERTER_RS = 0x06, DEVICE_TYPE_INVERTER_RS = 0x06,
DEVICE_TYPE_SMART_BATTERY_PROTECT = 0x07, DEVICE_TYPE_GX_DEVICE = 0x07,
DEVICE_TYPE_LYNX_SMART_BMS = 0x08, DEVICE_TYPE_AC_CHARGER = 0x08,
DEVICE_TYPE_MULTI_RS = 0x09, DEVICE_TYPE_SMART_BATTERY_PROTECT = 0x09,
DEVICE_TYPE_VE_BUS = 0x0A, DEVICE_TYPE_LYNX_SMART_BMS = 0x0A,
DEVICE_TYPE_DC_ENERGY_METER = 0x0B DEVICE_TYPE_MULTI_RS = 0x0B,
DEVICE_TYPE_VE_BUS = 0x0C,
DEVICE_TYPE_DC_ENERGY_METER = 0x0D,
DEVICE_TYPE_ORION_XS = 0x0F
}; };
// Device state for Solar Charger // --- Device state for Solar Charger ---
enum SolarChargerState { enum SolarChargerState {
CHARGER_OFF = 0, CHARGER_OFF = 0,
CHARGER_LOW_POWER = 1, CHARGER_LOW_POWER = 1,
@@ -53,96 +73,238 @@ enum SolarChargerState {
CHARGER_EXTERNAL_CONTROL = 252 CHARGER_EXTERNAL_CONTROL = 252
}; };
// Base structure for all device data // ============================================================
struct VictronDeviceData { // Wire-format packed structures for decoding BLE advertisements
String deviceName; // ============================================================
String macAddress;
VictronDeviceType deviceType;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
VictronDeviceData() : deviceType(DEVICE_TYPE_UNKNOWN), rssi(-100), struct victronManufacturerData {
lastUpdate(0), dataValid(false) {} uint16_t vendorID;
}; uint8_t beaconType; // 0x10 = Product Advertisement
uint8_t unknownData1[3];
uint8_t victronRecordType; // Device type (see VictronDeviceType)
uint16_t nonceDataCounter;
uint8_t encryptKeyMatch; // Should match encryption key byte 0
uint8_t victronEncryptedData[VICTRON_ENCRYPTED_LEN];
} __attribute__((packed));
// Solar Charger specific data struct victronSolarChargerPayload {
struct SolarChargerData : public VictronDeviceData { uint8_t deviceState;
SolarChargerState chargeState; uint8_t errorCode;
int16_t batteryVoltage; // 0.01V units (signed)
int16_t batteryCurrent; // 0.1A units (signed)
uint16_t yieldToday; // 0.01kWh (10Wh) units
uint16_t inputPower; // 1W units
uint16_t loadCurrent; // 9-bit field, 0.1A units (0x1FF = no load)
uint8_t reserved[2];
} __attribute__((packed));
// NOTE: The battery monitor payload is bit-packed (16-bit alarm, 2-bit aux mode,
// 22-bit current, 20-bit consumed Ah, 10-bit SOC) and does NOT byte-align, so it
// is decoded by bit offset directly in parseBatteryMonitor() rather than a struct.
struct victronInverterPayload {
uint8_t deviceState;
uint8_t errorCode;
uint16_t batteryVoltage; // 10mV units
int16_t batteryCurrent; // 10mA units (signed)
uint8_t acPowerLow;
uint8_t acPowerMid;
uint8_t acPowerHigh; // Signed 24-bit
uint8_t alarms;
uint8_t reserved[4];
} __attribute__((packed));
struct victronDCDCConverterPayload {
uint8_t chargeState;
uint8_t errorCode;
uint16_t inputVoltage; // 10mV units
uint16_t outputVoltage; // 10mV units
uint16_t outputCurrent; // 10mA units
uint8_t reserved[6];
} __attribute__((packed));
// ============================================================
// Parsed data structures (flat, no inheritance)
// ============================================================
struct VictronSolarData {
uint8_t chargeState; // SolarChargerState enum
uint8_t errorCode;
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 VictronACChargerData {
struct BatteryMonitorData : public VictronDeviceData { uint8_t chargeState; // SolarChargerState enum (shared charger states)
uint8_t errorCode;
float voltage1; // V (output 1)
float current1; // A (output 1)
float voltage2; // V (output 2, 0 if absent)
float current2; // A (output 2, 0 if absent)
float voltage3; // V (output 3, 0 if absent)
float current3; // A (output 3, 0 if absent)
float temperature; // C (0 if not available)
float acCurrent; // A (0 if not available)
};
struct VictronBatteryData {
float voltage; // V float 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;
VictronACChargerData acCharger;
};
};
// ============================================================
// 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:
struct DeviceEntry {
VictronDevice device;
uint8_t key[16];
uint16_t lastNonce;
bool active;
};
// --- Common state (platform-independent) ---
DeviceEntry devices[VICTRON_MAX_DEVICES];
size_t deviceCount;
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);
// Common entry point fed by each platform BLE backend with one raw
// manufacturer-data record (vendor ID first), the device MAC and RSSI.
void onAdvertisement(const uint8_t* mfgData, size_t len,
const char* macStr, int8_t rssi);
bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg);
bool parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result);
bool parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result);
bool parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result);
bool parseInverter(const uint8_t* data, size_t len, VictronInverterData& result);
bool parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result);
// --- Platform-specific BLE backend (see src/esp32 and src/nrf52) ---
#if defined(VICTRON_BACKEND_ESP32)
friend class VictronBLEAdvertisedDeviceCallbacks;
BLEScan* pBLEScan;
VictronBLEAdvertisedDeviceCallbacks* scanCallbackObj;
void processDevice(BLEAdvertisedDevice& dev);
#elif defined(VICTRON_BACKEND_NRF52)
static VictronBLE* s_instance;
static void scanCallback(ble_gap_evt_adv_report_t* report);
#endif
};
#if defined(VICTRON_BACKEND_ESP32)
// 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;
};
#endif
// ============================================================
// 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() {}
@@ -152,113 +314,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) {}
};
// Main VictronBLE class
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); std::vector<String> getDevicesByType(VictronDeviceType type);
String getLastError() const;
// Enable/disable debug output #endif // commented-out features
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;
// 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 uint8_t* manufacturerData, size_t len,
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);
void debugPrintHex(const char* label, const uint8_t* data, size_t len);
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
+186
View File
@@ -0,0 +1,186 @@
/**
* Minimal AES-128 CTR-mode implementation for VictronBLE.
*
* Trimmed and symbol-prefixed adaptation of kokke/tiny-AES-c (public domain /
* Unlicense). Only AES-128 encryption (forward Cipher) and CTR mode are kept,
* since CTR uses the forward cipher for both encrypt and decrypt. The S-box and
* Rcon tables and the round transforms are unchanged from the upstream, which is
* verified against NIST SP 800-38A.
*/
#include <string.h>
#include "vble_aes.h"
#define Nb 4 // columns in the state
#define Nk 4 // 32-bit words in an AES-128 key
#define Nr 10 // rounds for AES-128
typedef uint8_t state_t[4][4];
static const uint8_t sbox[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 };
static const uint8_t Rcon[11] = {
0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36 };
#define getSBoxValue(num) (sbox[(num)])
static void KeyExpansion(uint8_t* RoundKey, const uint8_t* Key)
{
unsigned i, j, k;
uint8_t tempa[4];
for (i = 0; i < Nk; ++i) {
RoundKey[(i * 4) + 0] = Key[(i * 4) + 0];
RoundKey[(i * 4) + 1] = Key[(i * 4) + 1];
RoundKey[(i * 4) + 2] = Key[(i * 4) + 2];
RoundKey[(i * 4) + 3] = Key[(i * 4) + 3];
}
for (i = Nk; i < Nb * (Nr + 1); ++i) {
k = (i - 1) * 4;
tempa[0] = RoundKey[k + 0];
tempa[1] = RoundKey[k + 1];
tempa[2] = RoundKey[k + 2];
tempa[3] = RoundKey[k + 3];
if (i % Nk == 0) {
// RotWord
const uint8_t u8tmp = tempa[0];
tempa[0] = tempa[1];
tempa[1] = tempa[2];
tempa[2] = tempa[3];
tempa[3] = u8tmp;
// SubWord
tempa[0] = getSBoxValue(tempa[0]);
tempa[1] = getSBoxValue(tempa[1]);
tempa[2] = getSBoxValue(tempa[2]);
tempa[3] = getSBoxValue(tempa[3]);
tempa[0] = tempa[0] ^ Rcon[i / Nk];
}
j = i * 4; k = (i - Nk) * 4;
RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0];
RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1];
RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2];
RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3];
}
}
static void AddRoundKey(uint8_t round, state_t* state, const uint8_t* RoundKey)
{
uint8_t i, j;
for (i = 0; i < 4; ++i)
for (j = 0; j < 4; ++j)
(*state)[i][j] ^= RoundKey[(round * Nb * 4) + (i * Nb) + j];
}
static void SubBytes(state_t* state)
{
uint8_t i, j;
for (i = 0; i < 4; ++i)
for (j = 0; j < 4; ++j)
(*state)[j][i] = getSBoxValue((*state)[j][i]);
}
static void ShiftRows(state_t* state)
{
uint8_t temp;
temp = (*state)[0][1];
(*state)[0][1] = (*state)[1][1];
(*state)[1][1] = (*state)[2][1];
(*state)[2][1] = (*state)[3][1];
(*state)[3][1] = temp;
temp = (*state)[0][2];
(*state)[0][2] = (*state)[2][2];
(*state)[2][2] = temp;
temp = (*state)[1][2];
(*state)[1][2] = (*state)[3][2];
(*state)[3][2] = temp;
temp = (*state)[0][3];
(*state)[0][3] = (*state)[3][3];
(*state)[3][3] = (*state)[2][3];
(*state)[2][3] = (*state)[1][3];
(*state)[1][3] = temp;
}
static uint8_t xtime(uint8_t x)
{
return ((x << 1) ^ (((x >> 7) & 1) * 0x1b));
}
static void MixColumns(state_t* state)
{
uint8_t i, Tmp, Tm, t;
for (i = 0; i < 4; ++i) {
t = (*state)[i][0];
Tmp = (*state)[i][0] ^ (*state)[i][1] ^ (*state)[i][2] ^ (*state)[i][3];
Tm = (*state)[i][0] ^ (*state)[i][1]; Tm = xtime(Tm); (*state)[i][0] ^= Tm ^ Tmp;
Tm = (*state)[i][1] ^ (*state)[i][2]; Tm = xtime(Tm); (*state)[i][1] ^= Tm ^ Tmp;
Tm = (*state)[i][2] ^ (*state)[i][3]; Tm = xtime(Tm); (*state)[i][2] ^= Tm ^ Tmp;
Tm = (*state)[i][3] ^ t; Tm = xtime(Tm); (*state)[i][3] ^= Tm ^ Tmp;
}
}
static void Cipher(state_t* state, const uint8_t* RoundKey)
{
uint8_t round = 0;
AddRoundKey(0, state, RoundKey);
for (round = 1; ; ++round) {
SubBytes(state);
ShiftRows(state);
if (round == Nr) break;
MixColumns(state);
AddRoundKey(round, state, RoundKey);
}
AddRoundKey(Nr, state, RoundKey);
}
void vble_aes_init_ctx_iv(struct vble_aes_ctx* ctx,
const uint8_t* key, const uint8_t* iv)
{
KeyExpansion(ctx->RoundKey, key);
memcpy(ctx->Iv, iv, VBLE_AES_BLOCKLEN);
}
void vble_aes_ctr_xcrypt(struct vble_aes_ctx* ctx, uint8_t* buf, size_t length)
{
uint8_t buffer[VBLE_AES_BLOCKLEN];
size_t i;
int bi;
for (i = 0, bi = VBLE_AES_BLOCKLEN; i < length; ++i, ++bi) {
if (bi == VBLE_AES_BLOCKLEN) { // regenerate keystream block
memcpy(buffer, ctx->Iv, VBLE_AES_BLOCKLEN);
Cipher((state_t*)buffer, ctx->RoundKey);
// Increment counter (Iv) from the least-significant byte.
for (bi = (VBLE_AES_BLOCKLEN - 1); bi >= 0; --bi) {
if (ctx->Iv[bi] == 255) {
ctx->Iv[bi] = 0;
continue;
}
ctx->Iv[bi] += 1;
break;
}
bi = 0;
}
buf[i] = (buf[i] ^ buffer[bi]);
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Minimal AES-128 CTR-mode implementation for VictronBLE.
*
* Trimmed (CTR only, AES-128 only) and symbol-prefixed adaptation of
* kokke/tiny-AES-c (public domain / Unlicense), verified against the test
* vectors in NIST SP 800-38A. Bundled so the library has no external crypto
* dependency and builds identically on ESP32, nRF52 and any other target.
*
* Counter increment matches mbedTLS mbedtls_aes_crypt_ctr (increments the
* 128-bit counter from the least-significant byte), so output is byte-identical
* to the previous ESP32 mbedTLS-based decryption.
*/
#ifndef VBLE_AES_H_
#define VBLE_AES_H_
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define VBLE_AES_BLOCKLEN 16 // AES block length in bytes (128-bit)
#define VBLE_AES_KEYLEN 16 // AES-128 key length in bytes
#define VBLE_AES_KEYEXPSIZE 176
struct vble_aes_ctx {
uint8_t RoundKey[VBLE_AES_KEYEXPSIZE];
uint8_t Iv[VBLE_AES_BLOCKLEN];
};
// Initialise context with a 16-byte key and 16-byte IV (counter).
void vble_aes_init_ctx_iv(struct vble_aes_ctx* ctx,
const uint8_t* key, const uint8_t* iv);
// CTR-mode keystream XOR. Symmetric: same call encrypts and decrypts.
// Operates in place on `buf` for `length` bytes (length need not be a
// multiple of the block size).
void vble_aes_ctr_xcrypt(struct vble_aes_ctx* ctx, uint8_t* buf, size_t length);
#ifdef __cplusplus
}
#endif
#endif // VBLE_AES_H_
+69
View File
@@ -0,0 +1,69 @@
/**
* VictronBLE - ESP32 BLE scanning backend
*
* Uses the ESP32 Arduino BLE library (Bluedroid). Extracts the manufacturer
* data, MAC and RSSI from each passive scan result and hands them to the
* platform-independent VictronBLE::onAdvertisement().
*
* Copyright (c) 2025 Scott Penrose
* License: MIT
*/
#include "../VictronBLE.h"
#if defined(VICTRON_BACKEND_ESP32)
#include <string>
// Scan complete callback — clears the flag so loop() restarts the scan
static bool s_scanning = false;
static void onScanDone(BLEScanResults results) {
s_scanning = false;
}
bool VictronBLE::begin(uint32_t scanDuration) {
if (initialized) return true;
this->scanDuration = scanDuration;
BLEDevice::init("VictronBLE");
pBLEScan = BLEDevice::getScan();
if (!pBLEScan) return false;
scanCallbackObj = new VictronBLEAdvertisedDeviceCallbacks(this);
pBLEScan->setAdvertisedDeviceCallbacks(scanCallbackObj, true);
pBLEScan->setActiveScan(false); // passive: Victron beacons are non-connectable
pBLEScan->setInterval(100);
pBLEScan->setWindow(99);
initialized = true;
if (debugEnabled) Serial.println("[VictronBLE] Initialized (ESP32 backend)");
return true;
}
void VictronBLE::loop() {
if (!initialized) return;
if (!s_scanning) {
pBLEScan->clearResults();
s_scanning = pBLEScan->start(scanDuration, onScanDone, false);
}
}
// BLE scan callback
void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) {
if (victronBLE) victronBLE->processDevice(advertisedDevice);
}
void VictronBLE::processDevice(BLEAdvertisedDevice& advertisedDevice) {
if (!advertisedDevice.haveManufacturerData()) return;
// getManufacturerData() returns std::string on older ESP32 BLE libraries and
// an Arduino String on newer ones. Both expose c_str()/length(); building a
// std::string from (ptr, len) preserves the binary payload's null bytes.
auto mfg = advertisedDevice.getManufacturerData();
std::string raw(mfg.c_str(), mfg.length());
onAdvertisement(reinterpret_cast<const uint8_t*>(raw.data()), raw.length(),
advertisedDevice.getAddress().toString().c_str(),
advertisedDevice.getRSSI());
}
#endif // VICTRON_BACKEND_ESP32
+66
View File
@@ -0,0 +1,66 @@
/**
* VictronBLE - nRF52 BLE scanning backend (Adafruit/Seeed Bluefruit)
*
* Uses the Bluefruit nRF52 library bundled with the Adafruit/Seeed nRF52 core.
* Performs a continuous passive scan and extracts the manufacturer data, MAC
* and RSSI from each advertisement, handing them to the platform-independent
* VictronBLE::onAdvertisement().
*
* Tested target: Seeed XIAO nRF52840.
*
* Copyright (c) 2025 Scott Penrose
* License: MIT
*/
#include "../VictronBLE.h"
#if defined(VICTRON_BACKEND_NRF52)
VictronBLE* VictronBLE::s_instance = nullptr;
bool VictronBLE::begin(uint32_t scanDuration) {
if (initialized) return true;
this->scanDuration = scanDuration; // not used for nRF52 (scan is continuous)
s_instance = this;
Bluefruit.begin(0, 1); // 0 peripheral, 1 central (observer)
Bluefruit.setName("VictronBLE");
Bluefruit.Scanner.setRxCallback(VictronBLE::scanCallback);
Bluefruit.Scanner.restartOnDisconnect(true);
Bluefruit.Scanner.setInterval(160, 80); // 100ms interval / 50ms window (0.625ms units)
Bluefruit.Scanner.useActiveScan(false); // passive: Victron beacons are non-connectable
Bluefruit.Scanner.start(0); // 0 = scan forever
initialized = true;
if (debugEnabled) Serial.println("[VictronBLE] Initialized (nRF52 Bluefruit backend)");
return true;
}
void VictronBLE::loop() {
// Scanning is fully event-driven on nRF52 (SoftDevice invokes scanCallback);
// nothing to pump here. Kept for API parity with the ESP32 backend.
}
void VictronBLE::scanCallback(ble_gap_evt_adv_report_t* report) {
if (s_instance) {
// Manufacturer specific data (AD type 0xFF) — includes the 0x02E1 vendor ID
uint8_t buf[31];
uint8_t len = Bluefruit.Scanner.parseReportByType(
report, BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA, buf, sizeof(buf));
if (len >= 2) {
// peer_addr.addr is little-endian (LSB first); format big-endian to
// match the AA:BB:CC:DD:EE:FF MAC users copy from VictronConnect.
const uint8_t* a = report->peer_addr.addr;
char mac[18];
snprintf(mac, sizeof(mac), "%02x:%02x:%02x:%02x:%02x:%02x",
a[5], a[4], a[3], a[2], a[1], a[0]);
s_instance->onAdvertisement(buf, len, mac, report->rssi);
}
}
// Bluefruit pauses scanning while the RX callback runs — must resume.
Bluefruit.Scanner.resume();
}
#endif // VICTRON_BACKEND_NRF52