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