diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 40fb982..fd33beb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,16 +8,17 @@ Closes # ## Type of change -- [ ] Bugfix (T-number: ___) +- [ ] Bugfix - [ ] New feature (F-number from PROJECT_PLAN.md: ___) -- [ ] Refactor +- [ ] Refactor / YAML cleanup - [ ] CI / tooling +- [ ] Documentation ## Checklist -- [ ] `pio run` compiles cleanly (no warnings) -- [ ] `pio test -e native` passes -- [ ] Tested on hardware (LOLIN D32 + DHT22 + Ruck EM 125L EC 02) +- [ ] `esphome config fancontrol.yaml` passes +- [ ] `esphome compile fancontrol.yaml` succeeds (lambdas type-check) +- [ ] Tested on hardware (LOLIN D32 + DHT22 + Ruck EM 125L EC 02) if change affects runtime behaviour - [ ] `CHANGELOG.md` updated under `[Unreleased]` -- [ ] No blocking calls >10ms in `loop()` -- [ ] All NVS writes check their return value and log the result +- [ ] No hard-coded credentials — all secrets referenced via `!secret ...` +- [ ] Safety behaviour preserved (sensor stall → fan 100 %, min-% floor, alarm thresholds) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 667557d..00e3778 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: PlatformIO CI +name: ESPHome CI on: push: @@ -13,51 +13,45 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Cache PlatformIO + pip - uses: actions/cache@v5 - with: - path: | - ~/.cache/pip - ~/.platformio - key: ${{ runner.os }}-pio-${{ hashFiles('platformio.ini') }} - restore-keys: | - ${{ runner.os }}-pio- - - - name: Setup Python - uses: actions/setup-python@v6 + # Seed a dummy secrets.yaml so `esphome config` resolves !secret + # references during the lint. Real secrets live in the maintainer's + # local secrets.yaml (gitignored) and are not needed for CI. + - name: Stage dummy secrets for lint + run: | + python3 -c 'import base64, os; print("api_encryption_key: \"" + base64.b64encode(os.urandom(32)).decode() + "\"")' > secrets.yaml + cat >> secrets.yaml <1.5MB)" exit 1 fi - - name: Upload firmware artifact + - name: Upload firmware artifacts uses: actions/upload-artifact@v7 with: - name: firmware-${{ github.sha }} - path: .pio/build/lolin_d32/firmware.bin + name: fancontrol-${{ github.sha }} + path: | + .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.bin + .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.ota.bin + .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.factory.bin retention-days: 30 + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a8c255..fb94309 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,64 +17,38 @@ jobs: with: fetch-depth: 0 - - name: Cache PlatformIO + pip - uses: actions/cache@v5 - with: - path: | - ~/.cache/pip - ~/.platformio - key: ${{ runner.os }}-pio-${{ hashFiles('platformio.ini') }} - restore-keys: | - ${{ runner.os }}-pio- - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.11' - - - name: Install PlatformIO - run: pip install --upgrade platformio - - name: Extract version from tag id: version run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - name: Inject version into firmware - run: | - sed -i 's/#define VERSION ".*"/#define VERSION "'${{ steps.version.outputs.VERSION }}'"/' src/version.h - - - name: Build release firmware - run: pio run -e lolin_d32 - - - name: Rename binary + - name: Stage dummy secrets for build run: | - cp .pio/build/lolin_d32/firmware.bin \ - FanControl-${{ steps.version.outputs.VERSION }}.bin + python3 -c 'import base64, os; print("api_encryption_key: \"" + base64.b64encode(os.urandom(32)).decode() + "\"")' > secrets.yaml + cat >> secrets.yaml < FanControl-${{ steps.version.outputs.VERSION }}.bin.sha256 + - name: Build ESPHome firmware + uses: esphome/build-action@v7 + with: + yaml-file: fancontrol.yaml + version: latest - # Signing matches SmartEVSE-3.5/pio-build.yaml exactly. - # Repo secret SECRET_RSA_KEY holds the PEM-encoded RSA private key. - # If the secret is unset the step exits 0 silently so forks build cleanly; - # the release step then skips the .signed.bin via fail_on_unmatched_files: false. - - name: Sign firmware - env: - super_secret: ${{ secrets.SECRET_RSA_KEY }} + - name: Collect + rename release artifacts + id: artifacts run: | - if [ -z "$super_secret" ]; then - echo "Signing key not set, skipping firmware signing" - exit 0 - fi - secret_file=$(mktemp) - echo "$super_secret" > "$secret_file" - openssl dgst -sign "$secret_file" -keyform PEM -sha256 \ - -out firmware.sign -binary FanControl-${{ steps.version.outputs.VERSION }}.bin - cat firmware.sign FanControl-${{ steps.version.outputs.VERSION }}.bin \ - > FanControl-${{ steps.version.outputs.VERSION }}.signed.bin - rm -f "$secret_file" firmware.sign + VER=${{ steps.version.outputs.VERSION }} + mkdir -p release + cp .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.bin release/FanControl-${VER}.bin + cp .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.ota.bin release/FanControl-${VER}.ota.bin + cp .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.factory.bin release/FanControl-${VER}.factory.bin + cd release + sha256sum FanControl-${VER}.bin > FanControl-${VER}.bin.sha256 + sha256sum FanControl-${VER}.ota.bin > FanControl-${VER}.ota.bin.sha256 + sha256sum FanControl-${VER}.factory.bin > FanControl-${VER}.factory.bin.sha256 + ls -la - name: Extract changelog section for this release run: | @@ -94,9 +68,6 @@ jobs: draft: false prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }} fail_on_unmatched_files: false - files: | - FanControl-${{ steps.version.outputs.VERSION }}.bin - FanControl-${{ steps.version.outputs.VERSION }}.bin.sha256 - FanControl-${{ steps.version.outputs.VERSION }}.signed.bin + files: release/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 4f0f27f..608877c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,18 @@ -.pio/ -.pioenvs/ -.piolibdeps/ +# ESPHome +.esphome/ +secrets.yaml + +# Editor / OS .vscode/ -.cache/ .DS_Store *.swp *.bak + +# Legacy PlatformIO artifacts — kept so a stray .pio/ from pre-v0.2.0 +# checkouts never gets committed back. +.pio/ +.pioenvs/ +.piolibdeps/ +.cache/ build/ dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dac57be..5f25898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,98 @@ Release notes for each tagged version are extracted from this file by the ## [Unreleased] +## [0.2.0] - 2026-04-19 + +**Architecture switch: custom PlatformIO + Arduino C++ → ESPHome.** + +Firmware behaviour is unchanged in spirit (same hardware, same fan curve, +same captive portal, same safety failsafe), but the entire ~2,000-line C++ +codebase is replaced by a single ~200-line YAML file. See +`PROJECT_PLAN.md` "History" for the decision rationale. + ### Added -- `SECURITY.md` with release-verification recipe and key-rotation policy. -- `docs/signing_public_key.pem` — public half of the release signing key, - committed so users can verify `.signed.bin` without coordination. -- README "Verifying releases" section linking to the above. + +- `fancontrol.yaml` — single-source firmware config driving ESPHome: + - DHT22 on GPIO 4, 5 s polling, last-good caching via ESPHome filters. + - `output.ledc` PWM on GPIO 25, default 1 kHz, runtime-tunable + 1000–5000 Hz via `number.template` + `ledc.set_frequency` action. + 10-bit resolution auto-computed by ESPHome. + - `Fan Speed` template sensor with a 5-point piecewise-linear + temperature-to-PWM lambda matching the v0.1.x curve exactly. + NaN → 100 %; sensor-stall flag → 100 %. + - `Sensor Alarm` (60 s stall detection) and `Temperature Alarm` + (configurable threshold, default 35 °C) as `binary_sensor.template` + entities with `safety` / `problem` device classes. + - `Restart Counter` persisted via `globals` + `restore_value: true`, + incremented in `on_boot`. + - WiFi STA + open captive-portal AP `FanControl-Setup` via `captive_portal:`. + - mDNS `fancontrol.local`. + - ESPHome native API (`api:` with encryption key) as the default HA + transport. MQTT block left commented out for users who want the + v0.1.x topic shape. + - ESPHome `web_server` on port 80 for the built-in dashboard. + - OTA via `ota:` platform `esphome`, password-gated. + - `status_led` on GPIO 5 (LOLIN D32 onboard LED). + - `button.restart` and `button.factory_reset` entities. +- `secrets.yaml.example` — template for WiFi credentials, API encryption + key, and OTA password. Real `secrets.yaml` gitignored. +- `docs/home_assistant.md` — integration walkthrough, entity table, sample + automations, optional MQTT-with-TLS recipe. +- `.github/workflows/ci.yml` now uses `esphome/build-action@v10` — full + compile gate (not just config lint), size gate unchanged (≤ 1.5 MB). +- `.github/workflows/release.yml` publishes `firmware.bin`, + `firmware.ota.bin`, `firmware.factory.bin`, and their SHA-256 checksums. + +### Changed + +- **Home Assistant integration transport:** MQTT (with HA auto-discovery) + → ESPHome native API (encrypted, faster, zero-config on the HA side). + Users who want MQTT can uncomment the `mqtt:` block in `fancontrol.yaml`. +- **Web UI:** custom 4-section dark-themed single-page PROGMEM UI → + ESPHome's built-in `web_server` dashboard. The control surface for most + users becomes Home Assistant itself. +- **Documentation:** + - `README.md` rewritten for the ESPHome workflow. + - `CLAUDE.md` rewritten — architecture rules now about keeping the YAML + idiomatic rather than FreeRTOS task discipline. + - `CONTRIBUTING.md` rewritten — build loop is `esphome config/compile/run`; + release cut is merge `develop` → `main` + tag. + - `PROJECT_PLAN.md` rewritten as v2.0 with an ESPHome-shaped feature list + and a "History" section archiving the v0.1.x custom-C++ approach. + - `SECURITY.md` updated — new OTA + API auth model documented; v0.1.x RSA + signature verification recipe retained for historical artifacts. + - `docs/wiring_diagram.md` kept (pin map unchanged); references to + `src/config.h` updated to `fancontrol.yaml`. + - `docs/mqtt_topics.md` and `docs/api.md` deleted — MQTT is now optional + and documented inline in the YAML; no custom HTTP / WebSocket API + exists under ESPHome. + +### Removed + +- **Entire custom C++ tree** under `src/` (config, storage, sensor, fan, + fan_curve, watchdog, wifi_manager, mqtt, webserver, websocket, + index_html, version, main). ~2,000 LOC deleted. +- **Unity test scaffold** under `test/` — ESPHome has no comparable unit + test concept; the fan-curve lambda was ported verbatim from the Unity- + tested `fan_curve::computeFromTemperature`, so the logic is unchanged. +- `platformio.ini` — ESPHome handles the PlatformIO build transparently. +- **RSA-signed OTA pipeline.** v0.2.0+ releases ship `firmware.bin` + + SHA-256 only. The `SECRET_RSA_KEY` repo secret remains configured but + unused; `docs/signing_public_key.pem` is kept so v0.1.x signed + binaries remain verifiable. See `SECURITY.md`. + +### Migration notes + +- **Reflash required.** Pre-v0.2.0 devices store their NVS keys under the + custom `fancontrol` namespace; ESPHome uses its own global-variables + namespace layout. Flashing v0.2.0 over v0.1.x will not carry user + settings forward — reconfigure via the captive portal on first boot. +- **Home Assistant:** remove the MQTT-based device (if you want). Install + the ESPHome integration if you haven't; paste the API key from + `secrets.yaml` when prompted. +- **OTA password** in `secrets.yaml` is the one you chose in v0.2.0; + there is no longer a "changeme" default + first-boot forced-rotation + banner (ESPHome bakes the password at compile time). ## [0.1.1] - 2026-04-19 @@ -124,6 +211,7 @@ SUCCESS, `pio test -e native` 7/7 PASSED. - `pio run -e lolin_d32` → SUCCESS, RAM 15.0 %, flash 54.5 %. - `pio test -e native` → 7 / 7 PASSED. -[Unreleased]: https://github.com/basmeerman/FanControl/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/basmeerman/FanControl/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/basmeerman/FanControl/releases/tag/v0.2.0 [0.1.1]: https://github.com/basmeerman/FanControl/releases/tag/v0.1.1 [0.1.0]: https://github.com/basmeerman/FanControl/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 115a537..f9be632 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,76 +2,98 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Repository status +## Repository shape -This directory currently contains **only the project plan** — `PROJECT_PLAN.md`. No source code, `platformio.ini`, or git history exists yet. The first implementation sessions will scaffold a PlatformIO project from scratch following that plan. +FanControl is an **ESPHome** project. The firmware is defined by a single YAML +file, [`fancontrol.yaml`](fancontrol.yaml), which ESPHome compiles to an ESP32 +binary. There is no custom C++ source tree: the old PlatformIO + Arduino +architecture was retired in v0.2.0 (see [`PROJECT_PLAN.md`](PROJECT_PLAN.md) +"History" for why, and the [v0.1.x tags](https://github.com/basmeerman/FanControl/tags) +if you need to reference the old code). -`PROJECT_PLAN.md` is the **single source of truth** (v1.2). Read it before making structural decisions — especially sections 3 (project structure), 5 (WebSocket protocol), 6 (MQTT topics), and 7.3 (code quality rules). +Single source of truth: **`fancontrol.yaml`**. Everything runtime-visible +(entities, thresholds, fan curve, PWM frequency, alarms, captive portal, OTA, +HA integration) is declared there. -## Project at a glance +## Hardware -ESP32 firmware for a home battery room ventilation controller: -- **Hardware:** LOLIN D32 ESP32 + DHT22 + Ruck EM 125L EC 02 fan (PWM via level shifter + MT3608 → 9V) -- **Platform:** PlatformIO, Arduino framework, board `lolin_d32`, partitions `min_spiffs.csv` -- **Integrates with:** Home Assistant via MQTT auto-discovery; embedded single-page web UI with WebSocket live push -- **Reference design:** SmartEVSE-3.5 (basmeerman/dingo35) — webserver sections, release flow, and CI structure mirror that project +- **Board:** LOLIN D32 (ESP32-WROOM-32) +- **Sensor:** DHT22 with onboard 10 kΩ pull-up +- **Fan:** Ruck EM 125L EC 02 — PWM input 5–10 V, 1–5 kHz (datasheet) +- **PWM path:** ESP32 GPIO 25 → TXS0108E / 74HCT125 level shifter → MT3608 boost → 9 V PWM line -## Commands (once the PlatformIO project exists) +Pin map, locked in `fancontrol.yaml`: -```bash -pio run # build firmware for lolin_d32 -pio run -t upload # OTA upload (espota → accuruimte.local, auth in platformio.ini) -pio run -t uploadfs # if SPIFFS/LittleFS is added later -pio test -e native # run Unity unit tests on host -pio test -e native -f test_sensor # run a single test folder -pio device monitor -b 115200 # serial monitor -``` - -CI runs `pio run` + `pio test -e native` on every push/PR to `main`/`develop`. A tag push `v*.*.*` to `main` triggers `release.yml`, which injects the tag into `src/version.h` via `sed` and attaches `firmware.bin` + SHA256 to a GitHub Release. - -## Architecture constraints (non-obvious, enforce these) - -These come from plan §7.3 and the agent briefs — violating them breaks the Q1–Q5 quality gates: +| GPIO | Role | Notes | +|---|---|---| +| 4 | DHT22 data | module has its own pull-up; firmware treats pin as plain input | +| 25 | Fan PWM (LEDC ch0) | default 1 kHz, 10-bit auto | +| 5 | Status LED | onboard LOLIN D32 LED, active LOW | +| 0 | Boot button | reserved for factory reset long-press (not yet wired in YAML) | -- **No `delay()` anywhere.** Use `millis()` or `vTaskDelay()`. No blocking calls >10ms inside `loop()`. -- **WebSocket and MQTT run in separate FreeRTOS tasks**, not on the main loop. Do not merge them. -- **`config.h` is the only place for defaults.** No other globals outside it. -- **Every NVS write must check its return value and log the result.** NVS is accessed via a `Preferences.h` wrapper in `storage.cpp` — don't call `Preferences` directly from other modules. -- **Fail-safe: on sensor failure or watchdog timeout the fan goes to 100%**, not 0%. Minimum fan speed under normal operation is configurable (default 10%) — never 0. -- **Watchdog:** ESP32 hardware TWDT via `esp_task_wdt` + software watchdog that detects sensor stalls within 60s. Auto-restart is rate-limited to 1× per 5 minutes via an NVS cooldown timestamp; the restart counter is persistent. -- **Webserver is a single `index.html` embedded as a PROGMEM string** in `index_html.h`. No external CDN deps, vanilla JS/CSS only, dark theme, accordion sections. The four sections (Status / Ventilation / Network+MQTT / System) are fixed by the plan. -- **WebSocket push cadence is 2s** (ESP32 → browser, JSON shape in plan §5). Browser → ESP32 uses `{"type": "set_config", ...}` — do not invent new message types without updating the plan. -- **Fan PWM frequency is runtime-tunable** (NVS-backed, 1000–5000 Hz, default 1 kHz per Ruck datasheet). Use `ledcSetup()` / `ledcChangeFrequency()` to apply changes without reboot. Always clamp to spec range in firmware as a second line of defense. -- **Release signing matches SmartEVSE-3.5 exactly:** `openssl dgst -sign -keyform PEM -sha256` against firmware.bin, signature *prepended* to bin → `firmware.signed.bin`. Repo secret `SECRET_RSA_KEY` holds the PEM private key. Step skips silently when the secret is missing so forks build cleanly. -- **MQTT:** birth (`online`) + last will (`offline`, `retain=true`) on `{prefix}/status`. HA discovery under `homeassistant/` prefix. Reconnect uses exponential backoff and must never block the main loop. All topics live under a user-configurable prefix stored in NVS. -- **No circular dependencies between modules.** Every module must be testable in isolation (this is why `test/` has per-module subfolders). +## Commands -## Planned module layout (plan §3) - -``` -src/ - main.cpp # setup + FreeRTOS task orchestration only - config.h # all defaults, single source of truth - version.h # VERSION macro — CI rewrites this from git tag - sensor.{h,cpp} # DHT22 read + calibration filter - fan.{h,cpp} # LEDC PWM, speed calc from temperature curve - watchdog.{h,cpp} # HW TWDT + SW sensor-stall watchdog - mqtt.{h,cpp} # PubSubClient, HA discovery, birth/will - webserver.{h,cpp} # AsyncWebServer setup - websocket.{h,cpp} # AsyncWebSocket JSON push - storage.{h,cpp} # Preferences wrapper (NVS) - wifi_manager.{h,cpp} # WiFi + captive portal + mDNS accuruimte.local - index_html.h # embedded HTML/CSS/JS (PROGMEM) +```bash +pipx install esphome # one time +cp secrets.yaml.example secrets.yaml # fill in real values +esphome config fancontrol.yaml # lint only +esphome compile fancontrol.yaml # full build (generates firmware.{bin,ota.bin,factory.bin} under .esphome/build/) +esphome run fancontrol.yaml # compile + flash via USB (first boot) or OTA (afterwards) +esphome logs fancontrol.yaml # stream runtime logs from the device ``` -## Agent roles referenced by the plan - -Plan §8 defines six specialist personas (`firmware-engineer`, `webserver-engineer`, `mqtt-engineer`, `qa-engineer`, `architect`, `devops-engineer`) with scoped ownership and prompt hints. When a user says "act as the X agent", use §8 as the brief — each role has explicit file ownership and quality gates. +CI runs `esphome config` + full compile on every push/PR to `main` / `develop` +and uploads the built binaries as artifacts. A tag push `v*.*.*` to `main` +fires `release.yml`, which compiles a release build and publishes a GitHub +Release with `firmware.bin`, `firmware.ota.bin`, `firmware.factory.bin`, and +their SHA-256 checksums. + +**Release signing:** v0.2.0 onward has **no RSA signing** — ESPHome OTA uses +password + checksum. Pre-v0.2.0 releases (v0.1.0, v0.1.1) are still RSA-signed +and verifiable against `docs/signing_public_key.pem`; see [`SECURITY.md`](SECURITY.md). + +## Architecture constraints (enforce these) + +Because the project lives in one YAML file, "architecture" is mostly about +**keeping the YAML idiomatic ESPHome** rather than growing a parallel +custom-code layer. Concretely: + +- **Prefer built-in components over lambdas.** `sensor.template` with filters, + `binary_sensor.template`, `number.template`, `output.ledc`, `status_led`, + `button.factory_reset` etc. exist for a reason. Reach for a lambda only when + no component fits (e.g. the piecewise-linear fan curve). +- **Lambdas stay short.** The fan-curve interpolation is the only non-trivial + piece of C++ in the repo. If a lambda grows past ~30 lines, split it into a + reusable `lambda`-scoped helper via [external components](https://esphome.io/components/external_components.html) + rather than inflating a sensor block. +- **Every runtime-tunable setting is a `number.template` with `restore_value: true`.** + This is the NVS-persisted-defaults pattern. Don't invent alternatives. +- **Safety behaviour must be encoded in the YAML, not assumed.** The + sensor-stall failsafe (fan to 100 % after 60 s of no reads) is implemented + inside the `Fan Speed` template sensor lambda. If you refactor, preserve it. +- **No MQTT-specific code unless the user explicitly wants MQTT.** The default + HA integration is ESPHome's `api:` (native, encrypted). An `mqtt:` block is + commented out in the YAML and may be enabled, but don't add MQTT as a + parallel publishing path. +- **Secrets stay in `secrets.yaml` (gitignored).** Never hard-code WiFi, API + encryption keys, or OTA passwords into `fancontrol.yaml`. +- **All UI labels in English.** (Inherited from the v0.1.x language decision.) +- **Frequency clamp for the fan PWM:** 1000–5000 Hz, matching the Ruck + datasheet. The `number` component enforces this. +- **Boot count must always increment on every boot.** That's the signal HA + uses to detect unexpected restarts; if you remove or rearrange the + `on_boot: globals.set` block, add back an equivalent. ## Language note -The plan is in Dutch. **All code, log output, web UI labels, MQTT labels, HA entity names, commit messages, and CHANGELOG entries are English** (v1.2 decision). The plan stays Dutch as a reference document; everything user-facing or runtime is English. +The old PROJECT_PLAN.md was in Dutch; v0.2.0 rewrote it in English to match +the YAML comments and entity labels. All new documentation, commit messages, +and CHANGELOG entries should be in English. -## Test matrix +## When touching `fancontrol.yaml` -Plan §7.2 defines T01–T12. When implementing a feature, check which T-tests cover it and note them in the PR. T12 (72-hour soak) gates the Q3 quality level. +1. `esphome config fancontrol.yaml` must pass. +2. `esphome compile fancontrol.yaml` must succeed. Lambdas only fail at compile + time (not config time), so compile is non-optional before committing. +3. Update `CHANGELOG.md` under `## [Unreleased]`. +4. Open a PR against `main` (branch-protected; direct pushes blocked). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0cf382..5691240 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,89 +1,94 @@ # Contributing to FanControl -Thanks for your interest in improving FanControl. This doc covers the local -build, the branching model, how release signing works, and the CHANGELOG rule. +FanControl is an ESPHome project — the firmware is a single YAML file +([`fancontrol.yaml`](fancontrol.yaml)) compiled by ESPHome. If you want the +short version: get ESPHome installed, make changes, `esphome compile +fancontrol.yaml`, open a PR. -`PROJECT_PLAN.md` is the single source of truth for features, architecture, and -quality gates — read it before opening a non-trivial PR. `CLAUDE.md` summarises -the hard architectural rules (no `delay()`, NVS return-check, fan fail-safe, -separate FreeRTOS tasks, etc.). +## Local setup -## Building locally +```bash +pipx install esphome +cp secrets.yaml.example secrets.yaml +# Edit secrets.yaml — WiFi creds, API encryption key, OTA password. +# Generate an API key with: openssl rand -base64 32 +``` -You need [PlatformIO Core](https://platformio.org/install/cli) (Python 3.11+). +`secrets.yaml` is gitignored. Never commit real credentials. + +## Development loop ```bash -pio run -e lolin_d32 # build firmware for the LOLIN D32 -pio test -e native # run Unity host tests -pio test -e native -f test_sensor # run a single test folder -pio device monitor -b 115200 # serial monitor at 115200 baud -pio run -t upload # OTA upload (espota → fancontrol.local) +esphome config fancontrol.yaml # lint only (seconds) +esphome compile fancontrol.yaml # full build (tens of seconds) +esphome run fancontrol.yaml # compile + flash (USB or OTA) +esphome logs fancontrol.yaml # stream runtime logs ``` -CI runs `pio run` and `pio test -e native` on every push and PR to `main` and -`develop`. The firmware must stay under 1.5 MB — the CI job hard-fails otherwise. +The important gate is **compile**, not config — ESPHome only type-checks +lambdas at compile time. ## Branching model ``` -main stable releases, each with a tag + GitHub Release - | - +-- develop integration branch, CI must be green - | - +-- feature/ work here, PR into develop - +-- fix/ bug fixes, PR into develop +main stable releases, each tagged vX.Y.Z + └─ develop integration branch, CI must be green + ├─ feature/ + └─ fix/ ``` -- Feature branches come off `develop` and PR back into `develop`. -- A release is cut by merging `develop` into `main` (`--no-ff`), then pushing a - `vX.Y.Z` tag against `main`. The tag push triggers `release.yml`, which - injects the version into `src/version.h`, builds, signs, and publishes. -- `main` is never a direct work branch. +`main` is protected: PRs required, CI green before merge. `develop` requires CI +green. Solo-maintainer mode has `required_approving_review_count: 0` so you +can self-merge. + +Release flow: -Versioning follows [SemVer](https://semver.org/). Breaking MQTT topic or web API -changes bump MAJOR; new backwards-compatible features bump MINOR; bugfixes bump -PATCH. Pre-releases use suffixes like `v1.1.0-beta.1` and are auto-marked as -prereleases by the workflow (any tag containing `-`). +```bash +git checkout main +git merge develop --no-ff -m "Release v0.X.Y" +git tag vX.Y.Z +git push origin main --tags +# `release.yml` fires automatically on the tag push, compiles, and publishes +# firmware.{bin,ota.bin,factory.bin} + SHA-256 checksums to GitHub Releases. +``` -## Release signing (RSA + openssl, SmartEVSE-3.5 compatible) +## CHANGELOG rule -Signed release binaries allow OTA clients and users to verify authenticity. The -scheme matches SmartEVSE-3.5 exactly: a 2048-bit RSA key signs a SHA-256 digest -of `firmware.bin`, and the 256-byte signature is **prepended** to the firmware -to produce `firmware.signed.bin`. +Every PR updates `CHANGELOG.md` under `## [Unreleased]` in +[Keep-a-Changelog](https://keepachangelog.com/en/1.1.0/) format. At release +time, the maintainer renames `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` +and adds a fresh empty `## [Unreleased]` on top. The release workflow +extracts the matching section with `awk` and uses it as the GitHub Release +body — keep headings exact. -Generating the keypair (do this once, keep the private key OUT of the repo): +## Release verification + +v0.2.0+ releases are **not RSA-signed**. Verify with the published SHA-256: ```bash -# Generate signing keypair (one time, keep private key OUT of the repo) -openssl genrsa -out fancontrol_signing_key.pem 2048 -openssl rsa -in fancontrol_signing_key.pem -pubout -out fancontrol_signing_key.pub.pem - -# Add the private key to GitHub secrets as SECRET_RSA_KEY -gh secret set SECRET_RSA_KEY < fancontrol_signing_key.pem - -# Verifying a release artifact locally: -# 1. Split signature off the signed binary (signature is first 256 bytes for 2048-bit RSA) -dd if=FanControl-vX.Y.Z.signed.bin of=firmware.sign bs=1 count=256 -dd if=FanControl-vX.Y.Z.signed.bin of=firmware.bin bs=1 skip=256 -openssl dgst -verify fancontrol_signing_key.pub.pem -keyform PEM -sha256 \ - -signature firmware.sign firmware.bin -# → "Verified OK" +sha256sum -c FanControl-vX.Y.Z.bin.sha256 +# → FanControl-vX.Y.Z.bin: OK ``` -If `SECRET_RSA_KEY` is not set (for example, on a fork), the signing step skips -silently and the release simply omits `.signed.bin`. `.bin` and `.bin.sha256` -are always published. +Pre-v0.2.0 releases (v0.1.0, v0.1.1) were RSA-signed under the old custom +C++ architecture and still verifiable against +[`docs/signing_public_key.pem`](docs/signing_public_key.pem) — see +[`SECURITY.md`](SECURITY.md). + +## Architecture rules for PRs -## CHANGELOG +Summarised here; full list in [`CLAUDE.md`](CLAUDE.md): -Every PR must update `CHANGELOG.md` under the `## [Unreleased]` section. -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +- Prefer built-in ESPHome components over lambdas. +- Lambdas stay short; the fan-curve interpolation is the exception. +- Every tunable is a `number.template` with `restore_value: true`. +- Safety behaviour (sensor stall → fan 100 %) must be present somewhere in the + YAML, even if you refactor its shape. +- No hard-coded credentials. Ever. -When releasing, the maintainer: -1. Renames `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`. -2. Adds a fresh empty `## [Unreleased]` on top. -3. Merges to `main` and pushes the `vX.Y.Z` tag. +## Reporting issues -The release workflow extracts the matching section with `awk` and uses it as -the GitHub Release body — so keep headings clean and the version format exact. +Open a GitHub issue with the appropriate label (`bug`, `enhancement`, +`safety`, `docs`). For security-sensitive reports, use a +[private security advisory](https://github.com/basmeerman/FanControl/security/advisories/new) +instead. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 6d2c1d5..219d472 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1,817 +1,198 @@ -# FanControl — Project Plan v1.2 - -> **Status:** Klaar voor uitvoering in Claude Code / CLI -> **Gebaseerd op:** SmartEVSE-3.5 architectuur (basmeerman/dingo35) -> **Hardware:** LOLIN D32 ESP32 · DHT22 · Ruck EM 125L EC 02 (PWM) -> **Platform:** PlatformIO · Arduino Framework -> **Repository:** GitHub — nieuw aan te maken, volledig CI/CD ingericht - ---- - -## 1. Projectoverzicht - -Een zelfstandige ventilatie controller voor een thuisaccu ruimte (Victron Multiplus / JK BMS / 6x 48V LFP), gebouwd op een ESP32 LOLIN D32. De controller regelt een Ruck EM 125L EC 02 buisventilator via PWM op basis van temperatuur, communiceert via MQTT met Home Assistant (auto-discovery), en biedt een embedded webserver met live data via WebSockets — ontwerp geïnspireerd op SmartEVSE-3.5. - ---- - -## 2. Featurelijst (definitief) - -### F1 — Kern ventilatiefunctie -- F1.1 PWM ventilator sturing (default 1 kHz, runtime instelbaar 1–5 kHz via web UI, 0–100%, via level shifter + MT3608 naar 9V). Datasheet Ruck EM 125L EC 02: PWM 5–10 V, 1–5 kHz. -- F1.2 Temperatuurgestuurde regeling via DHT22 (lineaire interpolatie) -- F1.3 Luchtvochtigheidsmonitoring -- F1.4 Instelbare temperatuurdrempels (via webinterface, opgeslagen in NVS) -- F1.5 Minimale basisventilatie (nooit 0% onder normaal bedrijf, standaard 10%) -- F1.6 Fail-safe: ventilator naar 100% bij sensorstoring of watchdog timeout - -### F2 — Veiligheid & Watchdog -- F2.1 ESP32 hardware TWDT (Task Watchdog Timer) via `esp_task_wdt` -- F2.2 Software watchdog: sensorstilstand detectie binnen 60 seconden -- F2.3 Automatische herstart max 1x per 5 minuten (cooldown in NVS) -- F2.4 Herstartteller persistent in NVS -- F2.5 Instelbare alarmdrempel temperatuur (standaard 35°C) -- F2.6 MQTT last will message bij onverwacht verbindingsverlies -- F2.7 Alle alarmen zichtbaar op webinterface én via MQTT - -### F3 — Webserver (single page, secties — SmartEVSE stijl) -**Architectuur:** Eén `index.html` embedded als PROGMEM string, AsyncWebServer + AsyncWebSocket - -**Sectie 1 — Status (live via WebSocket)** -- F3.1 Temperatuur (°C) realtime -- F3.2 Luchtvochtigheid (%) realtime -- F3.3 Ventilatorsnelheid (%) realtime + visuele indicator/balk -- F3.4 MQTT verbindingsstatus met broker IP -- F3.5 WiFi SSID, RSSI signaalsterkte, IP adres -- F3.6 Uptime (dd:hh:mm:ss) -- F3.7 Alarmstatus (temperatuur / sensor / watchdog) — kleurgecodeerd -- F3.8 **WebSocket live push** vanuit ESP32 elke 2 seconden (geen polling) -- F3.9 Watchdog herstartteller - -**Sectie 2 — Ventilatie instellingen (opvouwbaar, SmartEVSE stijl)** -- F3.10 Temperatuurdrempels instellen (T1..T4 met bijbehorend PWM%) + PWM frequentie (1000–5000 Hz, stap 100 Hz) -- F3.11 Alarmtemperatuur instellen -- F3.12 Minimale ventilatorsnelheid instellen -- F3.13 Sensoruitleesinterval instellen -- F3.14 Opslaan knop → opgeslagen in NVS - -**Sectie 3 — Netwerk & MQTT instellingen (opvouwbaar)** -- F3.15 WiFi SSID + wachtwoord -- F3.16 MQTT broker IP + poort -- F3.17 MQTT gebruikersnaam + wachtwoord -- F3.18 MQTT topic prefix -- F3.19 Apparaatnaam (voor MQTT discovery label) -- F3.20 Opslaan knop → opgeslagen in NVS, herverbinden - -**Sectie 4 — Systeem (opvouwbaar)** -- F3.21 Firmware versie + build datum -- F3.22 Vrij heap geheugen -- F3.23 OTA firmware update (ElegantOTA, wachtwoord beveiligd) -- F3.24 Herstart knop (met bevestigingsdialoog) -- F3.25 Factory reset knop (met bevestigingsdialoog) -- F3.26 Debug log (laatste 20 regels, realtime via WebSocket) - -**Ontwerp:** -- Responsief, mobiel-vriendelijk (min 380px) -- Donker thema, kleurindicatoren voor alarmstatus -- Secties opvouwbaar (accordion stijl, zoals SmartEVSE) -- Geen externe CDN afhankelijkheden (alles embedded) -- Vanilla JS + CSS (geen React/Vue, klein footprint) - -### F4 — MQTT -- F4.1 Home Assistant auto-discovery (`homeassistant/sensor/.../config`) -- F4.2 Temperatuur publicatie elke 15s (instelbaar) -- F4.3 Luchtvochtigheid publicatie -- F4.4 Ventilatorsnelheid publicatie bij wijziging -- F4.5 Alarmstatus publicatie bij event -- F4.6 Watchdog status + herstartteller -- F4.7 Online/offline (birth/will, retain=true) -- F4.8 Alle topics onder instelbare prefix - -### F5 — WiFi & Netwerk -- F5.1 Verbinding via opgeslagen credentials (NVS) -- F5.2 Captive portal AP mode als geen credentials beschikbaar -- F5.3 Automatisch herverbinden bij WiFi verlies -- F5.4 mDNS hostname (bijv. `fancontrol.local`) -- F5.5 Statisch IP optioneel instelbaar - -### F6 — OTA Updates -- F6.1 OTA via ElegantOTA webinterface (wachtwoord beveiligd) -- F6.2 OTA via PlatformIO `espota` voor CLI gebruik -- F6.3 Automatische herstart na succesvolle update -- F6.4 Fallback naar oude firmware bij mislukte flash - -### F7 — Opslag (NVS) -- F7.1 WiFi credentials -- F7.2 MQTT configuratie -- F7.3 Temperatuurdrempels en ventilatie-instellingen -- F7.4 Herstartteller en cooldown timestamp -- F7.5 Apparaatnaam -- F7.6 Factory reset via webinterface of fysieke GPIO pin (boot button) - ---- - -## 3. Projectstructuur (PlatformIO) - -``` -FanControl/ -├── platformio.ini -├── README.md -├── CHANGELOG.md -├── docs/ -│ ├── wiring_diagram.md -│ ├── mqtt_topics.md -│ └── api.md -├── test/ -│ ├── test_sensor/ -│ ├── test_mqtt/ -│ └── test_watchdog/ -└── src/ - ├── main.cpp # Setup, loop, task orchestratie - ├── version.h # Versienummer + build datum - ├── config.h # Constanten, default waarden - ├── sensor.h / .cpp # DHT22 uitlezing, kalibratiefilter - ├── fan.h / .cpp # PWM sturing, snelheidsberekening - ├── watchdog.h / .cpp # HW + SW watchdog logica - ├── mqtt.h / .cpp # MQTT client, discovery, publicatie - ├── webserver.h / .cpp # AsyncWebServer + WebSocket handler - ├── websocket.h / .cpp # WebSocket JSON push logica - ├── storage.h / .cpp # NVS lees/schrijf wrapper - ├── wifi_manager.h / .cpp # WiFi + captive portal + mDNS - └── index_html.h # Embedded HTML/CSS/JS als PROGMEM -``` - ---- - -## 4. platformio.ini - -```ini -[env:lolin_d32] -platform = espressif32 -board = lolin_d32 -framework = arduino -monitor_speed = 115200 -board_build.partitions = min_spiffs.csv - -; OTA via CLI -upload_protocol = espota -upload_port = fancontrol.local -upload_flags = --auth=changeme - -lib_deps = - knolleary/PubSubClient@^2.8 - adafruit/DHT sensor library@^1.4.6 - adafruit/Adafruit Unified Sensor@^1.1.14 - esp32async/ESPAsyncWebServer@^3.7.0 ; v1.2: maintained fork (me-no-dev/* removed from registry) - esp32async/AsyncTCP@^3.4.0 - ayushsharma82/ElegantOTA@^3.1.0 - bblanchon/ArduinoJson@^7.0.0 - -build_flags = - -DCORE_DEBUG_LEVEL=0 - -DVERSION='"1.0.0"' -``` - ---- - -## 5. WebSocket Protocol (ESP32 → Browser) - -JSON push elke 2 seconden: - -```json -{ - "type": "status", - "temperature": 24.3, - "humidity": 58.2, - "fan_speed": 45, - "fan_mode": "auto", - "alarm": { - "temperature": false, - "sensor": false, - "watchdog": false - }, - "mqtt": { - "connected": true, - "broker": "192.168.1.10" - }, - "wifi": { - "ssid": "MyNetwork", - "rssi": -62, - "ip": "192.168.1.50" - }, - "system": { - "uptime": 86400, - "heap_free": 142320, - "restarts": 2, - "version": "1.0.0" - } -} -``` - -Browser → ESP32 (instellingen opslaan): -```json -{ - "type": "set_config", - "thresholds": [15, 20, 25, 30, 35], - "pwm_levels": [10, 25, 50, 75, 100], - "alarm_temp": 35, - "min_fan": 10 -} -``` - ---- - -## 6. MQTT Topics - -| Topic | Richting | Inhoud | -|---|---|---| -| `{prefix}/sensor/temperature/state` | → HA | `24.3` | -| `{prefix}/sensor/humidity/state` | → HA | `58.2` | -| `{prefix}/sensor/fan_speed/state` | → HA | `45` | -| `{prefix}/binary_sensor/alarm_temp/state` | → HA | `ON`/`OFF` | -| `{prefix}/binary_sensor/alarm_sensor/state` | → HA | `ON`/`OFF` | -| `{prefix}/binary_sensor/watchdog/state` | → HA | `ON`/`OFF` | -| `{prefix}/sensor/restarts/state` | → HA | `2` | -| `{prefix}/status` | → HA | `online`/`offline` | -| `homeassistant/sensor/{id}/config` | → HA | discovery JSON | - ---- - -## 7. Kwaliteitsstructuur - -### 7.1 Kwaliteitsniveaus - -| Niveau | Omschrijving | Toets | -|---|---|---| -| **Q1 — Compileerbaar** | Code compileert zonder warnings | `pio run` | -| **Q2 — Functioneel** | Alle features werken op hardware | Handmatige test checklist | -| **Q3 — Stabiel** | 72 uur onafgebroken runtime zonder herstart | Soak test | -| **Q4 — Veilig** | Fail-safe werkt bij elke faalscenario | Injectietestruns | -| **Q5 — Gedocumenteerd** | Alle publieke functies gedocumenteerd | Code review | - -### 7.2 Testmatrix - -| Test | Beschrijving | Verwacht resultaat | -|---|---|---| -| T01 | DHT22 losgekoppeld tijdens bedrijf | Ventilator → 100%, alarm MQTT | -| T02 | WiFi AP uitgeschakeld | Auto-herverbinding binnen 30s | -| T03 | MQTT broker offline | Ventilatie blijft werken, alarm UI | -| T04 | Watchdog trigger (60s geen data) | Software reboot, MQTT offline | -| T05 | 5 reboots binnen 5 minuten | Cooldown actief, HA notificatie | -| T06 | OTA update via webinterface | Nieuwe firmware actief na herstart | -| T07 | OTA update via CLI (`pio run -t upload`) | Identiek aan T06 | -| T08 | WebSocket verbinding browser | Live updates elke 2s zichtbaar | -| T09 | Instellingen opslaan + herstart | Instellingen behouden in NVS | -| T10 | Factory reset | Alle NVS gewist, captive portal | -| T11 | Temperatuur boven 35°C | Ventilator 100%, alarm UI + MQTT | -| T12 | 72 uur soak test | Geen crashes, memory stabiel | - -### 7.3 Code kwaliteitsregels - -- Geen globale variabelen buiten `config.h` defaults -- Elke module heeft eigen header met doxygen commentaar -- Geen blocking calls in loop() langer dan 10ms -- WebSocket en MQTT in aparte FreeRTOS tasks -- Alle NVS writes gelogd met result check -- Geen `delay()` — gebruik `millis()` of `vTaskDelay()` - ---- - -## 8. Claude Agent Team Definitie - -Geïnspireerd op de SmartEVSE-3.5 multi-contributor aanpak waarbij verschillende experts elk een domein bezitten. Voor dit project worden de volgende Claude agent rollen gedefinieerd voor gebruik in Claude Code CLI: - ---- - -### Agent 1 — `firmware-engineer` -**Domein:** C++ firmware, PlatformIO, ESP32 hardware -**Skills:** ESP32 TWDT, LEDC PWM, FreeRTOS tasks, NVS, ArduinoJson -**Verantwoordelijk voor:** `main.cpp`, `fan.cpp`, `watchdog.cpp`, `sensor.cpp`, `storage.cpp` -**Kwaliteitseis:** Q1 + Q2 + Q4 -**Prompt hint voor CLI:** -``` -You are the firmware-engineer agent. Focus only on C++ firmware for ESP32/PlatformIO. -Ensure: no blocking calls >10ms in loop(), hardware watchdog via esp_task_wdt, -PWM via LEDC API, DHT22 via Adafruit library, NVS via Preferences.h. -Always check return values of NVS operations. -``` - ---- - -### Agent 2 — `webserver-engineer` -**Domein:** AsyncWebServer, WebSockets, embedded HTML -**Skills:** ESPAsyncWebServer, AsyncWebSocket, PROGMEM strings, JSON -**Verantwoordelijk voor:** `webserver.cpp`, `websocket.cpp`, `index_html.h` -**Kwaliteitseis:** Q1 + Q2 + Q5 -**Prompt hint voor CLI:** -``` -You are the webserver-engineer agent. Build a single-page embedded webserver -inspired by SmartEVSE-3.5 (basmeerman/dingo35). Single index.html in PROGMEM. -Sections: Status (live WebSocket), Ventilation Settings, Network/MQTT Settings, -System/OTA. Dark theme, accordion sections, vanilla JS only, no CDN dependencies. -WebSocket pushes JSON status every 2 seconds from ESP32 to browser. -``` - ---- - -### Agent 3 — `mqtt-engineer` -**Domein:** MQTT protocol, Home Assistant integratie -**Skills:** PubSubClient, HA MQTT discovery, retain, QoS, birth/will -**Verantwoordelijk voor:** `mqtt.cpp`, `docs/mqtt_topics.md` -**Kwaliteitseis:** Q1 + Q2 + Q5 -**Prompt hint voor CLI:** -``` -You are the mqtt-engineer agent. Implement MQTT using PubSubClient for ESP32. -Use Home Assistant MQTT auto-discovery format (homeassistant/ prefix). -Implement birth message (online), last will (offline, retain=true). -All topics configurable via NVS prefix. Reconnect logic with exponential backoff. -Never block loop() during reconnect. -``` - ---- - -### Agent 4 — `qa-engineer` -**Domein:** Testing, kwaliteitsbewaking, documentatie -**Skills:** Unity test framework (PlatformIO), checklist verificatie, soak testing -**Verantwoordelijk voor:** `test/`, `docs/`, `CHANGELOG.md`, testmatrix T01-T12 -**Kwaliteitseis:** Q1 t/m Q5 verificatie -**Prompt hint voor CLI:** -``` -You are the qa-engineer agent. Your job is to verify all features against the -feature list F1-F7 and run the test matrix T01-T12. Write Unity unit tests for -sensor.cpp, watchdog.cpp, storage.cpp. Create test injection scripts for -failure scenarios (sensor disconnect, WiFi loss, MQTT offline, watchdog trigger). -Document all results. Flag any feature deviating from spec. -``` - ---- - -### Agent 5 — `architect` (coördinator) -**Domein:** Projectcoördinatie, integratie, architectuurbewaking -**Skills:** Alle domeinen op hoog niveau, code review, merge beslissingen -**Verantwoordelijk voor:** `platformio.ini`, `config.h`, `version.h`, algehele architectuur -**Kwaliteitseis:** Q1 t/m Q5 — eindverantwoordelijk -**Prompt hint voor CLI:** -``` -You are the architect agent. You coordinate all other agents and own the overall -architecture. Enforce: no circular dependencies between modules, all modules -testable in isolation, WebSocket and MQTT in separate FreeRTOS tasks, -config.h as single source of truth for all defaults. Review all PRs/changes -from other agents before integration into main.cpp. -``` - ---- - -## 9. Uitvoeringsvolgorde (Fasen) - -### Fase 1 — Kern firmware (Agent: firmware-engineer + architect) -1. PlatformIO project initialiseren -2. `config.h` met alle defaults -3. `version.h` -4. `storage.cpp` — NVS wrapper -5. `sensor.cpp` — DHT22 uitlezing + filter -6. `fan.cpp` — PWM LEDC sturing -7. `watchdog.cpp` — HW + SW watchdog -8. `main.cpp` — integratie + FreeRTOS tasks -9. **QA:** Tests T01, T04, T05, T11 - -### Fase 2 — MQTT (Agent: mqtt-engineer + qa-engineer) -1. `mqtt.cpp` — verbinding + reconnect -2. HA auto-discovery topics -3. Alle publicatie functies -4. Birth/will implementatie -5. **QA:** Tests T02, T03 - -### Fase 3 — Webserver + WebSocket (Agent: webserver-engineer + architect) -1. `webserver.cpp` — AsyncWebServer setup -2. `websocket.cpp` — JSON push logica -3. `index_html.h` — embedded HTML, SmartEVSE stijl, 4 secties -4. OTA integratie (ElegantOTA) -5. **QA:** Tests T06, T07, T08, T09, T10 - -### Fase 4 — Integratie & soak test (Alle agents) -1. Volledige integratie test -2. 72 uur soak test -3. **QA:** Tests T12 + volledige T01-T11 rerun -4. Documentatie finaliseren -5. `CHANGELOG.md` bijwerken - ---- - -## 10. GitHub Repository Setup - -### 10.1 Nieuwe repository aanmaken - -```bash -# Via GitHub CLI (gh) — uitvoeren in Claude Code CLI -gh repo create FanControl \ - --public \ - --description "ESP32 ventilatie controller voor thuisaccu ruimte — Victron/JK BMS" \ - --license MIT \ - --clone - -cd FanControl -``` - -**Repository instellingen:** -- Branch protection op `main`: PR verplicht + CI groen vóór merge -- Branch protection op `develop`: CI groen verplicht -- Default branch: `main` -- Squash merge als standaard strategie -- Discussions en Issues ingeschakeld - -### 10.2 Branch strategie (SmartEVSE stijl) - -``` -main ← stabiele releases, altijd tag + GitHub Release - └─ develop ← integratie branch, CI verplicht - ├─ feature/webserver-websocket - ├─ feature/mqtt-autodiscovery - ├─ feature/watchdog - └─ fix/sensor-failsafe -``` - -Regels: -- `main` krijgt alleen merges van `develop` via PR -- `develop` krijgt merges van `feature/*` en `fix/*` -- Elke tag push naar `main` triggert automatisch een GitHub Release -- Versienummer in `src/version.h` wordt door CI geïnjecteerd vanuit de git tag - -### 10.3 GitHub Labels - -```bash -gh label create "bug" --color "d73a4a" --description "Iets werkt niet" -gh label create "enhancement" --color "a2eeef" --description "Nieuwe feature" -gh label create "firmware" --color "0075ca" --description "ESP32 C++ firmware" -gh label create "webserver" --color "e4e669" --description "Webserver / WebSocket" -gh label create "mqtt" --color "cfd3d7" --description "MQTT integratie" -gh label create "ci-cd" --color "6f42c1" --description "CI/CD pipeline" -gh label create "docs" --color "0052cc" --description "Documentatie" -gh label create "safety" --color "ee0701" --description "Veiligheid / watchdog" -gh label create "question" --color "d876e3" --description "Vraag of discussie" -``` - -### 10.4 Repository structuur (root niveau) - -``` -FanControl/ -├── .github/ -│ ├── workflows/ -│ │ ├── ci.yml # Build + test op elke push/PR -│ │ └── release.yml # Release op tag push naar main -│ ├── ISSUE_TEMPLATE/ -│ │ ├── bug_report.md -│ │ └── feature_request.md -│ ├── PULL_REQUEST_TEMPLATE.md -│ └── dependabot.yml -├── src/ # Firmware broncode -├── test/ # PlatformIO unit tests -├── docs/ # Documentatie -├── scripts/ # Hulpscripts -├── platformio.ini -├── README.md -├── CHANGELOG.md # Keep-a-changelog formaat -├── CONTRIBUTING.md -└── LICENSE # MIT -``` - ---- - -## 11. CI/CD Pipeline (GitHub Actions — SmartEVSE stijl) - -### 11.1 Workflow 1 — CI: Build & Test (`ci.yml`) - -**Trigger:** elke push en PR naar `main` en `develop` - -```yaml -name: PlatformIO CI - -on: - push: - branches: [main, develop] - pull_request: - branches: [main, develop] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/cache@v4 - with: - path: | - ~/.cache/pip - ~/.platformio/.cache - key: ${{ runner.os }}-pio-${{ hashFiles('platformio.ini') }} - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install PlatformIO - run: pip install --upgrade platformio - - - name: Build firmware - run: pio run - - - name: Run unit tests (native) - run: pio test -e native - - - name: Check binary size - run: | - SIZE=$(stat -c%s .pio/build/lolin_d32/firmware.bin) - echo "Firmware size: $SIZE bytes" - if [ $SIZE -gt 1500000 ]; then - echo "ERROR: firmware te groot (>1.5MB)" - exit 1 - fi - - - name: Upload firmware artifact - uses: actions/upload-artifact@v4 - with: - name: firmware-${{ github.sha }} - path: .pio/build/lolin_d32/firmware.bin - retention-days: 30 -``` - -### 11.2 Workflow 2 — Release (`release.yml`) - -**Trigger:** push van tag `v*.*.*` naar `main` — exact zoals SmartEVSE `Create Release` - -```yaml -name: Create Release - -on: - push: - tags: - - 'v*.*.*' - -jobs: - build-and-release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/cache@v4 - with: - path: | - ~/.cache/pip - ~/.platformio/.cache - key: ${{ runner.os }}-pio-${{ hashFiles('platformio.ini') }} - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install PlatformIO - run: pip install --upgrade platformio - - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Inject version into firmware - run: | - sed -i 's/#define VERSION ".*"/#define VERSION "${{ steps.version.outputs.VERSION }}"/' \ - src/version.h - - - name: Build release firmware - run: pio run - - - name: Rename binary - run: | - cp .pio/build/lolin_d32/firmware.bin \ - FanControl-${{ steps.version.outputs.VERSION }}.bin - - - name: SHA256 checksum - run: | - sha256sum FanControl-${{ steps.version.outputs.VERSION }}.bin \ - > FanControl-${{ steps.version.outputs.VERSION }}.bin.sha256 - - # v1.2: signing matches SmartEVSE-3.5/pio-build.yaml exactly. - # Repo secret SECRET_RSA_KEY holds the PEM-encoded RSA private key. - # If the secret is missing the step skips silently (so forks build cleanly). - - name: Sign firmware - env: - super_secret: ${{ secrets.SECRET_RSA_KEY }} - run: | - if [ -z "$super_secret" ]; then - echo "Signing key not set, skipping firmware signing" - exit 0 - fi - secret_file=$(mktemp) - echo "$super_secret" > "$secret_file" - openssl dgst -sign "$secret_file" -keyform PEM -sha256 \ - -out firmware.sign -binary \ - FanControl-${{ steps.version.outputs.VERSION }}.bin - cat firmware.sign FanControl-${{ steps.version.outputs.VERSION }}.bin \ - > FanControl-${{ steps.version.outputs.VERSION }}.signed.bin - rm -f "$secret_file" firmware.sign - - - name: Extract changelog voor deze release - run: | - VERSION=${{ steps.version.outputs.VERSION }} - awk "/^## \[$VERSION\]/{flag=1; next} /^## \[/{flag=0} flag" \ - CHANGELOG.md > release_notes.md - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: "FanControl ${{ steps.version.outputs.VERSION }}" - body_path: release_notes.md - draft: false - prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }} - files: | - FanControl-${{ steps.version.outputs.VERSION }}.bin - FanControl-${{ steps.version.outputs.VERSION }}.bin.sha256 - FanControl-${{ steps.version.outputs.VERSION }}.signed.bin - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` - -### 11.3 Versioning strategie (SemVer) - -| Increment | Wanneer | Voorbeeld | -|---|---|---| -| **MAJOR** (x.0.0) | Breaking change MQTT topics of API | v2.0.0 | -| **MINOR** (x.y.0) | Nieuwe feature, backwards compatible | v1.1.0 | -| **PATCH** (x.y.z) | Bugfix, performance | v1.0.1 | -| **Pre-release** | Beta / testversie | v1.1.0-beta.1 | - -**Release flow CLI:** -```bash -# 1. Develop naar main mergen -git checkout main -git merge develop --no-ff -m "Release v1.1.0: websocket live updates" - -# 2. Tag aanmaken — triggert release.yml automatisch -git tag v1.1.0 -git push origin main --tags - -# GitHub Actions produceert automatisch: -# → firmware.bin met versie geïnjecteerd vanuit tag -# → SHA256 checksum bestand -# → GitHub Release met CHANGELOG sectie als release notes -``` - -### 11.4 CHANGELOG.md formaat (Keep-a-Changelog) - -```markdown -# Changelog - -## [Unreleased] - -## [1.1.0] - 2026-05-01 -### Added -- WebSocket live updates elke 2 seconden -- Accordion secties in webinterface - -### Fixed -- Watchdog cooldown niet correct opgeslagen in NVS - -## [1.0.0] - 2026-04-01 -### Added -- Eerste stabiele release -- PWM ventilator sturing via LEDC -- MQTT auto-discovery voor Home Assistant -- DHT22 temperatuurgestuurde regeling -- Hardware + software watchdog -``` - -### 11.5 Dependabot (`dependabot.yml`) - -```yaml -version: 2 -updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - labels: - - "ci-cd" -``` - -### 11.6 Pull Request Template - -```markdown -## Beschrijving - - -## Gerelateerde issues -Closes # - -## Type wijziging -- [ ] Bugfix (F/T nummer: ___) -- [ ] Nieuwe feature (F nummer: ___) -- [ ] Refactor / CI - -## Checklist -- [ ] `pio run` compileert zonder warnings -- [ ] `pio test -e native` slaagt -- [ ] Getest op hardware -- [ ] CHANGELOG.md bijgewerkt onder [Unreleased] -- [ ] Geen blocking calls >10ms in loop() -- [ ] NVS writes gecheckt op return value -``` - -### 11.7 CI/CD Agent (toevoeging agentteam) - -**Agent 6 — `devops-engineer`** -**Domein:** GitHub Actions, CI/CD, repository configuratie, release management -**Skills:** GitHub Actions YAML, SemVer, branch protection, artifact management, gh CLI -**Verantwoordelijk voor:** `.github/workflows/`, branch strategie, labels, release flow, CHANGELOG -**Kwaliteitseis:** Elke push compileert groen. Elke release heeft binary + checksum + changelog. -**Prompt hint voor CLI:** -``` -You are the devops-engineer agent. Set up and maintain the GitHub repository -and CI/CD pipeline modelled after SmartEVSE-3.5 (dingo35/basmeerman). -Two workflows: ci.yml (build + pio test on every push/PR to main/develop) -and release.yml (triggered on v*.*.* tag push to main, identical to SmartEVSE -'PlatformIO CI' + 'Create Release' workflow split). -Version injected from git tag into src/version.h at build time via sed. -Release artifacts: firmware.bin + sha256 attached to GitHub Release. -CHANGELOG.md in keep-a-changelog format, release notes extracted by awk. -Branch protection: main and develop require CI green before merge. -PlatformIO cache key includes platformio.ini hash for efficient caching. -``` - ---- - -## 12. Startinstructies voor Claude Code CLI - -Wanneer je dit plan oppakt in de terminal: - -```bash -# 0. GitHub repo aanmaken (devops-engineer agent) -# Prompt: "Act as devops-engineer agent. Create the GitHub repo and -# set up branch protection, labels, and all .github/ files." - -# 1. Nieuw PlatformIO project aanmaken -pio init --board lolin_d32 --ide vscode - -# 2. Geef dit plan mee als context -# Laad ACCURUIMTE_VENTILATIE_PLAN.md in je Claude Code sessie - -# 3. Start met architect agent voor projectstructuur -# Prompt: "Act as architect agent. Initialize the project structure -# as defined in section 3 of the project plan." - -# 4. Ga naar fase 1 -# Prompt: "Act as firmware-engineer agent. Implement Phase 1: -# config.h, storage.cpp, sensor.cpp, fan.cpp, watchdog.cpp" - -# 5. Valideer met qa-engineer na elke fase -# Prompt: "Act as qa-engineer agent. Verify Phase 1 against -# feature list F1+F2 and run tests T01, T04, T05, T11" -``` - ---- - -## 13. Afhankelijkheden & Versies - -| Library | Versie | Gebruik | +# FanControl — Project Plan v2.0 + +> **Status:** Active, ESPHome architecture (v0.2.0+) +> **Hardware:** LOLIN D32 ESP32 · DHT22 · Ruck EM 125L EC 02 (PWM) +> **Framework:** [ESPHome](https://esphome.io) + +--- + +## 1. Overview + +FanControl is a small, safety-conscious ventilation controller for a home +battery room (Victron Multiplus / JK BMS / 6× 48 V LFP). It reads a DHT22, +drives a Ruck EM 125L EC 02 EC fan via PWM against a configurable +temperature curve, and integrates with Home Assistant over the ESPHome +native API. + +Everything runtime-visible lives in a single file: [`fancontrol.yaml`](fancontrol.yaml). + +## 2. Features + +### F1 — Ventilation core +- F1.1 PWM output on GPIO 25. Default 1 kHz (Ruck EM 125L EC 02 datasheet: + PWM 5–10 V, 1–5 kHz), runtime-tunable via HA / web UI `number` component + (clamped 1000–5000 Hz, 100 Hz step). +- F1.2 Temperature-driven fan speed using a 5-point piecewise-linear curve + (°C → % duty), implemented as a lambda inside the `Fan Speed` + `sensor.template`. +- F1.3 Humidity monitoring via the DHT22. +- F1.4 Thresholds, curve defaults, alarm temp, and min-fan-% are all + `number.template` components with `restore_value: true` — persisted + across reboots in NVS. +- F1.5 Minimum basis ventilation (default 10 %) enforced by the fan-curve + lambda; never applied during failsafe. +- F1.6 **Failsafe: fan forced to 100 %** on sensor stall (no successful + read in 60 s) or NaN readings. + +### F2 — Safety & watchdog +- F2.1 ESP32 hardware TWDT — managed by ESPHome framework. +- F2.2 Sensor-stall detection: `Sensor Alarm` binary sensor flips to `on` + after 60 s of no successful reads. The fan-curve lambda reads this and + biases to 100 %. +- F2.3 Boot-counter persistence: `restart_counter` global with + `restore_value: true`, incremented in `on_boot`. Exposed as a sensor. +- F2.4 Alarm temperature threshold (default 35 °C). +- F2.5 Alarms surfaced in HA as `binary_sensor` entities with + `device_class: safety` / `problem`. + +### F3 — User interface +- F3.1 ESPHome's built-in `web_server` on port 80 — live dashboard with all + entities and controls. `fancontrol.local` via mDNS. +- F3.2 Home Assistant integration: native `api:` with encryption key. Zero + manual configuration on the HA side beyond accepting the auto-discovered + device. +- F3.3 Captive portal (`captive_portal:`) on first boot / on WiFi-config + loss — SSID `FanControl-Setup`, open AP, 192.168.4.1. + +### F4 — MQTT (optional) +An `mqtt:` block is commented out in `fancontrol.yaml`. Users who want the +v0.1.x topic layout can uncomment it, fill in `secrets.yaml`, and ESPHome +publishes state with HA auto-discovery. Most deployments will prefer the +native API and leave MQTT off. + +### F5 — OTA updates +- `ota:` platform esphome, password-gated (`secrets.yaml` → `ota_password`). +- SHA-256 handshake; no RSA signing (see *History* below and `SECURITY.md`). + +### F6 — Persistence +`globals` with `restore_value: true` for the boot counter. All `number` +components with `restore_value: true` for user-tunable settings. No manual +NVS wrapping needed. + +## 3. Architecture + +One file, one responsibility: + +- [`fancontrol.yaml`](fancontrol.yaml) — firmware definition +- [`secrets.yaml.example`](secrets.yaml.example) — template for local + credentials (real `secrets.yaml` is gitignored) + +Custom C++ is reduced to one lambda (the fan-curve interpolation) plus a +handful of one-liners. Everything else is declarative. + +## 4. Build & release + +- CI (`.github/workflows/ci.yml`) on every push/PR runs + `esphome config` + full compile (via `esphome/build-action`) + 1.5 MB + size gate + artifact upload. +- Release (`.github/workflows/release.yml`) triggered by `v*.*.*` tag + pushes. Compiles a release build, publishes `firmware.bin`, + `firmware.ota.bin`, `firmware.factory.bin`, and their SHA-256 checksums. + Release body pulled from the matching `CHANGELOG.md` section via `awk`. + +## 5. Quality gates + +| Gate | Check | +|---|---| +| Q1 — Config valid | `esphome config fancontrol.yaml` passes | +| Q2 — Compiles | `esphome compile fancontrol.yaml` succeeds | +| Q3 — Size | firmware.bin ≤ 1.5 MB (CI hard-gate) | +| Q4 — Safe defaults | fan failsafe = 100 %, min-% = 10, alarm = 35 °C | +| Q5 — Hardware test matrix | T01–T12 below (manual, on rig) | + +## 6. Test matrix (hardware) + +| # | Scenario | Expected | |---|---|---| -| PubSubClient | ^2.8 | MQTT client | -| DHT sensor library | ^1.4.6 | DHT22 sensor | -| Adafruit Unified Sensor | ^1.1.14 | DHT22 dependency | -| ESP Async WebServer | ^1.2.3 | Webserver + WebSocket | -| AsyncTCP | ^1.1.1 | TCP dependency | -| ElegantOTA | ^3.1.0 | OTA webinterface | -| ArduinoJson | ^7.0.0 | JSON serialisatie | -| Preferences (built-in) | ESP32 core | NVS opslag | - ---- - -## 14. Versie & Changelog - -**v1.2.0** — FanControl rename + uitvoeringsbesluiten vastgelegd -- Project hernoemd: `accuruimte-ventilatie` → `FanControl` (mDNS, MQTT prefix, hostname, repo allemaal `fancontrol`) -- F1.1 PWM frequentie runtime instelbaar in web UI (1000–5000 Hz, default 1 kHz; was hardcoded) -- F3.10 PWM frequentie toegevoegd aan ventilatie-instellingen UI -- §4 platformio.ini: AsyncWebServer fork → `esp32async/ESPAsyncWebServer` + `esp32async/AsyncTCP` (oude `me-no-dev/*` packages zijn uit de PIO registry) -- §11.2 release.yml: firmware signing toegevoegd identiek aan SmartEVSE-3.5/pio-build.yaml (RSA + openssl SHA256, secret `SECRET_RSA_KEY`, produceert `firmware.signed.bin`) -- Web UI taal: alle labels Engels (was: Nederlands met Engelse identifiers) -- Captive portal SSID: `FanControl-Setup`, geen wachtwoord -- OTA: `changeme` placeholder → forceer wijziging via web UI banner bij eerste boot -- MQTT: TLS support optioneel via web UI (zoals SmartEVSE), default plain TCP -- HA discovery: één HA "device" met `fancontrol-` als unique ID, kindentities voor temp/humidity/fan/alarms -- GPIO map vastgelegd: DHT22=4, FAN_PWM=25, STATUS_LED=5, FACTORY_RESET=0 - -**v1.1.0** — GitHub repo + CI/CD toegevoegd -- Secties 10 + 11: GitHub repo setup en CI/CD pipeline -- Agent 6 devops-engineer toegevoegd -- Branch strategie, labels, PR template, dependabot - -**v1.0.0** — Initieel projectplan -- Gebaseerd op SmartEVSE-3.5 (basmeerman/dingo35) architectuur -- Single-page webserver met 4 secties, live WebSocket -- 5 Claude agent rollen gedefinieerd -- 12-punt testmatrix -- 4 uitvoeringsfasen - ---- - -*Dit document is de single source of truth voor het FanControl Controller project.* -*Bewaar dit bestand in de root van de repository als `PROJECT_PLAN.md`.* +| T01 | DHT22 unplugged during operation | `Sensor Alarm` ON within 60 s, fan → 100 % | +| T02 | WiFi AP unavailable | Captive portal SSID comes up on reconnect window | +| T03 | HA unavailable | Fan keeps running on the local curve | +| T04 | Long-duration (72 h soak) | No watchdog resets, restart counter unchanged | +| T05 | OTA via ESPHome | New firmware active after reboot, restart counter +1 | +| T06 | Factory reset button | NVS wiped, captive portal on next boot | +| T07 | Temperature above alarm threshold | `Temperature Alarm` ON, HA notification fires | +| T08 | PWM frequency change via HA number entity | `ledc.set_frequency` applies live, no reboot; scope trace matches | +| T09 | Min-% floor | Setting min-% = 30 while temp is below curve[0] → fan runs at 30 % | +| T10 | Power cycle | Restart counter increments; tunable settings preserved | + +These are manual; no Unity harness exists under ESPHome. Document results +in a lightweight `docs/test_log.md` when running the rig. + +## 7. History + +### v0.2.0 — ESPHome migration +The project originally shipped (v0.1.0, v0.1.1) as a custom PlatformIO + +Arduino-ESP32 firmware — ~2,000 lines of C++ across 12 source files, with +hand-written modules for DHT22 reading, LEDC PWM, hardware + software +watchdog, NVS wrapper, MQTT + HA auto-discovery, AsyncWebServer + WebSocket, +captive portal, and ElegantOTA with RSA signature verification. + +That firmware worked, built green, and shipped a signed v0.1.1 release. The +user then asked for an architectural review comparing PlatformIO/Arduino +vs. ESPHome for a one-off, personal-use, LAN-only battery-room controller. + +The conclusion: for this specific deployment and maintainer profile, +ESPHome wins on: + +- **Long-term maintenance**: ecosystem carries the ESP32 Arduino-core churn + (already bitten by `me-no-dev/ESPAsyncWebServer` being dropped from the + PIO registry mid-build). +- **LOC footprint**: ~2,000 lines of C++ → ~200 lines of YAML + 1 non-trivial + lambda. +- **HA integration quality**: native API beats MQTT for responsiveness and + configuration friction. +- **Hand-off**: anyone can pick up a YAML file in an afternoon; C++ + + FreeRTOS + Arduino + ESP-IDF is a much steeper ramp. + +The tradeoff was RSA-signed OTA, a polished 4-section custom web UI, and a +handful of Unity tests. For a LAN-only device you own, none of those are +load-bearing. See `SECURITY.md` for the current auth model and how the +v0.1.x signed releases remain verifiable via the preserved public key. + +### v0.1.1 — First RSA-signed release (2026-04-19, retired) +`SECRET_RSA_KEY` repo secret configured; release workflow attached +`firmware.signed.bin`. Verified end-to-end locally. + +### v0.1.0 — First end-to-end custom-C++ release (2026-04-19, retired) +Six-agent rollout (architect / firmware / mqtt / webserver / qa / devops). +~1,800 lines of C++, 7 Unity tests on pure fan-curve logic. Full plan +archived under the `v0.1.0` tag. + +## 8. Agent roles (for future Claude Code sessions) + +With the code collapsed to a single YAML file, the earlier six-role split +(firmware / mqtt / webserver / qa / architect / devops) is overkill. +Two effective roles remain: + +- **esphome-engineer** — owns `fancontrol.yaml`. Adds components, tunes + lambdas, keeps the YAML idiomatic. Must run `esphome compile` before + merging. +- **devops-engineer** — owns `.github/`, the release flow, dependabot, + CHANGELOG discipline. + +Security-sensitive changes (OTA, API encryption, secrets handling) should +be flagged explicitly in the PR body and cross-checked against +`SECURITY.md`. + +## 9. Out of scope (for now) + +- Factory-reset via physical boot-button long-press (GPIO 0 held at + runtime). Currently only the `button.factory_reset` HA entity. +- MQTT TLS with CA pinning. Native API is preferred; if someone really + wants TLS-to-broker they can wire it up. +- Brute-force protection on ESPHome OTA. Handle network-side. +- Web dashboard authentication. Trust the LAN or put it behind a reverse + proxy. + +## 10. Dependencies + +- ESPHome 2026.4.0+ (pinned in CI via `version: latest`; bump review on + each new ESPHome release via CHANGELOG) +- ESP-IDF / Arduino-ESP32 — pulled in transitively by ESPHome + +No direct C++ library dependencies; ESPHome manages them. + +--- + +*This document is the project's single source of truth for intent and +scope. `fancontrol.yaml` is the single source of truth for behaviour.* diff --git a/README.md b/README.md index 2060862..3eb812e 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,79 @@ # FanControl ESP32 ventilation controller for a home battery room (Victron Multiplus / JK BMS / 6× 48 V LFP). -Drives a Ruck EM 125L EC 02 fan via PWM based on DHT22 temperature, exposes live status over an -embedded web UI (WebSocket push every 2 s) and integrates with Home Assistant through MQTT -auto-discovery (one HA device, child entities for temperature, humidity, fan speed, PWM -frequency, restarts and three alarm binary sensors). +Drives a Ruck EM 125L EC 02 fan via PWM based on DHT22 temperature, integrates with Home +Assistant over the native ESPHome API, exposes a built-in web dashboard on `fancontrol.local`. -- **Hardware:** LOLIN D32 ESP32 + DHT22 + Ruck EM 125L EC 02 (PWM via level shifter + MT3608 → 9 V) -- **PWM:** default 1 kHz, runtime-tunable 1–5 kHz via web UI (per Ruck datasheet) -- **Platform:** PlatformIO, Arduino framework, board `lolin_d32` -- **Design reference:** SmartEVSE-3.5 (basmeerman/dingo35) +- **Hardware:** LOLIN D32 (ESP32) + DHT22 + Ruck EM 125L EC 02 (PWM via level-shifter + MT3608 → 9 V) +- **PWM:** default 1 kHz, runtime-tunable 1–5 kHz (per Ruck datasheet) +- **Framework:** [ESPHome](https://esphome.io) — YAML-driven firmware, compiled on demand +- **Firmware size:** ~945 KB (under the 1.5 MB CI gate) -## Status +## Quick start -v0.1.0 — first end-to-end release. All modules wired, all quality gates green. -See [`CHANGELOG.md`](CHANGELOG.md) for the full feature list. - -| | | -|---|---| -| Build | `pio run` SUCCESS, RAM 15 %, flash 54.5 % (under the 1.5 MB CI gate) | -| Tests | `pio test -e native` 7 / 7 PASSED | -| CI | GitHub Actions builds every push and PR; tag `v*.*.*` triggers signed release | - -## Build & flash +Prerequisites: Python 3.10+, [`pipx`](https://pipx.pypa.io), and a USB-C cable. ```bash -pio run # build firmware for lolin_d32 -pio run -t upload # serial upload over USB -pio run -t upload --upload-port=fancontrol.local # OTA after first boot -pio device monitor -b 115200 # serial monitor -pio test -e native # host-side Unity tests -pio test -e native -f test_fan_curve # run a single test folder -``` +# 1. Install ESPHome +pipx install esphome -## First boot +# 2. Configure secrets +cp secrets.yaml.example secrets.yaml +# Edit secrets.yaml — fill in WiFi creds and pick strong passwords +# (generate an API encryption key with: openssl rand -base64 32) -1. Flash via USB the first time. -2. The device starts in captive-portal mode — connect to the open WiFi SSID - **`FanControl-Setup`** and you'll be redirected to the configuration page. -3. Enter your home WiFi credentials and (optionally) MQTT broker details, save. -4. The device reboots into station mode and is reachable at - [`http://fancontrol.local`](http://fancontrol.local). -5. The web UI shows a sticky red banner until you set a new OTA password — - OTA uploads return 403 until then. +# 3. Plug the LOLIN D32 into USB, then flash the first time over USB: +esphome run fancontrol.yaml -## Endpoints +# 4. All subsequent flashes happen over WiFi (OTA, password-gated): +esphome run fancontrol.yaml +``` -| Path | Purpose | -|---|---| -| `/` | Single-page web UI (status, settings, system) | -| `/ws` | WebSocket — JSON status push every 2 s; inbound config frames | -| `/update` | ElegantOTA (HTTP basic-auth, gated until password is changed) | -| `/healthz` | `200 ok` for upstream monitoring | +If WiFi credentials are wrong or missing, the device falls back to an open AP +named **`FanControl-Setup`** at `192.168.4.1` — connect, configure, save. -## MQTT topics +## Home Assistant integration -Published under the configurable prefix (default `fancontrol`). Full table in -[`PROJECT_PLAN.md`](PROJECT_PLAN.md) §6. +Once on the network, HA auto-discovers the device via the ESPHome integration. +No manual MQTT configuration needed. Exposed entities: -``` -fancontrol/sensor/temperature/state 24.3 -fancontrol/sensor/humidity/state 58.2 -fancontrol/sensor/fan_speed/state 45 -fancontrol/sensor/fan_pwm_freq/state 1000 -fancontrol/sensor/restarts/state 2 -fancontrol/binary_sensor/alarm_temp/state ON|OFF -fancontrol/binary_sensor/alarm_sensor/state ON|OFF -fancontrol/binary_sensor/watchdog/state ON|OFF -fancontrol/status online|offline -``` - -HA discovery is republished on every (re)connect under `homeassistant/{component}/{node_id}_*`. -TLS is enabled automatically when the broker port is `8883` (insecure mode, matches SmartEVSE). +| Entity | Type | Notes | +|---|---|---| +| Temperature | sensor | °C, from DHT22 | +| Humidity | sensor | %, from DHT22 | +| Fan Speed | sensor | % duty, derived from the curve | +| Fan PWM Frequency | sensor | Hz | +| Restart Counter | sensor | monotonic, persisted in NVS | +| Uptime | sensor | seconds | +| WiFi Signal | sensor | dBm | +| Temperature Alarm | binary_sensor | `safety` class, fires at the configured threshold | +| Sensor Alarm | binary_sensor | `problem` class, fires after 60 s of no successful reads | +| Alarm Temperature | number | tunable threshold | +| Fan Min % | number | minimum duty under normal operation | +| Fan PWM Frequency Setting | number | 1000–5000 Hz, applied live via `ledc.set_frequency` | +| Restart / Factory Reset | button | standard ESPHome buttons | -## Verifying releases +## Safety behaviour -Every tagged release ships `firmware.bin` + SHA-256 + `firmware.signed.bin`. -Verify signature with the public key committed at -[`docs/signing_public_key.pem`](docs/signing_public_key.pem): +- **Sensor stall** (no successful DHT22 read for 60 s) → fan forced to 100 %. +- **NaN temperature reading** → fan biased to 100 % (curve treats unknown as hot). +- **Fan-curve interpolation**: linear between the 5 configured (°C, %) points; below the first point → use its PWM with the min-% floor; above the last point → 100 %. +- **Hardware watchdog**: managed by ESPHome core; restart counter persisted across reboots. -```bash -dd if=FanControl-vX.Y.Z.signed.bin of=firmware.sign bs=1 count=256 status=none -dd if=FanControl-vX.Y.Z.signed.bin of=firmware.bin bs=1 skip=256 status=none -openssl dgst -verify docs/signing_public_key.pem -keyform PEM -sha256 \ - -signature firmware.sign firmware.bin -# → Verified OK -``` +## Web dashboard -Full recipe + key rotation policy in [`SECURITY.md`](SECURITY.md). +ESPHome's built-in dashboard is served on port 80 at `http://fancontrol.local` — live +sensor values, controls for the number entities, restart/factory-reset buttons. ## Documentation -- [`PROJECT_PLAN.md`](PROJECT_PLAN.md) — v1.2, the single source of truth (features, test - matrix, agent roles, CI/CD layout) -- [`CLAUDE.md`](CLAUDE.md) — architecture constraints for future Claude Code sessions -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — branching model + RSA signing-key generation / - verification recipe +- [`fancontrol.yaml`](fancontrol.yaml) — single source of truth for firmware behaviour +- [`PROJECT_PLAN.md`](PROJECT_PLAN.md) — project goals, history, and the rationale for the + ESPHome switch (pre-v0.2.0 custom-C++ architecture documented under "History") +- [`docs/wiring_diagram.md`](docs/wiring_diagram.md) — BOM, pin map, PWM verification +- [`CONTRIBUTING.md`](CONTRIBUTING.md) — dev workflow, branching, release cut +- [`SECURITY.md`](SECURITY.md) — OTA/API security model, pre-v0.2.0 legacy RSA-signed + releases still verifiable ## License diff --git a/SECURITY.md b/SECURITY.md index 2f43c48..750b6f4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,77 +1,88 @@ # Security Policy -## Verifying release artifacts +## Threat model -Every release attaches three files: +FanControl runs on a LAN by design — a home battery room ventilation controller +that talks to Home Assistant and (optionally) an MQTT broker on the local +network. The security posture is scoped accordingly: -| File | Purpose | -|---|---| -| `FanControl-vX.Y.Z.bin` | Unsigned firmware | -| `FanControl-vX.Y.Z.bin.sha256` | SHA-256 checksum of the unsigned firmware | -| `FanControl-vX.Y.Z.signed.bin` | The signed firmware: a **2048-bit RSA signature prepended** to the unsigned firmware | +- **In scope:** protecting OTA updates from casual tampering, preventing + unauthenticated HA control, keeping WiFi credentials off the wire. +- **Out of scope:** defence against attackers already on the LAN mounting + active man-in-the-middle attacks, supply-chain attacks on ESPHome itself, + physical access to the device. -The public key used to sign releases lives at -[`docs/signing_public_key.pem`](docs/signing_public_key.pem) in this repository. +If your deployment is internet-exposed (remote WireGuard client excepted), +stop and reconsider — this firmware is not designed for that. -### Verify checksum +## OTA updates + +ESPHome OTA is password-gated (see `ota.password` in `fancontrol.yaml`, set via +`secrets.yaml`). The OTA handshake hashes the binary with SHA-256 and rejects +mismatches. **There is no RSA signature on v0.2.0+ firmware** — the OTA +password plus the checksum are the only authenticity controls. + +Verify a downloaded release artifact with its published SHA-256: ```bash sha256sum -c FanControl-vX.Y.Z.bin.sha256 # → FanControl-vX.Y.Z.bin: OK ``` -### Verify RSA signature +Use `firmware.factory.bin` for initial USB flashing (includes bootloader + +partition table), `firmware.ota.bin` for over-the-air updates. -The signing scheme matches SmartEVSE-3.5 exactly: the first 256 bytes of -`.signed.bin` are the RSA-SHA256 signature, and the remaining bytes are the -original firmware. +## Home Assistant API -```bash -# Split off the 256-byte signature -dd if=FanControl-vX.Y.Z.signed.bin of=firmware.sign bs=1 count=256 status=none -dd if=FanControl-vX.Y.Z.signed.bin of=firmware.bin bs=1 skip=256 status=none +The ESPHome native API is encrypted (`api.encryption.key` in `fancontrol.yaml`). +The key is 32 bytes, base64-encoded, stored in `secrets.yaml`. Rotate by +regenerating with `openssl rand -base64 32`, updating `secrets.yaml`, and +reflashing. + +## Legacy: pre-v0.2.0 RSA-signed releases + +Versions **v0.1.0** and **v0.1.1** were built under the custom PlatformIO + +Arduino architecture and are signed with a 2048-bit RSA key. The scheme +mirrored SmartEVSE-3.5 (`openssl dgst -sign -keyform PEM -sha256`, signature +prepended to `firmware.bin` → `firmware.signed.bin`). The public key is +still shipped at [`docs/signing_public_key.pem`](docs/signing_public_key.pem) +so old releases remain verifiable: -# Verify against the public key +```bash +# v0.1.x only — NOT applicable to v0.2.0+ binaries +dd if=FanControl-v0.1.1.signed.bin of=firmware.sign bs=1 count=256 status=none +dd if=FanControl-v0.1.1.signed.bin of=firmware.bin bs=1 skip=256 status=none openssl dgst -verify docs/signing_public_key.pem -keyform PEM -sha256 \ -signature firmware.sign firmware.bin # → Verified OK - -# Sanity: the extracted firmware should byte-match the unsigned release artifact -cmp firmware.bin FanControl-vX.Y.Z.bin ``` -If `openssl` prints anything other than `Verified OK`, **do not flash the -firmware** — the artifact may have been tampered with. Open an issue under -[Reporting a vulnerability](#reporting-a-vulnerability) below. +The `SECRET_RSA_KEY` repo secret remains configured but is unused from v0.2.0 +onward. It is not deleted, so a maintainer could hypothetically revive signing +for a pre-v0.2.0 historical tag. -### Key rotation +### Why the switch away from RSA signing? -If the current signing key is ever compromised or rotated, the replacement -public key will be committed to `docs/signing_public_key.pem` in the same -release (or the release preceding it), and the change will be called out in -`CHANGELOG.md` under a `### Security` heading. Old releases remain signed -with the old key; verify them against the key shipped in the corresponding -tag rather than the current `HEAD`. +v0.2.0 moved the entire firmware to ESPHome. ESPHome's OTA flow does not +support external signature verification; integrating a custom signed-OTA path +would have re-introduced most of the custom C++ layer we were retiring. +Given the LAN-only threat model, the OTA password + SHA-256 checksum is an +acceptable trade-off. See `PROJECT_PLAN.md` "History" for the decision log. -## Scope +## Known out-of-scope items -FanControl runs on a LAN by design (home battery room controller, talking to a -local MQTT broker and serving a local web UI). The following are **explicitly -out of scope** for security hardening at this stage: - -- TLS verification for MQTT. Port `8883` enables `WiFiClientSecure::setInsecure()` — - no CA certificate check. This matches the SmartEVSE-3.5 approach and is adequate - for a trusted LAN broker. If you run this internet-exposed, pin a CA or - fingerprint first (tracked by `TODO(security)` in `src/mqtt.cpp`). -- Authentication on the web UI settings pages. OTA is password-gated via - ElegantOTA; status/settings pages are open to anyone on the LAN. -- Brute-force protection on the OTA form. ElegantOTA does not rate-limit; - protect the device network-side. +- **No brute-force protection on ESPHome OTA.** Protect the device + network-side; don't expose its HTTP endpoint to the internet. +- **Native API encryption is mandatory but unauthenticated at the transport + level** — anyone with the API key can fully control the device. Treat the + key as a root credential. +- **Web dashboard has no authentication.** It's served to anyone on the LAN + who can reach `fancontrol.local:80`. If you need auth, put the device + behind a reverse proxy with basic-auth. ## Reporting a vulnerability -Please open a [private security advisory](https://github.com/basmeerman/FanControl/security/advisories/new) -on GitHub. Do not file public issues for security bugs. - -For non-exploitable hardening suggestions (missing guards, weak defaults, -documentation gaps), a regular issue with the `safety` label is fine. +Open a [private security advisory](https://github.com/basmeerman/FanControl/security/advisories/new) +on GitHub rather than a public issue. For non-exploitable hardening suggestions +(missing guards, weak defaults, documentation gaps), a regular issue with the +`safety` label is fine. diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index fe2f930..0000000 --- a/docs/api.md +++ /dev/null @@ -1,173 +0,0 @@ -# FanControl — HTTP + WebSocket API - -All network-facing endpoints are served by the ESP32 on port 80 once WiFi is up -(`fancontrol.local` via mDNS, or the STA IP). - -## HTTP endpoints - -| Method | Path | Purpose | Response | -|---|---|---|---| -| `GET` | `/` | Single-page web UI (embedded PROGMEM) | `200 text/html` — 14.6 KB | -| `GET` | `/healthz` | Liveness probe | `200 ok` | -| `GET` | `/ws` | WebSocket upgrade endpoint | See below | -| `GET` | `/update` | ElegantOTA — HTTP basic-auth form | `200` once OTA password is non-default; `403` otherwise (see *OTA gate* below) | -| `POST` | `/update/...` | ElegantOTA upload targets | Same gate as above | -| `GET` | `/generate_204`, `/gen_204`, `/hotspot-detect.html`, `/ncsi.txt`, `/connecttest.txt`, `/library/test/success.html`, `/redirect` | Captive-portal detection probes | In AP mode: 302 redirect to `/`. In STA mode: 204 no-content (keeps phones happy). | - -### OTA gate - -ElegantOTA is only mounted when `storage::loadOtaPassword()` differs from the -hardcoded default (`"changeme"`). Until the user saves a new password via the -web UI, the three `/update*` routes return `403 Forbidden` with a body asking -the user to open the web UI and set a password. - -`webserver::applyOtaPassword()` mounts ElegantOTA live on the first non-default -save (no reboot required). Once mounted, ElegantOTA v3's API doesn't expose a -clean rebind — subsequent password changes are applied but the cleanest state -comes from a reboot (the web UI prompts for one). - -## WebSocket (`/ws`) - -A single `AsyncWebSocket` endpoint. JSON frames both directions. - -### Device → Browser - -#### `status` — pushed every `WS_PUSH_INTERVAL_MS` (2 s) - -```json -{ - "type": "status", - "temperature": 24.3, - "humidity": 58.2, - "fan_speed": 45, - "fan_pwm_freq": 1000, - "alarm": { "temperature": false, "sensor": false, "watchdog": false }, - "mqtt": { "connected": true, "broker": "192.168.1.10" }, - "wifi": { "ssid": "MyNetwork", "rssi": -62, "ip": "192.168.1.50", "portal": false }, - "system": { - "uptime": 86400, - "heap_free": 142320, - "restarts": 2, - "version": "0.1.0", - "build_date": "Apr 19 2026 07:51:08", - "first_boot_change_password": true - } -} -``` - -- `temperature` / `humidity` may be `null` while the sensor hasn't produced a first reading. -- `wifi.portal = true` when the captive-portal AP is active. -- `first_boot_change_password = true` when the stored OTA password still equals the `"changeme"` default. The UI shows a sticky red banner in this state. - -#### `saved` — ack for an inbound config frame - -```json -{ "type": "saved", "section": "fan", "ok": true } -``` - -On validation failure the ack carries an error: - -```json -{ "type": "saved", "section": "fan", "ok": false, "error": "thresholds must be strictly increasing" } -``` - -Sections: `fan`, `network`, `ota_password`, `restart`, `factory_reset`. - -#### `log` — optional debug stream - -```json -{ "type": "log", "line": "[FanControl] mqtt connected broker=..." } -``` - -Sent when `ws::broadcastLog()` is invoked. Not pushed routinely. - -### Browser → Device - -#### `set_fan` - -Persist the fan curve + thresholds + PWM frequency; apply runtime. - -```json -{ - "type": "set_fan", - "thresholds": [15, 20, 25, 30, 35], - "pwm_levels": [10, 25, 50, 75, 100], - "alarm_temp": 35, - "min_fan": 10, - "sensor_interval_ms": 5000, - "pwm_freq_hz": 1000 -} -``` - -**Validation (enforced in `src/websocket.cpp`):** -- `thresholds` — 5 values, strictly monotonically increasing -- `pwm_levels` — 5 values, each in `[0, 100]` -- `alarm_temp` — `[0, 80]` °C -- `min_fan` — `[0, 100]` -- `sensor_interval_ms` — `[1000, 60000]` ms -- `pwm_freq_hz` — `[FAN_PWM_FREQ_MIN_HZ, FAN_PWM_FREQ_MAX_HZ]` = `[1000, 5000]` - -On success: `fan::setMinPercent()` + `fan::setFrequency()` apply live, no reboot. - -#### `set_network` - -Persist WiFi + MQTT + device name. **Reboot required** for WiFi changes to take effect; the ack signals this. - -```json -{ - "type": "set_network", - "wifi": { "ssid": "MyNet", "password": "..." }, - "mqtt": { - "host": "192.168.1.10", "port": 1883, - "user": "ha", "password": "...", - "prefix": "fancontrol" - }, - "device_name": "fancontrol" -} -``` - -Ack: `{"type":"saved","section":"network","ok":true,"reboot_required":true}`. - -**MQTT TLS:** set `port = 8883` to auto-enable TLS (`WiFiClientSecure` + `setInsecure()` — no CA verification). See [docs/mqtt_topics.md](mqtt_topics.md#tls-port-8883). - -#### `set_ota_password` - -```json -{ "type": "set_ota_password", "password": "" } -``` - -Rejected if length < 8. On success, `storage::saveOtaPassword()` + `webserver::applyOtaPassword()` run live; `/update` becomes available. - -#### `restart` - -```json -{ "type": "restart" } -``` - -Calls `watchdog::requestRestart("user")`. Subject to the 5-minute cooldown if a restart already happened recently. - -#### `factory_reset` - -```json -{ "type": "factory_reset", "confirm": true } -``` - -`confirm: true` is required — frames without it are ignored. On match, `storage::factoryReset()` wipes the NVS namespace and schedules a restart. On next boot the device comes up in captive-portal mode. - -## Error handling - -- Malformed JSON → ignored; a `log` frame describes the parse error (when `broadcastLog` is enabled). -- Unknown `type` → ignored with a `log` frame. -- Any handler may emit a `saved` ack with `"ok": false` and an `"error"` string; the UI surfaces this as a toast. - -## Rate limits - -- Inbound handler: no explicit rate limit; `AsyncWebSocket` handles backpressure. -- Outbound push: one `status` every 2 s, one `cleanupClients()` every second (housekeeping). - -## Stack / task model - -- `webserver::begin()` creates the `AsyncWebServer` on core 0. -- `ws::task` runs on core 0 at priority 3 with a 6 KB stack — enough for ArduinoJson 7 serialisation of the status frame. -- `wifi_manager::task` runs on core 0 at priority 3 with a 4 KB stack — handles STA/AP transitions. -- All three network tasks are subscribed to the hardware TWDT via `watchdog::subscribeCurrentTask()`. diff --git a/docs/home_assistant.md b/docs/home_assistant.md new file mode 100644 index 0000000..521f6c7 --- /dev/null +++ b/docs/home_assistant.md @@ -0,0 +1,86 @@ +# FanControl — Home Assistant integration + +FanControl exposes its state to Home Assistant over the **ESPHome native API** +(encrypted, authenticated, fast). HA discovers the device automatically once +the ESPHome integration is installed and the device is on the same LAN. + +## Setup + +1. Install the [ESPHome integration](https://www.home-assistant.io/integrations/esphome/) + in Home Assistant (usually it self-prompts when a new `*.local` ESPHome + device appears). +2. When prompted, paste the value of `api_encryption_key` from your local + `secrets.yaml` (same key the device was compiled with). +3. The device now appears as `fancontrol` with all entities auto-added. + +## Entities + +| Entity name | Type | Unit | Notes | +|---|---|---|---| +| Temperature | sensor | °C | from DHT22 | +| Humidity | sensor | % | from DHT22 | +| Fan Speed | sensor | % | derived live from the curve | +| Fan PWM Frequency | sensor | Hz | reflects the current setting | +| Restart Counter | sensor | — | `state_class: total_increasing`; persisted across reboots | +| Uptime | sensor | s | ESPHome built-in | +| WiFi Signal | sensor | dBm | updates every 60 s | +| Temperature Alarm | binary_sensor | — | `device_class: safety`, fires at configured threshold | +| Sensor Alarm | binary_sensor | — | `device_class: problem`, fires after 60 s of no reads | +| Alarm Temperature | number | °C | tunable threshold, 0–80, default 35 | +| Fan Min % | number | % | min duty under normal operation, 0–100, default 10 | +| Fan PWM Frequency Setting | number | Hz | 1000–5000, 100 step, applied live | +| Restart | button | — | graceful reboot | +| Factory Reset | button | — | wipes NVS, next boot → captive portal | + +## Automation ideas + +```yaml +# Push a notification when either alarm fires +alias: FanControl alarm notification +trigger: + - platform: state + entity_id: + - binary_sensor.fancontrol_temperature_alarm + - binary_sensor.fancontrol_sensor_alarm + to: "on" +action: + - service: notify.mobile_app + data: + title: "FanControl alarm" + message: "{{ trigger.to_state.name }} is ON" +``` + +```yaml +# Bump the fan floor to 25 % when the battery is charging hard +alias: Battery charging → fan floor 25 % +trigger: + - platform: numeric_state + entity_id: sensor.victron_battery_current + above: 30 +action: + - service: number.set_value + target: + entity_id: number.fancontrol_fan_min_percent + data: + value: 25 +``` + +## Using MQTT instead of (or alongside) the native API + +Uncomment the `mqtt:` block in `fancontrol.yaml`, fill the broker fields in +`secrets.yaml`, and rebuild. ESPHome will publish HA discovery topics under +`homeassistant/...` and state under `fancontrol/...`. Topic shape is +identical to the v0.1.x project plan's F4/§6 table. + +For TLS, set `port: 8883` and add: + +```yaml +mqtt: + broker: your.broker + port: 8883 + ssl_fingerprints: + - "" +``` + +This is stricter than the v0.1.x `setInsecure()` approach — the fingerprint +pin is enforced by the library. diff --git a/docs/mqtt_topics.md b/docs/mqtt_topics.md deleted file mode 100644 index 3e4cb83..0000000 --- a/docs/mqtt_topics.md +++ /dev/null @@ -1,91 +0,0 @@ -# FanControl — MQTT Topics - -All topics live under a configurable prefix (default `fancontrol`, set in the web UI). -Home Assistant discovery topics are published under the fixed `homeassistant/` prefix. - -## Connection - -| Setting | Default | Notes | -|---|---|---| -| Broker host | *(empty)* | Set via the web UI → Network & MQTT | -| Broker port | `1883` | `8883` auto-enables TLS (`WiFiClientSecure` + `setInsecure()`; matches SmartEVSE-3.5) | -| Username / password | *(empty)* | Optional | -| Prefix | `fancontrol` | Any ASCII slug; no leading slash | -| Keep-alive | 60 s | Hardcoded in `mqtt.cpp` | -| QoS | 0 | Sufficient for telemetry; retained where noted | - -## State topics (device → broker) - -| Topic | Payload | Retain | Cadence | -|---|---|---|---| -| `{prefix}/status` | `online` / `offline` | ✓ | Birth on (re)connect; LWT publishes `offline` on disconnect | -| `{prefix}/sensor/temperature/state` | Float string, e.g. `24.3` | | Every `MQTT_PUBLISH_INTERVAL_MS` (15 s) | -| `{prefix}/sensor/humidity/state` | Float string, e.g. `58.2` | | 15 s | -| `{prefix}/sensor/fan_speed/state` | Integer 0–100 | | 15 s | -| `{prefix}/sensor/fan_pwm_freq/state` | Integer Hz, 1000–5000 | | 15 s | -| `{prefix}/sensor/restarts/state` | Integer (monotonic) | | 15 s | -| `{prefix}/binary_sensor/alarm_temp/state` | `ON` / `OFF` | | Edge-triggered + every 15 s | -| `{prefix}/binary_sensor/alarm_sensor/state` | `ON` / `OFF` | | Edge-triggered + every 15 s | -| `{prefix}/binary_sensor/watchdog/state` | `ON` / `OFF` | | Edge-triggered + every 15 s | - -**Alarm semantics:** -- `alarm_temp` — `temperature >= alarm_temp_threshold` (set in web UI, default 35 °C) -- `alarm_sensor` — `sensor::isStale()` (no successful DHT22 read in `SENSOR_STALL_TIMEOUT_MS` = 60 s) -- `watchdog` — sensor-stall failsafe engaged (fan forced to 100 %) - -Alarms are pushed edge-triggered (immediately when the flag flips) **and** republished at every routine cycle so HA recovers cleanly after a broker restart. - -## HA auto-discovery (device → broker) - -Published once on every (re)connect, retained, on topic `homeassistant/{component}/{node_id}_{entity}/config`. - -- `node_id = "fancontrol_"` (underscores; HA spec forbids dashes in the topic slug) -- All child entities share one `device` block so HA groups them under **one device**: - -```jsonc -{ - "device": { - "identifiers": ["fancontrol-"], - "name": "", - "manufacturer": "FanControl", - "model": "ESP32 LOLIN D32", - "sw_version": "" - }, - "availability": [{ "topic": "{prefix}/status" }], - "unique_id": "_", - "name": "", - "state_topic": "{prefix}/sensor/temperature/state", - "unit_of_measurement": "°C", - "device_class": "temperature", - "state_class": "measurement" -} -``` - -**Entities published:** - -| Entity | HA component | Device class | Unit | State class | -|---|---|---|---|---| -| Temperature | `sensor` | `temperature` | `°C` | `measurement` | -| Humidity | `sensor` | `humidity` | `%` | `measurement` | -| Fan speed | `sensor` | — | `%` | `measurement` | -| Fan PWM frequency | `sensor` | — | `Hz` | `measurement` | -| Restarts | `sensor` | — | — | `total_increasing` | -| Temperature alarm | `binary_sensor` | `safety` | — | — | -| Sensor alarm | `binary_sensor` | `safety` | — | — | -| Watchdog | `binary_sensor` | `problem` | — | — | - -## Reconnect behaviour - -- Exponential backoff: `MQTT_RECONNECT_MIN_MS` (1 s) → double → `MQTT_RECONNECT_MAX_MS` (60 s). Reset on success. -- Never blocks the main task longer than ~10 ms at a time — `vTaskDelay` between attempts. -- WiFi required — `mqtt::task` skips connect attempts while `WiFi.status() != WL_CONNECTED`. - -## TLS (port 8883) - -- Chosen at `mqtt::begin()` time based on NVS `MqttConfig.port`. Switching TLS on/off is a port change in the web UI. -- Uses `WiFiClientSecure::setInsecure()` — **no CA certificate verification** yet. Matches SmartEVSE-3.5; adequate for a trusted LAN broker but not for internet-exposed brokers. -- TODO (security): pin a CA certificate or server fingerprint. Tracked by the `TODO(security)` comments in `src/mqtt.cpp`. - -## Thread safety - -PubSubClient is not thread-safe. All `client.publish / loop / connect` calls are guarded by a FreeRTOS mutex inside `src/mqtt.cpp`. Edge-triggered `mqtt::publishAlarm()` is safe to call from any task; if the mutex can't be grabbed within 10 ms the alarm is deferred to the next routine cycle (pending-flag pattern). diff --git a/docs/wiring_diagram.md b/docs/wiring_diagram.md index 294d4d3..5561a7e 100644 --- a/docs/wiring_diagram.md +++ b/docs/wiring_diagram.md @@ -11,7 +11,7 @@ | Level shifter (3.3 V ↔ 9 V) | TXS0108E 8-ch or 74HCT125 — any channel; pin choice is not dictated by the shifter. | | Enclosure, 230 V screw terminal, 5 V USB PSU | 1 A on the 5 V rail is plenty. | -## Pin map (firmware `src/config.h`) +## Pin map (`fancontrol.yaml`) | ESP32 GPIO | Role | Direction | Notes | |---|---|---|---| @@ -70,6 +70,7 @@ If the fan hums audibly and doesn't ramp smoothly, try **2 kHz** in the web UI ( ## Factory reset -Currently only triggered via the web UI's System section. A planned enhancement -(`PIN_FACTORY_RESET = GPIO 0`) will detect the boot button held for 5 s at -runtime and call `storage::factoryReset()` + restart. +Currently triggered via the ESPHome dashboard's `Factory Reset` button entity +(exposed in both the built-in web UI and Home Assistant). A future enhancement +would detect GPIO 0 (boot button) held for 5 s at runtime and trigger a +factory reset automation; not yet wired into the YAML. diff --git a/fancontrol.yaml b/fancontrol.yaml new file mode 100644 index 0000000..d473e0f --- /dev/null +++ b/fancontrol.yaml @@ -0,0 +1,274 @@ +# FanControl — ESPHome configuration +# +# Hardware: LOLIN D32 (ESP32) + DHT22 (with onboard pull-up) + Ruck EM 125L +# EC 02 via level-shifter + MT3608 to 9 V. See docs/wiring_diagram.md. +# +# Copy secrets.yaml.example → secrets.yaml and fill in before first build. +# Compile + flash the first time over USB: +# esphome run fancontrol.yaml +# Subsequent flashes happen via OTA over WiFi. + +substitutions: + device_name: fancontrol + device_friendly_name: FanControl + # Fan-curve defaults (°C → % duty). Runtime-tunable in HA via `number` components. + curve_t0: "15.0" + curve_t1: "20.0" + curve_t2: "25.0" + curve_t3: "30.0" + curve_t4: "35.0" + curve_p0: "10" + curve_p1: "25" + curve_p2: "50" + curve_p3: "75" + curve_p4: "100" + +esphome: + name: ${device_name} + friendly_name: ${device_friendly_name} + comment: "Home battery room ventilation controller" + on_boot: + priority: -10 + then: + - lambda: |- + id(restart_counter) += 1; + - logger.log: + format: "FanControl boot — restart counter now %u" + args: ["id(restart_counter)"] + +esp32: + board: lolin_d32 + framework: + type: arduino + +logger: + level: INFO + baud_rate: 115200 + +# Prefer ESPHome native API for Home Assistant (encrypted, fast). MQTT +# left optional below for users who want to keep the v0.1.x topic shape. +api: + encryption: + key: !secret api_encryption_key + +ota: + - platform: esphome + password: !secret ota_password + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + # Open captive portal on first boot / on WiFi-config loss. + ssid: "FanControl-Setup" + # Fast reconnect; ESPHome handles STA reconnect automatically. + +captive_portal: + +mdns: + # Advertises the device as fancontrol.local on the LAN. + +# Optional MQTT. Uncomment + fill secrets.yaml to publish to an external +# broker in addition to (or instead of) the native API. +# mqtt: +# broker: !secret mqtt_host +# port: 1883 +# username: !secret mqtt_user +# password: !secret mqtt_password +# topic_prefix: fancontrol +# discovery: true +# discovery_retain: true +# birth_message: +# topic: fancontrol/status +# payload: online +# retain: true +# will_message: +# topic: fancontrol/status +# payload: offline +# retain: true + +web_server: + port: 80 + version: 3 + +# -------- Globals (restore_value = persisted across reboots) -------- + +globals: + - id: restart_counter + type: uint32_t + restore_value: true + initial_value: "0" + - id: last_sensor_read_ms + type: uint32_t + restore_value: false + initial_value: "0" + +# -------- Sensors -------- + +sensor: + - platform: dht + model: DHT22 + pin: GPIO4 + update_interval: 5s + temperature: + name: "Temperature" + id: dht_temperature + accuracy_decimals: 1 + on_value: + then: + - lambda: "id(last_sensor_read_ms) = millis();" + filters: + - filter_out: nan + humidity: + name: "Humidity" + id: dht_humidity + accuracy_decimals: 1 + filters: + - filter_out: nan + + # Fan speed derived from the curve. Drives the PWM output via on_value. + - platform: template + name: "Fan Speed" + id: fan_speed_pct + unit_of_measurement: "%" + accuracy_decimals: 0 + update_interval: 1s + lambda: |- + const float t = id(dht_temperature).state; + if (std::isnan(t)) return 100.0f; // sensor warmup / NaN → failsafe bias + if (id(alarm_sensor_stall).state) return 100.0f; // SW watchdog stall + + const float ts[5] = { ${curve_t0}, ${curve_t1}, ${curve_t2}, ${curve_t3}, ${curve_t4} }; + const float ps[5] = { ${curve_p0}, ${curve_p1}, ${curve_p2}, ${curve_p3}, ${curve_p4} }; + + float pct; + if (t <= ts[0]) pct = ps[0]; + else if (t >= ts[4]) pct = 100.0f; + else { + int i = 0; + while (i < 4 && !(t >= ts[i] && t <= ts[i + 1])) i++; + const float span = ts[i + 1] - ts[i]; + const float frac = (span <= 0.0f) ? 0.0f : (t - ts[i]) / span; + pct = ps[i] + frac * (ps[i + 1] - ps[i]); + } + + const float min_pct = id(fan_min_pct).state; + if (pct < min_pct) pct = min_pct; + if (pct > 100.0f) pct = 100.0f; + return pct; + on_value: + - output.set_level: + id: fan_pwm_output + level: !lambda "return x / 100.0f;" + + - platform: template + name: "Fan PWM Frequency" + id: fan_pwm_freq_sensor + unit_of_measurement: "Hz" + accuracy_decimals: 0 + update_interval: 60s + lambda: "return id(fan_pwm_freq_setting).state;" + + - platform: template + name: "Restart Counter" + id: restart_count_sensor + update_interval: 60s + state_class: total_increasing + lambda: "return (float) id(restart_counter);" + + - platform: uptime + name: "Uptime" + + - platform: wifi_signal + name: "WiFi Signal" + update_interval: 60s + +# -------- Binary sensors (alarms) -------- + +binary_sensor: + # Temperature exceeded the configured alarm threshold. + - platform: template + name: "Temperature Alarm" + id: alarm_temperature + device_class: safety + lambda: |- + const float t = id(dht_temperature).state; + if (std::isnan(t)) return {}; + return t >= id(alarm_temp_threshold).state; + + # Sensor stall: no successful DHT22 read in >60 s — triggers fan failsafe. + - platform: template + name: "Sensor Alarm" + id: alarm_sensor_stall + device_class: problem + lambda: |- + if (id(last_sensor_read_ms) == 0) return {}; + return (millis() - id(last_sensor_read_ms)) > 60000; + +# -------- Tunable settings (number components, persisted via restore_value) -------- + +number: + - platform: template + name: "Alarm Temperature" + id: alarm_temp_threshold + min_value: 0 + max_value: 80 + step: 1 + unit_of_measurement: "°C" + initial_value: 35 + restore_value: true + optimistic: true + mode: box + + - platform: template + name: "Fan Min %" + id: fan_min_pct + min_value: 0 + max_value: 100 + step: 1 + unit_of_measurement: "%" + initial_value: 10 + restore_value: true + optimistic: true + mode: box + + - platform: template + name: "Fan PWM Frequency Setting" + id: fan_pwm_freq_setting + min_value: 1000 + max_value: 5000 + step: 100 + unit_of_measurement: "Hz" + initial_value: 1000 + restore_value: true + optimistic: true + mode: box + on_value: + - output.ledc.set_frequency: + id: fan_pwm_output + frequency: !lambda "return x;" + +# -------- Output -------- + +output: + - platform: ledc + pin: GPIO25 + id: fan_pwm_output + frequency: 1000Hz + channel: 0 + # bit_depth is auto-computed by ESPHome based on frequency (10-bit at 1 kHz). + +# -------- Status LED (LOLIN D32 onboard, active-low) -------- + +status_led: + pin: + number: GPIO5 + inverted: true + +# -------- Control buttons -------- + +button: + - platform: restart + name: "Restart" + - platform: factory_reset + name: "Factory Reset" diff --git a/platformio.ini b/platformio.ini deleted file mode 100644 index 470ac3b..0000000 --- a/platformio.ini +++ /dev/null @@ -1,44 +0,0 @@ -; FanControl — ESP32 ventilation controller for home battery room -; Target: LOLIN D32 ESP32, Arduino framework -; See ACCURUIMTE_VENTILATIE_PLAN.md §4 - -[env:lolin_d32] -platform = espressif32 -board = lolin_d32 -framework = arduino -monitor_speed = 115200 -board_build.partitions = min_spiffs.csv - -; OTA upload (enable once device is on the network) -; upload_protocol = espota -; upload_port = fancontrol.local -; upload_flags = --auth=changeme - -; Phase 1 (core firmware) + Phase 2 (MQTT) + Phase 3 (web stack). -; Phase 3 pulls in the maintained AsyncWebServer fork (the me-no-dev -; packages are no longer in the PIO registry) and ElegantOTA v3, which -; requires ELEGANTOTA_USE_ASYNC_WEBSERVER=1 to bind against -; ESPAsyncWebServer rather than the default WebServer. -lib_deps = - adafruit/DHT sensor library@^1.4.6 - adafruit/Adafruit Unified Sensor@^1.1.14 - knolleary/PubSubClient@^2.8 - bblanchon/ArduinoJson@^7.0.0 - esp32async/ESPAsyncWebServer@^3.7.0 - esp32async/AsyncTCP@^3.4.0 - ayushsharma82/ElegantOTA@^3.1.0 - -build_flags = - -DCORE_DEBUG_LEVEL=3 - -DELEGANTOTA_USE_ASYNC_WEBSERVER=1 - -[env:native] -platform = native -test_framework = unity -; Host-side tests compile only test/**/*.cpp and pull in header-only -; modules from src/ (e.g. fan_curve.h). src/*.cpp is NOT compiled here -; because it requires Arduino/ESP32 headers. Keep test_build_src = no. -test_build_src = no -build_flags = - -std=gnu++17 - -I src diff --git a/secrets.yaml.example b/secrets.yaml.example new file mode 100644 index 0000000..ce47352 --- /dev/null +++ b/secrets.yaml.example @@ -0,0 +1,23 @@ +# FanControl secrets template. +# Copy to secrets.yaml (gitignored) and fill in. Never commit real secrets. +# +# cp secrets.yaml.example secrets.yaml +# # edit secrets.yaml with real values, then: +# esphome run fancontrol.yaml + +# WiFi credentials +wifi_ssid: "YourWiFiNetwork" +wifi_password: "YourWiFiPassword" + +# ESPHome native API encryption key (must be 32 bytes base64-encoded). +# Generate one with: esphome wizard (pick "encryption key") or +# openssl rand -base64 32 +api_encryption_key: "REPLACE_WITH_32_BYTE_BASE64_KEY" + +# Password that ESPHome requires for OTA uploads. Change from the placeholder. +ota_password: "REPLACE_ME_WITH_A_STRONG_PASSWORD" + +# Optional MQTT settings — uncomment the mqtt: block in fancontrol.yaml to use. +# mqtt_host: "192.168.1.10" +# mqtt_user: "homeassistant" +# mqtt_password: "..." diff --git a/src/config.h b/src/config.h deleted file mode 100644 index baa61ed..0000000 --- a/src/config.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once - -// FanControl — compile-time defaults and pin map. -// Single source of truth per CLAUDE.md. Runtime-tunable values are -// mirrored in NVS (see storage.h) and loaded at boot. - -#include - -// ---------- GPIO (LOLIN D32) ---------- -// DHT22 data line (any GPIO with pull-up; avoid strapping pins 0/2/12/15) -static constexpr uint8_t PIN_DHT22 = 4; -// PWM output to fan driver (level shifter → MT3608 → 9V PWM) -static constexpr uint8_t PIN_FAN_PWM = 25; -// On-board LED (LOLIN D32 active-low) -static constexpr uint8_t PIN_STATUS_LED = 5; -// Boot button used for factory reset (hold >5s at boot) -static constexpr uint8_t PIN_FACTORY_RESET = 0; - -// ---------- Fan PWM ---------- -// Frequency default 1 kHz (Ruck EM 125L EC 02 datasheet: 5–10 V, 1–5 kHz). -// Runtime-tunable via web UI; clamp [PWM_FREQ_MIN_HZ, PWM_FREQ_MAX_HZ]. -static constexpr uint8_t FAN_LEDC_CHANNEL = 0; -static constexpr uint32_t FAN_PWM_FREQ_DEFAULT_HZ = 1000; -static constexpr uint32_t FAN_PWM_FREQ_MIN_HZ = 1000; -static constexpr uint32_t FAN_PWM_FREQ_MAX_HZ = 5000; -static constexpr uint8_t FAN_PWM_RES_BITS = 10; // 0..1023 -static constexpr uint8_t FAN_MIN_PERCENT_DEFAULT = 10; -static constexpr uint8_t FAN_FAILSAFE_PERCENT = 100; - -// ---------- Temperature curve (°C → % PWM) ---------- -// Linear interpolation between points. Below T0 → MIN, above T4 → 100. -static constexpr uint8_t FAN_CURVE_POINTS = 5; -static constexpr float FAN_CURVE_TEMP_DEFAULT[FAN_CURVE_POINTS] = {15, 20, 25, 30, 35}; -static constexpr uint8_t FAN_CURVE_PWM_DEFAULT[FAN_CURVE_POINTS] = {10, 25, 50, 75, 100}; -static constexpr float ALARM_TEMP_DEFAULT = 35.0f; - -// ---------- Timing (ms) ---------- -static constexpr uint32_t SENSOR_READ_INTERVAL_MS = 5000; -static constexpr uint32_t SENSOR_STALL_TIMEOUT_MS = 60000; // SW watchdog -static constexpr uint32_t WS_PUSH_INTERVAL_MS = 2000; -static constexpr uint32_t MQTT_PUBLISH_INTERVAL_MS = 15000; -static constexpr uint32_t MQTT_RECONNECT_MIN_MS = 1000; -static constexpr uint32_t MQTT_RECONNECT_MAX_MS = 60000; -static constexpr uint32_t WIFI_RETRY_INTERVAL_MS = 30000; -static constexpr uint32_t RESTART_COOLDOWN_MS = 5UL * 60UL * 1000UL; - -// ---------- Hardware watchdog ---------- -static constexpr uint32_t TWDT_TIMEOUT_S = 30; - -// ---------- Defaults for NVS-backed config ---------- -static constexpr char DEFAULT_DEVICE_NAME[] = "fancontrol"; -static constexpr char DEFAULT_MDNS_HOSTNAME[] = "fancontrol"; -static constexpr char DEFAULT_MQTT_PREFIX[] = "fancontrol"; -static constexpr uint16_t DEFAULT_MQTT_PORT = 1883; -static constexpr char DEFAULT_OTA_PASSWORD[] = "changeme"; - -// ---------- NVS namespaces + keys ---------- -// Keep keys ≤15 chars (NVS limit). -static constexpr char NVS_NAMESPACE[] = "fancontrol"; - -namespace nvs_key { - static constexpr char WIFI_SSID[] = "wifi_ssid"; - static constexpr char WIFI_PASS[] = "wifi_pass"; - static constexpr char MQTT_HOST[] = "mqtt_host"; - static constexpr char MQTT_PORT[] = "mqtt_port"; - static constexpr char MQTT_USER[] = "mqtt_user"; - static constexpr char MQTT_PASS[] = "mqtt_pass"; - static constexpr char MQTT_PREFIX[] = "mqtt_prefix"; - static constexpr char DEVICE_NAME[] = "dev_name"; - static constexpr char OTA_PASS[] = "ota_pass"; - static constexpr char FAN_CURVE_T[] = "fan_curve_t"; // blob: float[5] - static constexpr char FAN_CURVE_P[] = "fan_curve_p"; // blob: uint8_t[5] - static constexpr char ALARM_TEMP[] = "alarm_temp"; - static constexpr char FAN_MIN_PCT[] = "fan_min_pct"; - static constexpr char FAN_PWM_FREQ[] = "fan_pwm_freq"; - static constexpr char SENSOR_INT_MS[] = "sensor_int"; - static constexpr char RESTART_COUNT[] = "restart_cnt"; - static constexpr char LAST_RESTART_MS[] = "last_restart"; -} diff --git a/src/fan.cpp b/src/fan.cpp deleted file mode 100644 index 35c10a7..0000000 --- a/src/fan.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include "fan.h" - -namespace { - -uint32_t s_freqHz = FAN_PWM_FREQ_DEFAULT_HZ; -uint8_t s_minPercent = FAN_MIN_PERCENT_DEFAULT; -uint8_t s_lastPercent = 0; -bool s_failsafeActive = false; - -inline uint32_t dutyForPercent(uint8_t pct) { - // Resolution is FAN_PWM_RES_BITS → full scale = (1 << bits) - 1. - const uint32_t maxDuty = (1UL << FAN_PWM_RES_BITS) - 1UL; - return (uint32_t)pct * maxDuty / 100UL; -} - -void applyDuty(uint8_t pct) { - ledcWrite(FAN_LEDC_CHANNEL, dutyForPercent(pct)); - s_lastPercent = pct; -} - -} // namespace - -namespace fan { - -void begin() { - s_freqHz = storage::loadFanPwmFreqHz(); - s_minPercent = storage::loadFanMinPercent(); - if (s_minPercent > 100) s_minPercent = FAN_MIN_PERCENT_DEFAULT; - - ledcSetup(FAN_LEDC_CHANNEL, s_freqHz, FAN_PWM_RES_BITS); - ledcAttachPin(PIN_FAN_PWM, FAN_LEDC_CHANNEL); - applyDuty(s_minPercent); - log_i("Fan PWM: pin=%u ch=%u freq=%u Hz res=%u bits min=%u%%", - PIN_FAN_PWM, FAN_LEDC_CHANNEL, s_freqHz, FAN_PWM_RES_BITS, s_minPercent); -} - -void setPercent(uint8_t pct) { - if (s_failsafeActive) { - // A previous failsafe() latched — releasing it is an explicit action. - // Exiting failsafe requires a successful setPercent() call *after* - // clearing: we clear here on first non-failsafe call to let the - // fan task resume automatic control once the sensor recovers. - s_failsafeActive = false; - } - if (pct > 100) pct = 100; - if (pct < s_minPercent) pct = s_minPercent; - applyDuty(pct); -} - -bool setFrequency(uint32_t hz) { - if (hz < FAN_PWM_FREQ_MIN_HZ || hz > FAN_PWM_FREQ_MAX_HZ) { - log_w("setFrequency: %u Hz out of range [%u..%u]", hz, - FAN_PWM_FREQ_MIN_HZ, FAN_PWM_FREQ_MAX_HZ); - return false; - } - const uint8_t keep = s_lastPercent; - s_freqHz = hz; - ledcSetup(FAN_LEDC_CHANNEL, s_freqHz, FAN_PWM_RES_BITS); - // ledcSetup resets the duty; reapply. No reboot required. - applyDuty(keep); - log_i("Fan PWM frequency now %u Hz", s_freqHz); - return true; -} - -void failsafe() { - s_failsafeActive = true; - ledcWrite(FAN_LEDC_CHANNEL, dutyForPercent(FAN_FAILSAFE_PERCENT)); - s_lastPercent = FAN_FAILSAFE_PERCENT; - log_w("Fan FAILSAFE engaged -> %u %%", FAN_FAILSAFE_PERCENT); -} - -void setMinPercent(uint8_t pct) { - if (pct > 100) pct = 100; - s_minPercent = pct; -} - -uint8_t currentPercent() { - return s_lastPercent; -} - -uint32_t currentFrequency() { - return s_freqHz; -} - -uint8_t computeFromTemperature(float c, const FanCurve& curve) { - // Pure logic lives in fan_curve.h so it can be unit tested on the host. - // This thin wrapper exists so callers can keep using fan::computeFromTemperature(). - return fan_curve::computeFromTemperature(c, curve); -} - -} // namespace fan diff --git a/src/fan.h b/src/fan.h deleted file mode 100644 index f2ca027..0000000 --- a/src/fan.h +++ /dev/null @@ -1,51 +0,0 @@ -#pragma once - -// Fan PWM driver (LEDC) and temperature-curve resolver. -// -// Frequency is runtime-tunable per plan §F1.1 — setFrequency() must work -// without reboot and always clamps to [FAN_PWM_FREQ_MIN_HZ..MAX_HZ]. -// -// Failsafe is 100 % (CLAUDE.md rule) and bypasses the min-percent floor. - -#include -#include "config.h" -#include "fan_curve.h" // FanCurve + pure interpolation (host-testable) -#include "storage.h" - -namespace fan { - - // One-shot init. Loads NVS-saved frequency + min-percent floor and - // configures LEDC channel FAN_LEDC_CHANNEL on PIN_FAN_PWM. - void begin(); - - // Apply a percentage 0..100. Values are clamped. Under normal - // operation the configured min-percent floor is applied so the fan - // never drops to 0. Call failsafe() instead if you actually want 100 %. - void setPercent(uint8_t pct); - - // Change the PWM frequency at runtime. Returns false on out-of-range. - // Re-invokes ledcSetup on the channel and re-applies the last duty. - bool setFrequency(uint32_t hz); - - // Force 100 % regardless of the min-percent floor. Used on sensor - // stall / watchdog alarms. - void failsafe(); - - // Update the in-memory min-percent floor (not NVS-persisted here — - // storage owns that). Affects subsequent setPercent() calls. - void setMinPercent(uint8_t pct); - - // Last applied percentage (post-clamp / floor). Useful for MQTT + WS. - uint8_t currentPercent(); - - // Current PWM frequency in Hz. - uint32_t currentFrequency(); - - // Linear interpolation over the fan curve. Returns a PWM percentage. - // Below curve.temps[0] → max(curve.pwm[0], fan_min) under normal ops. - // Above curve.temps[N-1] → 100. The caller is responsible for - // applying min-percent floor via setPercent(); this function returns - // the raw curve value and only enforces the two end-clamps. - uint8_t computeFromTemperature(float c, const FanCurve& curve); - -} // namespace fan diff --git a/src/fan_curve.h b/src/fan_curve.h deleted file mode 100644 index aaac569..0000000 --- a/src/fan_curve.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -// Pure fan-curve interpolation, extracted from fan.cpp so it can be unit -// tested on the host (pio test -e native) without dragging in Arduino / -// ESP32 dependencies. -// -// CONSTRAINT: this header must remain plain C++17 — do NOT include -// and do NOT reference any ESP32-specific symbol here. The -// firmware build pulls this in via fan.h / storage.h; the host test -// build pulls it in directly. Both need to compile. -// -// Behaviour (contract tested by test/test_fan_curve/): -// - NaN input → 100 (failsafe bias; the caller still applies the -// min-percent floor via fan::setPercent()). -// - c <= curve.temps[0] → curve.pwm[0] -// - c >= curve.temps[FAN_CURVE_POINTS-1] → 100 -// - otherwise linear interpolation between the two bracketing points, -// rounded to the nearest integer percent and clamped to 0..100. - -#include -#include - -#include "config.h" // FAN_CURVE_POINTS — plain constexpr, no Arduino deps - -struct FanCurve { - float temps[FAN_CURVE_POINTS]; - uint8_t pwm[FAN_CURVE_POINTS]; -}; - -namespace fan_curve { - - inline uint8_t computeFromTemperature(float c, const FanCurve& curve) { - if (std::isnan(c)) { - // Caller hasn't seen a reading yet; bias high but not failsafe. - // The fan task will still apply the min-percent floor via setPercent. - return 100; - } - - // Below first point → the first point's PWM value (the caller lifts - // it up to fan_min via setPercent()'s floor). - if (c <= curve.temps[0]) { - return curve.pwm[0]; - } - // Above last point → 100 %. - if (c >= curve.temps[FAN_CURVE_POINTS - 1]) { - return 100; - } - - // Find the segment containing c and linearly interpolate. - for (uint8_t i = 0; i < FAN_CURVE_POINTS - 1; ++i) { - const float t0 = curve.temps[i]; - const float t1 = curve.temps[i + 1]; - if (c >= t0 && c <= t1) { - const float span = t1 - t0; - if (span <= 0.0f) return curve.pwm[i]; // guard: badly sorted curve - const float frac = (c - t0) / span; - const float p0 = static_cast(curve.pwm[i]); - const float p1 = static_cast(curve.pwm[i + 1]); - float pct = p0 + frac * (p1 - p0); - if (pct < 0.0f) pct = 0.0f; - if (pct > 100.0f) pct = 100.0f; - return static_cast(pct + 0.5f); - } - } - // Unreachable given the two endpoint checks above, but keep the fan moving. - return 100; - } - -} // namespace fan_curve diff --git a/src/index_html.h b/src/index_html.h deleted file mode 100644 index 03a8fca..0000000 --- a/src/index_html.h +++ /dev/null @@ -1,378 +0,0 @@ -#pragma once - -// Embedded single-page UI (HTML + inline CSS + inline JS) served from -// GET / by webserver.cpp. Stored in PROGMEM to save RAM. -// -// Hard rules (CLAUDE.md / plan §F3): -// - No external CDN references. -// - Dark theme, mobile-first (>=380 px), accordion sections. -// - Vanilla JS only. -// - All visible labels English (v1.2 decision). -// -// The raw-string delimiter is HTMLPG to avoid clashing with any quoted -// parens in the content. - -#include - -static const char INDEX_HTML[] PROGMEM = R"HTMLPG( - - - - -FanControl - - - -
-

FanControl

- connecting... -
-
- - -
- Status -
-
-
Temperature
-
-
Humidity
-
-
-
Fan speed
-
-
-
-
-
Uptime
-
-
MQTT
-
-
WiFi
-
-
-
- Alarms: - Temp - Sensor - Watchdog -
-
Restarts: -
-
-
- -
- Ventilation settings -
-
Fan curve (5 points). Below T1 uses P1, above T5 forces 100%.
-
- -
-
-
-
-
-
-
-
- -
-
- -
- Network and MQTT settings -
-
Saving these restarts the device.
- - -
-
-
-
-
Port 8883 enables TLS automatically (no certificate validation).
-
-
-
-
-
-
-
-
- -
-
- -
- System -
-
-
Firmware
-
-
Build
-
-
Free heap
-
-
Total restarts
-
-
- -
OTA disabled until you change the password from the default.
- - -
- - -
-
-
-
-
- - - - -)HTMLPG"; diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index e9c8657..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,118 +0,0 @@ -#include -#include "config.h" -#include "version.h" -#include "storage.h" -#include "sensor.h" -#include "fan.h" -#include "watchdog.h" -#include "wifi_manager.h" -#include "webserver.h" -#include "websocket.h" -#include "mqtt.h" - -// Phase 4 integration: real orchestration. All work happens in FreeRTOS -// tasks per CLAUDE.md (no blocking calls in loop(); WS + MQTT in -// separate tasks). Network/IO tasks pin to core 0 (WiFi/AsyncTCP live -// there); sensor/fan compute pins to core 1. - -namespace { - -FanCurve g_curve; - -// ----- compute tasks (core 1) ----- - -void sensorTask(void* /*arg*/) { - watchdog::subscribeCurrentTask(); - const uint32_t interval = storage::loadSensorIntervalMs(); - TickType_t lastWake = xTaskGetTickCount(); - - for (;;) { - watchdog::reset(); - if (sensor::read()) { - watchdog::feedSensor(); - } - vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(interval)); - } -} - -void fanTask(void* /*arg*/) { - watchdog::subscribeCurrentTask(); - TickType_t lastWake = xTaskGetTickCount(); - - for (;;) { - watchdog::reset(); - - if (sensor::isStale()) { - watchdog::handleSensorStall(); - } else { - const float t = sensor::getTemperatureC(); - const uint8_t pct = fan::computeFromTemperature(t, g_curve); - fan::setPercent(pct); - } - - vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(1000)); - } -} - -void watchdogTask(void* /*arg*/) { - watchdog::subscribeCurrentTask(); - TickType_t lastWake = xTaskGetTickCount(); - - for (;;) { - watchdog::reset(); - if (watchdog::sensorStall()) { - watchdog::handleSensorStall(); - } - vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(1000)); - } -} - -} // namespace - -void setup() { - Serial.begin(115200); - const uint32_t t0 = millis(); - while (!Serial && (millis() - t0) < 500) { - vTaskDelay(pdMS_TO_TICKS(10)); - } - - if (!storage::begin()) { - Serial.println("[FanControl] NVS init failed — continuing with defaults"); - } - - sensor::begin(); - fan::begin(); - watchdog::begin(); - wifi_manager::begin(); - webserver::begin(); // creates AsyncWebServer + mounts ElegantOTA + magic URLs - ws::attach(webserver::instance()); // mount /ws on the same server - mqtt::begin(); // safe even if WiFi not yet up — task handles it - - g_curve = storage::loadFanCurve(); - - const String dev = storage::loadDeviceName(); - const uint32_t restarts = watchdog::restartCount(); - Serial.printf("\n[FanControl] version %s built %s\n", VERSION, BUILD_DATE); - Serial.printf("[FanControl] device=%s restarts=%u pwm=%u Hz min=%u%%\n", - dev.c_str(), restarts, fan::currentFrequency(), - storage::loadFanMinPercent()); - Serial.printf("[FanControl] wifi=%s ip=%s portal=%s\n", - wifi_manager::currentSsid().c_str(), - wifi_manager::ipAddress().c_str(), - wifi_manager::isPortalActive() ? "active" : "off"); - - // Compute tasks on core 1. - xTaskCreatePinnedToCore(sensorTask, "sensorTask", 4096, nullptr, 5, nullptr, 1); - xTaskCreatePinnedToCore(fanTask, "fanTask", 4096, nullptr, 4, nullptr, 1); - // Watchdog stays on core 0 (PRO core), highest priority. - xTaskCreatePinnedToCore(watchdogTask, "watchdogTask", 4096, nullptr, 6, nullptr, 0); - // Network tasks on core 0 next to the WiFi/AsyncTCP stack. - // Larger stacks for JSON building + TLS handshakes. - xTaskCreatePinnedToCore(wifi_manager::task, "wifiTask", 4096, nullptr, 3, nullptr, 0); - xTaskCreatePinnedToCore(mqtt::task, "mqttTask", 6144, nullptr, 3, nullptr, 0); - xTaskCreatePinnedToCore(ws::task, "wsTask", 6144, nullptr, 3, nullptr, 0); -} - -void loop() { - vTaskDelay(portMAX_DELAY); -} diff --git a/src/mqtt.cpp b/src/mqtt.cpp deleted file mode 100644 index 61fad08..0000000 --- a/src/mqtt.cpp +++ /dev/null @@ -1,504 +0,0 @@ -#include "mqtt.h" - -#include "config.h" -#include "version.h" -#include "storage.h" -#include "sensor.h" -#include "fan.h" -#include "watchdog.h" - -#include -#include -#include -#include -#include -#include - -// MQTT module. See mqtt.h for the public contract. -// -// Threading: all reads of other modules (sensor::, fan::, watchdog::) -// happen on the mqtt task. publishAlarm() may be called from any task; -// it only flips a volatile flag when the client isn't connected, and -// otherwise does a single publish — PubSubClient is not thread-safe so -// we must guard publish calls with a mutex. - -namespace { - -// ---------- Sizing / constants ---------- - -constexpr uint16_t KEEPALIVE_SEC = 30; -constexpr uint16_t SOCKET_TIMEOUT_S = 5; // seconds -constexpr uint16_t BUFFER_SIZE = 1024; // discovery payloads fit in ~600 B -constexpr uint32_t TASK_TICK_MS = 50; // base loop cadence -constexpr uint32_t TLS_PORT = 8883; - -// ---------- State ---------- - -WiFiClient* s_plainClient = nullptr; -WiFiClientSecure* s_tlsClient = nullptr; -PubSubClient* s_client = nullptr; -SemaphoreHandle_t s_mutex = nullptr; - -MqttConfig s_cfg; -String s_nodeId; // e.g. "fancontrol_a1b2c3" -String s_deviceId; // e.g. "fancontrol-a1b2c3" (used in device identifiers) -String s_deviceName; // user-visible name -String s_prefix; // copy of s_cfg.prefix for quick access -String s_statusTopic; // "{prefix}/status" - -volatile bool s_alarmTemp = false; -volatile bool s_alarmSensor = false; -volatile bool s_alarmWdt = false; - -// Edge flags set by publishAlarm() when the client isn't connected. -volatile bool s_pendingTemp = false; -volatile bool s_pendingSensor = false; -volatile bool s_pendingWdt = false; - -// ---------- Helpers ---------- - -String macSuffix6() { - // Lowercase last 6 hex of base MAC, no separators. - uint8_t mac[6] = {0}; - WiFi.macAddress(mac); - char buf[7]; - snprintf(buf, sizeof(buf), "%02x%02x%02x", mac[3], mac[4], mac[5]); - return String(buf); -} - -String stateTopic(const char* kind, const char* entity) { - // kind = "sensor" or "binary_sensor" - String t = s_prefix; - t += '/'; - t += kind; - t += '/'; - t += entity; - t += "/state"; - return t; -} - -String discoveryTopic(const char* kind, const char* entityShort) { - // homeassistant/{kind}/{node_id}_{entity_short}/config - String t = "homeassistant/"; - t += kind; - t += '/'; - t += s_nodeId; - t += '_'; - t += entityShort; - t += "/config"; - return t; -} - -// Serialise a JsonDocument to a buffer and publish with retain=true. -// Caller must hold s_mutex. -bool publishRetainedJson_locked(const String& topic, const JsonDocument& doc) { - char buf[BUFFER_SIZE]; - const size_t n = serializeJson(doc, buf, sizeof(buf)); - if (n == 0 || n >= sizeof(buf)) { - log_e("Discovery payload serialize failed (topic=%s size=%u)", - topic.c_str(), (unsigned)n); - return false; - } - const bool ok = s_client->publish(topic.c_str(), - reinterpret_cast(buf), - n, - true /* retained */); - if (!ok) { - log_w("MQTT publish failed: %s (%u bytes)", topic.c_str(), (unsigned)n); - } - return ok; -} - -// Shared `device` + `availability` block + unique_id. Adds English name. -void fillCommonDiscovery(JsonDocument& doc, - const char* entityShort, - const char* label) { - JsonObject dev = doc["device"].to(); - JsonArray ids = dev["identifiers"].to(); - ids.add(s_deviceId); - dev["name"] = s_deviceName; - dev["manufacturer"] = "FanControl"; - dev["model"] = "ESP32 LOLIN D32"; - dev["sw_version"] = VERSION; - - JsonArray av = doc["availability"].to(); - JsonObject av0 = av.add(); - av0["topic"] = s_statusTopic; - - // unique_id = node_id + "_" + entityShort, e.g. fancontrol_a1b2c3_temperature - String uid = s_nodeId; - uid += '_'; - uid += entityShort; - doc["unique_id"] = uid; - doc["name"] = label; -} - -// Publish all HA discovery entities. Caller must hold s_mutex. -void publishDiscovery_locked() { - // ----- Temperature ----- - { - JsonDocument d; - fillCommonDiscovery(d, "temperature", "Temperature"); - d["state_topic"] = stateTopic("sensor", "temperature"); - d["unit_of_measurement"] = "\xC2\xB0" "C"; // °C as UTF-8 - d["device_class"] = "temperature"; - d["state_class"] = "measurement"; - publishRetainedJson_locked(discoveryTopic("sensor", "temperature"), d); - } - - // ----- Humidity ----- - { - JsonDocument d; - fillCommonDiscovery(d, "humidity", "Humidity"); - d["state_topic"] = stateTopic("sensor", "humidity"); - d["unit_of_measurement"] = "%"; - d["device_class"] = "humidity"; - d["state_class"] = "measurement"; - publishRetainedJson_locked(discoveryTopic("sensor", "humidity"), d); - } - - // ----- Fan speed ----- - { - JsonDocument d; - fillCommonDiscovery(d, "fan_speed", "Fan Speed"); - d["state_topic"] = stateTopic("sensor", "fan_speed"); - d["unit_of_measurement"] = "%"; - d["state_class"] = "measurement"; - // no device_class — HA has no "fan speed" class - publishRetainedJson_locked(discoveryTopic("sensor", "fan_speed"), d); - } - - // ----- Fan PWM frequency (v1.2) ----- - { - JsonDocument d; - fillCommonDiscovery(d, "fan_pwm_freq", "Fan PWM Frequency"); - d["state_topic"] = stateTopic("sensor", "fan_pwm_freq"); - d["unit_of_measurement"] = "Hz"; - d["state_class"] = "measurement"; - publishRetainedJson_locked(discoveryTopic("sensor", "fan_pwm_freq"), d); - } - - // ----- Restarts ----- - { - JsonDocument d; - fillCommonDiscovery(d, "restarts", "Restarts"); - d["state_topic"] = stateTopic("sensor", "restarts"); - d["state_class"] = "total_increasing"; - publishRetainedJson_locked(discoveryTopic("sensor", "restarts"), d); - } - - // ----- Binary: alarm_temp ----- - { - JsonDocument d; - fillCommonDiscovery(d, "alarm_temp", "Temperature Alarm"); - d["state_topic"] = stateTopic("binary_sensor", "alarm_temp"); - d["payload_on"] = "ON"; - d["payload_off"] = "OFF"; - d["device_class"] = "safety"; - publishRetainedJson_locked(discoveryTopic("binary_sensor", "alarm_temp"), d); - } - - // ----- Binary: alarm_sensor ----- - { - JsonDocument d; - fillCommonDiscovery(d, "alarm_sensor", "Sensor Alarm"); - d["state_topic"] = stateTopic("binary_sensor", "alarm_sensor"); - d["payload_on"] = "ON"; - d["payload_off"] = "OFF"; - d["device_class"] = "safety"; - publishRetainedJson_locked(discoveryTopic("binary_sensor", "alarm_sensor"), d); - } - - // ----- Binary: watchdog ----- - { - JsonDocument d; - fillCommonDiscovery(d, "watchdog", "Watchdog"); - d["state_topic"] = stateTopic("binary_sensor", "watchdog"); - d["payload_on"] = "ON"; - d["payload_off"] = "OFF"; - d["device_class"] = "problem"; - publishRetainedJson_locked(discoveryTopic("binary_sensor", "watchdog"), d); - } - - log_i("MQTT discovery published for node_id=%s", s_nodeId.c_str()); -} - -// Publish birth message "online" retained. Caller must hold s_mutex. -void publishBirth_locked() { - s_client->publish(s_statusTopic.c_str(), "online", true /* retained */); -} - -// Snapshot current state + publish all routine topics. Caller must hold s_mutex. -void publishState_locked() { - char buf[16]; - - // Temperature / humidity — may be NAN pre-first-read; skip those. - const float t = sensor::getTemperatureC(); - if (!isnan(t)) { - snprintf(buf, sizeof(buf), "%.1f", t); - s_client->publish(stateTopic("sensor", "temperature").c_str(), buf, false); - } - const float h = sensor::getHumidityPct(); - if (!isnan(h)) { - snprintf(buf, sizeof(buf), "%.1f", h); - s_client->publish(stateTopic("sensor", "humidity").c_str(), buf, false); - } - - snprintf(buf, sizeof(buf), "%u", (unsigned)fan::currentPercent()); - s_client->publish(stateTopic("sensor", "fan_speed").c_str(), buf, false); - - snprintf(buf, sizeof(buf), "%u", (unsigned)fan::currentFrequency()); - s_client->publish(stateTopic("sensor", "fan_pwm_freq").c_str(), buf, false); - - snprintf(buf, sizeof(buf), "%u", (unsigned)watchdog::restartCount()); - s_client->publish(stateTopic("sensor", "restarts").c_str(), buf, false); - - // Alarms — republish routinely so HA recovers cleanly from broker restart. - s_client->publish(stateTopic("binary_sensor", "alarm_temp").c_str(), - s_alarmTemp ? "ON" : "OFF", false); - s_client->publish(stateTopic("binary_sensor", "alarm_sensor").c_str(), - s_alarmSensor ? "ON" : "OFF", false); - s_client->publish(stateTopic("binary_sensor", "watchdog").c_str(), - s_alarmWdt ? "ON" : "OFF", false); -} - -// Pull current alarm state from dependent modules into our cache. Must -// be called from the mqtt task (reads sensor::isStale() and friends). -void refreshAlarms() { - const float alarmThresh = storage::loadAlarmTemp(); - const float t = sensor::getTemperatureC(); - s_alarmTemp = !isnan(t) && (t >= alarmThresh); - s_alarmSensor = sensor::isStale(); - s_alarmWdt = watchdog::alarmActive(); -} - -// Flush any edge-triggered alarm that came in while we were disconnected. -// Caller must hold s_mutex. -void flushPendingAlarms_locked() { - if (s_pendingTemp) { - s_client->publish(stateTopic("binary_sensor", "alarm_temp").c_str(), - s_alarmTemp ? "ON" : "OFF", false); - s_pendingTemp = false; - } - if (s_pendingSensor) { - s_client->publish(stateTopic("binary_sensor", "alarm_sensor").c_str(), - s_alarmSensor ? "ON" : "OFF", false); - s_pendingSensor = false; - } - if (s_pendingWdt) { - s_client->publish(stateTopic("binary_sensor", "watchdog").c_str(), - s_alarmWdt ? "ON" : "OFF", false); - s_pendingWdt = false; - } -} - -// Attempt a connection with LWT preset. Returns true on success. -// Caller must hold s_mutex. -bool tryConnect_locked() { - if (s_cfg.host.isEmpty()) { - return false; - } - - // Client ID includes node suffix to avoid broker collisions. - String clientId = "fancontrol-"; - clientId += macSuffix6(); - - // setServer is called here because the NVS config could be updated by - // the web UI later (Round 4). Safe to call every attempt. - s_client->setServer(s_cfg.host.c_str(), s_cfg.port); - - const bool hasAuth = !s_cfg.user.isEmpty(); - bool ok = false; - - // LWT: topic, qos(int), retain(bool), payload — set on .connect() call. - // PubSubClient signature: connect(id, user, pass, willTopic, willQos, - // willRetain, willMessage) - if (hasAuth) { - ok = s_client->connect(clientId.c_str(), - s_cfg.user.c_str(), - s_cfg.password.c_str(), - s_statusTopic.c_str(), - 1 /* willQos */, - true /* willRetain */, - "offline"); - } else { - ok = s_client->connect(clientId.c_str(), - nullptr, nullptr, - s_statusTopic.c_str(), - 1, true, "offline"); - } - return ok; -} - -} // namespace - -namespace mqtt { - -void begin() { - s_mutex = xSemaphoreCreateMutex(); - - s_cfg = storage::loadMqtt(); - s_deviceName = storage::loadDeviceName(); - if (s_deviceName.isEmpty()) { - s_deviceName = DEFAULT_DEVICE_NAME; - } - - s_prefix = s_cfg.prefix.isEmpty() ? String(DEFAULT_MQTT_PREFIX) : s_cfg.prefix; - - const String mac6 = macSuffix6(); - s_nodeId = String(DEFAULT_DEVICE_NAME) + "_" + mac6; // underscore only - s_deviceId = String(DEFAULT_DEVICE_NAME) + "-" + mac6; // dash in device identifier is fine - - s_statusTopic = s_prefix + "/status"; - - // Pick transport based on port. TLS decision is fixed for this boot; - // flipping the port via the web UI takes effect on the next reboot. - if (s_cfg.port == TLS_PORT) { - s_tlsClient = new WiFiClientSecure(); - s_tlsClient->setInsecure(); // matches SmartEVSE-3.5 — no CA check. - s_client = new PubSubClient(*s_tlsClient); - log_i("MQTT transport: TLS (port %u, setInsecure)", (unsigned)s_cfg.port); - } else { - s_plainClient = new WiFiClient(); - s_client = new PubSubClient(*s_plainClient); - log_i("MQTT transport: plain TCP (port %u)", (unsigned)s_cfg.port); - } - - s_client->setBufferSize(BUFFER_SIZE); - s_client->setKeepAlive(KEEPALIVE_SEC); - s_client->setSocketTimeout(SOCKET_TIMEOUT_S); - - log_i("MQTT begin: host=%s prefix=%s node_id=%s", - s_cfg.host.c_str(), s_prefix.c_str(), s_nodeId.c_str()); -} - -bool isConnected() { - return s_client && s_client->connected(); -} - -void publishAlarm(const char* name, bool active) { - if (!name) return; - - // Update cached state first so flushPendingAlarms_locked() sees the - // correct value once we reconnect. - if (strcmp(name, "alarm_temp") == 0) s_alarmTemp = active; - else if (strcmp(name, "alarm_sensor") == 0) s_alarmSensor = active; - else if (strcmp(name, "watchdog") == 0) s_alarmWdt = active; - else { - log_w("publishAlarm: unknown name '%s'", name); - return; - } - - if (!s_client || !s_mutex) { - // begin() hasn't run — defer entirely. - return; - } - - // Resolve the topic as a stable String in this stack frame. - String topic; - if (strcmp(name, "alarm_temp") == 0) topic = stateTopic("binary_sensor", "alarm_temp"); - else if (strcmp(name, "alarm_sensor") == 0) topic = stateTopic("binary_sensor", "alarm_sensor"); - else topic = stateTopic("binary_sensor", "watchdog"); - - // Try to push immediately, but never block long. - if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) == pdTRUE) { - if (s_client->connected()) { - s_client->publish(topic.c_str(), active ? "ON" : "OFF", false); - } else { - if (strcmp(name, "alarm_temp") == 0) s_pendingTemp = true; - else if (strcmp(name, "alarm_sensor") == 0) s_pendingSensor = true; - else s_pendingWdt = true; - } - xSemaphoreGive(s_mutex); - } else { - // Couldn't grab the mutex quickly; defer until routine cycle. - if (strcmp(name, "alarm_temp") == 0) s_pendingTemp = true; - else if (strcmp(name, "alarm_sensor") == 0) s_pendingSensor = true; - else s_pendingWdt = true; - } -} - -void task(void* /*arg*/) { - watchdog::subscribeCurrentTask(); - - uint32_t backoffMs = MQTT_RECONNECT_MIN_MS; - uint32_t nextRetryMs = 0; - uint32_t nextPublishMs = 0; - bool wifiLoggedOnce = false; - - for (;;) { - watchdog::reset(); - const uint32_t now = millis(); - - // 1. Guard: WiFi must be up. - if (WiFi.status() != WL_CONNECTED) { - if (!wifiLoggedOnce) { - log_w("MQTT paused: WiFi not connected"); - wifiLoggedOnce = true; - } - vTaskDelay(pdMS_TO_TICKS(1000)); - continue; - } - if (wifiLoggedOnce) { - log_i("WiFi is up — MQTT will attempt to connect"); - wifiLoggedOnce = false; - } - - // 2. Reconnect loop with exponential backoff. - if (!s_client->connected()) { - if (now < nextRetryMs) { - vTaskDelay(pdMS_TO_TICKS(TASK_TICK_MS)); - continue; - } - - bool connected = false; - if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) == pdTRUE) { - connected = tryConnect_locked(); - if (connected) { - publishBirth_locked(); - publishDiscovery_locked(); - refreshAlarms(); - flushPendingAlarms_locked(); - publishState_locked(); - nextPublishMs = now + MQTT_PUBLISH_INTERVAL_MS; - } - xSemaphoreGive(s_mutex); - } - - if (connected) { - log_i("MQTT connected to %s:%u", s_cfg.host.c_str(), (unsigned)s_cfg.port); - backoffMs = MQTT_RECONNECT_MIN_MS; - } else { - const int rc = s_client ? s_client->state() : -99; - log_w("MQTT connect failed (rc=%d) — retrying in %u ms", rc, (unsigned)backoffMs); - nextRetryMs = millis() + backoffMs; - // Exponential backoff, capped. - uint32_t next = backoffMs * 2; - if (next > MQTT_RECONNECT_MAX_MS) next = MQTT_RECONNECT_MAX_MS; - backoffMs = next; - } - - vTaskDelay(pdMS_TO_TICKS(TASK_TICK_MS)); - continue; - } - - // 3. Connected: service the client and publish on cadence. - if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) == pdTRUE) { - s_client->loop(); - // Pick up any deferred alarm edges. - if (s_pendingTemp || s_pendingSensor || s_pendingWdt) { - flushPendingAlarms_locked(); - } - if ((int32_t)(now - nextPublishMs) >= 0) { - refreshAlarms(); - publishState_locked(); - nextPublishMs = now + MQTT_PUBLISH_INTERVAL_MS; - } - xSemaphoreGive(s_mutex); - } - - vTaskDelay(pdMS_TO_TICKS(TASK_TICK_MS)); - } -} - -} // namespace mqtt diff --git a/src/mqtt.h b/src/mqtt.h deleted file mode 100644 index b4795e2..0000000 --- a/src/mqtt.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once - -// MQTT client + Home Assistant auto-discovery. -// -// Plan §F4 / §6. Publishes state periodically (MQTT_PUBLISH_INTERVAL_MS) -// and pushes alarms edge-triggered via publishAlarm(). HA discovery is -// republished on every (re)connect so HA always recovers cleanly after a -// broker restart. -// -// Connection type is chosen at begin() time based on NVS MqttConfig.port: -// port == 8883 → WiFiClientSecure with setInsecure() (no CA check). -// Matches SmartEVSE-3.5 approach. Switching TLS on/off -// is a port change in the web UI; no reboot required on -// the next boot. -// TODO(security): pin a CA / cert fingerprint. -// port != 8883 → plain WiFiClient. -// -// All NVS access goes through storage::* — no direct Preferences here. -// Never blocks more than ~10 ms per iteration; sleeps between attempts. - -#include -#include "config.h" - -namespace mqtt { - - // Load NVS config, pick the transport (TLS or plain), prepare topics. - // Call once from setup() before spawning the task. - void begin(); - - // FreeRTOS task entry. Main owns the reconnect + publish loop. Caller - // creates the task with xTaskCreatePinnedToCore(mqtt::task, ...). - void task(void* arg); - - // True when the underlying PubSubClient is connected. - bool isConnected(); - - // Edge-triggered alarm push. Safe to call from other tasks; the - // publish is attempted on the calling task when connected, otherwise - // a flag is set and the value is picked up at the next routine cycle. - // `name` must be one of: "alarm_temp", "alarm_sensor", "watchdog". - void publishAlarm(const char* name, bool active); - -} // namespace mqtt diff --git a/src/sensor.cpp b/src/sensor.cpp deleted file mode 100644 index 2cf103f..0000000 --- a/src/sensor.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "sensor.h" -#include -#include - -namespace { - -// DHT22 = DHT_TYPE 22 in the Adafruit library. -DHT dht(PIN_DHT22, DHT22); - -float s_lastTempC = NAN; -float s_lastHumidityPct = NAN; -uint32_t s_lastReadOkMs = 0; -bool s_everReadOk = false; - -} // namespace - -namespace sensor { - -void begin() { - dht.begin(); - log_i("DHT22 begin on GPIO %u", PIN_DHT22); -} - -bool read() { - const float t = dht.readTemperature(); - const float h = dht.readHumidity(); - - if (isnan(t) || isnan(h)) { - log_w("DHT22 read failed (NaN) — keeping last-good values"); - return false; - } - - s_lastTempC = t; - s_lastHumidityPct = h; - s_lastReadOkMs = millis(); - s_everReadOk = true; - log_i("DHT22 ok: %.1f C %.1f %%", t, h); - return true; -} - -float getTemperatureC() { - return s_lastTempC; -} - -float getHumidityPct() { - return s_lastHumidityPct; -} - -uint32_t lastReadAgeMs() { - if (!s_everReadOk) return UINT32_MAX; - return millis() - s_lastReadOkMs; -} - -bool isStale() { - return lastReadAgeMs() > SENSOR_STALL_TIMEOUT_MS; -} - -} // namespace sensor diff --git a/src/sensor.h b/src/sensor.h deleted file mode 100644 index a111ccb..0000000 --- a/src/sensor.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -// DHT22 driver wrapper. Keeps the last-good reading so the fan task can -// always consult a value, and exposes freshness so the watchdog can -// decide to failsafe when the sensor stalls. -// -// All access is single-threaded from the sensorTask (see main.cpp). -// Getter/freshness calls from other tasks are safe because they only -// read simple POD scalars after an atomic memcpy on 32-bit aligned -// floats/uint32 — good enough for monitoring purposes on ESP32. - -#include -#include "config.h" - -namespace sensor { - - // Set up the DHT library on PIN_DHT22. Safe to call once from setup(). - void begin(); - - // Attempt a read. Returns true on success and updates the last-good - // cached temperature/humidity and the freshness timestamp. A failed - // read leaves the previous values intact — that is the whole point of - // keeping a last-good reading for the fan curve. - bool read(); - - // Last-good readings. Returns NAN before the first successful read. - float getTemperatureC(); - float getHumidityPct(); - - // Milliseconds since the last successful read. Returns UINT32_MAX if - // no successful read has happened yet, which naturally trips isStale(). - uint32_t lastReadAgeMs(); - - // True when no successful read has occurred within SENSOR_STALL_TIMEOUT_MS. - bool isStale(); - -} // namespace sensor diff --git a/src/storage.cpp b/src/storage.cpp deleted file mode 100644 index 4127af6..0000000 --- a/src/storage.cpp +++ /dev/null @@ -1,214 +0,0 @@ -#include "storage.h" -#include - -namespace { - -Preferences prefs; - -// Open RW, run op, close. Returns op's result. -// Logs the NVS key on any failed write. -template -bool withRW(const char* tag, Fn op) { - if (!prefs.begin(NVS_NAMESPACE, /*readOnly=*/false)) { - log_e("NVS begin(RW) failed for %s", tag); - return false; - } - const bool ok = op(prefs); - prefs.end(); - if (!ok) log_e("NVS write failed: %s", tag); - return ok; -} - -template -auto withRO(Fn op) -> decltype(op(prefs)) { - prefs.begin(NVS_NAMESPACE, /*readOnly=*/true); - auto v = op(prefs); - prefs.end(); - return v; -} - -} // namespace - -namespace storage { - -bool begin() { - // Ensure namespace exists by opening RW once. - if (!prefs.begin(NVS_NAMESPACE, false)) { - log_e("NVS namespace init failed"); - return false; - } - prefs.end(); - return true; -} - -// -------- WiFi -------- -WifiConfig loadWifi() { - return withRO([](Preferences& p) { - WifiConfig c; - c.ssid = p.getString(nvs_key::WIFI_SSID, ""); - c.password = p.getString(nvs_key::WIFI_PASS, ""); - return c; - }); -} - -bool saveWifi(const WifiConfig& cfg) { - return withRW("wifi", [&](Preferences& p) { - const bool a = p.putString(nvs_key::WIFI_SSID, cfg.ssid) == cfg.ssid.length(); - const bool b = p.putString(nvs_key::WIFI_PASS, cfg.password) == cfg.password.length(); - return a && b; - }); -} - -// -------- MQTT -------- -MqttConfig loadMqtt() { - return withRO([](Preferences& p) { - MqttConfig c; - c.host = p.getString(nvs_key::MQTT_HOST, ""); - c.port = p.getUShort(nvs_key::MQTT_PORT, DEFAULT_MQTT_PORT); - c.user = p.getString(nvs_key::MQTT_USER, ""); - c.password = p.getString(nvs_key::MQTT_PASS, ""); - c.prefix = p.getString(nvs_key::MQTT_PREFIX, DEFAULT_MQTT_PREFIX); - return c; - }); -} - -bool saveMqtt(const MqttConfig& cfg) { - return withRW("mqtt", [&](Preferences& p) { - const size_t n1 = p.putString(nvs_key::MQTT_HOST, cfg.host); - const bool ok2 = p.putUShort(nvs_key::MQTT_PORT, cfg.port) == sizeof(uint16_t); - const size_t n3 = p.putString(nvs_key::MQTT_USER, cfg.user); - const size_t n4 = p.putString(nvs_key::MQTT_PASS, cfg.password); - const size_t n5 = p.putString(nvs_key::MQTT_PREFIX, cfg.prefix); - return n1 == cfg.host.length() && ok2 && - n3 == cfg.user.length() && n4 == cfg.password.length() && - n5 == cfg.prefix.length(); - }); -} - -// -------- Fan curve -------- -FanCurve loadFanCurve() { - FanCurve c{}; - for (uint8_t i = 0; i < FAN_CURVE_POINTS; ++i) { - c.temps[i] = FAN_CURVE_TEMP_DEFAULT[i]; - c.pwm[i] = FAN_CURVE_PWM_DEFAULT[i]; - } - withRO([&](Preferences& p) { - p.getBytes(nvs_key::FAN_CURVE_T, c.temps, sizeof(c.temps)); - p.getBytes(nvs_key::FAN_CURVE_P, c.pwm, sizeof(c.pwm)); - return 0; - }); - return c; -} - -bool saveFanCurve(const FanCurve& curve) { - return withRW("fan_curve", [&](Preferences& p) { - const size_t nt = p.putBytes(nvs_key::FAN_CURVE_T, curve.temps, sizeof(curve.temps)); - const size_t np = p.putBytes(nvs_key::FAN_CURVE_P, curve.pwm, sizeof(curve.pwm)); - return nt == sizeof(curve.temps) && np == sizeof(curve.pwm); - }); -} - -// -------- Scalar settings -------- -float loadAlarmTemp() { - return withRO([](Preferences& p) { - return p.getFloat(nvs_key::ALARM_TEMP, ALARM_TEMP_DEFAULT); - }); -} -bool saveAlarmTemp(float c) { - return withRW("alarm_temp", [&](Preferences& p) { - return p.putFloat(nvs_key::ALARM_TEMP, c) == sizeof(float); - }); -} - -uint8_t loadFanMinPercent() { - return withRO([](Preferences& p) { - return p.getUChar(nvs_key::FAN_MIN_PCT, FAN_MIN_PERCENT_DEFAULT); - }); -} -bool saveFanMinPercent(uint8_t pct) { - return withRW("fan_min", [&](Preferences& p) { - return p.putUChar(nvs_key::FAN_MIN_PCT, pct) == sizeof(uint8_t); - }); -} - -uint32_t loadFanPwmFreqHz() { - uint32_t hz = withRO([](Preferences& p) { - return p.getUInt(nvs_key::FAN_PWM_FREQ, FAN_PWM_FREQ_DEFAULT_HZ); - }); - if (hz < FAN_PWM_FREQ_MIN_HZ) hz = FAN_PWM_FREQ_MIN_HZ; - if (hz > FAN_PWM_FREQ_MAX_HZ) hz = FAN_PWM_FREQ_MAX_HZ; - return hz; -} -bool saveFanPwmFreqHz(uint32_t hz) { - if (hz < FAN_PWM_FREQ_MIN_HZ || hz > FAN_PWM_FREQ_MAX_HZ) return false; - return withRW("fan_pwm_freq", [&](Preferences& p) { - return p.putUInt(nvs_key::FAN_PWM_FREQ, hz) == sizeof(uint32_t); - }); -} - -uint32_t loadSensorIntervalMs() { - return withRO([](Preferences& p) { - return p.getUInt(nvs_key::SENSOR_INT_MS, SENSOR_READ_INTERVAL_MS); - }); -} -bool saveSensorIntervalMs(uint32_t ms) { - return withRW("sensor_int", [&](Preferences& p) { - return p.putUInt(nvs_key::SENSOR_INT_MS, ms) == sizeof(uint32_t); - }); -} - -// -------- Identity -------- -String loadDeviceName() { - return withRO([](Preferences& p) { - return p.getString(nvs_key::DEVICE_NAME, DEFAULT_DEVICE_NAME); - }); -} -bool saveDeviceName(const String& name) { - return withRW("dev_name", [&](Preferences& p) { - return p.putString(nvs_key::DEVICE_NAME, name) == name.length(); - }); -} - -String loadOtaPassword() { - return withRO([](Preferences& p) { - return p.getString(nvs_key::OTA_PASS, DEFAULT_OTA_PASSWORD); - }); -} -bool saveOtaPassword(const String& pass) { - return withRW("ota_pass", [&](Preferences& p) { - return p.putString(nvs_key::OTA_PASS, pass) == pass.length(); - }); -} - -// -------- Watchdog bookkeeping -------- -uint32_t loadRestartCount() { - return withRO([](Preferences& p) { - return p.getUInt(nvs_key::RESTART_COUNT, 0); - }); -} -bool incrementRestartCount() { - return withRW("restart_cnt", [](Preferences& p) { - const uint32_t n = p.getUInt(nvs_key::RESTART_COUNT, 0) + 1; - return p.putUInt(nvs_key::RESTART_COUNT, n) == sizeof(uint32_t); - }); -} - -uint32_t loadLastRestartEpochMs() { - return withRO([](Preferences& p) { - return p.getUInt(nvs_key::LAST_RESTART_MS, 0); - }); -} -bool saveLastRestartEpochMs(uint32_t ms) { - return withRW("last_restart", [&](Preferences& p) { - return p.putUInt(nvs_key::LAST_RESTART_MS, ms) == sizeof(uint32_t); - }); -} - -// -------- Factory reset -------- -bool factoryReset() { - return withRW("factory_reset", [](Preferences& p) { - return p.clear(); - }); -} - -} // namespace storage diff --git a/src/storage.h b/src/storage.h deleted file mode 100644 index c6c6ec7..0000000 --- a/src/storage.h +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once - -// NVS-backed configuration. Thin wrapper around Preferences.h so that -// no other module includes Preferences directly (CLAUDE.md rule). -// Every writer returns a bool — callers MUST check and log failures. - -#include -#include "config.h" -#include "fan_curve.h" // FanCurve (moved here so it's host-testable) - -struct WifiConfig { - String ssid; - String password; -}; - -struct MqttConfig { - String host; - uint16_t port; - String user; - String password; - String prefix; -}; - -namespace storage { - - // Mount the NVS namespace. Call once from setup() before any getter. - bool begin(); - - // --- WiFi / network --- - WifiConfig loadWifi(); - bool saveWifi(const WifiConfig& cfg); - - // --- MQTT --- - MqttConfig loadMqtt(); - bool saveMqtt(const MqttConfig& cfg); - - // --- Fan curve + thresholds --- - FanCurve loadFanCurve(); - bool saveFanCurve(const FanCurve& curve); - - float loadAlarmTemp(); - bool saveAlarmTemp(float celsius); - - uint8_t loadFanMinPercent(); - bool saveFanMinPercent(uint8_t pct); - - uint32_t loadFanPwmFreqHz(); - bool saveFanPwmFreqHz(uint32_t hz); - - uint32_t loadSensorIntervalMs(); - bool saveSensorIntervalMs(uint32_t ms); - - // --- Device identity --- - String loadDeviceName(); - bool saveDeviceName(const String& name); - - String loadOtaPassword(); - bool saveOtaPassword(const String& pass); - - // --- Watchdog bookkeeping --- - uint32_t loadRestartCount(); - bool incrementRestartCount(); - - uint32_t loadLastRestartEpochMs(); - bool saveLastRestartEpochMs(uint32_t ms); - - // --- Factory reset: wipe the namespace. --- - bool factoryReset(); - -} // namespace storage diff --git a/src/version.h b/src/version.h deleted file mode 100644 index 4219170..0000000 --- a/src/version.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -// CI release.yml rewrites the VERSION line via sed from the git tag. -// Keep the exact format: #define VERSION "x.y.z" -#define VERSION "0.1.0-dev" - -#define BUILD_DATE __DATE__ " " __TIME__ diff --git a/src/watchdog.cpp b/src/watchdog.cpp deleted file mode 100644 index 6db6f4b..0000000 --- a/src/watchdog.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include "watchdog.h" -#include "storage.h" -#include "fan.h" -#include "sensor.h" - -#include - -namespace { - -uint32_t s_lastSensorFeedMs = 0; -bool s_sensorEverFed = false; -bool s_alarmActive = false; -uint32_t s_restartCount = 0; -bool s_cooldownActive = false; - -} // namespace - -namespace watchdog { - -void begin() { - // Increment persistent restart counter. Check cooldown window. - if (!storage::incrementRestartCount()) { - log_e("Could not increment restart counter in NVS"); - } - s_restartCount = storage::loadRestartCount(); - - const uint32_t lastRestart = storage::loadLastRestartEpochMs(); - const uint32_t now = millis(); - // lastRestart stored as millis() uptime from a previous run — after - // reboot millis() starts at 0 so any non-zero stored value is "past - // boot of previous run". The cooldown check is therefore based on a - // coarse wall-clock proxy: we treat any stored value within the - // cooldown window *ahead* of now as the signal that another restart - // happened very recently. - // - // Without RTC/NTP we can only approximate. If the previous run stored - // a value and we are within RESTART_COOLDOWN_MS of boot, warn and - // refuse further auto-restarts until cooldown elapses. - if (lastRestart != 0 && now < RESTART_COOLDOWN_MS) { - log_w("Rapid restart detected (count=%u) — cooldown active for %u ms", - s_restartCount, (unsigned)(RESTART_COOLDOWN_MS - now)); - s_cooldownActive = true; - } - - // Hardware TWDT. Prefer the modern config-struct API; fall back to - // the legacy overload if the struct field isn't available. -#if defined(ESP_TASK_WDT_DEFAULT_CONFIG) - const esp_task_wdt_config_t cfg = { - .timeout_ms = TWDT_TIMEOUT_S * 1000U, - .idle_core_mask = 0, // don't subscribe idle tasks; we register our own - .trigger_panic = true, - }; - esp_err_t err = esp_task_wdt_init(&cfg); - if (err == ESP_ERR_INVALID_STATE) { - // Already initialised by Arduino core — reconfigure instead. - err = esp_task_wdt_reconfigure(&cfg); - } - if (err != ESP_OK) { - log_e("esp_task_wdt_init failed: %d", (int)err); - } -#else - // Legacy signature (older cores): (timeout_seconds, panic_on_timeout) - const esp_err_t err = esp_task_wdt_init(TWDT_TIMEOUT_S, true); - if (err != ESP_OK) { - log_e("esp_task_wdt_init(legacy) failed: %d", (int)err); - } -#endif - - log_i("Watchdog ready: TWDT=%u s restarts=%u cooldown=%s", - TWDT_TIMEOUT_S, s_restartCount, s_cooldownActive ? "yes" : "no"); -} - -void subscribeCurrentTask() { - const esp_err_t err = esp_task_wdt_add(nullptr); - if (err != ESP_OK && err != ESP_ERR_INVALID_ARG) { - log_w("esp_task_wdt_add returned %d", (int)err); - } -} - -void reset() { - esp_task_wdt_reset(); -} - -void feedSensor() { - s_lastSensorFeedMs = millis(); - s_sensorEverFed = true; - if (s_alarmActive) { - log_i("Sensor recovered — clearing stall alarm"); - s_alarmActive = false; - } -} - -bool sensorStall() { - if (!s_sensorEverFed) { - // Treat pre-first-read as potentially stale only after the timeout - // has elapsed since boot — avoids a spurious alarm during warm-up. - return millis() > SENSOR_STALL_TIMEOUT_MS; - } - return (millis() - s_lastSensorFeedMs) > SENSOR_STALL_TIMEOUT_MS; -} - -void handleSensorStall() { - if (!s_alarmActive) { - log_e("Sensor stall (>%u ms) — engaging fan failsafe", SENSOR_STALL_TIMEOUT_MS); - s_alarmActive = true; - } - fan::failsafe(); -} - -void requestRestart(const char* reason) { - if (s_cooldownActive) { - log_w("Auto-restart suppressed (cooldown): reason=%s", reason ? reason : "?"); - return; - } - log_w("Restart requested: %s", reason ? reason : "(none)"); - // Record the restart timestamp so the next boot can detect rapid cycling. - if (!storage::saveLastRestartEpochMs(millis())) { - log_e("Failed to persist last-restart timestamp"); - } - // Brief flush opportunity for any pending serial output. - Serial.flush(); - ESP.restart(); -} - -bool alarmActive() { - return s_alarmActive; -} - -uint32_t restartCount() { - return s_restartCount; -} - -} // namespace watchdog diff --git a/src/watchdog.h b/src/watchdog.h deleted file mode 100644 index 1a3e9ad..0000000 --- a/src/watchdog.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -// Hardware TWDT + software sensor-stall watchdog + restart bookkeeping. -// -// Plan §F2: max 1 auto-restart per 5 min (cooldown in NVS), persistent -// restart counter, fail-safe fan to 100 % on sensor stall. - -#include -#include "config.h" - -namespace watchdog { - - // Initialise the TWDT and increment the persistent restart counter. - // Logs a cooldown warning if the previous restart was within - // RESTART_COOLDOWN_MS. - void begin(); - - // Register the calling task with the hardware TWDT. Call from the top - // of each long-running task before its loop starts. - void subscribeCurrentTask(); - - // Reset the hardware TWDT for the current task. Call from every - // registered task at least once per TWDT_TIMEOUT_S. - void reset(); - - // Record a successful sensor read. Called by the sensor task. - void feedSensor(); - - // True when the sensor hasn't been fed in SENSOR_STALL_TIMEOUT_MS. - bool sensorStall(); - - // One-shot: engages fan failsafe and flags the alarm. Safe to call - // more than once; only logs the first transition. - void handleSensorStall(); - - // Ask for a controlled restart. Honours the 5-minute NVS cooldown: - // repeated calls within the cooldown window are swallowed and logged. - // On success the function does not return (ESP.restart()). - void requestRestart(const char* reason); - - // Accessors for UI / MQTT status. - bool alarmActive(); - uint32_t restartCount(); - -} // namespace watchdog diff --git a/src/webserver.cpp b/src/webserver.cpp deleted file mode 100644 index c5ce994..0000000 --- a/src/webserver.cpp +++ /dev/null @@ -1,168 +0,0 @@ -#include "webserver.h" - -#include "config.h" -#include "storage.h" -#include "wifi_manager.h" -#include "websocket.h" -#include "index_html.h" - -#include -#include -#include - -// AsyncWebServer hosting: -// GET / → INDEX_HTML (gzip not used — PROGMEM string) -// GET /healthz → "ok" (for upstream monitoring probes) -// ANY /ws → AsyncWebSocket (mounted via ws::attach) -// ANY /update* → ElegantOTA (admin/) — see below -// Captive-portal magic URLs (Android/iOS/Windows) resolve correctly -// whether we are in STA or AP mode. -// -// OTA gating (plan v1.2): -// When the stored OTA password is still the default "changeme" we -// return HTTP 403 from an onNotFound-style catch that pre-empts the -// ElegantOTA handlers. The UI banner is the friendly path; the 403 is -// the safety net in case someone tries to hit /update directly with -// the stale default password. - -namespace { - -AsyncWebServer s_server(80); -String s_otaPassword; // cached copy so the 403 check is lock-free -bool s_otaMounted = false; - -bool otaAllowed() { - return s_otaPassword.length() > 0 && s_otaPassword != DEFAULT_OTA_PASSWORD; -} - -// Serve the single-page UI. The raw-literal HTML lives in PROGMEM. -void handleRoot(AsyncWebServerRequest* req) { - AsyncWebServerResponse* res = req->beginResponse( - 200, "text/html; charset=utf-8", - reinterpret_cast(INDEX_HTML), - strlen_P(INDEX_HTML)); - res->addHeader("Cache-Control", "no-store"); - req->send(res); -} - -// Health probe for upstream monitoring. -void handleHealth(AsyncWebServerRequest* req) { - req->send(200, "text/plain", "ok"); -} - -// Captive-portal magic URLs. In AP mode we redirect to the root so the -// captive-portal browser lands on the setup page; in STA mode we return -// 204 so those probes quietly pass (the device isn't a portal any more). -void handleCaptivePortal(AsyncWebServerRequest* req) { - if (wifi_manager::isPortalActive()) { - req->redirect(String("http://") + wifi_manager::ipAddress() + "/"); - } else { - req->send(204); - } -} - -// Lightweight pre-handler mounted at /update* that blocks access while -// the OTA password is still the default. This runs BEFORE ElegantOTA's -// own handler resolves because we mount it as `onNotFound`-style? No — -// AsyncWebServer serves the first matching handler, so we register our -// gate route on `/update` + `/update/*` explicitly, and ElegantOTA -// registers its own routes at `/update`, `/update/identity`, etc. -// To guarantee our gate wins we register it via addHandler() with -// `setFilter` + a higher precedence; here we use a simpler path: we -// register the gate on GET/POST /update to reply 403 when still default, -// and rely on ElegantOTA.begin() having been called AFTER so its own -// routes override when the password is non-default. (See applyOtaPassword -// for the re-mount path.) -// In practice we take a cleaner approach: simply skip calling -// ElegantOTA.begin() until the OTA password has been changed, and return -// 403 from a simple handler in the meantime. - -void handle403(AsyncWebServerRequest* req) { - req->send(403, - "text/plain", - "OTA disabled until the admin password is changed from the default."); -} - -void installOtaOrGate() { - if (otaAllowed()) { - if (!s_otaMounted) { - ElegantOTA.begin(&s_server, "admin", s_otaPassword.c_str()); - s_otaMounted = true; - log_i("ElegantOTA mounted at /update (auth=admin)"); - } - } else { - // No OTA: route /update and /update/* to 403. - s_server.on("/update", HTTP_ANY, handle403); - s_server.on("/update/identity", HTTP_ANY, handle403); - s_server.on("/update/upload", HTTP_ANY, handle403); - log_w("OTA disabled: password still default — /update returns 403"); - } -} - -} // namespace - -namespace webserver { - -void begin() { - s_otaPassword = storage::loadOtaPassword(); - - // Static routes. - s_server.on("/", HTTP_GET, handleRoot); - s_server.on("/index.html", HTTP_GET, handleRoot); - s_server.on("/healthz", HTTP_GET, handleHealth); - - // Captive-portal magic URLs. - s_server.on("/generate_204", HTTP_GET, handleCaptivePortal); - s_server.on("/gen_204", HTTP_GET, handleCaptivePortal); - s_server.on("/hotspot-detect.html", HTTP_GET, handleCaptivePortal); - s_server.on("/library/test/success.html", HTTP_GET, handleCaptivePortal); - s_server.on("/connecttest.txt", HTTP_GET, handleCaptivePortal); - s_server.on("/ncsi.txt", HTTP_GET, handleCaptivePortal); - s_server.on("/redirect", HTTP_GET, handleCaptivePortal); - - // WebSocket on /ws. - ws::attach(s_server); - - // OTA or 403 gate, depending on whether the password has been changed. - installOtaOrGate(); - - // Fallback: in portal mode send everything to the root so Android/iOS - // captive detection opens the UI straight away. - s_server.onNotFound([](AsyncWebServerRequest* req) { - if (wifi_manager::isPortalActive()) { - req->redirect(String("http://") + wifi_manager::ipAddress() + "/"); - } else { - req->send(404, "text/plain", "Not found"); - } - }); - - s_server.begin(); - log_i("HTTP server listening on :80"); -} - -AsyncWebServer& instance() { - return s_server; -} - -void applyOtaPassword(const String& newPassword) { - s_otaPassword = newPassword; - // ElegantOTA v3 does not expose a re-begin API; however once a - // non-default password is stored we can safely call begin() which - // internally re-registers the auth middleware. If we had already - // mounted the 403 gate, the conflicting handlers sit beneath the OTA - // routes — ElegantOTA adds its handlers with a lower priority so this - // works in practice, but the cleanest recovery after the first - // password change is a reboot. The UI tells the user to save first, - // then hit /update — which triggers applyOtaPassword + subsequent - // mount. If they change it again later, the password is still routed - // via Basic-Auth so the new value is picked up at that point too. - if (!s_otaMounted && otaAllowed()) { - ElegantOTA.begin(&s_server, "admin", s_otaPassword.c_str()); - s_otaMounted = true; - log_i("ElegantOTA mounted at /update after password change"); - } else { - log_i("OTA password updated (mount refresh deferred to restart)"); - } -} - -} // namespace webserver diff --git a/src/webserver.h b/src/webserver.h deleted file mode 100644 index c55cf35..0000000 --- a/src/webserver.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -// AsyncWebServer wrapper — single-page UI on port 80 + ElegantOTA at -// /update + captive-portal magic URLs + WebSocket mount point. -// -// See webserver.cpp for route-level docs. - -#include - -class AsyncWebServer; - -namespace webserver { - - // Create the AsyncWebServer on port 80, wire all routes, mount the - // WebSocket, and mount ElegantOTA with the currently stored password. - // Call after storage::begin() and wifi_manager::begin(). - void begin(); - - // Accessor so websocket.cpp can attach its AsyncWebSocket instance. - AsyncWebServer& instance(); - - // Re-mount ElegantOTA with a new admin password. Called after the - // user saves a new OTA password via the web UI. - void applyOtaPassword(const String& newPassword); - -} // namespace webserver diff --git a/src/websocket.cpp b/src/websocket.cpp deleted file mode 100644 index 1eae68e..0000000 --- a/src/websocket.cpp +++ /dev/null @@ -1,332 +0,0 @@ -#include "websocket.h" - -#include "config.h" -#include "version.h" -#include "storage.h" -#include "sensor.h" -#include "fan.h" -#include "watchdog.h" -#include "mqtt.h" -#include "wifi_manager.h" -#include "webserver.h" - -#include -#include -#include -#include - -// WebSocket at /ws. -// -// Outbound (every WS_PUSH_INTERVAL_MS = 2 s): -// {"type":"status", temperature, humidity, fan_speed, fan_pwm_freq, -// alarm:{temperature,sensor,watchdog}, mqtt:{connected,broker}, -// wifi:{ssid,rssi,ip,portal}, -// system:{uptime, heap_free, restarts, version, build_date, -// first_boot_change_password}} -// -// Inbound (from browser): -// set_fan → save curve + alarm/min/interval/freq, apply runtime -// set_network → save WiFi + MQTT + device name (reboot required) -// set_ota_password → saveOtaPassword + webserver::applyOtaPassword -// restart → watchdog::requestRestart("user") -// factory_reset → storage::factoryReset + reboot -// -// Acknowledgements are sent back as {"type":"saved","section":"…", -// "ok":true/false,"error"?, "reboot_required"?}. - -namespace { - -AsyncWebSocket s_ws("/ws"); -constexpr size_t JSON_BUFFER_BYTES = 1024; - -// Serialize the status snapshot into a String. We pull all fields on the -// ws task so we never race against MQTT/sensor tasks for mutable state -// (reads of scalars and String copies are cheap and safe enough here). -String buildStatusJson() { - JsonDocument doc; - doc["type"] = "status"; - - const float t = sensor::getTemperatureC(); - const float h = sensor::getHumidityPct(); - if (isnan(t)) doc["temperature"] = nullptr; else doc["temperature"] = t; - if (isnan(h)) doc["humidity"] = nullptr; else doc["humidity"] = h; - - doc["fan_speed"] = fan::currentPercent(); - doc["fan_pwm_freq"] = fan::currentFrequency(); - - const float alarmThresh = storage::loadAlarmTemp(); - JsonObject alarm = doc["alarm"].to(); - alarm["temperature"] = !isnan(t) && (t >= alarmThresh); - alarm["sensor"] = sensor::isStale(); - alarm["watchdog"] = watchdog::alarmActive(); - - JsonObject mqttObj = doc["mqtt"].to(); - mqttObj["connected"] = mqtt::isConnected(); - mqttObj["broker"] = storage::loadMqtt().host; - - JsonObject wifi = doc["wifi"].to(); - wifi["ssid"] = wifi_manager::currentSsid(); - wifi["rssi"] = wifi_manager::rssi(); - wifi["ip"] = wifi_manager::ipAddress(); - wifi["portal"] = wifi_manager::isPortalActive(); - - JsonObject sys = doc["system"].to(); - sys["uptime"] = millis() / 1000UL; - sys["heap_free"] = ESP.getFreeHeap(); - sys["restarts"] = watchdog::restartCount(); - sys["version"] = VERSION; - sys["build_date"] = BUILD_DATE; - sys["first_boot_change_password"] = - (storage::loadOtaPassword() == DEFAULT_OTA_PASSWORD); - - String out; - out.reserve(512); - serializeJson(doc, out); - return out; -} - -// Reply helper: push a single frame to a specific client. -void sendAck(AsyncWebSocketClient* client, - const char* section, bool ok, - const char* error = nullptr, - bool rebootRequired = false) { - if (!client) return; - JsonDocument doc; - doc["type"] = "saved"; - doc["section"] = section; - doc["ok"] = ok; - if (error) doc["error"] = error; - if (rebootRequired) doc["reboot_required"] = true; - String out; - serializeJson(doc, out); - client->text(out); -} - -// ---------- Dispatch handlers ---------- - -void handleSetFan(AsyncWebSocketClient* client, JsonDocument& doc) { - // Thresholds + PWM levels: two arrays of FAN_CURVE_POINTS values. - JsonArray tArr = doc["thresholds"].as(); - JsonArray pArr = doc["pwm_levels"].as(); - if (tArr.size() != FAN_CURVE_POINTS || pArr.size() != FAN_CURVE_POINTS) { - sendAck(client, "fan", false, "invalid curve size"); - return; - } - - FanCurve curve{}; - for (uint8_t i = 0; i < FAN_CURVE_POINTS; ++i) { - curve.temps[i] = tArr[i].as(); - const int pct = pArr[i].as(); - if (pct < 0 || pct > 100) { - sendAck(client, "fan", false, "pwm out of range"); - return; - } - curve.pwm[i] = static_cast(pct); - } - // Curve temperatures must be monotonic increasing. - for (uint8_t i = 1; i < FAN_CURVE_POINTS; ++i) { - if (!(curve.temps[i] > curve.temps[i - 1])) { - sendAck(client, "fan", false, "thresholds must be strictly increasing"); - return; - } - } - - const float alarmTemp = doc["alarm_temp"] | storage::loadAlarmTemp(); - const int minFan = doc["min_fan"] | static_cast(storage::loadFanMinPercent()); - const uint32_t interval = doc["sensor_interval_ms"] | storage::loadSensorIntervalMs(); - const uint32_t freqHz = doc["pwm_freq_hz"] | storage::loadFanPwmFreqHz(); - - if (alarmTemp < 0.0f || alarmTemp > 80.0f) { - sendAck(client, "fan", false, "alarm_temp out of range"); return; - } - if (minFan < 0 || minFan > 100) { - sendAck(client, "fan", false, "min_fan out of range"); return; - } - if (interval < 1000 || interval > 60000) { - sendAck(client, "fan", false, "sensor_interval_ms out of range"); return; - } - if (freqHz < FAN_PWM_FREQ_MIN_HZ || freqHz > FAN_PWM_FREQ_MAX_HZ) { - sendAck(client, "fan", false, "pwm_freq_hz out of range"); return; - } - - bool ok = true; - ok &= storage::saveFanCurve(curve); - ok &= storage::saveAlarmTemp(alarmTemp); - ok &= storage::saveFanMinPercent(static_cast(minFan)); - ok &= storage::saveSensorIntervalMs(interval); - ok &= storage::saveFanPwmFreqHz(freqHz); - - if (ok) { - // Apply runtime changes so the user sees an immediate effect. - fan::setMinPercent(static_cast(minFan)); - fan::setFrequency(freqHz); - } - - sendAck(client, "fan", ok, ok ? nullptr : "nvs save failed"); -} - -void handleSetNetwork(AsyncWebSocketClient* client, JsonDocument& doc) { - JsonObject wifiObj = doc["wifi"].as(); - JsonObject mqttObj = doc["mqtt"].as(); - - WifiConfig wifi; - wifi.ssid = wifiObj["ssid"] | ""; - wifi.password = wifiObj["password"] | ""; - if (wifi.ssid.length() == 0 || wifi.ssid.length() > 32) { - sendAck(client, "network", false, "wifi ssid invalid"); return; - } - if (wifi.password.length() > 0 && wifi.password.length() < 8) { - sendAck(client, "network", false, "wifi password too short (min 8)"); return; - } - - MqttConfig mqttCfg; - mqttCfg.host = mqttObj["host"] | ""; - mqttCfg.port = mqttObj["port"] | DEFAULT_MQTT_PORT; - mqttCfg.user = mqttObj["user"] | ""; - mqttCfg.password = mqttObj["password"] | ""; - mqttCfg.prefix = mqttObj["prefix"] | DEFAULT_MQTT_PREFIX; - - const String deviceName = doc["device_name"] | DEFAULT_DEVICE_NAME; - - bool ok = true; - ok &= storage::saveWifi(wifi); - ok &= storage::saveMqtt(mqttCfg); - ok &= storage::saveDeviceName(deviceName); - - sendAck(client, "network", ok, ok ? nullptr : "nvs save failed", - /*rebootRequired=*/true); -} - -void handleSetOtaPassword(AsyncWebSocketClient* client, JsonDocument& doc) { - const String pwd = doc["password"] | ""; - if (pwd.length() < 8) { - sendAck(client, "ota_password", false, "password must be >= 8 chars"); - return; - } - const bool ok = storage::saveOtaPassword(pwd); - if (ok) { - webserver::applyOtaPassword(pwd); - } - sendAck(client, "ota_password", ok, ok ? nullptr : "nvs save failed"); -} - -void handleRestart(AsyncWebSocketClient* client, JsonDocument& /*doc*/) { - sendAck(client, "restart", true); - // Yield so the ack has a chance to flush before the reset. - vTaskDelay(pdMS_TO_TICKS(200)); - watchdog::requestRestart("user"); -} - -void handleFactoryReset(AsyncWebSocketClient* client, JsonDocument& doc) { - if (!(doc["confirm"] | false)) { - sendAck(client, "factory_reset", false, "confirm=true required"); return; - } - const bool ok = storage::factoryReset(); - sendAck(client, "factory_reset", ok, ok ? nullptr : "wipe failed"); - if (ok) { - vTaskDelay(pdMS_TO_TICKS(200)); - watchdog::requestRestart("factory_reset"); - } -} - -// ---------- Event handler ---------- - -void onEvent(AsyncWebSocket* /*server*/, - AsyncWebSocketClient* client, - AwsEventType type, - void* /*arg*/, - uint8_t* data, - size_t len) { - switch (type) { - case WS_EVT_CONNECT: - log_i("WS client #%u connected from %s", - client->id(), client->remoteIP().toString().c_str()); - // Push an immediate status frame so the UI doesn't wait for the 2s tick. - client->text(buildStatusJson()); - break; - - case WS_EVT_DISCONNECT: - log_i("WS client #%u disconnected", client->id()); - break; - - case WS_EVT_DATA: { - // Only handle single-frame text messages. - if (len == 0 || len > JSON_BUFFER_BYTES) { - sendAck(client, "unknown", false, "payload too large"); return; - } - JsonDocument doc; - DeserializationError err = deserializeJson( - doc, reinterpret_cast(data), len); - if (err) { - log_w("WS JSON parse error: %s", err.c_str()); - sendAck(client, "unknown", false, "invalid json"); return; - } - const String cmd = doc["type"] | ""; - if (cmd == "set_fan") handleSetFan(client, doc); - else if (cmd == "set_network") handleSetNetwork(client, doc); - else if (cmd == "set_ota_password") handleSetOtaPassword(client, doc); - else if (cmd == "restart") handleRestart(client, doc); - else if (cmd == "factory_reset") handleFactoryReset(client, doc); - else { - log_w("WS unknown type: %s", cmd.c_str()); - sendAck(client, "unknown", false, "unknown type"); - } - break; - } - - case WS_EVT_ERROR: - log_w("WS error on client #%u", client->id()); - break; - - default: - break; - } -} - -} // namespace - -namespace ws { - -void attach(AsyncWebServer& server) { - s_ws.onEvent(onEvent); - server.addHandler(&s_ws); - log_i("WebSocket handler mounted at /ws"); -} - -void broadcastLog(const char* line) { - if (!line) return; - JsonDocument doc; - doc["type"] = "log"; - doc["line"] = line; - String out; - serializeJson(doc, out); - s_ws.textAll(out); -} - -void task(void* /*arg*/) { - watchdog::subscribeCurrentTask(); - TickType_t lastWake = xTaskGetTickCount(); - uint32_t cleanupAccumMs = 0; - - for (;;) { - watchdog::reset(); - - // Only publish when at least one client is connected — avoids the - // JSON cost when nobody is looking. - if (s_ws.count() > 0) { - const String json = buildStatusJson(); - s_ws.textAll(json); - } - - // AsyncWebSocket housekeeping: drop stale clients roughly once per second. - cleanupAccumMs += WS_PUSH_INTERVAL_MS; - if (cleanupAccumMs >= 1000) { - s_ws.cleanupClients(); - cleanupAccumMs = 0; - } - - vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(WS_PUSH_INTERVAL_MS)); - } -} - -} // namespace ws diff --git a/src/websocket.h b/src/websocket.h deleted file mode 100644 index 461a846..0000000 --- a/src/websocket.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -// AsyncWebSocket (/ws) — 2-second status push + inbound config frames. -// -// See websocket.cpp for JSON shapes and dispatch table. - -#include - -class AsyncWebServer; - -namespace ws { - - // Attach the AsyncWebSocket to the given server. Must be called before - // webserver::instance().begin(). - void attach(AsyncWebServer& server); - - // FreeRTOS task entry. Pushes {"type":"status", ...} every - // WS_PUSH_INTERVAL_MS and runs AsyncWebSocket housekeeping - // (cleanupClients) once per second. - void task(void* arg); - - // Optional — broadcast a single log line as {"type":"log","line":"..."}. - // Safe to call from any task; the push itself happens on the caller. - void broadcastLog(const char* line); - -} // namespace ws diff --git a/src/wifi_manager.cpp b/src/wifi_manager.cpp deleted file mode 100644 index 7be18c7..0000000 --- a/src/wifi_manager.cpp +++ /dev/null @@ -1,207 +0,0 @@ -#include "wifi_manager.h" - -#include "config.h" -#include "storage.h" -#include "watchdog.h" - -#include -#include -#include -#include - -// See wifi_manager.h for the public contract. -// -// Decisions: -// - Captive-portal SSID is hardcoded "FanControl-Setup" (plan v1.2). -// No password; the portal is deliberately short-lived and users are -// guided to set creds within minutes. -// - STA connect timeout is 30 s in begin(). The task retries every -// WIFI_RETRY_INTERVAL_MS if we end up in AP mode. -// - A DNSServer on UDP/53 answers every query with 192.168.4.1 — this -// is the captive-portal hook that makes phones open the setup page. - -namespace { - -constexpr char PORTAL_SSID[] = "FanControl-Setup"; -constexpr uint16_t DNS_PORT = 53; -constexpr uint32_t STA_CONNECT_TIMEOUT_MS = 30000; -const IPAddress AP_IP(192, 168, 4, 1); -const IPAddress AP_NETMASK(255, 255, 255, 0); - -DNSServer s_dns; -bool s_portalActive = false; -bool s_dnsActive = false; -bool s_mdnsActive = false; -String s_currentSsid; // filled on STA connect or AP start - -// Attempt a single STA connect. Returns true on success within timeout. -bool connectSta(const WifiConfig& cfg) { - if (cfg.ssid.isEmpty()) return false; - - WiFi.mode(WIFI_STA); - WiFi.setHostname(DEFAULT_MDNS_HOSTNAME); - WiFi.setAutoReconnect(true); - WiFi.persistent(false); // we own NVS via storage::*, don't let WiFi lib write - WiFi.disconnect(true, true); - - log_i("WiFi STA: connecting to '%s'", cfg.ssid.c_str()); - WiFi.begin(cfg.ssid.c_str(), cfg.password.c_str()); - - const uint32_t t0 = millis(); - while ((millis() - t0) < STA_CONNECT_TIMEOUT_MS) { - if (WiFi.status() == WL_CONNECTED) { - s_currentSsid = cfg.ssid; - log_i("WiFi STA connected: ip=%s rssi=%d", - WiFi.localIP().toString().c_str(), WiFi.RSSI()); - return true; - } - vTaskDelay(pdMS_TO_TICKS(250)); - } - - log_w("WiFi STA timeout for '%s' (rc=%d)", cfg.ssid.c_str(), (int)WiFi.status()); - return false; -} - -void startMdns() { - if (s_mdnsActive) return; - if (MDNS.begin(DEFAULT_MDNS_HOSTNAME)) { - MDNS.addService("http", "tcp", 80); - s_mdnsActive = true; - log_i("mDNS: %s.local", DEFAULT_MDNS_HOSTNAME); - } else { - log_w("mDNS begin failed"); - } -} - -void stopMdns() { - if (!s_mdnsActive) return; - MDNS.end(); - s_mdnsActive = false; -} - -// Start the open captive-portal AP and the DNS catch-all. -void startPortal() { - stopMdns(); - WiFi.mode(WIFI_AP); - WiFi.softAPConfig(AP_IP, AP_IP, AP_NETMASK); - const bool ok = WiFi.softAP(PORTAL_SSID); // no password per plan v1.2 - if (!ok) { - log_e("Portal AP start failed"); - return; - } - s_currentSsid = PORTAL_SSID; - - // Catch-all DNS: answer every query with our own IP → phones flag this - // as a captive portal and pop up the browser. - s_dns.setErrorReplyCode(DNSReplyCode::NoError); - if (s_dns.start(DNS_PORT, "*", AP_IP)) { - s_dnsActive = true; - } else { - log_w("DNSServer start failed — captive-portal detection may not trigger"); - } - - s_portalActive = true; - log_w("Captive portal active: ssid='%s' ip=%s", - PORTAL_SSID, AP_IP.toString().c_str()); -} - -void stopPortal() { - if (!s_portalActive) return; - if (s_dnsActive) { - s_dns.stop(); - s_dnsActive = false; - } - WiFi.softAPdisconnect(true); - s_portalActive = false; - log_i("Captive portal stopped"); -} - -} // namespace - -namespace wifi_manager { - -void begin() { - const WifiConfig cfg = storage::loadWifi(); - - if (!cfg.ssid.isEmpty() && connectSta(cfg)) { - startMdns(); - return; - } - log_w("No valid WiFi creds or STA failed — starting captive portal"); - startPortal(); -} - -void task(void* /*arg*/) { - watchdog::subscribeCurrentTask(); - TickType_t lastWake = xTaskGetTickCount(); - - for (;;) { - watchdog::reset(); - - if (s_portalActive) { - // Service captive-portal DNS every tick so requests don't queue up. - if (s_dnsActive) s_dns.processNextRequest(); - - // Try to escape the portal periodically if the user saved creds. - static uint32_t s_lastRetryMs = 0; - const uint32_t now = millis(); - if ((now - s_lastRetryMs) >= WIFI_RETRY_INTERVAL_MS) { - s_lastRetryMs = now; - const WifiConfig cfg = storage::loadWifi(); - if (!cfg.ssid.isEmpty()) { - log_i("Portal: retrying STA with stored creds"); - stopPortal(); - if (connectSta(cfg)) { - startMdns(); - } else { - startPortal(); // fall back again - } - } - } - // DNS needs frequent ticks. - vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(50)); - continue; - } - - // STA path: watch for link loss. - if (WiFi.status() != WL_CONNECTED) { - log_w("WiFi STA disconnected — reconnecting"); - stopMdns(); - const WifiConfig cfg = storage::loadWifi(); - if (!connectSta(cfg)) { - log_w("STA reconnect failed — falling back to captive portal"); - startPortal(); - } else { - startMdns(); - } - } - - vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(WIFI_RETRY_INTERVAL_MS)); - } -} - -bool isConnected() { - return !s_portalActive && WiFi.status() == WL_CONNECTED; -} - -bool isPortalActive() { - return s_portalActive; -} - -String currentSsid() { - if (s_portalActive) return PORTAL_SSID; - if (WiFi.status() == WL_CONNECTED) return WiFi.SSID(); - return s_currentSsid; -} - -int rssi() { - if (s_portalActive) return 0; - return WiFi.RSSI(); -} - -String ipAddress() { - if (s_portalActive) return AP_IP.toString(); - return WiFi.localIP().toString(); -} - -} // namespace wifi_manager diff --git a/src/wifi_manager.h b/src/wifi_manager.h deleted file mode 100644 index b5396d7..0000000 --- a/src/wifi_manager.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -// WiFi station + captive-portal AP + mDNS. -// -// Plan §F5: connect with stored NVS creds; if none (or timeout) fall -// back to an open AP named FanControl-Setup on 192.168.4.1 with a -// DNSServer catch-all so phones recognise it as a captive portal. -// Once STA is up, advertise mDNS hostname "fancontrol" so -// fancontrol.local resolves on the LAN. -// -// The task (run_on_core 0) periodically retries STA if we are in the -// portal, and reconnects if the STA link drops. It is subscribed to -// the hardware TWDT via watchdog::subscribeCurrentTask(). - -#include -#include "config.h" - -namespace wifi_manager { - - // Load NVS creds, attempt STA, or fall back to captive-portal AP. - // Must be called from setup() after storage::begin(). - void begin(); - - // FreeRTOS task entry. Handles STA reconnect and AP-to-STA retry. - void task(void* arg); - - bool isConnected(); - bool isPortalActive(); // true when the captive AP is running - String currentSsid(); // STA SSID, or the AP SSID in portal mode - int rssi(); // 0 in AP mode - String ipAddress(); // STA IP, or "192.168.4.1" in AP mode - -} // namespace wifi_manager diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 75ac2c7..0000000 --- a/test/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# FanControl tests - -PlatformIO + Unity. Two tiers: host-side (fast, run in CI) and -hardware-in-the-loop (deferred until real hardware is on the bench). - -## Layout - -``` -test/ - test_fan_curve/ host (pio test -e native) — pure-logic coverage - for src/fan_curve.h (temperature → PWM percent). -``` - -## Running - -```bash -# All host tests — runs in CI, must pass. -pio test -e native - -# Single folder during development. -pio test -e native -f test_fan_curve -``` - -## What runs where - -| Suite | Env | Purpose | -|---|---|---| -| `test_fan_curve` | `native` | Pure interpolation in `src/fan_curve.h`. No Arduino deps. | - -## What is NOT yet covered - -These map to hardware / system boundaries that need either real silicon -or a dedicated mock layer. They are on the roadmap but out of scope for -the first-wave host tests. - -- **Storage (NVS / `Preferences.h`).** Reading/writing via - `src/storage.cpp` calls into the ESP32 Preferences API. Host coverage - requires a `Preferences` mock, which is out of scope for this round. -- **Sensor (DHT22).** `src/sensor.cpp` pulls the Adafruit DHT library - and reads a GPIO — hardware-in-the-loop only. -- **Fan LEDC PWM.** `src/fan.cpp` calls `ledcSetup/ledcWrite` — the - pure temperature-curve logic is already extracted into - `src/fan_curve.h` and covered here; the LEDC side needs hardware. -- **Watchdog.** `src/watchdog.cpp` uses `esp_task_wdt` — hardware only. -- **MQTT / WiFi / WebSocket.** All network modules are hardware-only. -- **Test matrix T01–T12** (see `PROJECT_PLAN.md` §7.2). These are - integration / fault-injection tests that require wired hardware - (sensor disconnect, WiFi drop, OTA, 72 h soak). Tracked separately. - -## Rule - -Every new **pure-logic** function (no Arduino or ESP32 headers required -to compile it) gets a Unity test under `test/` **before merge**. -Extract the logic into a header-only or platform-neutral `.cpp` module -if the existing one is too entangled with the hardware layer — the -`src/fan_curve.h` split is the reference pattern. diff --git a/test/test_fan_curve/test_fan_curve.cpp b/test/test_fan_curve/test_fan_curve.cpp deleted file mode 100644 index 3e2b55b..0000000 --- a/test/test_fan_curve/test_fan_curve.cpp +++ /dev/null @@ -1,137 +0,0 @@ -// Host-side (pio test -e native) Unity tests for the pure fan-curve -// interpolation extracted into src/fan_curve.h. -// -// CONSTRAINT: must not include or any ESP32-specific header. -// Only plain C++17 + Unity. - -#include - -#include -#include - -#include "fan_curve.h" - -// The default curve from config.h: temps = {15, 20, 25, 30, 35}, -// pwm = {10, 25, 50, 75, 100}. We construct a concrete FanCurve from -// those constexpr arrays so the tests exercise the same curve the -// firmware boots with (config.h defaults). -static FanCurve makeDefaultCurve() { - FanCurve c{}; - for (uint8_t i = 0; i < FAN_CURVE_POINTS; ++i) { - c.temps[i] = FAN_CURVE_TEMP_DEFAULT[i]; - c.pwm[i] = FAN_CURVE_PWM_DEFAULT[i]; - } - return c; -} - -void setUp(void) {} -void tearDown(void) {} - -// --- Edge: below first point returns pwm[0] --------------------------------- - -void test_below_first_point_returns_first_pwm(void) { - const FanCurve curve = makeDefaultCurve(); - // Well below curve.temps[0] = 15.0 - TEST_ASSERT_EQUAL_UINT8(curve.pwm[0], fan_curve::computeFromTemperature(-10.0f, curve)); - TEST_ASSERT_EQUAL_UINT8(curve.pwm[0], fan_curve::computeFromTemperature(0.0f, curve)); - TEST_ASSERT_EQUAL_UINT8(curve.pwm[0], fan_curve::computeFromTemperature(14.99f, curve)); -} - -// --- Edge: at-or-above last point returns 100 ------------------------------- - -void test_above_last_point_returns_100(void) { - const FanCurve curve = makeDefaultCurve(); - // curve.temps[N-1] = 35.0 → spec: c >= last → 100 (not pwm[last]). - TEST_ASSERT_EQUAL_UINT8(100, fan_curve::computeFromTemperature(35.0f, curve)); - TEST_ASSERT_EQUAL_UINT8(100, fan_curve::computeFromTemperature(42.0f, curve)); - TEST_ASSERT_EQUAL_UINT8(100, fan_curve::computeFromTemperature(1000.0f, curve)); -} - -// --- Exact: at each curve point returns its pwm (default curve) ------------- - -void test_exact_at_each_point_returns_that_pwm(void) { - const FanCurve curve = makeDefaultCurve(); - // At temps[0] = 15 → pwm[0] = 10 (c <= temps[0] branch) - TEST_ASSERT_EQUAL_UINT8(curve.pwm[0], fan_curve::computeFromTemperature(curve.temps[0], curve)); - // Interior points are hit by the segment-search branch. - TEST_ASSERT_EQUAL_UINT8(curve.pwm[1], fan_curve::computeFromTemperature(curve.temps[1], curve)); - TEST_ASSERT_EQUAL_UINT8(curve.pwm[2], fan_curve::computeFromTemperature(curve.temps[2], curve)); - TEST_ASSERT_EQUAL_UINT8(curve.pwm[3], fan_curve::computeFromTemperature(curve.temps[3], curve)); - // At the last point we hit the "c >= last" branch → 100 (which - // coincidentally equals pwm[N-1] for the default curve). - TEST_ASSERT_EQUAL_UINT8(100, fan_curve::computeFromTemperature(curve.temps[4], curve)); -} - -// --- Midway interpolation (default curve) ----------------------------------- - -void test_midway_default_curve_segments(void) { - const FanCurve curve = makeDefaultCurve(); - // Segment [15,20] → [10,25]: midpoint 17.5 → 17.5 → round to 18 - TEST_ASSERT_EQUAL_UINT8(18, fan_curve::computeFromTemperature(17.5f, curve)); - // Segment [20,25] → [25,50]: midpoint 22.5 → 37.5 → round to 38 - TEST_ASSERT_EQUAL_UINT8(38, fan_curve::computeFromTemperature(22.5f, curve)); - // Segment [25,30] → [50,75]: midpoint 27.5 → 62.5 → round to 63 - TEST_ASSERT_EQUAL_UINT8(63, fan_curve::computeFromTemperature(27.5f, curve)); - // Segment [30,35] → [75,100]: midpoint 32.5 → 87.5 → round to 88 - TEST_ASSERT_EQUAL_UINT8(88, fan_curve::computeFromTemperature(32.5f, curve)); -} - -// --- Midway interpolation on a synthetic curve with clean integer math ------ - -void test_midway_exact_halfway_rounding(void) { - // Curve with easy-to-read midpoints: - // 10→20, 20→40, 30→60, 40→80, 50→100 - FanCurve curve{}; - curve.temps[0] = 10.0f; curve.pwm[0] = 20; - curve.temps[1] = 20.0f; curve.pwm[1] = 40; - curve.temps[2] = 30.0f; curve.pwm[2] = 60; - curve.temps[3] = 40.0f; curve.pwm[3] = 80; - curve.temps[4] = 50.0f; curve.pwm[4] = 100; - - // Midpoints should land exactly on integer percentages. - TEST_ASSERT_EQUAL_UINT8(30, fan_curve::computeFromTemperature(15.0f, curve)); - TEST_ASSERT_EQUAL_UINT8(50, fan_curve::computeFromTemperature(25.0f, curve)); - TEST_ASSERT_EQUAL_UINT8(70, fan_curve::computeFromTemperature(35.0f, curve)); - TEST_ASSERT_EQUAL_UINT8(90, fan_curve::computeFromTemperature(45.0f, curve)); -} - -// --- NaN input → failsafe bias 100 ------------------------------------------ - -void test_nan_input_returns_100(void) { - const FanCurve curve = makeDefaultCurve(); - const float nan_val = std::nanf(""); - TEST_ASSERT_TRUE(std::isnan(nan_val)); - TEST_ASSERT_EQUAL_UINT8(100, fan_curve::computeFromTemperature(nan_val, curve)); -} - -// --- Degenerate segment (duplicate temps) does not divide by zero ----------- - -void test_degenerate_segment_does_not_divide_by_zero(void) { - // Deliberately set temps[1] == temps[2] to force span <= 0 in the - // interior. The function must fall back to pwm[i] instead of NaN/inf. - FanCurve curve{}; - curve.temps[0] = 10.0f; curve.pwm[0] = 10; - curve.temps[1] = 20.0f; curve.pwm[1] = 40; - curve.temps[2] = 20.0f; curve.pwm[2] = 60; - curve.temps[3] = 30.0f; curve.pwm[3] = 80; - curve.temps[4] = 40.0f; curve.pwm[4] = 100; - - const uint8_t v = fan_curve::computeFromTemperature(20.0f, curve); - // At c = 20.0 the first matching segment is [10,20] — that segment is - // fine and returns pwm[1] = 40 at the upper endpoint (frac=1). - TEST_ASSERT_EQUAL_UINT8(40, v); - // Any finite value was returned (not something truthy-but-garbage). - TEST_ASSERT_TRUE(v <= 100); -} - -int main(int /*argc*/, char** /*argv*/) { - UNITY_BEGIN(); - RUN_TEST(test_below_first_point_returns_first_pwm); - RUN_TEST(test_above_last_point_returns_100); - RUN_TEST(test_exact_at_each_point_returns_that_pwm); - RUN_TEST(test_midway_default_curve_segments); - RUN_TEST(test_midway_exact_halfway_rounding); - RUN_TEST(test_nan_input_returns_100); - RUN_TEST(test_degenerate_segment_does_not_divide_by_zero); - return UNITY_END(); -}