import { announce, apiJson, registerWorkspace, setActiveButton, } from "../core.js"; import { setPreviewRefreshInterval } from "../scene-board.js"; import { ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT, ANIMATION_PREVIEW_MAX_MAX_CONCURRENT, ANIMATION_PREVIEW_MIN_MAX_CONCURRENT, normalizeAnimationPreviewMaxConcurrent, setAnimationPreviewMaxConcurrent, } from "../animation-thumbnail-preview.js"; import { refreshStatus } from "./device.js"; import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js"; import { discardWifiSettingsDrafts, loadWifiSettings } from "./wifi-settings.js"; import { discardFrpDrafts, loadFrpSettings } from "./frp-settings.js"; const orientationButtons = Array.from(document.querySelectorAll("[data-orientation]")); const matrixRefreshRateButtons = Array.from( document.querySelectorAll("[data-matrix-refresh-rate]"), ); const brightness = document.getElementById("config-brightness"); const brightnessValue = document.getElementById("brightness-value"); const brightnessProtectionStatus = document.getElementById("brightness-protection-status"); const feedback = document.getElementById("settings-feedback"); const voltageCurrent = document.getElementById("voltage-current"); const voltageSensorStatus = document.getElementById("voltage-sensor-status"); const voltageCalibrationStatus = document.getElementById("voltage-calibration-status"); const voltageCalibratedAt = document.getElementById("voltage-calibrated-at"); const startCalibration = document.getElementById("start-voltage-calibration"); const resetCalibration = document.getElementById("reset-voltage-calibration"); const calibrationDialog = document.getElementById("voltage-calibration-dialog"); const calibrationReference = document.getElementById("voltage-reference-input"); const calibrationDialogFeedback = document.getElementById("voltage-calibration-dialog-feedback"); const calibrationPreview = document.getElementById("voltage-calibration-preview"); const previewCalibration = document.getElementById("preview-voltage-calibration"); const confirmCalibration = document.getElementById("confirm-voltage-calibration"); const previewRefreshInput = document.getElementById("preview-refresh-interval"); const previewRefreshWarning = document.getElementById("preview-refresh-warning"); const previewRefreshError = document.getElementById("preview-refresh-error"); const lowVoltageProtectionToggle = document.getElementById("low-voltage-protection-enabled"); const lowVoltageProtectionSwitchState = document.getElementById("low-voltage-protection-switch-state"); const lowVoltageProtectionFeedback = document.getElementById("low-voltage-protection-feedback"); const performanceModeToggle = document.getElementById("performance-mode-enabled"); const performanceModeSwitchState = document.getElementById("performance-mode-switch-state"); const performanceModeFeedback = document.getElementById("performance-mode-feedback"); const animationPreviewMaxConcurrentInput = document.getElementById("animation-preview-max-concurrent"); const animationPreviewConcurrencyFeedback = document.getElementById("animation-preview-concurrency-feedback"); const otaCurrentVersion = document.getElementById("ota-current-version"); const otaFeatureUpdatedAt = document.getElementById("ota-feature-updated-at"); const otaFileInput = document.getElementById("ota-file-input"); const otaSelectFile = document.getElementById("ota-select-file"); const otaProgressGroup = document.getElementById("ota-progress-group"); const otaProgressStage = document.getElementById("ota-progress-stage"); const otaProgressValue = document.getElementById("ota-progress-value"); const otaProgress = document.getElementById("ota-progress"); const otaFeedback = document.getElementById("ota-feedback"); const otaFailureLogActions = document.getElementById("ota-failure-log-actions"); const otaViewFailureLog = document.getElementById("ota-view-failure-log"); const otaCopyFailureLog = document.getElementById("ota-copy-failure-log"); const otaFailureLogDialog = document.getElementById("ota-failure-log-dialog"); const otaFailureLogContent = document.getElementById("ota-failure-log-content"); const otaFailureLogFeedback = document.getElementById("ota-failure-log-feedback"); const otaFailureLogClose = document.getElementById("ota-failure-log-close"); const otaFailureLogCopy = document.getElementById("ota-failure-log-copy"); let confirmedOrientation = 0; let confirmedBrightness = 40; let confirmedMatrixRefreshRate = 100; let confirmedPreviewRefreshInterval = 1000; let confirmedLowVoltageProtectionEnabled = false; let confirmedPerformanceModeEnabled = false; let confirmedAnimationPreviewMaxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT; let voltageConfig = null; let voltageStatus = null; let lowVoltageProtectionStatus = null; let latestEffectiveBrightness = null; let displayTestActive = false; let displayTestBrightness = null; let calibrationProposal = null; let desiredBrightness = null; let applyingBrightness = false; let orientationPending = false; let matrixRefreshRatePending = false; let previewRefreshPending = false; let desiredPreviewRefreshInterval = null; let savingPreviewRefreshInterval = null; let lowVoltageProtectionPending = false; let performanceModePending = false; let animationPreviewConcurrencyPending = false; let otaCurrentSoftwareVersion = null; let otaPolling = false; let otaFailureLogText = ""; let otaFailureDialogShownFor = null; const LOW_VOLTAGE_PROTECTION_MODES = new Set([ "disabled", "unavailable", "inactive", "limiting", "critical", ]); function setFeedback(text, state = "idle") { feedback.textContent = text; feedback.dataset.state = state; feedback.hidden = !text; } function setOtaFeedback(text, state = "idle") { otaFeedback.textContent = text; otaFeedback.dataset.state = state; otaFeedback.hidden = !text; } function showOtaProgress(percent, stage) { const value = Math.max(0, Math.min(100, Number(percent) || 0)); otaProgressGroup.hidden = false; otaProgress.value = value; otaProgress.textContent = `${value}%`; otaProgressValue.textContent = `${value}%`; otaProgressStage.textContent = stage || "正在更新"; } function xhrUpload(file) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.open("POST", `/api/ota/update?filename=${encodeURIComponent(file.name)}`); request.setRequestHeader("Content-Type", "application/octet-stream"); request.upload.addEventListener("progress", (event) => { if (!event.lengthComputable) return; showOtaProgress(Math.floor(event.loaded * 100 / event.total), "正在上传更新包"); }); request.addEventListener("load", () => { let body = {}; try { body = request.responseText ? JSON.parse(request.responseText) : {}; } catch {} if (request.status >= 200 && request.status < 300) resolve(body); else reject(new Error(body.detail || `上传失败(HTTP ${request.status})`)); }); request.addEventListener("error", () => reject(new Error("上传连接中断,设备尚未接受更新包"))); request.addEventListener("abort", () => reject(new Error("上传已中止"))); request.send(file); }); } async function fetchOtaStatus() { const response = await fetch("/api/ota/status", { cache: "no-store" }); if (!response.ok) throw new Error(`读取 OTA 状态失败(HTTP ${response.status})`); return response.json(); } async function fetchOtaFailureLog() { const response = await fetch("/api/ota/failure-log", { cache: "no-store" }); if (!response.ok) throw new Error(`读取 OTA 失败日志失败(HTTP ${response.status})`); return response.text(); } function setOtaFailureLogFeedback(text, state = "idle") { otaFailureLogFeedback.textContent = text; otaFailureLogFeedback.dataset.state = state; otaFailureLogFeedback.hidden = !text; } async function ensureOtaFailureLog() { if (!otaFailureLogText) otaFailureLogText = await fetchOtaFailureLog(); return otaFailureLogText; } async function showOtaFailureLog() { try { const text = await ensureOtaFailureLog(); otaFailureLogContent.textContent = text; setOtaFailureLogFeedback(""); if (!otaFailureLogDialog.open) otaFailureLogDialog.showModal(); otaFailureLogContent.focus(); } catch (error) { announce(error.message, "error"); } } function fallbackCopyText(text) { const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.append(textarea); textarea.select(); const copied = document.execCommand("copy"); textarea.remove(); if (!copied) throw new Error("浏览器拒绝复制操作"); } async function copyOtaFailureLog() { try { const text = await ensureOtaFailureLog(); let copied = false; if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); copied = true; } catch {} } if (!copied) fallbackCopyText(text); setOtaFailureLogFeedback("日志已复制到剪贴板。", "success"); announce("OTA 失败日志已复制", "success"); } catch (error) { setOtaFailureLogFeedback(`复制失败:${error.message}`, "error"); announce(`复制 OTA 失败日志失败:${error.message}`, "error"); } } function formatBeijingFeatureUpdatedAt(value) { const date = new Date(value); if (!value || Number.isNaN(date.getTime())) return "读取失败"; const parts = Object.fromEntries( new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).formatToParts(date).map(({ type, value: part }) => [type, part]), ); return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}(北京时间)`; } function applyOtaStatus(status) { const current = status?.current_version; if (current) { otaCurrentVersion.textContent = current; if (otaCurrentSoftwareVersion && current !== otaCurrentSoftwareVersion) { window.location.reload(); return; } otaCurrentSoftwareVersion = current; } otaFeatureUpdatedAt.textContent = formatBeijingFeatureUpdatedAt(status?.feature_updated_at); const job = status?.job; if (status?.active && job) { otaFailureLogActions.hidden = true; otaFailureLogText = ""; showOtaProgress(job.percent, job.message || job.phase); setControlBusy(otaSelectFile, { label: "正在更新…" }); setOtaFeedback("更新已交给设备后台处理;网页短暂断开属于正常现象。", "pending"); } else { setControlReady(otaSelectFile); if (job?.phase === "failed") { showOtaProgress(0, "更新失败"); setOtaFeedback(job.error || "更新失败,设备已恢复原版本。", "error"); otaFailureLogActions.hidden = !status.failure_log_available; const failureKey = `${job.id || "unknown"}:${job.finished_at || ""}`; if (status.failure_log_available && otaFailureDialogShownFor !== failureKey) { otaFailureDialogShownFor = failureKey; void showOtaFailureLog(); } } else if (job?.phase === "complete") { otaFailureLogActions.hidden = true; otaFailureLogText = ""; otaProgressGroup.hidden = true; setOtaFeedback(""); } if (job?.phase !== "failed") { const recovery = status?.kernel_recovery; if (recovery?.state === "pending") { setOtaFeedback("应用更新完成;正在进行一次候选内核恢复重启…", "pending"); } else if (recovery?.state === "succeeded") { setOtaFeedback("候选内核与 CPU 调频策略已恢复。", "ready"); } else if (recovery?.state === "failed") { setOtaFeedback(`内核恢复失败:${recovery.reason || "请查看设备诊断"}`, "error"); } else if (recovery?.state === "requires_package") { setOtaFeedback("应用已更新;内核材料缺失或损坏,需要专用修复包。", "error"); } else if (recovery?.state === "ready") { setOtaFeedback("当前设备运行原内核,性能模式不可用;下次 OTA 将尝试轻量恢复。", "error"); } } } } otaViewFailureLog.addEventListener("click", () => void showOtaFailureLog()); otaCopyFailureLog.addEventListener("click", () => void copyOtaFailureLog()); otaFailureLogCopy.addEventListener("click", () => void copyOtaFailureLog()); otaFailureLogClose.addEventListener("click", () => otaFailureLogDialog.close()); async function pollOtaUntilFinished() { if (otaPolling) return; otaPolling = true; let disconnected = false; try { while (true) { try { const status = await fetchOtaStatus(); if (disconnected) setOtaFeedback("设备服务已恢复,正在确认更新结果…", "pending"); applyOtaStatus(status); if (!status.active && status.kernel_recovery?.state !== "pending") return; } catch { disconnected = true; setOtaFeedback("设备正在切换软件版本,等待服务恢复…", "pending"); } await new Promise((resolve) => window.setTimeout(resolve, 1000)); } } finally { otaPolling = false; } } async function loadOtaStatus() { try { const status = await fetchOtaStatus(); applyOtaStatus(status); if (status.active || status.kernel_recovery?.state === "pending") pollOtaUntilFinished(); } catch (error) { otaCurrentVersion.textContent = "读取失败"; otaFeatureUpdatedAt.textContent = "读取失败"; setOtaFeedback(error.message, "error"); } } otaSelectFile.addEventListener("click", () => otaFileInput.click()); otaFileInput.addEventListener("change", async () => { const file = otaFileInput.files?.[0]; if (!file) return; setControlBusy(otaSelectFile, { label: "正在上传…", message: "开始上传完整 OTA 更新包" }); setOtaFeedback("正在上传;上传完成并通过校验后将自动开始安装。", "pending"); showOtaProgress(0, "正在上传更新包"); try { await xhrUpload(file); setOtaFeedback("更新包已接受,安装将在设备后台继续。", "success"); await pollOtaUntilFinished(); } catch (error) { setControlReady(otaSelectFile); setOtaFeedback(error.message, "error"); } finally { otaFileInput.value = ""; } }); function showOrientation(value) { setActiveButton( orientationButtons, orientationButtons.find((button) => Number(button.dataset.orientation) === Number(value)), ); } function showBrightness(value) { brightness.value = String(value); brightnessValue.textContent = `${value}%`; } function showMatrixRefreshRate(value) { setActiveButton( matrixRefreshRateButtons, matrixRefreshRateButtons.find( (button) => Number(button.dataset.matrixRefreshRate) === Number(value), ), ); } function finitePercent(value) { if (value === null || value === undefined || value === "") return null; const number = Number(value); if (!Number.isFinite(number)) return null; return Math.max(1, Math.min(100, Math.floor(number))); } function lowVoltageProtectionMode() { const mode = lowVoltageProtectionStatus?.mode; return LOW_VOLTAGE_PROTECTION_MODES.has(mode) ? mode : "unavailable"; } function estimatedEffectiveBrightness(userBrightness) { const user = finitePercent(userBrightness); if (user === null) return null; const mode = lowVoltageProtectionMode(); if (mode === "critical") return 35; if (mode === "limiting") { const limit = finitePercent(lowVoltageProtectionStatus?.brightness_limit_percent); return limit === null ? user : Math.min(user, limit); } return user; } function showBrightnessProtectionStatus(userBrightness, effectiveBrightness = undefined) { const mode = lowVoltageProtectionMode(); brightnessProtectionStatus.dataset.mode = mode; if (mode !== "limiting" && mode !== "critical") { brightnessProtectionStatus.hidden = true; if (brightnessProtectionStatus.textContent) brightnessProtectionStatus.textContent = ""; return; } const user = finitePercent(userBrightness) ?? confirmedBrightness; const reportedEffective = finitePercent(effectiveBrightness); const effective = reportedEffective ?? estimatedEffectiveBrightness(user); const staleSuffix = lowVoltageProtectionStatus?.reading_stale === true ? " 电压读数异常,正在保持上次保护。" : ""; let text; if (mode === "critical") { text = `用户画面已禁用,低电图标固定以 35% 亮度显示。用户设置 ${user}% 会保留并在电压恢复后生效。${staleSuffix}`; } else { const limit = finitePercent(lowVoltageProtectionStatus?.brightness_limit_percent); const limitText = limit === null ? "-" : String(limit); const effectiveText = effective === null ? "-" : String(effective); text = `当前允许的最高亮度:${limitText}%。用户设置:${user}%;当前实际亮度:${effectiveText}%。${staleSuffix}`; } if (brightnessProtectionStatus.textContent !== text) { brightnessProtectionStatus.textContent = text; } brightnessProtectionStatus.hidden = false; } function showLowVoltageProtectionSwitch(enabled) { const checked = enabled === true; lowVoltageProtectionToggle.checked = checked; lowVoltageProtectionSwitchState.textContent = checked ? "开启" : "关闭"; } function setLowVoltageProtectionFeedback(text, state = "idle") { lowVoltageProtectionFeedback.textContent = text; lowVoltageProtectionFeedback.dataset.state = state; lowVoltageProtectionFeedback.hidden = !text; } function syncLowVoltageProtectionEnabled(value) { if (typeof value !== "boolean") return false; confirmedLowVoltageProtectionEnabled = value; showLowVoltageProtectionSwitch(value); if (!lowVoltageProtectionPending) setControlReady(lowVoltageProtectionToggle); return true; } function showPerformanceModeSwitch(enabled) { const checked = enabled === true; performanceModeToggle.checked = checked; performanceModeSwitchState.textContent = checked ? "开启" : "关闭"; } function setPerformanceModeFeedback(text, state = "idle") { performanceModeFeedback.textContent = text; performanceModeFeedback.dataset.state = state; performanceModeFeedback.hidden = !text; } function syncPerformanceModeStatus(status) { if (!status || typeof status !== "object") { setControlUnavailable(performanceModeToggle, "CPU 调频状态不可用,请稍后重试。"); setPerformanceModeFeedback("CPU 调频状态不可用", "error"); return; } if (!performanceModePending && typeof status.requested === "boolean") { confirmedPerformanceModeEnabled = status.requested === true && status.effective === true; showPerformanceModeSwitch(confirmedPerformanceModeEnabled); } if (performanceModePending) return; if (status.available === true) { setControlReady(performanceModeToggle); if (status.requested === true && status.effective !== true) { setPerformanceModeFeedback( `性能模式未实际生效${status.last_error ? `:${status.last_error}` : ""}`, "error", ); } else { setPerformanceModeFeedback( status.effective === true ? "性能模式已开启并实际生效" : "性能模式已关闭", "ready", ); } return; } const reason = status.last_error ? `CPU 调频策略不可用:${status.last_error}` : "CPU 调频策略不可用;系统修复完成前不能开启性能模式。"; setControlUnavailable(performanceModeToggle, reason); setPerformanceModeFeedback(reason, "error"); } function brightnessFeedbackText(stage, userBrightness) { const user = finitePercent(userBrightness) ?? confirmedBrightness; const mode = lowVoltageProtectionMode(); if (displayTestActive && mode !== "critical") { const test = finitePercent(displayTestBrightness); const suffix = test === null ? "测试亮度" : `测试亮度 ${test}%`; return stage === "pending" ? `正在保存用户亮度 ${user}%;当前仍由${suffix}覆盖…` : `用户亮度 ${user}% 已保存,但当前由${suffix}覆盖`; } if (mode !== "limiting" && mode !== "critical") { return stage === "pending" ? `正在应用并保存用户亮度 ${user}%…` : `用户亮度 ${user}% 已生效并保存`; } const effective = estimatedEffectiveBrightness(user); return stage === "pending" ? `正在应用并保存用户亮度 ${user}%;当前实际亮度 ${effective}%…` : `用户亮度 ${user}% 已保存;当前实际亮度 ${effective}%`; } function showPreviewRefreshWarning(value) { previewRefreshWarning.hidden = !(Number.isInteger(value) && value < 100); } function showPreviewRefreshError(message = "") { previewRefreshError.textContent = message; previewRefreshError.hidden = !message; } function showPreviewRefreshInterval(value) { previewRefreshInput.value = String(value); showPreviewRefreshWarning(Number(value)); showPreviewRefreshError(); } function showAnimationPreviewMaxConcurrent(value) { animationPreviewMaxConcurrentInput.value = String(value); } function setAnimationPreviewConcurrencyFeedback(message = "", state = "idle") { animationPreviewConcurrencyFeedback.textContent = message; animationPreviewConcurrencyFeedback.dataset.state = state; animationPreviewConcurrencyFeedback.hidden = !message; } function voltageStatusText(status) { if (status === "ok") return "正常"; if (status === "disconnected") return "传感器断开"; if (status === "error") return "读取异常"; return "正在读取"; } function showVoltageDetails() { const status = voltageStatus?.status || "starting"; voltageCurrent.textContent = status === "ok" && Number.isFinite(Number(voltageStatus.volts)) ? `${Number(voltageStatus.volts).toFixed(2)} V` : "-"; voltageSensorStatus.textContent = voltageStatusText(status); const calibratedAt = voltageConfig?.voltage_calibrated_at; voltageCalibrationStatus.textContent = calibratedAt ? "已校准" : "未校准(标称值)"; voltageCalibratedAt.textContent = calibratedAt ? new Date(calibratedAt).toLocaleString("zh-CN") : "-"; if (status === "ok") setControlReady(startCalibration); else if (status === "disconnected") setControlUnavailable(startCalibration, "电压传感器已断开,请连接传感器并等待状态恢复后再校准。"); else if (status === "error") setControlUnavailable(startCalibration, "电压读取异常,请检查传感器和接线后再校准。"); else setControlUnavailable(startCalibration, "正在读取电压传感器状态,请稍候。"); if (resetCalibration.dataset.controlState !== "busy") { if (calibratedAt) setControlReady(resetCalibration); else setControlUnavailable(resetCalibration, "当前没有已保存的校准,无需恢复标称值。"); } } function resetCalibrationDialog() { calibrationProposal = null; calibrationPreview.hidden = true; setControlUnavailable(confirmCalibration, "请先完成“采样并预览”,再保存校准。"); setControlReady(previewCalibration); calibrationReference.disabled = false; calibrationDialogFeedback.textContent = "点击“采样并预览”,约需 1 秒。"; } function showCalibrationProposal(proposal) { document.getElementById("calibration-reference-result").textContent = `${Number(proposal.reference_volts).toFixed(3)} V`; document.getElementById("calibration-raw-result").textContent = `${Number(proposal.uncalibrated_volts).toFixed(3)} V`; document.getElementById("calibration-current-factor").textContent = Number(proposal.current_factor).toFixed(6); document.getElementById("calibration-proposed-factor").textContent = Number(proposal.proposed_factor).toFixed(6); document.getElementById("calibration-projected-result").textContent = `${Number(proposal.projected_volts).toFixed(3)} V`; calibrationPreview.hidden = false; } async function loadSettings() { loadWifiSettings(); loadFrpSettings(); try { const config = await apiJson("/api/config"); confirmedOrientation = Number(config.orientation); confirmedBrightness = Number(config.brightness); confirmedMatrixRefreshRate = Number(config.matrix_refresh_rate_limit_hz); confirmedPreviewRefreshInterval = Number(config.preview_refresh_interval_ms); confirmedPerformanceModeEnabled = config.performance_mode_enabled === true; confirmedAnimationPreviewMaxConcurrent = normalizeAnimationPreviewMaxConcurrent( config.animation_preview_max_concurrent, ); setAnimationPreviewMaxConcurrent(confirmedAnimationPreviewMaxConcurrent); voltageConfig = config; syncLowVoltageProtectionEnabled(config.low_voltage_protection_enabled); showPerformanceModeSwitch(confirmedPerformanceModeEnabled); showAnimationPreviewMaxConcurrent(confirmedAnimationPreviewMaxConcurrent); setAnimationPreviewConcurrencyFeedback(); showOrientation(confirmedOrientation); showBrightness(confirmedBrightness); showMatrixRefreshRate(confirmedMatrixRefreshRate); showBrightnessProtectionStatus(confirmedBrightness, latestEffectiveBrightness); showPreviewRefreshInterval(confirmedPreviewRefreshInterval); setPreviewRefreshInterval(confirmedPreviewRefreshInterval); showVoltageDetails(); setFeedback(""); } catch (error) { setFeedback(`设置读取失败:${error.message}`, "error"); announce(`设置读取失败:${error.message}`, true); } } loadOtaStatus(); startCalibration.addEventListener("click", () => { resetCalibrationDialog(); calibrationDialog.showModal(); calibrationReference.focus(); calibrationReference.select(); }); previewCalibration.addEventListener("click", async () => { const referenceVolts = Number(calibrationReference.value); if (!Number.isFinite(referenceVolts) || referenceVolts < 4.5 || referenceVolts > 5.5) { calibrationDialogFeedback.textContent = "请输入 4.500V 到 5.500V 之间的万用表读数。"; return; } calibrationProposal = null; calibrationPreview.hidden = true; setControlUnavailable(confirmCalibration, "正在生成新的校准预览,请稍候。"); setControlBusy(previewCalibration, { label: "正在采样…", message: "正在采集 15 个新样本…" }); calibrationReference.disabled = true; calibrationDialogFeedback.textContent = "正在采集 15 个新样本,请保持供电和表笔稳定…"; try { const proposal = await apiJson("/api/power/voltage/calibration/preview", { method: "POST", body: JSON.stringify({ reference_volts: referenceVolts }), }); calibrationProposal = proposal; showCalibrationProposal(proposal); setControlReady(confirmCalibration); calibrationDialogFeedback.textContent = "请核对预览结果;确认后才会写入本机校准。"; } catch (error) { calibrationDialogFeedback.textContent = `校准预览失败:${error.message}`; setControlUnavailable(confirmCalibration, "请先完成“采样并预览”,再保存校准。"); } finally { setControlReady(previewCalibration); calibrationReference.disabled = false; } }); confirmCalibration.addEventListener("click", async () => { if (!calibrationProposal) return; setControlBusy(confirmCalibration, { label: "正在保存…", message: "正在保存校准并重新采样…" }); setControlBusy(previewCalibration); calibrationDialogFeedback.textContent = "正在保存并重新采样…"; try { const result = await apiJson("/api/power/voltage/calibration/confirm", { method: "POST", body: JSON.stringify({ proposal_id: calibrationProposal.proposal_id }), }); voltageConfig = result.config; voltageStatus = result.screen_input_voltage; syncLowVoltageProtectionEnabled(result.config?.low_voltage_protection_enabled); showVoltageDetails(); calibrationDialog.close(); setFeedback("电压传感器已完成本机校准", "success"); announce("电压传感器校准已保存"); await refreshStatus(); } catch (error) { calibrationDialogFeedback.textContent = `保存校准失败:${error.message}`; } finally { setControlReady(confirmCalibration); setControlReady(previewCalibration); } }); resetCalibration.addEventListener("click", async () => { const confirmed = window.confirm("确认恢复标称校准系数 1.0?更换 ADC 或测量接线后应执行此操作并重新校准。"); if (!confirmed) return; setControlBusy(resetCalibration, { label: "正在恢复…", message: "正在恢复标称校准…" }); setFeedback("正在恢复标称校准…", "pending"); try { const result = await apiJson("/api/power/voltage/calibration/reset", { method: "POST", body: "{}", }); voltageConfig = result.config; voltageStatus = result.screen_input_voltage; syncLowVoltageProtectionEnabled(result.config?.low_voltage_protection_enabled); showVoltageDetails(); setFeedback("已恢复标称校准系数 1.0", "success"); announce("电压传感器已恢复标称值"); await refreshStatus(); } catch (error) { setFeedback(`恢复标称值失败:${error.message}`, "error"); announce(`恢复标称值失败:${error.message}`, true); } finally { setControlReady(resetCalibration); showVoltageDetails(); } }); lowVoltageProtectionToggle.addEventListener("change", async () => { if (lowVoltageProtectionPending) { showLowVoltageProtectionSwitch(confirmedLowVoltageProtectionEnabled); return; } const requested = lowVoltageProtectionToggle.checked === true; const previous = confirmedLowVoltageProtectionEnabled; lowVoltageProtectionPending = true; setControlBusy(lowVoltageProtectionToggle); lowVoltageProtectionSwitchState.textContent = requested ? "正在开启" : "正在关闭"; setLowVoltageProtectionFeedback( requested ? "正在开启低电压亮度保护…" : "正在关闭低电压亮度保护…", "pending", ); let saved = false; try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ low_voltage_protection_enabled: requested }), }); if (typeof config.low_voltage_protection_enabled !== "boolean") { throw new Error("服务器返回了无效的保护开关状态"); } voltageConfig = config; confirmedLowVoltageProtectionEnabled = config.low_voltage_protection_enabled; showLowVoltageProtectionSwitch(confirmedLowVoltageProtectionEnabled); setLowVoltageProtectionFeedback( confirmedLowVoltageProtectionEnabled ? "低电压亮度保护已开启" : "低电压亮度保护已关闭", "success", ); announce(confirmedLowVoltageProtectionEnabled ? "低电压亮度保护已开启" : "低电压亮度保护已关闭"); saved = true; } catch (error) { confirmedLowVoltageProtectionEnabled = previous; showLowVoltageProtectionSwitch(previous); setLowVoltageProtectionFeedback(`低电压亮度保护保存失败:${error.message}`, "error"); announce(`低电压亮度保护保存失败:${error.message}`, true); } finally { lowVoltageProtectionPending = false; setControlReady(lowVoltageProtectionToggle); } if (saved) { try { await refreshStatus(); } catch (error) { setLowVoltageProtectionFeedback( `保护开关已保存,但状态刷新失败:${error.message}`, "error", ); } } }); performanceModeToggle.addEventListener("change", async () => { if (performanceModePending) { showPerformanceModeSwitch(confirmedPerformanceModeEnabled); return; } const requested = performanceModeToggle.checked === true; const previous = confirmedPerformanceModeEnabled; performanceModePending = true; setControlBusy(performanceModeToggle); performanceModeSwitchState.textContent = requested ? "正在开启" : "正在关闭"; setPerformanceModeFeedback( requested ? "正在应用 performance governor…" : "正在恢复原 CPU governor…", "pending", ); try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ performance_mode_enabled: requested }), }); if (typeof config.performance_mode_enabled !== "boolean") { throw new Error("服务器返回了无效的性能模式状态"); } confirmedPerformanceModeEnabled = config.performance_mode_enabled; showPerformanceModeSwitch(confirmedPerformanceModeEnabled); setPerformanceModeFeedback( confirmedPerformanceModeEnabled ? "性能模式已开启" : "性能模式已关闭并恢复原 governor", "success", ); announce(confirmedPerformanceModeEnabled ? "性能模式已开启" : "性能模式已关闭"); } catch (error) { confirmedPerformanceModeEnabled = previous; showPerformanceModeSwitch(previous); setPerformanceModeFeedback(`性能模式切换失败:${error.message}`, "error"); announce(`性能模式切换失败:${error.message}`, true); } finally { performanceModePending = false; setControlReady(performanceModeToggle); } try { await refreshStatus(); } catch (error) { setPerformanceModeFeedback(`性能模式状态刷新失败:${error.message}`, "error"); } }); async function saveAnimationPreviewMaxConcurrent(requested) { if (animationPreviewConcurrencyPending) return; const previous = confirmedAnimationPreviewMaxConcurrent; animationPreviewConcurrencyPending = true; setControlBusy(animationPreviewMaxConcurrentInput); setAnimationPreviewConcurrencyFeedback(`正在保存同时播放数量 ${requested}…`, "pending"); try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ animation_preview_max_concurrent: requested }), }); const saved = normalizeAnimationPreviewMaxConcurrent( config.animation_preview_max_concurrent, ); confirmedAnimationPreviewMaxConcurrent = saved; setAnimationPreviewMaxConcurrent(saved); showAnimationPreviewMaxConcurrent(saved); setAnimationPreviewConcurrencyFeedback(`同时播放数量 ${saved} 已保存`, "success"); announce(`模板界面同时播放的动图数量已设置为 ${saved}`); } catch (error) { confirmedAnimationPreviewMaxConcurrent = previous; setAnimationPreviewMaxConcurrent(previous); showAnimationPreviewMaxConcurrent(previous); setAnimationPreviewConcurrencyFeedback(`同时播放数量保存失败:${error.message}`, "error"); announce(`同时播放数量保存失败:${error.message}`, true); } finally { animationPreviewConcurrencyPending = false; setControlReady(animationPreviewMaxConcurrentInput); } } function commitAnimationPreviewMaxConcurrent() { if (animationPreviewConcurrencyPending) return; const raw = animationPreviewMaxConcurrentInput.value.trim(); const requested = Number(raw); try { if (raw === "" || !/^\d+$/.test(raw)) throw new Error(); normalizeAnimationPreviewMaxConcurrent(requested); } catch { showAnimationPreviewMaxConcurrent(confirmedAnimationPreviewMaxConcurrent); setAnimationPreviewConcurrencyFeedback( `请输入 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数。`, "error", ); return; } if (requested === confirmedAnimationPreviewMaxConcurrent) { setAnimationPreviewConcurrencyFeedback(); return; } void saveAnimationPreviewMaxConcurrent(requested); } animationPreviewMaxConcurrentInput.addEventListener("input", () => { setAnimationPreviewConcurrencyFeedback(); }); animationPreviewMaxConcurrentInput.addEventListener("change", commitAnimationPreviewMaxConcurrent); animationPreviewMaxConcurrentInput.addEventListener("blur", commitAnimationPreviewMaxConcurrent); animationPreviewMaxConcurrentInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); animationPreviewMaxConcurrentInput.blur(); }); orientationButtons.forEach((button) => { button.addEventListener("click", async () => { if (orientationPending) return; const requested = Number(button.dataset.orientation); if (requested === confirmedOrientation) return; orientationPending = true; orientationButtons.forEach((item) => setControlBusy(item)); showOrientation(requested); setFeedback("正在应用屏幕方向…", "pending"); try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ orientation: requested }), }); confirmedOrientation = Number(config.orientation); showOrientation(confirmedOrientation); setFeedback(`方向 ${confirmedOrientation}° 已生效并保存`, "success"); announce(`屏幕方向已切换为 ${confirmedOrientation}°`); await refreshStatus(); } catch (error) { showOrientation(confirmedOrientation); setFeedback(`方向应用失败:${error.message}`, "error"); announce(`方向应用失败:${error.message}`, true); } finally { orientationPending = false; orientationButtons.forEach((item) => setControlReady(item)); } }); }); matrixRefreshRateButtons.forEach((button) => { button.addEventListener("click", async () => { if (matrixRefreshRatePending) return; const requested = Number(button.dataset.matrixRefreshRate); if (requested === confirmedMatrixRefreshRate) return; const previous = confirmedMatrixRefreshRate; matrixRefreshRatePending = true; matrixRefreshRateButtons.forEach((item) => setControlBusy(item)); showMatrixRefreshRate(requested); setFeedback(`正在应用 ${requested}Hz 扫描上限…`, "pending"); try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ matrix_refresh_rate_limit_hz: requested }), }); const applied = Number(config.matrix_refresh_rate_limit_hz); if (!matrixRefreshRateButtons.some( (item) => Number(item.dataset.matrixRefreshRate) === applied )) { throw new Error("服务器返回了无效的扫描刷新率挡位"); } confirmedMatrixRefreshRate = applied; showMatrixRefreshRate(confirmedMatrixRefreshRate); setFeedback(`${confirmedMatrixRefreshRate}Hz 上限已生效并保存`, "success"); announce(`屏幕扫描刷新率上限已切换为 ${confirmedMatrixRefreshRate}Hz`); await refreshStatus(); } catch (error) { confirmedMatrixRefreshRate = previous; showMatrixRefreshRate(previous); setFeedback(`扫描刷新率应用失败:${error.message}`, "error"); announce(`扫描刷新率应用失败:${error.message}`, true); } finally { matrixRefreshRatePending = false; matrixRefreshRateButtons.forEach((item) => setControlReady(item)); } }); }); async function processBrightnessQueue() { if (applyingBrightness) return; applyingBrightness = true; while (desiredBrightness !== null) { const requested = desiredBrightness; desiredBrightness = null; setFeedback(brightnessFeedbackText("pending", requested), "pending"); try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ brightness: requested }), }); confirmedBrightness = Number(config.brightness); latestEffectiveBrightness = null; showBrightnessProtectionStatus(confirmedBrightness); setFeedback(brightnessFeedbackText("success", confirmedBrightness), "success"); } catch (error) { if (desiredBrightness === null) { showBrightness(confirmedBrightness); showBrightnessProtectionStatus(confirmedBrightness, latestEffectiveBrightness); } setFeedback(`亮度应用失败:${error.message}`, "error"); announce(`亮度应用失败:${error.message}`, true); } } applyingBrightness = false; await refreshStatus().catch(() => {}); } function queueBrightness(value) { desiredBrightness = Math.max(1, Math.min(100, Number(value))); processBrightnessQueue(); } brightness.addEventListener("input", () => { brightnessValue.textContent = `${brightness.value}%`; showBrightnessProtectionStatus(brightness.value); setFeedback(brightnessFeedbackText("pending", brightness.value), "pending"); queueBrightness(brightness.value); }); brightness.addEventListener("change", () => { queueBrightness(brightness.value); }); async function processPreviewRefreshQueue() { if (previewRefreshPending) return; previewRefreshPending = true; while (desiredPreviewRefreshInterval !== null) { const requested = desiredPreviewRefreshInterval; desiredPreviewRefreshInterval = null; savingPreviewRefreshInterval = requested; setFeedback(`正在保存预览刷新间隔 ${requested}ms…`, "pending"); try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ preview_refresh_interval_ms: requested }), }); confirmedPreviewRefreshInterval = Number(config.preview_refresh_interval_ms); showPreviewRefreshInterval(confirmedPreviewRefreshInterval); setPreviewRefreshInterval(confirmedPreviewRefreshInterval); setFeedback(`预览刷新间隔 ${confirmedPreviewRefreshInterval}ms 已保存`, "success"); } catch (error) { if (desiredPreviewRefreshInterval === null) { showPreviewRefreshInterval(confirmedPreviewRefreshInterval); } setFeedback(`预览刷新间隔保存失败:${error.message}`, "error"); announce(`预览刷新间隔保存失败:${error.message}`, true); } finally { savingPreviewRefreshInterval = null; } } previewRefreshPending = false; await refreshStatus().catch(() => {}); } function commitPreviewRefreshInterval() { const value = previewRefreshInput.value.trim(); const requested = Number(value); if ( value === "" || !/^\d+$/.test(value) || !Number.isInteger(requested) || requested < 1 || requested > 60000 ) { previewRefreshInput.value = String(confirmedPreviewRefreshInterval); showPreviewRefreshWarning(confirmedPreviewRefreshInterval); showPreviewRefreshError("请输入 1 到 60000 之间的整数。"); return; } showPreviewRefreshError(); showPreviewRefreshWarning(requested); if ( requested === savingPreviewRefreshInterval || requested === desiredPreviewRefreshInterval ) return; if (requested === confirmedPreviewRefreshInterval && !previewRefreshPending) return; desiredPreviewRefreshInterval = requested; void processPreviewRefreshQueue(); } previewRefreshInput.addEventListener("input", () => { showPreviewRefreshWarning(null); showPreviewRefreshError(); }); previewRefreshInput.addEventListener("change", commitPreviewRefreshInterval); previewRefreshInput.addEventListener("blur", commitPreviewRefreshInterval); previewRefreshInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); previewRefreshInput.blur(); }); document.addEventListener("matrix:status", (event) => { const detail = event.detail || {}; syncPerformanceModeStatus(detail.system?.performance_mode); const statusMatrixRefreshRate = Number( detail.screen?.driver_options?.limit_refresh_rate_hz, ); if ( Number.isInteger(statusMatrixRefreshRate) && !matrixRefreshRatePending && matrixRefreshRateButtons.some( (button) => Number(button.dataset.matrixRefreshRate) === statusMatrixRefreshRate, ) ) { confirmedMatrixRefreshRate = statusMatrixRefreshRate; showMatrixRefreshRate(confirmedMatrixRefreshRate); } const statusPreviewInterval = Number(detail.ui?.preview_refresh_interval_ms); if ( Number.isInteger(statusPreviewInterval) && !previewRefreshPending && desiredPreviewRefreshInterval === null && document.activeElement !== previewRefreshInput ) { confirmedPreviewRefreshInterval = statusPreviewInterval; showPreviewRefreshInterval(confirmedPreviewRefreshInterval); setPreviewRefreshInterval(confirmedPreviewRefreshInterval); } const state = detail.state; displayTestActive = state?.display_test?.active === true; displayTestBrightness = displayTestActive ? finitePercent(state?.display_test?.brightness) : null; voltageStatus = detail.power?.screen_input_voltage || null; lowVoltageProtectionStatus = detail.power?.low_voltage_protection || null; latestEffectiveBrightness = finitePercent(state?.effective_brightness); showVoltageDetails(); if (!lowVoltageProtectionPending && typeof lowVoltageProtectionStatus?.enabled === "boolean") { const protectionChanged = lowVoltageProtectionStatus.enabled !== confirmedLowVoltageProtectionEnabled; syncLowVoltageProtectionEnabled(lowVoltageProtectionStatus.enabled); if (protectionChanged && voltageConfig !== null) { setLowVoltageProtectionFeedback( confirmedLowVoltageProtectionEnabled ? "保护开关已从其他页面同步为开启" : "保护开关已从其他页面同步为关闭", "ready", ); } } showBrightnessProtectionStatus(state?.brightness ?? confirmedBrightness, latestEffectiveBrightness); if (orientationPending || applyingBrightness || desiredBrightness !== null) return; if (!state) return; const nextOrientation = Number(state.orientation); const nextBrightness = Number(state.brightness); const changed = nextOrientation !== confirmedOrientation || nextBrightness !== confirmedBrightness; confirmedOrientation = nextOrientation; confirmedBrightness = nextBrightness; showOrientation(confirmedOrientation); showBrightness(confirmedBrightness); showBrightnessProtectionStatus(confirmedBrightness, latestEffectiveBrightness); if (changed && detail.externalChange) { setFeedback("方向或亮度已由其他控制页面更新", "ready"); } }); registerWorkspace({ id: "settings", title: "系统设置", elementId: "workspace-settings", order: 20, persistence: "server", onEnter: loadSettings, onLeave: () => { discardWifiSettingsDrafts(); discardFrpDrafts(); }, });