Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion zeclock/backends/zedmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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).

Expand Down
133 changes: 89 additions & 44 deletions zeclock/clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
)
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -431,20 +455,41 @@ 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:
# Increase backoff
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

Expand All @@ -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:
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions zeclock/wifi_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading