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).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:59:19 +10:00
co-authored by Claude Fable 5
parent cc8c4d36d5
commit 037af33154
20 changed files with 2145 additions and 284 deletions
+12
View File
@@ -0,0 +1,12 @@
# Zephyr module entry point. PlatformIO/Arduino builds read library.json /
# library.properties and ignore this file entirely.
if(CONFIG_VICTRONBLE)
zephyr_library()
zephyr_library_sources(src/victronble_core.c)
zephyr_library_sources(src/victronble_zephyr.c)
zephyr_library_sources_ifdef(CONFIG_VICTRONBLE_CRYPTO_SOFTWARE
src/victronble_aes_sw.c
src/crypto/vble_aes.c
)
zephyr_include_directories(include)
endif()
+73
View File
@@ -0,0 +1,73 @@
menuconfig VICTRONBLE
bool "Victron Instant Readout BLE observer"
depends on BT_OBSERVER
help
Passive BLE observer for Victron Energy devices broadcasting
Instant Readout advertisements (manufacturer ID 0x02E1,
AES-128-CTR encrypted). No connection or pairing required.
The application must call bt_enable() before victronble_start().
if VICTRONBLE
config VICTRONBLE_MAX_DEVICES
int "Maximum monitored devices"
default 4
config VICTRONBLE_QUEUE_DEPTH
int "Advertisement queue depth"
default 8
help
Frames are copied off the BT RX thread into this queue and decoded
by a dedicated thread. A full queue drops the newest frame and
counts the drop (see victronble_get_stats()).
config VICTRONBLE_THREAD_STACK_SIZE
int "Decode thread stack size"
default 2048
config VICTRONBLE_THREAD_PRIORITY
int "Decode thread priority (preemptible)"
default 10
config VICTRONBLE_DEDUP
bool "Drop repeated advertisements by nonce counter"
default y
help
Each advertisement is broadcast repeatedly on three channels.
Tracking the last nonce per device suppresses duplicates so the
record callback only fires when the device published new data.
config VICTRONBLE_SCAN_INTERVAL
int "Scan interval (0.625 ms units)"
default 2048
help
Default 2048 = 1.28 s (BT_GAP_SCAN_SLOW_INTERVAL_1). Victron
devices advertise roughly once per second, so a low duty cycle
catches records at a fraction of the radio-on time.
config VICTRONBLE_SCAN_WINDOW
int "Scan window (0.625 ms units)"
default 18
help
Default 18 = 11.25 ms (BT_GAP_SCAN_SLOW_WINDOW_1). Raise toward
the interval for faster acquisition at higher power draw.
choice VICTRONBLE_CRYPTO
prompt "AES-CTR backend"
default VICTRONBLE_CRYPTO_SOFTWARE
config VICTRONBLE_CRYPTO_SOFTWARE
bool "Bundled software AES-128"
help
The library's dependency-free tiny-AES CTR implementation. An
external backend can instead provide a strong
victronble_aes_ctr_default() (weak symbol) or register one at
runtime with victronble_set_aes_ctr().
endchoice
module = VICTRONBLE
module-str = victronble
source "subsys/logging/Kconfig.template.log_config"
endif # VICTRONBLE
+42
View File
@@ -1,5 +1,47 @@
# Version History # Version History
## 0.7.0 (2026-08-21)
Pure C core + Zephyr support. One repo now serves three ecosystems: Arduino
(unchanged public API), PlatformIO, and Zephyr west workspaces.
### Pure C99 core (`include/victronble.h`, `src/victronble_core.c`)
- All decoding and decryption extracted into a dependency-free, reentrant,
allocation-free C core: `victronble_decode(mfg, len, key, out)` plus the
cheap pre-filters `victronble_is_product_adv()` / `victronble_key_matches()`
and helpers (`victronble_parse_key`, `strerror`, `device_type_str`,
`state_str`). Explicit little-endian accessors — no packed-struct punning.
- Absent wire fields are now NAN in the core (test with `isnan()`); the
Arduino wrapper converts back to the legacy `0` convention, so existing
sketches see identical values.
- AES is behind a CTR-shaped hook (`victronble_aes_ctr_fn`): weak-symbol
default = the bundled tiny-AES (linker-droppable), runtime override via
`victronble_set_aes_ctr()` for PSA/mbedTLS/hardware backends.
- `VictronBLE` (Arduino) is now a thin wrapper over the core — registry,
nonce dedup and rate limiting only. Public C++ API unchanged.
### Host test vectors (`tests/vectors/`)
- Plain-gcc harness (`run.sh`), no framework. Positive vectors for all five
payload shapes are generated by `gen_vectors.py` and encrypted with the
openssl CLI — independent of the bundled AES, so the CTR/nonce semantics
are cross-checked — plus negative cases (truncated, wrong vendor, wrong
key, unsupported record type).
### Zephyr module (`zephyr/module.yml`, `Kconfig`, `CMakeLists.txt`)
- `CONFIG_VICTRONBLE` (needs `CONFIG_BT_OBSERVER`): passive-scan observer
(`include/victronble_zephyr.h`, `src/victronble_zephyr.c`). The scan
callback only pre-filters and queues; a dedicated thread decrypts, decodes,
nonce-dedups and fans out to registered listeners
(`victronble_device_add(addr, key)` / `victronble_cb_register()` /
`victronble_start()`), with `victronble_get_stats()` counters. Default scan
is slow/low-duty (1.28 s / 11.25 ms — Victron advertises ~1 Hz). Consume as
a west project or via `-DZEPHYR_EXTRA_MODULES=<path>`; see
`docs/ZEPHYR_PORT.md` for the porting plan this implements.
### Fixed
- `library.properties` URL now points at the real repo (gitea) instead of a
nonexistent GitHub mirror.
## 0.6.0 (2026-06-04) ## 0.6.0 (2026-06-04)
Multi-platform support. The library now runs on **nRF52840** (Adafruit/Seeed Multi-platform support. The library now runs on **nRF52840** (Adafruit/Seeed
+560
View File
@@ -0,0 +1,560 @@
# victronble → pure C core + Zephyr module
Staged porting plan. Five stages, each independently shippable. Stages 02 leave the
existing PlatformIO library working the whole way through; Zephyr only appears at Stage 3.
**Assumption throughout:** the protocol offsets, model IDs, sentinel values and per-device
bit layouts already exist and are correct in your ESP32/nRF52 implementation. This plan does
not re-derive them — it restructures around them. Where a byte offset appears below it is
illustrative; take the canonical values from your working code and from Victron's
*Extra Manufacturer Data* PDF.
---
## Stage 0 — Throwaway Zephyr spike
**Time:** 2 hours. **Output:** deleted afterwards. **Purpose:** de-risk three unknowns
before you commit to an API shape.
Copy the parse functions verbatim into a single `main.c`. Hardcode the key and MAC.
`printk` one decoded SmartSolar record. Do not abstract anything.
What you are actually finding out:
1. **Does the bundled AES build clean under Zephyr's toolchain** with no Arduino headers
dragged in behind it. If it doesn't, you learn that now rather than at Stage 3.
2. **Where the decrypt has to live.** The scan callback runs on the BT RX thread. Time
spent there delays HCI event processing. Confirm you can decrypt inline for a spike,
then confirm you don't want to.
3. **Whether `bt_data_parse()` gives you what you expect.** In particular that
`BT_DATA_MANUFACTURER_DATA` arrives with the company ID as the first two bytes of
`data->data`, and that the payload is intact at the length you expect.
Minimal `prj.conf`:
```
CONFIG_BT=y
CONFIG_BT_OBSERVER=y
CONFIG_BT_DEVICE_NAME="victron-spike"
CONFIG_LOG=y
CONFIG_LOG_MODE_IMMEDIATE=y
```
Minimal scan setup:
```c
static const struct bt_le_scan_param scan_param = {
.type = BT_LE_SCAN_TYPE_PASSIVE,
.options = BT_LE_SCAN_OPT_NONE,
.interval = BT_GAP_SCAN_FAST_INTERVAL,
.window = BT_GAP_SCAN_FAST_WINDOW,
};
static bool ad_cb(struct bt_data *data, void *user_data)
{
if (data->type != BT_DATA_MANUFACTURER_DATA) {
return true; /* keep walking the AD structures */
}
if (data->data_len < 10 || sys_get_le16(data->data) != 0x02E1) {
return true;
}
/* ... spike decrypt here ... */
return false; /* found it, stop */
}
static void scan_recv(const bt_addr_le_t *addr, int8_t rssi,
uint8_t adv_type, struct net_buf_simple *ad)
{
bt_data_parse(ad, ad_cb, (void *)addr);
}
```
Two traps worth knowing before you hit them:
- **`bt_data_parse()` consumes the buffer.** It pulls from the `net_buf_simple` as it
walks. If you need the raw advertisement afterwards, clone the state or copy the bytes
out first.
- **Callback registration is version-sensitive.** The `bt_le_scan_start(&param, cb)` form
and the newer `bt_le_scan_cb_register()` / `struct bt_le_scan_cb` form have coexisted
across releases with the former deprecated at various points. Check which one your
Zephyr/NCS version wants rather than trusting any example you find online, including
this one.
**Exit criterion:** one real record from one real SmartSolar, decrypted and printed
correctly on hardware. Then delete the spike.
---
## Stage 1 — Extract the pure C99 core
This is the bulk of the work and the part with value independent of Zephyr. When it's
done you can unit-test the parser on your workstation for the first time.
### Rules for the core
- C99. No C++, no `String`, no Arduino headers, no `Serial`.
- No allocation. Ever. Caller owns all storage.
- No I/O. No logging. Return codes only — the caller decides what to say about them.
- Freestanding-safe: `<stdint.h>`, `<stddef.h>`, `<string.h>`, `<math.h>` only.
- Reentrant. No file-scope mutable state in the parse path.
### `include/victronble.h`
```c
#ifndef VICTRONBLE_H
#define VICTRONBLE_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#define VICTRONBLE_COMPANY_ID 0x02E1u /* Victron Energy BV */
#define VICTRONBLE_KEY_LEN 16
#define VICTRONBLE_MAX_MFG_LEN 31
typedef enum {
VICTRONBLE_OK = 0,
VICTRONBLE_ERR_NOT_VICTRON = -1, /* company ID mismatch */
VICTRONBLE_ERR_SHORT = -2, /* truncated advertisement */
VICTRONBLE_ERR_NOT_PRODUCT = -3, /* not a product-advertisement record */
VICTRONBLE_ERR_KEY_MISMATCH = -4, /* key check byte failed */
VICTRONBLE_ERR_UNSUPPORTED = -5, /* known record type, no decoder */
VICTRONBLE_ERR_CRYPTO = -6, /* AES backend failed */
VICTRONBLE_ERR_DUPLICATE = -7, /* counter already seen (dedup enabled) */
} victronble_err_t;
typedef enum {
VICTRONBLE_DEV_UNKNOWN = 0,
VICTRONBLE_DEV_SOLAR_CHARGER,
VICTRONBLE_DEV_BATTERY_MONITOR,
VICTRONBLE_DEV_INVERTER,
VICTRONBLE_DEV_DCDC_CONVERTER,
VICTRONBLE_DEV_SMART_LITHIUM,
VICTRONBLE_DEV_AC_CHARGER,
/* extend from your existing enum */
} victronble_device_type_t;
typedef struct {
float battery_voltage; /* V, NAN if not present */
float battery_current; /* A, NAN if not present */
float yield_today; /* kWh */
float pv_power; /* W */
float load_current; /* A, NAN if load output absent */
uint8_t state;
uint8_t error;
} victronble_solar_charger_t;
/* ... battery monitor, inverter, dcdc, etc. ... */
typedef struct {
victronble_device_type_t type;
uint16_t model_id;
uint16_t counter; /* nonce / data counter, as received */
union {
victronble_solar_charger_t solar;
victronble_battery_monitor_t batmon;
/* ... */
} u;
} victronble_record_t;
/**
* Decode one Victron manufacturer-data blob.
*
* @param mfg Manufacturer-specific data, starting at the company ID.
* @param len Length of @p mfg.
* @param key 16-byte per-device advertisement key.
* @param out Populated on VICTRONBLE_OK. Untouched otherwise.
*
* Reentrant, allocation-free, no I/O.
*/
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN],
victronble_record_t *out);
/** Cheap pre-filter: company ID + record type only, no crypto. */
bool victronble_is_product_adv(const uint8_t *mfg, size_t len);
/** Key check byte test, so callers with several keys can pick one without decrypting. */
bool victronble_key_matches(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN]);
const char *victronble_strerror(victronble_err_t err);
#ifdef __cplusplus
}
#endif
#endif /* VICTRONBLE_H */
```
`victronble_key_matches()` is worth having separately. With several monitored devices you
otherwise burn an AES operation per device per advertisement just to find out which key
applies. The key check byte answers it for free.
### Sentinels
Victron encodes "not available" as per-field sentinel values, and they differ by field
width and signedness. Getting this wrong is the most likely source of a plausible-looking
but wrong reading, so decide the convention once and apply it everywhere.
Recommendation: **`NAN` for every float field that has a sentinel.** It propagates
correctly through arithmetic, tests cleanly with `isnan()`, and can't be confused with a
real zero the way a magic float can. For integer fields (state, error codes) keep the raw
value and document the sentinel.
If you'd rather avoid `<math.h>` on the smallest targets, the alternative is a
`uint32_t valid` bitmask per record — more code at every call site, but no FP dependency.
I'd only do this if flash is genuinely tight.
### Header layout
Encode the frame header as one internal struct with a single parse function, rather than
scattered offset arithmetic. Fields: record type, model ID (LE16), device/read-out type,
nonce counter (LE16), key check byte, then ciphertext offset and length. Use explicit
`sys_get_le16()`-style accessors rather than casting to packed structs — you'll want this
core to build on anything, and unaligned struct punning is exactly the kind of thing that
works on Cortex-M4 and bites you elsewhere.
---
## Stage 2 — AES abstraction
Split out because it's the one design decision that's hard to reverse later.
### Make the hook CTR-shaped, not ECB-shaped
Tempting to expose a single AES-128-ECB block encrypt, since for a ≤16-byte payload
CTR reduces to *ECB(counter block) XOR ciphertext* and you'd never need more. Don't.
Some record types (VE.Bus, Lynx BMS) exceed one block, and — more importantly — PSA and
every hardware accelerator expose CTR natively. An ECB-shaped hook forces those backends
to reimplement the counter loop that PSA would have done for them.
```c
/**
* AES-128-CTR transform hook.
*
* @param key 16-byte key.
* @param iv 16-byte initial counter block (nonce in the low bytes, rest zero).
* @param in Ciphertext.
* @param out Plaintext. May alias @p in.
* @param len Byte count, not necessarily a multiple of 16.
* @param user Opaque context supplied at registration.
* @return 0 on success, negative on failure.
*/
typedef int (*victronble_aes_ctr_fn)(const uint8_t key[16],
const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user);
```
### Selection mechanism
Use a **weak symbol default plus a runtime setter**:
```c
__attribute__((weak))
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
```
The weak symbol lets the linker drop the bundled software AES entirely when a backend
overrides it — which matters on a flash-constrained solar node. The runtime setter covers
the case where the backend is chosen at runtime or in a test harness. Both, not one.
### Backends to ship
| Backend | File | Notes |
|---|---|---|
| Bundled software | `victronble_aes_sw.c` | Current implementation, unchanged. Default. Zero dependencies — keep this property, it's the reason your library ports easily. |
| PSA Crypto | `victronble_aes_psa.c` | `psa_crypto_init()` once, then `psa_cipher_encrypt()` with `PSA_ALG_CTR`. On nRF52840 this routes to CryptoCell (CC310). |
| mbedTLS | `victronble_aes_mbedtls.c` | Optional. `mbedtls_aes_crypt_ctr()`. Mostly for ESP-IDF users who already link it. |
Two PSA notes worth writing down now:
- Key lifetime. Importing a volatile key per advertisement is wasteful. Import once per
monitored device at registration and cache the `psa_key_id_t`, which means your device
registry needs somewhere to hold it — plan the struct field now rather than retrofitting.
- `psa_crypto_init()` must have run before any use, and on NCS the relevant Kconfig lives
under `NRF_SECURITY` rather than plain `MBEDTLS_*`. This diverges between upstream Zephyr
and NCS and is the single most annoying part of Stage 3.
### Host test harness
This is the payoff for Stages 12. Capture advertisement frames from your working ESP32
build as hex, pair them with expected decoded values, and run the core under plain `gcc`
on the workstation:
```c
static const struct {
const char *hex;
const char *key_hex;
victronble_err_t expect_err;
victronble_device_type_t expect_type;
float expect_batt_v;
} vectors[] = {
{ "e10210...", "0df4d0...", VICTRONBLE_OK, VICTRONBLE_DEV_SOLAR_CHARGER, 13.24f },
/* one per device type, plus: truncated frame, wrong key, unknown record type */
};
```
No test framework needed — a `main()` and a non-zero exit is enough, and it drops straight
into CI. Same shape as the LoRaScope parser vectors. Include the negative cases; the
error paths are where a parser rewrite actually breaks.
---
## Stage 3 — Arduino wrapper over the C core
Before touching Zephyr, prove the extraction by making the existing library a consumer
of it. `VictronBLE` becomes a thin C++ class that owns the device table and calls
`victronble_decode()`. The BLE backends (NimBLE / Bluefruit) keep their current structure
and feed raw manufacturer bytes into the core.
If the public C++ API is unchanged, this is a patch release and existing PlatformIO users
notice nothing. That's the goal. Any pressure to change the C++ API here is a signal that
the C core's shape is wrong — fix the core, not the wrapper.
---
## Stage 4 — Zephyr module
Now the C core exists and is tested, this is mostly plumbing.
### Repo layout
One repo serves both ecosystems. PlatformIO reads `library.json` and ignores CMake;
Zephyr reads `zephyr/module.yml` and ignores `library.json`.
```
victronble/
├── library.json # PlatformIO
├── CMakeLists.txt # Zephyr module entry point
├── Kconfig
├── zephyr/
│ └── module.yml
├── include/
│ └── victronble.h # pure C core
│ └── victronble_zephyr.h # Zephyr-specific observer API
├── src/
│ ├── victronble_core.c # pure C99, no dependencies
│ ├── victronble_aes_sw.c
│ ├── victronble_aes_psa.c
│ ├── victronble_zephyr.c # scan + workqueue + device registry
│ ├── VictronBLE.cpp # Arduino wrapper
│ └── ble_backend_*.cpp # NimBLE / Bluefruit
├── samples/
│ └── observer/ # Zephyr sample app
└── tests/
└── vectors/ # host-runnable, also Ztest under native_sim
```
### `zephyr/module.yml`
```yaml
name: victronble
build:
cmake: .
kconfig: Kconfig
```
### `CMakeLists.txt`
```cmake
if(CONFIG_VICTRONBLE)
zephyr_library()
zephyr_library_sources(src/victronble_core.c)
zephyr_library_sources(src/victronble_zephyr.c)
zephyr_library_sources_ifdef(CONFIG_VICTRONBLE_CRYPTO_SOFTWARE src/victronble_aes_sw.c)
zephyr_library_sources_ifdef(CONFIG_VICTRONBLE_CRYPTO_PSA src/victronble_aes_psa.c)
zephyr_include_directories(include)
endif()
```
### `Kconfig`
```
menuconfig VICTRONBLE
bool "Victron Instant Readout BLE observer"
depends on BT_OBSERVER
help
Passive BLE observer for Victron Energy devices broadcasting
Instant Readout advertisements. No connection or pairing required.
if VICTRONBLE
config VICTRONBLE_MAX_DEVICES
int "Maximum monitored devices"
default 4
config VICTRONBLE_QUEUE_DEPTH
int "Advertisement queue depth"
default 8
help
Frames are copied off the BT RX thread into this queue and decoded
by a dedicated thread. Overflow drops the oldest frame.
config VICTRONBLE_THREAD_STACK_SIZE
int "Decode thread stack size"
default 1024
config VICTRONBLE_THREAD_PRIORITY
int "Decode thread priority"
default 10
config VICTRONBLE_DEDUP
bool "Drop repeated advertisements by nonce counter"
default y
help
Each advertisement is broadcast on three channels and repeated.
Tracking the last counter per device suppresses the duplicates.
choice VICTRONBLE_CRYPTO
prompt "AES-CTR backend"
default VICTRONBLE_CRYPTO_SOFTWARE
config VICTRONBLE_CRYPTO_SOFTWARE
bool "Bundled software AES-128"
config VICTRONBLE_CRYPTO_PSA
bool "PSA Crypto"
depends on MBEDTLS_PSA_CRYPTO_C || NRF_SECURITY
endchoice
module = VICTRONBLE
module-str = victronble
source "subsys/logging/Kconfig.template.log_config"
endif
```
### Threading model
Do not decode in the scan callback. Copy and hand off:
```c
struct victronble_frame {
bt_addr_le_t addr;
int8_t rssi;
uint8_t len;
uint8_t data[VICTRONBLE_MAX_MFG_LEN];
};
K_MSGQ_DEFINE(vb_msgq, sizeof(struct victronble_frame),
CONFIG_VICTRONBLE_QUEUE_DEPTH, 4);
```
The scan callback pre-filters with `victronble_is_product_adv()` — company ID and record
type, no crypto — then `k_msgq_put()` with `K_NO_WAIT`. A dedicated thread pops frames,
matches against the device registry by address, calls `victronble_decode()`, and invokes
the user callback from its own context. Drop on full queue and count the drops; a
saturated queue is a real signal on a busy site and you want it visible.
Use a dedicated thread rather than the system workqueue. Crypto on the system workqueue
will eventually collide with something else that assumed it was free.
### Public Zephyr API
Since you're dropping the C++ callback structure, make this idiomatic Zephyr rather than
a translation of the Arduino API. A registered-listener list in the style of
`bt_conn_cb_register()` will read as native to anyone in this ecosystem:
```c
struct victronble_cb {
void (*record)(const bt_addr_le_t *addr, int8_t rssi,
const victronble_record_t *rec);
void (*decode_error)(const bt_addr_le_t *addr, victronble_err_t err);
sys_snode_t node;
};
int victronble_cb_register(struct victronble_cb *cb);
int victronble_device_add(const bt_addr_le_t *addr,
const uint8_t key[VICTRONBLE_KEY_LEN]);
int victronble_device_remove(const bt_addr_le_t *addr);
int victronble_start(void);
int victronble_stop(void);
```
Consider a devicetree binding for statically configured devices later — it's the most
Zephyr-native option and would let a node declare its Victron gear in the overlay — but
don't do it in the first release. Get the runtime API right first.
### Scan parameters
`BT_GAP_SCAN_FAST_*` is wrong for a long-running solar node. Victron broadcasts roughly
once per second, so a low duty cycle catches everything at a fraction of the radio-on
time. Start at `BT_GAP_SCAN_SLOW_INTERVAL_1` / `BT_GAP_SCAN_SLOW_WINDOW_1` and measure —
you have the PPK2 set up, and this is exactly the knob worth characterising for the
downstream OGLAS power budget.
### Sample `prj.conf`
```
CONFIG_BT=y
CONFIG_BT_OBSERVER=y
CONFIG_BT_DEVICE_NAME="victron-observer"
CONFIG_VICTRONBLE=y
CONFIG_VICTRONBLE_MAX_DEVICES=4
CONFIG_LOG=y
CONFIG_VICTRONBLE_LOG_LEVEL_INF=y
```
On a busy site you may need to raise `CONFIG_BT_BUF_EVT_DISCARDABLE_COUNT`; advertising
reports are discardable events and the default pool is easy to exhaust with a passive
scan in a dense RF environment.
### Development loop worth setting up
`native_sim` with `CONFIG_BT_USERCHAN=y` binds the Zephyr Bluetooth host to a real HCI
controller on the Linux host. You can run the full observer on the workstation against
your actual SmartSolar, with gdb and no flash cycle. Worth the half hour it takes to
configure — it will pay for itself during the record-type work.
---
## Stage 5 — Publish
1. `samples/observer/` that builds for `nrf52840dk/nrf52840` and `rak4631/nrf52840`.
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:
```yaml
manifest:
remotes:
- name: dd
url-base: https://github.com/scottp
projects:
- name: victronble
remote: dd
revision: main
path: modules/lib/victronble
```
4. Announce, roughly in descending order of return:
- The Victron Community *Bluetooth advertising protocol* thread.
- PR to `keshavdv/victron-ble`'s related-projects list — that repo is the ecosystem hub.
- Nordic DevZone and the Zephyr Discord `#bluetooth` channel.
- `zephyr-rtos` GitHub topic, awesome-list PR.
---
## Sequencing summary
| Stage | Effort | Ships? | Risk if skipped |
|---|---|---|---|
| 0 — Spike | 2 h | No | Design the C API around assumptions Zephyr won't honour |
| 1 — C core | 12 days | Yes (patch) | — |
| 2 — AES hook | half day | Yes | Hard to change once backends exist downstream |
| 3 — Arduino wrapper | half day | Yes (patch) | Core shape never validated against a real consumer |
| 4 — Zephyr module | 12 days | Yes (minor) | — |
| 5 — Publish | half day | Yes | Nobody finds it |
The only stage with real unknowns is 0, which is why it's first and disposable.
+206
View File
@@ -0,0 +1,206 @@
/**
* victronble — pure C99 decoder for Victron Energy "Instant Readout" BLE
* advertisements (manufacturer ID 0x02E1, record type 0x10, AES-128-CTR).
*
* This is the portable core of the VictronBLE library: no Arduino, no BLE
* stack, no allocation, no I/O, reentrant. Feed it one manufacturer-specific
* data blob (starting at the company ID) plus the device's 16-byte
* advertisement key; get a decoded record back. Transport (scanning), device
* registries, rate limiting and logging belong to the platform wrappers
* (Arduino C++ class, Zephyr module).
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#ifndef VICTRONBLE_H
#define VICTRONBLE_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#define VICTRONBLE_COMPANY_ID 0x02E1u /* Victron Energy BV */
#define VICTRONBLE_KEY_LEN 16
#define VICTRONBLE_MAX_CIPHER_LEN 21 /* encrypted payload in one advert */
#define VICTRONBLE_MIN_MFG_LEN 10 /* header before the ciphertext */
typedef enum {
VICTRONBLE_OK = 0,
VICTRONBLE_ERR_NOT_VICTRON = -1, /* company ID mismatch */
VICTRONBLE_ERR_SHORT = -2, /* truncated advertisement */
VICTRONBLE_ERR_NOT_PRODUCT = -3, /* not a product-advertisement record */
VICTRONBLE_ERR_KEY_MISMATCH = -4, /* key check byte failed */
VICTRONBLE_ERR_UNSUPPORTED = -5, /* known record type, no decoder */
VICTRONBLE_ERR_CRYPTO = -6, /* AES backend failed */
} victronble_err_t;
/* Values are the raw Victron record-type IDs. The inverter family
* (0x03/0x06/0x0B/0x0C) all decode to VICTRONBLE_DEV_INVERTER. */
typedef enum {
VICTRONBLE_DEV_UNKNOWN = 0x00,
VICTRONBLE_DEV_SOLAR_CHARGER = 0x01,
VICTRONBLE_DEV_BATTERY_MONITOR = 0x02,
VICTRONBLE_DEV_INVERTER = 0x03,
VICTRONBLE_DEV_DCDC_CONVERTER = 0x04,
VICTRONBLE_DEV_SMART_LITHIUM = 0x05,
VICTRONBLE_DEV_INVERTER_RS = 0x06,
VICTRONBLE_DEV_GX_DEVICE = 0x07,
VICTRONBLE_DEV_AC_CHARGER = 0x08,
VICTRONBLE_DEV_BATTERY_PROTECT = 0x09,
VICTRONBLE_DEV_LYNX_SMART_BMS = 0x0A,
VICTRONBLE_DEV_MULTI_RS = 0x0B,
VICTRONBLE_DEV_VE_BUS = 0x0C,
VICTRONBLE_DEV_DC_ENERGY_METER = 0x0D,
VICTRONBLE_DEV_ORION_XS = 0x0F,
} victronble_device_type_t;
/* Charger states shared by solar / AC chargers (VE.Direct "CS"). */
enum {
VICTRONBLE_STATE_OFF = 0,
VICTRONBLE_STATE_LOW_POWER = 1,
VICTRONBLE_STATE_FAULT = 2,
VICTRONBLE_STATE_BULK = 3,
VICTRONBLE_STATE_ABSORPTION = 4,
VICTRONBLE_STATE_FLOAT = 5,
VICTRONBLE_STATE_STORAGE = 6,
VICTRONBLE_STATE_EQUALIZE = 7,
VICTRONBLE_STATE_INVERTING = 9,
VICTRONBLE_STATE_POWER_SUPPLY = 11,
VICTRONBLE_STATE_EXTERNAL_CONTROL = 252,
};
/* Fields the wire encodes as "not available" are NAN (test with isnan()).
* Integer fields keep the raw value; sentinels are documented per field. */
typedef struct {
uint8_t state; /* VICTRONBLE_STATE_* */
uint8_t error;
float battery_voltage; /* V */
float battery_current; /* A */
float pv_power; /* W */
uint32_t yield_today_wh; /* Wh */
float load_current; /* A, NAN if no load output */
} victronble_solar_charger_t;
typedef struct {
float voltage; /* V */
float current; /* A */
float temperature; /* degC, NAN unless aux mode = temperature */
float aux_voltage; /* V, NAN unless aux mode = aux voltage */
uint16_t remaining_minutes; /* time-to-go; 0xFFFF = not available */
float consumed_ah; /* Ah, negative = consumed */
float soc; /* % */
uint8_t aux_mode; /* 0=aux V, 1=midpoint, 2=temperature, 3=none */
uint16_t alarm; /* raw 16-bit alarm bitmask */
} victronble_battery_monitor_t;
typedef struct {
uint8_t state;
uint8_t alarms; /* raw alarm bits: 0x01 lowV, 0x02 highV,
* 0x04 highT, 0x08 overload */
float battery_voltage; /* V */
float battery_current; /* A */
float ac_power; /* W (signed) */
} victronble_inverter_t;
typedef struct {
uint8_t state; /* charge state */
uint8_t error;
float input_voltage; /* V */
float output_voltage; /* V */
float output_current; /* A */
} victronble_dcdc_t;
typedef struct {
uint8_t state;
uint8_t error;
float voltage1, current1; /* output 1 (V, A), NAN if absent */
float voltage2, current2; /* output 2 */
float voltage3, current3; /* output 3 */
float temperature; /* degC, NAN if not available */
float ac_current; /* A, NAN if not available */
} victronble_ac_charger_t;
typedef struct {
victronble_device_type_t type; /* decoded family (inverter collapsed) */
uint8_t record_type; /* raw record type from the wire */
uint16_t model_id;
uint8_t readout_type;
uint16_t nonce; /* data counter, as received */
union {
victronble_solar_charger_t solar;
victronble_battery_monitor_t batmon;
victronble_inverter_t inverter;
victronble_dcdc_t dcdc;
victronble_ac_charger_t ac;
} u;
} victronble_record_t;
/**
* Decode one Victron manufacturer-data blob.
*
* @param mfg Manufacturer-specific data, starting at the company ID.
* @param len Length of @p mfg in bytes.
* @param key 16-byte per-device advertisement key.
* @param out Populated on VICTRONBLE_OK; untouched otherwise.
*
* Reentrant, allocation-free, no I/O. Duplicate suppression (nonce
* tracking) is the caller's job — the nonce is returned in @p out.
*/
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN],
victronble_record_t *out);
/** Cheap pre-filter: company ID + product-advertisement record, no crypto. */
bool victronble_is_product_adv(const uint8_t *mfg, size_t len);
/** Key check byte test — pick the right key from several without decrypting. */
bool victronble_key_matches(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN]);
/** Parse a 32-hex-char advertisement key. Returns false on bad input. */
bool victronble_parse_key(const char *hex, uint8_t key[VICTRONBLE_KEY_LEN]);
const char *victronble_strerror(victronble_err_t err);
const char *victronble_device_type_str(victronble_device_type_t type);
/** Short lower-case charger-state label ("bulk", "float", ...). */
const char *victronble_state_str(uint8_t state);
/**
* AES-128-CTR transform hook.
*
* @param key 16-byte key.
* @param iv 16-byte initial counter block (nonce in the low bytes, rest 0).
* @param in Ciphertext.
* @param out Plaintext. May alias @p in.
* @param len Byte count, not necessarily a multiple of 16.
* @param user Opaque context supplied at registration.
* @return 0 on success, negative on failure.
*/
typedef int (*victronble_aes_ctr_fn)(const uint8_t key[16],
const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
/** Override the AES backend at runtime (NULL restores the default). */
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user);
/**
* Default AES backend. Weak symbol: the bundled software AES
* (victronble_aes_sw.c) provides it; an alternative backend (PSA, mbedTLS,
* hardware) may define it strong and the linker drops the bundled code.
*/
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user);
#ifdef __cplusplus
}
#endif
#endif /* VICTRONBLE_H */
+71
View File
@@ -0,0 +1,71 @@
/**
* victronble — Zephyr BLE observer API.
*
* Passive-scans for Victron Instant Readout advertisements, decodes them
* off the BT RX thread (frames are queued to a dedicated decode thread)
* and delivers records to registered listeners.
*
* The application owns the Bluetooth stack: call bt_enable() before
* victronble_start(). Enable with CONFIG_VICTRONBLE=y (needs
* CONFIG_BT_OBSERVER=y).
*
* Copyright (c) 2026 Scott Penrose
* License: MIT
*/
#ifndef VICTRONBLE_ZEPHYR_H
#define VICTRONBLE_ZEPHYR_H
#include <zephyr/bluetooth/addr.h>
#include <zephyr/sys/slist.h>
#include "victronble.h"
#ifdef __cplusplus
extern "C" {
#endif
/** Listener; register with victronble_cb_register(). Callbacks run on the
* module's decode thread. */
struct victronble_cb {
/** A monitored device published a new record. */
void (*record)(const bt_addr_le_t *addr, int8_t rssi,
const victronble_record_t *rec);
/** Optional: a monitored device's advertisement failed to decode. */
void (*decode_error)(const bt_addr_le_t *addr, victronble_err_t err);
sys_snode_t node;
};
struct victronble_stats {
uint32_t adverts; /* Victron product adverts seen (any device) */
uint32_t queued; /* frames queued for a monitored device */
uint32_t dropped; /* frames lost to a full queue */
uint32_t decoded; /* records decoded OK */
uint32_t duplicates; /* suppressed by nonce dedup */
uint32_t errors; /* decode failures */
};
/** Register a listener. Returns -EALREADY if already registered. */
int victronble_cb_register(struct victronble_cb *cb);
/** Monitor a device. @p key is its 16-byte advertisement key (VictronConnect
* → Product Info). Returns -ENOMEM when full, -EALREADY if present. */
int victronble_device_add(const bt_addr_le_t *addr,
const uint8_t key[VICTRONBLE_KEY_LEN]);
/** Stop monitoring a device. Returns -ENOENT if unknown. */
int victronble_device_remove(const bt_addr_le_t *addr);
/** Start the passive scan (bt_enable() must have succeeded first). */
int victronble_start(void);
/** Stop the scan. Queued frames still drain to callbacks. */
int victronble_stop(void);
void victronble_get_stats(struct victronble_stats *out);
#ifdef __cplusplus
}
#endif
#endif /* VICTRONBLE_ZEPHYR_H */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "victronble", "name": "victronble",
"version": "0.6.1", "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.", "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", "keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, esp32-s3, esp32-c3, nrf52, nrf52840, nrf52832, xiao, iot, energy, monitoring",
"repository": { "repository": {
+2 -2
View File
@@ -1,11 +1,11 @@
name=VictronBLE name=VictronBLE
version=0.6.1 version=0.7.0
author=Scott Penrose author=Scott Penrose
maintainer=Scott Penrose <scottp@dd.com.au> maintainer=Scott Penrose <scottp@dd.com.au>
sentence=Portable library for reading Victron Energy device data via BLE on ESP32/S3/C3 and nRF52 (nRF52840/nRF52832) sentence=Portable library for reading Victron Energy device data via BLE on ESP32/S3/C3 and nRF52 (nRF52840/nRF52832)
paragraph=Read data from Victron SmartSolar, SmartShunt, BMV, inverters, Blue Smart AC chargers and other devices using Bluetooth Low Energy advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832 — Bluefruit or Seeed cores) with no external crypto dependency. Supports multiple devices simultaneously with no pairing required. paragraph=Read data from Victron SmartSolar, SmartShunt, BMV, inverters, Blue Smart AC chargers and other devices using Bluetooth Low Energy advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832 — Bluefruit or Seeed cores) with no external crypto dependency. Supports multiple devices simultaneously with no pairing required.
category=Communication category=Communication
url=https://github.com/SH3D/VictronBLE url=https://gitea.sh3d.com.au/Sh3d/VictronBLE
architectures=esp32,nrf52 architectures=esp32,nrf52
depends= depends=
includes=VictronBLE.h includes=VictronBLE.h
+107 -260
View File
@@ -1,15 +1,23 @@
/** /**
* VictronBLE - portable library for Victron Energy BLE devices * VictronBLE - portable library for Victron Energy BLE devices
* Common implementation (platform-independent: decoding + AES-128-CTR decrypt). *
* BLE scanning lives in the per-platform backends under src/esp32 and src/nrf52. * Thin Arduino wrapper over the pure C core (src/victronble_core.c): this
* file owns the device registry, nonce dedup and rate limiting; decryption
* and payload decoding live in victronble_decode(). BLE scanning lives in
* the per-platform backends under src/esp32 and src/nrf52.
* *
* Copyright (c) 2025 Scott Penrose * Copyright (c) 2025 Scott Penrose
* License: MIT * License: MIT
*/ */
#include "VictronBLE.h" #include "VictronBLE.h"
#include "crypto/vble_aes.h" #include "victronble.h"
#include <string.h> #include <string.h>
#include <math.h>
// The public API keeps the legacy "absent = 0" convention; the core reports
// absent fields as NAN.
static inline float nan_to_zero(float v) { return isnan(v) ? 0.0f : v; }
VictronBLE::VictronBLE() VictronBLE::VictronBLE()
: deviceCount(0), callback(nullptr), debugEnabled(false), : deviceCount(0), callback(nullptr), debugEnabled(false),
@@ -24,7 +32,6 @@ VictronBLE::VictronBLE()
bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey, bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
VictronDeviceType type) { VictronDeviceType type) {
if (deviceCount >= VICTRON_MAX_DEVICES) return false; if (deviceCount >= VICTRON_MAX_DEVICES) return false;
if (!hexKey || strlen(hexKey) != 32) return false;
if (!mac || strlen(mac) == 0) return false; if (!mac || strlen(mac) == 0) return false;
char normalizedMAC[VICTRON_MAC_LEN]; char normalizedMAC[VICTRON_MAC_LEN];
@@ -35,6 +42,8 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
DeviceEntry* entry = &devices[deviceCount]; DeviceEntry* entry = &devices[deviceCount];
memset(entry, 0, sizeof(DeviceEntry)); memset(entry, 0, sizeof(DeviceEntry));
if (!victronble_parse_key(hexKey, entry->key)) return false;
entry->active = true; entry->active = true;
strncpy(entry->device.name, name ? name : "", VICTRON_NAME_LEN - 1); strncpy(entry->device.name, name ? name : "", VICTRON_NAME_LEN - 1);
@@ -43,8 +52,6 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
entry->device.deviceType = type; entry->device.deviceType = type;
entry->device.rssi = -100; entry->device.rssi = -100;
if (!hexToBytes(hexKey, entry->key, 16)) return false;
deviceCount++; deviceCount++;
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC); if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
@@ -56,17 +63,7 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
// result and feeds them here. // result and feeds them here.
void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len, void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
const char* macStr, int8_t rssi) { const char* macStr, int8_t rssi) {
if (!mfgData || len < 10) return; if (!victronble_is_product_adv(mfgData, len)) return;
// Quick vendor ID check before any other work
uint16_t vendorID = mfgData[0] | ((uint16_t)mfgData[1] << 8);
if (vendorID != VICTRON_MANUFACTURER_ID) return;
// Copy into the wire-format struct
victronManufacturerData mfg;
memset(&mfg, 0, sizeof(mfg));
size_t copyLen = len > sizeof(mfg) ? sizeof(mfg) : len;
memcpy(&mfg, mfgData, copyLen);
// Normalize MAC and find device // Normalize MAC and find device
char normalizedMAC[VICTRON_MAC_LEN]; char normalizedMAC[VICTRON_MAC_LEN];
@@ -79,7 +76,8 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
} }
// Skip if nonce unchanged (data hasn't changed on the device) // Skip if nonce unchanged (data hasn't changed on the device)
if (entry->device.dataValid && mfg.nonceDataCounter == entry->lastNonce) { uint16_t nonce = mfgData[7] | ((uint16_t)mfgData[8] << 8);
if (entry->device.dataValid && nonce == entry->lastNonce) {
entry->device.rssi = rssi; // still refresh RSSI entry->device.rssi = rssi; // still refresh RSSI
return; return;
} }
@@ -90,274 +88,123 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
return; return;
} }
if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n", victronble_record_t rec;
entry->device.name, mfg.nonceDataCounter); victronble_err_t err = victronble_decode(mfgData, len, entry->key, &rec);
if (err != VICTRONBLE_OK) {
if (debugEnabled) Serial.printf("[VictronBLE] Decode %s: %s\n",
entry->device.name, victronble_strerror(err));
return;
}
if (parseAdvertisement(entry, mfg)) { if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
entry->lastNonce = mfg.nonceDataCounter; entry->device.name, rec.nonce);
storeRecord(entry, rec);
entry->lastNonce = nonce;
entry->device.rssi = rssi; entry->device.rssi = rssi;
entry->device.lastUpdate = now; entry->device.lastUpdate = now;
}
}
bool VictronBLE::parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg) {
if (debugEnabled) {
Serial.printf("[VictronBLE] Beacon:0x%02X Record:0x%02X Nonce:0x%04X\n",
mfg.beaconType, mfg.victronRecordType, mfg.nonceDataCounter);
}
// Quick key check before expensive decryption
if (mfg.encryptKeyMatch != entry->key[0]) {
if (debugEnabled) Serial.println("[VictronBLE] Key byte mismatch");
return false;
}
// Build IV from nonce (2 bytes little-endian + 14 zero bytes)
uint8_t iv[16] = {0};
iv[0] = mfg.nonceDataCounter & 0xFF;
iv[1] = (mfg.nonceDataCounter >> 8) & 0xFF;
// Decrypt
uint8_t decrypted[VICTRON_ENCRYPTED_LEN];
if (!decryptData(mfg.victronEncryptedData, VICTRON_ENCRYPTED_LEN,
entry->key, iv, decrypted)) {
if (debugEnabled) Serial.println("[VictronBLE] Decryption failed");
return false;
}
// Parse based on record type (auto-detects device type)
bool ok = false;
switch (mfg.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER:
entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
ok = parseSolarCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.solar);
break;
case DEVICE_TYPE_BATTERY_MONITOR:
entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
ok = parseBatteryMonitor(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.battery);
break;
case DEVICE_TYPE_INVERTER:
case DEVICE_TYPE_INVERTER_RS:
case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS:
entry->device.deviceType = DEVICE_TYPE_INVERTER;
ok = parseInverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.inverter);
break;
case DEVICE_TYPE_DCDC_CONVERTER:
entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
ok = parseDCDCConverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.dcdc);
break;
case DEVICE_TYPE_AC_CHARGER:
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
ok = parseACCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.acCharger);
break;
default:
if (debugEnabled) Serial.printf("[VictronBLE] Unknown type: 0x%02X\n", mfg.victronRecordType);
return false;
}
if (ok) {
entry->device.dataValid = true; entry->device.dataValid = true;
if (callback) callback(&entry->device); if (callback) callback(&entry->device);
} }
return ok; // Map a decoded core record into the legacy public structs (NAN -> 0).
} void VictronBLE::storeRecord(DeviceEntry* entry, const victronble_record_t& rec) {
switch (rec.type) {
bool VictronBLE::decryptData(const uint8_t* encrypted, size_t len, case VICTRONBLE_DEV_SOLAR_CHARGER: {
const uint8_t* key, const uint8_t* iv, entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
uint8_t* decrypted) { VictronSolarData& s = entry->device.solar;
// AES-128-CTR via the bundled portable implementation (was mbedTLS on ESP32). s.chargeState = rec.u.solar.state;
// CTR is symmetric and operates in place, so copy then XOR the keystream. s.errorCode = rec.u.solar.error;
struct vble_aes_ctx ctx; s.batteryVoltage = rec.u.solar.battery_voltage;
vble_aes_init_ctx_iv(&ctx, key, iv); s.batteryCurrent = rec.u.solar.battery_current;
memcpy(decrypted, encrypted, len); s.panelPower = rec.u.solar.pv_power;
vble_aes_ctr_xcrypt(&ctx, decrypted, len); s.yieldToday = (uint16_t)rec.u.solar.yield_today_wh;
return true; s.loadCurrent = nan_to_zero(rec.u.solar.load_current);
}
bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result) {
if (len < sizeof(victronSolarChargerPayload)) return false;
const auto* p = reinterpret_cast<const victronSolarChargerPayload*>(data);
result.chargeState = p->deviceState;
result.errorCode = p->errorCode;
result.batteryVoltage = p->batteryVoltage * 0.01f; // 0.01V units
result.batteryCurrent = p->batteryCurrent * 0.1f; // 0.1A units
result.yieldToday = p->yieldToday * 10;
result.panelPower = p->inputPower;
// Load current is a 9-bit field (0.1A units); 0x1FF = no load output
uint16_t loadRaw = p->loadCurrent & 0x1FF;
result.loadCurrent = (loadRaw != 0x1FF) ? loadRaw * 0.1f : 0;
if (debugEnabled) { if (debugEnabled) {
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n", Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
result.batteryVoltage, result.batteryCurrent, s.batteryVoltage, s.batteryCurrent,
(int)result.panelPower, result.chargeState); (int)s.panelPower, s.chargeState);
} }
return true; break;
} }
case VICTRONBLE_DEV_BATTERY_MONITOR: {
bool VictronBLE::parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result) { entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
// Payload is bit-packed (10 fields, 104 bits ending in byte 12). Decode LSB-first. VictronBatteryData& b = entry->device.battery;
if (len < 13) return false; b.voltage = rec.u.batmon.voltage;
b.current = rec.u.batmon.current;
size_t bit = 0; b.temperature = nan_to_zero(rec.u.batmon.temperature);
auto readBits = [&](uint8_t width) -> uint32_t { b.auxVoltage = nan_to_zero(rec.u.batmon.aux_voltage);
uint32_t value = 0; b.remainingMinutes = rec.u.batmon.remaining_minutes;
for (uint8_t i = 0; i < width; i++) { b.consumedAh = rec.u.batmon.consumed_ah;
size_t b = bit + i; b.soc = rec.u.batmon.soc;
value |= (uint32_t)((data[b >> 3] >> (b & 7)) & 0x01) << i; b.alarmLowVoltage = (rec.u.batmon.alarm & 0x0001) != 0;
} b.alarmHighVoltage = (rec.u.batmon.alarm & 0x0002) != 0;
bit += width; b.alarmLowSOC = (rec.u.batmon.alarm & 0x0004) != 0;
return value; b.alarmLowTemperature = (rec.u.batmon.alarm & 0x0010) != 0;
}; b.alarmHighTemperature = (rec.u.batmon.alarm & 0x0020) != 0;
result.chargeState = (uint8_t)readBits(8);
result.errorCode = (uint8_t)readBits(8);
uint32_t v1 = readBits(13), i1 = readBits(11);
uint32_t v2 = readBits(13), i2 = readBits(11);
uint32_t v3 = readBits(13), i3 = readBits(11);
uint32_t temp = readBits(7);
uint32_t acCur = readBits(9);
result.voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : 0;
result.current1 = (i1 != 0x7FF) ? i1 * 0.1f : 0;
result.voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : 0;
result.current2 = (i2 != 0x7FF) ? i2 * 0.1f : 0;
result.voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : 0;
result.current3 = (i3 != 0x7FF) ? i3 * 0.1f : 0;
result.temperature = (temp != 0x7F) ? (float)temp - 40.0f : 0; // C offset by -40
result.acCurrent = (acCur != 0x1FF) ? acCur * 0.1f : 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
result.voltage1, result.current1, result.temperature, result.chargeState);
}
return true;
}
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result) {
// The payload is bit-packed and not byte-aligned, so it is decoded by bit
// offset directly rather than via a struct. SOC ends at bit 117 (byte 14).
if (len < 15) return false;
// TTG (bits 0-15), unsigned minutes
result.remainingMinutes = data[0] | ((uint16_t)data[1] << 8);
// Voltage (bits 16-31), signed, 0.01V units
result.voltage = (int16_t)(data[2] | ((uint16_t)data[3] << 8)) * 0.01f;
// Alarm (bits 32-47), 16-bit bitmask
uint16_t alarm = data[4] | ((uint16_t)data[5] << 8);
result.alarmLowVoltage = (alarm & 0x0001) != 0;
result.alarmHighVoltage = (alarm & 0x0002) != 0;
result.alarmLowSOC = (alarm & 0x0004) != 0;
result.alarmLowTemperature = (alarm & 0x0010) != 0;
result.alarmHighTemperature = (alarm & 0x0020) != 0;
// Aux value (bits 48-63) interpreted per aux mode (bits 64-65)
uint16_t auxRaw = data[6] | ((uint16_t)data[7] << 8);
uint8_t auxMode = data[8] & 0x03; // 0=aux voltage, 1=midpoint, 2=temperature, 3=none
if (auxMode == 0) {
result.auxVoltage = auxRaw * 0.01f;
result.temperature = 0;
} else if (auxMode == 2) {
result.temperature = auxRaw * 0.01f - 273.15f; // 0.01K -> C
result.auxVoltage = 0;
} else {
result.auxVoltage = 0;
result.temperature = 0;
}
// Battery current (bits 66-87), 22-bit signed, 0.001A units
int32_t current = ((uint32_t)(data[8] >> 2) & 0x3F)
| ((uint32_t)data[9] << 6)
| ((uint32_t)data[10] << 14);
if (current & 0x200000) current |= 0xFFC00000; // Sign extend 22-bit
result.current = current * 0.001f;
// Consumed Ah (bits 88-107), 20-bit, stored as a positive count, 0.1Ah units.
// Reported as a negative value (amp-hours consumed).
uint32_t consumed = (uint32_t)data[11]
| ((uint32_t)data[12] << 8)
| ((uint32_t)(data[13] & 0x0F) << 16);
result.consumedAh = -((float)consumed * 0.1f);
// SOC (bits 108-117), 10-bit, 0.1% units
uint16_t soc = ((uint16_t)(data[13] >> 4) | ((uint16_t)data[14] << 4)) & 0x3FF;
result.soc = soc * 0.1f;
if (debugEnabled) { if (debugEnabled) {
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n", Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
result.voltage, result.current, result.soc); b.voltage, b.current, b.soc);
} }
return true; break;
} }
case VICTRONBLE_DEV_INVERTER: {
bool VictronBLE::parseInverter(const uint8_t* data, size_t len, VictronInverterData& result) { entry->device.deviceType = DEVICE_TYPE_INVERTER;
if (len < sizeof(victronInverterPayload)) return false; VictronInverterData& inv = entry->device.inverter;
const auto* p = reinterpret_cast<const victronInverterPayload*>(data); inv.batteryVoltage = rec.u.inverter.battery_voltage;
inv.batteryCurrent = rec.u.inverter.battery_current;
result.state = p->deviceState; inv.acPower = rec.u.inverter.ac_power;
result.batteryVoltage = p->batteryVoltage * 0.01f; inv.state = rec.u.inverter.state;
result.batteryCurrent = p->batteryCurrent * 0.01f; inv.alarmLowVoltage = (rec.u.inverter.alarms & 0x01) != 0;
inv.alarmHighVoltage = (rec.u.inverter.alarms & 0x02) != 0;
// AC Power (signed 24-bit) inv.alarmHighTemperature = (rec.u.inverter.alarms & 0x04) != 0;
int32_t acPower = p->acPowerLow | (p->acPowerMid << 8) | (p->acPowerHigh << 16); inv.alarmOverload = (rec.u.inverter.alarms & 0x08) != 0;
if (acPower & 0x800000) acPower |= 0xFF000000; // Sign extend
result.acPower = acPower;
// Alarm bits
result.alarmLowVoltage = (p->alarms & 0x01) != 0;
result.alarmHighVoltage = (p->alarms & 0x02) != 0;
result.alarmHighTemperature = (p->alarms & 0x04) != 0;
result.alarmOverload = (p->alarms & 0x08) != 0;
if (debugEnabled) { if (debugEnabled) {
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n", Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
result.batteryVoltage, (int)result.acPower, result.state); inv.batteryVoltage, (int)inv.acPower, inv.state);
} }
return true; break;
} }
case VICTRONBLE_DEV_DCDC_CONVERTER: {
bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result) { entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
if (len < sizeof(victronDCDCConverterPayload)) return false; VictronDCDCData& d = entry->device.dcdc;
const auto* p = reinterpret_cast<const victronDCDCConverterPayload*>(data); d.chargeState = rec.u.dcdc.state;
d.errorCode = rec.u.dcdc.error;
result.chargeState = p->chargeState; d.inputVoltage = rec.u.dcdc.input_voltage;
result.errorCode = p->errorCode; d.outputVoltage = rec.u.dcdc.output_voltage;
result.inputVoltage = p->inputVoltage * 0.01f; d.outputCurrent = rec.u.dcdc.output_current;
result.outputVoltage = p->outputVoltage * 0.01f;
result.outputCurrent = p->outputCurrent * 0.01f;
if (debugEnabled) { if (debugEnabled) {
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n", Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
result.inputVoltage, result.outputVoltage, result.outputCurrent); d.inputVoltage, d.outputVoltage, d.outputCurrent);
}
break;
}
case VICTRONBLE_DEV_AC_CHARGER: {
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
VictronACChargerData& a = entry->device.acCharger;
a.chargeState = rec.u.ac.state;
a.errorCode = rec.u.ac.error;
a.voltage1 = nan_to_zero(rec.u.ac.voltage1);
a.current1 = nan_to_zero(rec.u.ac.current1);
a.voltage2 = nan_to_zero(rec.u.ac.voltage2);
a.current2 = nan_to_zero(rec.u.ac.current2);
a.voltage3 = nan_to_zero(rec.u.ac.voltage3);
a.current3 = nan_to_zero(rec.u.ac.current3);
a.temperature = nan_to_zero(rec.u.ac.temperature);
a.acCurrent = nan_to_zero(rec.u.ac.ac_current);
if (debugEnabled) {
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
a.voltage1, a.current1, a.temperature, a.chargeState);
}
break;
}
default:
break;
} }
return true;
} }
// --- Helpers --- // --- Helpers ---
bool VictronBLE::hexToBytes(const char* hex, uint8_t* out, size_t len) {
if (strlen(hex) != len * 2) return false;
for (size_t i = 0; i < len; i++) {
uint8_t hi = hex[i * 2], lo = hex[i * 2 + 1];
if (hi >= '0' && hi <= '9') hi -= '0';
else if (hi >= 'a' && hi <= 'f') hi = hi - 'a' + 10;
else if (hi >= 'A' && hi <= 'F') hi = hi - 'A' + 10;
else return false;
if (lo >= '0' && lo <= '9') lo -= '0';
else if (lo >= 'a' && lo <= 'f') lo = lo - 'a' + 10;
else if (lo >= 'A' && lo <= 'F') lo = lo - 'A' + 10;
else return false;
out[i] = (hi << 4) | lo;
}
return true;
}
void VictronBLE::normalizeMAC(const char* input, char* output) { void VictronBLE::normalizeMAC(const char* input, char* output) {
int j = 0; int j = 0;
for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) { for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) {
+5 -9
View File
@@ -16,6 +16,7 @@
#define VICTRON_BLE_H #define VICTRON_BLE_H
#include <Arduino.h> #include <Arduino.h>
#include "victronble.h" // pure C core: decode + decrypt (src/victronble_core.c)
// --- Platform BLE backend selection --- // --- Platform BLE backend selection ---
// The BLE scanning layer is the only platform-specific part of the library. // The BLE scanning layer is the only platform-specific part of the library.
@@ -247,22 +248,17 @@ private:
uint32_t minIntervalMs; uint32_t minIntervalMs;
bool initialized; bool initialized;
static bool hexToBytes(const char* hex, uint8_t* out, size_t len);
static void normalizeMAC(const char* input, char* output); static void normalizeMAC(const char* input, char* output);
DeviceEntry* findDevice(const char* normalizedMAC); DeviceEntry* findDevice(const char* normalizedMAC);
bool decryptData(const uint8_t* encrypted, size_t len,
const uint8_t* key, const uint8_t* iv, uint8_t* decrypted);
// Common entry point fed by each platform BLE backend with one raw // Common entry point fed by each platform BLE backend with one raw
// manufacturer-data record (vendor ID first), the device MAC and RSSI. // manufacturer-data record (vendor ID first), the device MAC and RSSI.
// Decryption and payload decoding are delegated to victronble_decode()
// in the pure C core; storeRecord() maps the result into the legacy
// public structs (core NAN sentinels become 0).
void onAdvertisement(const uint8_t* mfgData, size_t len, void onAdvertisement(const uint8_t* mfgData, size_t len,
const char* macStr, int8_t rssi); const char* macStr, int8_t rssi);
bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg); void storeRecord(DeviceEntry* entry, const victronble_record_t& rec);
bool parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result);
bool parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result);
bool parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result);
bool parseInverter(const uint8_t* data, size_t len, VictronInverterData& result);
bool parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result);
// --- Platform-specific BLE backend (see src/esp32 and src/nrf52) --- // --- Platform-specific BLE backend (see src/esp32 and src/nrf52) ---
#if defined(VICTRON_BACKEND_ESP32) #if defined(VICTRON_BACKEND_ESP32)
+5
View File
@@ -0,0 +1,5 @@
/* Arduino include-path shim: Arduino builds only add src/ to the include
* path, so route to the canonical core header in include/. Zephyr and host
* builds add include/ directly and never see this file first — both paths
* end up in the same header (it has an include guard). */
#include "../include/victronble.h"
+38
View File
@@ -0,0 +1,38 @@
/**
* victronble — bundled software AES-128-CTR backend.
*
* Weak symbol: an alternative backend (PSA Crypto, mbedTLS, hardware) defines
* victronble_aes_ctr_default strong and the linker drops this file's code —
* and with it the bundled AES tables — from the final image.
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#include "victronble.h"
#include "crypto/vble_aes.h"
#include <string.h>
#if defined(_MSC_VER)
#define VICTRONBLE_WEAK
#else
#define VICTRONBLE_WEAK __attribute__((weak))
#endif
VICTRONBLE_WEAK
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user)
{
(void)user;
struct vble_aes_ctx ctx;
vble_aes_init_ctx_iv(&ctx, key, iv);
if (out != in) {
memcpy(out, in, len);
}
vble_aes_ctr_xcrypt(&ctx, out, len);
return 0;
}
+382
View File
@@ -0,0 +1,382 @@
/**
* victronble core — decode + decrypt for Victron Instant Readout adverts.
* Pure C99: no Arduino, no BLE stack, no allocation, no I/O, reentrant.
*
* Byte offsets and bit layouts match the proven ESP32/nRF52 implementation
* in src/VictronBLE.cpp and Victron's "Extra Manufacturer Data" document.
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#include "victronble.h"
#include <string.h>
#include <math.h>
/* Manufacturer-data layout (offsets from the company ID):
* 0-1 company ID (LE, 0x02E1)
* 2 record type, 0x10 = product advertisement
* 3-4 model ID (LE)
* 5 read-out type
* 6 device record type (victronble_device_type_t)
* 7-8 nonce / data counter (LE)
* 9 key check byte (== key[0])
* 10- AES-128-CTR ciphertext, up to 21 bytes
*/
#define OFF_RECORD 2
#define OFF_MODEL 3
#define OFF_READOUT 5
#define OFF_DEVTYPE 6
#define OFF_NONCE 7
#define OFF_KEYCHECK 9
#define OFF_CIPHER 10
#define PRODUCT_ADV 0x10
static uint16_t get_le16(const uint8_t *p)
{
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
}
/* --- AES backend selection ------------------------------------------- */
static victronble_aes_ctr_fn aes_fn;
static void *aes_user;
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user)
{
aes_fn = fn;
aes_user = user;
}
static int aes_ctr(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out, size_t len)
{
if (aes_fn != NULL) {
return aes_fn(key, iv, in, out, len, aes_user);
}
return victronble_aes_ctr_default(key, iv, in, out, len, NULL);
}
/* --- Pre-filters ------------------------------------------------------ */
bool victronble_is_product_adv(const uint8_t *mfg, size_t len)
{
return mfg != NULL && len >= VICTRONBLE_MIN_MFG_LEN &&
get_le16(mfg) == VICTRONBLE_COMPANY_ID &&
mfg[OFF_RECORD] == PRODUCT_ADV;
}
bool victronble_key_matches(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN])
{
return victronble_is_product_adv(mfg, len) && mfg[OFF_KEYCHECK] == key[0];
}
/* --- Per-type payload decoders ---------------------------------------
* All operate on the decrypted payload, zero-padded to
* VICTRONBLE_MAX_CIPHER_LEN bytes, so length checks always pass at the
* decode() call site; they remain for direct-call safety. */
static bool parse_solar_charger(const uint8_t *d, size_t len,
victronble_solar_charger_t *r)
{
if (len < 12) {
return false;
}
r->state = d[0];
r->error = d[1];
r->battery_voltage = (int16_t)get_le16(d + 2) * 0.01f; /* 0.01 V */
r->battery_current = (int16_t)get_le16(d + 4) * 0.1f; /* 0.1 A */
r->yield_today_wh = (uint32_t)get_le16(d + 6) * 10u; /* 0.01 kWh */
r->pv_power = get_le16(d + 8); /* 1 W */
/* Load current is a 9-bit field (0.1 A units); 0x1FF = no load output */
uint16_t load_raw = get_le16(d + 10) & 0x1FF;
r->load_current = (load_raw != 0x1FF) ? load_raw * 0.1f : NAN;
return true;
}
static bool parse_battery_monitor(const uint8_t *d, size_t len,
victronble_battery_monitor_t *r)
{
/* Bit-packed, not byte-aligned; decoded by bit offset. SOC ends at
* bit 117 (byte 14). */
if (len < 15) {
return false;
}
r->remaining_minutes = get_le16(d); /* bits 0-15 */
r->voltage = (int16_t)get_le16(d + 2) * 0.01f; /* bits 16-31 */
r->alarm = get_le16(d + 4); /* bits 32-47 */
/* Aux value (bits 48-63) interpreted per aux mode (bits 64-65) */
uint16_t aux_raw = get_le16(d + 6);
r->aux_mode = d[8] & 0x03;
r->aux_voltage = (r->aux_mode == 0) ? aux_raw * 0.01f : NAN;
r->temperature = (r->aux_mode == 2) ? aux_raw * 0.01f - 273.15f : NAN;
/* Battery current (bits 66-87), 22-bit signed, 0.001 A units */
int32_t current = (int32_t)(((uint32_t)(d[8] >> 2) & 0x3F) |
((uint32_t)d[9] << 6) |
((uint32_t)d[10] << 14));
if (current & 0x200000) {
current |= (int32_t)0xFFC00000; /* sign extend */
}
r->current = current * 0.001f;
/* Consumed Ah (bits 88-107), 20-bit positive count, 0.1 Ah units,
* reported negative (amp-hours consumed). */
uint32_t consumed = (uint32_t)d[11] | ((uint32_t)d[12] << 8) |
((uint32_t)(d[13] & 0x0F) << 16);
r->consumed_ah = -((float)consumed * 0.1f);
/* SOC (bits 108-117), 10-bit, 0.1 % units */
uint16_t soc = (uint16_t)(((d[13] >> 4) | ((uint16_t)d[14] << 4)) & 0x3FF);
r->soc = soc * 0.1f;
return true;
}
static bool parse_inverter(const uint8_t *d, size_t len,
victronble_inverter_t *r)
{
if (len < 9) {
return false;
}
r->state = d[0];
/* d[1] is the error code on the wire; kept out of the struct for parity
* with the proven implementation, which only surfaced the alarm bits. */
r->battery_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
r->battery_current = (int16_t)get_le16(d + 4) * 0.01f; /* 10 mA */
int32_t ac_power = (int32_t)((uint32_t)d[6] | ((uint32_t)d[7] << 8) |
((uint32_t)d[8] << 16));
if (ac_power & 0x800000) {
ac_power |= (int32_t)0xFF000000; /* sign extend */
}
r->ac_power = (float)ac_power;
r->alarms = (len > 9) ? d[9] : 0;
return true;
}
static bool parse_dcdc(const uint8_t *d, size_t len, victronble_dcdc_t *r)
{
if (len < 8) {
return false;
}
r->state = d[0];
r->error = d[1];
r->input_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
r->output_voltage = get_le16(d + 4) * 0.01f; /* 10 mV */
r->output_current = get_le16(d + 6) * 0.01f; /* 10 mA */
return true;
}
static uint32_t read_bits(const uint8_t *d, size_t *bit, uint8_t width)
{
uint32_t value = 0;
for (uint8_t i = 0; i < width; i++) {
size_t b = *bit + i;
value |= (uint32_t)((d[b >> 3] >> (b & 7)) & 0x01) << i;
}
*bit += width;
return value;
}
static bool parse_ac_charger(const uint8_t *d, size_t len,
victronble_ac_charger_t *r)
{
/* Bit-packed: 10 fields, 104 bits ending in byte 12, LSB-first. */
if (len < 13) {
return false;
}
size_t bit = 0;
r->state = (uint8_t)read_bits(d, &bit, 8);
r->error = (uint8_t)read_bits(d, &bit, 8);
uint32_t v1 = read_bits(d, &bit, 13), i1 = read_bits(d, &bit, 11);
uint32_t v2 = read_bits(d, &bit, 13), i2 = read_bits(d, &bit, 11);
uint32_t v3 = read_bits(d, &bit, 13), i3 = read_bits(d, &bit, 11);
uint32_t temp = read_bits(d, &bit, 7);
uint32_t ac_cur = read_bits(d, &bit, 9);
r->voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : NAN;
r->current1 = (i1 != 0x7FF) ? i1 * 0.1f : NAN;
r->voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : NAN;
r->current2 = (i2 != 0x7FF) ? i2 * 0.1f : NAN;
r->voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : NAN;
r->current3 = (i3 != 0x7FF) ? i3 * 0.1f : NAN;
r->temperature = (temp != 0x7F) ? (float)temp - 40.0f : NAN;
r->ac_current = (ac_cur != 0x1FF) ? ac_cur * 0.1f : NAN;
return true;
}
/* --- Decode entry point ----------------------------------------------- */
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN],
victronble_record_t *out)
{
if (mfg == NULL || len < VICTRONBLE_MIN_MFG_LEN) {
return VICTRONBLE_ERR_SHORT;
}
if (get_le16(mfg) != VICTRONBLE_COMPANY_ID) {
return VICTRONBLE_ERR_NOT_VICTRON;
}
if (mfg[OFF_RECORD] != PRODUCT_ADV) {
return VICTRONBLE_ERR_NOT_PRODUCT;
}
if (mfg[OFF_KEYCHECK] != key[0]) {
return VICTRONBLE_ERR_KEY_MISMATCH;
}
uint16_t nonce = get_le16(mfg + OFF_NONCE);
/* IV: nonce in the two low bytes (LE), remaining 14 bytes zero. */
uint8_t iv[16] = {0};
iv[0] = (uint8_t)(nonce & 0xFF);
iv[1] = (uint8_t)(nonce >> 8);
/* Decrypt what's on the wire; zero-pad to the full payload size so the
* per-type decoders see a fixed-length buffer (matches the proven
* implementation, which zero-filled the wire struct before copy-in). */
uint8_t plain[VICTRONBLE_MAX_CIPHER_LEN] = {0};
size_t cipher_len = len - OFF_CIPHER;
if (cipher_len > VICTRONBLE_MAX_CIPHER_LEN) {
cipher_len = VICTRONBLE_MAX_CIPHER_LEN;
}
if (aes_ctr(key, iv, mfg + OFF_CIPHER, plain, cipher_len) != 0) {
return VICTRONBLE_ERR_CRYPTO;
}
victronble_record_t rec;
memset(&rec, 0, sizeof(rec));
rec.record_type = mfg[OFF_DEVTYPE];
rec.model_id = get_le16(mfg + OFF_MODEL);
rec.readout_type = mfg[OFF_READOUT];
rec.nonce = nonce;
bool ok = false;
switch (mfg[OFF_DEVTYPE]) {
case VICTRONBLE_DEV_SOLAR_CHARGER:
rec.type = VICTRONBLE_DEV_SOLAR_CHARGER;
ok = parse_solar_charger(plain, sizeof(plain), &rec.u.solar);
break;
case VICTRONBLE_DEV_BATTERY_MONITOR:
rec.type = VICTRONBLE_DEV_BATTERY_MONITOR;
ok = parse_battery_monitor(plain, sizeof(plain), &rec.u.batmon);
break;
case VICTRONBLE_DEV_INVERTER:
case VICTRONBLE_DEV_INVERTER_RS:
case VICTRONBLE_DEV_MULTI_RS:
case VICTRONBLE_DEV_VE_BUS:
rec.type = VICTRONBLE_DEV_INVERTER;
ok = parse_inverter(plain, sizeof(plain), &rec.u.inverter);
break;
case VICTRONBLE_DEV_DCDC_CONVERTER:
rec.type = VICTRONBLE_DEV_DCDC_CONVERTER;
ok = parse_dcdc(plain, sizeof(plain), &rec.u.dcdc);
break;
case VICTRONBLE_DEV_AC_CHARGER:
rec.type = VICTRONBLE_DEV_AC_CHARGER;
ok = parse_ac_charger(plain, sizeof(plain), &rec.u.ac);
break;
default:
return VICTRONBLE_ERR_UNSUPPORTED;
}
if (!ok) {
return VICTRONBLE_ERR_SHORT;
}
*out = rec;
return VICTRONBLE_OK;
}
/* --- Helpers ----------------------------------------------------------- */
static int hex_nibble(char c)
{
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
if (c >= 'A' && c <= 'F') {
return c - 'A' + 10;
}
return -1;
}
bool victronble_parse_key(const char *hex, uint8_t key[VICTRONBLE_KEY_LEN])
{
if (hex == NULL || strlen(hex) != VICTRONBLE_KEY_LEN * 2) {
return false;
}
for (size_t i = 0; i < VICTRONBLE_KEY_LEN; i++) {
int hi = hex_nibble(hex[i * 2]);
int lo = hex_nibble(hex[i * 2 + 1]);
if (hi < 0 || lo < 0) {
return false;
}
key[i] = (uint8_t)((hi << 4) | lo);
}
return true;
}
const char *victronble_strerror(victronble_err_t err)
{
switch (err) {
case VICTRONBLE_OK: return "ok";
case VICTRONBLE_ERR_NOT_VICTRON: return "not victron";
case VICTRONBLE_ERR_SHORT: return "truncated";
case VICTRONBLE_ERR_NOT_PRODUCT: return "not product adv";
case VICTRONBLE_ERR_KEY_MISMATCH: return "key mismatch";
case VICTRONBLE_ERR_UNSUPPORTED: return "unsupported type";
case VICTRONBLE_ERR_CRYPTO: return "crypto error";
default: return "unknown error";
}
}
const char *victronble_device_type_str(victronble_device_type_t type)
{
switch (type) {
case VICTRONBLE_DEV_SOLAR_CHARGER: return "solar charger";
case VICTRONBLE_DEV_BATTERY_MONITOR: return "battery monitor";
case VICTRONBLE_DEV_INVERTER: return "inverter";
case VICTRONBLE_DEV_DCDC_CONVERTER: return "dc-dc converter";
case VICTRONBLE_DEV_SMART_LITHIUM: return "smart lithium";
case VICTRONBLE_DEV_INVERTER_RS: return "inverter rs";
case VICTRONBLE_DEV_GX_DEVICE: return "gx device";
case VICTRONBLE_DEV_AC_CHARGER: return "ac charger";
case VICTRONBLE_DEV_BATTERY_PROTECT: return "battery protect";
case VICTRONBLE_DEV_LYNX_SMART_BMS: return "lynx smart bms";
case VICTRONBLE_DEV_MULTI_RS: return "multi rs";
case VICTRONBLE_DEV_VE_BUS: return "ve.bus";
case VICTRONBLE_DEV_DC_ENERGY_METER: return "dc energy meter";
case VICTRONBLE_DEV_ORION_XS: return "orion xs";
default: return "unknown";
}
}
const char *victronble_state_str(uint8_t state)
{
switch (state) {
case VICTRONBLE_STATE_OFF: return "off";
case VICTRONBLE_STATE_LOW_POWER: return "low";
case VICTRONBLE_STATE_FAULT: return "fault";
case VICTRONBLE_STATE_BULK: return "bulk";
case VICTRONBLE_STATE_ABSORPTION: return "abs";
case VICTRONBLE_STATE_FLOAT: return "float";
case VICTRONBLE_STATE_STORAGE: return "store";
case VICTRONBLE_STATE_EQUALIZE: return "eq";
case VICTRONBLE_STATE_INVERTING: return "invert";
case VICTRONBLE_STATE_POWER_SUPPLY: return "psu";
case VICTRONBLE_STATE_EXTERNAL_CONTROL: return "ext";
default: return "?";
}
}
+301
View File
@@ -0,0 +1,301 @@
/**
* victronble — Zephyr BLE observer backend.
*
* Scan callback (BT RX context) does the cheap work only: AD walk, product
* pre-filter, registry match, copy into a message queue. A dedicated thread
* decrypts, decodes, dedups and fans out to registered listeners.
*
* Copyright (c) 2026 Scott Penrose
* License: MIT
*/
/* Arduino/PlatformIO builds compile every file under src/ — this backend
* only exists under Zephyr (the Zephyr CMake build lists sources
* explicitly, so the reverse problem doesn't arise). */
#ifdef __ZEPHYR__
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gap.h>
#include <zephyr/logging/log.h>
#include "victronble_zephyr.h"
LOG_MODULE_REGISTER(victronble, CONFIG_VICTRONBLE_LOG_LEVEL);
#define MAX_MFG_LEN (VICTRONBLE_MIN_MFG_LEN + VICTRONBLE_MAX_CIPHER_LEN)
struct vb_frame {
bt_addr_le_t addr;
int8_t rssi;
uint8_t len;
uint8_t data[MAX_MFG_LEN];
};
struct vb_device {
bt_addr_le_t addr;
uint8_t key[VICTRONBLE_KEY_LEN];
uint16_t last_nonce;
bool have_nonce;
bool used;
};
K_MSGQ_DEFINE(vb_msgq, sizeof(struct vb_frame),
CONFIG_VICTRONBLE_QUEUE_DEPTH, 4);
static struct vb_device devices[CONFIG_VICTRONBLE_MAX_DEVICES];
static struct k_mutex dev_mtx;
static sys_slist_t callbacks = SYS_SLIST_STATIC_INIT(&callbacks);
static struct victronble_stats stats;
static bool scanning;
static struct vb_device *find_device(const bt_addr_le_t *addr)
{
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
if (devices[i].used &&
bt_addr_le_cmp(&devices[i].addr, addr) == 0) {
return &devices[i];
}
}
return NULL;
}
/* --- Scan path (BT RX context) --------------------------------------- */
struct ad_ctx {
const bt_addr_le_t *addr;
int8_t rssi;
};
static bool ad_cb(struct bt_data *data, void *user_data)
{
struct ad_ctx *ctx = user_data;
if (data->type != BT_DATA_MANUFACTURER_DATA) {
return true; /* keep walking the AD structures */
}
if (!victronble_is_product_adv(data->data, data->data_len)) {
return true;
}
stats.adverts++;
/* Registry check is a handful of compares — cheap enough here, and
* it keeps other people's Victrons out of the queue. */
if (find_device(ctx->addr) == NULL) {
return false;
}
struct vb_frame frame;
bt_addr_le_copy(&frame.addr, ctx->addr);
frame.rssi = ctx->rssi;
frame.len = MIN(data->data_len, sizeof(frame.data));
memcpy(frame.data, data->data, frame.len);
if (k_msgq_put(&vb_msgq, &frame, K_NO_WAIT) == 0) {
stats.queued++;
} else {
stats.dropped++;
}
return false; /* found the record — stop walking */
}
static void scan_recv(const bt_addr_le_t *addr, int8_t rssi,
uint8_t adv_type, struct net_buf_simple *ad)
{
ARG_UNUSED(adv_type);
struct ad_ctx ctx = { .addr = addr, .rssi = rssi };
bt_data_parse(ad, ad_cb, &ctx);
}
/* --- Decode thread ----------------------------------------------------- */
static void decode_frame(const struct vb_frame *frame)
{
uint8_t key[VICTRONBLE_KEY_LEN];
uint16_t last_nonce;
bool have_nonce;
k_mutex_lock(&dev_mtx, K_FOREVER);
struct vb_device *dev = find_device(&frame->addr);
if (dev == NULL) { /* removed while queued */
k_mutex_unlock(&dev_mtx);
return;
}
memcpy(key, dev->key, sizeof(key));
last_nonce = dev->last_nonce;
have_nonce = dev->have_nonce;
k_mutex_unlock(&dev_mtx);
victronble_record_t rec;
victronble_err_t err = victronble_decode(frame->data, frame->len,
key, &rec);
struct victronble_cb *cb;
if (err != VICTRONBLE_OK) {
stats.errors++;
LOG_DBG("decode failed: %s", victronble_strerror(err));
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
if (cb->decode_error != NULL) {
cb->decode_error(&frame->addr, err);
}
}
return;
}
if (IS_ENABLED(CONFIG_VICTRONBLE_DEDUP) &&
have_nonce && rec.nonce == last_nonce) {
stats.duplicates++;
return;
}
k_mutex_lock(&dev_mtx, K_FOREVER);
dev = find_device(&frame->addr);
if (dev != NULL) {
dev->last_nonce = rec.nonce;
dev->have_nonce = true;
}
k_mutex_unlock(&dev_mtx);
stats.decoded++;
LOG_DBG("%s record, nonce 0x%04x, rssi %d",
victronble_device_type_str(rec.type), rec.nonce, frame->rssi);
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
if (cb->record != NULL) {
cb->record(&frame->addr, frame->rssi, &rec);
}
}
}
static void vb_thread_fn(void *a, void *b, void *c)
{
ARG_UNUSED(a);
ARG_UNUSED(b);
ARG_UNUSED(c);
struct vb_frame frame;
while (true) {
k_msgq_get(&vb_msgq, &frame, K_FOREVER);
decode_frame(&frame);
}
}
K_THREAD_DEFINE(vb_thread, CONFIG_VICTRONBLE_THREAD_STACK_SIZE,
vb_thread_fn, NULL, NULL, NULL,
CONFIG_VICTRONBLE_THREAD_PRIORITY, 0, 0);
/* --- Public API -------------------------------------------------------- */
int victronble_cb_register(struct victronble_cb *cb)
{
struct victronble_cb *it;
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, it, node) {
if (it == cb) {
return -EALREADY;
}
}
sys_slist_append(&callbacks, &cb->node);
return 0;
}
int victronble_device_add(const bt_addr_le_t *addr,
const uint8_t key[VICTRONBLE_KEY_LEN])
{
int ret = -ENOMEM;
k_mutex_lock(&dev_mtx, K_FOREVER);
if (find_device(addr) != NULL) {
ret = -EALREADY;
} else {
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
if (!devices[i].used) {
bt_addr_le_copy(&devices[i].addr, addr);
memcpy(devices[i].key, key,
VICTRONBLE_KEY_LEN);
devices[i].have_nonce = false;
devices[i].used = true;
ret = 0;
break;
}
}
}
k_mutex_unlock(&dev_mtx);
return ret;
}
int victronble_device_remove(const bt_addr_le_t *addr)
{
int ret = -ENOENT;
k_mutex_lock(&dev_mtx, K_FOREVER);
struct vb_device *dev = find_device(addr);
if (dev != NULL) {
memset(dev, 0, sizeof(*dev));
ret = 0;
}
k_mutex_unlock(&dev_mtx);
return ret;
}
int victronble_start(void)
{
/* Passive scan at a low duty cycle: Victron devices advertise about
* once per second, so slow-scan parameters catch every record for a
* fraction of the radio-on time. */
static const struct bt_le_scan_param param = {
.type = BT_LE_SCAN_TYPE_PASSIVE,
.options = BT_LE_SCAN_OPT_NONE,
.interval = CONFIG_VICTRONBLE_SCAN_INTERVAL,
.window = CONFIG_VICTRONBLE_SCAN_WINDOW,
};
int err;
if (scanning) {
return -EALREADY;
}
err = bt_le_scan_start(&param, scan_recv);
if (err != 0) {
LOG_ERR("scan start failed (%d)", err);
return err;
}
scanning = true;
LOG_INF("observing (interval %u window %u)",
CONFIG_VICTRONBLE_SCAN_INTERVAL, CONFIG_VICTRONBLE_SCAN_WINDOW);
return 0;
}
int victronble_stop(void)
{
int err;
if (!scanning) {
return -EALREADY;
}
err = bt_le_scan_stop();
if (err == 0) {
scanning = false;
}
return err;
}
void victronble_get_stats(struct victronble_stats *out)
{
*out = stats;
}
static int vb_init(void)
{
k_mutex_init(&dev_mtx);
return 0;
}
SYS_INIT(vb_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY);
#endif /* __ZEPHYR__ */
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Generate test_vectors.h for the victronble core host tests.
Plaintext payloads are packed here from human-readable field values
(mirroring Victron's Extra Manufacturer Data layouts) and encrypted with
the openssl CLI — an implementation independent of the library's bundled
tiny-AES — so the vectors cross-check the AES-CTR semantics as well as
the parsers. The generated header is committed; python/openssl are only
needed to regenerate it.
"""
import subprocess
from pathlib import Path
COMPANY_ID = 0x02E1
PRODUCT_ADV = 0x10
KEY = bytes.fromhex("0df4d0395b7d5d4f5a0d0af52e1b4c1e")
def aes_ctr(key: bytes, nonce: int, plaintext: bytes) -> bytes:
iv = bytes([nonce & 0xFF, (nonce >> 8) & 0xFF] + [0] * 14)
return subprocess.run(
["openssl", "enc", "-aes-128-ctr", "-K", key.hex(), "-iv", iv.hex(),
"-nopad"],
input=plaintext, capture_output=True, check=True).stdout
def frame(record_type: int, model_id: int, readout: int, nonce: int,
plaintext: bytes, key: bytes = KEY) -> bytes:
head = bytes([COMPANY_ID & 0xFF, COMPANY_ID >> 8, PRODUCT_ADV,
model_id & 0xFF, model_id >> 8, readout, record_type,
nonce & 0xFF, (nonce >> 8) & 0xFF, key[0]])
return head + aes_ctr(key, nonce, plaintext)
def le16(v: int) -> bytes:
return bytes([v & 0xFF, (v >> 8) & 0xFF])
def pad21(b: bytes) -> bytes:
assert len(b) <= 21
return b + bytes(21 - len(b))
def pack_bits(fields):
"""fields: list of (value, width). LSB-first bit packing."""
total = sum(w for _, w in fields)
out = bytearray((total + 7) // 8)
bit = 0
for value, width in fields:
for i in range(width):
if (value >> i) & 1:
out[(bit + i) >> 3] |= 1 << ((bit + i) & 7)
bit += width
return bytes(out)
# --- Payloads ---------------------------------------------------------------
# Solar charger: bulk, no error, 13.24 V, 5.4 A, 1.20 kWh today, 340 W,
# no load output (9-bit 0x1FF).
solar = pad21(bytes([3, 0]) + le16(1324) + le16(54) + le16(120) + le16(340) +
le16(0x1FF))
# Battery monitor: TTG 600 min, 12.80 V, alarms lowV|lowSOC, aux mode 2
# (temperature 25.00 C = 29815 * 0.01 K), current -2.5 A, consumed 50.0 Ah,
# SOC 85.5 %.
batmon = pad21(pack_bits([
(600, 16), # TTG minutes
(1280, 16), # voltage, 0.01 V
(0x0005, 16), # alarm bitmask
(29815, 16), # aux raw (0.01 K)
(2, 2), # aux mode = temperature
(-2500 & 0x3FFFFF, 22), # current, 0.001 A
(500, 20), # consumed, 0.1 Ah
(855, 10), # SOC, 0.1 %
]))
# Inverter: inverting, 25.86 V, -12.34 A, -230 W, overload alarm.
inverter = pad21(bytes([9, 0]) + le16(2586) + le16(-1234 & 0xFFFF) +
((-230) & 0xFFFFFF).to_bytes(3, "little") + bytes([0x08]))
# DC-DC converter: float, no error, in 25.30 V, out 13.31 V, 7.65 A.
dcdc = pad21(bytes([5, 0]) + le16(2530) + le16(1331) + le16(765))
# AC charger: absorption, no error, out1 14.40 V / 10.0 A, out2/3 absent,
# temp 35 C, AC current 1.2 A.
accharger = pad21(pack_bits([
(4, 8), (0, 8),
(1440, 13), (100, 11),
(0x1FFF, 13), (0x7FF, 11),
(0x1FFF, 13), (0x7FF, 11),
(35 + 40, 7),
(12, 9),
]))
VECTORS = [
("solar", frame(0x01, 0xA060, 0x00, 0x1234, solar)),
("batmon", frame(0x02, 0xA389, 0x00, 0xBEEF, batmon)),
("inverter", frame(0x03, 0xA2FA, 0x00, 0x0001, inverter)),
("dcdc", frame(0x04, 0xA3C0, 0x00, 0xFFFF, dcdc)),
("accharger", frame(0x08, 0xA339, 0x00, 0x00C8, accharger)),
# Multi RS record type decodes via the inverter parser.
("multirs", frame(0x0B, 0xA512, 0x00, 0x0042, inverter)),
# GX device: recognised record type, no decoder -> ERR_UNSUPPORTED.
("gx", frame(0x07, 0xA100, 0x00, 0x0007, pad21(b""))),
]
def main():
out = Path(__file__).with_name("test_vectors.h")
lines = [
"/* Generated by gen_vectors.py — do not edit by hand.",
" * Ciphertext produced with `openssl enc -aes-128-ctr`, independent",
" * of the library's bundled AES. */",
"",
f'static const char VEC_KEY_HEX[] = "{KEY.hex()}";',
"",
]
for name, data in VECTORS:
arr = ", ".join(f"0x{b:02x}" for b in data)
lines.append(f"static const uint8_t VEC_{name.upper()}[] = {{ {arr} }};")
lines.append("")
out.write_text("\n".join(lines))
print(f"wrote {out} ({len(VECTORS)} vectors)")
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Build and run the victronble core host tests (plain gcc, no framework).
# Regenerate vectors first with: python3 gen_vectors.py
set -e
cd "$(dirname "$0")"
cc -std=c99 -Wall -Wextra -Werror -I../../include -I../../src \
../../src/victronble_core.c ../../src/victronble_aes_sw.c \
../../src/crypto/vble_aes.c test_main.c -lm -o victronble_test
./victronble_test
+173
View File
@@ -0,0 +1,173 @@
/**
* victronble core host tests.
*
* Plain C, no framework: non-zero exit on failure. Positive vectors come
* from test_vectors.h (openssl-encrypted, independent of the bundled AES);
* negative cases are built inline.
*
* Build & run: ./run.sh (or see the gcc line inside it)
*/
#include <stdio.h>
#include <string.h>
#include <math.h>
#include "victronble.h"
#include "test_vectors.h"
static int failures;
#define CHECK(cond) do { \
if (!(cond)) { \
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
failures++; \
} \
} while (0)
static int feq(float a, float b)
{
return fabsf(a - b) < 0.005f;
}
static victronble_err_t decode(const uint8_t *frame, size_t len,
const uint8_t key[16], victronble_record_t *rec)
{
memset(rec, 0xAA, sizeof(*rec));
return victronble_decode(frame, len, key, rec);
}
int main(void)
{
uint8_t key[VICTRONBLE_KEY_LEN];
victronble_record_t rec;
CHECK(victronble_parse_key(VEC_KEY_HEX, key));
/* --- solar charger --- */
CHECK(victronble_is_product_adv(VEC_SOLAR, sizeof(VEC_SOLAR)));
CHECK(victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), key));
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_SOLAR_CHARGER);
CHECK(rec.model_id == 0xA060);
CHECK(rec.nonce == 0x1234);
CHECK(rec.u.solar.state == VICTRONBLE_STATE_BULK);
CHECK(rec.u.solar.error == 0);
CHECK(feq(rec.u.solar.battery_voltage, 13.24f));
CHECK(feq(rec.u.solar.battery_current, 5.4f));
CHECK(rec.u.solar.yield_today_wh == 1200);
CHECK(feq(rec.u.solar.pv_power, 340.0f));
CHECK(isnan(rec.u.solar.load_current));
CHECK(strcmp(victronble_state_str(rec.u.solar.state), "bulk") == 0);
/* --- battery monitor --- */
CHECK(decode(VEC_BATMON, sizeof(VEC_BATMON), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_BATTERY_MONITOR);
CHECK(rec.u.batmon.remaining_minutes == 600);
CHECK(feq(rec.u.batmon.voltage, 12.80f));
CHECK(rec.u.batmon.alarm == 0x0005);
CHECK(rec.u.batmon.aux_mode == 2);
CHECK(feq(rec.u.batmon.temperature, 25.0f));
CHECK(isnan(rec.u.batmon.aux_voltage));
CHECK(feq(rec.u.batmon.current, -2.5f));
CHECK(feq(rec.u.batmon.consumed_ah, -50.0f));
CHECK(feq(rec.u.batmon.soc, 85.5f));
/* --- inverter --- */
CHECK(decode(VEC_INVERTER, sizeof(VEC_INVERTER), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
CHECK(rec.u.inverter.state == VICTRONBLE_STATE_INVERTING);
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
CHECK(feq(rec.u.inverter.battery_current, -12.34f));
CHECK(feq(rec.u.inverter.ac_power, -230.0f));
CHECK(rec.u.inverter.alarms == 0x08);
/* --- dc-dc converter --- */
CHECK(decode(VEC_DCDC, sizeof(VEC_DCDC), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_DCDC_CONVERTER);
CHECK(rec.u.dcdc.state == VICTRONBLE_STATE_FLOAT);
CHECK(feq(rec.u.dcdc.input_voltage, 25.30f));
CHECK(feq(rec.u.dcdc.output_voltage, 13.31f));
CHECK(feq(rec.u.dcdc.output_current, 7.65f));
CHECK(rec.nonce == 0xFFFF);
/* --- ac charger --- */
CHECK(decode(VEC_ACCHARGER, sizeof(VEC_ACCHARGER), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_AC_CHARGER);
CHECK(rec.u.ac.state == VICTRONBLE_STATE_ABSORPTION);
CHECK(feq(rec.u.ac.voltage1, 14.40f));
CHECK(feq(rec.u.ac.current1, 10.0f));
CHECK(isnan(rec.u.ac.voltage2) && isnan(rec.u.ac.current2));
CHECK(isnan(rec.u.ac.voltage3) && isnan(rec.u.ac.current3));
CHECK(feq(rec.u.ac.temperature, 35.0f));
CHECK(feq(rec.u.ac.ac_current, 1.2f));
/* --- multi RS collapses to the inverter decoder --- */
CHECK(decode(VEC_MULTIRS, sizeof(VEC_MULTIRS), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
CHECK(rec.record_type == VICTRONBLE_DEV_MULTI_RS);
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
/* --- negative cases --- */
/* Known record type, no decoder */
CHECK(decode(VEC_GX, sizeof(VEC_GX), key, &rec) == VICTRONBLE_ERR_UNSUPPORTED);
/* Truncated: shorter than the header */
CHECK(decode(VEC_SOLAR, 9, key, &rec) == VICTRONBLE_ERR_SHORT);
CHECK(!victronble_is_product_adv(VEC_SOLAR, 9));
/* Wrong company ID */
{
uint8_t bad[sizeof(VEC_SOLAR)];
memcpy(bad, VEC_SOLAR, sizeof(bad));
bad[0] = 0x4C; bad[1] = 0x00; /* Apple */
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_VICTRON);
CHECK(!victronble_is_product_adv(bad, sizeof(bad)));
}
/* Not a product advertisement */
{
uint8_t bad[sizeof(VEC_SOLAR)];
memcpy(bad, VEC_SOLAR, sizeof(bad));
bad[2] = 0x01;
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_PRODUCT);
}
/* Wrong key: check byte catches it without decrypting */
{
uint8_t wrong_key[16];
memcpy(wrong_key, key, 16);
wrong_key[0] ^= 0xFF;
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec) ==
VICTRONBLE_ERR_KEY_MISMATCH);
CHECK(!victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key));
}
/* Wrong key with a matching check byte: decrypts to garbage but must
* not crash; solar parser accepts any bytes, so OK with junk values is
* acceptable — just require no error other than OK/SHORT. */
{
uint8_t wrong_key[16];
memcpy(wrong_key, key, 16);
wrong_key[15] ^= 0xFF;
victronble_err_t err = decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec);
CHECK(err == VICTRONBLE_OK || err == VICTRONBLE_ERR_SHORT);
}
/* Key parsing */
{
uint8_t k[16];
CHECK(!victronble_parse_key("00112233", k)); /* short */
CHECK(!victronble_parse_key(NULL, k));
CHECK(!victronble_parse_key("zz112233445566778899aabbccddeeff", k));
CHECK(victronble_parse_key("00112233445566778899AABBCCDDEEFF", k));
CHECK(k[0] == 0x00 && k[15] == 0xFF);
}
if (failures == 0) {
printf("victronble core: all tests passed\n");
return 0;
}
printf("victronble core: %d FAILURE(S)\n", failures);
return 1;
}
+13
View File
@@ -0,0 +1,13 @@
/* Generated by gen_vectors.py — do not edit by hand.
* Ciphertext produced with `openssl enc -aes-128-ctr`, independent
* of the library's bundled AES. */
static const char VEC_KEY_HEX[] = "0df4d0395b7d5d4f5a0d0af52e1b4c1e";
static const uint8_t VEC_SOLAR[] = { 0xe1, 0x02, 0x10, 0x60, 0xa0, 0x00, 0x01, 0x34, 0x12, 0x0d, 0x53, 0xb0, 0x25, 0x4c, 0x65, 0xd3, 0x4a, 0x92, 0x34, 0x70, 0x3a, 0x6c, 0x19, 0x73, 0x7e, 0x65, 0xf6, 0xa4, 0x42, 0xd0, 0x56 };
static const uint8_t VEC_BATMON[] = { 0xe1, 0x02, 0x10, 0x89, 0xa3, 0x00, 0x02, 0xef, 0xbe, 0x0d, 0x41, 0x08, 0x53, 0x2f, 0x0d, 0x44, 0xa5, 0x1c, 0x62, 0xa0, 0x51, 0xe9, 0x7c, 0x1f, 0xae, 0x2f, 0xe9, 0xe8, 0x2a, 0x0e, 0x15 };
static const uint8_t VEC_INVERTER[] = { 0xe1, 0x02, 0x10, 0xfa, 0xa2, 0x00, 0x03, 0x01, 0x00, 0x0d, 0xf6, 0x2d, 0x11, 0x5e, 0x6f, 0x60, 0x17, 0x3f, 0x62, 0xeb, 0x75, 0xfe, 0x67, 0x69, 0xef, 0x59, 0x71, 0xb6, 0xb6, 0xd4, 0x2c };
static const uint8_t VEC_DCDC[] = { 0xe1, 0x02, 0x10, 0xc0, 0xa3, 0x00, 0x04, 0xff, 0xff, 0x0d, 0x66, 0xcc, 0x80, 0x55, 0xf1, 0x96, 0xe8, 0xb8, 0x15, 0x7f, 0x76, 0xd2, 0x4a, 0x5c, 0xeb, 0xf2, 0xbb, 0x6c, 0x9f, 0x58, 0x08 };
static const uint8_t VEC_ACCHARGER[] = { 0xe1, 0x02, 0x10, 0x39, 0xa3, 0x00, 0x08, 0xc8, 0x00, 0x0d, 0x10, 0xc5, 0xcd, 0xca, 0xbb, 0x29, 0x20, 0xda, 0xf8, 0x1e, 0xae, 0xf6, 0x8e, 0xce, 0xd4, 0xec, 0xb5, 0x6b, 0xa9, 0x99, 0x4a };
static const uint8_t VEC_MULTIRS[] = { 0xe1, 0x02, 0x10, 0x12, 0xa5, 0x00, 0x0b, 0x42, 0x00, 0x0d, 0xf2, 0x03, 0x6f, 0xd7, 0xe5, 0x20, 0x2a, 0x5c, 0x6e, 0x96, 0x59, 0xf8, 0x26, 0x28, 0x40, 0x2b, 0xdb, 0x7d, 0xe5, 0x4b, 0x88 };
static const uint8_t VEC_GX[] = { 0xe1, 0x02, 0x10, 0x00, 0xa1, 0x00, 0x07, 0x07, 0x00, 0x0d, 0xe3, 0x06, 0xe4, 0xb3, 0xc3, 0x81, 0x4e, 0x8a, 0xf0, 0x82, 0x6e, 0xd9, 0xf0, 0x47, 0x06, 0x3e, 0x10, 0x2e, 0xcc, 0x1f, 0x56 };
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
name: victronble
build:
cmake: .
kconfig: Kconfig