Add firmware verification harness
CI / Non-hardware verification (push) Has been cancelled

This commit is contained in:
2026-07-13 11:10:19 +02:00
parent def09160d0
commit 99c07ecda6
16 changed files with 1004 additions and 32 deletions
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
from __future__ import annotations
import configparser
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
EXPECTED_PLATFORMIO_CORE = "6.1.18"
EXPECTED_PLATFORM = "https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip"
EXPECTED_ENVS = {"production", "debug", "test"}
EXPECTED_BOARD = "ttgo-lora32-v1"
EXPECTED_FRAMEWORK = "arduino"
PROPOSED_EXACT_PINS = {
"sandeepmistry/LoRa": "0.8.0",
"bblanchon/ArduinoJson": "6.21.6",
"adafruit/Adafruit SSD1306": "2.5.17",
"adafruit/Adafruit GFX Library": "1.12.6",
"knolleary/PubSubClient": "2.8.0",
"throwtheswitch/Unity": "2.6.1",
}
def command_text(command: list[str]) -> str:
return " ".join(f'"{part}"' if any(ch.isspace() for ch in part) else part for part in command)
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUTF8"] = "1"
env["PIO_FORCE_COLOR"] = "0"
print(f"$ {command_text(command)}", flush=True)
result = subprocess.run(
command,
cwd=ROOT,
env=env,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
print(result.stdout, end="")
return result
def split_multiline(value: str) -> list[str]:
return [line.strip() for line in value.splitlines() if line.strip()]
def parse_platformio() -> configparser.RawConfigParser:
parser = configparser.RawConfigParser()
parser.optionxform = str
with (ROOT / "platformio.ini").open(encoding="utf-8") as handle:
parser.read_file(handle)
return parser
def main() -> int:
errors: list[str] = []
warnings: list[str] = []
version_result = run([sys.executable, "-m", "platformio", "--version"])
if version_result.returncode != 0:
errors.append("PlatformIO version command failed.")
else:
match = re.search(r"version\s+([0-9.]+)", version_result.stdout)
observed = match.group(1) if match else "unknown"
if observed != EXPECTED_PLATFORMIO_CORE:
errors.append(f"Expected PlatformIO Core {EXPECTED_PLATFORMIO_CORE}, observed {observed}.")
config_result = run([sys.executable, "-m", "platformio", "project", "config", "--json-output"])
if config_result.returncode != 0:
errors.append("PlatformIO project config command failed.")
pkg_result = run([sys.executable, "-m", "platformio", "pkg", "list"])
if pkg_result.returncode != 0:
errors.append("PlatformIO package resolution failed.")
parser = parse_platformio()
env_sections = {section.split(":", 1)[1] for section in parser.sections() if section.startswith("env:")}
missing = EXPECTED_ENVS - env_sections
if missing:
errors.append(f"Missing required PlatformIO environments: {', '.join(sorted(missing))}")
base_platform = parser.get("env", "platform", fallback="")
if base_platform != EXPECTED_PLATFORM:
errors.append("Base PlatformIO platform is not the expected pinned pioarduino release URL.")
base_board = parser.get("env", "board", fallback="")
base_framework = parser.get("env", "framework", fallback="")
if base_board != EXPECTED_BOARD:
errors.append(f"Expected board {EXPECTED_BOARD}, observed {base_board}.")
if base_framework != EXPECTED_FRAMEWORK:
errors.append(f"Expected framework {EXPECTED_FRAMEWORK}, observed {base_framework}.")
lib_deps = split_multiline(parser.get("env", "lib_deps", fallback=""))
for dep in lib_deps:
if "@" not in dep:
errors.append(f"Dependency lacks an explicit version requirement: {dep}")
elif "@^" in dep:
warnings.append(f"Ranged dependency allowed for now, exact pin proposed separately: {dep}")
for env_name in EXPECTED_ENVS:
section = f"env:{env_name}"
if not parser.has_section(section):
continue
if parser.get(section, "platform", fallback=base_platform) != EXPECTED_PLATFORM:
errors.append(f"{section} platform differs from the pinned base platform.")
if parser.get(section, "board", fallback=base_board) != EXPECTED_BOARD:
errors.append(f"{section} board differs from {EXPECTED_BOARD}.")
if parser.get(section, "framework", fallback=base_framework) != EXPECTED_FRAMEWORK:
errors.append(f"{section} framework differs from Arduino.")
print("Proposed exact direct dependency pins for a separate commit:")
for package, version in PROPOSED_EXACT_PINS.items():
print(f"- {package}@{version}")
for warning in warnings:
print(f"WARNING: {warning}")
if errors:
for error in errors:
print(f"ERROR: {error}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
from __future__ import annotations
import configparser
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
REQUIRED_PATHS = [
"AGENTS.md",
".gitea/workflows/ci.yml",
"README.md",
"Requirements.md",
"docs/TESTS.md",
"docs/POWER_OPTIMIZATION.md",
"docs/engineering/current-state.md",
"docs/engineering/risk-register.md",
"docs/engineering/test-matrix.md",
"docs/engineering/hil-strategy.md",
"hil_tests/README.md",
]
TEST_SUITES = [
"test_html_escape",
"test_json_codec",
"test_lora_transport",
"test_meter_fault_count",
"test_payload_codec",
"test_refactor_smoke",
"test_security_fuzz",
]
TRACEABILITY_CONTRACTS = [
"LoRa frame format",
"CRC validation",
"Chunk reassembly",
"Payload schema",
"Sender-ID validation",
"ACK matching",
"Time bootstrap",
"Retry bounds",
"Queue bounds",
"Duplicate handling",
"MQTT JSON keys",
"Home Assistant discovery fields",
"CSV compatibility",
"HTML escaping",
"Web input validation",
]
def read_text(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def platformio_envs() -> set[str]:
parser = configparser.RawConfigParser()
parser.optionxform = str
with (ROOT / "platformio.ini").open(encoding="utf-8") as handle:
parser.read_file(handle)
return {section.split(":", 1)[1] for section in parser.sections() if section.startswith("env:")}
def main() -> int:
errors: list[str] = []
for rel in REQUIRED_PATHS:
if not (ROOT / rel).exists():
errors.append(f"Missing required documentation or harness file: {rel}")
if errors:
for error in errors:
print(error)
return 1
envs = platformio_envs()
tests_doc = read_text("docs/TESTS.md")
readme = read_text("README.md")
requirements = read_text("Requirements.md")
current_state = read_text("docs/engineering/current-state.md")
risk_register = read_text("docs/engineering/risk-register.md")
test_matrix = read_text("docs/engineering/test-matrix.md")
agents = read_text("AGENTS.md")
for env in sorted(envs):
if f"`{env}`" not in tests_doc:
errors.append(f"docs/TESTS.md does not document environment {env}.")
for stale in ["lilygo-t3-v1-6-1", "schema v3", "schema `3`"]:
if stale in tests_doc:
errors.append(f"docs/TESTS.md still contains stale text: {stale}")
for suite in TEST_SUITES:
if suite not in tests_doc:
errors.append(f"docs/TESTS.md does not document suite {suite}.")
for contract in TRACEABILITY_CONTRACTS:
if contract not in tests_doc or contract not in test_matrix:
errors.append(f"Traceability contract missing from docs: {contract}")
schema_v4_markers = ["schema v4", "schema `4`", "schema `v4`"]
for path, text in [("README.md", readme), ("Requirements.md", requirements), ("current-state.md", current_state)]:
if not any(marker in text for marker in schema_v4_markers):
errors.append(f"{path} does not document payload schema v4.")
if "schema v3" in text or "schema `3`" in text:
errors.append(f"{path} still documents schema v3.")
for category in [
"Verified Defects",
"Probable Risks",
"Missing Tests",
"Missing Documentation",
"Security Decisions",
"Hardware-Dependent Questions",
]:
if category not in risk_register:
errors.append(f"risk-register.md missing category: {category}")
if "python tools/verify.py" not in agents:
errors.append("AGENTS.md does not name the canonical verification command.")
if errors:
for error in errors:
print(f"ERROR: {error}")
return 1
print("Documentation consistency checks passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
from __future__ import annotations
import py_compile
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
TEXT_SUFFIXES = {
".c",
".cc",
".cpp",
".h",
".hpp",
".ini",
".json",
".md",
".ps1",
".py",
".txt",
".yaml",
".yml",
}
SKIP_PATHS = {
".vscode/c_cpp_properties.json",
".vscode/launch.json",
}
def tracked_and_untracked_files() -> list[Path]:
result = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
cwd=ROOT,
text=True,
encoding="utf-8",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
print(result.stderr, end="")
raise SystemExit(result.returncode)
return [ROOT / line.strip() for line in result.stdout.splitlines() if line.strip()]
def is_text_candidate(path: Path) -> bool:
rel = path.relative_to(ROOT).as_posix()
if rel in SKIP_PATHS:
return False
return path.suffix.lower() in TEXT_SUFFIXES
def main() -> int:
errors: list[str] = []
for path in tracked_and_untracked_files():
if not path.is_file() or not is_text_candidate(path):
continue
rel = path.relative_to(ROOT).as_posix()
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
errors.append(f"{rel}: not valid UTF-8: {exc}")
continue
for line_no, line in enumerate(text.splitlines(), 1):
if line.startswith(("<<<<<<<", "=======", ">>>>>>>")):
errors.append(f"{rel}:{line_no}: merge conflict marker")
for script in sorted((ROOT / "tools").glob("*.py")):
try:
py_compile.compile(str(script), doraise=True)
except py_compile.PyCompileError as exc:
errors.append(str(exc))
if not (ROOT / ".clang-format").exists():
print("No .clang-format found; source formatting is not reformatted by this check.")
print("Checked text encodings, conflict markers, and Python syntax.")
if errors:
for error in errors:
print(error)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
BASELINE_PATH = ROOT / "docs" / "engineering" / "size-baseline.json"
ENVS = ["production", "debug", "test"]
def file_size(path: Path) -> int:
return path.stat().st_size if path.exists() else -1
def main() -> int:
if not BASELINE_PATH.exists():
print(f"Missing size baseline: {BASELINE_PATH.relative_to(ROOT)}")
return 1
baseline = json.loads(BASELINE_PATH.read_text(encoding="utf-8"))
baseline_envs = baseline.get("environments", {})
errors: list[str] = []
print("Firmware size report:")
for env in ENVS:
build_dir = ROOT / ".pio" / "build" / env
bin_path = build_dir / "firmware.bin"
elf_path = build_dir / "firmware.elf"
bin_size = file_size(bin_path)
elf_size = file_size(elf_path)
if bin_size < 0 or elf_size < 0:
errors.append(f"{env}: missing firmware artifacts; run python -m platformio run -e {env}")
continue
entry = baseline_envs.get(env)
if not entry:
errors.append(f"{env}: missing baseline entry")
continue
baseline_bin = int(entry["firmware_bin_bytes"])
max_bin = int(entry.get("max_firmware_bin_bytes", baseline_bin))
delta = bin_size - baseline_bin
print(
f"- {env}: firmware.bin={bin_size} bytes "
f"(baseline {baseline_bin}, delta {delta:+}), firmware.elf={elf_size} bytes"
)
if bin_size > max_bin:
errors.append(f"{env}: firmware.bin {bin_size} exceeds limit {max_bin}")
if errors:
for error in errors:
print(f"ERROR: {error}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
def command_text(command: list[str]) -> str:
parts: list[str] = []
for part in command:
if any(ch.isspace() for ch in part):
parts.append(f'"{part}"')
else:
parts.append(part)
return " ".join(parts)
def run_step(name: str, command: list[str]) -> bool:
print(f"\n== {name} ==", flush=True)
print(f"$ {command_text(command)}", flush=True)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUTF8"] = "1"
env["PIO_FORCE_COLOR"] = "0"
result = subprocess.run(command, cwd=ROOT, env=env)
if result.returncode != 0:
print(f"FAILED: {name} exited with {result.returncode}", flush=True)
return False
print(f"PASSED: {name}", flush=True)
return True
def main() -> int:
python = sys.executable
steps = [
("repository formatting check", [python, "tools/check_format.py"]),
("dependency and PlatformIO policy check", [python, "tools/check_dependencies.py"]),
("production build", [python, "-m", "platformio", "run", "-e", "production"]),
("debug build", [python, "-m", "platformio", "run", "-e", "debug"]),
("test firmware build", [python, "-m", "platformio", "run", "-e", "test"]),
(
"compile-only embedded Unity tests",
[python, "-m", "platformio", "test", "-e", "test", "--without-uploading", "--without-testing"],
),
("static analysis", [python, "-m", "platformio", "check", "-e", "production", "--fail-on-defect=high"]),
("documentation consistency check", [python, "tools/check_docs.py"]),
("firmware size check", [python, "tools/check_size.py"]),
]
failures: list[str] = []
for name, command in steps:
if not run_step(name, command):
failures.append(name)
if failures:
print("\nVerification failed:")
for failure in failures:
print(f"- {failure}")
return 1
print("\nVerification passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())