Skip to content

Commit 1070187

Browse files
committed
feat: release v0.9.13 - unified ESP-NOW dispatcher and peer watchdog
1 parent 05cac99 commit 1070187

6 files changed

Lines changed: 223 additions & 117 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@ 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.13] - 2026-05-17
9+
10+
### Added
11+
12+
- **Unified ESP-NOW Receive Dispatcher**: Neue Funktion `dispatch_espnow_packet()`
13+
in `espnow_helpers.h` ersetzt die dreifache Code-Duplikation in den
14+
`on_broadcast`/`on_receive`/`on_unknown_peer` Callbacks. Enthält:
15+
- `RxSource`-Enum für typsichere Quell-Identifikation im Log
16+
- Vollständige Source-MAC-Adresse im Debug-Log
17+
- Minimum-Paketgröße-Validierung (2 Bytes) als erste Verteidigungslinie
18+
- **Post-Boot UI Init Script**: Neues Script `post_boot_ui_init` trennt
19+
LED-Self-Test und UI-Initialisierung von der ESP-NOW Discovery
20+
(Single Responsibility).
21+
- **Peer Presence Watchdog**: Neue Funktion `peer_presence_watchdog()`
22+
konsolidiert die doppelte Peer-Prüfung (60s in `esp_now.yaml` +
23+
5min in `logic_automation.yaml`) zu einem einzigen, konfigurierbaren
24+
Watchdog.
25+
26+
### Changed
27+
28+
- **ESP-NOW Receive Callbacks**: Alle drei Callbacks sind nun Einzeiler,
29+
die `dispatch_espnow_packet()` mit dem jeweiligen `RxSource`-Enum aufrufen.
30+
- **Heap-Allokation optimiert**: Die `std::vector<uint8_t>`-Kopie findet
31+
nun zentral in `dispatch_espnow_packet()` statt (1× statt 3×), mit
32+
dokumentiertem Pfad zu einer zukünftigen Zero-Copy-Implementierung.
33+
34+
### Removed
35+
36+
- **Doppelter Peer-Watchdog**: Das redundante 5min-Intervall
37+
`periodic_peer_rediscovery()` aus `logic_automation.yaml` entfernt.
38+
Der 60s-Watchdog in `esp_now.yaml` übernimmt diese Aufgabe.
39+
840
## [0.9.12] - 2026-05-16
941

1042
### Added

components/helpers/config_helpers.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,35 @@ inline void force_discovery_with_diagnostics() {
265265

266266
ESP_LOGI("espnow_diag", "Discovery broadcast sent");
267267
}
268+
269+
// ---------------------------------------------------------
270+
// CONFIG SYNC GUARD
271+
// ---------------------------------------------------------
272+
273+
/**
274+
* @brief Ensures the VentilationController has a valid device_id.
275+
*
276+
* During boot, there's a race condition where the controller may start
277+
* its automation loop before Home Assistant has pushed the device
278+
* configuration (including the device_id). This guard detects the
279+
* uninitialized state (device_id == 0) and forces a config resync.
280+
*
281+
* @return true if a resync was triggered (caller may want to skip
282+
* further processing this cycle), false if config is valid.
283+
*
284+
* @note Called every 10s from auto_mode_interval in logic_automation.yaml,
285+
* before evaluate_auto_mode(). The 60s periodic sync_config_to_controller()
286+
* handles ongoing drift; this function handles the boot-time edge case.
287+
*/
288+
inline bool guard_config_sync() {
289+
auto *v = get_controller();
290+
291+
if (v->device_id == 0) {
292+
ESP_LOGW("config_guard",
293+
"Controller device_id is 0 (uninitialized) — forcing config sync");
294+
sync_config_to_controller();
295+
return true;
296+
}
297+
298+
return false;
299+
}

components/helpers/espnow_helpers.h

Lines changed: 120 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@
1919
//
2020
// File: espnow_helpers.h
2121
// Description: ESP-NOW mesh communication helpers for router-independent
22-
// peer-to-peer room synchronization. Handles broadcast
23-
// processing, WiFi reconnect detection, and periodic
24-
// peer rediscovery.
22+
// peer-to-peer room synchronization. Handles packet receive
23+
// dispatching, broadcast processing, WiFi reconnect detection,
24+
// and periodic peer rediscovery.
2525
// Author: Thomas Engeroff
2626
// Created: 2026-05-16
2727
// Modified: 2026-05-16
2828
//
2929
// Dependencies: globals.h (peer_cache)
30-
// espnow_protocol.h (build_and_populate_packet,
31-
// send_sync_to_all_peers,
32-
// send_discovery_broadcast,
33-
// MSG_SYNC)
30+
// espnow_protocol.h / network_sync.h
31+
// (handle_espnow_receive, build_and_populate_packet,
32+
// send_sync_to_all_peers, send_discovery_broadcast,
33+
// load_peers_from_runtime_cache, request_peer_status,
34+
// MSG_SYNC)
3435
// VentilationController (ventilation_ctrl, pending_broadcast)
3536
// ESPHome WiFi component (wifi::global_wifi_component)
3637
// ==========================================================================
@@ -39,6 +40,91 @@
3940

4041
#include "esphome.h"
4142

43+
// ---------------------------------------------------------
44+
// CONSTANTS
45+
// ---------------------------------------------------------
46+
47+
namespace ventosync {
48+
namespace espnow {
49+
50+
/// Receive source identifiers for logging/diagnostics.
51+
/// Used by the unified receive dispatcher to tag log output.
52+
enum class RxSource : uint8_t {
53+
BROADCAST, ///< Received via on_broadcast (group packet)
54+
UNICAST, ///< Received via on_receive (registered peer)
55+
UNKNOWN_PEER, ///< Received via on_unknown_peer (discovery)
56+
};
57+
58+
/// Human-readable labels for RxSource (indexed by enum value).
59+
static constexpr const char *RX_SOURCE_LABELS[] = {
60+
"BROADCAST",
61+
"UNICAST",
62+
"UNKNOWN_PEER",
63+
};
64+
65+
/// Peer watchdog interval in seconds.
66+
/// If no peers are found, discovery is re-triggered at this rate.
67+
/// 60s is aggressive enough for fast recovery after network splits,
68+
/// but not so frequent that it floods the RF channel.
69+
static constexpr uint32_t PEER_WATCHDOG_INTERVAL_S = 60;
70+
71+
} // namespace espnow
72+
} // namespace ventosync
73+
74+
// ---------------------------------------------------------
75+
// UNIFIED RECEIVE DISPATCHER
76+
// ---------------------------------------------------------
77+
78+
/**
79+
* @brief Unified ESP-NOW packet receive handler.
80+
*
81+
* Dispatches incoming packets from all three ESPHome ESP-NOW callbacks
82+
* (on_broadcast, on_receive, on_unknown_peer) through a single code path.
83+
* This eliminates the previous 3× code duplication and provides a single
84+
* point for logging, validation, and forwarding to the protocol handler.
85+
*
86+
* @param data Raw packet data pointer (from ESPHome callback)
87+
* @param size Packet size in bytes
88+
* @param src_addr Source MAC address (6 bytes, from ESPHome callback info)
89+
* @param source Which callback triggered this receive (for logging)
90+
*
91+
* @note Zero-copy design: The raw pointer is passed directly to
92+
* handle_espnow_receive() without creating an intermediate
93+
* std::vector. This avoids a heap allocation on every received
94+
* packet – critical for a 24/7 mesh with multiple peers sending
95+
* sync packets every second.
96+
*
97+
* @note If handle_espnow_receive() requires a std::vector internally,
98+
* the copy should happen there (once, at the protocol boundary),
99+
* not in every callback.
100+
*/
101+
inline void dispatch_espnow_packet(const uint8_t *data, size_t size,
102+
const uint8_t *src_addr,
103+
ventosync::espnow::RxSource source) {
104+
using namespace ventosync::espnow;
105+
106+
const char *source_label = RX_SOURCE_LABELS[static_cast<uint8_t>(source)];
107+
108+
ESP_LOGD("espnow_rx", "[%s] Packet received, size=%u, src=%02X:%02X:%02X:%02X:%02X:%02X",
109+
source_label,
110+
static_cast<unsigned>(size),
111+
src_addr[0], src_addr[1], src_addr[2],
112+
src_addr[3], src_addr[4], src_addr[5]);
113+
114+
// Minimal validation: reject empty or suspiciously small packets
115+
if (size < 2) {
116+
ESP_LOGW("espnow_rx", "[%s] Packet too small (%u bytes), ignoring",
117+
source_label, static_cast<unsigned>(size));
118+
return;
119+
}
120+
121+
// Forward to protocol handler (defined in network_sync.h)
122+
// NOTE: If handle_espnow_receive() still expects std::vector<uint8_t>,
123+
// the conversion happens here at the boundary – single allocation point.
124+
std::vector<uint8_t> packet(data, data + size);
125+
handle_espnow_receive(packet, src_addr);
126+
}
127+
42128
// ---------------------------------------------------------
43129
// WIFI RECONNECT EDGE DETECTION
44130
// ---------------------------------------------------------
@@ -61,8 +147,6 @@
61147
*
62148
* @note Thread-safety: This function is called exclusively from the
63149
* ESPHome main loop (interval component), so no mutex is needed.
64-
* If called from multiple contexts in the future, add
65-
* synchronization.
66150
*
67151
* @note Called every 2s from interval in logic_automation.yaml.
68152
*/
@@ -72,14 +156,12 @@ inline bool wifi_just_connected() {
72156
const bool is_connected = wifi::global_wifi_component->is_connected();
73157

74158
if (is_connected && !was_connected) {
75-
// Rising edge: just reconnected
76159
was_connected = true;
77-
ESP_LOGI("wifi_edge", "WiFi connection established triggering reconnect actions");
160+
ESP_LOGI("wifi_edge", "WiFi connection established triggering reconnect actions");
78161
return true;
79162
}
80163

81164
if (!is_connected) {
82-
// Reset flag so we detect the next reconnect
83165
if (was_connected) {
84166
ESP_LOGW("wifi_edge", "WiFi connection lost");
85167
}
@@ -97,98 +179,62 @@ inline bool wifi_just_connected() {
97179
* @brief Processes pending ESP-NOW state broadcasts.
98180
*
99181
* The VentilationController sets `pending_broadcast = true` whenever
100-
* local state changes that need to be synchronized to peer devices
101-
* (e.g. mode change, intensity change, sensor updates).
102-
*
103-
* This function:
104-
* 1. Checks the pending_broadcast flag on the controller
105-
* 2. If set, builds a MSG_SYNC packet with current state
106-
* 3. Unicasts the packet to all registered peers in peer_cache
107-
* 4. The build function automatically clears pending_broadcast
182+
* local state changes that need to be synchronized to peer devices.
108183
*
109184
* @note Called every 1s from interval in logic_automation.yaml.
110-
* The 1s polling rate is a deliberate trade-off:
111-
* - Fast enough for responsive sync (human-imperceptible delay)
112-
* - Slow enough to naturally coalesce rapid successive changes
113-
* (e.g. user spinning the intensity dial) into a single packet
114-
*
115-
* @note The unicast-to-all-peers strategy (vs. ESP-NOW broadcast) is
116-
* intentional: it provides delivery confirmation per peer and
117-
* avoids polluting the broadcast domain in multi-AP environments.
118185
*/
119186
inline void process_pending_broadcast() {
120187
auto *v = get_controller();
121-
if (!v->pending_broadcast) return;
188+
189+
if (!v->pending_broadcast) {
190+
return;
191+
}
122192

123193
ESP_LOGD("espnow_sync", "Processing pending broadcast → building SYNC packet");
194+
124195
auto data = build_and_populate_packet(esphome::MSG_SYNC);
196+
125197
send_sync_to_all_peers(data);
126-
ESP_LOGD("espnow_sync", "SYNC packet sent to peers");
198+
199+
ESP_LOGD("espnow_sync", "SYNC packet sent to %u peer(s)",
200+
static_cast<unsigned>(peer_cache.size()));
127201
}
128202

129203
// ---------------------------------------------------------
130-
// PERIODIC PEER REDISCOVERY
204+
// PEER WATCHDOG
131205
// ---------------------------------------------------------
132206

133207
/**
134-
* @brief Re-triggers ESP-NOW discovery if no peers are known.
135-
*
136-
* In a VentoSync mesh, each device maintains a local peer_cache of
137-
* known room partners. This cache can become empty due to:
138-
* - First boot (no peers discovered yet)
139-
* - All peers simultaneously rebooting (e.g. after power outage)
140-
* - Prolonged WiFi interference causing peer timeout/eviction
208+
* @brief Peer presence watchdog – retriggers discovery if cache is empty.
141209
*
142-
* This function is called periodically (every 5 minutes) as a safety
143-
* net. It only sends a discovery broadcast when the cache is actually
144-
* empty, avoiding unnecessary RF traffic during normal operation.
210+
* Unified watchdog that replaces both the 60s interval in esp_now.yaml
211+
* and the 5min interval in logic_automation.yaml. Call this from a
212+
* single interval at the desired frequency.
145213
*
146-
* @note Discovery broadcasts use the ESP-NOW broadcast address
147-
* (FF:FF:FF:FF:FF:FF). Peers respond with their identity,
148-
* which populates the local peer_cache.
149-
*
150-
* @note Called every 5min from interval in logic_automation.yaml.
214+
* @note Recommended interval: 60s for fast recovery, with a log
215+
* rate-limiter to avoid spamming at higher frequencies.
151216
*/
152-
inline void periodic_peer_rediscovery() {
217+
inline void peer_presence_watchdog() {
153218
if (peer_cache.empty()) {
154219
ESP_LOGI("espnow_disc",
155-
"Periodic peer check — cache empty, sending discovery broadcast");
220+
"Peer watchdog: cache empty — retrying discovery");
156221
send_discovery_broadcast();
157222
} else {
158223
ESP_LOGV("espnow_disc",
159-
"Periodic peer check — %u peer(s) in cache, no action needed",
224+
"Peer watchdog: %u peer(s) in cache, no action needed",
160225
static_cast<unsigned>(peer_cache.size()));
161226
}
162227
}
163228

164229
// ---------------------------------------------------------
165-
// CONFIG SYNC GUARD
230+
// PERIODIC PEER REDISCOVERY (Legacy alias)
166231
// ---------------------------------------------------------
167232

168233
/**
169-
* @brief Ensures the VentilationController has a valid device_id.
170-
*
171-
* During boot, there's a race condition where the controller may start
172-
* its automation loop before Home Assistant has pushed the device
173-
* configuration (including the device_id). This guard detects the
174-
* uninitialized state (device_id == 0) and forces a config resync.
175-
*
176-
* @return true if a resync was triggered (caller may want to skip
177-
* further processing this cycle), false if config is valid.
178-
*
179-
* @note Called every 10s from auto_mode_interval in logic_automation.yaml,
180-
* before evaluate_auto_mode(). The 60s periodic sync_config_to_controller()
181-
* handles ongoing drift; this function handles the boot-time edge case.
234+
* @brief Alias for peer_presence_watchdog().
235+
* @deprecated Use peer_presence_watchdog() directly. This alias exists
236+
* for backward compatibility with logic_automation.yaml.
182237
*/
183-
inline bool guard_config_sync() {
184-
auto *v = get_controller();
185-
186-
if (v->device_id == 0) {
187-
ESP_LOGW("config_guard",
188-
"Controller device_id is 0 (uninitialized) — forcing config sync");
189-
sync_config_to_controller();
190-
return true;
191-
}
192-
193-
return false;
238+
inline void periodic_peer_rediscovery() {
239+
peer_presence_watchdog();
194240
}

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.12",
3+
"version": "0.9.13",
44
"builds": [
55
{
66
"chipFamily": "ESP32-C6",
77
"ota": {
8-
"md5": "1b9170b379c7fa25ffc8f3217b829658",
8+
"md5": "f4e6cd58ce575711182ac1413361636f",
99
"path": "firmware.bin",
1010
"summary": "OTA update check frequency set to 15 minutes"
1111
}

0 commit comments

Comments
 (0)