Files
MeshCore/tools/hil/contact_cache_serial_stress.py
T

183 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Qualify a 350-contact Full Companion's path and shared-secret caches.
Requires pyserial and exclusive access to the specified Companion data port.
--populate writes 350 deterministic contacts and changes the node's name;
use a dedicated test node. --secrets sends 23 directed LoRa CLI datagrams to
synthetic peers to exercise eviction. Without either flag, only local reads
are performed. --reboot restarts the node after validation.
Run again without --populate after reboot to check persistence. The generation
must match the previous population. Results are newline-delimited JSON.
"""
import argparse
import hashlib
import json
import re
import struct
import time
import serial
from esp32_companion_serial_stress import (
DeviceFrameReader,
TransportCounters,
encode_host_frame,
validate_core_stats_response,
)
def report(event, **values):
print(json.dumps(dict(event=event, **values)), flush=True)
def secret_counters(reply):
return {
key: int(value)
for key, value in re.findall(
r"(ram_hits|flash_hits|calculations|save_skips)=(\d+)", reply
)
}
def run(args):
port = serial.Serial(None, 115200, timeout=0.05, write_timeout=10)
port.dtr = False
port.rts = False
port.port = args.port
port.open()
try:
port.dtr = True
time.sleep(4)
reader = DeviceFrameReader(TransportCounters())
def send(payload):
port.write(encode_host_frame(payload))
port.flush()
def request(payload, expected, timeout=12):
send(payload)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = reader.read_frame(port, deadline - time.monotonic())
if value[0] == expected:
return value
if value[0] == 1:
raise RuntimeError(f"Command {payload[0]} failed: {value.hex()}")
raise TimeoutError(f"Command {payload[0]}")
def cli(command):
reply = request(b"\x42" + command.encode("ascii"), 29)[1:].decode("ascii")
report("cli", command=command, reply=reply)
return reply
def fixture(index):
key = hashlib.sha256(f"contact-cache-hil-{index}".encode()).digest()
route = bytes(
(index * 19 + j + (index >> 8) + args.generation * 37) % 256
for j in range(64)
)
name = f"Cache test {index:03d}".encode("ascii").ljust(32, b"\0")
return key, route, name
version = request(b"\x16\x0e", 13)
report("device", board=version[20:60].split(b"\0")[0].decode("ascii"),
contacts=version[2] * 2, channels=version[3])
assert version[2] * 2 == 350 and version[3] == 40
request(b"\x01" + b"\0" * 7 + b"Contact Cache HIL", 5)
policy = cli("get contact.cache")
assert "paths=16 secrets=16" in policy
assert ("miss=calculate" if args.expect_calculate else "miss=flash") in policy
cli("memory") # Some nRF52 builds do not implement this diagnostic.
if args.populate:
request(b"\x08" + args.name.encode("ascii"), 0)
for index in range(350):
key, route, name = fixture(index)
data = (b"\x09" + key + b"\x01\x00\x3f" + route + name
+ b"\0" * 12 + b"\x01\0\0\0")
try:
request(data, 0, timeout=60)
except Exception:
report("insert_failed", index=index)
raise
if index % 25 == 24:
stats = validate_core_stats_response(request(b"\x38\x00", 24))
report("inserted", count=index + 1, **stats)
time.sleep(6)
start = request(b"\x04", 2, timeout=30)
assert int.from_bytes(start[1:5], "little") == 350
expected = {fixture(i)[0]: fixture(i) for i in range(350)}
seen = set()
while True:
value = reader.read_frame(port, 30)
if value[0] == 4:
break
if value[0] != 3:
continue
key = value[1:33]
assert len(value) == 148 and key in expected and key not in seen
assert value[35] == 63 and value[36:100] == expected[key][1]
assert value[100:132] == expected[key][2]
seen.add(key)
assert seen == expected.keys()
report("sync", contacts=len(seen), verified_path_bytes=64)
for index in range(350):
key, route, _ = fixture(index)
value = request(b"\x1e" + key, 3)
assert value[36:100] == route
report("individual_reads", contacts=350)
if args.secrets:
before = secret_counters(cli("get contact.cache"))
peers = list(range(args.secret_start, args.secret_start + 21))
for index in peers + [args.secret_start, args.secret_start]:
key, _, _ = fixture(index)
# CLI datagrams do not occupy an ACK/correlation slot.
message = (b"\x02\x01\x00" + struct.pack("<I", int(time.time()))
+ key[:6] + b"get name")
request(message, 6)
time.sleep(2)
after = secret_counters(cli("get contact.cache"))
delta = {key: value - before[key] for key, value in after.items()}
assert delta["ram_hits"] >= 1 and delta["save_skips"] == 0
if args.expect_calculate:
assert delta["flash_hits"] == 0 and delta["calculations"] >= 22
else:
assert delta["flash_hits"] >= 1
report("secret_eviction", **delta)
cli("get contact.cache.timing")
cli("memory")
report("stats", **validate_core_stats_response(request(b"\x38\x00", 24)))
if args.reboot:
send(b"\x13reboot")
report("reboot_requested")
report("passed")
finally:
port.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", required=True)
parser.add_argument("--populate", action="store_true")
parser.add_argument("--secrets", action="store_true")
parser.add_argument("--reboot", action="store_true")
parser.add_argument("--generation", type=int, default=0)
parser.add_argument("--secret-start", type=int, default=0)
parser.add_argument("--expect-calculate", action="store_true")
parser.add_argument("--name", default="Contact-Cache-HIL")
args = parser.parse_args()
if not 0 <= args.secret_start <= 329:
parser.error("--secret-start must leave 21 peers within contacts 0..349")
if not args.name.isascii() or len(args.name) > 31:
parser.error("--name must contain at most 31 ASCII characters")
run(args)
if __name__ == "__main__":
main()