From 639b2b5d6f888e60478cc0894f12711f494c1c0e Mon Sep 17 00:00:00 2001 From: fjoelnir Date: Sun, 22 Mar 2026 11:56:23 +0100 Subject: [PATCH] docs: add hardware preflight check --- README.md | 10 ++- docs/STATUS.md | 3 +- docs/operations.md | 2 + docs/verification.md | 10 +-- scripts/hardware_smoke_check.py | 124 ++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 scripts/hardware_smoke_check.py diff --git a/README.md b/README.md index 5a29f66..2200a99 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ python scripts/compile_check.py If `src/config.h` is missing, the script copies `src/config_template.h`, runs `platformio run`, and removes the temporary file again. +For real node validation before OTA upload: + +```bash +python scripts/hardware_smoke_check.py +``` + +The preflight checks whether `src/config.h` exists, whether it still contains placeholder values, whether the configured OTA target is reachable, and whether the configured MQTT broker answers on port `1883`. + Additional operating context now lives in: - [docs/hardware.md](docs/hardware.md) @@ -50,6 +58,6 @@ Additional operating context now lives in: ## Immediate Next Steps 1. Replace the legacy `config.h` flow with a clearer credentials/config separation. -2. Add a real hardware smoke-test routine after the new compile-only validation path. +2. Turn the current hardware preflight into a fuller post-upload smoke workflow with serial/MQTT assertions. 3. Decide which parts should remain standalone versus moving into `SkySentinel`. 4. Add hive-specific telemetry beyond the current BME280 baseline once the hardware scope is stable. diff --git a/docs/STATUS.md b/docs/STATUS.md index af825f3..2340977 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -11,6 +11,7 @@ HiveTech is an ESP32 firmware prototype for live beehive monitoring. It reads BM - Still uses a legacy `config.h` include path - PlatformIO/IoT profile context is now committed in `anr.profile.yaml` plus focused hardware and operations docs - Compile-only validation now works via `python scripts/compile_check.py` +- Hardware preflight now works via `python scripts/hardware_smoke_check.py` - CI build validation is defined in `.github/workflows/build.yml` - Local developer deployment settings are still present outside committed templates @@ -20,7 +21,7 @@ HiveTech is an ESP32 firmware prototype for live beehive monitoring. It reads BM - Hardware wiring is documented only to the level visible in current firmware assumptions - Deployment is still local and OTA-target specific - MQTT contract is documented but still topic-only, without retained/availability semantics -- Real hardware smoke validation is still manual +- Real hardware post-upload smoke validation is still manual ## Next Milestone diff --git a/docs/operations.md b/docs/operations.md index 9504746..7887d04 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -18,7 +18,9 @@ Current runtime flow: ## Build And Validation - `python scripts/compile_check.py` is the default compile-only validation path +- `python scripts/hardware_smoke_check.py` is the preflight path before OTA upload - the script creates a temporary `src/config.h` from `src/config_template.h` when needed +- the hardware preflight checks local config completeness, OTA target reachability, and MQTT broker reachability - CI uses the same path so local and remote validation stay aligned ## Safety Rules diff --git a/docs/verification.md b/docs/verification.md index 0fcacc0..d7feb1a 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -7,14 +7,15 @@ Keep firmware changes reproducible and separate "compiles locally" from "works o ## Minimum Checks 1. Run `python scripts/compile_check.py` for a compile-only check. -2. If you need device-specific validation, provide a real local `src/config.h`. -3. Flash or OTA-update the node using the local deployment path from `platformio.ini`. -4. Confirm serial boot output reaches: +2. Run `python scripts/hardware_smoke_check.py` before OTA upload. +3. If you need device-specific validation, provide a real local `src/config.h`. +4. Flash or OTA-update the node using the local deployment path from `platformio.ini`. +5. Confirm serial boot output reaches: - Wi-Fi connect - MQTT connect - BME280 detection - deep sleep entry -5. Verify these MQTT topics receive fresh values: +6. Verify these MQTT topics receive fresh values: - `HT_BME280_Temperature` - `HT_BME280_Humidity` - `HT_BME280_Pressure` @@ -23,4 +24,5 @@ Keep firmware changes reproducible and separate "compiles locally" from "works o ## Separation Of Checks - Compile-only validation is now available via `python scripts/compile_check.py`. +- Hardware preflight is now available via `python scripts/hardware_smoke_check.py`. - Real hardware validation is still mandatory after sensor, networking, OTA, or sleep-logic changes. diff --git a/scripts/hardware_smoke_check.py b/scripts/hardware_smoke_check.py new file mode 100644 index 0000000..7568708 --- /dev/null +++ b/scripts/hardware_smoke_check.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import configparser +import re +import socket +import sys +from dataclasses import dataclass +from pathlib import Path + + +PLACEHOLDER_VALUES = {"your_wifi_ssid", "your_wifi_password", "your_mqtt_server"} + + +@dataclass +class CheckResult: + name: str + ok: bool + detail: str + + +def _load_platformio_env(repo_root: Path) -> tuple[str, dict[str, str]]: + config = configparser.ConfigParser(inline_comment_prefixes=(";", "#")) + config.read(repo_root / "platformio.ini", encoding="utf-8") + env_sections = [section for section in config.sections() if section.startswith("env:")] + if not env_sections: + raise ValueError("platformio.ini does not define an [env:*] section") + section = env_sections[0] + return section, {key: value.strip() for key, value in config[section].items()} + + +def _read_config_values(config_path: Path) -> dict[str, str]: + content = config_path.read_text(encoding="utf-8") + values: dict[str, str] = {} + for key in ("ssid", "password", "mqtt_server"): + match = re.search(rf'{key}\s*=\s*"([^"]+)"', content) + if match: + values[key] = match.group(1).strip() + return values + + +def _is_serial_target(value: str) -> bool: + upper = value.upper() + return upper.startswith("COM") or value.startswith("/dev/") + + +def _check_host(host: str, port: int | None = None) -> CheckResult: + try: + resolved = socket.gethostbyname(host) + except OSError as exc: + return CheckResult(f"resolve:{host}", False, f"name resolution failed: {exc}") + + if port is None: + return CheckResult(f"resolve:{host}", True, f"resolved to {resolved}") + + try: + with socket.create_connection((host, port), timeout=2): + return CheckResult(f"connect:{host}:{port}", True, f"reachable at {resolved}:{port}") + except OSError as exc: + return CheckResult(f"connect:{host}:{port}", False, f"connection failed: {exc}") + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + config_path = repo_root / "src" / "config.h" + + results: list[CheckResult] = [] + + try: + env_name, env_config = _load_platformio_env(repo_root) + except ValueError as exc: + print(f"preflight error: {exc}") + return 1 + + upload_target = env_config.get("upload_port", "") + upload_protocol = env_config.get("upload_protocol", "") + + results.append(CheckResult("platformio-env", True, f"using {env_name}")) + results.append(CheckResult("upload-protocol", bool(upload_protocol), f"value={upload_protocol or 'missing'}")) + + if not config_path.exists(): + results.append(CheckResult("config.h", False, f"missing local config: {config_path}")) + else: + values = _read_config_values(config_path) + missing_keys = [key for key in ("ssid", "password", "mqtt_server") if key not in values] + if missing_keys: + results.append(CheckResult("config.h", False, f"missing keys: {', '.join(missing_keys)}")) + else: + placeholders = [key for key, value in values.items() if value in PLACEHOLDER_VALUES] + if placeholders: + results.append(CheckResult("config.h", False, f"placeholder values still set: {', '.join(placeholders)}")) + else: + results.append(CheckResult("config.h", True, "real local credentials/config detected")) + + mqtt_host = values.get("mqtt_server", "") + if mqtt_host: + results.append(_check_host(mqtt_host, 1883)) + + if not upload_target: + results.append(CheckResult("upload-target", False, "upload_port missing")) + elif _is_serial_target(upload_target): + results.append(CheckResult("upload-target", True, f"serial target configured: {upload_target}")) + else: + results.append(_check_host(upload_target, 3232)) + + failures = 0 + print("HiveTech hardware preflight") + print("") + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"[{status}] {result.name}: {result.detail}") + if not result.ok: + failures += 1 + + print("") + if failures: + print(f"preflight result: FAIL ({failures} failed checks)") + return 1 + + print("preflight result: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())