Skip to content

Commit 339669a

Browse files
committed
refactor: extract HA fan entity integration to C++ helper
1 parent 1070187 commit 339669a

6 files changed

Lines changed: 338 additions & 198 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.14] - 2026-05-17
9+
10+
### Added
11+
12+
- **HA Fan Helper-Modul**: Neuer Header `ha_fan_helpers.h` mit vollständiger
13+
Auslagerung der Fan-Entity-Logik:
14+
- `ha_fan_sync_state()` – 65-Zeilen Sync-Engine aus dem Intervall-Lambda extrahiert
15+
- `ha_fan_on_speed_set/preset_set/turn_on/turn_off()` – Alle Card-Input-Handler
16+
- `ha_fan_guard_active/set_guard/try_release_guard()` – Feedback-Loop-Guard als
17+
dedizierte Funktionen (vorher in 5 Lambdas verstreut)
18+
- Benannte Konstanten für Guard-Timeouts (`GUARD_PROPAGATION_MS`,
19+
`GUARD_STUCK_TIMEOUT_MS`) und Mode-Indizes
20+
21+
### Changed
22+
23+
- **ha_fan_entity.yaml**: Von ~100 Inline-C++-Zeilen auf 5 Einzeiler-Lambdas reduziert.
24+
Die gesamte Sync-Engine und Guard-Logik lebt nun testbar in `ha_fan_helpers.h`.
25+
826
## [0.9.13] - 2026-05-17
927

1028
### Added
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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+
}

manifest_example.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"name": "thomasengeroff.ventosync",
3-
"version": "0.9.13",
3+
"version": "0.9.14",
44
"builds": [
55
{
66
"chipFamily": "ESP32-C6",
77
"ota": {
8-
"md5": "f4e6cd58ce575711182ac1413361636f",
8+
"md5": "16f12dece143c47cc6f55fbaed47bbdc",
99
"path": "firmware.bin",
1010
"summary": "OTA update check frequency set to 15 minutes"
1111
}

packages/base/ventosync_base.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ esphome:
103103
includes:
104104
- include/globals.h
105105
- include/automation_helpers.h
106+
- include/ha_fan_helpers.h
106107
- include/config_helpers.h
107108
- include/vacation_helpers.h
108109
- include/health_helpers.h

0 commit comments

Comments
 (0)