Firmware update modal added

This commit is contained in:
SebastianOtt 2026-05-28 00:45:18 +02:00
parent a326853d45
commit f9c7ab2b50
8 changed files with 1020 additions and 2 deletions

View file

@ -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<string>("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<UpdateState>("initial");
export const $updateInProgress = atom<boolean>(false);
// What the server is offering. Iterated by the modals.
export const $availableUpdates = atom<UpdateInfo[]>(initialUpdates);
export const $updateChangelog = atom<string>("");
// 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<Record<UpdateTarget, string>>({} as Record<UpdateTarget, string>);
// Modal open state
export const $isUpdaterModalOpen = atom<boolean>(false);
export const $isSuccessPopupOpen = atom<boolean>(false);
export const $isErrorPopupOpen = atom<boolean>(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<string>("unknown");
export const $webinterfaceVersion = atom<string>(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<UpdateTarget, string>) =>
$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<UpdateTarget, string>);
},
};

View file

@ -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"}`);

View file

@ -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 && (
<dialog class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">System Status</h3>
<div class="overflow-x-auto">
<table class="table table-zebra">
<tbody>
<tr>
<td class="font-bold">WebSocket Status</td>
<td>{wsConnectionStatus}</td>
</tr>
<tr>
<td class="font-bold">S3 Firmware Version</td>
<td>{firmwareVersion}</td>
</tr>
<tr>
<td class="font-bold">Webinterface Version</td>
<td>{webinterfaceVersion}</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex justify-end">
<button class="btn" onClick={() => setIsStatusModalOpen(false)}>Close</button>
</div>
</div>
<div class="modal-backdrop" onClick={() => setIsStatusModalOpen(false)}></div>
</dialog>
)}
{/* Firmware Update check spinner — shown briefly while we wait for the
firmware to reply with update_information. */}
{isFirmwareModalOpen && (
<dialog class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Firmware Update</h3>
<div class="flex flex-col items-center justify-center py-6">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-center">Checking for firmware updates…</p>
</div>
<div class="modal-action">
<button class="btn" onClick={() => setIsFirmwareModalOpen(false)}>Close</button>
</div>
</div>
<div class="modal-backdrop" onClick={() => setIsFirmwareModalOpen(false)}></div>
</dialog>
)}
<UpdaterModal
isOpen={isUpdaterModalOpen}
onClose={() => {
stateSetters.setUpdaterModalOpen(false);
}}
/>
<UpdateSuccessPopup
isOpen={isSuccessPopupOpen}
onClose={() => {
stateSetters.setSuccessPopupOpen(false);
stateSetters.setUpdateState('initial');
}}
/>
<UpdateErrorPopup
isOpen={isErrorPopupOpen}
onClose={() => {
stateSetters.setErrorPopupOpen(false);
stateSetters.setUpdateState('initial');
}}
/>
</>
);
}

View file

@ -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;
}

View file

@ -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' ? <IconCpu strokeWidth={2} /> : <IconFileTypeJs strokeWidth={2} />;
const targetOrder: Record<UpdateTarget, number> = {
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<UpdateTarget, string>);
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 (
<dialog class="modal modal-open">
<div class="modal-box max-w-2xl">
<div class="flex flex-col items-center justify-center py-8">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-error mb-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
<h3 class="font-bold text-lg text-error mb-2">Update Failed</h3>
<p class="text-center mb-6">One or more updates failed to complete successfully.</p>
</div>
<div class="space-y-2 mb-6">
{availableUpdates
.filter((u) => u.updateAvailable)
.map((update, i) => {
const [, status] = progressFor(update.target);
const errorMessage = updateErrorMessages[update.target] || '';
const isFailed = status === 'error';
return (
<div
key={i}
class={`flex items-center justify-between p-3 rounded-lg ${
isFailed ? 'bg-error/10' : 'bg-success/10'
}`}
>
<div class="flex items-center gap-3">
{getUpdateIcon(update.target)}
<div class="flex-1">
<span class="font-medium">{update.label}</span>
{isFailed && errorMessage && (
<div class="text-sm text-error mt-1">{errorMessage}</div>
)}
</div>
</div>
<div class="flex items-center gap-2">
{isFailed ? (
<span class="text-sm text-error">Failed</span>
) : (
<span class="text-sm">v{update.newVersion}</span>
)}
</div>
</div>
);
})}
</div>
<div class="modal-action">
<button class="btn btn-outline" onClick={handleClose}>
Close
</button>
<button class="btn btn-primary" onClick={retryUpdate}>
Retry Update
</button>
</div>
</div>
<div class="modal-backdrop" onClick={handleClose} />
</dialog>
);
}

View file

@ -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' ? <IconCpu strokeWidth={2} /> : <IconFileTypeJs strokeWidth={2} />;
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 (
<dialog class="modal modal-open">
<div class="modal-box max-w-2xl">
<div class="flex flex-col items-center justify-center py-8">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-success mb-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 12l2 2l4 -4" />
</svg>
<h3 class="font-bold text-lg text-success mb-2">Update Completed Successfully!</h3>
<p class="text-center mb-6">All components have been updated successfully.</p>
{showRestarting && (
<div class="border-t border-base-300 pt-6 mt-2 w-full">
<div class="flex flex-col items-center">
<IconRefresh size={48} class="text-primary animate-spin mb-3" strokeWidth={1.5} />
<h4 class="font-semibold text-md text-primary mb-2">Restarting Device…</h4>
<p class="text-center text-sm text-base-content/70">
The device is restarting to apply the firmware update. Please wait for
reconnection.
</p>
</div>
</div>
)}
</div>
{(updateState === 'success' || updateState === 'restarting') && updatedRows.length > 0 && (
<div class="space-y-2 mb-6">
{updatedRows.map((update, i) => (
<div key={i} class="flex items-center justify-between p-3 bg-success/10 rounded-lg">
<div class="flex items-center gap-3">
{getUpdateIcon(update.target)}
<span class="font-medium">{update.label}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">v{update.newVersion}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-success"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 12l2 2l4 -4" />
</svg>
</div>
</div>
))}
</div>
)}
{updateState === 'success' && (
<div class="modal-action">
<button class="btn btn-primary" onClick={handleClose}>
OK
</button>
</div>
)}
</div>
<div class="modal-backdrop" onClick={handleClose} />
</dialog>
);
}

View file

@ -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' ? <IconCpu strokeWidth={2} /> : <IconFileTypeJs strokeWidth={2} />;
// 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<UpdateTarget, number> = {
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<UpdateTarget, string>);
// 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 (
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
{updateState === 'initial' && (
<>
<h3 class="font-bold text-lg mb-4">Over The Air Update Available</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-semibold mb-3">Available Updates:</h4>
<div class="space-y-3">
{availableUpdates
.filter((u) => u.updateAvailable)
.sort((a, b) => targetOrder[a.target] - targetOrder[b.target])
.map((update, i) => (
<div key={i} class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="flex items-center gap-3">
{getUpdateIcon(update.target)}
<div>
<div class="font-medium">{update.label}</div>
<div class="text-sm opacity-70">
{update.currentVersion || '—'} → {update.newVersion}
</div>
</div>
</div>
<div class="badge badge-success">Update</div>
</div>
))}
</div>
</div>
<div>
<h4 class="font-semibold mb-3">Changelog</h4>
<div class="bg-base-200 rounded-lg p-4 h-80 overflow-y-auto">
{updateChangelog ? (
<div
class="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: marked.parse(updateChangelog) as string }}
/>
) : (
<div class="text-center text-gray-500">No changelog available</div>
)}
</div>
</div>
</div>
{!hasAvailableUpdates(availableUpdates) && (
<div class="alert alert-info mt-4">
<span>No updates available.</span>
</div>
)}
<div class="alert alert-warning mt-4">
<span>
The device will restart automatically after a firmware update. This process takes
approximately 1–2 minutes.
</span>
</div>
<div class="modal-action">
<button class="btn btn-outline" onClick={cancelModal}>
Cancel
</button>
<button
class="btn btn-primary"
onClick={startUpdate}
disabled={!hasAvailableUpdates(availableUpdates)}
>
Start Update
</button>
</div>
</>
)}
{updateState === 'updating' && (
<>
<h3 class="font-bold text-lg mb-4">Updating Over The Air…</h3>
<div class="space-y-4">
{availableUpdates
.filter((u) => u.updateAvailable)
.sort((a, b) => targetOrder[a.target] - targetOrder[b.target])
.map((update, i) => {
const [progress, status] = getProgressForTarget(update.target);
let stateClass = 'progress-base-300';
let statusText = 'Waiting';
if (status === 'error') {
stateClass = 'progress-error';
statusText = 'Failed';
} else if (status === 'completed' || progress >= 100) {
stateClass = 'progress-success';
statusText = 'Complete';
} else if (status === 'started' || status === 'in_progress') {
stateClass = 'progress-primary';
statusText = `${progress}%`;
}
return (
<div key={i} class="flex items-center gap-4 p-4 bg-base-200 rounded-lg">
{getUpdateIcon(update.target)}
<div class="flex-1">
<div class="flex justify-between items-center mb-2">
<span class="font-medium">{update.label}</span>
<span class="text-sm">{statusText}</span>
</div>
<progress class={`progress w-full ${stateClass}`} value={progress} max={100} />
</div>
</div>
);
})}
</div>
<div class="alert alert-info mt-4">
<span>Please do not power off the device during the update process.</span>
</div>
</>
)}
</div>
{updateState === 'initial' && <div class="modal-backdrop" onClick={cancelModal} />}
</dialog>
);
}

View file

@ -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";
---
<html lang="en" data-theme="emerald">
@ -15,6 +16,7 @@ import NetworkOverview from "../components/NetworkOverview.astro";
</head>
<body class="min-h-screen bg-gradient-to-b from-emerald-50 to-base-100">
<WebSocketProvider client:load />
<GlobalModals client:load />
<!-- Hero / greeting -->
<header class="pt-12 pb-8 px-4">