|
| 1 | +// ========================================================================== |
| 2 | +// VentoSync HRV – ESPHome Custom Component |
| 3 | +// https://github.com/thomasengeroff-dotcom/VentoSync |
| 4 | +// |
| 5 | +// Copyright (c) 2026 Thomas Engeroff |
| 6 | +// |
| 7 | +// This program is free software: you can redistribute it and/or modify |
| 8 | +// it under the terms of the GNU General Public License as published by |
| 9 | +// the Free Software Foundation, either version 3 of the License, or |
| 10 | +// (at your option) any later version. |
| 11 | +// |
| 12 | +// This program is distributed in the hope that it will be useful, |
| 13 | +// but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 14 | +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 15 | +// GNU General Public License for more details. |
| 16 | +// |
| 17 | +// You should have received a copy of the GNU General Public License |
| 18 | +// along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 19 | +// |
| 20 | +// File: ha_fan_helpers.h |
| 21 | +// Description: Home Assistant fan entity integration helpers. |
| 22 | +// Manages the bidirectional state sync between the VentoSync |
| 23 | +// system and the HA fan entity (used by ventosync-card). |
| 24 | +// Implements a feedback-loop guard to prevent infinite |
| 25 | +// re-triggering between card input and system state sync. |
| 26 | +// Author: Thomas Engeroff |
| 27 | +// Created: 2026-05-16 |
| 28 | +// Modified: 2026-05-16 |
| 29 | +// |
| 30 | +// Dependencies: globals.h (MODE_NAMES, MODE_NAME_OFF, MODE_NAME_AUTO, |
| 31 | +// system_on, ventilation_enabled, |
| 32 | +// current_mode_index, fan_intensity_level) |
| 33 | +// automation_helpers.h (set_operating_mode_select, |
| 34 | +// set_fan_intensity_slider) |
| 35 | +// ha_fan_entity.yaml (ventosync_hrv_fan, ha_fan_syncing, |
| 36 | +// ha_fan_guard_set_ms) |
| 37 | +// ========================================================================== |
| 38 | + |
| 39 | +#pragma once |
| 40 | + |
| 41 | +#include "esphome.h" |
| 42 | +#include <algorithm> // std::clamp |
| 43 | + |
| 44 | +// --------------------------------------------------------- |
| 45 | +// CONSTANTS |
| 46 | +// --------------------------------------------------------- |
| 47 | + |
| 48 | +namespace ventosync { |
| 49 | +namespace ha_fan { |
| 50 | + |
| 51 | +/// Propagation window after a sync write. |
| 52 | +/// ESPHome processes call.perform() asynchronously in the next loop cycle. |
| 53 | +/// We must block on_speed_set/on_preset_set during this window to prevent |
| 54 | +/// the feedback loop: sync → perform → on_speed_set → set_intensity → sync... |
| 55 | +static constexpr uint32_t GUARD_PROPAGATION_MS = 200; |
| 56 | + |
| 57 | +/// Maximum time the guard may stay active before forced reset. |
| 58 | +/// Safety net against permanent lockout if something goes wrong. |
| 59 | +static constexpr uint32_t GUARD_STUCK_TIMEOUT_MS = 5000; |
| 60 | + |
| 61 | +/// Mode index that represents "Aus" (Off). |
| 62 | +static constexpr int MODE_INDEX_OFF = 4; |
| 63 | + |
| 64 | +/// Maximum number of active preset modes (0–3, excluding "Aus"). |
| 65 | +static constexpr int MODE_INDEX_MAX_ACTIVE = 3; |
| 66 | + |
| 67 | +} // namespace ha_fan |
| 68 | +} // namespace ventosync |
| 69 | + |
| 70 | +// --------------------------------------------------------- |
| 71 | +// GUARD MANAGEMENT |
| 72 | +// --------------------------------------------------------- |
| 73 | + |
| 74 | +/** |
| 75 | + * @brief Checks if the sync guard is currently active. |
| 76 | + * |
| 77 | + * When the guard is active, all on_speed_set / on_preset_set / |
| 78 | + * on_turn_on / on_turn_off callbacks should return immediately |
| 79 | + * without processing the event – it was triggered by our own |
| 80 | + * sync interval, not by user input. |
| 81 | + * |
| 82 | + * @return true if the guard is active (skip callback processing) |
| 83 | + */ |
| 84 | +inline bool ha_fan_guard_active() { |
| 85 | + return id(ha_fan_syncing); |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * @brief Attempts to release the sync guard if the propagation window has elapsed. |
| 90 | + * |
| 91 | + * Called at the top of each sync cycle. The guard is released when: |
| 92 | + * - Normal: >200ms have elapsed since it was set |
| 93 | + * - Emergency: >5s have elapsed (stuck guard, should never happen) |
| 94 | + * |
| 95 | + * @return true if the guard was active and we should skip this sync cycle |
| 96 | + * (still within propagation window). |
| 97 | + * false if the guard is released and sync can proceed. |
| 98 | + */ |
| 99 | +inline bool ha_fan_try_release_guard() { |
| 100 | + using namespace ventosync::ha_fan; |
| 101 | + |
| 102 | + if (!id(ha_fan_syncing)) { |
| 103 | + return false; // Guard not active, proceed normally |
| 104 | + } |
| 105 | + |
| 106 | + const uint32_t elapsed = millis() - id(ha_fan_guard_set_ms); |
| 107 | + |
| 108 | + // Emergency: Force-reset stuck guard |
| 109 | + if (elapsed > GUARD_STUCK_TIMEOUT_MS) { |
| 110 | + ESP_LOGW("ha_fan", "Guard stuck for %lu ms → forced reset", |
| 111 | + static_cast<unsigned long>(elapsed)); |
| 112 | + id(ha_fan_syncing) = false; |
| 113 | + return false; // Released, proceed with sync |
| 114 | + } |
| 115 | + |
| 116 | + // Normal: Release after propagation window |
| 117 | + if (elapsed > GUARD_PROPAGATION_MS) { |
| 118 | + id(ha_fan_syncing) = false; |
| 119 | + return false; // Released, proceed with sync |
| 120 | + } |
| 121 | + |
| 122 | + // Still within propagation window – skip this cycle |
| 123 | + return true; |
| 124 | +} |
| 125 | + |
| 126 | +/** |
| 127 | + * @brief Activates the sync guard. |
| 128 | + * |
| 129 | + * Must be called BEFORE call.perform() to block any re-entrant |
| 130 | + * triggers from on_speed_set / on_preset_set that fire when |
| 131 | + * ESPHome processes the state change. |
| 132 | + */ |
| 133 | +inline void ha_fan_set_guard() { |
| 134 | + id(ha_fan_syncing) = true; |
| 135 | + id(ha_fan_guard_set_ms) = millis(); |
| 136 | +} |
| 137 | + |
| 138 | +// --------------------------------------------------------- |
| 139 | +// CARD → SYSTEM: Input Handlers |
| 140 | +// --------------------------------------------------------- |
| 141 | + |
| 142 | +/** |
| 143 | + * @brief Handles speed changes from the ventosync-card circular slider. |
| 144 | + * |
| 145 | + * @param speed Speed value from ESPHome (1–10 for speed_count: 10, |
| 146 | + * 0 = turn off via HA service call with percentage: 0) |
| 147 | + */ |
| 148 | +inline void ha_fan_on_speed_set(int speed) { |
| 149 | + if (ha_fan_guard_active()) return; |
| 150 | + |
| 151 | + // Speed 0 = turn off (handles HA service calls that send |
| 152 | + // percentage: 0 instead of fan.turn_off) |
| 153 | + if (speed <= 0) { |
| 154 | + set_operating_mode_select(MODE_NAME_OFF); |
| 155 | + ESP_LOGI("ha_fan", "Speed 0 received → turning off"); |
| 156 | + return; |
| 157 | + } |
| 158 | + |
| 159 | + const int step = std::clamp(speed, 1, 10); |
| 160 | + set_fan_intensity_slider(static_cast<float>(step)); |
| 161 | + ESP_LOGI("ha_fan", "Card set intensity to step %d", step); |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * @brief Handles preset mode changes from the ventosync-card. |
| 166 | + * |
| 167 | + * @param preset Preset mode string from ESPHome (StringRef) |
| 168 | + */ |
| 169 | +inline void ha_fan_on_preset_set(const std::string &preset) { |
| 170 | + if (ha_fan_guard_active()) return; |
| 171 | + |
| 172 | + set_operating_mode_select(preset); |
| 173 | + ESP_LOGI("ha_fan", "Card set mode: %s", preset.c_str()); |
| 174 | +} |
| 175 | + |
| 176 | +/** |
| 177 | + * @brief Handles turn-on from the ventosync-card or HA service call. |
| 178 | + * |
| 179 | + * Resumes the last active preset mode. If no preset was set or |
| 180 | + * the last preset was "Aus", defaults to Smart-Automatik. |
| 181 | + * Also ensures the system globals (ventilation_enabled, system_on) |
| 182 | + * are set to true. |
| 183 | + */ |
| 184 | +inline void ha_fan_on_turn_on() { |
| 185 | + if (ha_fan_guard_active()) return; |
| 186 | + |
| 187 | + // Determine target mode: resume last preset or default to Auto |
| 188 | + std::string target_mode = MODE_NAME_AUTO; |
| 189 | + |
| 190 | + auto &fan = id(ventosync_hrv_fan); |
| 191 | + if (fan.has_preset_mode()) { |
| 192 | + std::string last = fan.get_preset_mode().str(); |
| 193 | + if (last != MODE_NAME_OFF) { |
| 194 | + target_mode = last; |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + // Ensure system is enabled |
| 199 | + if (!id(ventilation_enabled)) { |
| 200 | + id(ventilation_enabled) = true; |
| 201 | + id(system_on) = true; |
| 202 | + } |
| 203 | + |
| 204 | + set_operating_mode_select(target_mode); |
| 205 | + ESP_LOGI("ha_fan", "Turned ON → mode: %s", target_mode.c_str()); |
| 206 | +} |
| 207 | + |
| 208 | +/** |
| 209 | + * @brief Handles turn-off from the ventosync-card or HA service call. |
| 210 | + */ |
| 211 | +inline void ha_fan_on_turn_off() { |
| 212 | + if (ha_fan_guard_active()) return; |
| 213 | + |
| 214 | + set_operating_mode_select(MODE_NAME_OFF); |
| 215 | + ESP_LOGI("ha_fan", "Turned OFF"); |
| 216 | +} |
| 217 | + |
| 218 | +// --------------------------------------------------------- |
| 219 | +// SYSTEM → CARD: State Sync Engine |
| 220 | +// --------------------------------------------------------- |
| 221 | + |
| 222 | +/** |
| 223 | + * @brief Synchronizes the current system state to the HA fan entity. |
| 224 | + * |
| 225 | + * Called every 3s from the sync interval. Reads the actual system state |
| 226 | + * (mode, intensity, on/off) and publishes it to the fan entity so the |
| 227 | + * ventosync-card stays in sync. |
| 228 | + * |
| 229 | + * Implements a 3-phase approach: |
| 230 | + * 1. Guard management (release previous cycle's guard) |
| 231 | + * 2. Read system state + detect changes |
| 232 | + * 3. Apply changes via fan.make_call() + set guard |
| 233 | + * |
| 234 | + * The guard prevents the feedback loop: |
| 235 | + * sync → call.perform() → on_speed_set fires → set_intensity |
| 236 | + * → intensity changes → next sync detects change → loop forever |
| 237 | + * |
| 238 | + * By setting the guard BEFORE call.perform() and releasing it at the |
| 239 | + * TOP of the NEXT sync cycle (after 200ms propagation), we ensure |
| 240 | + * ESPHome has fully processed the state change before accepting |
| 241 | + * new user input. |
| 242 | + */ |
| 243 | +inline void ha_fan_sync_state() { |
| 244 | + using namespace ventosync::ha_fan; |
| 245 | + |
| 246 | + // ── Phase 0: Guard management ── |
| 247 | + if (ha_fan_try_release_guard()) { |
| 248 | + return; // Still within propagation window, skip |
| 249 | + } |
| 250 | + |
| 251 | + // ── Phase 1: Read current system state ── |
| 252 | + bool sys_on = id(system_on) && id(ventilation_enabled); |
| 253 | + const int mode_idx = id(current_mode_index); |
| 254 | + |
| 255 | + // Mode index 4 = "Aus" → system is logically off |
| 256 | + if (mode_idx == MODE_INDEX_OFF) { |
| 257 | + sys_on = false; |
| 258 | + } |
| 259 | + |
| 260 | + const int intensity = std::clamp( |
| 261 | + static_cast<int>(id(fan_intensity_level)), 1, 10); |
| 262 | + |
| 263 | + // Map mode index → preset name (only for active modes 0–3) |
| 264 | + const char *target_preset = nullptr; |
| 265 | + if (sys_on && mode_idx >= 0 && mode_idx <= MODE_INDEX_MAX_ACTIVE) { |
| 266 | + target_preset = MODE_NAMES[mode_idx]; |
| 267 | + } |
| 268 | + |
| 269 | + // ── Phase 2: Detect changes ── |
| 270 | + auto &fan = id(ventosync_hrv_fan); |
| 271 | + |
| 272 | + const bool state_changed = (fan.state != sys_on); |
| 273 | + const bool speed_changed = (sys_on && fan.speed != intensity); |
| 274 | + |
| 275 | + bool preset_changed = false; |
| 276 | + if (sys_on && target_preset != nullptr) { |
| 277 | + std::string current_preset; |
| 278 | + if (fan.has_preset_mode()) { |
| 279 | + current_preset = fan.get_preset_mode().str(); |
| 280 | + } |
| 281 | + preset_changed = (current_preset != target_preset); |
| 282 | + } |
| 283 | + |
| 284 | + // Nothing changed → no work |
| 285 | + if (!state_changed && !speed_changed && !preset_changed) { |
| 286 | + return; |
| 287 | + } |
| 288 | + |
| 289 | + // ── Phase 3: Apply changes + set guard ── |
| 290 | + ha_fan_set_guard(); |
| 291 | + |
| 292 | + auto call = fan.make_call(); |
| 293 | + if (sys_on) { |
| 294 | + call.set_state(true); |
| 295 | + call.set_speed(intensity); |
| 296 | + if (target_preset != nullptr) { |
| 297 | + call.set_preset_mode(target_preset); |
| 298 | + } |
| 299 | + } else { |
| 300 | + call.set_state(false); |
| 301 | + } |
| 302 | + call.perform(); |
| 303 | + |
| 304 | + ESP_LOGD("ha_fan", "Synced → on:%s speed:%d preset:%s", |
| 305 | + sys_on ? "yes" : "no", |
| 306 | + intensity, |
| 307 | + target_preset ? target_preset : "n/a"); |
| 308 | +} |
0 commit comments