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// ==========================================================================
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// ---------------------------------------------------------
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 */
119186inline 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}
0 commit comments