v0.7.0: pure C core + Zephyr module
- Extract all decode/decrypt into a dependency-free C99 core
(include/victronble.h, src/victronble_core.c): victronble_decode(),
is_product_adv/key_matches pre-filters, NAN sentinels, LE accessors.
- AES-128-CTR behind a hook: weak-symbol bundled tiny-AES default,
runtime override (victronble_set_aes_ctr) for PSA/mbedTLS/hardware.
- Arduino VictronBLE class becomes a thin wrapper over the core
(registry + nonce dedup + rate limit); public C++ API unchanged,
NAN converted back to the legacy 0 convention.
- Host test vectors (tests/vectors): openssl-generated ciphertext,
independent of the bundled AES; all five payload shapes + negatives.
- Zephyr module: zephyr/module.yml + Kconfig (CONFIG_VICTRONBLE) +
observer backend (victronble_zephyr.{h,c}) — scan cb pre-filters and
queues, dedicated decode thread, listener callbacks, slow passive
scan defaults, stats counters. docs/ZEPHYR_PORT.md records the plan.
- library.properties: fix URL (gitea, not the nonexistent GitHub).
This commit is contained in:
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,47 @@
|
|||||||
# Version History
|
# Version History
|
||||||
|
|
||||||
|
## 0.7.0 (2026-08-21)
|
||||||
|
|
||||||
|
Pure C core + Zephyr support. One repo now serves three ecosystems: Arduino
|
||||||
|
(unchanged public API), PlatformIO, and Zephyr west workspaces.
|
||||||
|
|
||||||
|
### Pure C99 core (`include/victronble.h`, `src/victronble_core.c`)
|
||||||
|
- All decoding and decryption extracted into a dependency-free, reentrant,
|
||||||
|
allocation-free C core: `victronble_decode(mfg, len, key, out)` plus the
|
||||||
|
cheap pre-filters `victronble_is_product_adv()` / `victronble_key_matches()`
|
||||||
|
and helpers (`victronble_parse_key`, `strerror`, `device_type_str`,
|
||||||
|
`state_str`). Explicit little-endian accessors — no packed-struct punning.
|
||||||
|
- Absent wire fields are now NAN in the core (test with `isnan()`); the
|
||||||
|
Arduino wrapper converts back to the legacy `0` convention, so existing
|
||||||
|
sketches see identical values.
|
||||||
|
- AES is behind a CTR-shaped hook (`victronble_aes_ctr_fn`): weak-symbol
|
||||||
|
default = the bundled tiny-AES (linker-droppable), runtime override via
|
||||||
|
`victronble_set_aes_ctr()` for PSA/mbedTLS/hardware backends.
|
||||||
|
- `VictronBLE` (Arduino) is now a thin wrapper over the core — registry,
|
||||||
|
nonce dedup and rate limiting only. Public C++ API unchanged.
|
||||||
|
|
||||||
|
### Host test vectors (`tests/vectors/`)
|
||||||
|
- Plain-gcc harness (`run.sh`), no framework. Positive vectors for all five
|
||||||
|
payload shapes are generated by `gen_vectors.py` and encrypted with the
|
||||||
|
openssl CLI — independent of the bundled AES, so the CTR/nonce semantics
|
||||||
|
are cross-checked — plus negative cases (truncated, wrong vendor, wrong
|
||||||
|
key, unsupported record type).
|
||||||
|
|
||||||
|
### Zephyr module (`zephyr/module.yml`, `Kconfig`, `CMakeLists.txt`)
|
||||||
|
- `CONFIG_VICTRONBLE` (needs `CONFIG_BT_OBSERVER`): passive-scan observer
|
||||||
|
(`include/victronble_zephyr.h`, `src/victronble_zephyr.c`). The scan
|
||||||
|
callback only pre-filters and queues; a dedicated thread decrypts, decodes,
|
||||||
|
nonce-dedups and fans out to registered listeners
|
||||||
|
(`victronble_device_add(addr, key)` / `victronble_cb_register()` /
|
||||||
|
`victronble_start()`), with `victronble_get_stats()` counters. Default scan
|
||||||
|
is slow/low-duty (1.28 s / 11.25 ms — Victron advertises ~1 Hz). Consume as
|
||||||
|
a west project or via `-DZEPHYR_EXTRA_MODULES=<path>`; see
|
||||||
|
`docs/ZEPHYR_PORT.md` for the porting plan this implements.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `library.properties` URL now points at the real repo (gitea) instead of a
|
||||||
|
nonexistent GitHub mirror.
|
||||||
|
|
||||||
## 0.6.0 (2026-06-04)
|
## 0.6.0 (2026-06-04)
|
||||||
|
|
||||||
Multi-platform support. The library now runs on **nRF52840** (Adafruit/Seeed
|
Multi-platform support. The library now runs on **nRF52840** (Adafruit/Seeed
|
||||||
|
|||||||
@@ -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: `<stdint.h>`, `<stddef.h>`, `<string.h>`, `<math.h>` only.
|
||||||
|
- Reentrant. No file-scope mutable state in the parse path.
|
||||||
|
|
||||||
|
### `include/victronble.h`
|
||||||
|
|
||||||
|
```c
|
||||||
|
#ifndef VICTRONBLE_H
|
||||||
|
#define VICTRONBLE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define VICTRONBLE_COMPANY_ID 0x02E1u /* Victron Energy BV */
|
||||||
|
#define VICTRONBLE_KEY_LEN 16
|
||||||
|
#define VICTRONBLE_MAX_MFG_LEN 31
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
VICTRONBLE_OK = 0,
|
||||||
|
VICTRONBLE_ERR_NOT_VICTRON = -1, /* company ID mismatch */
|
||||||
|
VICTRONBLE_ERR_SHORT = -2, /* truncated advertisement */
|
||||||
|
VICTRONBLE_ERR_NOT_PRODUCT = -3, /* not a product-advertisement record */
|
||||||
|
VICTRONBLE_ERR_KEY_MISMATCH = -4, /* key check byte failed */
|
||||||
|
VICTRONBLE_ERR_UNSUPPORTED = -5, /* known record type, no decoder */
|
||||||
|
VICTRONBLE_ERR_CRYPTO = -6, /* AES backend failed */
|
||||||
|
VICTRONBLE_ERR_DUPLICATE = -7, /* counter already seen (dedup enabled) */
|
||||||
|
} victronble_err_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
VICTRONBLE_DEV_UNKNOWN = 0,
|
||||||
|
VICTRONBLE_DEV_SOLAR_CHARGER,
|
||||||
|
VICTRONBLE_DEV_BATTERY_MONITOR,
|
||||||
|
VICTRONBLE_DEV_INVERTER,
|
||||||
|
VICTRONBLE_DEV_DCDC_CONVERTER,
|
||||||
|
VICTRONBLE_DEV_SMART_LITHIUM,
|
||||||
|
VICTRONBLE_DEV_AC_CHARGER,
|
||||||
|
/* extend from your existing enum */
|
||||||
|
} victronble_device_type_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
float battery_voltage; /* V, NAN if not present */
|
||||||
|
float battery_current; /* A, NAN if not present */
|
||||||
|
float yield_today; /* kWh */
|
||||||
|
float pv_power; /* W */
|
||||||
|
float load_current; /* A, NAN if load output absent */
|
||||||
|
uint8_t state;
|
||||||
|
uint8_t error;
|
||||||
|
} victronble_solar_charger_t;
|
||||||
|
|
||||||
|
/* ... battery monitor, inverter, dcdc, etc. ... */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
victronble_device_type_t type;
|
||||||
|
uint16_t model_id;
|
||||||
|
uint16_t counter; /* nonce / data counter, as received */
|
||||||
|
union {
|
||||||
|
victronble_solar_charger_t solar;
|
||||||
|
victronble_battery_monitor_t batmon;
|
||||||
|
/* ... */
|
||||||
|
} u;
|
||||||
|
} victronble_record_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode one Victron manufacturer-data blob.
|
||||||
|
*
|
||||||
|
* @param mfg Manufacturer-specific data, starting at the company ID.
|
||||||
|
* @param len Length of @p mfg.
|
||||||
|
* @param key 16-byte per-device advertisement key.
|
||||||
|
* @param out Populated on VICTRONBLE_OK. Untouched otherwise.
|
||||||
|
*
|
||||||
|
* Reentrant, allocation-free, no I/O.
|
||||||
|
*/
|
||||||
|
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN],
|
||||||
|
victronble_record_t *out);
|
||||||
|
|
||||||
|
/** Cheap pre-filter: company ID + record type only, no crypto. */
|
||||||
|
bool victronble_is_product_adv(const uint8_t *mfg, size_t len);
|
||||||
|
|
||||||
|
/** Key check byte test, so callers with several keys can pick one without decrypting. */
|
||||||
|
bool victronble_key_matches(const uint8_t *mfg, size_t len,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN]);
|
||||||
|
|
||||||
|
const char *victronble_strerror(victronble_err_t err);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif /* VICTRONBLE_H */
|
||||||
|
```
|
||||||
|
|
||||||
|
`victronble_key_matches()` is worth having separately. With several monitored devices you
|
||||||
|
otherwise burn an AES operation per device per advertisement just to find out which key
|
||||||
|
applies. The key check byte answers it for free.
|
||||||
|
|
||||||
|
### Sentinels
|
||||||
|
|
||||||
|
Victron encodes "not available" as per-field sentinel values, and they differ by field
|
||||||
|
width and signedness. Getting this wrong is the most likely source of a plausible-looking
|
||||||
|
but wrong reading, so decide the convention once and apply it everywhere.
|
||||||
|
|
||||||
|
Recommendation: **`NAN` for every float field that has a sentinel.** It propagates
|
||||||
|
correctly through arithmetic, tests cleanly with `isnan()`, and can't be confused with a
|
||||||
|
real zero the way a magic float can. For integer fields (state, error codes) keep the raw
|
||||||
|
value and document the sentinel.
|
||||||
|
|
||||||
|
If you'd rather avoid `<math.h>` on the smallest targets, the alternative is a
|
||||||
|
`uint32_t valid` bitmask per record — more code at every call site, but no FP dependency.
|
||||||
|
I'd only do this if flash is genuinely tight.
|
||||||
|
|
||||||
|
### Header layout
|
||||||
|
|
||||||
|
Encode the frame header as one internal struct with a single parse function, rather than
|
||||||
|
scattered offset arithmetic. Fields: record type, model ID (LE16), device/read-out type,
|
||||||
|
nonce counter (LE16), key check byte, then ciphertext offset and length. Use explicit
|
||||||
|
`sys_get_le16()`-style accessors rather than casting to packed structs — you'll want this
|
||||||
|
core to build on anything, and unaligned struct punning is exactly the kind of thing that
|
||||||
|
works on Cortex-M4 and bites you elsewhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 2 — AES abstraction
|
||||||
|
|
||||||
|
Split out because it's the one design decision that's hard to reverse later.
|
||||||
|
|
||||||
|
### Make the hook CTR-shaped, not ECB-shaped
|
||||||
|
|
||||||
|
Tempting to expose a single AES-128-ECB block encrypt, since for a ≤16-byte payload
|
||||||
|
CTR reduces to *ECB(counter block) XOR ciphertext* and you'd never need more. Don't.
|
||||||
|
Some record types (VE.Bus, Lynx BMS) exceed one block, and — more importantly — PSA and
|
||||||
|
every hardware accelerator expose CTR natively. An ECB-shaped hook forces those backends
|
||||||
|
to reimplement the counter loop that PSA would have done for them.
|
||||||
|
|
||||||
|
```c
|
||||||
|
/**
|
||||||
|
* AES-128-CTR transform hook.
|
||||||
|
*
|
||||||
|
* @param key 16-byte key.
|
||||||
|
* @param iv 16-byte initial counter block (nonce in the low bytes, rest zero).
|
||||||
|
* @param in Ciphertext.
|
||||||
|
* @param out Plaintext. May alias @p in.
|
||||||
|
* @param len Byte count, not necessarily a multiple of 16.
|
||||||
|
* @param user Opaque context supplied at registration.
|
||||||
|
* @return 0 on success, negative on failure.
|
||||||
|
*/
|
||||||
|
typedef int (*victronble_aes_ctr_fn)(const uint8_t key[16],
|
||||||
|
const uint8_t iv[16],
|
||||||
|
const uint8_t *in, uint8_t *out,
|
||||||
|
size_t len, void *user);
|
||||||
|
|
||||||
|
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selection mechanism
|
||||||
|
|
||||||
|
Use a **weak symbol default plus a runtime setter**:
|
||||||
|
|
||||||
|
```c
|
||||||
|
__attribute__((weak))
|
||||||
|
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
|
||||||
|
const uint8_t *in, uint8_t *out,
|
||||||
|
size_t len, void *user);
|
||||||
|
```
|
||||||
|
|
||||||
|
The weak symbol lets the linker drop the bundled software AES entirely when a backend
|
||||||
|
overrides it — which matters on a flash-constrained solar node. The runtime setter covers
|
||||||
|
the case where the backend is chosen at runtime or in a test harness. Both, not one.
|
||||||
|
|
||||||
|
### Backends to ship
|
||||||
|
|
||||||
|
| Backend | File | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Bundled software | `victronble_aes_sw.c` | Current implementation, unchanged. Default. Zero dependencies — keep this property, it's the reason your library ports easily. |
|
||||||
|
| PSA Crypto | `victronble_aes_psa.c` | `psa_crypto_init()` once, then `psa_cipher_encrypt()` with `PSA_ALG_CTR`. On nRF52840 this routes to CryptoCell (CC310). |
|
||||||
|
| mbedTLS | `victronble_aes_mbedtls.c` | Optional. `mbedtls_aes_crypt_ctr()`. Mostly for ESP-IDF users who already link it. |
|
||||||
|
|
||||||
|
Two PSA notes worth writing down now:
|
||||||
|
|
||||||
|
- Key lifetime. Importing a volatile key per advertisement is wasteful. Import once per
|
||||||
|
monitored device at registration and cache the `psa_key_id_t`, which means your device
|
||||||
|
registry needs somewhere to hold it — plan the struct field now rather than retrofitting.
|
||||||
|
- `psa_crypto_init()` must have run before any use, and on NCS the relevant Kconfig lives
|
||||||
|
under `NRF_SECURITY` rather than plain `MBEDTLS_*`. This diverges between upstream Zephyr
|
||||||
|
and NCS and is the single most annoying part of Stage 3.
|
||||||
|
|
||||||
|
### Host test harness
|
||||||
|
|
||||||
|
This is the payoff for Stages 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.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* victronble — pure C99 decoder for Victron Energy "Instant Readout" BLE
|
||||||
|
* advertisements (manufacturer ID 0x02E1, record type 0x10, AES-128-CTR).
|
||||||
|
*
|
||||||
|
* This is the portable core of the VictronBLE library: no Arduino, no BLE
|
||||||
|
* stack, no allocation, no I/O, reentrant. Feed it one manufacturer-specific
|
||||||
|
* data blob (starting at the company ID) plus the device's 16-byte
|
||||||
|
* advertisement key; get a decoded record back. Transport (scanning), device
|
||||||
|
* registries, rate limiting and logging belong to the platform wrappers
|
||||||
|
* (Arduino C++ class, Zephyr module).
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Scott Penrose
|
||||||
|
* License: MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef VICTRONBLE_H
|
||||||
|
#define VICTRONBLE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define VICTRONBLE_COMPANY_ID 0x02E1u /* Victron Energy BV */
|
||||||
|
#define VICTRONBLE_KEY_LEN 16
|
||||||
|
#define VICTRONBLE_MAX_CIPHER_LEN 21 /* encrypted payload in one advert */
|
||||||
|
#define VICTRONBLE_MIN_MFG_LEN 10 /* header before the ciphertext */
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
VICTRONBLE_OK = 0,
|
||||||
|
VICTRONBLE_ERR_NOT_VICTRON = -1, /* company ID mismatch */
|
||||||
|
VICTRONBLE_ERR_SHORT = -2, /* truncated advertisement */
|
||||||
|
VICTRONBLE_ERR_NOT_PRODUCT = -3, /* not a product-advertisement record */
|
||||||
|
VICTRONBLE_ERR_KEY_MISMATCH = -4, /* key check byte failed */
|
||||||
|
VICTRONBLE_ERR_UNSUPPORTED = -5, /* known record type, no decoder */
|
||||||
|
VICTRONBLE_ERR_CRYPTO = -6, /* AES backend failed */
|
||||||
|
} victronble_err_t;
|
||||||
|
|
||||||
|
/* Values are the raw Victron record-type IDs. The inverter family
|
||||||
|
* (0x03/0x06/0x0B/0x0C) all decode to VICTRONBLE_DEV_INVERTER. */
|
||||||
|
typedef enum {
|
||||||
|
VICTRONBLE_DEV_UNKNOWN = 0x00,
|
||||||
|
VICTRONBLE_DEV_SOLAR_CHARGER = 0x01,
|
||||||
|
VICTRONBLE_DEV_BATTERY_MONITOR = 0x02,
|
||||||
|
VICTRONBLE_DEV_INVERTER = 0x03,
|
||||||
|
VICTRONBLE_DEV_DCDC_CONVERTER = 0x04,
|
||||||
|
VICTRONBLE_DEV_SMART_LITHIUM = 0x05,
|
||||||
|
VICTRONBLE_DEV_INVERTER_RS = 0x06,
|
||||||
|
VICTRONBLE_DEV_GX_DEVICE = 0x07,
|
||||||
|
VICTRONBLE_DEV_AC_CHARGER = 0x08,
|
||||||
|
VICTRONBLE_DEV_BATTERY_PROTECT = 0x09,
|
||||||
|
VICTRONBLE_DEV_LYNX_SMART_BMS = 0x0A,
|
||||||
|
VICTRONBLE_DEV_MULTI_RS = 0x0B,
|
||||||
|
VICTRONBLE_DEV_VE_BUS = 0x0C,
|
||||||
|
VICTRONBLE_DEV_DC_ENERGY_METER = 0x0D,
|
||||||
|
VICTRONBLE_DEV_ORION_XS = 0x0F,
|
||||||
|
} victronble_device_type_t;
|
||||||
|
|
||||||
|
/* Charger states shared by solar / AC chargers (VE.Direct "CS"). */
|
||||||
|
enum {
|
||||||
|
VICTRONBLE_STATE_OFF = 0,
|
||||||
|
VICTRONBLE_STATE_LOW_POWER = 1,
|
||||||
|
VICTRONBLE_STATE_FAULT = 2,
|
||||||
|
VICTRONBLE_STATE_BULK = 3,
|
||||||
|
VICTRONBLE_STATE_ABSORPTION = 4,
|
||||||
|
VICTRONBLE_STATE_FLOAT = 5,
|
||||||
|
VICTRONBLE_STATE_STORAGE = 6,
|
||||||
|
VICTRONBLE_STATE_EQUALIZE = 7,
|
||||||
|
VICTRONBLE_STATE_INVERTING = 9,
|
||||||
|
VICTRONBLE_STATE_POWER_SUPPLY = 11,
|
||||||
|
VICTRONBLE_STATE_EXTERNAL_CONTROL = 252,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Fields the wire encodes as "not available" are NAN (test with isnan()).
|
||||||
|
* Integer fields keep the raw value; sentinels are documented per field. */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t state; /* VICTRONBLE_STATE_* */
|
||||||
|
uint8_t error;
|
||||||
|
float battery_voltage; /* V */
|
||||||
|
float battery_current; /* A */
|
||||||
|
float pv_power; /* W */
|
||||||
|
uint32_t yield_today_wh; /* Wh */
|
||||||
|
float load_current; /* A, NAN if no load output */
|
||||||
|
} victronble_solar_charger_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
float voltage; /* V */
|
||||||
|
float current; /* A */
|
||||||
|
float temperature; /* degC, NAN unless aux mode = temperature */
|
||||||
|
float aux_voltage; /* V, NAN unless aux mode = aux voltage */
|
||||||
|
uint16_t remaining_minutes; /* time-to-go; 0xFFFF = not available */
|
||||||
|
float consumed_ah; /* Ah, negative = consumed */
|
||||||
|
float soc; /* % */
|
||||||
|
uint8_t aux_mode; /* 0=aux V, 1=midpoint, 2=temperature, 3=none */
|
||||||
|
uint16_t alarm; /* raw 16-bit alarm bitmask */
|
||||||
|
} victronble_battery_monitor_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t state;
|
||||||
|
uint8_t alarms; /* raw alarm bits: 0x01 lowV, 0x02 highV,
|
||||||
|
* 0x04 highT, 0x08 overload */
|
||||||
|
float battery_voltage; /* V */
|
||||||
|
float battery_current; /* A */
|
||||||
|
float ac_power; /* W (signed) */
|
||||||
|
} victronble_inverter_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t state; /* charge state */
|
||||||
|
uint8_t error;
|
||||||
|
float input_voltage; /* V */
|
||||||
|
float output_voltage; /* V */
|
||||||
|
float output_current; /* A */
|
||||||
|
} victronble_dcdc_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t state;
|
||||||
|
uint8_t error;
|
||||||
|
float voltage1, current1; /* output 1 (V, A), NAN if absent */
|
||||||
|
float voltage2, current2; /* output 2 */
|
||||||
|
float voltage3, current3; /* output 3 */
|
||||||
|
float temperature; /* degC, NAN if not available */
|
||||||
|
float ac_current; /* A, NAN if not available */
|
||||||
|
} victronble_ac_charger_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
victronble_device_type_t type; /* decoded family (inverter collapsed) */
|
||||||
|
uint8_t record_type; /* raw record type from the wire */
|
||||||
|
uint16_t model_id;
|
||||||
|
uint8_t readout_type;
|
||||||
|
uint16_t nonce; /* data counter, as received */
|
||||||
|
union {
|
||||||
|
victronble_solar_charger_t solar;
|
||||||
|
victronble_battery_monitor_t batmon;
|
||||||
|
victronble_inverter_t inverter;
|
||||||
|
victronble_dcdc_t dcdc;
|
||||||
|
victronble_ac_charger_t ac;
|
||||||
|
} u;
|
||||||
|
} victronble_record_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode one Victron manufacturer-data blob.
|
||||||
|
*
|
||||||
|
* @param mfg Manufacturer-specific data, starting at the company ID.
|
||||||
|
* @param len Length of @p mfg in bytes.
|
||||||
|
* @param key 16-byte per-device advertisement key.
|
||||||
|
* @param out Populated on VICTRONBLE_OK; untouched otherwise.
|
||||||
|
*
|
||||||
|
* Reentrant, allocation-free, no I/O. Duplicate suppression (nonce
|
||||||
|
* tracking) is the caller's job — the nonce is returned in @p out.
|
||||||
|
*/
|
||||||
|
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN],
|
||||||
|
victronble_record_t *out);
|
||||||
|
|
||||||
|
/** Cheap pre-filter: company ID + product-advertisement record, no crypto. */
|
||||||
|
bool victronble_is_product_adv(const uint8_t *mfg, size_t len);
|
||||||
|
|
||||||
|
/** Key check byte test — pick the right key from several without decrypting. */
|
||||||
|
bool victronble_key_matches(const uint8_t *mfg, size_t len,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN]);
|
||||||
|
|
||||||
|
/** Parse a 32-hex-char advertisement key. Returns false on bad input. */
|
||||||
|
bool victronble_parse_key(const char *hex, uint8_t key[VICTRONBLE_KEY_LEN]);
|
||||||
|
|
||||||
|
const char *victronble_strerror(victronble_err_t err);
|
||||||
|
const char *victronble_device_type_str(victronble_device_type_t type);
|
||||||
|
/** Short lower-case charger-state label ("bulk", "float", ...). */
|
||||||
|
const char *victronble_state_str(uint8_t state);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-128-CTR transform hook.
|
||||||
|
*
|
||||||
|
* @param key 16-byte key.
|
||||||
|
* @param iv 16-byte initial counter block (nonce in the low bytes, rest 0).
|
||||||
|
* @param in Ciphertext.
|
||||||
|
* @param out Plaintext. May alias @p in.
|
||||||
|
* @param len Byte count, not necessarily a multiple of 16.
|
||||||
|
* @param user Opaque context supplied at registration.
|
||||||
|
* @return 0 on success, negative on failure.
|
||||||
|
*/
|
||||||
|
typedef int (*victronble_aes_ctr_fn)(const uint8_t key[16],
|
||||||
|
const uint8_t iv[16],
|
||||||
|
const uint8_t *in, uint8_t *out,
|
||||||
|
size_t len, void *user);
|
||||||
|
|
||||||
|
/** Override the AES backend at runtime (NULL restores the default). */
|
||||||
|
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default AES backend. Weak symbol: the bundled software AES
|
||||||
|
* (victronble_aes_sw.c) provides it; an alternative backend (PSA, mbedTLS,
|
||||||
|
* hardware) may define it strong and the linker drops the bundled code.
|
||||||
|
*/
|
||||||
|
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
|
||||||
|
const uint8_t *in, uint8_t *out,
|
||||||
|
size_t len, void *user);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* VICTRONBLE_H */
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* victronble — Zephyr BLE observer API.
|
||||||
|
*
|
||||||
|
* Passive-scans for Victron Instant Readout advertisements, decodes them
|
||||||
|
* off the BT RX thread (frames are queued to a dedicated decode thread)
|
||||||
|
* and delivers records to registered listeners.
|
||||||
|
*
|
||||||
|
* The application owns the Bluetooth stack: call bt_enable() before
|
||||||
|
* victronble_start(). Enable with CONFIG_VICTRONBLE=y (needs
|
||||||
|
* CONFIG_BT_OBSERVER=y).
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 Scott Penrose
|
||||||
|
* License: MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef VICTRONBLE_ZEPHYR_H
|
||||||
|
#define VICTRONBLE_ZEPHYR_H
|
||||||
|
|
||||||
|
#include <zephyr/bluetooth/addr.h>
|
||||||
|
#include <zephyr/sys/slist.h>
|
||||||
|
|
||||||
|
#include "victronble.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/** Listener; register with victronble_cb_register(). Callbacks run on the
|
||||||
|
* module's decode thread. */
|
||||||
|
struct victronble_cb {
|
||||||
|
/** A monitored device published a new record. */
|
||||||
|
void (*record)(const bt_addr_le_t *addr, int8_t rssi,
|
||||||
|
const victronble_record_t *rec);
|
||||||
|
/** Optional: a monitored device's advertisement failed to decode. */
|
||||||
|
void (*decode_error)(const bt_addr_le_t *addr, victronble_err_t err);
|
||||||
|
sys_snode_t node;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct victronble_stats {
|
||||||
|
uint32_t adverts; /* Victron product adverts seen (any device) */
|
||||||
|
uint32_t queued; /* frames queued for a monitored device */
|
||||||
|
uint32_t dropped; /* frames lost to a full queue */
|
||||||
|
uint32_t decoded; /* records decoded OK */
|
||||||
|
uint32_t duplicates; /* suppressed by nonce dedup */
|
||||||
|
uint32_t errors; /* decode failures */
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Register a listener. Returns -EALREADY if already registered. */
|
||||||
|
int victronble_cb_register(struct victronble_cb *cb);
|
||||||
|
|
||||||
|
/** Monitor a device. @p key is its 16-byte advertisement key (VictronConnect
|
||||||
|
* → Product Info). Returns -ENOMEM when full, -EALREADY if present. */
|
||||||
|
int victronble_device_add(const bt_addr_le_t *addr,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN]);
|
||||||
|
|
||||||
|
/** Stop monitoring a device. Returns -ENOENT if unknown. */
|
||||||
|
int victronble_device_remove(const bt_addr_le_t *addr);
|
||||||
|
|
||||||
|
/** Start the passive scan (bt_enable() must have succeeded first). */
|
||||||
|
int victronble_start(void);
|
||||||
|
|
||||||
|
/** Stop the scan. Queued frames still drain to callbacks. */
|
||||||
|
int victronble_stop(void);
|
||||||
|
|
||||||
|
void victronble_get_stats(struct victronble_stats *out);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* VICTRONBLE_ZEPHYR_H */
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "victronble",
|
"name": "victronble",
|
||||||
"version": "0.6.1",
|
"version": "0.7.0",
|
||||||
"description": "Portable Arduino library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832). Supports SmartSolar MPPT, SmartShunt, BMV, MultiPlus, Orion, Blue Smart AC chargers and other Victron devices. No external crypto dependency.",
|
"description": "Portable Arduino library for reading Victron Energy device data via Bluetooth Low Energy (BLE) advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832). Supports SmartSolar MPPT, SmartShunt, BMV, MultiPlus, Orion, Blue Smart AC chargers and other Victron devices. No external crypto dependency.",
|
||||||
"keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, esp32-s3, esp32-c3, nrf52, nrf52840, nrf52832, xiao, iot, energy, monitoring",
|
"keywords": "victron, ble, bluetooth, solar, mppt, battery, smartshunt, smartsolar, bmv, inverter, multiplus, esp32, esp32-s3, esp32-c3, nrf52, nrf52840, nrf52832, xiao, iot, energy, monitoring",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
name=VictronBLE
|
name=VictronBLE
|
||||||
version=0.6.1
|
version=0.7.0
|
||||||
author=Scott Penrose
|
author=Scott Penrose
|
||||||
maintainer=Scott Penrose <scottp@dd.com.au>
|
maintainer=Scott Penrose <scottp@dd.com.au>
|
||||||
sentence=Portable library for reading Victron Energy device data via BLE on ESP32/S3/C3 and nRF52 (nRF52840/nRF52832)
|
sentence=Portable library for reading Victron Energy device data via BLE on ESP32/S3/C3 and nRF52 (nRF52840/nRF52832)
|
||||||
paragraph=Read data from Victron SmartSolar, SmartShunt, BMV, inverters, Blue Smart AC chargers and other devices using Bluetooth Low Energy advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832 — Bluefruit or Seeed cores) with no external crypto dependency. Supports multiple devices simultaneously with no pairing required.
|
paragraph=Read data from Victron SmartSolar, SmartShunt, BMV, inverters, Blue Smart AC chargers and other devices using Bluetooth Low Energy advertisements. Runs on ESP32, ESP32-S3, ESP32-C3 and nRF52 (nRF52840, nRF52832 — Bluefruit or Seeed cores) with no external crypto dependency. Supports multiple devices simultaneously with no pairing required.
|
||||||
category=Communication
|
category=Communication
|
||||||
url=https://github.com/SH3D/VictronBLE
|
url=https://gitea.sh3d.com.au/Sh3d/VictronBLE
|
||||||
architectures=esp32,nrf52
|
architectures=esp32,nrf52
|
||||||
depends=
|
depends=
|
||||||
includes=VictronBLE.h
|
includes=VictronBLE.h
|
||||||
|
|||||||
+107
-260
@@ -1,15 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* VictronBLE - portable library for Victron Energy BLE devices
|
* VictronBLE - portable library for Victron Energy BLE devices
|
||||||
* Common implementation (platform-independent: decoding + AES-128-CTR decrypt).
|
*
|
||||||
* BLE scanning lives in the per-platform backends under src/esp32 and src/nrf52.
|
* Thin Arduino wrapper over the pure C core (src/victronble_core.c): this
|
||||||
|
* file owns the device registry, nonce dedup and rate limiting; decryption
|
||||||
|
* and payload decoding live in victronble_decode(). BLE scanning lives in
|
||||||
|
* the per-platform backends under src/esp32 and src/nrf52.
|
||||||
*
|
*
|
||||||
* Copyright (c) 2025 Scott Penrose
|
* Copyright (c) 2025 Scott Penrose
|
||||||
* License: MIT
|
* License: MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "VictronBLE.h"
|
#include "VictronBLE.h"
|
||||||
#include "crypto/vble_aes.h"
|
#include "victronble.h"
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
// The public API keeps the legacy "absent = 0" convention; the core reports
|
||||||
|
// absent fields as NAN.
|
||||||
|
static inline float nan_to_zero(float v) { return isnan(v) ? 0.0f : v; }
|
||||||
|
|
||||||
VictronBLE::VictronBLE()
|
VictronBLE::VictronBLE()
|
||||||
: deviceCount(0), callback(nullptr), debugEnabled(false),
|
: deviceCount(0), callback(nullptr), debugEnabled(false),
|
||||||
@@ -24,7 +32,6 @@ VictronBLE::VictronBLE()
|
|||||||
bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
|
bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
|
||||||
VictronDeviceType type) {
|
VictronDeviceType type) {
|
||||||
if (deviceCount >= VICTRON_MAX_DEVICES) return false;
|
if (deviceCount >= VICTRON_MAX_DEVICES) return false;
|
||||||
if (!hexKey || strlen(hexKey) != 32) return false;
|
|
||||||
if (!mac || strlen(mac) == 0) return false;
|
if (!mac || strlen(mac) == 0) return false;
|
||||||
|
|
||||||
char normalizedMAC[VICTRON_MAC_LEN];
|
char normalizedMAC[VICTRON_MAC_LEN];
|
||||||
@@ -35,6 +42,8 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
|
|||||||
|
|
||||||
DeviceEntry* entry = &devices[deviceCount];
|
DeviceEntry* entry = &devices[deviceCount];
|
||||||
memset(entry, 0, sizeof(DeviceEntry));
|
memset(entry, 0, sizeof(DeviceEntry));
|
||||||
|
|
||||||
|
if (!victronble_parse_key(hexKey, entry->key)) return false;
|
||||||
entry->active = true;
|
entry->active = true;
|
||||||
|
|
||||||
strncpy(entry->device.name, name ? name : "", VICTRON_NAME_LEN - 1);
|
strncpy(entry->device.name, name ? name : "", VICTRON_NAME_LEN - 1);
|
||||||
@@ -43,8 +52,6 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
|
|||||||
entry->device.deviceType = type;
|
entry->device.deviceType = type;
|
||||||
entry->device.rssi = -100;
|
entry->device.rssi = -100;
|
||||||
|
|
||||||
if (!hexToBytes(hexKey, entry->key, 16)) return false;
|
|
||||||
|
|
||||||
deviceCount++;
|
deviceCount++;
|
||||||
|
|
||||||
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
|
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
|
||||||
@@ -56,17 +63,7 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
|
|||||||
// result and feeds them here.
|
// result and feeds them here.
|
||||||
void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
|
void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
|
||||||
const char* macStr, int8_t rssi) {
|
const char* macStr, int8_t rssi) {
|
||||||
if (!mfgData || len < 10) return;
|
if (!victronble_is_product_adv(mfgData, len)) return;
|
||||||
|
|
||||||
// Quick vendor ID check before any other work
|
|
||||||
uint16_t vendorID = mfgData[0] | ((uint16_t)mfgData[1] << 8);
|
|
||||||
if (vendorID != VICTRON_MANUFACTURER_ID) return;
|
|
||||||
|
|
||||||
// Copy into the wire-format struct
|
|
||||||
victronManufacturerData mfg;
|
|
||||||
memset(&mfg, 0, sizeof(mfg));
|
|
||||||
size_t copyLen = len > sizeof(mfg) ? sizeof(mfg) : len;
|
|
||||||
memcpy(&mfg, mfgData, copyLen);
|
|
||||||
|
|
||||||
// Normalize MAC and find device
|
// Normalize MAC and find device
|
||||||
char normalizedMAC[VICTRON_MAC_LEN];
|
char normalizedMAC[VICTRON_MAC_LEN];
|
||||||
@@ -79,7 +76,8 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip if nonce unchanged (data hasn't changed on the device)
|
// Skip if nonce unchanged (data hasn't changed on the device)
|
||||||
if (entry->device.dataValid && mfg.nonceDataCounter == entry->lastNonce) {
|
uint16_t nonce = mfgData[7] | ((uint16_t)mfgData[8] << 8);
|
||||||
|
if (entry->device.dataValid && nonce == entry->lastNonce) {
|
||||||
entry->device.rssi = rssi; // still refresh RSSI
|
entry->device.rssi = rssi; // still refresh RSSI
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,274 +88,123 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
|
victronble_record_t rec;
|
||||||
entry->device.name, mfg.nonceDataCounter);
|
victronble_err_t err = victronble_decode(mfgData, len, entry->key, &rec);
|
||||||
|
if (err != VICTRONBLE_OK) {
|
||||||
|
if (debugEnabled) Serial.printf("[VictronBLE] Decode %s: %s\n",
|
||||||
|
entry->device.name, victronble_strerror(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parseAdvertisement(entry, mfg)) {
|
if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
|
||||||
entry->lastNonce = mfg.nonceDataCounter;
|
entry->device.name, rec.nonce);
|
||||||
|
|
||||||
|
storeRecord(entry, rec);
|
||||||
|
entry->lastNonce = nonce;
|
||||||
entry->device.rssi = rssi;
|
entry->device.rssi = rssi;
|
||||||
entry->device.lastUpdate = now;
|
entry->device.lastUpdate = now;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool VictronBLE::parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg) {
|
|
||||||
if (debugEnabled) {
|
|
||||||
Serial.printf("[VictronBLE] Beacon:0x%02X Record:0x%02X Nonce:0x%04X\n",
|
|
||||||
mfg.beaconType, mfg.victronRecordType, mfg.nonceDataCounter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick key check before expensive decryption
|
|
||||||
if (mfg.encryptKeyMatch != entry->key[0]) {
|
|
||||||
if (debugEnabled) Serial.println("[VictronBLE] Key byte mismatch");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build IV from nonce (2 bytes little-endian + 14 zero bytes)
|
|
||||||
uint8_t iv[16] = {0};
|
|
||||||
iv[0] = mfg.nonceDataCounter & 0xFF;
|
|
||||||
iv[1] = (mfg.nonceDataCounter >> 8) & 0xFF;
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
uint8_t decrypted[VICTRON_ENCRYPTED_LEN];
|
|
||||||
if (!decryptData(mfg.victronEncryptedData, VICTRON_ENCRYPTED_LEN,
|
|
||||||
entry->key, iv, decrypted)) {
|
|
||||||
if (debugEnabled) Serial.println("[VictronBLE] Decryption failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse based on record type (auto-detects device type)
|
|
||||||
bool ok = false;
|
|
||||||
switch (mfg.victronRecordType) {
|
|
||||||
case DEVICE_TYPE_SOLAR_CHARGER:
|
|
||||||
entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
|
|
||||||
ok = parseSolarCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.solar);
|
|
||||||
break;
|
|
||||||
case DEVICE_TYPE_BATTERY_MONITOR:
|
|
||||||
entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
|
|
||||||
ok = parseBatteryMonitor(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.battery);
|
|
||||||
break;
|
|
||||||
case DEVICE_TYPE_INVERTER:
|
|
||||||
case DEVICE_TYPE_INVERTER_RS:
|
|
||||||
case DEVICE_TYPE_MULTI_RS:
|
|
||||||
case DEVICE_TYPE_VE_BUS:
|
|
||||||
entry->device.deviceType = DEVICE_TYPE_INVERTER;
|
|
||||||
ok = parseInverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.inverter);
|
|
||||||
break;
|
|
||||||
case DEVICE_TYPE_DCDC_CONVERTER:
|
|
||||||
entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
|
|
||||||
ok = parseDCDCConverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.dcdc);
|
|
||||||
break;
|
|
||||||
case DEVICE_TYPE_AC_CHARGER:
|
|
||||||
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
|
|
||||||
ok = parseACCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.acCharger);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (debugEnabled) Serial.printf("[VictronBLE] Unknown type: 0x%02X\n", mfg.victronRecordType);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ok) {
|
|
||||||
entry->device.dataValid = true;
|
entry->device.dataValid = true;
|
||||||
if (callback) callback(&entry->device);
|
if (callback) callback(&entry->device);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok;
|
// Map a decoded core record into the legacy public structs (NAN -> 0).
|
||||||
}
|
void VictronBLE::storeRecord(DeviceEntry* entry, const victronble_record_t& rec) {
|
||||||
|
switch (rec.type) {
|
||||||
bool VictronBLE::decryptData(const uint8_t* encrypted, size_t len,
|
case VICTRONBLE_DEV_SOLAR_CHARGER: {
|
||||||
const uint8_t* key, const uint8_t* iv,
|
entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
|
||||||
uint8_t* decrypted) {
|
VictronSolarData& s = entry->device.solar;
|
||||||
// AES-128-CTR via the bundled portable implementation (was mbedTLS on ESP32).
|
s.chargeState = rec.u.solar.state;
|
||||||
// CTR is symmetric and operates in place, so copy then XOR the keystream.
|
s.errorCode = rec.u.solar.error;
|
||||||
struct vble_aes_ctx ctx;
|
s.batteryVoltage = rec.u.solar.battery_voltage;
|
||||||
vble_aes_init_ctx_iv(&ctx, key, iv);
|
s.batteryCurrent = rec.u.solar.battery_current;
|
||||||
memcpy(decrypted, encrypted, len);
|
s.panelPower = rec.u.solar.pv_power;
|
||||||
vble_aes_ctr_xcrypt(&ctx, decrypted, len);
|
s.yieldToday = (uint16_t)rec.u.solar.yield_today_wh;
|
||||||
return true;
|
s.loadCurrent = nan_to_zero(rec.u.solar.load_current);
|
||||||
}
|
|
||||||
|
|
||||||
bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result) {
|
|
||||||
if (len < sizeof(victronSolarChargerPayload)) return false;
|
|
||||||
const auto* p = reinterpret_cast<const victronSolarChargerPayload*>(data);
|
|
||||||
|
|
||||||
result.chargeState = p->deviceState;
|
|
||||||
result.errorCode = p->errorCode;
|
|
||||||
result.batteryVoltage = p->batteryVoltage * 0.01f; // 0.01V units
|
|
||||||
result.batteryCurrent = p->batteryCurrent * 0.1f; // 0.1A units
|
|
||||||
result.yieldToday = p->yieldToday * 10;
|
|
||||||
result.panelPower = p->inputPower;
|
|
||||||
// Load current is a 9-bit field (0.1A units); 0x1FF = no load output
|
|
||||||
uint16_t loadRaw = p->loadCurrent & 0x1FF;
|
|
||||||
result.loadCurrent = (loadRaw != 0x1FF) ? loadRaw * 0.1f : 0;
|
|
||||||
|
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
|
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
|
||||||
result.batteryVoltage, result.batteryCurrent,
|
s.batteryVoltage, s.batteryCurrent,
|
||||||
(int)result.panelPower, result.chargeState);
|
(int)s.panelPower, s.chargeState);
|
||||||
}
|
}
|
||||||
return true;
|
break;
|
||||||
}
|
}
|
||||||
|
case VICTRONBLE_DEV_BATTERY_MONITOR: {
|
||||||
bool VictronBLE::parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result) {
|
entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
|
||||||
// Payload is bit-packed (10 fields, 104 bits ending in byte 12). Decode LSB-first.
|
VictronBatteryData& b = entry->device.battery;
|
||||||
if (len < 13) return false;
|
b.voltage = rec.u.batmon.voltage;
|
||||||
|
b.current = rec.u.batmon.current;
|
||||||
size_t bit = 0;
|
b.temperature = nan_to_zero(rec.u.batmon.temperature);
|
||||||
auto readBits = [&](uint8_t width) -> uint32_t {
|
b.auxVoltage = nan_to_zero(rec.u.batmon.aux_voltage);
|
||||||
uint32_t value = 0;
|
b.remainingMinutes = rec.u.batmon.remaining_minutes;
|
||||||
for (uint8_t i = 0; i < width; i++) {
|
b.consumedAh = rec.u.batmon.consumed_ah;
|
||||||
size_t b = bit + i;
|
b.soc = rec.u.batmon.soc;
|
||||||
value |= (uint32_t)((data[b >> 3] >> (b & 7)) & 0x01) << i;
|
b.alarmLowVoltage = (rec.u.batmon.alarm & 0x0001) != 0;
|
||||||
}
|
b.alarmHighVoltage = (rec.u.batmon.alarm & 0x0002) != 0;
|
||||||
bit += width;
|
b.alarmLowSOC = (rec.u.batmon.alarm & 0x0004) != 0;
|
||||||
return value;
|
b.alarmLowTemperature = (rec.u.batmon.alarm & 0x0010) != 0;
|
||||||
};
|
b.alarmHighTemperature = (rec.u.batmon.alarm & 0x0020) != 0;
|
||||||
|
|
||||||
result.chargeState = (uint8_t)readBits(8);
|
|
||||||
result.errorCode = (uint8_t)readBits(8);
|
|
||||||
|
|
||||||
uint32_t v1 = readBits(13), i1 = readBits(11);
|
|
||||||
uint32_t v2 = readBits(13), i2 = readBits(11);
|
|
||||||
uint32_t v3 = readBits(13), i3 = readBits(11);
|
|
||||||
uint32_t temp = readBits(7);
|
|
||||||
uint32_t acCur = readBits(9);
|
|
||||||
|
|
||||||
result.voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : 0;
|
|
||||||
result.current1 = (i1 != 0x7FF) ? i1 * 0.1f : 0;
|
|
||||||
result.voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : 0;
|
|
||||||
result.current2 = (i2 != 0x7FF) ? i2 * 0.1f : 0;
|
|
||||||
result.voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : 0;
|
|
||||||
result.current3 = (i3 != 0x7FF) ? i3 * 0.1f : 0;
|
|
||||||
result.temperature = (temp != 0x7F) ? (float)temp - 40.0f : 0; // C offset by -40
|
|
||||||
result.acCurrent = (acCur != 0x1FF) ? acCur * 0.1f : 0;
|
|
||||||
|
|
||||||
if (debugEnabled) {
|
|
||||||
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
|
|
||||||
result.voltage1, result.current1, result.temperature, result.chargeState);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result) {
|
|
||||||
// The payload is bit-packed and not byte-aligned, so it is decoded by bit
|
|
||||||
// offset directly rather than via a struct. SOC ends at bit 117 (byte 14).
|
|
||||||
if (len < 15) return false;
|
|
||||||
|
|
||||||
// TTG (bits 0-15), unsigned minutes
|
|
||||||
result.remainingMinutes = data[0] | ((uint16_t)data[1] << 8);
|
|
||||||
|
|
||||||
// Voltage (bits 16-31), signed, 0.01V units
|
|
||||||
result.voltage = (int16_t)(data[2] | ((uint16_t)data[3] << 8)) * 0.01f;
|
|
||||||
|
|
||||||
// Alarm (bits 32-47), 16-bit bitmask
|
|
||||||
uint16_t alarm = data[4] | ((uint16_t)data[5] << 8);
|
|
||||||
result.alarmLowVoltage = (alarm & 0x0001) != 0;
|
|
||||||
result.alarmHighVoltage = (alarm & 0x0002) != 0;
|
|
||||||
result.alarmLowSOC = (alarm & 0x0004) != 0;
|
|
||||||
result.alarmLowTemperature = (alarm & 0x0010) != 0;
|
|
||||||
result.alarmHighTemperature = (alarm & 0x0020) != 0;
|
|
||||||
|
|
||||||
// Aux value (bits 48-63) interpreted per aux mode (bits 64-65)
|
|
||||||
uint16_t auxRaw = data[6] | ((uint16_t)data[7] << 8);
|
|
||||||
uint8_t auxMode = data[8] & 0x03; // 0=aux voltage, 1=midpoint, 2=temperature, 3=none
|
|
||||||
if (auxMode == 0) {
|
|
||||||
result.auxVoltage = auxRaw * 0.01f;
|
|
||||||
result.temperature = 0;
|
|
||||||
} else if (auxMode == 2) {
|
|
||||||
result.temperature = auxRaw * 0.01f - 273.15f; // 0.01K -> C
|
|
||||||
result.auxVoltage = 0;
|
|
||||||
} else {
|
|
||||||
result.auxVoltage = 0;
|
|
||||||
result.temperature = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Battery current (bits 66-87), 22-bit signed, 0.001A units
|
|
||||||
int32_t current = ((uint32_t)(data[8] >> 2) & 0x3F)
|
|
||||||
| ((uint32_t)data[9] << 6)
|
|
||||||
| ((uint32_t)data[10] << 14);
|
|
||||||
if (current & 0x200000) current |= 0xFFC00000; // Sign extend 22-bit
|
|
||||||
result.current = current * 0.001f;
|
|
||||||
|
|
||||||
// Consumed Ah (bits 88-107), 20-bit, stored as a positive count, 0.1Ah units.
|
|
||||||
// Reported as a negative value (amp-hours consumed).
|
|
||||||
uint32_t consumed = (uint32_t)data[11]
|
|
||||||
| ((uint32_t)data[12] << 8)
|
|
||||||
| ((uint32_t)(data[13] & 0x0F) << 16);
|
|
||||||
result.consumedAh = -((float)consumed * 0.1f);
|
|
||||||
|
|
||||||
// SOC (bits 108-117), 10-bit, 0.1% units
|
|
||||||
uint16_t soc = ((uint16_t)(data[13] >> 4) | ((uint16_t)data[14] << 4)) & 0x3FF;
|
|
||||||
result.soc = soc * 0.1f;
|
|
||||||
|
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
|
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
|
||||||
result.voltage, result.current, result.soc);
|
b.voltage, b.current, b.soc);
|
||||||
}
|
}
|
||||||
return true;
|
break;
|
||||||
}
|
}
|
||||||
|
case VICTRONBLE_DEV_INVERTER: {
|
||||||
bool VictronBLE::parseInverter(const uint8_t* data, size_t len, VictronInverterData& result) {
|
entry->device.deviceType = DEVICE_TYPE_INVERTER;
|
||||||
if (len < sizeof(victronInverterPayload)) return false;
|
VictronInverterData& inv = entry->device.inverter;
|
||||||
const auto* p = reinterpret_cast<const victronInverterPayload*>(data);
|
inv.batteryVoltage = rec.u.inverter.battery_voltage;
|
||||||
|
inv.batteryCurrent = rec.u.inverter.battery_current;
|
||||||
result.state = p->deviceState;
|
inv.acPower = rec.u.inverter.ac_power;
|
||||||
result.batteryVoltage = p->batteryVoltage * 0.01f;
|
inv.state = rec.u.inverter.state;
|
||||||
result.batteryCurrent = p->batteryCurrent * 0.01f;
|
inv.alarmLowVoltage = (rec.u.inverter.alarms & 0x01) != 0;
|
||||||
|
inv.alarmHighVoltage = (rec.u.inverter.alarms & 0x02) != 0;
|
||||||
// AC Power (signed 24-bit)
|
inv.alarmHighTemperature = (rec.u.inverter.alarms & 0x04) != 0;
|
||||||
int32_t acPower = p->acPowerLow | (p->acPowerMid << 8) | (p->acPowerHigh << 16);
|
inv.alarmOverload = (rec.u.inverter.alarms & 0x08) != 0;
|
||||||
if (acPower & 0x800000) acPower |= 0xFF000000; // Sign extend
|
|
||||||
result.acPower = acPower;
|
|
||||||
|
|
||||||
// Alarm bits
|
|
||||||
result.alarmLowVoltage = (p->alarms & 0x01) != 0;
|
|
||||||
result.alarmHighVoltage = (p->alarms & 0x02) != 0;
|
|
||||||
result.alarmHighTemperature = (p->alarms & 0x04) != 0;
|
|
||||||
result.alarmOverload = (p->alarms & 0x08) != 0;
|
|
||||||
|
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
|
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
|
||||||
result.batteryVoltage, (int)result.acPower, result.state);
|
inv.batteryVoltage, (int)inv.acPower, inv.state);
|
||||||
}
|
}
|
||||||
return true;
|
break;
|
||||||
}
|
}
|
||||||
|
case VICTRONBLE_DEV_DCDC_CONVERTER: {
|
||||||
bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result) {
|
entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
|
||||||
if (len < sizeof(victronDCDCConverterPayload)) return false;
|
VictronDCDCData& d = entry->device.dcdc;
|
||||||
const auto* p = reinterpret_cast<const victronDCDCConverterPayload*>(data);
|
d.chargeState = rec.u.dcdc.state;
|
||||||
|
d.errorCode = rec.u.dcdc.error;
|
||||||
result.chargeState = p->chargeState;
|
d.inputVoltage = rec.u.dcdc.input_voltage;
|
||||||
result.errorCode = p->errorCode;
|
d.outputVoltage = rec.u.dcdc.output_voltage;
|
||||||
result.inputVoltage = p->inputVoltage * 0.01f;
|
d.outputCurrent = rec.u.dcdc.output_current;
|
||||||
result.outputVoltage = p->outputVoltage * 0.01f;
|
|
||||||
result.outputCurrent = p->outputCurrent * 0.01f;
|
|
||||||
|
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
|
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
|
||||||
result.inputVoltage, result.outputVoltage, result.outputCurrent);
|
d.inputVoltage, d.outputVoltage, d.outputCurrent);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case VICTRONBLE_DEV_AC_CHARGER: {
|
||||||
|
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
|
||||||
|
VictronACChargerData& a = entry->device.acCharger;
|
||||||
|
a.chargeState = rec.u.ac.state;
|
||||||
|
a.errorCode = rec.u.ac.error;
|
||||||
|
a.voltage1 = nan_to_zero(rec.u.ac.voltage1);
|
||||||
|
a.current1 = nan_to_zero(rec.u.ac.current1);
|
||||||
|
a.voltage2 = nan_to_zero(rec.u.ac.voltage2);
|
||||||
|
a.current2 = nan_to_zero(rec.u.ac.current2);
|
||||||
|
a.voltage3 = nan_to_zero(rec.u.ac.voltage3);
|
||||||
|
a.current3 = nan_to_zero(rec.u.ac.current3);
|
||||||
|
a.temperature = nan_to_zero(rec.u.ac.temperature);
|
||||||
|
a.acCurrent = nan_to_zero(rec.u.ac.ac_current);
|
||||||
|
if (debugEnabled) {
|
||||||
|
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
|
||||||
|
a.voltage1, a.current1, a.temperature, a.chargeState);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
bool VictronBLE::hexToBytes(const char* hex, uint8_t* out, size_t len) {
|
|
||||||
if (strlen(hex) != len * 2) return false;
|
|
||||||
for (size_t i = 0; i < len; i++) {
|
|
||||||
uint8_t hi = hex[i * 2], lo = hex[i * 2 + 1];
|
|
||||||
if (hi >= '0' && hi <= '9') hi -= '0';
|
|
||||||
else if (hi >= 'a' && hi <= 'f') hi = hi - 'a' + 10;
|
|
||||||
else if (hi >= 'A' && hi <= 'F') hi = hi - 'A' + 10;
|
|
||||||
else return false;
|
|
||||||
if (lo >= '0' && lo <= '9') lo -= '0';
|
|
||||||
else if (lo >= 'a' && lo <= 'f') lo = lo - 'a' + 10;
|
|
||||||
else if (lo >= 'A' && lo <= 'F') lo = lo - 'A' + 10;
|
|
||||||
else return false;
|
|
||||||
out[i] = (hi << 4) | lo;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void VictronBLE::normalizeMAC(const char* input, char* output) {
|
void VictronBLE::normalizeMAC(const char* input, char* output) {
|
||||||
int j = 0;
|
int j = 0;
|
||||||
for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) {
|
for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) {
|
||||||
|
|||||||
+5
-9
@@ -16,6 +16,7 @@
|
|||||||
#define VICTRON_BLE_H
|
#define VICTRON_BLE_H
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
#include "victronble.h" // pure C core: decode + decrypt (src/victronble_core.c)
|
||||||
|
|
||||||
// --- Platform BLE backend selection ---
|
// --- Platform BLE backend selection ---
|
||||||
// The BLE scanning layer is the only platform-specific part of the library.
|
// The BLE scanning layer is the only platform-specific part of the library.
|
||||||
@@ -247,22 +248,17 @@ private:
|
|||||||
uint32_t minIntervalMs;
|
uint32_t minIntervalMs;
|
||||||
bool initialized;
|
bool initialized;
|
||||||
|
|
||||||
static bool hexToBytes(const char* hex, uint8_t* out, size_t len);
|
|
||||||
static void normalizeMAC(const char* input, char* output);
|
static void normalizeMAC(const char* input, char* output);
|
||||||
DeviceEntry* findDevice(const char* normalizedMAC);
|
DeviceEntry* findDevice(const char* normalizedMAC);
|
||||||
bool decryptData(const uint8_t* encrypted, size_t len,
|
|
||||||
const uint8_t* key, const uint8_t* iv, uint8_t* decrypted);
|
|
||||||
|
|
||||||
// Common entry point fed by each platform BLE backend with one raw
|
// Common entry point fed by each platform BLE backend with one raw
|
||||||
// manufacturer-data record (vendor ID first), the device MAC and RSSI.
|
// manufacturer-data record (vendor ID first), the device MAC and RSSI.
|
||||||
|
// Decryption and payload decoding are delegated to victronble_decode()
|
||||||
|
// in the pure C core; storeRecord() maps the result into the legacy
|
||||||
|
// public structs (core NAN sentinels become 0).
|
||||||
void onAdvertisement(const uint8_t* mfgData, size_t len,
|
void onAdvertisement(const uint8_t* mfgData, size_t len,
|
||||||
const char* macStr, int8_t rssi);
|
const char* macStr, int8_t rssi);
|
||||||
bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg);
|
void storeRecord(DeviceEntry* entry, const victronble_record_t& rec);
|
||||||
bool parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result);
|
|
||||||
bool parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result);
|
|
||||||
bool parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result);
|
|
||||||
bool parseInverter(const uint8_t* data, size_t len, VictronInverterData& result);
|
|
||||||
bool parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result);
|
|
||||||
|
|
||||||
// --- Platform-specific BLE backend (see src/esp32 and src/nrf52) ---
|
// --- Platform-specific BLE backend (see src/esp32 and src/nrf52) ---
|
||||||
#if defined(VICTRON_BACKEND_ESP32)
|
#if defined(VICTRON_BACKEND_ESP32)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* victronble — bundled software AES-128-CTR backend.
|
||||||
|
*
|
||||||
|
* Weak symbol: an alternative backend (PSA Crypto, mbedTLS, hardware) defines
|
||||||
|
* victronble_aes_ctr_default strong and the linker drops this file's code —
|
||||||
|
* and with it the bundled AES tables — from the final image.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Scott Penrose
|
||||||
|
* License: MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "victronble.h"
|
||||||
|
#include "crypto/vble_aes.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#if defined(_MSC_VER)
|
||||||
|
#define VICTRONBLE_WEAK
|
||||||
|
#else
|
||||||
|
#define VICTRONBLE_WEAK __attribute__((weak))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
VICTRONBLE_WEAK
|
||||||
|
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
|
||||||
|
const uint8_t *in, uint8_t *out,
|
||||||
|
size_t len, void *user)
|
||||||
|
{
|
||||||
|
(void)user;
|
||||||
|
|
||||||
|
struct vble_aes_ctx ctx;
|
||||||
|
|
||||||
|
vble_aes_init_ctx_iv(&ctx, key, iv);
|
||||||
|
if (out != in) {
|
||||||
|
memcpy(out, in, len);
|
||||||
|
}
|
||||||
|
vble_aes_ctr_xcrypt(&ctx, out, len);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* victronble core — decode + decrypt for Victron Instant Readout adverts.
|
||||||
|
* Pure C99: no Arduino, no BLE stack, no allocation, no I/O, reentrant.
|
||||||
|
*
|
||||||
|
* Byte offsets and bit layouts match the proven ESP32/nRF52 implementation
|
||||||
|
* in src/VictronBLE.cpp and Victron's "Extra Manufacturer Data" document.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Scott Penrose
|
||||||
|
* License: MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "victronble.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
/* Manufacturer-data layout (offsets from the company ID):
|
||||||
|
* 0-1 company ID (LE, 0x02E1)
|
||||||
|
* 2 record type, 0x10 = product advertisement
|
||||||
|
* 3-4 model ID (LE)
|
||||||
|
* 5 read-out type
|
||||||
|
* 6 device record type (victronble_device_type_t)
|
||||||
|
* 7-8 nonce / data counter (LE)
|
||||||
|
* 9 key check byte (== key[0])
|
||||||
|
* 10- AES-128-CTR ciphertext, up to 21 bytes
|
||||||
|
*/
|
||||||
|
#define OFF_RECORD 2
|
||||||
|
#define OFF_MODEL 3
|
||||||
|
#define OFF_READOUT 5
|
||||||
|
#define OFF_DEVTYPE 6
|
||||||
|
#define OFF_NONCE 7
|
||||||
|
#define OFF_KEYCHECK 9
|
||||||
|
#define OFF_CIPHER 10
|
||||||
|
#define PRODUCT_ADV 0x10
|
||||||
|
|
||||||
|
static uint16_t get_le16(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- AES backend selection ------------------------------------------- */
|
||||||
|
|
||||||
|
static victronble_aes_ctr_fn aes_fn;
|
||||||
|
static void *aes_user;
|
||||||
|
|
||||||
|
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user)
|
||||||
|
{
|
||||||
|
aes_fn = fn;
|
||||||
|
aes_user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int aes_ctr(const uint8_t key[16], const uint8_t iv[16],
|
||||||
|
const uint8_t *in, uint8_t *out, size_t len)
|
||||||
|
{
|
||||||
|
if (aes_fn != NULL) {
|
||||||
|
return aes_fn(key, iv, in, out, len, aes_user);
|
||||||
|
}
|
||||||
|
return victronble_aes_ctr_default(key, iv, in, out, len, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pre-filters ------------------------------------------------------ */
|
||||||
|
|
||||||
|
bool victronble_is_product_adv(const uint8_t *mfg, size_t len)
|
||||||
|
{
|
||||||
|
return mfg != NULL && len >= VICTRONBLE_MIN_MFG_LEN &&
|
||||||
|
get_le16(mfg) == VICTRONBLE_COMPANY_ID &&
|
||||||
|
mfg[OFF_RECORD] == PRODUCT_ADV;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool victronble_key_matches(const uint8_t *mfg, size_t len,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN])
|
||||||
|
{
|
||||||
|
return victronble_is_product_adv(mfg, len) && mfg[OFF_KEYCHECK] == key[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Per-type payload decoders ---------------------------------------
|
||||||
|
* All operate on the decrypted payload, zero-padded to
|
||||||
|
* VICTRONBLE_MAX_CIPHER_LEN bytes, so length checks always pass at the
|
||||||
|
* decode() call site; they remain for direct-call safety. */
|
||||||
|
|
||||||
|
static bool parse_solar_charger(const uint8_t *d, size_t len,
|
||||||
|
victronble_solar_charger_t *r)
|
||||||
|
{
|
||||||
|
if (len < 12) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
r->state = d[0];
|
||||||
|
r->error = d[1];
|
||||||
|
r->battery_voltage = (int16_t)get_le16(d + 2) * 0.01f; /* 0.01 V */
|
||||||
|
r->battery_current = (int16_t)get_le16(d + 4) * 0.1f; /* 0.1 A */
|
||||||
|
r->yield_today_wh = (uint32_t)get_le16(d + 6) * 10u; /* 0.01 kWh */
|
||||||
|
r->pv_power = get_le16(d + 8); /* 1 W */
|
||||||
|
/* Load current is a 9-bit field (0.1 A units); 0x1FF = no load output */
|
||||||
|
uint16_t load_raw = get_le16(d + 10) & 0x1FF;
|
||||||
|
r->load_current = (load_raw != 0x1FF) ? load_raw * 0.1f : NAN;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parse_battery_monitor(const uint8_t *d, size_t len,
|
||||||
|
victronble_battery_monitor_t *r)
|
||||||
|
{
|
||||||
|
/* Bit-packed, not byte-aligned; decoded by bit offset. SOC ends at
|
||||||
|
* bit 117 (byte 14). */
|
||||||
|
if (len < 15) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
r->remaining_minutes = get_le16(d); /* bits 0-15 */
|
||||||
|
r->voltage = (int16_t)get_le16(d + 2) * 0.01f; /* bits 16-31 */
|
||||||
|
r->alarm = get_le16(d + 4); /* bits 32-47 */
|
||||||
|
|
||||||
|
/* Aux value (bits 48-63) interpreted per aux mode (bits 64-65) */
|
||||||
|
uint16_t aux_raw = get_le16(d + 6);
|
||||||
|
r->aux_mode = d[8] & 0x03;
|
||||||
|
r->aux_voltage = (r->aux_mode == 0) ? aux_raw * 0.01f : NAN;
|
||||||
|
r->temperature = (r->aux_mode == 2) ? aux_raw * 0.01f - 273.15f : NAN;
|
||||||
|
|
||||||
|
/* Battery current (bits 66-87), 22-bit signed, 0.001 A units */
|
||||||
|
int32_t current = (int32_t)(((uint32_t)(d[8] >> 2) & 0x3F) |
|
||||||
|
((uint32_t)d[9] << 6) |
|
||||||
|
((uint32_t)d[10] << 14));
|
||||||
|
if (current & 0x200000) {
|
||||||
|
current |= (int32_t)0xFFC00000; /* sign extend */
|
||||||
|
}
|
||||||
|
r->current = current * 0.001f;
|
||||||
|
|
||||||
|
/* Consumed Ah (bits 88-107), 20-bit positive count, 0.1 Ah units,
|
||||||
|
* reported negative (amp-hours consumed). */
|
||||||
|
uint32_t consumed = (uint32_t)d[11] | ((uint32_t)d[12] << 8) |
|
||||||
|
((uint32_t)(d[13] & 0x0F) << 16);
|
||||||
|
r->consumed_ah = -((float)consumed * 0.1f);
|
||||||
|
|
||||||
|
/* SOC (bits 108-117), 10-bit, 0.1 % units */
|
||||||
|
uint16_t soc = (uint16_t)(((d[13] >> 4) | ((uint16_t)d[14] << 4)) & 0x3FF);
|
||||||
|
r->soc = soc * 0.1f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parse_inverter(const uint8_t *d, size_t len,
|
||||||
|
victronble_inverter_t *r)
|
||||||
|
{
|
||||||
|
if (len < 9) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
r->state = d[0];
|
||||||
|
/* d[1] is the error code on the wire; kept out of the struct for parity
|
||||||
|
* with the proven implementation, which only surfaced the alarm bits. */
|
||||||
|
r->battery_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
|
||||||
|
r->battery_current = (int16_t)get_le16(d + 4) * 0.01f; /* 10 mA */
|
||||||
|
|
||||||
|
int32_t ac_power = (int32_t)((uint32_t)d[6] | ((uint32_t)d[7] << 8) |
|
||||||
|
((uint32_t)d[8] << 16));
|
||||||
|
if (ac_power & 0x800000) {
|
||||||
|
ac_power |= (int32_t)0xFF000000; /* sign extend */
|
||||||
|
}
|
||||||
|
r->ac_power = (float)ac_power;
|
||||||
|
r->alarms = (len > 9) ? d[9] : 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parse_dcdc(const uint8_t *d, size_t len, victronble_dcdc_t *r)
|
||||||
|
{
|
||||||
|
if (len < 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
r->state = d[0];
|
||||||
|
r->error = d[1];
|
||||||
|
r->input_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
|
||||||
|
r->output_voltage = get_le16(d + 4) * 0.01f; /* 10 mV */
|
||||||
|
r->output_current = get_le16(d + 6) * 0.01f; /* 10 mA */
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t read_bits(const uint8_t *d, size_t *bit, uint8_t width)
|
||||||
|
{
|
||||||
|
uint32_t value = 0;
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < width; i++) {
|
||||||
|
size_t b = *bit + i;
|
||||||
|
|
||||||
|
value |= (uint32_t)((d[b >> 3] >> (b & 7)) & 0x01) << i;
|
||||||
|
}
|
||||||
|
*bit += width;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parse_ac_charger(const uint8_t *d, size_t len,
|
||||||
|
victronble_ac_charger_t *r)
|
||||||
|
{
|
||||||
|
/* Bit-packed: 10 fields, 104 bits ending in byte 12, LSB-first. */
|
||||||
|
if (len < 13) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t bit = 0;
|
||||||
|
r->state = (uint8_t)read_bits(d, &bit, 8);
|
||||||
|
r->error = (uint8_t)read_bits(d, &bit, 8);
|
||||||
|
|
||||||
|
uint32_t v1 = read_bits(d, &bit, 13), i1 = read_bits(d, &bit, 11);
|
||||||
|
uint32_t v2 = read_bits(d, &bit, 13), i2 = read_bits(d, &bit, 11);
|
||||||
|
uint32_t v3 = read_bits(d, &bit, 13), i3 = read_bits(d, &bit, 11);
|
||||||
|
uint32_t temp = read_bits(d, &bit, 7);
|
||||||
|
uint32_t ac_cur = read_bits(d, &bit, 9);
|
||||||
|
|
||||||
|
r->voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : NAN;
|
||||||
|
r->current1 = (i1 != 0x7FF) ? i1 * 0.1f : NAN;
|
||||||
|
r->voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : NAN;
|
||||||
|
r->current2 = (i2 != 0x7FF) ? i2 * 0.1f : NAN;
|
||||||
|
r->voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : NAN;
|
||||||
|
r->current3 = (i3 != 0x7FF) ? i3 * 0.1f : NAN;
|
||||||
|
r->temperature = (temp != 0x7F) ? (float)temp - 40.0f : NAN;
|
||||||
|
r->ac_current = (ac_cur != 0x1FF) ? ac_cur * 0.1f : NAN;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Decode entry point ----------------------------------------------- */
|
||||||
|
|
||||||
|
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN],
|
||||||
|
victronble_record_t *out)
|
||||||
|
{
|
||||||
|
if (mfg == NULL || len < VICTRONBLE_MIN_MFG_LEN) {
|
||||||
|
return VICTRONBLE_ERR_SHORT;
|
||||||
|
}
|
||||||
|
if (get_le16(mfg) != VICTRONBLE_COMPANY_ID) {
|
||||||
|
return VICTRONBLE_ERR_NOT_VICTRON;
|
||||||
|
}
|
||||||
|
if (mfg[OFF_RECORD] != PRODUCT_ADV) {
|
||||||
|
return VICTRONBLE_ERR_NOT_PRODUCT;
|
||||||
|
}
|
||||||
|
if (mfg[OFF_KEYCHECK] != key[0]) {
|
||||||
|
return VICTRONBLE_ERR_KEY_MISMATCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t nonce = get_le16(mfg + OFF_NONCE);
|
||||||
|
|
||||||
|
/* IV: nonce in the two low bytes (LE), remaining 14 bytes zero. */
|
||||||
|
uint8_t iv[16] = {0};
|
||||||
|
iv[0] = (uint8_t)(nonce & 0xFF);
|
||||||
|
iv[1] = (uint8_t)(nonce >> 8);
|
||||||
|
|
||||||
|
/* Decrypt what's on the wire; zero-pad to the full payload size so the
|
||||||
|
* per-type decoders see a fixed-length buffer (matches the proven
|
||||||
|
* implementation, which zero-filled the wire struct before copy-in). */
|
||||||
|
uint8_t plain[VICTRONBLE_MAX_CIPHER_LEN] = {0};
|
||||||
|
size_t cipher_len = len - OFF_CIPHER;
|
||||||
|
|
||||||
|
if (cipher_len > VICTRONBLE_MAX_CIPHER_LEN) {
|
||||||
|
cipher_len = VICTRONBLE_MAX_CIPHER_LEN;
|
||||||
|
}
|
||||||
|
if (aes_ctr(key, iv, mfg + OFF_CIPHER, plain, cipher_len) != 0) {
|
||||||
|
return VICTRONBLE_ERR_CRYPTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
victronble_record_t rec;
|
||||||
|
memset(&rec, 0, sizeof(rec));
|
||||||
|
rec.record_type = mfg[OFF_DEVTYPE];
|
||||||
|
rec.model_id = get_le16(mfg + OFF_MODEL);
|
||||||
|
rec.readout_type = mfg[OFF_READOUT];
|
||||||
|
rec.nonce = nonce;
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
switch (mfg[OFF_DEVTYPE]) {
|
||||||
|
case VICTRONBLE_DEV_SOLAR_CHARGER:
|
||||||
|
rec.type = VICTRONBLE_DEV_SOLAR_CHARGER;
|
||||||
|
ok = parse_solar_charger(plain, sizeof(plain), &rec.u.solar);
|
||||||
|
break;
|
||||||
|
case VICTRONBLE_DEV_BATTERY_MONITOR:
|
||||||
|
rec.type = VICTRONBLE_DEV_BATTERY_MONITOR;
|
||||||
|
ok = parse_battery_monitor(plain, sizeof(plain), &rec.u.batmon);
|
||||||
|
break;
|
||||||
|
case VICTRONBLE_DEV_INVERTER:
|
||||||
|
case VICTRONBLE_DEV_INVERTER_RS:
|
||||||
|
case VICTRONBLE_DEV_MULTI_RS:
|
||||||
|
case VICTRONBLE_DEV_VE_BUS:
|
||||||
|
rec.type = VICTRONBLE_DEV_INVERTER;
|
||||||
|
ok = parse_inverter(plain, sizeof(plain), &rec.u.inverter);
|
||||||
|
break;
|
||||||
|
case VICTRONBLE_DEV_DCDC_CONVERTER:
|
||||||
|
rec.type = VICTRONBLE_DEV_DCDC_CONVERTER;
|
||||||
|
ok = parse_dcdc(plain, sizeof(plain), &rec.u.dcdc);
|
||||||
|
break;
|
||||||
|
case VICTRONBLE_DEV_AC_CHARGER:
|
||||||
|
rec.type = VICTRONBLE_DEV_AC_CHARGER;
|
||||||
|
ok = parse_ac_charger(plain, sizeof(plain), &rec.u.ac);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return VICTRONBLE_ERR_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
return VICTRONBLE_ERR_SHORT;
|
||||||
|
}
|
||||||
|
*out = rec;
|
||||||
|
return VICTRONBLE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Helpers ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
static int hex_nibble(char c)
|
||||||
|
{
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
return c - '0';
|
||||||
|
}
|
||||||
|
if (c >= 'a' && c <= 'f') {
|
||||||
|
return c - 'a' + 10;
|
||||||
|
}
|
||||||
|
if (c >= 'A' && c <= 'F') {
|
||||||
|
return c - 'A' + 10;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool victronble_parse_key(const char *hex, uint8_t key[VICTRONBLE_KEY_LEN])
|
||||||
|
{
|
||||||
|
if (hex == NULL || strlen(hex) != VICTRONBLE_KEY_LEN * 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < VICTRONBLE_KEY_LEN; i++) {
|
||||||
|
int hi = hex_nibble(hex[i * 2]);
|
||||||
|
int lo = hex_nibble(hex[i * 2 + 1]);
|
||||||
|
|
||||||
|
if (hi < 0 || lo < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
key[i] = (uint8_t)((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *victronble_strerror(victronble_err_t err)
|
||||||
|
{
|
||||||
|
switch (err) {
|
||||||
|
case VICTRONBLE_OK: return "ok";
|
||||||
|
case VICTRONBLE_ERR_NOT_VICTRON: return "not victron";
|
||||||
|
case VICTRONBLE_ERR_SHORT: return "truncated";
|
||||||
|
case VICTRONBLE_ERR_NOT_PRODUCT: return "not product adv";
|
||||||
|
case VICTRONBLE_ERR_KEY_MISMATCH: return "key mismatch";
|
||||||
|
case VICTRONBLE_ERR_UNSUPPORTED: return "unsupported type";
|
||||||
|
case VICTRONBLE_ERR_CRYPTO: return "crypto error";
|
||||||
|
default: return "unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *victronble_device_type_str(victronble_device_type_t type)
|
||||||
|
{
|
||||||
|
switch (type) {
|
||||||
|
case VICTRONBLE_DEV_SOLAR_CHARGER: return "solar charger";
|
||||||
|
case VICTRONBLE_DEV_BATTERY_MONITOR: return "battery monitor";
|
||||||
|
case VICTRONBLE_DEV_INVERTER: return "inverter";
|
||||||
|
case VICTRONBLE_DEV_DCDC_CONVERTER: return "dc-dc converter";
|
||||||
|
case VICTRONBLE_DEV_SMART_LITHIUM: return "smart lithium";
|
||||||
|
case VICTRONBLE_DEV_INVERTER_RS: return "inverter rs";
|
||||||
|
case VICTRONBLE_DEV_GX_DEVICE: return "gx device";
|
||||||
|
case VICTRONBLE_DEV_AC_CHARGER: return "ac charger";
|
||||||
|
case VICTRONBLE_DEV_BATTERY_PROTECT: return "battery protect";
|
||||||
|
case VICTRONBLE_DEV_LYNX_SMART_BMS: return "lynx smart bms";
|
||||||
|
case VICTRONBLE_DEV_MULTI_RS: return "multi rs";
|
||||||
|
case VICTRONBLE_DEV_VE_BUS: return "ve.bus";
|
||||||
|
case VICTRONBLE_DEV_DC_ENERGY_METER: return "dc energy meter";
|
||||||
|
case VICTRONBLE_DEV_ORION_XS: return "orion xs";
|
||||||
|
default: return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *victronble_state_str(uint8_t state)
|
||||||
|
{
|
||||||
|
switch (state) {
|
||||||
|
case VICTRONBLE_STATE_OFF: return "off";
|
||||||
|
case VICTRONBLE_STATE_LOW_POWER: return "low";
|
||||||
|
case VICTRONBLE_STATE_FAULT: return "fault";
|
||||||
|
case VICTRONBLE_STATE_BULK: return "bulk";
|
||||||
|
case VICTRONBLE_STATE_ABSORPTION: return "abs";
|
||||||
|
case VICTRONBLE_STATE_FLOAT: return "float";
|
||||||
|
case VICTRONBLE_STATE_STORAGE: return "store";
|
||||||
|
case VICTRONBLE_STATE_EQUALIZE: return "eq";
|
||||||
|
case VICTRONBLE_STATE_INVERTING: return "invert";
|
||||||
|
case VICTRONBLE_STATE_POWER_SUPPLY: return "psu";
|
||||||
|
case VICTRONBLE_STATE_EXTERNAL_CONTROL: return "ext";
|
||||||
|
default: return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* victronble — Zephyr BLE observer backend.
|
||||||
|
*
|
||||||
|
* Scan callback (BT RX context) does the cheap work only: AD walk, product
|
||||||
|
* pre-filter, registry match, copy into a message queue. A dedicated thread
|
||||||
|
* decrypts, decodes, dedups and fans out to registered listeners.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 Scott Penrose
|
||||||
|
* License: MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Arduino/PlatformIO builds compile every file under src/ — this backend
|
||||||
|
* only exists under Zephyr (the Zephyr CMake build lists sources
|
||||||
|
* explicitly, so the reverse problem doesn't arise). */
|
||||||
|
#ifdef __ZEPHYR__
|
||||||
|
|
||||||
|
#include <zephyr/kernel.h>
|
||||||
|
#include <zephyr/bluetooth/bluetooth.h>
|
||||||
|
#include <zephyr/bluetooth/gap.h>
|
||||||
|
#include <zephyr/logging/log.h>
|
||||||
|
|
||||||
|
#include "victronble_zephyr.h"
|
||||||
|
|
||||||
|
LOG_MODULE_REGISTER(victronble, CONFIG_VICTRONBLE_LOG_LEVEL);
|
||||||
|
|
||||||
|
#define MAX_MFG_LEN (VICTRONBLE_MIN_MFG_LEN + VICTRONBLE_MAX_CIPHER_LEN)
|
||||||
|
|
||||||
|
struct vb_frame {
|
||||||
|
bt_addr_le_t addr;
|
||||||
|
int8_t rssi;
|
||||||
|
uint8_t len;
|
||||||
|
uint8_t data[MAX_MFG_LEN];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct vb_device {
|
||||||
|
bt_addr_le_t addr;
|
||||||
|
uint8_t key[VICTRONBLE_KEY_LEN];
|
||||||
|
uint16_t last_nonce;
|
||||||
|
bool have_nonce;
|
||||||
|
bool used;
|
||||||
|
};
|
||||||
|
|
||||||
|
K_MSGQ_DEFINE(vb_msgq, sizeof(struct vb_frame),
|
||||||
|
CONFIG_VICTRONBLE_QUEUE_DEPTH, 4);
|
||||||
|
|
||||||
|
static struct vb_device devices[CONFIG_VICTRONBLE_MAX_DEVICES];
|
||||||
|
static struct k_mutex dev_mtx;
|
||||||
|
static sys_slist_t callbacks = SYS_SLIST_STATIC_INIT(&callbacks);
|
||||||
|
static struct victronble_stats stats;
|
||||||
|
static bool scanning;
|
||||||
|
|
||||||
|
static struct vb_device *find_device(const bt_addr_le_t *addr)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
|
||||||
|
if (devices[i].used &&
|
||||||
|
bt_addr_le_cmp(&devices[i].addr, addr) == 0) {
|
||||||
|
return &devices[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scan path (BT RX context) --------------------------------------- */
|
||||||
|
|
||||||
|
struct ad_ctx {
|
||||||
|
const bt_addr_le_t *addr;
|
||||||
|
int8_t rssi;
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool ad_cb(struct bt_data *data, void *user_data)
|
||||||
|
{
|
||||||
|
struct ad_ctx *ctx = user_data;
|
||||||
|
|
||||||
|
if (data->type != BT_DATA_MANUFACTURER_DATA) {
|
||||||
|
return true; /* keep walking the AD structures */
|
||||||
|
}
|
||||||
|
if (!victronble_is_product_adv(data->data, data->data_len)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
stats.adverts++;
|
||||||
|
|
||||||
|
/* Registry check is a handful of compares — cheap enough here, and
|
||||||
|
* it keeps other people's Victrons out of the queue. */
|
||||||
|
if (find_device(ctx->addr) == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct vb_frame frame;
|
||||||
|
|
||||||
|
bt_addr_le_copy(&frame.addr, ctx->addr);
|
||||||
|
frame.rssi = ctx->rssi;
|
||||||
|
frame.len = MIN(data->data_len, sizeof(frame.data));
|
||||||
|
memcpy(frame.data, data->data, frame.len);
|
||||||
|
|
||||||
|
if (k_msgq_put(&vb_msgq, &frame, K_NO_WAIT) == 0) {
|
||||||
|
stats.queued++;
|
||||||
|
} else {
|
||||||
|
stats.dropped++;
|
||||||
|
}
|
||||||
|
return false; /* found the record — stop walking */
|
||||||
|
}
|
||||||
|
|
||||||
|
static void scan_recv(const bt_addr_le_t *addr, int8_t rssi,
|
||||||
|
uint8_t adv_type, struct net_buf_simple *ad)
|
||||||
|
{
|
||||||
|
ARG_UNUSED(adv_type);
|
||||||
|
|
||||||
|
struct ad_ctx ctx = { .addr = addr, .rssi = rssi };
|
||||||
|
|
||||||
|
bt_data_parse(ad, ad_cb, &ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Decode thread ----------------------------------------------------- */
|
||||||
|
|
||||||
|
static void decode_frame(const struct vb_frame *frame)
|
||||||
|
{
|
||||||
|
uint8_t key[VICTRONBLE_KEY_LEN];
|
||||||
|
uint16_t last_nonce;
|
||||||
|
bool have_nonce;
|
||||||
|
|
||||||
|
k_mutex_lock(&dev_mtx, K_FOREVER);
|
||||||
|
struct vb_device *dev = find_device(&frame->addr);
|
||||||
|
|
||||||
|
if (dev == NULL) { /* removed while queued */
|
||||||
|
k_mutex_unlock(&dev_mtx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memcpy(key, dev->key, sizeof(key));
|
||||||
|
last_nonce = dev->last_nonce;
|
||||||
|
have_nonce = dev->have_nonce;
|
||||||
|
k_mutex_unlock(&dev_mtx);
|
||||||
|
|
||||||
|
victronble_record_t rec;
|
||||||
|
victronble_err_t err = victronble_decode(frame->data, frame->len,
|
||||||
|
key, &rec);
|
||||||
|
struct victronble_cb *cb;
|
||||||
|
|
||||||
|
if (err != VICTRONBLE_OK) {
|
||||||
|
stats.errors++;
|
||||||
|
LOG_DBG("decode failed: %s", victronble_strerror(err));
|
||||||
|
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
|
||||||
|
if (cb->decode_error != NULL) {
|
||||||
|
cb->decode_error(&frame->addr, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_ENABLED(CONFIG_VICTRONBLE_DEDUP) &&
|
||||||
|
have_nonce && rec.nonce == last_nonce) {
|
||||||
|
stats.duplicates++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
k_mutex_lock(&dev_mtx, K_FOREVER);
|
||||||
|
dev = find_device(&frame->addr);
|
||||||
|
if (dev != NULL) {
|
||||||
|
dev->last_nonce = rec.nonce;
|
||||||
|
dev->have_nonce = true;
|
||||||
|
}
|
||||||
|
k_mutex_unlock(&dev_mtx);
|
||||||
|
|
||||||
|
stats.decoded++;
|
||||||
|
LOG_DBG("%s record, nonce 0x%04x, rssi %d",
|
||||||
|
victronble_device_type_str(rec.type), rec.nonce, frame->rssi);
|
||||||
|
|
||||||
|
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
|
||||||
|
if (cb->record != NULL) {
|
||||||
|
cb->record(&frame->addr, frame->rssi, &rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void vb_thread_fn(void *a, void *b, void *c)
|
||||||
|
{
|
||||||
|
ARG_UNUSED(a);
|
||||||
|
ARG_UNUSED(b);
|
||||||
|
ARG_UNUSED(c);
|
||||||
|
|
||||||
|
struct vb_frame frame;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
k_msgq_get(&vb_msgq, &frame, K_FOREVER);
|
||||||
|
decode_frame(&frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
K_THREAD_DEFINE(vb_thread, CONFIG_VICTRONBLE_THREAD_STACK_SIZE,
|
||||||
|
vb_thread_fn, NULL, NULL, NULL,
|
||||||
|
CONFIG_VICTRONBLE_THREAD_PRIORITY, 0, 0);
|
||||||
|
|
||||||
|
/* --- Public API -------------------------------------------------------- */
|
||||||
|
|
||||||
|
int victronble_cb_register(struct victronble_cb *cb)
|
||||||
|
{
|
||||||
|
struct victronble_cb *it;
|
||||||
|
|
||||||
|
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, it, node) {
|
||||||
|
if (it == cb) {
|
||||||
|
return -EALREADY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys_slist_append(&callbacks, &cb->node);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int victronble_device_add(const bt_addr_le_t *addr,
|
||||||
|
const uint8_t key[VICTRONBLE_KEY_LEN])
|
||||||
|
{
|
||||||
|
int ret = -ENOMEM;
|
||||||
|
|
||||||
|
k_mutex_lock(&dev_mtx, K_FOREVER);
|
||||||
|
if (find_device(addr) != NULL) {
|
||||||
|
ret = -EALREADY;
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
|
||||||
|
if (!devices[i].used) {
|
||||||
|
bt_addr_le_copy(&devices[i].addr, addr);
|
||||||
|
memcpy(devices[i].key, key,
|
||||||
|
VICTRONBLE_KEY_LEN);
|
||||||
|
devices[i].have_nonce = false;
|
||||||
|
devices[i].used = true;
|
||||||
|
ret = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k_mutex_unlock(&dev_mtx);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int victronble_device_remove(const bt_addr_le_t *addr)
|
||||||
|
{
|
||||||
|
int ret = -ENOENT;
|
||||||
|
|
||||||
|
k_mutex_lock(&dev_mtx, K_FOREVER);
|
||||||
|
struct vb_device *dev = find_device(addr);
|
||||||
|
|
||||||
|
if (dev != NULL) {
|
||||||
|
memset(dev, 0, sizeof(*dev));
|
||||||
|
ret = 0;
|
||||||
|
}
|
||||||
|
k_mutex_unlock(&dev_mtx);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int victronble_start(void)
|
||||||
|
{
|
||||||
|
/* Passive scan at a low duty cycle: Victron devices advertise about
|
||||||
|
* once per second, so slow-scan parameters catch every record for a
|
||||||
|
* fraction of the radio-on time. */
|
||||||
|
static const struct bt_le_scan_param param = {
|
||||||
|
.type = BT_LE_SCAN_TYPE_PASSIVE,
|
||||||
|
.options = BT_LE_SCAN_OPT_NONE,
|
||||||
|
.interval = CONFIG_VICTRONBLE_SCAN_INTERVAL,
|
||||||
|
.window = CONFIG_VICTRONBLE_SCAN_WINDOW,
|
||||||
|
};
|
||||||
|
int err;
|
||||||
|
|
||||||
|
if (scanning) {
|
||||||
|
return -EALREADY;
|
||||||
|
}
|
||||||
|
err = bt_le_scan_start(¶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__ */
|
||||||
@@ -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()
|
||||||
Executable
+9
@@ -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
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* victronble core host tests.
|
||||||
|
*
|
||||||
|
* Plain C, no framework: non-zero exit on failure. Positive vectors come
|
||||||
|
* from test_vectors.h (openssl-encrypted, independent of the bundled AES);
|
||||||
|
* negative cases are built inline.
|
||||||
|
*
|
||||||
|
* Build & run: ./run.sh (or see the gcc line inside it)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
#include "victronble.h"
|
||||||
|
#include "test_vectors.h"
|
||||||
|
|
||||||
|
static int failures;
|
||||||
|
|
||||||
|
#define CHECK(cond) do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
||||||
|
failures++; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static int feq(float a, float b)
|
||||||
|
{
|
||||||
|
return fabsf(a - b) < 0.005f;
|
||||||
|
}
|
||||||
|
|
||||||
|
static victronble_err_t decode(const uint8_t *frame, size_t len,
|
||||||
|
const uint8_t key[16], victronble_record_t *rec)
|
||||||
|
{
|
||||||
|
memset(rec, 0xAA, sizeof(*rec));
|
||||||
|
return victronble_decode(frame, len, key, rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
uint8_t key[VICTRONBLE_KEY_LEN];
|
||||||
|
victronble_record_t rec;
|
||||||
|
|
||||||
|
CHECK(victronble_parse_key(VEC_KEY_HEX, key));
|
||||||
|
|
||||||
|
/* --- solar charger --- */
|
||||||
|
CHECK(victronble_is_product_adv(VEC_SOLAR, sizeof(VEC_SOLAR)));
|
||||||
|
CHECK(victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), key));
|
||||||
|
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), key, &rec) == VICTRONBLE_OK);
|
||||||
|
CHECK(rec.type == VICTRONBLE_DEV_SOLAR_CHARGER);
|
||||||
|
CHECK(rec.model_id == 0xA060);
|
||||||
|
CHECK(rec.nonce == 0x1234);
|
||||||
|
CHECK(rec.u.solar.state == VICTRONBLE_STATE_BULK);
|
||||||
|
CHECK(rec.u.solar.error == 0);
|
||||||
|
CHECK(feq(rec.u.solar.battery_voltage, 13.24f));
|
||||||
|
CHECK(feq(rec.u.solar.battery_current, 5.4f));
|
||||||
|
CHECK(rec.u.solar.yield_today_wh == 1200);
|
||||||
|
CHECK(feq(rec.u.solar.pv_power, 340.0f));
|
||||||
|
CHECK(isnan(rec.u.solar.load_current));
|
||||||
|
CHECK(strcmp(victronble_state_str(rec.u.solar.state), "bulk") == 0);
|
||||||
|
|
||||||
|
/* --- battery monitor --- */
|
||||||
|
CHECK(decode(VEC_BATMON, sizeof(VEC_BATMON), key, &rec) == VICTRONBLE_OK);
|
||||||
|
CHECK(rec.type == VICTRONBLE_DEV_BATTERY_MONITOR);
|
||||||
|
CHECK(rec.u.batmon.remaining_minutes == 600);
|
||||||
|
CHECK(feq(rec.u.batmon.voltage, 12.80f));
|
||||||
|
CHECK(rec.u.batmon.alarm == 0x0005);
|
||||||
|
CHECK(rec.u.batmon.aux_mode == 2);
|
||||||
|
CHECK(feq(rec.u.batmon.temperature, 25.0f));
|
||||||
|
CHECK(isnan(rec.u.batmon.aux_voltage));
|
||||||
|
CHECK(feq(rec.u.batmon.current, -2.5f));
|
||||||
|
CHECK(feq(rec.u.batmon.consumed_ah, -50.0f));
|
||||||
|
CHECK(feq(rec.u.batmon.soc, 85.5f));
|
||||||
|
|
||||||
|
/* --- inverter --- */
|
||||||
|
CHECK(decode(VEC_INVERTER, sizeof(VEC_INVERTER), key, &rec) == VICTRONBLE_OK);
|
||||||
|
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
|
||||||
|
CHECK(rec.u.inverter.state == VICTRONBLE_STATE_INVERTING);
|
||||||
|
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
|
||||||
|
CHECK(feq(rec.u.inverter.battery_current, -12.34f));
|
||||||
|
CHECK(feq(rec.u.inverter.ac_power, -230.0f));
|
||||||
|
CHECK(rec.u.inverter.alarms == 0x08);
|
||||||
|
|
||||||
|
/* --- dc-dc converter --- */
|
||||||
|
CHECK(decode(VEC_DCDC, sizeof(VEC_DCDC), key, &rec) == VICTRONBLE_OK);
|
||||||
|
CHECK(rec.type == VICTRONBLE_DEV_DCDC_CONVERTER);
|
||||||
|
CHECK(rec.u.dcdc.state == VICTRONBLE_STATE_FLOAT);
|
||||||
|
CHECK(feq(rec.u.dcdc.input_voltage, 25.30f));
|
||||||
|
CHECK(feq(rec.u.dcdc.output_voltage, 13.31f));
|
||||||
|
CHECK(feq(rec.u.dcdc.output_current, 7.65f));
|
||||||
|
CHECK(rec.nonce == 0xFFFF);
|
||||||
|
|
||||||
|
/* --- ac charger --- */
|
||||||
|
CHECK(decode(VEC_ACCHARGER, sizeof(VEC_ACCHARGER), key, &rec) == VICTRONBLE_OK);
|
||||||
|
CHECK(rec.type == VICTRONBLE_DEV_AC_CHARGER);
|
||||||
|
CHECK(rec.u.ac.state == VICTRONBLE_STATE_ABSORPTION);
|
||||||
|
CHECK(feq(rec.u.ac.voltage1, 14.40f));
|
||||||
|
CHECK(feq(rec.u.ac.current1, 10.0f));
|
||||||
|
CHECK(isnan(rec.u.ac.voltage2) && isnan(rec.u.ac.current2));
|
||||||
|
CHECK(isnan(rec.u.ac.voltage3) && isnan(rec.u.ac.current3));
|
||||||
|
CHECK(feq(rec.u.ac.temperature, 35.0f));
|
||||||
|
CHECK(feq(rec.u.ac.ac_current, 1.2f));
|
||||||
|
|
||||||
|
/* --- multi RS collapses to the inverter decoder --- */
|
||||||
|
CHECK(decode(VEC_MULTIRS, sizeof(VEC_MULTIRS), key, &rec) == VICTRONBLE_OK);
|
||||||
|
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
|
||||||
|
CHECK(rec.record_type == VICTRONBLE_DEV_MULTI_RS);
|
||||||
|
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
|
||||||
|
|
||||||
|
/* --- negative cases --- */
|
||||||
|
|
||||||
|
/* Known record type, no decoder */
|
||||||
|
CHECK(decode(VEC_GX, sizeof(VEC_GX), key, &rec) == VICTRONBLE_ERR_UNSUPPORTED);
|
||||||
|
|
||||||
|
/* Truncated: shorter than the header */
|
||||||
|
CHECK(decode(VEC_SOLAR, 9, key, &rec) == VICTRONBLE_ERR_SHORT);
|
||||||
|
CHECK(!victronble_is_product_adv(VEC_SOLAR, 9));
|
||||||
|
|
||||||
|
/* Wrong company ID */
|
||||||
|
{
|
||||||
|
uint8_t bad[sizeof(VEC_SOLAR)];
|
||||||
|
memcpy(bad, VEC_SOLAR, sizeof(bad));
|
||||||
|
bad[0] = 0x4C; bad[1] = 0x00; /* Apple */
|
||||||
|
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_VICTRON);
|
||||||
|
CHECK(!victronble_is_product_adv(bad, sizeof(bad)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not a product advertisement */
|
||||||
|
{
|
||||||
|
uint8_t bad[sizeof(VEC_SOLAR)];
|
||||||
|
memcpy(bad, VEC_SOLAR, sizeof(bad));
|
||||||
|
bad[2] = 0x01;
|
||||||
|
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_PRODUCT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrong key: check byte catches it without decrypting */
|
||||||
|
{
|
||||||
|
uint8_t wrong_key[16];
|
||||||
|
memcpy(wrong_key, key, 16);
|
||||||
|
wrong_key[0] ^= 0xFF;
|
||||||
|
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec) ==
|
||||||
|
VICTRONBLE_ERR_KEY_MISMATCH);
|
||||||
|
CHECK(!victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrong key with a matching check byte: decrypts to garbage but must
|
||||||
|
* not crash; solar parser accepts any bytes, so OK with junk values is
|
||||||
|
* acceptable — just require no error other than OK/SHORT. */
|
||||||
|
{
|
||||||
|
uint8_t wrong_key[16];
|
||||||
|
memcpy(wrong_key, key, 16);
|
||||||
|
wrong_key[15] ^= 0xFF;
|
||||||
|
victronble_err_t err = decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec);
|
||||||
|
CHECK(err == VICTRONBLE_OK || err == VICTRONBLE_ERR_SHORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key parsing */
|
||||||
|
{
|
||||||
|
uint8_t k[16];
|
||||||
|
CHECK(!victronble_parse_key("00112233", k)); /* short */
|
||||||
|
CHECK(!victronble_parse_key(NULL, k));
|
||||||
|
CHECK(!victronble_parse_key("zz112233445566778899aabbccddeeff", k));
|
||||||
|
CHECK(victronble_parse_key("00112233445566778899AABBCCDDEEFF", k));
|
||||||
|
CHECK(k[0] == 0x00 && k[15] == 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures == 0) {
|
||||||
|
printf("victronble core: all tests passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
printf("victronble core: %d FAILURE(S)\n", failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
name: victronble
|
||||||
|
build:
|
||||||
|
cmake: .
|
||||||
|
kconfig: Kconfig
|
||||||
Reference in New Issue
Block a user