diff --git a/src/components/AppProviderStore.tsx b/src/components/AppProviderStore.tsx index 574b4b0..a26cce2 100644 --- a/src/components/AppProviderStore.tsx +++ b/src/components/AppProviderStore.tsx @@ -4,10 +4,109 @@ import { atom } from "nanostores"; // Consumers can subscribe to react to connectivity (e.g. show a toast, gate UI actions). export const $wsConnectionStatus = atom("Disconnected"); +// --------------------------------------------------------------------------- +// OTA update state +// --------------------------------------------------------------------------- + +// LittleFS bundle version. Bumped manually when the Astro web app is rebuilt +// and a new image is shipped. Sent to the firmware over WS on connect so the +// firmware uses it as the "currentVersion" for the S3 webinterface OTA check. +// The S3 firmware version is read on-device from esp_app_get_description() +// (baked from version.txt) — no constant for that here. +export const LITTLEFS_VERSION = "0.0.1"; + +// Two OTA targets in the S3 build: the app firmware and the LittleFS image. +export type UpdateTarget = "s3_firmware" | "s3_webinterface"; + +// Per-target status (mirrors target_update_status_t in update_notifier.h). +export type UpdateStatus = + | "no_update" + | "started" + | "in_progress" + | "completed" + | "error" + | "restarting" + | "waiting"; + +// Aggregate updater UI state machine. +export type UpdateState = "initial" | "updating" | "success" | "error" | "restarting"; + +export interface UpdateInfo { + target: UpdateTarget; + label: string; // human-readable + currentVersion: string; + newVersion: string; + updateAvailable: boolean; +} + +const initialUpdates: UpdateInfo[] = [ + { target: "s3_webinterface", label: "Webinterface", currentVersion: LITTLEFS_VERSION, newVersion: "", updateAvailable: false }, + { target: "s3_firmware", label: "S3 Firmware", currentVersion: "", newVersion: "", updateAvailable: false }, +]; + +// Aggregate state machine + flags +export const $updateState = atom("initial"); +export const $updateInProgress = atom(false); + +// What the server is offering. Iterated by the modals. +export const $availableUpdates = atom(initialUpdates); +export const $updateChangelog = atom(""); + +// Per-target [progress 0..100, status]. Tuple kept for compatibility with the +// modal components that destructure it. +export const $s3FirmwareProgress = atom<[number, UpdateStatus]>([0, "no_update"]); +export const $s3WebinterfaceProgress = atom<[number, UpdateStatus]>([0, "no_update"]); + +// Error messages, keyed by target. +export const $updateErrorMessages = atom>({} as Record); + +// Modal open state +export const $isUpdaterModalOpen = atom(false); +export const $isSuccessPopupOpen = atom(false); +export const $isErrorPopupOpen = atom(false); + +// Device-reported versions (filled from FW state snapshots / status responses). +// $firmwareVersion is set when the device tells us its esp_app_get_description() +// version; $webinterfaceVersion is the version this bundle was built with. +export const $firmwareVersion = atom("unknown"); +export const $webinterfaceVersion = atom(LITTLEFS_VERSION); + // Centralised mutators so WebSocketProvider doesn't need to know about every atom. -// More setters can be added here as the app gains state beyond the WiFi config UI. export const stateSetters = { setWebSocketConnectionStatus: (status: string) => { $wsConnectionStatus.set(status); }, + + setUpdateState: (state: UpdateState) => $updateState.set(state), + setUpdateInProgress: (v: boolean) => $updateInProgress.set(v), + + setAvailableUpdates: (updates: UpdateInfo[]) => $availableUpdates.set(updates), + setUpdateChangelog: (s: string) => $updateChangelog.set(s), + + setS3FirmwareProgress: (progress: number, status: UpdateStatus) => + $s3FirmwareProgress.set([progress, status]), + setS3WebinterfaceProgress: (progress: number, status: UpdateStatus) => + $s3WebinterfaceProgress.set([progress, status]), + + setUpdateErrorMessages: (errs: Record) => + $updateErrorMessages.set(errs), + setUpdateErrorFor: (target: UpdateTarget, msg: string) => + $updateErrorMessages.set({ ...$updateErrorMessages.get(), [target]: msg }), + + setUpdaterModalOpen: (v: boolean) => $isUpdaterModalOpen.set(v), + setSuccessPopupOpen: (v: boolean) => $isSuccessPopupOpen.set(v), + setErrorPopupOpen: (v: boolean) => $isErrorPopupOpen.set(v), + + setFirmwareVersion: (v: string) => $firmwareVersion.set(v), + setWebinterfaceVersion: (v: string) => $webinterfaceVersion.set(v), + + // Wipe per-target progress + errors. Leaves $availableUpdates intact so the + // popups can still show what was being updated until the user dismisses them. + resetUpdateStatus: () => { + $updateState.set("initial"); + $updateInProgress.set(false); + $s3FirmwareProgress.set([0, "no_update"]); + $s3WebinterfaceProgress.set([0, "no_update"]); + $updateErrorMessages.set({} as Record); + }, }; diff --git a/src/components/WebSocketProvider.tsx b/src/components/WebSocketProvider.tsx index e5ad77f..f415d4d 100644 --- a/src/components/WebSocketProvider.tsx +++ b/src/components/WebSocketProvider.tsx @@ -1,6 +1,12 @@ import { useEffect } from "preact/hooks"; import { $websocket } from "./WebSocketAtom"; -import { stateSetters } from "./AppProviderStore"; +import { + stateSetters, + LITTLEFS_VERSION, + type UpdateInfo, + type UpdateStatus, + type UpdateTarget, +} from "./AppProviderStore"; import { $scanned_wifi_networks, $current_network_configuration, @@ -11,6 +17,7 @@ import { authmodeToSecurity, ConnectionMode, } from "./NetworkManagerStore"; +import { send_webinterface_version } from "./ota_update_components/OtaCommands"; export { $websocket } from "./WebSocketAtom"; @@ -113,6 +120,71 @@ function onOpen() { 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) { @@ -181,6 +253,52 @@ function onMessage(message: MessageEvent) { $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"}`); diff --git a/src/components/ota_update_components/GlobalModals.tsx b/src/components/ota_update_components/GlobalModals.tsx new file mode 100644 index 0000000..abcaee0 --- /dev/null +++ b/src/components/ota_update_components/GlobalModals.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect } from 'preact/hooks'; +import { useStore } from '@nanostores/preact'; +import { + $wsConnectionStatus, + $updateState, + $isUpdaterModalOpen, + $isSuccessPopupOpen, + $isErrorPopupOpen, + $firmwareVersion, + $webinterfaceVersion, + stateSetters, + type UpdateInfo, + type UpdateTarget, +} from '../AppProviderStore'; +import UpdaterModal from './UpdaterModal'; +import UpdateSuccessPopup from './UpdateSuccessPopup'; +import UpdateErrorPopup from './UpdateErrorPopup'; +import { request_available_updates } from './OtaCommands'; + +declare global { + interface Window { + openStatusModal?: () => void; + openFirmwareModal?: () => void; + openUpdaterModal?: () => void; + // Mockup helpers — call from the browser console to preview each modal + // state with fake data, no firmware needed. See setupOtaMocks() below. + devOtaMock?: { + initial: () => void; // UpdaterModal showing available updates + updating: () => void; // UpdaterModal mid-progress + updatingMixed: () => void; // web done, fw at 40% + success: () => void; // UpdateSuccessPopup + restarting: () => void; // UpdateSuccessPopup + restarting spinner + error: () => void; // UpdateErrorPopup + reset: () => void; // close all + clear state + }; + } +} + +// Mock data shared by the dev helpers. Two-target sample: webinterface 0.0.1 +// → 0.0.2, S3 firmware 1.0.0 → 1.0.1. Changelog has a few lines of markdown +// so the rendered prose can be inspected. +const MOCK_UPDATES: UpdateInfo[] = [ + { target: 's3_webinterface' as UpdateTarget, label: 'Webinterface', currentVersion: '0.0.1', newVersion: '0.0.2', updateAvailable: true }, + { target: 's3_firmware' as UpdateTarget, label: 'S3 Firmware', currentVersion: '1.0.0', newVersion: '1.0.1', updateAvailable: true }, +]; + +const MOCK_CHANGELOG = [ + '## 1.0.1', + '- Stabilised pH sensor read at startup', + '- Faster Wi-Fi reconnect after power loss', + '', + '## 0.0.2 (webinterface)', + '- New OTA progress modal', + '- Dark-mode polish in dashboard', +].join('\n'); + +function setupOtaMocks() { + if (typeof window === 'undefined') return; + window.devOtaMock = { + initial: () => { + console.log('[OTA][mock] initial — UpdaterModal with available updates'); + stateSetters.resetUpdateStatus(); + stateSetters.setAvailableUpdates(MOCK_UPDATES); + stateSetters.setUpdateChangelog(MOCK_CHANGELOG); + stateSetters.setUpdaterModalOpen(true); + }, + updating: () => { + console.log('[OTA][mock] updating — webinterface at 60%'); + stateSetters.resetUpdateStatus(); + stateSetters.setAvailableUpdates(MOCK_UPDATES); + stateSetters.setUpdateChangelog(MOCK_CHANGELOG); + stateSetters.setUpdateState('updating'); + stateSetters.setUpdateInProgress(true); + stateSetters.setS3WebinterfaceProgress(60, 'in_progress'); + stateSetters.setS3FirmwareProgress(0, 'waiting'); + stateSetters.setUpdaterModalOpen(true); + }, + updatingMixed: () => { + console.log('[OTA][mock] updatingMixed — web done, firmware at 40%'); + stateSetters.resetUpdateStatus(); + stateSetters.setAvailableUpdates(MOCK_UPDATES); + stateSetters.setUpdateChangelog(MOCK_CHANGELOG); + stateSetters.setUpdateState('updating'); + stateSetters.setUpdateInProgress(true); + stateSetters.setS3WebinterfaceProgress(100, 'completed'); + stateSetters.setS3FirmwareProgress(40, 'in_progress'); + stateSetters.setUpdaterModalOpen(true); + }, + success: () => { + console.log('[OTA][mock] success — UpdateSuccessPopup'); + stateSetters.resetUpdateStatus(); + stateSetters.setAvailableUpdates(MOCK_UPDATES); + stateSetters.setS3WebinterfaceProgress(100, 'completed'); + stateSetters.setS3FirmwareProgress(100, 'completed'); + stateSetters.setUpdaterModalOpen(false); + stateSetters.setUpdateState('success'); + stateSetters.setSuccessPopupOpen(true); + }, + restarting: () => { + console.log('[OTA][mock] restarting — success popup + restart spinner'); + stateSetters.resetUpdateStatus(); + stateSetters.setAvailableUpdates(MOCK_UPDATES); + stateSetters.setS3WebinterfaceProgress(100, 'completed'); + stateSetters.setS3FirmwareProgress(100, 'completed'); + stateSetters.setUpdaterModalOpen(false); + stateSetters.setUpdateState('restarting'); + stateSetters.setSuccessPopupOpen(true); + }, + error: () => { + console.log('[OTA][mock] error — UpdateErrorPopup'); + stateSetters.resetUpdateStatus(); + stateSetters.setAvailableUpdates(MOCK_UPDATES); + stateSetters.setS3WebinterfaceProgress(100, 'completed'); + stateSetters.setS3FirmwareProgress(45, 'error'); + stateSetters.setUpdateErrorFor('s3_firmware', 'OTA download aborted: server returned 500'); + stateSetters.setUpdaterModalOpen(false); + stateSetters.setUpdateState('error'); + stateSetters.setErrorPopupOpen(true); + }, + reset: () => { + console.log('[OTA][mock] reset — closing everything'); + stateSetters.resetUpdateStatus(); + stateSetters.setUpdaterModalOpen(false); + stateSetters.setSuccessPopupOpen(false); + stateSetters.setErrorPopupOpen(false); + }, + }; + console.info( + '[OTA] dev mocks registered. Try:\n' + + ' window.devOtaMock.initial()\n' + + ' window.devOtaMock.updating()\n' + + ' window.devOtaMock.updatingMixed()\n' + + ' window.devOtaMock.success()\n' + + ' window.devOtaMock.restarting()\n' + + ' window.devOtaMock.error()\n' + + ' window.devOtaMock.reset()' + ); +} + +export default function GlobalModals() { + const [isStatusModalOpen, setIsStatusModalOpen] = useState(false); + const [isFirmwareModalOpen, setIsFirmwareModalOpen] = useState(false); + + const updateState = useStore($updateState); + const isUpdaterModalOpen = useStore($isUpdaterModalOpen); + const isSuccessPopupOpen = useStore($isSuccessPopupOpen); + const isErrorPopupOpen = useStore($isErrorPopupOpen); + const wsConnectionStatus = useStore($wsConnectionStatus); + const firmwareVersion = useStore($firmwareVersion); + const webinterfaceVersion = useStore($webinterfaceVersion); + + // Register window.devOtaMock.* once on mount so the helpers are available + // from the browser console without needing the firmware running. + useEffect(() => { + setupOtaMocks(); + }, []); + + // Auto-open success/error popups when update state changes. + useEffect(() => { + console.log("[OTA][GlobalModals] state=", updateState, + "isUpdaterModalOpen=", isUpdaterModalOpen, + "isSuccessPopupOpen=", isSuccessPopupOpen, + "isErrorPopupOpen=", isErrorPopupOpen); + + if ((updateState === 'success' || updateState === 'restarting') && !isSuccessPopupOpen) { + console.log("[OTA][GlobalModals] opening SuccessPopup for state=", updateState); + stateSetters.setSuccessPopupOpen(true); + } else if (updateState === 'error' && !isErrorPopupOpen) { + console.log("[OTA][GlobalModals] opening ErrorPopup"); + stateSetters.setErrorPopupOpen(true); + } + }, [updateState, isSuccessPopupOpen, isErrorPopupOpen]); + + // Global window handles so the rest of the app can pop the modals open + // without importing the atoms directly. Triggered from header buttons / + // dev console. + if (typeof window !== 'undefined') { + window.openStatusModal = () => setIsStatusModalOpen(true); + window.openFirmwareModal = () => { + console.log("[OTA][GlobalModals] openFirmwareModal — requesting available updates"); + setIsFirmwareModalOpen(true); + request_available_updates(); + }; + window.openUpdaterModal = () => { + console.log("[OTA][GlobalModals] openUpdaterModal"); + if (!$isUpdaterModalOpen.get()) { + stateSetters.setUpdaterModalOpen(true); + } + }; + } + + return ( + <> + {/* Status Modal */} + {isStatusModalOpen && ( + + + + + )} + + {/* Firmware Update check spinner — shown briefly while we wait for the + firmware to reply with update_information. */} + {isFirmwareModalOpen && ( + + + + + )} + + { + stateSetters.setUpdaterModalOpen(false); + }} + /> + + { + stateSetters.setSuccessPopupOpen(false); + stateSetters.setUpdateState('initial'); + }} + /> + + { + stateSetters.setErrorPopupOpen(false); + stateSetters.setUpdateState('initial'); + }} + /> + + ); +} diff --git a/src/components/ota_update_components/OtaCommands.ts b/src/components/ota_update_components/OtaCommands.ts new file mode 100644 index 0000000..5196d99 --- /dev/null +++ b/src/components/ota_update_components/OtaCommands.ts @@ -0,0 +1,53 @@ +// OTA command senders. The firmware listens on the WebSocket for two-byte +// binary frames [FIRMWARE_UPDATE_CMD_HEAD, cmd_id] (see command_interface.h +// in the firmware). The dispatch is currently commented out in +// websocket_server.cpp — uncomment it on the firmware side to make these flow. +// +// Per the firmware retarget choice, the S3 build reuses the P4 enum slots: +// 0x00 = REQUEST_AVAILABLE_UPDATES +// 0x02 = START_UPDATE (S3 firmware) +// 0x03 = START_UPDATE (S3 webinterface) +import { $websocket } from "../WebSocketAtom"; + +const FIRMWARE_UPDATE_CMD_HEAD = 0xBB; + +const FIRMWARE_UPDATE_CMD_REQUEST_AVAILABLE_UPDATES = 0x00; +const FIRMWARE_UPDATE_CMD_START_UPDATE_S3_FIRMWARE = 0x02; // reused P4 slot +const FIRMWARE_UPDATE_CMD_START_UPDATE_S3_WEBINTERFACE = 0x03; // reused P4 slot + +function wsOpen(): WebSocket | null { + const ws = $websocket.get(); + return ws && (ws as WebSocket).readyState === WebSocket.OPEN ? (ws as WebSocket) : null; +} + +function sendFwCommand(cmdId: number): boolean { + const ws = wsOpen(); + if (!ws) { + console.error("[OTA] WS not open, cannot send command", cmdId); + return false; + } + ws.send(new Uint8Array([FIRMWARE_UPDATE_CMD_HEAD, cmdId])); + return true; +} + +export function request_available_updates(): boolean { + return sendFwCommand(FIRMWARE_UPDATE_CMD_REQUEST_AVAILABLE_UPDATES); +} + +export function start_firmware_update_s3_fw(): boolean { + return sendFwCommand(FIRMWARE_UPDATE_CMD_START_UPDATE_S3_FIRMWARE); +} + +export function start_firmware_update_s3_web(): boolean { + return sendFwCommand(FIRMWARE_UPDATE_CMD_START_UPDATE_S3_WEBINTERFACE); +} + +// Sent on WS open so the firmware updates its currentVersion for the +// LittleFS image. The bundled web app is the source of truth for this +// version (LITTLEFS_VERSION in AppProviderStore.tsx). +export function send_webinterface_version(version: string): boolean { + const ws = wsOpen(); + if (!ws) return false; + ws.send(JSON.stringify({ message_type: "set_webinterface_version", version })); + return true; +} diff --git a/src/components/ota_update_components/UpdateErrorPopup.tsx b/src/components/ota_update_components/UpdateErrorPopup.tsx new file mode 100644 index 0000000..c0bb3e8 --- /dev/null +++ b/src/components/ota_update_components/UpdateErrorPopup.tsx @@ -0,0 +1,143 @@ +import { useStore } from '@nanostores/preact'; +import { IconCpu, IconFileTypeJs } from '@tabler/icons-preact'; +import { + $availableUpdates, + $updateState, + $updateErrorMessages, + $s3FirmwareProgress, + $s3WebinterfaceProgress, + stateSetters, + type UpdateTarget, +} from '../AppProviderStore'; +import { + request_available_updates, + start_firmware_update_s3_fw, + start_firmware_update_s3_web, +} from './OtaCommands'; + +interface UpdateErrorPopupProps { + isOpen: boolean; + onClose: () => void; +} + +const getUpdateIcon = (target: UpdateTarget) => + target === 's3_firmware' ? : ; + +const targetOrder: Record = { + s3_webinterface: 0, + s3_firmware: 1, +}; + +export default function UpdateErrorPopup({ isOpen, onClose }: UpdateErrorPopupProps) { + const availableUpdates = useStore($availableUpdates); + const updateState = useStore($updateState); + const updateErrorMessages = useStore($updateErrorMessages); + const s3FirmwareProgress = useStore($s3FirmwareProgress); + const s3WebinterfaceProgress = useStore($s3WebinterfaceProgress); + + if (!isOpen || updateState !== 'error') return null; + + console.log("[OTA][ErrorPopup] rendering, errors=", updateErrorMessages, + "s3FwProgress=", s3FirmwareProgress, "s3WebProgress=", s3WebinterfaceProgress); + + const handleClose = () => { + console.log("[OTA][ErrorPopup] handleClose"); + onClose(); + stateSetters.resetUpdateStatus(); + request_available_updates(); + }; + + const retryUpdate = () => { + console.log("[OTA][ErrorPopup] retryUpdate"); + stateSetters.setUpdateState('updating'); + stateSetters.setUpdateErrorMessages({} as Record); + stateSetters.setUpdateInProgress(false); + stateSetters.setS3FirmwareProgress(0, 'no_update'); + stateSetters.setS3WebinterfaceProgress(0, 'no_update'); + + const order = [...availableUpdates] + .filter((u) => u.updateAvailable) + .sort((a, b) => targetOrder[a.target] - targetOrder[b.target]); + if (order.length > 0) { + if (order[0].target === 's3_webinterface') start_firmware_update_s3_web(); + else start_firmware_update_s3_fw(); + } + onClose(); + }; + + const progressFor = (target: UpdateTarget) => + target === 's3_firmware' ? s3FirmwareProgress : s3WebinterfaceProgress; + + return ( + + + + ); +} diff --git a/src/components/ota_update_components/UpdateSuccessPopup.tsx b/src/components/ota_update_components/UpdateSuccessPopup.tsx new file mode 100644 index 0000000..5897d70 --- /dev/null +++ b/src/components/ota_update_components/UpdateSuccessPopup.tsx @@ -0,0 +1,121 @@ +import { useStore } from '@nanostores/preact'; +import { useEffect, useState } from 'preact/hooks'; +import { IconCpu, IconFileTypeJs, IconRefresh } from '@tabler/icons-preact'; +import { + $availableUpdates, + $updateState, + stateSetters, + type UpdateTarget, +} from '../AppProviderStore'; + +interface UpdateSuccessPopupProps { + isOpen: boolean; + onClose: () => void; +} + +const getUpdateIcon = (target: UpdateTarget) => + target === 's3_firmware' ? : ; + +export default function UpdateSuccessPopup({ isOpen, onClose }: UpdateSuccessPopupProps) { + const availableUpdates = useStore($availableUpdates); + const updateState = useStore($updateState); + const [showRestarting, setShowRestarting] = useState(false); + + if (!isOpen) return null; + + useEffect(() => { + console.log("[OTA][SuccessPopup] updateState=", updateState, "isOpen=", isOpen); + setShowRestarting(updateState === 'restarting'); + }, [updateState, isOpen]); + + const handleClose = () => { + console.log("[OTA][SuccessPopup] handleClose, updateState=", updateState); + if (updateState !== 'restarting') { + onClose(); + } + stateSetters.resetUpdateStatus(); + }; + + const updatedRows = availableUpdates.filter((u) => u.updateAvailable); + + return ( + + + + ); +} diff --git a/src/components/ota_update_components/UpdaterModal.tsx b/src/components/ota_update_components/UpdaterModal.tsx new file mode 100644 index 0000000..0f79fa8 --- /dev/null +++ b/src/components/ota_update_components/UpdaterModal.tsx @@ -0,0 +1,213 @@ +import { useEffect } from 'preact/hooks'; +import { marked } from 'marked'; +import { useStore } from '@nanostores/preact'; +import { IconCpu, IconFileTypeJs } from '@tabler/icons-preact'; +import { + $availableUpdates, + $updateChangelog, + $s3FirmwareProgress, + $s3WebinterfaceProgress, + $updateState, + stateSetters, + type UpdateInfo, + type UpdateTarget, +} from '../AppProviderStore'; +import { + start_firmware_update_s3_fw, + start_firmware_update_s3_web, +} from './OtaCommands'; + +marked.setOptions({ breaks: true, gfm: true }); + +interface UpdaterModalProps { + isOpen: boolean; + onClose: () => void; +} + +const hasAvailableUpdates = (updates: UpdateInfo[]) => + updates.some((u) => u.updateAvailable); + +const getUpdateIcon = (target: UpdateTarget) => + target === 's3_firmware' ? : ; + +// Web first (filesystem), then firmware — same order the FW's sequential +// update walks, so the modal rows mirror what the device is actually doing. +const targetOrder: Record = { + s3_webinterface: 0, + s3_firmware: 1, +}; + +export default function UpdaterModal({ isOpen, onClose }: UpdaterModalProps) { + const updateState = useStore($updateState); + const availableUpdates = useStore($availableUpdates); + const updateChangelog = useStore($updateChangelog); + const s3FirmwareProgress = useStore($s3FirmwareProgress); + const s3WebinterfaceProgress = useStore($s3WebinterfaceProgress); + + useEffect(() => { + console.log("[OTA][UpdaterModal] updateState=", updateState, "isOpen=", isOpen); + if (updateState === 'success' || updateState === 'error' || updateState === 'restarting') { + if (isOpen) { + console.log("[OTA][UpdaterModal] auto-closing because state=", updateState); + onClose(); + } + } + }, [updateState, onClose, isOpen]); + + const getProgressForTarget = (target: UpdateTarget) => + target === 's3_firmware' ? s3FirmwareProgress : s3WebinterfaceProgress; + + const startUpdate = () => { + console.log("[OTA][UpdaterModal] startUpdate, availableUpdates=", availableUpdates); + stateSetters.setUpdateState('updating'); + stateSetters.setUpdateErrorMessages({} as Record); + // The firmware drives the sequential update once one command is sent — + // it walks webinterface → firmware on its own (see start_sequential_update + // in firmware_update.cpp). The web app just kicks it off. + const order = [...availableUpdates] + .filter((u) => u.updateAvailable) + .sort((a, b) => targetOrder[a.target] - targetOrder[b.target]); + if (order.length === 0) { + console.warn("[OTA][UpdaterModal] no updates available to start"); + return; + } + if (order[0].target === 's3_webinterface') { + console.log("[OTA][UpdaterModal] sending start_firmware_update_s3_web (FW will sequence to fw next)"); + start_firmware_update_s3_web(); + } else { + console.log("[OTA][UpdaterModal] sending start_firmware_update_s3_fw"); + start_firmware_update_s3_fw(); + } + }; + + const cancelModal = () => { + console.log("[OTA][UpdaterModal] cancelled by user"); + stateSetters.setUpdateState('initial'); + stateSetters.setUpdaterModalOpen(false); + onClose(); + }; + + if (!isOpen) return null; + + return ( + + + ); +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 4ade9a4..f195eff 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,6 +3,7 @@ import { WebSocketProvider } from "../components/WebSocketProvider"; import { IconPlant } from "@tabler/icons-preact"; import WifiSetupWizard from "../components/WifiSetupWizard.astro"; import NetworkOverview from "../components/NetworkOverview.astro"; +import GlobalModals from "../components/ota_update_components/GlobalModals"; --- @@ -15,6 +16,7 @@ import NetworkOverview from "../components/NetworkOverview.astro"; +