Firmware update modal added
This commit is contained in:
parent
a326853d45
commit
f9c7ab2b50
8 changed files with 1020 additions and 2 deletions
|
|
@ -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>);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"}`);
|
||||
|
|
|
|||
269
src/components/ota_update_components/GlobalModals.tsx
Normal file
269
src/components/ota_update_components/GlobalModals.tsx
Normal 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');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
src/components/ota_update_components/OtaCommands.ts
Normal file
53
src/components/ota_update_components/OtaCommands.ts
Normal 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;
|
||||
}
|
||||
143
src/components/ota_update_components/UpdateErrorPopup.tsx
Normal file
143
src/components/ota_update_components/UpdateErrorPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/components/ota_update_components/UpdateSuccessPopup.tsx
Normal file
121
src/components/ota_update_components/UpdateSuccessPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
src/components/ota_update_components/UpdaterModal.tsx
Normal file
213
src/components/ota_update_components/UpdaterModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue