v0.7.0: pure C core + Zephyr module

- Extract all decode/decrypt into a dependency-free C99 core
  (include/victronble.h, src/victronble_core.c): victronble_decode(),
  is_product_adv/key_matches pre-filters, NAN sentinels, LE accessors.
- AES-128-CTR behind a hook: weak-symbol bundled tiny-AES default,
  runtime override (victronble_set_aes_ctr) for PSA/mbedTLS/hardware.
- Arduino VictronBLE class becomes a thin wrapper over the core
  (registry + nonce dedup + rate limit); public C++ API unchanged,
  NAN converted back to the legacy 0 convention.
- Host test vectors (tests/vectors): openssl-generated ciphertext,
  independent of the bundled AES; all five payload shapes + negatives.
- Zephyr module: zephyr/module.yml + Kconfig (CONFIG_VICTRONBLE) +
  observer backend (victronble_zephyr.{h,c}) — scan cb pre-filters and
  queues, dedicated decode thread, listener callbacks, slow passive
  scan defaults, stats counters. docs/ZEPHYR_PORT.md records the plan.
- library.properties: fix URL (gitea, not the nonexistent GitHub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:59:19 +10:00
co-authored by Claude Fable 5
parent cc8c4d36d5
commit 037af33154
20 changed files with 2145 additions and 284 deletions
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Generate test_vectors.h for the victronble core host tests.
Plaintext payloads are packed here from human-readable field values
(mirroring Victron's Extra Manufacturer Data layouts) and encrypted with
the openssl CLI — an implementation independent of the library's bundled
tiny-AES — so the vectors cross-check the AES-CTR semantics as well as
the parsers. The generated header is committed; python/openssl are only
needed to regenerate it.
"""
import subprocess
from pathlib import Path
COMPANY_ID = 0x02E1
PRODUCT_ADV = 0x10
KEY = bytes.fromhex("0df4d0395b7d5d4f5a0d0af52e1b4c1e")
def aes_ctr(key: bytes, nonce: int, plaintext: bytes) -> bytes:
iv = bytes([nonce & 0xFF, (nonce >> 8) & 0xFF] + [0] * 14)
return subprocess.run(
["openssl", "enc", "-aes-128-ctr", "-K", key.hex(), "-iv", iv.hex(),
"-nopad"],
input=plaintext, capture_output=True, check=True).stdout
def frame(record_type: int, model_id: int, readout: int, nonce: int,
plaintext: bytes, key: bytes = KEY) -> bytes:
head = bytes([COMPANY_ID & 0xFF, COMPANY_ID >> 8, PRODUCT_ADV,
model_id & 0xFF, model_id >> 8, readout, record_type,
nonce & 0xFF, (nonce >> 8) & 0xFF, key[0]])
return head + aes_ctr(key, nonce, plaintext)
def le16(v: int) -> bytes:
return bytes([v & 0xFF, (v >> 8) & 0xFF])
def pad21(b: bytes) -> bytes:
assert len(b) <= 21
return b + bytes(21 - len(b))
def pack_bits(fields):
"""fields: list of (value, width). LSB-first bit packing."""
total = sum(w for _, w in fields)
out = bytearray((total + 7) // 8)
bit = 0
for value, width in fields:
for i in range(width):
if (value >> i) & 1:
out[(bit + i) >> 3] |= 1 << ((bit + i) & 7)
bit += width
return bytes(out)
# --- Payloads ---------------------------------------------------------------
# Solar charger: bulk, no error, 13.24 V, 5.4 A, 1.20 kWh today, 340 W,
# no load output (9-bit 0x1FF).
solar = pad21(bytes([3, 0]) + le16(1324) + le16(54) + le16(120) + le16(340) +
le16(0x1FF))
# Battery monitor: TTG 600 min, 12.80 V, alarms lowV|lowSOC, aux mode 2
# (temperature 25.00 C = 29815 * 0.01 K), current -2.5 A, consumed 50.0 Ah,
# SOC 85.5 %.
batmon = pad21(pack_bits([
(600, 16), # TTG minutes
(1280, 16), # voltage, 0.01 V
(0x0005, 16), # alarm bitmask
(29815, 16), # aux raw (0.01 K)
(2, 2), # aux mode = temperature
(-2500 & 0x3FFFFF, 22), # current, 0.001 A
(500, 20), # consumed, 0.1 Ah
(855, 10), # SOC, 0.1 %
]))
# Inverter: inverting, 25.86 V, -12.34 A, -230 W, overload alarm.
inverter = pad21(bytes([9, 0]) + le16(2586) + le16(-1234 & 0xFFFF) +
((-230) & 0xFFFFFF).to_bytes(3, "little") + bytes([0x08]))
# DC-DC converter: float, no error, in 25.30 V, out 13.31 V, 7.65 A.
dcdc = pad21(bytes([5, 0]) + le16(2530) + le16(1331) + le16(765))
# AC charger: absorption, no error, out1 14.40 V / 10.0 A, out2/3 absent,
# temp 35 C, AC current 1.2 A.
accharger = pad21(pack_bits([
(4, 8), (0, 8),
(1440, 13), (100, 11),
(0x1FFF, 13), (0x7FF, 11),
(0x1FFF, 13), (0x7FF, 11),
(35 + 40, 7),
(12, 9),
]))
VECTORS = [
("solar", frame(0x01, 0xA060, 0x00, 0x1234, solar)),
("batmon", frame(0x02, 0xA389, 0x00, 0xBEEF, batmon)),
("inverter", frame(0x03, 0xA2FA, 0x00, 0x0001, inverter)),
("dcdc", frame(0x04, 0xA3C0, 0x00, 0xFFFF, dcdc)),
("accharger", frame(0x08, 0xA339, 0x00, 0x00C8, accharger)),
# Multi RS record type decodes via the inverter parser.
("multirs", frame(0x0B, 0xA512, 0x00, 0x0042, inverter)),
# GX device: recognised record type, no decoder -> ERR_UNSUPPORTED.
("gx", frame(0x07, 0xA100, 0x00, 0x0007, pad21(b""))),
]
def main():
out = Path(__file__).with_name("test_vectors.h")
lines = [
"/* Generated by gen_vectors.py — do not edit by hand.",
" * Ciphertext produced with `openssl enc -aes-128-ctr`, independent",
" * of the library's bundled AES. */",
"",
f'static const char VEC_KEY_HEX[] = "{KEY.hex()}";',
"",
]
for name, data in VECTORS:
arr = ", ".join(f"0x{b:02x}" for b in data)
lines.append(f"static const uint8_t VEC_{name.upper()}[] = {{ {arr} }};")
lines.append("")
out.write_text("\n".join(lines))
print(f"wrote {out} ({len(VECTORS)} vectors)")
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Build and run the victronble core host tests (plain gcc, no framework).
# Regenerate vectors first with: python3 gen_vectors.py
set -e
cd "$(dirname "$0")"
cc -std=c99 -Wall -Wextra -Werror -I../../include -I../../src \
../../src/victronble_core.c ../../src/victronble_aes_sw.c \
../../src/crypto/vble_aes.c test_main.c -lm -o victronble_test
./victronble_test
+173
View File
@@ -0,0 +1,173 @@
/**
* victronble core host tests.
*
* Plain C, no framework: non-zero exit on failure. Positive vectors come
* from test_vectors.h (openssl-encrypted, independent of the bundled AES);
* negative cases are built inline.
*
* Build & run: ./run.sh (or see the gcc line inside it)
*/
#include <stdio.h>
#include <string.h>
#include <math.h>
#include "victronble.h"
#include "test_vectors.h"
static int failures;
#define CHECK(cond) do { \
if (!(cond)) { \
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
failures++; \
} \
} while (0)
static int feq(float a, float b)
{
return fabsf(a - b) < 0.005f;
}
static victronble_err_t decode(const uint8_t *frame, size_t len,
const uint8_t key[16], victronble_record_t *rec)
{
memset(rec, 0xAA, sizeof(*rec));
return victronble_decode(frame, len, key, rec);
}
int main(void)
{
uint8_t key[VICTRONBLE_KEY_LEN];
victronble_record_t rec;
CHECK(victronble_parse_key(VEC_KEY_HEX, key));
/* --- solar charger --- */
CHECK(victronble_is_product_adv(VEC_SOLAR, sizeof(VEC_SOLAR)));
CHECK(victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), key));
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_SOLAR_CHARGER);
CHECK(rec.model_id == 0xA060);
CHECK(rec.nonce == 0x1234);
CHECK(rec.u.solar.state == VICTRONBLE_STATE_BULK);
CHECK(rec.u.solar.error == 0);
CHECK(feq(rec.u.solar.battery_voltage, 13.24f));
CHECK(feq(rec.u.solar.battery_current, 5.4f));
CHECK(rec.u.solar.yield_today_wh == 1200);
CHECK(feq(rec.u.solar.pv_power, 340.0f));
CHECK(isnan(rec.u.solar.load_current));
CHECK(strcmp(victronble_state_str(rec.u.solar.state), "bulk") == 0);
/* --- battery monitor --- */
CHECK(decode(VEC_BATMON, sizeof(VEC_BATMON), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_BATTERY_MONITOR);
CHECK(rec.u.batmon.remaining_minutes == 600);
CHECK(feq(rec.u.batmon.voltage, 12.80f));
CHECK(rec.u.batmon.alarm == 0x0005);
CHECK(rec.u.batmon.aux_mode == 2);
CHECK(feq(rec.u.batmon.temperature, 25.0f));
CHECK(isnan(rec.u.batmon.aux_voltage));
CHECK(feq(rec.u.batmon.current, -2.5f));
CHECK(feq(rec.u.batmon.consumed_ah, -50.0f));
CHECK(feq(rec.u.batmon.soc, 85.5f));
/* --- inverter --- */
CHECK(decode(VEC_INVERTER, sizeof(VEC_INVERTER), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
CHECK(rec.u.inverter.state == VICTRONBLE_STATE_INVERTING);
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
CHECK(feq(rec.u.inverter.battery_current, -12.34f));
CHECK(feq(rec.u.inverter.ac_power, -230.0f));
CHECK(rec.u.inverter.alarms == 0x08);
/* --- dc-dc converter --- */
CHECK(decode(VEC_DCDC, sizeof(VEC_DCDC), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_DCDC_CONVERTER);
CHECK(rec.u.dcdc.state == VICTRONBLE_STATE_FLOAT);
CHECK(feq(rec.u.dcdc.input_voltage, 25.30f));
CHECK(feq(rec.u.dcdc.output_voltage, 13.31f));
CHECK(feq(rec.u.dcdc.output_current, 7.65f));
CHECK(rec.nonce == 0xFFFF);
/* --- ac charger --- */
CHECK(decode(VEC_ACCHARGER, sizeof(VEC_ACCHARGER), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_AC_CHARGER);
CHECK(rec.u.ac.state == VICTRONBLE_STATE_ABSORPTION);
CHECK(feq(rec.u.ac.voltage1, 14.40f));
CHECK(feq(rec.u.ac.current1, 10.0f));
CHECK(isnan(rec.u.ac.voltage2) && isnan(rec.u.ac.current2));
CHECK(isnan(rec.u.ac.voltage3) && isnan(rec.u.ac.current3));
CHECK(feq(rec.u.ac.temperature, 35.0f));
CHECK(feq(rec.u.ac.ac_current, 1.2f));
/* --- multi RS collapses to the inverter decoder --- */
CHECK(decode(VEC_MULTIRS, sizeof(VEC_MULTIRS), key, &rec) == VICTRONBLE_OK);
CHECK(rec.type == VICTRONBLE_DEV_INVERTER);
CHECK(rec.record_type == VICTRONBLE_DEV_MULTI_RS);
CHECK(feq(rec.u.inverter.battery_voltage, 25.86f));
/* --- negative cases --- */
/* Known record type, no decoder */
CHECK(decode(VEC_GX, sizeof(VEC_GX), key, &rec) == VICTRONBLE_ERR_UNSUPPORTED);
/* Truncated: shorter than the header */
CHECK(decode(VEC_SOLAR, 9, key, &rec) == VICTRONBLE_ERR_SHORT);
CHECK(!victronble_is_product_adv(VEC_SOLAR, 9));
/* Wrong company ID */
{
uint8_t bad[sizeof(VEC_SOLAR)];
memcpy(bad, VEC_SOLAR, sizeof(bad));
bad[0] = 0x4C; bad[1] = 0x00; /* Apple */
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_VICTRON);
CHECK(!victronble_is_product_adv(bad, sizeof(bad)));
}
/* Not a product advertisement */
{
uint8_t bad[sizeof(VEC_SOLAR)];
memcpy(bad, VEC_SOLAR, sizeof(bad));
bad[2] = 0x01;
CHECK(decode(bad, sizeof(bad), key, &rec) == VICTRONBLE_ERR_NOT_PRODUCT);
}
/* Wrong key: check byte catches it without decrypting */
{
uint8_t wrong_key[16];
memcpy(wrong_key, key, 16);
wrong_key[0] ^= 0xFF;
CHECK(decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec) ==
VICTRONBLE_ERR_KEY_MISMATCH);
CHECK(!victronble_key_matches(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key));
}
/* Wrong key with a matching check byte: decrypts to garbage but must
* not crash; solar parser accepts any bytes, so OK with junk values is
* acceptable — just require no error other than OK/SHORT. */
{
uint8_t wrong_key[16];
memcpy(wrong_key, key, 16);
wrong_key[15] ^= 0xFF;
victronble_err_t err = decode(VEC_SOLAR, sizeof(VEC_SOLAR), wrong_key, &rec);
CHECK(err == VICTRONBLE_OK || err == VICTRONBLE_ERR_SHORT);
}
/* Key parsing */
{
uint8_t k[16];
CHECK(!victronble_parse_key("00112233", k)); /* short */
CHECK(!victronble_parse_key(NULL, k));
CHECK(!victronble_parse_key("zz112233445566778899aabbccddeeff", k));
CHECK(victronble_parse_key("00112233445566778899AABBCCDDEEFF", k));
CHECK(k[0] == 0x00 && k[15] == 0xFF);
}
if (failures == 0) {
printf("victronble core: all tests passed\n");
return 0;
}
printf("victronble core: %d FAILURE(S)\n", failures);
return 1;
}
+13
View File
@@ -0,0 +1,13 @@
/* Generated by gen_vectors.py — do not edit by hand.
* Ciphertext produced with `openssl enc -aes-128-ctr`, independent
* of the library's bundled AES. */
static const char VEC_KEY_HEX[] = "0df4d0395b7d5d4f5a0d0af52e1b4c1e";
static const uint8_t VEC_SOLAR[] = { 0xe1, 0x02, 0x10, 0x60, 0xa0, 0x00, 0x01, 0x34, 0x12, 0x0d, 0x53, 0xb0, 0x25, 0x4c, 0x65, 0xd3, 0x4a, 0x92, 0x34, 0x70, 0x3a, 0x6c, 0x19, 0x73, 0x7e, 0x65, 0xf6, 0xa4, 0x42, 0xd0, 0x56 };
static const uint8_t VEC_BATMON[] = { 0xe1, 0x02, 0x10, 0x89, 0xa3, 0x00, 0x02, 0xef, 0xbe, 0x0d, 0x41, 0x08, 0x53, 0x2f, 0x0d, 0x44, 0xa5, 0x1c, 0x62, 0xa0, 0x51, 0xe9, 0x7c, 0x1f, 0xae, 0x2f, 0xe9, 0xe8, 0x2a, 0x0e, 0x15 };
static const uint8_t VEC_INVERTER[] = { 0xe1, 0x02, 0x10, 0xfa, 0xa2, 0x00, 0x03, 0x01, 0x00, 0x0d, 0xf6, 0x2d, 0x11, 0x5e, 0x6f, 0x60, 0x17, 0x3f, 0x62, 0xeb, 0x75, 0xfe, 0x67, 0x69, 0xef, 0x59, 0x71, 0xb6, 0xb6, 0xd4, 0x2c };
static const uint8_t VEC_DCDC[] = { 0xe1, 0x02, 0x10, 0xc0, 0xa3, 0x00, 0x04, 0xff, 0xff, 0x0d, 0x66, 0xcc, 0x80, 0x55, 0xf1, 0x96, 0xe8, 0xb8, 0x15, 0x7f, 0x76, 0xd2, 0x4a, 0x5c, 0xeb, 0xf2, 0xbb, 0x6c, 0x9f, 0x58, 0x08 };
static const uint8_t VEC_ACCHARGER[] = { 0xe1, 0x02, 0x10, 0x39, 0xa3, 0x00, 0x08, 0xc8, 0x00, 0x0d, 0x10, 0xc5, 0xcd, 0xca, 0xbb, 0x29, 0x20, 0xda, 0xf8, 0x1e, 0xae, 0xf6, 0x8e, 0xce, 0xd4, 0xec, 0xb5, 0x6b, 0xa9, 0x99, 0x4a };
static const uint8_t VEC_MULTIRS[] = { 0xe1, 0x02, 0x10, 0x12, 0xa5, 0x00, 0x0b, 0x42, 0x00, 0x0d, 0xf2, 0x03, 0x6f, 0xd7, 0xe5, 0x20, 0x2a, 0x5c, 0x6e, 0x96, 0x59, 0xf8, 0x26, 0x28, 0x40, 0x2b, 0xdb, 0x7d, 0xe5, 0x4b, 0x88 };
static const uint8_t VEC_GX[] = { 0xe1, 0x02, 0x10, 0x00, 0xa1, 0x00, 0x07, 0x07, 0x00, 0x0d, 0xe3, 0x06, 0xe4, 0xb3, 0xc3, 0x81, 0x4e, 0x8a, 0xf0, 0x82, 0x6e, 0xd9, 0xf0, 0x47, 0x06, 0x3e, 0x10, 0x2e, 0xcc, 0x1f, 0x56 };
BIN
View File
Binary file not shown.