From ec6de35d0d0043601f97b59b15cc578abefe673b Mon Sep 17 00:00:00 2001 From: Olivier Jacques Date: Sun, 26 Jul 2026 10:44:37 +0200 Subject: [PATCH] fix: ZeDMD reconnect resilience + mDNS re-resolution - Improve connection logging: log method (USB/WiFi) and host at each attempt - Alternate USB and WiFi (via zedmd-wifi.local) during retry loop - Re-resolve mDNS on every WiFi retry to handle IP changes after network outages or DHCP renewal - Add periodic HTTP health check (every 10s) to detect silent WiFi disconnections (UDP sends don't fail when device disappears) - Add autoconnect-retries=0 (infinite) to WiFi connection profiles created by the captive portal - Replace emoji print() with logger calls in clock.py --- zeclock/backends/zedmd.py | 52 ++++++++++++++- zeclock/clock.py | 133 +++++++++++++++++++++++++------------- zeclock/wifi_setup.py | 1 + 3 files changed, 141 insertions(+), 45 deletions(-) diff --git a/zeclock/backends/zedmd.py b/zeclock/backends/zedmd.py index b62bf7d..4774716 100644 --- a/zeclock/backends/zedmd.py +++ b/zeclock/backends/zedmd.py @@ -105,6 +105,12 @@ def __init__( self._stream_error_flag = False self._error_lock = threading.Lock() + # WiFi health check state (periodic HTTP probe) + import time as _time + + self._last_health_check = _time.monotonic() + self._health_check_interval = 10.0 # seconds between probes + # Load library — raises ImportError if not found lib_path = _find_library() try: @@ -283,15 +289,21 @@ def connect(self) -> bool: # Connect via WiFi or USB if self._wifi_addr: + logger.info("Connecting to ZeDMD via WiFi (host=%s)...", self._wifi_addr) ok = self._lib.ZeDMD_OpenWiFi(self._instance, self._wifi_addr.encode()) elif self._device: + logger.info("Connecting to ZeDMD via USB (device=%s)...", self._device) self._lib.ZeDMD_SetDevice(self._instance, self._device.encode()) ok = self._lib.ZeDMD_Open(self._instance) else: + logger.info("Connecting to ZeDMD via USB (auto-detect)...") ok = self._lib.ZeDMD_Open(self._instance) if not ok: - logger.warning("Failed to connect to ZeDMD") + if self._wifi_addr: + logger.warning("Failed to connect to ZeDMD at %s", self._wifi_addr) + else: + logger.warning("Failed to connect to ZeDMD via USB") self._instance = None self._log_callback_ref = None return False @@ -333,6 +345,9 @@ def send_frame( marks as disconnected, closes the instance, and returns False. The caller (clock.py main loop) handles the wait and retry. + For WiFi connections, periodically probes the device via HTTP + to detect silent disconnections (UDP sends won't fail on their own). + Args: image: PIL Image to send (any mode, will be converted to RGB). buffered: Unused, kept for interface compatibility. @@ -357,6 +372,22 @@ def send_frame( self._close_instance() return False + # WiFi health check: probe device every ~10s to detect silent loss + if self._wifi_addr: + import time + + now = time.monotonic() + if now - self._last_health_check >= self._health_check_interval: + self._last_health_check = now + if not self._probe_device(): + logger.warning( + "ZeDMD health check failed (host=%s) — marking disconnected", + self._wifi_addr, + ) + self._connected = False + self._close_instance() + return False + # Colorize grayscale if needed if image.mode != "RGB": image = colorize_grayscale(image, color) @@ -378,6 +409,25 @@ def send_frame( return True + def _probe_device(self) -> bool: + """Quick HTTP probe to check if the ZeDMD is still reachable. + + Returns True if the device responds, False otherwise. + Uses a very short timeout (2s) to avoid blocking the render loop. + """ + import urllib.request + + try: + req = urllib.request.Request( + f"http://{self._wifi_addr}/get_version", + headers={"User-Agent": "zeClock"}, + ) + with urllib.request.urlopen(req, timeout=2) as resp: + resp.read() + return True + except Exception: + return False + def _close_instance(self) -> None: """Close the current ZeDMD instance (best effort). diff --git a/zeclock/clock.py b/zeclock/clock.py index e74c394..acc2247 100644 --- a/zeclock/clock.py +++ b/zeclock/clock.py @@ -149,7 +149,7 @@ async def run(self) -> None: hasattr(self.dmd_client, "_wifi_addr") and not self.dmd_client._wifi_addr ): - print("⚠️ ZeDMD not found via USB — starting network discovery...") + logger.info("ZeDMD not found via USB -- starting network discovery...") self._discovery_state.update( "scanning", "ZeDMD not found via USB, starting network discovery..." ) @@ -161,23 +161,28 @@ async def run(self) -> None: if result: # Found via discovery — reconfigure the backend with the discovered IP - print( - f"✅ ZeDMD discovered at {result.ip}:{result.port} (v{result.version})" + logger.info( + "ZeDMD discovered at %s:%d (v%s)", + result.ip, + result.port, + result.version, ) self.dmd_client._wifi_addr = result.ip if self.dmd_client.connect(): - print("✅ ZeDMD connected via WiFi") + logger.info("ZeDMD connected via WiFi") _persist_wifi_addr(result.ip) else: - print( - "⚠️ Discovery found ZeDMD but connection failed — retrying..." + logger.warning( + "Discovery found ZeDMD but connection failed -- retrying..." ) else: - print("⚠️ ZeDMD not found on network — waiting for device...") + logger.warning( + "ZeDMD not found on network -- waiting for device..." + ) if not self.dmd_client.connected: - print( - "👉 Check your backend configuration (--backend, --wifi-addr, --device)" + logger.warning( + "Check your backend configuration (--backend, --wifi-addr, --device)" ) self._discovery_state.update("waiting", "Waiting for ZeDMD...") delay = 3.0 @@ -186,42 +191,61 @@ async def run(self) -> None: await asyncio.sleep(delay) attempt += 1 - # Alternate: odd attempts try USB, even attempts try mDNS + # Alternate: odd attempts try USB, even attempts try WiFi/mDNS if attempt % 2 == 1: - # Try USB + # Try USB (auto-detect) + logger.debug("Retry #%d: trying USB...", attempt) + saved_wifi = self.dmd_client._wifi_addr # type: ignore[attr-defined] + self.dmd_client._wifi_addr = None # type: ignore[attr-defined] if self.dmd_client.connect(): - print("✅ DMD backend connected (USB)") - self._discovery_state.update("found", "ZeDMD connected") - break - else: - # Try mDNS discovery - if not self.dmd_client._wifi_addr: # type: ignore[attr-defined] + logger.info("✅ DMD backend connected (USB)") self._discovery_state.update( - "scanning", "Retrying network discovery..." + "found", "ZeDMD connected (USB)" ) - result = await asyncio.get_event_loop().run_in_executor( - None, discover_zedmd, self._discovery_state + break + # Restore wifi_addr for next WiFi attempt + self.dmd_client._wifi_addr = saved_wifi # type: ignore[attr-defined] + else: + # Try WiFi: re-resolve mDNS in case IP changed, + # Resolve zedmd-wifi.local via mDNS — always use the + # freshly resolved IP (it may have changed after a + # network outage / DHCP renewal) + from .discovery import resolve_mdns, ZEDMD_MDNS_HOSTNAME + + new_ip = await asyncio.get_event_loop().run_in_executor( + None, resolve_mdns, ZEDMD_MDNS_HOSTNAME + ) + if new_ip: + if ( + new_ip + != self.dmd_client._wifi_addr # type: ignore[attr-defined] + ): + logger.info( + "ZeDMD IP changed: %s -> %s (via mDNS)", + self.dmd_client._wifi_addr, # type: ignore[attr-defined] + new_ip, + ) + _persist_wifi_addr(new_ip) + self.dmd_client._wifi_addr = new_ip # type: ignore[attr-defined] + logger.debug( + "Retry #%d: trying WiFi via zedmd-wifi.local" + " (resolved=%s)...", + attempt, + new_ip, ) - if result: - self.dmd_client._wifi_addr = result.ip # type: ignore[attr-defined] - if self.dmd_client.connect(): - print( - f"✅ ZeDMD discovered and connected at {result.ip}" - ) - self._discovery_state.update( - "found", f"ZeDMD connected at {result.ip}" - ) - _persist_wifi_addr(result.ip) - break - else: - # WiFi addr known but connection failed — retry connect if self.dmd_client.connect(): - print("✅ DMD backend connected (WiFi)") - self._discovery_state.update("found", "ZeDMD connected") + logger.info("✅ DMD backend connected (WiFi)") + self._discovery_state.update( + "found", "ZeDMD connected (WiFi)" + ) break + else: + logger.debug( + "Retry #%d: zedmd-wifi.local not resolvable", attempt + ) delay = min(delay * 1.3, 10.0) - logger.info("DMD still unavailable — next retry in %.0fs", delay) + logger.info("DMD still unavailable -- next retry in %.0fs", delay) if not self.running: return else: @@ -249,7 +273,7 @@ async def run(self) -> None: await self._brightness_scheduler.update_sun_data() frame_time = 1 / self.fps - print(f"🕒 Starting zeClock at {self.fps} FPS") + print(f"Starting zeClock at {self.fps} FPS") try: while self.running: @@ -431,12 +455,33 @@ async def run(self) -> None: # Handle connection loss — wait with backoff, then reconnect if not success: if not self._reconnect_logged: - print("⚠️ ZeDMD disconnected — waiting to reconnect...") + logger.warning("ZeDMD disconnected -- waiting to reconnect...") self._reconnect_logged = True await asyncio.sleep(self._reconnect_delay) + + # Always re-resolve zedmd-wifi.local to get current IP + if ( + hasattr(self.dmd_client, "_wifi_addr") + and self.dmd_client._wifi_addr + ): + from .discovery import resolve_mdns, ZEDMD_MDNS_HOSTNAME + + new_ip = await asyncio.get_event_loop().run_in_executor( + None, resolve_mdns, ZEDMD_MDNS_HOSTNAME + ) + if new_ip: + if new_ip != self.dmd_client._wifi_addr: + logger.info( + "ZeDMD IP changed: %s -> %s (via mDNS)", + self.dmd_client._wifi_addr, + new_ip, + ) + _persist_wifi_addr(new_ip) + self.dmd_client._wifi_addr = new_ip + # Try to reconnect if self.dmd_client.connect(): - print("✅ ZeDMD reconnected — resuming display") + logger.info("✅ ZeDMD reconnected -- resuming display") self._reconnect_logged = False self._reconnect_delay = 2.0 # Reset backoff else: @@ -444,7 +489,7 @@ async def run(self) -> None: self._reconnect_delay = min(self._reconnect_delay * 1.5, 30.0) continue elif self._reconnect_logged: - print("✅ ZeDMD reconnected — resuming display") + logger.info("✅ ZeDMD reconnected -- resuming display") self._reconnect_logged = False self._reconnect_delay = 2.0 @@ -455,9 +500,9 @@ async def run(self) -> None: await asyncio.sleep(sleep_time) except KeyboardInterrupt: - print("\n🛑 Stopping zeClock...") + print("\nStopping zeClock...") except asyncio.CancelledError: - print("\n🛑 Stopping zeClock...") + print("\nStopping zeClock...") finally: # Stop remote control services if self._mqtt_remote: @@ -468,7 +513,7 @@ async def run(self) -> None: if self._plugin_manager and self._plugin_manager.is_plugin_active(): await self._plugin_manager.deactivate_plugin() self.dmd_client.disconnect() - print("✅ ZeDMD disconnected") + print("ZeDMD disconnected") async def _init_plugin_system(self) -> None: """Initialize the PluginManager, discover and load plugins.""" @@ -1225,7 +1270,7 @@ def main() -> None: # was skipped due to aggressive cancellation, disconnect here as a safety net. if clock.dmd_client.connected: clock.dmd_client.disconnect() - print("✅ ZeDMD disconnected") + print("ZeDMD disconnected") def _handle_list_plugins(args: Any) -> None: diff --git a/zeclock/wifi_setup.py b/zeclock/wifi_setup.py index 09cb0a8..55082df 100644 --- a/zeclock/wifi_setup.py +++ b/zeclock/wifi_setup.py @@ -244,6 +244,7 @@ def connect_to_network(ssid: str, password: str) -> Tuple[bool, str]: type=wifi interface-name={AP_INTERFACE} autoconnect=true +autoconnect-retries=0 [wifi] mode=infrastructure