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:
2026-08-21 11:59:19 +10:00
parent 617c240e02
commit b390c0d579
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()