Add complete device and kiosk backup

This commit is contained in:
Mikei386
2026-07-22 11:58:58 +02:00
parent 890a47a272
commit 22444ff5bd
13 changed files with 767 additions and 18 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
<div class="opt-group">
<details class="changelog-version" open>
<summary>3.8 (Phoenix 1.0)</summary>
<summary>3.8.5 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p class="changelog-date">Veröffentlichung: 21.07.2026</p>
@@ -21,6 +21,10 @@
<li><strong>Phasenrad:</strong> Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.</li>
<li><strong>True Peak, RMS und VU:</strong> True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.</li>
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
<li><strong>Gerätesicherung:</strong> Versioniertes Gesamtbackup und geprüfte Wiederherstellung für globale Geräteeinstellungen, vollständige RTA-Konfiguration, Software-Presets und Layout-Slots ergänzt. Konfigurationsdateien werden atomar ersetzt; bei einem Restore-Fehler wird der vorherige Zustand wiederhergestellt.</li>
<li><strong>Internes Pi-Display:</strong> Die Phoenix-eigenen lokalen Einstellungen des Chromium-Kiosks werden gezielt und cachefrei mit dem Gerät synchronisiert, im Gesamtbackup gesichert und nach einem Restore ausschließlich auf dem internen Display wiederhergestellt. Externe Browserprofile bleiben unberührt.</li>
<li><strong>GUI-Aufräumarbeiten:</strong> Split- und Quad-View teilen sich gemeinsame interne Bausteine; Canvas-Caches sind begrenzt, doppelte Zeichenhelfer vereinheitlicht, Optionscode aufgeteilt, direkte Stildefinitionen reduziert und der Changelog wird erst beim Öffnen geladen. Das sichtbare Layout bleibt erhalten.</li>
<li><strong>Sicht-Auswahl:</strong> Die dynamische Breite des Sicht-Dropdowns wiederhergestellt, damit Meter-Slots und weitere Bedienelemente nicht mehr unnötig in eine zweite Zeile umbrechen.</li>
</ul>
</div>
</div>
+10 -1
View File
@@ -13,7 +13,7 @@
<div class="hud">
<label for="styleSel">Sicht</label>
<select class="opt-w-220" id="styleSel">
<select id="styleSel" style="width:220px">
<option value="realtime">Real Time Analyzer</option>
<option value="peak-history">Peak History</option>
<option value="spectrogram">Spectrogram</option>
@@ -995,6 +995,15 @@
</div>
<small>Speichert nur Ansicht und Layout. Layouts können per URL, z. B. <code>?layout=1</code>, oder über die Aktivieren-Tasten geladen werden.</small>
</div>
<div class="opt">
<label>Gerätesicherung</label>
<div class="row" style="gap:8px;align-items:center;flex-wrap:wrap">
<button id="btnDeviceBackupExport" class="btn btn-compact" type="button">Gesamtbackup herunterladen</button>
<label for="impDeviceBackup" class="btn btn-compact" style="cursor:pointer">Gesamtbackup einlesen</label>
<input id="impDeviceBackup" type="file" accept="application/json" style="display:none">
</div>
<small id="deviceBackupStatus">Enthält globale Geräteeinstellungen, RTA-Laufzeitkonfiguration, Software-Presets und Layout-Slots. Browserlokale Ansichten sind nicht enthalten.</small>
</div>
</div>
</details>
<details>
+2
View File
@@ -51,6 +51,7 @@ import * as stopwatch from './meters/stopwatch.js';
// Options UI
import { setupOptions } from './ui/options.js';
import { setupKioskBrowserBackup } from './ui/kiosk_browser_backup.js';
// --- Canvas & Context ---------------------------------------------------------
const C = document.getElementById('cv');
@@ -2734,6 +2735,7 @@ async function loop(now){
(async function boot(){
try{
const start = async () => {
if (!await setupKioskBrowserBackup()) return;
await new Promise((resolve) => setTimeout(resolve, 2000));
const layoutParam = (() => {
try {
+92
View File
@@ -0,0 +1,92 @@
const BACKUP_KIND = 'phoenix-device-backup';
const BACKUP_SCHEMA_VERSION = 1;
function setStatus(element, message, isError = false) {
if (!element) return;
element.textContent = message;
element.style.color = isError ? '#ff6b6b' : '';
}
function backupFileName() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
return `phoenix-device-backup-${stamp}.json`;
}
function validateBackup(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Die Datei enthält kein gültiges JSON-Objekt.');
}
if (payload.kind !== BACKUP_KIND) {
throw new Error('Die Datei ist keine Phoenix-Gerätesicherung.');
}
if (Number(payload.schemaVersion) !== BACKUP_SCHEMA_VERSION) {
throw new Error(`Nicht unterstützte Backup-Version: ${payload.schemaVersion ?? '?'}.`);
}
if (!payload.globalConfig || !payload.rtaConfig) {
throw new Error('Gerätekonfiguration oder RTA-Konfiguration fehlt.');
}
return payload;
}
export function setupDeviceBackup({ exportButton, importInput, status, resolveApiBaseUrl }) {
if (!exportButton || !importInput || typeof resolveApiBaseUrl !== 'function') return;
exportButton.addEventListener('click', async () => {
const original = exportButton.textContent;
exportButton.disabled = true;
exportButton.textContent = 'Sichere…';
setStatus(status, 'Gerätekonfiguration wird gelesen…');
try {
const response = await fetch(`${resolveApiBaseUrl()}/api/v1/device-backup?_=${Date.now()}`, {
cache: 'no-store',
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`);
validateBackup(payload);
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = backupFileName();
link.click();
URL.revokeObjectURL(link.href);
const kioskCount = Object.keys(payload.kioskBrowserStorage || {}).length;
setStatus(status, `Backup vollständig heruntergeladen (${kioskCount} Kiosk-Einstellungen).`);
} catch (error) {
console.error('Device backup export error:', error);
setStatus(status, `Backup fehlgeschlagen: ${error?.message || String(error)}`, true);
} finally {
exportButton.disabled = false;
exportButton.textContent = original;
}
});
importInput.addEventListener('change', async () => {
const file = importInput.files?.[0];
if (!file) return;
try {
const payload = validateBackup(JSON.parse(await file.text()));
const confirmed = globalThis.confirm?.(
'Gerätesicherung wiederherstellen?\n\nGlobale Geräteeinstellungen, RTA, Software-Presets, Layout-Slots und die Oberfläche des internen Pi-Displays werden ersetzt. Externe Browser bleiben unverändert.'
);
if (!confirmed) return;
setStatus(status, 'Backup wird geprüft und wiederhergestellt…');
const response = await fetch(`${resolveApiBaseUrl()}/api/v1/device-backup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
cache: 'no-store',
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.ok === false) {
throw new Error(result?.error || `HTTP ${response.status}`);
}
setStatus(status, 'Wiederherstellung abgeschlossen. Oberfläche wird neu geladen.');
setTimeout(() => globalThis.location?.reload?.(), 500);
} catch (error) {
console.error('Device backup restore error:', error);
setStatus(status, `Wiederherstellung fehlgeschlagen: ${error?.message || String(error)}`, true);
} finally {
importInput.value = '';
}
});
}
+162
View File
@@ -0,0 +1,162 @@
const KIOSK_QUERY_VALUE = '1';
const REVISION_KEY = 'analyzer_kiosk_backup_revision_v1';
const SYNC_INTERVAL_MS = 2000;
function isKioskBrowser() {
try {
return new URLSearchParams(globalThis.location?.search || '').get('kiosk') === KIOSK_QUERY_VALUE;
} catch (_) {
return false;
}
}
function isPhoenixStorageKey(key) {
return key !== REVISION_KEY && (
key.startsWith('analyzer_') ||
key.startsWith('MIGRATE_') ||
[
'ppm_din_mode',
'calibration_notice_seen_v1',
'recorder_warning_ack_v1',
'ppm_din_loudness_warn_ack_v1',
'migrate_rta_peak_fall_default_v1',
'migrate_vu_offset_default_zero_v2',
'migrate_lr_delay_zero_to_0_05_v1',
].includes(key)
);
}
function collectStorage() {
const storage = {};
try {
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (!key || !isPhoenixStorageKey(key)) continue;
const value = localStorage.getItem(key);
if (value !== null) storage[key] = value;
}
} catch (_) {}
return storage;
}
function storageFingerprint(storage) {
return JSON.stringify(Object.entries(storage).sort(([left], [right]) => left.localeCompare(right)));
}
function localRevision() {
try {
const value = Number(localStorage.getItem(REVISION_KEY));
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
} catch (_) {
return 0;
}
}
function setLocalRevision(revision) {
try { localStorage.setItem(REVISION_KEY, String(revision)); } catch (_) {}
}
function applyStorage(storage, revision) {
try {
const remove = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key && isPhoenixStorageKey(key)) remove.push(key);
}
remove.forEach((key) => localStorage.removeItem(key));
Object.entries(storage || {}).forEach(([key, value]) => {
if (isPhoenixStorageKey(key) && typeof value === 'string') localStorage.setItem(key, value);
});
setLocalRevision(revision);
return true;
} catch (error) {
console.warn('Kiosk browser restore failed:', error);
return false;
}
}
function apiUrl() {
const hostname = String(globalThis.location?.hostname || '127.0.0.1');
return `http://${hostname}:8789/api/v1/kiosk-browser-config`;
}
async function readRemote() {
const response = await fetch(`${apiUrl()}?_=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
async function writeRemote(baseRevision, storage) {
const response = await fetch(apiUrl(), {
method: 'POST',
headers: { 'content-type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({ baseRevision, storage }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(payload?.error || `HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return payload;
}
export async function setupKioskBrowserBackup() {
if (!isKioskBrowser()) return true;
let remote;
try {
remote = await readRemote();
} catch (error) {
console.warn('Kiosk browser backup unavailable:', error);
return true;
}
let revision = Number(remote?.revision) || 0;
const storedRevision = localRevision();
if (revision > storedRevision) {
if (applyStorage(remote?.storage || {}, revision)) {
globalThis.location?.reload?.();
return false;
}
} else if (revision === 0 && Object.keys(remote?.storage || {}).length === 0) {
try {
const result = await writeRemote(0, collectStorage());
revision = Number(result?.revision) || 1;
setLocalRevision(revision);
} catch (error) {
console.warn('Initial kiosk browser backup failed:', error);
}
} else {
setLocalRevision(revision);
}
let fingerprint = storageFingerprint(collectStorage());
let running = false;
globalThis.setInterval(async () => {
if (running) return;
running = true;
try {
const latest = await readRemote();
const latestRevision = Number(latest?.revision) || 0;
if (latestRevision > revision) {
if (applyStorage(latest?.storage || {}, latestRevision)) globalThis.location?.reload?.();
return;
}
const storage = collectStorage();
const nextFingerprint = storageFingerprint(storage);
if (nextFingerprint !== fingerprint) {
const result = await writeRemote(revision, storage);
revision = Number(result?.revision) || (revision + 1);
setLocalRevision(revision);
fingerprint = nextFingerprint;
}
} catch (error) {
if (error?.status !== 409) console.warn('Kiosk browser backup sync failed:', error);
} finally {
running = false;
}
}, SYNC_INTERVAL_MS);
return true;
}
+7
View File
@@ -5,6 +5,7 @@ import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelec
import { clamp, clampPow2 } from '../core/utils.js';
import { bindOptionsViewportAssist } from './options_viewport.js';
import { setupLazyChangelog } from './changelog.js';
import { setupDeviceBackup } from './device_backup.js';
// DOM helper
const E = (id) => document.getElementById(id);
@@ -90,6 +91,12 @@ export function setupOptions(env) {
wireHandlers(env);
bindOptionsViewportAssist(E('optionsPanelNew'));
setupLazyChangelog(E('changelogDetails'), E('changelogContent'));
setupDeviceBackup({
exportButton: E('btnDeviceBackupExport'),
importInput: E('impDeviceBackup'),
status: E('deviceBackupStatus'),
resolveApiBaseUrl: resolvePhoenixApiBaseUrl,
});
bindAlignmentToggle(env);
bindVuRefToggle(env);
// Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion