#!/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()