Compare commits
42
Commits
main
..
e7024d9983
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7024d9983 | ||
|
|
261cc0d1fe | ||
|
|
39a89c816c | ||
|
|
4944757903 | ||
|
|
31765c7ac8 | ||
|
|
84d153c9a8 | ||
|
|
8a4e010324 | ||
|
|
977641b093 | ||
|
|
24712c206a | ||
|
|
8a2402cb63 | ||
|
|
a64fef899b | ||
|
|
a843eb924b | ||
|
|
5a210fb88f | ||
|
|
30c93af18b | ||
|
|
d9577be900 | ||
|
|
f9e72a68fe | ||
|
|
a8d40ba260 | ||
|
|
af39db8732 | ||
|
|
26b0196791 | ||
|
|
0863f8572c | ||
|
|
d3b1c632db | ||
|
|
03d8da3b7d | ||
|
|
6a517246ea | ||
|
|
4bbab345b0 | ||
|
|
1a651b149d | ||
|
|
cec45524d3 | ||
|
|
2bd6094955 | ||
|
|
9f0f2ce8fd | ||
|
|
8e5eba47d7 | ||
|
|
95d83b492a | ||
|
|
139c6f961d | ||
|
|
2ccac7b0c8 | ||
|
|
97a71ce34c | ||
|
|
e827dea4e5 | ||
|
|
364462a4ed | ||
|
|
bc766f7cfc | ||
|
|
fcd84b92eb | ||
|
|
16e81071c8 | ||
|
|
c8fff522b1 | ||
|
|
d8128dae2e | ||
|
|
3daf61d78a | ||
|
|
d5f3d3ecbe |
@@ -0,0 +1,283 @@
|
||||
# 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
|
||||
|
||||
|
||||
### Session: 2026-02-12 18:23
|
||||
**Commits:**
|
||||
```
|
||||
a843eb9 Keep v0.3.1
|
||||
5a210fb Experimenting with a claude file and created new logging example
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- README.md
|
||||
- VERSIONS
|
||||
- library.json
|
||||
- library.properties
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-12 18:35
|
||||
**Commits:**
|
||||
```
|
||||
a64fef8 New version with smaller memory footprint etc
|
||||
a843eb9 Keep v0.3.1
|
||||
5a210fb Experimenting with a claude file and created new logging example
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-13 11:02
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-15 18:59
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- library.json
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-15 19:06
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- library.json
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-15 19:10
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- library.json
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-15 19:18
|
||||
**Commits:**
|
||||
```
|
||||
8a2402c Repeater and Test code for ESP Now
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- examples/FakeRepeater/platformio.ini
|
||||
- examples/FakeRepeater/src/main.cpp
|
||||
- examples/Receiver/platformio.ini
|
||||
- examples/Receiver/src/main.cpp
|
||||
- examples/Repeater/platformio.ini
|
||||
- examples/Repeater/src/main.cpp
|
||||
- library.json
|
||||
|
||||
|
||||
### Session: 2026-02-15 19:20
|
||||
**Commits:**
|
||||
```
|
||||
24712c2 Work on receiver and sender
|
||||
8a2402c Repeater and Test code for ESP Now
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- examples/Receiver/platformio.ini
|
||||
- examples/Receiver/src/main.cpp
|
||||
- examples/Repeater/src/main.cpp
|
||||
|
||||
|
||||
### Session: 2026-02-28 12:26
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- examples/Receiver/src/main.cpp
|
||||
- examples/Repeater/src/main.cpp
|
||||
|
||||
|
||||
### Session: 2026-02-28 14:32
|
||||
**Commits:**
|
||||
```
|
||||
4944757 Fix to be non blocking without tasks
|
||||
31765c7 Update notes
|
||||
84d153c Single callback version - vastly simplified.
|
||||
```
|
||||
**Modified files:**
|
||||
- examples/Logger/src/main.cpp
|
||||
- examples/MultiDevice/src/main.cpp
|
||||
- examples/Repeater/src/main.cpp
|
||||
- library.json
|
||||
- library.properties
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-28 14:33
|
||||
**Commits:**
|
||||
```
|
||||
4944757 Fix to be non blocking without tasks
|
||||
31765c7 Update notes
|
||||
84d153c Single callback version - vastly simplified.
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- VERSIONS
|
||||
- examples/Logger/src/main.cpp
|
||||
- examples/MultiDevice/src/main.cpp
|
||||
- examples/Repeater/src/main.cpp
|
||||
- library.json
|
||||
- library.properties
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-28 14:36
|
||||
**Commits:**
|
||||
```
|
||||
4944757 Fix to be non blocking without tasks
|
||||
31765c7 Update notes
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- VERSIONS
|
||||
- examples/Logger/src/main.cpp
|
||||
- examples/MultiDevice/src/main.cpp
|
||||
- examples/Repeater/src/main.cpp
|
||||
- library.json
|
||||
- library.properties
|
||||
- src/VictronBLE.cpp
|
||||
- src/VictronBLE.h
|
||||
|
||||
|
||||
### Session: 2026-02-28 14:40
|
||||
**Commits:**
|
||||
```
|
||||
39a89c8 Versions v0.4 ready for release
|
||||
4944757 Fix to be non blocking without tasks
|
||||
31765c7 Update notes
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- README.md
|
||||
- VERSIONS
|
||||
- library.json
|
||||
- library.properties
|
||||
|
||||
|
||||
### Session: 2026-02-28 14:48
|
||||
**Commits:**
|
||||
```
|
||||
261cc0d Improve readme ready for v0.4 release
|
||||
39a89c8 Versions v0.4 ready for release
|
||||
4944757 Fix to be non blocking without tasks
|
||||
31765c7 Update notes
|
||||
```
|
||||
**Modified files:**
|
||||
- .claude/CLAUDE.md
|
||||
- README.md
|
||||
- REVIEW.md
|
||||
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Auto-update CLAUDE.md at end of session
|
||||
|
||||
CLAUDE_MD="$(git rev-parse --show-toplevel)/.claude/CLAUDE.md"
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M')
|
||||
|
||||
# Get recent git activity from this session (last hour)
|
||||
RECENT_COMMITS=$(git log --oneline --since="1 hour ago" 2>/dev/null | head -5)
|
||||
MODIFIED_FILES=$(git diff --name-only HEAD~1 2>/dev/null | head -10)
|
||||
|
||||
# Append session summary
|
||||
{
|
||||
echo ""
|
||||
echo "### Session: $TIMESTAMP"
|
||||
|
||||
if [ -n "$RECENT_COMMITS" ]; then
|
||||
echo "**Commits:**"
|
||||
echo "\`\`\`"
|
||||
echo "$RECENT_COMMITS"
|
||||
echo "\`\`\`"
|
||||
fi
|
||||
|
||||
if [ -n "$MODIFIED_FILES" ]; then
|
||||
echo "**Modified files:**"
|
||||
echo "$MODIFIED_FILES" | sed 's/^/- /'
|
||||
fi
|
||||
|
||||
echo ""
|
||||
} >> "$CLAUDE_MD"
|
||||
|
||||
echo "Updated CLAUDE.md with session summary"
|
||||
-13
@@ -1,5 +1,3 @@
|
||||
PKA_SUMMARY.md
|
||||
|
||||
# PlatformIO
|
||||
.pio
|
||||
.pioenvs
|
||||
@@ -14,8 +12,6 @@ compile_commands.json
|
||||
!.vscode/extensions.json
|
||||
.history/
|
||||
|
||||
bugs
|
||||
|
||||
# Build artifacts
|
||||
*.o
|
||||
*.a
|
||||
@@ -72,12 +68,3 @@ venv/
|
||||
env/
|
||||
|
||||
*.tar.gz
|
||||
# Compiled host binaries
|
||||
tests/vectors/victronble_test
|
||||
examples/NativeDecode/nativedecode
|
||||
|
||||
# Zephyr build trees
|
||||
samples/*/build/
|
||||
build/
|
||||
twister-out/
|
||||
twister-out.*/
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,73 +0,0 @@
|
||||
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
|
||||
+2
-2
@@ -43,7 +43,7 @@ your-project/
|
||||
|
||||
### Step 3: Update the Example Code
|
||||
|
||||
Open `examples/MultiDevice/src/main.cpp` and update these lines with YOUR device information:
|
||||
Open `examples/MultiDevice/main.cpp` and update these lines with YOUR device information:
|
||||
|
||||
```cpp
|
||||
// Replace these with YOUR actual device details:
|
||||
@@ -81,7 +81,7 @@ pio run -t upload && pio device monitor
|
||||
```
|
||||
|
||||
#### Arduino IDE:
|
||||
1. Open `examples/MultiDevice/src/main.cpp` as an .ino file
|
||||
1. Open `examples/MultiDevice/main.cpp` as an .ino file
|
||||
2. Select your ESP32 board from Tools → Board
|
||||
3. Select your COM port from Tools → Port
|
||||
4. Click Upload
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
# VictronBLE
|
||||
|
||||
A portable library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Use it as an **Arduino** library on ESP32 and nRF52840, as a **Zephyr** module on any Bluetooth-capable board, or drop the **pure C99 core** into anything else.
|
||||
ESP32 library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements.
|
||||
|
||||
v0.7 splits the library into a **dependency-free pure C99 core** (decode + decrypt, no BLE stack, no allocation, no I/O) plus thin platform layers: the Arduino C++ wrapper, and a **Zephyr module** with its own observer API. (v0.6 added multi-platform support for ESP32 + nRF52840; v0.5 brought the decoding accuracy fixes and AC charger support.) See [VERSIONS](VERSIONS) for full details. A stable **v1.0** release with a consistent, long-term API is coming soon.
|
||||
**⚠️ API CHANGE in v0.4 — not backwards compatible with v0.3.x**
|
||||
|
||||
v0.4 is a major rework of the library internals: new callback API, reduced memory usage, non-blocking scanning. See [VERSIONS](VERSIONS) for full details. A stable **v1.0** release with a consistent, long-term API is coming soon.
|
||||
|
||||
---
|
||||
|
||||
Why another library? Most of the Victron BLE examples are built into other frameworks (e.g. ESPHome) 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.
|
||||
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.
|
||||
|
||||
Under Arduino it 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). Under **Zephyr** it is board-agnostic — anything with a Bluetooth controller and the observer role (tested on nRF52840DK and RAK4631). All decoding and decryption is shared; only the BLE scanning layer is platform-specific, so other stacks can be added by implementing one more backend.
|
||||
Currently supporting ESP32 S and C series (tested on older ESP32, ESP32-S3 and ESP32-C3). Other chipsets can be added with abstraction of Bluetooth code.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Multi-Platform**: ESP32 and nRF52840 under Arduino, any Bluetooth board under Zephyr
|
||||
- ✅ **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, AC chargers
|
||||
- ✅ **Framework Friendly**: Arduino library, Zephyr module, or the bare C core
|
||||
- ✅ **All Device Types**: Solar chargers, battery monitors, inverters, DC-DC converters
|
||||
- ✅ **Framework Agnostic**: Works with Arduino and ESP-IDF
|
||||
- ✅ **Clean API**: Simple, intuitive interface with callback support
|
||||
- ✅ **No Pairing Required**: Reads BLE advertisement data directly
|
||||
- ✅ **Low Power**: Uses passive BLE scanning
|
||||
@@ -29,138 +29,35 @@ Under Arduino it supports **ESP32** (original, S and C series — tested on olde
|
||||
- **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
|
||||
|
||||
- An ESP32 (original / S / C series) **or** an nRF52840 board (Adafruit/Seeed
|
||||
Bluefruit core — e.g. Seeed XIAO nRF52840) for the Arduino API
|
||||
- Or any Zephyr-supported board with a Bluetooth controller (tested on
|
||||
nRF52840DK and RAK4631)
|
||||
- ESP32, ESP32-S3, or ESP32-C3 board
|
||||
- Victron devices with BLE "Instant Readout" enabled
|
||||
|
||||
Under Arduino 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` (recommended — installs from the PlatformIO registry):
|
||||
1. Add to `platformio.ini`:
|
||||
```ini
|
||||
lib_deps =
|
||||
scottp/victronble
|
||||
```
|
||||
|
||||
Or install directly from git. Note the **`.git` suffix is required** — the bare
|
||||
repository URL is not accepted by PlatformIO:
|
||||
```ini
|
||||
lib_deps =
|
||||
https://gitea.sh3d.com.au/Sh3d/VictronBLE.git
|
||||
lib_deps =
|
||||
https://gitea.sh3d.com.au/Sh3d/VictronBLE
|
||||
```
|
||||
|
||||
2. Or clone into your project's `lib` folder:
|
||||
```bash
|
||||
cd lib
|
||||
git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE.git
|
||||
git clone https://gitea.sh3d.com.au/Sh3d/VictronBLE
|
||||
```
|
||||
|
||||
#### 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
|
||||
2. Move the `VictronBLE` folder to your Arduino libraries directory
|
||||
3. Restart Arduino IDE
|
||||
|
||||
### Zephyr
|
||||
|
||||
The repository is a Zephyr module (`zephyr/module.yml`), so Zephyr finds it
|
||||
automatically once it is in your workspace. Add it to your `west.yml`:
|
||||
|
||||
```yaml
|
||||
manifest:
|
||||
remotes:
|
||||
- name: sh3d
|
||||
url-base: https://gitea.sh3d.com.au/Sh3d
|
||||
projects:
|
||||
- name: VictronBLE
|
||||
remote: sh3d
|
||||
revision: main
|
||||
path: modules/lib/victronble
|
||||
```
|
||||
|
||||
Then `west update`, and enable it in your `prj.conf`:
|
||||
|
||||
```
|
||||
CONFIG_BT=y
|
||||
CONFIG_BT_OBSERVER=y
|
||||
CONFIG_VICTRONBLE=y
|
||||
CONFIG_CBPRINTF_FP_SUPPORT=y # only if you print the float fields
|
||||
```
|
||||
|
||||
`CONFIG_VICTRONBLE` depends on `CONFIG_BT_OBSERVER`, and the application must
|
||||
call `bt_enable()` before `victronble_start()` — the library scans, it does not
|
||||
own the Bluetooth stack.
|
||||
|
||||
To build against a local checkout that is not in the manifest, point Zephyr at
|
||||
it directly instead:
|
||||
|
||||
```sh
|
||||
west build -b nrf52840dk/nrf52840 -d /tmp/build /path/to/app \
|
||||
-- -DZEPHYR_EXTRA_MODULES=/path/to/VictronBLE
|
||||
```
|
||||
|
||||
#### Zephyr API
|
||||
|
||||
```c
|
||||
#include "victronble_zephyr.h"
|
||||
|
||||
int victronble_cb_register(struct victronble_cb *cb);
|
||||
int victronble_device_add(const bt_addr_le_t *addr, const uint8_t key[16]);
|
||||
int victronble_device_remove(const bt_addr_le_t *addr);
|
||||
void victronble_watch_set(bool on); /* log every advert, no keys needed */
|
||||
int victronble_start(void);
|
||||
int victronble_stop(void);
|
||||
void victronble_get_stats(struct victronble_stats *out);
|
||||
```
|
||||
|
||||
Records are decoded on a dedicated thread, not the Bluetooth RX thread, so
|
||||
your `record` callback can log freely without stalling the controller.
|
||||
|
||||
#### Kconfig options
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `VICTRONBLE_MAX_DEVICES` | 4 | Size of the monitored-device registry |
|
||||
| `VICTRONBLE_QUEUE_DEPTH` | 8 | Adverts buffered between the RX and decode threads |
|
||||
| `VICTRONBLE_THREAD_STACK_SIZE` | 2048 | Decode thread stack |
|
||||
| `VICTRONBLE_THREAD_PRIORITY` | 10 | Decode thread priority (preemptible) |
|
||||
| `VICTRONBLE_DEDUP` | y | Suppress repeated adverts by nonce |
|
||||
| `VICTRONBLE_SCAN_INTERVAL` | 2048 | Scan interval, 0.625 ms units (1.28 s) |
|
||||
| `VICTRONBLE_SCAN_WINDOW` | 18 | Scan window, 0.625 ms units (11.25 ms) |
|
||||
| `VICTRONBLE_LOG_LEVEL` | — | Standard Zephyr per-module log level |
|
||||
|
||||
Working applications are in [`samples/`](samples/) — start with
|
||||
[`samples/scan`](samples/scan/) to discover your devices, then
|
||||
[`samples/observer`](samples/observer/) to read them.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Get Your Encryption Keys
|
||||
@@ -365,23 +262,6 @@ struct VictronDCDCData {
|
||||
};
|
||||
```
|
||||
|
||||
#### VictronACChargerData
|
||||
|
||||
```cpp
|
||||
struct VictronACChargerData {
|
||||
uint8_t chargeState; // SolarChargerState enum (shared charger states)
|
||||
uint8_t errorCode;
|
||||
float voltage1; // V (output 1)
|
||||
float current1; // A (output 1)
|
||||
float voltage2; // V (output 2, 0 if absent)
|
||||
float current2; // A (output 2, 0 if absent)
|
||||
float voltage3; // V (output 3, 0 if absent)
|
||||
float current3; // A (output 3, 0 if absent)
|
||||
float temperature; // C (0 if not available)
|
||||
float acCurrent; // A (0 if not available)
|
||||
};
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Multiple Devices
|
||||
@@ -419,11 +299,6 @@ void onVictronData(const VictronDevice* dev) {
|
||||
Serial.printf("%s: %.2fV -> %.2fV\n", dev->name,
|
||||
dev->dcdc.inputVoltage, dev->dcdc.outputVoltage);
|
||||
break;
|
||||
case DEVICE_TYPE_AC_CHARGER:
|
||||
Serial.printf("%s: %.2fV %.2fA %.0fC\n", dev->name,
|
||||
dev->acCharger.voltage1, dev->acCharger.current1,
|
||||
dev->acCharger.temperature);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -453,12 +328,6 @@ void setup() {
|
||||
5. **Disconnect VictronConnect**: App must be disconnected from device
|
||||
6. **Enable debug**: `victron.setDebug(true);` to see detailed logs
|
||||
|
||||
On **Zephyr**, the stats line from `victronble_get_stats()` narrows this down
|
||||
fast. If `adverts` climbs but `queued` and `decoded` stay at zero, the device
|
||||
is being heard but never matched: check the Bluetooth address **type**, which
|
||||
must be `random` for Victron devices, not `public`. Or run `samples/scan`,
|
||||
which needs neither addresses nor keys.
|
||||
|
||||
### Decryption Failures
|
||||
|
||||
- Encryption key must match exactly
|
||||
@@ -482,81 +351,21 @@ 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:
|
||||
|
||||
```
|
||||
include/
|
||||
├── victronble.h Pure C99 core API — decode one advert, no I/O
|
||||
└── victronble_zephyr.h Zephyr observer API
|
||||
src/
|
||||
├── victronble_core.c Decrypt + parse; no BLE, no alloc, reentrant
|
||||
├── crypto/vble_aes.{h,c} Bundled AES-128-CTR (no external dependency)
|
||||
├── VictronBLE.{h,cpp} Arduino C++ wrapper over the core
|
||||
├── esp32/ ESP32 backend — Bluedroid BLEScan
|
||||
├── nrf52/ nRF52 backend — Bluefruit passive scan
|
||||
└── victronble_zephyr.c Zephyr backend — passive scan + decode thread
|
||||
CMakeLists.txt, Kconfig Zephyr module glue (ignored by PlatformIO)
|
||||
```
|
||||
|
||||
- **A portable core.** `victronble_core.c` is C99 with no dependencies: no
|
||||
Arduino, no BLE stack, no allocation, no I/O, reentrant. Give it a
|
||||
manufacturer-data blob and a key, get a record back. Everything else —
|
||||
scanning, device registries, rate limiting, logging — belongs to the
|
||||
platform layers. `examples/NativeDecode` runs it on a PC.
|
||||
- **One BLE HAL.** Each backend extracts the manufacturer data, MAC and RSSI
|
||||
from a scan result and hands it to the core. Under Arduino 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. Under Zephyr the backend is
|
||||
`victronble_zephyr.c`, selected by `CONFIG_VICTRONBLE`.
|
||||
- **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 →
|
||||
hand to the core); the rest is reused unchanged.
|
||||
|
||||
> Under Arduino 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()`.
|
||||
> Under Zephyr this does not apply: records are delivered from the library's
|
||||
> own decode thread, so callbacks may log and block.
|
||||
|
||||
## Examples
|
||||
|
||||
Arduino / PlatformIO, in [`examples/`](examples/):
|
||||
See the `examples/` directory for:
|
||||
|
||||
- **MultiDevice**: Monitor multiple devices with callbacks. One sketch, multiple
|
||||
PlatformIO environments — builds for ESP32 (`esp32dev`, …) and nRF52840
|
||||
(`xiao_nrf52840`, `adafruit_feather_nrf52840`).
|
||||
- **MultiDevice**: Monitor multiple devices with callbacks
|
||||
- **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
|
||||
|
||||
Zephyr, in [`samples/`](samples/):
|
||||
|
||||
- **scan**: List every Victron device advertising nearby. No keys needed —
|
||||
run this first to find your MAC addresses.
|
||||
- **observer**: Monitor known devices and log every decoded field. The
|
||||
reference for the Zephyr API.
|
||||
|
||||
No hardware at all, in [`examples/`](examples/):
|
||||
|
||||
- **NativeDecode**: Decode an advertisement on your PC with plain `make`.
|
||||
Good for checking a key or a sniffer capture before you flash anything.
|
||||
|
||||
## 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 [GitHub mirror](https://github.com/SH3D/VictronBLE)
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Test thoroughly on real hardware
|
||||
4. Submit a pull request
|
||||
@@ -588,10 +397,7 @@ See [VERSIONS](VERSIONS) file for detailed changelog and release history.
|
||||
|
||||
## Support
|
||||
|
||||
- 📫 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.
|
||||
- 📫 Report issues on GitHub
|
||||
- 📖 Check the examples directory
|
||||
- 🔧 Enable debug mode for diagnostics
|
||||
- 📚 See [Victron documentation](https://www.victronenergy.com/live/)
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Upgrading to VictronBLE v0.4
|
||||
|
||||
v0.4 is a breaking API change that simplifies the library significantly.
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
- **Callback**: Virtual class → function pointer
|
||||
- **Data access**: Inheritance → tagged union (`VictronDevice` with `solar`, `battery`, `inverter`, `dcdc` members)
|
||||
- **Strings**: Arduino `String` → fixed `char[]` arrays
|
||||
- **Memory**: `std::map` + heap allocation → fixed array, zero dynamic allocation
|
||||
- **Removed**: `getLastError()`, `removeDevice()`, `getDevicesByType()`, per-type getter methods, `VictronDeviceConfig` struct, `VictronDeviceCallback` class
|
||||
- **Removed field**: `panelVoltage` (was unreliably derived from `panelPower / batteryCurrent`)
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### 1. Callback: class → function pointer
|
||||
|
||||
**Before (v0.3):**
|
||||
```cpp
|
||||
class MyCallback : public VictronDeviceCallback {
|
||||
void onSolarChargerData(const SolarChargerData& data) override {
|
||||
Serial.println(data.deviceName + ": " + String(data.panelPower) + "W");
|
||||
}
|
||||
void onBatteryMonitorData(const BatteryMonitorData& data) override {
|
||||
Serial.println("SOC: " + String(data.soc) + "%");
|
||||
}
|
||||
};
|
||||
MyCallback callback;
|
||||
victron.setCallback(&callback);
|
||||
```
|
||||
|
||||
**After (v0.4):**
|
||||
```cpp
|
||||
void onVictronData(const VictronDevice* dev) {
|
||||
switch (dev->deviceType) {
|
||||
case DEVICE_TYPE_SOLAR_CHARGER:
|
||||
Serial.printf("%s: %.0fW\n", dev->name, dev->solar.panelPower);
|
||||
break;
|
||||
case DEVICE_TYPE_BATTERY_MONITOR:
|
||||
Serial.printf("SOC: %.1f%%\n", dev->battery.soc);
|
||||
break;
|
||||
}
|
||||
}
|
||||
victron.setCallback(onVictronData);
|
||||
```
|
||||
|
||||
### 2. Data field access
|
||||
|
||||
Fields moved from flat `SolarChargerData` etc. into the `VictronDevice` tagged union:
|
||||
|
||||
| Old (v0.3) | New (v0.4) |
|
||||
|---|---|
|
||||
| `data.deviceName` | `dev->name` (char[32]) |
|
||||
| `data.macAddress` | `dev->mac` (char[13]) |
|
||||
| `data.rssi` | `dev->rssi` |
|
||||
| `data.lastUpdate` | `dev->lastUpdate` |
|
||||
| `data.batteryVoltage` | `dev->solar.batteryVoltage` |
|
||||
| `data.batteryCurrent` | `dev->solar.batteryCurrent` |
|
||||
| `data.panelPower` | `dev->solar.panelPower` |
|
||||
| `data.yieldToday` | `dev->solar.yieldToday` |
|
||||
| `data.loadCurrent` | `dev->solar.loadCurrent` |
|
||||
| `data.chargeState` | `dev->solar.chargeState` (uint8_t, was enum) |
|
||||
| `data.panelVoltage` | **Removed** - see below |
|
||||
|
||||
### 3. panelVoltage removed
|
||||
|
||||
`panelVoltage` was a derived value (`panelPower / batteryCurrent`) that was unreliable (division by zero when no current, inaccurate due to MPPT conversion). It has been removed.
|
||||
|
||||
If you need an estimate:
|
||||
```cpp
|
||||
float panelVoltage = (dev->solar.batteryCurrent > 0.1f)
|
||||
? dev->solar.panelPower / dev->solar.batteryCurrent
|
||||
: 0.0f;
|
||||
```
|
||||
|
||||
### 4. getLastError() removed
|
||||
|
||||
Debug output now goes directly to Serial when `setDebug(true)` is enabled. Remove any `getLastError()` calls.
|
||||
|
||||
**Before:**
|
||||
```cpp
|
||||
if (!victron.begin(2)) {
|
||||
Serial.println(victron.getLastError());
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```cpp
|
||||
if (!victron.begin(2)) {
|
||||
Serial.println("Failed to initialize VictronBLE!");
|
||||
}
|
||||
```
|
||||
|
||||
### 5. String types
|
||||
|
||||
Device name and MAC are now `char[]` instead of Arduino `String`. Use `Serial.printf()` or `String(dev->name)` if you need a String object.
|
||||
|
||||
### 6. addDevice() parameters
|
||||
|
||||
Parameters changed from `String` to `const char*`. Existing string literals work unchanged. `VictronDeviceConfig` struct is no longer needed.
|
||||
|
||||
```cpp
|
||||
// Both v0.3 and v0.4 - string literals work the same
|
||||
victron.addDevice("MySolar", "f69dfcce55eb",
|
||||
"bf25c098c156afd6a180157b8a3ab1fb", DEVICE_TYPE_SOLAR_CHARGER);
|
||||
```
|
||||
@@ -1,124 +1,5 @@
|
||||
# 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.
|
||||
|
||||
- Watch mode (`victronble_watch_set(true)`): logs every Victron product
|
||||
advert heard, registered or not — MAC, RSSI, record type, length and
|
||||
key-check byte. Reads only the plaintext header, so it needs no keys.
|
||||
Discovery and key debugging; `samples/scan` is built around it.
|
||||
|
||||
### Samples and examples
|
||||
- `samples/observer/` — Zephyr reference app: known devices from a table of
|
||||
MAC + key, full field decode for every device type, and a 30-second stats
|
||||
line for diagnosing a quiet console. Builds for `nrf52840dk/nrf52840` and
|
||||
`rak4631/nrf52840`.
|
||||
- `samples/scan/` — Zephyr discovery app: watch mode only, no keys or
|
||||
addresses needed. Run it first to find what you have.
|
||||
- Both carry a `sample.yaml`, so `west twister -T samples` build-tests them.
|
||||
- `examples/NativeDecode/` — decode an advertisement on a PC with plain
|
||||
`make`. No board, no BLE stack; exercises the pure C core directly, which
|
||||
makes it useful for checking a key or a sniffer capture.
|
||||
|
||||
### Fixed
|
||||
- `library.properties` URL now points at the real repo (gitea) instead of a
|
||||
nonexistent GitHub mirror.
|
||||
- `library.json` no longer claims the `espidf` framework. There is no ESP-IDF
|
||||
BLE backend — `src/esp32/` is Arduino/Bluedroid only — so the claim was
|
||||
never true. The pure C core works fine under ESP-IDF; scanning is the
|
||||
missing piece, and a native backend is future work.
|
||||
- `QUICK_START.md` pointed at `examples/MultiDevice/main.cpp`; the file is at
|
||||
`examples/MultiDevice/src/main.cpp`.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,567 +0,0 @@
|
||||
# victronble → pure C core + Zephyr module
|
||||
|
||||
Staged porting plan. Five stages, each independently shippable. Stages 0–2 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(¶m, 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 1–2. 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 # not implemented — Kconfig ships software only
|
||||
│ ├── victronble_zephyr.c # scan + workqueue + device registry
|
||||
│ ├── VictronBLE.cpp # Arduino wrapper
|
||||
│ └── ble_backend_*.cpp # NimBLE / Bluefruit
|
||||
├── samples/
|
||||
│ ├── observer/ # Zephyr sample app — known devices, full records
|
||||
│ └── scan/ # Zephyr sample app — watch mode discovery
|
||||
└── 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. **Done.** `samples/observer/` and `samples/scan/` build for
|
||||
`nrf52840dk/nrf52840` and `rak4631/nrf52840` (verified against Zephyr
|
||||
v4.4.0), each with a `sample.yaml` so twister can build-test them:
|
||||
`west twister -T samples -p nrf52840dk/nrf52840 -p rak4631/nrf52840`.
|
||||
`examples/NativeDecode/` covers the no-hardware case with plain `make`.
|
||||
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. **Done.** README has a Zephyr section with the west manifest snippet, the
|
||||
`ZEPHYR_EXTRA_MODULES` alternative for local development, the API summary
|
||||
and the Kconfig table — 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 | 1–2 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 | 1–2 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.
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,40 +1,6 @@
|
||||
[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
|
||||
|
||||
@@ -3,14 +3,10 @@
|
||||
*
|
||||
* Demonstrates reading data from multiple Victron device types via BLE.
|
||||
*
|
||||
* 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
|
||||
* 1. Get your device encryption keys from VictronConnect app
|
||||
* (Settings > Product Info > Instant readout via Bluetooth > Show)
|
||||
* 2. Update the device configurations below with your MAC and key.
|
||||
* 2. Update the device configurations below with your MAC and key
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
@@ -22,7 +18,6 @@ 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;
|
||||
|
||||
static const char* chargeStateName(uint8_t state) {
|
||||
switch (state) {
|
||||
@@ -125,21 +120,6 @@ void onVictronData(const VictronDevice* dev) {
|
||||
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;
|
||||
}
|
||||
@@ -147,9 +127,7 @@ void onVictronData(const VictronDevice* dev) {
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
// 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);
|
||||
delay(1000);
|
||||
|
||||
Serial.println("\n\n=================================");
|
||||
Serial.println("VictronBLE Multi-Device Example");
|
||||
@@ -160,10 +138,9 @@ void setup() {
|
||||
while (1) delay(1000);
|
||||
}
|
||||
|
||||
victron.setDebug(true);
|
||||
victron.setDebug(false);
|
||||
victron.setCallback(onVictronData);
|
||||
|
||||
// Replace with your own devices (MAC + 32-char hex key from VictronConnect)
|
||||
victron.addDevice(
|
||||
"Rainbow48V",
|
||||
"E4:05:42:34:14:F3",
|
||||
@@ -186,7 +163,7 @@ static uint32_t loopCount = 0;
|
||||
static uint32_t lastReport = 0;
|
||||
|
||||
void loop() {
|
||||
victron.loop(); // Non-blocking on both backends
|
||||
victron.loop(); // Non-blocking: returns immediately if scan is running
|
||||
loopCount++;
|
||||
|
||||
uint32_t now = millis();
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Build the native (host) VictronBLE example.
|
||||
#
|
||||
# make build ./nativedecode
|
||||
# make run build and decode the built-in sample advert
|
||||
# make clean
|
||||
#
|
||||
# No board, no PlatformIO, no toolchain beyond a C compiler — the library core
|
||||
# is plain C99. Mirrors the build line in tests/vectors/run.sh.
|
||||
|
||||
ROOT := ../..
|
||||
CC ?= cc
|
||||
CFLAGS ?= -std=c99 -Wall -Wextra -O2
|
||||
LDLIBS := -lm
|
||||
|
||||
SRCS := \
|
||||
$(ROOT)/src/victronble_core.c \
|
||||
$(ROOT)/src/victronble_aes_sw.c \
|
||||
$(ROOT)/src/crypto/vble_aes.c \
|
||||
main.c
|
||||
|
||||
nativedecode: $(SRCS)
|
||||
$(CC) $(CFLAGS) -I$(ROOT)/include -I$(ROOT)/src $(SRCS) $(LDLIBS) -o $@
|
||||
|
||||
run: nativedecode
|
||||
./nativedecode
|
||||
|
||||
clean:
|
||||
rm -f nativedecode
|
||||
|
||||
.PHONY: run clean
|
||||
@@ -1,84 +0,0 @@
|
||||
# NativeDecode — decode an advertisement on your PC
|
||||
|
||||
Decodes one Victron "Instant Readout" advertisement on the host. No board, no
|
||||
BLE stack, no PlatformIO — just a C compiler. The library's core
|
||||
(`src/victronble_core.c`) is plain C99 with no dependencies, so the same
|
||||
decoder that runs on an ESP32, an nRF52 or under Zephyr also runs here.
|
||||
|
||||
Handy for:
|
||||
|
||||
- checking an advertisement key before you flash anything
|
||||
- decoding a capture from nRF Connect, `btmon` or a sniffer
|
||||
- seeing the record layout and which fields your device actually sends
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
make
|
||||
./nativedecode
|
||||
```
|
||||
|
||||
With no arguments it decodes a built-in sample advert (a SmartSolar MPPT in
|
||||
bulk charge, taken from `tests/vectors/`):
|
||||
|
||||
```
|
||||
advert 31 bytes
|
||||
device type solar charger (0x01)
|
||||
model id 0xa060
|
||||
nonce 4660 (0x1234)
|
||||
fields:
|
||||
state bulk (error 0)
|
||||
battery 13.24 V
|
||||
current 5.4 A
|
||||
pv power 340 W
|
||||
yield today 1200 Wh
|
||||
load current n/a
|
||||
```
|
||||
|
||||
## Your own capture
|
||||
|
||||
```sh
|
||||
./nativedecode <advert-hex> <key-hex>
|
||||
```
|
||||
|
||||
`advert-hex` is the manufacturer-specific data **starting at the company ID**
|
||||
(`e1 02 ...`), exactly as a sniffer reports it. Separators are ignored, so
|
||||
`e1:02:10…` and `e10210…` both work. `key-hex` is the 32-character
|
||||
advertisement key from VictronConnect → device → gear icon → Product info →
|
||||
*Instant readout via Bluetooth*.
|
||||
|
||||
```sh
|
||||
./nativedecode "e1021089a30002efbe0d4108532f0d44a51c62a051e97c1fae2fe9e82a0e15" \
|
||||
0df4d0395b7d5d4f5a0d0af52e1b4c1e
|
||||
```
|
||||
|
||||
## Reading the output
|
||||
|
||||
`n/a` means the device did not send that field — the core returns `NAN` for
|
||||
absent floats and `0xFFFF` for an unavailable time-to-go, and this example
|
||||
renders both as `n/a`. An MPPT with no load output always shows
|
||||
`load current n/a`; that is not a fault.
|
||||
|
||||
Three failure messages are worth telling apart:
|
||||
|
||||
| Message | Meaning |
|
||||
|---|---|
|
||||
| `not a Victron product advertisement` | wrong company ID, or not a product record — you captured something else |
|
||||
| `key check failed` | the advert is Victron's, but this key belongs to a different device |
|
||||
| `decode failed: unsupported type` | a real Victron record the library has no decoder for yet (e.g. GX devices) |
|
||||
|
||||
## Which API this shows
|
||||
|
||||
```c
|
||||
victronble_parse_key() /* 32 hex chars -> 16 bytes */
|
||||
victronble_is_product_adv() /* cheap pre-filter, no crypto */
|
||||
victronble_key_matches() /* key-check byte, still no crypto */
|
||||
victronble_decode() /* decrypt + parse into a record */
|
||||
victronble_strerror()
|
||||
victronble_device_type_str()
|
||||
victronble_state_str()
|
||||
```
|
||||
|
||||
That is the whole portable core. See `include/victronble.h` for the record
|
||||
structures, and `samples/observer/` for the same printing logic driven by a
|
||||
live BLE scan under Zephyr.
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* VictronBLE native (host) example.
|
||||
*
|
||||
* Decodes a Victron "Instant Readout" advertisement on your PC — no board, no
|
||||
* BLE stack, no toolchain beyond a C compiler. The library's core is plain
|
||||
* C99, so the same code that runs on an ESP32, an nRF52 or under Zephyr also
|
||||
* runs here.
|
||||
*
|
||||
* Useful for checking a key, understanding the record layout, or debugging a
|
||||
* capture from a BLE sniffer before you flash anything.
|
||||
*
|
||||
* make && ./nativedecode # built-in sample advert
|
||||
* ./nativedecode <advert-hex> <key-hex> # your own capture
|
||||
*
|
||||
* The advert hex is the manufacturer-specific data starting at the company ID
|
||||
* (e1 02 ...), exactly as a sniffer or nRF Connect reports it. Separators are
|
||||
* ignored, so "e1:02:10" and "e10210" both work.
|
||||
*
|
||||
* Copyright (c) 2026 Scott Penrose
|
||||
* License: MIT
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "victronble.h"
|
||||
|
||||
/*
|
||||
* Sample advertisement from the library's own test vectors
|
||||
* (tests/vectors/test_vectors.h): a SmartSolar MPPT in bulk charge.
|
||||
*/
|
||||
static const char DEFAULT_ADVERT[] =
|
||||
"e1021060a000013412 0d53b0254c65d34a923470 3a6c19737e65f6a442d056";
|
||||
static const char DEFAULT_KEY[] = "0df4d0395b7d5d4f5a0d0af52e1b4c1e";
|
||||
|
||||
/* --- input parsing ----------------------------------------------------- */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Parse a hex string into bytes, skipping any separator (space, colon, dash).
|
||||
* Returns the byte count, or -1 on a stray character or an odd digit count.
|
||||
* (Keys are 32 hex characters exactly, so those use the library's own
|
||||
* victronble_parse_key() instead of this.)
|
||||
*/
|
||||
static int parse_hex(const char *s, uint8_t *out, size_t max)
|
||||
{
|
||||
size_t n = 0;
|
||||
int hi = -1;
|
||||
|
||||
for (; *s != '\0'; s++) {
|
||||
if (*s == ' ' || *s == ':' || *s == '-' || *s == '\t') {
|
||||
continue;
|
||||
}
|
||||
|
||||
int v = hex_nibble(*s);
|
||||
|
||||
if (v < 0) {
|
||||
fprintf(stderr, "bad hex character '%c'\n", *s);
|
||||
return -1;
|
||||
}
|
||||
if (hi < 0) {
|
||||
hi = v;
|
||||
continue;
|
||||
}
|
||||
if (n >= max) {
|
||||
fprintf(stderr, "advert too long (max %zu bytes)\n",
|
||||
max);
|
||||
return -1;
|
||||
}
|
||||
out[n++] = (uint8_t)((hi << 4) | v);
|
||||
hi = -1;
|
||||
}
|
||||
|
||||
if (hi >= 0) {
|
||||
fprintf(stderr, "hex string has an odd number of digits\n");
|
||||
return -1;
|
||||
}
|
||||
return (int)n;
|
||||
}
|
||||
|
||||
/* --- record printing --------------------------------------------------- */
|
||||
|
||||
/* Fields the device did not send come back as NAN — report them as "n/a"
|
||||
* rather than printing "nan", which reads like a fault. */
|
||||
static void pf(const char *label, float v, const char *unit, int dp)
|
||||
{
|
||||
if (isnan(v)) {
|
||||
printf(" %-16s n/a\n", label);
|
||||
} else {
|
||||
printf(" %-16s %.*f %s\n", label, dp, (double)v, unit);
|
||||
}
|
||||
}
|
||||
|
||||
static void print_solar(const victronble_solar_charger_t *s)
|
||||
{
|
||||
printf(" %-16s %s (error %u)\n", "state",
|
||||
victronble_state_str(s->state), s->error);
|
||||
pf("battery", s->battery_voltage, "V", 2);
|
||||
pf("current", s->battery_current, "A", 1);
|
||||
pf("pv power", s->pv_power, "W", 0);
|
||||
printf(" %-16s %u Wh\n", "yield today", s->yield_today_wh);
|
||||
pf("load current", s->load_current, "A", 1);
|
||||
}
|
||||
|
||||
static void print_batmon(const victronble_battery_monitor_t *m)
|
||||
{
|
||||
static const char *const aux_mode[] = { "aux voltage", "midpoint",
|
||||
"temperature", "none" };
|
||||
|
||||
pf("battery", m->voltage, "V", 2);
|
||||
pf("current", m->current, "A", 2);
|
||||
pf("soc", m->soc, "%", 1);
|
||||
pf("consumed", m->consumed_ah, "Ah", 1);
|
||||
|
||||
/* 0xFFFF is the wire's "not available", not 45 days of runtime. */
|
||||
if (m->remaining_minutes == 0xFFFF) {
|
||||
printf(" %-16s n/a\n", "time to go");
|
||||
} else {
|
||||
printf(" %-16s %u min\n", "time to go", m->remaining_minutes);
|
||||
}
|
||||
|
||||
printf(" %-16s %s\n", "aux mode",
|
||||
m->aux_mode < 4 ? aux_mode[m->aux_mode] : "?");
|
||||
pf("aux voltage", m->aux_voltage, "V", 2);
|
||||
pf("temperature", m->temperature, "degC", 1);
|
||||
printf(" %-16s 0x%04x\n", "alarm", m->alarm);
|
||||
}
|
||||
|
||||
static void print_record(const victronble_record_t *rec)
|
||||
{
|
||||
printf("device type %s (0x%02x)\n",
|
||||
victronble_device_type_str(rec->type), rec->record_type);
|
||||
printf("model id 0x%04x\n", rec->model_id);
|
||||
printf("nonce %u (0x%04x)\n", rec->nonce, rec->nonce);
|
||||
printf("fields:\n");
|
||||
|
||||
switch (rec->type) {
|
||||
case VICTRONBLE_DEV_SOLAR_CHARGER:
|
||||
print_solar(&rec->u.solar);
|
||||
break;
|
||||
case VICTRONBLE_DEV_BATTERY_MONITOR:
|
||||
print_batmon(&rec->u.batmon);
|
||||
break;
|
||||
case VICTRONBLE_DEV_INVERTER:
|
||||
printf(" %-16s %s\n", "state",
|
||||
victronble_state_str(rec->u.inverter.state));
|
||||
pf("battery", rec->u.inverter.battery_voltage, "V", 2);
|
||||
pf("current", rec->u.inverter.battery_current, "A", 2);
|
||||
pf("ac power", rec->u.inverter.ac_power, "W", 0);
|
||||
printf(" %-16s 0x%02x\n", "alarms", rec->u.inverter.alarms);
|
||||
break;
|
||||
case VICTRONBLE_DEV_DCDC_CONVERTER:
|
||||
printf(" %-16s %s (error %u)\n", "state",
|
||||
victronble_state_str(rec->u.dcdc.state),
|
||||
rec->u.dcdc.error);
|
||||
pf("input", rec->u.dcdc.input_voltage, "V", 2);
|
||||
pf("output", rec->u.dcdc.output_voltage, "V", 2);
|
||||
pf("output current", rec->u.dcdc.output_current, "A", 1);
|
||||
break;
|
||||
case VICTRONBLE_DEV_AC_CHARGER:
|
||||
printf(" %-16s %s (error %u)\n", "state",
|
||||
victronble_state_str(rec->u.ac.state), rec->u.ac.error);
|
||||
pf("output 1", rec->u.ac.voltage1, "V", 2);
|
||||
pf("current 1", rec->u.ac.current1, "A", 1);
|
||||
pf("output 2", rec->u.ac.voltage2, "V", 2);
|
||||
pf("current 2", rec->u.ac.current2, "A", 1);
|
||||
pf("output 3", rec->u.ac.voltage3, "V", 2);
|
||||
pf("current 3", rec->u.ac.current3, "A", 1);
|
||||
pf("ac current", rec->u.ac.ac_current, "A", 1);
|
||||
pf("temperature", rec->u.ac.temperature, "degC", 1);
|
||||
break;
|
||||
default:
|
||||
printf(" (decoded, but this build has no field printer for "
|
||||
"that device type)\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- main -------------------------------------------------------------- */
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
const char *advert_hex = argc > 1 ? argv[1] : DEFAULT_ADVERT;
|
||||
const char *key_hex = argc > 2 ? argv[2] : DEFAULT_KEY;
|
||||
uint8_t advert[VICTRONBLE_MIN_MFG_LEN + VICTRONBLE_MAX_CIPHER_LEN];
|
||||
uint8_t key[VICTRONBLE_KEY_LEN];
|
||||
victronble_record_t rec;
|
||||
victronble_err_t err;
|
||||
int len;
|
||||
|
||||
if (argc > 3 || (argc == 2 && strcmp(argv[1], "-h") == 0)) {
|
||||
fprintf(stderr, "usage: %s [advert-hex [key-hex]]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
if (argc == 1) {
|
||||
printf("(no arguments — decoding the built-in sample advert)\n\n");
|
||||
}
|
||||
|
||||
len = parse_hex(advert_hex, advert, sizeof(advert));
|
||||
if (len < 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!victronble_parse_key(key_hex, key)) {
|
||||
fprintf(stderr, "key must be exactly 32 hex characters\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("advert %d bytes\n", len);
|
||||
|
||||
/* Cheap pre-filter: company ID and record type only, no crypto. On a
|
||||
* real scanner this is what keeps every other BLE beacon out of the
|
||||
* decode path. */
|
||||
if (!victronble_is_product_adv(advert, (size_t)len)) {
|
||||
printf("not a Victron product advertisement\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Key-check byte. Lets a scanner pick the right key out of several
|
||||
* without doing the AES work — and tells you a wrong key apart from a
|
||||
* corrupt payload. */
|
||||
if (!victronble_key_matches(advert, (size_t)len, key)) {
|
||||
printf("key check failed — this key is not for this device\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
err = victronble_decode(advert, (size_t)len, key, &rec);
|
||||
if (err != VICTRONBLE_OK) {
|
||||
printf("decode failed: %s (%d)\n", victronble_strerror(err),
|
||||
err);
|
||||
return 1;
|
||||
}
|
||||
|
||||
print_record(&rec);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* 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 */
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/** Watch mode: when on, every Victron product advert heard — registered or
|
||||
* not — is logged (MAC, RSSI, record type, key-check byte). Discovery and
|
||||
* key debugging; unregistered devices are not nonce-deduped, so expect a
|
||||
* line or two per device per second. */
|
||||
void victronble_watch_set(bool on);
|
||||
|
||||
/** 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 */
|
||||
+5
-13
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "victronble",
|
||||
"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. Also ships a Zephyr module (CONFIG_VICTRONBLE) and a dependency-free pure C99 core usable on any platform.",
|
||||
"keywords": "victron, ble, zephyr, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, esp32-s3, esp32-c3, nrf52, nrf52840, nrf52832, xiao, iot, energy, monitoring",
|
||||
"version": "0.4.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",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.sh3d.com.au/Sh3d/VictronBLE.git"
|
||||
@@ -17,8 +17,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"homepage": "https://gitea.sh3d.com.au/Sh3d/VictronBLE",
|
||||
"frameworks": ["arduino"],
|
||||
"platforms": ["espressif32", "nordicnrf52"],
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"platforms": ["espressif32"],
|
||||
"headers": ["VictronBLE.h"],
|
||||
"dependencies": [],
|
||||
"examples": [
|
||||
@@ -46,19 +46,11 @@
|
||||
"name": "FakeRepeater",
|
||||
"base": "examples/FakeRepeater",
|
||||
"files": ["src/main.cpp"]
|
||||
},
|
||||
{
|
||||
"name": "NativeDecode",
|
||||
"base": "examples/NativeDecode",
|
||||
"files": ["main.c", "Makefile", "README.md"]
|
||||
}
|
||||
],
|
||||
"export": {
|
||||
"exclude": [
|
||||
"examples/*/.pio",
|
||||
"examples/NativeDecode/nativedecode",
|
||||
"samples/*/build",
|
||||
"experiment",
|
||||
"examples/*/.vscode",
|
||||
"examples/*/test",
|
||||
"test",
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
name=VictronBLE
|
||||
version=0.7.0
|
||||
version=0.4.1
|
||||
author=Scott Penrose
|
||||
maintainer=Scott Penrose <scottp@dd.com.au>
|
||||
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.
|
||||
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.
|
||||
category=Communication
|
||||
url=https://gitea.sh3d.com.au/Sh3d/VictronBLE
|
||||
architectures=esp32,nrf52
|
||||
architectures=esp32
|
||||
depends=
|
||||
includes=VictronBLE.h
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Zephyr samples
|
||||
|
||||
Zephyr applications live here; `examples/` holds the Arduino/PlatformIO ones.
|
||||
|
||||
| Sample | What it does |
|
||||
|---|---|
|
||||
| [`scan/`](scan/) | Lists every Victron device advertising nearby. No keys needed. **Start here.** |
|
||||
| [`observer/`](observer/) | Monitors known devices and decodes their records. |
|
||||
|
||||
Both need `CONFIG_VICTRONBLE=y`, which depends on `CONFIG_BT_OBSERVER=y`. See
|
||||
the Zephyr section of the top-level [README](../README.md) for how to add this
|
||||
library to a west workspace.
|
||||
|
||||
## Building
|
||||
|
||||
The library is a Zephyr module. If it is already in your `west.yml`, the
|
||||
samples build with no extra flags:
|
||||
|
||||
```sh
|
||||
west build -p -b nrf52840dk/nrf52840 samples/observer
|
||||
```
|
||||
|
||||
For local development against a checkout that is *not* in the manifest, point
|
||||
Zephyr at it directly:
|
||||
|
||||
```sh
|
||||
west build -p -b nrf52840dk/nrf52840 -d /tmp/vb_obs \
|
||||
/path/to/VictronBLE/samples/observer \
|
||||
-- -DZEPHYR_EXTRA_MODULES=/path/to/VictronBLE
|
||||
```
|
||||
|
||||
Then `west flash`, and watch the console at 115200 baud.
|
||||
|
||||
Tested on `nrf52840dk/nrf52840` and `rak4631/nrf52840` with Zephyr v4.4.0. Any
|
||||
board with a Bluetooth controller and the observer role should work — nothing
|
||||
in the library is nRF-specific.
|
||||
|
||||
Build-test both samples without hardware. For a checkout outside the manifest,
|
||||
twister needs the same module hint via the environment:
|
||||
|
||||
```sh
|
||||
ZEPHYR_EXTRA_MODULES=$PWD \
|
||||
west twister -T samples -p nrf52840dk/nrf52840 -p rak4631/nrf52840
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
cmake_minimum_required(VERSION 3.20.0)
|
||||
|
||||
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
|
||||
project(victronble_observer)
|
||||
|
||||
target_sources(app PRIVATE src/main.c)
|
||||
@@ -1,88 +0,0 @@
|
||||
# observer — decode records from known devices
|
||||
|
||||
Monitors a list of Victron devices, decrypts their Instant Readout
|
||||
advertisements and logs every field. This is the reference for using the
|
||||
Zephyr API.
|
||||
|
||||
Don't know your devices' addresses yet? Build [`../scan`](../scan/) first.
|
||||
|
||||
## Configure your devices
|
||||
|
||||
Edit the `known_devices` table at the top of [`src/main.c`](src/main.c):
|
||||
|
||||
```c
|
||||
static const struct { ... } known_devices[] = {
|
||||
{
|
||||
.name = "Rainbow48V",
|
||||
.addr = "E4:05:42:34:14:F3",
|
||||
.addr_type = "random",
|
||||
.key = "0ec3adf7433dd61793ff2f3b8ad32ed8",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The key is in VictronConnect: device → gear icon → Product info → *Instant
|
||||
readout via Bluetooth* → show/copy the encryption key (32 hex characters).
|
||||
|
||||
**The address type must be `random`.** Victron devices use random static
|
||||
Bluetooth addresses, and the registry lookup compares the type as well as the
|
||||
bytes. Get it wrong and the symptom is quiet and confusing: `adverts` climbs
|
||||
in the stats line but `decoded` stays at zero, because the adverts arrive and
|
||||
never match a registered device.
|
||||
|
||||
Add more than four devices and you also need to raise
|
||||
`CONFIG_VICTRONBLE_MAX_DEVICES` in `prj.conf`.
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
west build -p -b nrf52840dk/nrf52840 -d /tmp/vb_obs samples/observer \
|
||||
-- -DZEPHYR_EXTRA_MODULES=$PWD
|
||||
west flash -d /tmp/vb_obs
|
||||
```
|
||||
|
||||
Console (115200 baud):
|
||||
|
||||
```
|
||||
<inf> observer: VictronBLE observer starting
|
||||
<inf> observer: monitoring Rainbow48V (E4:05:42:34:14:F3)
|
||||
<inf> victronble: observing (interval 2048 window 18)
|
||||
<inf> observer: E4:05:42:34:14:F3 (random) solar charger rssi -67 model 0xa060 nonce 4660
|
||||
<inf> observer: state bulk error 0
|
||||
<inf> observer: battery 13.24 V 5.4 A pv 340 W yield today 1200 Wh
|
||||
<inf> observer: load n/a
|
||||
<inf> observer: stats: adverts 61 queued 30 dropped 0 decoded 28 dup 2 err 0
|
||||
```
|
||||
|
||||
## Reading the stats line
|
||||
|
||||
Printed every 30 s, and the fastest way to diagnose a quiet console:
|
||||
|
||||
| Counter | Meaning if it misbehaves |
|
||||
|---|---|
|
||||
| `adverts` | Victron adverts seen from **any** device. Zero → nothing in range, or Instant Readout is off on the device. |
|
||||
| `queued` | adverts from your registered devices. Zero while `adverts` climbs → wrong address or wrong address type. |
|
||||
| `dropped` | queue was full. Raise `CONFIG_VICTRONBLE_QUEUE_DEPTH`. |
|
||||
| `decoded` | records delivered to the callback. |
|
||||
| `dup` | repeats suppressed by nonce dedup. A steady trickle is normal — each advert is broadcast on three channels. |
|
||||
| `err` | decode failures. A wrong key shows up here, and in the `decode failed: key mismatch` warning. |
|
||||
|
||||
## What the code demonstrates
|
||||
|
||||
- `bt_enable()` **before** `victronble_start()` — the application owns the
|
||||
Bluetooth stack, the library only scans.
|
||||
- `bt_addr_le_from_str()` + `victronble_parse_key()` to turn human-readable
|
||||
config into what `victronble_device_add()` wants.
|
||||
- `victronble_cb_register()` with both `record` and `decode_error` set.
|
||||
Callbacks run on the library's decode thread, not the Bluetooth RX thread,
|
||||
so logging in them is safe.
|
||||
- Formatting a record: `isnan()` guards on every float, and
|
||||
`remaining_minutes == 0xFFFF` for time-to-go. Absent fields are normal —
|
||||
an MPPT with no load output always reports `load n/a`.
|
||||
- `victronble_get_stats()` for the periodic health line.
|
||||
|
||||
Printing floats needs `CONFIG_CBPRINTF_FP_SUPPORT=y`; without it the numbers
|
||||
come out empty.
|
||||
|
||||
To decode a captured advert on your PC instead — no board involved — see
|
||||
[`examples/NativeDecode`](../../examples/NativeDecode/).
|
||||
@@ -1,19 +0,0 @@
|
||||
# Bluetooth: observer role only — no connections, no pairing, no advertising.
|
||||
CONFIG_BT=y
|
||||
CONFIG_BT_OBSERVER=y
|
||||
CONFIG_BT_DEVICE_NAME="victron-observer"
|
||||
|
||||
# Advertising reports are "discardable" HCI events. The default pool is easy
|
||||
# to exhaust with a passive scan in a dense RF environment; if the stats line
|
||||
# shows adverts arriving in bursts and then stalling, raise this further.
|
||||
CONFIG_BT_BUF_EVT_DISCARDABLE_COUNT=20
|
||||
|
||||
CONFIG_VICTRONBLE=y
|
||||
CONFIG_VICTRONBLE_MAX_DEVICES=4
|
||||
|
||||
CONFIG_LOG=y
|
||||
CONFIG_VICTRONBLE_LOG_LEVEL_INF=y
|
||||
|
||||
# Records carry floats (volts, amps, watts). Without this, %f in LOG_INF()
|
||||
# and snprintk() prints nothing useful.
|
||||
CONFIG_CBPRINTF_FP_SUPPORT=y
|
||||
@@ -1,10 +0,0 @@
|
||||
sample:
|
||||
name: VictronBLE observer
|
||||
description: Decode Victron Instant Readout advertisements from known devices
|
||||
tests:
|
||||
sample.victronble.observer:
|
||||
build_only: true
|
||||
platform_allow:
|
||||
- nrf52840dk/nrf52840
|
||||
- rak4631/nrf52840
|
||||
tags: bluetooth victron
|
||||
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* VictronBLE Zephyr observer sample.
|
||||
*
|
||||
* Monitors a fixed list of Victron devices, decodes their Instant Readout
|
||||
* advertisements and logs every record. Prints a statistics line every 30 s
|
||||
* so a silent console can be diagnosed without a sniffer.
|
||||
*
|
||||
* Don't know your devices' MAC addresses yet? Build samples/scan first — it
|
||||
* lists every Victron device in range.
|
||||
*
|
||||
* Copyright (c) 2026 Scott Penrose
|
||||
* License: MIT
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/bluetooth/bluetooth.h>
|
||||
#include <zephyr/logging/log.h>
|
||||
#include <zephyr/sys/util.h>
|
||||
|
||||
#include "victronble_zephyr.h"
|
||||
|
||||
LOG_MODULE_REGISTER(observer, LOG_LEVEL_INF);
|
||||
|
||||
#define STATS_INTERVAL K_SECONDS(30)
|
||||
|
||||
/*
|
||||
* Your devices. The advertisement key is in VictronConnect:
|
||||
* device -> gear icon -> Product info -> "Instant readout via Bluetooth"
|
||||
* -> SHOW / copy the encryption key (32 hex characters).
|
||||
*
|
||||
* Victron devices use a random static Bluetooth address, so the address type
|
||||
* is "random" — not "public". Getting this wrong is the usual reason a device
|
||||
* never matches: the adverts arrive (the stats line counts them) but no
|
||||
* record is ever decoded, because the registry lookup compares the type too.
|
||||
*/
|
||||
static const struct {
|
||||
const char *name;
|
||||
const char *addr;
|
||||
const char *addr_type;
|
||||
const char *key;
|
||||
} known_devices[] = {
|
||||
{
|
||||
.name = "Rainbow48V",
|
||||
.addr = "E4:05:42:34:14:F3",
|
||||
.addr_type = "random",
|
||||
.key = "0ec3adf7433dd61793ff2f3b8ad32ed8",
|
||||
},
|
||||
{
|
||||
.name = "ScottTrailer",
|
||||
.addr = "E6:45:59:78:3C:FB",
|
||||
.addr_type = "random",
|
||||
.key = "3fa658aded4f309b9bc17a2318cb1f56",
|
||||
},
|
||||
};
|
||||
|
||||
/* --- record formatting ------------------------------------------------- */
|
||||
|
||||
#define FBUF_LEN 20
|
||||
|
||||
/*
|
||||
* A float field the device did not send comes back as NAN (see
|
||||
* include/victronble.h). Render that as "n/a" rather than letting "nan" leak
|
||||
* into the log — a missing load-current reading is not a fault. The unit goes
|
||||
* in here too, so an absent field reads "n/a" and not "n/a A".
|
||||
*
|
||||
* Needs CONFIG_CBPRINTF_FP_SUPPORT=y, or every number comes out empty.
|
||||
*/
|
||||
static const char *flt(char *buf, float v, int dp, const char *unit)
|
||||
{
|
||||
if (isnan(v)) {
|
||||
strcpy(buf, "n/a");
|
||||
} else {
|
||||
snprintk(buf, FBUF_LEN, "%.*f %s", dp, (double)v, unit);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
static void print_solar(const victronble_solar_charger_t *s)
|
||||
{
|
||||
char a[FBUF_LEN], b[FBUF_LEN], c[FBUF_LEN], d[FBUF_LEN];
|
||||
|
||||
LOG_INF(" state %s error %u", victronble_state_str(s->state),
|
||||
s->error);
|
||||
LOG_INF(" battery %s %s pv %s yield today %u Wh",
|
||||
flt(a, s->battery_voltage, 2, "V"),
|
||||
flt(b, s->battery_current, 1, "A"),
|
||||
flt(c, s->pv_power, 0, "W"), s->yield_today_wh);
|
||||
LOG_INF(" load %s", flt(d, s->load_current, 1, "A"));
|
||||
}
|
||||
|
||||
static void print_batmon(const victronble_battery_monitor_t *m)
|
||||
{
|
||||
char a[FBUF_LEN], b[FBUF_LEN], c[FBUF_LEN], d[FBUF_LEN];
|
||||
|
||||
LOG_INF(" battery %s %s soc %s", flt(a, m->voltage, 2, "V"),
|
||||
flt(b, m->current, 2, "A"), flt(c, m->soc, 1, "%"));
|
||||
LOG_INF(" consumed %s alarm 0x%04x",
|
||||
flt(d, m->consumed_ah, 1, "Ah"), m->alarm);
|
||||
|
||||
/* 0xFFFF is the wire's "not available", not 45 days of runtime. */
|
||||
if (m->remaining_minutes == 0xFFFF) {
|
||||
LOG_INF(" time to go n/a");
|
||||
} else {
|
||||
LOG_INF(" time to go %u min", m->remaining_minutes);
|
||||
}
|
||||
|
||||
/* The aux channel is one of three things, chosen on the device. */
|
||||
switch (m->aux_mode) {
|
||||
case 0:
|
||||
LOG_INF(" aux voltage %s", flt(a, m->aux_voltage, 2, "V"));
|
||||
break;
|
||||
case 2:
|
||||
LOG_INF(" temperature %s", flt(a, m->temperature, 1, "degC"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void print_record(const bt_addr_le_t *addr, int8_t rssi,
|
||||
const victronble_record_t *rec)
|
||||
{
|
||||
char addr_str[BT_ADDR_LE_STR_LEN];
|
||||
char a[FBUF_LEN], b[FBUF_LEN], c[FBUF_LEN];
|
||||
|
||||
bt_addr_le_to_str(addr, addr_str, sizeof(addr_str));
|
||||
|
||||
LOG_INF("%s %s rssi %d model 0x%04x nonce %u", addr_str,
|
||||
victronble_device_type_str(rec->type), rssi, rec->model_id,
|
||||
rec->nonce);
|
||||
|
||||
switch (rec->type) {
|
||||
case VICTRONBLE_DEV_SOLAR_CHARGER:
|
||||
print_solar(&rec->u.solar);
|
||||
break;
|
||||
case VICTRONBLE_DEV_BATTERY_MONITOR:
|
||||
print_batmon(&rec->u.batmon);
|
||||
break;
|
||||
case VICTRONBLE_DEV_INVERTER:
|
||||
LOG_INF(" state %s battery %s %s ac %s",
|
||||
victronble_state_str(rec->u.inverter.state),
|
||||
flt(a, rec->u.inverter.battery_voltage, 2, "V"),
|
||||
flt(b, rec->u.inverter.battery_current, 2, "A"),
|
||||
flt(c, rec->u.inverter.ac_power, 0, "W"));
|
||||
break;
|
||||
case VICTRONBLE_DEV_DCDC_CONVERTER:
|
||||
LOG_INF(" state %s in %s out %s %s",
|
||||
victronble_state_str(rec->u.dcdc.state),
|
||||
flt(a, rec->u.dcdc.input_voltage, 2, "V"),
|
||||
flt(b, rec->u.dcdc.output_voltage, 2, "V"),
|
||||
flt(c, rec->u.dcdc.output_current, 1, "A"));
|
||||
break;
|
||||
case VICTRONBLE_DEV_AC_CHARGER:
|
||||
LOG_INF(" state %s out1 %s %s temp %s",
|
||||
victronble_state_str(rec->u.ac.state),
|
||||
flt(a, rec->u.ac.voltage1, 2, "V"),
|
||||
flt(b, rec->u.ac.current1, 1, "A"),
|
||||
flt(c, rec->u.ac.temperature, 1, "degC"));
|
||||
break;
|
||||
default:
|
||||
LOG_INF(" (no decoder for record type 0x%02x)",
|
||||
rec->record_type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- callbacks --------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* These run on the victronble decode thread, not the Bluetooth RX thread, so
|
||||
* logging here is fine — it cannot stall the controller.
|
||||
*/
|
||||
static void on_record(const bt_addr_le_t *addr, int8_t rssi,
|
||||
const victronble_record_t *rec)
|
||||
{
|
||||
print_record(addr, rssi, rec);
|
||||
}
|
||||
|
||||
static void on_decode_error(const bt_addr_le_t *addr, victronble_err_t err)
|
||||
{
|
||||
char addr_str[BT_ADDR_LE_STR_LEN];
|
||||
|
||||
bt_addr_le_to_str(addr, addr_str, sizeof(addr_str));
|
||||
LOG_WRN("%s decode failed: %s", addr_str, victronble_strerror(err));
|
||||
}
|
||||
|
||||
static struct victronble_cb callbacks = {
|
||||
.record = on_record,
|
||||
.decode_error = on_decode_error,
|
||||
};
|
||||
|
||||
/* --- setup ------------------------------------------------------------- */
|
||||
|
||||
static int register_devices(void)
|
||||
{
|
||||
int registered = 0;
|
||||
|
||||
for (size_t i = 0; i < ARRAY_SIZE(known_devices); i++) {
|
||||
bt_addr_le_t addr;
|
||||
uint8_t key[VICTRONBLE_KEY_LEN];
|
||||
int err;
|
||||
|
||||
err = bt_addr_le_from_str(known_devices[i].addr,
|
||||
known_devices[i].addr_type, &addr);
|
||||
if (err != 0) {
|
||||
LOG_ERR("%s: bad address '%s' (%d)",
|
||||
known_devices[i].name, known_devices[i].addr,
|
||||
err);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!victronble_parse_key(known_devices[i].key, key)) {
|
||||
LOG_ERR("%s: key must be 32 hex characters",
|
||||
known_devices[i].name);
|
||||
continue;
|
||||
}
|
||||
|
||||
err = victronble_device_add(&addr, key);
|
||||
if (err != 0) {
|
||||
LOG_ERR("%s: victronble_device_add failed (%d)",
|
||||
known_devices[i].name, err);
|
||||
continue;
|
||||
}
|
||||
|
||||
LOG_INF("monitoring %s (%s)", known_devices[i].name,
|
||||
known_devices[i].addr);
|
||||
registered++;
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
struct victronble_stats stats;
|
||||
int err;
|
||||
|
||||
LOG_INF("VictronBLE observer starting");
|
||||
|
||||
/* The application owns the Bluetooth stack: victronble_start() needs
|
||||
* it up already. */
|
||||
err = bt_enable(NULL);
|
||||
if (err != 0) {
|
||||
LOG_ERR("bt_enable failed (%d)", err);
|
||||
return 0;
|
||||
}
|
||||
|
||||
err = victronble_cb_register(&callbacks);
|
||||
if (err != 0) {
|
||||
LOG_ERR("victronble_cb_register failed (%d)", err);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (register_devices() == 0) {
|
||||
LOG_ERR("no devices registered — nothing to observe");
|
||||
return 0;
|
||||
}
|
||||
|
||||
err = victronble_start();
|
||||
if (err != 0) {
|
||||
LOG_ERR("victronble_start failed (%d)", err);
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (1) {
|
||||
k_sleep(STATS_INTERVAL);
|
||||
|
||||
victronble_get_stats(&stats);
|
||||
LOG_INF("stats: adverts %u queued %u dropped %u decoded %u dup %u err %u",
|
||||
stats.adverts, stats.queued, stats.dropped,
|
||||
stats.decoded, stats.duplicates, stats.errors);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
cmake_minimum_required(VERSION 3.20.0)
|
||||
|
||||
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
|
||||
project(victronble_scan)
|
||||
|
||||
target_sources(app PRIVATE src/main.c)
|
||||
@@ -1,58 +0,0 @@
|
||||
# scan — find your Victron devices
|
||||
|
||||
Logs every Victron "Instant Readout" advertisement in range. **No
|
||||
advertisement keys and no device list required**, so this is the first thing
|
||||
to run: it tells you what you have and what its Bluetooth address is.
|
||||
|
||||
The whole sample is `victronble_watch_set(true)` plus `victronble_start()` —
|
||||
all the output comes from the library's own log module.
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
west build -p -b nrf52840dk/nrf52840 -d /tmp/vb_scan samples/scan \
|
||||
-- -DZEPHYR_EXTRA_MODULES=$PWD
|
||||
west flash -d /tmp/vb_scan
|
||||
```
|
||||
|
||||
Console (115200 baud):
|
||||
|
||||
```
|
||||
<inf> scan: VictronBLE discovery — logging every Victron advert in range
|
||||
<inf> victronble: observing (interval 96 window 48)
|
||||
<inf> victronble: watch: E4:05:42:34:14:F3 (random) rssi -67 type 0x01 (solar charger) len 31 keycheck 0x0d
|
||||
<inf> victronble: watch: E6:45:59:78:3C:FB (random) rssi -82 type 0x02 (battery monitor) len 31 keycheck 0x3f
|
||||
<inf> scan: stats: adverts 61 queued 61 dropped 0
|
||||
```
|
||||
|
||||
Each line gives you everything you need for `samples/observer`:
|
||||
|
||||
| Field | Use |
|
||||
|---|---|
|
||||
| address + `(random)` | Victron uses **random** static addresses — that address type matters |
|
||||
| `type` | which device family it is, before any decryption |
|
||||
| `keycheck` | first byte of the advertisement key; confirms you copied the right key |
|
||||
| `rssi` | how well you can hear it — useful for placing the node |
|
||||
|
||||
Nothing here is decrypted. Watch mode reads only the plaintext header, which
|
||||
is why it works without keys.
|
||||
|
||||
## Next step
|
||||
|
||||
Copy the addresses into the `known_devices` table in
|
||||
[`../observer/src/main.c`](../observer/src/main.c) along with each device's key
|
||||
from VictronConnect (device → gear icon → Product info → *Instant readout via
|
||||
Bluetooth*), then build `observer`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Unregistered devices are **not** deduplicated by nonce, so expect roughly
|
||||
one line per device per second. That is the point — it shows liveness.
|
||||
- `prj.conf` raises the scan duty cycle to 60 ms / 30 ms
|
||||
(`BT_GAP_SCAN_FAST_*`) instead of the library default 1.28 s / 11.25 ms.
|
||||
Discovery should be quick; `observer` uses the low-power default.
|
||||
- `CONFIG_VICTRONBLE_QUEUE_DEPTH=16` because watch mode queues every advert,
|
||||
not just the ones from known devices. If `dropped` climbs on a busy site,
|
||||
raise it further.
|
||||
- `adverts 0` after a minute means either nothing is in range or the device
|
||||
has *Instant readout via Bluetooth* switched off in VictronConnect.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Bluetooth: observer role only — no connections, no pairing, no advertising.
|
||||
CONFIG_BT=y
|
||||
CONFIG_BT_OBSERVER=y
|
||||
CONFIG_BT_DEVICE_NAME="victron-scan"
|
||||
|
||||
# Discovery hears every Victron in range, so the advertising-report pool and
|
||||
# the library's decode queue both see more traffic than in normal operation.
|
||||
CONFIG_BT_BUF_EVT_DISCARDABLE_COUNT=20
|
||||
|
||||
CONFIG_VICTRONBLE=y
|
||||
|
||||
# Watch mode queues every Victron advert, not just the registered ones.
|
||||
CONFIG_VICTRONBLE_QUEUE_DEPTH=16
|
||||
|
||||
# No devices are registered — this sample never decrypts anything.
|
||||
CONFIG_VICTRONBLE_MAX_DEVICES=1
|
||||
|
||||
# Discovery wants to find things quickly, so trade power for a much higher
|
||||
# duty cycle than the library's defaults: 60 ms interval / 30 ms window
|
||||
# (BT_GAP_SCAN_FAST_*) instead of 1.28 s / 11.25 ms.
|
||||
CONFIG_VICTRONBLE_SCAN_INTERVAL=96
|
||||
CONFIG_VICTRONBLE_SCAN_WINDOW=48
|
||||
|
||||
CONFIG_LOG=y
|
||||
CONFIG_VICTRONBLE_LOG_LEVEL_INF=y
|
||||
@@ -1,10 +0,0 @@
|
||||
sample:
|
||||
name: VictronBLE scan
|
||||
description: List every Victron device advertising nearby, no keys required
|
||||
tests:
|
||||
sample.victronble.scan:
|
||||
build_only: true
|
||||
platform_allow:
|
||||
- nrf52840dk/nrf52840
|
||||
- rak4631/nrf52840
|
||||
tags: bluetooth victron
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* VictronBLE Zephyr discovery sample.
|
||||
*
|
||||
* Turns on the library's watch mode and does nothing else. No advertisement
|
||||
* keys, no device list: every Victron Instant Readout advert in range is
|
||||
* logged with its address, RSSI, device type and key-check byte.
|
||||
*
|
||||
* Run this first to find out what you have and what its MAC address is, then
|
||||
* put the addresses and keys into samples/observer.
|
||||
*
|
||||
* Copyright (c) 2026 Scott Penrose
|
||||
* License: MIT
|
||||
*/
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/bluetooth/bluetooth.h>
|
||||
#include <zephyr/logging/log.h>
|
||||
|
||||
#include "victronble_zephyr.h"
|
||||
|
||||
LOG_MODULE_REGISTER(scan, LOG_LEVEL_INF);
|
||||
|
||||
#define STATS_INTERVAL K_SECONDS(30)
|
||||
|
||||
int main(void)
|
||||
{
|
||||
struct victronble_stats stats;
|
||||
int err;
|
||||
|
||||
LOG_INF("VictronBLE discovery — logging every Victron advert in range");
|
||||
|
||||
/* The application owns the Bluetooth stack: victronble_start() needs
|
||||
* it up already. */
|
||||
err = bt_enable(NULL);
|
||||
if (err != 0) {
|
||||
LOG_ERR("bt_enable failed (%d)", err);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* All output comes from the library's own log module (victronble).
|
||||
* Unregistered devices are not nonce-deduplicated, so expect roughly
|
||||
* one line per device per second. */
|
||||
victronble_watch_set(true);
|
||||
|
||||
err = victronble_start();
|
||||
if (err != 0) {
|
||||
LOG_ERR("victronble_start failed (%d)", err);
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (1) {
|
||||
k_sleep(STATS_INTERVAL);
|
||||
|
||||
victronble_get_stats(&stats);
|
||||
LOG_INF("stats: adverts %u queued %u dropped %u", stats.adverts,
|
||||
stats.queued, stats.dropped);
|
||||
|
||||
if (stats.adverts == 0) {
|
||||
LOG_WRN("nothing heard yet — check the device has "
|
||||
"'Instant readout via Bluetooth' enabled in "
|
||||
"VictronConnect");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
+275
-133
@@ -1,37 +1,44 @@
|
||||
/**
|
||||
* 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.
|
||||
* VictronBLE - ESP32 library for Victron Energy BLE devices
|
||||
* Implementation file
|
||||
*
|
||||
* 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; }
|
||||
|
||||
VictronBLE::VictronBLE()
|
||||
: deviceCount(0), callback(nullptr), debugEnabled(false),
|
||||
scanDuration(5), minIntervalMs(1000), initialized(false)
|
||||
#if defined(VICTRON_BACKEND_ESP32)
|
||||
, pBLEScan(nullptr), scanCallbackObj(nullptr)
|
||||
#endif
|
||||
{
|
||||
: deviceCount(0), pBLEScan(nullptr), scanCallbackObj(nullptr),
|
||||
callback(nullptr), debugEnabled(false), scanDuration(5),
|
||||
minIntervalMs(1000), initialized(false) {
|
||||
memset(devices, 0, sizeof(devices));
|
||||
}
|
||||
|
||||
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);
|
||||
pBLEScan->setInterval(100);
|
||||
pBLEScan->setWindow(99);
|
||||
|
||||
initialized = true;
|
||||
if (debugEnabled) Serial.println("[VictronBLE] Initialized");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
|
||||
VictronDeviceType type) {
|
||||
if (deviceCount >= VICTRON_MAX_DEVICES) return false;
|
||||
if (!hexKey || strlen(hexKey) != 32) return false;
|
||||
if (!mac || strlen(mac) == 0) return false;
|
||||
|
||||
char normalizedMAC[VICTRON_MAC_LEN];
|
||||
@@ -42,8 +49,6 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
|
||||
|
||||
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);
|
||||
@@ -52,22 +57,52 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
|
||||
entry->device.deviceType = type;
|
||||
entry->device.rssi = -100;
|
||||
|
||||
if (!hexToBytes(hexKey, entry->key, 16)) return false;
|
||||
|
||||
deviceCount++;
|
||||
|
||||
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Scan complete callback — sets flag so loop() restarts
|
||||
static bool s_scanning = false;
|
||||
static void onScanDone(BLEScanResults results) {
|
||||
s_scanning = false;
|
||||
}
|
||||
|
||||
void VictronBLE::loop() {
|
||||
if (!initialized) return;
|
||||
if (!s_scanning) {
|
||||
pBLEScan->clearResults();
|
||||
s_scanning = pBLEScan->start(scanDuration, onScanDone, false);
|
||||
}
|
||||
}
|
||||
|
||||
// BLE scan callback
|
||||
void VictronBLEAdvertisedDeviceCallbacks::onResult(BLEAdvertisedDevice advertisedDevice) {
|
||||
if (victronBLE) victronBLE->processDevice(advertisedDevice);
|
||||
}
|
||||
|
||||
void VictronBLE::processDevice(BLEAdvertisedDevice& advertisedDevice) {
|
||||
if (!advertisedDevice.haveManufacturerData()) return;
|
||||
|
||||
std::string raw = advertisedDevice.getManufacturerData();
|
||||
if (raw.length() < 10) return;
|
||||
|
||||
// Quick vendor ID check before any other work
|
||||
uint16_t vendorID = (uint8_t)raw[0] | ((uint8_t)raw[1] << 8);
|
||||
if (vendorID != VICTRON_MANUFACTURER_ID) return;
|
||||
|
||||
// Parse manufacturer data
|
||||
victronManufacturerData mfgData;
|
||||
memset(&mfgData, 0, sizeof(mfgData));
|
||||
size_t copyLen = raw.length() > sizeof(mfgData) ? sizeof(mfgData) : raw.length();
|
||||
raw.copy(reinterpret_cast<char*>(&mfgData), copyLen);
|
||||
|
||||
// Normalize MAC and find device
|
||||
char normalizedMAC[VICTRON_MAC_LEN];
|
||||
normalizeMAC(macStr, normalizedMAC);
|
||||
normalizeMAC(advertisedDevice.getAddress().toString().c_str(), normalizedMAC);
|
||||
|
||||
DeviceEntry* entry = findDevice(normalizedMAC);
|
||||
if (!entry) {
|
||||
@@ -76,9 +111,9 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
|
||||
}
|
||||
|
||||
// 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
|
||||
if (entry->device.dataValid && mfgData.nonceDataCounter == entry->lastNonce) {
|
||||
// Still update RSSI since we got a packet
|
||||
entry->device.rssi = advertisedDevice.getRSSI();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,123 +123,230 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
|
||||
entry->device.name, rec.nonce);
|
||||
entry->device.name, mfgData.nonceDataCounter);
|
||||
|
||||
storeRecord(entry, rec);
|
||||
entry->lastNonce = nonce;
|
||||
entry->device.rssi = rssi;
|
||||
entry->device.lastUpdate = now;
|
||||
entry->device.dataValid = true;
|
||||
if (callback) callback(&entry->device);
|
||||
if (parseAdvertisement(entry, mfgData)) {
|
||||
entry->lastNonce = mfgData.nonceDataCounter;
|
||||
entry->device.rssi = advertisedDevice.getRSSI();
|
||||
entry->device.lastUpdate = now;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
|
||||
s.batteryVoltage, s.batteryCurrent,
|
||||
(int)s.panelPower, s.chargeState);
|
||||
}
|
||||
break;
|
||||
bool VictronBLE::parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg) {
|
||||
if (debugEnabled) {
|
||||
Serial.printf("[VictronBLE] Beacon:0x%02X Record:0x%02X Nonce:0x%04X\n",
|
||||
mfg.beaconType, mfg.victronRecordType, mfg.nonceDataCounter);
|
||||
}
|
||||
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;
|
||||
|
||||
// Quick key check before expensive decryption
|
||||
if (mfg.encryptKeyMatch != entry->key[0]) {
|
||||
if (debugEnabled) Serial.println("[VictronBLE] Key byte mismatch");
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
|
||||
// Build IV from nonce (2 bytes little-endian + 14 zero bytes)
|
||||
uint8_t iv[16] = {0};
|
||||
iv[0] = mfg.nonceDataCounter & 0xFF;
|
||||
iv[1] = (mfg.nonceDataCounter >> 8) & 0xFF;
|
||||
|
||||
// Decrypt
|
||||
uint8_t decrypted[VICTRON_ENCRYPTED_LEN];
|
||||
if (!decryptData(mfg.victronEncryptedData, VICTRON_ENCRYPTED_LEN,
|
||||
entry->key, iv, decrypted)) {
|
||||
if (debugEnabled) Serial.println("[VictronBLE] Decryption failed");
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
|
||||
// Parse based on record type (auto-detects device type)
|
||||
bool ok = false;
|
||||
switch (mfg.victronRecordType) {
|
||||
case DEVICE_TYPE_SOLAR_CHARGER:
|
||||
entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
|
||||
ok = parseSolarCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.solar);
|
||||
break;
|
||||
case DEVICE_TYPE_BATTERY_MONITOR:
|
||||
entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
|
||||
ok = parseBatteryMonitor(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.battery);
|
||||
break;
|
||||
case DEVICE_TYPE_INVERTER:
|
||||
case DEVICE_TYPE_INVERTER_RS:
|
||||
case DEVICE_TYPE_MULTI_RS:
|
||||
case DEVICE_TYPE_VE_BUS:
|
||||
entry->device.deviceType = DEVICE_TYPE_INVERTER;
|
||||
ok = parseInverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.inverter);
|
||||
break;
|
||||
case DEVICE_TYPE_DCDC_CONVERTER:
|
||||
entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
|
||||
ok = parseDCDCConverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.dcdc);
|
||||
break;
|
||||
default:
|
||||
if (debugEnabled) Serial.printf("[VictronBLE] Unknown type: 0x%02X\n", mfg.victronRecordType);
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
|
||||
if (ok) {
|
||||
entry->device.dataValid = true;
|
||||
if (callback) callback(&entry->device);
|
||||
}
|
||||
default:
|
||||
break;
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool VictronBLE::decryptData(const uint8_t* encrypted, size_t len,
|
||||
const uint8_t* key, const uint8_t* iv,
|
||||
uint8_t* decrypted) {
|
||||
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_counter[16];
|
||||
uint8_t stream_block[16];
|
||||
memcpy(nonce_counter, iv, 16);
|
||||
memset(stream_block, 0, 16);
|
||||
|
||||
int ret = mbedtls_aes_crypt_ctr(&aes, len, &nc_off, nonce_counter,
|
||||
stream_block, encrypted, decrypted);
|
||||
mbedtls_aes_free(&aes);
|
||||
return (ret == 0);
|
||||
}
|
||||
|
||||
bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result) {
|
||||
if (len < sizeof(victronSolarChargerPayload)) return false;
|
||||
const auto* p = reinterpret_cast<const victronSolarChargerPayload*>(data);
|
||||
|
||||
result.chargeState = p->deviceState;
|
||||
result.errorCode = p->errorCode;
|
||||
result.batteryVoltage = p->batteryVoltage * 0.01f;
|
||||
result.batteryCurrent = p->batteryCurrent * 0.01f;
|
||||
result.yieldToday = p->yieldToday * 10;
|
||||
result.panelPower = p->inputPower;
|
||||
result.loadCurrent = (p->loadCurrent != 0xFFFF) ? p->loadCurrent * 0.01f : 0;
|
||||
|
||||
if (debugEnabled) {
|
||||
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
|
||||
result.batteryVoltage, result.batteryCurrent,
|
||||
(int)result.panelPower, result.chargeState);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result) {
|
||||
if (len < sizeof(victronBatteryMonitorPayload)) return false;
|
||||
const auto* p = reinterpret_cast<const victronBatteryMonitorPayload*>(data);
|
||||
|
||||
result.remainingMinutes = p->remainingMins;
|
||||
result.voltage = p->batteryVoltage * 0.01f;
|
||||
|
||||
// Alarm bits
|
||||
result.alarmLowVoltage = (p->alarms & 0x01) != 0;
|
||||
result.alarmHighVoltage = (p->alarms & 0x02) != 0;
|
||||
result.alarmLowSOC = (p->alarms & 0x04) != 0;
|
||||
result.alarmLowTemperature = (p->alarms & 0x10) != 0;
|
||||
result.alarmHighTemperature = (p->alarms & 0x20) != 0;
|
||||
|
||||
// Aux data: voltage or temperature (heuristic: < 30V = voltage)
|
||||
// NOTE: Victron protocol uses a flag bit for this, but it's not exposed
|
||||
// in the BLE advertisement. This heuristic may misclassify edge cases.
|
||||
if (p->auxData < 3000) {
|
||||
result.auxVoltage = p->auxData * 0.01f;
|
||||
result.temperature = 0;
|
||||
} else {
|
||||
result.temperature = (p->auxData * 0.01f) - 273.15f;
|
||||
result.auxVoltage = 0;
|
||||
}
|
||||
|
||||
// Battery current (22-bit signed, 1 mA units)
|
||||
int32_t current = p->currentLow |
|
||||
(p->currentMid << 8) |
|
||||
((p->currentHigh_consumedLow & 0x3F) << 16);
|
||||
if (current & 0x200000) current |= 0xFFC00000; // Sign extend
|
||||
result.current = current * 0.001f;
|
||||
|
||||
// Consumed Ah (18-bit signed, 10 mAh units)
|
||||
int32_t consumedAh = ((p->currentHigh_consumedLow & 0xC0) >> 6) |
|
||||
(p->consumedMid << 2) |
|
||||
(p->consumedHigh << 10);
|
||||
if (consumedAh & 0x20000) consumedAh |= 0xFFFC0000; // Sign extend
|
||||
result.consumedAh = consumedAh * 0.01f;
|
||||
|
||||
// SOC (10-bit, 0.1% units)
|
||||
result.soc = (p->soc & 0x3FF) * 0.1f;
|
||||
|
||||
if (debugEnabled) {
|
||||
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
|
||||
result.voltage, result.current, result.soc);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VictronBLE::parseInverter(const uint8_t* data, size_t len, VictronInverterData& result) {
|
||||
if (len < sizeof(victronInverterPayload)) return false;
|
||||
const auto* p = reinterpret_cast<const victronInverterPayload*>(data);
|
||||
|
||||
result.state = p->deviceState;
|
||||
result.batteryVoltage = p->batteryVoltage * 0.01f;
|
||||
result.batteryCurrent = p->batteryCurrent * 0.01f;
|
||||
|
||||
// AC Power (signed 24-bit)
|
||||
int32_t acPower = p->acPowerLow | (p->acPowerMid << 8) | (p->acPowerHigh << 16);
|
||||
if (acPower & 0x800000) acPower |= 0xFF000000; // Sign extend
|
||||
result.acPower = acPower;
|
||||
|
||||
// Alarm bits
|
||||
result.alarmLowVoltage = (p->alarms & 0x01) != 0;
|
||||
result.alarmHighVoltage = (p->alarms & 0x02) != 0;
|
||||
result.alarmHighTemperature = (p->alarms & 0x04) != 0;
|
||||
result.alarmOverload = (p->alarms & 0x08) != 0;
|
||||
|
||||
if (debugEnabled) {
|
||||
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
|
||||
result.batteryVoltage, (int)result.acPower, result.state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result) {
|
||||
if (len < sizeof(victronDCDCConverterPayload)) return false;
|
||||
const auto* p = reinterpret_cast<const victronDCDCConverterPayload*>(data);
|
||||
|
||||
result.chargeState = p->chargeState;
|
||||
result.errorCode = p->errorCode;
|
||||
result.inputVoltage = p->inputVoltage * 0.01f;
|
||||
result.outputVoltage = p->outputVoltage * 0.01f;
|
||||
result.outputCurrent = p->outputCurrent * 0.01f;
|
||||
|
||||
if (debugEnabled) {
|
||||
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
|
||||
result.inputVoltage, result.outputVoltage, result.outputCurrent);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
bool VictronBLE::hexToBytes(const char* hex, uint8_t* out, size_t len) {
|
||||
if (strlen(hex) != len * 2) return false;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
uint8_t hi = hex[i * 2], lo = hex[i * 2 + 1];
|
||||
if (hi >= '0' && hi <= '9') hi -= '0';
|
||||
else if (hi >= 'a' && hi <= 'f') hi = hi - 'a' + 10;
|
||||
else if (hi >= 'A' && hi <= 'F') hi = hi - 'A' + 10;
|
||||
else return false;
|
||||
if (lo >= '0' && lo <= '9') lo -= '0';
|
||||
else if (lo >= 'a' && lo <= 'f') lo = lo - 'a' + 10;
|
||||
else if (lo >= 'A' && lo <= 'F') lo = lo - 'A' + 10;
|
||||
else return false;
|
||||
out[i] = (hi << 4) | lo;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void VictronBLE::normalizeMAC(const char* input, char* output) {
|
||||
int j = 0;
|
||||
for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) {
|
||||
|
||||
+39
-72
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* 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.
|
||||
* VictronBLE - ESP32 library for Victron Energy BLE devices
|
||||
*
|
||||
* Based on Victron's official BLE Advertising protocol documentation
|
||||
* Inspired by hoberman's examples and keshavdv's Python library
|
||||
@@ -16,22 +12,10 @@
|
||||
#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>
|
||||
#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
|
||||
#include <BLEDevice.h>
|
||||
#include <BLEAdvertisedDevice.h>
|
||||
#include <BLEScan.h>
|
||||
#include "mbedtls/aes.h"
|
||||
|
||||
// --- Constants ---
|
||||
static constexpr uint16_t VICTRON_MANUFACTURER_ID = 0x02E1;
|
||||
@@ -49,14 +33,11 @@ enum VictronDeviceType {
|
||||
DEVICE_TYPE_DCDC_CONVERTER = 0x04,
|
||||
DEVICE_TYPE_SMART_LITHIUM = 0x05,
|
||||
DEVICE_TYPE_INVERTER_RS = 0x06,
|
||||
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_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 state for Solar Charger ---
|
||||
@@ -91,17 +72,27 @@ struct victronManufacturerData {
|
||||
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
|
||||
int16_t batteryVoltage; // 10mV units
|
||||
int16_t batteryCurrent; // 10mA units (signed)
|
||||
uint16_t yieldToday; // 10Wh units
|
||||
uint16_t inputPower; // 1W units
|
||||
uint16_t loadCurrent; // 9-bit field, 0.1A units (0x1FF = no load)
|
||||
uint16_t loadCurrent; // 10mA units (0xFFFF = no load)
|
||||
uint8_t reserved[2];
|
||||
} __attribute__((packed));
|
||||
|
||||
// NOTE: The battery monitor payload is bit-packed (16-bit alarm, 2-bit aux mode,
|
||||
// 22-bit current, 20-bit consumed Ah, 10-bit SOC) and does NOT byte-align, so it
|
||||
// is decoded by bit offset directly in parseBatteryMonitor() rather than a struct.
|
||||
struct victronBatteryMonitorPayload {
|
||||
uint16_t remainingMins;
|
||||
uint16_t batteryVoltage; // 10mV units
|
||||
uint8_t alarms;
|
||||
uint16_t auxData; // 10mV (voltage) or 0.01K (temperature)
|
||||
uint8_t currentLow;
|
||||
uint8_t currentMid;
|
||||
uint8_t currentHigh_consumedLow; // Current bits 16-21 (low 6), consumed bits 0-1 (high 2)
|
||||
uint8_t consumedMid;
|
||||
uint8_t consumedHigh;
|
||||
uint16_t soc; // 0.1% units (10-bit)
|
||||
uint8_t reserved[2];
|
||||
} __attribute__((packed));
|
||||
|
||||
struct victronInverterPayload {
|
||||
uint8_t deviceState;
|
||||
@@ -138,19 +129,6 @@ struct VictronSolarData {
|
||||
float loadCurrent; // A
|
||||
};
|
||||
|
||||
struct VictronACChargerData {
|
||||
uint8_t chargeState; // SolarChargerState enum (shared charger states)
|
||||
uint8_t errorCode;
|
||||
float voltage1; // V (output 1)
|
||||
float current1; // A (output 1)
|
||||
float voltage2; // V (output 2, 0 if absent)
|
||||
float current2; // A (output 2, 0 if absent)
|
||||
float voltage3; // V (output 3, 0 if absent)
|
||||
float current3; // A (output 3, 0 if absent)
|
||||
float temperature; // C (0 if not available)
|
||||
float acCurrent; // A (0 if not available)
|
||||
};
|
||||
|
||||
struct VictronBatteryData {
|
||||
float voltage; // V
|
||||
float current; // A
|
||||
@@ -201,7 +179,6 @@ struct VictronDevice {
|
||||
VictronBatteryData battery;
|
||||
VictronInverterData inverter;
|
||||
VictronDCDCData dcdc;
|
||||
VictronACChargerData acCharger;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -232,6 +209,8 @@ public:
|
||||
void loop();
|
||||
|
||||
private:
|
||||
friend class VictronBLEAdvertisedDeviceCallbacks;
|
||||
|
||||
struct DeviceEntry {
|
||||
VictronDevice device;
|
||||
uint8_t key[16];
|
||||
@@ -239,40 +218,29 @@ private:
|
||||
bool active;
|
||||
};
|
||||
|
||||
// --- Common state (platform-independent) ---
|
||||
DeviceEntry devices[VICTRON_MAX_DEVICES];
|
||||
size_t deviceCount;
|
||||
BLEScan* pBLEScan;
|
||||
VictronBLEAdvertisedDeviceCallbacks* scanCallbackObj;
|
||||
VictronCallback callback;
|
||||
bool debugEnabled;
|
||||
uint32_t scanDuration;
|
||||
uint32_t minIntervalMs;
|
||||
bool initialized;
|
||||
|
||||
static bool hexToBytes(const char* hex, uint8_t* out, size_t len);
|
||||
static void normalizeMAC(const char* input, char* output);
|
||||
DeviceEntry* findDevice(const char* normalizedMAC);
|
||||
|
||||
// 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;
|
||||
bool decryptData(const uint8_t* encrypted, size_t len,
|
||||
const uint8_t* key, const uint8_t* iv, uint8_t* decrypted);
|
||||
void processDevice(BLEAdvertisedDevice& dev);
|
||||
#elif defined(VICTRON_BACKEND_NRF52)
|
||||
static VictronBLE* s_instance;
|
||||
static void scanCallback(ble_gap_evt_adv_report_t* report);
|
||||
#endif
|
||||
bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg);
|
||||
bool parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result);
|
||||
bool parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result);
|
||||
bool parseInverter(const uint8_t* data, size_t len, VictronInverterData& result);
|
||||
bool parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result);
|
||||
};
|
||||
|
||||
#if defined(VICTRON_BACKEND_ESP32)
|
||||
// BLE scan callback (required by ESP32 BLE API)
|
||||
class VictronBLEAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
|
||||
public:
|
||||
@@ -281,7 +249,6 @@ public:
|
||||
private:
|
||||
VictronBLE* victronBLE;
|
||||
};
|
||||
#endif
|
||||
|
||||
// ============================================================
|
||||
// Commented-out features — kept for reference / future use
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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_
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -1,5 +0,0 @@
|
||||
/* 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"
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
/**
|
||||
* 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 "?";
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* 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 bool watch_mode;
|
||||
|
||||
void victronble_watch_set(bool on)
|
||||
{
|
||||
watch_mode = on;
|
||||
}
|
||||
|
||||
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. Watch mode
|
||||
* queues everything so the decode thread can log it. */
|
||||
if (!watch_mode && 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) { /* unregistered (watch mode) or removed while queued */
|
||||
k_mutex_unlock(&dev_mtx);
|
||||
if (watch_mode && frame->len > 9) {
|
||||
char mac[BT_ADDR_LE_STR_LEN];
|
||||
|
||||
bt_addr_le_to_str(&frame->addr, mac, sizeof(mac));
|
||||
LOG_INF("watch: %s rssi %d type 0x%02x (%s) len %u keycheck 0x%02x",
|
||||
mac, frame->rssi, frame->data[6],
|
||||
victronble_device_type_str(frame->data[6]),
|
||||
frame->len, frame->data[9]);
|
||||
}
|
||||
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++;
|
||||
if (watch_mode) {
|
||||
char mac[BT_ADDR_LE_STR_LEN];
|
||||
|
||||
bt_addr_le_to_str(&frame->addr, mac, sizeof(mac));
|
||||
LOG_INF("watch: %s decode failed: %s", mac,
|
||||
victronble_strerror(err));
|
||||
} else {
|
||||
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++;
|
||||
if (watch_mode) {
|
||||
char mac[BT_ADDR_LE_STR_LEN];
|
||||
|
||||
bt_addr_le_to_str(&frame->addr, mac, sizeof(mac));
|
||||
LOG_INF("watch: %s decoded %s nonce 0x%04x rssi %d", mac,
|
||||
victronble_device_type_str(rec.type), rec.nonce,
|
||||
frame->rssi);
|
||||
} else {
|
||||
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(¶m, 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__ */
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/* 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 };
|
||||
@@ -1,4 +0,0 @@
|
||||
name: victronble
|
||||
build:
|
||||
cmake: .
|
||||
kconfig: Kconfig
|
||||
Reference in New Issue
Block a user