Examples and samples ready for Zephyr release

This commit is contained in:
2026-08-26 00:58:57 +10:00
parent 67ff8721c0
commit 22af47854d
20 changed files with 1158 additions and 29 deletions
+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;
}