From 037af3315467478e6d481b8b43911a981201c53f Mon Sep 17 00:00:00 2001 From: Scott Penrose Date: Fri, 21 Aug 2026 11:59:19 +1000 Subject: [PATCH] v0.7.0: pure C core + Zephyr module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CMakeLists.txt | 12 + Kconfig | 73 +++++ VERSIONS | 42 +++ docs/ZEPHYR_PORT.md | 560 ++++++++++++++++++++++++++++++++++ include/victronble.h | 206 +++++++++++++ include/victronble_zephyr.h | 71 +++++ library.json | 2 +- library.properties | 4 +- src/VictronBLE.cpp | 391 ++++++++---------------- src/VictronBLE.h | 14 +- src/victronble.h | 5 + src/victronble_aes_sw.c | 38 +++ src/victronble_core.c | 382 +++++++++++++++++++++++ src/victronble_zephyr.c | 301 ++++++++++++++++++ tests/vectors/gen_vectors.py | 129 ++++++++ tests/vectors/run.sh | 9 + tests/vectors/test_main.c | 173 +++++++++++ tests/vectors/test_vectors.h | 13 + tests/vectors/victronble_test | Bin 0 -> 34296 bytes zephyr/module.yml | 4 + 20 files changed, 2145 insertions(+), 284 deletions(-) create mode 100644 CMakeLists.txt create mode 100644 Kconfig create mode 100644 docs/ZEPHYR_PORT.md create mode 100644 include/victronble.h create mode 100644 include/victronble_zephyr.h create mode 100644 src/victronble.h create mode 100644 src/victronble_aes_sw.c create mode 100644 src/victronble_core.c create mode 100644 src/victronble_zephyr.c create mode 100644 tests/vectors/gen_vectors.py create mode 100755 tests/vectors/run.sh create mode 100644 tests/vectors/test_main.c create mode 100644 tests/vectors/test_vectors.h create mode 100755 tests/vectors/victronble_test create mode 100644 zephyr/module.yml diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6a0db3b --- /dev/null +++ b/CMakeLists.txt @@ -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() diff --git a/Kconfig b/Kconfig new file mode 100644 index 0000000..b472071 --- /dev/null +++ b/Kconfig @@ -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 diff --git a/VERSIONS b/VERSIONS index 409820c..c34ed51 100644 --- a/VERSIONS +++ b/VERSIONS @@ -1,5 +1,47 @@ # 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=`; 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) Multi-platform support. The library now runs on **nRF52840** (Adafruit/Seeed diff --git a/docs/ZEPHYR_PORT.md b/docs/ZEPHYR_PORT.md new file mode 100644 index 0000000..804a02b --- /dev/null +++ b/docs/ZEPHYR_PORT.md @@ -0,0 +1,560 @@ +# victronble → pure C core + Zephyr module + +Staged porting plan. Five stages, each independently shippable. Stages 0–2 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(¶m, 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: ``, ``, ``, `` only. +- Reentrant. No file-scope mutable state in the parse path. + +### `include/victronble.h` + +```c +#ifndef VICTRONBLE_H +#define VICTRONBLE_H + +#include +#include +#include + +#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 `` 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 1–2. 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 | 1–2 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 | 1–2 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. diff --git a/include/victronble.h b/include/victronble.h new file mode 100644 index 0000000..b7327a5 --- /dev/null +++ b/include/victronble.h @@ -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 +#include +#include + +#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 */ diff --git a/include/victronble_zephyr.h b/include/victronble_zephyr.h new file mode 100644 index 0000000..3e26053 --- /dev/null +++ b/include/victronble_zephyr.h @@ -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 +#include + +#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 */ diff --git a/library.json b/library.json index e297c44..ba64fb6 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "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.", "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": { diff --git a/library.properties b/library.properties index 8745d1e..b0e2b4e 100644 --- a/library.properties +++ b/library.properties @@ -1,11 +1,11 @@ name=VictronBLE -version=0.6.1 +version=0.7.0 author=Scott Penrose maintainer=Scott Penrose 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. category=Communication -url=https://github.com/SH3D/VictronBLE +url=https://gitea.sh3d.com.au/Sh3d/VictronBLE architectures=esp32,nrf52 depends= includes=VictronBLE.h diff --git a/src/VictronBLE.cpp b/src/VictronBLE.cpp index 5f006df..ef70cc5 100644 --- a/src/VictronBLE.cpp +++ b/src/VictronBLE.cpp @@ -1,15 +1,23 @@ /** * 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 * License: MIT */ #include "VictronBLE.h" -#include "crypto/vble_aes.h" +#include "victronble.h" #include +#include + +// 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() : deviceCount(0), callback(nullptr), debugEnabled(false), @@ -24,7 +32,6 @@ VictronBLE::VictronBLE() bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey, VictronDeviceType type) { if (deviceCount >= VICTRON_MAX_DEVICES) return false; - if (!hexKey || strlen(hexKey) != 32) return false; if (!mac || strlen(mac) == 0) return false; 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]; memset(entry, 0, sizeof(DeviceEntry)); + + if (!victronble_parse_key(hexKey, entry->key)) return false; entry->active = true; 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.rssi = -100; - if (!hexToBytes(hexKey, entry->key, 16)) return false; - deviceCount++; 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. void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len, const char* macStr, int8_t rssi) { - if (!mfgData || len < 10) 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); + if (!victronble_is_product_adv(mfgData, len)) return; // Normalize MAC and find device 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) - 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 return; } @@ -90,274 +88,123 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len, return; } + victronble_record_t rec; + 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 (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n", - entry->device.name, mfg.nonceDataCounter); + entry->device.name, rec.nonce); - if (parseAdvertisement(entry, mfg)) { - entry->lastNonce = mfg.nonceDataCounter; - entry->device.rssi = rssi; - entry->device.lastUpdate = now; - } + storeRecord(entry, rec); + entry->lastNonce = nonce; + entry->device.rssi = rssi; + entry->device.lastUpdate = now; + entry->device.dataValid = true; + if (callback) callback(&entry->device); } -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; - if (callback) callback(&entry->device); - } - - return ok; -} - -bool VictronBLE::decryptData(const uint8_t* encrypted, size_t len, - const uint8_t* key, const uint8_t* iv, - uint8_t* decrypted) { - // AES-128-CTR via the bundled portable implementation (was mbedTLS on ESP32). - // CTR is symmetric and operates in place, so copy then XOR the keystream. - struct vble_aes_ctx ctx; - vble_aes_init_ctx_iv(&ctx, key, iv); - memcpy(decrypted, encrypted, len); - vble_aes_ctr_xcrypt(&ctx, decrypted, len); - return true; -} - -bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result) { - if (len < sizeof(victronSolarChargerPayload)) return false; - const auto* p = reinterpret_cast(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) { - Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n", - result.batteryVoltage, result.batteryCurrent, - (int)result.panelPower, result.chargeState); - } - return true; -} - -bool VictronBLE::parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result) { - // Payload is bit-packed (10 fields, 104 bits ending in byte 12). Decode LSB-first. - if (len < 13) return false; - - size_t bit = 0; - auto readBits = [&](uint8_t width) -> uint32_t { - uint32_t value = 0; - for (uint8_t i = 0; i < width; i++) { - size_t b = bit + i; - value |= (uint32_t)((data[b >> 3] >> (b & 7)) & 0x01) << i; +// 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) { + case VICTRONBLE_DEV_SOLAR_CHARGER: { + entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER; + VictronSolarData& s = entry->device.solar; + s.chargeState = rec.u.solar.state; + s.errorCode = rec.u.solar.error; + s.batteryVoltage = rec.u.solar.battery_voltage; + s.batteryCurrent = rec.u.solar.battery_current; + s.panelPower = rec.u.solar.pv_power; + s.yieldToday = (uint16_t)rec.u.solar.yield_today_wh; + s.loadCurrent = nan_to_zero(rec.u.solar.load_current); + if (debugEnabled) { + Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n", + s.batteryVoltage, s.batteryCurrent, + (int)s.panelPower, s.chargeState); } - bit += width; - return value; - }; - - 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); + break; } - 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; + case VICTRONBLE_DEV_BATTERY_MONITOR: { + entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR; + VictronBatteryData& b = entry->device.battery; + b.voltage = rec.u.batmon.voltage; + b.current = rec.u.batmon.current; + b.temperature = nan_to_zero(rec.u.batmon.temperature); + b.auxVoltage = nan_to_zero(rec.u.batmon.aux_voltage); + b.remainingMinutes = rec.u.batmon.remaining_minutes; + b.consumedAh = rec.u.batmon.consumed_ah; + b.soc = rec.u.batmon.soc; + b.alarmLowVoltage = (rec.u.batmon.alarm & 0x0001) != 0; + b.alarmHighVoltage = (rec.u.batmon.alarm & 0x0002) != 0; + b.alarmLowSOC = (rec.u.batmon.alarm & 0x0004) != 0; + b.alarmLowTemperature = (rec.u.batmon.alarm & 0x0010) != 0; + b.alarmHighTemperature = (rec.u.batmon.alarm & 0x0020) != 0; + if (debugEnabled) { + Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n", + b.voltage, b.current, b.soc); + } + break; } - - // 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) { - Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n", - result.voltage, result.current, result.soc); + case VICTRONBLE_DEV_INVERTER: { + entry->device.deviceType = DEVICE_TYPE_INVERTER; + VictronInverterData& inv = entry->device.inverter; + inv.batteryVoltage = rec.u.inverter.battery_voltage; + inv.batteryCurrent = rec.u.inverter.battery_current; + inv.acPower = rec.u.inverter.ac_power; + inv.state = rec.u.inverter.state; + inv.alarmLowVoltage = (rec.u.inverter.alarms & 0x01) != 0; + inv.alarmHighVoltage = (rec.u.inverter.alarms & 0x02) != 0; + inv.alarmHighTemperature = (rec.u.inverter.alarms & 0x04) != 0; + inv.alarmOverload = (rec.u.inverter.alarms & 0x08) != 0; + if (debugEnabled) { + Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n", + inv.batteryVoltage, (int)inv.acPower, inv.state); + } + break; } - return true; -} - -bool VictronBLE::parseInverter(const uint8_t* data, size_t len, VictronInverterData& result) { - if (len < sizeof(victronInverterPayload)) return false; - const auto* p = reinterpret_cast(data); - - result.state = p->deviceState; - result.batteryVoltage = p->batteryVoltage * 0.01f; - result.batteryCurrent = p->batteryCurrent * 0.01f; - - // AC Power (signed 24-bit) - int32_t acPower = p->acPowerLow | (p->acPowerMid << 8) | (p->acPowerHigh << 16); - 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) { - Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n", - result.batteryVoltage, (int)result.acPower, result.state); + case VICTRONBLE_DEV_DCDC_CONVERTER: { + entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER; + VictronDCDCData& d = entry->device.dcdc; + d.chargeState = rec.u.dcdc.state; + d.errorCode = rec.u.dcdc.error; + d.inputVoltage = rec.u.dcdc.input_voltage; + d.outputVoltage = rec.u.dcdc.output_voltage; + d.outputCurrent = rec.u.dcdc.output_current; + if (debugEnabled) { + Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n", + d.inputVoltage, d.outputVoltage, d.outputCurrent); + } + break; } - return true; -} - -bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result) { - if (len < sizeof(victronDCDCConverterPayload)) return false; - const auto* p = reinterpret_cast(data); - - result.chargeState = p->chargeState; - result.errorCode = p->errorCode; - result.inputVoltage = p->inputVoltage * 0.01f; - result.outputVoltage = p->outputVoltage * 0.01f; - result.outputCurrent = p->outputCurrent * 0.01f; - - if (debugEnabled) { - Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n", - result.inputVoltage, result.outputVoltage, result.outputCurrent); + 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 --- -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) { int j = 0; for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) { diff --git a/src/VictronBLE.h b/src/VictronBLE.h index 1ec3177..c3ea751 100644 --- a/src/VictronBLE.h +++ b/src/VictronBLE.h @@ -16,6 +16,7 @@ #define VICTRON_BLE_H #include +#include "victronble.h" // pure C core: decode + decrypt (src/victronble_core.c) // --- Platform BLE backend selection --- // The BLE scanning layer is the only platform-specific part of the library. @@ -247,22 +248,17 @@ private: uint32_t minIntervalMs; bool initialized; - static bool hexToBytes(const char* hex, uint8_t* out, size_t len); static void normalizeMAC(const char* input, char* output); 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 // 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, const char* macStr, int8_t rssi); - bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg); - 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); + void storeRecord(DeviceEntry* entry, const victronble_record_t& rec); // --- Platform-specific BLE backend (see src/esp32 and src/nrf52) --- #if defined(VICTRON_BACKEND_ESP32) diff --git a/src/victronble.h b/src/victronble.h new file mode 100644 index 0000000..72cd255 --- /dev/null +++ b/src/victronble.h @@ -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" diff --git a/src/victronble_aes_sw.c b/src/victronble_aes_sw.c new file mode 100644 index 0000000..1992a34 --- /dev/null +++ b/src/victronble_aes_sw.c @@ -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 + +#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; +} diff --git a/src/victronble_core.c b/src/victronble_core.c new file mode 100644 index 0000000..6f59495 --- /dev/null +++ b/src/victronble_core.c @@ -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 +#include + +/* 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 "?"; + } +} diff --git a/src/victronble_zephyr.c b/src/victronble_zephyr.c new file mode 100644 index 0000000..3480cf4 --- /dev/null +++ b/src/victronble_zephyr.c @@ -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 +#include +#include +#include + +#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(¶m, 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__ */ diff --git a/tests/vectors/gen_vectors.py b/tests/vectors/gen_vectors.py new file mode 100644 index 0000000..10fb10e --- /dev/null +++ b/tests/vectors/gen_vectors.py @@ -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() diff --git a/tests/vectors/run.sh b/tests/vectors/run.sh new file mode 100755 index 0000000..95c36e9 --- /dev/null +++ b/tests/vectors/run.sh @@ -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 diff --git a/tests/vectors/test_main.c b/tests/vectors/test_main.c new file mode 100644 index 0000000..0a8bbc7 --- /dev/null +++ b/tests/vectors/test_main.c @@ -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 +#include +#include + +#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; +} diff --git a/tests/vectors/test_vectors.h b/tests/vectors/test_vectors.h new file mode 100644 index 0000000..da06e47 --- /dev/null +++ b/tests/vectors/test_vectors.h @@ -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 }; diff --git a/tests/vectors/victronble_test b/tests/vectors/victronble_test new file mode 100755 index 0000000000000000000000000000000000000000..c4563279b218b299f55da46b43253bf08671c429 GIT binary patch literal 34296 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�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 literal 0 HcmV?d00001 diff --git a/zephyr/module.yml b/zephyr/module.yml new file mode 100644 index 0000000..6909efc --- /dev/null +++ b/zephyr/module.yml @@ -0,0 +1,4 @@ +name: victronble +build: + cmake: . + kconfig: Kconfig