import { useEffect } from "preact/hooks"; import { $websocket } from "./WebSocketAtom"; import { stateSetters, LITTLEFS_VERSION, type UpdateInfo, type UpdateStatus, type UpdateTarget, } from "./AppProviderStore"; import { $scanned_wifi_networks, $current_network_configuration, $network_status_display, $network_config_error, request_network_config, type WiFiNetwork, authmodeToSecurity, ConnectionMode, } from "./NetworkManagerStore"; import { send_webinterface_version } from "./ota_update_components/OtaCommands"; export { $websocket } from "./WebSocketAtom"; // Heartbeat configuration const HEARTBEAT_INTERVAL = 1500; // Send ping every 1.5 seconds const HEARTBEAT_TIMEOUT = 1000; // Wait 1 second for pong response const MAX_MISSED_HEARTBEATS = 5; // Close connection after 5 missed pongs // Heartbeat state let heartbeatInterval: ReturnType | null = null; let heartbeatTimeout: ReturnType | null = null; let missedHeartbeats = 0; let _lastPingSentAt = 0; // Reconnect state let isReconnecting = false; let reconnectTimeout: ReturnType | null = null; function clearHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; } missedHeartbeats = 0; } function clearReconnect() { if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } isReconnecting = false; } function scheduleReconnect() { if (isReconnecting) { return; } isReconnecting = true; reconnectTimeout = setTimeout(() => { connectWebSocket(); isReconnecting = false; }, 1000); } function sendHeartbeat() { const ws = $websocket.get(); if (ws && (ws as WebSocket).readyState === (ws as WebSocket).OPEN) { // Send ping message via WebSocket const pingMessage = JSON.stringify({ message_type: "ping" }); try { (ws as WebSocket).send(pingMessage); _lastPingSentAt = performance.now(); } catch (error) { missedHeartbeats++; return; } // Set timeout for pong response if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; } heartbeatTimeout = setTimeout(() => { missedHeartbeats++; if (missedHeartbeats >= MAX_MISSED_HEARTBEATS) { // First stop the heartbeat, then close + null the socket clearHeartbeat(); const cur = $websocket.get(); if (cur) { (cur as WebSocket).close(); stateSetters.setWebSocketConnectionStatus("Disconnected"); $websocket.set(null); } } }, HEARTBEAT_TIMEOUT); } } function startHeartbeat() { clearHeartbeat(); // Clear any existing heartbeat heartbeatInterval = setInterval(() => { sendHeartbeat(); }, HEARTBEAT_INTERVAL); } function onOpen() { stateSetters.setWebSocketConnectionStatus("Connected"); missedHeartbeats = 0; // Start heartbeat mechanism startHeartbeat(); // Ask the device for its current network config so the UI knows whether to show // the first-time wizard or the configured overview. setTimeout(() => { request_network_config(); }, 100); // Inform the firmware which webinterface version this bundle was built with. // The firmware uses this as update_info_s3_webinterface.currentVersion for the // OTA check, so independent FS-only updates work correctly. See // set_webinterface_version handler in websocket_server.cpp. setTimeout(() => { console.log("[OTA] sending LITTLEFS_VERSION=", LITTLEFS_VERSION); send_webinterface_version(LITTLEFS_VERSION); }, 200); } // Map FW status strings (target_update_status_t -> getStatusString in // update_notifier.cpp) to the web app's UpdateStatus union. function mapStatus(s: string | undefined): UpdateStatus { switch (s) { case "no_update": case "started": case "in_progress": case "completed": case "error": case "restarting": case "waiting": return s; default: console.warn("[OTA] unknown status string from FW:", s); return "no_update"; } } function isUpdateTarget(s: string | undefined): s is UpdateTarget { return s === "s3_firmware" || s === "s3_webinterface"; } // Translate an `update_progress` frame into atom updates + drive the modal // state machine. Called from onMessage. function handleUpdateProgress(target: UpdateTarget, progress: number, status: UpdateStatus, errorMsg?: string) { console.log(`[OTA] progress: target=${target} progress=${progress} status=${status}`, errorMsg ? `error=${errorMsg}` : ""); if (target === "s3_firmware") { stateSetters.setS3FirmwareProgress(progress, status); } else { stateSetters.setS3WebinterfaceProgress(progress, status); } if (status === "error") { if (errorMsg) stateSetters.setUpdateErrorFor(target, errorMsg); stateSetters.setUpdateState("error"); } else if (status === "restarting") { stateSetters.setUpdateState("restarting"); } else if (status === "started" || status === "in_progress") { stateSetters.setUpdateState("updating"); } else if (status === "completed") { // Completion of one target doesn't necessarily mean the whole sequence is // done — the firmware sends per-target completed frames and a final // restart frame when the firmware part lands. We let device_restarting / // a separate aggregate trigger flip to 'success'. if (target === "s3_firmware") { // FW always restarts after its own update → next frame will be // device_restarting. Mark as restarting now so the success popup picks // up the right copy. stateSetters.setUpdateState("restarting"); } else { // Web-only successful update: no restart, switch straight to success. stateSetters.setUpdateState("success"); } } } function onMessage(message: MessageEvent) { // Verarbeite JSON Nachrichten const dataFromServer = JSON.parse(message.data.toString()); if (dataFromServer.message_type === "pong") { // reset missed heartbeats and clear pending timeout if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; } missedHeartbeats = 0; if ((window as any).__wsDebug && _lastPingSentAt > 0) console.log(`[WS] pong RTT=${(performance.now() - _lastPingSentAt).toFixed(1)}ms`); } else if (dataFromServer.message_type === "wifi_networks_response") { const networks: WiFiNetwork[] = (dataFromServer.networks ?? []).map((n: any) => ({ SSID: n.ssid ?? "", RSSI: n.rssi ?? 0, Channel: n.channel ?? 0, SecurityType: authmodeToSecurity(n.authmode ?? 0), })); $scanned_wifi_networks.set(networks); } else if (dataFromServer.message_type === "network_status_update") { $network_status_display.set({ Connection_Mode: dataFromServer.Connection_Mode ?? ConnectionMode.CONNECTION_MODE_UNKNOWN, AP_Channel: dataFromServer.AP_Channel ?? 0, IP_Address_Station: dataFromServer.IP_Address_Station ?? "", Gateway_IP_Address_Station: dataFromServer.Gateway_IP_Address_Station ?? "", Netmask_Station: dataFromServer.Netmask_Station ?? "", MDNS_Hostname: dataFromServer.MDNS_Hostname ?? "", MAC_Address_WiFi: dataFromServer.MAC_Address_WiFi ?? "", rssi: dataFromServer.rssi ?? null, }); } else if (dataFromServer.message_type === "network_config_response") { console.log("[WS] network_config_response received:", dataFromServer); $current_network_configuration.set({ Connection_Mode: dataFromServer.Connection_Mode ?? ConnectionMode.CONNECTION_MODE_UNKNOWN, DHCP_Mode_Station: dataFromServer.DHCP_Mode_Station ?? true, AP_Channel: dataFromServer.AP_Channel ?? 0, IP_Address_Station: dataFromServer.IP_Address_Station ?? "", Gateway_IP_Address_Station: dataFromServer.Gateway_IP_Address_Station ?? "", Netmask_Station: dataFromServer.Netmask_Station ?? "", Static_Cfg_IP_Address_Station: dataFromServer.Static_Cfg_IP_Address_Station ?? "", Static_Cfg_Gateway_IP_Address_Station: dataFromServer.Static_Cfg_Gateway_IP_Address_Station ?? "", Static_Cfg_Netmask_Station: dataFromServer.Static_Cfg_Netmask_Station ?? "", SSID_Station: dataFromServer.SSID_Station ?? "", Password_Station: dataFromServer.Password_Station ?? "", SSID_AP: dataFromServer.SSID_AP ?? "", Password_AP: dataFromServer.Password_AP ?? "", MDNS_Hostname: dataFromServer.MDNS_Hostname ?? "hydrobuddy", }); // Update MAC in status display — config response is the first message that carries it if (dataFromServer.MAC_Address_WiFi) { $network_status_display.set({ ...$network_status_display.get(), MAC_Address_WiFi: dataFromServer.MAC_Address_WiFi || $network_status_display.get().MAC_Address_WiFi, }); } console.log("[WS] $current_network_configuration updated:", $current_network_configuration.get()); } else if (dataFromServer.message_type === "network_config_failed") { const failedSsid: string = dataFromServer.failed_ssid ?? ""; const reason: string = dataFromServer.reason ?? "unknown"; const connectionMode: ConnectionMode = dataFromServer.connection_mode ?? ConnectionMode.CONNECTION_MODE_UNKNOWN; console.warn(`[WS] network_config_failed: ssid='${failedSsid}' reason='${reason}'`); $network_config_error.set({ reason, failed_ssid: failedSsid, connection_mode: connectionMode }); } else if (dataFromServer.message_type === "error_popup") { console.warn(`[error_popup] ${dataFromServer.error ?? dataFromServer.message ?? "(no detail)"}`); } else if (dataFromServer.message_type === "update_information") { // Server-side response to FIRMWARE_UPDATE_CMD_REQUEST_AVAILABLE_UPDATES. // Shape: { update_available: { s3_firmware?: {...}, s3_webinterface?: {...} }, changelog } console.log("[OTA] update_information received", dataFromServer); const ua = dataFromServer.update_available ?? {}; const updates: UpdateInfo[] = [ { target: "s3_webinterface", label: "Webinterface", currentVersion: ua.s3_webinterface?.current_version ?? LITTLEFS_VERSION, newVersion: ua.s3_webinterface?.new_version ?? "", updateAvailable: !!ua.s3_webinterface?.updateAvailable, }, { target: "s3_firmware", label: "S3 Firmware", currentVersion: ua.s3_firmware?.current_version ?? "", newVersion: ua.s3_firmware?.new_version ?? "", updateAvailable: !!ua.s3_firmware?.updateAvailable, }, ]; stateSetters.setAvailableUpdates(updates); stateSetters.setUpdateChangelog(dataFromServer.changelog ?? ""); if (updates.some((u) => u.updateAvailable)) { console.log("[OTA] updates available — opening UpdaterModal"); stateSetters.setUpdaterModalOpen(true); } else { console.log("[OTA] no updates available"); } } else if (dataFromServer.message_type === "update_progress") { if (!isUpdateTarget(dataFromServer.target)) { console.warn("[OTA] update_progress with unknown target:", dataFromServer.target); } else { handleUpdateProgress( dataFromServer.target, Number(dataFromServer.progress ?? 0), mapStatus(dataFromServer.status), dataFromServer.error, ); } } else if (dataFromServer.message_type === "update_in_progress") { console.log("[OTA] update_in_progress=", dataFromServer.in_progress, "target=", dataFromServer.target); stateSetters.setUpdateInProgress(!!dataFromServer.in_progress); } else if (dataFromServer.message_type === "device_restarting") { console.log("[OTA] device_restarting received — FW is rebooting"); stateSetters.setUpdateState("restarting"); } else { // Unbekannter Nachrichtentyp - korrektes Feld protokollieren console.warn(`[${new Date().toISOString()}] Unbekannter Nachrichtentyp: ${dataFromServer.message_type ?? "undefined"}`); console.debug("Payload:", dataFromServer); } } function onError(error: Event) { stateSetters.setWebSocketConnectionStatus("Disconnected"); console.log("WebSocket error:", error); clearHeartbeat(); scheduleReconnect(); } function onClose(event: CloseEvent) { stateSetters.setWebSocketConnectionStatus("Disconnected"); console.log("WebSocket closed with code:", event.code); console.log("Close reason:", event.reason); console.log("Clean close:", event.wasClean); clearHeartbeat(); if (event.code === 1000) { console.log("Normal closure"); } scheduleReconnect(); } function getWsAddress(): string { // Derive WS address from the page's origin so it works with any hostname or IP. const h = window.location.hostname; if (h === "localhost" || h === "127.0.0.1") { return (import.meta.env.PUBLIC_DEV_WS_HOST as string | undefined) ?? "ws://hydrobuddy.local/ws"; } return `ws://${h}/ws`; } const connectWebSocket = () => { // Clear any existing reconnect attempts clearReconnect(); clearHeartbeat(); const wsAddress = getWsAddress(); console.log(`Verbinde mit: ${wsAddress}`); if ($websocket.get() !== null) { ($websocket.get() as WebSocket).close(1000); $websocket.set(null); } $websocket.set(new WebSocket(wsAddress)); const ws = $websocket.get(); if (ws !== null) { (ws as WebSocket).onopen = onOpen; (ws as WebSocket).onmessage = onMessage; (ws as WebSocket).onerror = onError; (ws as WebSocket).onclose = onClose; } }; export const WebSocketProvider = () => { // Auto-connect WebSocket at first render useEffect(() => { console.log("Component mounted, starting WebSocket connection..."); connectWebSocket(); // Cleanup beim Unmounting return () => { clearHeartbeat(); clearReconnect(); const cur = $websocket.get(); if (cur) (cur as WebSocket).close(); $websocket.set(null); stateSetters.setWebSocketConnectionStatus("Disconnected"); }; }, []); };