- 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>
130 lines
4.5 KiB
Python
130 lines
4.5 KiB
Python
#!/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()
|