54 Commits
Author SHA1 Message Date
scottp 22af47854d Examples and samples ready for Zephyr release 2026-08-26 00:58:57 +10:00
scottp 67ff8721c0 Zephyr: add watch mode (log every Victron advert heard)
victronble_watch_set(true) queues and logs adverts from any Victron
device — MAC, RSSI, record type, key-check byte, decode results — for
device discovery and key debugging at the bench.
2026-08-21 12:38:24 +10:00
scottp a4505019e8 Ignore compiled host test binary 2026-08-21 11:59:28 +10:00
scottp b390c0d579 v0.7.0: pure C core + Zephyr module
- Extract all decode/decrypt into a dependency-free C99 core
 (include/victronble.h, src/victronble_core.c): victronble_decode(),
 is_product_adv/key_matches pre-filters, NAN sentinels, LE accessors.
- AES-128-CTR behind a hook: weak-symbol bundled tiny-AES default,
 runtime override (victronble_set_aes_ctr) for PSA/mbedTLS/hardware.
- Arduino VictronBLE class becomes a thin wrapper over the core
 (registry + nonce dedup + rate limit); public C++ API unchanged,
 NAN converted back to the legacy 0 convention.
- Host test vectors (tests/vectors): openssl-generated ciphertext,
 independent of the bundled AES; all five payload shapes + negatives.
- Zephyr module: zephyr/module.yml + Kconfig (CONFIG_VICTRONBLE) +
 observer backend (victronble_zephyr.{h,c}) — scan cb pre-filters and
 queues, dedicated decode thread, listener callbacks, slow passive
 scan defaults, stats counters. docs/ZEPHYR_PORT.md records the plan.
- library.properties: fix URL (gitea, not the nonexistent GitHub).
2026-08-21 11:59:19 +10:00
scottp 617c240e02 check version before release 2026-06-08 17:14:39 +10:00
scottp 8e91bd7b83 Add a rak4631 board definition testing 2026-06-07 23:22:36 +10:00
scottp 96184d888b Fix chips supported 2026-06-07 19:27:12 +10:00
scottp 2505612f0b Working across ESP32 S3, ESP32 C3 and nRF52 2026-06-07 19:23:13 +10:00
scottp abf0e414da Cleanup READMED and release notes 2026-06-05 00:00:25 +10:00
scottp 865c8dc4d4 ignore pka file 2026-06-04 23:48:47 +10:00
scottp 6c30e0d01d Branch version ready for testing with nRF52 2026-06-04 23:47:55 +10:00
scottp cedef26f34 Update info 2026-06-04 22:55:40 +10:00
scottp c2c5441398 ignore local 2026-06-04 21:21:55 +10:00
scottp 0791fd8cec Bug fixes for reported issues 2026-06-04 21:21:18 +10:00
scottp 55b12ec837 Plans for part 2 - multiple bluetooth devices 2026-02-28 15:00:45 +11:00
scottp cd3e32abeb Improve readme ready for v0.4 release 2026-02-28 14:40:41 +11:00
scottp 3a0adfab82 Versions v0.4 ready for release 2026-02-28 14:38:30 +11:00
scottp c3ee3577e1 Fix to be non blocking without tasks 2026-02-28 14:31:42 +11:00
scottp 166bc003d0 Update notes 2026-02-28 13:52:16 +11:00
scottp 4c9819758f Single callback version - vastly simplified. 2026-02-28 13:34:20 +11:00
scottp 08417ec8f1 More planning 2026-02-28 12:31:04 +11:00
scottp 4e5e4c258d Forwarding code 2026-02-17 09:27:48 +11:00
scottp cdb94ff23d Work on receiver and sender 2026-02-15 19:20:03 +11:00
scottp a3cd309581 Repeater and Test code for ESP Now 2026-02-15 19:10:19 +11:00
scottp a49686087c New version with smaller memory footprint etc 2026-02-12 18:33:56 +11:00
scottp 4aa386a5da Keep v0.3.1 2026-02-12 18:10:35 +11:00
scottp 6ab8ae1618 Created new logging example 2026-02-12 18:04:02 +11:00
scottp 77d1545378 Examples todo 2025-12-30 20:54:04 +11:00
scottp 8ebe36c58e Fix notes on testing 2025-12-29 20:35:47 +11:00
scottp 0fb4e82801 Fix name 2025-12-29 20:31:07 +11:00
scottp 76a0935518 Decoding working for MPPT 2025-12-29 20:22:41 +11:00
scottp 66b24d9434 Finally working decode MPPT 2025-12-29 20:00:05 +11:00
scottp 53418b5889 work on better mac address 2025-12-29 19:26:32 +11:00
scottp 3acb14872e Cleaning up by using structs and reusing data blocks 2025-12-29 19:12:47 +11:00
scottp 14dfd41626 Experimental version 2025-12-29 13:38:47 +11:00
scottp c9d49336d4 Working serial on S3 too. Interesting... 2025-12-29 11:40:46 +11:00
scottp eb9e1261c8 Merge branch 'main' of https://gitea.sh3d.com.au/sh3d/VictronBLE 2025-12-29 11:16:43 +11:00
scottp 047e5c2580 Add core2 2025-12-29 11:16:41 +11:00
scottp 531b67263c Cleanup only 2025-12-29 11:09:33 +11:00
scottp 28dec5ce3d Improved structs 2025-12-28 23:35:27 +11:00
scottp 04a5c545fd Working C3 build 2025-12-28 23:27:40 +11:00
scottp 054dd01a71 Playing with debug 2025-12-19 12:46:28 +11:00
scottp 2d0f88fd0b Work on decoding using structs 2025-12-18 22:27:15 +11:00
scottp 3d09d234f0 Experimenting and decoding - seems some structs are wrong, check original code 2025-12-18 21:49:04 +11:00
scottp 425eb1a178 TODO and m5stick and debug 2025-12-18 20:43:10 +11:00
scottp f21b6008a1 Ignore builds 2025-12-18 18:26:27 +11:00
scottp f2adf5b462 Fix version for examples 2025-12-18 18:23:31 +11:00
scottp 44294e26c6 Fix library 2025-12-18 18:22:33 +11:00
scottp cd94b6dd08 Unsure name 2025-12-18 18:17:49 +11:00
scottp 340539275a Fix name 2025-12-18 18:10:05 +11:00
scottp bda096f245 Fix version 2025-12-18 18:08:08 +11:00
scottp d760e7d8dc Initial version only 2025-12-18 17:53:39 +11:00
scottp b8c5f948fe Copyright etc 2025-12-18 17:48:55 +11:00
scottp a89cef3c89 Initial readme up 2025-12-18 17:39:13 +11:00
23 changed files with 1205 additions and 35 deletions
+9
View File
@@ -72,3 +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
View File
@@ -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
+125 -19
View File
@@ -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),
+23
View File
@@ -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
View File
@@ -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:
+30
View File
@@ -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
+84
View File
@@ -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.
+251
View File
@@ -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;
}
+6
View File
@@ -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
View File
@@ -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",
+44
View File
@@ -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
```
+7
View File
@@ -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)
+88
View File
@@ -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/).
+19
View File
@@ -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
+10
View File
@@ -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
+278
View File
@@ -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;
}
+7
View File
@@ -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)
+58
View File
@@ -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.
+25
View File
@@ -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
+10
View File
@@ -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
+66
View File
@@ -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
View File
@@ -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) {
Binary file not shown.