From 6998592ca60dc21d1e6869ddc9e7c926ca3577dd Mon Sep 17 00:00:00 2001 From: Bas Meerman <30606346+basmeerman@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:51:24 +0200 Subject: [PATCH] feat: add Auto/Manual operating modes Adds an Operating Mode select (Auto / Manual) alongside the existing temperature-driven curve: - Auto (default): unchanged 5-point fan curve, clamped to Fan Min %. - Manual: Manual Fan switch (on/off) + Manual Fan Speed number (0-100 %); off forces 0 %. The resolved-speed template sensor (fan_speed_pct) now branches by mode and remains the single driver of the LEDC output, so "Fan Speed" always reflects the actual duty applied. Safety failsafes are preserved and apply in ALL modes: NaN read and >60 s sensor stall still force 100 %, including in Manual. Temperature alarm stays indicator-only. Mode + manual entities are grouped on the web UI via web_server sorting groups. The stock web page can't hide entities by state, so the manual controls are always shown but inert in Auto; per-mode UI belongs in a Home Assistant dashboard. Docs updated: PROJECT_PLAN F7, CHANGELOG, README. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++ PROJECT_PLAN.md | 15 ++++++++++ README.md | 20 +++++++++++-- fancontrol.yaml | 79 +++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8222b44..e7fe853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ Release notes for each tagged version are extracted from this file by the ## [Unreleased] +### Added +- **Operating modes (Auto / Manual).** New `Operating Mode` select switches + between the existing temperature-driven curve (**Auto**) and a new **Manual** + mode with a `Manual Fan` on/off switch and a `Manual Fan Speed` (0–100 %) + number. A single resolved-speed template sensor drives the PWM in both modes, + so `Fan Speed` always reflects the actual duty. Safety failsafes (NaN read, + >60 s sensor stall → 100 %) still apply in **all** modes, including Manual. + Mode + manual controls are grouped on the web UI via `web_server` + sorting groups (the stock UI can't hide entities by state, so they are shown + but inert in Auto). + ### Changed - Home Assistant transport switched from the native ESPHome **`api:` to `mqtt:`** in `fancontrol.yaml`. The encrypted native-API block is now diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 9ff0f2a..e8864f9 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -92,6 +92,21 @@ or the device won't join WiFi. components with `restore_value: true` for user-tunable settings. No manual NVS wrapping needed. +### F7 — Operating modes +- **Operating Mode** `select` (`Auto` / `Manual`, persisted). Default `Auto`. +- **Auto** — unchanged: temperature-driven 5-point fan curve, clamped to Fan Min %. +- **Manual** — **Manual Fan** `switch` (on/off) + **Manual Fan Speed** `number` + (0–100 %). Off forces 0 %. +- A single template sensor (`fan_speed_pct`) resolves the duty for both modes + and drives the PWM output, so "Fan Speed" always reflects the actual duty. +- **Safety overrides apply in all modes:** NaN sensor read and the >60 s + sensor-stall failsafe still force the fan to 100 % even in Manual. The + temperature alarm remains indicator-only (does not force the fan). +- The stock ESPHome web UI cannot hide entities by state, so manual controls + are always shown (inert in Auto); `web_server` sorting groups keep them + organised. Per-mode conditional UI, if wanted, belongs in a Home Assistant + dashboard keyed on the mode entity. + ## 3. Architecture One file, one responsibility: diff --git a/README.md b/README.md index 01256f1..17cb867 100644 --- a/README.md +++ b/README.md @@ -74,18 +74,34 @@ broker — no manual entity setup needed. Exposed entities: |---|---|---| | Temperature | sensor | °C, from DHT22 | | Humidity | sensor | %, from DHT22 | -| Fan Speed | sensor | % duty, derived from the curve | +| Fan Speed | sensor | % duty actually applied (curve in Auto, manual setting in Manual) | | 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 | +| Operating Mode | select | `Auto` (curve) or `Manual`, persisted | +| Manual Fan | switch | on/off — Manual mode only | +| Manual Fan Speed | number | 0–100 % — Manual mode only | | Alarm Temperature | number | tunable threshold | -| Fan Min % | number | minimum duty under normal operation | +| Fan Min % | number | minimum duty under normal operation (Auto) | | Fan PWM Frequency Setting | number | 1000–5000 Hz, applied live via `ledc.set_frequency` | | Restart / Factory Reset | button | standard ESPHome buttons | +## Operating modes + +- **Auto** (default) — fan speed follows the temperature curve, clamped to `Fan Min %`. +- **Manual** — `Manual Fan` toggles the fan and `Manual Fan Speed` sets the duty + directly (off forces 0 %). + +Safety failsafes apply in **both** modes: a NaN reading or a >60 s sensor stall +forces the fan to 100 %, even in Manual. + +> The built-in ESPHome web page can't hide entities by mode, so the manual +> controls are always visible (they're simply inert in Auto). For per-mode UI, +> use a Home Assistant dashboard with conditional cards keyed on `Operating Mode`. + ## Safety behaviour - **Sensor stall** (no successful DHT22 read for 60 s) → fan forced to 100 %. diff --git a/fancontrol.yaml b/fancontrol.yaml index b17dd82..8b46223 100644 --- a/fancontrol.yaml +++ b/fancontrol.yaml @@ -91,6 +91,15 @@ mqtt: web_server: port: 80 version: 3 + # The stock web UI cannot hide entities by state, so manual controls are + # always visible (they are simply inert in Auto mode). Groups keep it tidy. + sorting_groups: + - id: group_operation + name: "Operation" + sorting_weight: 10 + - id: group_manual + name: "Manual control" + sorting_weight: 20 # -------- Globals (restore_value = persisted across reboots) -------- @@ -127,7 +136,8 @@ sensor: filters: - filter_out: nan - # Fan speed derived from the curve. Drives the PWM output via on_value. + # Resolved fan speed. Single driver of the PWM output (via on_value) for + # BOTH modes, so this sensor always reflects the actual duty being applied. - platform: template name: "Fan Speed" id: fan_speed_pct @@ -136,9 +146,21 @@ sensor: 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 + // Safety overrides apply in ALL modes (Auto and Manual). Preserve these. + if (std::isnan(t)) return 100.0f; // sensor warmup / NaN → failsafe + if (id(alarm_sensor_stall).state) return 100.0f; // SW watchdog stall → failsafe + + // Manual mode: user-controlled on/off + speed. Off forces 0 %. + if (id(operating_mode).state == "Manual") { + if (!id(manual_fan_switch).state) return 0.0f; + float m = id(manual_fan_speed).state; + if (m < 0.0f) m = 0.0f; + if (m > 100.0f) m = 100.0f; + return m; + } + + // Auto mode: temperature-driven curve (clamped to Fan Min %). 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} }; @@ -206,9 +228,60 @@ binary_sensor: if (id(last_sensor_read_ms) == 0) return {}; return (millis() - id(last_sensor_read_ms)) > 60000; +# -------- Operating mode + manual controls -------- + +select: + - platform: template + name: "Operating Mode" + id: operating_mode + icon: mdi:fan-auto + web_server: + sorting_group_id: group_operation + optimistic: true + restore_value: true + initial_option: "Auto" + options: + - "Auto" + - "Manual" + # Recompute the resolved fan speed immediately on mode change. + on_value: + - component.update: fan_speed_pct + +switch: + - platform: template + name: "Manual Fan" + id: manual_fan_switch + icon: mdi:fan + web_server: + sorting_group_id: group_manual + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + # Only takes effect in Manual mode; inert in Auto. + on_turn_on: + - component.update: fan_speed_pct + on_turn_off: + - component.update: fan_speed_pct + # -------- Tunable settings (number components, persisted via restore_value) -------- number: + - platform: template + name: "Manual Fan Speed" + id: manual_fan_speed + web_server: + sorting_group_id: group_manual + min_value: 0 + max_value: 100 + step: 1 + unit_of_measurement: "%" + initial_value: 50 + restore_value: true + optimistic: true + mode: slider + # Only takes effect in Manual mode (with Manual Fan on); inert in Auto. + on_value: + - component.update: fan_speed_pct + - platform: template name: "Alarm Temperature" id: alarm_temp_threshold