Ruview/scripts/generate_nvs_matrix.py
rUv 5b2aacd923
fix(firmware): fall detection, 4MB flash, QEMU CI (#263, #265)
* fix(firmware): fall detection false positives + 4MB flash support (#263, #265)

Issue #263: Default fall_thresh raised from 2.0 to 15.0 rad/s² — normal
walking produces accelerations of 2.5-5.0 which triggered constant false
"Fall Detected" alerts. Added consecutive-frame requirement (3 frames)
and 5-second cooldown debounce to prevent alert storms.

Issue #265: Added partitions_4mb.csv and sdkconfig.defaults.4mb for
ESP32-S3 boards with 4MB flash (e.g. SuperMini). OTA slots are 1.856MB
each, fitting the ~978KB firmware binary with room to spare.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): repair all 3 QEMU workflow job failures

1. Fuzz Tests: add esp_timer_create_args_t, esp_timer_create(),
   esp_timer_start_periodic(), esp_timer_delete() stubs to
   esp_stubs.h — csi_collector.c uses these for channel hop timer.

2. QEMU Build: add libgcrypt20-dev to apt dependencies —
   Espressif QEMU's esp32_flash_enc.c includes <gcrypt.h>.
   Bump cache key v4→v5 to force rebuild with new dep.

3. NVS Matrix: switch to subprocess-first invocation of
   nvs_partition_gen to avoid 'str' has no attribute 'size' error
   from esp_idf_nvs_partition_gen API change. Falls back to
   direct import with both int and hex size args.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): pip3 in IDF container + fix swarm QEMU artifact path

QEMU Test jobs: espressif/idf:v5.4 container has pip3, not pip.
Swarm Test: use /opt/qemu-esp32 (fixed path) instead of
${{ github.workspace }}/qemu-build which resolves incorrectly
inside Docker containers.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): source IDF export.sh before pip install in container

espressif/idf:v5.4 container doesn't have pip/pip3 on PATH — it
lives inside the IDF Python venv which is only activated after
sourcing $IDF_PATH/export.sh.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): pad QEMU flash image to 8MB with --fill-flash-size

QEMU rejects flash images that aren't exactly 2/4/8/16 MB.
esptool merge_bin produces a sparse image (~1.1 MB) by default.
Add --fill-flash-size 8MB to pad with 0xFF to the full 8 MB.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): source IDF export before NVS matrix generation in QEMU tests

The generate_nvs_matrix.py script needs the IDF venv's python
(which has esp_idf_nvs_partition_gen installed) rather than the
system /usr/bin/python3 which doesn't have the package.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): QEMU validation treats WARNs as OK + swarm IDF export

1. validate_qemu_output.py: WARNs exit 0 by default (no real WiFi
   hardware in QEMU = no CSI data = expected WARNs for frame/vitals
   checks). Add --strict flag to fail on warnings when needed.

2. Swarm Test: source IDF export.sh before running qemu_swarm.py
   so pip-installed pyyaml is on the Python path.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): provision.py subprocess-first NVS gen + swarm IDF venv

provision.py had same 'str' has no attribute 'size' bug as the
NVS matrix generator — switch to subprocess-first approach.
Swarm test also needs IDF export for the swarm smoke test step.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): handle missing 'ip' command in QEMU swarm orchestrator

The IDF container doesn't have iproute2 installed, so 'ip' binary
is missing. Add shutil.which() check to can_tap guard and catch
FileNotFoundError in _run_ip() for robustness.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): skip Rust aggregator when cargo not available in swarm test

The IDF container doesn't have Rust installed. Check for cargo
with shutil.which() before attempting to spawn the aggregator,
falling back to aggregator-less mode (QEMU nodes still boot and
exercise the firmware pipeline).

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ci): treat swarm test WARNs as acceptable in CI

The max_boot_time_s assertion WARNs because QEMU doesn't produce
parseable boot time data. Exit code 1 (WARN) is acceptable in CI
without real hardware; only exit code 2+ (FAIL/FATAL) should fail.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): Kconfig EDGE_FALL_THRESH default 2000→15000

The nvs_config.c fallback (15.0f) was never reached because
Kconfig always defines CONFIG_EDGE_FALL_THRESH. The Kconfig
default was still 2000 (=2.0 rad/s²), causing false fall alerts
on real WiFi CSI data (7 alerts in 45s).

Fixed to 15000 (=15.0 rad/s²). Verified on real ESP32-S3 hardware
with live WiFi CSI: 0 false fall alerts in 60s / 1300+ frames.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: update README, CHANGELOG, user guide for v0.4.3-esp32

- README: add v0.4.3 to release table, 4MB flash instructions,
  fix fall-thresh example (5000→15000)
- CHANGELOG: v0.4.3-esp32 entry with all fixes and additions
- User guide: 4MB flash section with esptool commands

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-15 11:49:29 -04:00

432 lines
16 KiB
Python

#!/usr/bin/env python3
"""
NVS Test Matrix Generator (ADR-061)
Generates NVS partition binaries for 14 test configurations using the
provision.py script's CSV builder and NVS binary generator. Each binary
can be injected into a QEMU flash image at offset 0x9000 for automated
firmware testing under different NVS configurations.
Usage:
python3 generate_nvs_matrix.py --output-dir build/nvs_matrix
# Generate only specific configs:
python3 generate_nvs_matrix.py --output-dir build/nvs_matrix --only default,full-adr060
Requirements:
- esp_idf_nvs_partition_gen (pip install) or ESP-IDF nvs_partition_gen.py
- Python 3.8+
"""
import argparse
import csv
import io
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# NVS partition size must match partitions_display.csv: 0x6000 = 24576 bytes
NVS_PARTITION_SIZE = 0x6000
@dataclass
class NvsEntry:
"""A single NVS key-value entry."""
key: str
type: str # "data" or "namespace"
encoding: str # "string", "u8", "u16", "u32", "hex2bin", ""
value: str
@dataclass
class NvsConfig:
"""A named NVS configuration with a list of entries."""
name: str
description: str
entries: List[NvsEntry] = field(default_factory=list)
def to_csv(self) -> str:
"""Generate NVS CSV content."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["key", "type", "encoding", "value"])
writer.writerow(["csi_cfg", "namespace", "", ""])
for entry in self.entries:
writer.writerow([entry.key, entry.type, entry.encoding, entry.value])
return buf.getvalue()
def define_configs() -> List[NvsConfig]:
"""Define all 14 NVS test configurations."""
configs = []
# 1. default - no NVS entries (firmware uses Kconfig defaults)
configs.append(NvsConfig(
name="default",
description="No NVS entries; firmware uses Kconfig defaults",
entries=[],
))
# 2. wifi-only - just WiFi credentials
configs.append(NvsConfig(
name="wifi-only",
description="WiFi SSID and password only",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
],
))
# 3. full-adr060 - channel override + MAC filter
configs.append(NvsConfig(
name="full-adr060",
description="ADR-060: channel override + MAC filter + full config",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("target_port", "data", "u16", "5005"),
NvsEntry("node_id", "data", "u8", "1"),
NvsEntry("csi_channel", "data", "u8", "6"),
NvsEntry("filter_mac", "data", "hex2bin", "aabbccddeeff"),
],
))
# 4. edge-tier0 - raw passthrough (no DSP)
configs.append(NvsConfig(
name="edge-tier0",
description="Edge tier 0: raw CSI passthrough, no on-device DSP",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("edge_tier", "data", "u8", "0"),
],
))
# 5. edge-tier1 - basic presence/motion detection
configs.append(NvsConfig(
name="edge-tier1",
description="Edge tier 1: basic presence and motion detection",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("edge_tier", "data", "u8", "1"),
NvsEntry("pres_thresh", "data", "u16", "50"),
],
))
# 6. edge-tier2-custom - full pipeline with custom thresholds
configs.append(NvsConfig(
name="edge-tier2-custom",
description="Edge tier 2: full pipeline with custom thresholds",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("edge_tier", "data", "u8", "2"),
NvsEntry("pres_thresh", "data", "u16", "100"),
NvsEntry("fall_thresh", "data", "u16", "3000"),
NvsEntry("vital_win", "data", "u16", "256"),
NvsEntry("vital_int", "data", "u16", "500"),
NvsEntry("subk_count", "data", "u8", "16"),
],
))
# 7. tdm-3node - TDM mesh with 3 nodes (slot 0)
configs.append(NvsConfig(
name="tdm-3node",
description="TDM mesh: 3-node schedule, this node is slot 0",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("node_id", "data", "u8", "0"),
NvsEntry("tdm_slot", "data", "u8", "0"),
NvsEntry("tdm_nodes", "data", "u8", "3"),
],
))
# 8. wasm-signed - WASM runtime with signature verification
configs.append(NvsConfig(
name="wasm-signed",
description="WASM runtime enabled with Ed25519 signature verification",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("edge_tier", "data", "u8", "2"),
# wasm_verify=1 + a 32-byte dummy Ed25519 pubkey
NvsEntry("wasm_verify", "data", "u8", "1"),
NvsEntry("wasm_pubkey", "data", "hex2bin",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
],
))
# 9. wasm-unsigned - WASM runtime without signature verification
configs.append(NvsConfig(
name="wasm-unsigned",
description="WASM runtime with signature verification disabled",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("edge_tier", "data", "u8", "2"),
NvsEntry("wasm_verify", "data", "u8", "0"),
NvsEntry("wasm_max", "data", "u8", "2"),
],
))
# 10. 5ghz-channel - 5 GHz channel override
configs.append(NvsConfig(
name="5ghz-channel",
description="ADR-060: 5 GHz channel 36 override",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork5G"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("csi_channel", "data", "u8", "36"),
],
))
# 11. boundary-max - maximum VALID values for all numeric fields
# Uses firmware-validated max ranges (not raw u8/u16 max):
# vital_win: 32-256, top_k: 1-32, power_duty: 10-100
configs.append(NvsConfig(
name="boundary-max",
description="Boundary test: maximum valid values per firmware validation ranges",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("target_port", "data", "u16", "65535"),
NvsEntry("node_id", "data", "u8", "255"),
NvsEntry("edge_tier", "data", "u8", "2"),
NvsEntry("pres_thresh", "data", "u16", "65535"),
NvsEntry("fall_thresh", "data", "u16", "65535"),
NvsEntry("vital_win", "data", "u16", "256"), # max validated
NvsEntry("vital_int", "data", "u16", "10000"),
NvsEntry("subk_count", "data", "u8", "32"),
NvsEntry("power_duty", "data", "u8", "100"),
],
))
# 12. boundary-min - minimum VALID values for all numeric fields
configs.append(NvsConfig(
name="boundary-min",
description="Boundary test: minimum valid values per firmware validation ranges",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("target_port", "data", "u16", "1024"),
NvsEntry("node_id", "data", "u8", "0"),
NvsEntry("edge_tier", "data", "u8", "0"),
NvsEntry("pres_thresh", "data", "u16", "1"),
NvsEntry("fall_thresh", "data", "u16", "100"), # min valid (0.1 rad/s²)
NvsEntry("vital_win", "data", "u16", "32"), # min validated
NvsEntry("vital_int", "data", "u16", "100"),
NvsEntry("subk_count", "data", "u8", "1"),
NvsEntry("power_duty", "data", "u8", "10"),
],
))
# 13. power-save - low power duty cycle configuration
configs.append(NvsConfig(
name="power-save",
description="Power-save mode: 10% duty cycle for battery-powered nodes",
entries=[
NvsEntry("ssid", "data", "string", "TestNetwork"),
NvsEntry("password", "data", "string", "testpass123"),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
NvsEntry("edge_tier", "data", "u8", "1"),
NvsEntry("power_duty", "data", "u8", "10"),
],
))
# 14. empty-strings - empty SSID/password to test fallback to Kconfig
configs.append(NvsConfig(
name="empty-strings",
description="Empty SSID and password to verify Kconfig fallback",
entries=[
NvsEntry("ssid", "data", "string", ""),
NvsEntry("password", "data", "string", ""),
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
],
))
return configs
def generate_nvs_binary(csv_content: str, size: int) -> bytes:
"""Generate an NVS partition binary from CSV content.
Tries multiple methods to find nvs_partition_gen:
1. Subprocess invocation (most reliable across package versions)
2. esp_idf_nvs_partition_gen pip package (direct import)
3. Legacy nvs_partition_gen pip package
4. ESP-IDF bundled script (via IDF_PATH)
"""
import subprocess
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f_csv:
f_csv.write(csv_content)
csv_path = f_csv.name
bin_path = csv_path.replace(".csv", ".bin")
try:
# Method 1: subprocess invocation (most reliable — avoids API changes)
for module_name in ["esp_idf_nvs_partition_gen", "nvs_partition_gen"]:
try:
subprocess.check_call(
[sys.executable, "-m", module_name, "generate",
csv_path, bin_path, hex(size)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
with open(bin_path, "rb") as f:
return f.read()
except (subprocess.CalledProcessError, FileNotFoundError):
continue
# Method 2: direct import (handles older API where generate() takes int)
for module_name in ["esp_idf_nvs_partition_gen.nvs_partition_gen",
"nvs_partition_gen"]:
try:
mod = __import__(module_name, fromlist=["generate"])
# Try int size first, then hex string (API varies by version)
for size_arg in [size, hex(size)]:
try:
mod.generate(csv_path, bin_path, size_arg)
with open(bin_path, "rb") as f:
return f.read()
except (TypeError, AttributeError):
continue
except ImportError:
continue
# Method 3: ESP-IDF bundled script
idf_path = os.environ.get("IDF_PATH", "")
gen_script = os.path.join(
idf_path, "components", "nvs_flash",
"nvs_partition_generator", "nvs_partition_gen.py"
)
if os.path.isfile(gen_script):
subprocess.check_call([
sys.executable, gen_script, "generate",
csv_path, bin_path, hex(size)
])
with open(bin_path, "rb") as f:
return f.read()
print("ERROR: NVS partition generator tool not found.", file=sys.stderr)
print("Install: pip install esp-idf-nvs-partition-gen", file=sys.stderr)
print("Or set IDF_PATH to your ESP-IDF installation", file=sys.stderr)
raise RuntimeError(
"NVS partition generator not available. "
"Install: pip install esp-idf-nvs-partition-gen"
)
finally:
for p in set((csv_path, bin_path)):
if os.path.isfile(p):
os.unlink(p)
def main():
parser = argparse.ArgumentParser(
description="Generate NVS partition binaries for QEMU firmware test matrix (ADR-061)",
)
parser.add_argument(
"--output-dir", required=True,
help="Directory to write NVS binary files",
)
parser.add_argument(
"--only", type=str, default=None,
help="Comma-separated list of config names to generate (default: all)",
)
parser.add_argument(
"--csv-only", action="store_true",
help="Only generate CSV files, skip binary generation",
)
parser.add_argument(
"--list", action="store_true", dest="list_configs",
help="List all available configurations and exit",
)
args = parser.parse_args()
all_configs = define_configs()
if args.list_configs:
print(f"{'Name':<20} {'Description'}")
print("-" * 70)
for cfg in all_configs:
print(f"{cfg.name:<20} {cfg.description}")
sys.exit(0)
# Filter configs if --only specified
if args.only:
selected = set(args.only.split(","))
configs = [c for c in all_configs if c.name in selected]
missing = selected - {c.name for c in configs}
if missing:
print(f"WARNING: Unknown config names: {', '.join(sorted(missing))}",
file=sys.stderr)
else:
configs = all_configs
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Generating {len(configs)} NVS configurations in {output_dir}/")
print()
success = 0
errors = 0
for cfg in configs:
csv_content = cfg.to_csv()
# Always write the CSV for reference
csv_path = output_dir / f"nvs_{cfg.name}.csv"
csv_path.write_text(csv_content)
if cfg.name == "default" and not cfg.entries:
# "default" means no NVS — just produce an empty marker
print(f" [{cfg.name}] No NVS entries (uses Kconfig defaults)")
# Write a zero-filled NVS partition (erased state = 0xFF)
bin_path = output_dir / f"nvs_{cfg.name}.bin"
bin_path.write_bytes(b"\xff" * NVS_PARTITION_SIZE)
success += 1
continue
if args.csv_only:
print(f" [{cfg.name}] CSV only: {csv_path}")
success += 1
continue
try:
nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE)
bin_path = output_dir / f"nvs_{cfg.name}.bin"
bin_path.write_bytes(nvs_bin)
print(f" [{cfg.name}] {len(nvs_bin)} bytes -> {bin_path}")
success += 1
except Exception as e:
print(f" [{cfg.name}] ERROR: {e}", file=sys.stderr)
errors += 1
print()
print(f"Done: {success} succeeded, {errors} failed")
if errors > 0:
sys.exit(1)
if __name__ == "__main__":
main()