52 Commits
Author SHA1 Message Date
scottp a4505019e8 Ignore compiled host test binary 2026-08-21 11:59:28 +10:00
scottp b390c0d579 v0.7.0: pure C core + Zephyr module
- Extract all decode/decrypt into a dependency-free C99 core
 (include/victronble.h, src/victronble_core.c): victronble_decode(),
 is_product_adv/key_matches pre-filters, NAN sentinels, LE accessors.
- AES-128-CTR behind a hook: weak-symbol bundled tiny-AES default,
 runtime override (victronble_set_aes_ctr) for PSA/mbedTLS/hardware.
- Arduino VictronBLE class becomes a thin wrapper over the core
 (registry + nonce dedup + rate limit); public C++ API unchanged,
 NAN converted back to the legacy 0 convention.
- Host test vectors (tests/vectors): openssl-generated ciphertext,
 independent of the bundled AES; all five payload shapes + negatives.
- Zephyr module: zephyr/module.yml + Kconfig (CONFIG_VICTRONBLE) +
 observer backend (victronble_zephyr.{h,c}) — scan cb pre-filters and
 queues, dedicated decode thread, listener callbacks, slow passive
 scan defaults, stats counters. docs/ZEPHYR_PORT.md records the plan.
- library.properties: fix URL (gitea, not the nonexistent GitHub).
2026-08-21 11:59:19 +10:00
scottp 617c240e02 check version before release 2026-06-08 17:14:39 +10:00
scottp 8e91bd7b83 Add a rak4631 board definition testing 2026-06-07 23:22:36 +10:00
scottp 96184d888b Fix chips supported 2026-06-07 19:27:12 +10:00
scottp 2505612f0b Working across ESP32 S3, ESP32 C3 and nRF52 2026-06-07 19:23:13 +10:00
scottp abf0e414da Cleanup READMED and release notes 2026-06-05 00:00:25 +10:00
scottp 865c8dc4d4 ignore pka file 2026-06-04 23:48:47 +10:00
scottp 6c30e0d01d Branch version ready for testing with nRF52 2026-06-04 23:47:55 +10:00
scottp cedef26f34 Update info 2026-06-04 22:55:40 +10:00
scottp c2c5441398 ignore local 2026-06-04 21:21:55 +10:00
scottp 0791fd8cec Bug fixes for reported issues 2026-06-04 21:21:18 +10:00
scottp 55b12ec837 Plans for part 2 - multiple bluetooth devices 2026-02-28 15:00:45 +11:00
scottp cd3e32abeb Improve readme ready for v0.4 release 2026-02-28 14:40:41 +11:00
scottp 3a0adfab82 Versions v0.4 ready for release 2026-02-28 14:38:30 +11:00
scottp c3ee3577e1 Fix to be non blocking without tasks 2026-02-28 14:31:42 +11:00
scottp 166bc003d0 Update notes 2026-02-28 13:52:16 +11:00
scottp 4c9819758f Single callback version - vastly simplified. 2026-02-28 13:34:20 +11:00
scottp 08417ec8f1 More planning 2026-02-28 12:31:04 +11:00
scottp 4e5e4c258d Forwarding code 2026-02-17 09:27:48 +11:00
scottp cdb94ff23d Work on receiver and sender 2026-02-15 19:20:03 +11:00
scottp a3cd309581 Repeater and Test code for ESP Now 2026-02-15 19:10:19 +11:00
scottp a49686087c New version with smaller memory footprint etc 2026-02-12 18:33:56 +11:00
scottp 4aa386a5da Keep v0.3.1 2026-02-12 18:10:35 +11:00
scottp 6ab8ae1618 Created new logging example 2026-02-12 18:04:02 +11:00
scottp 77d1545378 Examples todo 2025-12-30 20:54:04 +11:00
scottp 8ebe36c58e Fix notes on testing 2025-12-29 20:35:47 +11:00
scottp 0fb4e82801 Fix name 2025-12-29 20:31:07 +11:00
scottp 76a0935518 Decoding working for MPPT 2025-12-29 20:22:41 +11:00
scottp 66b24d9434 Finally working decode MPPT 2025-12-29 20:00:05 +11:00
scottp 53418b5889 work on better mac address 2025-12-29 19:26:32 +11:00
scottp 3acb14872e Cleaning up by using structs and reusing data blocks 2025-12-29 19:12:47 +11:00
scottp 14dfd41626 Experimental version 2025-12-29 13:38:47 +11:00
scottp c9d49336d4 Working serial on S3 too. Interesting... 2025-12-29 11:40:46 +11:00
scottp eb9e1261c8 Merge branch 'main' of https://gitea.sh3d.com.au/sh3d/VictronBLE 2025-12-29 11:16:43 +11:00
scottp 047e5c2580 Add core2 2025-12-29 11:16:41 +11:00
scottp 531b67263c Cleanup only 2025-12-29 11:09:33 +11:00
scottp 28dec5ce3d Improved structs 2025-12-28 23:35:27 +11:00
scottp 04a5c545fd Working C3 build 2025-12-28 23:27:40 +11:00
scottp 054dd01a71 Playing with debug 2025-12-19 12:46:28 +11:00
scottp 2d0f88fd0b Work on decoding using structs 2025-12-18 22:27:15 +11:00
scottp 3d09d234f0 Experimenting and decoding - seems some structs are wrong, check original code 2025-12-18 21:49:04 +11:00
scottp 425eb1a178 TODO and m5stick and debug 2025-12-18 20:43:10 +11:00
scottp f21b6008a1 Ignore builds 2025-12-18 18:26:27 +11:00
scottp f2adf5b462 Fix version for examples 2025-12-18 18:23:31 +11:00
scottp 44294e26c6 Fix library 2025-12-18 18:22:33 +11:00
scottp cd94b6dd08 Unsure name 2025-12-18 18:17:49 +11:00
scottp 340539275a Fix name 2025-12-18 18:10:05 +11:00
scottp bda096f245 Fix version 2025-12-18 18:08:08 +11:00
scottp d760e7d8dc Initial version only 2025-12-18 17:53:39 +11:00
scottp b8c5f948fe Copyright etc 2025-12-18 17:48:55 +11:00
scottp a89cef3c89 Initial readme up 2025-12-18 17:39:13 +11:00
39 changed files with 4683 additions and 1477 deletions
-104
View File
@@ -1,104 +0,0 @@
# VictronBLE Project Context
## Project Overview
Arduino/ESP32 library for reading Victron Energy devices via Bluetooth Low Energy (BLE).
## 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
-31
View File
@@ -1,31 +0,0 @@
#!/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"
+5
View File
@@ -1,3 +1,5 @@
PKA_SUMMARY.md
# PlatformIO
.pio
.pioenvs
@@ -12,6 +14,8 @@ compile_commands.json
!.vscode/extensions.json
.history/
bugs
# Build artifacts
*.o
*.a
@@ -68,3 +72,4 @@ venv/
env/
*.tar.gz
tests/vectors/victronble_test
+12
View File
@@ -0,0 +1,12 @@
# Zephyr module entry point. PlatformIO/Arduino builds read library.json /
# library.properties and ignore this file entirely.
if(CONFIG_VICTRONBLE)
zephyr_library()
zephyr_library_sources(src/victronble_core.c)
zephyr_library_sources(src/victronble_zephyr.c)
zephyr_library_sources_ifdef(CONFIG_VICTRONBLE_CRYPTO_SOFTWARE
src/victronble_aes_sw.c
src/crypto/vble_aes.c
)
zephyr_include_directories(include)
endif()
+73
View File
@@ -0,0 +1,73 @@
menuconfig VICTRONBLE
bool "Victron Instant Readout BLE observer"
depends on BT_OBSERVER
help
Passive BLE observer for Victron Energy devices broadcasting
Instant Readout advertisements (manufacturer ID 0x02E1,
AES-128-CTR encrypted). No connection or pairing required.
The application must call bt_enable() before victronble_start().
if VICTRONBLE
config VICTRONBLE_MAX_DEVICES
int "Maximum monitored devices"
default 4
config VICTRONBLE_QUEUE_DEPTH
int "Advertisement queue depth"
default 8
help
Frames are copied off the BT RX thread into this queue and decoded
by a dedicated thread. A full queue drops the newest frame and
counts the drop (see victronble_get_stats()).
config VICTRONBLE_THREAD_STACK_SIZE
int "Decode thread stack size"
default 2048
config VICTRONBLE_THREAD_PRIORITY
int "Decode thread priority (preemptible)"
default 10
config VICTRONBLE_DEDUP
bool "Drop repeated advertisements by nonce counter"
default y
help
Each advertisement is broadcast repeatedly on three channels.
Tracking the last nonce per device suppresses duplicates so the
record callback only fires when the device published new data.
config VICTRONBLE_SCAN_INTERVAL
int "Scan interval (0.625 ms units)"
default 2048
help
Default 2048 = 1.28 s (BT_GAP_SCAN_SLOW_INTERVAL_1). Victron
devices advertise roughly once per second, so a low duty cycle
catches records at a fraction of the radio-on time.
config VICTRONBLE_SCAN_WINDOW
int "Scan window (0.625 ms units)"
default 18
help
Default 18 = 11.25 ms (BT_GAP_SCAN_SLOW_WINDOW_1). Raise toward
the interval for faster acquisition at higher power draw.
choice VICTRONBLE_CRYPTO
prompt "AES-CTR backend"
default VICTRONBLE_CRYPTO_SOFTWARE
config VICTRONBLE_CRYPTO_SOFTWARE
bool "Bundled software AES-128"
help
The library's dependency-free tiny-AES CTR implementation. An
external backend can instead provide a strong
victronble_aes_ctr_default() (weak symbol) or register one at
runtime with victronble_set_aes_ctr().
endchoice
module = VICTRONBLE
module-str = victronble
source "subsys/logging/Kconfig.template.log_config"
endif # VICTRONBLE
+205 -176
View File
@@ -1,23 +1,22 @@
# 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 - LIMITED TESTING DONE**
This is an initial release (v0.3.1) and has been tested with MPPT on an ESP32-S3 and ESP32-C3.
Use with caution and please report any issues you encounter. Testing and feedback are greatly appreciated!
v0.6 adds **multi-platform support** (ESP32 + nRF52840) via a hardware-abstracted BLE backend and dependency-free bundled crypto. (v0.5 brought the decoding accuracy fixes and AC charger support; v0.4 reworked the internals — function-pointer 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.
Currently supportin ESP32 S and C series (tested on older ESP32, and ESP32-S3 and ESP32-C3). Other chipsets can be added with abstraction of Bluetooth code.
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
-**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
-**All Device Types**: Solar chargers, battery monitors, inverters, DC-DC converters
-**Framework Agnostic**: Works with Arduino and ESP-IDF
-**All Device Types**: Solar chargers, battery monitors, inverters, DC-DC converters, AC chargers
-**Framework Friendly**: Works with Arduino (and ESP-IDF on ESP32)
-**Clean API**: Simple, intuitive interface with callback support
-**No Pairing Required**: Reads BLE advertisement data directly
-**Low Power**: Uses passive BLE scanning
@@ -30,29 +29,58 @@ Currently supportin ESP32 S and C series (tested on older ESP32, and ESP32-S3 an
- **Battery Monitors**: SmartShunt, BMV-712 Smart, BMV-700 series
- **Inverters**: MultiPlus, Quattro, Phoenix (with VE.Bus BLE dongle)
- **DC-DC Converters**: Orion Smart, Orion XS
- **AC Chargers**: Blue Smart IP22 / IP65 / IP67 chargers
- **Others**: Smart Battery Protect, Lynx Smart BMS, Smart Lithium batteries
## Hardware Requirements
- 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
The BLE backend is selected automatically at compile time from the board's
architecture — no code changes are needed to switch platforms.
## Installation
### PlatformIO
1. Add to `platformio.ini`:
1. Add to `platformio.ini` (recommended — installs from the PlatformIO registry):
```ini
lib_deps =
https://gitea.sh3d.com.au/Sh3d/VictronBLE
scottp/victronble
```
Or install directly from git. Note the **`.git` suffix is required** — the bare
repository URL is not accepted by PlatformIO:
```ini
lib_deps =
https://gitea.sh3d.com.au/Sh3d/VictronBLE.git
```
2. Or clone into your project's `lib` folder:
```bash
cd lib
git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE
git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE.git
```
#### 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
1. Download or clone this repository
@@ -81,38 +109,34 @@ Use the VictronConnect app to get your device's encryption key:
VictronBLE victron;
// Callback for data updates
class MyCallback : public VictronDeviceCallback {
public:
void onSolarChargerData(const SolarChargerData& data) override {
Serial.printf("Solar: %.2fV, %.2fA, %dW\n",
data.batteryVoltage,
data.batteryCurrent,
data.panelPower);
// Callback — receives a VictronDevice*, switch on deviceType
void onVictronData(const VictronDevice* dev) {
if (dev->deviceType == DEVICE_TYPE_SOLAR_CHARGER) {
Serial.printf("Solar %s: %.2fV %.2fA %dW\n",
dev->name,
dev->solar.batteryVoltage,
dev->solar.batteryCurrent,
(int)dev->solar.panelPower);
}
}
};
MyCallback callback;
void setup() {
Serial.begin(115200);
// Initialize library
victron.begin(5); // 5 second scan duration
victron.setCallback(&callback);
victron.setCallback(onVictronData);
// Add your device (replace with your MAC and key)
victron.addDevice(
"My MPPT", // Name
"AA:BB:CC:DD:EE:FF", // MAC address
"0123456789abcdef0123456789abcdef", // Encryption key
DEVICE_TYPE_SOLAR_CHARGER // Device type
DEVICE_TYPE_SOLAR_CHARGER // Device type (optional, auto-detected)
);
}
void loop() {
victron.loop();
delay(100);
victron.loop(); // Non-blocking, returns immediately
}
```
@@ -125,130 +149,113 @@ void loop() {
```cpp
bool begin(uint32_t scanDuration = 5);
```
Initialize BLE and start scanning. Returns `true` on success.
Initialize BLE scanning. Returns `true` on success.
**Parameters:**
- `scanDuration`: BLE scan duration in seconds (default: 5)
- `scanDuration`: BLE scan window in seconds (default: 5)
#### Device Management
```cpp
bool addDevice(String name, String macAddress, String encryptionKey,
VictronDeviceType expectedType = DEVICE_TYPE_UNKNOWN);
bool addDevice(const char* name, const char* mac, const char* hexKey,
VictronDeviceType type = DEVICE_TYPE_UNKNOWN);
```
Add a device to monitor.
Add a device to monitor (max 8 devices).
**Parameters:**
- `name`: Friendly name for the device
- `macAddress`: Device MAC address (format: "AA:BB:CC:DD:EE:FF")
- `encryptionKey`: 32-character hex encryption key from VictronConnect
- `expectedType`: Device type (optional, for validation)
- `mac`: Device MAC address (format: `"AA:BB:CC:DD:EE:FF"` or `"aabbccddeeff"`)
- `hexKey`: 32-character hex encryption key from VictronConnect
- `type`: Device type (optional, auto-detected from BLE advertisement)
**Returns:** `true` on success
```cpp
void removeDevice(String macAddress);
```
Remove a device from monitoring.
```cpp
size_t getDeviceCount();
size_t getDeviceCount() const;
```
Get the number of configured devices.
#### Data Access
#### Callback
```cpp
bool getSolarChargerData(String macAddress, SolarChargerData& data);
bool getBatteryMonitorData(String macAddress, BatteryMonitorData& data);
bool getInverterData(String macAddress, InverterData& data);
bool getDCDCConverterData(String macAddress, DCDCConverterData& data);
void setCallback(VictronCallback cb);
```
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
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
void setCallback(VictronDeviceCallback* callback);
void setMinInterval(uint32_t ms);
```
Set callback object to receive data updates automatically.
#### Utilities
Set minimum callback interval per device (default: 1000ms). Callbacks are also suppressed when the device nonce hasn't changed (data unchanged).
```cpp
void setDebug(bool enable);
```
Enable/disable debug output to Serial.
```cpp
String getLastError();
```
Get last error message.
#### Main Loop
```cpp
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
#### SolarChargerData
#### VictronDevice (main struct)
All device types share this struct. Access type-specific data via the union member matching `deviceType`.
```cpp
struct SolarChargerData {
String deviceName;
String macAddress;
struct VictronDevice {
char name[32];
char mac[13]; // 12 hex chars + null
VictronDeviceType deviceType;
int8_t rssi; // Signal strength (dBm)
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 batteryCurrent; // A
float panelVoltage; // V (calculated)
float panelPower; // W
uint16_t yieldToday; // Wh
float loadCurrent; // A (if load output present)
};
```
**Charge States:**
- `CHARGER_OFF` - Off
- `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
**Charge States** (`chargeState` values):
`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`
#### BatteryMonitorData
#### VictronBatteryData
```cpp
struct BatteryMonitorData {
String deviceName;
String macAddress;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
struct VictronBatteryData {
float voltage; // V
float current; // A (+ charging, - discharging)
float temperature; // °C (if configured)
float auxVoltage; // V (starter battery/midpoint)
uint16_t remainingMinutes; // Time remaining
float consumedAh; // Ah consumed
float temperature; // C (0 if aux is voltage)
float auxVoltage; // V (0 if aux is temperature)
uint16_t remainingMinutes;
float consumedAh; // Ah
float soc; // State of charge %
// Alarms
bool alarmLowVoltage;
bool alarmHighVoltage;
bool alarmLowSOC;
@@ -257,39 +264,25 @@ struct BatteryMonitorData {
};
```
#### InverterData
#### VictronInverterData
```cpp
struct InverterData {
String deviceName;
String macAddress;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
struct VictronInverterData {
float batteryVoltage; // V
float batteryCurrent; // A
float acPower; // W (+ inverting, - charging)
uint8_t state; // Inverter state
// Alarms
bool alarmHighVoltage;
uint8_t state;
bool alarmLowVoltage;
bool alarmHighVoltage;
bool alarmHighTemperature;
bool alarmOverload;
};
```
#### DCDCConverterData
#### VictronDCDCData
```cpp
struct DCDCConverterData {
String deviceName;
String macAddress;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
struct VictronDCDCData {
float inputVoltage; // V
float outputVoltage; // V
float outputCurrent; // A
@@ -298,6 +291,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
### Multiple Devices
@@ -305,78 +315,57 @@ struct DCDCConverterData {
```cpp
void setup() {
victron.begin(5);
victron.setCallback(&callback);
victron.setCallback(onVictronData);
// Add multiple devices
victron.addDevice("MPPT 1", "AA:BB:CC:DD:EE:01", "key1...", DEVICE_TYPE_SOLAR_CHARGER);
victron.addDevice("MPPT 2", "AA:BB:CC:DD:EE:02", "key2...", DEVICE_TYPE_SOLAR_CHARGER);
victron.addDevice("SmartShunt", "AA:BB:CC:DD:EE:03", "key3...", DEVICE_TYPE_BATTERY_MONITOR);
victron.addDevice("Inverter", "AA:BB:CC:DD:EE:04", "key4...", DEVICE_TYPE_INVERTER);
// Add multiple devices (type is auto-detected from BLE advertisements)
victron.addDevice("MPPT 1", "AA:BB:CC:DD:EE:01", "key1...");
victron.addDevice("MPPT 2", "AA:BB:CC:DD:EE:02", "key2...");
victron.addDevice("SmartShunt", "AA:BB:CC:DD:EE:03", "key3...");
victron.addDevice("Inverter", "AA:BB:CC:DD:EE:04", "key4...");
}
```
### Manual Data Polling
### Handling Multiple Device Types
```cpp
void loop() {
victron.loop();
// Query specific device
SolarChargerData mpptData;
if (victron.getSolarChargerData("AA:BB:CC:DD:EE:FF", mpptData)) {
if (mpptData.dataValid) {
// Use data
float power = mpptData.panelPower;
void onVictronData(const VictronDevice* dev) {
switch (dev->deviceType) {
case DEVICE_TYPE_SOLAR_CHARGER:
Serial.printf("%s: %.2fV %dW\n", dev->name,
dev->solar.batteryVoltage, (int)dev->solar.panelPower);
break;
case DEVICE_TYPE_BATTERY_MONITOR:
Serial.printf("%s: %.2fV %.1f%%\n", dev->name,
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
void loop() {
victron.loop();
void setup() {
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
@@ -413,18 +402,55 @@ This library implements the Victron BLE Advertising protocol:
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
See the `examples/` directory for:
- **MultiDevice**: Monitor multiple devices with callbacks
- More examples coming soon!
- **MultiDevice**: Monitor multiple devices with callbacks. One sketch, multiple
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
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:
1. Fork the repository
1. Fork the [GitHub mirror](https://github.com/SH3D/VictronBLE)
2. Create a feature branch
3. Test thoroughly on real hardware
4. Submit a pull request
@@ -456,7 +482,10 @@ See [VERSIONS](VERSIONS) file for detailed changelog and release history.
## 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
- 🔧 Enable debug mode for diagnostics
- 📚 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
+149
View File
@@ -1,5 +1,154 @@
# Version History
## 0.7.0 (2026-08-21)
Pure C core + Zephyr support. One repo now serves three ecosystems: Arduino
(unchanged public API), PlatformIO, and Zephyr west workspaces.
### Pure C99 core (`include/victronble.h`, `src/victronble_core.c`)
- All decoding and decryption extracted into a dependency-free, reentrant,
allocation-free C core: `victronble_decode(mfg, len, key, out)` plus the
cheap pre-filters `victronble_is_product_adv()` / `victronble_key_matches()`
and helpers (`victronble_parse_key`, `strerror`, `device_type_str`,
`state_str`). Explicit little-endian accessors — no packed-struct punning.
- Absent wire fields are now NAN in the core (test with `isnan()`); the
Arduino wrapper converts back to the legacy `0` convention, so existing
sketches see identical values.
- AES is behind a CTR-shaped hook (`victronble_aes_ctr_fn`): weak-symbol
default = the bundled tiny-AES (linker-droppable), runtime override via
`victronble_set_aes_ctr()` for PSA/mbedTLS/hardware backends.
- `VictronBLE` (Arduino) is now a thin wrapper over the core — registry,
nonce dedup and rate limiting only. Public C++ API unchanged.
### Host test vectors (`tests/vectors/`)
- Plain-gcc harness (`run.sh`), no framework. Positive vectors for all five
payload shapes are generated by `gen_vectors.py` and encrypted with the
openssl CLI — independent of the bundled AES, so the CTR/nonce semantics
are cross-checked — plus negative cases (truncated, wrong vendor, wrong
key, unsupported record type).
### Zephyr module (`zephyr/module.yml`, `Kconfig`, `CMakeLists.txt`)
- `CONFIG_VICTRONBLE` (needs `CONFIG_BT_OBSERVER`): passive-scan observer
(`include/victronble_zephyr.h`, `src/victronble_zephyr.c`). The scan
callback only pre-filters and queues; a dedicated thread decrypts, decodes,
nonce-dedups and fans out to registered listeners
(`victronble_device_add(addr, key)` / `victronble_cb_register()` /
`victronble_start()`), with `victronble_get_stats()` counters. Default scan
is slow/low-duty (1.28 s / 11.25 ms — Victron advertises ~1 Hz). Consume as
a west project or via `-DZEPHYR_EXTRA_MODULES=<path>`; see
`docs/ZEPHYR_PORT.md` for the porting plan this implements.
### Fixed
- `library.properties` URL now points at the real repo (gitea) instead of a
nonexistent GitHub mirror.
## 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
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# check_version.sh -- verify the library version is consistent and released.
#
# Read-only: confirms the version matches in both manifests and that a matching
# git tag (vX.Y.Z) exists. Run it before tagging a release. Non-zero exit if the
# versions disagree or the tag is missing.
#
# SPDX-License-Identifier: MIT
set -u
cd "$(dirname "$0")" || exit 2
json=$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' library.json | head -1)
prop=$(sed -nE 's/^version=([^[:space:]]+).*/\1/p' library.properties | tr -d '\r' | head -1)
echo "library.json : ${json:-<none>}"
echo "library.properties : ${prop:-<none>}"
fail=0
if [ -n "$json" ] && [ "$json" = "$prop" ]; then
echo "ok versions match ($json)"
else
echo "FAIL versions differ (json=$json properties=$prop)"; fail=1
fi
if git rev-parse -q --verify "refs/tags/v$json" >/dev/null 2>&1; then
echo "ok git tag v$json exists"
else
echo "FAIL no git tag v$json -- create one to release: git tag v$json"; fail=1
fi
exit "$fail"
+560
View File
@@ -0,0 +1,560 @@
# victronble → pure C core + Zephyr module
Staged porting plan. Five stages, each independently shippable. Stages 02 leave the
existing PlatformIO library working the whole way through; Zephyr only appears at Stage 3.
**Assumption throughout:** the protocol offsets, model IDs, sentinel values and per-device
bit layouts already exist and are correct in your ESP32/nRF52 implementation. This plan does
not re-derive them — it restructures around them. Where a byte offset appears below it is
illustrative; take the canonical values from your working code and from Victron's
*Extra Manufacturer Data* PDF.
---
## Stage 0 — Throwaway Zephyr spike
**Time:** 2 hours. **Output:** deleted afterwards. **Purpose:** de-risk three unknowns
before you commit to an API shape.
Copy the parse functions verbatim into a single `main.c`. Hardcode the key and MAC.
`printk` one decoded SmartSolar record. Do not abstract anything.
What you are actually finding out:
1. **Does the bundled AES build clean under Zephyr's toolchain** with no Arduino headers
dragged in behind it. If it doesn't, you learn that now rather than at Stage 3.
2. **Where the decrypt has to live.** The scan callback runs on the BT RX thread. Time
spent there delays HCI event processing. Confirm you can decrypt inline for a spike,
then confirm you don't want to.
3. **Whether `bt_data_parse()` gives you what you expect.** In particular that
`BT_DATA_MANUFACTURER_DATA` arrives with the company ID as the first two bytes of
`data->data`, and that the payload is intact at the length you expect.
Minimal `prj.conf`:
```
CONFIG_BT=y
CONFIG_BT_OBSERVER=y
CONFIG_BT_DEVICE_NAME="victron-spike"
CONFIG_LOG=y
CONFIG_LOG_MODE_IMMEDIATE=y
```
Minimal scan setup:
```c
static const struct bt_le_scan_param scan_param = {
.type = BT_LE_SCAN_TYPE_PASSIVE,
.options = BT_LE_SCAN_OPT_NONE,
.interval = BT_GAP_SCAN_FAST_INTERVAL,
.window = BT_GAP_SCAN_FAST_WINDOW,
};
static bool ad_cb(struct bt_data *data, void *user_data)
{
if (data->type != BT_DATA_MANUFACTURER_DATA) {
return true; /* keep walking the AD structures */
}
if (data->data_len < 10 || sys_get_le16(data->data) != 0x02E1) {
return true;
}
/* ... spike decrypt here ... */
return false; /* found it, stop */
}
static void scan_recv(const bt_addr_le_t *addr, int8_t rssi,
uint8_t adv_type, struct net_buf_simple *ad)
{
bt_data_parse(ad, ad_cb, (void *)addr);
}
```
Two traps worth knowing before you hit them:
- **`bt_data_parse()` consumes the buffer.** It pulls from the `net_buf_simple` as it
walks. If you need the raw advertisement afterwards, clone the state or copy the bytes
out first.
- **Callback registration is version-sensitive.** The `bt_le_scan_start(&param, cb)` form
and the newer `bt_le_scan_cb_register()` / `struct bt_le_scan_cb` form have coexisted
across releases with the former deprecated at various points. Check which one your
Zephyr/NCS version wants rather than trusting any example you find online, including
this one.
**Exit criterion:** one real record from one real SmartSolar, decrypted and printed
correctly on hardware. Then delete the spike.
---
## Stage 1 — Extract the pure C99 core
This is the bulk of the work and the part with value independent of Zephyr. When it's
done you can unit-test the parser on your workstation for the first time.
### Rules for the core
- C99. No C++, no `String`, no Arduino headers, no `Serial`.
- No allocation. Ever. Caller owns all storage.
- No I/O. No logging. Return codes only — the caller decides what to say about them.
- Freestanding-safe: `<stdint.h>`, `<stddef.h>`, `<string.h>`, `<math.h>` only.
- Reentrant. No file-scope mutable state in the parse path.
### `include/victronble.h`
```c
#ifndef VICTRONBLE_H
#define VICTRONBLE_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#define VICTRONBLE_COMPANY_ID 0x02E1u /* Victron Energy BV */
#define VICTRONBLE_KEY_LEN 16
#define VICTRONBLE_MAX_MFG_LEN 31
typedef enum {
VICTRONBLE_OK = 0,
VICTRONBLE_ERR_NOT_VICTRON = -1, /* company ID mismatch */
VICTRONBLE_ERR_SHORT = -2, /* truncated advertisement */
VICTRONBLE_ERR_NOT_PRODUCT = -3, /* not a product-advertisement record */
VICTRONBLE_ERR_KEY_MISMATCH = -4, /* key check byte failed */
VICTRONBLE_ERR_UNSUPPORTED = -5, /* known record type, no decoder */
VICTRONBLE_ERR_CRYPTO = -6, /* AES backend failed */
VICTRONBLE_ERR_DUPLICATE = -7, /* counter already seen (dedup enabled) */
} victronble_err_t;
typedef enum {
VICTRONBLE_DEV_UNKNOWN = 0,
VICTRONBLE_DEV_SOLAR_CHARGER,
VICTRONBLE_DEV_BATTERY_MONITOR,
VICTRONBLE_DEV_INVERTER,
VICTRONBLE_DEV_DCDC_CONVERTER,
VICTRONBLE_DEV_SMART_LITHIUM,
VICTRONBLE_DEV_AC_CHARGER,
/* extend from your existing enum */
} victronble_device_type_t;
typedef struct {
float battery_voltage; /* V, NAN if not present */
float battery_current; /* A, NAN if not present */
float yield_today; /* kWh */
float pv_power; /* W */
float load_current; /* A, NAN if load output absent */
uint8_t state;
uint8_t error;
} victronble_solar_charger_t;
/* ... battery monitor, inverter, dcdc, etc. ... */
typedef struct {
victronble_device_type_t type;
uint16_t model_id;
uint16_t counter; /* nonce / data counter, as received */
union {
victronble_solar_charger_t solar;
victronble_battery_monitor_t batmon;
/* ... */
} u;
} victronble_record_t;
/**
* Decode one Victron manufacturer-data blob.
*
* @param mfg Manufacturer-specific data, starting at the company ID.
* @param len Length of @p mfg.
* @param key 16-byte per-device advertisement key.
* @param out Populated on VICTRONBLE_OK. Untouched otherwise.
*
* Reentrant, allocation-free, no I/O.
*/
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN],
victronble_record_t *out);
/** Cheap pre-filter: company ID + record type only, no crypto. */
bool victronble_is_product_adv(const uint8_t *mfg, size_t len);
/** Key check byte test, so callers with several keys can pick one without decrypting. */
bool victronble_key_matches(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN]);
const char *victronble_strerror(victronble_err_t err);
#ifdef __cplusplus
}
#endif
#endif /* VICTRONBLE_H */
```
`victronble_key_matches()` is worth having separately. With several monitored devices you
otherwise burn an AES operation per device per advertisement just to find out which key
applies. The key check byte answers it for free.
### Sentinels
Victron encodes "not available" as per-field sentinel values, and they differ by field
width and signedness. Getting this wrong is the most likely source of a plausible-looking
but wrong reading, so decide the convention once and apply it everywhere.
Recommendation: **`NAN` for every float field that has a sentinel.** It propagates
correctly through arithmetic, tests cleanly with `isnan()`, and can't be confused with a
real zero the way a magic float can. For integer fields (state, error codes) keep the raw
value and document the sentinel.
If you'd rather avoid `<math.h>` on the smallest targets, the alternative is a
`uint32_t valid` bitmask per record — more code at every call site, but no FP dependency.
I'd only do this if flash is genuinely tight.
### Header layout
Encode the frame header as one internal struct with a single parse function, rather than
scattered offset arithmetic. Fields: record type, model ID (LE16), device/read-out type,
nonce counter (LE16), key check byte, then ciphertext offset and length. Use explicit
`sys_get_le16()`-style accessors rather than casting to packed structs — you'll want this
core to build on anything, and unaligned struct punning is exactly the kind of thing that
works on Cortex-M4 and bites you elsewhere.
---
## Stage 2 — AES abstraction
Split out because it's the one design decision that's hard to reverse later.
### Make the hook CTR-shaped, not ECB-shaped
Tempting to expose a single AES-128-ECB block encrypt, since for a ≤16-byte payload
CTR reduces to *ECB(counter block) XOR ciphertext* and you'd never need more. Don't.
Some record types (VE.Bus, Lynx BMS) exceed one block, and — more importantly — PSA and
every hardware accelerator expose CTR natively. An ECB-shaped hook forces those backends
to reimplement the counter loop that PSA would have done for them.
```c
/**
* AES-128-CTR transform hook.
*
* @param key 16-byte key.
* @param iv 16-byte initial counter block (nonce in the low bytes, rest zero).
* @param in Ciphertext.
* @param out Plaintext. May alias @p in.
* @param len Byte count, not necessarily a multiple of 16.
* @param user Opaque context supplied at registration.
* @return 0 on success, negative on failure.
*/
typedef int (*victronble_aes_ctr_fn)(const uint8_t key[16],
const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user);
```
### Selection mechanism
Use a **weak symbol default plus a runtime setter**:
```c
__attribute__((weak))
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
```
The weak symbol lets the linker drop the bundled software AES entirely when a backend
overrides it — which matters on a flash-constrained solar node. The runtime setter covers
the case where the backend is chosen at runtime or in a test harness. Both, not one.
### Backends to ship
| Backend | File | Notes |
|---|---|---|
| Bundled software | `victronble_aes_sw.c` | Current implementation, unchanged. Default. Zero dependencies — keep this property, it's the reason your library ports easily. |
| PSA Crypto | `victronble_aes_psa.c` | `psa_crypto_init()` once, then `psa_cipher_encrypt()` with `PSA_ALG_CTR`. On nRF52840 this routes to CryptoCell (CC310). |
| mbedTLS | `victronble_aes_mbedtls.c` | Optional. `mbedtls_aes_crypt_ctr()`. Mostly for ESP-IDF users who already link it. |
Two PSA notes worth writing down now:
- Key lifetime. Importing a volatile key per advertisement is wasteful. Import once per
monitored device at registration and cache the `psa_key_id_t`, which means your device
registry needs somewhere to hold it — plan the struct field now rather than retrofitting.
- `psa_crypto_init()` must have run before any use, and on NCS the relevant Kconfig lives
under `NRF_SECURITY` rather than plain `MBEDTLS_*`. This diverges between upstream Zephyr
and NCS and is the single most annoying part of Stage 3.
### Host test harness
This is the payoff for Stages 12. Capture advertisement frames from your working ESP32
build as hex, pair them with expected decoded values, and run the core under plain `gcc`
on the workstation:
```c
static const struct {
const char *hex;
const char *key_hex;
victronble_err_t expect_err;
victronble_device_type_t expect_type;
float expect_batt_v;
} vectors[] = {
{ "e10210...", "0df4d0...", VICTRONBLE_OK, VICTRONBLE_DEV_SOLAR_CHARGER, 13.24f },
/* one per device type, plus: truncated frame, wrong key, unknown record type */
};
```
No test framework needed — a `main()` and a non-zero exit is enough, and it drops straight
into CI. Same shape as the LoRaScope parser vectors. Include the negative cases; the
error paths are where a parser rewrite actually breaks.
---
## Stage 3 — Arduino wrapper over the C core
Before touching Zephyr, prove the extraction by making the existing library a consumer
of it. `VictronBLE` becomes a thin C++ class that owns the device table and calls
`victronble_decode()`. The BLE backends (NimBLE / Bluefruit) keep their current structure
and feed raw manufacturer bytes into the core.
If the public C++ API is unchanged, this is a patch release and existing PlatformIO users
notice nothing. That's the goal. Any pressure to change the C++ API here is a signal that
the C core's shape is wrong — fix the core, not the wrapper.
---
## Stage 4 — Zephyr module
Now the C core exists and is tested, this is mostly plumbing.
### Repo layout
One repo serves both ecosystems. PlatformIO reads `library.json` and ignores CMake;
Zephyr reads `zephyr/module.yml` and ignores `library.json`.
```
victronble/
├── library.json # PlatformIO
├── CMakeLists.txt # Zephyr module entry point
├── Kconfig
├── zephyr/
│ └── module.yml
├── include/
│ └── victronble.h # pure C core
│ └── victronble_zephyr.h # Zephyr-specific observer API
├── src/
│ ├── victronble_core.c # pure C99, no dependencies
│ ├── victronble_aes_sw.c
│ ├── victronble_aes_psa.c
│ ├── victronble_zephyr.c # scan + workqueue + device registry
│ ├── VictronBLE.cpp # Arduino wrapper
│ └── ble_backend_*.cpp # NimBLE / Bluefruit
├── samples/
│ └── observer/ # Zephyr sample app
└── tests/
└── vectors/ # host-runnable, also Ztest under native_sim
```
### `zephyr/module.yml`
```yaml
name: victronble
build:
cmake: .
kconfig: Kconfig
```
### `CMakeLists.txt`
```cmake
if(CONFIG_VICTRONBLE)
zephyr_library()
zephyr_library_sources(src/victronble_core.c)
zephyr_library_sources(src/victronble_zephyr.c)
zephyr_library_sources_ifdef(CONFIG_VICTRONBLE_CRYPTO_SOFTWARE src/victronble_aes_sw.c)
zephyr_library_sources_ifdef(CONFIG_VICTRONBLE_CRYPTO_PSA src/victronble_aes_psa.c)
zephyr_include_directories(include)
endif()
```
### `Kconfig`
```
menuconfig VICTRONBLE
bool "Victron Instant Readout BLE observer"
depends on BT_OBSERVER
help
Passive BLE observer for Victron Energy devices broadcasting
Instant Readout advertisements. No connection or pairing required.
if VICTRONBLE
config VICTRONBLE_MAX_DEVICES
int "Maximum monitored devices"
default 4
config VICTRONBLE_QUEUE_DEPTH
int "Advertisement queue depth"
default 8
help
Frames are copied off the BT RX thread into this queue and decoded
by a dedicated thread. Overflow drops the oldest frame.
config VICTRONBLE_THREAD_STACK_SIZE
int "Decode thread stack size"
default 1024
config VICTRONBLE_THREAD_PRIORITY
int "Decode thread priority"
default 10
config VICTRONBLE_DEDUP
bool "Drop repeated advertisements by nonce counter"
default y
help
Each advertisement is broadcast on three channels and repeated.
Tracking the last counter per device suppresses the duplicates.
choice VICTRONBLE_CRYPTO
prompt "AES-CTR backend"
default VICTRONBLE_CRYPTO_SOFTWARE
config VICTRONBLE_CRYPTO_SOFTWARE
bool "Bundled software AES-128"
config VICTRONBLE_CRYPTO_PSA
bool "PSA Crypto"
depends on MBEDTLS_PSA_CRYPTO_C || NRF_SECURITY
endchoice
module = VICTRONBLE
module-str = victronble
source "subsys/logging/Kconfig.template.log_config"
endif
```
### Threading model
Do not decode in the scan callback. Copy and hand off:
```c
struct victronble_frame {
bt_addr_le_t addr;
int8_t rssi;
uint8_t len;
uint8_t data[VICTRONBLE_MAX_MFG_LEN];
};
K_MSGQ_DEFINE(vb_msgq, sizeof(struct victronble_frame),
CONFIG_VICTRONBLE_QUEUE_DEPTH, 4);
```
The scan callback pre-filters with `victronble_is_product_adv()` — company ID and record
type, no crypto — then `k_msgq_put()` with `K_NO_WAIT`. A dedicated thread pops frames,
matches against the device registry by address, calls `victronble_decode()`, and invokes
the user callback from its own context. Drop on full queue and count the drops; a
saturated queue is a real signal on a busy site and you want it visible.
Use a dedicated thread rather than the system workqueue. Crypto on the system workqueue
will eventually collide with something else that assumed it was free.
### Public Zephyr API
Since you're dropping the C++ callback structure, make this idiomatic Zephyr rather than
a translation of the Arduino API. A registered-listener list in the style of
`bt_conn_cb_register()` will read as native to anyone in this ecosystem:
```c
struct victronble_cb {
void (*record)(const bt_addr_le_t *addr, int8_t rssi,
const victronble_record_t *rec);
void (*decode_error)(const bt_addr_le_t *addr, victronble_err_t err);
sys_snode_t node;
};
int victronble_cb_register(struct victronble_cb *cb);
int victronble_device_add(const bt_addr_le_t *addr,
const uint8_t key[VICTRONBLE_KEY_LEN]);
int victronble_device_remove(const bt_addr_le_t *addr);
int victronble_start(void);
int victronble_stop(void);
```
Consider a devicetree binding for statically configured devices later — it's the most
Zephyr-native option and would let a node declare its Victron gear in the overlay — but
don't do it in the first release. Get the runtime API right first.
### Scan parameters
`BT_GAP_SCAN_FAST_*` is wrong for a long-running solar node. Victron broadcasts roughly
once per second, so a low duty cycle catches everything at a fraction of the radio-on
time. Start at `BT_GAP_SCAN_SLOW_INTERVAL_1` / `BT_GAP_SCAN_SLOW_WINDOW_1` and measure —
you have the PPK2 set up, and this is exactly the knob worth characterising for the
downstream OGLAS power budget.
### Sample `prj.conf`
```
CONFIG_BT=y
CONFIG_BT_OBSERVER=y
CONFIG_BT_DEVICE_NAME="victron-observer"
CONFIG_VICTRONBLE=y
CONFIG_VICTRONBLE_MAX_DEVICES=4
CONFIG_LOG=y
CONFIG_VICTRONBLE_LOG_LEVEL_INF=y
```
On a busy site you may need to raise `CONFIG_BT_BUF_EVT_DISCARDABLE_COUNT`; advertising
reports are discardable events and the default pool is easy to exhaust with a passive
scan in a dense RF environment.
### Development loop worth setting up
`native_sim` with `CONFIG_BT_USERCHAN=y` binds the Zephyr Bluetooth host to a real HCI
controller on the Linux host. You can run the full observer on the workstation against
your actual SmartSolar, with gdb and no flash cycle. Worth the half hour it takes to
configure — it will pay for itself during the record-type work.
---
## Stage 5 — Publish
1. `samples/observer/` that builds for `nrf52840dk/nrf52840` and `rak4631/nrf52840`.
A sample that builds for a DK anyone owns is what makes people try it.
2. GitHub Actions: host vector tests, plus `west build` for both boards and `native_sim`.
3. README with the west manifest snippet up front — the first question every Zephyr user
has is how to add it to their workspace:
```yaml
manifest:
remotes:
- name: dd
url-base: https://github.com/scottp
projects:
- name: victronble
remote: dd
revision: main
path: modules/lib/victronble
```
4. Announce, roughly in descending order of return:
- The Victron Community *Bluetooth advertising protocol* thread.
- PR to `keshavdv/victron-ble`'s related-projects list — that repo is the ecosystem hub.
- Nordic DevZone and the Zephyr Discord `#bluetooth` channel.
- `zephyr-rtos` GitHub topic, awesome-list PR.
---
## Sequencing summary
| Stage | Effort | Ships? | Risk if skipped |
|---|---|---|---|
| 0 — Spike | 2 h | No | Design the C API around assumptions Zephyr won't honour |
| 1 — C core | 12 days | Yes (patch) | — |
| 2 — AES hook | half day | Yes | Hard to change once backends exist downstream |
| 3 — Arduino wrapper | half day | Yes (patch) | Core shape never validated against a real consumer |
| 4 — Zephyr module | 12 days | Yes (minor) | — |
| 5 — Publish | half day | Yes | Nobody finds it |
The only stage with real unknowns is 0, which is why it's first and disposable.
+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);
}
}
+42 -56
View File
@@ -3,8 +3,7 @@
*
* 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. This keeps serial output quiet
* and is useful for long-running monitoring / data logging.
* per minute if nothing has changed.
*
* Setup:
* 1. Get your device encryption keys from the VictronConnect app
@@ -16,13 +15,11 @@
VictronBLE victron;
// Tracks last-logged values per device for change detection
struct SolarChargerSnapshot {
bool valid = false;
SolarChargerState chargeState;
uint8_t chargeState;
float batteryVoltage;
float batteryCurrent;
float panelVoltage;
float panelPower;
uint16_t yieldToday;
float loadCurrent;
@@ -30,26 +27,26 @@ struct SolarChargerSnapshot {
uint32_t packetsSinceLastLog = 0;
};
// Store a snapshot per device (index by MAC string)
static const int MAX_DEVICES = 4;
static String deviceMACs[MAX_DEVICES];
static char deviceMACs[MAX_DEVICES][VICTRON_MAC_LEN];
static SolarChargerSnapshot snapshots[MAX_DEVICES];
static int deviceCount = 0;
static const unsigned long LOG_INTERVAL_MS = 60000; // 1 minute
static const unsigned long LOG_INTERVAL_MS = 60000;
static int findOrAddDevice(const String& mac) {
static int findOrAddDevice(const char* mac) {
for (int i = 0; i < deviceCount; i++) {
if (deviceMACs[i] == mac) return i;
if (strcmp(deviceMACs[i], mac) == 0) return i;
}
if (deviceCount < MAX_DEVICES) {
deviceMACs[deviceCount] = mac;
strncpy(deviceMACs[deviceCount], mac, VICTRON_MAC_LEN - 1);
deviceMACs[deviceCount][VICTRON_MAC_LEN - 1] = '\0';
return deviceCount++;
}
return -1;
}
static String chargeStateName(SolarChargerState state) {
static const char* chargeStateName(uint8_t state) {
switch (state) {
case CHARGER_OFF: return "Off";
case CHARGER_LOW_POWER: return "Low Power";
@@ -66,22 +63,23 @@ static String chargeStateName(SolarChargerState state) {
}
}
static void logData(const SolarChargerData& data, const char* reason, uint32_t packets) {
Serial.println("[" + data.deviceName + "] " + reason +
" pkts:" + String(packets) +
" | State:" + chargeStateName(data.chargeState) +
" Batt:" + String(data.batteryVoltage, 2) + "V" +
" " + String(data.batteryCurrent, 2) + "A" +
" PV:" + String(data.panelVoltage, 1) + "V" +
" " + String(data.panelPower, 0) + "W" +
" Yield:" + String(data.yieldToday) + "Wh" +
(data.loadCurrent > 0 ? " Load:" + String(data.loadCurrent, 2) + "A" : ""));
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();
}
class LoggerCallback : public VictronDeviceCallback {
public:
void onSolarChargerData(const SolarChargerData& data) override {
int idx = findOrAddDevice(data.macAddress);
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];
@@ -89,43 +87,34 @@ public:
prev.packetsSinceLastLog++;
if (!prev.valid) {
// First reading - always log
logData(data, "INIT", prev.packetsSinceLastLog);
logData(dev, s, "INIT", prev.packetsSinceLastLog);
} else {
// Check for changes (everything except RSSI)
bool changed = false;
if (prev.chargeState != data.chargeState) changed = true;
if (prev.batteryVoltage != data.batteryVoltage) changed = true;
if (prev.batteryCurrent != data.batteryCurrent) changed = true;
if (prev.panelVoltage != data.panelVoltage) changed = true;
if (prev.panelPower != data.panelPower) changed = true;
if (prev.yieldToday != data.yieldToday) changed = true;
if (prev.loadCurrent != data.loadCurrent) changed = true;
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(data, "CHG", prev.packetsSinceLastLog);
logData(dev, s, "CHG", prev.packetsSinceLastLog);
} else if (now - prev.lastLogTime >= LOG_INTERVAL_MS) {
logData(data, "HEARTBEAT", prev.packetsSinceLastLog);
logData(dev, s, "HEARTBEAT", prev.packetsSinceLastLog);
} else {
return; // Nothing to log
return;
}
}
// Update snapshot
prev.packetsSinceLastLog = 0;
prev.valid = true;
prev.chargeState = data.chargeState;
prev.batteryVoltage = data.batteryVoltage;
prev.batteryCurrent = data.batteryCurrent;
prev.panelVoltage = data.panelVoltage;
prev.panelPower = data.panelPower;
prev.yieldToday = data.yieldToday;
prev.loadCurrent = data.loadCurrent;
prev.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;
}
};
LoggerCallback callback;
void setup() {
Serial.begin(115200);
@@ -135,14 +124,12 @@ void setup() {
if (!victron.begin(5)) {
Serial.println("ERROR: Failed to initialize VictronBLE!");
Serial.println(victron.getLastError());
while (1) delay(1000);
}
victron.setDebug(false);
victron.setCallback(&callback);
victron.setCallback(onVictronData);
// Add your devices here
victron.addDevice(
"Rainbow48V",
"E4:05:42:34:14:F3",
@@ -157,11 +144,10 @@ void setup() {
DEVICE_TYPE_SOLAR_CHARGER
);
Serial.println("Configured " + String(victron.getDeviceCount()) + " devices");
Serial.printf("Configured %d devices\n", (int)victron.getDeviceCount());
Serial.println("Logging on change, or every 60s heartbeat\n");
}
void loop() {
victron.loop();
delay(100);
}
+52
View File
@@ -0,0 +1,52 @@
{
"build": {
"arduino": {
"ldscript": "nrf52840_s140_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DARDUINO_WISCORE_RAK4631_BOARD -DNRF52840_XXAA",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x8029"],
["0x239A", "0x0029"],
["0x239A", "0x002A"],
["0x239A", "0x802A"]
],
"usb_product": "WisCore RAK4631 Board",
"mcu": "nrf52840",
"variant": "wiscore_rak4631",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS140",
"sd_name": "s140",
"sd_version": "6.1.1",
"sd_fwid": "0x00B6"
},
"bootloader": {
"settings_addr": "0xFF000"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52840_xxAA",
"svd_path": "nrf52840.svd",
"openocd_target": "nrf52.cfg"
},
"frameworks": ["arduino"],
"name": "WisCore RAK4631 Board",
"upload": {
"maximum_ram_size": 235520,
"maximum_size": 815104,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
"use_1200bps_touch": true,
"require_upload_port": true,
"wait_for_upload_port": true
},
"url": "https://www.rakwireless.com",
"vendor": "RAKwireless"
}
+34
View File
@@ -1,6 +1,40 @@
[platformio]
src_dir = src
boards_dir = boards
[env]
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
; RAKwireless RAK4630 (WisBlock) — nRF52840 with Bluefruit backend.
; Board definition lives in boards/rak4631.json (always available).
[env:rak4630]
platform = nordicnrf52
platform_packages = framework-arduinoadafruitnrf52 @ 1.10700.0
framework = arduino
board = rak4631
monitor_speed = 115200
upload_protocol = nrfutil
build_flags = -DCFG_DEBUG=0
[env:esp32dev]
platform = espressif32
board = esp32dev
+141 -202
View File
@@ -1,136 +1,30 @@
/**
* VictronBLE Example
* VictronBLE Multi-Device Example
*
* This example demonstrates how to use the VictronBLE library to read data
* from multiple Victron devices simultaneously.
* Demonstrates reading data from multiple Victron device types via BLE.
*
* Hardware Requirements:
* - ESP32 board
* - Victron devices with BLE (SmartSolar, SmartShunt, etc.)
* The same sketch runs on both ESP32 and nRF52840 — the BLE backend is selected
* automatically at compile time. Pick the target with the PlatformIO
* environment (see platformio.ini): e.g. `esp32dev` or `xiao_nrf52840`.
*
* Setup:
* 1. Get your device encryption keys from the VictronConnect app:
* - Open VictronConnect
* - Connect to your device
* - 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
* 1. Get your device encryption keys from the VictronConnect app
* (Settings > Product Info > Instant readout via Bluetooth > Show)
* 2. Update the device configurations below with your MAC and key.
*/
#include <Arduino.h>
#include "VictronBLE.h"
// Create VictronBLE instance
VictronBLE victron;
// Device callback class - gets called when new data arrives
class MyVictronCallback : public VictronDeviceCallback {
public:
uint32_t solarChargerCount = 0;
uint32_t batteryMonitorCount = 0;
uint32_t inverterCount = 0;
uint32_t dcdcConverterCount = 0;
static uint32_t solarChargerCount = 0;
static uint32_t batteryMonitorCount = 0;
static uint32_t inverterCount = 0;
static uint32_t dcdcConverterCount = 0;
static uint32_t acChargerCount = 0;
void onSolarChargerData(const SolarChargerData& data) override {
solarChargerCount++;
Serial.println("\n=== Solar Charger: " + data.deviceName + " (#" + String(solarChargerCount) + ") ===");
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 {
batteryMonitorCount++;
Serial.println("\n=== Battery Monitor: " + data.deviceName + " (#" + String(batteryMonitorCount) + ") ===");
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 {
inverterCount++;
Serial.println("\n=== Inverter/Charger: " + data.deviceName + " (#" + String(inverterCount) + ") ===");
Serial.println("MAC: " + data.macAddress);
Serial.println("RSSI: " + String(data.rssi) + " dBm");
Serial.println("Battery: " + String(data.batteryVoltage, 2) + " V");
Serial.println("Current: " + String(data.batteryCurrent, 2) + " A");
Serial.println("AC Power: " + String(data.acPower) + " W");
Serial.println("State: " + String(data.state));
// Print alarms
if (data.alarmLowVoltage || data.alarmHighVoltage ||
data.alarmHighTemperature || data.alarmOverload) {
Serial.print("ALARMS: ");
if (data.alarmLowVoltage) Serial.print("LOW-V ");
if (data.alarmHighVoltage) Serial.print("HIGH-V ");
if (data.alarmHighTemperature) Serial.print("TEMP ");
if (data.alarmOverload) Serial.print("OVERLOAD ");
Serial.println();
}
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago");
}
void onDCDCConverterData(const DCDCConverterData& data) override {
dcdcConverterCount++;
Serial.println("\n=== DC-DC Converter: " + data.deviceName + " (#" + String(dcdcConverterCount) + ") ===");
Serial.println("MAC: " + data.macAddress);
Serial.println("RSSI: " + String(data.rssi) + " dBm");
Serial.println("Input: " + String(data.inputVoltage, 2) + " V");
Serial.println("Output: " + String(data.outputVoltage, 2) + " V");
Serial.println("Current: " + String(data.outputCurrent, 2) + " A");
Serial.println("State: " + String(data.chargeState));
if (data.errorCode != 0) {
Serial.println("Error Code: " + String(data.errorCode));
}
Serial.println("Last Update: " + String((millis() - data.lastUpdate) / 1000) + "s ago");
}
private:
String getChargeStateName(SolarChargerState state) {
static const char* chargeStateName(uint8_t state) {
switch (state) {
case CHARGER_OFF: return "Off";
case CHARGER_LOW_POWER: return "Low Power";
@@ -146,114 +40,159 @@ private:
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() {
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("VictronBLE Multi-Device Example");
Serial.println("=================================\n");
// Initialize VictronBLE with 5 second scan duration
if (!victron.begin(5)) {
Serial.println("ERROR: Failed to initialize VictronBLE!");
Serial.println(victron.getLastError());
while (1) delay(1000);
}
// Enable debug output (optional)
victron.setDebug(false);
victron.setDebug(true);
victron.setCallback(onVictronData);
// Set callback for data updates
victron.setCallback(&callback);
// Add your devices here
// Replace with your actual MAC addresses and encryption keys
// CORRECT in Alternative
// Rainbow48V at MAC e4:05:42:34:14:f3
// Temporary - Scott Example
// Replace with your own devices (MAC + 32-char hex key from VictronConnect)
victron.addDevice(
"Rainbow48V", // Device name
"E4:05:42:34:14:F3", // MAC address
"0ec3adf7433dd61793ff2f3b8ad32ed8", // Encryption key (32 hex chars)
DEVICE_TYPE_SOLAR_CHARGER // Device type
);
victron.addDevice(
"ScottTrailer", // Device name
"e64559783cfb",
"3fa658aded4f309b9bc17a2318cb1f56",
DEVICE_TYPE_SOLAR_CHARGER // Device type
);
// Example: Solar Charger #1
/*
victron.addDevice(
"MPPT 100/30", // Device name
"E7:48:D4:28:B7:9C", // MAC address
"0df4d0395b7d1a876c0c33ecb9e70dcd", // Encryption key (32 hex chars)
DEVICE_TYPE_SOLAR_CHARGER // Device type
);
*/
// Example: Solar Charger #2
/*
victron.addDevice(
"MPPT 75/15",
"AA:BB:CC:DD:EE:FF",
"1234567890abcdef1234567890abcdef",
"Rainbow48V",
"E4:05:42:34:14:F3",
"0ec3adf7433dd61793ff2f3b8ad32ed8",
DEVICE_TYPE_SOLAR_CHARGER
);
*/
// Example: Battery Monitor (SmartShunt)
/*
victron.addDevice(
"SmartShunt",
"11:22:33:44:55:66",
"fedcba0987654321fedcba0987654321",
DEVICE_TYPE_BATTERY_MONITOR
"ScottTrailer",
"e64559783cfb",
"3fa658aded4f309b9bc17a2318cb1f56",
DEVICE_TYPE_SOLAR_CHARGER
);
*/
// Example: Inverter/Charger
/*
victron.addDevice(
"MultiPlus",
"99:88:77:66:55:44",
"abcdefabcdefabcdefabcdefabcdefab",
DEVICE_TYPE_INVERTER
);
*/
Serial.println("Configured " + String(victron.getDeviceCount()) + " devices");
Serial.printf("Configured %d devices\n", (int)victron.getDeviceCount());
Serial.println("\nStarting BLE scan...\n");
}
static uint32_t loopCount = 0;
static uint32_t lastReport = 0;
void loop() {
// Process BLE scanning and data updates
victron.loop();
victron.loop(); // Non-blocking on both backends
loopCount++;
// Optional: You can also manually query device data
// This is useful if you're not using callbacks
/*
SolarChargerData solarData;
if (victron.getSolarChargerData("E7:48:D4:28:B7:9C", solarData)) {
// Do something with solarData
uint32_t now = millis();
if (now - lastReport >= 10000) {
Serial.printf("Loop iterations in last 10s: %lu\n", loopCount);
loopCount = 0;
lastReport = now;
}
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);
}
}
+206
View File
@@ -0,0 +1,206 @@
/**
* victronble — pure C99 decoder for Victron Energy "Instant Readout" BLE
* advertisements (manufacturer ID 0x02E1, record type 0x10, AES-128-CTR).
*
* This is the portable core of the VictronBLE library: no Arduino, no BLE
* stack, no allocation, no I/O, reentrant. Feed it one manufacturer-specific
* data blob (starting at the company ID) plus the device's 16-byte
* advertisement key; get a decoded record back. Transport (scanning), device
* registries, rate limiting and logging belong to the platform wrappers
* (Arduino C++ class, Zephyr module).
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#ifndef VICTRONBLE_H
#define VICTRONBLE_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#define VICTRONBLE_COMPANY_ID 0x02E1u /* Victron Energy BV */
#define VICTRONBLE_KEY_LEN 16
#define VICTRONBLE_MAX_CIPHER_LEN 21 /* encrypted payload in one advert */
#define VICTRONBLE_MIN_MFG_LEN 10 /* header before the ciphertext */
typedef enum {
VICTRONBLE_OK = 0,
VICTRONBLE_ERR_NOT_VICTRON = -1, /* company ID mismatch */
VICTRONBLE_ERR_SHORT = -2, /* truncated advertisement */
VICTRONBLE_ERR_NOT_PRODUCT = -3, /* not a product-advertisement record */
VICTRONBLE_ERR_KEY_MISMATCH = -4, /* key check byte failed */
VICTRONBLE_ERR_UNSUPPORTED = -5, /* known record type, no decoder */
VICTRONBLE_ERR_CRYPTO = -6, /* AES backend failed */
} victronble_err_t;
/* Values are the raw Victron record-type IDs. The inverter family
* (0x03/0x06/0x0B/0x0C) all decode to VICTRONBLE_DEV_INVERTER. */
typedef enum {
VICTRONBLE_DEV_UNKNOWN = 0x00,
VICTRONBLE_DEV_SOLAR_CHARGER = 0x01,
VICTRONBLE_DEV_BATTERY_MONITOR = 0x02,
VICTRONBLE_DEV_INVERTER = 0x03,
VICTRONBLE_DEV_DCDC_CONVERTER = 0x04,
VICTRONBLE_DEV_SMART_LITHIUM = 0x05,
VICTRONBLE_DEV_INVERTER_RS = 0x06,
VICTRONBLE_DEV_GX_DEVICE = 0x07,
VICTRONBLE_DEV_AC_CHARGER = 0x08,
VICTRONBLE_DEV_BATTERY_PROTECT = 0x09,
VICTRONBLE_DEV_LYNX_SMART_BMS = 0x0A,
VICTRONBLE_DEV_MULTI_RS = 0x0B,
VICTRONBLE_DEV_VE_BUS = 0x0C,
VICTRONBLE_DEV_DC_ENERGY_METER = 0x0D,
VICTRONBLE_DEV_ORION_XS = 0x0F,
} victronble_device_type_t;
/* Charger states shared by solar / AC chargers (VE.Direct "CS"). */
enum {
VICTRONBLE_STATE_OFF = 0,
VICTRONBLE_STATE_LOW_POWER = 1,
VICTRONBLE_STATE_FAULT = 2,
VICTRONBLE_STATE_BULK = 3,
VICTRONBLE_STATE_ABSORPTION = 4,
VICTRONBLE_STATE_FLOAT = 5,
VICTRONBLE_STATE_STORAGE = 6,
VICTRONBLE_STATE_EQUALIZE = 7,
VICTRONBLE_STATE_INVERTING = 9,
VICTRONBLE_STATE_POWER_SUPPLY = 11,
VICTRONBLE_STATE_EXTERNAL_CONTROL = 252,
};
/* Fields the wire encodes as "not available" are NAN (test with isnan()).
* Integer fields keep the raw value; sentinels are documented per field. */
typedef struct {
uint8_t state; /* VICTRONBLE_STATE_* */
uint8_t error;
float battery_voltage; /* V */
float battery_current; /* A */
float pv_power; /* W */
uint32_t yield_today_wh; /* Wh */
float load_current; /* A, NAN if no load output */
} victronble_solar_charger_t;
typedef struct {
float voltage; /* V */
float current; /* A */
float temperature; /* degC, NAN unless aux mode = temperature */
float aux_voltage; /* V, NAN unless aux mode = aux voltage */
uint16_t remaining_minutes; /* time-to-go; 0xFFFF = not available */
float consumed_ah; /* Ah, negative = consumed */
float soc; /* % */
uint8_t aux_mode; /* 0=aux V, 1=midpoint, 2=temperature, 3=none */
uint16_t alarm; /* raw 16-bit alarm bitmask */
} victronble_battery_monitor_t;
typedef struct {
uint8_t state;
uint8_t alarms; /* raw alarm bits: 0x01 lowV, 0x02 highV,
* 0x04 highT, 0x08 overload */
float battery_voltage; /* V */
float battery_current; /* A */
float ac_power; /* W (signed) */
} victronble_inverter_t;
typedef struct {
uint8_t state; /* charge state */
uint8_t error;
float input_voltage; /* V */
float output_voltage; /* V */
float output_current; /* A */
} victronble_dcdc_t;
typedef struct {
uint8_t state;
uint8_t error;
float voltage1, current1; /* output 1 (V, A), NAN if absent */
float voltage2, current2; /* output 2 */
float voltage3, current3; /* output 3 */
float temperature; /* degC, NAN if not available */
float ac_current; /* A, NAN if not available */
} victronble_ac_charger_t;
typedef struct {
victronble_device_type_t type; /* decoded family (inverter collapsed) */
uint8_t record_type; /* raw record type from the wire */
uint16_t model_id;
uint8_t readout_type;
uint16_t nonce; /* data counter, as received */
union {
victronble_solar_charger_t solar;
victronble_battery_monitor_t batmon;
victronble_inverter_t inverter;
victronble_dcdc_t dcdc;
victronble_ac_charger_t ac;
} u;
} victronble_record_t;
/**
* Decode one Victron manufacturer-data blob.
*
* @param mfg Manufacturer-specific data, starting at the company ID.
* @param len Length of @p mfg in bytes.
* @param key 16-byte per-device advertisement key.
* @param out Populated on VICTRONBLE_OK; untouched otherwise.
*
* Reentrant, allocation-free, no I/O. Duplicate suppression (nonce
* tracking) is the caller's job — the nonce is returned in @p out.
*/
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN],
victronble_record_t *out);
/** Cheap pre-filter: company ID + product-advertisement record, no crypto. */
bool victronble_is_product_adv(const uint8_t *mfg, size_t len);
/** Key check byte test — pick the right key from several without decrypting. */
bool victronble_key_matches(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN]);
/** Parse a 32-hex-char advertisement key. Returns false on bad input. */
bool victronble_parse_key(const char *hex, uint8_t key[VICTRONBLE_KEY_LEN]);
const char *victronble_strerror(victronble_err_t err);
const char *victronble_device_type_str(victronble_device_type_t type);
/** Short lower-case charger-state label ("bulk", "float", ...). */
const char *victronble_state_str(uint8_t state);
/**
* AES-128-CTR transform hook.
*
* @param key 16-byte key.
* @param iv 16-byte initial counter block (nonce in the low bytes, rest 0).
* @param in Ciphertext.
* @param out Plaintext. May alias @p in.
* @param len Byte count, not necessarily a multiple of 16.
* @param user Opaque context supplied at registration.
* @return 0 on success, negative on failure.
*/
typedef int (*victronble_aes_ctr_fn)(const uint8_t key[16],
const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
/** Override the AES backend at runtime (NULL restores the default). */
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user);
/**
* Default AES backend. Weak symbol: the bundled software AES
* (victronble_aes_sw.c) provides it; an alternative backend (PSA, mbedTLS,
* hardware) may define it strong and the linker drops the bundled code.
*/
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
#ifdef __cplusplus
}
#endif
#endif /* VICTRONBLE_H */
+71
View File
@@ -0,0 +1,71 @@
/**
* victronble — Zephyr BLE observer API.
*
* Passive-scans for Victron Instant Readout advertisements, decodes them
* off the BT RX thread (frames are queued to a dedicated decode thread)
* and delivers records to registered listeners.
*
* The application owns the Bluetooth stack: call bt_enable() before
* victronble_start(). Enable with CONFIG_VICTRONBLE=y (needs
* CONFIG_BT_OBSERVER=y).
*
* Copyright (c) 2026 Scott Penrose
* License: MIT
*/
#ifndef VICTRONBLE_ZEPHYR_H
#define VICTRONBLE_ZEPHYR_H
#include <zephyr/bluetooth/addr.h>
#include <zephyr/sys/slist.h>
#include "victronble.h"
#ifdef __cplusplus
extern "C" {
#endif
/** Listener; register with victronble_cb_register(). Callbacks run on the
* module's decode thread. */
struct victronble_cb {
/** A monitored device published a new record. */
void (*record)(const bt_addr_le_t *addr, int8_t rssi,
const victronble_record_t *rec);
/** Optional: a monitored device's advertisement failed to decode. */
void (*decode_error)(const bt_addr_le_t *addr, victronble_err_t err);
sys_snode_t node;
};
struct victronble_stats {
uint32_t adverts; /* Victron product adverts seen (any device) */
uint32_t queued; /* frames queued for a monitored device */
uint32_t dropped; /* frames lost to a full queue */
uint32_t decoded; /* records decoded OK */
uint32_t duplicates; /* suppressed by nonce dedup */
uint32_t errors; /* decode failures */
};
/** Register a listener. Returns -EALREADY if already registered. */
int victronble_cb_register(struct victronble_cb *cb);
/** Monitor a device. @p key is its 16-byte advertisement key (VictronConnect
* → Product Info). Returns -ENOMEM when full, -EALREADY if present. */
int victronble_device_add(const bt_addr_le_t *addr,
const uint8_t key[VICTRONBLE_KEY_LEN]);
/** Stop monitoring a device. Returns -ENOENT if unknown. */
int victronble_device_remove(const bt_addr_le_t *addr);
/** Start the passive scan (bt_enable() must have succeeded first). */
int victronble_start(void);
/** Stop the scan. Queued frames still drain to callbacks. */
int victronble_stop(void);
void victronble_get_stats(struct victronble_stats *out);
#ifdef __cplusplus
}
#endif
#endif /* VICTRONBLE_ZEPHYR_H */
+19 -4
View File
@@ -1,8 +1,8 @@
{
"name": "victronble",
"version": "0.3.1",
"description": "ESP32 library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Supports SmartSolar MPPT, SmartShunt, BMV, MultiPlus, Orion and other Victron devices.",
"keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, iot, energy, monitoring",
"version": "0.7.0",
"description": "Portable Arduino library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832). 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, esp32-s3, esp32-c3, nrf52, nrf52840, nrf52832, xiao, iot, energy, monitoring",
"repository": {
"type": "git",
"url": "https://gitea.sh3d.com.au/Sh3d/VictronBLE.git"
@@ -18,7 +18,7 @@
"license": "MIT",
"homepage": "https://gitea.sh3d.com.au/Sh3d/VictronBLE",
"frameworks": ["arduino", "espidf"],
"platforms": ["espressif32"],
"platforms": ["espressif32", "nordicnrf52"],
"headers": ["VictronBLE.h"],
"dependencies": [],
"examples": [
@@ -31,6 +31,21 @@
"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": {
+4 -4
View File
@@ -1,11 +1,11 @@
name=VictronBLE
version=0.3.1
version=0.7.0
author=Scott Penrose
maintainer=Scott Penrose <scottp@dd.com.au>
sentence=ESP32 library for reading Victron Energy device data via BLE for any ESP32
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.
sentence=Portable library for reading Victron Energy device data via BLE on ESP32/S3/C3 and nRF52 (nRF52840/nRF52832)
paragraph=Read data from Victron SmartSolar, SmartShunt, BMV, inverters, Blue Smart AC chargers and other devices using Bluetooth Low Energy advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832 — Bluefruit or Seeed cores) with no external crypto dependency. Supports multiple devices simultaneously with no pairing required.
category=Communication
url=https://gitea.sh3d.com.au/Sh3d/VictronBLE
architectures=esp32
architectures=esp32,nrf52
depends=
includes=VictronBLE.h
+178 -618
View File
@@ -1,665 +1,225 @@
/**
* VictronBLE - ESP32 library for Victron Energy BLE devices
* Implementation file
* VictronBLE - portable library for Victron Energy BLE devices
*
* Thin Arduino wrapper over the pure C core (src/victronble_core.c): this
* file owns the device registry, nonce dedup and rate limiting; decryption
* and payload decoding live in victronble_decode(). BLE scanning lives in
* the per-platform backends under src/esp32 and src/nrf52.
*
* Copyright (c) 2025 Scott Penrose
* License: MIT
*/
#include "VictronBLE.h"
#include "victronble.h"
#include <string.h>
#include <math.h>
// The public API keeps the legacy "absent = 0" convention; the core reports
// absent fields as NAN.
static inline float nan_to_zero(float v) { return isnan(v) ? 0.0f : v; }
// Constructor
VictronBLE::VictronBLE()
: pBLEScan(nullptr), callback(nullptr), debugEnabled(false),
scanDuration(5), initialized(false) {
: deviceCount(0), callback(nullptr), debugEnabled(false),
scanDuration(5), minIntervalMs(1000), initialized(false)
#if defined(VICTRON_BACKEND_ESP32)
, pBLEScan(nullptr), scanCallbackObj(nullptr)
#endif
{
memset(devices, 0, sizeof(devices));
}
// Destructor
VictronBLE::~VictronBLE() {
for (auto& pair : devices) {
delete pair.second;
}
devices.clear();
bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
VictronDeviceType type) {
if (deviceCount >= VICTRON_MAX_DEVICES) return false;
if (!mac || strlen(mac) == 0) return false;
if (pBLEScan) {
pBLEScan->stop();
}
}
char normalizedMAC[VICTRON_MAC_LEN];
normalizeMAC(mac, normalizedMAC);
// Initialize BLE
bool VictronBLE::begin(uint32_t scanDuration) {
if (initialized) {
debugPrint("VictronBLE already initialized");
// Check for duplicate
if (findDevice(normalizedMAC)) return false;
DeviceEntry* entry = &devices[deviceCount];
memset(entry, 0, sizeof(DeviceEntry));
if (!victronble_parse_key(hexKey, entry->key)) return false;
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;
deviceCount++;
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
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 (!victronble_is_product_adv(mfgData, len)) return;
debugPrint("Initializing VictronBLE...");
// Normalize MAC and find device
char normalizedMAC[VICTRON_MAC_LEN];
normalizeMAC(macStr, normalizedMAC);
BLEDevice::init("VictronBLE");
pBLEScan = BLEDevice::getScan();
if (!pBLEScan) {
lastError = "Failed to create BLE scanner";
debugPrint(lastError);
return false;
}
pBLEScan->setAdvertisedDeviceCallbacks(new VictronBLEAdvertisedDeviceCallbacks(this), true);
pBLEScan->setActiveScan(false); // Passive scan - lower power
pBLEScan->setInterval(100);
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";
debugPrint(lastError);
return false;
}
if (config.encryptionKey.length() != 32) {
lastError = "Encryption key must be 32 hex characters";
debugPrint(lastError);
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";
debugPrint(lastError);
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 + " (MAC: " + normalizedMAC + ")");
if (debugEnabled) {
debugPrint(" Original MAC input: " + config.macAddress);
debugPrint(" Stored normalized: " + 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) {
DeviceEntry* entry = findDevice(normalizedMAC);
if (!entry) {
if (debugEnabled) Serial.printf("[VictronBLE] Unmonitored Victron: %s\n", normalizedMAC);
return;
}
// Start a scan
BLEScanResults scanResults = pBLEScan->start(scanDuration, false);
pBLEScan->clearResults();
}
// BLE callback implementation
void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) {
if (victronBLE) {
victronBLE->processDevice(advertisedDevice);
}
}
// Process advertised device
void VictronBLE::processDevice(BLEAdvertisedDevice advertisedDevice) {
// Get MAC address from the advertised device
String mac = macAddressToString(advertisedDevice.getAddress());
String normalizedMAC = normalizeMAC(mac);
if (debugEnabled) {
debugPrint("Raw MAC: " + mac + " -> Normalized: " + normalizedMAC);
}
// TODO: Consider skipping with no manufacturer data?
memset(&manufacturerData, 0, sizeof(manufacturerData));
if (advertisedDevice.haveManufacturerData()) {
std::string mfgData = advertisedDevice.getManufacturerData();
// XXX Storing it this way is not thread safe - is that issue on this ESP32?
debugPrint("Getting manufacturer data: Size=" + String(mfgData.length()));
mfgData.copy((char*)&manufacturerData, (mfgData.length() > sizeof(manufacturerData) ? sizeof(manufacturerData) : mfgData.length()));
}
// Pointer? XXX
// Debug: Log all discovered BLE devices
if (debugEnabled) {
String debugMsg = "";
debugMsg += "BLE Device: " + mac;
debugMsg += ", RSSI: " + String(advertisedDevice.getRSSI()) + " dBm";
if (advertisedDevice.haveName())
debugMsg += ", Name: " + String(advertisedDevice.getName().c_str());
debugMsg += ", Mfg ID: 0x" + String(manufacturerData.vendorID, HEX);
if (manufacturerData.vendorID == VICTRON_MANUFACTURER_ID) {
debugMsg += " (Victron)";
}
debugPrint(debugMsg);
}
// Check if this is one of our configured devices
auto it = devices.find(normalizedMAC);
if (it == devices.end()) {
// XXX Check if the device is a Victron device
// This needs lots of improvemet and only do in debug
if (manufacturerData.vendorID == VICTRON_MANUFACTURER_ID) {
debugPrint("Found unmonitored Victron Device: " + normalizeMAC(mac));
// DeviceInfo* deviceInfo = new DeviceInfo(mac, advertisedDevice.getName());
// devices.insert({normalizedMAC, deviceInfo});
// XXX What type of Victron device is it?
// Check if it's a Victron Energy device
/*
if (advertisedDevice.haveServiceData()) {
std::string serviceData = advertisedDevice.getServiceData();
if (serviceData.length() >= 2) {
uint16_t serviceId = (uint8_t)serviceData[1] << 8 | (uint8_t)serviceData[0];
if (serviceId == VICTRON_ENERGY_SERVICE_ID) {
debugPrint("Found Victron Energy Device: " + mac);
}
}
}
*/
}
return; // Not a device we're monitoring
}
DeviceInfo* deviceInfo = it->second;
// Check if it's Victron (manufacturer ID 0x02E1)
if (manufacturerData.vendorID != VICTRON_MANUFACTURER_ID) {
debugPrint("Skipping non VICTRON");
// Skip if nonce unchanged (data hasn't changed on the device)
uint16_t nonce = mfgData[7] | ((uint16_t)mfgData[8] << 8);
if (entry->device.dataValid && nonce == entry->lastNonce) {
entry->device.rssi = rssi; // still refresh RSSI
return;
}
debugPrint("Processing data from: " + deviceInfo->config.name);
// Parse the advertisement
if (parseAdvertisement(normalizedMAC)) {
// Update RSSI
if (deviceInfo->data) {
deviceInfo->data->rssi = advertisedDevice.getRSSI();
deviceInfo->data->lastUpdate = millis();
}
}
// Skip if minimum interval hasn't elapsed
uint32_t now = millis();
if (entry->device.dataValid && (now - entry->device.lastUpdate) < minIntervalMs) {
return;
}
// Parse advertisement data
bool VictronBLE::parseAdvertisement(const String& macAddress) {
// XXX We already searched above - try not to again?
auto it = devices.find(macAddress);
if (it == devices.end()) {
debugPrint("parseAdvertisement: Device not found");
return false;
victronble_record_t rec;
victronble_err_t err = victronble_decode(mfgData, len, entry->key, &rec);
if (err != VICTRONBLE_OK) {
if (debugEnabled) Serial.printf("[VictronBLE] Decode %s: %s\n",
entry->device.name, victronble_strerror(err));
return;
}
DeviceInfo* deviceInfo = it->second;
if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
entry->device.name, rec.nonce);
storeRecord(entry, rec);
entry->lastNonce = nonce;
entry->device.rssi = rssi;
entry->device.lastUpdate = now;
entry->device.dataValid = true;
if (callback) callback(&entry->device);
}
// Map a decoded core record into the legacy public structs (NAN -> 0).
void VictronBLE::storeRecord(DeviceEntry* entry, const victronble_record_t& rec) {
switch (rec.type) {
case VICTRONBLE_DEV_SOLAR_CHARGER: {
entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
VictronSolarData& s = entry->device.solar;
s.chargeState = rec.u.solar.state;
s.errorCode = rec.u.solar.error;
s.batteryVoltage = rec.u.solar.battery_voltage;
s.batteryCurrent = rec.u.solar.battery_current;
s.panelPower = rec.u.solar.pv_power;
s.yieldToday = (uint16_t)rec.u.solar.yield_today_wh;
s.loadCurrent = nan_to_zero(rec.u.solar.load_current);
if (debugEnabled) {
debugPrint("Vendor ID: 0x" + String(manufacturerData.vendorID, HEX));
debugPrint("Beacon Type: 0x" + String(manufacturerData.beaconType, HEX));
debugPrint("Record Type: 0x" + String(manufacturerData.victronRecordType, HEX));
debugPrint("Nonce: 0x" + String(manufacturerData.nonceDataCounter, HEX));
}
// Build IV (initialization vector) from nonce
// IV is 16 bytes: nonce (2 bytes little-endian) + zeros (14 bytes)
uint8_t iv[16] = {0};
iv[0] = manufacturerData.nonceDataCounter & 0xFF; // Low byte
iv[1] = (manufacturerData.nonceDataCounter >> 8) & 0xFF; // High byte
// Remaining bytes stay zero
// Decrypt the data
uint8_t decrypted[32]; // Max expected size
if (!decryptAdvertisement(manufacturerData.victronEncryptedData,
sizeof(manufacturerData.victronEncryptedData),
deviceInfo->encryptionKeyBytes, iv, decrypted)) {
lastError = "Decryption failed";
debugPrint(lastError);
return false;
}
// Parse based on device type
bool parseOk = false;
switch (manufacturerData.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_SOLAR_CHARGER) {
parseOk = parseSolarCharger(decrypted, sizeof(decrypted),
*(SolarChargerData*)deviceInfo->data);
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
s.batteryVoltage, s.batteryCurrent,
(int)s.panelPower, s.chargeState);
}
break;
case DEVICE_TYPE_BATTERY_MONITOR:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_BATTERY_MONITOR) {
parseOk = parseBatteryMonitor(decrypted, sizeof(decrypted),
*(BatteryMonitorData*)deviceInfo->data);
}
case VICTRONBLE_DEV_BATTERY_MONITOR: {
entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
VictronBatteryData& b = entry->device.battery;
b.voltage = rec.u.batmon.voltage;
b.current = rec.u.batmon.current;
b.temperature = nan_to_zero(rec.u.batmon.temperature);
b.auxVoltage = nan_to_zero(rec.u.batmon.aux_voltage);
b.remainingMinutes = rec.u.batmon.remaining_minutes;
b.consumedAh = rec.u.batmon.consumed_ah;
b.soc = rec.u.batmon.soc;
b.alarmLowVoltage = (rec.u.batmon.alarm & 0x0001) != 0;
b.alarmHighVoltage = (rec.u.batmon.alarm & 0x0002) != 0;
b.alarmLowSOC = (rec.u.batmon.alarm & 0x0004) != 0;
b.alarmLowTemperature = (rec.u.batmon.alarm & 0x0010) != 0;
b.alarmHighTemperature = (rec.u.batmon.alarm & 0x0020) != 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
b.voltage, b.current, b.soc);
}
break;
case DEVICE_TYPE_INVERTER:
case DEVICE_TYPE_INVERTER_RS:
case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_INVERTER) {
parseOk = parseInverter(decrypted, sizeof(decrypted),
*(InverterData*)deviceInfo->data);
}
case VICTRONBLE_DEV_INVERTER: {
entry->device.deviceType = DEVICE_TYPE_INVERTER;
VictronInverterData& inv = entry->device.inverter;
inv.batteryVoltage = rec.u.inverter.battery_voltage;
inv.batteryCurrent = rec.u.inverter.battery_current;
inv.acPower = rec.u.inverter.ac_power;
inv.state = rec.u.inverter.state;
inv.alarmLowVoltage = (rec.u.inverter.alarms & 0x01) != 0;
inv.alarmHighVoltage = (rec.u.inverter.alarms & 0x02) != 0;
inv.alarmHighTemperature = (rec.u.inverter.alarms & 0x04) != 0;
inv.alarmOverload = (rec.u.inverter.alarms & 0x08) != 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
inv.batteryVoltage, (int)inv.acPower, inv.state);
}
break;
case DEVICE_TYPE_DCDC_CONVERTER:
if (deviceInfo->data && deviceInfo->data->deviceType == DEVICE_TYPE_DCDC_CONVERTER) {
parseOk = parseDCDCConverter(decrypted, sizeof(decrypted),
*(DCDCConverterData*)deviceInfo->data);
}
case VICTRONBLE_DEV_DCDC_CONVERTER: {
entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
VictronDCDCData& d = entry->device.dcdc;
d.chargeState = rec.u.dcdc.state;
d.errorCode = rec.u.dcdc.error;
d.inputVoltage = rec.u.dcdc.input_voltage;
d.outputVoltage = rec.u.dcdc.output_voltage;
d.outputCurrent = rec.u.dcdc.output_current;
if (debugEnabled) {
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
d.inputVoltage, d.outputVoltage, d.outputCurrent);
}
break;
}
case VICTRONBLE_DEV_AC_CHARGER: {
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
VictronACChargerData& a = entry->device.acCharger;
a.chargeState = rec.u.ac.state;
a.errorCode = rec.u.ac.error;
a.voltage1 = nan_to_zero(rec.u.ac.voltage1);
a.current1 = nan_to_zero(rec.u.ac.current1);
a.voltage2 = nan_to_zero(rec.u.ac.voltage2);
a.current2 = nan_to_zero(rec.u.ac.current2);
a.voltage3 = nan_to_zero(rec.u.ac.voltage3);
a.current3 = nan_to_zero(rec.u.ac.current3);
a.temperature = nan_to_zero(rec.u.ac.temperature);
a.acCurrent = nan_to_zero(rec.u.ac.ac_current);
if (debugEnabled) {
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
a.voltage1, a.current1, a.temperature, a.chargeState);
}
break;
}
default:
debugPrint("Unknown device type: 0x" + String(manufacturerData.victronRecordType, HEX));
return false;
}
if (parseOk && deviceInfo->data) {
deviceInfo->data->dataValid = true;
// Call appropriate callback
if (callback) {
switch (manufacturerData.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER:
callback->onSolarChargerData(*(SolarChargerData*)deviceInfo->data);
break;
case DEVICE_TYPE_BATTERY_MONITOR:
callback->onBatteryMonitorData(*(BatteryMonitorData*)deviceInfo->data);
break;
case DEVICE_TYPE_INVERTER:
case DEVICE_TYPE_INVERTER_RS:
case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS:
callback->onInverterData(*(InverterData*)deviceInfo->data);
break;
case DEVICE_TYPE_DCDC_CONVERTER:
callback->onDCDCConverterData(*(DCDCConverterData*)deviceInfo->data);
break;
}
}
// --- Helpers ---
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';
}
return parseOk;
}
// Decrypt advertisement using AES-128-CTR
bool VictronBLE::decryptAdvertisement(const uint8_t* encrypted, size_t encLen,
const uint8_t* key, const uint8_t* iv,
uint8_t* decrypted) {
mbedtls_aes_context aes;
mbedtls_aes_init(&aes);
// Set encryption key
int ret = mbedtls_aes_setkey_enc(&aes, key, 128);
if (ret != 0) {
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 < sizeof(victronSolarChargerPayload)) {
debugPrint("Solar charger data too short: " + String(len) + " bytes");
return false;
}
// Cast decrypted data to struct for easy access
const victronSolarChargerPayload* payload = (const victronSolarChargerPayload*)data;
// Parse charge state
result.chargeState = (SolarChargerState)payload->deviceState;
// Parse battery voltage (10 mV units -> volts)
result.batteryVoltage = payload->batteryVoltage * 0.01f;
// Parse battery current (10 mA units, signed -> amps)
result.batteryCurrent = payload->batteryCurrent * 0.01f;
// Parse yield today (10 Wh units -> Wh)
result.yieldToday = payload->yieldToday * 10;
// Parse PV power (1 W units)
result.panelPower = payload->inputPower;
// Parse load current (10 mA units -> amps, 0xFFFF = no load)
if (payload->loadCurrent != 0xFFFF) {
result.loadCurrent = payload->loadCurrent * 0.01f;
} else {
result.loadCurrent = 0;
}
// Calculate PV voltage from power and current (if current > 0)
if (result.batteryCurrent > 0.1f) {
result.panelVoltage = result.panelPower / result.batteryCurrent;
} else {
result.panelVoltage = 0;
}
debugPrint("Solar Charger: " + String(result.batteryVoltage, 2) + "V, " +
String(result.batteryCurrent, 2) + "A, " +
String(result.panelPower) + "W, State: " + String(result.chargeState));
return true;
}
// Parse Battery Monitor data
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMonitorData& result) {
if (len < sizeof(victronBatteryMonitorPayload)) {
debugPrint("Battery monitor data too short: " + String(len) + " bytes");
return false;
}
// Cast decrypted data to struct for easy access
const victronBatteryMonitorPayload* payload = (const victronBatteryMonitorPayload*)data;
// Parse remaining time (1 minute units)
result.remainingMinutes = payload->remainingMins;
// Parse battery voltage (10 mV units -> volts)
result.voltage = payload->batteryVoltage * 0.01f;
// Parse alarm bits
result.alarmLowVoltage = (payload->alarms & 0x01) != 0;
result.alarmHighVoltage = (payload->alarms & 0x02) != 0;
result.alarmLowSOC = (payload->alarms & 0x04) != 0;
result.alarmLowTemperature = (payload->alarms & 0x10) != 0;
result.alarmHighTemperature = (payload->alarms & 0x20) != 0;
// Parse aux data: voltage (10 mV units) or temperature (0.01K units)
if (payload->auxData < 3000) { // If < 30V, it's voltage
result.auxVoltage = payload->auxData * 0.01f;
result.temperature = 0;
} else { // Otherwise temperature in 0.01 Kelvin
result.temperature = (payload->auxData * 0.01f) - 273.15f;
result.auxVoltage = 0;
}
// Parse battery current (22-bit signed, 1 mA units)
// Bits 0-7: currentLow, Bits 8-15: currentMid, Bits 16-21: low 6 bits of currentHigh_consumedLow
int32_t current = payload->currentLow |
(payload->currentMid << 8) |
((payload->currentHigh_consumedLow & 0x3F) << 16);
// Sign extend from 22 bits to 32 bits
if (current & 0x200000) {
current |= 0xFFC00000;
}
result.current = current * 0.001f; // Convert mA to A
// Parse consumed Ah (18-bit signed, 10 mAh units)
// Bits 0-1: high 2 bits of currentHigh_consumedLow, Bits 2-9: consumedMid, Bits 10-17: consumedHigh
int32_t consumedAh = ((payload->currentHigh_consumedLow & 0xC0) >> 6) |
(payload->consumedMid << 2) |
(payload->consumedHigh << 10);
// Sign extend from 18 bits to 32 bits
if (consumedAh & 0x20000) {
consumedAh |= 0xFFFC0000;
}
result.consumedAh = consumedAh * 0.01f; // Convert 10mAh to Ah
// Parse SOC (10-bit value, 10 = 1.0%)
result.soc = (payload->soc & 0x3FF) * 0.1f;
debugPrint("Battery Monitor: " + String(result.voltage, 2) + "V, " +
String(result.current, 2) + "A, SOC: " + String(result.soc, 1) + "%");
return true;
}
// Parse Inverter data
bool VictronBLE::parseInverter(const uint8_t* data, size_t len, InverterData& result) {
if (len < sizeof(victronInverterPayload)) {
debugPrint("Inverter data too short: " + String(len) + " bytes");
return false;
}
// Cast decrypted data to struct for easy access
const victronInverterPayload* payload = (const victronInverterPayload*)data;
// Parse device state
result.state = payload->deviceState;
// Parse battery voltage (10 mV units -> volts)
result.batteryVoltage = payload->batteryVoltage * 0.01f;
// Parse battery current (10 mA units, signed -> amps)
result.batteryCurrent = payload->batteryCurrent * 0.01f;
// Parse AC Power (signed 24-bit, 1 W units)
int32_t acPower = payload->acPowerLow |
(payload->acPowerMid << 8) |
(payload->acPowerHigh << 16);
// Sign extend from 24 bits to 32 bits
if (acPower & 0x800000) {
acPower |= 0xFF000000;
}
result.acPower = acPower;
// Parse alarm bits
result.alarmLowVoltage = (payload->alarms & 0x01) != 0;
result.alarmHighVoltage = (payload->alarms & 0x02) != 0;
result.alarmHighTemperature = (payload->alarms & 0x04) != 0;
result.alarmOverload = (payload->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 < sizeof(victronDCDCConverterPayload)) {
debugPrint("DC-DC converter data too short: " + String(len) + " bytes");
return false;
}
// Cast decrypted data to struct for easy access
const victronDCDCConverterPayload* payload = (const victronDCDCConverterPayload*)data;
// Parse charge state
result.chargeState = payload->chargeState;
// Parse error code
result.errorCode = payload->errorCode;
// Parse input voltage (10 mV units -> volts)
result.inputVoltage = payload->inputVoltage * 0.01f;
// Parse output voltage (10 mV units -> volts)
result.outputVoltage = payload->outputVoltage * 0.01f;
// Parse output current (10 mA units -> amps)
result.outputCurrent = payload->outputCurrent * 0.01f;
debugPrint("DC-DC Converter: In=" + String(result.inputVoltage, 2) + "V, Out=" +
String(result.outputVoltage, 2) + "V, " + String(result.outputCurrent, 2) + "A");
return true;
}
// 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);
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 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) {
// Use the BLEAddress toString() method which provides consistent formatting
return String(address.toString().c_str());
}
// Helper: Normalize MAC address format
String VictronBLE::normalizeMAC(String mac) {
String normalized = mac;
normalized.toLowerCase();
// XXX - is this right, was - to : but not consistent location of pairs or not
normalized.replace("-", "");
normalized.replace(":", "");
return normalized;
}
// Debug helpers
void VictronBLE::debugPrint(const String& message) {
if (debugEnabled)
Serial.println("[VictronBLE] " + message);
return nullptr;
}
+231 -236
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
* Inspired by hoberman's examples and keshavdv's Python library
@@ -12,17 +16,31 @@
#define VICTRON_BLE_H
#include <Arduino.h>
#include "victronble.h" // pure C core: decode + decrypt (src/victronble_core.c)
// --- 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 <BLEAdvertisedDevice.h>
#include <BLEScan.h>
#include <map>
#include <vector>
#include "mbedtls/aes.h"
#define VICTRON_BACKEND_ESP32 1
#elif defined(ARDUINO_ARCH_NRF52) || defined(NRF52840_XXAA) || defined(NRF52832_XXAA)
#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
#define VICTRON_MANUFACTURER_ID 0x02E1
// --- Constants ---
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 {
DEVICE_TYPE_UNKNOWN = 0x00,
DEVICE_TYPE_SOLAR_CHARGER = 0x01,
@@ -31,14 +49,17 @@ enum VictronDeviceType {
DEVICE_TYPE_DCDC_CONVERTER = 0x04,
DEVICE_TYPE_SMART_LITHIUM = 0x05,
DEVICE_TYPE_INVERTER_RS = 0x06,
DEVICE_TYPE_SMART_BATTERY_PROTECT = 0x07,
DEVICE_TYPE_LYNX_SMART_BMS = 0x08,
DEVICE_TYPE_MULTI_RS = 0x09,
DEVICE_TYPE_VE_BUS = 0x0A,
DEVICE_TYPE_DC_ENERGY_METER = 0x0B
DEVICE_TYPE_GX_DEVICE = 0x07,
DEVICE_TYPE_AC_CHARGER = 0x08,
DEVICE_TYPE_SMART_BATTERY_PROTECT = 0x09,
DEVICE_TYPE_LYNX_SMART_BMS = 0x0A,
DEVICE_TYPE_MULTI_RS = 0x0B,
DEVICE_TYPE_VE_BUS = 0x0C,
DEVICE_TYPE_DC_ENERGY_METER = 0x0D,
DEVICE_TYPE_ORION_XS = 0x0F
};
// Device state for Solar Charger
// --- Device state for Solar Charger ---
enum SolarChargerState {
CHARGER_OFF = 0,
CHARGER_LOW_POWER = 1,
@@ -53,162 +74,233 @@ enum SolarChargerState {
CHARGER_EXTERNAL_CONTROL = 252
};
// Binary data structures for decoding BLE advertisements
// Must use __attribute__((packed)) to prevent compiler padding
// ============================================================
// Wire-format packed structures for decoding BLE advertisements
// ============================================================
// Manufacturer data structure (outer envelope)
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;
// Decrypted payload structures for each device type
struct victronManufacturerData {
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 decrypted payload
typedef struct {
uint8_t deviceState; // Charge state (SolarChargerState enum)
uint8_t errorCode; // Error code
int16_t batteryVoltage; // Battery voltage in 10mV units
int16_t batteryCurrent; // Battery current in 10mA units (signed)
uint16_t yieldToday; // Yield today in 10Wh units
uint16_t inputPower; // PV power in 1W units
uint16_t loadCurrent; // Load current in 10mA units (0xFFFF = no load)
uint8_t reserved[2]; // Reserved bytes
} __attribute__((packed)) victronSolarChargerPayload;
struct victronSolarChargerPayload {
uint8_t deviceState;
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));
// Battery Monitor decrypted payload
typedef struct {
uint16_t remainingMins; // Time remaining in minutes
uint16_t batteryVoltage; // Battery voltage in 10mV units
uint8_t alarms; // Alarm bits
uint16_t auxData; // Aux voltage (10mV) or temperature (0.01K)
uint8_t currentLow; // Battery current bits 0-7
uint8_t currentMid; // Battery current bits 8-15
uint8_t currentHigh_consumedLow; // Current bits 16-21 (low 6 bits), consumed bits 0-1 (high 2 bits)
uint8_t consumedMid; // Consumed Ah bits 2-9
uint8_t consumedHigh; // Consumed Ah bits 10-17
uint16_t soc; // State of charge in 0.1% units (10-bit value)
uint8_t reserved[2]; // Reserved bytes
} __attribute__((packed)) victronBatteryMonitorPayload;
// 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.
// Inverter decrypted payload
typedef struct {
uint8_t deviceState; // Device state
uint8_t errorCode; // Error code
uint16_t batteryVoltage; // Battery voltage in 10mV units
int16_t batteryCurrent; // Battery current in 10mA units (signed)
uint8_t acPowerLow; // AC Power bits 0-7
uint8_t acPowerMid; // AC Power bits 8-15
uint8_t acPowerHigh; // AC Power bits 16-23 (signed 24-bit)
uint8_t alarms; // Alarm bits
uint8_t reserved[4]; // Reserved bytes
} __attribute__((packed)) victronInverterPayload;
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));
// DC-DC Converter decrypted payload
typedef struct {
uint8_t chargeState; // Charge state
uint8_t errorCode; // Error code
uint16_t inputVoltage; // Input voltage in 10mV units
uint16_t outputVoltage; // Output voltage in 10mV units
uint16_t outputCurrent; // Output current in 10mA units
uint8_t reserved[6]; // Reserved bytes
} __attribute__((packed)) victronDCDCConverterPayload;
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));
// Base structure for all device data
struct VictronDeviceData {
String deviceName;
String macAddress;
VictronDeviceType deviceType;
int8_t rssi;
uint32_t lastUpdate;
bool dataValid;
// ============================================================
// Parsed data structures (flat, no inheritance)
// ============================================================
VictronDeviceData() : deviceType(DEVICE_TYPE_UNKNOWN), rssi(-100),
lastUpdate(0), dataValid(false) {}
};
// Solar Charger specific data
struct SolarChargerData : public VictronDeviceData {
SolarChargerState chargeState;
struct VictronSolarData {
uint8_t chargeState; // SolarChargerState enum
uint8_t errorCode;
float batteryVoltage; // V
float batteryCurrent; // A
float panelVoltage; // V (PV voltage)
float panelPower; // W
uint16_t yieldToday; // Wh
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 BatteryMonitorData : public VictronDeviceData {
struct VictronACChargerData {
uint8_t chargeState; // SolarChargerState enum (shared charger states)
uint8_t errorCode;
float voltage1; // V (output 1)
float current1; // A (output 1)
float voltage2; // V (output 2, 0 if absent)
float current2; // A (output 2, 0 if absent)
float voltage3; // V (output 3, 0 if absent)
float current3; // A (output 3, 0 if absent)
float temperature; // C (0 if not available)
float acCurrent; // A (0 if not available)
};
struct VictronBatteryData {
float voltage; // V
float current; // A (positive = charging, negative = discharging)
float temperature; // °C
float auxVoltage; // V (starter battery or midpoint)
uint16_t remainingMinutes; // Minutes
float current; // A
float temperature; // C (0 if aux is voltage)
float auxVoltage; // V (0 if aux is temperature)
uint16_t remainingMinutes;
float consumedAh; // Ah
float soc; // State of Charge %
float soc; // %
bool alarmLowVoltage;
bool alarmHighVoltage;
bool alarmLowSOC;
bool alarmLowTemperature;
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 InverterData : public VictronDeviceData {
struct VictronInverterData {
float batteryVoltage; // V
float batteryCurrent; // A
float acPower; // W
uint8_t state; // Inverter state
bool alarmHighVoltage;
uint8_t state;
bool alarmLowVoltage;
bool alarmHighVoltage;
bool alarmHighTemperature;
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 DCDCConverterData : public VictronDeviceData {
struct VictronDCDCData {
float inputVoltage; // V
float outputVoltage; // V
float outputCurrent; // A
uint8_t chargeState;
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 void normalizeMAC(const char* input, char* output);
DeviceEntry* findDevice(const char* normalizedMAC);
// Common entry point fed by each platform BLE backend with one raw
// manufacturer-data record (vendor ID first), the device MAC and RSSI.
// Decryption and payload decoding are delegated to victronble_decode()
// in the pure C core; storeRecord() maps the result into the legacy
// public structs (core NAN sentinels become 0).
void onAdvertisement(const uint8_t* mfgData, size_t len,
const char* macStr, int8_t rssi);
void storeRecord(DeviceEntry* entry, const victronble_record_t& rec);
// --- 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 {
public:
virtual ~VictronDeviceCallback() {}
@@ -218,114 +310,17 @@ public:
virtual void onDCDCConverterData(const DCDCConverterData& data) {}
};
// Device configuration structure
struct VictronDeviceConfig {
String name;
String macAddress;
String encryptionKey; // 32 character hex string
VictronDeviceType expectedType;
// --- Per-type getter methods (use callback instead) ---
bool getSolarChargerData(const String& macAddress, SolarChargerData& data);
bool getBatteryMonitorData(const String& macAddress, BatteryMonitorData& data);
bool getInverterData(const String& macAddress, InverterData& data);
bool getDCDCConverterData(const String& macAddress, DCDCConverterData& data);
VictronDeviceConfig() : expectedType(DEVICE_TYPE_UNKNOWN) {}
VictronDeviceConfig(String n, String mac, String key, VictronDeviceType type = DEVICE_TYPE_UNKNOWN)
: 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
// --- Other removed methods ---
void removeDevice(const String& macAddress);
std::vector<String> getDevicesByType(VictronDeviceType type);
String getLastError() const;
// Enable/disable debug output
void setDebug(bool enable) { debugEnabled = enable; }
// Get last error message
String getLastError() const { return lastError; }
private:
friend class VictronBLEAdvertisedDeviceCallbacks;
struct DeviceInfo {
VictronDeviceConfig config;
VictronDeviceData* data;
uint8_t encryptionKeyBytes[16];
DeviceInfo() : data(nullptr) {
memset(encryptionKeyBytes, 0, 16);
}
~DeviceInfo() {
if (data) delete data;
}
};
std::map<String, DeviceInfo*> devices;
BLEScan* pBLEScan;
VictronDeviceCallback* callback;
bool debugEnabled;
String lastError;
uint32_t scanDuration;
bool initialized;
// XXX Experiment with actual victron data
victronManufacturerData manufacturerData;
// Internal methods
bool hexStringToBytes(const String& hex, uint8_t* bytes, size_t len);
bool decryptAdvertisement(const uint8_t* encrypted, size_t encLen,
const uint8_t* key, const uint8_t* iv,
uint8_t* decrypted);
bool parseAdvertisement(const String& macAddress);
void processDevice(BLEAdvertisedDevice advertisedDevice);
VictronDeviceData* createDeviceData(VictronDeviceType type);
bool parseSolarCharger(const uint8_t* data, size_t len, SolarChargerData& result);
bool parseBatteryMonitor(const uint8_t* data, size_t len, BatteryMonitorData& result);
bool parseInverter(const uint8_t* data, size_t len, InverterData& result);
bool parseDCDCConverter(const uint8_t* data, size_t len, DCDCConverterData& result);
void debugPrint(const String& message);
String macAddressToString(BLEAddress address);
String normalizeMAC(String mac);
};
// BLE scan callback class
class VictronBLEAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
public:
VictronBLEAdvertisedDeviceCallbacks(VictronBLE* parent) : victronBLE(parent) {}
void onResult(BLEAdvertisedDevice advertisedDevice) override;
private:
VictronBLE* victronBLE;
};
#endif // commented-out features
#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_
+78
View File
@@ -0,0 +1,78 @@
/**
* 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) {
// Debug: print every BLE device seen (before any filtering)
if (debugEnabled) {
Serial.printf("[VictronBLE] MAC=%-17s RSSI=%-4d Name=%-20s ManData=%s\n",
advertisedDevice.getAddress().toString().c_str(),
advertisedDevice.getRSSI(),
advertisedDevice.haveName() ? advertisedDevice.getName().c_str() : "(none)",
advertisedDevice.haveManufacturerData() ? "yes" : "no");
}
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
+87
View File
@@ -0,0 +1,87 @@
/**
* 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) {
// Format MAC (little-endian to big-endian hex)
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]);
// Debug: print every BLE device seen (before any filtering)
if (s_instance->debugEnabled) {
// Manufacturer specific data (AD type 0xFF) — includes the 0x02E1 vendor ID
uint8_t mfgBuf[31];
uint8_t mfgLen = Bluefruit.Scanner.parseReportByType(
report, BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA, mfgBuf, sizeof(mfgBuf));
// Try to get device name
char nameBuf[32] = "(none)";
uint8_t nameLen = Bluefruit.Scanner.parseReportByType(
report, BLE_GAP_AD_TYPE_COMPLETE_LOCAL_NAME, (uint8_t*)nameBuf, sizeof(nameBuf) - 1);
if (nameLen == 0) {
nameLen = Bluefruit.Scanner.parseReportByType(
report, BLE_GAP_AD_TYPE_SHORT_LOCAL_NAME, (uint8_t*)nameBuf, sizeof(nameBuf) - 1);
}
if (nameLen > 0) nameBuf[nameLen] = '\0';
Serial.printf("[VictronBLE] MAC=%-17s RSSI=%-4d Name=%-20s ManData=%s\n",
mac, report->rssi, nameBuf, mfgLen >= 2 ? "yes" : "no");
}
// 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) {
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
+5
View File
@@ -0,0 +1,5 @@
/* Arduino include-path shim: Arduino builds only add src/ to the include
* path, so route to the canonical core header in include/. Zephyr and host
* builds add include/ directly and never see this file first — both paths
* end up in the same header (it has an include guard). */
#include "../include/victronble.h"
+38
View File
@@ -0,0 +1,38 @@
/**
* victronble — bundled software AES-128-CTR backend.
*
* Weak symbol: an alternative backend (PSA Crypto, mbedTLS, hardware) defines
* victronble_aes_ctr_default strong and the linker drops this file's code —
* and with it the bundled AES tables — from the final image.
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#include "victronble.h"
#include "crypto/vble_aes.h"
#include <string.h>
#if defined(_MSC_VER)
#define VICTRONBLE_WEAK
#else
#define VICTRONBLE_WEAK __attribute__((weak))
#endif
VICTRONBLE_WEAK
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user)
{
(void)user;
struct vble_aes_ctx ctx;
vble_aes_init_ctx_iv(&ctx, key, iv);
if (out != in) {
memcpy(out, in, len);
}
vble_aes_ctr_xcrypt(&ctx, out, len);
return 0;
}
+382
View File
@@ -0,0 +1,382 @@
/**
* victronble core — decode + decrypt for Victron Instant Readout adverts.
* Pure C99: no Arduino, no BLE stack, no allocation, no I/O, reentrant.
*
* Byte offsets and bit layouts match the proven ESP32/nRF52 implementation
* in src/VictronBLE.cpp and Victron's "Extra Manufacturer Data" document.
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#include "victronble.h"
#include <string.h>
#include <math.h>
/* Manufacturer-data layout (offsets from the company ID):
* 0-1 company ID (LE, 0x02E1)
* 2 record type, 0x10 = product advertisement
* 3-4 model ID (LE)
* 5 read-out type
* 6 device record type (victronble_device_type_t)
* 7-8 nonce / data counter (LE)
* 9 key check byte (== key[0])
* 10- AES-128-CTR ciphertext, up to 21 bytes
*/
#define OFF_RECORD 2
#define OFF_MODEL 3
#define OFF_READOUT 5
#define OFF_DEVTYPE 6
#define OFF_NONCE 7
#define OFF_KEYCHECK 9
#define OFF_CIPHER 10
#define PRODUCT_ADV 0x10
static uint16_t get_le16(const uint8_t *p)
{
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
}
/* --- AES backend selection ------------------------------------------- */
static victronble_aes_ctr_fn aes_fn;
static void *aes_user;
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user)
{
aes_fn = fn;
aes_user = user;
}
static int aes_ctr(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out, size_t len)
{
if (aes_fn != NULL) {
return aes_fn(key, iv, in, out, len, aes_user);
}
return victronble_aes_ctr_default(key, iv, in, out, len, NULL);
}
/* --- Pre-filters ------------------------------------------------------ */
bool victronble_is_product_adv(const uint8_t *mfg, size_t len)
{
return mfg != NULL && len >= VICTRONBLE_MIN_MFG_LEN &&
get_le16(mfg) == VICTRONBLE_COMPANY_ID &&
mfg[OFF_RECORD] == PRODUCT_ADV;
}
bool victronble_key_matches(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN])
{
return victronble_is_product_adv(mfg, len) && mfg[OFF_KEYCHECK] == key[0];
}
/* --- Per-type payload decoders ---------------------------------------
* All operate on the decrypted payload, zero-padded to
* VICTRONBLE_MAX_CIPHER_LEN bytes, so length checks always pass at the
* decode() call site; they remain for direct-call safety. */
static bool parse_solar_charger(const uint8_t *d, size_t len,
victronble_solar_charger_t *r)
{
if (len < 12) {
return false;
}
r->state = d[0];
r->error = d[1];
r->battery_voltage = (int16_t)get_le16(d + 2) * 0.01f; /* 0.01 V */
r->battery_current = (int16_t)get_le16(d + 4) * 0.1f; /* 0.1 A */
r->yield_today_wh = (uint32_t)get_le16(d + 6) * 10u; /* 0.01 kWh */
r->pv_power = get_le16(d + 8); /* 1 W */
/* Load current is a 9-bit field (0.1 A units); 0x1FF = no load output */
uint16_t load_raw = get_le16(d + 10) & 0x1FF;
r->load_current = (load_raw != 0x1FF) ? load_raw * 0.1f : NAN;
return true;
}
static bool parse_battery_monitor(const uint8_t *d, size_t len,
victronble_battery_monitor_t *r)
{
/* Bit-packed, not byte-aligned; decoded by bit offset. SOC ends at
* bit 117 (byte 14). */
if (len < 15) {
return false;
}
r->remaining_minutes = get_le16(d); /* bits 0-15 */
r->voltage = (int16_t)get_le16(d + 2) * 0.01f; /* bits 16-31 */
r->alarm = get_le16(d + 4); /* bits 32-47 */
/* Aux value (bits 48-63) interpreted per aux mode (bits 64-65) */
uint16_t aux_raw = get_le16(d + 6);
r->aux_mode = d[8] & 0x03;
r->aux_voltage = (r->aux_mode == 0) ? aux_raw * 0.01f : NAN;
r->temperature = (r->aux_mode == 2) ? aux_raw * 0.01f - 273.15f : NAN;
/* Battery current (bits 66-87), 22-bit signed, 0.001 A units */
int32_t current = (int32_t)(((uint32_t)(d[8] >> 2) & 0x3F) |
((uint32_t)d[9] << 6) |
((uint32_t)d[10] << 14));
if (current & 0x200000) {
current |= (int32_t)0xFFC00000; /* sign extend */
}
r->current = current * 0.001f;
/* Consumed Ah (bits 88-107), 20-bit positive count, 0.1 Ah units,
* reported negative (amp-hours consumed). */
uint32_t consumed = (uint32_t)d[11] | ((uint32_t)d[12] << 8) |
((uint32_t)(d[13] & 0x0F) << 16);
r->consumed_ah = -((float)consumed * 0.1f);
/* SOC (bits 108-117), 10-bit, 0.1 % units */
uint16_t soc = (uint16_t)(((d[13] >> 4) | ((uint16_t)d[14] << 4)) & 0x3FF);
r->soc = soc * 0.1f;
return true;
}
static bool parse_inverter(const uint8_t *d, size_t len,
victronble_inverter_t *r)
{
if (len < 9) {
return false;
}
r->state = d[0];
/* d[1] is the error code on the wire; kept out of the struct for parity
* with the proven implementation, which only surfaced the alarm bits. */
r->battery_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
r->battery_current = (int16_t)get_le16(d + 4) * 0.01f; /* 10 mA */
int32_t ac_power = (int32_t)((uint32_t)d[6] | ((uint32_t)d[7] << 8) |
((uint32_t)d[8] << 16));
if (ac_power & 0x800000) {
ac_power |= (int32_t)0xFF000000; /* sign extend */
}
r->ac_power = (float)ac_power;
r->alarms = (len > 9) ? d[9] : 0;
return true;
}
static bool parse_dcdc(const uint8_t *d, size_t len, victronble_dcdc_t *r)
{
if (len < 8) {
return false;
}
r->state = d[0];
r->error = d[1];
r->input_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
r->output_voltage = get_le16(d + 4) * 0.01f; /* 10 mV */
r->output_current = get_le16(d + 6) * 0.01f; /* 10 mA */
return true;
}
static uint32_t read_bits(const uint8_t *d, size_t *bit, uint8_t width)
{
uint32_t value = 0;
for (uint8_t i = 0; i < width; i++) {
size_t b = *bit + i;
value |= (uint32_t)((d[b >> 3] >> (b & 7)) & 0x01) << i;
}
*bit += width;
return value;
}
static bool parse_ac_charger(const uint8_t *d, size_t len,
victronble_ac_charger_t *r)
{
/* Bit-packed: 10 fields, 104 bits ending in byte 12, LSB-first. */
if (len < 13) {
return false;
}
size_t bit = 0;
r->state = (uint8_t)read_bits(d, &bit, 8);
r->error = (uint8_t)read_bits(d, &bit, 8);
uint32_t v1 = read_bits(d, &bit, 13), i1 = read_bits(d, &bit, 11);
uint32_t v2 = read_bits(d, &bit, 13), i2 = read_bits(d, &bit, 11);
uint32_t v3 = read_bits(d, &bit, 13), i3 = read_bits(d, &bit, 11);
uint32_t temp = read_bits(d, &bit, 7);
uint32_t ac_cur = read_bits(d, &bit, 9);
r->voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : NAN;
r->current1 = (i1 != 0x7FF) ? i1 * 0.1f : NAN;
r->voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : NAN;
r->current2 = (i2 != 0x7FF) ? i2 * 0.1f : NAN;
r->voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : NAN;
r->current3 = (i3 != 0x7FF) ? i3 * 0.1f : NAN;
r->temperature = (temp != 0x7F) ? (float)temp - 40.0f : NAN;
r->ac_current = (ac_cur != 0x1FF) ? ac_cur * 0.1f : NAN;
return true;
}
/* --- Decode entry point ----------------------------------------------- */
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN],
victronble_record_t *out)
{
if (mfg == NULL || len < VICTRONBLE_MIN_MFG_LEN) {
return VICTRONBLE_ERR_SHORT;
}
if (get_le16(mfg) != VICTRONBLE_COMPANY_ID) {
return VICTRONBLE_ERR_NOT_VICTRON;
}
if (mfg[OFF_RECORD] != PRODUCT_ADV) {
return VICTRONBLE_ERR_NOT_PRODUCT;
}
if (mfg[OFF_KEYCHECK] != key[0]) {
return VICTRONBLE_ERR_KEY_MISMATCH;
}
uint16_t nonce = get_le16(mfg + OFF_NONCE);
/* IV: nonce in the two low bytes (LE), remaining 14 bytes zero. */
uint8_t iv[16] = {0};
iv[0] = (uint8_t)(nonce & 0xFF);
iv[1] = (uint8_t)(nonce >> 8);
/* Decrypt what's on the wire; zero-pad to the full payload size so the
* per-type decoders see a fixed-length buffer (matches the proven
* implementation, which zero-filled the wire struct before copy-in). */
uint8_t plain[VICTRONBLE_MAX_CIPHER_LEN] = {0};
size_t cipher_len = len - OFF_CIPHER;
if (cipher_len > VICTRONBLE_MAX_CIPHER_LEN) {
cipher_len = VICTRONBLE_MAX_CIPHER_LEN;
}
if (aes_ctr(key, iv, mfg + OFF_CIPHER, plain, cipher_len) != 0) {
return VICTRONBLE_ERR_CRYPTO;
}
victronble_record_t rec;
memset(&rec, 0, sizeof(rec));
rec.record_type = mfg[OFF_DEVTYPE];
rec.model_id = get_le16(mfg + OFF_MODEL);
rec.readout_type = mfg[OFF_READOUT];
rec.nonce = nonce;
bool ok = false;
switch (mfg[OFF_DEVTYPE]) {
case VICTRONBLE_DEV_SOLAR_CHARGER:
rec.type = VICTRONBLE_DEV_SOLAR_CHARGER;
ok = parse_solar_charger(plain, sizeof(plain), &rec.u.solar);
break;
case VICTRONBLE_DEV_BATTERY_MONITOR:
rec.type = VICTRONBLE_DEV_BATTERY_MONITOR;
ok = parse_battery_monitor(plain, sizeof(plain), &rec.u.batmon);
break;
case VICTRONBLE_DEV_INVERTER:
case VICTRONBLE_DEV_INVERTER_RS:
case VICTRONBLE_DEV_MULTI_RS:
case VICTRONBLE_DEV_VE_BUS:
rec.type = VICTRONBLE_DEV_INVERTER;
ok = parse_inverter(plain, sizeof(plain), &rec.u.inverter);
break;
case VICTRONBLE_DEV_DCDC_CONVERTER:
rec.type = VICTRONBLE_DEV_DCDC_CONVERTER;
ok = parse_dcdc(plain, sizeof(plain), &rec.u.dcdc);
break;
case VICTRONBLE_DEV_AC_CHARGER:
rec.type = VICTRONBLE_DEV_AC_CHARGER;
ok = parse_ac_charger(plain, sizeof(plain), &rec.u.ac);
break;
default:
return VICTRONBLE_ERR_UNSUPPORTED;
}
if (!ok) {
return VICTRONBLE_ERR_SHORT;
}
*out = rec;
return VICTRONBLE_OK;
}
/* --- Helpers ----------------------------------------------------------- */
static int hex_nibble(char c)
{
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
if (c >= 'A' && c <= 'F') {
return c - 'A' + 10;
}
return -1;
}
bool victronble_parse_key(const char *hex, uint8_t key[VICTRONBLE_KEY_LEN])
{
if (hex == NULL || strlen(hex) != VICTRONBLE_KEY_LEN * 2) {
return false;
}
for (size_t i = 0; i < VICTRONBLE_KEY_LEN; i++) {
int hi = hex_nibble(hex[i * 2]);
int lo = hex_nibble(hex[i * 2 + 1]);
if (hi < 0 || lo < 0) {
return false;
}
key[i] = (uint8_t)((hi << 4) | lo);
}
return true;
}
const char *victronble_strerror(victronble_err_t err)
{
switch (err) {
case VICTRONBLE_OK: return "ok";
case VICTRONBLE_ERR_NOT_VICTRON: return "not victron";
case VICTRONBLE_ERR_SHORT: return "truncated";
case VICTRONBLE_ERR_NOT_PRODUCT: return "not product adv";
case VICTRONBLE_ERR_KEY_MISMATCH: return "key mismatch";
case VICTRONBLE_ERR_UNSUPPORTED: return "unsupported type";
case VICTRONBLE_ERR_CRYPTO: return "crypto error";
default: return "unknown error";
}
}
const char *victronble_device_type_str(victronble_device_type_t type)
{
switch (type) {
case VICTRONBLE_DEV_SOLAR_CHARGER: return "solar charger";
case VICTRONBLE_DEV_BATTERY_MONITOR: return "battery monitor";
case VICTRONBLE_DEV_INVERTER: return "inverter";
case VICTRONBLE_DEV_DCDC_CONVERTER: return "dc-dc converter";
case VICTRONBLE_DEV_SMART_LITHIUM: return "smart lithium";
case VICTRONBLE_DEV_INVERTER_RS: return "inverter rs";
case VICTRONBLE_DEV_GX_DEVICE: return "gx device";
case VICTRONBLE_DEV_AC_CHARGER: return "ac charger";
case VICTRONBLE_DEV_BATTERY_PROTECT: return "battery protect";
case VICTRONBLE_DEV_LYNX_SMART_BMS: return "lynx smart bms";
case VICTRONBLE_DEV_MULTI_RS: return "multi rs";
case VICTRONBLE_DEV_VE_BUS: return "ve.bus";
case VICTRONBLE_DEV_DC_ENERGY_METER: return "dc energy meter";
case VICTRONBLE_DEV_ORION_XS: return "orion xs";
default: return "unknown";
}
}
const char *victronble_state_str(uint8_t state)
{
switch (state) {
case VICTRONBLE_STATE_OFF: return "off";
case VICTRONBLE_STATE_LOW_POWER: return "low";
case VICTRONBLE_STATE_FAULT: return "fault";
case VICTRONBLE_STATE_BULK: return "bulk";
case VICTRONBLE_STATE_ABSORPTION: return "abs";
case VICTRONBLE_STATE_FLOAT: return "float";
case VICTRONBLE_STATE_STORAGE: return "store";
case VICTRONBLE_STATE_EQUALIZE: return "eq";
case VICTRONBLE_STATE_INVERTING: return "invert";
case VICTRONBLE_STATE_POWER_SUPPLY: return "psu";
case VICTRONBLE_STATE_EXTERNAL_CONTROL: return "ext";
default: return "?";
}
}
+301
View File
@@ -0,0 +1,301 @@
/**
* victronble — Zephyr BLE observer backend.
*
* Scan callback (BT RX context) does the cheap work only: AD walk, product
* pre-filter, registry match, copy into a message queue. A dedicated thread
* decrypts, decodes, dedups and fans out to registered listeners.
*
* Copyright (c) 2026 Scott Penrose
* License: MIT
*/
/* Arduino/PlatformIO builds compile every file under src/ — this backend
* only exists under Zephyr (the Zephyr CMake build lists sources
* explicitly, so the reverse problem doesn't arise). */
#ifdef __ZEPHYR__
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gap.h>
#include <zephyr/logging/log.h>
#include "victronble_zephyr.h"
LOG_MODULE_REGISTER(victronble, CONFIG_VICTRONBLE_LOG_LEVEL);
#define MAX_MFG_LEN (VICTRONBLE_MIN_MFG_LEN + VICTRONBLE_MAX_CIPHER_LEN)
struct vb_frame {
bt_addr_le_t addr;
int8_t rssi;
uint8_t len;
uint8_t data[MAX_MFG_LEN];
};
struct vb_device {
bt_addr_le_t addr;
uint8_t key[VICTRONBLE_KEY_LEN];
uint16_t last_nonce;
bool have_nonce;
bool used;
};
K_MSGQ_DEFINE(vb_msgq, sizeof(struct vb_frame),
CONFIG_VICTRONBLE_QUEUE_DEPTH, 4);
static struct vb_device devices[CONFIG_VICTRONBLE_MAX_DEVICES];
static struct k_mutex dev_mtx;
static sys_slist_t callbacks = SYS_SLIST_STATIC_INIT(&callbacks);
static struct victronble_stats stats;
static bool scanning;
static struct vb_device *find_device(const bt_addr_le_t *addr)
{
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
if (devices[i].used &&
bt_addr_le_cmp(&devices[i].addr, addr) == 0) {
return &devices[i];
}
}
return NULL;
}
/* --- Scan path (BT RX context) --------------------------------------- */
struct ad_ctx {
const bt_addr_le_t *addr;
int8_t rssi;
};
static bool ad_cb(struct bt_data *data, void *user_data)
{
struct ad_ctx *ctx = user_data;
if (data->type != BT_DATA_MANUFACTURER_DATA) {
return true; /* keep walking the AD structures */
}
if (!victronble_is_product_adv(data->data, data->data_len)) {
return true;
}
stats.adverts++;
/* Registry check is a handful of compares — cheap enough here, and
* it keeps other people's Victrons out of the queue. */
if (find_device(ctx->addr) == NULL) {
return false;
}
struct vb_frame frame;
bt_addr_le_copy(&frame.addr, ctx->addr);
frame.rssi = ctx->rssi;
frame.len = MIN(data->data_len, sizeof(frame.data));
memcpy(frame.data, data->data, frame.len);
if (k_msgq_put(&vb_msgq, &frame, K_NO_WAIT) == 0) {
stats.queued++;
} else {
stats.dropped++;
}
return false; /* found the record — stop walking */
}
static void scan_recv(const bt_addr_le_t *addr, int8_t rssi,
uint8_t adv_type, struct net_buf_simple *ad)
{
ARG_UNUSED(adv_type);
struct ad_ctx ctx = { .addr = addr, .rssi = rssi };
bt_data_parse(ad, ad_cb, &ctx);
}
/* --- Decode thread ----------------------------------------------------- */
static void decode_frame(const struct vb_frame *frame)
{
uint8_t key[VICTRONBLE_KEY_LEN];
uint16_t last_nonce;
bool have_nonce;
k_mutex_lock(&dev_mtx, K_FOREVER);
struct vb_device *dev = find_device(&frame->addr);
if (dev == NULL) { /* removed while queued */
k_mutex_unlock(&dev_mtx);
return;
}
memcpy(key, dev->key, sizeof(key));
last_nonce = dev->last_nonce;
have_nonce = dev->have_nonce;
k_mutex_unlock(&dev_mtx);
victronble_record_t rec;
victronble_err_t err = victronble_decode(frame->data, frame->len,
key, &rec);
struct victronble_cb *cb;
if (err != VICTRONBLE_OK) {
stats.errors++;
LOG_DBG("decode failed: %s", victronble_strerror(err));
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
if (cb->decode_error != NULL) {
cb->decode_error(&frame->addr, err);
}
}
return;
}
if (IS_ENABLED(CONFIG_VICTRONBLE_DEDUP) &&
have_nonce && rec.nonce == last_nonce) {
stats.duplicates++;
return;
}
k_mutex_lock(&dev_mtx, K_FOREVER);
dev = find_device(&frame->addr);
if (dev != NULL) {
dev->last_nonce = rec.nonce;
dev->have_nonce = true;
}
k_mutex_unlock(&dev_mtx);
stats.decoded++;
LOG_DBG("%s record, nonce 0x%04x, rssi %d",
victronble_device_type_str(rec.type), rec.nonce, frame->rssi);
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
if (cb->record != NULL) {
cb->record(&frame->addr, frame->rssi, &rec);
}
}
}
static void vb_thread_fn(void *a, void *b, void *c)
{
ARG_UNUSED(a);
ARG_UNUSED(b);
ARG_UNUSED(c);
struct vb_frame frame;
while (true) {
k_msgq_get(&vb_msgq, &frame, K_FOREVER);
decode_frame(&frame);
}
}
K_THREAD_DEFINE(vb_thread, CONFIG_VICTRONBLE_THREAD_STACK_SIZE,
vb_thread_fn, NULL, NULL, NULL,
CONFIG_VICTRONBLE_THREAD_PRIORITY, 0, 0);
/* --- Public API -------------------------------------------------------- */
int victronble_cb_register(struct victronble_cb *cb)
{
struct victronble_cb *it;
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, it, node) {
if (it == cb) {
return -EALREADY;
}
}
sys_slist_append(&callbacks, &cb->node);
return 0;
}
int victronble_device_add(const bt_addr_le_t *addr,
const uint8_t key[VICTRONBLE_KEY_LEN])
{
int ret = -ENOMEM;
k_mutex_lock(&dev_mtx, K_FOREVER);
if (find_device(addr) != NULL) {
ret = -EALREADY;
} else {
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
if (!devices[i].used) {
bt_addr_le_copy(&devices[i].addr, addr);
memcpy(devices[i].key, key,
VICTRONBLE_KEY_LEN);
devices[i].have_nonce = false;
devices[i].used = true;
ret = 0;
break;
}
}
}
k_mutex_unlock(&dev_mtx);
return ret;
}
int victronble_device_remove(const bt_addr_le_t *addr)
{
int ret = -ENOENT;
k_mutex_lock(&dev_mtx, K_FOREVER);
struct vb_device *dev = find_device(addr);
if (dev != NULL) {
memset(dev, 0, sizeof(*dev));
ret = 0;
}
k_mutex_unlock(&dev_mtx);
return ret;
}
int victronble_start(void)
{
/* Passive scan at a low duty cycle: Victron devices advertise about
* once per second, so slow-scan parameters catch every record for a
* fraction of the radio-on time. */
static const struct bt_le_scan_param param = {
.type = BT_LE_SCAN_TYPE_PASSIVE,
.options = BT_LE_SCAN_OPT_NONE,
.interval = CONFIG_VICTRONBLE_SCAN_INTERVAL,
.window = CONFIG_VICTRONBLE_SCAN_WINDOW,
};
int err;
if (scanning) {
return -EALREADY;
}
err = bt_le_scan_start(&param, scan_recv);
if (err != 0) {
LOG_ERR("scan start failed (%d)", err);
return err;
}
scanning = true;
LOG_INF("observing (interval %u window %u)",
CONFIG_VICTRONBLE_SCAN_INTERVAL, CONFIG_VICTRONBLE_SCAN_WINDOW);
return 0;
}
int victronble_stop(void)
{
int err;
if (!scanning) {
return -EALREADY;
}
err = bt_le_scan_stop();
if (err == 0) {
scanning = false;
}
return err;
}
void victronble_get_stats(struct victronble_stats *out)
{
*out = stats;
}
static int vb_init(void)
{
k_mutex_init(&dev_mtx);
return 0;
}
SYS_INIT(vb_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY);
#endif /* __ZEPHYR__ */
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Generate test_vectors.h for the victronble core host tests.
Plaintext payloads are packed here from human-readable field values
(mirroring Victron's Extra Manufacturer Data layouts) and encrypted with
the openssl CLI — an implementation independent of the library's bundled
tiny-AES — so the vectors cross-check the AES-CTR semantics as well as
the parsers. The generated header is committed; python/openssl are only
needed to regenerate it.
"""
import subprocess
from pathlib import Path
COMPANY_ID = 0x02E1
PRODUCT_ADV = 0x10
KEY = bytes.fromhex("0df4d0395b7d5d4f5a0d0af52e1b4c1e")
def aes_ctr(key: bytes, nonce: int, plaintext: bytes) -> bytes:
iv = bytes([nonce & 0xFF, (nonce >> 8) & 0xFF] + [0] * 14)
return subprocess.run(
["openssl", "enc", "-aes-128-ctr", "-K", key.hex(), "-iv", iv.hex(),
"-nopad"],
input=plaintext, capture_output=True, check=True).stdout
def frame(record_type: int, model_id: int, readout: int, nonce: int,
plaintext: bytes, key: bytes = KEY) -> bytes:
head = bytes([COMPANY_ID & 0xFF, COMPANY_ID >> 8, PRODUCT_ADV,
model_id & 0xFF, model_id >> 8, readout, record_type,
nonce & 0xFF, (nonce >> 8) & 0xFF, key[0]])
return head + aes_ctr(key, nonce, plaintext)
def le16(v: int) -> bytes:
return bytes([v & 0xFF, (v >> 8) & 0xFF])
def pad21(b: bytes) -> bytes:
assert len(b) <= 21
return b + bytes(21 - len(b))
def pack_bits(fields):
"""fields: list of (value, width). LSB-first bit packing."""
total = sum(w for _, w in fields)
out = bytearray((total + 7) // 8)
bit = 0
for value, width in fields:
for i in range(width):
if (value >> i) & 1:
out[(bit + i) >> 3] |= 1 << ((bit + i) & 7)
bit += width
return bytes(out)
# --- Payloads ---------------------------------------------------------------
# Solar charger: bulk, no error, 13.24 V, 5.4 A, 1.20 kWh today, 340 W,
# no load output (9-bit 0x1FF).
solar = pad21(bytes([3, 0]) + le16(1324) + le16(54) + le16(120) + le16(340) +
le16(0x1FF))
# Battery monitor: TTG 600 min, 12.80 V, alarms lowV|lowSOC, aux mode 2
# (temperature 25.00 C = 29815 * 0.01 K), current -2.5 A, consumed 50.0 Ah,
# SOC 85.5 %.
batmon = pad21(pack_bits([
(600, 16), # TTG minutes
(1280, 16), # voltage, 0.01 V
(0x0005, 16), # alarm bitmask
(29815, 16), # aux raw (0.01 K)
(2, 2), # aux mode = temperature
(-2500 & 0x3FFFFF, 22), # current, 0.001 A
(500, 20), # consumed, 0.1 Ah
(855, 10), # SOC, 0.1 %
]))
# Inverter: inverting, 25.86 V, -12.34 A, -230 W, overload alarm.
inverter = pad21(bytes([9, 0]) + le16(2586) + le16(-1234 & 0xFFFF) +
((-230) & 0xFFFFFF).to_bytes(3, "little") + bytes([0x08]))
# DC-DC converter: float, no error, in 25.30 V, out 13.31 V, 7.65 A.
dcdc = pad21(bytes([5, 0]) + le16(2530) + le16(1331) + le16(765))
# AC charger: absorption, no error, out1 14.40 V / 10.0 A, out2/3 absent,
# temp 35 C, AC current 1.2 A.
accharger = pad21(pack_bits([
(4, 8), (0, 8),
(1440, 13), (100, 11),
(0x1FFF, 13), (0x7FF, 11),
(0x1FFF, 13), (0x7FF, 11),
(35 + 40, 7),
(12, 9),
]))
VECTORS = [
("solar", frame(0x01, 0xA060, 0x00, 0x1234, solar)),
("batmon", frame(0x02, 0xA389, 0x00, 0xBEEF, batmon)),
("inverter", frame(0x03, 0xA2FA, 0x00, 0x0001, inverter)),
("dcdc", frame(0x04, 0xA3C0, 0x00, 0xFFFF, dcdc)),
("accharger", frame(0x08, 0xA339, 0x00, 0x00C8, accharger)),
# Multi RS record type decodes via the inverter parser.
("multirs", frame(0x0B, 0xA512, 0x00, 0x0042, inverter)),
# GX device: recognised record type, no decoder -> ERR_UNSUPPORTED.
("gx", frame(0x07, 0xA100, 0x00, 0x0007, pad21(b""))),
]
def main():
out = Path(__file__).with_name("test_vectors.h")
lines = [
"/* Generated by gen_vectors.py — do not edit by hand.",
" * Ciphertext produced with `openssl enc -aes-128-ctr`, independent",
" * of the library's bundled AES. */",
"",
f'static const char VEC_KEY_HEX[] = "{KEY.hex()}";',
"",
]
for name, data in VECTORS:
arr = ", ".join(f"0x{b:02x}" for b in data)
lines.append(f"static const uint8_t VEC_{name.upper()}[] = {{ {arr} }};")
lines.append("")
out.write_text("\n".join(lines))
print(f"wrote {out} ({len(VECTORS)} vectors)")
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Build and run the victronble core host tests (plain gcc, no framework).
# Regenerate vectors first with: python3 gen_vectors.py
set -e
cd "$(dirname "$0")"
cc -std=c99 -Wall -Wextra -Werror -I../../include -I../../src \
../../src/victronble_core.c ../../src/victronble_aes_sw.c \
../../src/crypto/vble_aes.c test_main.c -lm -o victronble_test
./victronble_test
+173
View File
@@ -0,0 +1,173 @@
/**
* victronble core host tests.
*
* Plain C, no framework: non-zero exit on failure. Positive vectors come
* from test_vectors.h (openssl-encrypted, independent of the bundled AES);
* negative cases are built inline.
*
* Build & run: ./run.sh (or see the gcc line inside it)
*/
#include <stdio.h>
#include <string.h>
#include <math.h>
#include "victronble.h"
#include "test_vectors.h"
static int failures;
#define CHECK(cond) do { \
if (!(cond)) { \
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
failures++; \
} \
} while (0)
static int feq(float a, float b)
{
return fabsf(a - b) < 0.005f;
}
static victronble_err_t decode(const uint8_t *frame, size_t len,
const uint8_t key[16], victronble_record_t *rec)
{
memset(rec, 0xAA, sizeof(*rec));
return victronble_decode(frame, len, key, rec);
}
int main(void)
{
uint8_t key[VICTRONBLE_KEY_LEN];
victronble_record_t rec;
CHECK(victronble_parse_key(VEC_KEY_HEX, key));
/* --- solar charger --- */
CHECK(victronble_is_product_adv(VEC_SOLAR, sizeof(VEC_SOLAR)));
CHECK(victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), key));
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_SOLAR_CHARGER);
CHECK(rec.model_id == 0xA060);
CHECK(rec.nonce == 0x1234);
CHECK(rec.u.solar.state == VICTRONBLE_STATE_BULK);
CHECK(rec.u.solar.error == 0);
CHECK(feq(rec.u.solar.battery_voltage, 13.24f));
CHECK(feq(rec.u.solar.battery_current, 5.4f));
CHECK(rec.u.solar.yield_today_wh == 1200);
CHECK(feq(rec.u.solar.pv_power, 340.0f));
CHECK(isnan(rec.u.solar.load_current));
CHECK(strcmp(victronble_state_str(rec.u.solar.state), "bulk") == 0);
/* --- battery monitor --- */
CHECK(decode(VEC_BATMON, sizeof(VEC_BATMON), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_BATTERY_MONITOR);
CHECK(rec.u.batmon.remaining_minutes == 600);
CHECK(feq(rec.u.batmon.voltage, 12.80f));
CHECK(rec.u.batmon.alarm == 0x0005);
CHECK(rec.u.batmon.aux_mode == 2);
CHECK(feq(rec.u.batmon.temperature, 25.0f));
CHECK(isnan(rec.u.batmon.aux_voltage));
CHECK(feq(rec.u.batmon.current, -2.5f));
CHECK(feq(rec.u.batmon.consumed_ah, -50.0f));
CHECK(feq(rec.u.batmon.soc, 85.5f));
/* --- inverter --- */
CHECK(decode(VEC_INVERTER, sizeof(VEC_INVERTER), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
CHECK(rec.u.inverter.state == VICTRONBLE_STATE_INVERTING);
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
CHECK(feq(rec.u.inverter.battery_current, -12.34f));
CHECK(feq(rec.u.inverter.ac_power, -230.0f));
CHECK(rec.u.inverter.alarms == 0x08);
/* --- dc-dc converter --- */
CHECK(decode(VEC_DCDC, sizeof(VEC_DCDC), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_DCDC_CONVERTER);
CHECK(rec.u.dcdc.state == VICTRONBLE_STATE_FLOAT);
CHECK(feq(rec.u.dcdc.input_voltage, 25.30f));
CHECK(feq(rec.u.dcdc.output_voltage, 13.31f));
CHECK(feq(rec.u.dcdc.output_current, 7.65f));
CHECK(rec.nonce == 0xFFFF);
/* --- ac charger --- */
CHECK(decode(VEC_ACCHARGER, sizeof(VEC_ACCHARGER), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_AC_CHARGER);
CHECK(rec.u.ac.state == VICTRONBLE_STATE_ABSORPTION);
CHECK(feq(rec.u.ac.voltage1, 14.40f));
CHECK(feq(rec.u.ac.current1, 10.0f));
CHECK(isnan(rec.u.ac.voltage2) && isnan(rec.u.ac.current2));
CHECK(isnan(rec.u.ac.voltage3) && isnan(rec.u.ac.current3));
CHECK(feq(rec.u.ac.temperature, 35.0f));
CHECK(feq(rec.u.ac.ac_current, 1.2f));
/* --- multi RS collapses to the inverter decoder --- */
CHECK(decode(VEC_MULTIRS, sizeof(VEC_MULTIRS), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
CHECK(rec.record_type == VICTRONBLE_DEV_MULTI_RS);
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
/* --- negative cases --- */
/* Known record type, no decoder */
CHECK(decode(VEC_GX, sizeof(VEC_GX), key, &rec) == VICTRONBLE_ERR_UNSUPPORTED);
/* Truncated: shorter than the header */
CHECK(decode(VEC_SOLAR, 9, key, &rec) == VICTRONBLE_ERR_SHORT);
CHECK(!victronble_is_product_adv(VEC_SOLAR, 9));
/* Wrong company ID */
{
uint8_t bad[sizeof(VEC_SOLAR)];
memcpy(bad, VEC_SOLAR, sizeof(bad));
bad[0] = 0x4C; bad[1] = 0x00; /* Apple */
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_VICTRON);
CHECK(!victronble_is_product_adv(bad, sizeof(bad)));
}
/* Not a product advertisement */
{
uint8_t bad[sizeof(VEC_SOLAR)];
memcpy(bad, VEC_SOLAR, sizeof(bad));
bad[2] = 0x01;
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_PRODUCT);
}
/* Wrong key: check byte catches it without decrypting */
{
uint8_t wrong_key[16];
memcpy(wrong_key, key, 16);
wrong_key[0] ^= 0xFF;
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec) ==
VICTRONBLE_ERR_KEY_MISMATCH);
CHECK(!victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key));
}
/* Wrong key with a matching check byte: decrypts to garbage but must
* not crash; solar parser accepts any bytes, so OK with junk values is
* acceptable — just require no error other than OK/SHORT. */
{
uint8_t wrong_key[16];
memcpy(wrong_key, key, 16);
wrong_key[15] ^= 0xFF;
victronble_err_t err = decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec);
CHECK(err == VICTRONBLE_OK || err == VICTRONBLE_ERR_SHORT);
}
/* Key parsing */
{
uint8_t k[16];
CHECK(!victronble_parse_key("00112233", k)); /* short */
CHECK(!victronble_parse_key(NULL, k));
CHECK(!victronble_parse_key("zz112233445566778899aabbccddeeff", k));
CHECK(victronble_parse_key("00112233445566778899AABBCCDDEEFF", k));
CHECK(k[0] == 0x00 && k[15] == 0xFF);
}
if (failures == 0) {
printf("victronble core: all tests passed\n");
return 0;
}
printf("victronble core: %d FAILURE(S)\n", failures);
return 1;
}
+13
View File
@@ -0,0 +1,13 @@
/* Generated by gen_vectors.py — do not edit by hand.
* Ciphertext produced with `openssl enc -aes-128-ctr`, independent
* of the library's bundled AES. */
static const char VEC_KEY_HEX[] = "0df4d0395b7d5d4f5a0d0af52e1b4c1e";
static const uint8_t VEC_SOLAR[] = { 0xe1, 0x02, 0x10, 0x60, 0xa0, 0x00, 0x01, 0x34, 0x12, 0x0d, 0x53, 0xb0, 0x25, 0x4c, 0x65, 0xd3, 0x4a, 0x92, 0x34, 0x70, 0x3a, 0x6c, 0x19, 0x73, 0x7e, 0x65, 0xf6, 0xa4, 0x42, 0xd0, 0x56 };
static const uint8_t VEC_BATMON[] = { 0xe1, 0x02, 0x10, 0x89, 0xa3, 0x00, 0x02, 0xef, 0xbe, 0x0d, 0x41, 0x08, 0x53, 0x2f, 0x0d, 0x44, 0xa5, 0x1c, 0x62, 0xa0, 0x51, 0xe9, 0x7c, 0x1f, 0xae, 0x2f, 0xe9, 0xe8, 0x2a, 0x0e, 0x15 };
static const uint8_t VEC_INVERTER[] = { 0xe1, 0x02, 0x10, 0xfa, 0xa2, 0x00, 0x03, 0x01, 0x00, 0x0d, 0xf6, 0x2d, 0x11, 0x5e, 0x6f, 0x60, 0x17, 0x3f, 0x62, 0xeb, 0x75, 0xfe, 0x67, 0x69, 0xef, 0x59, 0x71, 0xb6, 0xb6, 0xd4, 0x2c };
static const uint8_t VEC_DCDC[] = { 0xe1, 0x02, 0x10, 0xc0, 0xa3, 0x00, 0x04, 0xff, 0xff, 0x0d, 0x66, 0xcc, 0x80, 0x55, 0xf1, 0x96, 0xe8, 0xb8, 0x15, 0x7f, 0x76, 0xd2, 0x4a, 0x5c, 0xeb, 0xf2, 0xbb, 0x6c, 0x9f, 0x58, 0x08 };
static const uint8_t VEC_ACCHARGER[] = { 0xe1, 0x02, 0x10, 0x39, 0xa3, 0x00, 0x08, 0xc8, 0x00, 0x0d, 0x10, 0xc5, 0xcd, 0xca, 0xbb, 0x29, 0x20, 0xda, 0xf8, 0x1e, 0xae, 0xf6, 0x8e, 0xce, 0xd4, 0xec, 0xb5, 0x6b, 0xa9, 0x99, 0x4a };
static const uint8_t VEC_MULTIRS[] = { 0xe1, 0x02, 0x10, 0x12, 0xa5, 0x00, 0x0b, 0x42, 0x00, 0x0d, 0xf2, 0x03, 0x6f, 0xd7, 0xe5, 0x20, 0x2a, 0x5c, 0x6e, 0x96, 0x59, 0xf8, 0x26, 0x28, 0x40, 0x2b, 0xdb, 0x7d, 0xe5, 0x4b, 0x88 };
static const uint8_t VEC_GX[] = { 0xe1, 0x02, 0x10, 0x00, 0xa1, 0x00, 0x07, 0x07, 0x00, 0x0d, 0xe3, 0x06, 0xe4, 0xb3, 0xc3, 0x81, 0x4e, 0x8a, 0xf0, 0x82, 0x6e, 0xd9, 0xf0, 0x47, 0x06, 0x3e, 0x10, 0x2e, 0xcc, 0x1f, 0x56 };
+4
View File
@@ -0,0 +1,4 @@
name: victronble
build:
cmake: .
kconfig: Kconfig