diff --git a/.gitignore b/.gitignore index 76ef55e..2bc1e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -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.*/ diff --git a/QUICK_START.md b/QUICK_START.md index 9d732ae..a6db686 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -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 diff --git a/README.md b/README.md index e9bc96e..aa6b60f 100644 --- a/README.md +++ b/README.md @@ -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), diff --git a/VERSIONS b/VERSIONS index c34ed51..25bacc7 100644 --- a/VERSIONS +++ b/VERSIONS @@ -38,9 +38,32 @@ Pure C core + Zephyr support. One repo now serves three ecosystems: Arduino a west project or via `-DZEPHYR_EXTRA_MODULES=`; 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) diff --git a/docs/ZEPHYR_PORT.md b/docs/ZEPHYR_PORT.md index 804a02b..5744a95 100644 --- a/docs/ZEPHYR_PORT.md +++ b/docs/ZEPHYR_PORT.md @@ -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: diff --git a/examples/NativeDecode/Makefile b/examples/NativeDecode/Makefile new file mode 100644 index 0000000..2ee45cb --- /dev/null +++ b/examples/NativeDecode/Makefile @@ -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 diff --git a/examples/NativeDecode/README.md b/examples/NativeDecode/README.md new file mode 100644 index 0000000..78a21c8 --- /dev/null +++ b/examples/NativeDecode/README.md @@ -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` 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. diff --git a/examples/NativeDecode/main.c b/examples/NativeDecode/main.c new file mode 100644 index 0000000..f77ee44 --- /dev/null +++ b/examples/NativeDecode/main.c @@ -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 # 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 +#include +#include +#include + +#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; +} diff --git a/library.json b/library.json index ba64fb6..e7b564f 100644 --- a/library.json +++ b/library.json @@ -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", diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..5931029 --- /dev/null +++ b/samples/README.md @@ -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 +``` diff --git a/samples/observer/CMakeLists.txt b/samples/observer/CMakeLists.txt new file mode 100644 index 0000000..28d132c --- /dev/null +++ b/samples/observer/CMakeLists.txt @@ -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) diff --git a/samples/observer/README.md b/samples/observer/README.md new file mode 100644 index 0000000..f4c84a5 --- /dev/null +++ b/samples/observer/README.md @@ -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): + +``` + observer: VictronBLE observer starting + observer: monitoring Rainbow48V (E4:05:42:34:14:F3) + victronble: observing (interval 2048 window 18) + observer: E4:05:42:34:14:F3 (random) solar charger rssi -67 model 0xa060 nonce 4660 + observer: state bulk error 0 + observer: battery 13.24 V 5.4 A pv 340 W yield today 1200 Wh + observer: load n/a + 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/). diff --git a/samples/observer/prj.conf b/samples/observer/prj.conf new file mode 100644 index 0000000..89436e3 --- /dev/null +++ b/samples/observer/prj.conf @@ -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 diff --git a/samples/observer/sample.yaml b/samples/observer/sample.yaml new file mode 100644 index 0000000..186ac2c --- /dev/null +++ b/samples/observer/sample.yaml @@ -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 diff --git a/samples/observer/src/main.c b/samples/observer/src/main.c new file mode 100644 index 0000000..bdcffd2 --- /dev/null +++ b/samples/observer/src/main.c @@ -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 +#include + +#include +#include +#include +#include + +#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; +} diff --git a/samples/scan/CMakeLists.txt b/samples/scan/CMakeLists.txt new file mode 100644 index 0000000..94e5b2b --- /dev/null +++ b/samples/scan/CMakeLists.txt @@ -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) diff --git a/samples/scan/README.md b/samples/scan/README.md new file mode 100644 index 0000000..3fa7fea --- /dev/null +++ b/samples/scan/README.md @@ -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): + +``` + scan: VictronBLE discovery — logging every Victron advert in range + victronble: observing (interval 96 window 48) + victronble: watch: E4:05:42:34:14:F3 (random) rssi -67 type 0x01 (solar charger) len 31 keycheck 0x0d + victronble: watch: E6:45:59:78:3C:FB (random) rssi -82 type 0x02 (battery monitor) len 31 keycheck 0x3f + 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. diff --git a/samples/scan/prj.conf b/samples/scan/prj.conf new file mode 100644 index 0000000..866d778 --- /dev/null +++ b/samples/scan/prj.conf @@ -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 diff --git a/samples/scan/sample.yaml b/samples/scan/sample.yaml new file mode 100644 index 0000000..1b1fd64 --- /dev/null +++ b/samples/scan/sample.yaml @@ -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 diff --git a/samples/scan/src/main.c b/samples/scan/src/main.c new file mode 100644 index 0000000..63e3999 --- /dev/null +++ b/samples/scan/src/main.c @@ -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 +#include +#include + +#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; +}