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,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 */
|
||||
Reference in New Issue
Block a user