Compare commits
2
Commits
a4505019e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22af47854d | ||
|
|
67ff8721c0 |
@@ -72,4 +72,12 @@ venv/
|
||||
env/
|
||||
|
||||
*.tar.gz
|
||||
# Compiled host binaries
|
||||
tests/vectors/victronble_test
|
||||
examples/NativeDecode/nativedecode
|
||||
|
||||
# Zephyr build trees
|
||||
samples/*/build/
|
||||
build/
|
||||
twister-out/
|
||||
twister-out.*/
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ your-project/
|
||||
|
||||
### Step 3: Update the Example Code
|
||||
|
||||
Open `examples/MultiDevice/main.cpp` and update these lines with YOUR device information:
|
||||
Open `examples/MultiDevice/src/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/main.cpp` as an .ino file
|
||||
1. Open `examples/MultiDevice/src/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 Arduino library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements — runs on both **ESP32** and **nRF52840**.
|
||||
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.
|
||||
|
||||
v0.6 adds **multi-platform support** (ESP32 + nRF52840) via a hardware-abstracted BLE backend and dependency-free bundled crypto. (v0.5 brought the decoding accuracy fixes and AC charger support; v0.4 reworked the internals — function-pointer callback API, reduced memory usage, non-blocking scanning.) See [VERSIONS](VERSIONS) for full details. A stable **v1.0** release with a consistent, long-term API is coming soon.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
Why another library? Most of the Victron BLE examples are built into other frameworks (e.g. ESPHome) or are locked to a single chip. The goal here is one library that works across ESP32 and nRF52 (and is easy to extend to more), usable standalone or inside ESPHome and other frameworks, with a long-term plan to move others onto it and improve the code with many eyes.
|
||||
|
||||
Supports **ESP32** (original, S and C series — tested on older ESP32, ESP32-S3 and ESP32-C3) and **nRF52840** (Adafruit/Seeed Bluefruit core, e.g. Seeed XIAO nRF52840). All decoding and decryption is shared; only a thin BLE scanning backend is platform-specific (`src/esp32/`, `src/nrf52/`), so other chipsets can be added by implementing one more backend.
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Multi-Platform**: One API for ESP32 and nRF52840; backend chosen at compile time
|
||||
- ✅ **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**: Works with Arduino (and ESP-IDF on ESP32)
|
||||
- ✅ **Framework Friendly**: Arduino library, Zephyr module, or the bare C core
|
||||
- ✅ **Clean API**: Simple, intuitive interface with callback support
|
||||
- ✅ **No Pairing Required**: Reads BLE advertisement data directly
|
||||
- ✅ **Low Power**: Uses passive BLE scanning
|
||||
@@ -35,11 +35,13 @@ Supports **ESP32** (original, S and C series — tested on older ESP32, ESP32-S3
|
||||
## Hardware Requirements
|
||||
|
||||
- An ESP32 (original / S / C series) **or** an nRF52840 board (Adafruit/Seeed
|
||||
Bluefruit core — e.g. Seeed XIAO nRF52840)
|
||||
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)
|
||||
- Victron devices with BLE "Instant Readout" enabled
|
||||
|
||||
The BLE backend is selected automatically at compile time from the board's
|
||||
architecture — no code changes are needed to switch platforms.
|
||||
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
|
||||
|
||||
@@ -87,6 +89,78 @@ example's `platformio.ini` includes ready-made ESP32 and nRF52 environments.
|
||||
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
|
||||
@@ -379,6 +453,12 @@ 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
|
||||
@@ -407,31 +487,45 @@ Based on official [Victron BLE documentation](https://www.victronenergy.com/live
|
||||
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.{h,cpp} Common API, device management, payload decoding
|
||||
├── 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
|
||||
├── 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 calls the shared `onAdvertisement()`. All decryption and
|
||||
decoding is common code. The correct backend is selected automatically at
|
||||
compile time from the board architecture (`ARDUINO_ARCH_ESP32` /
|
||||
`ARDUINO_ARCH_NRF52`) — there is nothing platform-specific in your sketch.
|
||||
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 →
|
||||
`onAdvertisement`); the rest is reused unchanged.
|
||||
hand to the core); the rest is reused unchanged.
|
||||
|
||||
> The data callback runs in the BLE event context (the scan task on ESP32, the
|
||||
> SoftDevice/Bluefruit handler on nRF52). Keep work in the callback light — copy
|
||||
> what you need and process it from `loop()`.
|
||||
> 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
|
||||
|
||||
See the `examples/` directory for:
|
||||
Arduino / PlatformIO, in [`examples/`](examples/):
|
||||
|
||||
- **MultiDevice**: Monitor multiple devices with callbacks. One sketch, multiple
|
||||
PlatformIO environments — builds for ESP32 (`esp32dev`, …) and nRF52840
|
||||
@@ -441,6 +535,18 @@ See the `examples/` directory for:
|
||||
- **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),
|
||||
|
||||
@@ -38,9 +38,32 @@ Pure C core + Zephyr support. One repo now serves three ecosystems: Arduino
|
||||
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)
|
||||
|
||||
|
||||
+12
-5
@@ -344,12 +344,13 @@ victronble/
|
||||
├── src/
|
||||
│ ├── victronble_core.c # pure C99, no dependencies
|
||||
│ ├── victronble_aes_sw.c
|
||||
│ ├── victronble_aes_psa.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
|
||||
│ ├── observer/ # Zephyr sample app — known devices, full records
|
||||
│ └── scan/ # Zephyr sample app — watch mode discovery
|
||||
└── tests/
|
||||
└── vectors/ # host-runnable, also Ztest under native_sim
|
||||
```
|
||||
@@ -520,11 +521,17 @@ configure — it will pay for itself during the record-type work.
|
||||
|
||||
## Stage 5 — Publish
|
||||
|
||||
1. `samples/observer/` that builds for `nrf52840dk/nrf52840` and `rak4631/nrf52840`.
|
||||
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. README with the west manifest snippet up front — the first question every Zephyr user
|
||||
has is how to add it to their workspace:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -56,6 +56,12 @@ int victronble_device_add(const bt_addr_le_t *addr,
|
||||
/** 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);
|
||||
|
||||
|
||||
+11
-3
@@ -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.",
|
||||
"keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, esp32-s3, esp32-c3, nrf52, nrf52840, nrf52832, xiao, iot, energy, monitoring",
|
||||
"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",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.sh3d.com.au/Sh3d/VictronBLE.git"
|
||||
@@ -17,7 +17,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"homepage": "https://gitea.sh3d.com.au/Sh3d/VictronBLE",
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"frameworks": ["arduino"],
|
||||
"platforms": ["espressif32", "nordicnrf52"],
|
||||
"headers": ["VictronBLE.h"],
|
||||
"dependencies": [],
|
||||
@@ -46,11 +46,19 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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/).
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+38
-4
@@ -48,6 +48,12 @@ 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)
|
||||
{
|
||||
@@ -80,8 +86,9 @@ static bool ad_cb(struct bt_data *data, void *user_data)
|
||||
stats.adverts++;
|
||||
|
||||
/* Registry check is a handful of compares — cheap enough here, and
|
||||
* it keeps other people's Victrons out of the queue. */
|
||||
if (find_device(ctx->addr) == NULL) {
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -121,8 +128,17 @@ static void decode_frame(const struct vb_frame *frame)
|
||||
k_mutex_lock(&dev_mtx, K_FOREVER);
|
||||
struct vb_device *dev = find_device(&frame->addr);
|
||||
|
||||
if (dev == NULL) { /* removed while queued */
|
||||
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));
|
||||
@@ -137,7 +153,15 @@ static void decode_frame(const struct vb_frame *frame)
|
||||
|
||||
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);
|
||||
@@ -161,8 +185,18 @@ static void decode_frame(const struct vb_frame *frame)
|
||||
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);
|
||||
victronble_device_type_str(rec.type), rec.nonce,
|
||||
frame->rssi);
|
||||
}
|
||||
|
||||
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
|
||||
if (cb->record != NULL) {
|
||||
|
||||
Reference in New Issue
Block a user