From 44459279900622501bfe7b877e946220b1817ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Tue, 26 May 2026 12:11:02 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=E9=83=A8=E7=BD=B2=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=AE=89=E8=A3=85=20Web=20=E9=85=8D=E7=BD=AE=E5=86=99?= =?UTF-8?q?=E5=85=A5=20sudo=20=E5=8A=A9=E6=89=8B=20/=20Install=20Web=20con?= =?UTF-8?q?fig-write=20sudo=20helper=20in=20deploy=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ogscope-config-write 与 ogscope-config sudoers / Add config-write script and sudoers - install/board-update/network-init 自动安装并规范化 env 640 权限 / Auto-install on deploy paths - API 与配置页区分 direct/sudo 可写状态 / API and config UI expose direct vs sudo write access Co-authored-by: Cursor --- ogscope/web/api/system/config_files.py | 107 ++++++++++++++++++ ogscope/web/api/system/routes.py | 61 +--------- scripts/board-update.sh | 2 + scripts/install-min.sh | 2 + scripts/install.sh | 2 + scripts/mirror.sh | 36 ++++++ scripts/ogscope-config-write.sh | 53 +++++++++ scripts/ogscope-network-init.sh | 67 ++++++++++- tests/unit/test_config_files.py | 43 +++++++ web/spa/src/apps/system/pages/ConfigPage.tsx | 20 +++- .../analysis-lab/assets/system-B6miqBWl.js | 78 ------------- .../analysis-lab/assets/system-DQXiDxh6.js | 78 +++++++++++++ web/static/analysis-lab/system.html | 2 +- 13 files changed, 412 insertions(+), 139 deletions(-) create mode 100644 ogscope/web/api/system/config_files.py create mode 100755 scripts/ogscope-config-write.sh create mode 100644 tests/unit/test_config_files.py delete mode 100644 web/static/analysis-lab/assets/system-B6miqBWl.js create mode 100644 web/static/analysis-lab/assets/system-DQXiDxh6.js diff --git a/ogscope/web/api/system/config_files.py b/ogscope/web/api/system/config_files.py new file mode 100644 index 0000000..6818c7e --- /dev/null +++ b/ogscope/web/api/system/config_files.py @@ -0,0 +1,107 @@ +"""Web 配置 env 文件读写辅助 / Helpers for Web-managed env config files.""" + +from __future__ import annotations + +import grp +import os +import subprocess +from pathlib import Path + +CONFIG_WRITE_SCRIPT = Path("/usr/local/bin/ogscope-config-write") +CONFIG_SUDOERS = Path("/etc/sudoers.d/ogscope-config") +CONFIG_FILE_MODE = "640" + + +def config_file_group(path: Path) -> str: + """读取目标文件属组,供 chown 使用 / Group name for chown on target file.""" + if path.exists(): + try: + return grp.getgrgid(path.stat().st_gid).gr_name + except KeyError: + pass + return os.environ.get("USER", "ogscope") + + +def config_write_access() -> dict[str, bool]: + """评估 sudo 写入能力 / Assess sudo-backed config write access.""" + via_sudo = CONFIG_WRITE_SCRIPT.is_file() and CONFIG_SUDOERS.is_file() + return { + "writable_via_sudo": via_sudo, + } + + +def read_config_file_payload(path: Path) -> dict: + """读取配置文件内容与写入能力 / Read config file and write capability flags.""" + exists = path.exists() + access = config_write_access() + if not exists: + parent_writable = os.access(path.parent, os.W_OK) + return { + "path": str(path), + "exists": False, + "writable": parent_writable or access["writable_via_sudo"], + **access, + "content": "", + "error": "file not found", + } + try: + content = path.read_text(encoding="utf-8") + direct = os.access(path, os.W_OK) + writable = direct or access["writable_via_sudo"] + return { + "path": str(path), + "exists": True, + "writable": writable, + "writable_direct": direct, + "writable_via_sudo": access["writable_via_sudo"], + "content": content, + "error": None, + } + except OSError as exc: + return { + "path": str(path), + "exists": True, + "writable": access["writable_via_sudo"], + **access, + "content": "", + "error": str(exc), + } + + +def write_config_file(path: Path, content: str) -> None: + """写入 env 配置文件(必要时 sudo)/ Write env config, using sudo helper when needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + try: + path.write_text(content, encoding="utf-8") + return + except OSError: + pass + + if not CONFIG_WRITE_SCRIPT.is_file(): + raise RuntimeError( + "failed to write config file; install ogscope-config-write via install.sh " + "or ogscope-network-init.sh ensure-config" + ) + + group = config_file_group(path) + proc = subprocess.run( + [ + "sudo", + "-n", + str(CONFIG_WRITE_SCRIPT), + str(path), + CONFIG_FILE_MODE, + group, + ], + input=content, + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip() + raise RuntimeError( + "failed to write config file via sudo; run " + "sudo ./scripts/ogscope-network-init.sh ensure-config " + f"({detail})" + ) diff --git a/ogscope/web/api/system/routes.py b/ogscope/web/api/system/routes.py index 9513f9e..38f166e 100644 --- a/ogscope/web/api/system/routes.py +++ b/ogscope/web/api/system/routes.py @@ -3,8 +3,6 @@ """ from pathlib import Path -import os -import subprocess from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field @@ -14,6 +12,7 @@ from ogscope.domain.system.services import system_info_service from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client from ogscope.web.api.models.schemas import SystemInfo +from ogscope.web.api.system.config_files import read_config_file_payload, write_config_file router = APIRouter() @@ -59,64 +58,14 @@ def _validate_env_content(content: str) -> None: def _read_config_file(path: Path) -> dict: - exists = path.exists() - writable = os.access(path if exists else path.parent, os.W_OK) - if not exists: - return { - "path": str(path), - "exists": False, - "writable": writable, - "content": "", - "error": "file not found", - } - try: - content = path.read_text(encoding="utf-8") - return { - "path": str(path), - "exists": True, - "writable": writable, - "content": content, - "error": None, - } - except OSError as exc: - return { - "path": str(path), - "exists": True, - "writable": writable, - "content": "", - "error": str(exc), - } + return read_config_file_payload(path) def _write_config_file(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) try: - path.write_text(content, encoding="utf-8") - return - except OSError: - pass - - proc = subprocess.run( - ["sudo", "-n", "tee", str(path)], - input=content, - text=True, - capture_output=True, - check=False, - ) - if proc.returncode != 0: - raise HTTPException( - status_code=500, - detail=( - "failed to write config file; grant write permission " - "or allow sudo tee without password" - ), - ) - subprocess.run( - ["sudo", "-n", "chmod", "640", str(path)], - capture_output=True, - text=True, - check=False, - ) + write_config_file(path, content) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.get("/system/info", response_model=SystemInfo) diff --git a/scripts/board-update.sh b/scripts/board-update.sh index 93c53b1..3f90317 100755 --- a/scripts/board-update.sh +++ b/scripts/board-update.sh @@ -136,6 +136,8 @@ fi sudo chown "root:${USER}" "${OGSCOPE_ENV_FILE}" 2>/dev/null || true sudo chmod 640 "${OGSCOPE_ENV_FILE}" 2>/dev/null || true +ogscope_install_config_write_artifacts "${PROJECT_DIR}" "${USER}" + chmod +x "${PROJECT_DIR}/scripts/ogscope-network-boot.sh" 2>/dev/null || true ogscope_sync_network_boot_unit_if_needed "${PROJECT_DIR}" diff --git a/scripts/install-min.sh b/scripts/install-min.sh index 8fe88d3..68a2905 100644 --- a/scripts/install-min.sh +++ b/scripts/install-min.sh @@ -121,6 +121,8 @@ fi sudo chown "root:${USER}" "${OGSCOPE_ENV_FILE}" 2>/dev/null || true sudo chmod 640 "${OGSCOPE_ENV_FILE}" 2>/dev/null || true +ogscope_install_config_write_artifacts "${PROJECT_DIR}" "${USER}" + echo "⚙️ 写入 systemd 服务 / Writing systemd service..." sudo tee "${SERVICE_PATH}" >/dev/null </dev/null || true fi +ogscope_install_config_write_artifacts "${PROJECT_DIR}" "${USER}" + # ExecStart 使用 poetry env info --path(与 virtualenvs.in-project=true 时即项目 .venv),勿手写 ~/.virtualenvs/ # ExecStart uses poetry env path (project .venv when in-project=true); do not hardcode ~/.virtualenvs/ echo "⚙️ 写入 systemd: ${SERVICE_PATH}" diff --git a/scripts/mirror.sh b/scripts/mirror.sh index 7f0b9ee..be4d1e9 100644 --- a/scripts/mirror.sh +++ b/scripts/mirror.sh @@ -412,6 +412,41 @@ ogscope_report_plate_solve_database_status() { echo "⚠️ Plate solving needs default_database.npz under data/plate_solve/; see docs/development/plate-solve-data.md" } +# 增量更新:同步网络相关工件(与近期 wifi-nm / systemd 文档一致) +# Board update: sync network artifacts (matches wifi-nm + systemd docs) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +# $2 = 服务用户名(可选,默认 $USER)/ service user (optional, default $USER) +# 环境 / Env: OGSCOPE_SKIP_NETWORK_SYNC=1 跳过;需 sudo(免密或交互)/ skip; requires sudo +ogscope_install_config_write_artifacts() { + local project_dir="${1:?}" + local run_user="${2:-${USER:-}}" + local src="${project_dir}/scripts/ogscope-config-write.sh" + local dst="/usr/local/bin/ogscope-config-write" + local sudoers="/etc/sudoers.d/ogscope-config" + + if [ ! -f "${src}" ]; then + echo "⚠️ 未找到 ${src},跳过 config-write / Missing config-write script" + return 0 + fi + + echo "📝 安装 Web 配置写入助手 / Installing config-write helper → ${dst} ..." + sudo install -m 755 "${src}" "${dst}" + + if [ -z "${run_user}" ]; then + echo "⚠️ 未设置服务用户,跳过 ogscope-config sudoers / No service user; skip config sudoers" + return 0 + fi + + umask 077 + sudo tee "${sudoers}.tmp" >/dev/null </dev/null; then sudo env OGSCOPE_SERVICE_USER="${USER}" "${init_script}" ensure-systemd \ || echo "⚠️ ensure-systemd 失败;可手动: sudo env OGSCOPE_SERVICE_USER=\$USER ${init_script} ensure-systemd" + ogscope_install_config_write_artifacts "${project_dir}" "${USER}" else echo "⚠️ 无法免密 sudo,未运行 ensure-systemd;若 Web WiFi 异常请手动执行上述命令(见 docs/development/wifi-nm.md)" echo "⚠️ Non-interactive sudo unavailable; run ensure-systemd manually if WiFi/API issues" diff --git a/scripts/ogscope-config-write.sh b/scripts/ogscope-config-write.sh new file mode 100755 index 0000000..d4a9635 --- /dev/null +++ b/scripts/ogscope-config-write.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# 受控写入 /etc/ogscope/*.env(供 Web 配置 API 经 sudo 调用) +# Controlled write to /etc/ogscope/*.env (invoked via sudo from Web config API) +# +# 用法 / Usage: +# echo "KEY=value" | sudo -n ogscope-config-write /etc/ogscope/ogscope.env [mode] [group] +# +set -euo pipefail + +die() { + echo "ogscope-config-write: $*" >&2 + exit 1 +} + +dest="${1:-}" +mode="${2:-640}" +group="${3:-}" + +ALLOWED=( + "/etc/ogscope/ogscope.env" + "/etc/ogscope/network.env" +) + +[[ -n "${dest}" ]] || die "missing destination path" + +allowed=0 +for path in "${ALLOWED[@]}"; do + if [[ "${dest}" == "${path}" ]]; then + allowed=1 + break + fi +done +[[ "${allowed}" -eq 1 ]] || die "destination not allowed: ${dest}" + +if [[ ! "${mode}" =~ ^[0-7]{3,4}$ ]]; then + die "invalid mode: ${mode}" +fi + +if [[ -z "${group}" ]]; then + if [[ -e "${dest}" ]]; then + group="$(stat -c '%G' "${dest}" 2>/dev/null || true)" + fi + group="${group:-ogscope}" +fi + +mkdir -p "$(dirname "${dest}")" +tmp="$(mktemp "${dest}.tmp.XXXXXX")" +trap 'rm -f "${tmp}"' EXIT +cat >"${tmp}" +chown "root:${group}" "${tmp}" +chmod "${mode}" "${tmp}" +mv -f "${tmp}" "${dest}" +trap - EXIT diff --git a/scripts/ogscope-network-init.sh b/scripts/ogscope-network-init.sh index 478e424..4aee0bb 100755 --- a/scripts/ogscope-network-init.sh +++ b/scripts/ogscope-network-init.sh @@ -30,6 +30,10 @@ SWITCH_SRC="${SCRIPT_DIR}/ogscope-wifi-switch.sh" SWITCH_DST="/usr/local/bin/ogscope-wifi-switch" SUDOERS_D="/etc/sudoers.d/ogscope-wifi" SUDOERS_NMCLI="/etc/sudoers.d/ogscope-nmcli" +SUDOERS_CONFIG="/etc/sudoers.d/ogscope-config" +CONFIG_WRITE_SRC="${SCRIPT_DIR}/ogscope-config-write.sh" +CONFIG_WRITE_DST="/usr/local/bin/ogscope-config-write" +OGSCOPE_ENV_FILE="${ENV_DIR}/ogscope.env" # systemd drop-in:老部署主 unit 可能无 EnvironmentFile / Drop-in for units missing EnvironmentFile SYSTEMD_DROPIN_DIR="/etc/systemd/system/ogscope.service.d" SYSTEMD_NETWORK_ENV_CONF="${SYSTEMD_DROPIN_DIR}/ogscope-network-env.conf" @@ -104,8 +108,47 @@ write_sudoers_nmcli() { ok "已写入 ${SUDOERS_NMCLI}(免密 ${nmcli_bin},Web「激活」已保存 WiFi 等)" } +install_config_write_script() { + if [[ ! -f "${CONFIG_WRITE_SRC}" ]]; then + die "未找到 ${CONFIG_WRITE_SRC} / Config write script missing" + fi + install -m 755 "${CONFIG_WRITE_SRC}" "${CONFIG_WRITE_DST}" + ok "已安装 ${CONFIG_WRITE_DST}" +} + +write_sudoers_config() { + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" + if [[ -z "${run_user}" ]]; then + info "未设置 OGSCOPE_SERVICE_USER/SUDO_USER,跳过 config sudoers / Skipping config sudoers" + return 0 + fi + umask 077 + cat >"${SUDOERS_CONFIG}.tmp" </dev/null || true + chmod 640 "${f}" 2>/dev/null || true + done + ok "已规范化 ogscope.env / network.env 权限为 root:${run_user} 640" +} + write_network_env() { local suffix="$1" + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" umask 077 cat >"${ENV_FILE}.tmp" </dev/null || true + fi + chmod 640 "${ENV_FILE}.tmp" mv "${ENV_FILE}.tmp" "${ENV_FILE}" ok "已写入 ${ENV_FILE}" } @@ -252,6 +298,9 @@ cmd_init() { ensure_ogscope_systemd_network_env write_sudoers write_sudoers_nmcli + install_config_write_script + write_sudoers_config + normalize_config_env_permissions set_hostname_avahi "${suffix}" ok "init 完成。请 systemctl restart ogscope 并连接热点 OGScope_${suffix} / init done" @@ -276,9 +325,20 @@ cmd_ensure_systemd() { fi ensure_ogscope_systemd_network_env write_sudoers_nmcli + install_config_write_script + write_sudoers_config + normalize_config_env_permissions info "请执行: sudo systemctl restart ogscope / Please run: sudo systemctl restart ogscope" } +cmd_ensure_config() { + require_root + install_config_write_script + write_sudoers_config + normalize_config_env_permissions + ok "config-write 与 sudoers 已就绪 / config-write and sudoers ready" +} + cmd_diag() { info "=== OGScope 网络诊断 / Network diagnostics ===" command -v nmcli >/dev/null && ok "nmcli: OK" || echo "❌ nmcli 缺失" @@ -287,6 +347,8 @@ cmd_diag() { [[ -f "${SWITCH_DST}" ]] && ok "切换脚本: ${SWITCH_DST}" || echo "⚠️ 无 ${SWITCH_DST}" [[ -f "${SUDOERS_D}" ]] && ok "sudoers: ${SUDOERS_D}" || echo "⚠️ 无 sudoers" [[ -f "${SUDOERS_NMCLI}" ]] && ok "sudoers nmcli: ${SUDOERS_NMCLI}" || echo "⚠️ 无 ${SUDOERS_NMCLI}(Web 激活 WiFi 可能报 Not authorized)" + [[ -f "${SUDOERS_CONFIG}" ]] && ok "sudoers config: ${SUDOERS_CONFIG}" || echo "⚠️ 无 ${SUDOERS_CONFIG}(Web 配置页可能无法保存)" + [[ -x "${CONFIG_WRITE_DST}" ]] && ok "config-write: ${CONFIG_WRITE_DST}" || echo "⚠️ 无 ${CONFIG_WRITE_DST}" command -v avahi-daemon >/dev/null && ok "avahi-daemon 已安装" || echo "⚠️ avahi-daemon 未安装" if command -v nmcli >/dev/null; then nmcli connection show "${AP_NAME}" >/dev/null 2>&1 && ok "连接 ${AP_NAME} 存在" || echo "⚠️ 无 ${AP_NAME}" @@ -346,9 +408,10 @@ main() { init) cmd_init "${1:-}" ;; diag) cmd_diag ;; ensure-systemd) cmd_ensure_systemd ;; + ensure-config) cmd_ensure_config ;; reset) cmd_reset "${1:-}" ;; *) - echo "Usage: sudo $0 init [--yes] | diag | ensure-systemd | reset [--yes]" >&2 + echo "Usage: sudo $0 init [--yes] | diag | ensure-systemd | ensure-config | reset [--yes]" >&2 exit 1 ;; esac diff --git a/tests/unit/test_config_files.py b/tests/unit/test_config_files.py new file mode 100644 index 0000000..f2c0540 --- /dev/null +++ b/tests/unit/test_config_files.py @@ -0,0 +1,43 @@ +"""配置 env 文件读写辅助测试 / Tests for config env file helpers.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ogscope.web.api.system import config_files as mod + + +@pytest.mark.unit +def test_read_config_file_payload_marks_sudo_writable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + env_path = tmp_path / "ogscope.env" + env_path.write_text("OGSCOPE_PORT=8000\n", encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_WRITE_SCRIPT", tmp_path / "write.sh") + monkeypatch.setattr(mod, "CONFIG_SUDOERS", tmp_path / "sudoers") + mod.CONFIG_WRITE_SCRIPT.write_text("#!/bin/sh\n", encoding="utf-8") + mod.CONFIG_SUDOERS.write_text("ogscope ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-config-write\n") + + payload = mod.read_config_file_payload(env_path) + + assert payload["exists"] is True + assert payload["writable_via_sudo"] is True + assert payload["writable"] is True + + +@pytest.mark.unit +def test_read_config_file_payload_not_writable_without_sudoers( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + env_path = tmp_path / "ogscope.env" + env_path.write_text("OGSCOPE_PORT=8000\n", encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_WRITE_SCRIPT", tmp_path / "missing-write.sh") + monkeypatch.setattr(mod, "CONFIG_SUDOERS", tmp_path / "missing-sudoers") + monkeypatch.setattr(mod.os, "access", lambda _path, _mode: False) + + payload = mod.read_config_file_payload(env_path) + + assert payload["writable_via_sudo"] is False + assert payload["writable"] is False diff --git a/web/spa/src/apps/system/pages/ConfigPage.tsx b/web/spa/src/apps/system/pages/ConfigPage.tsx index e04e64e..6f115e9 100644 --- a/web/spa/src/apps/system/pages/ConfigPage.tsx +++ b/web/spa/src/apps/system/pages/ConfigPage.tsx @@ -8,6 +8,8 @@ type ConfigFileItem = { path: string; exists: boolean; writable: boolean; + writable_direct?: boolean; + writable_via_sudo?: boolean; content: string; error?: string | null; }; @@ -288,6 +290,21 @@ export function ConfigPage() { return label ? (isZh ? label.zh : label.en) : fileId; }; + const writableHint = (file: ConfigFileItem) => { + if (!file.writable) { + return isZh + ? "不可写:请运行 sudo ./scripts/ogscope-network-init.sh ensure-config" + : "Not writable: run sudo ./scripts/ogscope-network-init.sh ensure-config"; + } + if (file.writable_via_sudo && !file.writable_direct) { + return isZh ? "可写(经 sudo 助手)" : "Writable (via sudo helper)"; + } + if (file.writable_direct) { + return isZh ? "可写(直接)" : "Writable (direct)"; + } + return isZh ? "可写" : "Writable"; + }; + return (
@@ -360,8 +377,7 @@ export function ConfigPage() {

{activeFile.path}

- {isZh ? "可写" : "Writable"}: {String(activeFile.writable)} · {isZh ? "存在" : "Exists"}:{" "} - {String(activeFile.exists)} + {writableHint(activeFile)} · {isZh ? "存在" : "Exists"}: {String(activeFile.exists)}

diff --git a/web/static/analysis-lab/assets/system-B6miqBWl.js b/web/static/analysis-lab/assets/system-B6miqBWl.js deleted file mode 100644 index 3ce1222..0000000 --- a/web/static/analysis-lab/assets/system-B6miqBWl.js +++ /dev/null @@ -1,78 +0,0 @@ -import{j as e,r as a,a as $e,R as Me}from"./client-D1ZVDB-N.js";import{u as pe,C as Ee,r as K,a as U,S as Pe,b as Le}from"./http-ChPtkS1w.js";import{c as q,u as J,T as Re,I as Te}from"./index-CutgeBjy.js";import{R as he}from"./refresh-cw-BkMjDReH.js";/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const me=q("Activity",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Oe=q("Bolt",[["path",{d:"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z",key:"yt0hxn"}],["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ae=q("BookOpen",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Se=q("Cpu",[["rect",{width:"16",height:"16",x:"4",y:"4",rx:"2",key:"14l7u7"}],["rect",{width:"6",height:"6",x:"9",y:"9",rx:"1",key:"5aljv4"}],["path",{d:"M15 2v2",key:"13l42r"}],["path",{d:"M15 20v2",key:"15mkzm"}],["path",{d:"M2 15h2",key:"1gxd5l"}],["path",{d:"M2 9h2",key:"1bbxkp"}],["path",{d:"M20 15h2",key:"19e6y8"}],["path",{d:"M20 9h2",key:"19tzq7"}],["path",{d:"M9 2v2",key:"165o2o"}],["path",{d:"M9 20v2",key:"i2bqo8"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Fe=q("HardDrive",[["line",{x1:"22",x2:"2",y1:"12",y2:"12",key:"1y58io"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}],["line",{x1:"6",x2:"6.01",y1:"16",y2:"16",key:"sgf278"}],["line",{x1:"10",x2:"10.01",y1:"16",y2:"16",key:"1l4acy"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ie=q("LayoutDashboard",[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const ze=q("Network",[["rect",{x:"16",y:"16",width:"6",height:"6",rx:"1",key:"4q2zg0"}],["rect",{x:"2",y:"16",width:"6",height:"6",rx:"1",key:"8cvhb9"}],["rect",{x:"9",y:"2",width:"6",height:"6",rx:"1",key:"1egb70"}],["path",{d:"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3",key:"1jsf9p"}],["path",{d:"M12 12V8",key:"2874zd"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const De=q("Plus",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const _e=q("Search",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const qe=q("Settings",[["path",{d:"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",key:"1qme2f"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const ge=q("Sparkles",[["path",{d:"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z",key:"4pj2yx"}],["path",{d:"M20 3v4",key:"1olli1"}],["path",{d:"M22 5h-4",key:"1gvqau"}],["path",{d:"M4 17v2",key:"vumght"}],["path",{d:"M5 18H3",key:"zchphs"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const He=q("Thermometer",[["path",{d:"M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z",key:"17jzev"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ue=q("Touchpad",[["rect",{width:"20",height:"16",x:"2",y:"4",rx:"2",key:"18n3k1"}],["path",{d:"M2 14h20",key:"myj16y"}],["path",{d:"M12 20v-6",key:"1rm09r"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Be=q("TriangleAlert",[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const fe=q("Wifi",[["path",{d:"M12 20h.01",key:"zekei9"}],["path",{d:"M2 8.82a15 15 0 0 1 20 0",key:"dnpr2z"}],["path",{d:"M5 12.859a10 10 0 0 1 14 0",key:"1x1e6c"}],["path",{d:"M8.5 16.429a5 5 0 0 1 7 0",key:"1bycff"}]]),V=s=>`flex items-center gap-3 rounded-lg px-3 py-2.5 font-headline text-sm tracking-tight transition-colors ${s?"border-r-2 border-primary bg-white/5 font-semibold text-primary":"text-on-surface-variant hover:bg-white/5 hover:text-on-surface"}`;function We({route:s,allowNetworkRoute:o,onRouteChange:n,children:t}){const{t:c,locale:d,setLocale:N}=J(),{info:m}=pe(),f=(m==null?void 0:m.cpu_usage)!=null?Number(m.cpu_usage).toFixed(1):"—",y=(m==null?void 0:m.memory_usage)!=null?Number(m.memory_usage).toFixed(1):"—",g=(m==null?void 0:m.temperature)!=null?Number(m.temperature).toFixed(1):"—",S=(m==null?void 0:m.wifi_quality)!=null&&!Number.isNaN(Number(m.wifi_quality))?`${Number(m.wifi_quality).toFixed(0)}%`:"—",v={overview:c("sys.shell.top.overview"),network:c("sys.shell.top.network"),sensors:c("sys.shell.top.sensors"),hmi:c("sys.shell.top.hmi"),power:c("sys.shell.top.power"),config:c("sys.shell.top.config")},p=V(!1),$=(M,R)=>{const _=window.open(M,R);_&&_.focus()};return e.jsxs("div",{className:"flex h-full min-h-0 flex-col bg-background text-on-surface md:flex-row",children:[e.jsxs("aside",{className:"glass-panel z-50 flex w-full shrink-0 flex-col border-b border-outline-variant/20 bg-surface-container-low/80 backdrop-blur-xl md:fixed md:left-0 md:top-0 md:h-full md:w-64 md:border-b-0 md:border-r md:border-white/5",children:[e.jsxs("div",{className:"p-5",children:[e.jsxs("div",{className:"mb-8 flex items-center gap-3",children:[e.jsx("div",{className:"primary-gradient flex h-10 w-10 items-center justify-center rounded-lg shadow-lg",children:e.jsx(ge,{className:"h-5 w-5 text-on-primary-container"})}),e.jsxs("div",{children:[e.jsx("h1",{className:"font-headline text-lg font-bold tracking-widest text-primary",children:"OGScope"}),e.jsx("p",{className:"font-mono text-[10px] uppercase tracking-widest text-on-surface-variant",children:c("sys.shell.subtitle")})]})]}),e.jsxs("nav",{className:"flex flex-col gap-0.5",children:[e.jsxs("button",{type:"button",className:V(s==="overview"),onClick:()=>n("overview"),children:[e.jsx(Ie,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.overview")})]}),o&&e.jsxs("button",{type:"button",className:V(s==="network"),onClick:()=>n("network"),children:[e.jsx(ze,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.network")})]}),e.jsxs("a",{href:"/debug/camera",className:p,onClick:M=>{M.preventDefault(),$("/debug/camera","ogscopeCameraConsole")},children:[e.jsx(Ee,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.camera")})]}),e.jsxs("a",{href:"/debug/analysis",className:p,onClick:M=>{M.preventDefault(),$("/debug/analysis","ogscopeAnalysisConsole")},children:[e.jsx(ge,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.analysis")})]}),e.jsxs("button",{type:"button",className:V(s==="sensors"),onClick:()=>n("sensors"),children:[e.jsx(me,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.sensors")})]}),e.jsxs("button",{type:"button",className:V(s==="power"),onClick:()=>n("power"),children:[e.jsx(Oe,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.power")})]}),e.jsxs("button",{type:"button",className:V(s==="hmi"),onClick:()=>n("hmi"),children:[e.jsx(Ue,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.hmi")})]}),e.jsxs("button",{type:"button",className:V(s==="config"),onClick:()=>n("config"),children:[e.jsx(qe,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.config")})]})]})]}),e.jsx("div",{className:"mt-auto hidden p-5 md:block",children:e.jsxs("div",{className:"rounded-xl border border-white/5 bg-surface-container-low p-3",children:[e.jsx("p",{className:"truncate text-xs font-semibold text-on-surface",children:c("sys.shell.workbench")}),e.jsxs("p",{className:"font-mono text-[10px] text-on-surface-variant",children:[c("sys.shell.node"),": OGSCOPE_PI_ZERO_2W"]})]})})]}),e.jsxs("div",{className:"flex min-h-0 min-w-0 flex-1 flex-col md:ml-64",children:[e.jsxs("header",{className:"sticky top-0 z-40 flex h-14 shrink-0 items-center justify-between border-b border-white/5 bg-neutral-950/80 px-4 backdrop-blur-md md:px-8",children:[e.jsx("div",{className:"flex min-w-0 items-center gap-3",children:e.jsx("span",{className:"hidden truncate border-b-2 border-primary pb-0.5 font-mono text-xs uppercase tracking-wider text-primary sm:inline",children:v[s]})}),e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-3 sm:gap-4",children:[e.jsxs("div",{className:"mr-2 flex gap-1 text-[10px]",children:[e.jsx("button",{type:"button",className:`rounded px-2 py-0.5 ${d==="zh"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>N("zh"),children:c("lang.zh")}),e.jsx("button",{type:"button",className:`rounded px-2 py-0.5 ${d==="en"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>N("en"),children:c("lang.en")})]}),e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-3 font-mono text-[10px] uppercase tracking-wider text-on-surface-variant sm:gap-4",children:[e.jsxs("span",{className:"flex items-center gap-1 text-primary",children:[e.jsx(Se,{className:"h-3.5 w-3.5"})," CPU ",f,"%"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx(me,{className:"h-3.5 w-3.5"})," MEM ",y,"%"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"text-xs",children:"°C"})," ",g]}),e.jsxs("span",{className:"flex items-center gap-1 text-secondary",children:[e.jsx(fe,{className:"h-3.5 w-3.5"})," ",S]})]})]})]}),e.jsx("main",{className:"og-scrollbar min-h-0 flex-1 overflow-auto p-4 md:p-6",children:t})]})]})}async function Ke(){return await K("/api/dev/system/hardware-plane/status",{cache:"no-store"})}async function Xe(s){return await K("/api/dev/system/hardware-plane/command",{method:"POST",body:JSON.stringify(s)})}async function Ge(s){var t;const o=new URLSearchParams;s!=null&&s.service&&o.set("service",s.service),o.set("since_seconds",String(s.sinceSeconds)),o.set("limit",String(s.limit)),(t=s==null?void 0:s.levels)!=null&&t.length&&o.set("levels",s.levels.join(","));const n=o.toString();return await K(`/api/dev/debug/logs/systemd${n?`?${n}`:""}`,{cache:"no-store"})}function Ze(s){const o=Math.max(0,parseInt(String(s??0),10)||0),n=Math.floor(o/86400),t=Math.floor(o%86400/3600),c=Math.floor(o%3600/60);return n>0?`${n}d ${t}h`:t>0?`${t}h ${c}m`:`${c}m`}function de(s,o=1){return s==null||Number.isNaN(Number(s))?"—":Number(s).toFixed(o)}function ye(s){return s==="ERROR"?"text-error":s==="WARN"?"text-amber-300":"text-primary"}function Je(s){if(!s)return"--:--:--";const o=new Date(s);return Number.isNaN(o.getTime())?"--:--:--":o.toLocaleTimeString()}function Ye(){const{t:n}=J(),{info:t,error:c}=pe(),[d,N]=a.useState(!1),[m,f]=a.useState(["INFO","WARN","ERROR"]),[y,g]=a.useState([]),[S,v]=a.useState(null),[p,$]=a.useState(!1),[M,R]=a.useState(!0),_=a.useRef(null),E=a.useRef(new Set),h=de(t==null?void 0:t.cpu_usage),T=de(t==null?void 0:t.memory_usage),O=de(t==null?void 0:t.temperature),j=(t==null?void 0:t.load_average_1m)!=null?String(t.load_average_1m):"—",C=(t==null?void 0:t.wifi_quality)!=null&&!Number.isNaN(Number(t.wifi_quality))?`${Number(t.wifi_quality).toFixed(0)}%`:"—",w=(t==null?void 0:t.wifi_signal_dbm)!=null&&!Number.isNaN(Number(t.wifi_signal_dbm))?`${Number(t.wifi_signal_dbm).toFixed(0)} dBm`:"—",P=async()=>{if(m.length===0){g([]);return}$(!0);try{v(null);const i=await Ge({service:"ogscope",sinceSeconds:1200,limit:240,levels:m});g(k=>{const L=new Set(k.map(I=>`${I.ts??""}::${I.level}::${I.source}::${I.message}`)),A=[...k];for(const I of i.items){const B=`${I.ts??""}::${I.level}::${I.source}::${I.message}`;L.has(B)||(L.add(B),A.push(I))}return A.length<=300?A:A.slice(A.length-300)})}catch(i){v(i instanceof Error?i.message:String(i))}finally{$(!1)}};a.useEffect(()=>{if(!d)return;P();const i=window.setInterval(()=>{document.hidden||P()},4e3);return()=>window.clearInterval(i)},[d,m.join(",")]),a.useEffect(()=>{const i=_.current;if(!i)return;const k=()=>{const L=i.scrollHeight-i.scrollTop-i.clientHeight;R(L<=24)};return k(),i.addEventListener("scroll",k),()=>i.removeEventListener("scroll",k)},[]),a.useEffect(()=>{if(!_.current||y.length===0)return;const k=new Set(y.map(A=>`${A.ts??""}::${A.level}::${A.source}::${A.message}`));let L=!1;k.forEach(A=>{E.current.has(A)||(L=!0)}),E.current=k,M&&L&&requestAnimationFrame(()=>{_.current&&(_.current.scrollTop=_.current.scrollHeight)})},[y,M]);const z=a.useMemo(()=>new Set(m),[m]);return e.jsxs("div",{className:"mx-auto max-w-7xl space-y-6",children:[e.jsxs("header",{className:"mb-1",children:[e.jsxs("div",{className:"flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.breadcrumb.console")}),e.jsx("span",{children:"/"}),e.jsx("span",{className:"text-primary",children:n("sys.overview.breadcrumb.module")})]}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:n("sys.overview.title")}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:n("sys.overview.subtitle")})]}),c&&e.jsx("div",{className:"rounded-lg border border-error/40 bg-error-container/20 px-3 py-2 text-sm text-on-error-container",children:c}),e.jsxs("section",{className:"grid grid-cols-12 gap-4",children:[e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.cpu")}),e.jsx(Se,{className:"h-4 w-4 text-primary"})]}),e.jsxs("div",{className:"text-3xl font-bold text-on-surface",children:[h,"%"]}),e.jsx("div",{className:"mt-3 h-1.5 w-full overflow-hidden rounded bg-surface-container-high",children:e.jsx("div",{className:"h-full bg-primary",style:{width:`${Math.min(Number(h)||0,100)}%`}})})]}),e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.mem")}),e.jsx(me,{className:"h-4 w-4 text-secondary"})]}),e.jsxs("div",{className:"text-3xl font-bold text-on-surface",children:[T,"%"]}),e.jsx("div",{className:"mt-3 h-1.5 w-full overflow-hidden rounded bg-surface-container-high",children:e.jsx("div",{className:"h-full bg-secondary",style:{width:`${Math.min(Number(T)||0,100)}%`}})})]}),e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.temp")}),e.jsx(He,{className:"h-4 w-4 text-primary"})]}),e.jsxs("div",{className:"text-3xl font-bold text-on-surface",children:[O,"°C"]}),e.jsx("div",{className:"mt-3 text-xs text-on-surface-variant",children:n("sys.overview.tempState")})]}),e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.wifi")}),e.jsx(fe,{className:"h-4 w-4 text-primary"})]}),e.jsx("div",{className:"text-3xl font-bold text-on-surface",children:C}),e.jsx("div",{className:"mt-3 text-xs text-on-surface-variant",children:w})]})]}),e.jsxs("section",{className:"grid grid-cols-12 gap-4",children:[e.jsxs("article",{className:"col-span-12 rounded-xl border border-white/5 bg-surface-container-low p-6 lg:col-span-8",children:[e.jsxs("div",{className:"mb-6 flex items-center justify-between",children:[e.jsxs("div",{children:[e.jsx("h3",{className:"font-headline text-lg font-bold",children:n("sys.overview.wifiSummary")}),e.jsxs("p",{className:"text-xs text-on-surface-variant",children:[n("sys.overview.iface"),": ",String((t==null?void 0:t.wifi_interface)??"wlan0")]})]}),e.jsx("span",{className:"rounded border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] uppercase tracking-widest text-primary",children:n("sys.overview.linkActive")})]}),e.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[e.jsxs("div",{className:"rounded-lg border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.signal")}),e.jsx("p",{className:"mt-1 font-mono text-2xl font-bold",children:w})]}),e.jsxs("div",{className:"rounded-lg border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.quality")}),e.jsx("p",{className:"mt-1 font-mono text-2xl font-bold",children:C})]})]})]}),e.jsxs("aside",{className:"col-span-12 space-y-4 lg:col-span-4",children:[e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.metric.uptime")}),e.jsx("p",{className:"mt-1 text-xl font-bold",children:Ze(t==null?void 0:t.uptime_seconds)})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.metric.load")}),e.jsx("p",{className:"mt-1 text-xl font-bold",children:j})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.metric.storage")}),e.jsx(Fe,{className:"h-4 w-4 text-on-surface-variant"})]}),e.jsx("p",{className:"mt-2 text-sm text-on-surface-variant",children:n("sys.overview.storageComingSoon")})]})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-lowest p-4",children:[e.jsxs("div",{className:"mb-3 flex flex-wrap items-center justify-between gap-3 border-b border-outline-variant/20 pb-2",children:[e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("span",{className:"text-[10px] uppercase tracking-widest text-primary",children:n("sys.logs.title")}),e.jsxs("span",{className:"text-[10px] text-on-surface-variant",children:[n("sys.logs.kernel"),": ",String((t==null?void 0:t.os)??"—")]})]}),e.jsxs("div",{className:"flex items-center gap-2 text-xs",children:[e.jsxs("label",{className:"inline-flex items-center gap-2 text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:d,onChange:i=>N(i.target.checked)}),n("sys.logs.liveToggle")]}),e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-on-surface-variant hover:border-primary hover:text-on-surface",onClick:()=>void P(),children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(he,{className:"h-3.5 w-3.5"})," ",n("sys.logs.refresh")]})})]})]}),e.jsxs("div",{className:"mb-2 flex flex-wrap items-center gap-2 text-xs",children:[["INFO","WARN","ERROR"].map(i=>e.jsxs("label",{className:"inline-flex items-center gap-1.5 text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:z.has(i),onChange:k=>{f(L=>k.target.checked?Array.from(new Set([...L,i])):L.filter(A=>A!==i))}}),e.jsx("span",{className:ye(i),children:i})]},i)),!d&&e.jsxs("span",{className:"inline-flex items-center gap-1 rounded border border-outline-variant/30 px-2 py-0.5 text-[11px] text-on-surface-variant",children:[e.jsx(Be,{className:"h-3.5 w-3.5"})," ",n("sys.logs.liveOffHint")]})]}),S&&e.jsx("div",{className:"mb-2 rounded border border-error/40 bg-error-container/20 px-2 py-1 text-xs text-on-error-container",children:S}),e.jsxs("div",{ref:_,className:"og-scrollbar max-h-72 space-y-1 overflow-auto font-mono text-[11px] text-on-surface-variant",children:[p&&y.length===0&&e.jsx("div",{children:n("sys.logs.loading")}),!p&&y.length===0&&e.jsx("div",{children:n("sys.logs.empty")}),y.map((i,k)=>e.jsxs("div",{className:"flex items-start gap-2",children:[e.jsxs("span",{className:"shrink-0 text-primary",children:["[",Je(i.ts),"]"]}),e.jsx("span",{className:`shrink-0 ${ye(i.level)}`,children:i.level}),e.jsx("span",{className:"shrink-0 text-on-surface/80",children:i.source}),e.jsx("span",{className:"min-w-0 break-words text-on-surface",children:i.message})]},`${i.ts||"ts"}-${k}`))]})]})]})}function ie(){return typeof window.OGSCOPE_HTTP_PORT=="number"?window.OGSCOPE_HTTP_PORT:8e3}function Ve(s){const o=Math.max(0,parseInt(String(s??0),10)||0),n=Math.floor(o/86400),t=Math.floor(o%86400/3600),c=Math.floor(o%3600/60);return n>0?`${n}天 ${t}小时`:t>0?`${t}小时 ${c}分`:`${c}分`}function Qe(){const{info:s,error:o}=pe(),[n,t]=a.useState("加载中..."),[c,d]=a.useState(""),[N,m]=a.useState(""),[f,y]=a.useState([]),[g,S]=a.useState(!1),[v,p]=a.useState([]),[$,M]=a.useState(!1),[R,_]=a.useState(""),[E,h]=a.useState(""),[T,O]=a.useState(!1),[j,C]=a.useState(""),[w,P]=a.useState(`http://192.168.4.1:${ie()}`),[z,i]=a.useState("OGScope_xxxx"),[k,L]=a.useState(null),[A,I]=a.useState("—"),B=a.useCallback(r=>{const x=r.mode||"unknown",b=r.active_connection||"-",H=r.wireless_interface||"wlan0",D=r.ap_ipv4||"-",Z=r.configured?"是":"否",Y=r.message?`,消息: ${r.message}`:"";r.ap_url_hint&&P(r.ap_url_hint),r.ap_ssid&&i(r.ap_ssid);const te=ie();if(r.mdns_hostname_hint){const oe=`http://${r.mdns_hostname_hint}:${te}/debug`;L(oe),I(oe)}else if(r.device_id_suffix){const oe=`http://${`ogscope-${r.device_id_suffix}.local`}:${te}/debug`;L(oe),I(oe)}else L(null),I("未提供");t(`模式: ${x} | 活动连接: ${b} | 接口: ${H} | AP地址: ${D} | 已配置: ${Z}${Y}`)},[]),X=a.useCallback(async()=>{const r=await K("/api/network/wifi",{cache:"no-store"});B(r)},[B]);a.useEffect(()=>{X().catch(r=>t(`获取状态失败: ${r.message}`))},[X]);const Q=async r=>{t(`正在切换到 ${r.toUpperCase()}...`);const x=await K("/api/network/wifi",{method:"POST",body:JSON.stringify({mode:r})});B(x)},ae=async()=>{S(!0),m("扫描中..."),y([]);try{const r=await K("/api/network/wifi/scan",{cache:"no-store"}),x=r.networks||[],b=r.hint?` ${r.hint}`:"";y(x),m(`扫描到 ${x.length} 个网络${b}`.trim())}catch(r){m(`扫描失败: ${r instanceof Error?r.message:String(r)}`)}finally{S(!1)}},ee=async r=>{const x=window.prompt(`输入密码: ${r}`,"");if(x!==null){d(""),t("正在连接..."),O(!0);try{const b=await K("/api/network/wifi/sta/connect",{method:"POST",body:JSON.stringify({ssid:r,password:x||null})});B(b);const H=b.mode||"unknown";d(H==="sta"?`连接成功,当前连接: ${b.active_connection||"—"}`:`连接请求已提交,当前模式: ${H},连接: ${b.active_connection||"—"}`),window.setTimeout(()=>void X().catch(()=>{}),2500)}catch(b){t(`连接失败: ${b instanceof Error?b.message:String(b)}`),d(`错误详情: ${b instanceof Error?b.message:String(b)}`)}finally{O(!1)}}},se=async()=>{const r=R.trim();if(!r){window.alert("请输入 SSID");return}d(""),t("正在连接..."),O(!0);try{const x=await K("/api/network/wifi/sta/connect",{method:"POST",body:JSON.stringify({ssid:r,password:E||null})});B(x);const b=x.mode||"unknown";d(b==="sta"?`连接成功,当前连接: ${x.active_connection||"—"}`:`连接请求已提交,当前模式: ${b},连接: ${x.active_connection||"—"}`),window.setTimeout(()=>void X().catch(()=>{}),2500)}catch(x){t(`连接失败: ${x instanceof Error?x.message:String(x)}`),d(`错误详情: ${x instanceof Error?x.message:String(x)}`)}finally{O(!1)}},ne=async()=>{M(!0);try{const r=await K("/api/network/wifi/profiles",{cache:"no-store"});p(r.profiles||[])}catch(r){p([]),window.alert(r instanceof Error?r.message:String(r))}finally{M(!1)}},u=async r=>{try{await K("/api/network/wifi/profile/activate",{method:"POST",body:JSON.stringify({connection_name:r})}),t("已发送激活请求")}catch(x){window.alert(x instanceof Error?x.message:String(x))}};async function F(r,x){const b=`http://${r}:${x}/health`;try{const H=new AbortController,D=window.setTimeout(()=>H.abort(),700),Z=await fetch(b,{signal:H.signal,mode:"cors"});if(window.clearTimeout(D),!Z.ok)return null;const Y=await Z.json();if(Y&&Y.status==="healthy")return`http://${r}:${x}`}catch{}return null}const G=async()=>{const r=ie();C("扫描中...");const x=["192.168.0","192.168.1","192.168.31","10.0.0"];for(const b of x){const H=[];for(let D=1;D<255;D++)H.push(`${b}.${D}`);for(let D=0;DF(ce,r)))).find(Boolean);if(te){C(`已找到设备: ${te}`),window.location.href=`${te}/debug`;return}}}C("未找到设备")},l=a.useMemo(()=>{if(!s)return[];const r=s.wifi_quality!=null&&!Number.isNaN(Number(s.wifi_quality))?`${Number(s.wifi_quality).toFixed(1)}%`:"—",x=s.wifi_signal_dbm!=null&&!Number.isNaN(Number(s.wifi_signal_dbm))?`${Number(s.wifi_signal_dbm).toFixed(0)} dBm (${String(s.wifi_interface??"?")})`:"—";return[["平台",String(s.platform??"—")],["系统",String(s.os??"—")],["CPU 占用",`${Number(s.cpu_usage??0).toFixed(1)}%`],["内存占用",`${Number(s.memory_usage??0).toFixed(1)}%`],["CPU 温度",`${Number(s.temperature??0).toFixed(1)} °C`],["运行时长",Ve(s.uptime_seconds)],["1 分钟负载",String(s.load_average_1m??"—")],["WiFi 质量",r],["WiFi 信号",x]]},[s]);return e.jsxs("div",{className:"mx-auto max-w-7xl space-y-6",children:[e.jsxs("header",{children:[e.jsxs("div",{className:"flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:[e.jsx("span",{children:"Console"}),e.jsx("span",{children:"/"}),e.jsx("span",{className:"text-primary",children:"System_Network_Debug"})]}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:"NETWORK TERMINAL"}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:"WiFi 管理、模式切换、发现与恢复工具"})]}),o&&e.jsx("div",{className:"rounded-lg border border-error/40 bg-error-container/20 px-3 py-2 text-sm text-on-error-container",children:o}),e.jsxs("section",{className:"grid grid-cols-12 gap-6",children:[e.jsxs("div",{className:"col-span-12 space-y-6 lg:col-span-8",children:[e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container",children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-outline-variant/20 bg-surface-container-high px-4 py-3",children:[e.jsxs("div",{className:"flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-primary",children:[e.jsx(fe,{className:"h-4 w-4"}),"WiFi Control"]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-xs hover:border-primary",onClick:()=>void Q("sta").catch(r=>t(String(r))),children:"STA"}),e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-xs hover:border-primary",onClick:()=>void Q("ap").catch(r=>t(String(r))),children:"AP"}),e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 p-1.5 hover:border-primary",onClick:()=>void X().catch(r=>t(String(r))),children:e.jsx(he,{className:"h-3.5 w-3.5"})})]})]}),e.jsxs("div",{className:"space-y-3 p-4",children:[e.jsx("p",{className:"rounded border border-outline-variant/20 bg-surface-container-low p-3 font-mono text-xs text-on-surface",children:n}),e.jsxs("div",{className:"grid gap-2 text-xs text-on-surface-variant md:grid-cols-2",children:[e.jsxs("p",{children:["AP 地址:",e.jsx("span",{className:"font-mono text-on-surface",children:w})]}),e.jsxs("p",{children:["mDNS:"," ",k?e.jsx("a",{className:"font-mono text-primary underline",href:k,children:A}):e.jsx("span",{className:"font-mono text-on-surface",children:A})]})]})]})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between",children:[e.jsx("h3",{className:"text-sm font-semibold uppercase tracking-wider",children:"Scan Results"}),e.jsx("button",{type:"button",disabled:g,className:"rounded bg-primary-container px-3 py-1.5 text-xs font-medium text-on-primary-container disabled:opacity-50",onClick:()=>void ae(),children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(_e,{className:"h-3.5 w-3.5"})," 扫描 WiFi"]})})]}),e.jsx("p",{className:"mb-3 text-xs text-on-surface-variant",children:N||"由设备端 NetworkManager 执行扫描"}),e.jsx("div",{className:"overflow-x-auto",children:e.jsxs("table",{className:"w-full text-left text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"border-b border-outline-variant/30 text-xs uppercase tracking-wider text-on-surface-variant",children:[e.jsx("th",{className:"p-2",children:"SSID"}),e.jsx("th",{className:"p-2",children:"信号"}),e.jsx("th",{className:"p-2",children:"安全"}),e.jsx("th",{className:"p-2 text-right",children:"操作"})]})}),e.jsx("tbody",{children:f.map(r=>e.jsxs("tr",{className:"border-b border-outline-variant/10",children:[e.jsx("td",{className:"p-2 font-mono",children:r.ssid}),e.jsx("td",{className:"p-2",children:r.signal??"—"}),e.jsx("td",{className:"p-2",children:r.security??"—"}),e.jsx("td",{className:"p-2 text-right",children:e.jsx("button",{type:"button",disabled:T,className:"rounded border border-primary/40 px-2 py-1 text-xs text-primary disabled:opacity-50",onClick:()=>void ee(r.ssid),children:"Connect"})})]},r.ssid))})]})})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:"Known Networks"}),e.jsx("button",{type:"button",disabled:$,className:"rounded border border-outline-variant/40 px-3 py-1.5 text-xs disabled:opacity-50",onClick:()=>void ne(),children:"刷新已保存网络"}),e.jsxs("table",{className:"mt-3 w-full text-left text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"border-b border-outline-variant/30 text-xs uppercase tracking-wider text-on-surface-variant",children:[e.jsx("th",{className:"p-2",children:"连接名"}),e.jsx("th",{className:"p-2",children:"SSID"}),e.jsx("th",{className:"p-2",children:"自动连接"}),e.jsx("th",{className:"p-2 text-right",children:"操作"})]})}),e.jsx("tbody",{children:v.map(r=>e.jsxs("tr",{className:"border-b border-outline-variant/10",children:[e.jsx("td",{className:"p-2",children:r.connection_name}),e.jsx("td",{className:"p-2",children:r.ssid}),e.jsx("td",{className:"p-2",children:r.autoconnect?"是":"否"}),e.jsx("td",{className:"p-2 text-right",children:e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-xs hover:border-primary",onClick:()=>void u(r.connection_name),children:"Activate"})})]},r.connection_name))})]})]})]}),e.jsxs("aside",{className:"col-span-12 space-y-6 lg:col-span-4",children:[e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:"System Monitor"}),e.jsx("div",{className:"grid gap-2",children:l.map(([r,x])=>e.jsxs("div",{className:"flex justify-between border-b border-outline-variant/10 pb-1 text-xs",children:[e.jsx("span",{className:"text-on-surface-variant",children:r}),e.jsx("span",{className:"font-mono text-on-surface",children:x})]},r))})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:"Manual Connect"}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs("label",{className:"block text-xs text-on-surface-variant",children:["SSID",e.jsx("input",{className:"mt-1 w-full rounded border border-outline-variant/40 bg-surface-container-low px-2 py-1.5 text-sm",value:R,onChange:r=>_(r.target.value)})]}),e.jsxs("label",{className:"block text-xs text-on-surface-variant",children:["Password",e.jsx("input",{type:"password",className:"mt-1 w-full rounded border border-outline-variant/40 bg-surface-container-low px-2 py-1.5 text-sm",value:E,onChange:r=>h(r.target.value)})]}),e.jsx("button",{type:"button",disabled:T,className:"w-full rounded bg-primary-container px-4 py-2 text-sm font-medium text-on-primary-container disabled:opacity-50",onClick:()=>void se(),children:"连接并切换 STA"})]}),e.jsx("p",{className:"mt-2 text-xs text-on-surface-variant",children:c})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:"WiFi 引导"}),e.jsxs("ol",{className:"list-decimal space-y-1 pl-4 text-xs text-on-surface-variant",children:[e.jsxs("li",{children:["连接热点 ",e.jsx("strong",{children:z}),",密码 ",e.jsx("code",{children:"ogscopeadmin"})]}),e.jsxs("li",{children:["浏览器打开 ",e.jsxs("span",{className:"font-mono",children:["http://192.168.4.1:",ie()]})]}),e.jsx("li",{children:"扫描 WiFi 或手动填写 SSID 连接"})]})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:"Find Device"}),e.jsx("p",{className:"mb-3 text-xs text-on-surface-variant",children:"扫描常见网段并探测 /health"}),e.jsx("button",{type:"button",className:"w-full rounded border border-outline-variant/40 px-3 py-2 text-sm hover:border-primary",onClick:()=>void G(),children:"扫描局域网"}),e.jsx("p",{className:"mt-2 text-xs text-on-surface-variant",children:j})]})]})]})]})}function es(s){var t,c;const o=(c=(t=s==null?void 0:s.data)==null?void 0:t.services)==null?void 0:c.hmi;if(!o||typeof o!="object")return null;const n=o.display;return!n||typeof n!="object"?null:n}function ss(s){try{return JSON.stringify(s,null,2)}catch{return String(s)}}function ts(){var j,C;const{t:s}=J(),[o,n]=a.useState(null),[t,c]=a.useState(null),[d,N]=a.useState(!1),[m,f]=a.useState(null),[y,g]=a.useState(null),[S,v]=a.useState(40),[p,$]=a.useState(80),[M,R]=a.useState(200),_=a.useCallback(async()=>{try{c(null);const w=await Ke();n(w)}catch(w){n(null),c(w instanceof Error?w.message:String(w))}},[]);a.useEffect(()=>{_()},[_]);const E=a.useCallback(async(w,P={})=>{var z;N(!0),g(null);try{const i=await Xe({target:"hmi",action:w,payload:P,timeout_ms:8e3});if(f(i),!i.success){const A=((z=i.error)==null?void 0:z.message)??"RPC failed";g(A);return}const k=i.data,L=k==null?void 0:k.result;L&&L.accepted===!1&&L.message?g(L.message):g(null),await _()}catch(i){f(null),g(i instanceof Error?i.message:String(i))}finally{N(!1)}},[_]),h=es(o),T=(C=(j=o==null?void 0:o.data)==null?void 0:j.services)==null?void 0:C.hmi,O=typeof(T==null?void 0:T.screen_on)=="boolean"?T.screen_on:void 0;return e.jsxs("div",{className:"mx-auto max-w-6xl space-y-6",children:[e.jsxs("header",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:s("sys.placeholder.breadcrumb")}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:s("sys.hmi.title")}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:s("sys.hmi.desc")})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:s("sys.hmi.status.section")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 bg-surface-container px-3 py-1.5 text-xs font-medium text-on-surface hover:bg-surface-container-high",onClick:()=>void _(),disabled:d,children:s("sys.hmi.status.refresh")})]}),t?e.jsx("p",{className:"mt-2 text-sm text-error",children:t}):e.jsxs("dl",{className:"mt-3 grid gap-2 font-mono text-[11px] text-on-surface-variant sm:grid-cols-2",children:[e.jsxs("div",{children:[e.jsx("dt",{className:"text-on-surface-variant",children:s("sys.hmi.status.displayEnabled")}),e.jsx("dd",{className:"text-on-surface",children:(h==null?void 0:h.enabled)===!0?"true":"false"})]}),e.jsxs("div",{children:[e.jsx("dt",{children:s("sys.hmi.status.spidev")}),e.jsx("dd",{className:h!=null&&h.spidev_present?"text-primary":"text-error",children:h!=null&&h.spidev_present?s("sys.hmi.status.yes"):s("sys.hmi.status.no")})]}),e.jsxs("div",{children:[e.jsx("dt",{children:s("sys.hmi.status.resolution")}),e.jsxs("dd",{children:[(h==null?void 0:h.width)??"—"," × ",(h==null?void 0:h.height)??"—"," · DC GPIO ",(h==null?void 0:h.dc_pin)??"—"]})]}),e.jsxs("div",{children:[e.jsx("dt",{children:s("sys.hmi.status.driver")}),e.jsx("dd",{children:h!=null&&h.driver_open?s("sys.hmi.status.open"):s("sys.hmi.status.closed")})]}),e.jsxs("div",{children:[e.jsx("dt",{children:s("sys.hmi.status.screenOutput")}),e.jsx("dd",{children:O===void 0?"—":s(O?"sys.hmi.status.on":"sys.hmi.status.off")})]}),e.jsxs("div",{className:"sm:col-span-2",children:[e.jsx("dt",{children:s("sys.hmi.status.lastPattern")}),e.jsx("dd",{children:(h==null?void 0:h.last_pattern)??"—"})]}),h!=null&&h.last_error?e.jsxs("div",{className:"sm:col-span-2",children:[e.jsx("dt",{children:s("sys.hmi.status.lastError")}),e.jsx("dd",{className:"text-error",children:h.last_error})]}):null]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:s("sys.hmi.actions.section")}),e.jsx("p",{className:"mt-1 text-[11px] text-on-surface-variant",children:s("sys.hmi.actions.hint")}),e.jsxs("div",{className:"mt-4 flex flex-wrap gap-2",children:[e.jsx("button",{type:"button",className:"rounded-lg bg-primary px-3 py-2 text-xs font-semibold text-on-primary hover:opacity-90 disabled:opacity-50",disabled:d,onClick:()=>void E("display.test_pattern",{pattern:"smoke"}),children:s("sys.hmi.actions.smoke")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 bg-surface-container px-3 py-2 text-xs font-medium text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("display.test_pattern",{pattern:"colorbars"}),children:s("sys.hmi.actions.colorbars")})]}),e.jsxs("div",{className:"mt-6 flex flex-wrap items-end gap-3",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:["R",e.jsx("input",{type:"number",min:0,max:255,className:"w-20 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:S,onChange:w=>v(Number(w.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:["G",e.jsx("input",{type:"number",min:0,max:255,className:"w-20 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:p,onChange:w=>$(Number(w.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:["B",e.jsx("input",{type:"number",min:0,max:255,className:"w-20 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:M,onChange:w=>R(Number(w.target.value))})]}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 bg-surface-container px-3 py-2 text-xs font-medium text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("display.test_pattern",{pattern:"fill",r:S,g:p,b:M}),children:s("sys.hmi.actions.fill")})]}),e.jsxs("div",{className:"mt-6 flex flex-wrap gap-2 border-t border-outline-variant/20 pt-4",children:[e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 px-3 py-2 text-xs text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("screen.set",{on:!0}),children:s("sys.hmi.actions.screenOn")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 px-3 py-2 text-xs text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("screen.set",{on:!1}),children:s("sys.hmi.actions.screenOff")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 px-3 py-2 text-xs text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("display.release"),children:s("sys.hmi.actions.release")})]}),y?e.jsx("p",{className:"mt-3 text-sm text-error",children:y}):null,e.jsxs("details",{className:"mt-4",children:[e.jsx("summary",{className:"cursor-pointer text-[11px] text-on-surface-variant",children:s("sys.hmi.rawJson")}),e.jsx("pre",{className:"mt-2 max-h-64 overflow-auto rounded border border-outline-variant/30 bg-surface-container p-2 font-mono text-[10px] text-on-surface",children:m?ss(m):"—"})]})]})]})}const rs={sensors:{titleKey:"sys.placeholder.sensors.title",descKey:"sys.placeholder.sensors.desc",blocks:["sys.placeholder.sensors.block1","sys.placeholder.sensors.block2","sys.placeholder.sensors.block3"]},hmi:{titleKey:"sys.placeholder.hmi.title",descKey:"sys.placeholder.hmi.desc",blocks:["sys.placeholder.hmi.block1","sys.placeholder.hmi.block2","sys.placeholder.hmi.block3"]},power:{titleKey:"sys.placeholder.power.title",descKey:"sys.placeholder.power.desc",blocks:["sys.placeholder.power.block1","sys.placeholder.power.block2","sys.placeholder.power.block3"]}};function ve({scope:s}){const{t:o}=J(),n=rs[s];return e.jsxs("div",{className:"mx-auto max-w-6xl space-y-6",children:[e.jsxs("header",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:o("sys.placeholder.breadcrumb")}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:o(n.titleKey)}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:o(n.descKey)})]}),e.jsx("section",{className:"grid grid-cols-12 gap-4",children:n.blocks.map(t=>e.jsxs("article",{className:"col-span-12 rounded-xl border border-dashed border-outline-variant/40 bg-surface-container/60 p-5 md:col-span-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-primary",children:o("sys.placeholder.block")}),e.jsx("h3",{className:"mt-2 text-lg font-semibold",children:o(t)}),e.jsx("p",{className:"mt-2 text-sm text-on-surface-variant",children:o("sys.placeholder.desc")})]},t))}),e.jsx("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:e.jsx("p",{className:"font-mono text-xs text-on-surface-variant",children:o("sys.placeholder.status")})})]})}const W=62,re=4;function as(s){const{rollDeg:o,pitchDeg:n,yawRateDps:t}=s,{t:c}=J(),d=`rotateX(${-n}deg) rotateY(${o}deg)`;return e.jsxs("div",{className:"mt-5 rounded-xl border border-teal-500/25 bg-black/25 p-4 ring-1 ring-teal-500/10",children:[e.jsx("p",{className:"text-center text-[11px] font-semibold text-teal-200/95",children:c("sys.sensors.gyro.att3dTitle")}),e.jsx("p",{className:"mx-auto mt-1 max-w-xl text-center text-[10px] leading-relaxed text-on-surface-variant",children:c("sys.sensors.gyro.att3dDesc")}),e.jsxs("div",{className:"mt-4 flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between xl:gap-8",children:[e.jsxs("div",{className:"flex min-w-0 flex-1 flex-col items-center gap-4",children:[e.jsx("div",{className:"flex shrink-0 items-center justify-center py-2",style:{perspective:"820px"},children:e.jsxs("div",{className:"relative h-[188px] w-[220px]",style:{transformStyle:"preserve-3d",transform:d},children:[e.jsxs("div",{className:"absolute left-[10px] top-[18px] h-[152px] w-[200px] rounded-2xl border-2 border-teal-400/75 bg-gradient-to-br from-slate-600/90 via-slate-800/95 to-slate-950 shadow-[0_20px_50px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.08)] transition-transform duration-150 ease-out",style:{transformStyle:"preserve-3d"},children:[e.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-2 flex justify-center",children:e.jsx("span",{className:"rounded bg-black/35 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.2em] text-teal-100",children:"TOP"})}),e.jsx("div",{className:"absolute bottom-2 left-2 font-mono text-[9px] text-slate-400",children:"MPU-6050"})]}),e.jsxs("div",{className:"pointer-events-none absolute left-[110px] top-[94px] h-0 w-0",style:{transformStyle:"preserve-3d"},children:[e.jsx("div",{className:"absolute rounded-full bg-white/90 shadow-[0_0_6px_rgba(255,255,255,0.6)]",style:{width:7,height:7,left:-3.5,top:-3.5,transform:"translateZ(0.5px)"}}),e.jsx("div",{className:"absolute bg-amber-400 shadow-md ring-1 ring-amber-200/35",style:{width:W,height:re,left:0,top:-re/2,transformOrigin:"0 50%"}}),e.jsx("span",{className:"absolute whitespace-nowrap font-mono text-[10px] font-bold text-amber-200",style:{left:W+4,top:-8},children:c("sys.sensors.gyro.axisXLabel")}),e.jsx("div",{className:"absolute bg-sky-400 shadow-md ring-1 ring-sky-200/30",style:{width:W,height:re,left:0,top:-re/2,transformOrigin:"0 50%",transform:"rotateZ(90deg)"}}),e.jsx("span",{className:"absolute whitespace-nowrap font-mono text-[10px] font-bold text-sky-200",style:{left:-6,top:-W-16},children:c("sys.sensors.gyro.axisYLabel")}),e.jsx("div",{className:"absolute bg-violet-400 shadow-md ring-1 ring-violet-200/35",style:{width:W,height:re,left:0,top:-re/2,transformOrigin:"0 50%",transform:"rotateY(-90deg)"}}),e.jsx("span",{className:"absolute whitespace-nowrap font-mono text-[10px] font-bold text-violet-200",style:{left:W*.35,top:-W*.45,transform:"translateZ(28px)"},children:c("sys.sensors.gyro.axisZLabel")}),e.jsx("div",{className:"absolute bg-amber-900/55",style:{width:W*.45,height:2,left:-W*.45,top:-1,transformOrigin:"100% 50%"}}),e.jsx("div",{className:"absolute bg-sky-900/50",style:{width:W*.45,height:2,left:0,top:-1,transformOrigin:"0 50%",transform:`rotateZ(90deg) translateX(${-W*.45}px)`}}),e.jsx("div",{className:"absolute bg-violet-900/45",style:{width:W*.4,height:2,left:0,top:-1,transformOrigin:"0 50%",transform:`rotateY(-90deg) translateX(${-W*.4}px)`}})]})]})}),e.jsx("p",{className:"max-w-md text-center text-[10px] leading-snug text-on-surface-variant",children:c("sys.sensors.gyro.att3dBodyAxes")})]}),e.jsxs("div",{className:"flex w-full max-w-[220px] shrink-0 flex-col items-center gap-2 self-center xl:self-start",children:[e.jsx("p",{className:"text-center text-[10px] font-medium text-on-surface-variant",children:c("sys.sensors.gyro.att3dRefTitle")}),e.jsx("div",{className:"flex items-center justify-center rounded-lg border border-outline-variant/30 bg-slate-950/50 px-4 py-5 ring-1 ring-white/5",children:e.jsx("div",{style:{perspective:"280px"},children:e.jsx("div",{className:"relative h-[100px] w-[100px]",style:{transformStyle:"preserve-3d",transform:"rotateX(58deg) rotateZ(-42deg)"},children:e.jsxs("div",{className:"absolute left-1/2 top-1/2 h-0 w-0",style:{transformStyle:"preserve-3d"},children:[e.jsx("div",{className:"absolute bg-amber-400/95",style:{width:44,height:3,left:0,top:-1.5,transformOrigin:"0 50%"}}),e.jsx("div",{className:"absolute bg-sky-400/95",style:{width:44,height:3,left:0,top:-1.5,transformOrigin:"0 50%",transform:"rotateZ(90deg)"}}),e.jsx("div",{className:"absolute bg-violet-400/95",style:{width:44,height:3,left:0,top:-1.5,transformOrigin:"0 50%",transform:"rotateY(-90deg)"}}),e.jsx("div",{className:"absolute rounded-full bg-white/80",style:{width:5,height:5,left:-2.5,top:-2.5}})]})})})}),e.jsx("p",{className:"text-center text-[9px] leading-relaxed text-on-surface-variant/85",children:c("sys.sensors.gyro.att3dRefDesc")})]}),e.jsxs("div",{className:"grid w-full min-w-[200px] max-w-md grid-cols-3 gap-3 font-mono text-[11px] lg:max-w-lg xl:max-w-[340px]",children:[e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/80 px-2 py-2 text-center",children:[e.jsx("p",{className:"text-[9px] uppercase tracking-wider text-on-surface-variant",children:c("sys.sensors.gyro.roll")}),e.jsxs("p",{className:"mt-1 text-lg font-bold tabular-nums text-teal-200",children:[o.toFixed(1),"°"]})]}),e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/80 px-2 py-2 text-center",children:[e.jsx("p",{className:"text-[9px] uppercase tracking-wider text-on-surface-variant",children:c("sys.sensors.gyro.pitch")}),e.jsxs("p",{className:"mt-1 text-lg font-bold tabular-nums text-teal-200",children:[n.toFixed(1),"°"]})]}),e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/80 px-2 py-2 text-center",children:[e.jsx("p",{className:"text-[9px] uppercase tracking-wider text-on-surface-variant",children:c("sys.sensors.gyro.yawRate")}),e.jsx("p",{className:"mt-1 text-lg font-bold tabular-nums text-amber-200/95",children:t.toFixed(2)}),e.jsx("p",{className:"text-[9px] text-on-surface-variant",children:"°/s"})]})]})]})]})}const xe=250;function ue(s){const{label:o,value:n,maxAbs:t,unit:c}=s,d=Math.max(-1,Math.min(1,n/t)),N=d>=0?50:50+d*50,m=Math.abs(d)*50;return e.jsxs("div",{className:"space-y-1",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-2",children:[e.jsx("span",{className:"text-[11px] font-medium text-on-surface",children:o}),e.jsxs("span",{className:"font-mono text-xs tabular-nums text-sky-100",children:[n.toFixed(2),e.jsxs("span",{className:"text-on-surface-variant",children:[" ",c]})]})]}),e.jsxs("div",{className:"relative h-5 w-full overflow-hidden rounded-md bg-slate-900/80 ring-1 ring-slate-600/50",children:[e.jsx("div",{className:"absolute left-1/2 top-0 z-10 h-full w-px -translate-x-px bg-slate-500/90"}),e.jsx("div",{className:"absolute top-1 h-3 rounded-sm bg-gradient-to-r from-emerald-600 to-sky-500 shadow-sm",style:{left:`${N}%`,width:`${Math.max(m,d===0?0:.8)}%`}})]})]})}function ns(s){const{bus:o,addr:n}=s,{t}=J(),[c,d]=a.useState(!1),[N,m]=a.useState(null),[f,y]=a.useState(null),[g,S]=a.useState(null),[v,p]=a.useState(null),[$,M]=a.useState(null),[R,_]=a.useState(null),[E,h]=a.useState(!1),T=a.useRef(null),O=a.useCallback(async()=>{var C;d(!0),m(null);try{const w=new URLSearchParams({bus:String(o),addr:String(n)}),P=await U(`/api/debug/sensors/mpu6050/imu-sample?${w.toString()}`);if(!P.success){y(null),S(null),p(null),M(null),_(null),m(((C=P.sample)==null?void 0:C.error)||P.error||t("sys.sensors.gyro.errUnknown"));return}y(P.gyro_dps??null),S(P.gyro_raw??null),p(P.tilt_deg??null),M(P.yaw_rate_dps??null),_(P.accel_g??null)}catch(w){y(null),S(null),p(null),M(null),_(null),m(w instanceof Error?w.message:String(w))}finally{d(!1)}},[n,o,t]);a.useEffect(()=>{if(!E){T.current&&(clearInterval(T.current),T.current=null);return}return T.current=setInterval(()=>void O(),500),()=>{T.current&&clearInterval(T.current)}},[E,O]);const j=v!=null&&$!=null&&Number.isFinite(v.roll)&&Number.isFinite(v.pitch);return e.jsxs("div",{className:"mt-6 rounded-xl border border-emerald-600/35 bg-gradient-to-br from-slate-900/40 to-surface-container/70 p-4",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("p",{className:"text-sm font-semibold text-on-surface",children:t("sys.sensors.gyro.title")}),e.jsx("p",{className:"mt-1 max-w-xl text-[11px] leading-snug text-on-surface-variant",children:t("sys.sensors.gyro.subtitle")})]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsxs("label",{className:"flex cursor-pointer items-center gap-1.5 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:E,onChange:C=>h(C.target.checked)}),t("sys.sensors.gyro.live")]}),e.jsx("button",{type:"button",disabled:c,className:"rounded-lg bg-emerald-800/90 px-3 py-1.5 text-xs font-medium text-emerald-50 hover:bg-emerald-700 disabled:opacity-50",onClick:()=>void O(),children:t(c?"sys.sensors.gyro.loading":"sys.sensors.gyro.btn")})]})]}),j&&e.jsx(as,{rollDeg:v.roll,pitchDeg:v.pitch,yawRateDps:$}),f&&e.jsxs("div",{className:"mt-5 grid gap-4 md:grid-cols-3",children:[e.jsxs("div",{className:"rounded-lg border border-emerald-500/20 bg-black/20 px-3 py-3 text-center md:col-span-1",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"ωx"}),e.jsx("p",{className:"font-mono text-3xl font-bold tabular-nums text-emerald-200",children:f.x.toFixed(2)}),e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"°/s"})]}),e.jsxs("div",{className:"rounded-lg border border-emerald-500/20 bg-black/20 px-3 py-3 text-center md:col-span-1",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"ωy"}),e.jsx("p",{className:"font-mono text-3xl font-bold tabular-nums text-emerald-200",children:f.y.toFixed(2)}),e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"°/s"})]}),e.jsxs("div",{className:"rounded-lg border border-emerald-500/20 bg-black/20 px-3 py-3 text-center md:col-span-1",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"ωz"}),e.jsx("p",{className:"font-mono text-3xl font-bold tabular-nums text-emerald-200",children:f.z.toFixed(2)}),e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"°/s"})]})]}),f&&e.jsxs("div",{className:"mt-5 space-y-4",children:[e.jsx("p",{className:"text-[10px] font-medium uppercase tracking-wider text-on-surface-variant",children:t("sys.sensors.gyro.barsTitle")}),e.jsx(ue,{label:"X",value:f.x,maxAbs:xe,unit:"°/s"}),e.jsx(ue,{label:"Y",value:f.y,maxAbs:xe,unit:"°/s"}),e.jsx(ue,{label:"Z",value:f.z,maxAbs:xe,unit:"°/s"})]}),R&&e.jsxs("p",{className:"mt-3 text-center font-mono text-[10px] text-on-surface-variant",children:["g — X:",R.x.toFixed(3)," Y:",R.y.toFixed(3)," Z:",R.z.toFixed(3)]}),g&&e.jsxs("div",{className:"mt-4 rounded border border-outline-variant/25 bg-surface-container/50 px-3 py-2",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:t("sys.sensors.gyro.rawBlock")}),e.jsxs("p",{className:"mt-1 font-mono text-[11px] tabular-nums text-on-surface",children:["raw X=",g.x," · Y=",g.y," · Z=",g.z]})]}),!f&&!N&&!c&&e.jsx("p",{className:"mt-4 text-[11px] text-on-surface-variant",children:t("sys.sensors.gyro.hint")}),N&&e.jsx("p",{className:"mt-3 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 font-mono text-[11px] text-amber-100",children:N})]})}const be=3.4,Ce=7,os=3;function ls(s){const o=(s%360+360)%360;return o<1||o>359?"cardN":Math.abs(o-90)<1?"cardE":Math.abs(o-180)<1?"cardS":Math.abs(o-270)<1?"cardW":null}function is(){const s=[],o=Ce*360;for(let n=0;n<=o;n+=5){const t=n%360,c=n%30===0,d=!c&&n%10===0,N=ls(t),m=!N&&c&&t%90!==0?t:void 0;s.push({x:n*be,deg:n,h:c?"maj":d?"mid":"min",degLabel:m,cardKey:N??void 0})}return s}const cs=is(),je=Ce*360*be;function ds(s,o){const n=(s%360+360)%360;let c=(o%360+360)%360-n;return c=(c+180)%360-180,s+c}function xs(s){const{bus:o,addr:n}=s,{t}=J(),[c,d]=a.useState(!1),[N,m]=a.useState(null),[f,y]=a.useState(null),[g,S]=a.useState(null),[v,p]=a.useState(!1),$=a.useRef(null),M=a.useRef(0),R=a.useRef(null),[_,E]=a.useState(320);a.useEffect(()=>{const j=R.current;if(!j)return;const C=new ResizeObserver(()=>{E(Math.max(200,j.clientWidth))});return C.observe(j),E(Math.max(200,j.clientWidth)),()=>C.disconnect()},[]),a.useEffect(()=>{y(null),S(null),m(null)},[o,n]);const h=a.useCallback(async()=>{const j=++M.current;d(!0);try{const C=new URLSearchParams({bus:String(o),addr:String(n)}),w=await U(`/api/debug/sensors/magnetometer/sample?${C.toString()}`);if(j!==M.current)return;if(!w.success){y(null),S(null),m(w.error||t("sys.sensors.compass.err"));return}m(null);const P=w.heading_deg??null;P!=null&&y(z=>z==null?os*360+P:ds(z,P)),S(w.field_ut??null)}catch(C){if(j!==M.current)return;y(null),S(null),m(C instanceof Error?C.message:String(C))}finally{j===M.current&&d(!1)}},[n,o,t]);a.useEffect(()=>{if(!v){$.current&&(clearInterval($.current),$.current=null);return}return $.current=setInterval(()=>{h()},850),()=>{$.current&&clearInterval($.current)}},[v,h]);const T=a.useMemo(()=>{if(f==null)return 0;const j=f*be;return _/2-j},[f,_]),O=f!=null?(f%360+360)%360:null;return e.jsxs("div",{className:"rounded-xl border border-sky-500/30 bg-gradient-to-b from-slate-900/90 via-slate-900/70 to-surface-container/90 p-4",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("p",{className:"text-sm font-semibold text-on-surface",children:t("sys.sensors.compass.title")}),e.jsx("p",{className:"mt-1 max-w-xl text-[11px] leading-snug text-on-surface-variant",children:t("sys.sensors.compass.desc")})]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsxs("label",{className:"flex cursor-pointer items-center gap-1.5 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:v,onChange:j=>p(j.target.checked)}),t("sys.sensors.compass.live")]}),e.jsx("button",{type:"button",disabled:c,className:"rounded-lg bg-sky-700/80 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-50",onClick:()=>void h(),children:t(c?"sys.sensors.compass.loading":"sys.sensors.compass.btn")})]})]}),e.jsxs("div",{className:"mt-5",children:[e.jsx("p",{className:"mb-2 text-center text-[10px] uppercase tracking-[0.2em] text-sky-300/90",children:t("sys.sensors.compass.tapeCaption")}),e.jsxs("div",{ref:R,className:"relative mx-auto h-[112px] w-full max-w-3xl overflow-hidden rounded-lg ring-1 ring-sky-500/25",style:{maskImage:"linear-gradient(90deg, transparent 0%, black 10%, black 90%, transparent 100%)",WebkitMaskImage:"linear-gradient(90deg, transparent 0%, black 10%, black 90%, transparent 100%)"},children:[e.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center",children:e.jsxs("div",{className:"flex flex-col items-center",children:[e.jsx("div",{className:"h-0 w-0 border-x-[9px] border-x-transparent border-b-[12px] border-b-amber-400 drop-shadow"}),e.jsx("div",{className:"h-[88px] w-0.5 rounded-full bg-gradient-to-b from-amber-300/95 to-sky-400/40"})]})}),e.jsx("div",{className:"absolute bottom-0 left-0 top-0 will-change-transform",style:{width:je,transform:`translateX(${T}px)`,transition:"transform 0.42s cubic-bezier(0.22, 0.95, 0.28, 1)"},children:e.jsxs("div",{className:"relative h-full",style:{width:je,background:"linear-gradient(180deg, rgba(15,23,42,0.2) 0%, rgba(30,41,59,0.85) 40%, rgba(15,23,42,0.95) 100%)"},children:[e.jsx("div",{className:"absolute left-0 right-0 top-8 h-px bg-slate-600/60"}),cs.map(j=>{const C=j.h==="maj"?22:j.h==="mid"?14:8;return e.jsxs("div",{className:"absolute flex flex-col items-center",style:{left:j.x,transform:"translateX(-50%)",top:32},children:[(j.cardKey||j.degLabel!=null)&&e.jsx("span",{className:`mb-0.5 whitespace-nowrap font-mono ${j.cardKey?"text-[13px] font-bold text-sky-200":"text-[10px] font-medium text-slate-400"}`,style:{marginTop:-18},children:j.cardKey?t(`sys.sensors.compass.${j.cardKey}`):String(j.degLabel)}),e.jsx("div",{className:`w-px rounded-full ${j.h==="maj"?"bg-sky-300/90":j.h==="mid"?"bg-slate-500/85":"bg-slate-600/50"}`,style:{height:C}})]},j.deg)}),e.jsx("div",{className:"absolute bottom-6 left-0 right-0 h-px bg-slate-600/40"})]})})]})]}),e.jsxs("div",{className:"mt-5 flex flex-col gap-4 md:flex-row md:items-start md:justify-center md:gap-8",children:[e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/90 px-6 py-4 text-center md:min-w-[200px]",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:t("sys.sensors.compass.headingLabel")}),e.jsx("p",{className:"font-mono text-4xl font-bold tabular-nums text-sky-200",children:O!=null?`${O.toFixed(1)}°`:"—"}),e.jsx("p",{className:"mt-1 text-[10px] text-on-surface-variant",children:t("sys.sensors.compass.headingHint")})]}),g&&e.jsxs("div",{className:"rounded-lg border border-outline-variant/20 px-4 py-3 font-mono text-[11px] text-on-surface md:max-w-md",children:[e.jsx("p",{className:"mb-1 text-[10px] text-on-surface-variant",children:"µT (X / Y / Z)"}),e.jsxs("p",{className:"tabular-nums",children:[g.x.toFixed(2)," · ",g.y.toFixed(2)," · ",g.z.toFixed(2)]})]})]}),N&&e.jsx("p",{className:"mt-3 rounded border border-amber-500/40 bg-amber-500/10 px-3 py-2 font-mono text-[11px] text-amber-100",children:N}),e.jsx("p",{className:"mt-3 text-[10px] leading-relaxed text-on-surface-variant/90",children:t("sys.sensors.compass.footnote")})]})}function Ne(s){try{return JSON.stringify(s,null,2)}catch{return String(s)}}function us(){const{t:s}=J(),[o,n]=a.useState(1),[t,c]=a.useState(12),[d,N]=a.useState(!0),[m,f]=a.useState(!1),[y,g]=a.useState(null),[S,v]=a.useState(null),[p,$]=a.useState(null),[M,R]=a.useState(null),[_,E]=a.useState(1),[h,T]=a.useState(104),[O,j]=a.useState(!0),[C,w]=a.useState(!1),[P,z]=a.useState(null),[i,k]=a.useState(null),L=a.useCallback(async()=>{f(!0),g(null);try{const u=new URLSearchParams({bus:String(o),addr:String(t),i2cdetect:d?"true":"false"}),F=await U(`/api/debug/sensors/magnetometer/selftest?${u.toString()}`);v(F)}catch(u){v(null),g(u instanceof Error?u.message:String(u))}finally{f(!1)}},[t,o,d]),A=a.useCallback(async()=>{f(!0),g(null);try{const u=new URLSearchParams({addr:String(t)}),F=await U(`/api/debug/sensors/magnetometer/probe-buses?${u.toString()}`);v(F)}catch(u){v(null),g(u instanceof Error?u.message:String(u))}finally{f(!1)}},[t]),I=a.useCallback(async()=>{f(!0),g(null);try{const u=new URLSearchParams({bus:String(o),addr:String(t)}),F=await U(`/api/debug/sensors/magnetometer/calibration/start?${u.toString()}`,{method:"POST"});v(F),R("已开始方向校准,请缓慢旋转设备 5-15 秒。");const G=await U(`/api/debug/sensors/magnetometer/calibration/status?${u.toString()}`);$(G)}catch(u){v(null),g(u instanceof Error?u.message:String(u))}finally{f(!1)}},[t,o]),B=a.useCallback(async()=>{f(!0),g(null);try{const u=new URLSearchParams({bus:String(o),addr:String(t)}),F=await U(`/api/debug/sensors/magnetometer/calibration/commit?${u.toString()}`,{method:"POST"});v(F),R("已保存并锁定方向校准。");const G=await U(`/api/debug/sensors/magnetometer/calibration/status?${u.toString()}`);$(G)}catch(u){v(null),g(u instanceof Error?u.message:String(u))}finally{f(!1)}},[t,o]),X=a.useCallback(async()=>{f(!0),g(null);try{const u=new URLSearchParams({bus:String(o),addr:String(t)}),F=await U(`/api/debug/sensors/magnetometer/calibration/reset?${u.toString()}`,{method:"POST"});v(F),R("已重置到自动模式。");const G=await U(`/api/debug/sensors/magnetometer/calibration/status?${u.toString()}`);$(G)}catch(u){v(null),g(u instanceof Error?u.message:String(u))}finally{f(!1)}},[t,o]),Q=a.useCallback(async()=>{f(!0),g(null);try{const u=new URLSearchParams({bus:String(o),addr:String(t)}),F=await U(`/api/debug/sensors/magnetometer/calibration/status?${u.toString()}`);v(F),$(F)}catch(u){v(null),g(u instanceof Error?u.message:String(u))}finally{f(!1)}},[t,o]);a.useEffect(()=>{const u=new URLSearchParams({bus:String(o),addr:String(t)});U(`/api/debug/sensors/magnetometer/calibration/status?${u.toString()}`).then(F=>$(F)).catch(()=>{})},[t,o]),a.useEffect(()=>{if((p==null?void 0:p.mode)!=="recording")return;const u=setInterval(()=>{const F=new URLSearchParams({bus:String(o),addr:String(t)});U(`/api/debug/sensors/magnetometer/calibration/status?${F.toString()}`).then(G=>$(G)).catch(()=>{})},900);return()=>clearInterval(u)},[p==null?void 0:p.mode,o,t]);const ae=a.useMemo(()=>{const u=(p==null?void 0:p.mode)??"auto";return s(u==="recording"?"sys.sensors.mag.calModeRecording":u==="locked"?"sys.sensors.mag.calModeLocked":"sys.sensors.mag.calModeAuto")},[p==null?void 0:p.mode,s]),ee=Number((p==null?void 0:p.samples)??0),se=(p==null?void 0:p.mode)==="recording"&&ee>=10,ne=a.useCallback(async()=>{w(!0),z(null);try{const u=new URLSearchParams({bus:String(_),addr:String(h),i2cdetect:O?"true":"false"}),F=await U(`/api/debug/sensors/mpu6050/selftest?${u.toString()}`);k(F)}catch(u){k(null),z(u instanceof Error?u.message:String(u))}finally{w(!1)}},[h,_,O]);return e.jsxs("div",{className:"mx-auto max-w-6xl space-y-6",children:[e.jsxs("header",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:s("sys.placeholder.breadcrumb")}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:s("sys.sensors.title")}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:s("sys.sensors.desc")})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:s("sys.sensors.mag.section")}),e.jsx("p",{className:"mt-1 text-[11px] text-on-surface-variant",children:s("sys.sensors.mag.note")}),e.jsxs("div",{className:"mt-4 flex flex-wrap items-end gap-4",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[s("sys.sensors.mag.bus"),e.jsx("input",{type:"number",min:0,max:32,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:o,onChange:u=>n(Number(u.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[s("sys.sensors.mag.addr"),e.jsx("input",{type:"number",min:1,max:127,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:t,onChange:u=>c(Number(u.target.value))})]}),e.jsxs("label",{className:"flex items-center gap-2 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:d,onChange:u=>N(u.target.checked)}),s("sys.sensors.mag.i2cdetect")]})]}),e.jsx(xs,{bus:o,addr:t}),e.jsxs("div",{className:"mt-6 flex flex-wrap gap-2",children:[e.jsx("button",{type:"button",disabled:m,className:"rounded-lg bg-primary px-4 py-2 text-sm font-medium text-on-primary hover:opacity-90 disabled:opacity-50",onClick:()=>void L(),children:s(m?"sys.sensors.mag.running":"sys.sensors.mag.btnSelftest")}),e.jsx("button",{type:"button",disabled:m,className:"rounded-lg border border-outline-variant/40 px-4 py-2 text-sm text-on-surface hover:bg-surface-container/80 disabled:opacity-50",onClick:()=>void A(),children:s("sys.sensors.mag.btnProbe")})]}),e.jsxs("div",{className:"mt-3 flex flex-wrap gap-2",children:[e.jsx("button",{type:"button",disabled:m||(p==null?void 0:p.mode)==="recording",className:"rounded-lg border border-amber-400/40 px-3 py-1.5 text-xs text-on-surface hover:bg-amber-500/10 disabled:opacity-50",onClick:()=>void I(),children:s("sys.sensors.mag.btnCalStart")}),e.jsx("button",{type:"button",disabled:m||!se,className:"rounded-lg border border-emerald-400/40 px-3 py-1.5 text-xs text-on-surface hover:bg-emerald-500/10 disabled:opacity-50",onClick:()=>void B(),children:s("sys.sensors.mag.btnCalCommit")}),e.jsx("button",{type:"button",disabled:m,className:"rounded-lg border border-rose-400/40 px-3 py-1.5 text-xs text-on-surface hover:bg-rose-500/10 disabled:opacity-50",onClick:()=>void X(),children:s("sys.sensors.mag.btnCalReset")}),e.jsx("button",{type:"button",disabled:m,className:"rounded-lg border border-outline-variant/40 px-3 py-1.5 text-xs text-on-surface hover:bg-surface-container/80 disabled:opacity-50",onClick:()=>void Q(),children:s("sys.sensors.mag.btnCalStatus")})]}),e.jsxs("div",{className:"mt-3 rounded-lg border border-outline-variant/30 bg-surface-container/60 px-3 py-2 text-[11px] text-on-surface",children:[e.jsxs("p",{className:"font-medium",children:[s("sys.sensors.mag.calStatusPrefix")," ",ae]}),e.jsxs("p",{className:"mt-1 text-on-surface-variant",children:[s("sys.sensors.mag.calSamplesPrefix")," ",ee,(p==null?void 0:p.mode)==="recording"?" / 10+":""]}),(p==null?void 0:p.span_xyz)&&e.jsxs("p",{className:"mt-1 font-mono text-[10px] text-on-surface-variant",children:["span xyz: ",Number(p.span_xyz.x??0).toFixed(1)," /"," ",Number(p.span_xyz.y??0).toFixed(1)," /"," ",Number(p.span_xyz.z??0).toFixed(1)]}),(p==null?void 0:p.mode)==="recording"&&e.jsx("p",{className:"mt-1 text-amber-200",children:s("sys.sensors.mag.calRecordingHint")}),(p==null?void 0:p.mode)==="locked"&&p.locked&&e.jsxs("p",{className:"mt-1 text-emerald-200",children:[s("sys.sensors.mag.calLockedHint")," axes=",String(p.locked.axes_pair??"xy")]}),M&&e.jsx("p",{className:"mt-1 text-sky-200",children:M})]}),y&&e.jsx("p",{className:"mt-3 rounded border border-red-500/40 bg-red-500/10 px-3 py-2 font-mono text-xs text-red-200",children:y}),S&&e.jsxs("details",{className:"mt-4 rounded-lg border border-outline-variant/30 bg-surface-container/50",children:[e.jsx("summary",{className:"cursor-pointer px-3 py-2 text-[11px] text-on-surface-variant",children:s("sys.sensors.jsonToggle")}),e.jsx("pre",{className:"max-h-[320px] overflow-auto border-t border-outline-variant/20 p-3 font-mono text-[11px] leading-relaxed text-on-surface",children:Ne(S)})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:s("sys.sensors.mpu.section")}),e.jsx("p",{className:"mt-1 text-[11px] text-on-surface-variant",children:s("sys.sensors.mpu.note")}),e.jsxs("div",{className:"mt-4 flex flex-wrap items-end gap-4",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[s("sys.sensors.mag.bus"),e.jsx("input",{type:"number",min:0,max:32,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:_,onChange:u=>E(Number(u.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[s("sys.sensors.mpu.addr"),e.jsx("input",{type:"number",min:1,max:127,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:h,onChange:u=>T(Number(u.target.value))})]}),e.jsxs("label",{className:"flex items-center gap-2 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:O,onChange:u=>j(u.target.checked)}),s("sys.sensors.mag.i2cdetect")]})]}),e.jsx(ns,{bus:_,addr:h}),e.jsx("div",{className:"mt-4 flex flex-wrap gap-2",children:e.jsx("button",{type:"button",disabled:C,className:"rounded-lg bg-primary px-4 py-2 text-sm font-medium text-on-primary hover:opacity-90 disabled:opacity-50",onClick:()=>void ne(),children:s(C?"sys.sensors.mpu.running":"sys.sensors.mpu.btnSelftest")})}),P&&e.jsx("p",{className:"mt-3 rounded border border-red-500/40 bg-red-500/10 px-3 py-2 font-mono text-xs text-red-200",children:P}),i&&e.jsxs("details",{className:"mt-4 rounded-lg border border-outline-variant/30 bg-surface-container/50",children:[e.jsx("summary",{className:"cursor-pointer px-3 py-2 text-[11px] text-on-surface-variant",children:s("sys.sensors.jsonToggle")}),e.jsx("pre",{className:"max-h-[320px] overflow-auto border-t border-outline-variant/20 p-3 font-mono text-[11px] leading-relaxed text-on-surface",children:Ne(i)})]})]})]})}const ms={ogscope:{zh:"主配置 ogscope.env",en:"Primary ogscope.env"},network:{zh:"网络 network.env",en:"Network network.env"}};function ps(s){const o=[];let n=0;return s.split(/\r?\n/).forEach((c,d)=>{const N=c.trim();if(!N||N.startsWith("#"))return;const m=N.startsWith("export ")?N.slice(7):N,f=m.indexOf("=");if(f<=0){n+=1;return}const y=m.slice(0,f).trim();if(!y){n+=1;return}const g=m.slice(f+1);o.push({id:`env-${d}-${y}`,key:y,value:g})}),{entries:o,unsupportedLines:n}}function we(s){const o=s.map(n=>({key:n.key.trim(),value:n.value})).filter(n=>n.key.length>0).map(n=>`${n.key}=${n.value}`);return o.length>0?`${o.join(` -`)} -`:""}function le(s,o){return s==="both"?!0:s===o}function hs(){const{locale:s}=J(),[o,n]=a.useState([]),[t,c]=a.useState(null),[d,N]=a.useState(""),[m,f]=a.useState(""),[y,g]=a.useState("form"),[S,v]=a.useState([]),[p,$]=a.useState(0),[M,R]=a.useState(!1),[_,E]=a.useState(""),[h,T]=a.useState(""),[O,j]=a.useState(""),[C,w]=a.useState(!0),[P,z]=a.useState(""),i=s==="zh",k=a.useMemo(()=>o.find(l=>l.file_id===d)??null,[d,o]),L=a.useMemo(()=>{const l=new Map;for(const r of(t==null?void 0:t.sections)??[])for(const x of r.entries)l.set(x.key.toUpperCase(),x);for(const r of(t==null?void 0:t.network_only)??[])l.set(r.key.toUpperCase(),r);return l},[t]),A=a.useMemo(()=>{if(!d)return[];const l=new Set(S.map(x=>x.key.trim().toUpperCase()).filter(Boolean)),r=[];for(const x of(t==null?void 0:t.sections)??[])for(const b of x.entries)le(b.scope,d)&&(l.has(b.key.toUpperCase())||r.push(b));for(const x of(t==null?void 0:t.network_only)??[])le(x.scope,d)&&(l.has(x.key.toUpperCase())||r.push(x));return r.sort((x,b)=>x.key.localeCompare(b.key))},[d,t,S]),I=a.useMemo(()=>{const l=O.trim().toLowerCase();return l?S.filter(r=>{const x=r.key.toLowerCase(),b=L.get(r.key.trim().toUpperCase());return`${x} ${r.value} ${(b==null?void 0:b.zh)??""} ${(b==null?void 0:b.en)??""}`.toLowerCase().includes(l)}):S},[S,L,O]),B=l=>{const r=ps(l);v(r.entries),$(r.unsupportedLines)},X=async()=>{var l,r;R(!0),E("");try{const[x,b]=await Promise.all([K("/api/dev/system/config/files",{cache:"no-store"}),K("/api/dev/system/config/catalog",{cache:"no-store"})]);n(x.files??[]),c(b);const H=((r=(l=x.files)==null?void 0:l[0])==null?void 0:r.file_id)??"";N(D=>{var Z;return D&&((Z=x.files)!=null&&Z.some(Y=>Y.file_id===D))?D:H})}catch(x){E(x instanceof Error?x.message:String(x))}finally{R(!1)}},Q=async()=>{if(!d)return;if(y==="form"&&p>0){E(i?"当前文件包含无法表单化的行,请切换到「原始文本」模式编辑后再保存。":"This file has lines not supported by form mode. Switch to Raw mode before saving.");return}const l=y==="form"?we(S):m;R(!0),E(""),T("");try{const r=await K("/api/dev/system/config/files",{method:"POST",body:JSON.stringify({file_id:d,content:l})});T(i?`保存成功:${r.message??d};请重启 ogscope 服务使配置生效`:`Saved: ${r.message??d}; restart ogscope to apply changes`),await X()}catch(r){E(r instanceof Error?r.message:String(r))}finally{R(!1)}};a.useEffect(()=>{X()},[]),a.useEffect(()=>{if(!k){f(""),v([]),$(0),z("");return}const l=k.content??"";f(l),B(l),z("")},[k]);const ae=()=>{v(l=>[...l,{id:`env-new-${Date.now()}-${l.length}`,key:"",value:""}])},ee=()=>{const l=A.find(r=>r.key===P);l&&(v(r=>[...r,{id:`env-catalog-${Date.now()}-${l.key}`,key:l.key,value:l.default??""}]),z(""))},se=(l,r)=>{v(x=>x.map(b=>b.id===l?{...b,...r}:b))},ne=l=>{v(r=>r.filter(x=>x.id!==l))},u=l=>{const r=L.get(l.trim().toUpperCase());return r?i?r.zh:r.en:i?"暂无释义(可查阅 deploy/*.env.example)":"No hint (see deploy/*.env.example)"},F=l=>{const r=L.get(l.trim().toUpperCase());return r!=null&&r.default?i?`默认:${r.default}`:`Default: ${r.default}`:""},G=l=>{const r=ms[l];return r?i?r.zh:r.en:l};return e.jsxs("div",{className:"mx-auto max-w-7xl space-y-6",children:[e.jsxs("header",{children:[e.jsxs("div",{className:"flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:[e.jsx("span",{children:"Console"}),e.jsx("span",{children:"/"}),e.jsx("span",{className:"text-primary",children:i?"配置管理":"Config Manager"})]}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:i?"环境配置管理":"Environment Config Manager"}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:i?"编辑 /etc/ogscope 下的 ogscope.env 与 network.env。保存后请重启 ogscope 服务;配置项说明来自服务端目录 API。":"Edit ogscope.env and network.env under /etc/ogscope. Restart ogscope after saving; hints come from the server catalog API."})]}),_&&e.jsx("div",{className:"rounded-lg border border-error/40 bg-error-container/20 px-3 py-2 text-sm text-on-error-container",children:_}),h&&e.jsx("div",{className:"rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-sm",children:h}),e.jsxs("section",{className:"grid grid-cols-12 gap-4",children:[e.jsxs("aside",{className:"col-span-12 space-y-2 rounded-xl border border-outline-variant/20 bg-surface-container p-3 lg:col-span-3",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("p",{className:"text-xs uppercase tracking-wider text-on-surface-variant",children:i?"配置文件":"Config Files"}),e.jsx("button",{type:"button",onClick:()=>void X(),disabled:M,children:e.jsxs("span",{className:"inline-flex items-center gap-1 text-xs",children:[e.jsx(he,{className:"h-3.5 w-3.5"})," ",i?"刷新":"Refresh"]})})]}),o.map(l=>e.jsxs("button",{type:"button",onClick:()=>N(l.file_id),className:`w-full rounded-lg border px-3 py-2 text-left text-sm ${l.file_id===d?"border-primary bg-primary/10 text-on-surface":"border-outline-variant/30 bg-surface-container-low text-on-surface-variant"}`,children:[e.jsx("div",{className:"font-medium",children:G(l.file_id)}),e.jsx("div",{className:"mt-1 truncate font-mono text-[11px]",children:l.path})]},l.file_id)),(t==null?void 0:t.env_files)&&e.jsxs("div",{className:"mt-3 rounded border border-outline-variant/20 bg-surface-container-low px-2 py-2 text-[11px] text-on-surface-variant",children:[e.jsx("p",{className:"mb-1 font-medium text-on-surface",children:i?"配置路径":"Config paths"}),Object.entries(t.env_files).map(([l,r])=>e.jsxs("p",{className:"font-mono",children:[l,": ",r]},l))]})]}),e.jsxs("div",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 lg:col-span-9",children:[!k&&e.jsx("p",{className:"text-sm text-on-surface-variant",children:i?"暂无可编辑配置文件":"No editable config files."}),k&&e.jsxs("div",{className:"space-y-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-2",children:[e.jsxs("div",{children:[e.jsx("p",{className:"font-mono text-xs text-on-surface",children:k.path}),e.jsxs("p",{className:"text-xs text-on-surface-variant",children:[i?"可写":"Writable",": ",String(k.writable)," · ",i?"存在":"Exists",":"," ",String(k.exists)]})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("button",{type:"button",onClick:()=>{y==="raw"&&B(m),g("form")},className:`rounded px-2 py-1 text-xs ${y==="form"?"bg-primary-container text-on-primary-container":"border border-outline-variant/30 text-on-surface-variant"}`,children:i?"表单模式":"Form"}),e.jsx("button",{type:"button",onClick:()=>{y==="form"&&f(we(S)),g("raw")},className:`rounded px-2 py-1 text-xs ${y==="raw"?"bg-primary-container text-on-primary-container":"border border-outline-variant/30 text-on-surface-variant"}`,children:i?"原始文本":"Raw"})]}),e.jsx("button",{type:"button",onClick:()=>void Q(),disabled:M||!k.writable,children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Pe,{className:"h-3.5 w-3.5"}),i?"保存并提示重启":"Save"]})})]}),k.error&&e.jsx("div",{className:"rounded border border-error/40 bg-error-container/20 px-2 py-1 text-xs text-on-error-container",children:k.error}),y==="form"?e.jsxs("div",{className:"space-y-3",children:[p>0&&e.jsx("div",{className:"rounded border border-warning/40 bg-warning/10 px-2 py-1 text-xs text-on-surface",children:i?`检测到 ${p} 行无法转换为键值表单。请切到「原始文本」模式处理。`:`${p} line(s) cannot be represented in key-value form. Use Raw mode.`}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsxs("div",{className:"relative min-w-[200px] flex-1",children:[e.jsx(_e,{className:"pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-on-surface-variant"}),e.jsx("input",{className:"w-full rounded border border-outline-variant/30 bg-surface-container-low py-1.5 pl-8 pr-2 text-xs outline-none focus:border-primary",placeholder:i?"搜索键名、值或释义…":"Search keys, values, or hints…",value:O,onChange:l=>j(l.target.value)})]}),e.jsxs("select",{className:"max-w-xs flex-1 rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5 text-xs outline-none focus:border-primary",value:P,onChange:l=>z(l.target.value),children:[e.jsx("option",{value:"",children:i?"从目录添加配置项…":"Add from catalog…"}),A.map(l=>e.jsx("option",{value:l.key,children:l.key},l.key))]}),e.jsx("button",{type:"button",disabled:!P,onClick:ee,className:"rounded border border-outline-variant/30 px-2 py-1.5 text-xs disabled:opacity-50",children:i?"添加":"Add"})]}),e.jsxs("div",{className:"max-h-[420px] overflow-auto pr-1",children:[e.jsxs("table",{className:"w-full border-separate border-spacing-y-2",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"text-left text-[11px] uppercase tracking-wide text-on-surface-variant",children:[e.jsx("th",{children:i?"配置项":"Key"}),e.jsx("th",{children:i?"值":"Value"}),e.jsx("th",{children:i?"释义":"Meaning"}),e.jsx("th",{children:i?"操作":"Action"})]})}),e.jsx("tbody",{children:I.map(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"pr-2 align-top",children:e.jsx("input",{className:"w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1 font-mono text-xs outline-none focus:border-primary",placeholder:"OGSCOPE_PORT",value:l.key,onChange:r=>se(l.id,{key:r.target.value})})}),e.jsx("td",{className:"pr-2 align-top",children:e.jsx("input",{className:"w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1 font-mono text-xs outline-none focus:border-primary",placeholder:i?"变量值":"Value",value:l.value,onChange:r=>se(l.id,{value:r.target.value})})}),e.jsxs("td",{className:"pr-2 align-top text-xs text-on-surface-variant",children:[e.jsx("p",{children:u(l.key)}),F(l.key)&&e.jsx("p",{className:"mt-0.5 font-mono text-[10px] text-on-surface-variant/80",children:F(l.key)})]}),e.jsx("td",{className:"align-top",children:e.jsx("button",{type:"button",onClick:()=>ne(l.id),children:e.jsx(Re,{className:"h-3.5 w-3.5 text-on-surface-variant"})})})]},l.id))})]}),S.length===0&&e.jsx("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-2 text-xs text-on-surface-variant",children:i?"当前没有可编辑变量,可从目录添加或手动新增。":"No variables yet. Add from catalog or manually."}),S.length>0&&I.length===0&&e.jsx("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-2 text-xs text-on-surface-variant",children:i?"无匹配项,请调整搜索条件。":"No matches for the current search."})]}),e.jsx("div",{className:"flex flex-wrap gap-2",children:e.jsx("button",{type:"button",onClick:ae,children:e.jsxs("span",{className:"inline-flex items-center gap-1 text-xs",children:[e.jsx(De,{className:"h-3.5 w-3.5"}),i?"空白行":"Blank row"]})})}),e.jsxs("details",{open:C,onToggle:l=>w(l.target.open),className:"rounded border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-xs text-on-surface-variant",children:[e.jsx("summary",{className:"cursor-pointer font-medium text-on-surface",children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Ae,{className:"h-3.5 w-3.5"}),i?"配置目录(按模块)":"Config catalog (by section)"]})}),e.jsxs("div",{className:"mt-3 space-y-4",children:[((t==null?void 0:t.sections)??[]).map(l=>{const r=l.entries.filter(x=>d?le(x.scope,d):!0);return r.length===0?null:e.jsxs("div",{children:[e.jsx("p",{className:"mb-1 font-medium text-on-surface",children:i?l.title_zh:l.title_en}),e.jsx("div",{className:"space-y-1",children:r.map(x=>e.jsxs("p",{children:[e.jsx("span",{className:"font-mono text-[11px] text-on-surface",children:x.key}),x.default!=null&&x.default!==""&&e.jsxs("span",{className:"ml-1 font-mono text-[10px] text-on-surface-variant/80",children:["(= ",x.default,")"]})," — ",e.jsx("span",{children:i?x.zh:x.en})]},x.key))})]},l.id)}),((t==null?void 0:t.network_only)??[]).filter(l=>d?le(l.scope,d):!0).length>0&&e.jsxs("div",{children:[e.jsx("p",{className:"mb-1 font-medium text-on-surface",children:i?"仅 network.env / 脚本":"network.env / scripts only"}),e.jsx("div",{className:"space-y-1",children:((t==null?void 0:t.network_only)??[]).filter(l=>d?le(l.scope,d):!0).map(l=>e.jsxs("p",{children:[e.jsx("span",{className:"font-mono text-[11px] text-on-surface",children:l.key})," — ",e.jsx("span",{children:i?l.zh:l.en})]},l.key))})]})]})]})]}):e.jsx("textarea",{className:"h-[460px] w-full rounded-lg border border-outline-variant/30 bg-neutral-950 p-3 font-mono text-xs text-on-surface outline-none focus:border-primary",spellCheck:!1,value:m,onChange:l=>f(l.target.value)})]})]})]})]})}const fs=new Set(["overview","network","sensors","hmi","power","config"]);function ke(){const s=window.location.hash.replace(/^#\/?/,"").trim().toLowerCase();return fs.has(s)?s:"overview"}function bs(s){window.location.hash=`/${s}`}function gs(){const[s,o]=a.useState(()=>ke()),[n,t]=a.useState(!0);a.useEffect(()=>{const d=()=>o(ke());return window.addEventListener("hashchange",d),()=>window.removeEventListener("hashchange",d)},[]),a.useEffect(()=>{(async()=>{var d;try{const N=await fetch("/api",{cache:"no-store"});if(!N.ok)return;const m=await N.json();t(!!((d=m.endpoints)!=null&&d.network))}catch{}})()},[]);const c=a.useMemo(()=>s==="network"?n?e.jsx(Qe,{}):e.jsx(ve,{scope:"network"}):s==="sensors"?e.jsx(us,{}):s==="hmi"?e.jsx(ts,{}):s==="config"?e.jsx(hs,{}):s==="power"?e.jsx(ve,{scope:"power"}):e.jsx(Ye,{}),[n,s]);return e.jsx(We,{route:s,allowNetworkRoute:n,onRouteChange:d=>{d==="network"&&!n||d!==s&&bs(d)},children:c})}$e.createRoot(document.getElementById("root")).render(e.jsx(Me.StrictMode,{children:e.jsx(Te,{children:e.jsx(Le,{children:e.jsx(gs,{})})})})); diff --git a/web/static/analysis-lab/assets/system-DQXiDxh6.js b/web/static/analysis-lab/assets/system-DQXiDxh6.js new file mode 100644 index 0000000..7380d3d --- /dev/null +++ b/web/static/analysis-lab/assets/system-DQXiDxh6.js @@ -0,0 +1,78 @@ +import{j as e,r as a,a as $e,R as Me}from"./client-D1ZVDB-N.js";import{u as pe,C as Ee,r as W,a as q,S as Pe,b as Le}from"./http-ChPtkS1w.js";import{c as H,u as Z,T as Re,I as Te}from"./index-CutgeBjy.js";import{R as he}from"./refresh-cw-BkMjDReH.js";/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const me=H("Activity",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Oe=H("Bolt",[["path",{d:"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z",key:"yt0hxn"}],["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ae=H("BookOpen",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Se=H("Cpu",[["rect",{width:"16",height:"16",x:"4",y:"4",rx:"2",key:"14l7u7"}],["rect",{width:"6",height:"6",x:"9",y:"9",rx:"1",key:"5aljv4"}],["path",{d:"M15 2v2",key:"13l42r"}],["path",{d:"M15 20v2",key:"15mkzm"}],["path",{d:"M2 15h2",key:"1gxd5l"}],["path",{d:"M2 9h2",key:"1bbxkp"}],["path",{d:"M20 15h2",key:"19e6y8"}],["path",{d:"M20 9h2",key:"19tzq7"}],["path",{d:"M9 2v2",key:"165o2o"}],["path",{d:"M9 20v2",key:"i2bqo8"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Fe=H("HardDrive",[["line",{x1:"22",x2:"2",y1:"12",y2:"12",key:"1y58io"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}],["line",{x1:"6",x2:"6.01",y1:"16",y2:"16",key:"sgf278"}],["line",{x1:"10",x2:"10.01",y1:"16",y2:"16",key:"1l4acy"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ie=H("LayoutDashboard",[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ze=H("Network",[["rect",{x:"16",y:"16",width:"6",height:"6",rx:"1",key:"4q2zg0"}],["rect",{x:"2",y:"16",width:"6",height:"6",rx:"1",key:"8cvhb9"}],["rect",{x:"9",y:"2",width:"6",height:"6",rx:"1",key:"1egb70"}],["path",{d:"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3",key:"1jsf9p"}],["path",{d:"M12 12V8",key:"2874zd"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const De=H("Plus",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const _e=H("Search",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const He=H("Settings",[["path",{d:"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",key:"1qme2f"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ge=H("Sparkles",[["path",{d:"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z",key:"4pj2yx"}],["path",{d:"M20 3v4",key:"1olli1"}],["path",{d:"M22 5h-4",key:"1gvqau"}],["path",{d:"M4 17v2",key:"vumght"}],["path",{d:"M5 18H3",key:"zchphs"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const qe=H("Thermometer",[["path",{d:"M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z",key:"17jzev"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ue=H("Touchpad",[["rect",{width:"20",height:"16",x:"2",y:"4",rx:"2",key:"18n3k1"}],["path",{d:"M2 14h20",key:"myj16y"}],["path",{d:"M12 20v-6",key:"1rm09r"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Be=H("TriangleAlert",[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fe=H("Wifi",[["path",{d:"M12 20h.01",key:"zekei9"}],["path",{d:"M2 8.82a15 15 0 0 1 20 0",key:"dnpr2z"}],["path",{d:"M5 12.859a10 10 0 0 1 14 0",key:"1x1e6c"}],["path",{d:"M8.5 16.429a5 5 0 0 1 7 0",key:"1bycff"}]]),V=t=>`flex items-center gap-3 rounded-lg px-3 py-2.5 font-headline text-sm tracking-tight transition-colors ${t?"border-r-2 border-primary bg-white/5 font-semibold text-primary":"text-on-surface-variant hover:bg-white/5 hover:text-on-surface"}`;function We({route:t,allowNetworkRoute:o,onRouteChange:n,children:r}){const{t:c,locale:d,setLocale:j}=Z(),{info:u}=pe(),f=(u==null?void 0:u.cpu_usage)!=null?Number(u.cpu_usage).toFixed(1):"—",g=(u==null?void 0:u.memory_usage)!=null?Number(u.memory_usage).toFixed(1):"—",b=(u==null?void 0:u.temperature)!=null?Number(u.temperature).toFixed(1):"—",k=(u==null?void 0:u.wifi_quality)!=null&&!Number.isNaN(Number(u.wifi_quality))?`${Number(u.wifi_quality).toFixed(0)}%`:"—",y={overview:c("sys.shell.top.overview"),network:c("sys.shell.top.network"),sensors:c("sys.shell.top.sensors"),hmi:c("sys.shell.top.hmi"),power:c("sys.shell.top.power"),config:c("sys.shell.top.config")},m=V(!1),$=(M,R)=>{const S=window.open(M,R);S&&S.focus()};return e.jsxs("div",{className:"flex h-full min-h-0 flex-col bg-background text-on-surface md:flex-row",children:[e.jsxs("aside",{className:"glass-panel z-50 flex w-full shrink-0 flex-col border-b border-outline-variant/20 bg-surface-container-low/80 backdrop-blur-xl md:fixed md:left-0 md:top-0 md:h-full md:w-64 md:border-b-0 md:border-r md:border-white/5",children:[e.jsxs("div",{className:"p-5",children:[e.jsxs("div",{className:"mb-8 flex items-center gap-3",children:[e.jsx("div",{className:"primary-gradient flex h-10 w-10 items-center justify-center rounded-lg shadow-lg",children:e.jsx(ge,{className:"h-5 w-5 text-on-primary-container"})}),e.jsxs("div",{children:[e.jsx("h1",{className:"font-headline text-lg font-bold tracking-widest text-primary",children:"OGScope"}),e.jsx("p",{className:"font-mono text-[10px] uppercase tracking-widest text-on-surface-variant",children:c("sys.shell.subtitle")})]})]}),e.jsxs("nav",{className:"flex flex-col gap-0.5",children:[e.jsxs("button",{type:"button",className:V(t==="overview"),onClick:()=>n("overview"),children:[e.jsx(Ie,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.overview")})]}),o&&e.jsxs("button",{type:"button",className:V(t==="network"),onClick:()=>n("network"),children:[e.jsx(ze,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.network")})]}),e.jsxs("a",{href:"/debug/camera",className:m,onClick:M=>{M.preventDefault(),$("/debug/camera","ogscopeCameraConsole")},children:[e.jsx(Ee,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.camera")})]}),e.jsxs("a",{href:"/debug/analysis",className:m,onClick:M=>{M.preventDefault(),$("/debug/analysis","ogscopeAnalysisConsole")},children:[e.jsx(ge,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.analysis")})]}),e.jsxs("button",{type:"button",className:V(t==="sensors"),onClick:()=>n("sensors"),children:[e.jsx(me,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.sensors")})]}),e.jsxs("button",{type:"button",className:V(t==="power"),onClick:()=>n("power"),children:[e.jsx(Oe,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.power")})]}),e.jsxs("button",{type:"button",className:V(t==="hmi"),onClick:()=>n("hmi"),children:[e.jsx(Ue,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.hmi")})]}),e.jsxs("button",{type:"button",className:V(t==="config"),onClick:()=>n("config"),children:[e.jsx(He,{className:"h-4 w-4 shrink-0"}),e.jsx("span",{children:c("sys.shell.nav.config")})]})]})]}),e.jsx("div",{className:"mt-auto hidden p-5 md:block",children:e.jsxs("div",{className:"rounded-xl border border-white/5 bg-surface-container-low p-3",children:[e.jsx("p",{className:"truncate text-xs font-semibold text-on-surface",children:c("sys.shell.workbench")}),e.jsxs("p",{className:"font-mono text-[10px] text-on-surface-variant",children:[c("sys.shell.node"),": OGSCOPE_PI_ZERO_2W"]})]})})]}),e.jsxs("div",{className:"flex min-h-0 min-w-0 flex-1 flex-col md:ml-64",children:[e.jsxs("header",{className:"sticky top-0 z-40 flex h-14 shrink-0 items-center justify-between border-b border-white/5 bg-neutral-950/80 px-4 backdrop-blur-md md:px-8",children:[e.jsx("div",{className:"flex min-w-0 items-center gap-3",children:e.jsx("span",{className:"hidden truncate border-b-2 border-primary pb-0.5 font-mono text-xs uppercase tracking-wider text-primary sm:inline",children:y[t]})}),e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-3 sm:gap-4",children:[e.jsxs("div",{className:"mr-2 flex gap-1 text-[10px]",children:[e.jsx("button",{type:"button",className:`rounded px-2 py-0.5 ${d==="zh"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>j("zh"),children:c("lang.zh")}),e.jsx("button",{type:"button",className:`rounded px-2 py-0.5 ${d==="en"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>j("en"),children:c("lang.en")})]}),e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-3 font-mono text-[10px] uppercase tracking-wider text-on-surface-variant sm:gap-4",children:[e.jsxs("span",{className:"flex items-center gap-1 text-primary",children:[e.jsx(Se,{className:"h-3.5 w-3.5"})," CPU ",f,"%"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx(me,{className:"h-3.5 w-3.5"})," MEM ",g,"%"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"text-xs",children:"°C"})," ",b]}),e.jsxs("span",{className:"flex items-center gap-1 text-secondary",children:[e.jsx(fe,{className:"h-3.5 w-3.5"})," ",k]})]})]})]}),e.jsx("main",{className:"og-scrollbar min-h-0 flex-1 overflow-auto p-4 md:p-6",children:r})]})]})}async function Ke(){return await W("/api/dev/system/hardware-plane/status",{cache:"no-store"})}async function Xe(t){return await W("/api/dev/system/hardware-plane/command",{method:"POST",body:JSON.stringify(t)})}async function Ge(t){var r;const o=new URLSearchParams;t!=null&&t.service&&o.set("service",t.service),o.set("since_seconds",String(t.sinceSeconds)),o.set("limit",String(t.limit)),(r=t==null?void 0:t.levels)!=null&&r.length&&o.set("levels",t.levels.join(","));const n=o.toString();return await W(`/api/dev/debug/logs/systemd${n?`?${n}`:""}`,{cache:"no-store"})}function Ze(t){const o=Math.max(0,parseInt(String(t??0),10)||0),n=Math.floor(o/86400),r=Math.floor(o%86400/3600),c=Math.floor(o%3600/60);return n>0?`${n}d ${r}h`:r>0?`${r}h ${c}m`:`${c}m`}function de(t,o=1){return t==null||Number.isNaN(Number(t))?"—":Number(t).toFixed(o)}function ye(t){return t==="ERROR"?"text-error":t==="WARN"?"text-amber-300":"text-primary"}function Je(t){if(!t)return"--:--:--";const o=new Date(t);return Number.isNaN(o.getTime())?"--:--:--":o.toLocaleTimeString()}function Ye(){const{t:n}=Z(),{info:r,error:c}=pe(),[d,j]=a.useState(!1),[u,f]=a.useState(["INFO","WARN","ERROR"]),[g,b]=a.useState([]),[k,y]=a.useState(null),[m,$]=a.useState(!1),[M,R]=a.useState(!0),S=a.useRef(null),E=a.useRef(new Set),h=de(r==null?void 0:r.cpu_usage),T=de(r==null?void 0:r.memory_usage),O=de(r==null?void 0:r.temperature),v=(r==null?void 0:r.load_average_1m)!=null?String(r.load_average_1m):"—",_=(r==null?void 0:r.wifi_quality)!=null&&!Number.isNaN(Number(r.wifi_quality))?`${Number(r.wifi_quality).toFixed(0)}%`:"—",N=(r==null?void 0:r.wifi_signal_dbm)!=null&&!Number.isNaN(Number(r.wifi_signal_dbm))?`${Number(r.wifi_signal_dbm).toFixed(0)} dBm`:"—",P=async()=>{if(u.length===0){b([]);return}$(!0);try{y(null);const i=await Ge({service:"ogscope",sinceSeconds:1200,limit:240,levels:u});b(w=>{const L=new Set(w.map(I=>`${I.ts??""}::${I.level}::${I.source}::${I.message}`)),A=[...w];for(const I of i.items){const U=`${I.ts??""}::${I.level}::${I.source}::${I.message}`;L.has(U)||(L.add(U),A.push(I))}return A.length<=300?A:A.slice(A.length-300)})}catch(i){y(i instanceof Error?i.message:String(i))}finally{$(!1)}};a.useEffect(()=>{if(!d)return;P();const i=window.setInterval(()=>{document.hidden||P()},4e3);return()=>window.clearInterval(i)},[d,u.join(",")]),a.useEffect(()=>{const i=S.current;if(!i)return;const w=()=>{const L=i.scrollHeight-i.scrollTop-i.clientHeight;R(L<=24)};return w(),i.addEventListener("scroll",w),()=>i.removeEventListener("scroll",w)},[]),a.useEffect(()=>{if(!S.current||g.length===0)return;const w=new Set(g.map(A=>`${A.ts??""}::${A.level}::${A.source}::${A.message}`));let L=!1;w.forEach(A=>{E.current.has(A)||(L=!0)}),E.current=w,M&&L&&requestAnimationFrame(()=>{S.current&&(S.current.scrollTop=S.current.scrollHeight)})},[g,M]);const z=a.useMemo(()=>new Set(u),[u]);return e.jsxs("div",{className:"mx-auto max-w-7xl space-y-6",children:[e.jsxs("header",{className:"mb-1",children:[e.jsxs("div",{className:"flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.breadcrumb.console")}),e.jsx("span",{children:"/"}),e.jsx("span",{className:"text-primary",children:n("sys.overview.breadcrumb.module")})]}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:n("sys.overview.title")}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:n("sys.overview.subtitle")})]}),c&&e.jsx("div",{className:"rounded-lg border border-error/40 bg-error-container/20 px-3 py-2 text-sm text-on-error-container",children:c}),e.jsxs("section",{className:"grid grid-cols-12 gap-4",children:[e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.cpu")}),e.jsx(Se,{className:"h-4 w-4 text-primary"})]}),e.jsxs("div",{className:"text-3xl font-bold text-on-surface",children:[h,"%"]}),e.jsx("div",{className:"mt-3 h-1.5 w-full overflow-hidden rounded bg-surface-container-high",children:e.jsx("div",{className:"h-full bg-primary",style:{width:`${Math.min(Number(h)||0,100)}%`}})})]}),e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.mem")}),e.jsx(me,{className:"h-4 w-4 text-secondary"})]}),e.jsxs("div",{className:"text-3xl font-bold text-on-surface",children:[T,"%"]}),e.jsx("div",{className:"mt-3 h-1.5 w-full overflow-hidden rounded bg-surface-container-high",children:e.jsx("div",{className:"h-full bg-secondary",style:{width:`${Math.min(Number(T)||0,100)}%`}})})]}),e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.temp")}),e.jsx(qe,{className:"h-4 w-4 text-primary"})]}),e.jsxs("div",{className:"text-3xl font-bold text-on-surface",children:[O,"°C"]}),e.jsx("div",{className:"mt-3 text-xs text-on-surface-variant",children:n("sys.overview.tempState")})]}),e.jsxs("article",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 md:col-span-3",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between text-[10px] uppercase tracking-wider text-on-surface-variant",children:[e.jsx("span",{children:n("sys.overview.metric.wifi")}),e.jsx(fe,{className:"h-4 w-4 text-primary"})]}),e.jsx("div",{className:"text-3xl font-bold text-on-surface",children:_}),e.jsx("div",{className:"mt-3 text-xs text-on-surface-variant",children:N})]})]}),e.jsxs("section",{className:"grid grid-cols-12 gap-4",children:[e.jsxs("article",{className:"col-span-12 rounded-xl border border-white/5 bg-surface-container-low p-6 lg:col-span-8",children:[e.jsxs("div",{className:"mb-6 flex items-center justify-between",children:[e.jsxs("div",{children:[e.jsx("h3",{className:"font-headline text-lg font-bold",children:n("sys.overview.wifiSummary")}),e.jsxs("p",{className:"text-xs text-on-surface-variant",children:[n("sys.overview.iface"),": ",String((r==null?void 0:r.wifi_interface)??"wlan0")]})]}),e.jsx("span",{className:"rounded border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] uppercase tracking-widest text-primary",children:n("sys.overview.linkActive")})]}),e.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[e.jsxs("div",{className:"rounded-lg border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.signal")}),e.jsx("p",{className:"mt-1 font-mono text-2xl font-bold",children:N})]}),e.jsxs("div",{className:"rounded-lg border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.quality")}),e.jsx("p",{className:"mt-1 font-mono text-2xl font-bold",children:_})]})]})]}),e.jsxs("aside",{className:"col-span-12 space-y-4 lg:col-span-4",children:[e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.metric.uptime")}),e.jsx("p",{className:"mt-1 text-xl font-bold",children:Ze(r==null?void 0:r.uptime_seconds)})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.metric.load")}),e.jsx("p",{className:"mt-1 text-xl font-bold",children:v})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:n("sys.overview.metric.storage")}),e.jsx(Fe,{className:"h-4 w-4 text-on-surface-variant"})]}),e.jsx("p",{className:"mt-2 text-sm text-on-surface-variant",children:n("sys.overview.storageComingSoon")})]})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-lowest p-4",children:[e.jsxs("div",{className:"mb-3 flex flex-wrap items-center justify-between gap-3 border-b border-outline-variant/20 pb-2",children:[e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("span",{className:"text-[10px] uppercase tracking-widest text-primary",children:n("sys.logs.title")}),e.jsxs("span",{className:"text-[10px] text-on-surface-variant",children:[n("sys.logs.kernel"),": ",String((r==null?void 0:r.os)??"—")]})]}),e.jsxs("div",{className:"flex items-center gap-2 text-xs",children:[e.jsxs("label",{className:"inline-flex items-center gap-2 text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:d,onChange:i=>j(i.target.checked)}),n("sys.logs.liveToggle")]}),e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-on-surface-variant hover:border-primary hover:text-on-surface",onClick:()=>void P(),children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(he,{className:"h-3.5 w-3.5"})," ",n("sys.logs.refresh")]})})]})]}),e.jsxs("div",{className:"mb-2 flex flex-wrap items-center gap-2 text-xs",children:[["INFO","WARN","ERROR"].map(i=>e.jsxs("label",{className:"inline-flex items-center gap-1.5 text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:z.has(i),onChange:w=>{f(L=>w.target.checked?Array.from(new Set([...L,i])):L.filter(A=>A!==i))}}),e.jsx("span",{className:ye(i),children:i})]},i)),!d&&e.jsxs("span",{className:"inline-flex items-center gap-1 rounded border border-outline-variant/30 px-2 py-0.5 text-[11px] text-on-surface-variant",children:[e.jsx(Be,{className:"h-3.5 w-3.5"})," ",n("sys.logs.liveOffHint")]})]}),k&&e.jsx("div",{className:"mb-2 rounded border border-error/40 bg-error-container/20 px-2 py-1 text-xs text-on-error-container",children:k}),e.jsxs("div",{ref:S,className:"og-scrollbar max-h-72 space-y-1 overflow-auto font-mono text-[11px] text-on-surface-variant",children:[m&&g.length===0&&e.jsx("div",{children:n("sys.logs.loading")}),!m&&g.length===0&&e.jsx("div",{children:n("sys.logs.empty")}),g.map((i,w)=>e.jsxs("div",{className:"flex items-start gap-2",children:[e.jsxs("span",{className:"shrink-0 text-primary",children:["[",Je(i.ts),"]"]}),e.jsx("span",{className:`shrink-0 ${ye(i.level)}`,children:i.level}),e.jsx("span",{className:"shrink-0 text-on-surface/80",children:i.source}),e.jsx("span",{className:"min-w-0 break-words text-on-surface",children:i.message})]},`${i.ts||"ts"}-${w}`))]})]})]})}function le(){return typeof window.OGSCOPE_HTTP_PORT=="number"?window.OGSCOPE_HTTP_PORT:8e3}function Ve(t){const o=Math.max(0,parseInt(String(t??0),10)||0),n=Math.floor(o/86400),r=Math.floor(o%86400/3600),c=Math.floor(o%3600/60);return n>0?`${n}天 ${r}小时`:r>0?`${r}小时 ${c}分`:`${c}分`}function Qe(){const{info:t,error:o}=pe(),[n,r]=a.useState("加载中..."),[c,d]=a.useState(""),[j,u]=a.useState(""),[f,g]=a.useState([]),[b,k]=a.useState(!1),[y,m]=a.useState([]),[$,M]=a.useState(!1),[R,S]=a.useState(""),[E,h]=a.useState(""),[T,O]=a.useState(!1),[v,_]=a.useState(""),[N,P]=a.useState(`http://192.168.4.1:${le()}`),[z,i]=a.useState("OGScope_xxxx"),[w,L]=a.useState(null),[A,I]=a.useState("—"),U=a.useCallback(s=>{const l=s.mode||"unknown",p=s.active_connection||"-",C=s.wireless_interface||"wlan0",D=s.ap_ipv4||"-",G=s.configured?"是":"否",J=s.message?`,消息: ${s.message}`:"";s.ap_url_hint&&P(s.ap_url_hint),s.ap_ssid&&i(s.ap_ssid);const Y=le();if(s.mdns_hostname_hint){const ne=`http://${s.mdns_hostname_hint}:${Y}/debug`;L(ne),I(ne)}else if(s.device_id_suffix){const ne=`http://${`ogscope-${s.device_id_suffix}.local`}:${Y}/debug`;L(ne),I(ne)}else L(null),I("未提供");r(`模式: ${l} | 活动连接: ${p} | 接口: ${C} | AP地址: ${D} | 已配置: ${G}${J}`)},[]),K=a.useCallback(async()=>{const s=await W("/api/network/wifi",{cache:"no-store"});U(s)},[U]);a.useEffect(()=>{K().catch(s=>r(`获取状态失败: ${s.message}`))},[K]);const Q=async s=>{r(`正在切换到 ${s.toUpperCase()}...`);const l=await W("/api/network/wifi",{method:"POST",body:JSON.stringify({mode:s})});U(l)},re=async()=>{k(!0),u("扫描中..."),g([]);try{const s=await W("/api/network/wifi/scan",{cache:"no-store"}),l=s.networks||[],p=s.hint?` ${s.hint}`:"";g(l),u(`扫描到 ${l.length} 个网络${p}`.trim())}catch(s){u(`扫描失败: ${s instanceof Error?s.message:String(s)}`)}finally{k(!1)}},ee=async s=>{const l=window.prompt(`输入密码: ${s}`,"");if(l!==null){d(""),r("正在连接..."),O(!0);try{const p=await W("/api/network/wifi/sta/connect",{method:"POST",body:JSON.stringify({ssid:s,password:l||null})});U(p);const C=p.mode||"unknown";d(C==="sta"?`连接成功,当前连接: ${p.active_connection||"—"}`:`连接请求已提交,当前模式: ${C},连接: ${p.active_connection||"—"}`),window.setTimeout(()=>void K().catch(()=>{}),2500)}catch(p){r(`连接失败: ${p instanceof Error?p.message:String(p)}`),d(`错误详情: ${p instanceof Error?p.message:String(p)}`)}finally{O(!1)}}},se=async()=>{const s=R.trim();if(!s){window.alert("请输入 SSID");return}d(""),r("正在连接..."),O(!0);try{const l=await W("/api/network/wifi/sta/connect",{method:"POST",body:JSON.stringify({ssid:s,password:E||null})});U(l);const p=l.mode||"unknown";d(p==="sta"?`连接成功,当前连接: ${l.active_connection||"—"}`:`连接请求已提交,当前模式: ${p},连接: ${l.active_connection||"—"}`),window.setTimeout(()=>void K().catch(()=>{}),2500)}catch(l){r(`连接失败: ${l instanceof Error?l.message:String(l)}`),d(`错误详情: ${l instanceof Error?l.message:String(l)}`)}finally{O(!1)}},ae=async()=>{M(!0);try{const s=await W("/api/network/wifi/profiles",{cache:"no-store"});m(s.profiles||[])}catch(s){m([]),window.alert(s instanceof Error?s.message:String(s))}finally{M(!1)}},x=async s=>{try{await W("/api/network/wifi/profile/activate",{method:"POST",body:JSON.stringify({connection_name:s})}),r("已发送激活请求")}catch(l){window.alert(l instanceof Error?l.message:String(l))}};async function F(s,l){const p=`http://${s}:${l}/health`;try{const C=new AbortController,D=window.setTimeout(()=>C.abort(),700),G=await fetch(p,{signal:C.signal,mode:"cors"});if(window.clearTimeout(D),!G.ok)return null;const J=await G.json();if(J&&J.status==="healthy")return`http://${s}:${l}`}catch{}return null}const X=async()=>{const s=le();_("扫描中...");const l=["192.168.0","192.168.1","192.168.31","10.0.0"];for(const p of l){const C=[];for(let D=1;D<255;D++)C.push(`${p}.${D}`);for(let D=0;DF(ce,s)))).find(Boolean);if(Y){_(`已找到设备: ${Y}`),window.location.href=`${Y}/debug`;return}}}_("未找到设备")},ie=a.useMemo(()=>{if(!t)return[];const s=t.wifi_quality!=null&&!Number.isNaN(Number(t.wifi_quality))?`${Number(t.wifi_quality).toFixed(1)}%`:"—",l=t.wifi_signal_dbm!=null&&!Number.isNaN(Number(t.wifi_signal_dbm))?`${Number(t.wifi_signal_dbm).toFixed(0)} dBm (${String(t.wifi_interface??"?")})`:"—";return[["平台",String(t.platform??"—")],["系统",String(t.os??"—")],["CPU 占用",`${Number(t.cpu_usage??0).toFixed(1)}%`],["内存占用",`${Number(t.memory_usage??0).toFixed(1)}%`],["CPU 温度",`${Number(t.temperature??0).toFixed(1)} °C`],["运行时长",Ve(t.uptime_seconds)],["1 分钟负载",String(t.load_average_1m??"—")],["WiFi 质量",s],["WiFi 信号",l]]},[t]);return e.jsxs("div",{className:"mx-auto max-w-7xl space-y-6",children:[e.jsxs("header",{children:[e.jsxs("div",{className:"flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:[e.jsx("span",{children:"Console"}),e.jsx("span",{children:"/"}),e.jsx("span",{className:"text-primary",children:"System_Network_Debug"})]}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:"NETWORK TERMINAL"}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:"WiFi 管理、模式切换、发现与恢复工具"})]}),o&&e.jsx("div",{className:"rounded-lg border border-error/40 bg-error-container/20 px-3 py-2 text-sm text-on-error-container",children:o}),e.jsxs("section",{className:"grid grid-cols-12 gap-6",children:[e.jsxs("div",{className:"col-span-12 space-y-6 lg:col-span-8",children:[e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container",children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-outline-variant/20 bg-surface-container-high px-4 py-3",children:[e.jsxs("div",{className:"flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-primary",children:[e.jsx(fe,{className:"h-4 w-4"}),"WiFi Control"]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-xs hover:border-primary",onClick:()=>void Q("sta").catch(s=>r(String(s))),children:"STA"}),e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-xs hover:border-primary",onClick:()=>void Q("ap").catch(s=>r(String(s))),children:"AP"}),e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 p-1.5 hover:border-primary",onClick:()=>void K().catch(s=>r(String(s))),children:e.jsx(he,{className:"h-3.5 w-3.5"})})]})]}),e.jsxs("div",{className:"space-y-3 p-4",children:[e.jsx("p",{className:"rounded border border-outline-variant/20 bg-surface-container-low p-3 font-mono text-xs text-on-surface",children:n}),e.jsxs("div",{className:"grid gap-2 text-xs text-on-surface-variant md:grid-cols-2",children:[e.jsxs("p",{children:["AP 地址:",e.jsx("span",{className:"font-mono text-on-surface",children:N})]}),e.jsxs("p",{children:["mDNS:"," ",w?e.jsx("a",{className:"font-mono text-primary underline",href:w,children:A}):e.jsx("span",{className:"font-mono text-on-surface",children:A})]})]})]})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between",children:[e.jsx("h3",{className:"text-sm font-semibold uppercase tracking-wider",children:"Scan Results"}),e.jsx("button",{type:"button",disabled:b,className:"rounded bg-primary-container px-3 py-1.5 text-xs font-medium text-on-primary-container disabled:opacity-50",onClick:()=>void re(),children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(_e,{className:"h-3.5 w-3.5"})," 扫描 WiFi"]})})]}),e.jsx("p",{className:"mb-3 text-xs text-on-surface-variant",children:j||"由设备端 NetworkManager 执行扫描"}),e.jsx("div",{className:"overflow-x-auto",children:e.jsxs("table",{className:"w-full text-left text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"border-b border-outline-variant/30 text-xs uppercase tracking-wider text-on-surface-variant",children:[e.jsx("th",{className:"p-2",children:"SSID"}),e.jsx("th",{className:"p-2",children:"信号"}),e.jsx("th",{className:"p-2",children:"安全"}),e.jsx("th",{className:"p-2 text-right",children:"操作"})]})}),e.jsx("tbody",{children:f.map(s=>e.jsxs("tr",{className:"border-b border-outline-variant/10",children:[e.jsx("td",{className:"p-2 font-mono",children:s.ssid}),e.jsx("td",{className:"p-2",children:s.signal??"—"}),e.jsx("td",{className:"p-2",children:s.security??"—"}),e.jsx("td",{className:"p-2 text-right",children:e.jsx("button",{type:"button",disabled:T,className:"rounded border border-primary/40 px-2 py-1 text-xs text-primary disabled:opacity-50",onClick:()=>void ee(s.ssid),children:"Connect"})})]},s.ssid))})]})})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:"Known Networks"}),e.jsx("button",{type:"button",disabled:$,className:"rounded border border-outline-variant/40 px-3 py-1.5 text-xs disabled:opacity-50",onClick:()=>void ae(),children:"刷新已保存网络"}),e.jsxs("table",{className:"mt-3 w-full text-left text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"border-b border-outline-variant/30 text-xs uppercase tracking-wider text-on-surface-variant",children:[e.jsx("th",{className:"p-2",children:"连接名"}),e.jsx("th",{className:"p-2",children:"SSID"}),e.jsx("th",{className:"p-2",children:"自动连接"}),e.jsx("th",{className:"p-2 text-right",children:"操作"})]})}),e.jsx("tbody",{children:y.map(s=>e.jsxs("tr",{className:"border-b border-outline-variant/10",children:[e.jsx("td",{className:"p-2",children:s.connection_name}),e.jsx("td",{className:"p-2",children:s.ssid}),e.jsx("td",{className:"p-2",children:s.autoconnect?"是":"否"}),e.jsx("td",{className:"p-2 text-right",children:e.jsx("button",{type:"button",className:"rounded border border-outline-variant/40 px-2 py-1 text-xs hover:border-primary",onClick:()=>void x(s.connection_name),children:"Activate"})})]},s.connection_name))})]})]})]}),e.jsxs("aside",{className:"col-span-12 space-y-6 lg:col-span-4",children:[e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:"System Monitor"}),e.jsx("div",{className:"grid gap-2",children:ie.map(([s,l])=>e.jsxs("div",{className:"flex justify-between border-b border-outline-variant/10 pb-1 text-xs",children:[e.jsx("span",{className:"text-on-surface-variant",children:s}),e.jsx("span",{className:"font-mono text-on-surface",children:l})]},s))})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:"Manual Connect"}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs("label",{className:"block text-xs text-on-surface-variant",children:["SSID",e.jsx("input",{className:"mt-1 w-full rounded border border-outline-variant/40 bg-surface-container-low px-2 py-1.5 text-sm",value:R,onChange:s=>S(s.target.value)})]}),e.jsxs("label",{className:"block text-xs text-on-surface-variant",children:["Password",e.jsx("input",{type:"password",className:"mt-1 w-full rounded border border-outline-variant/40 bg-surface-container-low px-2 py-1.5 text-sm",value:E,onChange:s=>h(s.target.value)})]}),e.jsx("button",{type:"button",disabled:T,className:"w-full rounded bg-primary-container px-4 py-2 text-sm font-medium text-on-primary-container disabled:opacity-50",onClick:()=>void se(),children:"连接并切换 STA"})]}),e.jsx("p",{className:"mt-2 text-xs text-on-surface-variant",children:c})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:"WiFi 引导"}),e.jsxs("ol",{className:"list-decimal space-y-1 pl-4 text-xs text-on-surface-variant",children:[e.jsxs("li",{children:["连接热点 ",e.jsx("strong",{children:z}),",密码 ",e.jsx("code",{children:"ogscopeadmin"})]}),e.jsxs("li",{children:["浏览器打开 ",e.jsxs("span",{className:"font-mono",children:["http://192.168.4.1:",le()]})]}),e.jsx("li",{children:"扫描 WiFi 或手动填写 SSID 连接"})]})]}),e.jsxs("div",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h3",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:"Find Device"}),e.jsx("p",{className:"mb-3 text-xs text-on-surface-variant",children:"扫描常见网段并探测 /health"}),e.jsx("button",{type:"button",className:"w-full rounded border border-outline-variant/40 px-3 py-2 text-sm hover:border-primary",onClick:()=>void X(),children:"扫描局域网"}),e.jsx("p",{className:"mt-2 text-xs text-on-surface-variant",children:v})]})]})]})]})}function es(t){var r,c;const o=(c=(r=t==null?void 0:t.data)==null?void 0:r.services)==null?void 0:c.hmi;if(!o||typeof o!="object")return null;const n=o.display;return!n||typeof n!="object"?null:n}function ss(t){try{return JSON.stringify(t,null,2)}catch{return String(t)}}function ts(){var v,_;const{t}=Z(),[o,n]=a.useState(null),[r,c]=a.useState(null),[d,j]=a.useState(!1),[u,f]=a.useState(null),[g,b]=a.useState(null),[k,y]=a.useState(40),[m,$]=a.useState(80),[M,R]=a.useState(200),S=a.useCallback(async()=>{try{c(null);const N=await Ke();n(N)}catch(N){n(null),c(N instanceof Error?N.message:String(N))}},[]);a.useEffect(()=>{S()},[S]);const E=a.useCallback(async(N,P={})=>{var z;j(!0),b(null);try{const i=await Xe({target:"hmi",action:N,payload:P,timeout_ms:8e3});if(f(i),!i.success){const A=((z=i.error)==null?void 0:z.message)??"RPC failed";b(A);return}const w=i.data,L=w==null?void 0:w.result;L&&L.accepted===!1&&L.message?b(L.message):b(null),await S()}catch(i){f(null),b(i instanceof Error?i.message:String(i))}finally{j(!1)}},[S]),h=es(o),T=(_=(v=o==null?void 0:o.data)==null?void 0:v.services)==null?void 0:_.hmi,O=typeof(T==null?void 0:T.screen_on)=="boolean"?T.screen_on:void 0;return e.jsxs("div",{className:"mx-auto max-w-6xl space-y-6",children:[e.jsxs("header",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:t("sys.placeholder.breadcrumb")}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:t("sys.hmi.title")}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:t("sys.hmi.desc")})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:t("sys.hmi.status.section")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 bg-surface-container px-3 py-1.5 text-xs font-medium text-on-surface hover:bg-surface-container-high",onClick:()=>void S(),disabled:d,children:t("sys.hmi.status.refresh")})]}),r?e.jsx("p",{className:"mt-2 text-sm text-error",children:r}):e.jsxs("dl",{className:"mt-3 grid gap-2 font-mono text-[11px] text-on-surface-variant sm:grid-cols-2",children:[e.jsxs("div",{children:[e.jsx("dt",{className:"text-on-surface-variant",children:t("sys.hmi.status.displayEnabled")}),e.jsx("dd",{className:"text-on-surface",children:(h==null?void 0:h.enabled)===!0?"true":"false"})]}),e.jsxs("div",{children:[e.jsx("dt",{children:t("sys.hmi.status.spidev")}),e.jsx("dd",{className:h!=null&&h.spidev_present?"text-primary":"text-error",children:h!=null&&h.spidev_present?t("sys.hmi.status.yes"):t("sys.hmi.status.no")})]}),e.jsxs("div",{children:[e.jsx("dt",{children:t("sys.hmi.status.resolution")}),e.jsxs("dd",{children:[(h==null?void 0:h.width)??"—"," × ",(h==null?void 0:h.height)??"—"," · DC GPIO ",(h==null?void 0:h.dc_pin)??"—"]})]}),e.jsxs("div",{children:[e.jsx("dt",{children:t("sys.hmi.status.driver")}),e.jsx("dd",{children:h!=null&&h.driver_open?t("sys.hmi.status.open"):t("sys.hmi.status.closed")})]}),e.jsxs("div",{children:[e.jsx("dt",{children:t("sys.hmi.status.screenOutput")}),e.jsx("dd",{children:O===void 0?"—":t(O?"sys.hmi.status.on":"sys.hmi.status.off")})]}),e.jsxs("div",{className:"sm:col-span-2",children:[e.jsx("dt",{children:t("sys.hmi.status.lastPattern")}),e.jsx("dd",{children:(h==null?void 0:h.last_pattern)??"—"})]}),h!=null&&h.last_error?e.jsxs("div",{className:"sm:col-span-2",children:[e.jsx("dt",{children:t("sys.hmi.status.lastError")}),e.jsx("dd",{className:"text-error",children:h.last_error})]}):null]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:t("sys.hmi.actions.section")}),e.jsx("p",{className:"mt-1 text-[11px] text-on-surface-variant",children:t("sys.hmi.actions.hint")}),e.jsxs("div",{className:"mt-4 flex flex-wrap gap-2",children:[e.jsx("button",{type:"button",className:"rounded-lg bg-primary px-3 py-2 text-xs font-semibold text-on-primary hover:opacity-90 disabled:opacity-50",disabled:d,onClick:()=>void E("display.test_pattern",{pattern:"smoke"}),children:t("sys.hmi.actions.smoke")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 bg-surface-container px-3 py-2 text-xs font-medium text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("display.test_pattern",{pattern:"colorbars"}),children:t("sys.hmi.actions.colorbars")})]}),e.jsxs("div",{className:"mt-6 flex flex-wrap items-end gap-3",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:["R",e.jsx("input",{type:"number",min:0,max:255,className:"w-20 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:k,onChange:N=>y(Number(N.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:["G",e.jsx("input",{type:"number",min:0,max:255,className:"w-20 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:m,onChange:N=>$(Number(N.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:["B",e.jsx("input",{type:"number",min:0,max:255,className:"w-20 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:M,onChange:N=>R(Number(N.target.value))})]}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 bg-surface-container px-3 py-2 text-xs font-medium text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("display.test_pattern",{pattern:"fill",r:k,g:m,b:M}),children:t("sys.hmi.actions.fill")})]}),e.jsxs("div",{className:"mt-6 flex flex-wrap gap-2 border-t border-outline-variant/20 pt-4",children:[e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 px-3 py-2 text-xs text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("screen.set",{on:!0}),children:t("sys.hmi.actions.screenOn")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 px-3 py-2 text-xs text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("screen.set",{on:!1}),children:t("sys.hmi.actions.screenOff")}),e.jsx("button",{type:"button",className:"rounded-lg border border-outline-variant/40 px-3 py-2 text-xs text-on-surface hover:bg-surface-container-high disabled:opacity-50",disabled:d,onClick:()=>void E("display.release"),children:t("sys.hmi.actions.release")})]}),g?e.jsx("p",{className:"mt-3 text-sm text-error",children:g}):null,e.jsxs("details",{className:"mt-4",children:[e.jsx("summary",{className:"cursor-pointer text-[11px] text-on-surface-variant",children:t("sys.hmi.rawJson")}),e.jsx("pre",{className:"mt-2 max-h-64 overflow-auto rounded border border-outline-variant/30 bg-surface-container p-2 font-mono text-[10px] text-on-surface",children:u?ss(u):"—"})]})]})]})}const rs={sensors:{titleKey:"sys.placeholder.sensors.title",descKey:"sys.placeholder.sensors.desc",blocks:["sys.placeholder.sensors.block1","sys.placeholder.sensors.block2","sys.placeholder.sensors.block3"]},hmi:{titleKey:"sys.placeholder.hmi.title",descKey:"sys.placeholder.hmi.desc",blocks:["sys.placeholder.hmi.block1","sys.placeholder.hmi.block2","sys.placeholder.hmi.block3"]},power:{titleKey:"sys.placeholder.power.title",descKey:"sys.placeholder.power.desc",blocks:["sys.placeholder.power.block1","sys.placeholder.power.block2","sys.placeholder.power.block3"]}};function ve({scope:t}){const{t:o}=Z(),n=rs[t];return e.jsxs("div",{className:"mx-auto max-w-6xl space-y-6",children:[e.jsxs("header",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:o("sys.placeholder.breadcrumb")}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:o(n.titleKey)}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:o(n.descKey)})]}),e.jsx("section",{className:"grid grid-cols-12 gap-4",children:n.blocks.map(r=>e.jsxs("article",{className:"col-span-12 rounded-xl border border-dashed border-outline-variant/40 bg-surface-container/60 p-5 md:col-span-4",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-primary",children:o("sys.placeholder.block")}),e.jsx("h3",{className:"mt-2 text-lg font-semibold",children:o(r)}),e.jsx("p",{className:"mt-2 text-sm text-on-surface-variant",children:o("sys.placeholder.desc")})]},r))}),e.jsx("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:e.jsx("p",{className:"font-mono text-xs text-on-surface-variant",children:o("sys.placeholder.status")})})]})}const B=62,te=4;function as(t){const{rollDeg:o,pitchDeg:n,yawRateDps:r}=t,{t:c}=Z(),d=`rotateX(${-n}deg) rotateY(${o}deg)`;return e.jsxs("div",{className:"mt-5 rounded-xl border border-teal-500/25 bg-black/25 p-4 ring-1 ring-teal-500/10",children:[e.jsx("p",{className:"text-center text-[11px] font-semibold text-teal-200/95",children:c("sys.sensors.gyro.att3dTitle")}),e.jsx("p",{className:"mx-auto mt-1 max-w-xl text-center text-[10px] leading-relaxed text-on-surface-variant",children:c("sys.sensors.gyro.att3dDesc")}),e.jsxs("div",{className:"mt-4 flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between xl:gap-8",children:[e.jsxs("div",{className:"flex min-w-0 flex-1 flex-col items-center gap-4",children:[e.jsx("div",{className:"flex shrink-0 items-center justify-center py-2",style:{perspective:"820px"},children:e.jsxs("div",{className:"relative h-[188px] w-[220px]",style:{transformStyle:"preserve-3d",transform:d},children:[e.jsxs("div",{className:"absolute left-[10px] top-[18px] h-[152px] w-[200px] rounded-2xl border-2 border-teal-400/75 bg-gradient-to-br from-slate-600/90 via-slate-800/95 to-slate-950 shadow-[0_20px_50px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.08)] transition-transform duration-150 ease-out",style:{transformStyle:"preserve-3d"},children:[e.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-2 flex justify-center",children:e.jsx("span",{className:"rounded bg-black/35 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.2em] text-teal-100",children:"TOP"})}),e.jsx("div",{className:"absolute bottom-2 left-2 font-mono text-[9px] text-slate-400",children:"MPU-6050"})]}),e.jsxs("div",{className:"pointer-events-none absolute left-[110px] top-[94px] h-0 w-0",style:{transformStyle:"preserve-3d"},children:[e.jsx("div",{className:"absolute rounded-full bg-white/90 shadow-[0_0_6px_rgba(255,255,255,0.6)]",style:{width:7,height:7,left:-3.5,top:-3.5,transform:"translateZ(0.5px)"}}),e.jsx("div",{className:"absolute bg-amber-400 shadow-md ring-1 ring-amber-200/35",style:{width:B,height:te,left:0,top:-te/2,transformOrigin:"0 50%"}}),e.jsx("span",{className:"absolute whitespace-nowrap font-mono text-[10px] font-bold text-amber-200",style:{left:B+4,top:-8},children:c("sys.sensors.gyro.axisXLabel")}),e.jsx("div",{className:"absolute bg-sky-400 shadow-md ring-1 ring-sky-200/30",style:{width:B,height:te,left:0,top:-te/2,transformOrigin:"0 50%",transform:"rotateZ(90deg)"}}),e.jsx("span",{className:"absolute whitespace-nowrap font-mono text-[10px] font-bold text-sky-200",style:{left:-6,top:-B-16},children:c("sys.sensors.gyro.axisYLabel")}),e.jsx("div",{className:"absolute bg-violet-400 shadow-md ring-1 ring-violet-200/35",style:{width:B,height:te,left:0,top:-te/2,transformOrigin:"0 50%",transform:"rotateY(-90deg)"}}),e.jsx("span",{className:"absolute whitespace-nowrap font-mono text-[10px] font-bold text-violet-200",style:{left:B*.35,top:-B*.45,transform:"translateZ(28px)"},children:c("sys.sensors.gyro.axisZLabel")}),e.jsx("div",{className:"absolute bg-amber-900/55",style:{width:B*.45,height:2,left:-B*.45,top:-1,transformOrigin:"100% 50%"}}),e.jsx("div",{className:"absolute bg-sky-900/50",style:{width:B*.45,height:2,left:0,top:-1,transformOrigin:"0 50%",transform:`rotateZ(90deg) translateX(${-B*.45}px)`}}),e.jsx("div",{className:"absolute bg-violet-900/45",style:{width:B*.4,height:2,left:0,top:-1,transformOrigin:"0 50%",transform:`rotateY(-90deg) translateX(${-B*.4}px)`}})]})]})}),e.jsx("p",{className:"max-w-md text-center text-[10px] leading-snug text-on-surface-variant",children:c("sys.sensors.gyro.att3dBodyAxes")})]}),e.jsxs("div",{className:"flex w-full max-w-[220px] shrink-0 flex-col items-center gap-2 self-center xl:self-start",children:[e.jsx("p",{className:"text-center text-[10px] font-medium text-on-surface-variant",children:c("sys.sensors.gyro.att3dRefTitle")}),e.jsx("div",{className:"flex items-center justify-center rounded-lg border border-outline-variant/30 bg-slate-950/50 px-4 py-5 ring-1 ring-white/5",children:e.jsx("div",{style:{perspective:"280px"},children:e.jsx("div",{className:"relative h-[100px] w-[100px]",style:{transformStyle:"preserve-3d",transform:"rotateX(58deg) rotateZ(-42deg)"},children:e.jsxs("div",{className:"absolute left-1/2 top-1/2 h-0 w-0",style:{transformStyle:"preserve-3d"},children:[e.jsx("div",{className:"absolute bg-amber-400/95",style:{width:44,height:3,left:0,top:-1.5,transformOrigin:"0 50%"}}),e.jsx("div",{className:"absolute bg-sky-400/95",style:{width:44,height:3,left:0,top:-1.5,transformOrigin:"0 50%",transform:"rotateZ(90deg)"}}),e.jsx("div",{className:"absolute bg-violet-400/95",style:{width:44,height:3,left:0,top:-1.5,transformOrigin:"0 50%",transform:"rotateY(-90deg)"}}),e.jsx("div",{className:"absolute rounded-full bg-white/80",style:{width:5,height:5,left:-2.5,top:-2.5}})]})})})}),e.jsx("p",{className:"text-center text-[9px] leading-relaxed text-on-surface-variant/85",children:c("sys.sensors.gyro.att3dRefDesc")})]}),e.jsxs("div",{className:"grid w-full min-w-[200px] max-w-md grid-cols-3 gap-3 font-mono text-[11px] lg:max-w-lg xl:max-w-[340px]",children:[e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/80 px-2 py-2 text-center",children:[e.jsx("p",{className:"text-[9px] uppercase tracking-wider text-on-surface-variant",children:c("sys.sensors.gyro.roll")}),e.jsxs("p",{className:"mt-1 text-lg font-bold tabular-nums text-teal-200",children:[o.toFixed(1),"°"]})]}),e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/80 px-2 py-2 text-center",children:[e.jsx("p",{className:"text-[9px] uppercase tracking-wider text-on-surface-variant",children:c("sys.sensors.gyro.pitch")}),e.jsxs("p",{className:"mt-1 text-lg font-bold tabular-nums text-teal-200",children:[n.toFixed(1),"°"]})]}),e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/80 px-2 py-2 text-center",children:[e.jsx("p",{className:"text-[9px] uppercase tracking-wider text-on-surface-variant",children:c("sys.sensors.gyro.yawRate")}),e.jsx("p",{className:"mt-1 text-lg font-bold tabular-nums text-amber-200/95",children:r.toFixed(2)}),e.jsx("p",{className:"text-[9px] text-on-surface-variant",children:"°/s"})]})]})]})]})}const xe=250;function ue(t){const{label:o,value:n,maxAbs:r,unit:c}=t,d=Math.max(-1,Math.min(1,n/r)),j=d>=0?50:50+d*50,u=Math.abs(d)*50;return e.jsxs("div",{className:"space-y-1",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-2",children:[e.jsx("span",{className:"text-[11px] font-medium text-on-surface",children:o}),e.jsxs("span",{className:"font-mono text-xs tabular-nums text-sky-100",children:[n.toFixed(2),e.jsxs("span",{className:"text-on-surface-variant",children:[" ",c]})]})]}),e.jsxs("div",{className:"relative h-5 w-full overflow-hidden rounded-md bg-slate-900/80 ring-1 ring-slate-600/50",children:[e.jsx("div",{className:"absolute left-1/2 top-0 z-10 h-full w-px -translate-x-px bg-slate-500/90"}),e.jsx("div",{className:"absolute top-1 h-3 rounded-sm bg-gradient-to-r from-emerald-600 to-sky-500 shadow-sm",style:{left:`${j}%`,width:`${Math.max(u,d===0?0:.8)}%`}})]})]})}function ns(t){const{bus:o,addr:n}=t,{t:r}=Z(),[c,d]=a.useState(!1),[j,u]=a.useState(null),[f,g]=a.useState(null),[b,k]=a.useState(null),[y,m]=a.useState(null),[$,M]=a.useState(null),[R,S]=a.useState(null),[E,h]=a.useState(!1),T=a.useRef(null),O=a.useCallback(async()=>{var _;d(!0),u(null);try{const N=new URLSearchParams({bus:String(o),addr:String(n)}),P=await q(`/api/debug/sensors/mpu6050/imu-sample?${N.toString()}`);if(!P.success){g(null),k(null),m(null),M(null),S(null),u(((_=P.sample)==null?void 0:_.error)||P.error||r("sys.sensors.gyro.errUnknown"));return}g(P.gyro_dps??null),k(P.gyro_raw??null),m(P.tilt_deg??null),M(P.yaw_rate_dps??null),S(P.accel_g??null)}catch(N){g(null),k(null),m(null),M(null),S(null),u(N instanceof Error?N.message:String(N))}finally{d(!1)}},[n,o,r]);a.useEffect(()=>{if(!E){T.current&&(clearInterval(T.current),T.current=null);return}return T.current=setInterval(()=>void O(),500),()=>{T.current&&clearInterval(T.current)}},[E,O]);const v=y!=null&&$!=null&&Number.isFinite(y.roll)&&Number.isFinite(y.pitch);return e.jsxs("div",{className:"mt-6 rounded-xl border border-emerald-600/35 bg-gradient-to-br from-slate-900/40 to-surface-container/70 p-4",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("p",{className:"text-sm font-semibold text-on-surface",children:r("sys.sensors.gyro.title")}),e.jsx("p",{className:"mt-1 max-w-xl text-[11px] leading-snug text-on-surface-variant",children:r("sys.sensors.gyro.subtitle")})]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsxs("label",{className:"flex cursor-pointer items-center gap-1.5 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:E,onChange:_=>h(_.target.checked)}),r("sys.sensors.gyro.live")]}),e.jsx("button",{type:"button",disabled:c,className:"rounded-lg bg-emerald-800/90 px-3 py-1.5 text-xs font-medium text-emerald-50 hover:bg-emerald-700 disabled:opacity-50",onClick:()=>void O(),children:r(c?"sys.sensors.gyro.loading":"sys.sensors.gyro.btn")})]})]}),v&&e.jsx(as,{rollDeg:y.roll,pitchDeg:y.pitch,yawRateDps:$}),f&&e.jsxs("div",{className:"mt-5 grid gap-4 md:grid-cols-3",children:[e.jsxs("div",{className:"rounded-lg border border-emerald-500/20 bg-black/20 px-3 py-3 text-center md:col-span-1",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"ωx"}),e.jsx("p",{className:"font-mono text-3xl font-bold tabular-nums text-emerald-200",children:f.x.toFixed(2)}),e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"°/s"})]}),e.jsxs("div",{className:"rounded-lg border border-emerald-500/20 bg-black/20 px-3 py-3 text-center md:col-span-1",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"ωy"}),e.jsx("p",{className:"font-mono text-3xl font-bold tabular-nums text-emerald-200",children:f.y.toFixed(2)}),e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"°/s"})]}),e.jsxs("div",{className:"rounded-lg border border-emerald-500/20 bg-black/20 px-3 py-3 text-center md:col-span-1",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"ωz"}),e.jsx("p",{className:"font-mono text-3xl font-bold tabular-nums text-emerald-200",children:f.z.toFixed(2)}),e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:"°/s"})]})]}),f&&e.jsxs("div",{className:"mt-5 space-y-4",children:[e.jsx("p",{className:"text-[10px] font-medium uppercase tracking-wider text-on-surface-variant",children:r("sys.sensors.gyro.barsTitle")}),e.jsx(ue,{label:"X",value:f.x,maxAbs:xe,unit:"°/s"}),e.jsx(ue,{label:"Y",value:f.y,maxAbs:xe,unit:"°/s"}),e.jsx(ue,{label:"Z",value:f.z,maxAbs:xe,unit:"°/s"})]}),R&&e.jsxs("p",{className:"mt-3 text-center font-mono text-[10px] text-on-surface-variant",children:["g — X:",R.x.toFixed(3)," Y:",R.y.toFixed(3)," Z:",R.z.toFixed(3)]}),b&&e.jsxs("div",{className:"mt-4 rounded border border-outline-variant/25 bg-surface-container/50 px-3 py-2",children:[e.jsx("p",{className:"text-[10px] text-on-surface-variant",children:r("sys.sensors.gyro.rawBlock")}),e.jsxs("p",{className:"mt-1 font-mono text-[11px] tabular-nums text-on-surface",children:["raw X=",b.x," · Y=",b.y," · Z=",b.z]})]}),!f&&!j&&!c&&e.jsx("p",{className:"mt-4 text-[11px] text-on-surface-variant",children:r("sys.sensors.gyro.hint")}),j&&e.jsx("p",{className:"mt-3 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 font-mono text-[11px] text-amber-100",children:j})]})}const be=3.4,Ce=7,os=3;function ls(t){const o=(t%360+360)%360;return o<1||o>359?"cardN":Math.abs(o-90)<1?"cardE":Math.abs(o-180)<1?"cardS":Math.abs(o-270)<1?"cardW":null}function is(){const t=[],o=Ce*360;for(let n=0;n<=o;n+=5){const r=n%360,c=n%30===0,d=!c&&n%10===0,j=ls(r),u=!j&&c&&r%90!==0?r:void 0;t.push({x:n*be,deg:n,h:c?"maj":d?"mid":"min",degLabel:u,cardKey:j??void 0})}return t}const cs=is(),je=Ce*360*be;function ds(t,o){const n=(t%360+360)%360;let c=(o%360+360)%360-n;return c=(c+180)%360-180,t+c}function xs(t){const{bus:o,addr:n}=t,{t:r}=Z(),[c,d]=a.useState(!1),[j,u]=a.useState(null),[f,g]=a.useState(null),[b,k]=a.useState(null),[y,m]=a.useState(!1),$=a.useRef(null),M=a.useRef(0),R=a.useRef(null),[S,E]=a.useState(320);a.useEffect(()=>{const v=R.current;if(!v)return;const _=new ResizeObserver(()=>{E(Math.max(200,v.clientWidth))});return _.observe(v),E(Math.max(200,v.clientWidth)),()=>_.disconnect()},[]),a.useEffect(()=>{g(null),k(null),u(null)},[o,n]);const h=a.useCallback(async()=>{const v=++M.current;d(!0);try{const _=new URLSearchParams({bus:String(o),addr:String(n)}),N=await q(`/api/debug/sensors/magnetometer/sample?${_.toString()}`);if(v!==M.current)return;if(!N.success){g(null),k(null),u(N.error||r("sys.sensors.compass.err"));return}u(null);const P=N.heading_deg??null;P!=null&&g(z=>z==null?os*360+P:ds(z,P)),k(N.field_ut??null)}catch(_){if(v!==M.current)return;g(null),k(null),u(_ instanceof Error?_.message:String(_))}finally{v===M.current&&d(!1)}},[n,o,r]);a.useEffect(()=>{if(!y){$.current&&(clearInterval($.current),$.current=null);return}return $.current=setInterval(()=>{h()},850),()=>{$.current&&clearInterval($.current)}},[y,h]);const T=a.useMemo(()=>{if(f==null)return 0;const v=f*be;return S/2-v},[f,S]),O=f!=null?(f%360+360)%360:null;return e.jsxs("div",{className:"rounded-xl border border-sky-500/30 bg-gradient-to-b from-slate-900/90 via-slate-900/70 to-surface-container/90 p-4",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("p",{className:"text-sm font-semibold text-on-surface",children:r("sys.sensors.compass.title")}),e.jsx("p",{className:"mt-1 max-w-xl text-[11px] leading-snug text-on-surface-variant",children:r("sys.sensors.compass.desc")})]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsxs("label",{className:"flex cursor-pointer items-center gap-1.5 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:y,onChange:v=>m(v.target.checked)}),r("sys.sensors.compass.live")]}),e.jsx("button",{type:"button",disabled:c,className:"rounded-lg bg-sky-700/80 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-50",onClick:()=>void h(),children:r(c?"sys.sensors.compass.loading":"sys.sensors.compass.btn")})]})]}),e.jsxs("div",{className:"mt-5",children:[e.jsx("p",{className:"mb-2 text-center text-[10px] uppercase tracking-[0.2em] text-sky-300/90",children:r("sys.sensors.compass.tapeCaption")}),e.jsxs("div",{ref:R,className:"relative mx-auto h-[112px] w-full max-w-3xl overflow-hidden rounded-lg ring-1 ring-sky-500/25",style:{maskImage:"linear-gradient(90deg, transparent 0%, black 10%, black 90%, transparent 100%)",WebkitMaskImage:"linear-gradient(90deg, transparent 0%, black 10%, black 90%, transparent 100%)"},children:[e.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center",children:e.jsxs("div",{className:"flex flex-col items-center",children:[e.jsx("div",{className:"h-0 w-0 border-x-[9px] border-x-transparent border-b-[12px] border-b-amber-400 drop-shadow"}),e.jsx("div",{className:"h-[88px] w-0.5 rounded-full bg-gradient-to-b from-amber-300/95 to-sky-400/40"})]})}),e.jsx("div",{className:"absolute bottom-0 left-0 top-0 will-change-transform",style:{width:je,transform:`translateX(${T}px)`,transition:"transform 0.42s cubic-bezier(0.22, 0.95, 0.28, 1)"},children:e.jsxs("div",{className:"relative h-full",style:{width:je,background:"linear-gradient(180deg, rgba(15,23,42,0.2) 0%, rgba(30,41,59,0.85) 40%, rgba(15,23,42,0.95) 100%)"},children:[e.jsx("div",{className:"absolute left-0 right-0 top-8 h-px bg-slate-600/60"}),cs.map(v=>{const _=v.h==="maj"?22:v.h==="mid"?14:8;return e.jsxs("div",{className:"absolute flex flex-col items-center",style:{left:v.x,transform:"translateX(-50%)",top:32},children:[(v.cardKey||v.degLabel!=null)&&e.jsx("span",{className:`mb-0.5 whitespace-nowrap font-mono ${v.cardKey?"text-[13px] font-bold text-sky-200":"text-[10px] font-medium text-slate-400"}`,style:{marginTop:-18},children:v.cardKey?r(`sys.sensors.compass.${v.cardKey}`):String(v.degLabel)}),e.jsx("div",{className:`w-px rounded-full ${v.h==="maj"?"bg-sky-300/90":v.h==="mid"?"bg-slate-500/85":"bg-slate-600/50"}`,style:{height:_}})]},v.deg)}),e.jsx("div",{className:"absolute bottom-6 left-0 right-0 h-px bg-slate-600/40"})]})})]})]}),e.jsxs("div",{className:"mt-5 flex flex-col gap-4 md:flex-row md:items-start md:justify-center md:gap-8",children:[e.jsxs("div",{className:"rounded-lg border border-outline-variant/30 bg-surface-container/90 px-6 py-4 text-center md:min-w-[200px]",children:[e.jsx("p",{className:"text-[10px] uppercase tracking-widest text-on-surface-variant",children:r("sys.sensors.compass.headingLabel")}),e.jsx("p",{className:"font-mono text-4xl font-bold tabular-nums text-sky-200",children:O!=null?`${O.toFixed(1)}°`:"—"}),e.jsx("p",{className:"mt-1 text-[10px] text-on-surface-variant",children:r("sys.sensors.compass.headingHint")})]}),b&&e.jsxs("div",{className:"rounded-lg border border-outline-variant/20 px-4 py-3 font-mono text-[11px] text-on-surface md:max-w-md",children:[e.jsx("p",{className:"mb-1 text-[10px] text-on-surface-variant",children:"µT (X / Y / Z)"}),e.jsxs("p",{className:"tabular-nums",children:[b.x.toFixed(2)," · ",b.y.toFixed(2)," · ",b.z.toFixed(2)]})]})]}),j&&e.jsx("p",{className:"mt-3 rounded border border-amber-500/40 bg-amber-500/10 px-3 py-2 font-mono text-[11px] text-amber-100",children:j}),e.jsx("p",{className:"mt-3 text-[10px] leading-relaxed text-on-surface-variant/90",children:r("sys.sensors.compass.footnote")})]})}function Ne(t){try{return JSON.stringify(t,null,2)}catch{return String(t)}}function us(){const{t}=Z(),[o,n]=a.useState(1),[r,c]=a.useState(12),[d,j]=a.useState(!0),[u,f]=a.useState(!1),[g,b]=a.useState(null),[k,y]=a.useState(null),[m,$]=a.useState(null),[M,R]=a.useState(null),[S,E]=a.useState(1),[h,T]=a.useState(104),[O,v]=a.useState(!0),[_,N]=a.useState(!1),[P,z]=a.useState(null),[i,w]=a.useState(null),L=a.useCallback(async()=>{f(!0),b(null);try{const x=new URLSearchParams({bus:String(o),addr:String(r),i2cdetect:d?"true":"false"}),F=await q(`/api/debug/sensors/magnetometer/selftest?${x.toString()}`);y(F)}catch(x){y(null),b(x instanceof Error?x.message:String(x))}finally{f(!1)}},[r,o,d]),A=a.useCallback(async()=>{f(!0),b(null);try{const x=new URLSearchParams({addr:String(r)}),F=await q(`/api/debug/sensors/magnetometer/probe-buses?${x.toString()}`);y(F)}catch(x){y(null),b(x instanceof Error?x.message:String(x))}finally{f(!1)}},[r]),I=a.useCallback(async()=>{f(!0),b(null);try{const x=new URLSearchParams({bus:String(o),addr:String(r)}),F=await q(`/api/debug/sensors/magnetometer/calibration/start?${x.toString()}`,{method:"POST"});y(F),R("已开始方向校准,请缓慢旋转设备 5-15 秒。");const X=await q(`/api/debug/sensors/magnetometer/calibration/status?${x.toString()}`);$(X)}catch(x){y(null),b(x instanceof Error?x.message:String(x))}finally{f(!1)}},[r,o]),U=a.useCallback(async()=>{f(!0),b(null);try{const x=new URLSearchParams({bus:String(o),addr:String(r)}),F=await q(`/api/debug/sensors/magnetometer/calibration/commit?${x.toString()}`,{method:"POST"});y(F),R("已保存并锁定方向校准。");const X=await q(`/api/debug/sensors/magnetometer/calibration/status?${x.toString()}`);$(X)}catch(x){y(null),b(x instanceof Error?x.message:String(x))}finally{f(!1)}},[r,o]),K=a.useCallback(async()=>{f(!0),b(null);try{const x=new URLSearchParams({bus:String(o),addr:String(r)}),F=await q(`/api/debug/sensors/magnetometer/calibration/reset?${x.toString()}`,{method:"POST"});y(F),R("已重置到自动模式。");const X=await q(`/api/debug/sensors/magnetometer/calibration/status?${x.toString()}`);$(X)}catch(x){y(null),b(x instanceof Error?x.message:String(x))}finally{f(!1)}},[r,o]),Q=a.useCallback(async()=>{f(!0),b(null);try{const x=new URLSearchParams({bus:String(o),addr:String(r)}),F=await q(`/api/debug/sensors/magnetometer/calibration/status?${x.toString()}`);y(F),$(F)}catch(x){y(null),b(x instanceof Error?x.message:String(x))}finally{f(!1)}},[r,o]);a.useEffect(()=>{const x=new URLSearchParams({bus:String(o),addr:String(r)});q(`/api/debug/sensors/magnetometer/calibration/status?${x.toString()}`).then(F=>$(F)).catch(()=>{})},[r,o]),a.useEffect(()=>{if((m==null?void 0:m.mode)!=="recording")return;const x=setInterval(()=>{const F=new URLSearchParams({bus:String(o),addr:String(r)});q(`/api/debug/sensors/magnetometer/calibration/status?${F.toString()}`).then(X=>$(X)).catch(()=>{})},900);return()=>clearInterval(x)},[m==null?void 0:m.mode,o,r]);const re=a.useMemo(()=>{const x=(m==null?void 0:m.mode)??"auto";return t(x==="recording"?"sys.sensors.mag.calModeRecording":x==="locked"?"sys.sensors.mag.calModeLocked":"sys.sensors.mag.calModeAuto")},[m==null?void 0:m.mode,t]),ee=Number((m==null?void 0:m.samples)??0),se=(m==null?void 0:m.mode)==="recording"&&ee>=10,ae=a.useCallback(async()=>{N(!0),z(null);try{const x=new URLSearchParams({bus:String(S),addr:String(h),i2cdetect:O?"true":"false"}),F=await q(`/api/debug/sensors/mpu6050/selftest?${x.toString()}`);w(F)}catch(x){w(null),z(x instanceof Error?x.message:String(x))}finally{N(!1)}},[h,S,O]);return e.jsxs("div",{className:"mx-auto max-w-6xl space-y-6",children:[e.jsxs("header",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:t("sys.placeholder.breadcrumb")}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:t("sys.sensors.title")}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:t("sys.sensors.desc")})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:t("sys.sensors.mag.section")}),e.jsx("p",{className:"mt-1 text-[11px] text-on-surface-variant",children:t("sys.sensors.mag.note")}),e.jsxs("div",{className:"mt-4 flex flex-wrap items-end gap-4",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[t("sys.sensors.mag.bus"),e.jsx("input",{type:"number",min:0,max:32,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:o,onChange:x=>n(Number(x.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[t("sys.sensors.mag.addr"),e.jsx("input",{type:"number",min:1,max:127,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:r,onChange:x=>c(Number(x.target.value))})]}),e.jsxs("label",{className:"flex items-center gap-2 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:d,onChange:x=>j(x.target.checked)}),t("sys.sensors.mag.i2cdetect")]})]}),e.jsx(xs,{bus:o,addr:r}),e.jsxs("div",{className:"mt-6 flex flex-wrap gap-2",children:[e.jsx("button",{type:"button",disabled:u,className:"rounded-lg bg-primary px-4 py-2 text-sm font-medium text-on-primary hover:opacity-90 disabled:opacity-50",onClick:()=>void L(),children:t(u?"sys.sensors.mag.running":"sys.sensors.mag.btnSelftest")}),e.jsx("button",{type:"button",disabled:u,className:"rounded-lg border border-outline-variant/40 px-4 py-2 text-sm text-on-surface hover:bg-surface-container/80 disabled:opacity-50",onClick:()=>void A(),children:t("sys.sensors.mag.btnProbe")})]}),e.jsxs("div",{className:"mt-3 flex flex-wrap gap-2",children:[e.jsx("button",{type:"button",disabled:u||(m==null?void 0:m.mode)==="recording",className:"rounded-lg border border-amber-400/40 px-3 py-1.5 text-xs text-on-surface hover:bg-amber-500/10 disabled:opacity-50",onClick:()=>void I(),children:t("sys.sensors.mag.btnCalStart")}),e.jsx("button",{type:"button",disabled:u||!se,className:"rounded-lg border border-emerald-400/40 px-3 py-1.5 text-xs text-on-surface hover:bg-emerald-500/10 disabled:opacity-50",onClick:()=>void U(),children:t("sys.sensors.mag.btnCalCommit")}),e.jsx("button",{type:"button",disabled:u,className:"rounded-lg border border-rose-400/40 px-3 py-1.5 text-xs text-on-surface hover:bg-rose-500/10 disabled:opacity-50",onClick:()=>void K(),children:t("sys.sensors.mag.btnCalReset")}),e.jsx("button",{type:"button",disabled:u,className:"rounded-lg border border-outline-variant/40 px-3 py-1.5 text-xs text-on-surface hover:bg-surface-container/80 disabled:opacity-50",onClick:()=>void Q(),children:t("sys.sensors.mag.btnCalStatus")})]}),e.jsxs("div",{className:"mt-3 rounded-lg border border-outline-variant/30 bg-surface-container/60 px-3 py-2 text-[11px] text-on-surface",children:[e.jsxs("p",{className:"font-medium",children:[t("sys.sensors.mag.calStatusPrefix")," ",re]}),e.jsxs("p",{className:"mt-1 text-on-surface-variant",children:[t("sys.sensors.mag.calSamplesPrefix")," ",ee,(m==null?void 0:m.mode)==="recording"?" / 10+":""]}),(m==null?void 0:m.span_xyz)&&e.jsxs("p",{className:"mt-1 font-mono text-[10px] text-on-surface-variant",children:["span xyz: ",Number(m.span_xyz.x??0).toFixed(1)," /"," ",Number(m.span_xyz.y??0).toFixed(1)," /"," ",Number(m.span_xyz.z??0).toFixed(1)]}),(m==null?void 0:m.mode)==="recording"&&e.jsx("p",{className:"mt-1 text-amber-200",children:t("sys.sensors.mag.calRecordingHint")}),(m==null?void 0:m.mode)==="locked"&&m.locked&&e.jsxs("p",{className:"mt-1 text-emerald-200",children:[t("sys.sensors.mag.calLockedHint")," axes=",String(m.locked.axes_pair??"xy")]}),M&&e.jsx("p",{className:"mt-1 text-sky-200",children:M})]}),g&&e.jsx("p",{className:"mt-3 rounded border border-red-500/40 bg-red-500/10 px-3 py-2 font-mono text-xs text-red-200",children:g}),k&&e.jsxs("details",{className:"mt-4 rounded-lg border border-outline-variant/30 bg-surface-container/50",children:[e.jsx("summary",{className:"cursor-pointer px-3 py-2 text-[11px] text-on-surface-variant",children:t("sys.sensors.jsonToggle")}),e.jsx("pre",{className:"max-h-[320px] overflow-auto border-t border-outline-variant/20 p-3 font-mono text-[11px] leading-relaxed text-on-surface",children:Ne(k)})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container-low p-4",children:[e.jsx("p",{className:"text-xs font-medium text-on-surface",children:t("sys.sensors.mpu.section")}),e.jsx("p",{className:"mt-1 text-[11px] text-on-surface-variant",children:t("sys.sensors.mpu.note")}),e.jsxs("div",{className:"mt-4 flex flex-wrap items-end gap-4",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[t("sys.sensors.mag.bus"),e.jsx("input",{type:"number",min:0,max:32,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:S,onChange:x=>E(Number(x.target.value))})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-[11px] text-on-surface-variant",children:[t("sys.sensors.mpu.addr"),e.jsx("input",{type:"number",min:1,max:127,className:"w-24 rounded border border-outline-variant/40 bg-surface-container px-2 py-1 font-mono text-sm text-on-surface",value:h,onChange:x=>T(Number(x.target.value))})]}),e.jsxs("label",{className:"flex items-center gap-2 text-[11px] text-on-surface-variant",children:[e.jsx("input",{type:"checkbox",checked:O,onChange:x=>v(x.target.checked)}),t("sys.sensors.mag.i2cdetect")]})]}),e.jsx(ns,{bus:S,addr:h}),e.jsx("div",{className:"mt-4 flex flex-wrap gap-2",children:e.jsx("button",{type:"button",disabled:_,className:"rounded-lg bg-primary px-4 py-2 text-sm font-medium text-on-primary hover:opacity-90 disabled:opacity-50",onClick:()=>void ae(),children:t(_?"sys.sensors.mpu.running":"sys.sensors.mpu.btnSelftest")})}),P&&e.jsx("p",{className:"mt-3 rounded border border-red-500/40 bg-red-500/10 px-3 py-2 font-mono text-xs text-red-200",children:P}),i&&e.jsxs("details",{className:"mt-4 rounded-lg border border-outline-variant/30 bg-surface-container/50",children:[e.jsx("summary",{className:"cursor-pointer px-3 py-2 text-[11px] text-on-surface-variant",children:t("sys.sensors.jsonToggle")}),e.jsx("pre",{className:"max-h-[320px] overflow-auto border-t border-outline-variant/20 p-3 font-mono text-[11px] leading-relaxed text-on-surface",children:Ne(i)})]})]})]})}const ms={ogscope:{zh:"主配置 ogscope.env",en:"Primary ogscope.env"},network:{zh:"网络 network.env",en:"Network network.env"}};function ps(t){const o=[];let n=0;return t.split(/\r?\n/).forEach((c,d)=>{const j=c.trim();if(!j||j.startsWith("#"))return;const u=j.startsWith("export ")?j.slice(7):j,f=u.indexOf("=");if(f<=0){n+=1;return}const g=u.slice(0,f).trim();if(!g){n+=1;return}const b=u.slice(f+1);o.push({id:`env-${d}-${g}`,key:g,value:b})}),{entries:o,unsupportedLines:n}}function we(t){const o=t.map(n=>({key:n.key.trim(),value:n.value})).filter(n=>n.key.length>0).map(n=>`${n.key}=${n.value}`);return o.length>0?`${o.join(` +`)} +`:""}function oe(t,o){return t==="both"?!0:t===o}function hs(){const{locale:t}=Z(),[o,n]=a.useState([]),[r,c]=a.useState(null),[d,j]=a.useState(""),[u,f]=a.useState(""),[g,b]=a.useState("form"),[k,y]=a.useState([]),[m,$]=a.useState(0),[M,R]=a.useState(!1),[S,E]=a.useState(""),[h,T]=a.useState(""),[O,v]=a.useState(""),[_,N]=a.useState(!0),[P,z]=a.useState(""),i=t==="zh",w=a.useMemo(()=>o.find(s=>s.file_id===d)??null,[d,o]),L=a.useMemo(()=>{const s=new Map;for(const l of(r==null?void 0:r.sections)??[])for(const p of l.entries)s.set(p.key.toUpperCase(),p);for(const l of(r==null?void 0:r.network_only)??[])s.set(l.key.toUpperCase(),l);return s},[r]),A=a.useMemo(()=>{if(!d)return[];const s=new Set(k.map(p=>p.key.trim().toUpperCase()).filter(Boolean)),l=[];for(const p of(r==null?void 0:r.sections)??[])for(const C of p.entries)oe(C.scope,d)&&(s.has(C.key.toUpperCase())||l.push(C));for(const p of(r==null?void 0:r.network_only)??[])oe(p.scope,d)&&(s.has(p.key.toUpperCase())||l.push(p));return l.sort((p,C)=>p.key.localeCompare(C.key))},[d,r,k]),I=a.useMemo(()=>{const s=O.trim().toLowerCase();return s?k.filter(l=>{const p=l.key.toLowerCase(),C=L.get(l.key.trim().toUpperCase());return`${p} ${l.value} ${(C==null?void 0:C.zh)??""} ${(C==null?void 0:C.en)??""}`.toLowerCase().includes(s)}):k},[k,L,O]),U=s=>{const l=ps(s);y(l.entries),$(l.unsupportedLines)},K=async()=>{var s,l;R(!0),E("");try{const[p,C]=await Promise.all([W("/api/dev/system/config/files",{cache:"no-store"}),W("/api/dev/system/config/catalog",{cache:"no-store"})]);n(p.files??[]),c(C);const D=((l=(s=p.files)==null?void 0:s[0])==null?void 0:l.file_id)??"";j(G=>{var J;return G&&((J=p.files)!=null&&J.some(Y=>Y.file_id===G))?G:D})}catch(p){E(p instanceof Error?p.message:String(p))}finally{R(!1)}},Q=async()=>{if(!d)return;if(g==="form"&&m>0){E(i?"当前文件包含无法表单化的行,请切换到「原始文本」模式编辑后再保存。":"This file has lines not supported by form mode. Switch to Raw mode before saving.");return}const s=g==="form"?we(k):u;R(!0),E(""),T("");try{const l=await W("/api/dev/system/config/files",{method:"POST",body:JSON.stringify({file_id:d,content:s})});T(i?`保存成功:${l.message??d};请重启 ogscope 服务使配置生效`:`Saved: ${l.message??d}; restart ogscope to apply changes`),await K()}catch(l){E(l instanceof Error?l.message:String(l))}finally{R(!1)}};a.useEffect(()=>{K()},[]),a.useEffect(()=>{if(!w){f(""),y([]),$(0),z("");return}const s=w.content??"";f(s),U(s),z("")},[w]);const re=()=>{y(s=>[...s,{id:`env-new-${Date.now()}-${s.length}`,key:"",value:""}])},ee=()=>{const s=A.find(l=>l.key===P);s&&(y(l=>[...l,{id:`env-catalog-${Date.now()}-${s.key}`,key:s.key,value:s.default??""}]),z(""))},se=(s,l)=>{y(p=>p.map(C=>C.id===s?{...C,...l}:C))},ae=s=>{y(l=>l.filter(p=>p.id!==s))},x=s=>{const l=L.get(s.trim().toUpperCase());return l?i?l.zh:l.en:i?"暂无释义(可查阅 deploy/*.env.example)":"No hint (see deploy/*.env.example)"},F=s=>{const l=L.get(s.trim().toUpperCase());return l!=null&&l.default?i?`默认:${l.default}`:`Default: ${l.default}`:""},X=s=>{const l=ms[s];return l?i?l.zh:l.en:s},ie=s=>s.writable?s.writable_via_sudo&&!s.writable_direct?i?"可写(经 sudo 助手)":"Writable (via sudo helper)":s.writable_direct?i?"可写(直接)":"Writable (direct)":i?"可写":"Writable":i?"不可写:请运行 sudo ./scripts/ogscope-network-init.sh ensure-config":"Not writable: run sudo ./scripts/ogscope-network-init.sh ensure-config";return e.jsxs("div",{className:"mx-auto max-w-7xl space-y-6",children:[e.jsxs("header",{children:[e.jsxs("div",{className:"flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-on-surface-variant",children:[e.jsx("span",{children:"Console"}),e.jsx("span",{children:"/"}),e.jsx("span",{className:"text-primary",children:i?"配置管理":"Config Manager"})]}),e.jsx("h2",{className:"mt-1 font-headline text-3xl font-black tracking-tight",children:i?"环境配置管理":"Environment Config Manager"}),e.jsx("p",{className:"text-sm text-on-surface-variant",children:i?"编辑 /etc/ogscope 下的 ogscope.env 与 network.env。保存后请重启 ogscope 服务;配置项说明来自服务端目录 API。":"Edit ogscope.env and network.env under /etc/ogscope. Restart ogscope after saving; hints come from the server catalog API."})]}),S&&e.jsx("div",{className:"rounded-lg border border-error/40 bg-error-container/20 px-3 py-2 text-sm text-on-error-container",children:S}),h&&e.jsx("div",{className:"rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-sm",children:h}),e.jsxs("section",{className:"grid grid-cols-12 gap-4",children:[e.jsxs("aside",{className:"col-span-12 space-y-2 rounded-xl border border-outline-variant/20 bg-surface-container p-3 lg:col-span-3",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("p",{className:"text-xs uppercase tracking-wider text-on-surface-variant",children:i?"配置文件":"Config Files"}),e.jsx("button",{type:"button",onClick:()=>void K(),disabled:M,children:e.jsxs("span",{className:"inline-flex items-center gap-1 text-xs",children:[e.jsx(he,{className:"h-3.5 w-3.5"})," ",i?"刷新":"Refresh"]})})]}),o.map(s=>e.jsxs("button",{type:"button",onClick:()=>j(s.file_id),className:`w-full rounded-lg border px-3 py-2 text-left text-sm ${s.file_id===d?"border-primary bg-primary/10 text-on-surface":"border-outline-variant/30 bg-surface-container-low text-on-surface-variant"}`,children:[e.jsx("div",{className:"font-medium",children:X(s.file_id)}),e.jsx("div",{className:"mt-1 truncate font-mono text-[11px]",children:s.path})]},s.file_id)),(r==null?void 0:r.env_files)&&e.jsxs("div",{className:"mt-3 rounded border border-outline-variant/20 bg-surface-container-low px-2 py-2 text-[11px] text-on-surface-variant",children:[e.jsx("p",{className:"mb-1 font-medium text-on-surface",children:i?"配置路径":"Config paths"}),Object.entries(r.env_files).map(([s,l])=>e.jsxs("p",{className:"font-mono",children:[s,": ",l]},s))]})]}),e.jsxs("div",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4 lg:col-span-9",children:[!w&&e.jsx("p",{className:"text-sm text-on-surface-variant",children:i?"暂无可编辑配置文件":"No editable config files."}),w&&e.jsxs("div",{className:"space-y-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-2",children:[e.jsxs("div",{children:[e.jsx("p",{className:"font-mono text-xs text-on-surface",children:w.path}),e.jsxs("p",{className:"text-xs text-on-surface-variant",children:[ie(w)," · ",i?"存在":"Exists",": ",String(w.exists)]})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("button",{type:"button",onClick:()=>{g==="raw"&&U(u),b("form")},className:`rounded px-2 py-1 text-xs ${g==="form"?"bg-primary-container text-on-primary-container":"border border-outline-variant/30 text-on-surface-variant"}`,children:i?"表单模式":"Form"}),e.jsx("button",{type:"button",onClick:()=>{g==="form"&&f(we(k)),b("raw")},className:`rounded px-2 py-1 text-xs ${g==="raw"?"bg-primary-container text-on-primary-container":"border border-outline-variant/30 text-on-surface-variant"}`,children:i?"原始文本":"Raw"})]}),e.jsx("button",{type:"button",onClick:()=>void Q(),disabled:M||!w.writable,children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Pe,{className:"h-3.5 w-3.5"}),i?"保存并提示重启":"Save"]})})]}),w.error&&e.jsx("div",{className:"rounded border border-error/40 bg-error-container/20 px-2 py-1 text-xs text-on-error-container",children:w.error}),g==="form"?e.jsxs("div",{className:"space-y-3",children:[m>0&&e.jsx("div",{className:"rounded border border-warning/40 bg-warning/10 px-2 py-1 text-xs text-on-surface",children:i?`检测到 ${m} 行无法转换为键值表单。请切到「原始文本」模式处理。`:`${m} line(s) cannot be represented in key-value form. Use Raw mode.`}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsxs("div",{className:"relative min-w-[200px] flex-1",children:[e.jsx(_e,{className:"pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-on-surface-variant"}),e.jsx("input",{className:"w-full rounded border border-outline-variant/30 bg-surface-container-low py-1.5 pl-8 pr-2 text-xs outline-none focus:border-primary",placeholder:i?"搜索键名、值或释义…":"Search keys, values, or hints…",value:O,onChange:s=>v(s.target.value)})]}),e.jsxs("select",{className:"max-w-xs flex-1 rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5 text-xs outline-none focus:border-primary",value:P,onChange:s=>z(s.target.value),children:[e.jsx("option",{value:"",children:i?"从目录添加配置项…":"Add from catalog…"}),A.map(s=>e.jsx("option",{value:s.key,children:s.key},s.key))]}),e.jsx("button",{type:"button",disabled:!P,onClick:ee,className:"rounded border border-outline-variant/30 px-2 py-1.5 text-xs disabled:opacity-50",children:i?"添加":"Add"})]}),e.jsxs("div",{className:"max-h-[420px] overflow-auto pr-1",children:[e.jsxs("table",{className:"w-full border-separate border-spacing-y-2",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"text-left text-[11px] uppercase tracking-wide text-on-surface-variant",children:[e.jsx("th",{children:i?"配置项":"Key"}),e.jsx("th",{children:i?"值":"Value"}),e.jsx("th",{children:i?"释义":"Meaning"}),e.jsx("th",{children:i?"操作":"Action"})]})}),e.jsx("tbody",{children:I.map(s=>e.jsxs("tr",{children:[e.jsx("td",{className:"pr-2 align-top",children:e.jsx("input",{className:"w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1 font-mono text-xs outline-none focus:border-primary",placeholder:"OGSCOPE_PORT",value:s.key,onChange:l=>se(s.id,{key:l.target.value})})}),e.jsx("td",{className:"pr-2 align-top",children:e.jsx("input",{className:"w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1 font-mono text-xs outline-none focus:border-primary",placeholder:i?"变量值":"Value",value:s.value,onChange:l=>se(s.id,{value:l.target.value})})}),e.jsxs("td",{className:"pr-2 align-top text-xs text-on-surface-variant",children:[e.jsx("p",{children:x(s.key)}),F(s.key)&&e.jsx("p",{className:"mt-0.5 font-mono text-[10px] text-on-surface-variant/80",children:F(s.key)})]}),e.jsx("td",{className:"align-top",children:e.jsx("button",{type:"button",onClick:()=>ae(s.id),children:e.jsx(Re,{className:"h-3.5 w-3.5 text-on-surface-variant"})})})]},s.id))})]}),k.length===0&&e.jsx("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-2 text-xs text-on-surface-variant",children:i?"当前没有可编辑变量,可从目录添加或手动新增。":"No variables yet. Add from catalog or manually."}),k.length>0&&I.length===0&&e.jsx("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-2 text-xs text-on-surface-variant",children:i?"无匹配项,请调整搜索条件。":"No matches for the current search."})]}),e.jsx("div",{className:"flex flex-wrap gap-2",children:e.jsx("button",{type:"button",onClick:re,children:e.jsxs("span",{className:"inline-flex items-center gap-1 text-xs",children:[e.jsx(De,{className:"h-3.5 w-3.5"}),i?"空白行":"Blank row"]})})}),e.jsxs("details",{open:_,onToggle:s=>N(s.target.open),className:"rounded border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-xs text-on-surface-variant",children:[e.jsx("summary",{className:"cursor-pointer font-medium text-on-surface",children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Ae,{className:"h-3.5 w-3.5"}),i?"配置目录(按模块)":"Config catalog (by section)"]})}),e.jsxs("div",{className:"mt-3 space-y-4",children:[((r==null?void 0:r.sections)??[]).map(s=>{const l=s.entries.filter(p=>d?oe(p.scope,d):!0);return l.length===0?null:e.jsxs("div",{children:[e.jsx("p",{className:"mb-1 font-medium text-on-surface",children:i?s.title_zh:s.title_en}),e.jsx("div",{className:"space-y-1",children:l.map(p=>e.jsxs("p",{children:[e.jsx("span",{className:"font-mono text-[11px] text-on-surface",children:p.key}),p.default!=null&&p.default!==""&&e.jsxs("span",{className:"ml-1 font-mono text-[10px] text-on-surface-variant/80",children:["(= ",p.default,")"]})," — ",e.jsx("span",{children:i?p.zh:p.en})]},p.key))})]},s.id)}),((r==null?void 0:r.network_only)??[]).filter(s=>d?oe(s.scope,d):!0).length>0&&e.jsxs("div",{children:[e.jsx("p",{className:"mb-1 font-medium text-on-surface",children:i?"仅 network.env / 脚本":"network.env / scripts only"}),e.jsx("div",{className:"space-y-1",children:((r==null?void 0:r.network_only)??[]).filter(s=>d?oe(s.scope,d):!0).map(s=>e.jsxs("p",{children:[e.jsx("span",{className:"font-mono text-[11px] text-on-surface",children:s.key})," — ",e.jsx("span",{children:i?s.zh:s.en})]},s.key))})]})]})]})]}):e.jsx("textarea",{className:"h-[460px] w-full rounded-lg border border-outline-variant/30 bg-neutral-950 p-3 font-mono text-xs text-on-surface outline-none focus:border-primary",spellCheck:!1,value:u,onChange:s=>f(s.target.value)})]})]})]})]})}const fs=new Set(["overview","network","sensors","hmi","power","config"]);function ke(){const t=window.location.hash.replace(/^#\/?/,"").trim().toLowerCase();return fs.has(t)?t:"overview"}function bs(t){window.location.hash=`/${t}`}function gs(){const[t,o]=a.useState(()=>ke()),[n,r]=a.useState(!0);a.useEffect(()=>{const d=()=>o(ke());return window.addEventListener("hashchange",d),()=>window.removeEventListener("hashchange",d)},[]),a.useEffect(()=>{(async()=>{var d;try{const j=await fetch("/api",{cache:"no-store"});if(!j.ok)return;const u=await j.json();r(!!((d=u.endpoints)!=null&&d.network))}catch{}})()},[]);const c=a.useMemo(()=>t==="network"?n?e.jsx(Qe,{}):e.jsx(ve,{scope:"network"}):t==="sensors"?e.jsx(us,{}):t==="hmi"?e.jsx(ts,{}):t==="config"?e.jsx(hs,{}):t==="power"?e.jsx(ve,{scope:"power"}):e.jsx(Ye,{}),[n,t]);return e.jsx(We,{route:t,allowNetworkRoute:n,onRouteChange:d=>{d==="network"&&!n||d!==t&&bs(d)},children:c})}$e.createRoot(document.getElementById("root")).render(e.jsx(Me.StrictMode,{children:e.jsx(Te,{children:e.jsx(Le,{children:e.jsx(gs,{})})})})); diff --git a/web/static/analysis-lab/system.html b/web/static/analysis-lab/system.html index d4bf4a2..93c894d 100644 --- a/web/static/analysis-lab/system.html +++ b/web/static/analysis-lab/system.html @@ -4,7 +4,7 @@ OGScope 系统调试控制台 - + From 186e90324c9351a4cf30a940ad892fb6200d3fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Tue, 26 May 2026 12:20:52 +0800 Subject: [PATCH 02/18] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?=E7=9A=84=20Ruff/Black=20=E4=B8=8E=20streaming=20=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=20/=20Fix=20CI=20Ruff,=20Black,=20and=20streaming=20u?= =?UTF-8?q?nit=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全仓 black 格式化与 ruff import/风格修复 / Run black and ruff across ogscope and tests - 补全 MJPEG streaming 测试 FakeSettings.shared_preview_fps / Add shared_preview_fps to fake settings Co-authored-by: Cursor --- .../plate_solve/centroid_quality.py | 12 +++-- ogscope/algorithms/plate_solve/solver.py | 4 +- ogscope/config_catalog.py | 4 +- ogscope/core/application/core_service.py | 46 ++++++++++------ ogscope/domain/__init__.py | 1 - ogscope/domain/analysis/__init__.py | 1 - ogscope/domain/analysis/services.py | 5 +- ogscope/domain/camera/__init__.py | 1 - ogscope/domain/camera/services.py | 41 +++++++++----- ogscope/domain/camera/sidecar.py | 1 - ogscope/domain/camera/stream_limiter.py | 1 - ogscope/domain/camera/streaming.py | 1 - ogscope/domain/network/__init__.py | 1 - ogscope/domain/network/nmcli_services.py | 1 - ogscope/domain/network/services.py | 19 ++++--- ogscope/domain/shared/filesystem.py | 1 - ogscope/domain/system/__init__.py | 1 - ogscope/domain/system/services.py | 5 +- ogscope/platform/adapters/debug_services.py | 1 - ogscope/platform/hardware/ak09911_i2c.py | 12 +++-- ogscope/platform/hardware/camera.py | 4 +- ogscope/platform/hardware/st7796_spi.py | 40 +++++++++++++- ogscope/platform/hardware_plane/__init__.py | 1 - ogscope/platform/hardware_plane/client.py | 5 +- ogscope/platform/hardware_plane/daemon.py | 5 +- ogscope/platform/hardware_plane/registry.py | 3 +- ogscope/platform/hardware_plane/runtime.py | 5 +- .../hardware_plane/services/__init__.py | 1 - .../platform/hardware_plane/services/base.py | 5 +- .../hardware_plane/services/camera_service.py | 1 - .../platform/hardware_plane/services/hmi.py | 8 ++- .../hardware_plane/services/sensor_hub.py | 1 - .../hardware_plane/transport/__init__.py | 1 - .../hardware_plane/transport/jsonrpc_uds.py | 11 ++-- ogscope/web/api/analysis/routes.py | 6 ++- ogscope/web/api/analysis/services.py | 4 +- ogscope/web/api/core/routes.py | 8 ++- ogscope/web/api/debug/magnetometer_service.py | 54 ++++++++++++------- ogscope/web/api/system/routes.py | 5 +- ogscope/web/app.py | 17 ++++-- ogscope/web/mjpeg_stream_limiter.py | 5 +- tests/conftest.py | 6 ++- tests/unit/test_analysis_api.py | 4 +- tests/unit/test_config_catalog.py | 4 +- tests/unit/test_config_files.py | 7 ++- tests/unit/test_core_contract_api.py | 8 ++- tests/unit/test_debug_camera_api.py | 4 +- tests/unit/test_dev_contract_api.py | 1 - tests/unit/test_domain_camera_sidecar.py | 1 - tests/unit/test_domain_camera_streaming.py | 10 ++-- tests/unit/test_domain_shared_filesystem.py | 1 - tests/unit/test_hardware_plane.py | 9 ++-- tests/unit/test_system_wifi_parse.py | 1 - tests/unit/test_wifi_switch.py | 17 ++++-- 54 files changed, 280 insertions(+), 142 deletions(-) diff --git a/ogscope/algorithms/plate_solve/centroid_quality.py b/ogscope/algorithms/plate_solve/centroid_quality.py index f2c0b88..b6fd5b6 100644 --- a/ogscope/algorithms/plate_solve/centroid_quality.py +++ b/ogscope/algorithms/plate_solve/centroid_quality.py @@ -67,7 +67,9 @@ def _reject_dense_clusters( return kept, removed, removed_pts -def _point_line_dist(points_yx: np.ndarray, p0: np.ndarray, p1: np.ndarray) -> np.ndarray: +def _point_line_dist( + points_yx: np.ndarray, p0: np.ndarray, p1: np.ndarray +) -> np.ndarray: """点到线段距离(像素)/ Distance from points to segment.""" # segment vector vx = p1[1] - p0[1] @@ -221,9 +223,11 @@ def filter_centroids_yx( ) n1 = int(xy3.shape[0]) - rejected_pts = np.concatenate( - [dense_removed, line_removed], axis=0 - ) if (dense_removed.size > 0 or line_removed.size > 0) else np.empty((0, 2), dtype=np.float64) + rejected_pts = ( + np.concatenate([dense_removed, line_removed], axis=0) + if (dense_removed.size > 0 or line_removed.size > 0) + else np.empty((0, 2), dtype=np.float64) + ) quality: dict[str, Any] = { "level": lv, "flags": flags, diff --git a/ogscope/algorithms/plate_solve/solver.py b/ogscope/algorithms/plate_solve/solver.py index 1ea366b..a878276 100644 --- a/ogscope/algorithms/plate_solve/solver.py +++ b/ogscope/algorithms/plate_solve/solver.py @@ -344,9 +344,7 @@ def solve( sorted_stars = sorted(stars, key=lambda s: s.flux, reverse=True) cyx = np.array([[s.y, s.x] for s in sorted_stars], dtype=np.float64) overlay = ( - _make_solve_overlay( - {}, cyx, None, (height, width), (height, width) - ) + _make_solve_overlay({}, cyx, None, (height, width), (height, width)) if len(cyx) > 0 else None ) diff --git a/ogscope/config_catalog.py b/ogscope/config_catalog.py index c0d2836..70a2aa9 100644 --- a/ogscope/config_catalog.py +++ b/ogscope/config_catalog.py @@ -10,7 +10,9 @@ ConfigFileScope = Literal["ogscope", "network", "both"] -_CATALOG_SECTIONS: tuple[tuple[str, str, str, ConfigFileScope, tuple[str, ...]], ...] = ( +_CATALOG_SECTIONS: tuple[ + tuple[str, str, str, ConfigFileScope, tuple[str, ...]], ... +] = ( ( "basic", "基础", diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index 7fe36f7..50a0715 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -17,8 +17,8 @@ stream_state_domain_service, ) from ogscope.domain.system.services import system_info_service -from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client from ogscope.platform.hardware.wifi_switch import wifi_switch_service +from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client @dataclass(slots=True) @@ -106,7 +106,9 @@ async def get_system_status(self) -> dict[str, Any]: wifi_raw = wifi_switch_service.get_status() hardware_client = get_hardware_plane_client() hw_status_resp = await hardware_client.status_get() - hw_status_data = hw_status_resp.get("data", {}) if hw_status_resp.get("success") else {} + hw_status_data = ( + hw_status_resp.get("data", {}) if hw_status_resp.get("success") else {} + ) camera_service_status = ( hw_status_data.get("services", {}).get("camera", {}) if isinstance(hw_status_data, dict) @@ -150,15 +152,21 @@ async def get_system_status(self) -> dict[str, Any]: "version": __version__, "capabilities": capability_map(), "hardware_plane": { - "started": bool(hw_status_data.get("started", False)) - if isinstance(hw_status_data, dict) - else False, - "metrics": hw_status_data.get("metrics", {}) - if isinstance(hw_status_data, dict) - else {}, - "services": hw_status_data.get("services", {}) - if isinstance(hw_status_data, dict) - else {}, + "started": ( + bool(hw_status_data.get("started", False)) + if isinstance(hw_status_data, dict) + else False + ), + "metrics": ( + hw_status_data.get("metrics", {}) + if isinstance(hw_status_data, dict) + else {} + ), + "services": ( + hw_status_data.get("services", {}) + if isinstance(hw_status_data, dict) + else {} + ), }, "system": system, "camera": {"success": True, **camera_status}, @@ -178,9 +186,15 @@ async def get_camera_status(self) -> dict[str, Any]: status = await camera_domain_service.get_status() normalized = self._normalize_camera_status(status) if hp_camera: - normalized["connected"] = bool(hp_camera.get("connected", normalized["connected"])) - normalized["streaming"] = bool(hp_camera.get("streaming", normalized["streaming"])) - normalized["recording"] = bool(hp_camera.get("recording", normalized["recording"])) + normalized["connected"] = bool( + hp_camera.get("connected", normalized["connected"]) + ) + normalized["streaming"] = bool( + hp_camera.get("streaming", normalized["streaming"]) + ) + normalized["recording"] = bool( + hp_camera.get("recording", normalized["recording"]) + ) return {"success": True, **normalized} async def start_camera(self) -> dict[str, Any]: @@ -222,7 +236,9 @@ async def tune_camera(self, payload: dict[str, Any]) -> dict[str, Any]: applied["auto_exposure"] = bool(auto_exposure) if payload.get("exposure_us") is not None: - await camera_domain_service.update_settings({"exposure": payload["exposure_us"]}) + await camera_domain_service.update_settings( + {"exposure": payload["exposure_us"]} + ) applied["exposure_us"] = int(payload["exposure_us"]) if payload.get("analogue_gain") is not None: diff --git a/ogscope/domain/__init__.py b/ogscope/domain/__init__.py index 58bf2dc..dcf5dab 100644 --- a/ogscope/domain/__init__.py +++ b/ogscope/domain/__init__.py @@ -1,4 +1,3 @@ """ 领域层聚合导出 / Domain layer package exports. """ - diff --git a/ogscope/domain/analysis/__init__.py b/ogscope/domain/analysis/__init__.py index 22d9b98..b83e152 100644 --- a/ogscope/domain/analysis/__init__.py +++ b/ogscope/domain/analysis/__init__.py @@ -1,4 +1,3 @@ from ogscope.domain.analysis.services import analysis_domain_service __all__ = ["analysis_domain_service"] - diff --git a/ogscope/domain/analysis/services.py b/ogscope/domain/analysis/services.py index 5579329..11ab5eb 100644 --- a/ogscope/domain/analysis/services.py +++ b/ogscope/domain/analysis/services.py @@ -29,7 +29,9 @@ def resolve_upload_file_response(path: Path) -> tuple[Path, str]: return path, media or "application/octet-stream" @staticmethod - def parse_frame_upload_payload(payload: str) -> tuple[dict[str, Any], dict[str, Any]]: + def parse_frame_upload_payload( + payload: str, + ) -> tuple[dict[str, Any], dict[str, Any]]: obj = json.loads(payload) if not isinstance(obj, dict): raise ValueError("payload 必须为 JSON 对象 / payload must be a JSON object") @@ -51,4 +53,3 @@ def parse_frame_upload_payload(payload: str) -> tuple[dict[str, Any], dict[str, analysis_domain_service = AnalysisDomainService() __all__ = ["analysis_domain_service", "AnalysisDomainService"] - diff --git a/ogscope/domain/camera/__init__.py b/ogscope/domain/camera/__init__.py index 0c2d1e4..99dca18 100644 --- a/ogscope/domain/camera/__init__.py +++ b/ogscope/domain/camera/__init__.py @@ -5,4 +5,3 @@ ) __all__ = ["DebugCameraService", "DebugFileService", "DebugPresetService"] - diff --git a/ogscope/domain/camera/services.py b/ogscope/domain/camera/services.py index 2c0e29a..8e1261d 100644 --- a/ogscope/domain/camera/services.py +++ b/ogscope/domain/camera/services.py @@ -8,13 +8,12 @@ import time from typing import Any -from fastapi import HTTPException from fastapi.responses import Response from starlette.requests import Request -from ogscope.platform.adapters.debug_services import get_debug_services_module from ogscope.config import get_settings from ogscope.domain.camera.stream_limiter import get_mjpeg_stream_limiter +from ogscope.platform.adapters.debug_services import get_debug_services_module logger = logging.getLogger(__name__) @@ -173,11 +172,15 @@ async def get_runtime_overrides(): @staticmethod async def clear_runtime_overrides(): - return await _debug_services_module().DebugCameraService.clear_runtime_overrides() + return ( + await _debug_services_module().DebugCameraService.clear_runtime_overrides() + ) @staticmethod async def apply_runtime_overrides_as_defaults(): - return await _debug_services_module().DebugCameraService.apply_runtime_overrides_as_defaults() + return ( + await _debug_services_module().DebugCameraService.apply_runtime_overrides_as_defaults() + ) @staticmethod async def start_camera(): @@ -229,7 +232,9 @@ async def set_fps(fps: int): @staticmethod async def update_settings(settings: dict[str, Any]): - return await _debug_services_module().DebugCameraService.update_settings(settings) + return await _debug_services_module().DebugCameraService.update_settings( + settings + ) @staticmethod async def set_auto_exposure_mode(enabled: bool): @@ -251,19 +256,27 @@ async def get_image_quality(): @staticmethod async def apply_night_mode_preset(): - return await _debug_services_module().DebugCameraService.apply_night_mode_preset() + return ( + await _debug_services_module().DebugCameraService.apply_night_mode_preset() + ) @staticmethod async def save_current_settings_backup(): - return await _debug_services_module().DebugCameraService.save_current_settings_backup() + return ( + await _debug_services_module().DebugCameraService.save_current_settings_backup() + ) @staticmethod async def restore_settings_backup(): - return await _debug_services_module().DebugCameraService.restore_settings_backup() + return ( + await _debug_services_module().DebugCameraService.restore_settings_backup() + ) @staticmethod async def set_color_mode(color_mode: str): - return await _debug_services_module().DebugCameraService.set_color_mode(color_mode) + return await _debug_services_module().DebugCameraService.set_color_mode( + color_mode + ) @staticmethod async def set_white_balance(mode: str, gain_r: float, gain_b: float): @@ -307,11 +320,16 @@ async def save_preset(payload: dict[str, Any]): @staticmethod async def apply_preset(preset_name: str): - return await _debug_services_module().DebugPresetService.apply_preset(preset_name) + return await _debug_services_module().DebugPresetService.apply_preset( + preset_name + ) @staticmethod async def delete_preset(preset_name: str): - return await _debug_services_module().DebugPresetService.delete_preset(preset_name) + return await _debug_services_module().DebugPresetService.delete_preset( + preset_name + ) + __all__ = [ "DebugCameraService", @@ -323,4 +341,3 @@ async def delete_preset(preset_name: str): "file_domain_service", "stream_state_domain_service", ] - diff --git a/ogscope/domain/camera/sidecar.py b/ogscope/domain/camera/sidecar.py index 4462386..fadd41c 100644 --- a/ogscope/domain/camera/sidecar.py +++ b/ogscope/domain/camera/sidecar.py @@ -41,4 +41,3 @@ def merge_capture_sidecar_into_info( if key not in capture_info: capture_info[key] = value info.update(capture_info) - diff --git a/ogscope/domain/camera/stream_limiter.py b/ogscope/domain/camera/stream_limiter.py index 740cab4..a952f48 100644 --- a/ogscope/domain/camera/stream_limiter.py +++ b/ogscope/domain/camera/stream_limiter.py @@ -52,4 +52,3 @@ def get_mjpeg_stream_limiter() -> MjpegStreamLimiter: if _limiter is None: _limiter = MjpegStreamLimiter(get_settings().stream_max_mjpeg_clients) return _limiter - diff --git a/ogscope/domain/camera/streaming.py b/ogscope/domain/camera/streaming.py index a2462f3..b8665a5 100644 --- a/ogscope/domain/camera/streaming.py +++ b/ogscope/domain/camera/streaming.py @@ -95,4 +95,3 @@ async def frame_generator(): frame_generator(), media_type=f"multipart/x-mixed-replace; boundary={boundary}", ) - diff --git a/ogscope/domain/network/__init__.py b/ogscope/domain/network/__init__.py index a3d1b76..87bcde1 100644 --- a/ogscope/domain/network/__init__.py +++ b/ogscope/domain/network/__init__.py @@ -1,4 +1,3 @@ from ogscope.domain.network.services import wifi_domain_service __all__ = ["wifi_domain_service"] - diff --git a/ogscope/domain/network/nmcli_services.py b/ogscope/domain/network/nmcli_services.py index f9d54bb..c1f1ace 100644 --- a/ogscope/domain/network/nmcli_services.py +++ b/ogscope/domain/network/nmcli_services.py @@ -403,4 +403,3 @@ async def _sta_rollback_loop() -> None: raise except Exception as e: logger.error("STA 回滚失败 / Rollback to AP failed: {}", e) - diff --git a/ogscope/domain/network/services.py b/ogscope/domain/network/services.py index d05e84d..1218ab9 100644 --- a/ogscope/domain/network/services.py +++ b/ogscope/domain/network/services.py @@ -8,8 +8,8 @@ import subprocess from ogscope.config import get_settings -from ogscope.platform.hardware.wifi_switch import wifi_switch_service from ogscope.domain.network import nmcli_services as net_impl +from ogscope.platform.hardware.wifi_switch import wifi_switch_service class WifiDomainService: @@ -27,7 +27,9 @@ def build_wifi_status() -> dict: ap_connection = data.get("AP_CONNECTION", settings.wifi_ap_connection) ap_ipv4 = data.get("AP_IPV4") or None ap_url_hint = ( - f"http://{settings.wifi_ap_url_host}:{settings.port}" if mode == "ap" else None + f"http://{settings.wifi_ap_url_host}:{settings.port}" + if mode == "ap" + else None ) message = data.get("error") suffix = settings.device_id_suffix or None @@ -58,7 +60,9 @@ async def switch_mode(self, mode: str) -> dict: async def scan_wifi(self): settings = get_settings() - return await asyncio.to_thread(net_impl.nmcli_wifi_scan, settings.wifi_interface) + return await asyncio.to_thread( + net_impl.nmcli_wifi_scan, settings.wifi_interface + ) async def list_profiles(self): settings = get_settings() @@ -68,7 +72,9 @@ async def connect_sta(self, ssid: str, password: str) -> dict: settings = get_settings() if not wifi_switch_service.is_configured(): raise RuntimeError("wifi_not_configured") - await asyncio.to_thread(net_impl.nmcli_modify_sta_to_ssid, settings, ssid, password) + await asyncio.to_thread( + net_impl.nmcli_modify_sta_to_ssid, settings, ssid, password + ) await asyncio.to_thread(wifi_switch_service.switch, "sta") net_impl.schedule_sta_rollback_watch() return self.build_wifi_status() @@ -83,7 +89,9 @@ async def activate_profile(self, connection_name: str) -> dict: if name == settings.wifi_sta_connection: await asyncio.to_thread(wifi_switch_service.switch, "sta") else: - await asyncio.to_thread(net_impl.nm_down_if_exists, settings.wifi_ap_connection) + await asyncio.to_thread( + net_impl.nm_down_if_exists, settings.wifi_ap_connection + ) await asyncio.to_thread(net_impl.nmcli_activate_connection, settings, name) net_impl.schedule_sta_rollback_watch() return self.build_wifi_status() @@ -115,4 +123,3 @@ async def activate_profile(self, connection_name: str) -> dict: "TimeoutExpired", "CalledProcessError", ] - diff --git a/ogscope/domain/shared/filesystem.py b/ogscope/domain/shared/filesystem.py index c5fff0b..7e2ae19 100644 --- a/ogscope/domain/shared/filesystem.py +++ b/ogscope/domain/shared/filesystem.py @@ -39,4 +39,3 @@ def ensure_safe_basename(filename: str) -> str: if "/" in safe_name or "\\" in safe_name: raise ValueError("invalid filename") return safe_name - diff --git a/ogscope/domain/system/__init__.py b/ogscope/domain/system/__init__.py index 3925486..77c8228 100644 --- a/ogscope/domain/system/__init__.py +++ b/ogscope/domain/system/__init__.py @@ -1,4 +1,3 @@ from ogscope.domain.system.services import system_info_service __all__ = ["system_info_service"] - diff --git a/ogscope/domain/system/services.py b/ogscope/domain/system/services.py index 7e5cad1..de9a238 100644 --- a/ogscope/domain/system/services.py +++ b/ogscope/domain/system/services.py @@ -225,7 +225,9 @@ def read_systemd_logs( rt = item.get("__REALTIME_TIMESTAMP") try: if rt is not None: - ts = dt.datetime.fromtimestamp(int(str(rt)) / 1_000_000, tz=dt.timezone.utc) + ts = dt.datetime.fromtimestamp( + int(str(rt)) / 1_000_000, tz=dt.timezone.utc + ) ts_iso = ts.isoformat() except (ValueError, TypeError): ts_iso = None @@ -256,4 +258,3 @@ def _journal_priority_to_level(priority: str | int | None) -> str: system_info_service = SystemInfoService() __all__ = ["SystemInfoService", "system_info_service", "read_systemd_logs"] - diff --git a/ogscope/platform/adapters/debug_services.py b/ogscope/platform/adapters/debug_services.py index 10fa692..5b37dd6 100644 --- a/ogscope/platform/adapters/debug_services.py +++ b/ogscope/platform/adapters/debug_services.py @@ -10,4 +10,3 @@ def get_debug_services_module(): """延迟加载调试实现模块 / Lazy load debug implementation module.""" return importlib.import_module("ogscope.web.api.debug.services") - diff --git a/ogscope/platform/hardware/ak09911_i2c.py b/ogscope/platform/hardware/ak09911_i2c.py index 040196d..27fbfb7 100644 --- a/ogscope/platform/hardware/ak09911_i2c.py +++ b/ogscope/platform/hardware/ak09911_i2c.py @@ -204,7 +204,9 @@ def _measure_body_smbus(smbus: Any, addr7: int) -> Ak09911Measurement: time.sleep(0.006 * (read_try + 1)) if data is None: if last_io is not None: - raise RuntimeError(_err_ctx("hxl_read", last_io, addr7).to_text()) from last_io + raise RuntimeError( + _err_ctx("hxl_read", last_io, addr7).to_text() + ) from last_io raise RuntimeError("hxl_read unknown error") try: _ = smbus.read_byte_data(addr7, REG_ST2) @@ -215,7 +217,9 @@ def _measure_body_smbus(smbus: Any, addr7: int) -> Ak09911Measurement: try: _ = smbus.read_byte_data(addr7, REG_ST2) except OSError as exc2: - raise RuntimeError(_err_ctx("st2_read", exc2, addr7).to_text()) from exc2 + raise RuntimeError( + _err_ctx("st2_read", exc2, addr7).to_text() + ) from exc2 else: raise RuntimeError(_err_ctx("st2_read", exc, addr7).to_text()) from exc hx, hy, hz = _combine_hxl_6(data) @@ -303,7 +307,9 @@ def measure_heading_with_cad_fallback( ) -def measure_single(bus: int, addr7: int) -> tuple[Ak09911Measurement | None, str | None]: +def measure_single( + bus: int, addr7: int +) -> tuple[Ak09911Measurement | None, str | None]: path = ensure_i2c_dev_node(bus) if path is None: return None, f"missing {i2c_dev_path(bus)}" diff --git a/ogscope/platform/hardware/camera.py b/ogscope/platform/hardware/camera.py index 737662e..9d11270 100644 --- a/ogscope/platform/hardware/camera.py +++ b/ogscope/platform/hardware/camera.py @@ -771,9 +771,7 @@ def set_flip(self, flip_horizontal: bool, flip_vertical: bool) -> bool: return False self.flip_horizontal = bool(flip_horizontal) self.flip_vertical = bool(flip_vertical) - logger.info( - f"图像镜像: 水平={self.flip_horizontal}, 垂直={self.flip_vertical}" - ) + logger.info(f"图像镜像: 水平={self.flip_horizontal}, 垂直={self.flip_vertical}") return True def set_sampling_mode(self, mode: str) -> bool: diff --git a/ogscope/platform/hardware/st7796_spi.py b/ogscope/platform/hardware/st7796_spi.py index 59f4394..ec0ee59 100644 --- a/ogscope/platform/hardware/st7796_spi.py +++ b/ogscope/platform/hardware/st7796_spi.py @@ -102,8 +102,44 @@ def _init_sequence(self) -> None: d(0xC2, [0xA7]) d(0xC5, [0x18]) time.sleep(0.12) - d(0xE0, [0xF0, 0x09, 0x0B, 0x06, 0x04, 0x15, 0x2F, 0x54, 0x42, 0x3C, 0x17, 0x14, 0x18, 0x1B]) - d(0xE1, [0xE0, 0x09, 0x0B, 0x06, 0x04, 0x03, 0x2B, 0x43, 0x42, 0x3B, 0x16, 0x14, 0x17, 0x1B]) + d( + 0xE0, + [ + 0xF0, + 0x09, + 0x0B, + 0x06, + 0x04, + 0x15, + 0x2F, + 0x54, + 0x42, + 0x3C, + 0x17, + 0x14, + 0x18, + 0x1B, + ], + ) + d( + 0xE1, + [ + 0xE0, + 0x09, + 0x0B, + 0x06, + 0x04, + 0x03, + 0x2B, + 0x43, + 0x42, + 0x3B, + 0x16, + 0x14, + 0x17, + 0x1B, + ], + ) time.sleep(0.12) d(0xF0, [0x3C]) d(0xF0, [0x69]) diff --git a/ogscope/platform/hardware_plane/__init__.py b/ogscope/platform/hardware_plane/__init__.py index b2f9f9b..e96ef1a 100644 --- a/ogscope/platform/hardware_plane/__init__.py +++ b/ogscope/platform/hardware_plane/__init__.py @@ -21,4 +21,3 @@ "start_hardware_plane", "stop_hardware_plane", ] - diff --git a/ogscope/platform/hardware_plane/client.py b/ogscope/platform/hardware_plane/client.py index bd05246..cdd18e2 100644 --- a/ogscope/platform/hardware_plane/client.py +++ b/ogscope/platform/hardware_plane/client.py @@ -43,7 +43,9 @@ def __init__( self._daemon = daemon self._default_timeout_ms = max(50, int(default_timeout_ms)) self._remote_sensor_transport = remote_sensor_transport - self._remote_sensor_enabled = bool(remote_sensor_enabled and remote_sensor_transport) + self._remote_sensor_enabled = bool( + remote_sensor_enabled and remote_sensor_transport + ) self._runtime_profile = dict(runtime_profile or {}) async def _call( @@ -108,4 +110,3 @@ async def event_subscribe(self, topic: str) -> dict[str, Any]: def runtime_profile(self) -> dict[str, Any]: """运行时角色信息 / Runtime role profile.""" return dict(self._runtime_profile) - diff --git a/ogscope/platform/hardware_plane/daemon.py b/ogscope/platform/hardware_plane/daemon.py index 6fa2c0c..3dbabb4 100644 --- a/ogscope/platform/hardware_plane/daemon.py +++ b/ogscope/platform/hardware_plane/daemon.py @@ -195,9 +195,7 @@ async def handle_call( code=PlaneErrorCode.UNAVAILABLE, message="local sensor service is disabled; use delegated sensor backend", ) - return ok_payload( - {"sensor": await sensor_hub.read(sensor_name)} - ) + return ok_payload({"sensor": await sensor_hub.read(sensor_name)}) if method == PlaneMethod.DEVICE_COMMAND.value: target = str(params.get("target", "")) action = str(params.get("action", "")) @@ -257,4 +255,3 @@ async def status(self) -> dict[str, Any]: def metrics(self) -> dict[str, Any]: return self._metrics.to_dict() - diff --git a/ogscope/platform/hardware_plane/registry.py b/ogscope/platform/hardware_plane/registry.py index e8a0eb0..124f7dc 100644 --- a/ogscope/platform/hardware_plane/registry.py +++ b/ogscope/platform/hardware_plane/registry.py @@ -55,9 +55,8 @@ def update_state(self, name: str, state: CapabilityState) -> None: def list_records(self) -> list[CapabilityRecord]: """列出所有能力 / List all capabilities.""" with self._lock: - return [record for record in self._records.values()] + return list(self._records.values()) def as_dict_list(self) -> list[dict[str, Any]]: """字典列表表示 / Dict-list representation.""" return [record.to_dict() for record in self.list_records()] - diff --git a/ogscope/platform/hardware_plane/runtime.py b/ogscope/platform/hardware_plane/runtime.py index 0c4ecfd..72ff19c 100644 --- a/ogscope/platform/hardware_plane/runtime.py +++ b/ogscope/platform/hardware_plane/runtime.py @@ -73,7 +73,9 @@ def _ensure_runtime(settings: Settings) -> None: ) remote_sensor_transport = None if profile["subordinate_mode"]: - remote_sensor_transport = JsonRpcUdsClient(str(settings.hardware_plane_remote_uds_socket)) + remote_sensor_transport = JsonRpcUdsClient( + str(settings.hardware_plane_remote_uds_socket) + ) _client = HardwarePlaneClient( _daemon, default_timeout_ms=settings.hardware_plane_rpc_timeout_ms, @@ -125,4 +127,3 @@ async def stop_hardware_plane() -> None: """停止硬件平面 / Stop hardware plane.""" daemon = get_hardware_plane_daemon() await daemon.stop() - diff --git a/ogscope/platform/hardware_plane/services/__init__.py b/ogscope/platform/hardware_plane/services/__init__.py index 6bf89ec..1bd2ec1 100644 --- a/ogscope/platform/hardware_plane/services/__init__.py +++ b/ogscope/platform/hardware_plane/services/__init__.py @@ -7,4 +7,3 @@ from ogscope.platform.hardware_plane.services.sensor_hub import SensorHubService __all__ = ["CameraPlaneService", "HmiService", "SensorHubService"] - diff --git a/ogscope/platform/hardware_plane/services/base.py b/ogscope/platform/hardware_plane/services/base.py index ab24826..4702048 100644 --- a/ogscope/platform/hardware_plane/services/base.py +++ b/ogscope/platform/hardware_plane/services/base.py @@ -21,6 +21,7 @@ async def stop(self) -> None: async def status(self) -> dict[str, Any]: """读取服务状态 / Read service status.""" - async def command(self, action: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + async def command( + self, action: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: """执行命令 / Execute command.""" - diff --git a/ogscope/platform/hardware_plane/services/camera_service.py b/ogscope/platform/hardware_plane/services/camera_service.py index a687fa2..7ea4364 100644 --- a/ogscope/platform/hardware_plane/services/camera_service.py +++ b/ogscope/platform/hardware_plane/services/camera_service.py @@ -88,4 +88,3 @@ async def command( frame.pop("payload", None) return frame return {"accepted": False, "message": f"unsupported action: {action}"} - diff --git a/ogscope/platform/hardware_plane/services/hmi.py b/ogscope/platform/hardware_plane/services/hmi.py index 2b129e5..7fd629d 100644 --- a/ogscope/platform/hardware_plane/services/hmi.py +++ b/ogscope/platform/hardware_plane/services/hmi.py @@ -73,9 +73,13 @@ def _ensure_display_sync(self) -> Any: "display_disabled:在环境变量或 .env 中设置 OGSCOPE_DISPLAY_ENABLED=true" ) if settings.display_type.lower() != "st7796": - raise RuntimeError(f"unsupported display_type: {settings.display_type!r} (expected st7796)") + raise RuntimeError( + f"unsupported display_type: {settings.display_type!r} (expected st7796)" + ) if sys.platform != "linux": - raise RuntimeError("ST7796 仅支持 Linux(树莓派)/ ST7796 requires Linux (Raspberry Pi)") + raise RuntimeError( + "ST7796 仅支持 Linux(树莓派)/ ST7796 requires Linux (Raspberry Pi)" + ) if self._display is not None: return self._display from ogscope.platform.hardware.st7796_spi import ST7796SPI diff --git a/ogscope/platform/hardware_plane/services/sensor_hub.py b/ogscope/platform/hardware_plane/services/sensor_hub.py index 5eea6b8..67770e1 100644 --- a/ogscope/platform/hardware_plane/services/sensor_hub.py +++ b/ogscope/platform/hardware_plane/services/sensor_hub.py @@ -63,4 +63,3 @@ async def command( self._running = True return {"accepted": True, "message": "sensor hub restarted"} return {"accepted": False, "message": f"unsupported action: {action}"} - diff --git a/ogscope/platform/hardware_plane/transport/__init__.py b/ogscope/platform/hardware_plane/transport/__init__.py index 3b55b41..1e20b7c 100644 --- a/ogscope/platform/hardware_plane/transport/__init__.py +++ b/ogscope/platform/hardware_plane/transport/__init__.py @@ -8,4 +8,3 @@ ) __all__ = ["JsonRpcUdsServer", "JsonRpcUdsClient"] - diff --git a/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py b/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py index 7515534..35c5197 100644 --- a/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py +++ b/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py @@ -96,13 +96,18 @@ async def call( "method": method, "params": params or {}, } - writer.write((json.dumps(request, ensure_ascii=False) + "\n").encode("utf-8")) + writer.write( + (json.dumps(request, ensure_ascii=False) + "\n").encode("utf-8") + ) await asyncio.wait_for(writer.drain(), timeout=budget_s) line = await asyncio.wait_for(reader.readline(), timeout=budget_s) if not line: - return {"success": False, "error": {"message": "empty response"}, "data": {}} + return { + "success": False, + "error": {"message": "empty response"}, + "data": {}, + } return json.loads(line.decode("utf-8", errors="ignore")) finally: writer.close() await writer.wait_closed() - diff --git a/ogscope/web/api/analysis/routes.py b/ogscope/web/api/analysis/routes.py index 8ff2314..114b812 100644 --- a/ogscope/web/api/analysis/routes.py +++ b/ogscope/web/api/analysis/routes.py @@ -8,6 +8,7 @@ from fastapi.responses import FileResponse, PlainTextResponse from ogscope.domain.analysis.services import analysis_domain_service +from ogscope.web.api.analysis.services import analysis_service from ogscope.web.api.models.schemas import ( AnalysisBatchSolveRequest, AnalysisExperimentCreate, @@ -19,7 +20,6 @@ AnalysisSolveVideoFrameRequest, ImportFromDebugRequest, ) -from ogscope.web.api.analysis.services import analysis_service router = APIRouter() @@ -287,7 +287,9 @@ async def solve_uploaded_frame( """上传单帧 JPEG/PNG 并解算 / Solve a single uploaded frame (multipart).""" try: raw = await file.read() - payload_dict, extras = analysis_domain_service.parse_frame_upload_payload(payload) + payload_dict, extras = analysis_domain_service.parse_frame_upload_payload( + payload + ) data = AnalysisSolveImageRequest.model_validate(payload_dict) return await analysis_service.solve_uploaded_frame( image_bytes=raw, diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index f14fc70..b372cc0 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -1317,7 +1317,9 @@ async def solve_video_frame( ) loop = asyncio.get_running_loop() - cr_frame = self._clamp_centroid_rejection_level(body.centroid_rejection_level) + cr_frame = self._clamp_centroid_rejection_level( + body.centroid_rejection_level + ) def _run() -> dict[str, Any]: return self._solve_bgr_to_row( diff --git a/ogscope/web/api/core/routes.py b/ogscope/web/api/core/routes.py index 4a53330..a0dc471 100644 --- a/ogscope/web/api/core/routes.py +++ b/ogscope/web/api/core/routes.py @@ -25,7 +25,9 @@ "/core/v1/analysis/start", response_model=CoreAnalysisControlResponse, ) -async def core_start_analysis(body: CoreStartAnalysisRequest) -> CoreAnalysisControlResponse: +async def core_start_analysis( + body: CoreStartAnalysisRequest, +) -> CoreAnalysisControlResponse: """开始分析(Core 标准契约)/ Start analysis (Core contract).""" try: data = await core_contract_service.start_analysis( @@ -113,7 +115,9 @@ async def core_camera_stop() -> CoreCameraControlResponse: async def core_camera_tune(payload: CoreCameraTuneRequest) -> CoreCameraControlResponse: """微调相机参数(Core 标准契约)/ Tune camera settings (Core contract).""" try: - data = await core_contract_service.tune_camera(payload.model_dump(exclude_none=True)) + data = await core_contract_service.tune_camera( + payload.model_dump(exclude_none=True) + ) return CoreCameraControlResponse(**data) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/ogscope/web/api/debug/magnetometer_service.py b/ogscope/web/api/debug/magnetometer_service.py index 3e15859..8788bbf 100644 --- a/ogscope/web/api/debug/magnetometer_service.py +++ b/ogscope/web/api/debug/magnetometer_service.py @@ -35,7 +35,12 @@ def _smbus_read_wia(bus: int, addr7: int) -> dict[str, Any]: try: from smbus2 import SMBus except ImportError: - return {"ok": False, "error": "smbus2 not installed", "wia1": None, "wia2": None} + return { + "ok": False, + "error": "smbus2 not installed", + "wia1": None, + "wia2": None, + } path = f"/dev/i2c-{bus}" if not os.path.exists(path): @@ -60,9 +65,7 @@ def _read() -> tuple[int | None, int | None, str | None]: wia1, wia2, err = _read() if err: return {"ok": False, "error": err, "wia1": None, "wia2": None} - match = ( - wia1 == _AKM_WIA1 and wia2 is not None and int(wia2) in _KNOWN_WIA2 - ) + match = wia1 == _AKM_WIA1 and wia2 is not None and int(wia2) in _KNOWN_WIA2 return { "ok": True, "error": None, @@ -96,6 +99,7 @@ def _smbus_read_wia_first_matching( class MagnetometerDebugService: """AK09911 系列探针与总线扫描 / AK09911 family probe and bus scan.""" + _xy_calib: dict[tuple[int, int], dict[str, Any]] = {} _heading_mode: dict[tuple[int, int], str] = {} _heading_locked: dict[tuple[int, int], dict[str, Any]] = {} @@ -226,14 +230,22 @@ async def calibration_commit(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, An d = ((d + 180.0) % 360.0) - 180.0 unwrapped += d prev_u = unwrapped - trend = unwrapped - ((math.degrees(math.atan2( - *MagnetometerDebugService._pair_values( - axes, - float(hx_hist[0]) - cx, - float(hy_hist[0]) - cy, - float(hz_hist[0]) - cz, + trend = unwrapped - ( + ( + math.degrees( + math.atan2( + *MagnetometerDebugService._pair_values( + axes, + float(hx_hist[0]) - cx, + float(hy_hist[0]) - cy, + float(hz_hist[0]) - cz, + ) + ) + ) + + 360.0 ) - )) + 360.0) % 360.0) + % 360.0 + ) sign = 1 if trend >= 0 else -1 locked = { @@ -284,7 +296,11 @@ async def calibration_status(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, An "addr_7bit": int(k[1]), "addr_7bit_hex": f"0x{int(k[1]):02x}", "samples": int(float(st.get("samples", 0.0))), - "span_xyz": {"x": round(span_x, 3), "y": round(span_y, 3), "z": round(span_z, 3)}, + "span_xyz": { + "x": round(span_x, 3), + "y": round(span_y, 3), + "z": round(span_z, 3), + }, "locked": locked, } @@ -416,9 +432,7 @@ async def probe_address_on_buses( _smbus_read_wia_first_matching, b, addr7 ) results.append({"bus": b, "addr_7bit_used": int(used), **w}) - any_ok = any( - r.get("ok") and r.get("matches_ak099xx") for r in results - ) + any_ok = any(r.get("ok") and r.get("matches_ak099xx") for r in results) return { "success": any_ok, "addr_7bit": int(addr7), @@ -530,9 +544,8 @@ async def sample_heading(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, Any]: lz = hz - float(c.get("z", cz)) la, lb = MagnetometerDebugService._pair_values(axes_locked, lx, ly, lz) heading_deg_locked = ( - (math.degrees(math.atan2(sign_locked * la, lb)) + offset_locked + 360.0) - % 360.0 - ) + math.degrees(math.atan2(sign_locked * la, lb)) + offset_locked + 360.0 + ) % 360.0 heading_deg = heading_deg_locked heading_source = f"locked_{axes_locked}" return { @@ -548,7 +561,9 @@ async def sample_heading(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, Any]: "heading_raw_deg": round(heading_raw_deg, 2), "heading_calibrated_deg": round(heading_cal_deg, 2), "heading_auto_deg": round(heading_auto_deg, 2), - "heading_locked_deg": None if heading_deg_locked is None else round(heading_deg_locked, 2), + "heading_locked_deg": ( + None if heading_deg_locked is None else round(heading_deg_locked, 2) + ), "heading_source": heading_source, "heading_axes_auto": auto_axes, "heading_mode": mode, @@ -580,4 +595,3 @@ async def sample_heading(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, Any]: "calibration_samples": int(float(st.get("samples", 0.0))), "calibration_locked": locked, } - diff --git a/ogscope/web/api/system/routes.py b/ogscope/web/api/system/routes.py index 38f166e..278513e 100644 --- a/ogscope/web/api/system/routes.py +++ b/ogscope/web/api/system/routes.py @@ -12,7 +12,10 @@ from ogscope.domain.system.services import system_info_service from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client from ogscope.web.api.models.schemas import SystemInfo -from ogscope.web.api.system.config_files import read_config_file_payload, write_config_file +from ogscope.web.api.system.config_files import ( + read_config_file_payload, + write_config_file, +) router = APIRouter() diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 6f5c290..4dfe82a 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -101,7 +101,9 @@ async def _warm_solver() -> None: "相机自动启动失败,将按需延迟启动 / Camera auto-start failed, fallback to lazy start: {}", e, ) - phase_elapsed_ms = int((asyncio.get_running_loop().time() - phase_p0_started) * 1000) + phase_elapsed_ms = int( + (asyncio.get_running_loop().time() - phase_p0_started) * 1000 + ) logger.info("启动阶段完成 / Startup phases ready in {} ms", phase_elapsed_ms) try: @@ -141,8 +143,12 @@ async def _warm_solver() -> None: logger.warning( "硬件平面停止超时或异常 / Hardware plane stop timeout or error: {}", e ) - shutdown_elapsed_ms = int((asyncio.get_running_loop().time() - shutdown_started) * 1000) - logger.info("退出阶段完成 / Shutdown cleanup finished in {} ms", shutdown_elapsed_ms) + shutdown_elapsed_ms = int( + (asyncio.get_running_loop().time() - shutdown_started) * 1000 + ) + logger.info( + "退出阶段完成 / Shutdown cleanup finished in {} ms", shutdown_elapsed_ms + ) # API 文档分组标签 / API documentation group tags @@ -268,6 +274,7 @@ async def _guard_subordinate_dev_routes(request: Request, call_next): ) return await call_next(request) + # 挂载静态文件 / Mount static files if bool(hardware_profile["enable_ui"]) and settings.static_dir.exists(): app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static") @@ -438,7 +445,9 @@ def _filtered_openapi_schema(*, mode: str) -> dict: filtered_paths: dict[str, dict] = {} if mode == "core": filtered_paths = { - path: data for path, data in paths.items() if path.startswith("/api/core/v1/") + path: data + for path, data in paths.items() + if path.startswith("/api/core/v1/") } elif mode == "dev": filtered_paths = { diff --git a/ogscope/web/mjpeg_stream_limiter.py b/ogscope/web/mjpeg_stream_limiter.py index 0c2371b..44bd961 100644 --- a/ogscope/web/mjpeg_stream_limiter.py +++ b/ogscope/web/mjpeg_stream_limiter.py @@ -2,6 +2,9 @@ MJPEG 长连接并发限制 / Concurrent MJPEG stream limiter """ -from ogscope.domain.camera.stream_limiter import MjpegStreamLimiter, get_mjpeg_stream_limiter +from ogscope.domain.camera.stream_limiter import ( + MjpegStreamLimiter, + get_mjpeg_stream_limiter, +) __all__ = ["MjpegStreamLimiter", "get_mjpeg_stream_limiter"] diff --git a/tests/conftest.py b/tests/conftest.py index b147c19..115c6f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,9 +19,9 @@ def client(): @pytest.fixture def temp_debug_dir(monkeypatch, tmp_path: Path): """将调试目录重定向到临时目录,避免污染用户目录。 / Redirect the debug directory to a temporary directory to avoid polluting the user directory.""" - from ogscope.web.api.debug import services as debug_services from ogscope.domain import shared as domain_shared_pkg from ogscope.domain.shared import filesystem as shared_fs + from ogscope.web.api.debug import services as debug_services debug_root = tmp_path / "dev_captures" debug_root.mkdir(parents=True, exist_ok=True) @@ -32,7 +32,9 @@ def temp_debug_dir(monkeypatch, tmp_path: Path): if hasattr(debug_services, "DEBUG_CAPTURES_DIR"): monkeypatch.setattr(debug_services, "DEBUG_CAPTURES_DIR", debug_root) if hasattr(domain_shared_pkg, "filesystem"): - monkeypatch.setattr(domain_shared_pkg.filesystem, "DEV_CAPTURES_DIR", debug_root) + monkeypatch.setattr( + domain_shared_pkg.filesystem, "DEV_CAPTURES_DIR", debug_root + ) monkeypatch.setattr(debug_services, "is_recording", False) monkeypatch.setattr(debug_services, "recording_task", None) monkeypatch.setattr(debug_services, "recording_stem", None) diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py index ed9e2d1..a59895a 100644 --- a/tests/unit/test_analysis_api.py +++ b/tests/unit/test_analysis_api.py @@ -219,7 +219,9 @@ def test_analysis_list_presets_and_batch( ) assert exp.status_code == 200 - el = client.get("/api/dev/analysis/experiments", params={"page": 1, "page_size": 10}) + el = client.get( + "/api/dev/analysis/experiments", params={"page": 1, "page_size": 10} + ) assert el.status_code == 200 assert el.json()["total"] >= 1 diff --git a/tests/unit/test_config_catalog.py b/tests/unit/test_config_catalog.py index ac3d922..b3f8429 100644 --- a/tests/unit/test_config_catalog.py +++ b/tests/unit/test_config_catalog.py @@ -12,9 +12,7 @@ def test_build_config_catalog_includes_new_preview_fields() -> None: catalog = build_config_catalog() keys = { - entry["key"] - for section in catalog["sections"] - for entry in section["entries"] + entry["key"] for section in catalog["sections"] for entry in section["entries"] } assert "OGSCOPE_SHARED_PREVIEW_FPS" in keys assert "OGSCOPE_PREVIEW_JPEG_QUALITY" in keys diff --git a/tests/unit/test_config_files.py b/tests/unit/test_config_files.py index f2c0540..7a8306c 100644 --- a/tests/unit/test_config_files.py +++ b/tests/unit/test_config_files.py @@ -18,7 +18,9 @@ def test_read_config_file_payload_marks_sudo_writable( monkeypatch.setattr(mod, "CONFIG_WRITE_SCRIPT", tmp_path / "write.sh") monkeypatch.setattr(mod, "CONFIG_SUDOERS", tmp_path / "sudoers") mod.CONFIG_WRITE_SCRIPT.write_text("#!/bin/sh\n", encoding="utf-8") - mod.CONFIG_SUDOERS.write_text("ogscope ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-config-write\n") + mod.CONFIG_SUDOERS.write_text( + "ogscope ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-config-write\n" + ) payload = mod.read_config_file_payload(env_path) @@ -29,7 +31,8 @@ def test_read_config_file_payload_marks_sudo_writable( @pytest.mark.unit def test_read_config_file_payload_not_writable_without_sudoers( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: env_path = tmp_path / "ogscope.env" env_path.write_text("OGSCOPE_PORT=8000\n", encoding="utf-8") diff --git a/tests/unit/test_core_contract_api.py b/tests/unit/test_core_contract_api.py index e6afa0d..9fb96a8 100644 --- a/tests/unit/test_core_contract_api.py +++ b/tests/unit/test_core_contract_api.py @@ -118,11 +118,15 @@ async def _fake_stop_camera(): monkeypatch.setattr( core_service.core_contract_service, "get_camera_status", _fake_camera_status ) - monkeypatch.setattr(core_service.core_contract_service, "tune_camera", _fake_camera_tune) + monkeypatch.setattr( + core_service.core_contract_service, "tune_camera", _fake_camera_tune + ) monkeypatch.setattr( core_service.core_contract_service, "start_camera", _fake_start_camera ) - monkeypatch.setattr(core_service.core_contract_service, "stop_camera", _fake_stop_camera) + monkeypatch.setattr( + core_service.core_contract_service, "stop_camera", _fake_stop_camera + ) monkeypatch.setattr( core_service.core_contract_service, "get_stream_status", _fake_stream_status ) diff --git a/tests/unit/test_debug_camera_api.py b/tests/unit/test_debug_camera_api.py index 2fb7e74..fe0623f 100644 --- a/tests/unit/test_debug_camera_api.py +++ b/tests/unit/test_debug_camera_api.py @@ -236,7 +236,9 @@ def test_debug_camera_update_settings_success(client, fake_camera_env): @pytest.mark.unit def test_debug_camera_auto_exposure_switch_success(client, fake_camera_env): - response = client.post("/api/dev/debug/camera/auto-exposure", params={"enabled": False}) + response = client.post( + "/api/dev/debug/camera/auto-exposure", params={"enabled": False} + ) assert response.status_code == 200 body = response.json() assert body["success"] is True diff --git a/tests/unit/test_dev_contract_api.py b/tests/unit/test_dev_contract_api.py index e0ca105..ab79da3 100644 --- a/tests/unit/test_dev_contract_api.py +++ b/tests/unit/test_dev_contract_api.py @@ -58,4 +58,3 @@ def test_dev_hardware_plane_metrics_include_profile(client) -> None: def test_legacy_debug_path_not_exposed(client) -> None: resp = client.get("/api/debug/camera/status") assert resp.status_code in {404, 405} - diff --git a/tests/unit/test_domain_camera_sidecar.py b/tests/unit/test_domain_camera_sidecar.py index 281827b..7857a99 100644 --- a/tests/unit/test_domain_camera_sidecar.py +++ b/tests/unit/test_domain_camera_sidecar.py @@ -39,4 +39,3 @@ def test_merge_capture_sidecar_does_not_override_existing_fields() -> None: merge_capture_sidecar_into_info(info, capture_info) assert info["resolution"] == "640x480" - diff --git a/tests/unit/test_domain_camera_streaming.py b/tests/unit/test_domain_camera_streaming.py index 5a11548..c8d7827 100644 --- a/tests/unit/test_domain_camera_streaming.py +++ b/tests/unit/test_domain_camera_streaming.py @@ -35,7 +35,9 @@ async def release(self) -> None: @pytest.mark.unit @pytest.mark.asyncio -async def test_build_camera_mjpeg_stream_rejects_when_limit_reached(monkeypatch) -> None: +async def test_build_camera_mjpeg_stream_rejects_when_limit_reached( + monkeypatch, +) -> None: limiter = _FakeLimiter(can_acquire=False) monkeypatch.setattr(streaming_mod, "get_mjpeg_stream_limiter", lambda: limiter) @@ -60,10 +62,13 @@ async def test_build_camera_mjpeg_stream_yields_frame_and_releases(monkeypatch) class _FakeSettings: stream_mjpeg_frame_fetch_timeout_ms = 1000 + shared_preview_fps = 8 monkeypatch.setattr(streaming_mod, "get_settings", lambda: _FakeSettings()) - async def _fake_get_stream_frame_bytes(fmt: str, quality: int, *, since_frame_id: int): + async def _fake_get_stream_frame_bytes( + fmt: str, quality: int, *, since_frame_id: int + ): _ = fmt, quality, since_frame_id return 200, b"abc", 1 @@ -87,4 +92,3 @@ async def _fake_get_stream_frame_bytes(fmt: str, quality: int, *, since_frame_id assert b"Content-Type: image/jpeg" in first_chunk await body_iter.aclose() assert limiter.released is True - diff --git a/tests/unit/test_domain_shared_filesystem.py b/tests/unit/test_domain_shared_filesystem.py index c945ba0..77e430a 100644 --- a/tests/unit/test_domain_shared_filesystem.py +++ b/tests/unit/test_domain_shared_filesystem.py @@ -34,4 +34,3 @@ def test_ensure_safe_basename_accepts_valid_names(name: str) -> None: def test_ensure_safe_basename_rejects_invalid_names(name: str) -> None: with pytest.raises(ValueError): ensure_safe_basename(name) - diff --git a/tests/unit/test_hardware_plane.py b/tests/unit/test_hardware_plane.py index cc866cd..5314e18 100644 --- a/tests/unit/test_hardware_plane.py +++ b/tests/unit/test_hardware_plane.py @@ -38,7 +38,9 @@ async def test_hardware_plane_daemon_minimal_methods() -> None: @pytest.mark.unit @pytest.mark.asyncio -async def test_hardware_plane_daemon_subordinate_profile_disables_local_services() -> None: +async def test_hardware_plane_daemon_subordinate_profile_disables_local_services() -> ( + None +): daemon = HardwarePlaneDaemon( enable_local_sensors=False, enable_hmi=False, @@ -62,7 +64,9 @@ async def test_hardware_plane_daemon_subordinate_profile_disables_local_services @pytest.mark.asyncio async def test_jsonrpc_uds_sensor_read_roundtrip(tmp_path: Path) -> None: _ = tmp_path - socket_path = Path("/tmp") / f"external-sensor-{os.getpid()}-{int(time.time() * 1000)}.sock" + socket_path = ( + Path("/tmp") / f"external-sensor-{os.getpid()}-{int(time.time() * 1000)}.sock" + ) async def _handler(method: str, params: dict[str, object]) -> dict[str, object]: if method != "sensor.read": @@ -108,4 +112,3 @@ def test_runtime_profile_subordinate_disables_ui_hmi_local_sensors() -> None: assert profile["enable_hmi"] is False assert profile["enable_ui"] is True assert profile["enable_local_sensors"] is False - diff --git a/tests/unit/test_system_wifi_parse.py b/tests/unit/test_system_wifi_parse.py index f3e206e..9b5246a 100644 --- a/tests/unit/test_system_wifi_parse.py +++ b/tests/unit/test_system_wifi_parse.py @@ -4,7 +4,6 @@ import pytest -from ogscope.web.api.system import services as system_services from ogscope.web.api.system.services import SystemInfoService _WIRELESS_SAMPLE = """Inter-| sta-| Quality | Discarded packets diff --git a/tests/unit/test_wifi_switch.py b/tests/unit/test_wifi_switch.py index 0769de6..8a05c78 100644 --- a/tests/unit/test_wifi_switch.py +++ b/tests/unit/test_wifi_switch.py @@ -10,7 +10,10 @@ import pytest from ogscope.config import Settings -from ogscope.platform.hardware.wifi_switch import WifiSwitchService, _parse_status_output +from ogscope.platform.hardware.wifi_switch import ( + WifiSwitchService, + _parse_status_output, +) @pytest.mark.unit @@ -119,7 +122,9 @@ async def _fake_switch_mode(mode: str): _ = mode return network_routes.wifi_domain_service.build_wifi_status() - monkeypatch.setattr(network_routes.wifi_domain_service, "switch_mode", _fake_switch_mode) + monkeypatch.setattr( + network_routes.wifi_domain_service, "switch_mode", _fake_switch_mode + ) response = client.get("/api/network/wifi") assert response.status_code == 200 @@ -139,7 +144,9 @@ def test_network_wifi_scan_api(client, monkeypatch) -> None: async def _fake_scan_wifi(): return [{"ssid": "Home", "signal": 80, "security": "WPA2"}], None - monkeypatch.setattr(network_routes.wifi_domain_service, "scan_wifi", _fake_scan_wifi) + monkeypatch.setattr( + network_routes.wifi_domain_service, "scan_wifi", _fake_scan_wifi + ) response = client.get("/api/network/wifi/scan") assert response.status_code == 200 @@ -163,7 +170,9 @@ async def _fake_profiles(): } ] - monkeypatch.setattr(network_routes.wifi_domain_service, "list_profiles", _fake_profiles) + monkeypatch.setattr( + network_routes.wifi_domain_service, "list_profiles", _fake_profiles + ) response = client.get("/api/network/wifi/profiles") assert response.status_code == 200 data = response.json() From 57fa847b99e869085d2abc60dc87a60d9e84e563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Thu, 11 Jun 2026 23:46:03 +0800 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=E7=B3=BB=E7=BB=9F=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=A2=9E=E5=8A=A0=20health=5Freasons=20=E5=B9=B6?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E5=A7=94=E6=89=98=E7=BD=91=E7=BB=9C=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=20/=20Add=20health=5Freasons=20and=20exclude=20delega?= =?UTF-8?q?ted=20network=20from=20health?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - system/status 返回稳定 health_reasons;subordinate 或未配 WiFi 时 network 标记 delegated 不参与 health / Return stable health_reasons; delegated network does not degrade health - capabilities.network 与 OGScope 是否管网一致 / Align capabilities.network with OGScope network ownership - 更新 Core 契约与 subordinate 文档及单元测试 / Update contract docs and unit tests Co-authored-by: Cursor --- docs/contracts/core-rest-v1.md | 5 +- docs/contracts/core-rest-v1_EN.md | 5 +- docs/contracts/subordinate-mode.md | 5 ++ ogscope/core/application/core_service.py | 97 +++++++++++++++++++----- ogscope/core/capabilities/registry.py | 14 +++- ogscope/web/api/models/schemas.py | 1 + tests/unit/test_core_contract_api.py | 50 ++++++++++++ 7 files changed, 153 insertions(+), 24 deletions(-) diff --git a/docs/contracts/core-rest-v1.md b/docs/contracts/core-rest-v1.md index 3d8ff13..07f9d4d 100644 --- a/docs/contracts/core-rest-v1.md +++ b/docs/contracts/core-rest-v1.md @@ -55,12 +55,13 @@ - `GET /api/core/v1/system/status` - 响应: - `success: bool` - - `health: str` + - `health: str`(`healthy` | `degraded`) + - `health_reasons: string[]`(降级时的稳定原因码,如 `camera_not_connected`、`network_wifi_not_configured`;`healthy` 时为空数组) - `version: str` - `capabilities: object` - `system: object` - `camera: object`(相机在线与运行态摘要) - - `network: object`(WiFi 模式/信号/连接态) + - `network: object`(WiFi 模式/信号/连接态;含 `managed_by`、`in_health_scope`;subordinate 或最小部署未配 WiFi 时为 `delegated`,**不参与** `health`) - `sensors: object`(温度/CPU/内存等核心传感状态) ### 5) Camera Runtime & Preview (MJPEG / single-frame) diff --git a/docs/contracts/core-rest-v1_EN.md b/docs/contracts/core-rest-v1_EN.md index 4faa1de..20932c2 100644 --- a/docs/contracts/core-rest-v1_EN.md +++ b/docs/contracts/core-rest-v1_EN.md @@ -55,12 +55,13 @@ This document defines the **minimal stable REST surface** for callers integratin - `GET /api/core/v1/system/status` - Response: - `success: bool` - - `health: str` + - `health: str` (`healthy` | `degraded`) + - `health_reasons: string[]` — stable degradation codes when not healthy (e.g. `camera_not_connected`, `network_wifi_not_configured`); empty when `healthy` - `version: str` - `capabilities: object` - `system: object` - `camera: object` — camera online and runtime summary - - `network: object` — WiFi mode / signal / connection state + - `network: object` — WiFi mode / signal / connection; includes `managed_by`, `in_health_scope`; when subordinate or minimal deploy without OGScope WiFi config, status is `delegated` and **does not** affect `health` - `sensors: object` — temperature / CPU / memory, etc. ### 5) Camera Runtime & Preview (MJPEG / single-frame) diff --git a/docs/contracts/subordinate-mode.md b/docs/contracts/subordinate-mode.md index 6dfd22a..2581ef9 100644 --- a/docs/contracts/subordinate-mode.md +++ b/docs/contracts/subordinate-mode.md @@ -42,6 +42,11 @@ OGScope 支持两种硬件平面角色: - **业务调用**:上层集成方 → OGScope `REST /api/core/v1/*`(详见 [core-rest-v1](core-rest-v1.md))。 - **传感器委托**:OGScope → 外部传感器服务 `UDS JSON-RPC`(详见 [hardware-plane-uds-v1](hardware-plane-uds-v1.md))。 +## 健康状态(health) + +- subordinate 或最小部署未配置 OGScope WiFi 脚本/连接名时,`GET /api/core/v1/system/status` 的 `network` 块为 `managed_by: external`(或 standalone 未配时为 `unconfigured`)、`status: delegated`,**不参与** `health` / `health_reasons` 计算。 +- 此时 `health` 仅反映 OGScope 职责内子系统(当前主要为相机);上层集成方应自行监控网络。 + ## 版本与兼容 - 契约以增量扩展为主;破坏性变更须更新本文档与 [core-compatibility-matrix](core-compatibility-matrix.md)。 diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index 7fe36f7..571974f 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -17,7 +17,10 @@ stream_state_domain_service, ) from ogscope.domain.system.services import system_info_service -from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client +from ogscope.platform.hardware_plane.runtime import ( + describe_hardware_plane_profile, + get_hardware_plane_client, +) from ogscope.platform.hardware.wifi_switch import wifi_switch_service @@ -47,6 +50,71 @@ def _normalize_camera_status(status: dict[str, Any]) -> dict[str, Any]: "error": status.get("error"), } + @staticmethod + def _network_health_in_scope(profile: dict[str, Any]) -> bool: + """网络是否纳入 OGScope health 评估 / Whether network affects OGScope health.""" + if bool(profile.get("subordinate_mode")): + return False + return wifi_switch_service.is_configured() + + @staticmethod + def _health_reasons( + camera_status: dict[str, Any], + network: dict[str, Any], + *, + network_in_health_scope: bool, + ) -> list[str]: + """稳定 health 降级原因码 / Stable machine-readable health degradation codes.""" + reasons: list[str] = [] + if not camera_status.get("connected", False): + reasons.append("camera_not_connected") + if not network_in_health_scope: + return reasons + net_err = network.get("error") + if net_err: + token = str(net_err).strip().lower().replace("-", "_") + if token and token.replace("_", "").isalnum(): + reasons.append(f"network_{token}") + else: + reasons.append("network_error") + return reasons + + @staticmethod + def _build_network_status( + profile: dict[str, Any], + system: dict[str, Any], + ) -> dict[str, Any]: + """构造 network 块:职责外仅遥测,不参与 health / Build network block with scope metadata.""" + settings = get_settings() + in_health_scope = CoreContractService._network_health_in_scope(profile) + base: dict[str, Any] = { + "wireless_interface": settings.wifi_interface, + "signal_dbm": system.get("wifi_signal_dbm"), + "quality_percent": system.get("wifi_quality"), + "in_health_scope": in_health_scope, + } + if not in_health_scope: + managed_by = "external" if profile.get("subordinate_mode") else "unconfigured" + return { + **base, + "managed_by": managed_by, + "status": "delegated", + "mode": "unknown", + "active_connection": None, + "ap_ipv4": None, + "error": None, + } + wifi_raw = wifi_switch_service.get_status() + return { + **base, + "managed_by": "ogscope", + "status": "managed", + "mode": wifi_raw.get("MODE", "unknown"), + "active_connection": wifi_raw.get("ACTIVE_CONNECTION"), + "ap_ipv4": wifi_raw.get("AP_IPV4"), + "error": wifi_raw.get("error"), + } + async def start_analysis( self, *, @@ -103,7 +171,8 @@ async def stop_analysis(self) -> dict[str, Any]: async def get_system_status(self) -> dict[str, Any]: """系统状态与能力 / System status and capability map.""" - wifi_raw = wifi_switch_service.get_status() + profile = describe_hardware_plane_profile() + network_in_health_scope = self._network_health_in_scope(profile) hardware_client = get_hardware_plane_client() hw_status_resp = await hardware_client.status_get() hw_status_data = hw_status_resp.get("data", {}) if hw_status_resp.get("success") else {} @@ -128,25 +197,17 @@ async def get_system_status(self) -> dict[str, Any]: "memory_usage_percent": system.get("memory_usage"), "uptime_seconds": system.get("uptime_seconds"), } - network = { - "mode": wifi_raw.get("MODE", "unknown"), - "wireless_interface": wifi_raw.get( - "WIRELESS_INTERFACE", get_settings().wifi_interface - ), - "signal_dbm": system.get("wifi_signal_dbm"), - "quality_percent": system.get("wifi_quality"), - "active_connection": wifi_raw.get("ACTIVE_CONNECTION"), - "ap_ipv4": wifi_raw.get("AP_IPV4"), - "error": wifi_raw.get("error"), - } - health = "healthy" - if network.get("error"): - health = "degraded" - if not camera_status.get("connected", False): - health = "degraded" + network = self._build_network_status(profile, system) + health_reasons = self._health_reasons( + camera_status, + network, + network_in_health_scope=network_in_health_scope, + ) + health = "healthy" if not health_reasons else "degraded" return { "success": True, "health": health, + "health_reasons": health_reasons, "version": __version__, "capabilities": capability_map(), "hardware_plane": { diff --git a/ogscope/core/capabilities/registry.py b/ogscope/core/capabilities/registry.py index f98931c..5866422 100644 --- a/ogscope/core/capabilities/registry.py +++ b/ogscope/core/capabilities/registry.py @@ -8,7 +8,9 @@ from dataclasses import dataclass from typing import Any -from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client +from ogscope.config import get_settings +from ogscope.platform.hardware_plane.runtime import describe_hardware_plane_profile +from ogscope.platform.hardware.wifi_switch import wifi_switch_service def _module_available(module_name: str) -> bool: @@ -35,10 +37,16 @@ def to_dict(self) -> dict[str, bool]: def detect_capabilities() -> CapabilitySnapshot: """检测当前运行能力 / Detect runtime capabilities.""" + profile = describe_hardware_plane_profile(get_settings()) + network_managed = ( + not bool(profile.get("subordinate_mode")) + and wifi_switch_service.is_configured() + and _module_available("ogscope.domain.network.services") + ) return CapabilitySnapshot( analysis=_module_available("ogscope.domain.analysis.services"), camera=_module_available("ogscope.platform.hardware.camera"), - network=_module_available("ogscope.domain.network.services"), + network=network_managed, ) @@ -49,6 +57,8 @@ def capability_map() -> dict[str, Any]: async def capability_inventory() -> list[dict[str, Any]]: """返回硬件平面能力清单 / Return hardware-plane capability inventory.""" + from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client + client = get_hardware_plane_client() resp = await client.capability_list() if not resp.get("success"): diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index abd80e7..12f8d07 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -435,6 +435,7 @@ class CoreSystemStatusResponse(BaseModel): success: bool health: str + health_reasons: list[str] = Field(default_factory=list) version: str capabilities: dict[str, bool] system: dict[str, Any] diff --git a/tests/unit/test_core_contract_api.py b/tests/unit/test_core_contract_api.py index e6afa0d..204d98f 100644 --- a/tests/unit/test_core_contract_api.py +++ b/tests/unit/test_core_contract_api.py @@ -19,6 +19,56 @@ def test_core_system_status(client) -> None: assert "system" in data assert "hardware_plane" in data assert "hardware_plane" in data + assert "health_reasons" in data + assert isinstance(data["health_reasons"], list) + if data["health"] == "healthy": + assert data["health_reasons"] == [] + else: + assert len(data["health_reasons"]) >= 1 + + +@pytest.mark.unit +def test_core_system_status_health_reasons() -> None: + """health_reasons 反映相机与网络降级 / health_reasons reflect camera and network degradation.""" + from ogscope.core.application.core_service import CoreContractService + + reasons = CoreContractService._health_reasons( + CoreContractService._normalize_camera_status( + {"connected": False, "error": "Camera not initialized"}, + ), + {"error": "wifi_not_configured"}, + network_in_health_scope=True, + ) + assert "camera_not_connected" in reasons + assert "network_wifi_not_configured" in reasons + + +@pytest.mark.unit +def test_core_system_status_health_reasons_ignore_delegated_network() -> None: + """职责外网络不参与 health / Delegated network does not affect health.""" + from ogscope.core.application.core_service import CoreContractService + + reasons = CoreContractService._health_reasons( + CoreContractService._normalize_camera_status({"connected": True}), + {"error": "wifi_not_configured"}, + network_in_health_scope=False, + ) + assert reasons == [] + + +@pytest.mark.unit +def test_core_system_status_network_delegated_when_subordinate(monkeypatch) -> None: + """subordinate 下 network 标记 delegated 且不降级 / Subordinate marks network delegated.""" + from ogscope.core.application.core_service import CoreContractService + + network = CoreContractService._build_network_status( + {"role": "subordinate", "subordinate_mode": True}, + {"wifi_signal_dbm": -50.0, "wifi_quality": 88.0}, + ) + assert network["managed_by"] == "external" + assert network["in_health_scope"] is False + assert network["error"] is None + assert network["signal_dbm"] == -50.0 @pytest.mark.unit From 3ec99421c6b9cc96db4a231fe93b65d3f6fad5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Fri, 12 Jun 2026 00:27:04 +0800 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20=E7=9B=B8=E6=9C=BA=20stop=20?= =?UTF-8?q?=E6=9C=9F=E9=97=B4=E9=98=BB=E6=AD=A2=20MJPEG=20=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E6=8B=89=E8=B5=B7=E9=87=87=E9=9B=86=20/=20Prevent=20M?= =?UTF-8?q?JPEG=20from=20restarting=20camera=20during=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 _stopping 守卫,stop 时拒绝 ensure_started / Add _stopping guard to reject ensure_started while stopping - stop 获取控制锁失败时重试,成功后清除 health_error / Retry control lock on stop and clear health_error on success Co-authored-by: Cursor --- ogscope/web/camera_shared.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index e355fb8..a453ea7 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -54,6 +54,7 @@ def __init__(self) -> None: # Whether to retain raw frame cache; default off to reduce RAM (analysis can sync-grab). self._keep_raw_cache = bool(settings.keep_raw_cache) self._logger = logging.getLogger(__name__) + self._stopping = False @property def preview_jpeg_quality(self) -> int: @@ -121,7 +122,11 @@ def _read_frame_sync(self): async def ensure_started(self) -> None: """确保单相机进入采集并启动共享帧抓取 / Ensure capture and shared frame grabber.""" + if self._stopping: + raise RuntimeError("相机正在停止 / Camera is stopping") async with self._control_lock: + if self._stopping: + raise RuntimeError("相机正在停止 / Camera is stopping") if self._camera is None: self._health_error = None self._camera = await asyncio.to_thread(self._create_camera_sync) @@ -150,15 +155,22 @@ async def ensure_started(self) -> None: async def stop(self) -> None: """停止相机采集 / Stop camera capture.""" + self._stopping = True acquired = False - try: - await asyncio.wait_for(self._control_lock.acquire(), timeout=2.0) - acquired = True - except asyncio.TimeoutError: - self._logger.warning( - "等待相机控制锁超时,跳过优雅停机 / Timed out waiting camera lock, skip graceful stop" - ) - return + for attempt in range(6): + try: + await asyncio.wait_for(self._control_lock.acquire(), timeout=2.0) + acquired = True + break + except asyncio.TimeoutError: + if attempt < 5: + await asyncio.sleep(0.25) + continue + self._logger.warning( + "等待相机控制锁超时,跳过优雅停机 / Timed out waiting camera lock, skip graceful stop" + ) + self._stopping = False + return try: await self._stop_grabber_locked() if self._camera is None: @@ -186,9 +198,11 @@ async def stop(self) -> None: self._latest_ts = 0.0 self._latest_w = 0 self._latest_h = 0 + self._health_error = None finally: if acquired: self._control_lock.release() + self._stopping = False def _safe_stop_capture_sync(self) -> None: camera = self._camera From 1b0bc6f0ad69c9e1dbe0c2ab069d895465461ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Fri, 12 Jun 2026 00:45:02 +0800 Subject: [PATCH 05/18] =?UTF-8?q?revert:=20=E5=9B=9E=E9=80=80=20camera=20s?= =?UTF-8?q?top=20=E7=9A=84=20=5Fstopping=20=E8=BF=87=E5=BA=A6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20/=20Revert=20over-engineered=20=5Fstopping=20guard?= =?UTF-8?q?=20on=20camera=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 恢复原有 control_lock 停机逻辑,避免 ensure_started 永久被拒 / Restore original stop lock behavior - 预览释放由前端对齐 OGScope 调试台流程负责 / Stream release remains handled by debug console pattern on clients Co-authored-by: Cursor --- ogscope/web/camera_shared.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index a453ea7..e355fb8 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -54,7 +54,6 @@ def __init__(self) -> None: # Whether to retain raw frame cache; default off to reduce RAM (analysis can sync-grab). self._keep_raw_cache = bool(settings.keep_raw_cache) self._logger = logging.getLogger(__name__) - self._stopping = False @property def preview_jpeg_quality(self) -> int: @@ -122,11 +121,7 @@ def _read_frame_sync(self): async def ensure_started(self) -> None: """确保单相机进入采集并启动共享帧抓取 / Ensure capture and shared frame grabber.""" - if self._stopping: - raise RuntimeError("相机正在停止 / Camera is stopping") async with self._control_lock: - if self._stopping: - raise RuntimeError("相机正在停止 / Camera is stopping") if self._camera is None: self._health_error = None self._camera = await asyncio.to_thread(self._create_camera_sync) @@ -155,22 +150,15 @@ async def ensure_started(self) -> None: async def stop(self) -> None: """停止相机采集 / Stop camera capture.""" - self._stopping = True acquired = False - for attempt in range(6): - try: - await asyncio.wait_for(self._control_lock.acquire(), timeout=2.0) - acquired = True - break - except asyncio.TimeoutError: - if attempt < 5: - await asyncio.sleep(0.25) - continue - self._logger.warning( - "等待相机控制锁超时,跳过优雅停机 / Timed out waiting camera lock, skip graceful stop" - ) - self._stopping = False - return + try: + await asyncio.wait_for(self._control_lock.acquire(), timeout=2.0) + acquired = True + except asyncio.TimeoutError: + self._logger.warning( + "等待相机控制锁超时,跳过优雅停机 / Timed out waiting camera lock, skip graceful stop" + ) + return try: await self._stop_grabber_locked() if self._camera is None: @@ -198,11 +186,9 @@ async def stop(self) -> None: self._latest_ts = 0.0 self._latest_w = 0 self._latest_h = 0 - self._health_error = None finally: if acquired: self._control_lock.release() - self._stopping = False def _safe_stop_capture_sync(self) -> None: camera = self._camera From 5984fa5300ff5aa17cd9892abb607f744e7133c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Mon, 15 Jun 2026 00:38:03 +0800 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20=E5=8D=95=E5=9B=BE=E8=A7=A3?= =?UTF-8?q?=E7=AE=97=E8=A1=A5=E9=BD=90=20overlay=5Fext.polar=5Fguide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solve/image 与视频帧解算一致返回极轴指引 overlay,并增加对应 API 测试。 Co-authored-by: Cursor --- ogscope/web/api/analysis/services.py | 98 ++++++++++++++-------------- ogscope/web/api/models/schemas.py | 9 +++ tests/unit/test_analysis_api.py | 37 +++++++++++ 3 files changed, 96 insertions(+), 48 deletions(-) diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index f14fc70..7f27278 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -333,10 +333,12 @@ def _build_polar_guide(self, row: dict[str, Any]) -> dict[str, Any] | None: east_deg = d_ra * math.cos(math.radians(dec_center)) north_deg = d_dec roll_rad = math.radians(roll) - x_deg = east_deg * math.cos(roll_rad) + north_deg * math.sin(roll_rad) - y_deg = -east_deg * math.sin(roll_rad) + north_deg * math.cos(roll_rad) + # Image x right, y down; Tetra3 Roll is CCW from image up (y→0). + x_deg = east_deg * math.cos(roll_rad) - north_deg * math.sin(roll_rad) + y_deg = east_deg * math.sin(roll_rad) + north_deg * math.cos(roll_rad) - px_per_deg = (min(w, h) / max(fov, 1e-6)) if fov > 0 else 1.0 + # Tetra3 FOV is horizontal; scale pixels per degree by frame width. + px_per_deg = (w / max(fov, 1e-6)) if fov > 0 else 1.0 dx_px = x_deg * px_per_deg dy_px = -y_deg * px_per_deg cx = w * 0.5 @@ -366,6 +368,38 @@ def _build_polar_guide(self, row: dict[str, Any]) -> dict[str, Any] | None: "angular_sep_deg": angular_sep_deg, } + def _attach_overlay_ext( + self, + row: dict[str, Any], + *, + overlay_topn_count: int | None = None, + enable_polar_guide: bool | None = None, + ) -> None: + """为解算结果附加 overlay_ext(Top-N 标注与极轴引导)/ Attach overlay_ext to solve row.""" + topn = ( + int(overlay_topn_count) + if overlay_topn_count is not None + else self._overlay_topn_default + ) + enable_polar = ( + bool(enable_polar_guide) + if enable_polar_guide is not None + else self._polar_guide_default + ) + overlay_ext: dict[str, Any] = {} + try: + overlay_ext["labels_topn"] = self._build_topn_labels( + row, topn_count=topn + ) + except Exception: + overlay_ext["labels_topn"] = [] + if enable_polar: + try: + overlay_ext["polar_guide"] = self._build_polar_guide(row) + except Exception: + overlay_ext["polar_guide"] = None + row["overlay_ext"] = overlay_ext + def _centroid_params_from_payload( self, payload: CentroidParamsPayload | None ) -> CentroidExtractionParams | None: @@ -713,6 +747,11 @@ def _run_two_stage() -> list[dict[str, Any]]: if row and detail_level != "full": row.pop("tetra", None) if row: + self._attach_overlay_ext( + row, + overlay_topn_count=getattr(body, "overlay_topn_count", None), + enable_polar_guide=getattr(body, "enable_polar_guide", None), + ) self._lab.update_last_solve( source.name, self._metrics_from_solve_row(row), @@ -975,30 +1014,11 @@ def _run() -> dict[str, Any]: loop.run_in_executor(self._solver_executor, _run), timeout=hard_timeout_sec, ) - # 统一 overlay_ext 结构,便于前端复用渲染逻辑 - topn = ( - int(overlay_topn_count) - if overlay_topn_count is not None - else self._overlay_topn_default - ) - enable_polar = ( - bool(enable_polar_guide) - if enable_polar_guide is not None - else self._polar_guide_default + self._attach_overlay_ext( + row, + overlay_topn_count=overlay_topn_count, + enable_polar_guide=enable_polar_guide, ) - overlay_ext: dict[str, Any] = {} - try: - overlay_ext["labels_topn"] = self._build_topn_labels( - row, topn_count=topn - ) - except Exception: - overlay_ext["labels_topn"] = [] - if enable_polar: - try: - overlay_ext["polar_guide"] = self._build_polar_guide(row) - except Exception: - overlay_ext["polar_guide"] = None - row["overlay_ext"] = overlay_ext row["solve_profile"] = effective_profile row["t_backend_total_ms"] = round( (time.perf_counter() - t_total) * 1000.0, 3 @@ -1342,29 +1362,11 @@ def _run() -> dict[str, Any]: timeout=hard_timeout_sec, ) # 二次分析与极轴引导(失败降级,不影响基础解算) - topn = ( - int(body.overlay_topn_count) - if getattr(body, "overlay_topn_count", None) is not None - else self._overlay_topn_default + self._attach_overlay_ext( + row, + overlay_topn_count=getattr(body, "overlay_topn_count", None), + enable_polar_guide=getattr(body, "enable_polar_guide", None), ) - enable_polar = ( - bool(body.enable_polar_guide) - if getattr(body, "enable_polar_guide", None) is not None - else self._polar_guide_default - ) - overlay_ext: dict[str, Any] = {} - try: - overlay_ext["labels_topn"] = self._build_topn_labels( - row, topn_count=topn - ) - except Exception: - overlay_ext["labels_topn"] = [] - if enable_polar: - try: - overlay_ext["polar_guide"] = self._build_polar_guide(row) - except Exception: - overlay_ext["polar_guide"] = None - row["overlay_ext"] = overlay_ext if t_open_decode_ms is not None: row["t_open_decode_ms"] = round(t_open_decode_ms, 3) elapsed_ms = (time.perf_counter() - t_total) * 1000.0 diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index 12f8d07..9943052 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -215,6 +215,15 @@ class AnalysisSolveImageRequest(BaseModel): le=5, description="1=mild … 5=aggressive dense+collinear rejection", ) + # 叠加与引导选项(可选,未提供则使用后端默认)/ Optional overlay & guidance options + overlay_topn_count: Optional[int] = Field( + default=None, + description="自动标注的星点数量上限(Top-N),未填用服务器默认 / Max number of stars to label (Top-N); server default if omitted", + ) + enable_polar_guide: Optional[bool] = Field( + default=None, + description="是否计算极轴引导信息;未填用服务器默认 / Whether to compute polar guide info; server default if omitted", + ) class AnalysisExtractPreviewRequest(BaseModel): diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py index ed9e2d1..874095d 100644 --- a/tests/unit/test_analysis_api.py +++ b/tests/unit/test_analysis_api.py @@ -83,6 +83,43 @@ def test_analysis_upload_and_single_image_solve( assert "status" in result +@pytest.mark.unit +def test_analysis_solve_image_overlay_ext( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """单图解算返回扩展叠加字段(含极轴引导)/ Image solve returns overlay extension.""" + image_path = tmp_path / "stars_polar.jpg" + _build_star_image(image_path) + with image_path.open("rb") as f: + up = client.post( + "/api/dev/analysis/upload", + files={"file": ("stars_polar.jpg", f, "image/jpeg")}, + ) + assert up.status_code == 200 + + resp = client.post( + "/api/dev/analysis/solve/image", + json={ + "input_name": "stars_polar.jpg", + "hint_ra_deg": 45.0, + "hint_dec_deg": 70.0, + "overlay_topn_count": 2, + "enable_polar_guide": True, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("success") is True + row = data.get("result") or {} + ext = row.get("overlay_ext") or {} + labels = ext.get("labels_topn") or [] + assert isinstance(labels, list) + assert len(labels) >= 1 + guide = ext.get("polar_guide") + assert isinstance(guide, dict) + assert "delta_px" in guide + + @pytest.mark.unit def test_analysis_extract_preview( client, temp_analysis_dir, monkeypatch, tmp_path: Path From 9c7426534b317d6885b571189d451c48f45c97f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Fri, 19 Jun 2026 20:10:15 +0800 Subject: [PATCH 07/18] =?UTF-8?q?chore:=20rsync=20=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E7=9B=AE=E5=BD=95=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=9D=BF=E7=AB=AF=E5=90=8C=E6=AD=A5=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=20/=20Exclude=20runtime=20dirs=20in=20rsync=20and=20add=20boar?= =?UTF-8?q?d=20sync=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bootstrap rsync 排除 uploads/logs/data,避免板上测试数据被删 / Exclude uploads, logs, data from deploy rsync - 新增 sync_board_code.sh 用于 OGScope 代码同步 / Add sync_board_code.sh for OGScope board sync Co-authored-by: Cursor --- scripts/bootstrap.sh | 3 +++ scripts/sync_board_code.sh | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100755 scripts/sync_board_code.sh diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 4c72749..f733666 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -44,6 +44,9 @@ rsync -a --delete \ --exclude "__pycache__/" \ --exclude ".pytest_cache/" \ --exclude "web/spa/node_modules/" \ + --exclude "uploads/" \ + --exclude "logs/" \ + --exclude "data/" \ "${SOURCE_DIR}/" "${DEPLOY_DIR}/" INSTALL_SCRIPT="${DEPLOY_DIR}/scripts/install.sh" diff --git a/scripts/sync_board_code.sh b/scripts/sync_board_code.sh new file mode 100755 index 0000000..c407b3c --- /dev/null +++ b/scripts/sync_board_code.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 同步 OGScope 源码到开发板并执行 board-update(保留 uploads/logs/data) +# Sync OGScope source to dev board and run board-update (keeps uploads/logs/data) +# +# 用法 / Usage: +# export OGSCOPE_DEV_HOST=192.168.31.231 +# export OGSCOPE_DEV_USER=ogscope +# ./scripts/sync_board_code.sh +# +# 注意:勿对整仓使用 rsync --delete 且不排除 uploads/,否则会删除板上已上传的测试图片。 +# Note: Never full-repo rsync --delete without excluding uploads/ — it wipes board uploads. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEV_HOST="${OGSCOPE_DEV_HOST:-192.168.31.231}" +DEV_USER="${OGSCOPE_DEV_USER:-ogscope}" +DEV_PATH="${OGSCOPE_DEV_PATH:-/opt/ogscope}" +REMOTE="${DEV_USER}@${DEV_HOST}" + +RSYNC_SSH="ssh -o ConnectTimeout=15 -o BatchMode=yes" + +echo "== Sync OGScope code → ${REMOTE}:${DEV_PATH} (uploads/logs/data preserved) ==" + +rsync -avz --delete \ + -e "${RSYNC_SSH}" \ + --exclude '.git/' \ + --exclude '.venv/' \ + --exclude 'node_modules/' \ + --exclude '__pycache__/' \ + --exclude '.pytest_cache/' \ + --exclude 'uploads/' \ + --exclude 'logs/' \ + --exclude 'data/' \ + "${ROOT}/" "${REMOTE}:${DEV_PATH}/" + +echo "== Remote board-update ==" +ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE}" \ + "cd '${DEV_PATH}' && bash scripts/board-update.sh" + +echo "✅ OGScope sync complete" +echo " Health: http://${DEV_HOST}:8000/health" From e6ffd758c8b629b34103991b681265a09471b917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Mon, 22 Jun 2026 16:28:30 +0800 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=9B=B8?= =?UTF-8?q?=E6=9C=BA=E7=AE=A1=E7=BA=BF=E4=B8=8E=E5=BC=80=E5=8F=91=E8=80=85?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=20/=20Optimize=20camera=20pipeline?= =?UTF-8?q?=20and=20developer=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ogscope/algorithms/plate_solve/solver.py | 14 +- ogscope/algorithms/star_extract/extractor.py | 11 +- ogscope/config.py | 20 +- ogscope/config_catalog.py | 2 + ogscope/core/application/core_service.py | 2 +- ogscope/core/realtime/service.py | 24 +- ogscope/domain/camera/services.py | 9 +- ogscope/domain/camera/streaming.py | 7 +- ogscope/platform/hardware/camera.py | 29 +- ogscope/web/api/analysis/services.py | 35 ++- ogscope/web/api/debug/routes.py | 18 +- ogscope/web/api/debug/services.py | 9 +- ogscope/web/api/models/schemas.py | 15 + ogscope/web/camera_shared.py | 262 ++++++++++++++++-- scripts/sync_board_code.sh | 2 + .../ogscope.service.d/ogscope-low-ram.conf | 4 +- tests/unit/test_camera_manager_health.py | 45 +++ tests/unit/test_domain_camera_streaming.py | 17 +- tests/unit/test_plate_large_scale_bg.py | 23 ++ web/spa/src/apps/camera/CameraConsoleApp.tsx | 103 +++++-- web/spa/src/apps/lab/AnalysisLabApp.tsx | 7 +- web/spa/src/shared/api.ts | 1 + .../analysis-lab/assets/analysis-D4cZ3rj1.js | 51 ++++ .../analysis-lab/assets/analysis-DwGzxfRb.js | 51 ---- .../analysis-lab/assets/camera-CwUwmTEp.js | 61 ++++ .../analysis-lab/assets/camera-Dzx-jn-A.js | 61 ---- .../{http-ChPtkS1w.js => http-B53ovOR5.js} | 2 +- .../analysis-lab/assets/index-Cu-N6Gfx.js | 26 ++ .../analysis-lab/assets/index-CutgeBjy.js | 26 -- ...-cw-BkMjDReH.js => refresh-cw-BebYMFdn.js} | 2 +- ...{system-B6miqBWl.js => system-C_f3WXI6.js} | 2 +- web/static/analysis-lab/camera.html | 6 +- web/static/analysis-lab/index.html | 6 +- web/static/analysis-lab/system.html | 8 +- web/static/i18n/analysis.en.json | 8 + web/static/i18n/analysis.zh.json | 8 + 36 files changed, 725 insertions(+), 252 deletions(-) create mode 100644 web/static/analysis-lab/assets/analysis-D4cZ3rj1.js delete mode 100644 web/static/analysis-lab/assets/analysis-DwGzxfRb.js create mode 100644 web/static/analysis-lab/assets/camera-CwUwmTEp.js delete mode 100644 web/static/analysis-lab/assets/camera-Dzx-jn-A.js rename web/static/analysis-lab/assets/{http-ChPtkS1w.js => http-B53ovOR5.js} (96%) create mode 100644 web/static/analysis-lab/assets/index-Cu-N6Gfx.js delete mode 100644 web/static/analysis-lab/assets/index-CutgeBjy.js rename web/static/analysis-lab/assets/{refresh-cw-BkMjDReH.js => refresh-cw-BebYMFdn.js} (91%) rename web/static/analysis-lab/assets/{system-B6miqBWl.js => system-C_f3WXI6.js} (99%) diff --git a/ogscope/algorithms/plate_solve/solver.py b/ogscope/algorithms/plate_solve/solver.py index 1ea366b..8d5ef91 100644 --- a/ogscope/algorithms/plate_solve/solver.py +++ b/ogscope/algorithms/plate_solve/solver.py @@ -176,11 +176,15 @@ def subtract_large_scale_background_bgr( bg_small = cv2.GaussianBlur(small, (0, 0), sigmaX=sigma_s, sigmaY=sigma_s) bg = cv2.resize(bg_small, (w, h), interpolation=cv2.INTER_LINEAR).astype(np.float32) mean_gray = float(np.mean(gray)) - corr = gray - bg + mean_gray - corr = np.clip(corr, 1e-3, 255.0) - ratio = corr / np.maximum(gray, 1e-3) - ratio = np.clip(ratio, 0.0, 4.0) - out = frame_bgr.astype(np.float32) * ratio[..., np.newaxis] + # 复用背景数组承载校正亮度和比例,减少两张全画幅float32临时图 + # Reuse the background buffer for corrected luminance and ratio to drop two float32 frames. + np.subtract(gray, bg, out=bg) + bg += mean_gray + np.clip(bg, 1e-3, 255.0, out=bg) + np.maximum(gray, 1e-3, out=gray) + np.divide(bg, gray, out=bg) + np.clip(bg, 0.0, 4.0, out=bg) + out = frame_bgr.astype(np.float32) * bg[..., np.newaxis] return np.clip(np.round(out), 0, 255).astype(np.uint8) diff --git a/ogscope/algorithms/star_extract/extractor.py b/ogscope/algorithms/star_extract/extractor.py index 79ebc22..66fe83a 100644 --- a/ogscope/algorithms/star_extract/extractor.py +++ b/ogscope/algorithms/star_extract/extractor.py @@ -114,9 +114,14 @@ def _extract_gray_scaled(self, gray: np.ndarray, scale: float) -> list[StarPoint continue cx = float(m["m10"] / m["m00"]) * scale cy = float(m["m01"] / m["m00"]) * scale - mask = np.zeros_like(gray, dtype=np.uint8) - cv2.drawContours(mask, [contour], -1, color=255, thickness=-1) - flux = float(cv2.mean(gray, mask=mask)[0] * area) + # 仅为轮廓包围框分配掩膜,避免每颗候选星都创建全画幅数组 + # Allocate a mask only for the contour ROI instead of one full-frame array per star. + x, y, roi_w, roi_h = cv2.boundingRect(contour) + roi = gray[y : y + roi_h, x : x + roi_w] + roi_mask = np.zeros((roi_h, roi_w), dtype=np.uint8) + shifted = contour - np.array([[[x, y]]], dtype=contour.dtype) + cv2.drawContours(roi_mask, [shifted], -1, color=255, thickness=-1) + flux = float(cv2.mean(roi, mask=roi_mask)[0] * area) points.append(StarPoint(x=cx, y=cy, flux=flux, area=area)) points.sort(key=lambda p: p.flux, reverse=True) diff --git a/ogscope/config.py b/ogscope/config.py index 604a222..08de93c 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -95,7 +95,7 @@ class Settings(BaseSettings): default=720, description="图像高度 / Default capture height" ) camera_fps: int = Field( - default=5, description="预览与调试默认帧率 / Default preview FPS" + default=8, description="传感器目标帧率 / Target sensor FPS" ) camera_sampling_mode: str = Field( default="native", description="采样模式: supersample/native/crop" @@ -244,8 +244,8 @@ class Settings(BaseSettings): description="大尺度背景减除:小图长边上限(像素),越小越快 / Large-scale BG downsample max side", ) star_analysis_target_fps: float = Field( - default=2 / 3, - description="星空分析目标帧率(约 1.5 秒 1 帧),仅用于前端节流 / Target star-analysis FPS for UI throttle (~1.5s per frame)", + default=0.5, + description="星空分析目标帧率(默认 2 秒 1 帧)/ Target star-analysis FPS (one frame per 2 seconds)", ) star_analysis_min_interval_ms: int = Field( default=2000, @@ -300,7 +300,7 @@ class Settings(BaseSettings): description="共享预览/MJPEG 目标帧率 / Target FPS for shared preview and MJPEG", ) preview_jpeg_quality: int = Field( - default=75, + default=65, ge=1, le=100, description="共享抓帧 JPEG 质量 / JPEG quality for shared frame grabber", @@ -327,6 +327,18 @@ class Settings(BaseSettings): "连续抓帧失败多少次后标记离线 / Consecutive grab failures before marking offline" ), ) + camera_idle_shutdown_sec: float = Field( + default=20.0, + ge=0.0, + le=300.0, + description="无消费者后相机热驻留秒数 / Camera warm-idle timeout after the last consumer", + ) + camera_frame_stale_timeout_sec: float = Field( + default=5.0, + ge=0.5, + le=60.0, + description="超过该时间无成功帧时重新探测 / Re-probe after no successful frame for this duration", + ) keep_raw_cache: bool = Field( default=False, description=( diff --git a/ogscope/config_catalog.py b/ogscope/config_catalog.py index c0d2836..2f0e0cb 100644 --- a/ogscope/config_catalog.py +++ b/ogscope/config_catalog.py @@ -81,6 +81,8 @@ "debug_preview_min_interval_ms", "camera_probe_timeout_sec", "camera_grab_failures_offline", + "camera_idle_shutdown_sec", + "camera_frame_stale_timeout_sec", "keep_raw_cache", "stream_max_mjpeg_clients", "stream_mjpeg_frame_fetch_timeout_ms", diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index 571974f..996344c 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -358,7 +358,7 @@ async def tune_camera(self, payload: dict[str, Any]) -> dict[str, Any]: async def get_stream_status(self) -> dict[str, Any]: """获取流控状态 / Get stream limiter status.""" - stream = stream_state_domain_service.get_stream_status() + stream = await stream_state_domain_service.get_stream_status() return { "success": True, **stream, diff --git a/ogscope/core/realtime/service.py b/ogscope/core/realtime/service.py index 0add1ed..f05c0f2 100644 --- a/ogscope/core/realtime/service.py +++ b/ogscope/core/realtime/service.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio +import time from dataclasses import dataclass from typing import Any @@ -45,6 +46,10 @@ def __init__(self) -> None: self._fov_estimate: float | None = None self._fov_max_error: float | None = None self._solve_timeout_ms: int | None = None + self._analysis_interval_sec = max( + float(settings.star_analysis_min_interval_ms) / 1000.0, + 1.0 / max(0.01, float(settings.star_analysis_target_fps)), + ) async def start( self, @@ -96,8 +101,15 @@ async def get_status(self) -> dict[str, Any]: async def _loop(self) -> None: """后台循环 / Background loop""" + last_started_mono = 0.0 + last_frame_id = -1 while self.state.running: try: + remaining = self._analysis_interval_sec - ( + time.monotonic() - last_started_mono + ) + if remaining > 0: + await asyncio.sleep(remaining) manager = get_camera_manager() cam = manager.get_camera_instance() if not cam or not getattr(cam, "is_capturing", False): @@ -106,13 +118,18 @@ async def _loop(self) -> None: # 必须与共享预览走同一套读锁 + 线程卸载,禁止在事件循环线程里直接 capture_array # Must share the same read lock as shared preview; never call capture_array on the event-loop thread. try: - frame, _fid, _ts = await manager.get_raw_frame() + frame, frame_id, _ts = await manager.get_raw_frame() except RuntimeError: - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) continue if frame is None: - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) + continue + if frame_id == last_frame_id: + await asyncio.sleep(0.05) continue + last_frame_id = frame_id + last_started_mono = time.monotonic() stars = self.extractor.extract(frame) self.state.frame_count += 1 @@ -129,7 +146,6 @@ async def _loop(self) -> None: self._apply_solve_result(solved) self.state.fullsolve_count += 1 self._previous_stars = stars - await asyncio.sleep(0.02) except Exception as exc: # noqa: BLE001 self.state.last_error = str(exc) await asyncio.sleep(0.1) diff --git a/ogscope/domain/camera/services.py b/ogscope/domain/camera/services.py index 2c0e29a..419d88b 100644 --- a/ogscope/domain/camera/services.py +++ b/ogscope/domain/camera/services.py @@ -137,14 +137,18 @@ async def get_file_info(self, filename: str) -> dict[str, Any]: class StreamStateDomainService: """流状态门面 / Stream state facade.""" - def get_stream_status(self) -> dict[str, int]: + async def get_stream_status(self) -> dict[str, Any]: limiter = get_mjpeg_stream_limiter() settings = get_settings() + from ogscope.web.camera_shared import get_camera_manager + + metrics = await get_camera_manager().stream_metrics() return { "max_clients": int(limiter.max_clients), "active_clients": int(limiter.active_clients), "frame_fetch_timeout_ms": int(settings.stream_mjpeg_frame_fetch_timeout_ms), - "target_preview_fps": int(settings.shared_preview_fps), + "target_preview_fps": int(metrics["preview_target_fps"]), + **metrics, } @@ -323,4 +327,3 @@ async def delete_preset(preset_name: str): "file_domain_service", "stream_state_domain_service", ] - diff --git a/ogscope/domain/camera/streaming.py b/ogscope/domain/camera/streaming.py index a2462f3..c9ebe2c 100644 --- a/ogscope/domain/camera/streaming.py +++ b/ogscope/domain/camera/streaming.py @@ -16,6 +16,7 @@ from ogscope.domain.camera.services import camera_domain_service from ogscope.domain.camera.stream_limiter import get_mjpeg_stream_limiter from ogscope.web.mjpeg_stream_helpers import mjpeg_sleep_or_disconnect +from ogscope.web.camera_shared import get_camera_manager async def build_camera_mjpeg_stream( @@ -40,12 +41,13 @@ async def build_camera_mjpeg_stream( raise HTTPException(status_code=503, detail=limit_detail) boundary = "frame" settings = get_settings() - min_emit_interval = 1.0 / max(1, int(settings.shared_preview_fps)) fetch_timeout_s = settings.stream_mjpeg_frame_fetch_timeout_ms / 1000.0 content_type = "image/jpeg" if image_format.lower() == "jpeg" else "image/png" async def frame_generator(): + manager = get_camera_manager() try: + await manager.acquire_preview_consumer() last_snap_frame_id = -1 last_emit_mono = 0.0 while True: @@ -70,6 +72,7 @@ async def frame_generator(): break continue now = time.monotonic() + min_emit_interval = 1.0 / max(1, manager.preview_target_fps) wait = last_emit_mono + min_emit_interval - now if wait > 0 and not await mjpeg_sleep_or_disconnect(request, wait): break @@ -89,10 +92,10 @@ async def frame_generator(): + b"\r\n" ) finally: + await manager.release_preview_consumer() await limiter.release() return StreamingResponse( frame_generator(), media_type=f"multipart/x-mixed-replace; boundary={boundary}", ) - diff --git a/ogscope/platform/hardware/camera.py b/ogscope/platform/hardware/camera.py index 737662e..691e69d 100644 --- a/ogscope/platform/hardware/camera.py +++ b/ogscope/platform/hardware/camera.py @@ -69,6 +69,7 @@ def __init__(self, config: dict[str, Any]): self.camera = None self.is_initialized = False self.is_capturing = False + self._last_metadata: dict[str, Any] = {} # 相机参数 / Camera parameters requested_width = int(config.get("width", 640)) @@ -416,18 +417,6 @@ def start_capture(self) -> bool: return False try: - # 使用视频配置以获得更高实时性 / Use video configuration for greater real-time performance - try: - video_config = self.camera.create_video_configuration( - main={ - "size": (self.capture_width, self.capture_height), - "format": "RGB888", - }, - buffer_count=self.PREVIEW_BUFFER_COUNT, - ) - self.camera.configure(video_config) - except Exception as e: - logger.warning(f"视频配置失败,回退到当前配置: {e}") # 设置目标帧率(若固件支持) / Set target frame rate (if supported by firmware) try: self.camera.set_controls({"FrameRate": self.fps}) @@ -486,8 +475,14 @@ def capture_image(self) -> Optional[np.ndarray]: return None try: - # 捕获图像 / capture image - image = self.camera.capture_array() + # 同一请求读取图像与元数据,避免额外等待下一帧 + # Read image and metadata from one request to avoid waiting for another frame. + request = self.camera.capture_request() + try: + image = request.make_array("main") + self._last_metadata = dict(request.get_metadata() or {}) + finally: + request.release() # 如果是 RAW 格式,需要转换为 RGB / If it is RAW format, it needs to be converted to RGB if len(image.shape) == 2: # RAW 格式 / RAW format @@ -859,6 +854,12 @@ def get_camera_info(self) -> dict[str, Any]: "resolution": f"{self.width}x{self.height}", "fps": self.fps, "exposure_us": self.exposure_us, + "actual_exposure_us": int( + self._last_metadata.get("ExposureTime", self.exposure_us) or 0 + ), + "frame_duration_us": int( + self._last_metadata.get("FrameDuration", 0) or 0 + ), "analogue_gain": self.analogue_gain, "digital_gain": self.digital_gain, "auto_exposure": self.auto_exposure, diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index 7f27278..23149af 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -191,8 +191,8 @@ async def _try_enter_realtime_gate( "gate_reason": "previous request still running", "next_allowed_in_ms": 0, } - if state.last_finished_mono > 0: - elapsed = now - state.last_finished_mono + if state.last_started_mono > 0 and state.last_finished_mono > 0: + elapsed = now - state.last_started_mono if elapsed < interval: wait_ms = max(0, int((interval - elapsed) * 1000.0)) return { @@ -224,9 +224,9 @@ def _resolve_realtime_interval_ms( self, requested_ms: int | None ) -> tuple[int, int]: """解析实时解算间隔并按系统上下限裁剪 / Resolve realtime interval with server bounds.""" - if requested_ms is None: - return 0, 0 settings = get_settings() + if requested_ms is None: + requested_ms = round(1000.0 / max(0.01, settings.star_analysis_target_fps)) min_interval_ms = int(settings.star_analysis_min_interval_ms) max_interval_ms = int(settings.star_analysis_max_interval_ms) requested_interval_ms = int(requested_ms) @@ -1032,6 +1032,13 @@ def _run() -> dict[str, Any]: "gate_status": "SOLVED", "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, + "next_allowed_in_ms": max( + 0, + int( + effective_interval_ms + - (time.perf_counter() - t_total) * 1000.0 + ), + ), } except asyncio.TimeoutError: return { @@ -1048,6 +1055,13 @@ def _run() -> dict[str, Any]: "gate_reason": "outer request timeout", "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, + "next_allowed_in_ms": max( + 0, + int( + effective_interval_ms + - (time.perf_counter() - t_total) * 1000.0 + ), + ), } finally: await self._leave_realtime_gate("file_upload") @@ -1390,7 +1404,9 @@ def _run() -> dict[str, Any]: ), "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, - "next_allowed_in_ms": effective_interval_ms, + "next_allowed_in_ms": max( + 0, int(effective_interval_ms - elapsed_ms) + ), } except asyncio.TimeoutError: return { @@ -1410,7 +1426,13 @@ def _run() -> dict[str, Any]: "gate_reason": "outer request timeout", "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, - "next_allowed_in_ms": effective_interval_ms, + "next_allowed_in_ms": max( + 0, + int( + effective_interval_ms + - (time.perf_counter() - t_total) * 1000.0 + ), + ), } finally: await self._leave_realtime_gate(gate_source_key) @@ -1432,6 +1454,7 @@ def lab_public_settings(self) -> dict[str, Any]: "camera_width": s.camera_width, "camera_height": s.camera_height, "camera_fps": s.camera_fps, + "shared_preview_fps": s.shared_preview_fps, "solver_fov_deg": s.solver_fov_deg, "solver_max_image_side": s.solver_max_image_side, "solver_large_scale_bg_downsample": s.solver_large_scale_bg_downsample, diff --git a/ogscope/web/api/debug/routes.py b/ogscope/web/api/debug/routes.py index ff030f7..320b480 100644 --- a/ogscope/web/api/debug/routes.py +++ b/ogscope/web/api/debug/routes.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import FileResponse, StreamingResponse +from ogscope.config import get_settings from ogscope.core.application import core_contract_service from ogscope.core.realtime import realtime_solve_service from ogscope.domain.camera.services import ( @@ -36,9 +37,6 @@ "已达到 MJPEG 同时连接上限,请关闭其他标签页的预览" ) -_DEFAULT_PREVIEW_JPEG_QUALITY = 75 - - # ==================== 相机控制 ==================== / ==================== Camera Control ==================== @@ -106,12 +104,13 @@ async def _streaming_response_debug_camera_mjpeg( @router.get("/debug/camera/stream") async def stream_debug_camera( request: Request, - quality: int = Query(_DEFAULT_PREVIEW_JPEG_QUALITY, ge=10, le=100), + quality: int | None = Query(None, ge=10, le=100), ): """MJPEG 实时流 - 可配置压缩质量 / MJPEG live streaming - configurable compression quality""" try: + effective_quality = int(quality or get_settings().preview_jpeg_quality) return await _streaming_response_debug_camera_mjpeg( - request, image_format="jpeg", quality=quality + request, image_format="jpeg", quality=effective_quality ) except HTTPException: raise @@ -235,6 +234,15 @@ async def set_camera_fps(fps: int = Query(..., gt=0)): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/debug/camera/preview-fps") +async def set_camera_preview_fps(fps: int = Query(..., ge=1, le=30)): + """独立设置共享预览帧率 / Set shared preview FPS independently.""" + from ogscope.web.camera_shared import get_camera_manager + + applied = get_camera_manager().set_preview_fps(fps) + return {"success": True, "preview_target_fps": applied} + + @router.post("/debug/camera/settings") async def update_debug_camera_settings(settings: CameraSettings): """更新调试相机设置 / Update debug camera settings""" diff --git a/ogscope/web/api/debug/services.py b/ogscope/web/api/debug/services.py index 0177e4b..0f810a8 100644 --- a/ogscope/web/api/debug/services.py +++ b/ogscope/web/api/debug/services.py @@ -532,9 +532,9 @@ async def start_recording(): except ImportError: pass - camera = get_camera_instance() - if not camera or not camera.is_capturing: - raise Exception("相机未运行") + manager = get_camera_manager() + await manager.acquire_recording_consumer() + camera = manager.get_camera_instance() try: import cv2 @@ -611,8 +611,10 @@ async def record_video(): "path": str(video_path), } except ImportError: + await manager.release_recording_consumer() raise Exception("OpenCV未安装") except Exception as e: + await manager.release_recording_consumer() raise Exception(f"录制启动失败: {str(e)}") @staticmethod @@ -679,6 +681,7 @@ async def stop_recording(): recording_media_filename = None recording_codec_fourcc = "MJPG" recording_container = "AVI" + await get_camera_manager().release_recording_consumer() return { "success": True, diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index 9943052..0b87cb5 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -514,6 +514,21 @@ class CoreStreamStatusResponse(BaseModel): active_clients: int frame_fetch_timeout_ms: int target_preview_fps: int + sensor_target_fps: float = 0.0 + preview_target_fps: int = 0 + actual_capture_fps: float = 0.0 + actual_preview_fps: float = 0.0 + actual_exposure_us: int = 0 + frame_duration_us: int = 0 + preview_consumers: int = 0 + analysis_consumers: int = 0 + recording_consumers: int = 0 + jpeg_average_encode_ms: float = 0.0 + jpeg_cached_bytes: int = 0 + throttle_reason: Optional[str] = None + process_rss_kb: int = 0 + process_swap_kb: int = 0 + cma_free_kb: int = 0 class CoreVideoFileEntry(BaseModel): diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index e355fb8..683456d 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -6,7 +6,10 @@ import asyncio import logging +import os import time +from collections import deque +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from threading import Lock from typing import Any, Callable @@ -35,7 +38,9 @@ def __init__(self) -> None: self._read_lock = Lock() self._frame_lock = Lock() self._grabber_task: asyncio.Task | None = None + self._idle_shutdown_task: asyncio.Task | None = None self._frame_id = 0 + self._capture_sequence = 0 self._latest_raw = None self._latest_jpeg: bytes | None = None self._latest_ts = 0.0 @@ -46,10 +51,24 @@ def __init__(self) -> None: self._jpeg_quality = int(settings.preview_jpeg_quality) self._target_fps = max(1, int(settings.shared_preview_fps)) self._probe_timeout_sec = max(0.5, float(settings.camera_probe_timeout_sec)) + self._stale_timeout_sec = max( + 0.5, float(settings.camera_frame_stale_timeout_sec) + ) + self._idle_shutdown_sec = max(0.0, float(settings.camera_idle_shutdown_sec)) self._max_grab_failures = max(1, int(settings.camera_grab_failures_offline)) self._health_error: str | None = None self._consecutive_grab_failures = 0 self._stream_started_at = 0.0 + self._last_capture_success_mono = 0.0 + self._preview_consumers = 0 + self._analysis_consumers = 0 + self._recording_consumers = 0 + self._capture_timestamps: deque[float] = deque(maxlen=120) + self._jpeg_timestamps: deque[float] = deque(maxlen=120) + self._jpeg_encode_ms: deque[float] = deque(maxlen=60) + self._jpeg_executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="ogscope-jpeg" + ) # 是否常驻 raw 帧缓存;默认关闭以降低内存占用(分析路径可同步抓帧) # Whether to retain raw frame cache; default off to reduce RAM (analysis can sync-grab). self._keep_raw_cache = bool(settings.keep_raw_cache) @@ -60,6 +79,11 @@ def preview_jpeg_quality(self) -> int: """共享抓帧 JPEG 质量(与缓存一致)/ Shared grabber JPEG quality (matches cache).""" return int(self._jpeg_quality) + @property + def preview_target_fps(self) -> int: + """共享预览目标帧率 / Shared preview target FPS.""" + return int(self._target_fps) + def _build_base_config(self) -> dict[str, Any]: from ogscope.config import get_settings @@ -117,11 +141,50 @@ def _read_frame_sync(self): with self._read_lock: if self._camera is None or not getattr(self._camera, "is_capturing", False): return None - return self._camera.get_video_frame() - - async def ensure_started(self) -> None: + frame = self._camera.get_video_frame() + if frame is not None: + now = time.monotonic() + self._capture_sequence += 1 + self._last_capture_success_mono = now + self._capture_timestamps.append(now) + return frame + + def _camera_is_fresh(self) -> bool: + """判断运行中的相机是否仍有新鲜帧 / Check whether a running camera is still fresh.""" + if self._camera is None or not getattr(self._camera, "is_capturing", False): + return False + if self._last_capture_success_mono <= 0: + return False + return ( + time.monotonic() - self._last_capture_success_mono + ) <= self._stale_timeout_sec + + def _has_consumers(self) -> bool: + return ( + self._preview_consumers + + self._analysis_consumers + + self._recording_consumers + ) > 0 + + def _cancel_idle_shutdown(self) -> None: + task = self._idle_shutdown_task + if task is not None and not task.done(): + task.cancel() + self._idle_shutdown_task = None + + async def ensure_started(self, *, start_grabber: bool = False) -> None: """确保单相机进入采集并启动共享帧抓取 / Ensure capture and shared frame grabber.""" + self._cancel_idle_shutdown() + if self._camera_is_fresh(): + if start_grabber: + async with self._control_lock: + await self._ensure_grabber_locked() + return async with self._control_lock: + if self._camera_is_fresh(): + if start_grabber: + await self._ensure_grabber_locked() + return if self._camera is None: self._health_error = None self._camera = await asyncio.to_thread(self._create_camera_sync) @@ -146,10 +209,66 @@ async def ensure_started(self) -> None: raise RuntimeError(self._health_error or "相机无有效帧") self._health_error = None self._consecutive_grab_failures = 0 - await self._ensure_grabber_locked() + if start_grabber: + await self._ensure_grabber_locked() + + async def acquire_preview_consumer(self) -> None: + """注册预览消费者并启动共享编码 / Register a preview consumer.""" + self._preview_consumers += 1 + try: + await self.ensure_started(start_grabber=True) + except Exception: + self._preview_consumers = max(0, self._preview_consumers - 1) + raise + + async def release_preview_consumer(self) -> None: + """释放预览消费者;最后一路离开时停止JPEG流水线 / Release preview consumer.""" + self._preview_consumers = max(0, self._preview_consumers - 1) + if self._preview_consumers == 0: + async with self._control_lock: + await self._stop_grabber_locked() + with self._frame_lock: + self._latest_jpeg = None + self._schedule_idle_shutdown() + + async def acquire_recording_consumer(self) -> None: + """注册录像消费者 / Register a recording consumer.""" + self._recording_consumers += 1 + try: + await self.ensure_started() + except Exception: + self._recording_consumers = max(0, self._recording_consumers - 1) + raise + + async def release_recording_consumer(self) -> None: + """释放录像消费者 / Release a recording consumer.""" + self._recording_consumers = max(0, self._recording_consumers - 1) + self._schedule_idle_shutdown() + + def _schedule_idle_shutdown(self) -> None: + if self._has_consumers(): + return + self._cancel_idle_shutdown() + self._idle_shutdown_task = asyncio.create_task(self._idle_shutdown_after_delay()) + + async def _idle_shutdown_after_delay(self) -> None: + """热驻留结束后释放相机 / Release camera after the warm-idle period.""" + try: + if self._idle_shutdown_sec > 0: + await asyncio.sleep(self._idle_shutdown_sec) + if not self._has_consumers(): + await self.stop() + except asyncio.CancelledError: + raise + finally: + if asyncio.current_task() is self._idle_shutdown_task: + self._idle_shutdown_task = None async def stop(self) -> None: """停止相机采集 / Stop camera capture.""" + current = asyncio.current_task() + if self._idle_shutdown_task is not current: + self._cancel_idle_shutdown() acquired = False try: await asyncio.wait_for(self._control_lock.acquire(), timeout=2.0) @@ -180,6 +299,7 @@ async def stop(self) -> None: "相机关闭超时,继续执行退出流程 / Camera close timed out, continue shutdown" ) self._camera = None + self._last_capture_success_mono = 0.0 with self._frame_lock: self._latest_raw = None self._latest_jpeg = None @@ -310,18 +430,20 @@ async def _stop_grabber_locked(self) -> None: self._grabber_task = None async def _grabber_loop(self) -> None: - interval = 1.0 / float(self._target_fps) loop = asyncio.get_running_loop() try: while True: + interval = 1.0 / float(max(1, self._target_fps)) t0 = time.time() try: frame = await asyncio.to_thread(self._read_frame_sync) if frame is not None: self._consecutive_grab_failures = 0 + encode_t0 = time.perf_counter() jpeg = await loop.run_in_executor( - None, self._encode_preview_jpeg_sync, frame + self._jpeg_executor, self._encode_preview_jpeg_sync, frame ) + encode_ms = (time.perf_counter() - encode_t0) * 1000.0 h = int(getattr(frame, "shape", [0, 0])[0] or 0) w = int(getattr(frame, "shape", [0, 0])[1] or 0) with self._frame_lock: @@ -333,6 +455,9 @@ async def _grabber_loop(self) -> None: self._latest_ts = time.time() self._latest_w = w self._latest_h = h + now_mono = time.monotonic() + self._jpeg_timestamps.append(now_mono) + self._jpeg_encode_ms.append(encode_ms) else: self._consecutive_grab_failures += 1 if self._consecutive_grab_failures >= self._max_grab_failures: @@ -416,7 +541,7 @@ async def get_preview_frame( self, since_id: int | None = None, wait_timeout_sec: float = 0.8 ) -> tuple[int, SharedFrame | None]: """读取预览帧;如未更新则返回 304 / Get preview frame; return 304 if unchanged.""" - await self.ensure_started() + await self.ensure_started(start_grabber=True) deadline = time.time() + max(0.0, float(wait_timeout_sec)) while True: with self._frame_lock: @@ -438,27 +563,27 @@ async def get_preview_frame( async def get_raw_frame(self) -> tuple[Any, int, float]: """读取分析帧 / Get frame for analysis.""" - await self.ensure_started() - with self._frame_lock: - if self._latest_raw is not None: - try: - frame = self._latest_raw.copy() - except Exception: - frame = self._latest_raw - return frame, self._frame_id, self._latest_ts - # 无常驻 raw 时同步抓一帧,供解算使用(不写入 _latest_raw,除非开启 keep cache) - # Sync-grab when raw cache is disabled; avoids breaking analysis while saving RAM. - frame = await asyncio.to_thread(self._read_frame_sync) - if frame is None: - raise RuntimeError("无可用视频帧 / No frame available") - with self._frame_lock: - fid = self._frame_id - ts = self._latest_ts + self._analysis_consumers += 1 try: - out = frame.copy() - except Exception: - out = frame - return out, fid, ts + await self.ensure_started() + with self._frame_lock: + if self._latest_raw is not None: + return ( + self._latest_raw.copy(), + self._capture_sequence, + self._latest_ts, + ) + # 无常驻 raw 时同步抓一帧,供解算使用 / Sync-grab without retaining raw. + frame = await asyncio.to_thread(self._read_frame_sync) + if frame is None: + raise RuntimeError("无可用视频帧 / No frame available") + with self._frame_lock: + fid = self._capture_sequence + ts = time.time() + return frame, fid, ts + finally: + self._analysis_consumers = max(0, self._analysis_consumers - 1) + self._schedule_idle_shutdown() async def get_cached_frame_snapshot(self) -> SharedFrame | None: """读取当前缓存帧快照(不触发 ensure)/ Read cached snapshot without ensure.""" @@ -500,6 +625,89 @@ def update_runtime_overrides(self, updates: dict[str, Any]) -> None: """更新运行时覆盖参数(不落盘)/ Update runtime overrides (memory only).""" self._runtime_overrides.update(updates) + def set_preview_fps(self, fps: int) -> int: + """独立更新共享预览帧率 / Independently update shared preview FPS.""" + self._target_fps = max(1, min(30, int(fps))) + return self._target_fps + + @staticmethod + def _rate(values: deque[float], window_sec: float = 3.0) -> float: + now = time.monotonic() + recent = [v for v in values if now - v <= window_sec] + if len(recent) < 2: + return 0.0 + span = recent[-1] - recent[0] + return 0.0 if span <= 0 else (len(recent) - 1) / span + + async def stream_metrics(self) -> dict[str, Any]: + """返回预览运行指标 / Return preview runtime metrics.""" + cam = self._camera + info: dict[str, Any] = {} + if cam is not None: + info = await asyncio.to_thread(cam.get_camera_info) + actual_capture_fps = self._rate(self._capture_timestamps) + actual_preview_fps = self._rate(self._jpeg_timestamps) + sensor_target_fps = float(info.get("fps", 0) or 0) + exposure_us = int(info.get("actual_exposure_us", info.get("exposure_us", 0)) or 0) + frame_duration_us = int(info.get("frame_duration_us", 0) or 0) + throttle_reason = None + if ( + bool(info.get("auto_exposure")) + and sensor_target_fps > 0 + and actual_capture_fps > 0 + and actual_capture_fps < sensor_target_fps * 0.75 + ): + throttle_reason = "auto_exposure_long" + memory = self._memory_metrics() + return { + "sensor_target_fps": sensor_target_fps, + "preview_target_fps": int(self._target_fps), + "actual_capture_fps": round(actual_capture_fps, 2), + "actual_preview_fps": round(actual_preview_fps, 2), + "actual_exposure_us": exposure_us, + "frame_duration_us": frame_duration_us, + "preview_consumers": int(self._preview_consumers), + "analysis_consumers": int(self._analysis_consumers), + "recording_consumers": int(self._recording_consumers), + "jpeg_average_encode_ms": round( + sum(self._jpeg_encode_ms) / len(self._jpeg_encode_ms), 2 + ) + if self._jpeg_encode_ms + else 0.0, + "jpeg_cached_bytes": len(self._latest_jpeg or b""), + "throttle_reason": throttle_reason, + **memory, + } + + @staticmethod + def _memory_metrics() -> dict[str, int]: + """读取轻量进程与CMA指标 / Read lightweight process and CMA metrics.""" + rss_kb = 0 + swap_kb = 0 + cma_free_kb = 0 + try: + with open(f"/proc/{os.getpid()}/status", encoding="utf-8") as status_file: + for line in status_file: + if line.startswith("VmRSS:"): + rss_kb = int(line.split()[1]) + elif line.startswith("VmSwap:"): + swap_kb = int(line.split()[1]) + except (OSError, ValueError, IndexError): + pass + try: + with open("/proc/meminfo", encoding="utf-8") as meminfo_file: + for line in meminfo_file: + if line.startswith("CmaFree:"): + cma_free_kb = int(line.split()[1]) + break + except (OSError, ValueError, IndexError): + pass + return { + "process_rss_kb": rss_kb, + "process_swap_kb": swap_kb, + "cma_free_kb": cma_free_kb, + } + def get_runtime_overrides(self) -> dict[str, Any]: """读取运行时覆盖参数 / Read runtime overrides.""" return dict(self._runtime_overrides) diff --git a/scripts/sync_board_code.sh b/scripts/sync_board_code.sh index c407b3c..ce159b6 100755 --- a/scripts/sync_board_code.sh +++ b/scripts/sync_board_code.sh @@ -29,6 +29,8 @@ rsync -avz --delete \ --exclude 'node_modules/' \ --exclude '__pycache__/' \ --exclude '.pytest_cache/' \ + --exclude '.coverage' \ + --exclude 'htmlcov/' \ --exclude 'uploads/' \ --exclude 'logs/' \ --exclude 'data/' \ diff --git a/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf b/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf index b32eaf1..95f5a57 100644 --- a/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf +++ b/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf @@ -5,7 +5,7 @@ [Service] Environment=OGSCOPE_SOLVER_MAX_STARS_HARD_CAP=40 Environment=OGSCOPE_SOLVER_MAX_IMAGE_SIDE_HARD_CAP=1280 -Environment=OGSCOPE_SHARED_PREVIEW_FPS=5 -Environment=OGSCOPE_PREVIEW_JPEG_QUALITY=65 +Environment=OGSCOPE_SHARED_PREVIEW_FPS=8 +Environment=OGSCOPE_PREVIEW_JPEG_QUALITY=55 # 与默认 config 一致允许多路 MJPEG(多标签/短暂重叠);需更多路可改大或设 0 不限制 / Match default cap; 0=unlimited Environment=OGSCOPE_STREAM_MAX_MJPEG_CLIENTS=4 diff --git a/tests/unit/test_camera_manager_health.py b/tests/unit/test_camera_manager_health.py index 0961e2e..738c830 100644 --- a/tests/unit/test_camera_manager_health.py +++ b/tests/unit/test_camera_manager_health.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + import numpy as np import pytest @@ -28,7 +30,11 @@ def get_video_frame(self): class _FrameCamera(_NoFrameCamera): + def __init__(self) -> None: + self.read_count = 0 + def get_video_frame(self): + self.read_count += 1 return np.zeros((360, 640, 3), dtype=np.uint8) @@ -59,3 +65,42 @@ async def test_ensure_started_succeeds_when_frames_available() -> None: assert status["streaming"] is True await manager.stop() + + +@pytest.mark.asyncio +async def test_ensure_started_fast_path_does_not_probe_again() -> None: + """新鲜相机重复ensure不应额外抓帧 / Fresh repeated ensure must not grab another frame.""" + manager = CameraManager() + camera = _FrameCamera() + manager.attach_camera_instance(camera) + + await manager.ensure_started() + first_reads = camera.read_count + await manager.ensure_started() + + assert first_reads == 1 + assert camera.read_count == first_reads + await manager.stop() + + +@pytest.mark.asyncio +async def test_preview_consumer_stops_grabber_on_last_release() -> None: + """最后一个预览消费者离开后停止编码任务 / Stop encoding after the last preview consumer.""" + manager = CameraManager() + manager._idle_shutdown_sec = 60 + manager.attach_camera_instance(_FrameCamera()) + + await manager.acquire_preview_consumer() + await asyncio.sleep(0.03) + await manager.release_preview_consumer() + + metrics = await manager.stream_metrics() + assert metrics["preview_consumers"] == 0 + assert manager._grabber_task is None + await manager.stop() + + +def test_preview_fps_is_independent_runtime_setting() -> None: + manager = CameraManager() + assert manager.set_preview_fps(12) == 12 + assert manager._target_fps == 12 diff --git a/tests/unit/test_domain_camera_streaming.py b/tests/unit/test_domain_camera_streaming.py index 5a11548..fb77032 100644 --- a/tests/unit/test_domain_camera_streaming.py +++ b/tests/unit/test_domain_camera_streaming.py @@ -63,6 +63,20 @@ class _FakeSettings: monkeypatch.setattr(streaming_mod, "get_settings", lambda: _FakeSettings()) + class _FakeManager: + acquired = False + released = False + preview_target_fps = 8 + + async def acquire_preview_consumer(self) -> None: + self.acquired = True + + async def release_preview_consumer(self) -> None: + self.released = True + + manager = _FakeManager() + monkeypatch.setattr(streaming_mod, "get_camera_manager", lambda: manager) + async def _fake_get_stream_frame_bytes(fmt: str, quality: int, *, since_frame_id: int): _ = fmt, quality, since_frame_id return 200, b"abc", 1 @@ -87,4 +101,5 @@ async def _fake_get_stream_frame_bytes(fmt: str, quality: int, *, since_frame_id assert b"Content-Type: image/jpeg" in first_chunk await body_iter.aclose() assert limiter.released is True - + assert manager.acquired is True + assert manager.released is True diff --git a/tests/unit/test_plate_large_scale_bg.py b/tests/unit/test_plate_large_scale_bg.py index e10f22b..f7d4eda 100644 --- a/tests/unit/test_plate_large_scale_bg.py +++ b/tests/unit/test_plate_large_scale_bg.py @@ -4,6 +4,7 @@ import numpy as np import pytest +import cv2 from ogscope.algorithms.plate_solve.solver import subtract_large_scale_background_bgr @@ -25,3 +26,25 @@ def test_subtract_large_scale_background_bgr_non_bgr_passthrough() -> None: """非三通道图原样返回 / Non-3-channel frames pass through unchanged.""" gray = np.zeros((10, 10), dtype=np.uint8) assert subtract_large_scale_background_bgr(gray, downsample_max_side=32) is gray + + +@pytest.mark.unit +def test_background_optimization_matches_reference_math() -> None: + """复用缓冲后的结果应与原公式一致 / Buffer reuse must preserve reference output.""" + rng = np.random.default_rng(42) + bgr = rng.integers(0, 256, size=(96, 128, 3), dtype=np.uint8) + gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) + sw, sh = 64, 48 + small = cv2.resize(gray, (sw, sh), interpolation=cv2.INTER_AREA) + bg_small = cv2.GaussianBlur(small, (0, 0), sigmaX=2.0, sigmaY=2.0) + bg = cv2.resize(bg_small, (128, 96), interpolation=cv2.INTER_LINEAR).astype( + np.float32 + ) + corr = np.clip(gray - bg + float(np.mean(gray)), 1e-3, 255.0) + ratio = np.clip(corr / np.maximum(gray, 1e-3), 0.0, 4.0) + expected = np.clip( + np.round(bgr.astype(np.float32) * ratio[..., np.newaxis]), 0, 255 + ).astype(np.uint8) + + actual = subtract_large_scale_background_bgr(bgr, downsample_max_side=64) + np.testing.assert_array_equal(actual, expected) diff --git a/web/spa/src/apps/camera/CameraConsoleApp.tsx b/web/spa/src/apps/camera/CameraConsoleApp.tsx index 1433ec6..e39def3 100644 --- a/web/spa/src/apps/camera/CameraConsoleApp.tsx +++ b/web/spa/src/apps/camera/CameraConsoleApp.tsx @@ -69,6 +69,25 @@ type CameraStatus = { runtime_overrides?: Record; }; +type StreamMetrics = { + target_preview_fps?: number; + sensor_target_fps?: number; + preview_target_fps?: number; + actual_capture_fps?: number; + actual_preview_fps?: number; + actual_exposure_us?: number; + frame_duration_us?: number; + preview_consumers?: number; + analysis_consumers?: number; + recording_consumers?: number; + jpeg_average_encode_ms?: number; + jpeg_cached_bytes?: number; + throttle_reason?: string | null; + process_rss_kb?: number; + process_swap_kb?: number; + cma_free_kb?: number; +}; + type CameraForm = { exposure: number; gain: number; @@ -126,7 +145,7 @@ type DebugFileInfo = { fps?: number; }; -const RES_PRESETS = ["640x360", "1280x720", "1600x900", "1920x1080"] as const; +const RES_PRESETS = ["640x360", "1280x720", "1600x900", "1920x1020"] as const; const ROTATION_PRESETS = [0, 90, 180, 270] as const; const FILE_PAGE_SIZE = 12; @@ -229,7 +248,8 @@ export function CameraConsoleApp() { const [previewBusy, setPreviewBusy] = useState(false); const [recordBusy, setRecordBusy] = useState(false); const [captureBusy, setCaptureBusy] = useState(false); - const [fpsValue, setFpsValue] = useState("5"); + const [fpsValue, setFpsValue] = useState("8"); + const [previewFpsValue, setPreviewFpsValue] = useState("8"); const [resValue, setResValue] = useState("1280x720"); const [samplingMode, setSamplingMode] = useState("supersample"); const [runtimeDirty, setRuntimeDirty] = useState(false); @@ -242,7 +262,7 @@ export function CameraConsoleApp() { /** 最近约 1s 内画面像素变化次数(rAF 采样)/ ~1s sliding window from pixel deltas */ const [liveFps, setLiveFps] = useState(0); /** 与 OGSCOPE_SHARED_PREVIEW_FPS 一致:共享抓帧与 MJPEG 最小帧间隔 / Env stream pacing cap */ - const [streamPacingFps, setStreamPacingFps] = useState(null); + const [streamMetrics, setStreamMetrics] = useState(null); const [recordElapsed, setRecordElapsed] = useState(0); const [rotationValue, setRotationValue] = useState(180); const [flipHorizontal, setFlipHorizontal] = useState(false); @@ -357,14 +377,14 @@ export function CameraConsoleApp() { setPreviewBusy(true); setErr(null); try { - // 先卸载预览,释放长连接,再通知后端停止 / Release stream before stop API + // 仅卸载预览流,后端会释放消费者并让相机热驻留后延迟关闭。 + // Only detach the preview stream; backend releases the consumer and keeps the camera warm briefly. clearReconnectTimer(); setPreviewActive(false); previewActiveRef.current = false; setPreviewStreamHint(null); setPreviewStreamIsBusy(false); resetStreamStats(); - setStatus((prev) => (prev ? { ...prev, streaming: false, recording: false } : prev)); if (imgRef.current) { imgRef.current.onload = null; imgRef.current.onerror = null; @@ -372,10 +392,7 @@ export function CameraConsoleApp() { imgRef.current.removeAttribute("src"); } setStreamNonce(Date.now()); - await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); - await requestJson("/api/debug/camera/stop", { method: "POST" }); setNotice(t("cam.notice.previewStop")); - await updateCameraStatus(); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { @@ -423,6 +440,10 @@ export function CameraConsoleApp() { try { const fps = clamp(parseInt(fpsValue, 10) || 5, 1, 60); await requestJson(`/api/debug/camera/fps?fps=${fps}`, { method: "POST" }); + const previewFps = clamp(parseInt(previewFpsValue, 10) || 8, 1, 30); + await requestJson(`/api/debug/camera/preview-fps?fps=${previewFps}`, { + method: "POST", + }); const [w, h] = resValue.split("x").map((x) => parseInt(x, 10)); if (w && h) { await requestJson(`/api/debug/camera/size?width=${w}&height=${h}`, { method: "POST" }); @@ -491,7 +512,7 @@ export function CameraConsoleApp() { whiteBalanceGainB: clamp(toNum(info.white_balance_gain_b, 1.0), 0.1, 3.0), colorMode: String(info.color_mode ?? "color"), }); - setFpsValue(String(Math.round(toNum(info.fps, 5)))); + setFpsValue(String(Math.round(toNum(info.fps, 8)))); setResValue(`${Math.round(toNum(info.width, 1280))}x${Math.round(toNum(info.height, 720))}`); setSamplingMode(String(info.sampling_mode ?? "supersample")); setRuntimeDirty(false); @@ -950,7 +971,7 @@ export function CameraConsoleApp() { useEffect(() => { if (!previewActive) { - setStreamPacingFps(null); + setStreamMetrics(null); return; } let cancelled = false; @@ -961,11 +982,16 @@ export function CameraConsoleApp() { credentials: "same-origin", }); if (!res.ok || cancelled) return; - const j = (await res.json()) as { target_preview_fps?: number }; - const v = Number(j.target_preview_fps ?? 0); - if (!cancelled) setStreamPacingFps(Number.isFinite(v) && v >= 0 ? v : null); + const j = (await res.json()) as StreamMetrics; + if (!cancelled) { + setStreamMetrics(j); + const v = Number(j.preview_target_fps ?? j.target_preview_fps ?? 0); + if (Number.isFinite(v) && v > 0 && !runtimeDirty) { + setPreviewFpsValue(String(Math.round(v))); + } + } } catch { - if (!cancelled) setStreamPacingFps(null); + if (!cancelled) setStreamMetrics(null); } }; void pull(); @@ -974,7 +1000,7 @@ export function CameraConsoleApp() { cancelled = true; window.clearInterval(id); }; - }, [previewActive]); + }, [previewActive, runtimeDirty]); useEffect(() => { if (!notice) return; @@ -1140,7 +1166,7 @@ export function CameraConsoleApp() {

- {t("cam.preview.state")}: {status?.streaming ? t("cam.state.streaming") : t("cam.state.idle")} + {t("cam.preview.state")}: {previewActive ? t("cam.state.streaming") : t("cam.state.idle")}
{previewStreamHint && !previewActive && ( @@ -1260,7 +1286,9 @@ export function CameraConsoleApp() {
{t("cam.stats.frameFps")}: - {liveFps.toFixed(2)} + + {Number(streamMetrics?.actual_preview_fps ?? liveFps).toFixed(2)} +

{t("cam.stats.fpsMeasureNote")}

@@ -1272,12 +1300,43 @@ export function CameraConsoleApp() {
{t("cam.stats.streamPacingFps")}: - {streamPacingFps != null ? String(streamPacingFps) : "—"} + {streamMetrics?.preview_target_fps ?? streamMetrics?.target_preview_fps ?? "—"}

{t("cam.stats.streamPacingHint")}

+
+ {t("cam.stats.captureFps")}: + + {Number(streamMetrics?.actual_capture_fps ?? 0).toFixed(2)} + +
+
+ {t("cam.stats.encodeMs")}: + + {Number(streamMetrics?.jpeg_average_encode_ms ?? 0).toFixed(1)} ms + +
+
+ {t("cam.stats.consumers")}: + + {`${streamMetrics?.preview_consumers ?? 0}/${streamMetrics?.analysis_consumers ?? 0}/${streamMetrics?.recording_consumers ?? 0}`} + +
+
+ {t("cam.stats.cameraMemory")}: + + {`${Math.round(Number(streamMetrics?.process_rss_kb ?? 0) / 1024)} / ${Math.round(Number(streamMetrics?.process_swap_kb ?? 0) / 1024)} MB`} + +
+ {streamMetrics?.throttle_reason === "auto_exposure_long" && ( +
+ {t("cam.stats.longExposureThrottle", { + exposure: Math.round(Number(streamMetrics.actual_exposure_us ?? 0) / 1000), + })} +
+ )}
{t("cam.stats.uptime")}: {streamStartedAtRef.current != null ? `${Math.max(0, Math.round((performance.now() - streamStartedAtRef.current) / 1000))}s` : "0s"} @@ -1419,9 +1478,15 @@ export function CameraConsoleApp() {
+ +
+
{ setFormDirty(true); setForm((p) => ({ ...p, contrast: Number(v.toFixed(1)) })); }} /> { setFormDirty(true); setForm((p) => ({ ...p, brightness: Number(v.toFixed(1)) })); }} /> { setFormDirty(true); setForm((p) => ({ ...p, saturation: Number(v.toFixed(1)) })); }} /> { setFormDirty(true); setForm((p) => ({ ...p, sharpness: Number(v.toFixed(1)) })); }} />
+ {!digitalGainWritable && ( +

+ {t("cam.controls.digitalGainReadOnly")}: {Number(status?.info?.actual_digital_gain ?? form.digitalGain).toFixed(2)} +

+ )}
diff --git a/web/static/analysis-lab/assets/analysis-D4cZ3rj1.js b/web/static/analysis-lab/assets/analysis-D_aCLSNL.js similarity index 99% rename from web/static/analysis-lab/assets/analysis-D4cZ3rj1.js rename to web/static/analysis-lab/assets/analysis-D_aCLSNL.js index df40ee1..7584981 100644 --- a/web/static/analysis-lab/assets/analysis-D4cZ3rj1.js +++ b/web/static/analysis-lab/assets/analysis-D_aCLSNL.js @@ -1,4 +1,4 @@ -var Os=Object.defineProperty;var $a=e=>{throw TypeError(e)};var Ps=(e,s,n)=>s in e?Os(e,s,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[s]=n;var F=(e,s,n)=>Ps(e,typeof s!="symbol"?s+"":s,n),Ta=(e,s,n)=>s.has(e)||$a("Cannot "+n);var f=(e,s,n)=>(Ta(e,s,"read from private field"),n?n.call(e):s.get(e)),ae=(e,s,n)=>s.has(e)?$a("Cannot add the same private member more than once"):s instanceof WeakSet?s.add(e):s.set(e,n),De=(e,s,n,r)=>(Ta(e,s,"write to private field"),r?r.call(e,n):s.set(e,n),n);import{j as t,r as l,a as $s,R as Ts}from"./client-D1ZVDB-N.js";import{c as J,u as $e,T as es,I as As}from"./index-Cu-N6Gfx.js";import{R as Us}from"./refresh-cw-BebYMFdn.js";/** +var Os=Object.defineProperty;var $a=e=>{throw TypeError(e)};var Ps=(e,s,n)=>s in e?Os(e,s,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[s]=n;var F=(e,s,n)=>Ps(e,typeof s!="symbol"?s+"":s,n),Ta=(e,s,n)=>s.has(e)||$a("Cannot "+n);var f=(e,s,n)=>(Ta(e,s,"read from private field"),n?n.call(e):s.get(e)),ae=(e,s,n)=>s.has(e)?$a("Cannot add the same private member more than once"):s instanceof WeakSet?s.add(e):s.set(e,n),De=(e,s,n,r)=>(Ta(e,s,"write to private field"),r?r.call(e,n):s.set(e,n),n);import{j as t,r as l,a as $s,R as Ts}from"./client-D1ZVDB-N.js";import{c as J,u as $e,T as es,I as As}from"./index-C78KOEFu.js";import{R as Us}from"./refresh-cw-gseCLMRP.js";/** * @license lucide-react v0.460.0 - ISC * * This source code is licensed under the ISC license. diff --git a/web/static/analysis-lab/assets/camera-DtuKW1WE.js b/web/static/analysis-lab/assets/camera-DtuKW1WE.js deleted file mode 100644 index fc21c0d..0000000 --- a/web/static/analysis-lab/assets/camera-DtuKW1WE.js +++ /dev/null @@ -1,61 +0,0 @@ -import{r as n,j as e,a as ya,R as wa}from"./client-D1ZVDB-N.js";import{c as C,u as ja,T as Na,I as Sa}from"./index-Cu-N6Gfx.js";import{u as ka,S as Ca,C as Tt,r as _a,b as Ra}from"./http-B53ovOR5.js";/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const It=C("Circle",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ea=C("Download",[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["polyline",{points:"7 10 12 15 17 10",key:"2ggqvy"}],["line",{x1:"12",x2:"12",y1:"15",y2:"3",key:"1vk2je"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ma=C("FileText",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Fa=C("FolderOpen",[["path",{d:"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2",key:"usdka0"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ba=C("Info",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 16v-4",key:"1dtifu"}],["path",{d:"M12 8h.01",key:"e9boi3"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Pa=C("Moon",[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ot=C("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const $a=C("Settings2",[["path",{d:"M20 7h-9",key:"3s1dr2"}],["path",{d:"M14 17H5",key:"gfn3mx"}],["circle",{cx:"17",cy:"17",r:"3",key:"18b49y"}],["circle",{cx:"7",cy:"7",r:"3",key:"dfmy0x"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ta=C("SlidersHorizontal",[["line",{x1:"21",x2:"14",y1:"4",y2:"4",key:"obuewd"}],["line",{x1:"10",x2:"3",y1:"4",y2:"4",key:"1q6298"}],["line",{x1:"21",x2:"12",y1:"12",y2:"12",key:"1iu8h1"}],["line",{x1:"8",x2:"3",y1:"12",y2:"12",key:"ntss68"}],["line",{x1:"21",x2:"16",y1:"20",y2:"20",key:"14d8ph"}],["line",{x1:"12",x2:"3",y1:"20",y2:"20",key:"m0wm8r"}],["line",{x1:"14",x2:"14",y1:"2",y2:"6",key:"14e1ph"}],["line",{x1:"8",x2:"8",y1:"10",y2:"14",key:"1i6ji0"}],["line",{x1:"16",x2:"16",y1:"18",y2:"22",key:"1lctlv"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Ia=C("Square",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Oa=C("Sun",[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]]);/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Da=C("X",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]),Ha="/api/dev/debug";function L(a){return`${Ha}${a}`}async function d(a,b={}){const g=a.startsWith("/api/debug/")?a.replace("/api/debug/","/api/dev/debug/"):a;return _a(g,b)}const Aa=["640x360","1280x720","1600x900","1920x1020"],Ga=[0,90,180,270],Se=12;function S(a,b,g){return Math.min(g,Math.max(b,a))}function j(a,b){return a==null||Number.isNaN(Number(a))?b:Number(a)}function Dt(a){if(!a)return"0 B";const b=["B","KB","MB","GB"];let g=0,f=a;for(;f>=1024&&g0&&f>=g?"busy":"ok"}catch{return"fail"}}function M({label:a,value:b,min:g,max:f,step:s,onChange:ke,disabled:u=!1,unit:U=""}){return e.jsxs("label",{className:`block ${u?"opacity-50":""}`,children:[e.jsxs("div",{className:"mb-1 flex items-center justify-between",children:[e.jsx("span",{children:a}),e.jsxs("span",{className:"font-mono text-[11px]",children:[b.toFixed(s>=1?0:s>=.1?1:2),U]})]}),e.jsx("input",{type:"range",min:g,max:f,step:s,value:b,disabled:u,onChange:te=>ke(Number(te.target.value)),className:"w-full accent-primary"})]})}function za(){var Ct,_t,Rt,Et,Mt,Ft;const{t:a,locale:b,setLocale:g}=ja(),{info:f}=ka(),[s,ke]=n.useState(null),[u,U]=n.useState(!1),[te,Ce]=n.useState(()=>Date.now()),[_e,o]=n.useState(null),[ae,H]=n.useState(null),[Ue,A]=n.useState(!1),[V,p]=n.useState(null),[F,re]=n.useState(!1),[Re,Ve]=n.useState(!1),[We,Je]=n.useState(!1),[Ke,Xe]=n.useState("8"),[Ze,Qe]=n.useState("8"),[Ye,et]=n.useState("1280x720"),[tt,at]=n.useState("supersample"),[W,G]=n.useState(!1),[rt,At]=n.useState(!0),[Ee,Gt]=n.useState(!0),[Me,zt]=n.useState(!1),[nt,qt]=n.useState(!1),[st,Lt]=n.useState(!1),[Fe,Ut]=n.useState({mean:0,std:0,over:0}),[Vt,ne]=n.useState(0),[i,Be]=n.useState(null),[ot,it]=n.useState(0),[ct,lt]=n.useState(180),[se,dt]=n.useState(!1),[oe,ut]=n.useState(!1),[c,y]=n.useState({exposure:5e3,gain:1,digitalGain:1,autoExposure:!0,contrast:1,brightness:0,saturation:1,sharpness:1,noiseReduction:0,whiteBalanceMode:"auto",whiteBalanceGainR:1,whiteBalanceGainB:1,colorMode:"color"}),[ie,v]=n.useState(!1),[Pe,mt]=n.useState(""),[pt,xt]=n.useState(""),[ht,Wt]=n.useState([]),[$e,P]=n.useState(!1),[J,Jt]=n.useState([]),[bt,ft]=n.useState(!1),[w,Te]=n.useState(null),[Kt,Ie]=n.useState(!1),[ce,Oe]=n.useState(null),[De,le]=n.useState(1),_=n.useRef(null),de=n.useRef(null),K=n.useRef(null),ue=n.useRef(null),me=n.useRef(null),$=n.useRef(null),pe=n.useRef(null),D=n.useRef(!1),xe=n.useRef(null),T=n.useRef([]),z=n.useRef(0),He=n.useRef(null),gt=()=>{T.current=[],z.current=0,xe.current=null,ne(0)},N=async()=>{try{const t=await d("/api/debug/camera/status",{cache:"no-store"});ke(t),t.streaming||(U(!1),D.current=!1)}catch(t){o(t instanceof Error?t.message:String(t))}},X=()=>{pe.current!=null&&(window.clearTimeout(pe.current),pe.current=null)},vt=async()=>{if(!F){re(!0),o(null),H(null),A(!1);try{X(),s!=null&&s.streaming||await d("/api/debug/camera/start",{method:"POST"});const t=Date.now(),r=await Ht();if(r==="busy"){H(a("cam.err.streamBusy")),A(!0);return}if(r==="fail"){H(a("cam.err.streamProbeFailed")),A(!1);return}U(!0),D.current=!0,p(a("cam.notice.previewStart")),gt(),xe.current=performance.now(),Ce(t),await N()}catch(t){o(t instanceof Error?t.message:String(t))}finally{re(!1)}}},Xt=async()=>{if(!F){re(!0),o(null);try{X(),U(!1),D.current=!1,H(null),A(!1),gt(),_.current&&(_.current.onload=null,_.current.onerror=null,_.current.src="",_.current.removeAttribute("src")),Ce(Date.now()),p(a("cam.notice.previewStop"))}catch(t){o(t instanceof Error?t.message:String(t))}finally{re(!1)}}},Zt=async()=>{if(!(!u&&!(s!=null&&s.streaming))){Je(!0),o(null);try{const t=await d("/api/debug/camera/capture",{method:"POST"});p(a("cam.notice.captureSaved",{name:t.filename||"capture"}))}catch(t){o(t instanceof Error?t.message:String(t))}finally{Je(!1)}}},Qt=async()=>{if(!Re){Ve(!0),o(null);try{if(s!=null&&s.recording)await d("/api/debug/camera/record/stop",{method:"POST"}),p(a("cam.notice.recordStop"));else{const t=await d("/api/debug/camera/record/start",{method:"POST"});p(a("cam.notice.recordStart",{name:t.filename||"video.avi"}))}await N()}catch(t){o(t instanceof Error?t.message:String(t))}finally{Ve(!1)}}},Yt=async()=>{o(null);try{const t=S(parseInt(Ke,10)||5,1,60);await d(`/api/debug/camera/fps?fps=${t}`,{method:"POST"});const r=S(parseInt(Ze,10)||8,1,30);await d(`/api/debug/camera/preview-fps?fps=${r}`,{method:"POST"});const[m,x]=Ye.split("x").map(l=>parseInt(l,10));m&&x&&await d(`/api/debug/camera/size?width=${m}&height=${x}`,{method:"POST"}),await d(`/api/debug/camera/sampling?mode=${encodeURIComponent(tt)}`,{method:"POST"}),p(a("cam.notice.runtimeApplied")),G(!1),await N()}catch(t){o(t instanceof Error?t.message:String(t))}},ea=async()=>{o(null);try{await d("/api/debug/camera/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)}),p(a("cam.notice.settingsApplied")),v(!1),await N()}catch(t){o(t instanceof Error?t.message:String(t))}},ta=async()=>{o(null);try{await d(`/api/debug/camera/auto-exposure?enabled=${c.autoExposure?"true":"false"}`,{method:"POST"}),await d(`/api/debug/camera/white-balance?mode=${encodeURIComponent(c.whiteBalanceMode)}&gain_r=${c.whiteBalanceGainR}&gain_b=${c.whiteBalanceGainB}`,{method:"POST"}),await d(`/api/debug/camera/color-mode?color_mode=${encodeURIComponent(c.colorMode)}`,{method:"POST"}),p(a("cam.notice.modeApplied")),await N()}catch(t){o(t instanceof Error?t.message:String(t))}},aa=(t,r)=>{if(!t)return;const m=(r==null?void 0:r.syncRuntime)??!0;y({exposure:S(Math.round(j(t.exposure_us,5e3)),100,12e4),gain:S(j(t.analogue_gain,1),1,24),digitalGain:S(j(t.digital_gain,1),1,8),autoExposure:!!(t.auto_exposure??!0),contrast:S(j(t.contrast,1),0,2),brightness:S(j(t.brightness,0),-1,1),saturation:S(j(t.saturation,1),0,2),sharpness:S(j(t.sharpness,1),0,2),noiseReduction:S(Math.round(j(t.noise_reduction,0)),0,4),whiteBalanceMode:String(t.white_balance_mode??"auto"),whiteBalanceGainR:S(j(t.white_balance_gain_r,1),.1,3),whiteBalanceGainB:S(j(t.white_balance_gain_b,1),.1,3),colorMode:String(t.color_mode??"color")}),m&&(Xe(String(Math.round(j(t.fps,8)))),et(`${Math.round(j(t.width,1280))}x${Math.round(j(t.height,720))}`),at(String(t.sampling_mode??"supersample")),G(!1)),lt(S(Math.round(j(t.rotation,180)),0,270)),dt(!!t.flip_horizontal),ut(!!t.flip_vertical),v(!1)},yt=async(t,r)=>{o(null);try{await d("/api/debug/camera/mirror",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({flip_horizontal:t,flip_vertical:r})}),dt(t),ut(r),p(a("cam.notice.mirrorApplied")),await N()}catch(m){o(m instanceof Error?m.message:String(m))}},ra=async t=>{o(null);try{await d(`/api/debug/camera/rotation/${t}`,{method:"POST"}),lt(t),p(a("cam.notice.rotationApplied",{value:t})),await N()}catch(r){o(r instanceof Error?r.message:String(r))}},wt=async t=>{o(null);try{await d(`/api/debug/camera/night-mode?enabled=${t?"true":"false"}`,{method:"POST"}),p(a(t?"cam.notice.nightOn":"cam.notice.nightOff")),await N()}catch(r){o(r instanceof Error?r.message:String(r))}},na=async()=>{o(null);try{await d("/api/debug/camera/reset",{method:"POST"}),p(a("cam.notice.reset")),await N()}catch(t){o(t instanceof Error?t.message:String(t))}},sa=async()=>{o(null);try{await d("/api/debug/camera/backup-settings",{method:"POST"}),p(a("cam.notice.backup"))}catch(t){o(t instanceof Error?t.message:String(t))}},oa=async()=>{o(null);try{await d("/api/debug/camera/restore-settings",{method:"POST"}),p(a("cam.notice.restore")),await N()}catch(t){o(t instanceof Error?t.message:String(t))}},he=async()=>{P(!0);try{const t=await d("/api/debug/camera/presets",{cache:"no-store"});Wt(t.presets??[])}catch(t){o(t instanceof Error?t.message:String(t))}finally{P(!1)}},ia=async()=>{const t=Pe.trim();if(t){P(!0),o(null);try{await d("/api/debug/camera/presets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t,description:pt.trim(),exposure_us:c.exposure,analogue_gain:c.gain,digital_gain:c.digitalGain,auto_exposure:c.autoExposure,contrast:c.contrast,brightness:c.brightness,saturation:c.saturation,sharpness:c.sharpness,noise_reduction:c.noiseReduction,white_balance_mode:c.whiteBalanceMode,white_balance_gain_r:c.whiteBalanceGainR,white_balance_gain_b:c.whiteBalanceGainB,rotation:ct,flip_horizontal:se,flip_vertical:oe,color_mode:c.colorMode})}),p(a("cam.notice.presetSaved",{name:t})),mt(""),xt(""),await he()}catch(r){o(r instanceof Error?r.message:String(r))}finally{P(!1)}}},ca=async t=>{P(!0),o(null);try{await d(`/api/debug/camera/presets/${encodeURIComponent(t)}/apply`,{method:"POST"}),p(a("cam.notice.presetApplied",{name:t})),await N(),await he()}catch(r){o(r instanceof Error?r.message:String(r))}finally{P(!1)}},la=async t=>{if(window.confirm(a("cam.confirm.deletePreset",{name:t}))){P(!0),o(null);try{await d(`/api/debug/camera/presets/${encodeURIComponent(t)}`,{method:"DELETE"}),p(a("cam.notice.presetDeleted",{name:t})),await he()}catch(r){o(r instanceof Error?r.message:String(r))}finally{P(!1)}}},Ae=async()=>{ft(!0);try{const t=await d("/api/debug/files",{cache:"no-store"});Jt(t.files??[]),le(1)}catch(t){o(t instanceof Error?t.message:String(t))}finally{ft(!1)}},Ge=()=>{Oe(null),Te(null),Ie(!1)},da=async t=>{if(ce===t){Ge();return}Oe(t),Te(null),Ie(!0),o(null);try{const r=await d(`/api/debug/files/${encodeURIComponent(t)}/info`,{cache:"no-store"});Te(r)}catch(r){o(r instanceof Error?r.message:String(r)),Oe(null)}finally{Ie(!1)}},ua=t=>{const r=(x,l)=>{const h=document.createElement("a");h.href=l,h.download=x,document.body.appendChild(h),h.click(),document.body.removeChild(h)};r(t,`${L("/files")}/${encodeURIComponent(t)}`);const m=t.match(/\.(jpe?g|png|bmp|tiff?|webp|mp4|avi|mov|mkv|wmv|flv|webm|m4v)$/i);if(m){const l=`${t.slice(0,-m[0].length)}.txt`;(async()=>{try{if(!(await fetch(`${L("/files")}/${encodeURIComponent(l)}`)).ok)return;r(l,`${L("/files")}/${encodeURIComponent(l)}`),p(a("cam.notice.downloadWithSidecar",{name:t,sidecar:l}))}catch{p(a("cam.notice.download",{name:t}))}})();return}p(a("cam.notice.download",{name:t}))},ma=async t=>{if(window.confirm(a("cam.confirm.deleteFile",{name:t}))){o(null);try{await d(`/api/debug/files/${encodeURIComponent(t)}`,{method:"DELETE"}),p(a("cam.notice.fileDeleted",{name:t})),(ce===t||(w==null?void 0:w.filename)===t)&&Ge(),await Ae()}catch(r){o(r instanceof Error?r.message:String(r))}}},pa=()=>{if(!rt||!_.current||!de.current)return;const t=_.current;if(!t.naturalWidth||!t.naturalHeight||(K.current||(K.current=document.createElement("canvas"),ue.current=K.current.getContext("2d",{willReadFrequently:!0})),me.current||(me.current=de.current.getContext("2d")),!ue.current||!me.current))return;const m=Math.min(1,320/t.naturalWidth),x=Math.max(1,Math.round(t.naturalWidth*m)),l=Math.max(1,Math.round(t.naturalHeight*m));K.current.width=x,K.current.height=l,ue.current.drawImage(t,0,0,x,l);const h=ue.current.getImageData(0,0,x,l).data,B=new Array(256).fill(0),k=new Array(256).fill(0),Z=new Array(256).fill(0),qe=new Array(256).fill(0);let Bt=0,Pt=0,$t=0;const q=x*l;for(let E=0;E=250&&($t+=1)}const Le=q?Bt/q:0,ga=q?Pt/q-Le*Le:0;Ut({mean:Le,std:Math.sqrt(Math.max(0,ga)),over:q?$t/q*100:0});const ye=de.current,R=me.current,we=window.devicePixelRatio||1,Q=Math.max(1,ye.clientWidth||240),Y=Math.max(1,ye.clientHeight||120);ye.width=Math.floor(Q*we),ye.height=Math.floor(Y*we),R.setTransform(we,0,0,we,0,0),R.clearRect(0,0,Q,Y);const va=Math.max(1,...Ee?[Math.max(...B),Math.max(...k),Math.max(...Z)]:[0],...Me?[Math.max(...qe)]:[0]),je=(E,Ne)=>{R.beginPath(),R.strokeStyle=Ne,R.lineWidth=1.2;for(let I=0;I<256;I+=1){const ee=I/255*Q,O=Y-E[I]/va*Y;I===0?R.moveTo(ee,O):R.lineTo(ee,O)}R.stroke()};if(Ee&&(je(B,"rgba(255,80,80,0.85)"),je(k,"rgba(80,255,80,0.85)"),je(Z,"rgba(80,160,255,0.85)")),Me&&je(qe,"rgba(255,255,255,0.95)"),nt){const E=.9803921568627451*Q;R.fillStyle="rgba(255,100,100,0.12)",R.fillRect(E,0,Q-E,Y)}};n.useEffect(()=>{N(),he(),Ae();const t=window.setInterval(()=>{document.hidden||N()},2e3);return()=>window.clearInterval(t)},[s==null?void 0:s.streaming]),n.useEffect(()=>{!(s!=null&&s.info)||ie||aa(s.info,{syncRuntime:!W})},[s==null?void 0:s.info,ie,W]),n.useEffect(()=>{if(!(s!=null&&s.recording)){$.current&&(window.clearInterval($.current),$.current=null),it(0);return}if($.current)return;const t=Date.now();return $.current=window.setInterval(()=>{it(Math.max(0,Math.floor((Date.now()-t)/1e3)))},1e3),()=>{$.current&&(window.clearInterval($.current),$.current=null)}},[s==null?void 0:s.recording]),n.useEffect(()=>{D.current=u,u||X()},[u]),n.useEffect(()=>{u&&(T.current=[],z.current=0)},[te,u]),n.useEffect(()=>{if(!u){T.current=[],z.current=0,ne(0);return}if(!He.current){const l=document.createElement("canvas");l.width=32,l.height=32,He.current=l}const r=He.current.getContext("2d",{willReadFrequently:!0});if(!r)return;let m=0;const x=()=>{const l=_.current;if(l!=null&&l.complete&&l.naturalWidth>0)try{r.drawImage(l,0,0,32,32);const h=r.getImageData(0,0,32,32).data;let B=2166136261;for(let k=0;kwindow.cancelAnimationFrame(m)},[u]),n.useEffect(()=>{if(!u){ne(0);return}const t=window.setInterval(()=>{const m=performance.now()-1e3,x=T.current;for(;x.length&&x[0]window.clearInterval(t)},[u]),n.useEffect(()=>{if(!u){Be(null);return}let t=!1;const r=async()=>{try{const x=await fetch(`${L("/camera/stream/status")}`,{cache:"no-store",credentials:"same-origin"});if(!x.ok||t)return;const l=await x.json();if(!t){Be(l);const h=Number(l.preview_target_fps??l.target_preview_fps??0);Number.isFinite(h)&&h>0&&!W&&Qe(String(Math.round(h)))}}catch{t||Be(null)}};r();const m=window.setInterval(r,2e3);return()=>{t=!0,window.clearInterval(m)}},[u,W]),n.useEffect(()=>{if(!V)return;const t=window.setTimeout(()=>p(null),3200);return()=>window.clearTimeout(t)},[V]),n.useEffect(()=>()=>X(),[]),n.useEffect(()=>{const t=Math.max(1,Math.ceil(J.length/Se));De>t&&le(t)},[J.length,De]);const xa=u?`${L("/camera/stream")}?t=${te}`:"",be=c.autoExposure,ze=c.whiteBalanceMode==="manual",jt=!!((Ct=s==null?void 0:s.info)!=null&&Ct.night_mode),Nt=u,fe=!F&&!u&&!(s!=null&&s.recording),St=!F&&u,ha=!F&&!We&&Nt,ba=!F&&!Re&&Nt,ge=Math.max(1,Math.ceil(J.length/Se)),ve=Math.min(De,ge),kt=(ve-1)*Se,fa=J.slice(kt,kt+Se);return e.jsxs("div",{className:"min-h-screen bg-background text-on-surface",children:[e.jsx("header",{className:"sticky top-0 z-30 border-b border-outline-variant/20 bg-surface-container-low/90 px-4 py-3 backdrop-blur",children:e.jsxs("div",{className:"flex w-full items-center justify-between gap-4",children:[e.jsx("div",{children:e.jsx("h1",{className:"font-headline text-xl font-bold text-primary",children:`OGScope ${a("cam.title")}`})}),e.jsxs("div",{className:"flex items-center gap-2 text-xs",children:[e.jsx("button",{type:"button",className:`inline-flex h-7 items-center rounded px-2 py-1 ${b==="zh"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>g("zh"),children:a("lang.zh")}),e.jsx("button",{type:"button",className:`inline-flex h-7 items-center rounded px-2 py-1 ${b==="en"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>g("en"),children:a("lang.en")}),e.jsxs("a",{href:"/debug",className:"inline-flex h-7 items-center gap-1 rounded border border-outline-variant/30 px-2 py-1 hover:bg-surface-container",children:[e.jsx($a,{className:"h-3.5 w-3.5"})," ",a("cam.btn.system")]})]})]})}),e.jsxs("main",{className:"mx-auto grid max-w-[1880px] grid-cols-12 gap-4 p-4",children:[e.jsxs("aside",{className:"order-3 col-span-12 space-y-4 xl:order-1 xl:col-span-2",children:[e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("div",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:a("cam.controls.tools")}),e.jsx("div",{className:"mb-2 flex flex-wrap gap-1 text-xs",children:Ga.map(t=>e.jsxs("button",{type:"button",onClick:()=>void ra(t),className:`rounded border px-2 py-1 ${ct===t?"border-primary text-primary":"border-outline-variant/30"}`,children:[t,"°"]},t))}),e.jsx("div",{className:"mb-2 text-[11px] text-on-surface-variant",children:a("cam.mirror.hint")}),e.jsxs("div",{className:"mb-2 flex flex-wrap gap-1 text-xs",children:[e.jsx("button",{type:"button",onClick:()=>void yt(!se,oe),className:`rounded border px-2 py-1 ${se?"border-primary text-primary":"border-outline-variant/30"}`,children:a("cam.mirror.horizontal")}),e.jsx("button",{type:"button",onClick:()=>void yt(se,!oe),className:`rounded border px-2 py-1 ${oe?"border-primary text-primary":"border-outline-variant/30"}`,children:a("cam.mirror.vertical")})]}),e.jsxs("div",{className:"flex flex-wrap gap-2 text-xs",children:[e.jsxs("button",{type:"button",onClick:()=>void sa(),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Ca,{className:"mr-1 inline h-3.5 w-3.5"}),a("cam.controls.backup")]}),e.jsxs("button",{type:"button",onClick:()=>void oa(),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Fa,{className:"mr-1 inline h-3.5 w-3.5"}),a("cam.controls.restore")]}),e.jsx("button",{type:"button",onClick:()=>void na(),className:"rounded border border-outline-variant/40 px-2 py-1",children:a("cam.controls.reset")})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4 text-xs",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:a("cam.quick.title")}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx("span",{className:"text-[11px] text-on-surface-variant",children:a("cam.quick.nightHint")}),e.jsxs("button",{type:"button",onClick:()=>void wt(!0),className:`rounded border px-2 py-1 ${jt?"border-primary/70 text-primary":"border-outline-variant/40"}`,children:[e.jsx(Pa,{className:"mr-1 inline h-3.5 w-3.5"}),a("cam.controls.nightOn")]}),e.jsxs("button",{type:"button",onClick:()=>void wt(!1),className:`rounded border px-2 py-1 ${jt?"border-error/60 text-error":"border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Oa,{className:"mr-1 inline h-3.5 w-3.5"}),a("cam.controls.nightOff")]})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4 text-xs",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:a("cam.presets.title")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2",children:[e.jsx("input",{value:Pe,onChange:t=>mt(t.target.value),placeholder:a("cam.presets.name"),className:"rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"}),e.jsx("input",{value:pt,onChange:t=>xt(t.target.value),placeholder:a("cam.presets.desc"),className:"rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"})]}),e.jsx("div",{className:"mt-2",children:e.jsx("button",{type:"button",disabled:$e||!Pe.trim(),onClick:()=>void ia(),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:a("cam.presets.save")})}),e.jsxs("div",{className:"mt-3 max-h-48 space-y-2 overflow-auto",children:[ht.length===0&&e.jsx("div",{className:"text-on-surface-variant",children:a("cam.presets.empty")}),ht.map(t=>e.jsxs("div",{className:"rounded border border-outline-variant/20 p-2",children:[e.jsx("div",{className:"font-semibold",children:t.name}),e.jsx("div",{className:"text-on-surface-variant",children:t.description||a("cam.presets.noDesc")}),e.jsxs("div",{className:"mt-1 text-on-surface-variant",children:[a("cam.controls.exposure"),": ",t.exposure_us,"us | ",a("cam.controls.gain"),": ",t.analogue_gain]}),e.jsxs("div",{className:"mt-2 flex gap-2",children:[e.jsx("button",{type:"button",disabled:$e,onClick:()=>void ca(t.name),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:a("cam.presets.apply")}),e.jsx("button",{type:"button",disabled:$e,onClick:()=>void la(t.name),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:a("cam.presets.delete")})]})]},t.name))]})]})]}),e.jsxs("section",{className:"order-1 col-span-12 grid grid-cols-12 items-start gap-4 xl:order-2 xl:col-span-10",children:[e.jsxs("div",{className:"col-span-12 space-y-4 xl:col-span-9",children:[e.jsxs("section",{className:"self-start rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"mb-2 grid grid-cols-3 gap-2 text-xs",children:[e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1",children:["CPU: ",e.jsxs("span",{className:"font-mono",children:[Number((f==null?void 0:f.cpu_usage)??0).toFixed(1),"%"]})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1",children:["MEM: ",e.jsxs("span",{className:"font-mono",children:[Number((f==null?void 0:f.memory_usage)??0).toFixed(1),"%"]})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1",children:["TEMP: ",e.jsxs("span",{className:"font-mono",children:[Number((f==null?void 0:f.temperature)??0).toFixed(1),"°C"]})]})]}),e.jsxs("div",{className:"mb-2 flex flex-wrap items-start justify-between gap-2",children:[e.jsxs("div",{children:[e.jsx("h2",{className:"text-sm font-semibold uppercase tracking-wider",children:a("cam.preview.title")}),e.jsx("p",{className:"mt-1 max-w-2xl text-[11px] leading-snug text-on-surface-variant",children:a("cam.hint.mjpegSingleStream")})]}),e.jsxs("div",{className:"shrink-0 font-mono text-xs text-on-surface-variant",children:[a("cam.preview.state"),": ",a(u?"cam.state.streaming":"cam.state.idle")]})]}),ae&&!u&&e.jsxs("div",{className:"mb-2 rounded-lg border border-error/35 bg-error-container/15 px-3 py-2 text-left",role:"alert",children:[e.jsx("p",{className:"text-sm font-medium text-error",children:ae}),Ue?e.jsx("p",{className:"mt-1 max-w-2xl text-[11px] leading-snug text-on-surface-variant",children:a("cam.err.streamBusyHint")}):e.jsx("p",{className:"mt-1 max-w-2xl text-[11px] leading-snug text-on-surface-variant",children:a("cam.err.streamProbeDetail")})]}),e.jsxs("div",{className:"relative aspect-video overflow-hidden rounded border border-outline-variant/20 bg-black",children:[u&&ae&&e.jsxs("div",{className:"pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-black/80 px-4 text-center",role:"alert",children:[e.jsx("p",{className:"text-sm font-medium text-error",children:ae}),Ue?e.jsx("p",{className:"max-w-md text-[11px] leading-snug text-on-surface-variant",children:a("cam.err.streamBusyHint")}):e.jsx("p",{className:"max-w-md text-[11px] leading-snug text-on-surface-variant",children:a("cam.err.streamProbeDetail")})]}),u?e.jsx("img",{ref:_,alt:"camera-preview",className:"h-full w-full object-contain",src:xa,onLoad:()=>{H(null),A(!1),pa()},onError:()=>{var r;if(X(),!D.current)return;const t=(r=_.current)==null?void 0:r.src;(async()=>{if(!D.current||!t)return;if(await Ht()==="busy"){H(a("cam.err.streamBusy")),A(!0);return}pe.current=window.setTimeout(()=>{D.current&&Ce(Date.now())},400)})()}}):e.jsxs("div",{className:"flex h-full w-full flex-col items-center justify-center gap-3 text-center text-on-surface-variant",children:[e.jsx(Tt,{className:"h-10 w-10 text-primary/80"}),e.jsx("div",{className:"text-sm",children:a("cam.preview.emptyTitle")}),e.jsx("div",{className:"text-xs",children:a("cam.preview.emptyDesc")}),e.jsxs("button",{type:"button",disabled:!fe,onClick:()=>void vt(),className:`rounded px-3 py-1.5 text-xs disabled:opacity-50 ${fe?"bg-primary-container text-on-primary-container":"border border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Ot,{className:"mr-1 inline h-3.5 w-3.5"}),a(F?"cam.btn.starting":"cam.btn.start")]})]}),(s==null?void 0:s.recording)&&e.jsxs("div",{className:"absolute right-3 top-3 flex items-center gap-2 rounded bg-black/60 px-2 py-1 text-xs text-error",children:[e.jsx(It,{className:"h-3.5 w-3.5 fill-current"})," ",a("cam.state.rec"),e.jsx("span",{className:"font-mono",children:`${Math.floor(ot/60).toString().padStart(2,"0")}:${(ot%60).toString().padStart(2,"0")}`})]}),e.jsx("div",{className:"absolute left-3 top-3",children:e.jsx("button",{type:"button",onClick:()=>Lt(t=>!t),className:"rounded border border-outline-variant/40 bg-black/70 px-2 py-1 text-xs text-white",children:a(st?"cam.hist.expand":"cam.hist.collapse")})}),!st&&e.jsxs("div",{className:"absolute left-3 top-12 w-[360px] max-w-[calc(100%-1.5rem)] rounded border border-outline-variant/30 bg-black/70 p-2 text-white",children:[e.jsxs("div",{className:"mb-2 flex flex-wrap items-center gap-3 text-[11px]",children:[e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:rt,onChange:t=>At(t.target.checked)})," ",a("cam.hist.enabled")]}),e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:Ee,onChange:t=>Gt(t.target.checked)})," RGB"]}),e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:Me,onChange:t=>zt(t.target.checked)})," ",a("cam.hist.luminance")]}),e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:nt,onChange:t=>qt(t.target.checked)})," ",a("cam.hist.over")]})]}),e.jsx("canvas",{ref:de,className:"h-24 w-full rounded border border-white/20 bg-black/60"}),e.jsxs("div",{className:"mt-2 grid grid-cols-3 gap-2 text-[11px]",children:[e.jsxs("div",{children:["mean: ",e.jsx("span",{className:"font-mono",children:Fe.mean.toFixed(1)})]}),e.jsxs("div",{children:["std: ",e.jsx("span",{className:"font-mono",children:Fe.std.toFixed(1)})]}),e.jsxs("div",{children:["over: ",e.jsxs("span",{className:"font-mono",children:[Fe.over.toFixed(2),"%"]})]})]})]})]}),e.jsxs("div",{className:"mt-3 grid grid-cols-1 gap-2 text-xs md:grid-cols-4",children:[e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.frameFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:Number((i==null?void 0:i.actual_preview_fps)??Vt).toFixed(2)}),e.jsx("p",{className:"mt-0.5 text-[10px] leading-tight text-on-surface-variant/90",children:a("cam.stats.fpsMeasureNote")})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.targetFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:Number(((_t=s==null?void 0:s.info)==null?void 0:_t.fps)??0).toFixed(2)})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.streamPacingFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:(i==null?void 0:i.preview_target_fps)??(i==null?void 0:i.target_preview_fps)??"—"}),e.jsx("p",{className:"mt-0.5 text-[10px] leading-tight text-on-surface-variant/90",children:a("cam.stats.streamPacingHint")})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.captureFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:Number((i==null?void 0:i.actual_capture_fps)??0).toFixed(2)})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.encodeMs"),": "]}),e.jsxs("span",{className:"font-mono text-on-surface",children:[Number((i==null?void 0:i.jpeg_average_encode_ms)??0).toFixed(1)," ms"]})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.consumers"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:`${(i==null?void 0:i.preview_consumers)??0}/${(i==null?void 0:i.analysis_consumers)??0}/${(i==null?void 0:i.recording_consumers)??0}`})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.cameraMemory"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:`${Math.round(Number((i==null?void 0:i.process_rss_kb)??0)/1024)} / ${Math.round(Number((i==null?void 0:i.process_swap_kb)??0)/1024)} MB`})]}),(i==null?void 0:i.throttle_reason)==="auto_exposure_long"&&e.jsx("div",{className:"rounded border border-tertiary/40 bg-tertiary-container/20 px-2 py-1.5 text-left text-tertiary",children:a("cam.stats.longExposureThrottle",{exposure:Math.round(Number(i.actual_exposure_us??0)/1e3)})}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.stats.uptime"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:xe.current!=null?`${Math.max(0,Math.round((performance.now()-xe.current)/1e3))}s`:"0s"})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.system.sensor"),": "]}),e.jsx("span",{className:"font-mono",children:String(((Rt=s==null?void 0:s.info)==null?void 0:Rt.sensor)??"—")})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.controls.resolution"),": "]}),e.jsx("span",{className:"font-mono",children:`${((Et=s==null?void 0:s.info)==null?void 0:Et.width)??"—"}x${((Mt=s==null?void 0:s.info)==null?void 0:Mt.height)??"—"}`})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[a("cam.preview.mode"),": "]}),e.jsx("span",{className:"font-mono",children:(Ft=s==null?void 0:s.info)!=null&&Ft.auto_exposure?a("cam.controls.auto"):a("cam.controls.manual")})]})]}),e.jsxs("div",{className:"mt-3 flex flex-wrap gap-2",children:[e.jsxs("button",{type:"button",disabled:!fe,onClick:()=>void vt(),className:`rounded px-3 py-2 text-sm disabled:opacity-50 ${fe?"bg-primary-container text-on-primary-container":"border border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Ot,{className:"inline h-4 w-4"})," ",a(F?"cam.btn.starting":"cam.btn.start")]}),e.jsxs("button",{type:"button",disabled:!St,onClick:()=>void Xt(),className:`rounded border px-3 py-2 text-sm disabled:opacity-50 ${St?"border-error/60 text-error hover:bg-error/10":"border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Ia,{className:"inline h-4 w-4"})," ",a(F?"cam.btn.stopping":"cam.btn.stop")]}),e.jsxs("button",{type:"button",disabled:!ha,onClick:()=>void Zt(),className:"rounded border border-outline-variant/40 px-3 py-2 text-sm disabled:opacity-50",children:[e.jsx(Tt,{className:"inline h-4 w-4"})," ",a(We?"cam.btn.capturing":"cam.btn.capture")]}),e.jsxs("button",{type:"button",disabled:!ba,onClick:()=>void Qt(),className:`rounded border px-3 py-2 text-sm disabled:opacity-50 ${s!=null&&s.recording?"border-error/60 bg-error/10 text-error":"border-outline-variant/40"}`,children:[e.jsx(It,{className:"inline h-4 w-4"})," ",Re?a("cam.btn.recordBusy"):s!=null&&s.recording?a("cam.btn.recordStop"):a("cam.btn.recordStart")]})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4 text-xs",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:a("cam.files.title")}),e.jsx("div",{className:"mb-2",children:e.jsx("button",{type:"button",disabled:bt,onClick:()=>void Ae(),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:a("cam.files.refresh")})}),Kt&&e.jsx("div",{className:"mb-2 text-on-surface-variant",children:a("cam.files.loadingInfo")}),w&&e.jsxs("div",{className:"mb-3 rounded border border-outline-variant/20 p-2",children:[e.jsxs("div",{className:"mb-2 flex items-center justify-between gap-2 font-semibold",children:[e.jsxs("div",{className:"flex min-w-0 items-center gap-1",children:[e.jsx(Ma,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",children:w.filename})]}),e.jsx("button",{type:"button",onClick:()=>Ge(),className:"shrink-0 rounded border border-outline-variant/40 p-1 text-on-surface-variant hover:bg-surface-container","aria-label":a("cam.files.closeDetail"),children:e.jsx(Da,{className:"h-3.5 w-3.5"})})]}),e.jsxs("div",{children:[a("cam.files.size"),": ",e.jsx("span",{className:"font-mono",children:Dt(w.size)})]}),e.jsxs("div",{children:[a("cam.files.type"),": ",e.jsx("span",{className:"font-mono",children:w.type})]}),e.jsxs("div",{children:[a("cam.files.modified"),": ",e.jsx("span",{className:"font-mono",children:new Date(w.modified).toLocaleString()})]}),w.exposure_us!=null&&e.jsxs("div",{children:[a("cam.controls.exposure"),": ",e.jsxs("span",{className:"font-mono",children:[w.exposure_us,"us"]})]}),w.analogue_gain!=null&&e.jsxs("div",{children:[a("cam.controls.gain"),": ",e.jsx("span",{className:"font-mono",children:w.analogue_gain})]}),w.resolution&&e.jsxs("div",{children:[a("cam.controls.resolution"),": ",e.jsx("span",{className:"font-mono",children:w.resolution})]})]}),e.jsxs("div",{className:"max-h-96 space-y-2 overflow-auto",children:[J.length===0&&!bt&&e.jsx("div",{className:"text-on-surface-variant",children:a("cam.files.empty")}),fa.map(t=>e.jsxs("div",{className:`rounded border p-2 ${ce===t.name?"border-primary/50 bg-primary/5":"border-outline-variant/20"}`,children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("div",{className:"truncate font-semibold",children:t.name}),e.jsxs("div",{className:"text-on-surface-variant",children:[Dt(t.size)," | ",new Date(t.modified).toLocaleString()]})]}),e.jsx("div",{className:"shrink-0 text-on-surface-variant",children:t.type})]}),e.jsxs("div",{className:"mt-2 flex gap-2",children:[e.jsxs("button",{type:"button",onClick:()=>ua(t.name),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Ea,{className:"mr-1 inline h-3.5 w-3.5"}),a("cam.files.download")]}),e.jsxs("button",{type:"button",onClick:()=>void da(t.name),className:`rounded border px-2 py-1 ${ce===t.name?"border-primary text-primary":"border-outline-variant/40"}`,children:[e.jsx(Ba,{className:"mr-1 inline h-3.5 w-3.5"}),a("cam.files.info")]}),e.jsxs("button",{type:"button",onClick:()=>void ma(t.name),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Na,{className:"mr-1 inline h-3.5 w-3.5"}),a("cam.files.delete")]})]})]},t.name))]}),e.jsxs("div",{className:"mt-3 flex items-center justify-between",children:[e.jsx("button",{type:"button",disabled:ve<=1,onClick:()=>le(t=>Math.max(1,t-1)),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:a("cam.files.prev")}),e.jsx("div",{className:"text-on-surface-variant",children:a("cam.files.page",{current:ve,total:ge})}),e.jsx("button",{type:"button",disabled:ve>=ge,onClick:()=>le(t=>Math.min(ge,t+1)),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:a("cam.files.next")})]})]})]}),e.jsxs("section",{className:"col-span-12 grid grid-cols-12 gap-4 xl:col-span-3 xl:self-start",children:[e.jsxs("section",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:a("cam.controls.title")}),e.jsxs("div",{className:"space-y-3 text-xs",children:[e.jsxs("div",{className:"grid grid-cols-2 gap-2",children:[e.jsxs("label",{className:"block",children:[a("cam.controls.sensorFps"),e.jsx("input",{value:Ke,onChange:t=>{Xe(t.target.value),G(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"})]}),e.jsxs("label",{className:"block",children:[a("cam.controls.previewFps"),e.jsx("input",{value:Ze,onChange:t=>{Qe(t.target.value),G(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"})]})]}),e.jsx("div",{className:"grid grid-cols-1 gap-2",children:e.jsxs("label",{className:"block",children:[a("cam.controls.sampling"),e.jsxs("select",{value:tt,onChange:t=>{at(t.target.value),G(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"supersample",children:"supersample"}),e.jsx("option",{value:"native",children:"native"}),e.jsx("option",{value:"crop",children:"crop"})]})]})}),e.jsxs("label",{className:"block",children:[a("cam.controls.resolution"),e.jsx("div",{className:"mt-1 grid grid-cols-2 gap-1",children:Aa.map(t=>e.jsx("button",{type:"button",onClick:()=>{et(t),G(!0)},className:`rounded border px-2 py-1 ${Ye===t?"border-primary text-primary":"border-outline-variant/30"}`,children:t},t))})]}),e.jsx("button",{type:"button",disabled:!W,onClick:()=>void Yt(),className:"w-full rounded border border-outline-variant/40 px-2 py-1.5 disabled:opacity-50",children:a("cam.controls.applyRuntime")})]})]}),e.jsxs("section",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"mb-2 flex items-center gap-1 text-sm font-semibold uppercase tracking-wider",children:[e.jsx(Ta,{className:"h-3.5 w-3.5"})," ",a("cam.controls.core")]}),ie&&e.jsx("div",{className:"mb-2 rounded border border-primary/40 bg-primary/10 px-2 py-1 text-[11px] text-primary",children:a("cam.controls.pendingChanges")}),be&&e.jsx("p",{className:"mb-2 text-[11px] text-on-surface-variant",children:a("cam.controls.lockedByAe")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2 text-xs",children:[e.jsx(M,{label:a("cam.controls.exposure"),value:c.exposure,min:100,max:12e4,step:100,unit:"us",disabled:be,onChange:t=>{v(!0),y(r=>({...r,exposure:t}))}}),e.jsx(M,{label:a("cam.controls.gain"),value:c.gain,min:1,max:24,step:.1,disabled:be,onChange:t=>{v(!0),y(r=>({...r,gain:Number(t.toFixed(1))}))}}),e.jsx(M,{label:a("cam.controls.digitalGain"),value:c.digitalGain,min:1,max:8,step:.1,disabled:be,onChange:t=>{v(!0),y(r=>({...r,digitalGain:Number(t.toFixed(1))}))}}),e.jsx(M,{label:a("cam.controls.noiseReduction"),value:c.noiseReduction,min:0,max:4,step:1,onChange:t=>{v(!0),y(r=>({...r,noiseReduction:Math.round(t)}))}}),e.jsx(M,{label:a("cam.controls.contrast"),value:c.contrast,min:0,max:2,step:.1,onChange:t=>{v(!0),y(r=>({...r,contrast:Number(t.toFixed(1))}))}}),e.jsx(M,{label:a("cam.controls.brightness"),value:c.brightness,min:-1,max:1,step:.1,onChange:t=>{v(!0),y(r=>({...r,brightness:Number(t.toFixed(1))}))}}),e.jsx(M,{label:a("cam.controls.saturation"),value:c.saturation,min:0,max:2,step:.1,onChange:t=>{v(!0),y(r=>({...r,saturation:Number(t.toFixed(1))}))}}),e.jsx(M,{label:a("cam.controls.sharpness"),value:c.sharpness,min:0,max:2,step:.1,onChange:t=>{v(!0),y(r=>({...r,sharpness:Number(t.toFixed(1))}))}})]}),e.jsx("div",{className:"mt-2",children:e.jsx("button",{type:"button",disabled:!ie,onClick:()=>void ea(),className:"w-full rounded border border-outline-variant/40 px-2 py-1.5 disabled:opacity-50",children:a("cam.controls.applySettings")})})]}),e.jsxs("section",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("div",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:a("cam.controls.mode")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2 text-xs",children:[e.jsxs("label",{className:"block",children:[a("cam.controls.autoExposure"),e.jsxs("select",{value:c.autoExposure?"auto":"manual",onChange:t=>{y(r=>({...r,autoExposure:t.target.value==="auto"})),v(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"auto",children:a("cam.controls.auto")}),e.jsx("option",{value:"manual",children:a("cam.controls.manual")})]})]}),e.jsxs("label",{className:"block",children:[a("cam.controls.colorMode"),e.jsxs("select",{value:c.colorMode,onChange:t=>{y(r=>({...r,colorMode:t.target.value})),v(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"color",children:a("cam.controls.color")}),e.jsx("option",{value:"mono",children:a("cam.controls.mono")})]})]}),e.jsxs("label",{className:"block",children:[a("cam.controls.whiteBalance"),e.jsxs("select",{value:c.whiteBalanceMode,onChange:t=>{y(r=>({...r,whiteBalanceMode:t.target.value})),v(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"auto",children:a("cam.controls.auto")}),e.jsx("option",{value:"manual",children:a("cam.controls.manual")}),e.jsx("option",{value:"night",children:a("cam.controls.night")})]})]}),e.jsx(M,{label:"R Gain",value:c.whiteBalanceGainR,min:.1,max:3,step:.1,disabled:!ze,onChange:t=>{y(r=>({...r,whiteBalanceGainR:Number(t.toFixed(1))})),v(!0)}}),e.jsx(M,{label:"B Gain",value:c.whiteBalanceGainB,min:.1,max:3,step:.1,disabled:!ze,onChange:t=>{y(r=>({...r,whiteBalanceGainB:Number(t.toFixed(1))})),v(!0)}})]}),!ze&&e.jsx("p",{className:"mt-2 text-[11px] text-on-surface-variant",children:a("cam.controls.lockedByWb")}),e.jsx("div",{className:"mt-2",children:e.jsx("button",{type:"button",onClick:()=>void ta(),className:"w-full rounded border border-outline-variant/40 px-2 py-1.5",children:a("cam.controls.applyMode")})})]})]})]})]}),(_e||V)&&e.jsxs("div",{className:"fixed bottom-4 right-4 z-40 max-w-md space-y-2 text-xs",children:[_e&&e.jsx("div",{className:"rounded border border-error/40 bg-error-container/20 px-3 py-2 text-on-error-container",children:_e}),V&&e.jsx("div",{className:"rounded border border-primary/30 bg-primary/10 px-3 py-2 text-on-surface",children:V})]})]})}ya.createRoot(document.getElementById("root")).render(e.jsx(wa.StrictMode,{children:e.jsx(Sa,{children:e.jsx(Ra,{children:e.jsx(za,{})})})})); diff --git a/web/static/analysis-lab/assets/camera-hzdNY7z2.js b/web/static/analysis-lab/assets/camera-hzdNY7z2.js new file mode 100644 index 0000000..9d54da9 --- /dev/null +++ b/web/static/analysis-lab/assets/camera-hzdNY7z2.js @@ -0,0 +1,61 @@ +import{r as s,j as e,a as Ft,R as $t}from"./client-D1ZVDB-N.js";import{c as _,u as Bt,T as Pt,I as Tt}from"./index-C78KOEFu.js";import{u as It,S as Ot,C as Ua,r as Dt,b as At}from"./http-VZMNcsmS.js";/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const La=_("Circle",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ht=_("Download",[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["polyline",{points:"7 10 12 15 17 10",key:"2ggqvy"}],["line",{x1:"12",x2:"12",y1:"15",y2:"3",key:"1vk2je"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const zt=_("FileText",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Gt=_("FolderOpen",[["path",{d:"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2",key:"usdka0"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const qt=_("Info",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 16v-4",key:"1dtifu"}],["path",{d:"M12 8h.01",key:"e9boi3"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ut=_("Moon",[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Wa=_("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Lt=_("Settings2",[["path",{d:"M20 7h-9",key:"3s1dr2"}],["path",{d:"M14 17H5",key:"gfn3mx"}],["circle",{cx:"17",cy:"17",r:"3",key:"18b49y"}],["circle",{cx:"7",cy:"7",r:"3",key:"dfmy0x"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Wt=_("SlidersHorizontal",[["line",{x1:"21",x2:"14",y1:"4",y2:"4",key:"obuewd"}],["line",{x1:"10",x2:"3",y1:"4",y2:"4",key:"1q6298"}],["line",{x1:"21",x2:"12",y1:"12",y2:"12",key:"1iu8h1"}],["line",{x1:"8",x2:"3",y1:"12",y2:"12",key:"ntss68"}],["line",{x1:"21",x2:"16",y1:"20",y2:"20",key:"14d8ph"}],["line",{x1:"12",x2:"3",y1:"20",y2:"20",key:"m0wm8r"}],["line",{x1:"14",x2:"14",y1:"2",y2:"6",key:"14e1ph"}],["line",{x1:"8",x2:"8",y1:"10",y2:"14",key:"1i6ji0"}],["line",{x1:"16",x2:"16",y1:"18",y2:"22",key:"1lctlv"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Vt=_("Square",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Jt=_("Sun",[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Kt=_("X",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]),Xt="/api/dev/debug";function L(t){return`${Xt}${t}`}async function d(t,b={}){const v=t.startsWith("/api/debug/")?t.replace("/api/debug/","/api/dev/debug/"):t;return Dt(v,b)}const Zt=["640x360","1280x720","1600x900","1920x1020"],Qt=[0,90,180,270],ke=12;function j(t,b,v){return Math.min(v,Math.max(b,t))}function w(t,b){return t==null||Number.isNaN(Number(t))?b:Number(t)}function Va(t){if(!t)return"0 B";const b=["B","KB","MB","GB"];let v=0,g=t;for(;g>=1024&&v0&&g>=v?"busy":"ok"}catch{return"fail"}}function $({label:t,value:b,min:v,max:g,step:n,onChange:_e,disabled:u=!1,unit:W=""}){return e.jsxs("label",{className:`block ${u?"opacity-50":""}`,children:[e.jsxs("div",{className:"mb-1 flex items-center justify-between",children:[e.jsx("span",{children:t}),e.jsxs("span",{className:"font-mono text-[11px]",children:[b.toFixed(n>=1?0:n>=.1?1:2),W]})]}),e.jsx("input",{type:"range",min:v,max:g,step:n,value:b,disabled:u,onChange:te=>_e(Number(te.target.value)),className:"w-full accent-primary"})]})}function Yt(){var Ra,Ea,Ma,Fa,$a,Ba,Pa,Ta,Ia,Oa,Da,Aa,Ha;const{t,locale:b,setLocale:v}=Bt(),{info:g}=It(),[n,_e]=s.useState(null),[u,W]=s.useState(!1),[te,Ce]=s.useState(()=>Date.now()),[Re,i]=s.useState(null),[re,H]=s.useState(null),[We,z]=s.useState(!1),[V,p]=s.useState(null),[M,ne]=s.useState(!1),[Ee,Ve]=s.useState(!1),[Je,Ke]=s.useState(!1),[Xe,Ze]=s.useState("8"),[Qe,Ye]=s.useState("8"),[ea,aa]=s.useState("1280x720"),[ta,ra]=s.useState("supersample"),[J,G]=s.useState(!1),[na,Ka]=s.useState(!0),[Me,Xa]=s.useState(!0),[Fe,Za]=s.useState(!1),[sa,Qa]=s.useState(!1),[oa,Ya]=s.useState(!1),[$e,et]=s.useState({mean:0,std:0,over:0}),[at,se]=s.useState(0),[o,Be]=s.useState(null),[ia,ca]=s.useState(0),[la,da]=s.useState(180),[oe,ua]=s.useState(!1),[ie,ma]=s.useState(!1),[c,y]=s.useState({exposure:5e3,gain:1,digitalGain:1,autoExposure:!0,contrast:1,brightness:0,saturation:1,sharpness:1,noiseReduction:0,noiseReductionMode:"fast",aeFlickerMode:"off",autoExposureMaxUs:2e6,whiteBalanceMode:"auto",whiteBalanceGainR:1,whiteBalanceGainB:1,colorMode:"color"}),[ce,f]=s.useState(!1),[Pe,pa]=s.useState(""),[xa,ha]=s.useState(""),[fa,tt]=s.useState([]),[Te,B]=s.useState(!1),[K,rt]=s.useState([]),[ba,ga]=s.useState(!1),[N,Ie]=s.useState(null),[nt,Oe]=s.useState(!1),[le,De]=s.useState(null),[Ae,de]=s.useState(1),C=s.useRef(null),ue=s.useRef(null),X=s.useRef(null),me=s.useRef(null),pe=s.useRef(null),P=s.useRef(null),xe=s.useRef(null),D=s.useRef(!1),he=s.useRef(null),T=s.useRef([]),q=s.useRef(0),He=s.useRef(null),va=()=>{T.current=[],q.current=0,he.current=null,se(0)},S=async()=>{try{const a=await d("/api/debug/camera/status",{cache:"no-store"});_e(a),a.streaming||(W(!1),D.current=!1)}catch(a){i(a instanceof Error?a.message:String(a))}},Z=()=>{xe.current!=null&&(window.clearTimeout(xe.current),xe.current=null)},ya=async()=>{if(!M){ne(!0),i(null),H(null),z(!1);try{Z(),n!=null&&n.streaming||await d("/api/debug/camera/start",{method:"POST"});const a=Date.now(),r=await Ja();if(r==="busy"){H(t("cam.err.streamBusy")),z(!0);return}if(r==="fail"){H(t("cam.err.streamProbeFailed")),z(!1);return}W(!0),D.current=!0,p(t("cam.notice.previewStart")),va(),he.current=performance.now(),Ce(a),await S()}catch(a){i(a instanceof Error?a.message:String(a))}finally{ne(!1)}}},st=async()=>{if(!M){ne(!0),i(null);try{Z(),W(!1),D.current=!1,H(null),z(!1),va(),C.current&&(C.current.onload=null,C.current.onerror=null,C.current.src="",C.current.removeAttribute("src")),Ce(Date.now()),p(t("cam.notice.previewStop"))}catch(a){i(a instanceof Error?a.message:String(a))}finally{ne(!1)}}},ot=async()=>{if(!(!u&&!(n!=null&&n.streaming))){Ke(!0),i(null);try{const a=await d("/api/debug/camera/capture",{method:"POST"});p(t("cam.notice.captureSaved",{name:a.filename||"capture"}))}catch(a){i(a instanceof Error?a.message:String(a))}finally{Ke(!1)}}},it=async()=>{if(!Ee){Ve(!0),i(null);try{if(n!=null&&n.recording)await d("/api/debug/camera/record/stop",{method:"POST"}),p(t("cam.notice.recordStop"));else{const a=await d("/api/debug/camera/record/start",{method:"POST"});p(t("cam.notice.recordStart",{name:a.filename||"video.avi"}))}await S()}catch(a){i(a instanceof Error?a.message:String(a))}finally{Ve(!1)}}},ct=async()=>{i(null);try{const a=j(parseInt(Xe,10)||5,1,60);await d(`/api/debug/camera/fps?fps=${a}`,{method:"POST"});const r=j(parseInt(Qe,10)||8,1,30);await d(`/api/debug/camera/preview-fps?fps=${r}`,{method:"POST"});const[m,x]=ea.split("x").map(l=>parseInt(l,10));m&&x&&await d(`/api/debug/camera/size?width=${m}&height=${x}`,{method:"POST"}),await d(`/api/debug/camera/sampling?mode=${encodeURIComponent(ta)}`,{method:"POST"}),p(t("cam.notice.runtimeApplied")),G(!1),await S()}catch(a){i(a instanceof Error?a.message:String(a))}},lt=async()=>{i(null);try{await d("/api/debug/camera/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)}),p(t("cam.notice.settingsApplied")),f(!1),await S()}catch(a){i(a instanceof Error?a.message:String(a))}},dt=async()=>{i(null);try{await d("/api/debug/camera/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)}),p(t("cam.notice.modeApplied")),f(!1),await S()}catch(a){i(a instanceof Error?a.message:String(a))}},ut=(a,r)=>{if(!a)return;const m=(r==null?void 0:r.syncRuntime)??!0;y({exposure:j(Math.round(w(a.exposure_us,5e3)),100,12e4),gain:j(w(a.analogue_gain,1),1,24),digitalGain:j(w(a.digital_gain,1),1,8),autoExposure:!!(a.auto_exposure??!0),contrast:j(w(a.contrast,1),0,2),brightness:j(w(a.brightness,0),-1,1),saturation:j(w(a.saturation,1),0,2),sharpness:j(w(a.sharpness,1),0,2),noiseReduction:j(Math.round(w(a.noise_reduction,0)),0,4),noiseReductionMode:String(a.noise_reduction_mode??"fast"),aeFlickerMode:String(a.ae_flicker_mode??"off"),autoExposureMaxUs:j(Math.round(w(a.auto_exposure_max_us,2e6)),1e4,1e7),whiteBalanceMode:String(a.white_balance_mode??"auto"),whiteBalanceGainR:j(w(a.white_balance_gain_r,1),.1,3),whiteBalanceGainB:j(w(a.white_balance_gain_b,1),.1,3),colorMode:String(a.color_mode??"color")}),m&&(Ze(String(Math.round(w(a.fps,8)))),aa(`${Math.round(w(a.width,1280))}x${Math.round(w(a.height,720))}`),ra(String(a.sampling_mode??"supersample")),G(!1)),da(j(Math.round(w(a.rotation,180)),0,270)),ua(!!a.flip_horizontal),ma(!!a.flip_vertical),f(!1)},ja=async(a,r)=>{i(null);try{await d("/api/debug/camera/mirror",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({flip_horizontal:a,flip_vertical:r})}),ua(a),ma(r),p(t("cam.notice.mirrorApplied")),await S()}catch(m){i(m instanceof Error?m.message:String(m))}},mt=async a=>{i(null);try{await d(`/api/debug/camera/rotation/${a}`,{method:"POST"}),da(a),p(t("cam.notice.rotationApplied",{value:a})),await S()}catch(r){i(r instanceof Error?r.message:String(r))}},wa=async a=>{i(null);try{await d(`/api/debug/camera/night-mode?enabled=${a?"true":"false"}`,{method:"POST"}),p(t(a?"cam.notice.nightOn":"cam.notice.nightOff")),await S()}catch(r){i(r instanceof Error?r.message:String(r))}},pt=async()=>{i(null);try{await d("/api/debug/camera/reset",{method:"POST"}),p(t("cam.notice.reset")),await S()}catch(a){i(a instanceof Error?a.message:String(a))}},xt=async()=>{i(null);try{await d("/api/debug/camera/backup-settings",{method:"POST"}),p(t("cam.notice.backup"))}catch(a){i(a instanceof Error?a.message:String(a))}},ht=async()=>{i(null);try{await d("/api/debug/camera/restore-settings",{method:"POST"}),p(t("cam.notice.restore")),await S()}catch(a){i(a instanceof Error?a.message:String(a))}},fe=async()=>{B(!0);try{const a=await d("/api/debug/camera/presets",{cache:"no-store"});tt(a.presets??[])}catch(a){i(a instanceof Error?a.message:String(a))}finally{B(!1)}},ft=async()=>{const a=Pe.trim();if(a){B(!0),i(null);try{await d("/api/debug/camera/presets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:a,description:xa.trim(),exposure_us:c.exposure,analogue_gain:c.gain,digital_gain:c.digitalGain,auto_exposure:c.autoExposure,contrast:c.contrast,brightness:c.brightness,saturation:c.saturation,sharpness:c.sharpness,noise_reduction:c.noiseReduction,white_balance_mode:c.whiteBalanceMode,white_balance_gain_r:c.whiteBalanceGainR,white_balance_gain_b:c.whiteBalanceGainB,rotation:la,flip_horizontal:oe,flip_vertical:ie,color_mode:c.colorMode})}),p(t("cam.notice.presetSaved",{name:a})),pa(""),ha(""),await fe()}catch(r){i(r instanceof Error?r.message:String(r))}finally{B(!1)}}},bt=async a=>{B(!0),i(null);try{await d(`/api/debug/camera/presets/${encodeURIComponent(a)}/apply`,{method:"POST"}),p(t("cam.notice.presetApplied",{name:a})),await S(),await fe()}catch(r){i(r instanceof Error?r.message:String(r))}finally{B(!1)}},gt=async a=>{if(window.confirm(t("cam.confirm.deletePreset",{name:a}))){B(!0),i(null);try{await d(`/api/debug/camera/presets/${encodeURIComponent(a)}`,{method:"DELETE"}),p(t("cam.notice.presetDeleted",{name:a})),await fe()}catch(r){i(r instanceof Error?r.message:String(r))}finally{B(!1)}}},ze=async()=>{ga(!0);try{const a=await d("/api/debug/files",{cache:"no-store"});rt(a.files??[]),de(1)}catch(a){i(a instanceof Error?a.message:String(a))}finally{ga(!1)}},Ge=()=>{De(null),Ie(null),Oe(!1)},vt=async a=>{if(le===a){Ge();return}De(a),Ie(null),Oe(!0),i(null);try{const r=await d(`/api/debug/files/${encodeURIComponent(a)}/info`,{cache:"no-store"});Ie(r)}catch(r){i(r instanceof Error?r.message:String(r)),De(null)}finally{Oe(!1)}},yt=a=>{const r=(x,l)=>{const h=document.createElement("a");h.href=l,h.download=x,document.body.appendChild(h),h.click(),document.body.removeChild(h)};r(a,`${L("/files")}/${encodeURIComponent(a)}`);const m=a.match(/\.(jpe?g|png|bmp|tiff?|webp|mp4|avi|mov|mkv|wmv|flv|webm|m4v)$/i);if(m){const l=`${a.slice(0,-m[0].length)}.txt`;(async()=>{try{if(!(await fetch(`${L("/files")}/${encodeURIComponent(l)}`)).ok)return;r(l,`${L("/files")}/${encodeURIComponent(l)}`),p(t("cam.notice.downloadWithSidecar",{name:a,sidecar:l}))}catch{p(t("cam.notice.download",{name:a}))}})();return}p(t("cam.notice.download",{name:a}))},jt=async a=>{if(window.confirm(t("cam.confirm.deleteFile",{name:a}))){i(null);try{await d(`/api/debug/files/${encodeURIComponent(a)}`,{method:"DELETE"}),p(t("cam.notice.fileDeleted",{name:a})),(le===a||(N==null?void 0:N.filename)===a)&&Ge(),await ze()}catch(r){i(r instanceof Error?r.message:String(r))}}},wt=()=>{if(!na||!C.current||!ue.current)return;const a=C.current;if(!a.naturalWidth||!a.naturalHeight||(X.current||(X.current=document.createElement("canvas"),me.current=X.current.getContext("2d",{willReadFrequently:!0})),pe.current||(pe.current=ue.current.getContext("2d")),!me.current||!pe.current))return;const m=Math.min(1,320/a.naturalWidth),x=Math.max(1,Math.round(a.naturalWidth*m)),l=Math.max(1,Math.round(a.naturalHeight*m));X.current.width=x,X.current.height=l,me.current.drawImage(a,0,0,x,l);const h=me.current.getImageData(0,0,x,l).data,F=new Array(256).fill(0),k=new Array(256).fill(0),Q=new Array(256).fill(0),Ue=new Array(256).fill(0);let za=0,Ga=0,qa=0;const U=x*l;for(let E=0;E=250&&(qa+=1)}const Le=U?za/U:0,Et=U?Ga/U-Le*Le:0;et({mean:Le,std:Math.sqrt(Math.max(0,Et)),over:U?qa/U*100:0});const je=ue.current,R=pe.current,we=window.devicePixelRatio||1,Y=Math.max(1,je.clientWidth||240),ee=Math.max(1,je.clientHeight||120);je.width=Math.floor(Y*we),je.height=Math.floor(ee*we),R.setTransform(we,0,0,we,0,0),R.clearRect(0,0,Y,ee);const Mt=Math.max(1,...Me?[Math.max(...F),Math.max(...k),Math.max(...Q)]:[0],...Fe?[Math.max(...Ue)]:[0]),Ne=(E,Se)=>{R.beginPath(),R.strokeStyle=Se,R.lineWidth=1.2;for(let I=0;I<256;I+=1){const ae=I/255*Y,O=ee-E[I]/Mt*ee;I===0?R.moveTo(ae,O):R.lineTo(ae,O)}R.stroke()};if(Me&&(Ne(F,"rgba(255,80,80,0.85)"),Ne(k,"rgba(80,255,80,0.85)"),Ne(Q,"rgba(80,160,255,0.85)")),Fe&&Ne(Ue,"rgba(255,255,255,0.95)"),sa){const E=.9803921568627451*Y;R.fillStyle="rgba(255,100,100,0.12)",R.fillRect(E,0,Y-E,ee)}};s.useEffect(()=>{S(),fe(),ze();const a=window.setInterval(()=>{document.hidden||S()},2e3);return()=>window.clearInterval(a)},[n==null?void 0:n.streaming]),s.useEffect(()=>{!(n!=null&&n.info)||ce||ut(n.info,{syncRuntime:!J})},[n==null?void 0:n.info,ce,J]),s.useEffect(()=>{if(!(n!=null&&n.recording)){P.current&&(window.clearInterval(P.current),P.current=null),ca(0);return}if(P.current)return;const a=Date.now();return P.current=window.setInterval(()=>{ca(Math.max(0,Math.floor((Date.now()-a)/1e3)))},1e3),()=>{P.current&&(window.clearInterval(P.current),P.current=null)}},[n==null?void 0:n.recording]),s.useEffect(()=>{D.current=u,u||Z()},[u]),s.useEffect(()=>{u&&(T.current=[],q.current=0)},[te,u]),s.useEffect(()=>{if(!u){T.current=[],q.current=0,se(0);return}if(!He.current){const l=document.createElement("canvas");l.width=32,l.height=32,He.current=l}const r=He.current.getContext("2d",{willReadFrequently:!0});if(!r)return;let m=0;const x=()=>{const l=C.current;if(l!=null&&l.complete&&l.naturalWidth>0)try{r.drawImage(l,0,0,32,32);const h=r.getImageData(0,0,32,32).data;let F=2166136261;for(let k=0;kwindow.cancelAnimationFrame(m)},[u]),s.useEffect(()=>{if(!u){se(0);return}const a=window.setInterval(()=>{const m=performance.now()-1e3,x=T.current;for(;x.length&&x[0]window.clearInterval(a)},[u]),s.useEffect(()=>{if(!u){Be(null);return}let a=!1;const r=async()=>{try{const x=await fetch(`${L("/camera/stream/status")}`,{cache:"no-store",credentials:"same-origin"});if(!x.ok||a)return;const l=await x.json();if(!a){Be(l);const h=Number(l.preview_target_fps??l.target_preview_fps??0);Number.isFinite(h)&&h>0&&!J&&Ye(String(Math.round(h)))}}catch{a||Be(null)}};r();const m=window.setInterval(r,2e3);return()=>{a=!0,window.clearInterval(m)}},[u,J]),s.useEffect(()=>{if(!V)return;const a=window.setTimeout(()=>p(null),3200);return()=>window.clearTimeout(a)},[V]),s.useEffect(()=>()=>Z(),[]),s.useEffect(()=>{const a=Math.max(1,Math.ceil(K.length/ke));Ae>a&&de(a)},[K.length,Ae]);const Nt=u?`${L("/camera/stream")}?t=${te}`:"",be=c.autoExposure,qe=c.whiteBalanceMode==="manual",A=((Ra=n==null?void 0:n.info)==null?void 0:Ra.capabilities)??{},St=Array.isArray(A.awb_modes)&&A.awb_modes.length>0?A.awb_modes:["auto","daylight","cloudy","tungsten","fluorescent","indoor","manual","night"],kt=Array.isArray(A.noise_reduction_modes)&&A.noise_reduction_modes.length>0?A.noise_reduction_modes:["off","fast","high_quality"],Na=A.manual_digital_gain!==!1,Sa=!!((Ea=n==null?void 0:n.info)!=null&&Ea.night_mode),ka=u,ge=!M&&!u&&!(n!=null&&n.recording),_a=!M&&u,_t=!M&&!Je&&ka,Ct=!M&&!Ee&&ka,ve=Math.max(1,Math.ceil(K.length/ke)),ye=Math.min(Ae,ve),Ca=(ye-1)*ke,Rt=K.slice(Ca,Ca+ke);return e.jsxs("div",{className:"min-h-screen bg-background text-on-surface",children:[e.jsx("header",{className:"sticky top-0 z-30 border-b border-outline-variant/20 bg-surface-container-low/90 px-4 py-3 backdrop-blur",children:e.jsxs("div",{className:"flex w-full items-center justify-between gap-4",children:[e.jsx("div",{children:e.jsx("h1",{className:"font-headline text-xl font-bold text-primary",children:`OGScope ${t("cam.title")}`})}),e.jsxs("div",{className:"flex items-center gap-2 text-xs",children:[e.jsx("button",{type:"button",className:`inline-flex h-7 items-center rounded px-2 py-1 ${b==="zh"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>v("zh"),children:t("lang.zh")}),e.jsx("button",{type:"button",className:`inline-flex h-7 items-center rounded px-2 py-1 ${b==="en"?"bg-primary-container text-on-primary-container":"text-on-surface-variant"}`,onClick:()=>v("en"),children:t("lang.en")}),e.jsxs("a",{href:"/debug",className:"inline-flex h-7 items-center gap-1 rounded border border-outline-variant/30 px-2 py-1 hover:bg-surface-container",children:[e.jsx(Lt,{className:"h-3.5 w-3.5"})," ",t("cam.btn.system")]})]})]})}),e.jsxs("main",{className:"mx-auto grid max-w-[1880px] grid-cols-12 gap-4 p-4",children:[e.jsxs("aside",{className:"order-3 col-span-12 space-y-4 xl:order-1 xl:col-span-2",children:[e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("div",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:t("cam.controls.tools")}),e.jsx("div",{className:"mb-2 flex flex-wrap gap-1 text-xs",children:Qt.map(a=>e.jsxs("button",{type:"button",onClick:()=>void mt(a),className:`rounded border px-2 py-1 ${la===a?"border-primary text-primary":"border-outline-variant/30"}`,children:[a,"°"]},a))}),e.jsx("div",{className:"mb-2 text-[11px] text-on-surface-variant",children:t("cam.mirror.hint")}),e.jsxs("div",{className:"mb-2 flex flex-wrap gap-1 text-xs",children:[e.jsx("button",{type:"button",onClick:()=>void ja(!oe,ie),className:`rounded border px-2 py-1 ${oe?"border-primary text-primary":"border-outline-variant/30"}`,children:t("cam.mirror.horizontal")}),e.jsx("button",{type:"button",onClick:()=>void ja(oe,!ie),className:`rounded border px-2 py-1 ${ie?"border-primary text-primary":"border-outline-variant/30"}`,children:t("cam.mirror.vertical")})]}),e.jsxs("div",{className:"flex flex-wrap gap-2 text-xs",children:[e.jsxs("button",{type:"button",onClick:()=>void xt(),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Ot,{className:"mr-1 inline h-3.5 w-3.5"}),t("cam.controls.backup")]}),e.jsxs("button",{type:"button",onClick:()=>void ht(),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Gt,{className:"mr-1 inline h-3.5 w-3.5"}),t("cam.controls.restore")]}),e.jsx("button",{type:"button",onClick:()=>void pt(),className:"rounded border border-outline-variant/40 px-2 py-1",children:t("cam.controls.reset")})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4 text-xs",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:t("cam.quick.title")}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx("span",{className:"text-[11px] text-on-surface-variant",children:t("cam.quick.nightHint")}),e.jsxs("button",{type:"button",onClick:()=>void wa(!0),className:`rounded border px-2 py-1 ${Sa?"border-primary/70 text-primary":"border-outline-variant/40"}`,children:[e.jsx(Ut,{className:"mr-1 inline h-3.5 w-3.5"}),t("cam.controls.nightOn")]}),e.jsxs("button",{type:"button",onClick:()=>void wa(!1),className:`rounded border px-2 py-1 ${Sa?"border-error/60 text-error":"border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Jt,{className:"mr-1 inline h-3.5 w-3.5"}),t("cam.controls.nightOff")]})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4 text-xs",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:t("cam.presets.title")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2",children:[e.jsx("input",{value:Pe,onChange:a=>pa(a.target.value),placeholder:t("cam.presets.name"),className:"rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"}),e.jsx("input",{value:xa,onChange:a=>ha(a.target.value),placeholder:t("cam.presets.desc"),className:"rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"})]}),e.jsx("div",{className:"mt-2",children:e.jsx("button",{type:"button",disabled:Te||!Pe.trim(),onClick:()=>void ft(),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:t("cam.presets.save")})}),e.jsxs("div",{className:"mt-3 max-h-48 space-y-2 overflow-auto",children:[fa.length===0&&e.jsx("div",{className:"text-on-surface-variant",children:t("cam.presets.empty")}),fa.map(a=>e.jsxs("div",{className:"rounded border border-outline-variant/20 p-2",children:[e.jsx("div",{className:"font-semibold",children:a.name}),e.jsx("div",{className:"text-on-surface-variant",children:a.description||t("cam.presets.noDesc")}),e.jsxs("div",{className:"mt-1 text-on-surface-variant",children:[t("cam.controls.exposure"),": ",a.exposure_us,"us | ",t("cam.controls.gain"),": ",a.analogue_gain]}),e.jsxs("div",{className:"mt-2 flex gap-2",children:[e.jsx("button",{type:"button",disabled:Te,onClick:()=>void bt(a.name),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:t("cam.presets.apply")}),e.jsx("button",{type:"button",disabled:Te,onClick:()=>void gt(a.name),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:t("cam.presets.delete")})]})]},a.name))]})]})]}),e.jsxs("section",{className:"order-1 col-span-12 grid grid-cols-12 items-start gap-4 xl:order-2 xl:col-span-10",children:[e.jsxs("div",{className:"col-span-12 space-y-4 xl:col-span-9",children:[e.jsxs("section",{className:"self-start rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"mb-2 grid grid-cols-3 gap-2 text-xs",children:[e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1",children:["CPU: ",e.jsxs("span",{className:"font-mono",children:[Number((g==null?void 0:g.cpu_usage)??0).toFixed(1),"%"]})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1",children:["MEM: ",e.jsxs("span",{className:"font-mono",children:[Number((g==null?void 0:g.memory_usage)??0).toFixed(1),"%"]})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1",children:["TEMP: ",e.jsxs("span",{className:"font-mono",children:[Number((g==null?void 0:g.temperature)??0).toFixed(1),"°C"]})]})]}),e.jsxs("div",{className:"mb-2 flex flex-wrap items-start justify-between gap-2",children:[e.jsxs("div",{children:[e.jsx("h2",{className:"text-sm font-semibold uppercase tracking-wider",children:t("cam.preview.title")}),e.jsx("p",{className:"mt-1 max-w-2xl text-[11px] leading-snug text-on-surface-variant",children:t("cam.hint.mjpegSingleStream")})]}),e.jsxs("div",{className:"shrink-0 font-mono text-xs text-on-surface-variant",children:[t("cam.preview.state"),": ",t(u?"cam.state.streaming":"cam.state.idle")]})]}),re&&!u&&e.jsxs("div",{className:"mb-2 rounded-lg border border-error/35 bg-error-container/15 px-3 py-2 text-left",role:"alert",children:[e.jsx("p",{className:"text-sm font-medium text-error",children:re}),We?e.jsx("p",{className:"mt-1 max-w-2xl text-[11px] leading-snug text-on-surface-variant",children:t("cam.err.streamBusyHint")}):e.jsx("p",{className:"mt-1 max-w-2xl text-[11px] leading-snug text-on-surface-variant",children:t("cam.err.streamProbeDetail")})]}),e.jsxs("div",{className:"relative aspect-video overflow-hidden rounded border border-outline-variant/20 bg-black",children:[u&&re&&e.jsxs("div",{className:"pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-black/80 px-4 text-center",role:"alert",children:[e.jsx("p",{className:"text-sm font-medium text-error",children:re}),We?e.jsx("p",{className:"max-w-md text-[11px] leading-snug text-on-surface-variant",children:t("cam.err.streamBusyHint")}):e.jsx("p",{className:"max-w-md text-[11px] leading-snug text-on-surface-variant",children:t("cam.err.streamProbeDetail")})]}),u?e.jsx("img",{ref:C,alt:"camera-preview",className:"h-full w-full object-contain",src:Nt,onLoad:()=>{H(null),z(!1),wt()},onError:()=>{var r;if(Z(),!D.current)return;const a=(r=C.current)==null?void 0:r.src;(async()=>{if(!D.current||!a)return;if(await Ja()==="busy"){H(t("cam.err.streamBusy")),z(!0);return}xe.current=window.setTimeout(()=>{D.current&&Ce(Date.now())},400)})()}}):e.jsxs("div",{className:"flex h-full w-full flex-col items-center justify-center gap-3 text-center text-on-surface-variant",children:[e.jsx(Ua,{className:"h-10 w-10 text-primary/80"}),e.jsx("div",{className:"text-sm",children:t("cam.preview.emptyTitle")}),e.jsx("div",{className:"text-xs",children:t("cam.preview.emptyDesc")}),e.jsxs("button",{type:"button",disabled:!ge,onClick:()=>void ya(),className:`rounded px-3 py-1.5 text-xs disabled:opacity-50 ${ge?"bg-primary-container text-on-primary-container":"border border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Wa,{className:"mr-1 inline h-3.5 w-3.5"}),t(M?"cam.btn.starting":"cam.btn.start")]})]}),(n==null?void 0:n.recording)&&e.jsxs("div",{className:"absolute right-3 top-3 flex items-center gap-2 rounded bg-black/60 px-2 py-1 text-xs text-error",children:[e.jsx(La,{className:"h-3.5 w-3.5 fill-current"})," ",t("cam.state.rec"),e.jsx("span",{className:"font-mono",children:`${Math.floor(ia/60).toString().padStart(2,"0")}:${(ia%60).toString().padStart(2,"0")}`})]}),e.jsx("div",{className:"absolute left-3 top-3",children:e.jsx("button",{type:"button",onClick:()=>Ya(a=>!a),className:"rounded border border-outline-variant/40 bg-black/70 px-2 py-1 text-xs text-white",children:t(oa?"cam.hist.expand":"cam.hist.collapse")})}),!oa&&e.jsxs("div",{className:"absolute left-3 top-12 w-[360px] max-w-[calc(100%-1.5rem)] rounded border border-outline-variant/30 bg-black/70 p-2 text-white",children:[e.jsxs("div",{className:"mb-2 flex flex-wrap items-center gap-3 text-[11px]",children:[e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:na,onChange:a=>Ka(a.target.checked)})," ",t("cam.hist.enabled")]}),e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:Me,onChange:a=>Xa(a.target.checked)})," RGB"]}),e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:Fe,onChange:a=>Za(a.target.checked)})," ",t("cam.hist.luminance")]}),e.jsxs("label",{className:"inline-flex items-center gap-1",children:[e.jsx("input",{type:"checkbox",checked:sa,onChange:a=>Qa(a.target.checked)})," ",t("cam.hist.over")]})]}),e.jsx("canvas",{ref:ue,className:"h-24 w-full rounded border border-white/20 bg-black/60"}),e.jsxs("div",{className:"mt-2 grid grid-cols-3 gap-2 text-[11px]",children:[e.jsxs("div",{children:["mean: ",e.jsx("span",{className:"font-mono",children:$e.mean.toFixed(1)})]}),e.jsxs("div",{children:["std: ",e.jsx("span",{className:"font-mono",children:$e.std.toFixed(1)})]}),e.jsxs("div",{children:["over: ",e.jsxs("span",{className:"font-mono",children:[$e.over.toFixed(2),"%"]})]})]})]})]}),e.jsxs("div",{className:"mt-3 grid grid-cols-1 gap-2 text-xs md:grid-cols-4",children:[e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.frameFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:Number((o==null?void 0:o.actual_preview_fps)??at).toFixed(2)}),e.jsx("p",{className:"mt-0.5 text-[10px] leading-tight text-on-surface-variant/90",children:t("cam.stats.fpsMeasureNote")})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.targetFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:Number(((Ma=n==null?void 0:n.info)==null?void 0:Ma.fps)??0).toFixed(2)})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.streamPacingFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:(o==null?void 0:o.preview_target_fps)??(o==null?void 0:o.target_preview_fps)??"—"}),e.jsx("p",{className:"mt-0.5 text-[10px] leading-tight text-on-surface-variant/90",children:t("cam.stats.streamPacingHint")})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.captureFps"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:Number((o==null?void 0:o.actual_capture_fps)??0).toFixed(2)})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.encodeMs"),": "]}),e.jsxs("span",{className:"font-mono text-on-surface",children:[Number((o==null?void 0:o.jpeg_average_encode_ms)??0).toFixed(1)," ms"]})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.encoder"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:String((o==null?void 0:o.preview_encoder)??"—")}),e.jsx("p",{className:"mt-0.5 text-[10px] leading-tight text-on-surface-variant/90",children:`${(o==null?void 0:o.jpeg_source_format)??"RGB888"} / fail ${(o==null?void 0:o.jpeg_encode_failures)??0}`})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.consumers"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:`${(o==null?void 0:o.preview_consumers)??0}/${(o==null?void 0:o.analysis_consumers)??0}/${(o==null?void 0:o.recording_consumers)??0}`})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.cameraMemory"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:`${Math.round(Number((o==null?void 0:o.process_rss_kb)??0)/1024)} / ${Math.round(Number((o==null?void 0:o.process_swap_kb)??0)/1024)} MB`})]}),(o==null?void 0:o.throttle_reason)==="auto_exposure_long"&&e.jsx("div",{className:"rounded border border-tertiary/40 bg-tertiary-container/20 px-2 py-1.5 text-left text-tertiary",children:t("cam.stats.longExposureThrottle",{exposure:Math.round(Number(o.actual_exposure_us??0)/1e3)})}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.uptime"),": "]}),e.jsx("span",{className:"font-mono text-on-surface",children:he.current!=null?`${Math.max(0,Math.round((performance.now()-he.current)/1e3))}s`:"0s"})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.system.sensor"),": "]}),e.jsx("span",{className:"font-mono",children:String(((Fa=n==null?void 0:n.info)==null?void 0:Fa.sensor)??"—")})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.driver"),": "]}),e.jsx("span",{className:"font-mono",children:String((($a=n==null?void 0:n.info)==null?void 0:$a.driver)??(o==null?void 0:o.camera_driver)??"—")}),e.jsx("p",{className:"mt-0.5 text-[10px] leading-tight text-on-surface-variant/90",children:`${((Ba=n==null?void 0:n.info)==null?void 0:Ba.backend)??(o==null?void 0:o.camera_backend)??"—"} · lores ${(Pa=n==null?void 0:n.info)!=null&&Pa.lores_available||o!=null&&o.lores_available?"on":"off"}`})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.stats.metadata"),": "]}),e.jsx("span",{className:"font-mono",children:((Ta=n==null?void 0:n.info)==null?void 0:Ta.lux)!=null?`${Number(n.info.lux).toFixed(1)} lux`:"—"}),e.jsx("p",{className:"mt-0.5 text-[10px] leading-tight text-on-surface-variant/90",children:((Ia=n==null?void 0:n.info)==null?void 0:Ia.colour_temperature)!=null?`${Math.round(Number(n.info.colour_temperature))}K`:"—"})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.controls.resolution"),": "]}),e.jsx("span",{className:"font-mono",children:`${((Oa=n==null?void 0:n.info)==null?void 0:Oa.width)??"—"}x${((Da=n==null?void 0:n.info)==null?void 0:Da.height)??"—"}`})]}),e.jsxs("div",{className:"rounded border border-outline-variant/20 bg-surface-container-low px-2 py-1.5 text-left",children:[e.jsxs("span",{className:"text-on-surface-variant",children:[t("cam.preview.mode"),": "]}),e.jsx("span",{className:"font-mono",children:(Aa=n==null?void 0:n.info)!=null&&Aa.auto_exposure?t("cam.controls.auto"):t("cam.controls.manual")})]})]}),e.jsxs("div",{className:"mt-3 flex flex-wrap gap-2",children:[e.jsxs("button",{type:"button",disabled:!ge,onClick:()=>void ya(),className:`rounded px-3 py-2 text-sm disabled:opacity-50 ${ge?"bg-primary-container text-on-primary-container":"border border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Wa,{className:"inline h-4 w-4"})," ",t(M?"cam.btn.starting":"cam.btn.start")]}),e.jsxs("button",{type:"button",disabled:!_a,onClick:()=>void st(),className:`rounded border px-3 py-2 text-sm disabled:opacity-50 ${_a?"border-error/60 text-error hover:bg-error/10":"border-outline-variant/40 text-on-surface-variant"}`,children:[e.jsx(Vt,{className:"inline h-4 w-4"})," ",t(M?"cam.btn.stopping":"cam.btn.stop")]}),e.jsxs("button",{type:"button",disabled:!_t,onClick:()=>void ot(),className:"rounded border border-outline-variant/40 px-3 py-2 text-sm disabled:opacity-50",children:[e.jsx(Ua,{className:"inline h-4 w-4"})," ",t(Je?"cam.btn.capturing":"cam.btn.capture")]}),e.jsxs("button",{type:"button",disabled:!Ct,onClick:()=>void it(),className:`rounded border px-3 py-2 text-sm disabled:opacity-50 ${n!=null&&n.recording?"border-error/60 bg-error/10 text-error":"border-outline-variant/40"}`,children:[e.jsx(La,{className:"inline h-4 w-4"})," ",Ee?t("cam.btn.recordBusy"):n!=null&&n.recording?t("cam.btn.recordStop"):t("cam.btn.recordStart")]})]})]}),e.jsxs("section",{className:"rounded-xl border border-outline-variant/20 bg-surface-container p-4 text-xs",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:t("cam.files.title")}),e.jsx("div",{className:"mb-2",children:e.jsx("button",{type:"button",disabled:ba,onClick:()=>void ze(),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:t("cam.files.refresh")})}),nt&&e.jsx("div",{className:"mb-2 text-on-surface-variant",children:t("cam.files.loadingInfo")}),N&&e.jsxs("div",{className:"mb-3 rounded border border-outline-variant/20 p-2",children:[e.jsxs("div",{className:"mb-2 flex items-center justify-between gap-2 font-semibold",children:[e.jsxs("div",{className:"flex min-w-0 items-center gap-1",children:[e.jsx(zt,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",children:N.filename})]}),e.jsx("button",{type:"button",onClick:()=>Ge(),className:"shrink-0 rounded border border-outline-variant/40 p-1 text-on-surface-variant hover:bg-surface-container","aria-label":t("cam.files.closeDetail"),children:e.jsx(Kt,{className:"h-3.5 w-3.5"})})]}),e.jsxs("div",{children:[t("cam.files.size"),": ",e.jsx("span",{className:"font-mono",children:Va(N.size)})]}),e.jsxs("div",{children:[t("cam.files.type"),": ",e.jsx("span",{className:"font-mono",children:N.type})]}),e.jsxs("div",{children:[t("cam.files.modified"),": ",e.jsx("span",{className:"font-mono",children:new Date(N.modified).toLocaleString()})]}),N.exposure_us!=null&&e.jsxs("div",{children:[t("cam.controls.exposure"),": ",e.jsxs("span",{className:"font-mono",children:[N.exposure_us,"us"]})]}),N.analogue_gain!=null&&e.jsxs("div",{children:[t("cam.controls.gain"),": ",e.jsx("span",{className:"font-mono",children:N.analogue_gain})]}),N.resolution&&e.jsxs("div",{children:[t("cam.controls.resolution"),": ",e.jsx("span",{className:"font-mono",children:N.resolution})]})]}),e.jsxs("div",{className:"max-h-96 space-y-2 overflow-auto",children:[K.length===0&&!ba&&e.jsx("div",{className:"text-on-surface-variant",children:t("cam.files.empty")}),Rt.map(a=>e.jsxs("div",{className:`rounded border p-2 ${le===a.name?"border-primary/50 bg-primary/5":"border-outline-variant/20"}`,children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("div",{className:"truncate font-semibold",children:a.name}),e.jsxs("div",{className:"text-on-surface-variant",children:[Va(a.size)," | ",new Date(a.modified).toLocaleString()]})]}),e.jsx("div",{className:"shrink-0 text-on-surface-variant",children:a.type})]}),e.jsxs("div",{className:"mt-2 flex gap-2",children:[e.jsxs("button",{type:"button",onClick:()=>yt(a.name),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Ht,{className:"mr-1 inline h-3.5 w-3.5"}),t("cam.files.download")]}),e.jsxs("button",{type:"button",onClick:()=>void vt(a.name),className:`rounded border px-2 py-1 ${le===a.name?"border-primary text-primary":"border-outline-variant/40"}`,children:[e.jsx(qt,{className:"mr-1 inline h-3.5 w-3.5"}),t("cam.files.info")]}),e.jsxs("button",{type:"button",onClick:()=>void jt(a.name),className:"rounded border border-outline-variant/40 px-2 py-1",children:[e.jsx(Pt,{className:"mr-1 inline h-3.5 w-3.5"}),t("cam.files.delete")]})]})]},a.name))]}),e.jsxs("div",{className:"mt-3 flex items-center justify-between",children:[e.jsx("button",{type:"button",disabled:ye<=1,onClick:()=>de(a=>Math.max(1,a-1)),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:t("cam.files.prev")}),e.jsx("div",{className:"text-on-surface-variant",children:t("cam.files.page",{current:ye,total:ve})}),e.jsx("button",{type:"button",disabled:ye>=ve,onClick:()=>de(a=>Math.min(ve,a+1)),className:"rounded border border-outline-variant/40 px-2 py-1 disabled:opacity-50",children:t("cam.files.next")})]})]})]}),e.jsxs("section",{className:"col-span-12 grid grid-cols-12 gap-4 xl:col-span-3 xl:self-start",children:[e.jsxs("section",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("h2",{className:"mb-3 text-sm font-semibold uppercase tracking-wider",children:t("cam.controls.title")}),e.jsxs("div",{className:"space-y-3 text-xs",children:[e.jsxs("div",{className:"grid grid-cols-2 gap-2",children:[e.jsxs("label",{className:"block",children:[t("cam.controls.sensorFps"),e.jsx("input",{value:Xe,onChange:a=>{Ze(a.target.value),G(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"})]}),e.jsxs("label",{className:"block",children:[t("cam.controls.previewFps"),e.jsx("input",{value:Qe,onChange:a=>{Ye(a.target.value),G(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"})]})]}),e.jsx("div",{className:"grid grid-cols-1 gap-2",children:e.jsxs("label",{className:"block",children:[t("cam.controls.sampling"),e.jsxs("select",{value:ta,onChange:a=>{ra(a.target.value),G(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"supersample",children:"supersample"}),e.jsx("option",{value:"native",children:"native"}),e.jsx("option",{value:"crop",children:"crop"})]})]})}),e.jsxs("label",{className:"block",children:[t("cam.controls.resolution"),e.jsx("div",{className:"mt-1 grid grid-cols-2 gap-1",children:Zt.map(a=>e.jsx("button",{type:"button",onClick:()=>{aa(a),G(!0)},className:`rounded border px-2 py-1 ${ea===a?"border-primary text-primary":"border-outline-variant/30"}`,children:a},a))})]}),e.jsx("button",{type:"button",disabled:!J,onClick:()=>void ct(),className:"w-full rounded border border-outline-variant/40 px-2 py-1.5 disabled:opacity-50",children:t("cam.controls.applyRuntime")})]})]}),e.jsxs("section",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsxs("div",{className:"mb-2 flex items-center gap-1 text-sm font-semibold uppercase tracking-wider",children:[e.jsx(Wt,{className:"h-3.5 w-3.5"})," ",t("cam.controls.core")]}),ce&&e.jsx("div",{className:"mb-2 rounded border border-primary/40 bg-primary/10 px-2 py-1 text-[11px] text-primary",children:t("cam.controls.pendingChanges")}),be&&e.jsx("p",{className:"mb-2 text-[11px] text-on-surface-variant",children:t("cam.controls.lockedByAe")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2 text-xs",children:[e.jsx($,{label:t("cam.controls.exposure"),value:c.exposure,min:100,max:12e4,step:100,unit:"us",disabled:be,onChange:a=>{f(!0),y(r=>({...r,exposure:a}))}}),e.jsx($,{label:t("cam.controls.gain"),value:c.gain,min:1,max:24,step:.1,disabled:be,onChange:a=>{f(!0),y(r=>({...r,gain:Number(a.toFixed(1))}))}}),e.jsx($,{label:t("cam.controls.digitalGain"),value:c.digitalGain,min:1,max:8,step:.1,disabled:be||!Na,onChange:a=>{f(!0),y(r=>({...r,digitalGain:Number(a.toFixed(1))}))}}),e.jsxs("label",{className:"block",children:[t("cam.controls.noiseReductionMode"),e.jsx("select",{value:c.noiseReductionMode,onChange:a=>{f(!0),y(r=>({...r,noiseReductionMode:a.target.value}))},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:kt.map(a=>e.jsx("option",{value:a,children:t(`cam.controls.nr.${a}`)},a))})]}),e.jsx($,{label:t("cam.controls.contrast"),value:c.contrast,min:0,max:2,step:.1,onChange:a=>{f(!0),y(r=>({...r,contrast:Number(a.toFixed(1))}))}}),e.jsx($,{label:t("cam.controls.brightness"),value:c.brightness,min:-1,max:1,step:.1,onChange:a=>{f(!0),y(r=>({...r,brightness:Number(a.toFixed(1))}))}}),e.jsx($,{label:t("cam.controls.saturation"),value:c.saturation,min:0,max:2,step:.1,onChange:a=>{f(!0),y(r=>({...r,saturation:Number(a.toFixed(1))}))}}),e.jsx($,{label:t("cam.controls.sharpness"),value:c.sharpness,min:0,max:2,step:.1,onChange:a=>{f(!0),y(r=>({...r,sharpness:Number(a.toFixed(1))}))}})]}),!Na&&e.jsxs("p",{className:"mt-2 text-[11px] text-on-surface-variant",children:[t("cam.controls.digitalGainReadOnly"),": ",e.jsx("span",{className:"font-mono",children:Number(((Ha=n==null?void 0:n.info)==null?void 0:Ha.actual_digital_gain)??c.digitalGain).toFixed(2)})]}),e.jsx("div",{className:"mt-2",children:e.jsx("button",{type:"button",disabled:!ce,onClick:()=>void lt(),className:"w-full rounded border border-outline-variant/40 px-2 py-1.5 disabled:opacity-50",children:t("cam.controls.applySettings")})})]}),e.jsxs("section",{className:"col-span-12 rounded-xl border border-outline-variant/20 bg-surface-container p-4",children:[e.jsx("div",{className:"mb-2 text-sm font-semibold uppercase tracking-wider",children:t("cam.controls.mode")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2 text-xs",children:[e.jsxs("label",{className:"block",children:[t("cam.controls.autoExposure"),e.jsxs("select",{value:c.autoExposure?"auto":"manual",onChange:a=>{y(r=>({...r,autoExposure:a.target.value==="auto"})),f(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"auto",children:t("cam.controls.auto")}),e.jsx("option",{value:"manual",children:t("cam.controls.manual")})]})]}),e.jsxs("label",{className:"block",children:[t("cam.controls.colorMode"),e.jsxs("select",{value:c.colorMode,onChange:a=>{y(r=>({...r,colorMode:a.target.value})),f(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"color",children:t("cam.controls.color")}),e.jsx("option",{value:"mono",children:t("cam.controls.mono")})]})]}),e.jsxs("label",{className:"block",children:[t("cam.controls.whiteBalance"),e.jsx("select",{value:c.whiteBalanceMode,onChange:a=>{y(r=>({...r,whiteBalanceMode:a.target.value})),f(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:St.map(a=>e.jsx("option",{value:a,children:t(`cam.controls.wb.${a}`)},a))})]}),e.jsxs("label",{className:"block",children:[t("cam.controls.aeFlicker"),e.jsxs("select",{value:c.aeFlickerMode,onChange:a=>{y(r=>({...r,aeFlickerMode:a.target.value})),f(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5",children:[e.jsx("option",{value:"off",children:t("cam.controls.off")}),e.jsx("option",{value:"50hz",children:"50 Hz"}),e.jsx("option",{value:"60hz",children:"60 Hz"})]})]}),e.jsxs("label",{className:"block",children:[t("cam.controls.maxAeFrame"),e.jsx("input",{type:"number",min:1e4,max:1e7,step:1e4,value:c.autoExposureMaxUs,onChange:a=>{y(r=>({...r,autoExposureMaxUs:j(parseInt(a.target.value,10)||2e6,1e4,1e7)})),f(!0)},className:"mt-1 w-full rounded border border-outline-variant/30 bg-surface-container-low px-2 py-1.5"})]}),e.jsx($,{label:"R Gain",value:c.whiteBalanceGainR,min:.1,max:3,step:.1,disabled:!qe,onChange:a=>{y(r=>({...r,whiteBalanceGainR:Number(a.toFixed(1))})),f(!0)}}),e.jsx($,{label:"B Gain",value:c.whiteBalanceGainB,min:.1,max:3,step:.1,disabled:!qe,onChange:a=>{y(r=>({...r,whiteBalanceGainB:Number(a.toFixed(1))})),f(!0)}})]}),!qe&&e.jsx("p",{className:"mt-2 text-[11px] text-on-surface-variant",children:t("cam.controls.lockedByWb")}),e.jsx("div",{className:"mt-2",children:e.jsx("button",{type:"button",onClick:()=>void dt(),className:"w-full rounded border border-outline-variant/40 px-2 py-1.5",children:t("cam.controls.applyMode")})})]})]})]})]}),(Re||V)&&e.jsxs("div",{className:"fixed bottom-4 right-4 z-40 max-w-md space-y-2 text-xs",children:[Re&&e.jsx("div",{className:"rounded border border-error/40 bg-error-container/20 px-3 py-2 text-on-error-container",children:Re}),V&&e.jsx("div",{className:"rounded border border-primary/30 bg-primary/10 px-3 py-2 text-on-surface",children:V})]})]})}Ft.createRoot(document.getElementById("root")).render(e.jsx($t.StrictMode,{children:e.jsx(Tt,{children:e.jsx(At,{children:e.jsx(Yt,{})})})})); diff --git a/web/static/analysis-lab/assets/http-B53ovOR5.js b/web/static/analysis-lab/assets/http-VZMNcsmS.js similarity index 96% rename from web/static/analysis-lab/assets/http-B53ovOR5.js rename to web/static/analysis-lab/assets/http-VZMNcsmS.js index 0182dd9..cb65497 100644 --- a/web/static/analysis-lab/assets/http-B53ovOR5.js +++ b/web/static/analysis-lab/assets/http-VZMNcsmS.js @@ -1,4 +1,4 @@ -import{c as u}from"./index-Cu-N6Gfx.js";import{r as o,j as f}from"./client-D1ZVDB-N.js";/** +import{c as u}from"./index-C78KOEFu.js";import{r as o,j as f}from"./client-D1ZVDB-N.js";/** * @license lucide-react v0.460.0 - ISC * * This source code is licensed under the ISC license. diff --git a/web/static/analysis-lab/assets/index-C78KOEFu.js b/web/static/analysis-lab/assets/index-C78KOEFu.js new file mode 100644 index 0000000..269abd1 --- /dev/null +++ b/web/static/analysis-lab/assets/index-C78KOEFu.js @@ -0,0 +1,26 @@ +import{r as a,j as v}from"./client-D1ZVDB-N.js";/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const f=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),u=(...e)=>e.filter((s,t,o)=>!!s&&s.trim()!==""&&o.indexOf(s)===t).join(" ").trim();/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var w={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const S=a.forwardRef(({color:e="currentColor",size:s=24,strokeWidth:t=2,absoluteStrokeWidth:o,className:l="",children:r,iconNode:m,...n},c)=>a.createElement("svg",{ref:c,...w,width:s,height:s,stroke:e,strokeWidth:o?Number(t)*24/Number(s):t,className:u("lucide",l),...n},[...m.map(([i,d])=>a.createElement(i,d)),...Array.isArray(r)?r:[r]]));/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const P=(e,s)=>{const t=a.forwardRef(({className:o,...l},r)=>a.createElement(S,{ref:r,iconNode:s,className:u(`lucide-${f(e)}`,o),...l}));return t.displayName=`${e}`,t};/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const A=P("Trash2",[["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6",key:"4alrt4"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2",key:"v07s0e"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17",key:"1uufr5"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17",key:"xtxkd"}]]),k={"app.title":"OGScope Plate Solve Console","nav.lab":"Lab","nav.labImage":"Image solve","nav.labVideo":"Video solve","delete.uploadCascade":"Delete {n} linked experiment record(s) as well?","lab.solveCurrentFrame":"Solve current frame (file)","lab.solveFileStart":"Start continuous file solve","lab.solveFileStop":"Stop continuous file solve","lab.cameraPreviewLoading":"Connecting to shared preview…","lab.solveCameraFrame":"Solve live camera frame","lab.solveCameraStart":"Start camera solve","lab.solveCameraStop":"Stop camera solve","lab.videoPreviewFailed":"This video format may be unsupported by the browser. Try MP4 (H.264) or WebM.","lab.previewModeFile":"Pool file","lab.previewModeCamera":"Device camera","lab.videoLiveIntro":"Shares the same camera as Camera Debug — preview and solve live frames here without opening debug. Both pages can run together.","lab.videoMjpegHint":"MJPEG slots are full: close live previews on other pages or retry later (default ~4; tune OGSCOPE_STREAM_MAX_MJPEG_CLIENTS in systemd).","lab.cameraStreamStatusFail":"Could not read MJPEG slot status. Please retry shortly.","lab.cameraSnapshotName":"ogscope_camera_live","lab.metric.probRaw":"Raw Prob","lab.systemLoad":"System load","results.saveBatchAll":"Save all to records","nav.pool":"Assets","nav.history":"Records","nav.cameraDebug":"Camera Debug","nav.home":"Home","nav.systemAdmin":"Back to System Admin","lang.zh":"中文","lang.en":"EN","sidebar.assets":"Uploaded assets","sidebar.upload":"Upload","sidebar.refresh":"Refresh","sidebar.debugCaptures":"Debug console media","sidebar.assetTypeImage":"Image","sidebar.assetTypeVideo":"Video","sidebar.debugEmpty":"No debug files","sidebar.importToPool":"Import to pool","sidebar.importToPoolWithTranscode":"Transcode & Import","sidebar.importToPoolDirect":"Import Directly","sidebar.flowPreparing":"Preparing...","sidebar.flowImportingDebug":"Importing debug capture...","sidebar.flowDownloadingPool":"Downloading imported file...","sidebar.flowWritingBuffer":"Writing to transcoder buffer...","sidebar.flowLoadingTranscoder":"Loading transcoder...","sidebar.flowTranscoding":"Transcoding AVI -> MP4...","sidebar.flowPackaging":"Packaging output...","sidebar.flowUploadingMp4":"Uploading MP4...","sidebar.flowReplacing":"Replacing and removing original AVI...","sidebar.flowNoTranscode":"No transcode required, imported directly.","sidebar.flowDone":"Completed","sidebar.flowDoneMsg":"Done: {name}","sidebar.flowFailed":"Failed","sidebar.debugPage":"Page {cur} / {total}","sidebar.batchPresets":"Batch presets","sidebar.batchHint":"Check presets, then use Batch solve to compare multiple param sets.","lab.selectOrUpload":"Pick an uploaded or imported asset from the left","lab.selectOrUploadVideo":"Pick a pool video to preview, or use the button above for the live camera frame.","lab.file":"File","lab.source":"Source","lab.layers":"Layers","lab.layer.matched":"Matched","lab.layer.pattern":"Pattern","lab.layer.all":"All centroids","lab.layer.rejected":"Rejected (red cross)","lab.previewConfidence":"Confidence","lab.grid":"Grid","lab.zoomIn":"Zoom in","lab.zoomOut":"Zoom out","lab.zoomReset":"Reset","lab.resolution":"Resolution","lab.fwhm":"FWHM","lab.starsDetected":"Stars detected","lab.meta.title":"Capture & file info","lab.meta.noSidecar":"No sidecar (not from debug capture)","lab.meta.partial":"No detailed sidecar; file info only.","lab.solveSection":"Solve","lab.imageSection":"Image","lab.metric.solveMs":"Time","lab.metric.solveComputeMs":"Solve compute","lab.metric.solveComputeHelp":"Server-side Tetra3 + star extraction only (no network).","lab.metric.solveRoundTripMs":"End-to-end","lab.metric.solveRoundTripHelp":"From request start to UI updated: network + JSON + render.","lab.metric.backendTotalMs":"Backend total","lab.metric.openDecodeMs":"Open/decode","lab.metric.preprocessMs":"Preprocess","lab.metric.extractMs":"Extract","lab.metric.solveOnlyMs":"Solve match","lab.metric.probHelp":"Tetra3 `Prob` is false-positive probability (lower is better). We use (1−Prob)×100% when representable; for extremely small Prob, IEEE doubles round to 100%, so we map −log10(Prob) to ~70–100% for differentiation.","lab.metric.probRawHelp":"Raw Tetra3 Prob (e.g. log-likelihood); compare with the normalized line above.","lab.metric.radec":"RA / Dec","lab.metric.matches":"Matches","lab.metric.rmse":"RMSE","lab.metric.prob":"Prob.","lab.metric.status":"Status","lab.gateNextMs":"Retry in ~{ms} ms","lab.gate.skipRecording":"Recording is active: the camera is writing video, so live-frame solve is paused. Stop recording and try again.","lab.gate.skipBusyInFlight":"The previous solve is still running; please wait. If this repeats often, increase the realtime solve interval.","lab.gate.skipInterval":"Solve requests are arriving faster than allowed; the server is throttling. Wait for the countdown and retry.","lab.gate.skipBusyFallback":"Realtime solve could not start ({detail}).","lab.centroidQualityTitle":"Centroid quality","lab.centroidQualityMetrics":"In {in} → kept {out}; removed dense {dense}, collinear {line}","meta.exposure":"Exposure","meta.gain":"Gain","meta.fps":"FPS","meta.sensor":"Sensor","meta.colorMode":"Color","meta.outputResolution":"Output size","meta.fileTime":"File time","meta.fileSize":"File size","results.viewRaw":"Raw JSON","results.hideRaw":"Hide","results.solveHistoryTitle":"Solve history","results.historySingle":"Single frame","results.historyBatch":"Batch ({count} runs)","params.title":"Solve parameters","params.blockSolveIntro":"Plate-solve (Tetra3): FOV, timeout, and coarse sky hints. FOV should match your lens.","params.centroid":"Star detection","params.blockCentroidIntro":"Star detection: threshold, blob area, and local background window for centroids.","params.fov":"FOV estimate (°)","params.fovHelp":"Horizontal field of view for lost-in-space solve.","params.fovErr":"FOV max error (°)","params.fovErrHelp":"Search range around estimated FOV.","params.timeout":"Timeout (ms)","params.timeoutHelp":"Max wait time per solve.","params.solveIntervalMs":"Realtime solve interval (ms)","params.solveIntervalMsHelp":"Desired interval is adjustable, but backend clamps it into a safe range.","params.solveIntervalIndependent":"Solve cadence is independent from sensor and preview frame rates.","params.solveIntervalBound":"Backend bounds: {min}-{max} ms, effective now: {effective} ms","params.centroidRejectionLevel":"Centroid rejection (1–5)","params.centroidRejectionLevelHelp":"1 keeps more stars; 5 aggressively rejects dense clusters and collinear false detections; default 3.","params.centroidRejectionScale":"Conservative ← → Aggressive","params.solveProfile":"Solve profile","params.solveProfileHelp":"Speed/Balanced/Robust tune timeout, centroid thresholds, and matching star count together.","params.solveProfileSpeed":"Speed first","params.solveProfileBalanced":"Balanced","params.solveProfileRobust":"Robust first","params.ra":"RA hint (°)","params.raHelp":"Approximate right ascension in degrees.","params.dec":"Dec hint (°)","params.decHelp":"Approximate declination in degrees.","params.maxSide":"Max long side before extract (px)","params.maxSideHelp":"Downscale long edge for faster centroid extraction.","params.detailLevelFull":"Include full Tetra3 raw block (larger payload, for debugging only).","params.largeScaleBg":"Large-scale background flattening","params.largeScaleBgHelp":"Before centroiding, estimate a low-frequency background on a downscaled image and correct uneven illumination (e.g. corner glow). Off by default to match legacy behavior.","params.sigma":"σ threshold","params.sigmaHelp":"Multiplier over background noise for star candidates.","params.maxArea":"max_area","params.maxAreaHelp":"Max connected component area in pixels.","params.minArea":"min_area","params.minAreaHelp":"Min connected component area in pixels.","params.filtsize":"filtsize (odd)","params.filtsizeHelp":"Local filter window size, must be odd.","btn.solveOne":"Solve once","btn.solveBatch":"Batch solve (presets)","btn.applyPresets":"Apply preset to form","btn.savePreset":"Save","placeholder.newPreset":"New preset name","pool.title":"Server asset pool","pool.col.name":"Filename","pool.col.source":"Source","pool.col.size":"Size","pool.col.time":"Modified","pool.delete":"Delete","history.title":"Experiment records","history.intro":"Saved solve snapshots from the Lab. After a solve, use Save to records in the Lab main panel (Result comparison), or Save on each batch result card. Search by filename or preset; export JSON/CSV for backup.","history.search":"Search…","history.searchBtn":"Search","history.exportJson":"Export JSON","history.exportCsv":"Export CSV","history.total":"Total {n}","history.preset":"Preset","history.metrics":"Metrics","history.detail":"Details","history.collapse":"Collapse","history.prev":"Prev","history.next":"Next","history.delete":"Delete","delete.uploadFirst":'Delete "{name}" from the asset pool?',"delete.uploadSecond":"This cannot be undone. Confirm again?","delete.experimentFirst":"Delete this experiment record?","delete.experimentSecond":"This cannot be undone. Confirm again?","results.title":"Results","results.saveCurrent":"Save to records","results.saveRow":"Save","results.expand":"Expand","results.collapseJson":"Collapse","err.selectFile":"Select a file","err.selectPresets":"Select at least one preset","common.placeholder":"—","lab.transcode.title":"AVI requires transcoding","lab.transcode.desc":"This video is AVI. For browser preview and continuous solving, transcode it locally to MP4 and upload replacement. The original AVI on server will be removed after success.","lab.transcode.button":"Transcode and Upload Replacement","lab.transcode.loading":"Loading transcoder...","lab.transcode.writingBuffer":"Writing to transcoder buffer...","lab.transcode.running":"Transcoding AVI -> MP4...","lab.transcode.packaging":"Packaging transcoded output...","lab.transcode.uploading":"Uploading transcoded output...","lab.transcode.done":"Transcode and replacement completed.","lab.transcode.failed":"Transcode or upload failed. Please retry.","lab.transcode.fetchFailed":"Failed to fetch AVI from server.","sys.shell.subtitle":"system debug console","sys.shell.nav.overview":"Overview","sys.shell.nav.network":"Network","sys.shell.nav.camera":"Camera Debug","sys.shell.nav.analysis":"Analysis Console","sys.shell.nav.sensors":"Sensors","sys.shell.nav.power":"Power","sys.shell.nav.hmi":"HMI","sys.shell.nav.config":"Config","sys.shell.workbench":"WORKBENCH / System workspace","sys.shell.node":"Node","sys.shell.top.overview":"System Status","sys.shell.top.network":"Network & WiFi","sys.shell.top.sensors":"Sensors","sys.shell.top.power":"Power Management","sys.shell.top.hmi":"HMI","sys.shell.top.config":"Config","sys.overview.breadcrumb.console":"Console","sys.overview.breadcrumb.module":"System Overview","sys.overview.title":"System Overview","sys.overview.subtitle":"Live system telemetry snapshot","sys.overview.metric.cpu":"CPU Usage","sys.overview.metric.mem":"Memory Usage","sys.overview.metric.temp":"Core Temperature","sys.overview.metric.wifi":"WiFi Link","sys.overview.metric.uptime":"Uptime","sys.overview.metric.load":"Load (1m)","sys.overview.metric.storage":"Storage","sys.overview.linkActive":"Link Active","sys.overview.tempState":"status: nominal","sys.overview.wifiSummary":"WiFi Summary","sys.overview.iface":"Interface","sys.overview.signal":"Signal","sys.overview.quality":"Quality","sys.overview.storageComingSoon":"Coming soon","sys.logs.title":"System Logs","sys.logs.kernel":"Kernel","sys.logs.liveToggle":"Live pull","sys.logs.refresh":"Refresh","sys.logs.liveOffHint":"Live pull is off (default)","sys.logs.loading":"Loading logs...","sys.logs.empty":"No logs","sys.placeholder.breadcrumb":"Console / Reserved Module","sys.placeholder.block":"PLANNED BLOCK","sys.placeholder.desc":"Shell layout is reserved and ready for real data integration.","sys.placeholder.status":"STATUS: RESERVED / Structured placeholder page.","sys.placeholder.sensors.title":"Sensors Console","sys.placeholder.sensors.desc":"Unified monitor for IMU, environment, power rails, and timeline sampling.","sys.placeholder.sensors.block1":"Realtime chart area","sys.placeholder.sensors.block2":"Device health cards","sys.placeholder.sensors.block3":"Threshold alerts","sys.sensors.title":"Sensors Console","sys.sensors.desc":"Visual compass (magnetometer) and gyro rate gauges; backed by dev debug APIs.","sys.sensors.mag.section":"Magnetometer (AK09911 / I²C)","sys.sensors.mag.note":"Defaults: bus 1, address 12 (0x0C with CAD to GND). No app-level GPIO mapping is required; enable I²C in firmware and load i2c-dev.","sys.sensors.mag.bus":"I²C bus","sys.sensors.mag.addr":"7-bit address (decimal, 12 = 0x0C)","sys.sensors.mag.i2cdetect":"Run i2cdetect","sys.sensors.mag.btnSelftest":"Run self-test","sys.sensors.mag.btnProbe":"Probe all buses","sys.sensors.mag.btnCalStart":"Start heading calibration","sys.sensors.mag.btnCalCommit":"Save & lock heading","sys.sensors.mag.btnCalReset":"Reset to auto mode","sys.sensors.mag.btnCalStatus":"Calibration status","sys.sensors.mag.running":"Running…","sys.sensors.mpu.section":"IMU (MPU-6050 / I²C)","sys.sensors.mpu.note":"Defaults: bus 1, address 104 (0x68 with AD0 low). Includes gyro angular-rate sampling (MPU integrates accel + gyro).","sys.sensors.mpu.addr":"7-bit address (decimal, 104 = 0x68)","sys.sensors.mpu.btnSelftest":"Run MPU self-test","sys.sensors.mpu.running":"Running…","sys.sensors.gyro.title":"Gyroscope (MPU-6050)","sys.sensors.gyro.subtitle":"Uses the same bus/address as above; one IMU read: 3-axis gyro, accel-derived roll/pitch, and a 3D board hint.","sys.sensors.gyro.btn":"Read angular rate","sys.sensors.gyro.loading":"Sampling…","sys.sensors.gyro.dps":"Angular rate","sys.sensors.gyro.raw":"Raw","sys.sensors.gyro.unitDps":"°/s, ±250 °/s range, default sensitivity","sys.sensors.gyro.unitRaw":"signed 16-bit","sys.sensors.gyro.hint":"Click “Read angular rate” to sample accel (0x3B) and gyro (0x43). Roll/pitch use gravity; ωz is yaw rate, not absolute heading.","sys.sensors.gyro.errUnknown":"Sample failed","sys.sensors.gyro.live":"Live update (~2 Hz)","sys.sensors.gyro.barsTitle":"Angular rate bars (±250 °/s full scale)","sys.sensors.gyro.rawBlock":"Raw register values (16-bit)","sys.sensors.gyro.att3dTitle":"3D attitude hint","sys.sensors.gyro.att3dDesc":"Roll/pitch from accelerometer (gravity); ωz is yaw rate (not magnetic heading). Body XYZ triad is fixed to the PCB and rotates in 3D.","sys.sensors.gyro.axisXLabel":"+X","sys.sensors.gyro.axisYLabel":"+Y","sys.sensors.gyro.axisZLabel":"+Z","sys.sensors.gyro.att3dBodyAxes":"Body frame on the PCB: +X amber, +Y sky, +Z violet (normal); short dashes mark negative directions; the whole frame follows roll/pitch.","sys.sensors.gyro.att3dRefTitle":"Orthogonal axes (fixed view)","sys.sensors.gyro.att3dRefDesc":"Same colors as left; use this to read XYZ directions at a glance.","sys.sensors.gyro.roll":"Roll","sys.sensors.gyro.pitch":"Pitch","sys.sensors.gyro.yawRate":"Yaw rate","sys.sensors.compass.tapeCaption":"Heading tape · center = current readout","sys.sensors.compass.title":"Magnetometer · North compass","sys.sensors.compass.desc":"Horizontal-plane heading for “roughly north” debugging; precise alignment needs calibration and a fixed mount definition.","sys.sensors.compass.btn":"Refresh compass","sys.sensors.compass.loading":"Measuring…","sys.sensors.compass.live":"Live update","sys.sensors.compass.headingLabel":"Horizontal heading","sys.sensors.compass.headingHint":"From atan2(Hx, Hy), degrees","sys.sensors.compass.cardN":"N","sys.sensors.compass.cardE":"E","sys.sensors.compass.cardS":"S","sys.sensors.compass.cardW":"W","sys.sensors.compass.err":"Sample failed","sys.sensors.compass.footnote":"The red tip tracks the horizontal XY field direction; it should move as you rotate the board. Use soft/hard-iron calibration for tight north lock.","sys.sensors.jsonToggle":"Raw JSON (troubleshooting)","sys.hmi.title":"HMI · SPI display","sys.hmi.desc":"ST7796 via hardware plane (default 320×320). Requires OGSCOPE_DISPLAY_ENABLED=true and SPI enabled (/dev/spidev0.0).","sys.hmi.status.section":"Display & hardware plane","sys.hmi.status.refresh":"Refresh","sys.hmi.status.displayEnabled":"DISPLAY_ENABLED","sys.hmi.status.spidev":"/dev/spidev0.0","sys.hmi.status.yes":"present","sys.hmi.status.no":"missing","sys.hmi.status.resolution":"Resolution / DC","sys.hmi.status.driver":"Driver handle","sys.hmi.status.open":"open","sys.hmi.status.closed":"closed","sys.hmi.status.screenOutput":"Logical output","sys.hmi.status.on":"on","sys.hmi.status.off":"off","sys.hmi.status.lastPattern":"Last pattern","sys.hmi.status.lastError":"Last error","sys.hmi.actions.section":"Output to panel","sys.hmi.actions.hint":"Uses hardware plane device.command → hmi; large frames use a longer RPC timeout.","sys.hmi.actions.smoke":"Test pattern (text + frame)","sys.hmi.actions.colorbars":"Color bars","sys.hmi.actions.fill":"Fill RGB","sys.hmi.actions.screenOn":"Enable panel output","sys.hmi.actions.screenOff":"Pause panel output","sys.hmi.actions.release":"Release driver (close SPI/GPIO)","sys.hmi.rawJson":"Raw JSON response","sys.placeholder.hmi.title":"HMI Console","sys.placeholder.hmi.desc":"Debug workspace for SPI display, key matrix, backlight, and UI replay.","sys.placeholder.hmi.block1":"Display preview","sys.placeholder.hmi.block2":"Input event stream","sys.placeholder.hmi.block3":"Mode controls","sys.placeholder.power.title":"Power Console","sys.placeholder.power.desc":"Debug workspace for battery, charging, power profile, and thermal policy.","sys.placeholder.power.block1":"Power dashboard","sys.placeholder.power.block2":"Power policy table","sys.placeholder.power.block3":"Recovery actions","cam.title":"Camera Debug Console","cam.subtitle":"Migrated to unified admin UI with key optimizations preserved","cam.btn.system":"System Admin","cam.btn.analysis":"Analysis Console","cam.btn.home":"Home","cam.btn.start":"Start Preview","cam.btn.starting":"Starting...","cam.btn.stop":"Stop Preview","cam.btn.stopping":"Stopping...","cam.btn.capture":"Capture","cam.btn.capturing":"Capturing...","cam.btn.recordStart":"Start Recording","cam.btn.recordStop":"Stop Recording","cam.btn.recordBusy":"Switching recording...","cam.btn.refresh":"Refresh Status","cam.preview.title":"Live Preview","cam.preview.state":"State","cam.preview.mode":"Exposure Mode","cam.preview.emptyTitle":"Preview is not started","cam.preview.emptyDesc":"Start the camera preview stream with the button below.","cam.state.streaming":"streaming","cam.state.idle":"idle","cam.state.rec":"REC","cam.stats.requestFps":"Request FPS","cam.hint.mjpegSingleStream":"MJPEG concurrent streams are limited (default ~2). Beyond the cap, extra tabs may contend or get busy—reduce simultaneous live previews.","cam.stats.frameFps":"Display FPS","cam.stats.fpsMeasureNote":"Pixel deltas on a 32×32 sample over ~1s. Multiple previews, slow links, or a stream cap below the sensor target often read much lower.","cam.err.streamBusy":"Preview unavailable: MJPEG slot is already in use","cam.err.streamBusyHint":"Close the live preview in other tabs or in the Analysis console, then try again.","cam.err.streamProbeFailed":"Could not open the preview stream","cam.err.streamProbeDetail":"Check network and camera service, or retry in a moment.","cam.stats.targetFps":"Camera capture target FPS","cam.stats.streamPacingFps":"Shared stream / MJPEG cap FPS","cam.stats.streamPacingHint":"Matches OGSCOPE_SHARED_PREVIEW_FPS: shared grabber pacing and per-stream MJPEG min frame spacing.","cam.stats.captureFps":"Actual capture FPS","cam.stats.encodeMs":"Average JPEG encode","cam.stats.encoder":"Preview encoder","cam.stats.driver":"Camera driver","cam.stats.metadata":"Metadata","cam.stats.consumers":"Preview/analysis/record consumers","cam.stats.cameraMemory":"Process memory/swap","cam.stats.longExposureThrottle":"Auto exposure is about {exposure} ms, so long exposure is limiting FPS; exposure is not shortened to preserve analysis quality.","cam.stats.uptime":"Stream Uptime","cam.controls.title":"Controls","cam.controls.core":"Core Settings","cam.controls.fps":"FPS","cam.controls.sensorFps":"Sensor target FPS","cam.controls.previewFps":"Preview target FPS","cam.controls.resolution":"Resolution","cam.controls.applyRes":"Apply resolution","cam.controls.sampling":"Sampling mode","cam.controls.exposure":"Exposure","cam.controls.gain":"Analog Gain","cam.controls.digitalGain":"Digital Gain","cam.controls.noiseReduction":"Noise Reduction","cam.controls.noiseReductionMode":"Noise Reduction","cam.controls.digitalGainReadOnly":"Digital gain is read-only on this driver","cam.controls.aeFlicker":"AE Flicker","cam.controls.maxAeFrame":"Max AE frame (us)","cam.controls.contrast":"Contrast","cam.controls.brightness":"Brightness","cam.controls.saturation":"Saturation","cam.controls.sharpness":"Sharpness","cam.controls.applySettings":"Apply All Settings","cam.controls.applyAll":"Save Parameters","cam.controls.applyRuntime":"Save Runtime Settings","cam.controls.mode":"Mode Controls","cam.controls.autoExposure":"Exposure Mode","cam.controls.whiteBalance":"White Balance","cam.controls.colorMode":"Color Mode","cam.controls.auto":"Auto","cam.controls.manual":"Manual","cam.controls.night":"Night","cam.controls.off":"Off","cam.controls.nr.off":"Off","cam.controls.nr.fast":"Fast","cam.controls.nr.high_quality":"High quality","cam.controls.wb.auto":"Auto","cam.controls.wb.daylight":"Daylight","cam.controls.wb.cloudy":"Cloudy","cam.controls.wb.tungsten":"Tungsten","cam.controls.wb.fluorescent":"Fluorescent","cam.controls.wb.indoor":"Indoor","cam.controls.wb.manual":"Manual","cam.controls.wb.night":"Night","cam.controls.color":"Color","cam.controls.mono":"Mono","cam.controls.applyAe":"Apply Exposure Mode","cam.controls.applyWb":"Apply White Balance","cam.controls.applyColor":"Apply Color Mode","cam.controls.applyMode":"Apply Mode Settings","cam.controls.lockedByAe":"Exposure and gain are locked while auto exposure is enabled.","cam.controls.lockedByWb":"R/B gains are locked unless white balance is in manual mode.","cam.controls.pendingChanges":"You have unapplied parameter changes","cam.controls.tools":"Tools","cam.controls.nightPreset":"Night Preset","cam.controls.nightOn":"Enable Night Mode","cam.controls.nightOff":"Disable Night Mode","cam.controls.backup":"Backup Settings","cam.controls.restore":"Restore Settings","cam.controls.reset":"Reset Camera","cam.quick.title":"Quick Presets & Smart Tuning","cam.quick.daylight":"Daylight","cam.quick.night":"Night","cam.quick.nightPreset":"Apply Night Preset","cam.quick.deep-sky":"Deep Sky","cam.quick.planetary":"Planetary","cam.quick.autoAdjust":"Auto Adjust","cam.quick.nightHint":"Night preset is one-click tuning; night mode toggle is runtime on/off control.","cam.system.title":"System Monitor","cam.system.status":"Camera Ready","cam.system.stream":"Stream","cam.system.sensor":"Sensor","cam.system.quality":"Image Quality","cam.presets.title":"Preset Management","cam.presets.name":"Preset Name","cam.presets.desc":"Preset Description","cam.presets.save":"Save Preset","cam.presets.empty":"No presets","cam.presets.noDesc":"No description","cam.presets.apply":"Apply","cam.presets.delete":"Delete","cam.files.title":"File Management","cam.files.refresh":"Refresh File List","cam.files.empty":"No files","cam.files.download":"Download","cam.files.info":"Details","cam.files.delete":"Delete","cam.files.loadingInfo":"Loading file details...","cam.files.prev":"Prev","cam.files.next":"Next","cam.files.page":"Page {current}/{total}","cam.files.closeDetail":"Close details","cam.files.size":"Size","cam.files.type":"Type","cam.files.modified":"Modified","cam.hist.title":"Histogram","cam.hist.enabled":"Enable histogram","cam.hist.luminance":"Luminance","cam.hist.over":"Over-exposure","cam.hist.expand":"Expand Histogram","cam.hist.collapse":"Collapse Histogram","cam.notice.previewStart":"Preview started","cam.notice.previewStop":"Preview stopped","cam.notice.captureSaved":"Capture saved: {name}","cam.notice.recordStart":"Recording started: {name}","cam.notice.recordStop":"Recording stopped","cam.notice.fpsApplied":"FPS updated to {fps}","cam.notice.resApplied":"Resolution updated to {w}x{h}","cam.notice.samplingApplied":"Sampling mode updated: {mode}","cam.notice.settingsApplied":"Settings applied","cam.notice.aeApplied":"Exposure mode applied","cam.notice.wbApplied":"White balance applied","cam.notice.colorApplied":"Color mode applied","cam.notice.modeApplied":"Mode settings applied","cam.notice.runtimeApplied":"Runtime settings applied","cam.notice.rotationApplied":"Rotation set to {value}°","cam.mirror.hint":"Use with rotation when the camera is mounted inverted; preview, plate solve, and polar guide share one coordinate frame.","cam.mirror.horizontal":"Horizontal mirror","cam.mirror.vertical":"Vertical mirror","cam.notice.mirrorApplied":"Mirror settings updated","cam.notice.nightOn":"Night mode enabled","cam.notice.nightOff":"Night mode disabled","cam.notice.nightPreset":"Night preset applied","cam.notice.reset":"Camera settings reset","cam.notice.backup":"Settings backed up","cam.notice.restore":"Settings restored","cam.notice.presetSaved":"Preset saved: {name}","cam.notice.presetApplied":"Preset applied: {name}","cam.notice.presetDeleted":"Preset deleted: {name}","cam.notice.download":"Download started: {name}","cam.notice.downloadWithSidecar":"Downloaded: {name} (with sidecar {sidecar})","cam.notice.fileDeleted":"File deleted: {name}","cam.notice.quickPresetApplied":"Quick preset applied: {preset}","cam.notice.needPreview":"Start preview first","cam.notice.needManualForAutoAdjust":"Switch to manual exposure before auto-adjust","cam.notice.autoAdjusted":"Auto-adjust completed and applied","cam.confirm.deletePreset":"Delete preset {name}?","cam.confirm.deleteFile":"Delete file {name}?","sys.sensors.mag.calStatusPrefix":"Calibration mode:","sys.sensors.mag.calModeAuto":"Auto (not locked)","sys.sensors.mag.calModeRecording":"Recording (rotate device)","sys.sensors.mag.calModeLocked":"Locked (saved heading)","sys.sensors.mag.calSamplesPrefix":"Recorded samples:","sys.sensors.mag.calRecordingHint":"Tip: keep level and rotate slowly; save after 10+ samples.","sys.sensors.mag.calLockedHint":"Locked heading params:"},C={"app.title":"OGScope 星空解算控制台","nav.lab":"解算台","nav.labImage":"图片解算","nav.labVideo":"视频解算","nav.pool":"素材池","nav.history":"实验记录","nav.cameraDebug":"相机调试控制台","nav.home":"首页","nav.systemAdmin":"返回系统后台","lang.zh":"中文","lang.en":"EN","sidebar.assets":"自行上传素材","sidebar.upload":"上传文件","sidebar.refresh":"刷新列表","sidebar.debugCaptures":"调试控制台素材","sidebar.assetTypeImage":"图片","sidebar.assetTypeVideo":"视频","sidebar.debugEmpty":"暂无调试文件","sidebar.importToPool":"导入到素材池","sidebar.importToPoolWithTranscode":"转码并导入素材池","sidebar.importToPoolDirect":"直接导入素材池","sidebar.flowPreparing":"准备中…","sidebar.flowImportingDebug":"正在导入调试素材…","sidebar.flowDownloadingPool":"正在下载已导入文件…","sidebar.flowWritingBuffer":"正在写入转码缓冲区…","sidebar.flowLoadingTranscoder":"正在加载转码器…","sidebar.flowTranscoding":"正在转码 AVI -> MP4…","sidebar.flowPackaging":"正在封装转码结果…","sidebar.flowUploadingMp4":"正在上传 MP4…","sidebar.flowReplacing":"正在替换并清理原 AVI…","sidebar.flowNoTranscode":"该文件无需转码,已直接导入。","sidebar.flowDone":"流程完成","sidebar.flowDoneMsg":"已完成:{name}","sidebar.flowFailed":"流程失败","sidebar.debugPage":"第 {cur} / {total} 页","sidebar.batchPresets":"批量预设","sidebar.batchHint":"勾选后点击「批量解算」可一次用多组参数对比结果。","lab.selectOrUpload":"从左侧选择自行上传或已导入的素材","lab.selectOrUploadVideo":"从左侧选择视频素材预览;或使用上方按钮解算设备相机实时帧。","lab.file":"文件","lab.source":"来源","lab.layers":"叠加层","lab.layer.matched":"匹配星","lab.layer.pattern":"图案星","lab.layer.all":"全部质心","lab.layer.rejected":"已剔除(红叉)","lab.previewConfidence":"置信度","lab.grid":"网格","lab.zoomIn":"放大","lab.zoomOut":"缩小","lab.zoomReset":"复位","lab.resolution":"分辨率","lab.fwhm":"FWHM","lab.starsDetected":"检测星点","lab.meta.title":"拍摄与文件信息","lab.meta.noSidecar":"无侧车信息(非调试采集或仅本地上传)","lab.meta.partial":"暂无侧车详细字段,仅显示文件信息。","lab.solveSection":"解算","lab.imageSection":"图像","lab.metric.solveMs":"用时","lab.metric.solveComputeMs":"解算计算用时","lab.metric.solveComputeHelp":"服务端 Tetra3 与提星等纯计算耗时(与网络无关)。","lab.metric.solveRoundTripMs":"全链路用时","lab.metric.solveRoundTripHelp":"从本页发起请求到收到结果并完成界面刷新的总耗时,含网络往返与浏览器渲染。","lab.metric.backendTotalMs":"后端总用时","lab.metric.openDecodeMs":"读取/解码","lab.metric.preprocessMs":"预处理","lab.metric.extractMs":"提星","lab.metric.solveOnlyMs":"匹配解算","lab.metric.probHelp":"Tetra3 的 Prob 为假阳性概率(越低越可信)。一般使用 (1−Prob)×100%;当 Prob 极小时浮点数会舍入为 100%,此时用 −log₁₀(Prob) 映射到约 70–100% 以便区分。","lab.metric.probRawHelp":"Tetra3 返回的原始 Prob 字段,可能为对数似然等内部量;与上一行换算后的百分比对照查看即可。","lab.metric.radec":"RA / Dec","lab.metric.matches":"匹配","lab.metric.rmse":"RMSE","lab.metric.prob":"置信","lab.metric.status":"状态","lab.gateNextMs":"约 {ms} ms 后可重试","lab.gate.skipRecording":"录制中:相机正写入录像,暂无法从相机取帧解算;请先停止录制后再试。","lab.gate.skipBusyInFlight":"上一帧解算仍在处理中,请稍候。若频繁出现,可适当增大「实时解算间隔」。","lab.gate.skipInterval":"解算请求过于频繁,已按后端节流排队。","lab.gate.skipBusyFallback":"实时解算暂时无法开始({detail})。","lab.centroidQualityTitle":"质心质量","lab.centroidQualityMetrics":"输入 {in} → 保留 {out};剔除过密 {dense},共线 {line}","meta.exposure":"曝光","meta.gain":"增益","meta.fps":"帧率","meta.sensor":"传感器","meta.colorMode":"色彩","meta.outputResolution":"输出分辨率","meta.fileTime":"文件时间","meta.fileSize":"文件大小","results.viewRaw":"原始 JSON","results.hideRaw":"收起","results.solveHistoryTitle":"解算历史","results.historySingle":"单帧","results.historyBatch":"批量 {count} 组","params.title":"解算参数","params.blockSolveIntro":"以下为板块求解(Tetra3)搜索天区、超时与粗略指向提示;FOV 需与镜头视场大致一致。","params.centroid":"提星","params.blockCentroidIntro":"以下为星点检测:阈值、连通域面积与局部背景窗口,用于从图像中提取星点质心。","params.fov":"FOV 估计 (°)","params.fovHelp":"水平视场角估计值,用于 lost-in-space 解算。","params.fovErr":"FOV 允许误差 (°)","params.fovErrHelp":"允许 Tetra3 在估计 FOV 附近的搜索范围。","params.timeout":"超时 (ms)","params.timeoutHelp":"单次解算最长等待时间。","params.solveIntervalMs":"实时解算间隔 (ms)","params.solveIntervalMsHelp":"期望间隔可调整,但会被后端限制在安全范围内。","params.solveIntervalIndependent":"解算间隔独立于相机采集和预览帧率。","params.solveIntervalBound":"后端限制范围:{min}-{max} ms,当前生效:{effective} ms","params.centroidRejectionLevel":"质心剔除强度 (1–5)","params.centroidRejectionLevelHelp":"1 最保守保留更多星点,5 更激进剔除过密与共线假星;默认 3。","params.centroidRejectionScale":"保守 ← → 激进","params.solveProfile":"解算档位","params.solveProfileHelp":"速度/平衡/稳健三档会同时调整超时、提星阈值与参与匹配星点数。","params.solveProfileSpeed":"速度优先","params.solveProfileBalanced":"平衡","params.solveProfileRobust":"稳健优先","params.ra":"RA 提示 (°)","params.raHelp":"大致天球赤经,缩小搜索范围(度)。","params.dec":"Dec 提示 (°)","params.decHelp":"大致天球赤纬(度)。","params.maxSide":"提星前长边上界 (px)","params.maxSideHelp":"降采样长边上限,大图可加速提星。","params.detailLevelFull":"包含完整 Tetra3 原始结果(体积略大,仅调试时开启)","params.largeScaleBg":"大尺度背景减除","params.largeScaleBgHelp":"在提星前用低分辨率平滑估计并校正大尺度亮度不均,可减轻角部光晕导致的假星;默认关闭以保持与过往行为一致。","params.sigma":"σ(阈值倍数)","params.sigmaHelp":"高于背景噪声倍数的区域视为星点候选。","params.maxArea":"max_area","params.maxAreaHelp":"连通域最大像素面积。","params.minArea":"min_area","params.minAreaHelp":"连通域最小像素面积。","params.filtsize":"filtsize(奇数)","params.filtsizeHelp":"局部背景滤波窗口边长,须为奇数。","btn.solveOne":"单张解算","btn.solveBatch":"批量解算(勾选预设)","btn.applyPresets":"应用预设到表单","btn.savePreset":"保存","placeholder.newPreset":"新预设名称","pool.title":"服务器素材池","pool.col.name":"文件名","pool.col.source":"来源","pool.col.size":"大小","pool.col.time":"修改时间","pool.delete":"删除","history.title":"实验记录","history.intro":"此处展示你在解算台完成解算后手动保存的快照。用法:在「解算台」主栏「结果对比」中,单张解算后点「保存当前到实验记录」,或批量解算后在某张结果卡片上点「保存记录」。本页可按文件名或预设名搜索,支持导出 JSON/CSV 备份。","history.search":"搜索…","history.searchBtn":"搜索","history.exportJson":"导出 JSON","history.exportCsv":"导出 CSV","history.total":"共 {n} 条","history.preset":"预设","history.metrics":"指标","history.detail":"详情","history.collapse":"收起","history.prev":"上一页","history.next":"下一页","history.delete":"删除","delete.uploadFirst":"确定要从素材池删除「{name}」吗?","delete.uploadSecond":"此操作不可恢复,再次确认删除?","delete.experimentFirst":"确定要删除这条实验记录吗?","delete.experimentSecond":"此操作不可恢复,再次确认删除?","results.title":"结果对比","results.saveCurrent":"保存当前到实验记录","results.saveRow":"保存记录","results.expand":"展开","results.collapseJson":"收起","err.selectFile":"请选择素材","err.selectPresets":"请勾选至少一个预设","common.placeholder":"—","delete.uploadCascade":"该素材有 {n} 条实验记录,是否一并删除?","lab.solveCurrentFrame":"解算当前帧(文件)","lab.solveFileStart":"开始文件连续解算","lab.solveFileStop":"停止文件连续解算","lab.cameraPreviewLoading":"正在连接共享预览…","lab.solveCameraFrame":"解算相机当前帧","lab.solveCameraStart":"开始相机解算","lab.solveCameraStop":"停止相机解算","lab.videoPreviewFailed":"该视频格式可能不受浏览器支持,建议使用 MP4(H.264) 或 WebM。","lab.previewModeFile":"素材文件","lab.previewModeCamera":"设备相机","lab.stopCameraPreview":"停止预览","lab.videoLiveIntro":"与调试控制台共用同一相机;此处可预览并解算实时帧,无需单独打开调试页。两页可同时使用。","lab.videoMjpegHint":"MJPEG 同时连接数已满:请关闭其它页面的实时预览或稍后重试(默认允许约 4 路,可在 systemd 调整 OGSCOPE_STREAM_MAX_MJPEG_CLIENTS)。","lab.cameraStreamStatusFail":"无法读取视频流名额状态,请稍后重试。","lab.cameraSnapshotName":"ogscope_camera_live","lab.metric.probRaw":"原始 Prob","lab.systemLoad":"系统负载","results.saveBatchAll":"保存全部到实验记录","lab.transcode.title":"AVI 文件需先转码","lab.transcode.desc":"当前视频为 AVI。为保证浏览器可预览与连续解算,请先在本地转码为 MP4 并上传替换。上传成功后将自动删除服务器上的原 AVI。","lab.transcode.button":"转码并上传替换","lab.transcode.loading":"正在加载转码器…","lab.transcode.writingBuffer":"正在写入转码缓冲区…","lab.transcode.running":"正在转码 AVI -> MP4…","lab.transcode.packaging":"正在封装转码结果…","lab.transcode.uploading":"正在上传转码结果…","lab.transcode.done":"转码并替换完成,可继续预览与解算。","lab.transcode.failed":"转码或上传失败,请重试。","lab.transcode.fetchFailed":"无法读取服务器上的 AVI 文件。","sys.shell.subtitle":"系统调试控制台","sys.shell.nav.overview":"总览","sys.shell.nav.network":"网络","sys.shell.nav.camera":"相机调试","sys.shell.nav.analysis":"寻星控制台","sys.shell.nav.sensors":"传感器","sys.shell.nav.power":"电源","sys.shell.nav.hmi":"人机交互","sys.shell.nav.config":"配置管理","sys.shell.workbench":"WORKBENCH / 系统工作台","sys.shell.node":"节点","sys.shell.top.overview":"系统状态","sys.shell.top.network":"网络与 WiFi","sys.shell.top.sensors":"传感器","sys.shell.top.power":"电源管理","sys.shell.top.hmi":"人机交互","sys.shell.top.config":"配置管理","sys.overview.breadcrumb.console":"控制台","sys.overview.breadcrumb.module":"系统总览","sys.overview.title":"系统总览","sys.overview.subtitle":"实时系统运行健康状况","sys.overview.metric.cpu":"CPU 使用率","sys.overview.metric.mem":"内存占用","sys.overview.metric.temp":"核心温度","sys.overview.metric.wifi":"WiFi 链路","sys.overview.metric.uptime":"运行时长","sys.overview.metric.load":"1 分钟负载","sys.overview.metric.storage":"存储","sys.overview.linkActive":"链路在线","sys.overview.tempState":"状态: 正常","sys.overview.wifiSummary":"无线网络摘要","sys.overview.iface":"接口","sys.overview.signal":"信号","sys.overview.quality":"质量","sys.overview.storageComingSoon":"即将接入","sys.logs.title":"系统日志","sys.logs.kernel":"内核","sys.logs.liveToggle":"实时拉取","sys.logs.refresh":"刷新","sys.logs.liveOffHint":"实时拉取关闭(默认)","sys.logs.loading":"日志加载中...","sys.logs.empty":"暂无日志","sys.placeholder.breadcrumb":"控制台 / 预留模块","sys.placeholder.block":"预留模块","sys.placeholder.desc":"模块壳层已预留,后续将按统一组件体系接入真实数据。","sys.placeholder.status":"STATUS: RESERVED / 当前为结构化占位页面。","sys.placeholder.sensors.title":"传感器诊断台","sys.placeholder.sensors.desc":"用于 IMU、温湿度、电流电压、姿态与时序采样的统一监控面板。","sys.placeholder.sensors.block1":"实时曲线区","sys.placeholder.sensors.block2":"设备健康卡","sys.placeholder.sensors.block3":"阈值告警区","sys.sensors.title":"传感器诊断台","sys.sensors.desc":"磁力计罗盘指北与 MPU-6050 陀螺仪角速度可视化;底层仍为开发者调试 API。","sys.sensors.mag.section":"磁力计(AK09911 / I²C)","sys.sensors.mag.note":"默认总线 1、地址 12(0x0C,CAD 接 GND)。无需在应用层“定义 GPIO”;请确保固件已启用 I²C 且已加载 i2c-dev。","sys.sensors.mag.bus":"I²C 总线号","sys.sensors.mag.addr":"7-bit 地址(十进制,12=0x0C)","sys.sensors.mag.i2cdetect":"附带运行 i2cdetect","sys.sensors.mag.btnSelftest":"运行自检","sys.sensors.mag.btnProbe":"扫描各总线","sys.sensors.mag.btnCalStart":"开始方向校准","sys.sensors.mag.btnCalCommit":"保存并锁定方向","sys.sensors.mag.btnCalReset":"重置到自动模式","sys.sensors.mag.btnCalStatus":"查看校准状态","sys.sensors.mag.running":"执行中…","sys.sensors.mpu.section":"IMU(MPU-6050 / I²C)","sys.sensors.mpu.note":"默认总线 1、地址 104(0x68,AD0 接 GND)。含陀螺仪角速度采样(MPU 内集成加速度计与陀螺仪)。","sys.sensors.mpu.addr":"7-bit 地址(十进制,104=0x68)","sys.sensors.mpu.btnSelftest":"运行 MPU 自检","sys.sensors.mpu.running":"执行中…","sys.sensors.gyro.title":"陀螺仪(MPU-6050)","sys.sensors.gyro.subtitle":"使用上方同一总线/地址;一次读取加速度与陀螺仪,估算横滚/俯仰并显示 3D 板卡示意。","sys.sensors.gyro.btn":"读取角速度","sys.sensors.gyro.loading":"采样中…","sys.sensors.gyro.dps":"角速度","sys.sensors.gyro.raw":"原始值","sys.sensors.gyro.unitDps":"°/s,±250°/s 量程、默认灵敏度","sys.sensors.gyro.unitRaw":"16-bit 有符号","sys.sensors.gyro.hint":"点击「读取角速度」从 IMU 采样(加速度 0x3B + 陀螺仪 0x43)。横滚/俯仰来自重力;ωz 为绕竖直轴角速度,并非绝对航向。","sys.sensors.gyro.errUnknown":"采样失败","sys.sensors.gyro.live":"连续刷新(约 2 Hz)","sys.sensors.gyro.barsTitle":"角速度条(相对 ±250°/s 满偏)","sys.sensors.gyro.rawBlock":"寄存器原始值(16-bit)","sys.sensors.gyro.att3dTitle":"3D 姿态示意","sys.sensors.gyro.att3dDesc":"横滚/俯仰由加速度计重力估算;ωz 为陀螺仪绕竖直轴角速度(非磁航向)。机体系三轴与板卡固连,随姿态在三维空间中转动。","sys.sensors.gyro.axisXLabel":"+X","sys.sensors.gyro.axisYLabel":"+Y","sys.sensors.gyro.axisZLabel":"+Z","sys.sensors.gyro.att3dBodyAxes":"左侧为与电路板固连的机体系:琥珀 +X、天蓝 +Y、紫 +Z(法向);短划为负向参考,整体随横滚/俯仰旋转。","sys.sensors.gyro.att3dRefTitle":"正交轴参考(固定视角)","sys.sensors.gyro.att3dRefDesc":"与左侧同色对应,便于辨认三轴空间关系。","sys.sensors.gyro.roll":"横滚","sys.sensors.gyro.pitch":"俯仰","sys.sensors.gyro.yawRate":"偏航角速度","sys.sensors.compass.tapeCaption":"水平航向带 · 中心为当前读数","sys.sensors.compass.title":"磁力计 · 指北罗盘","sys.sensors.compass.desc":"根据水平面磁场分量估算方向角,用于调试「是否大致指向北方」;精对准需校准与固定安装姿态。","sys.sensors.compass.btn":"刷新罗盘","sys.sensors.compass.loading":"测量中…","sys.sensors.compass.live":"连续刷新","sys.sensors.compass.headingLabel":"水平航向角","sys.sensors.compass.headingHint":"由 atan2(Hx, Hy) 得到,单位度","sys.sensors.compass.cardN":"北","sys.sensors.compass.cardE":"东","sys.sensors.compass.cardS":"南","sys.sensors.compass.cardW":"西","sys.sensors.compass.err":"采样失败","sys.sensors.compass.footnote":"红色针尖表示 XY 平面内磁场水平分量方向;转动设备时指针应随之变化。精对准需软铁/硬铁校准。","sys.sensors.jsonToggle":"展开原始 JSON(排障用)","sys.hmi.title":"人机交互 · SPI 显示","sys.hmi.desc":"通过硬件平面驱动 ST7796(默认 320×320);需 OGSCOPE_DISPLAY_ENABLED=true 且系统已启用 SPI(/dev/spidev0.0)。","sys.hmi.status.section":"显示与硬件平面状态","sys.hmi.status.refresh":"刷新状态","sys.hmi.status.displayEnabled":"DISPLAY_ENABLED","sys.hmi.status.spidev":"/dev/spidev0.0","sys.hmi.status.yes":"存在","sys.hmi.status.no":"不存在","sys.hmi.status.resolution":"分辨率 / DC","sys.hmi.status.driver":"驱动句柄","sys.hmi.status.open":"已打开","sys.hmi.status.closed":"未打开","sys.hmi.status.screenOutput":"逻辑输出","sys.hmi.status.on":"开","sys.hmi.status.off":"关","sys.hmi.status.lastPattern":"上次图案","sys.hmi.status.lastError":"上次错误","sys.hmi.actions.section":"输出到屏幕","sys.hmi.actions.hint":"以下为硬件平面 device.command → hmi;大屏刷新使用较长 RPC 超时。","sys.hmi.actions.smoke":"测试画面(文字+边框)","sys.hmi.actions.colorbars":"彩条","sys.hmi.actions.fill":"填充 RGB","sys.hmi.actions.screenOn":"允许屏幕输出","sys.hmi.actions.screenOff":"暂停屏幕输出","sys.hmi.actions.release":"释放驱动(关闭 SPI/GPIO)","sys.hmi.rawJson":"原始 JSON 响应","sys.placeholder.hmi.title":"人机交互台","sys.placeholder.hmi.desc":"用于 SPI 显示、按键矩阵、背光/对比度与界面回放的调试工作区。","sys.placeholder.hmi.block1":"显示预览区","sys.placeholder.hmi.block2":"输入事件流","sys.placeholder.hmi.block3":"参数与模式控制","sys.placeholder.power.title":"电源与功耗台","sys.placeholder.power.desc":"用于电池、充电、瞬时功耗与热管理策略联动调试。","sys.placeholder.power.block1":"功耗看板","sys.placeholder.power.block2":"电源策略表","sys.placeholder.power.block3":"恢复与保护动作","cam.title":"相机调试控制台","cam.subtitle":"迁移到统一后台架构,保留关键性能优化","cam.btn.system":"系统后台","cam.btn.analysis":"寻星控制台","cam.btn.home":"首页","cam.btn.start":"启动预览","cam.btn.starting":"启动中...","cam.btn.stop":"停止预览","cam.btn.stopping":"停止中...","cam.btn.capture":"拍摄","cam.btn.capturing":"拍摄中...","cam.btn.recordStart":"开始录制","cam.btn.recordStop":"停止录制","cam.btn.recordBusy":"录制切换中...","cam.btn.refresh":"刷新状态","cam.preview.title":"实时预览","cam.preview.state":"状态","cam.preview.mode":"曝光模式","cam.preview.emptyTitle":"预览尚未启动","cam.preview.emptyDesc":"点击下方按钮启动相机预览流。","cam.state.streaming":"流已启动","cam.state.idle":"未启动","cam.state.rec":"录制中","cam.stats.requestFps":"请求 FPS","cam.hint.mjpegSingleStream":"MJPEG 同时连接数有限(默认约两路);超出上限时多标签页会争用或返回忙,请减少同时打开的实时预览。","cam.stats.frameFps":"画面 FPS","cam.stats.fpsMeasureNote":"统计最近约 1 秒内在 32×32 采样上的像素变化次数;多路同时预览、网络较慢、或下方「共享流上限」低于相机目标时,读数常明显偏低。","cam.err.streamBusy":"无法连接预览:MJPEG 名额已被占用","cam.err.streamBusyHint":"请关闭其它标签页或解算台中的设备实时预览,或稍后再试。","cam.err.streamProbeFailed":"无法建立预览流","cam.err.streamProbeDetail":"请检查网络与相机服务状态,或稍后重试。","cam.stats.targetFps":"相机采集目标 FPS","cam.stats.streamPacingFps":"共享流/MJPEG 上限 FPS","cam.stats.streamPacingHint":"与进程环境 OGSCOPE_SHARED_PREVIEW_FPS 一致,约束共享抓帧节奏与每路 MJPEG 的最小帧间隔。","cam.stats.captureFps":"实际采集 FPS","cam.stats.encodeMs":"JPEG 平均编码","cam.stats.encoder":"预览编码器","cam.stats.driver":"相机驱动","cam.stats.metadata":"元数据","cam.stats.consumers":"预览/分析/录像消费者","cam.stats.cameraMemory":"进程内存/Swap","cam.stats.longExposureThrottle":"自动曝光约 {exposure} ms,实际帧率受长曝光限制;为保证分析效果未强制缩短曝光。","cam.stats.uptime":"流运行时长","cam.controls.title":"参数控制","cam.controls.core":"核心参数","cam.controls.fps":"帧率","cam.controls.sensorFps":"传感器目标 FPS","cam.controls.previewFps":"预览目标 FPS","cam.controls.resolution":"分辨率","cam.controls.applyRes":"应用分辨率","cam.controls.sampling":"采样模式","cam.controls.exposure":"曝光","cam.controls.gain":"模拟增益","cam.controls.digitalGain":"数字增益","cam.controls.noiseReduction":"降噪级别","cam.controls.noiseReductionMode":"降噪模式","cam.controls.digitalGainReadOnly":"当前驱动仅上报数字增益,不支持手动设置","cam.controls.aeFlicker":"AE 防闪烁","cam.controls.maxAeFrame":"最长自动曝光帧周期(us)","cam.controls.contrast":"对比度","cam.controls.brightness":"亮度","cam.controls.saturation":"饱和度","cam.controls.sharpness":"锐度","cam.controls.applySettings":"应用全部设置","cam.controls.applyAll":"保存参数","cam.controls.applyRuntime":"保存运行参数","cam.controls.mode":"模式控制","cam.controls.autoExposure":"曝光模式","cam.controls.whiteBalance":"白平衡","cam.controls.colorMode":"颜色模式","cam.controls.auto":"自动","cam.controls.manual":"手动","cam.controls.night":"夜间","cam.controls.off":"关闭","cam.controls.nr.off":"关闭","cam.controls.nr.fast":"快速","cam.controls.nr.high_quality":"高质量","cam.controls.wb.auto":"自动","cam.controls.wb.daylight":"日光","cam.controls.wb.cloudy":"阴天","cam.controls.wb.tungsten":"钨丝灯","cam.controls.wb.fluorescent":"荧光灯","cam.controls.wb.indoor":"室内","cam.controls.wb.manual":"手动","cam.controls.wb.night":"夜间","cam.controls.color":"彩色","cam.controls.mono":"黑白","cam.controls.applyAe":"应用曝光模式","cam.controls.applyWb":"应用白平衡","cam.controls.applyColor":"应用颜色模式","cam.controls.applyMode":"应用模式设置","cam.controls.lockedByAe":"自动曝光开启时,曝光与增益参数锁定。","cam.controls.lockedByWb":"当前非手动白平衡模式,R/B 增益参数锁定。","cam.controls.pendingChanges":"有未应用的参数修改","cam.controls.tools":"工具","cam.controls.nightPreset":"夜间预设","cam.controls.nightOn":"开启夜间模式","cam.controls.nightOff":"关闭夜间模式","cam.controls.backup":"备份设置","cam.controls.restore":"恢复设置","cam.controls.reset":"重置相机","cam.quick.title":"快速预设与智能调参","cam.quick.daylight":"白天模式","cam.quick.night":"夜间模式","cam.quick.nightPreset":"应用夜间预设","cam.quick.deep-sky":"深空模式","cam.quick.planetary":"行星模式","cam.quick.autoAdjust":"智能调整","cam.quick.nightHint":"夜间预设用于一键切换夜拍参数;夜间模式开关用于实时启停红外/夜景模式。","cam.system.title":"系统监控","cam.system.status":"相机就绪","cam.system.stream":"流状态","cam.system.sensor":"传感器","cam.system.quality":"图像质量","cam.presets.title":"预设管理","cam.presets.name":"预设名称","cam.presets.desc":"预设描述","cam.presets.save":"保存预设","cam.presets.empty":"暂无预设","cam.presets.noDesc":"无描述","cam.presets.apply":"应用","cam.presets.delete":"删除","cam.files.title":"文件管理","cam.files.refresh":"刷新文件列表","cam.files.empty":"暂无文件","cam.files.download":"下载","cam.files.info":"详情","cam.files.delete":"删除","cam.files.loadingInfo":"正在读取文件详情...","cam.files.prev":"上一页","cam.files.next":"下一页","cam.files.page":"第 {current}/{total} 页","cam.files.closeDetail":"关闭详情","cam.files.size":"文件大小","cam.files.type":"文件类型","cam.files.modified":"修改时间","cam.hist.title":"直方图","cam.hist.enabled":"启用直方图","cam.hist.luminance":"亮度通道","cam.hist.over":"过曝警告","cam.hist.expand":"展开直方图","cam.hist.collapse":"收起直方图","cam.notice.previewStart":"预览已启动","cam.notice.previewStop":"预览已停止","cam.notice.captureSaved":"拍摄完成: {name}","cam.notice.recordStart":"开始录制: {name}","cam.notice.recordStop":"录制已停止","cam.notice.fpsApplied":"帧率已设置为 {fps}","cam.notice.resApplied":"分辨率已设置为 {w}x{h}","cam.notice.samplingApplied":"采样模式已设置为 {mode}","cam.notice.settingsApplied":"参数已应用","cam.notice.aeApplied":"曝光模式已应用","cam.notice.wbApplied":"白平衡已应用","cam.notice.colorApplied":"颜色模式已应用","cam.notice.modeApplied":"模式设置已应用","cam.notice.runtimeApplied":"运行参数已应用","cam.notice.rotationApplied":"旋转已设置为 {value}°","cam.mirror.hint":"相机倒装时可配合旋转使用;镜像后预览与星点解算、极轴引导同坐标系。","cam.mirror.horizontal":"水平镜像","cam.mirror.vertical":"垂直镜像","cam.notice.mirrorApplied":"镜像设置已更新","cam.notice.nightOn":"夜间模式已开启","cam.notice.nightOff":"夜间模式已关闭","cam.notice.nightPreset":"夜间预设已应用","cam.notice.reset":"相机设置已重置","cam.notice.backup":"设置已备份","cam.notice.restore":"设置已恢复","cam.notice.presetSaved":"预设已保存: {name}","cam.notice.presetApplied":"预设已应用: {name}","cam.notice.presetDeleted":"预设已删除: {name}","cam.notice.download":"已开始下载: {name}","cam.notice.downloadWithSidecar":"已下载: {name}(含侧车 {sidecar})","cam.notice.fileDeleted":"文件已删除: {name}","cam.notice.quickPresetApplied":"已应用快速预设: {preset}","cam.notice.needPreview":"请先启动预览","cam.notice.needManualForAutoAdjust":"请先切换为手动曝光再进行智能调整","cam.notice.autoAdjusted":"智能调整已完成并生效","cam.confirm.deletePreset":"确定删除预设 {name} 吗?","cam.confirm.deleteFile":"确定删除文件 {name} 吗?","sys.sensors.mag.calStatusPrefix":"校准状态:","sys.sensors.mag.calModeAuto":"自动模式(未锁定)","sys.sensors.mag.calModeRecording":"录制中(请旋转设备)","sys.sensors.mag.calModeLocked":"已锁定(使用保存方向)","sys.sensors.mag.calSamplesPrefix":"录制样本:","sys.sensors.mag.calRecordingHint":"操作提示:保持水平,缓慢旋转设备,建议累计 10+ 样本后保存。","sys.sensors.mag.calLockedHint":"已锁定方向参数:"},g=a.createContext(null),p={zh:C,en:k},b="ogscope.analysis.locale";function y(){const e=window.localStorage.getItem(b);return e==="zh"||e==="en"?e:(navigator.language||"zh").toLowerCase().startsWith("en")?"en":"zh"}function R({children:e}){const[s,t]=a.useState(y),[o,l]=a.useState(p[y()]);a.useEffect(()=>{l(p[s]),window.localStorage.setItem(b,s),document.documentElement.lang=s==="en"?"en":"zh-CN"},[s]);const r=a.useMemo(()=>(n,c)=>{let i=o[n]??n;if(c)for(const[d,h]of Object.entries(c))i=i.replace(new RegExp(`\\{${d}\\}`,"g"),String(h));return i},[o]),m=a.useMemo(()=>({locale:s,setLocale:t,t:r}),[s,r]);return v.jsx(g.Provider,{value:m,children:e})}function x(){const e=a.useContext(g);if(!e)throw new Error("useI18n must be used within I18nProvider");return e}export{R as I,A as T,P as c,x as u}; diff --git a/web/static/analysis-lab/assets/index-Cu-N6Gfx.js b/web/static/analysis-lab/assets/index-Cu-N6Gfx.js deleted file mode 100644 index 937041b..0000000 --- a/web/static/analysis-lab/assets/index-Cu-N6Gfx.js +++ /dev/null @@ -1,26 +0,0 @@ -import{r as a,j as v}from"./client-D1ZVDB-N.js";/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const f=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),u=(...e)=>e.filter((s,t,o)=>!!s&&s.trim()!==""&&o.indexOf(s)===t).join(" ").trim();/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */var w={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const S=a.forwardRef(({color:e="currentColor",size:s=24,strokeWidth:t=2,absoluteStrokeWidth:o,className:l="",children:r,iconNode:m,...n},c)=>a.createElement("svg",{ref:c,...w,width:s,height:s,stroke:e,strokeWidth:o?Number(t)*24/Number(s):t,className:u("lucide",l),...n},[...m.map(([i,d])=>a.createElement(i,d)),...Array.isArray(r)?r:[r]]));/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const P=(e,s)=>{const t=a.forwardRef(({className:o,...l},r)=>a.createElement(S,{ref:r,iconNode:s,className:u(`lucide-${f(e)}`,o),...l}));return t.displayName=`${e}`,t};/** - * @license lucide-react v0.460.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const A=P("Trash2",[["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6",key:"4alrt4"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2",key:"v07s0e"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17",key:"1uufr5"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17",key:"xtxkd"}]]),k={"app.title":"OGScope Plate Solve Console","nav.lab":"Lab","nav.labImage":"Image solve","nav.labVideo":"Video solve","delete.uploadCascade":"Delete {n} linked experiment record(s) as well?","lab.solveCurrentFrame":"Solve current frame (file)","lab.solveFileStart":"Start continuous file solve","lab.solveFileStop":"Stop continuous file solve","lab.cameraPreviewLoading":"Connecting to shared preview…","lab.solveCameraFrame":"Solve live camera frame","lab.solveCameraStart":"Start camera solve","lab.solveCameraStop":"Stop camera solve","lab.videoPreviewFailed":"This video format may be unsupported by the browser. Try MP4 (H.264) or WebM.","lab.previewModeFile":"Pool file","lab.previewModeCamera":"Device camera","lab.videoLiveIntro":"Shares the same camera as Camera Debug — preview and solve live frames here without opening debug. Both pages can run together.","lab.videoMjpegHint":"MJPEG slots are full: close live previews on other pages or retry later (default ~4; tune OGSCOPE_STREAM_MAX_MJPEG_CLIENTS in systemd).","lab.cameraStreamStatusFail":"Could not read MJPEG slot status. Please retry shortly.","lab.cameraSnapshotName":"ogscope_camera_live","lab.metric.probRaw":"Raw Prob","lab.systemLoad":"System load","results.saveBatchAll":"Save all to records","nav.pool":"Assets","nav.history":"Records","nav.cameraDebug":"Camera Debug","nav.home":"Home","nav.systemAdmin":"Back to System Admin","lang.zh":"中文","lang.en":"EN","sidebar.assets":"Uploaded assets","sidebar.upload":"Upload","sidebar.refresh":"Refresh","sidebar.debugCaptures":"Debug console media","sidebar.assetTypeImage":"Image","sidebar.assetTypeVideo":"Video","sidebar.debugEmpty":"No debug files","sidebar.importToPool":"Import to pool","sidebar.importToPoolWithTranscode":"Transcode & Import","sidebar.importToPoolDirect":"Import Directly","sidebar.flowPreparing":"Preparing...","sidebar.flowImportingDebug":"Importing debug capture...","sidebar.flowDownloadingPool":"Downloading imported file...","sidebar.flowWritingBuffer":"Writing to transcoder buffer...","sidebar.flowLoadingTranscoder":"Loading transcoder...","sidebar.flowTranscoding":"Transcoding AVI -> MP4...","sidebar.flowPackaging":"Packaging output...","sidebar.flowUploadingMp4":"Uploading MP4...","sidebar.flowReplacing":"Replacing and removing original AVI...","sidebar.flowNoTranscode":"No transcode required, imported directly.","sidebar.flowDone":"Completed","sidebar.flowDoneMsg":"Done: {name}","sidebar.flowFailed":"Failed","sidebar.debugPage":"Page {cur} / {total}","sidebar.batchPresets":"Batch presets","sidebar.batchHint":"Check presets, then use Batch solve to compare multiple param sets.","lab.selectOrUpload":"Pick an uploaded or imported asset from the left","lab.selectOrUploadVideo":"Pick a pool video to preview, or use the button above for the live camera frame.","lab.file":"File","lab.source":"Source","lab.layers":"Layers","lab.layer.matched":"Matched","lab.layer.pattern":"Pattern","lab.layer.all":"All centroids","lab.layer.rejected":"Rejected (red cross)","lab.previewConfidence":"Confidence","lab.grid":"Grid","lab.zoomIn":"Zoom in","lab.zoomOut":"Zoom out","lab.zoomReset":"Reset","lab.resolution":"Resolution","lab.fwhm":"FWHM","lab.starsDetected":"Stars detected","lab.meta.title":"Capture & file info","lab.meta.noSidecar":"No sidecar (not from debug capture)","lab.meta.partial":"No detailed sidecar; file info only.","lab.solveSection":"Solve","lab.imageSection":"Image","lab.metric.solveMs":"Time","lab.metric.solveComputeMs":"Solve compute","lab.metric.solveComputeHelp":"Server-side Tetra3 + star extraction only (no network).","lab.metric.solveRoundTripMs":"End-to-end","lab.metric.solveRoundTripHelp":"From request start to UI updated: network + JSON + render.","lab.metric.backendTotalMs":"Backend total","lab.metric.openDecodeMs":"Open/decode","lab.metric.preprocessMs":"Preprocess","lab.metric.extractMs":"Extract","lab.metric.solveOnlyMs":"Solve match","lab.metric.probHelp":"Tetra3 `Prob` is false-positive probability (lower is better). We use (1−Prob)×100% when representable; for extremely small Prob, IEEE doubles round to 100%, so we map −log10(Prob) to ~70–100% for differentiation.","lab.metric.probRawHelp":"Raw Tetra3 Prob (e.g. log-likelihood); compare with the normalized line above.","lab.metric.radec":"RA / Dec","lab.metric.matches":"Matches","lab.metric.rmse":"RMSE","lab.metric.prob":"Prob.","lab.metric.status":"Status","lab.gateNextMs":"Retry in ~{ms} ms","lab.gate.skipRecording":"Recording is active: the camera is writing video, so live-frame solve is paused. Stop recording and try again.","lab.gate.skipBusyInFlight":"The previous solve is still running; please wait. If this repeats often, increase the realtime solve interval.","lab.gate.skipInterval":"Solve requests are arriving faster than allowed; the server is throttling. Wait for the countdown and retry.","lab.gate.skipBusyFallback":"Realtime solve could not start ({detail}).","lab.centroidQualityTitle":"Centroid quality","lab.centroidQualityMetrics":"In {in} → kept {out}; removed dense {dense}, collinear {line}","meta.exposure":"Exposure","meta.gain":"Gain","meta.fps":"FPS","meta.sensor":"Sensor","meta.colorMode":"Color","meta.outputResolution":"Output size","meta.fileTime":"File time","meta.fileSize":"File size","results.viewRaw":"Raw JSON","results.hideRaw":"Hide","results.solveHistoryTitle":"Solve history","results.historySingle":"Single frame","results.historyBatch":"Batch ({count} runs)","params.title":"Solve parameters","params.blockSolveIntro":"Plate-solve (Tetra3): FOV, timeout, and coarse sky hints. FOV should match your lens.","params.centroid":"Star detection","params.blockCentroidIntro":"Star detection: threshold, blob area, and local background window for centroids.","params.fov":"FOV estimate (°)","params.fovHelp":"Horizontal field of view for lost-in-space solve.","params.fovErr":"FOV max error (°)","params.fovErrHelp":"Search range around estimated FOV.","params.timeout":"Timeout (ms)","params.timeoutHelp":"Max wait time per solve.","params.solveIntervalMs":"Realtime solve interval (ms)","params.solveIntervalMsHelp":"Desired interval is adjustable, but backend clamps it into a safe range.","params.solveIntervalIndependent":"Solve cadence is independent from sensor and preview frame rates.","params.solveIntervalBound":"Backend bounds: {min}-{max} ms, effective now: {effective} ms","params.centroidRejectionLevel":"Centroid rejection (1–5)","params.centroidRejectionLevelHelp":"1 keeps more stars; 5 aggressively rejects dense clusters and collinear false detections; default 3.","params.centroidRejectionScale":"Conservative ← → Aggressive","params.solveProfile":"Solve profile","params.solveProfileHelp":"Speed/Balanced/Robust tune timeout, centroid thresholds, and matching star count together.","params.solveProfileSpeed":"Speed first","params.solveProfileBalanced":"Balanced","params.solveProfileRobust":"Robust first","params.ra":"RA hint (°)","params.raHelp":"Approximate right ascension in degrees.","params.dec":"Dec hint (°)","params.decHelp":"Approximate declination in degrees.","params.maxSide":"Max long side before extract (px)","params.maxSideHelp":"Downscale long edge for faster centroid extraction.","params.detailLevelFull":"Include full Tetra3 raw block (larger payload, for debugging only).","params.largeScaleBg":"Large-scale background flattening","params.largeScaleBgHelp":"Before centroiding, estimate a low-frequency background on a downscaled image and correct uneven illumination (e.g. corner glow). Off by default to match legacy behavior.","params.sigma":"σ threshold","params.sigmaHelp":"Multiplier over background noise for star candidates.","params.maxArea":"max_area","params.maxAreaHelp":"Max connected component area in pixels.","params.minArea":"min_area","params.minAreaHelp":"Min connected component area in pixels.","params.filtsize":"filtsize (odd)","params.filtsizeHelp":"Local filter window size, must be odd.","btn.solveOne":"Solve once","btn.solveBatch":"Batch solve (presets)","btn.applyPresets":"Apply preset to form","btn.savePreset":"Save","placeholder.newPreset":"New preset name","pool.title":"Server asset pool","pool.col.name":"Filename","pool.col.source":"Source","pool.col.size":"Size","pool.col.time":"Modified","pool.delete":"Delete","history.title":"Experiment records","history.intro":"Saved solve snapshots from the Lab. After a solve, use Save to records in the Lab main panel (Result comparison), or Save on each batch result card. Search by filename or preset; export JSON/CSV for backup.","history.search":"Search…","history.searchBtn":"Search","history.exportJson":"Export JSON","history.exportCsv":"Export CSV","history.total":"Total {n}","history.preset":"Preset","history.metrics":"Metrics","history.detail":"Details","history.collapse":"Collapse","history.prev":"Prev","history.next":"Next","history.delete":"Delete","delete.uploadFirst":'Delete "{name}" from the asset pool?',"delete.uploadSecond":"This cannot be undone. Confirm again?","delete.experimentFirst":"Delete this experiment record?","delete.experimentSecond":"This cannot be undone. Confirm again?","results.title":"Results","results.saveCurrent":"Save to records","results.saveRow":"Save","results.expand":"Expand","results.collapseJson":"Collapse","err.selectFile":"Select a file","err.selectPresets":"Select at least one preset","common.placeholder":"—","lab.transcode.title":"AVI requires transcoding","lab.transcode.desc":"This video is AVI. For browser preview and continuous solving, transcode it locally to MP4 and upload replacement. The original AVI on server will be removed after success.","lab.transcode.button":"Transcode and Upload Replacement","lab.transcode.loading":"Loading transcoder...","lab.transcode.writingBuffer":"Writing to transcoder buffer...","lab.transcode.running":"Transcoding AVI -> MP4...","lab.transcode.packaging":"Packaging transcoded output...","lab.transcode.uploading":"Uploading transcoded output...","lab.transcode.done":"Transcode and replacement completed.","lab.transcode.failed":"Transcode or upload failed. Please retry.","lab.transcode.fetchFailed":"Failed to fetch AVI from server.","sys.shell.subtitle":"system debug console","sys.shell.nav.overview":"Overview","sys.shell.nav.network":"Network","sys.shell.nav.camera":"Camera Debug","sys.shell.nav.analysis":"Analysis Console","sys.shell.nav.sensors":"Sensors","sys.shell.nav.power":"Power","sys.shell.nav.hmi":"HMI","sys.shell.nav.config":"Config","sys.shell.workbench":"WORKBENCH / System workspace","sys.shell.node":"Node","sys.shell.top.overview":"System Status","sys.shell.top.network":"Network & WiFi","sys.shell.top.sensors":"Sensors","sys.shell.top.power":"Power Management","sys.shell.top.hmi":"HMI","sys.shell.top.config":"Config","sys.overview.breadcrumb.console":"Console","sys.overview.breadcrumb.module":"System Overview","sys.overview.title":"System Overview","sys.overview.subtitle":"Live system telemetry snapshot","sys.overview.metric.cpu":"CPU Usage","sys.overview.metric.mem":"Memory Usage","sys.overview.metric.temp":"Core Temperature","sys.overview.metric.wifi":"WiFi Link","sys.overview.metric.uptime":"Uptime","sys.overview.metric.load":"Load (1m)","sys.overview.metric.storage":"Storage","sys.overview.linkActive":"Link Active","sys.overview.tempState":"status: nominal","sys.overview.wifiSummary":"WiFi Summary","sys.overview.iface":"Interface","sys.overview.signal":"Signal","sys.overview.quality":"Quality","sys.overview.storageComingSoon":"Coming soon","sys.logs.title":"System Logs","sys.logs.kernel":"Kernel","sys.logs.liveToggle":"Live pull","sys.logs.refresh":"Refresh","sys.logs.liveOffHint":"Live pull is off (default)","sys.logs.loading":"Loading logs...","sys.logs.empty":"No logs","sys.placeholder.breadcrumb":"Console / Reserved Module","sys.placeholder.block":"PLANNED BLOCK","sys.placeholder.desc":"Shell layout is reserved and ready for real data integration.","sys.placeholder.status":"STATUS: RESERVED / Structured placeholder page.","sys.placeholder.sensors.title":"Sensors Console","sys.placeholder.sensors.desc":"Unified monitor for IMU, environment, power rails, and timeline sampling.","sys.placeholder.sensors.block1":"Realtime chart area","sys.placeholder.sensors.block2":"Device health cards","sys.placeholder.sensors.block3":"Threshold alerts","sys.sensors.title":"Sensors Console","sys.sensors.desc":"Visual compass (magnetometer) and gyro rate gauges; backed by dev debug APIs.","sys.sensors.mag.section":"Magnetometer (AK09911 / I²C)","sys.sensors.mag.note":"Defaults: bus 1, address 12 (0x0C with CAD to GND). No app-level GPIO mapping is required; enable I²C in firmware and load i2c-dev.","sys.sensors.mag.bus":"I²C bus","sys.sensors.mag.addr":"7-bit address (decimal, 12 = 0x0C)","sys.sensors.mag.i2cdetect":"Run i2cdetect","sys.sensors.mag.btnSelftest":"Run self-test","sys.sensors.mag.btnProbe":"Probe all buses","sys.sensors.mag.btnCalStart":"Start heading calibration","sys.sensors.mag.btnCalCommit":"Save & lock heading","sys.sensors.mag.btnCalReset":"Reset to auto mode","sys.sensors.mag.btnCalStatus":"Calibration status","sys.sensors.mag.running":"Running…","sys.sensors.mpu.section":"IMU (MPU-6050 / I²C)","sys.sensors.mpu.note":"Defaults: bus 1, address 104 (0x68 with AD0 low). Includes gyro angular-rate sampling (MPU integrates accel + gyro).","sys.sensors.mpu.addr":"7-bit address (decimal, 104 = 0x68)","sys.sensors.mpu.btnSelftest":"Run MPU self-test","sys.sensors.mpu.running":"Running…","sys.sensors.gyro.title":"Gyroscope (MPU-6050)","sys.sensors.gyro.subtitle":"Uses the same bus/address as above; one IMU read: 3-axis gyro, accel-derived roll/pitch, and a 3D board hint.","sys.sensors.gyro.btn":"Read angular rate","sys.sensors.gyro.loading":"Sampling…","sys.sensors.gyro.dps":"Angular rate","sys.sensors.gyro.raw":"Raw","sys.sensors.gyro.unitDps":"°/s, ±250 °/s range, default sensitivity","sys.sensors.gyro.unitRaw":"signed 16-bit","sys.sensors.gyro.hint":"Click “Read angular rate” to sample accel (0x3B) and gyro (0x43). Roll/pitch use gravity; ωz is yaw rate, not absolute heading.","sys.sensors.gyro.errUnknown":"Sample failed","sys.sensors.gyro.live":"Live update (~2 Hz)","sys.sensors.gyro.barsTitle":"Angular rate bars (±250 °/s full scale)","sys.sensors.gyro.rawBlock":"Raw register values (16-bit)","sys.sensors.gyro.att3dTitle":"3D attitude hint","sys.sensors.gyro.att3dDesc":"Roll/pitch from accelerometer (gravity); ωz is yaw rate (not magnetic heading). Body XYZ triad is fixed to the PCB and rotates in 3D.","sys.sensors.gyro.axisXLabel":"+X","sys.sensors.gyro.axisYLabel":"+Y","sys.sensors.gyro.axisZLabel":"+Z","sys.sensors.gyro.att3dBodyAxes":"Body frame on the PCB: +X amber, +Y sky, +Z violet (normal); short dashes mark negative directions; the whole frame follows roll/pitch.","sys.sensors.gyro.att3dRefTitle":"Orthogonal axes (fixed view)","sys.sensors.gyro.att3dRefDesc":"Same colors as left; use this to read XYZ directions at a glance.","sys.sensors.gyro.roll":"Roll","sys.sensors.gyro.pitch":"Pitch","sys.sensors.gyro.yawRate":"Yaw rate","sys.sensors.compass.tapeCaption":"Heading tape · center = current readout","sys.sensors.compass.title":"Magnetometer · North compass","sys.sensors.compass.desc":"Horizontal-plane heading for “roughly north” debugging; precise alignment needs calibration and a fixed mount definition.","sys.sensors.compass.btn":"Refresh compass","sys.sensors.compass.loading":"Measuring…","sys.sensors.compass.live":"Live update","sys.sensors.compass.headingLabel":"Horizontal heading","sys.sensors.compass.headingHint":"From atan2(Hx, Hy), degrees","sys.sensors.compass.cardN":"N","sys.sensors.compass.cardE":"E","sys.sensors.compass.cardS":"S","sys.sensors.compass.cardW":"W","sys.sensors.compass.err":"Sample failed","sys.sensors.compass.footnote":"The red tip tracks the horizontal XY field direction; it should move as you rotate the board. Use soft/hard-iron calibration for tight north lock.","sys.sensors.jsonToggle":"Raw JSON (troubleshooting)","sys.hmi.title":"HMI · SPI display","sys.hmi.desc":"ST7796 via hardware plane (default 320×320). Requires OGSCOPE_DISPLAY_ENABLED=true and SPI enabled (/dev/spidev0.0).","sys.hmi.status.section":"Display & hardware plane","sys.hmi.status.refresh":"Refresh","sys.hmi.status.displayEnabled":"DISPLAY_ENABLED","sys.hmi.status.spidev":"/dev/spidev0.0","sys.hmi.status.yes":"present","sys.hmi.status.no":"missing","sys.hmi.status.resolution":"Resolution / DC","sys.hmi.status.driver":"Driver handle","sys.hmi.status.open":"open","sys.hmi.status.closed":"closed","sys.hmi.status.screenOutput":"Logical output","sys.hmi.status.on":"on","sys.hmi.status.off":"off","sys.hmi.status.lastPattern":"Last pattern","sys.hmi.status.lastError":"Last error","sys.hmi.actions.section":"Output to panel","sys.hmi.actions.hint":"Uses hardware plane device.command → hmi; large frames use a longer RPC timeout.","sys.hmi.actions.smoke":"Test pattern (text + frame)","sys.hmi.actions.colorbars":"Color bars","sys.hmi.actions.fill":"Fill RGB","sys.hmi.actions.screenOn":"Enable panel output","sys.hmi.actions.screenOff":"Pause panel output","sys.hmi.actions.release":"Release driver (close SPI/GPIO)","sys.hmi.rawJson":"Raw JSON response","sys.placeholder.hmi.title":"HMI Console","sys.placeholder.hmi.desc":"Debug workspace for SPI display, key matrix, backlight, and UI replay.","sys.placeholder.hmi.block1":"Display preview","sys.placeholder.hmi.block2":"Input event stream","sys.placeholder.hmi.block3":"Mode controls","sys.placeholder.power.title":"Power Console","sys.placeholder.power.desc":"Debug workspace for battery, charging, power profile, and thermal policy.","sys.placeholder.power.block1":"Power dashboard","sys.placeholder.power.block2":"Power policy table","sys.placeholder.power.block3":"Recovery actions","cam.title":"Camera Debug Console","cam.subtitle":"Migrated to unified admin UI with key optimizations preserved","cam.btn.system":"System Admin","cam.btn.analysis":"Analysis Console","cam.btn.home":"Home","cam.btn.start":"Start Preview","cam.btn.starting":"Starting...","cam.btn.stop":"Stop Preview","cam.btn.stopping":"Stopping...","cam.btn.capture":"Capture","cam.btn.capturing":"Capturing...","cam.btn.recordStart":"Start Recording","cam.btn.recordStop":"Stop Recording","cam.btn.recordBusy":"Switching recording...","cam.btn.refresh":"Refresh Status","cam.preview.title":"Live Preview","cam.preview.state":"State","cam.preview.mode":"Exposure Mode","cam.preview.emptyTitle":"Preview is not started","cam.preview.emptyDesc":"Start the camera preview stream with the button below.","cam.state.streaming":"streaming","cam.state.idle":"idle","cam.state.rec":"REC","cam.stats.requestFps":"Request FPS","cam.hint.mjpegSingleStream":"MJPEG concurrent streams are limited (default ~2). Beyond the cap, extra tabs may contend or get busy—reduce simultaneous live previews.","cam.stats.frameFps":"Display FPS","cam.stats.fpsMeasureNote":"Pixel deltas on a 32×32 sample over ~1s. Multiple previews, slow links, or a stream cap below the sensor target often read much lower.","cam.err.streamBusy":"Preview unavailable: MJPEG slot is already in use","cam.err.streamBusyHint":"Close the live preview in other tabs or in the Analysis console, then try again.","cam.err.streamProbeFailed":"Could not open the preview stream","cam.err.streamProbeDetail":"Check network and camera service, or retry in a moment.","cam.stats.targetFps":"Camera capture target FPS","cam.stats.streamPacingFps":"Shared stream / MJPEG cap FPS","cam.stats.streamPacingHint":"Matches OGSCOPE_SHARED_PREVIEW_FPS: shared grabber pacing and per-stream MJPEG min frame spacing.","cam.stats.captureFps":"Actual capture FPS","cam.stats.encodeMs":"Average JPEG encode","cam.stats.consumers":"Preview/analysis/record consumers","cam.stats.cameraMemory":"Process memory/swap","cam.stats.longExposureThrottle":"Auto exposure is about {exposure} ms, so long exposure is limiting FPS; exposure is not shortened to preserve analysis quality.","cam.stats.uptime":"Stream Uptime","cam.controls.title":"Controls","cam.controls.core":"Core Settings","cam.controls.fps":"FPS","cam.controls.sensorFps":"Sensor target FPS","cam.controls.previewFps":"Preview target FPS","cam.controls.resolution":"Resolution","cam.controls.applyRes":"Apply resolution","cam.controls.sampling":"Sampling mode","cam.controls.exposure":"Exposure","cam.controls.gain":"Analog Gain","cam.controls.digitalGain":"Digital Gain","cam.controls.noiseReduction":"Noise Reduction","cam.controls.contrast":"Contrast","cam.controls.brightness":"Brightness","cam.controls.saturation":"Saturation","cam.controls.sharpness":"Sharpness","cam.controls.applySettings":"Apply All Settings","cam.controls.applyAll":"Save Parameters","cam.controls.applyRuntime":"Save Runtime Settings","cam.controls.mode":"Mode Controls","cam.controls.autoExposure":"Exposure Mode","cam.controls.whiteBalance":"White Balance","cam.controls.colorMode":"Color Mode","cam.controls.auto":"Auto","cam.controls.manual":"Manual","cam.controls.night":"Night","cam.controls.color":"Color","cam.controls.mono":"Mono","cam.controls.applyAe":"Apply Exposure Mode","cam.controls.applyWb":"Apply White Balance","cam.controls.applyColor":"Apply Color Mode","cam.controls.applyMode":"Apply Mode Settings","cam.controls.lockedByAe":"Exposure and gain are locked while auto exposure is enabled.","cam.controls.lockedByWb":"R/B gains are locked unless white balance is in manual mode.","cam.controls.pendingChanges":"You have unapplied parameter changes","cam.controls.tools":"Tools","cam.controls.nightPreset":"Night Preset","cam.controls.nightOn":"Enable Night Mode","cam.controls.nightOff":"Disable Night Mode","cam.controls.backup":"Backup Settings","cam.controls.restore":"Restore Settings","cam.controls.reset":"Reset Camera","cam.quick.title":"Quick Presets & Smart Tuning","cam.quick.daylight":"Daylight","cam.quick.night":"Night","cam.quick.nightPreset":"Apply Night Preset","cam.quick.deep-sky":"Deep Sky","cam.quick.planetary":"Planetary","cam.quick.autoAdjust":"Auto Adjust","cam.quick.nightHint":"Night preset is one-click tuning; night mode toggle is runtime on/off control.","cam.system.title":"System Monitor","cam.system.status":"Camera Ready","cam.system.stream":"Stream","cam.system.sensor":"Sensor","cam.system.quality":"Image Quality","cam.presets.title":"Preset Management","cam.presets.name":"Preset Name","cam.presets.desc":"Preset Description","cam.presets.save":"Save Preset","cam.presets.empty":"No presets","cam.presets.noDesc":"No description","cam.presets.apply":"Apply","cam.presets.delete":"Delete","cam.files.title":"File Management","cam.files.refresh":"Refresh File List","cam.files.empty":"No files","cam.files.download":"Download","cam.files.info":"Details","cam.files.delete":"Delete","cam.files.loadingInfo":"Loading file details...","cam.files.prev":"Prev","cam.files.next":"Next","cam.files.page":"Page {current}/{total}","cam.files.closeDetail":"Close details","cam.files.size":"Size","cam.files.type":"Type","cam.files.modified":"Modified","cam.hist.title":"Histogram","cam.hist.enabled":"Enable histogram","cam.hist.luminance":"Luminance","cam.hist.over":"Over-exposure","cam.hist.expand":"Expand Histogram","cam.hist.collapse":"Collapse Histogram","cam.notice.previewStart":"Preview started","cam.notice.previewStop":"Preview stopped","cam.notice.captureSaved":"Capture saved: {name}","cam.notice.recordStart":"Recording started: {name}","cam.notice.recordStop":"Recording stopped","cam.notice.fpsApplied":"FPS updated to {fps}","cam.notice.resApplied":"Resolution updated to {w}x{h}","cam.notice.samplingApplied":"Sampling mode updated: {mode}","cam.notice.settingsApplied":"Settings applied","cam.notice.aeApplied":"Exposure mode applied","cam.notice.wbApplied":"White balance applied","cam.notice.colorApplied":"Color mode applied","cam.notice.modeApplied":"Mode settings applied","cam.notice.runtimeApplied":"Runtime settings applied","cam.notice.rotationApplied":"Rotation set to {value}°","cam.mirror.hint":"Use with rotation when the camera is mounted inverted; preview, plate solve, and polar guide share one coordinate frame.","cam.mirror.horizontal":"Horizontal mirror","cam.mirror.vertical":"Vertical mirror","cam.notice.mirrorApplied":"Mirror settings updated","cam.notice.nightOn":"Night mode enabled","cam.notice.nightOff":"Night mode disabled","cam.notice.nightPreset":"Night preset applied","cam.notice.reset":"Camera settings reset","cam.notice.backup":"Settings backed up","cam.notice.restore":"Settings restored","cam.notice.presetSaved":"Preset saved: {name}","cam.notice.presetApplied":"Preset applied: {name}","cam.notice.presetDeleted":"Preset deleted: {name}","cam.notice.download":"Download started: {name}","cam.notice.downloadWithSidecar":"Downloaded: {name} (with sidecar {sidecar})","cam.notice.fileDeleted":"File deleted: {name}","cam.notice.quickPresetApplied":"Quick preset applied: {preset}","cam.notice.needPreview":"Start preview first","cam.notice.needManualForAutoAdjust":"Switch to manual exposure before auto-adjust","cam.notice.autoAdjusted":"Auto-adjust completed and applied","cam.confirm.deletePreset":"Delete preset {name}?","cam.confirm.deleteFile":"Delete file {name}?","sys.sensors.mag.calStatusPrefix":"Calibration mode:","sys.sensors.mag.calModeAuto":"Auto (not locked)","sys.sensors.mag.calModeRecording":"Recording (rotate device)","sys.sensors.mag.calModeLocked":"Locked (saved heading)","sys.sensors.mag.calSamplesPrefix":"Recorded samples:","sys.sensors.mag.calRecordingHint":"Tip: keep level and rotate slowly; save after 10+ samples.","sys.sensors.mag.calLockedHint":"Locked heading params:"},C={"app.title":"OGScope 星空解算控制台","nav.lab":"解算台","nav.labImage":"图片解算","nav.labVideo":"视频解算","nav.pool":"素材池","nav.history":"实验记录","nav.cameraDebug":"相机调试控制台","nav.home":"首页","nav.systemAdmin":"返回系统后台","lang.zh":"中文","lang.en":"EN","sidebar.assets":"自行上传素材","sidebar.upload":"上传文件","sidebar.refresh":"刷新列表","sidebar.debugCaptures":"调试控制台素材","sidebar.assetTypeImage":"图片","sidebar.assetTypeVideo":"视频","sidebar.debugEmpty":"暂无调试文件","sidebar.importToPool":"导入到素材池","sidebar.importToPoolWithTranscode":"转码并导入素材池","sidebar.importToPoolDirect":"直接导入素材池","sidebar.flowPreparing":"准备中…","sidebar.flowImportingDebug":"正在导入调试素材…","sidebar.flowDownloadingPool":"正在下载已导入文件…","sidebar.flowWritingBuffer":"正在写入转码缓冲区…","sidebar.flowLoadingTranscoder":"正在加载转码器…","sidebar.flowTranscoding":"正在转码 AVI -> MP4…","sidebar.flowPackaging":"正在封装转码结果…","sidebar.flowUploadingMp4":"正在上传 MP4…","sidebar.flowReplacing":"正在替换并清理原 AVI…","sidebar.flowNoTranscode":"该文件无需转码,已直接导入。","sidebar.flowDone":"流程完成","sidebar.flowDoneMsg":"已完成:{name}","sidebar.flowFailed":"流程失败","sidebar.debugPage":"第 {cur} / {total} 页","sidebar.batchPresets":"批量预设","sidebar.batchHint":"勾选后点击「批量解算」可一次用多组参数对比结果。","lab.selectOrUpload":"从左侧选择自行上传或已导入的素材","lab.selectOrUploadVideo":"从左侧选择视频素材预览;或使用上方按钮解算设备相机实时帧。","lab.file":"文件","lab.source":"来源","lab.layers":"叠加层","lab.layer.matched":"匹配星","lab.layer.pattern":"图案星","lab.layer.all":"全部质心","lab.layer.rejected":"已剔除(红叉)","lab.previewConfidence":"置信度","lab.grid":"网格","lab.zoomIn":"放大","lab.zoomOut":"缩小","lab.zoomReset":"复位","lab.resolution":"分辨率","lab.fwhm":"FWHM","lab.starsDetected":"检测星点","lab.meta.title":"拍摄与文件信息","lab.meta.noSidecar":"无侧车信息(非调试采集或仅本地上传)","lab.meta.partial":"暂无侧车详细字段,仅显示文件信息。","lab.solveSection":"解算","lab.imageSection":"图像","lab.metric.solveMs":"用时","lab.metric.solveComputeMs":"解算计算用时","lab.metric.solveComputeHelp":"服务端 Tetra3 与提星等纯计算耗时(与网络无关)。","lab.metric.solveRoundTripMs":"全链路用时","lab.metric.solveRoundTripHelp":"从本页发起请求到收到结果并完成界面刷新的总耗时,含网络往返与浏览器渲染。","lab.metric.backendTotalMs":"后端总用时","lab.metric.openDecodeMs":"读取/解码","lab.metric.preprocessMs":"预处理","lab.metric.extractMs":"提星","lab.metric.solveOnlyMs":"匹配解算","lab.metric.probHelp":"Tetra3 的 Prob 为假阳性概率(越低越可信)。一般使用 (1−Prob)×100%;当 Prob 极小时浮点数会舍入为 100%,此时用 −log₁₀(Prob) 映射到约 70–100% 以便区分。","lab.metric.probRawHelp":"Tetra3 返回的原始 Prob 字段,可能为对数似然等内部量;与上一行换算后的百分比对照查看即可。","lab.metric.radec":"RA / Dec","lab.metric.matches":"匹配","lab.metric.rmse":"RMSE","lab.metric.prob":"置信","lab.metric.status":"状态","lab.gateNextMs":"约 {ms} ms 后可重试","lab.gate.skipRecording":"录制中:相机正写入录像,暂无法从相机取帧解算;请先停止录制后再试。","lab.gate.skipBusyInFlight":"上一帧解算仍在处理中,请稍候。若频繁出现,可适当增大「实时解算间隔」。","lab.gate.skipInterval":"解算请求过于频繁,已按后端节流排队。","lab.gate.skipBusyFallback":"实时解算暂时无法开始({detail})。","lab.centroidQualityTitle":"质心质量","lab.centroidQualityMetrics":"输入 {in} → 保留 {out};剔除过密 {dense},共线 {line}","meta.exposure":"曝光","meta.gain":"增益","meta.fps":"帧率","meta.sensor":"传感器","meta.colorMode":"色彩","meta.outputResolution":"输出分辨率","meta.fileTime":"文件时间","meta.fileSize":"文件大小","results.viewRaw":"原始 JSON","results.hideRaw":"收起","results.solveHistoryTitle":"解算历史","results.historySingle":"单帧","results.historyBatch":"批量 {count} 组","params.title":"解算参数","params.blockSolveIntro":"以下为板块求解(Tetra3)搜索天区、超时与粗略指向提示;FOV 需与镜头视场大致一致。","params.centroid":"提星","params.blockCentroidIntro":"以下为星点检测:阈值、连通域面积与局部背景窗口,用于从图像中提取星点质心。","params.fov":"FOV 估计 (°)","params.fovHelp":"水平视场角估计值,用于 lost-in-space 解算。","params.fovErr":"FOV 允许误差 (°)","params.fovErrHelp":"允许 Tetra3 在估计 FOV 附近的搜索范围。","params.timeout":"超时 (ms)","params.timeoutHelp":"单次解算最长等待时间。","params.solveIntervalMs":"实时解算间隔 (ms)","params.solveIntervalMsHelp":"期望间隔可调整,但会被后端限制在安全范围内。","params.solveIntervalIndependent":"解算间隔独立于相机采集和预览帧率。","params.solveIntervalBound":"后端限制范围:{min}-{max} ms,当前生效:{effective} ms","params.centroidRejectionLevel":"质心剔除强度 (1–5)","params.centroidRejectionLevelHelp":"1 最保守保留更多星点,5 更激进剔除过密与共线假星;默认 3。","params.centroidRejectionScale":"保守 ← → 激进","params.solveProfile":"解算档位","params.solveProfileHelp":"速度/平衡/稳健三档会同时调整超时、提星阈值与参与匹配星点数。","params.solveProfileSpeed":"速度优先","params.solveProfileBalanced":"平衡","params.solveProfileRobust":"稳健优先","params.ra":"RA 提示 (°)","params.raHelp":"大致天球赤经,缩小搜索范围(度)。","params.dec":"Dec 提示 (°)","params.decHelp":"大致天球赤纬(度)。","params.maxSide":"提星前长边上界 (px)","params.maxSideHelp":"降采样长边上限,大图可加速提星。","params.detailLevelFull":"包含完整 Tetra3 原始结果(体积略大,仅调试时开启)","params.largeScaleBg":"大尺度背景减除","params.largeScaleBgHelp":"在提星前用低分辨率平滑估计并校正大尺度亮度不均,可减轻角部光晕导致的假星;默认关闭以保持与过往行为一致。","params.sigma":"σ(阈值倍数)","params.sigmaHelp":"高于背景噪声倍数的区域视为星点候选。","params.maxArea":"max_area","params.maxAreaHelp":"连通域最大像素面积。","params.minArea":"min_area","params.minAreaHelp":"连通域最小像素面积。","params.filtsize":"filtsize(奇数)","params.filtsizeHelp":"局部背景滤波窗口边长,须为奇数。","btn.solveOne":"单张解算","btn.solveBatch":"批量解算(勾选预设)","btn.applyPresets":"应用预设到表单","btn.savePreset":"保存","placeholder.newPreset":"新预设名称","pool.title":"服务器素材池","pool.col.name":"文件名","pool.col.source":"来源","pool.col.size":"大小","pool.col.time":"修改时间","pool.delete":"删除","history.title":"实验记录","history.intro":"此处展示你在解算台完成解算后手动保存的快照。用法:在「解算台」主栏「结果对比」中,单张解算后点「保存当前到实验记录」,或批量解算后在某张结果卡片上点「保存记录」。本页可按文件名或预设名搜索,支持导出 JSON/CSV 备份。","history.search":"搜索…","history.searchBtn":"搜索","history.exportJson":"导出 JSON","history.exportCsv":"导出 CSV","history.total":"共 {n} 条","history.preset":"预设","history.metrics":"指标","history.detail":"详情","history.collapse":"收起","history.prev":"上一页","history.next":"下一页","history.delete":"删除","delete.uploadFirst":"确定要从素材池删除「{name}」吗?","delete.uploadSecond":"此操作不可恢复,再次确认删除?","delete.experimentFirst":"确定要删除这条实验记录吗?","delete.experimentSecond":"此操作不可恢复,再次确认删除?","results.title":"结果对比","results.saveCurrent":"保存当前到实验记录","results.saveRow":"保存记录","results.expand":"展开","results.collapseJson":"收起","err.selectFile":"请选择素材","err.selectPresets":"请勾选至少一个预设","common.placeholder":"—","delete.uploadCascade":"该素材有 {n} 条实验记录,是否一并删除?","lab.solveCurrentFrame":"解算当前帧(文件)","lab.solveFileStart":"开始文件连续解算","lab.solveFileStop":"停止文件连续解算","lab.cameraPreviewLoading":"正在连接共享预览…","lab.solveCameraFrame":"解算相机当前帧","lab.solveCameraStart":"开始相机解算","lab.solveCameraStop":"停止相机解算","lab.videoPreviewFailed":"该视频格式可能不受浏览器支持,建议使用 MP4(H.264) 或 WebM。","lab.previewModeFile":"素材文件","lab.previewModeCamera":"设备相机","lab.stopCameraPreview":"停止预览","lab.videoLiveIntro":"与调试控制台共用同一相机;此处可预览并解算实时帧,无需单独打开调试页。两页可同时使用。","lab.videoMjpegHint":"MJPEG 同时连接数已满:请关闭其它页面的实时预览或稍后重试(默认允许约 4 路,可在 systemd 调整 OGSCOPE_STREAM_MAX_MJPEG_CLIENTS)。","lab.cameraStreamStatusFail":"无法读取视频流名额状态,请稍后重试。","lab.cameraSnapshotName":"ogscope_camera_live","lab.metric.probRaw":"原始 Prob","lab.systemLoad":"系统负载","results.saveBatchAll":"保存全部到实验记录","lab.transcode.title":"AVI 文件需先转码","lab.transcode.desc":"当前视频为 AVI。为保证浏览器可预览与连续解算,请先在本地转码为 MP4 并上传替换。上传成功后将自动删除服务器上的原 AVI。","lab.transcode.button":"转码并上传替换","lab.transcode.loading":"正在加载转码器…","lab.transcode.writingBuffer":"正在写入转码缓冲区…","lab.transcode.running":"正在转码 AVI -> MP4…","lab.transcode.packaging":"正在封装转码结果…","lab.transcode.uploading":"正在上传转码结果…","lab.transcode.done":"转码并替换完成,可继续预览与解算。","lab.transcode.failed":"转码或上传失败,请重试。","lab.transcode.fetchFailed":"无法读取服务器上的 AVI 文件。","sys.shell.subtitle":"系统调试控制台","sys.shell.nav.overview":"总览","sys.shell.nav.network":"网络","sys.shell.nav.camera":"相机调试","sys.shell.nav.analysis":"寻星控制台","sys.shell.nav.sensors":"传感器","sys.shell.nav.power":"电源","sys.shell.nav.hmi":"人机交互","sys.shell.nav.config":"配置管理","sys.shell.workbench":"WORKBENCH / 系统工作台","sys.shell.node":"节点","sys.shell.top.overview":"系统状态","sys.shell.top.network":"网络与 WiFi","sys.shell.top.sensors":"传感器","sys.shell.top.power":"电源管理","sys.shell.top.hmi":"人机交互","sys.shell.top.config":"配置管理","sys.overview.breadcrumb.console":"控制台","sys.overview.breadcrumb.module":"系统总览","sys.overview.title":"系统总览","sys.overview.subtitle":"实时系统运行健康状况","sys.overview.metric.cpu":"CPU 使用率","sys.overview.metric.mem":"内存占用","sys.overview.metric.temp":"核心温度","sys.overview.metric.wifi":"WiFi 链路","sys.overview.metric.uptime":"运行时长","sys.overview.metric.load":"1 分钟负载","sys.overview.metric.storage":"存储","sys.overview.linkActive":"链路在线","sys.overview.tempState":"状态: 正常","sys.overview.wifiSummary":"无线网络摘要","sys.overview.iface":"接口","sys.overview.signal":"信号","sys.overview.quality":"质量","sys.overview.storageComingSoon":"即将接入","sys.logs.title":"系统日志","sys.logs.kernel":"内核","sys.logs.liveToggle":"实时拉取","sys.logs.refresh":"刷新","sys.logs.liveOffHint":"实时拉取关闭(默认)","sys.logs.loading":"日志加载中...","sys.logs.empty":"暂无日志","sys.placeholder.breadcrumb":"控制台 / 预留模块","sys.placeholder.block":"预留模块","sys.placeholder.desc":"模块壳层已预留,后续将按统一组件体系接入真实数据。","sys.placeholder.status":"STATUS: RESERVED / 当前为结构化占位页面。","sys.placeholder.sensors.title":"传感器诊断台","sys.placeholder.sensors.desc":"用于 IMU、温湿度、电流电压、姿态与时序采样的统一监控面板。","sys.placeholder.sensors.block1":"实时曲线区","sys.placeholder.sensors.block2":"设备健康卡","sys.placeholder.sensors.block3":"阈值告警区","sys.sensors.title":"传感器诊断台","sys.sensors.desc":"磁力计罗盘指北与 MPU-6050 陀螺仪角速度可视化;底层仍为开发者调试 API。","sys.sensors.mag.section":"磁力计(AK09911 / I²C)","sys.sensors.mag.note":"默认总线 1、地址 12(0x0C,CAD 接 GND)。无需在应用层“定义 GPIO”;请确保固件已启用 I²C 且已加载 i2c-dev。","sys.sensors.mag.bus":"I²C 总线号","sys.sensors.mag.addr":"7-bit 地址(十进制,12=0x0C)","sys.sensors.mag.i2cdetect":"附带运行 i2cdetect","sys.sensors.mag.btnSelftest":"运行自检","sys.sensors.mag.btnProbe":"扫描各总线","sys.sensors.mag.btnCalStart":"开始方向校准","sys.sensors.mag.btnCalCommit":"保存并锁定方向","sys.sensors.mag.btnCalReset":"重置到自动模式","sys.sensors.mag.btnCalStatus":"查看校准状态","sys.sensors.mag.running":"执行中…","sys.sensors.mpu.section":"IMU(MPU-6050 / I²C)","sys.sensors.mpu.note":"默认总线 1、地址 104(0x68,AD0 接 GND)。含陀螺仪角速度采样(MPU 内集成加速度计与陀螺仪)。","sys.sensors.mpu.addr":"7-bit 地址(十进制,104=0x68)","sys.sensors.mpu.btnSelftest":"运行 MPU 自检","sys.sensors.mpu.running":"执行中…","sys.sensors.gyro.title":"陀螺仪(MPU-6050)","sys.sensors.gyro.subtitle":"使用上方同一总线/地址;一次读取加速度与陀螺仪,估算横滚/俯仰并显示 3D 板卡示意。","sys.sensors.gyro.btn":"读取角速度","sys.sensors.gyro.loading":"采样中…","sys.sensors.gyro.dps":"角速度","sys.sensors.gyro.raw":"原始值","sys.sensors.gyro.unitDps":"°/s,±250°/s 量程、默认灵敏度","sys.sensors.gyro.unitRaw":"16-bit 有符号","sys.sensors.gyro.hint":"点击「读取角速度」从 IMU 采样(加速度 0x3B + 陀螺仪 0x43)。横滚/俯仰来自重力;ωz 为绕竖直轴角速度,并非绝对航向。","sys.sensors.gyro.errUnknown":"采样失败","sys.sensors.gyro.live":"连续刷新(约 2 Hz)","sys.sensors.gyro.barsTitle":"角速度条(相对 ±250°/s 满偏)","sys.sensors.gyro.rawBlock":"寄存器原始值(16-bit)","sys.sensors.gyro.att3dTitle":"3D 姿态示意","sys.sensors.gyro.att3dDesc":"横滚/俯仰由加速度计重力估算;ωz 为陀螺仪绕竖直轴角速度(非磁航向)。机体系三轴与板卡固连,随姿态在三维空间中转动。","sys.sensors.gyro.axisXLabel":"+X","sys.sensors.gyro.axisYLabel":"+Y","sys.sensors.gyro.axisZLabel":"+Z","sys.sensors.gyro.att3dBodyAxes":"左侧为与电路板固连的机体系:琥珀 +X、天蓝 +Y、紫 +Z(法向);短划为负向参考,整体随横滚/俯仰旋转。","sys.sensors.gyro.att3dRefTitle":"正交轴参考(固定视角)","sys.sensors.gyro.att3dRefDesc":"与左侧同色对应,便于辨认三轴空间关系。","sys.sensors.gyro.roll":"横滚","sys.sensors.gyro.pitch":"俯仰","sys.sensors.gyro.yawRate":"偏航角速度","sys.sensors.compass.tapeCaption":"水平航向带 · 中心为当前读数","sys.sensors.compass.title":"磁力计 · 指北罗盘","sys.sensors.compass.desc":"根据水平面磁场分量估算方向角,用于调试「是否大致指向北方」;精对准需校准与固定安装姿态。","sys.sensors.compass.btn":"刷新罗盘","sys.sensors.compass.loading":"测量中…","sys.sensors.compass.live":"连续刷新","sys.sensors.compass.headingLabel":"水平航向角","sys.sensors.compass.headingHint":"由 atan2(Hx, Hy) 得到,单位度","sys.sensors.compass.cardN":"北","sys.sensors.compass.cardE":"东","sys.sensors.compass.cardS":"南","sys.sensors.compass.cardW":"西","sys.sensors.compass.err":"采样失败","sys.sensors.compass.footnote":"红色针尖表示 XY 平面内磁场水平分量方向;转动设备时指针应随之变化。精对准需软铁/硬铁校准。","sys.sensors.jsonToggle":"展开原始 JSON(排障用)","sys.hmi.title":"人机交互 · SPI 显示","sys.hmi.desc":"通过硬件平面驱动 ST7796(默认 320×320);需 OGSCOPE_DISPLAY_ENABLED=true 且系统已启用 SPI(/dev/spidev0.0)。","sys.hmi.status.section":"显示与硬件平面状态","sys.hmi.status.refresh":"刷新状态","sys.hmi.status.displayEnabled":"DISPLAY_ENABLED","sys.hmi.status.spidev":"/dev/spidev0.0","sys.hmi.status.yes":"存在","sys.hmi.status.no":"不存在","sys.hmi.status.resolution":"分辨率 / DC","sys.hmi.status.driver":"驱动句柄","sys.hmi.status.open":"已打开","sys.hmi.status.closed":"未打开","sys.hmi.status.screenOutput":"逻辑输出","sys.hmi.status.on":"开","sys.hmi.status.off":"关","sys.hmi.status.lastPattern":"上次图案","sys.hmi.status.lastError":"上次错误","sys.hmi.actions.section":"输出到屏幕","sys.hmi.actions.hint":"以下为硬件平面 device.command → hmi;大屏刷新使用较长 RPC 超时。","sys.hmi.actions.smoke":"测试画面(文字+边框)","sys.hmi.actions.colorbars":"彩条","sys.hmi.actions.fill":"填充 RGB","sys.hmi.actions.screenOn":"允许屏幕输出","sys.hmi.actions.screenOff":"暂停屏幕输出","sys.hmi.actions.release":"释放驱动(关闭 SPI/GPIO)","sys.hmi.rawJson":"原始 JSON 响应","sys.placeholder.hmi.title":"人机交互台","sys.placeholder.hmi.desc":"用于 SPI 显示、按键矩阵、背光/对比度与界面回放的调试工作区。","sys.placeholder.hmi.block1":"显示预览区","sys.placeholder.hmi.block2":"输入事件流","sys.placeholder.hmi.block3":"参数与模式控制","sys.placeholder.power.title":"电源与功耗台","sys.placeholder.power.desc":"用于电池、充电、瞬时功耗与热管理策略联动调试。","sys.placeholder.power.block1":"功耗看板","sys.placeholder.power.block2":"电源策略表","sys.placeholder.power.block3":"恢复与保护动作","cam.title":"相机调试控制台","cam.subtitle":"迁移到统一后台架构,保留关键性能优化","cam.btn.system":"系统后台","cam.btn.analysis":"寻星控制台","cam.btn.home":"首页","cam.btn.start":"启动预览","cam.btn.starting":"启动中...","cam.btn.stop":"停止预览","cam.btn.stopping":"停止中...","cam.btn.capture":"拍摄","cam.btn.capturing":"拍摄中...","cam.btn.recordStart":"开始录制","cam.btn.recordStop":"停止录制","cam.btn.recordBusy":"录制切换中...","cam.btn.refresh":"刷新状态","cam.preview.title":"实时预览","cam.preview.state":"状态","cam.preview.mode":"曝光模式","cam.preview.emptyTitle":"预览尚未启动","cam.preview.emptyDesc":"点击下方按钮启动相机预览流。","cam.state.streaming":"流已启动","cam.state.idle":"未启动","cam.state.rec":"录制中","cam.stats.requestFps":"请求 FPS","cam.hint.mjpegSingleStream":"MJPEG 同时连接数有限(默认约两路);超出上限时多标签页会争用或返回忙,请减少同时打开的实时预览。","cam.stats.frameFps":"画面 FPS","cam.stats.fpsMeasureNote":"统计最近约 1 秒内在 32×32 采样上的像素变化次数;多路同时预览、网络较慢、或下方「共享流上限」低于相机目标时,读数常明显偏低。","cam.err.streamBusy":"无法连接预览:MJPEG 名额已被占用","cam.err.streamBusyHint":"请关闭其它标签页或解算台中的设备实时预览,或稍后再试。","cam.err.streamProbeFailed":"无法建立预览流","cam.err.streamProbeDetail":"请检查网络与相机服务状态,或稍后重试。","cam.stats.targetFps":"相机采集目标 FPS","cam.stats.streamPacingFps":"共享流/MJPEG 上限 FPS","cam.stats.streamPacingHint":"与进程环境 OGSCOPE_SHARED_PREVIEW_FPS 一致,约束共享抓帧节奏与每路 MJPEG 的最小帧间隔。","cam.stats.captureFps":"实际采集 FPS","cam.stats.encodeMs":"JPEG 平均编码","cam.stats.consumers":"预览/分析/录像消费者","cam.stats.cameraMemory":"进程内存/Swap","cam.stats.longExposureThrottle":"自动曝光约 {exposure} ms,实际帧率受长曝光限制;为保证分析效果未强制缩短曝光。","cam.stats.uptime":"流运行时长","cam.controls.title":"参数控制","cam.controls.core":"核心参数","cam.controls.fps":"帧率","cam.controls.sensorFps":"传感器目标 FPS","cam.controls.previewFps":"预览目标 FPS","cam.controls.resolution":"分辨率","cam.controls.applyRes":"应用分辨率","cam.controls.sampling":"采样模式","cam.controls.exposure":"曝光","cam.controls.gain":"模拟增益","cam.controls.digitalGain":"数字增益","cam.controls.noiseReduction":"降噪级别","cam.controls.contrast":"对比度","cam.controls.brightness":"亮度","cam.controls.saturation":"饱和度","cam.controls.sharpness":"锐度","cam.controls.applySettings":"应用全部设置","cam.controls.applyAll":"保存参数","cam.controls.applyRuntime":"保存运行参数","cam.controls.mode":"模式控制","cam.controls.autoExposure":"曝光模式","cam.controls.whiteBalance":"白平衡","cam.controls.colorMode":"颜色模式","cam.controls.auto":"自动","cam.controls.manual":"手动","cam.controls.night":"夜间","cam.controls.color":"彩色","cam.controls.mono":"黑白","cam.controls.applyAe":"应用曝光模式","cam.controls.applyWb":"应用白平衡","cam.controls.applyColor":"应用颜色模式","cam.controls.applyMode":"应用模式设置","cam.controls.lockedByAe":"自动曝光开启时,曝光与增益参数锁定。","cam.controls.lockedByWb":"当前非手动白平衡模式,R/B 增益参数锁定。","cam.controls.pendingChanges":"有未应用的参数修改","cam.controls.tools":"工具","cam.controls.nightPreset":"夜间预设","cam.controls.nightOn":"开启夜间模式","cam.controls.nightOff":"关闭夜间模式","cam.controls.backup":"备份设置","cam.controls.restore":"恢复设置","cam.controls.reset":"重置相机","cam.quick.title":"快速预设与智能调参","cam.quick.daylight":"白天模式","cam.quick.night":"夜间模式","cam.quick.nightPreset":"应用夜间预设","cam.quick.deep-sky":"深空模式","cam.quick.planetary":"行星模式","cam.quick.autoAdjust":"智能调整","cam.quick.nightHint":"夜间预设用于一键切换夜拍参数;夜间模式开关用于实时启停红外/夜景模式。","cam.system.title":"系统监控","cam.system.status":"相机就绪","cam.system.stream":"流状态","cam.system.sensor":"传感器","cam.system.quality":"图像质量","cam.presets.title":"预设管理","cam.presets.name":"预设名称","cam.presets.desc":"预设描述","cam.presets.save":"保存预设","cam.presets.empty":"暂无预设","cam.presets.noDesc":"无描述","cam.presets.apply":"应用","cam.presets.delete":"删除","cam.files.title":"文件管理","cam.files.refresh":"刷新文件列表","cam.files.empty":"暂无文件","cam.files.download":"下载","cam.files.info":"详情","cam.files.delete":"删除","cam.files.loadingInfo":"正在读取文件详情...","cam.files.prev":"上一页","cam.files.next":"下一页","cam.files.page":"第 {current}/{total} 页","cam.files.closeDetail":"关闭详情","cam.files.size":"文件大小","cam.files.type":"文件类型","cam.files.modified":"修改时间","cam.hist.title":"直方图","cam.hist.enabled":"启用直方图","cam.hist.luminance":"亮度通道","cam.hist.over":"过曝警告","cam.hist.expand":"展开直方图","cam.hist.collapse":"收起直方图","cam.notice.previewStart":"预览已启动","cam.notice.previewStop":"预览已停止","cam.notice.captureSaved":"拍摄完成: {name}","cam.notice.recordStart":"开始录制: {name}","cam.notice.recordStop":"录制已停止","cam.notice.fpsApplied":"帧率已设置为 {fps}","cam.notice.resApplied":"分辨率已设置为 {w}x{h}","cam.notice.samplingApplied":"采样模式已设置为 {mode}","cam.notice.settingsApplied":"参数已应用","cam.notice.aeApplied":"曝光模式已应用","cam.notice.wbApplied":"白平衡已应用","cam.notice.colorApplied":"颜色模式已应用","cam.notice.modeApplied":"模式设置已应用","cam.notice.runtimeApplied":"运行参数已应用","cam.notice.rotationApplied":"旋转已设置为 {value}°","cam.mirror.hint":"相机倒装时可配合旋转使用;镜像后预览与星点解算、极轴引导同坐标系。","cam.mirror.horizontal":"水平镜像","cam.mirror.vertical":"垂直镜像","cam.notice.mirrorApplied":"镜像设置已更新","cam.notice.nightOn":"夜间模式已开启","cam.notice.nightOff":"夜间模式已关闭","cam.notice.nightPreset":"夜间预设已应用","cam.notice.reset":"相机设置已重置","cam.notice.backup":"设置已备份","cam.notice.restore":"设置已恢复","cam.notice.presetSaved":"预设已保存: {name}","cam.notice.presetApplied":"预设已应用: {name}","cam.notice.presetDeleted":"预设已删除: {name}","cam.notice.download":"已开始下载: {name}","cam.notice.downloadWithSidecar":"已下载: {name}(含侧车 {sidecar})","cam.notice.fileDeleted":"文件已删除: {name}","cam.notice.quickPresetApplied":"已应用快速预设: {preset}","cam.notice.needPreview":"请先启动预览","cam.notice.needManualForAutoAdjust":"请先切换为手动曝光再进行智能调整","cam.notice.autoAdjusted":"智能调整已完成并生效","cam.confirm.deletePreset":"确定删除预设 {name} 吗?","cam.confirm.deleteFile":"确定删除文件 {name} 吗?","sys.sensors.mag.calStatusPrefix":"校准状态:","sys.sensors.mag.calModeAuto":"自动模式(未锁定)","sys.sensors.mag.calModeRecording":"录制中(请旋转设备)","sys.sensors.mag.calModeLocked":"已锁定(使用保存方向)","sys.sensors.mag.calSamplesPrefix":"录制样本:","sys.sensors.mag.calRecordingHint":"操作提示:保持水平,缓慢旋转设备,建议累计 10+ 样本后保存。","sys.sensors.mag.calLockedHint":"已锁定方向参数:"},g=a.createContext(null),p={zh:C,en:k},b="ogscope.analysis.locale";function y(){const e=window.localStorage.getItem(b);return e==="zh"||e==="en"?e:(navigator.language||"zh").toLowerCase().startsWith("en")?"en":"zh"}function R({children:e}){const[s,t]=a.useState(y),[o,l]=a.useState(p[y()]);a.useEffect(()=>{l(p[s]),window.localStorage.setItem(b,s),document.documentElement.lang=s==="en"?"en":"zh-CN"},[s]);const r=a.useMemo(()=>(n,c)=>{let i=o[n]??n;if(c)for(const[d,h]of Object.entries(c))i=i.replace(new RegExp(`\\{${d}\\}`,"g"),String(h));return i},[o]),m=a.useMemo(()=>({locale:s,setLocale:t,t:r}),[s,r]);return v.jsx(g.Provider,{value:m,children:e})}function x(){const e=a.useContext(g);if(!e)throw new Error("useI18n must be used within I18nProvider");return e}export{R as I,A as T,P as c,x as u}; diff --git a/web/static/analysis-lab/assets/refresh-cw-BebYMFdn.js b/web/static/analysis-lab/assets/refresh-cw-gseCLMRP.js similarity index 91% rename from web/static/analysis-lab/assets/refresh-cw-BebYMFdn.js rename to web/static/analysis-lab/assets/refresh-cw-gseCLMRP.js index c782c78..0ea1c11 100644 --- a/web/static/analysis-lab/assets/refresh-cw-BebYMFdn.js +++ b/web/static/analysis-lab/assets/refresh-cw-gseCLMRP.js @@ -1,4 +1,4 @@ -import{c as e}from"./index-Cu-N6Gfx.js";/** +import{c as e}from"./index-C78KOEFu.js";/** * @license lucide-react v0.460.0 - ISC * * This source code is licensed under the ISC license. diff --git a/web/static/analysis-lab/assets/system-C_f3WXI6.js b/web/static/analysis-lab/assets/system-CXtFKEU2.js similarity index 99% rename from web/static/analysis-lab/assets/system-C_f3WXI6.js rename to web/static/analysis-lab/assets/system-CXtFKEU2.js index b13fde7..ca07c14 100644 --- a/web/static/analysis-lab/assets/system-C_f3WXI6.js +++ b/web/static/analysis-lab/assets/system-CXtFKEU2.js @@ -1,4 +1,4 @@ -import{j as e,r as a,a as $e,R as Me}from"./client-D1ZVDB-N.js";import{u as pe,C as Ee,r as K,a as U,S as Pe,b as Le}from"./http-B53ovOR5.js";import{c as q,u as J,T as Re,I as Te}from"./index-Cu-N6Gfx.js";import{R as he}from"./refresh-cw-BebYMFdn.js";/** +import{j as e,r as a,a as $e,R as Me}from"./client-D1ZVDB-N.js";import{u as pe,C as Ee,r as K,a as U,S as Pe,b as Le}from"./http-VZMNcsmS.js";import{c as q,u as J,T as Re,I as Te}from"./index-C78KOEFu.js";import{R as he}from"./refresh-cw-gseCLMRP.js";/** * @license lucide-react v0.460.0 - ISC * * This source code is licensed under the ISC license. diff --git a/web/static/analysis-lab/camera.html b/web/static/analysis-lab/camera.html index d275d2b..9fb1163 100644 --- a/web/static/analysis-lab/camera.html +++ b/web/static/analysis-lab/camera.html @@ -4,10 +4,10 @@ OGScope 相机调试控制台 - + - - + + diff --git a/web/static/analysis-lab/index.html b/web/static/analysis-lab/index.html index 0d05308..4180b35 100644 --- a/web/static/analysis-lab/index.html +++ b/web/static/analysis-lab/index.html @@ -4,10 +4,10 @@ OGScope 星空解算控制台 - + - - + + diff --git a/web/static/analysis-lab/system.html b/web/static/analysis-lab/system.html index 57f6555..7f55281 100644 --- a/web/static/analysis-lab/system.html +++ b/web/static/analysis-lab/system.html @@ -4,11 +4,11 @@ OGScope 系统调试控制台 - + - - - + + + diff --git a/web/static/i18n/analysis.en.json b/web/static/i18n/analysis.en.json index 5d498d7..0358472 100644 --- a/web/static/i18n/analysis.en.json +++ b/web/static/i18n/analysis.en.json @@ -381,6 +381,9 @@ "cam.stats.streamPacingHint": "Matches OGSCOPE_SHARED_PREVIEW_FPS: shared grabber pacing and per-stream MJPEG min frame spacing.", "cam.stats.captureFps": "Actual capture FPS", "cam.stats.encodeMs": "Average JPEG encode", + "cam.stats.encoder": "Preview encoder", + "cam.stats.driver": "Camera driver", + "cam.stats.metadata": "Metadata", "cam.stats.consumers": "Preview/analysis/record consumers", "cam.stats.cameraMemory": "Process memory/swap", "cam.stats.longExposureThrottle": "Auto exposure is about {exposure} ms, so long exposure is limiting FPS; exposure is not shortened to preserve analysis quality.", @@ -397,6 +400,10 @@ "cam.controls.gain": "Analog Gain", "cam.controls.digitalGain": "Digital Gain", "cam.controls.noiseReduction": "Noise Reduction", + "cam.controls.noiseReductionMode": "Noise Reduction", + "cam.controls.digitalGainReadOnly": "Digital gain is read-only on this driver", + "cam.controls.aeFlicker": "AE Flicker", + "cam.controls.maxAeFrame": "Max AE frame (us)", "cam.controls.contrast": "Contrast", "cam.controls.brightness": "Brightness", "cam.controls.saturation": "Saturation", @@ -411,6 +418,18 @@ "cam.controls.auto": "Auto", "cam.controls.manual": "Manual", "cam.controls.night": "Night", + "cam.controls.off": "Off", + "cam.controls.nr.off": "Off", + "cam.controls.nr.fast": "Fast", + "cam.controls.nr.high_quality": "High quality", + "cam.controls.wb.auto": "Auto", + "cam.controls.wb.daylight": "Daylight", + "cam.controls.wb.cloudy": "Cloudy", + "cam.controls.wb.tungsten": "Tungsten", + "cam.controls.wb.fluorescent": "Fluorescent", + "cam.controls.wb.indoor": "Indoor", + "cam.controls.wb.manual": "Manual", + "cam.controls.wb.night": "Night", "cam.controls.color": "Color", "cam.controls.mono": "Mono", "cam.controls.applyAe": "Apply Exposure Mode", diff --git a/web/static/i18n/analysis.zh.json b/web/static/i18n/analysis.zh.json index d81d6b0..3e7bd78 100644 --- a/web/static/i18n/analysis.zh.json +++ b/web/static/i18n/analysis.zh.json @@ -382,6 +382,9 @@ "cam.stats.streamPacingHint": "与进程环境 OGSCOPE_SHARED_PREVIEW_FPS 一致,约束共享抓帧节奏与每路 MJPEG 的最小帧间隔。", "cam.stats.captureFps": "实际采集 FPS", "cam.stats.encodeMs": "JPEG 平均编码", + "cam.stats.encoder": "预览编码器", + "cam.stats.driver": "相机驱动", + "cam.stats.metadata": "元数据", "cam.stats.consumers": "预览/分析/录像消费者", "cam.stats.cameraMemory": "进程内存/Swap", "cam.stats.longExposureThrottle": "自动曝光约 {exposure} ms,实际帧率受长曝光限制;为保证分析效果未强制缩短曝光。", @@ -398,6 +401,10 @@ "cam.controls.gain": "模拟增益", "cam.controls.digitalGain": "数字增益", "cam.controls.noiseReduction": "降噪级别", + "cam.controls.noiseReductionMode": "降噪模式", + "cam.controls.digitalGainReadOnly": "当前驱动仅上报数字增益,不支持手动设置", + "cam.controls.aeFlicker": "AE 防闪烁", + "cam.controls.maxAeFrame": "最长自动曝光帧周期(us)", "cam.controls.contrast": "对比度", "cam.controls.brightness": "亮度", "cam.controls.saturation": "饱和度", @@ -412,6 +419,18 @@ "cam.controls.auto": "自动", "cam.controls.manual": "手动", "cam.controls.night": "夜间", + "cam.controls.off": "关闭", + "cam.controls.nr.off": "关闭", + "cam.controls.nr.fast": "快速", + "cam.controls.nr.high_quality": "高质量", + "cam.controls.wb.auto": "自动", + "cam.controls.wb.daylight": "日光", + "cam.controls.wb.cloudy": "阴天", + "cam.controls.wb.tungsten": "钨丝灯", + "cam.controls.wb.fluorescent": "荧光灯", + "cam.controls.wb.indoor": "室内", + "cam.controls.wb.manual": "手动", + "cam.controls.wb.night": "夜间", "cam.controls.color": "彩色", "cam.controls.mono": "黑白", "cam.controls.applyAe": "应用曝光模式", From b9f78fb092abee03f5889dc02045c6c3fe4cc955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Mon, 22 Jun 2026 20:47:54 +0800 Subject: [PATCH 11/18] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20WiFi=20=E5=BA=94?= =?UTF-8?q?=E6=80=A5=20GPIO=20=E5=88=87=E6=8D=A2=20/=20Remove=20WiFi=20eme?= =?UTF-8?q?rgency=20GPIO=20switching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除短接引脚触发 STA/AP 切换的运行时监控,并从配置、配置目录与架构文档中移除相关项。 Remove the runtime short-pin STA/AP switching monitor and clean up the related settings, config catalog entries, and architecture documentation. --- .../OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md | 1 - ogscope/config.py | 18 --- ogscope/config_catalog.py | 4 - ogscope/platform/hardware/gpio_config.py | 13 +- .../platform/hardware/wifi_emergency_gpio.py | 130 ------------------ ogscope/web/app.py | 17 --- 6 files changed, 1 insertion(+), 182 deletions(-) delete mode 100644 ogscope/platform/hardware/wifi_emergency_gpio.py diff --git a/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md b/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md index 3c97466..458a17a 100644 --- a/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md +++ b/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md @@ -54,7 +54,6 @@ flowchart TD subgraph peripheralLayer["外围硬件层 / Peripheral Hardware Layer"] cameraHw["相机 / Camera IMX327"] wifiHw["网络模块 / WiFi and NetworkManager"] - gpioHw["应急GPIO / Emergency GPIO"] magnetometerHw["磁力计(规划) / Magnetometer (Planned)"] gpsHw["GPS(规划) / GPS (Planned)"] gyroHw["陀螺仪(规划) / Gyroscope (Planned)"] diff --git a/ogscope/config.py b/ogscope/config.py index b0cc450..a764ccc 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -459,24 +459,6 @@ class Settings(BaseSettings): default="192.168.4.1", description="AP 模式下前端提示用的主机地址(不含端口)/ AP URL hint host without port", ) - wifi_emergency_gpio_enabled: bool = Field( - default=False, - description="启用短接 GPIO 强制切 STA / Enable GPIO short-to-STA recovery", - ) - wifi_emergency_pin_out_bcm: int = Field( - default=22, - description="应急检测:输出低电平(BCM)/ Emergency: output LOW (BCM)", - ) - wifi_emergency_pin_in_bcm: int = Field( - default=23, - description="应急检测:上拉输入(BCM)/ Emergency: input with pull-up (BCM)", - ) - wifi_emergency_hold_seconds: float = Field( - default=2.0, - ge=0.5, - le=30.0, - description="短接持续多久触发 STA / Hold time before forcing STA", - ) device_id_suffix: str = Field( default="", description="设备后缀(network.env 中 OGSCOPE_DEVICE_ID_SUFFIX)/ Device id suffix from network.env", diff --git a/ogscope/config_catalog.py b/ogscope/config_catalog.py index 1d1019c..b275485 100644 --- a/ogscope/config_catalog.py +++ b/ogscope/config_catalog.py @@ -191,10 +191,6 @@ "wifi_ap_connection", "wifi_interface", "wifi_ap_url_host", - "wifi_emergency_gpio_enabled", - "wifi_emergency_pin_out_bcm", - "wifi_emergency_pin_in_bcm", - "wifi_emergency_hold_seconds", "device_id_suffix", "wifi_ap_ssid", "wifi_sta_rollback_timeout_seconds", diff --git a/ogscope/platform/hardware/gpio_config.py b/ogscope/platform/hardware/gpio_config.py index 2b6591c..89d850b 100644 --- a/ogscope/platform/hardware/gpio_config.py +++ b/ogscope/platform/hardware/gpio_config.py @@ -112,13 +112,6 @@ class RaspberryPiZero2WGPIO: "error_led_pin": 21, # 错误 LED / Error LED } - # WiFi 应急短接(BCM):输出低 + 上拉输入,短接 ≥2s 切 STA;物理排针 15–16 相邻 - # WiFi emergency short (BCM): OUT low + pull-up IN; hold ≥2s forces STA; physical pins 15–16 adjacent - WIFI_EMERGENCY_SHORT_PINS = { - "out_bcm": 22, - "in_bcm": 23, - } - class GPIOConfig: """GPIO 配置管理类 / GPIO configuration management class""" @@ -195,11 +188,7 @@ def get_pin_number(self, pin_name: str) -> Optional[int]: return self.gpio_config.GPIO_PINS.get(pin_name) def get_all_used_pins(self) -> list: - """获取所有已使用的引脚 / Get all used pins - - 注:WiFi 应急短接使用 BCM22/23,启用 `OGSCOPE_WIFI_EMERGENCY_GPIO_ENABLED` 时勿占用。 - Note: WiFi emergency uses BCM 22/23; avoid conflicts when emergency GPIO is enabled. - """ + """获取所有已使用的引脚 / Get all used pins.""" used_pins = [] # 显示屏引脚 / Display pins diff --git a/ogscope/platform/hardware/wifi_emergency_gpio.py b/ogscope/platform/hardware/wifi_emergency_gpio.py deleted file mode 100644 index 1e2ad88..0000000 --- a/ogscope/platform/hardware/wifi_emergency_gpio.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -WiFi 应急 GPIO 监控:短接 2s 强制切回 STA -WiFi emergency GPIO watcher: short pins to force STA. -""" - -from __future__ import annotations - -import threading -import time -from dataclasses import dataclass - -from loguru import logger - -from ogscope.config import Settings, get_settings -from ogscope.platform.hardware.wifi_switch import wifi_switch_service -from ogscope.utils.environment import is_raspberry_pi - - -@dataclass -class _WatcherState: - low_since: float | None = None - last_trigger_at: float = 0.0 - - -class WifiEmergencyGpioMonitor: - """应急 GPIO 监控器 / Emergency GPIO monitor.""" - - def __init__(self, settings: Settings | None = None) -> None: - self._settings = settings or get_settings() - self._thread: threading.Thread | None = None - self._stop_event = threading.Event() - self._gpio = None - self._state = _WatcherState() - - def start(self) -> None: - """启动监控线程 / Start monitor thread.""" - if not self._settings.wifi_emergency_gpio_enabled: - logger.info("应急 GPIO 未启用 / Emergency GPIO disabled by config") - return - if self._thread and self._thread.is_alive(): - return - if not is_raspberry_pi(): - logger.info("非树莓派环境,跳过应急 GPIO / Skip emergency GPIO on non-RPi") - return - try: - import RPi.GPIO as gpio # type: ignore - except Exception as e: - logger.warning( - "未安装 RPi.GPIO,无法启用应急短接 / RPi.GPIO unavailable: {}", e - ) - return - - self._gpio = gpio - self._setup_gpio() - self._stop_event.clear() - self._thread = threading.Thread( - target=self._run_loop, - name="wifi-emergency-gpio", - daemon=True, - ) - self._thread.start() - logger.info( - "应急 GPIO 已启动 / Emergency GPIO monitor started: out={}, in={}, hold={}s", - self._settings.wifi_emergency_pin_out_bcm, - self._settings.wifi_emergency_pin_in_bcm, - self._settings.wifi_emergency_hold_seconds, - ) - - def stop(self) -> None: - """停止监控线程并释放 GPIO / Stop monitor and cleanup GPIO.""" - self._stop_event.set() - if self._thread and self._thread.is_alive(): - self._thread.join(timeout=1.5) - self._thread = None - if self._gpio: - try: - self._gpio.cleanup( - [ - self._settings.wifi_emergency_pin_out_bcm, - self._settings.wifi_emergency_pin_in_bcm, - ] - ) - except Exception: - pass - self._gpio = None - logger.info("应急 GPIO 已停止 / Emergency GPIO monitor stopped") - - def _setup_gpio(self) -> None: - assert self._gpio is not None - g = self._gpio - g.setwarnings(False) - g.setmode(g.BCM) - g.setup(self._settings.wifi_emergency_pin_out_bcm, g.OUT, initial=g.LOW) - g.setup(self._settings.wifi_emergency_pin_in_bcm, g.IN, pull_up_down=g.PUD_UP) - - def _run_loop(self) -> None: - assert self._gpio is not None - g = self._gpio - interval = 0.05 - hold = self._settings.wifi_emergency_hold_seconds - while not self._stop_event.is_set(): - now = time.monotonic() - pin_low = g.input(self._settings.wifi_emergency_pin_in_bcm) == g.LOW - if pin_low: - if self._state.low_since is None: - self._state.low_since = now - if (now - self._state.low_since) >= hold: - if (now - self._state.last_trigger_at) >= hold: - self._state.last_trigger_at = now - self._force_sta() - else: - self._state.low_since = None - time.sleep(interval) - - def _force_sta(self) -> None: - logger.warning( - "检测到应急短接,强制切换 STA / Emergency short detected, forcing STA" - ) - if not wifi_switch_service.is_configured(): - logger.error( - "WiFi 未配置,无法应急切 STA / WiFi not configured, cannot force STA" - ) - return - try: - wifi_switch_service.switch("sta") - except Exception as e: - logger.error("应急切 STA 失败 / Failed to force STA: {}", e) - - -wifi_emergency_gpio_monitor = WifiEmergencyGpioMonitor() diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 6f5c290..04efe43 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -104,28 +104,11 @@ async def _warm_solver() -> None: phase_elapsed_ms = int((asyncio.get_running_loop().time() - phase_p0_started) * 1000) logger.info("启动阶段完成 / Startup phases ready in {} ms", phase_elapsed_ms) - try: - from ogscope.platform.hardware.wifi_emergency_gpio import ( - wifi_emergency_gpio_monitor, - ) - - wifi_emergency_gpio_monitor.start() - except Exception as e: - logger.warning("应急 GPIO 启动失败 / Emergency GPIO start failed: {}", e) - yield # 关闭时执行 / Execute on shutdown logger.info("清理资源...") shutdown_started = asyncio.get_running_loop().time() - try: - from ogscope.platform.hardware.wifi_emergency_gpio import ( - wifi_emergency_gpio_monitor, - ) - - wifi_emergency_gpio_monitor.stop() - except Exception as e: - logger.warning("应急 GPIO 停止异常 / Emergency GPIO stop error: {}", e) try: from ogscope.utils.environment import should_use_simulation_mode From 9065ca2bbac1b271724d198659e91b445a3bb56d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Mon, 22 Jun 2026 22:39:04 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9B=B8=E6=9C=BA?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E4=BA=AE=E5=BA=A6=E5=A5=91=E7=BA=A6=20/=20Ad?= =?UTF-8?q?d=20camera=20ambient-light=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Core v1 相机状态中暴露可选 ambient_hint,并补充中英文契约文档和单测。 Expose optional ambient_hint in Core v1 camera status, with bilingual contract docs and tests. --- docs/contracts/core-rest-v1.md | 5 +- docs/contracts/core-rest-v1_EN.md | 3 +- ogscope/core/application/core_service.py | 62 ++++++++++++++++++++++-- ogscope/web/api/models/schemas.py | 1 + tests/unit/test_core_contract_api.py | 26 ++++++++++ 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/contracts/core-rest-v1.md b/docs/contracts/core-rest-v1.md index 07f9d4d..8677dc6 100644 --- a/docs/contracts/core-rest-v1.md +++ b/docs/contracts/core-rest-v1.md @@ -67,7 +67,8 @@ ### 5) Camera Runtime & Preview (MJPEG / single-frame) - `GET /api/core/v1/camera/status` - - 返回相机连接状态、流状态与 runtime overrides + - 返回相机连接状态、流状态、runtime overrides 与可选 `ambient_hint` + - `ambient_hint` 是环境亮度建议遥测,供上层设备做显示/交互策略参考;典型字段包括 `available`、`dark_score`(0.0 明亮到 1.0 昏暗)、`lux`、`exposure_us`、`digital_gain` - `POST /api/core/v1/camera/start` - `POST /api/core/v1/camera/stop` @@ -97,4 +98,4 @@ MJPEG 连续视频流与流控状态、单帧 JPEG 预览(轮询、`since_fram - `4xx`:请求参数非法、契约字段校验失败。 - `5xx`:内部运行异常或底层能力不可用。 -- 契约版本路径固定为 `/v1/`。新增字段以可选形式扩展,不破坏既有消费者。 \ No newline at end of file +- 契约版本路径固定为 `/v1/`。新增字段以可选形式扩展,不破坏既有消费者。 diff --git a/docs/contracts/core-rest-v1_EN.md b/docs/contracts/core-rest-v1_EN.md index 20932c2..cacae6b 100644 --- a/docs/contracts/core-rest-v1_EN.md +++ b/docs/contracts/core-rest-v1_EN.md @@ -66,7 +66,8 @@ This document defines the **minimal stable REST surface** for callers integratin ### 5) Camera Runtime & Preview (MJPEG / single-frame) -- `GET /api/core/v1/camera/status` — connection, stream state, runtime overrides +- `GET /api/core/v1/camera/status` — connection, stream state, runtime overrides, and optional `ambient_hint` + - `ambient_hint` is advisory ambient-light telemetry for upstream display/interaction policy. Typical fields: `available`, `dark_score` (0.0 bright to 1.0 dark), `lux`, `exposure_us`, `digital_gain` - `POST /api/core/v1/camera/start` - `POST /api/core/v1/camera/stop` diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index 996344c..44d2d61 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -4,6 +4,7 @@ from __future__ import annotations +import math from dataclasses import dataclass from typing import Any @@ -17,11 +18,11 @@ stream_state_domain_service, ) from ogscope.domain.system.services import system_info_service +from ogscope.platform.hardware.wifi_switch import wifi_switch_service from ogscope.platform.hardware_plane.runtime import ( describe_hardware_plane_profile, get_hardware_plane_client, ) -from ogscope.platform.hardware.wifi_switch import wifi_switch_service @dataclass(slots=True) @@ -38,14 +39,69 @@ class CoreContractService: def __init__(self) -> None: self._session = CoreAnalysisSession() + @staticmethod + def _optional_float(value: Any) -> float | None: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _clamp01(value: float) -> float: + return max(0.0, min(1.0, value)) + + @staticmethod + def _build_ambient_hint(info: dict[str, Any], *, streaming: bool) -> dict[str, Any]: + """构造环境亮度建议遥测 / Build ambient brightness hint telemetry.""" + lux = CoreContractService._optional_float(info.get("lux")) + exposure_us = CoreContractService._optional_float( + info.get("actual_exposure_us", info.get("exposure_us")) + ) + digital_gain = CoreContractService._optional_float( + info.get("actual_digital_gain", info.get("digital_gain")) + ) + max_exposure_us = CoreContractService._optional_float( + info.get("auto_exposure_max_us", info.get("frame_duration_us")) + ) + + scores: list[float] = [] + if lux is not None and lux >= 0: + scores.append(CoreContractService._clamp01(1.0 - math.log10(lux + 1.0) / 2.0)) + if exposure_us is not None and exposure_us > 0: + exposure_ceiling = max(max_exposure_us or 100_000.0, 1.0) + exposure_score = CoreContractService._clamp01(exposure_us / exposure_ceiling) + gain_score = 0.0 + if digital_gain is not None: + gain_score = CoreContractService._clamp01((digital_gain - 1.0) / 7.0) + scores.append(CoreContractService._clamp01(exposure_score * 0.75 + gain_score * 0.25)) + + dark_score = sum(scores) / len(scores) if scores else None + return { + "available": bool(streaming and dark_score is not None), + "source": "camera_metadata" if dark_score is not None else "unavailable", + "confidence": "live" if streaming and dark_score is not None else "stale", + "dark_score": round(dark_score, 3) if dark_score is not None else None, + "lux": lux, + "exposure_us": int(exposure_us) if exposure_us is not None else None, + "digital_gain": digital_gain, + } + @staticmethod def _normalize_camera_status(status: dict[str, Any]) -> dict[str, Any]: """统一 Core 相机状态形状 / Normalize camera status payload shape.""" + streaming = bool(status.get("streaming", False)) + info = status.get("info", {}) or {} return { "connected": bool(status.get("connected", False)), - "streaming": bool(status.get("streaming", False)), + "streaming": streaming, "recording": bool(status.get("recording", False)), - "info": status.get("info", {}) or {}, + "info": info, + "ambient_hint": CoreContractService._build_ambient_hint( + info, + streaming=streaming, + ), "runtime_overrides": status.get("runtime_overrides", {}) or {}, "error": status.get("error"), } diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index 2bf7d00..6849b8f 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -505,6 +505,7 @@ class CoreCameraStatusResponse(BaseModel): streaming: bool recording: bool info: dict[str, Any] = Field(default_factory=dict) + ambient_hint: dict[str, Any] = Field(default_factory=dict) runtime_overrides: dict[str, Any] = Field(default_factory=dict) error: Optional[str] = None diff --git a/tests/unit/test_core_contract_api.py b/tests/unit/test_core_contract_api.py index 204d98f..075eb59 100644 --- a/tests/unit/test_core_contract_api.py +++ b/tests/unit/test_core_contract_api.py @@ -56,6 +56,32 @@ def test_core_system_status_health_reasons_ignore_delegated_network() -> None: assert reasons == [] +@pytest.mark.unit +def test_core_camera_ambient_hint_from_metadata() -> None: + """相机 metadata 生成环境亮度建议 / Camera metadata builds ambient hint.""" + from ogscope.core.application.core_service import CoreContractService + + normalized = CoreContractService._normalize_camera_status( + { + "connected": True, + "streaming": True, + "recording": False, + "info": { + "lux": 4.0, + "actual_exposure_us": 80_000, + "auto_exposure_max_us": 100_000, + "actual_digital_gain": 2.0, + }, + } + ) + + hint = normalized["ambient_hint"] + assert hint["available"] is True + assert hint["source"] == "camera_metadata" + assert 0.0 <= hint["dark_score"] <= 1.0 + assert hint["exposure_us"] == 80_000 + + @pytest.mark.unit def test_core_system_status_network_delegated_when_subordinate(monkeypatch) -> None: """subordinate 下 network 标记 delegated 且不降级 / Subordinate marks network delegated.""" From 809c6968c049add893e2cc28fcf2e65734283d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Fri, 26 Jun 2026 20:36:16 +0800 Subject: [PATCH 13/18] =?UTF-8?q?docs:=20sync=20camera=20pipeline=20docume?= =?UTF-8?q?ntation=20/=20=E5=90=8C=E6=AD=A5=E7=9B=B8=E6=9C=BA=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API_ARCHITECTURE.md | 2 ++ docs/API_ARCHITECTURE_EN.md | 2 ++ docs/DEBUG_CONSOLE.md | 43 ++++++++++++++++++++++++++++++-- docs/DEBUG_CONSOLE_EN.md | 43 ++++++++++++++++++++++++++++++-- docs/contracts/dev-rest-v1.md | 35 ++++++++++++++++++++++++++ docs/contracts/dev-rest-v1_EN.md | 35 ++++++++++++++++++++++++++ docs/development/README.md | 3 ++- docs/development/README_EN.md | 3 ++- 8 files changed, 160 insertions(+), 6 deletions(-) diff --git a/docs/API_ARCHITECTURE.md b/docs/API_ARCHITECTURE.md index 3c01c38..32e2a7b 100644 --- a/docs/API_ARCHITECTURE.md +++ b/docs/API_ARCHITECTURE.md @@ -17,6 +17,7 @@ 1. 业务逻辑放在 `domain/*` 或 `core/application/*`,`routes.py` 仅做 HTTP 适配。 2. 对外稳定能力使用 `/api/core/v1/*`;开发者域使用 `/api/dev/*`。 3. 同步更新契约文档:`docs/contracts/core-rest-v1.md` / `core-rest-v1_EN.md`,`dev-rest-v1.md` / `dev-rest-v1_EN.md`。 +4. 若新增调试页字段、相机遥测或分析结果 `overlay_ext`,同时更新 `docs/DEBUG_CONSOLE.md` / `DEBUG_CONSOLE_EN.md`。 开发板部署总览见 [开发指南](development/README.md)([English](development/README_EN.md))。提交前自检见下文第 5 节。 @@ -90,6 +91,7 @@ HTTP Request - `docs/contracts/core-rest-v1.md`、`docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md`、`docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md`(若涉及版本/路径策略) +7. 调试控制台字段、相机管线遥测与分析扩展字段已同步到调试文档。 ## 6) 快速验证命令 diff --git a/docs/API_ARCHITECTURE_EN.md b/docs/API_ARCHITECTURE_EN.md index 0e79548..5cd1ef6 100644 --- a/docs/API_ARCHITECTURE_EN.md +++ b/docs/API_ARCHITECTURE_EN.md @@ -17,6 +17,7 @@ For system-level architecture (core boundary, user vs developer surfaces, operat 1. Keep business logic in `domain/*` or `core/application/*`; `routes.py` handles HTTP adaptation only. 2. Stable surface: `/api/core/v1/*`; developer surface: `/api/dev/*`. 3. Update contract docs: `docs/contracts/core-rest-v1.md` / `core-rest-v1_EN.md`, `dev-rest-v1.md` / `dev-rest-v1_EN.md`. +4. When adding debug-console fields, camera telemetry, or analysis-result `overlay_ext`, also update `docs/DEBUG_CONSOLE.md` / `DEBUG_CONSOLE_EN.md`. Board deployment overview: [Development Guide](development/README_EN.md) | [中文](development/README.md). Pre-submit checks: section 5 below. @@ -90,6 +91,7 @@ Before API changes, confirm: - `docs/contracts/core-rest-v1.md` / `docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md` / `docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md` (when versioning/path policy changes) +7. Debug-console fields, camera-pipeline telemetry, and analysis extension fields are reflected in debug documentation. ## 6) Quick verification commands diff --git a/docs/DEBUG_CONSOLE.md b/docs/DEBUG_CONSOLE.md index 86e17eb..f5b5cbe 100644 --- a/docs/DEBUG_CONSOLE.md +++ b/docs/DEBUG_CONSOLE.md @@ -9,9 +9,9 @@ OGScope 调试控制台是一个专为开发者设计的相机调试工具,提 ## 🚀 功能特性 ### 📷 实时预览 -- 15fps 实时相机预览 +- 低内存板优先的实时相机预览,目标帧率由 `preview_target_fps` 与运行时节流共同决定 - 支持启动/停止预览 -- 实时状态显示 +- 实时状态显示:采集帧率、预览帧率、曝光、消费者数量、编码器与内存压力 ### 📸 拍摄控制 - **单张拍摄**: 拍摄高质量照片并自动保存 @@ -23,6 +23,10 @@ OGScope 调试控制台是一个专为开发者设计的相机调试工具,提 - **曝光时间**: 1ms - 100ms (微秒级调节) - **模拟增益**: 1x - 16x (0.1x步进) - **数字增益**: 1x - 4x (0.1x步进) +- **白平衡**: `auto` / `manual` / `night`,手动模式可设置红/蓝增益 +- **自动曝光上限**: `camera_auto_exposure_max_us` 控制暗场最长帧周期 +- **防闪烁与降噪**: 支持 AE flicker 与语义降噪模式 +- **预览编码器**: `auto` / `turbojpeg` / `opencv` - **实时应用**: 参数修改立即生效 - **一键重置**: 恢复到默认设置 @@ -157,6 +161,21 @@ python -m ogscope.web.app - `POST /api/dev/debug/camera/settings` - 更新相机设置 - `POST /api/dev/debug/camera/reset` - 重置到默认设置 +当前相机状态还会返回调试字段: + +| 字段 | 说明 | +|------|------| +| `sensor_target_fps` / `preview_target_fps` | 传感器与预览目标帧率 | +| `actual_capture_fps` / `actual_preview_fps` | 实测采集与预览帧率 | +| `actual_exposure_us` / `frame_duration_us` | 当前曝光与帧周期 | +| `preview_consumers` / `analysis_consumers` / `recording_consumers` | 预览、分析、录制消费者数量 | +| `jpeg_average_encode_ms` / `jpeg_cached_bytes` | JPEG 编码耗时与缓存大小 | +| `throttle_reason` | 当前节流原因,例如低内存或无消费者 | +| `process_rss_kb` / `process_swap_kb` / `cma_free_kb` | 进程内存、swap 与 CMA 可用量 | +| `preview_encoder` / `jpeg_source_format` | 当前预览编码器和输入格式 | +| `camera_driver` / `camera_backend` | 相机驱动与后端名称 | +| `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format` | 低分辨率辅助流状态 | + ### 预设管理 - `GET /api/dev/debug/camera/presets` - 获取预设列表 - `POST /api/dev/debug/camera/presets` - 保存预设 @@ -192,6 +211,26 @@ python scripts/test_debug_console.py --test deps 2. **权限要求**: 相机访问需要适当的系统权限 3. **存储空间**: 确保有足够的存储空间保存拍摄文件 4. **网络访问**: 调试控制台通过Web界面访问,确保网络连接正常 +5. **32 位系统**: OpenCV、SciPy、PyTurboJPEG 在 32 位系统上可能没有合适 wheel,优先使用系统包或 piwheels;低内存板建议降低预览帧率并启用自动编码器选择。 + +## 🧩 相机管线配置 + +这些配置可通过环境变量或配置文件进入运行时。名称与 `ogscope/config.py` 一致: + +| 配置 | 默认 | 说明 | +|------|------|------| +| `camera_idle_shutdown_sec` | `20.0` | 无消费者后相机热驻留时间,超时后释放采集 | +| `camera_frame_stale_timeout_sec` | `5.0` | 超过该时间没有成功帧时重新探测 | +| `camera_white_balance_mode` | `auto` | `auto` / `manual` / `night` | +| `camera_white_balance_gain_r` / `camera_white_balance_gain_b` | `1.0` | 手动白平衡红/蓝增益 | +| `camera_night_mode` | `false` | 启动时应用夜间白平衡标记 | +| `camera_auto_exposure_max_us` | `2000000` | 自动曝光最长帧周期,暗场允许降低帧率 | +| `camera_ae_flicker_mode` | `off` | `off` / `50hz` / `60hz` | +| `camera_noise_reduction_mode` | `fast` | `off` / `fast` / `high_quality` | +| `camera_lores_enabled` | `true` | 启用低分辨率辅助流统计 | +| `camera_lores_width` / `camera_lores_height` | `320` / `240` | 低分辨率辅助流尺寸 | +| `camera_lores_format` | `YUV420` | 低分辨率辅助流格式 | +| `preview_encoder` | `auto` | `auto` / `turbojpeg` / `opencv` | ## 🐛 故障排除 diff --git a/docs/DEBUG_CONSOLE_EN.md b/docs/DEBUG_CONSOLE_EN.md index 6bcf1b3..117c826 100644 --- a/docs/DEBUG_CONSOLE_EN.md +++ b/docs/DEBUG_CONSOLE_EN.md @@ -9,9 +9,9 @@ The OGScope debug console is a developer-focused camera tool: live preview, capt ## Features ### Live preview -- ~15 fps live preview +- Low-memory-board-friendly live preview. Effective FPS is governed by `preview_target_fps` and runtime throttling. - Start/stop preview -- Live status +- Live status: capture FPS, preview FPS, exposure, consumers, encoder, and memory pressure ### Capture - **Still capture**: high-quality photos with auto-save @@ -23,6 +23,10 @@ The OGScope debug console is a developer-focused camera tool: live preview, capt - **Exposure**: 1ms–100ms (fine steps) - **Analog gain**: 1x–16x (0.1x steps) - **Digital gain**: 1x–4x (0.1x steps) +- **White balance**: `auto` / `manual` / `night`; manual mode exposes red/blue gains +- **Auto-exposure ceiling**: `camera_auto_exposure_max_us` controls the longest dark-field frame duration +- **Flicker and noise reduction**: AE flicker and semantic noise-reduction modes +- **Preview encoder**: `auto` / `turbojpeg` / `opencv` - **Apply immediately**: changes take effect at once - **Reset**: restore defaults @@ -121,6 +125,21 @@ Browser: `http://localhost:8000/debug` - `POST /api/dev/debug/camera/settings` - `POST /api/dev/debug/camera/reset` +Camera status also exposes diagnostic fields: + +| Field | Meaning | +|-------|---------| +| `sensor_target_fps` / `preview_target_fps` | Sensor and preview target FPS | +| `actual_capture_fps` / `actual_preview_fps` | Measured capture and preview FPS | +| `actual_exposure_us` / `frame_duration_us` | Current exposure and frame duration | +| `preview_consumers` / `analysis_consumers` / `recording_consumers` | Preview, analysis, and recording consumers | +| `jpeg_average_encode_ms` / `jpeg_cached_bytes` | JPEG encode time and cached bytes | +| `throttle_reason` | Current throttle reason, for example low memory or no consumers | +| `process_rss_kb` / `process_swap_kb` / `cma_free_kb` | Process memory, swap, and CMA free memory | +| `preview_encoder` / `jpeg_source_format` | Selected preview encoder and input format | +| `camera_driver` / `camera_backend` | Camera driver and backend names | +| `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format` | Low-resolution helper stream state | + ### Presets - `GET /api/dev/debug/camera/presets` - `POST /api/dev/debug/camera/presets` @@ -147,6 +166,26 @@ python scripts/test_debug_console.py --test deps 2. **Permissions**: camera access for the service user. 3. **Disk**: ensure free space for captures. 4. **Network**: Web UI requires reachable HTTP port. +5. **32-bit OS**: OpenCV, SciPy, and PyTurboJPEG may not have suitable wheels. Prefer distro packages or piwheels, and use lower preview FPS plus automatic encoder selection on low-memory boards. + +## Camera Pipeline Configuration + +These settings enter runtime through environment variables or config files. Names match `ogscope/config.py`: + +| Setting | Default | Meaning | +|---------|---------|---------| +| `camera_idle_shutdown_sec` | `20.0` | Warm-idle timeout after the last consumer | +| `camera_frame_stale_timeout_sec` | `5.0` | Re-probe when no successful frame arrives within this duration | +| `camera_white_balance_mode` | `auto` | `auto` / `manual` / `night` | +| `camera_white_balance_gain_r` / `camera_white_balance_gain_b` | `1.0` | Manual white-balance red/blue gains | +| `camera_night_mode` | `false` | Apply night white-balance flag at startup | +| `camera_auto_exposure_max_us` | `2000000` | Longest AE frame duration for dark fields | +| `camera_ae_flicker_mode` | `off` | `off` / `50hz` / `60hz` | +| `camera_noise_reduction_mode` | `fast` | `off` / `fast` / `high_quality` | +| `camera_lores_enabled` | `true` | Enable the low-resolution helper stream | +| `camera_lores_width` / `camera_lores_height` | `320` / `240` | Low-resolution helper stream size | +| `camera_lores_format` | `YUV420` | Low-resolution helper stream format | +| `preview_encoder` | `auto` | `auto` / `turbojpeg` / `opencv` | ## Troubleshooting diff --git a/docs/contracts/dev-rest-v1.md b/docs/contracts/dev-rest-v1.md index e02bab1..1cdfa28 100644 --- a/docs/contracts/dev-rest-v1.md +++ b/docs/contracts/dev-rest-v1.md @@ -16,6 +16,41 @@ - 分析实验:`/api/dev/analysis/*` - 素材池、实验记录、离线/在线解算与参数试验 +## 调试相机状态 + +- `GET /api/dev/debug/camera/status` + - 用途:开发者调试页与板端性能排查。 + - 典型字段: + - `sensor_target_fps` / `preview_target_fps`:传感器与预览目标帧率 + - `actual_capture_fps` / `actual_preview_fps`:运行时采集与预览实际帧率 + - `actual_exposure_us` / `frame_duration_us`:曝光与帧时长遥测 + - `preview_consumers` / `analysis_consumers` / `recording_consumers`:消费者数量 + - `jpeg_average_encode_ms` / `jpeg_cached_bytes` / `jpeg_encode_failures`:JPEG 编码健康度 + - `throttle_reason`:运行时降速原因,空值表示未主动降速 + - `process_rss_kb` / `process_swap_kb` / `cma_free_kb`:低内存板排查指标 + - `preview_encoder` / `jpeg_source_format`:当前预览编码器与源格式 + - `camera_driver` / `camera_backend`:相机驱动与后端 + - `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format`:低分辨率支路状态 + +### 相机调试设置 + +- `POST /api/dev/debug/camera/settings` + - 用途:开发调试 UI 的增量设置入口,不属于稳定对外契约。 + - 近期字段包括: + - `whiteBalanceMode`、`whiteBalanceGainR`、`whiteBalanceGainB` + - `autoExposureMaxUs` + - `aeFlickerMode` + - `noiseReductionMode` + - `previewEncoder` + +## 分析实验扩展 + +- `POST /api/dev/analysis/solve/frame` +- `POST /api/dev/analysis/solve/frame_upload` + - 请求可选 `enable_polar_guide`。 + - 响应中的 `overlay_ext.polar_guide` 是实验性极轴引导叠加数据,用于开发 UI 验证,不属于 `core/v1` 稳定字段。 + - `overlay_ext.labels_topn` 与 `overlay_ext.polar_guide` 可独立存在;调用方应按可选字段处理。 + ## 文档入口 - 标准接口文档:`/docs`(默认) diff --git a/docs/contracts/dev-rest-v1_EN.md b/docs/contracts/dev-rest-v1_EN.md index 5199a2e..1bf304a 100644 --- a/docs/contracts/dev-rest-v1_EN.md +++ b/docs/contracts/dev-rest-v1_EN.md @@ -16,6 +16,41 @@ This document describes OGScope **developer-domain** APIs (internal). They are * - Analysis lab: `/api/dev/analysis/*` - Asset pool, experiment records, offline/online solving and parameter trials +## Debug camera status + +- `GET /api/dev/debug/camera/status` + - Purpose: developer console diagnostics and board-side performance triage. + - Typical fields: + - `sensor_target_fps` / `preview_target_fps`: sensor and preview target FPS + - `actual_capture_fps` / `actual_preview_fps`: runtime capture and preview FPS + - `actual_exposure_us` / `frame_duration_us`: exposure and frame-duration telemetry + - `preview_consumers` / `analysis_consumers` / `recording_consumers`: active consumers + - `jpeg_average_encode_ms` / `jpeg_cached_bytes` / `jpeg_encode_failures`: JPEG encoder health + - `throttle_reason`: runtime throttling reason; empty means no active throttling + - `process_rss_kb` / `process_swap_kb` / `cma_free_kb`: low-memory-board diagnostics + - `preview_encoder` / `jpeg_source_format`: active preview encoder and source format + - `camera_driver` / `camera_backend`: camera driver and backend + - `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format`: low-resolution stream status + +### Debug camera settings + +- `POST /api/dev/debug/camera/settings` + - Purpose: incremental settings endpoint for the developer UI; not part of the stable external contract. + - Recent fields include: + - `whiteBalanceMode`, `whiteBalanceGainR`, `whiteBalanceGainB` + - `autoExposureMaxUs` + - `aeFlickerMode` + - `noiseReductionMode` + - `previewEncoder` + +## Analysis lab extensions + +- `POST /api/dev/analysis/solve/frame` +- `POST /api/dev/analysis/solve/frame_upload` + - Request may include optional `enable_polar_guide`. + - `overlay_ext.polar_guide` in the response is experimental polar-guide overlay data for developer UI validation; it is not a stable `core/v1` field. + - `overlay_ext.labels_topn` and `overlay_ext.polar_guide` are independent optional fields; callers must handle either one being absent. + ## Documentation entrypoints - Standard OpenAPI: `/docs` (default) diff --git a/docs/development/README.md b/docs/development/README.md index 6f7019a..4b8e065 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -317,6 +317,7 @@ sudo journalctl -u ogscope -f - 若仅前端模板/静态文件变更,通常不需要 `poetry install` - 若服务文件配置有改动,需先 `sudo systemctl daemon-reload` - 脚本会同步主服务 `ExecStart` 与已安装的 `**ogscope-network-boot.service**` 内 `ExecStart`(项目目录变更时);未安装开机单元则跳过 +- `scripts/sync_board_code.sh` 是开发机到开发板的便捷同步脚本:通过 `rsync` 上传源码后在板端执行 `scripts/board-update.sh`,并保留 `uploads/`、`logs/`、`data/` 等运行数据。它适合频繁迭代;全量重装、系统依赖变化或服务单元首次安装仍应使用 `install.sh` / `bootstrap.sh`。 ### 6.3 卸载服务与本地环境(`scripts/uninstall.sh`) @@ -465,6 +466,7 @@ router.include_router(new_router, tags=["NewModule - 新模块"]) - `docs/contracts/core-rest-v1.md`、`docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md`、`docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md`(段内中英,单文件) +6. 若新增或变更调试页字段、相机管线遥测、分析结果 `overlay_ext`,同步更新 `docs/DEBUG_CONSOLE.md` / `docs/DEBUG_CONSOLE_EN.md` 与对应契约文档。 ## 10. 常见故障排查 @@ -495,4 +497,3 @@ sudo journalctl -u ogscope -f # ./scripts/uninstall.sh # OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh ``` - diff --git a/docs/development/README_EN.md b/docs/development/README_EN.md index 593b792..6f215eb 100644 --- a/docs/development/README_EN.md +++ b/docs/development/README_EN.md @@ -317,6 +317,7 @@ Notes: - if only templates/static files changed, `poetry install` is usually not needed - if service file changed, run `sudo systemctl daemon-reload` first - the script syncs `**ExecStart**` for the main `ogscope` unit and, if installed, `**ogscope-network-boot.service**` (when the project directory path changed); if the boot unit was never installed, that step is skipped +- `scripts/sync_board_code.sh` is the developer-machine-to-board convenience sync: it uploads source with `rsync`, then runs `scripts/board-update.sh` on the board while preserving runtime data such as `uploads/`, `logs/`, and `data/`. Use it for frequent iteration; use `install.sh` / `bootstrap.sh` for full reinstall, system dependency changes, or first-time service-unit installation. ### 6.3 Uninstall service and local environment (`scripts/uninstall.sh`) @@ -461,6 +462,7 @@ To reduce mistaken submissions as the architecture grows, verify all items below - `docs/contracts/core-rest-v1.md` / `docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md` / `docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md` (inline bilingual, single file) +6. If debug-console fields, camera-pipeline telemetry, or analysis-result `overlay_ext` changes, update `docs/DEBUG_CONSOLE.md` / `docs/DEBUG_CONSOLE_EN.md` and the matching contract docs. ## 10. Troubleshooting Checklist @@ -487,4 +489,3 @@ sudo journalctl -u ogscope -f # ./scripts/uninstall.sh # OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh ``` - From 620a4174ecbcfaa22d02ab515dedf196223c5707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Fri, 26 Jun 2026 21:30:11 +0800 Subject: [PATCH 14/18] =?UTF-8?q?feat:=20add=20sensor=20solve=20context=20?= =?UTF-8?q?prediction=20/=20=E5=A2=9E=E5=8A=A0=E4=BC=A0=E6=84=9F=E5=99=A8?= =?UTF-8?q?=E8=A7=A3=E7=AE=97=E4=B8=8A=E4=B8=8B=E6=96=87=E9=A2=84=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../algorithms/plate_solve/sensor_context.py | 221 ++++++++++++++++++ ogscope/core/application/core_service.py | 2 + ogscope/core/realtime/service.py | 8 +- ogscope/web/api/analysis/services.py | 13 +- ogscope/web/api/core/routes.py | 1 + ogscope/web/api/models/schemas.py | 47 ++++ tests/unit/test_sensor_solve_context.py | 85 +++++++ 7 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 ogscope/algorithms/plate_solve/sensor_context.py create mode 100644 tests/unit/test_sensor_solve_context.py diff --git a/ogscope/algorithms/plate_solve/sensor_context.py b/ogscope/algorithms/plate_solve/sensor_context.py new file mode 100644 index 0000000..b2edb7d --- /dev/null +++ b/ogscope/algorithms/plate_solve/sensor_context.py @@ -0,0 +1,221 @@ +"""Sensor-assisted solve prediction / 传感器辅助解算预测.""" + +from __future__ import annotations + +import math +from datetime import UTC, datetime +from typing import Any + +DEFAULT_SENSOR_MATCH_THRESHOLD_DEG = 25.0 + + +def _optional_float(value: Any) -> float | None: + try: + if value is None: + return None + result = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(result): + return None + return result + + +def _normalize_deg(value: float) -> float: + return value % 360.0 + + +def _parse_utc(value: Any) -> datetime | None: + if isinstance(value, datetime): + dt = value + elif isinstance(value, str): + text = value.strip() + if not text: + return None + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + else: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + + +def julian_date(when_utc: datetime) -> float: + """Julian date from UTC datetime / UTC 时间转儒略日.""" + dt = when_utc.astimezone(UTC) + year = dt.year + month = dt.month + day = dt.day + hour = dt.hour + dt.minute / 60.0 + (dt.second + dt.microsecond / 1e6) / 3600.0 + if month <= 2: + year -= 1 + month += 12 + a = year // 100 + b = 2 - a + a // 4 + jd = int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + day + b - 1524.5 + return jd + hour / 24.0 + + +def gmst_deg(jd: float) -> float: + """Greenwich mean sidereal time in degrees / 格林尼治平恒星时(度).""" + t = (jd - 2451545.0) / 36525.0 + gmst = ( + 280.46061837 + + 360.98564736629 * (jd - 2451545.0) + + 0.000387933 * t * t + - (t * t * t) / 38710000.0 + ) + return _normalize_deg(gmst) + + +def local_sidereal_time_deg(longitude_deg: float, when_utc: datetime) -> float: + """Local sidereal time in degrees / 地方恒星时(度).""" + return _normalize_deg(gmst_deg(julian_date(when_utc)) + longitude_deg) + + +def horizontal_to_equatorial( + *, + altitude_deg: float, + azimuth_deg: float, + latitude_deg: float, + longitude_deg: float, + when_utc: datetime, +) -> tuple[float, float]: + """Convert Alt/Az to RA/Dec; azimuth is north-based clockwise. + + 地平坐标转赤道坐标;方位角从北顺时针计算。 + """ + lat_r = math.radians(latitude_deg) + alt_r = math.radians(altitude_deg) + az_r = math.radians(azimuth_deg) + sin_dec = ( + math.sin(alt_r) * math.sin(lat_r) + + math.cos(alt_r) * math.cos(lat_r) * math.cos(az_r) + ) + dec_r = math.asin(max(-1.0, min(1.0, sin_dec))) + ha_r = math.atan2( + -math.sin(az_r) * math.cos(alt_r), + math.sin(alt_r) * math.cos(lat_r) + - math.cos(alt_r) * math.sin(lat_r) * math.cos(az_r), + ) + ra_deg = _normalize_deg( + local_sidereal_time_deg(longitude_deg, when_utc) - math.degrees(ha_r) + ) + return ra_deg, math.degrees(dec_r) + + +def angular_separation_deg( + ra1_deg: float, + dec1_deg: float, + ra2_deg: float, + dec2_deg: float, +) -> float: + """Great-circle distance between two RA/Dec points / 两个赤道坐标点的大圆距离.""" + ra1 = math.radians(ra1_deg) + dec1 = math.radians(dec1_deg) + ra2 = math.radians(ra2_deg) + dec2 = math.radians(dec2_deg) + cos_sep = ( + math.sin(dec1) * math.sin(dec2) + + math.cos(dec1) * math.cos(dec2) * math.cos(ra1 - ra2) + ) + return math.degrees(math.acos(max(-1.0, min(1.0, cos_sep)))) + + +def _as_dict(value: Any) -> dict[str, Any]: + if hasattr(value, "model_dump"): + dumped = value.model_dump(exclude_none=True) + return dumped if isinstance(dumped, dict) else {} + return value if isinstance(value, dict) else {} + + +def predict_from_solve_context( + solve_context: Any, +) -> dict[str, Any]: + """Build predicted RA/Dec from optional sensor context. + + 从可选传感器上下文生成预测赤经赤纬。 + """ + ctx = _as_dict(solve_context) + observer = _as_dict(ctx.get("observer")) + orientation = _as_dict(ctx.get("orientation")) + quality = _as_dict(ctx.get("quality")) + if not ctx: + return {"sensor_status": "unavailable"} + + gps_valid = bool(quality.get("gps_valid")) + time_valid = bool(quality.get("time_valid")) + mount_valid = bool(quality.get("mount_valid")) + heading_valid = bool(quality.get("heading_valid")) + lat = _optional_float(observer.get("latitude_deg")) + lon = _optional_float(observer.get("longitude_deg")) + when = _parse_utc(observer.get("time_utc")) + alt = _optional_float(orientation.get("altitude_deg")) + az = _optional_float(orientation.get("azimuth_deg")) + if az is None: + az = _optional_float(orientation.get("heading_deg")) + if ( + not gps_valid + or not time_valid + or lat is None + or lon is None + or when is None + or alt is None + or az is None + ): + return {"sensor_status": "unavailable"} + if not ( + -90.0 <= lat <= 90.0 + and -180.0 <= lon <= 180.0 + and -90.0 <= alt <= 90.0 + ): + return {"sensor_status": "unavailable"} + if not (mount_valid or heading_valid): + return {"sensor_status": "unavailable"} + predicted_ra, predicted_dec = horizontal_to_equatorial( + altitude_deg=alt, + azimuth_deg=az, + latitude_deg=lat, + longitude_deg=lon, + when_utc=when, + ) + return { + "predicted_ra_deg": round(predicted_ra, 6), + "predicted_dec_deg": round(predicted_dec, 6), + "sensor_delta_deg": None, + "sensor_status": "predicted", + } + + +def attach_sensor_prediction( + row: dict[str, Any], + solve_context: Any, + *, + threshold_deg: float = DEFAULT_SENSOR_MATCH_THRESHOLD_DEG, +) -> None: + """Attach sensor prediction to a solve row in-place / 就地附加传感器预测结果。""" + if solve_context is None: + return + prediction = predict_from_solve_context(solve_context) + if prediction.get("sensor_status") != "predicted": + row["sensor_prediction"] = prediction + return + ra = _optional_float(row.get("ra_deg")) + dec = _optional_float(row.get("dec_deg")) + if str(row.get("status") or "") != "MATCH_FOUND" or ra is None or dec is None: + row["sensor_prediction"] = prediction + return + delta = angular_separation_deg( + float(prediction["predicted_ra_deg"]), + float(prediction["predicted_dec_deg"]), + ra, + dec, + ) + prediction["sensor_delta_deg"] = round(delta, 6) + prediction["sensor_status"] = "matched" if delta <= threshold_deg else "mismatch" + row["sensor_prediction"] = prediction diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index 44d2d61..cea740e 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -179,6 +179,7 @@ async def start_analysis( fov_estimate: float | None = None, fov_max_error: float | None = None, solve_timeout_ms: int | None = None, + solve_context: Any | None = None, ) -> dict[str, Any]: """开始实时分析 / Start realtime analysis.""" result = await realtime_solve_service.start( @@ -187,6 +188,7 @@ async def start_analysis( fov_estimate=fov_estimate, fov_max_error=fov_max_error, solve_timeout_ms=solve_timeout_ms, + solve_context=solve_context, ) self._session.running = True return { diff --git a/ogscope/core/realtime/service.py b/ogscope/core/realtime/service.py index f05c0f2..61268b1 100644 --- a/ogscope/core/realtime/service.py +++ b/ogscope/core/realtime/service.py @@ -10,6 +10,7 @@ from typing import Any from ogscope.algorithms.plate_solve import PlateSolver, SolveResult +from ogscope.algorithms.plate_solve.sensor_context import attach_sensor_prediction from ogscope.algorithms.star_extract import StarExtractor, StarPoint from ogscope.config import effective_solver_max_stars, get_settings from ogscope.web.camera_shared import get_camera_manager @@ -46,6 +47,7 @@ def __init__(self) -> None: self._fov_estimate: float | None = None self._fov_max_error: float | None = None self._solve_timeout_ms: int | None = None + self._solve_context: Any | None = None self._analysis_interval_sec = max( float(settings.star_analysis_min_interval_ms) / 1000.0, 1.0 / max(0.01, float(settings.star_analysis_target_fps)), @@ -58,6 +60,7 @@ async def start( fov_estimate: float | None = None, fov_max_error: float | None = None, solve_timeout_ms: int | None = None, + solve_context: Any | None = None, ) -> dict[str, Any]: """启动实时解算 / Start realtime solving""" if self.state.running: @@ -72,6 +75,7 @@ async def start( self._fov_estimate = fov_estimate self._fov_max_error = fov_max_error self._solve_timeout_ms = solve_timeout_ms + self._solve_context = solve_context self.state = RealtimeState(running=True) self._previous_stars = None self._task = asyncio.create_task(self._loop()) @@ -169,7 +173,9 @@ def _solve_frame_sync( def _apply_solve_result(self, solved: SolveResult) -> None: """写入解算结果 / Persist solve result""" - self.state.last_result = solved.to_dict() + row = solved.to_dict() + attach_sensor_prediction(row, self._solve_context) + self.state.last_result = row self._hint_ra = solved.ra_deg self._hint_dec = solved.dec_deg diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index 23149af..3a59f00 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -25,6 +25,7 @@ centroid_extraction_preview, merge_centroid_params, ) +from ogscope.algorithms.plate_solve.sensor_context import attach_sensor_prediction from ogscope.algorithms.star_extract import StarExtractor from ogscope.config import ( effective_solver_max_image_side, @@ -671,6 +672,7 @@ def _run_single() -> list[dict[str, Any]]: max_stars=max_stars, large_scale_bg_subtract=ls_bg, centroid_rejection_level=cr_lv, + solve_context=body.solve_context, ) def _run_two_stage() -> list[dict[str, Any]]: @@ -693,6 +695,7 @@ def _run_two_stage() -> list[dict[str, Any]]: max_stars=speed_max_stars, large_scale_bg_subtract=ls_bg, centroid_rejection_level=cr_lv, + solve_context=body.solve_context, ) row0 = first[0] if first else None if row0 and row0.get("status") == "MATCH_FOUND": @@ -722,6 +725,7 @@ def _run_two_stage() -> list[dict[str, Any]]: max_stars=robust_max_stars, large_scale_bg_subtract=ls_bg, centroid_rejection_level=cr_lv, + solve_context=body.solve_context, ) if second: second[0]["solve_profile"] = "robust" @@ -1005,6 +1009,7 @@ def _run() -> dict[str, Any]: self._clamp_centroid_rejection_level( solve_params.centroid_rejection_level ), + solve_context=solve_params.solve_context, ) hard_timeout_sec = max( @@ -1152,6 +1157,7 @@ def _solve_bgr_to_row( max_stars: int | None = None, large_scale_bg_subtract: bool = False, centroid_rejection_level: int | None = None, + solve_context: Any | None = None, ) -> dict[str, Any]: """BGR 帧送 Tetra3 解算 / Plate-solve one BGR frame.""" cr_level = self._clamp_centroid_rejection_level( @@ -1179,7 +1185,9 @@ def _solve_bgr_to_row( large_scale_bg_subtract=large_scale_bg_subtract, centroid_rejection_level=cr_level, ) - return {"frame_index": 0, **solved.to_dict()} + row = {"frame_index": 0, **solved.to_dict()} + attach_sensor_prediction(row, solve_context) + return row def _analyze_image( self, @@ -1194,6 +1202,7 @@ def _analyze_image( max_stars: int | None = None, large_scale_bg_subtract: bool = False, centroid_rejection_level: int | None = None, + solve_context: Any | None = None, ) -> list[dict[str, Any]]: """分析单图 / Analyze image""" t_total = time.perf_counter() @@ -1214,6 +1223,7 @@ def _analyze_image( max_stars=max_stars, large_scale_bg_subtract=large_scale_bg_subtract, centroid_rejection_level=centroid_rejection_level, + solve_context=solve_context, ) row["t_open_decode_ms"] = round(t_open_decode_ms, 3) row["t_backend_total_ms"] = round((time.perf_counter() - t_total) * 1000.0, 3) @@ -1366,6 +1376,7 @@ def _run() -> dict[str, Any]: max_stars, bool(body.large_scale_bg_subtract), cr_frame, + solve_context=body.solve_context, ) hard_timeout_sec = max( diff --git a/ogscope/web/api/core/routes.py b/ogscope/web/api/core/routes.py index 4a53330..98ca46a 100644 --- a/ogscope/web/api/core/routes.py +++ b/ogscope/web/api/core/routes.py @@ -34,6 +34,7 @@ async def core_start_analysis(body: CoreStartAnalysisRequest) -> CoreAnalysisCon fov_estimate=body.fov_estimate, fov_max_error=body.fov_max_error, solve_timeout_ms=body.solve_timeout_ms, + solve_context=body.solve_context, ) return CoreAnalysisControlResponse(**data) except Exception as exc: # noqa: BLE001 diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index 6849b8f..6de996f 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -194,6 +194,50 @@ def filtsize_must_be_odd(cls, v: Optional[int]) -> Optional[int]: return v +class SolveObserverContext(BaseModel): + """Observer context for sensor-assisted solve / 传感器辅助解算的观测者上下文。""" + + model_config = ConfigDict(extra="forbid") + + latitude_deg: Optional[float] = Field(default=None, ge=-90.0, le=90.0) + longitude_deg: Optional[float] = Field(default=None, ge=-180.0, le=180.0) + altitude_m: Optional[float] = None + time_utc: Optional[str] = None + source: Optional[str] = None + + +class SolveOrientationContext(BaseModel): + """Orientation context for sensor-assisted solve / 传感器辅助解算的指向上下文。""" + + model_config = ConfigDict(extra="forbid") + + azimuth_deg: Optional[float] = Field(default=None, ge=0.0, le=360.0) + altitude_deg: Optional[float] = Field(default=None, ge=-90.0, le=90.0) + heading_deg: Optional[float] = Field(default=None, ge=0.0, le=360.0) + source: Optional[str] = None + + +class SolveContextQuality(BaseModel): + """Validity flags for sensor-assisted solve / 传感器辅助解算的有效性标记。""" + + model_config = ConfigDict(extra="forbid") + + gps_valid: bool = False + time_valid: bool = False + heading_valid: bool = False + mount_valid: bool = False + + +class SolveContextPayload(BaseModel): + """Optional sensor context from ZenitAPA / ZenitAPA 提供的可选传感器上下文。""" + + model_config = ConfigDict(extra="forbid") + + observer: Optional[SolveObserverContext] = None + orientation: Optional[SolveOrientationContext] = None + quality: Optional[SolveContextQuality] = None + + class AnalysisSolveImageRequest(BaseModel): """单图解算请求(JSON body)/ Single-image plate solve request.""" @@ -205,6 +249,7 @@ class AnalysisSolveImageRequest(BaseModel): fov_estimate: Optional[float] = None fov_max_error: Optional[float] = None solve_timeout_ms: Optional[int] = None + solve_context: Optional[SolveContextPayload] = None solve_profile: Optional[Literal["speed", "balanced", "robust"]] = None centroid: Optional[CentroidParamsPayload] = None max_image_side: Optional[int] = None @@ -365,6 +410,7 @@ class AnalysisSolveVideoFrameRequest(BaseModel): fov_estimate: Optional[float] = None fov_max_error: Optional[float] = None solve_timeout_ms: Optional[int] = None + solve_context: Optional[SolveContextPayload] = None solve_profile: Optional[Literal["speed", "balanced", "robust"]] = None centroid: Optional[CentroidParamsPayload] = None max_image_side: Optional[int] = None @@ -419,6 +465,7 @@ class CoreStartAnalysisRequest(BaseModel): fov_estimate: Optional[float] = None fov_max_error: Optional[float] = None solve_timeout_ms: Optional[int] = Field(default=None, ge=200, le=120000) + solve_context: Optional[SolveContextPayload] = None class CoreAnalysisControlResponse(BaseModel): diff --git a/tests/unit/test_sensor_solve_context.py b/tests/unit/test_sensor_solve_context.py new file mode 100644 index 0000000..87e3e1c --- /dev/null +++ b/tests/unit/test_sensor_solve_context.py @@ -0,0 +1,85 @@ +"""Tests for sensor-assisted solve context / 传感器辅助解算上下文测试.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from ogscope.algorithms.plate_solve.sensor_context import ( + attach_sensor_prediction, + local_sidereal_time_deg, +) +from ogscope.web.api.models.schemas import AnalysisSolveImageRequest + + +def _solve_context(*, azimuth_deg: float = 0.0, altitude_deg: float = 90.0) -> dict: + return { + "observer": { + "latitude_deg": 0.0, + "longitude_deg": 0.0, + "altitude_m": 0.0, + "time_utc": "2000-01-01T12:00:00Z", + "source": "test", + }, + "orientation": { + "azimuth_deg": azimuth_deg, + "altitude_deg": altitude_deg, + "heading_deg": azimuth_deg, + "source": "test", + }, + "quality": { + "gps_valid": True, + "time_valid": True, + "heading_valid": True, + "mount_valid": True, + }, + } + + +def test_analysis_solve_image_accepts_solve_context() -> None: + """旧请求兼容并接受新字段 / Old requests remain compatible and accept new field.""" + old_req = AnalysisSolveImageRequest.model_validate({"input_name": "stars.jpg"}) + assert old_req.solve_context is None + + req = AnalysisSolveImageRequest.model_validate( + {"input_name": "stars.jpg", "solve_context": _solve_context()} + ) + assert req.solve_context is not None + assert req.solve_context.quality.gps_valid is True + + +def test_sensor_prediction_matches_zenith_at_equator() -> None: + """赤道天顶预测应落在赤纬 0 附近 / Equator zenith predicts near Dec 0.""" + row = {"status": "MATCH_FOUND", "ra_deg": 0.0, "dec_deg": 0.0} + context = _solve_context() + expected_ra = local_sidereal_time_deg(0.0, datetime(2000, 1, 1, 12, tzinfo=UTC)) + row["ra_deg"] = expected_ra + attach_sensor_prediction(row, context) + + pred = row["sensor_prediction"] + assert pred["sensor_status"] == "matched" + assert pred["predicted_dec_deg"] == pytest.approx(0.0, abs=1e-6) + assert pred["predicted_ra_deg"] == pytest.approx(expected_ra, abs=1e-6) + assert pred["sensor_delta_deg"] == pytest.approx(0.0, abs=1e-6) + + +def test_sensor_prediction_flags_mismatch() -> None: + """偏差过大时标记 mismatch / Large delta only marks mismatch.""" + row = {"status": "MATCH_FOUND", "ra_deg": 180.0, "dec_deg": 0.0} + attach_sensor_prediction(row, _solve_context(), threshold_deg=25.0) + + assert row["status"] == "MATCH_FOUND" + assert row["sensor_prediction"]["sensor_status"] == "mismatch" + assert row["sensor_prediction"]["sensor_delta_deg"] > 25.0 + + +def test_sensor_prediction_unavailable_when_time_invalid() -> None: + """传感器时间无效时保持 unavailable / Invalid sensor time stays unavailable.""" + context = _solve_context() + context["quality"]["time_valid"] = False + context["observer"]["time_utc"] = None + row = {"status": "MATCH_FOUND", "ra_deg": 0.0, "dec_deg": 0.0} + attach_sensor_prediction(row, context) + + assert row["sensor_prediction"]["sensor_status"] == "unavailable" From d7daa7a697cac519ba51a0c9a6687f70e6a40292 Mon Sep 17 00:00:00 2001 From: Sylensky Date: Sun, 5 Jul 2026 23:57:25 +0200 Subject: [PATCH 15/18] ogscope: domain: filesystem: store dev captures on tmpfs --- ogscope/domain/shared/filesystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ogscope/domain/shared/filesystem.py b/ogscope/domain/shared/filesystem.py index c5fff0b..3daaef0 100644 --- a/ogscope/domain/shared/filesystem.py +++ b/ogscope/domain/shared/filesystem.py @@ -6,7 +6,7 @@ from pathlib import Path, PurePath -DEV_CAPTURES_DIR = Path.home() / "dev_captures" +DEV_CAPTURES_DIR = Path("/tmp/dev_captures") DEV_CAPTURES_DIR.mkdir(exist_ok=True) IMAGE_EXTENSIONS = { From 1cc85c4e46f495191e433a67d66eff205778c321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Mon, 6 Jul 2026 14:03:44 +0800 Subject: [PATCH 16/18] =?UTF-8?q?fix:=20repair=20OGScope=20camera=20setup?= =?UTF-8?q?=20in=20updates=20/=20=E4=BF=AE=E5=A4=8D=20OGScope=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=97=B6=E7=9B=B8=E6=9C=BA=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make board-update independently repair Picamera2/libcamera runtime packages and apply the default IMX327 boot overlay during non-interactive deployments.\n\n让 board-update 在非交互部署中独立补齐 Picamera2/libcamera 运行栈,并默认应用 IMX327 boot overlay。 --- docs/development/README.md | 4 +- docs/development/README_EN.md | 6 ++- scripts/board-update.sh | 5 +- scripts/boot-config-camera.sh | 90 +++++++++++++++++++++++++++++++---- scripts/install.sh | 12 ++--- scripts/sync_board_code.sh | 24 +++++++++- 6 files changed, 119 insertions(+), 22 deletions(-) diff --git a/docs/development/README.md b/docs/development/README.md index 4b8e065..e865e04 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -99,7 +99,7 @@ sudo journalctl -u ogscope -f | 现象 | 处理方向 | | ------------------------ | ------------------------------------------------------ | -| `ImportError: picamera2` | 用 `apt` 装相机栈;venv 由 `install.sh` 配置(**§1.2、§3**) | +| `ImportError: picamera2` | 重新跑 `install.sh` 或 `board-update.sh` 补齐相机栈;venv 由脚本配置(**§1.2、§3**) | | PEP 668 / 系统 pip 被拒 | 只用项目 `.venv`,勿在系统 Python 上混装(**§1.2**) | | 服务无法启动 | 查 `WorkingDirectory`、`ExecStart`、`journalctl`(**§10**) | @@ -169,6 +169,7 @@ chmod +x scripts/install.sh ## 2. 系统环境依赖(重点) OGScope 除 Python 包依赖外,还依赖开发板系统层的相机生态(如 `picamera2`/`libcamera`)。 +`scripts/install.sh` 与 `scripts/board-update.sh` 都会尝试补装 `python3-picamera2` 与可用的 `rpicam/libcamera` 工具;若目标板不是 IMX327,可用 `OGSCOPE_CAMERA=skip` 跳过 boot overlay 写入。 建议系统具备以下基础组件(按发行版实际包名调整): @@ -316,6 +317,7 @@ sudo journalctl -u ogscope -f - 若仅前端模板/静态文件变更,通常不需要 `poetry install` - 若服务文件配置有改动,需先 `sudo systemctl daemon-reload` +- 脚本会补齐 Picamera2/libcamera 相机运行栈;无 TTY 或 `OGSCOPE_NONINTERACTIVE=1` 时默认写入 IMX327 boot overlay,可用 `OGSCOPE_CAMERA=skip` 或 `OGSCOPE_SKIP_BOOT_CAMERA=1` 跳过 - 脚本会同步主服务 `ExecStart` 与已安装的 `**ogscope-network-boot.service**` 内 `ExecStart`(项目目录变更时);未安装开机单元则跳过 - `scripts/sync_board_code.sh` 是开发机到开发板的便捷同步脚本:通过 `rsync` 上传源码后在板端执行 `scripts/board-update.sh`,并保留 `uploads/`、`logs/`、`data/` 等运行数据。它适合频繁迭代;全量重装、系统依赖变化或服务单元首次安装仍应使用 `install.sh` / `bootstrap.sh`。 diff --git a/docs/development/README_EN.md b/docs/development/README_EN.md index 6f215eb..17e92e5 100644 --- a/docs/development/README_EN.md +++ b/docs/development/README_EN.md @@ -93,7 +93,7 @@ sudo journalctl -u ogscope -f | Symptom | Where to look | | ------------------------ | ---------------------------------------------------------------------- | -| `ImportError: picamera2` | Install camera stack with `apt`; venv from `install.sh` (**§1.2, §3**) | +| `ImportError: picamera2` | Re-run `install.sh` or `board-update.sh` to repair camera stack; scripts configure the venv (**§1.2, §3**) | | PEP 668 | Use project `.venv` only; do not mix into system Python (**§1.2**) | | Service fails to start | `WorkingDirectory`, `ExecStart`, `journalctl` (**§10**) | @@ -168,6 +168,9 @@ chmod +x scripts/install.sh OGScope depends on board-level camera stack (`picamera2`/`libcamera`) in addition to Poetry packages. +`scripts/install.sh` and `scripts/board-update.sh` both try to install +`python3-picamera2` and available `rpicam/libcamera` tools. If the target board +is not IMX327, set `OGSCOPE_CAMERA=skip` to skip the boot overlay write. Typical requirements: @@ -316,6 +319,7 @@ Notes: - if only templates/static files changed, `poetry install` is usually not needed - if service file changed, run `sudo systemctl daemon-reload` first +- the script repairs the Picamera2/libcamera camera runtime; without a TTY or with `OGSCOPE_NONINTERACTIVE=1`, it writes the IMX327 boot overlay by default. Use `OGSCOPE_CAMERA=skip` or `OGSCOPE_SKIP_BOOT_CAMERA=1` to skip it - the script syncs `**ExecStart**` for the main `ogscope` unit and, if installed, `**ogscope-network-boot.service**` (when the project directory path changed); if the boot unit was never installed, that step is skipped - `scripts/sync_board_code.sh` is the developer-machine-to-board convenience sync: it uploads source with `rsync`, then runs `scripts/board-update.sh` on the board while preserving runtime data such as `uploads/`, `logs/`, and `data/`. Use it for frequent iteration; use `install.sh` / `bootstrap.sh` for full reinstall, system dependency changes, or first-time service-unit installation. diff --git a/scripts/board-update.sh b/scripts/board-update.sh index 5cdc87f..7bff4fc 100755 --- a/scripts/board-update.sh +++ b/scripts/board-update.sh @@ -10,7 +10,9 @@ # OGSCOPE_SKIP_PLATE_DB=1 — 不复制 default_database.npz / Skip Tetra3 pattern DB copy # OGSCOPE_FORCE_PLATE_DB=1 — 覆盖已存在的 data/plate_solve/default_database.npz / Overwrite pattern DB # OGSCOPE_SKIP_NETWORK_SYNC=1 — 不同步 WiFi 切换脚本与 ensure-systemd(免密 sudo 不可用时可设)/ Skip WiFi script + ensure-systemd -# OGSCOPE_CAMERA=imx327|skip — 非交互指定摄像头 boot 配置 / Boot camera preset (non-interactive) +# OGSCOPE_CAMERA=imx327|skip — 指定摄像头 boot 配置 / Boot camera preset +# OGSCOPE_CAMERA_DEFAULT=imx327|skip — 无 TTY/非交互默认值,默认 imx327 / Default for non-TTY/non-interactive; default imx327 +# OGSCOPE_SKIP_CAMERA_STACK=1 — 不补装 Picamera2/libcamera 运行栈 / Skip Picamera2/libcamera runtime repair # OGSCOPE_SKIP_BOOT_CAMERA=1 — 不询问、不写入 /boot 摄像头配置 / Skip boot camera prompt and changes # OGSCOPE_SKIP_BOOT_I2C=1 — 不写入 /boot 中 dtparam=i2c_arm=on(仍会安装 i2c-tools、仍将用户加入 i2c 组)/ Skip I2C boot dtparam; still installs i2c-tools and adds user to i2c group # OGSCOPE_SKIP_JOURNALD_PERSISTENT=1 — 不同步 journald 持久化配置 / Skip journald persistent drop-in @@ -119,6 +121,7 @@ fi echo "📦 I²C 主机依赖(与 install.sh 对齐)/ I2C host setup (aligned with install.sh)..." sudo apt update -qq +ogscope_install_camera_stack_if_needed ogscope_i2c_host_setup_full 1 VENV_PYTHON="$(poetry env info --path)/bin/python" diff --git a/scripts/boot-config-camera.sh b/scripts/boot-config-camera.sh index 526d689..6fdcc94 100644 --- a/scripts/boot-config-camera.sh +++ b/scripts/boot-config-camera.sh @@ -2,9 +2,70 @@ # 由 install.sh、board-update.sh 用 `source` 加载 / Sourced by install.sh and board-update.sh # # 环境变量 / Environment: -# OGSCOPE_CAMERA=imx327|skip — 非交互时指定摄像头型号或跳过 / Preset camera model or skip (non-interactive) +# OGSCOPE_CAMERA=imx327|skip — 指定摄像头型号或跳过 / Preset camera model or skip +# OGSCOPE_CAMERA_DEFAULT=imx327|skip — 无 TTY/非交互默认值,默认 imx327 / Default for non-TTY/non-interactive; default imx327 +# OGSCOPE_SKIP_CAMERA_STACK=1 — 不补装 Picamera2/libcamera 运行栈 / Skip Picamera2/libcamera runtime repair # OGSCOPE_SKIP_BOOT_CAMERA=1 — 不询问、不修改 /boot 配置 / Do not prompt or modify boot config -# OGSCOPE_NONINTERACTIVE=1 — 无 TTY 时不提示;未设 OGSCOPE_CAMERA 时等同 skip / No prompt; default skip without OGSCOPE_CAMERA +# OGSCOPE_NONINTERACTIVE=1 — 不提示;未设 OGSCOPE_CAMERA 时使用 OGSCOPE_CAMERA_DEFAULT / No prompt; use OGSCOPE_CAMERA_DEFAULT without OGSCOPE_CAMERA + +ogscope_camera_apt_install_if_available() { + local pkg="$1" + local label="$2" + if ! apt-cache show "${pkg}" >/dev/null 2>&1; then + return 1 + fi + echo "📦 安装 ${label}: ${pkg} / Installing ${label}: ${pkg}" + if sudo apt install -y "${pkg}"; then + return 0 + fi + echo "⚠️ ${pkg} 安装失败,继续后续步骤 / ${pkg} install failed; continuing" >&2 + return 2 +} + +# 补齐树莓派 CSI 相机运行栈;增量更新也调用,避免只重装时才修复。 +# Repair Raspberry Pi CSI camera runtime; board-update calls this too, not only reinstall. +ogscope_install_camera_stack_if_needed() { + if [ "${OGSCOPE_SKIP_CAMERA_STACK:-}" = "1" ]; then + echo "⏭️ 跳过相机运行栈补装(OGSCOPE_SKIP_CAMERA_STACK=1)/ Skipping camera stack repair" + return 0 + fi + + if ! command -v apt-cache >/dev/null 2>&1; then + echo "ℹ️ 未找到 apt-cache,跳过相机运行栈补装 / apt-cache not found; skipped camera stack repair" + return 0 + fi + + if python3 -c 'from picamera2 import Picamera2' >/dev/null 2>&1; then + echo "✅ Picamera2 已可导入 / Picamera2 import OK" + else + if ogscope_camera_apt_install_if_available "python3-picamera2" "Picamera2"; then + : + else + _picamera_install_status=$? + if [ "${_picamera_install_status}" -eq 1 ]; then + echo "ℹ️ 未找到 python3-picamera2 软件包,请按板卡文档安装相机栈 / No python3-picamera2 package; install camera stack per board docs" + fi + fi + if python3 -c 'from picamera2 import Picamera2' >/dev/null 2>&1; then + echo "✅ Picamera2 补装完成 / Picamera2 repaired" + else + echo "⚠️ Picamera2 仍不可导入;若相机不可用请检查 apt 源与板卡相机栈 / Picamera2 still unavailable; check apt source and board camera stack" + fi + fi + + if command -v rpicam-hello >/dev/null 2>&1 || command -v libcamera-hello >/dev/null 2>&1; then + echo "✅ libcamera/rpicam 工具已存在 / libcamera/rpicam tools found" + return 0 + fi + + local pkg + for pkg in rpicam-apps-core rpicam-apps libcamera-apps; do + if ogscope_camera_apt_install_if_available "${pkg}" "libcamera/rpicam 工具 / tools"; then + return 0 + fi + done + echo "ℹ️ 未找到 rpicam/libcamera apps 软件包;Picamera2 可用时 OGScope 仍可运行 / No rpicam/libcamera apps package; OGScope can still run if Picamera2 works" +} # 返回可写的 config.txt 路径(Bookworm 多为 /boot/firmware/config.txt)/ Resolve config.txt path ogscope_boot_config_path() { @@ -71,7 +132,10 @@ ogscope_boot_config_apply_imx327() { END { exit (inserted ? 0 : 1) } ' "${cfg}" > "${tmp}"; then sudo cp -a "${cfg}" "${cfg}.bak.ogscope.$(date +%s)" - sudo mv "${tmp}" "${cfg}" + # /boot/firmware 常见为 FAT 分区,mv 会尝试保留所有权并打印误导性警告;用 cp 覆盖内容。 + # /boot/firmware is often FAT; mv may warn about ownership preservation, so copy contents instead. + sudo cp "${tmp}" "${cfg}" + rm -f "${tmp}" sudo chown root:root "${cfg}" 2>/dev/null || true sudo chmod 644 "${cfg}" 2>/dev/null || true return 0 @@ -130,13 +194,19 @@ ogscope_resolve_camera_choice() { return 0 fi - if [ "${OGSCOPE_NONINTERACTIVE:-}" = "1" ]; then - echo "skip" - return 0 - fi - - if [ ! -t 0 ]; then - echo "skip" + if [ "${OGSCOPE_NONINTERACTIVE:-}" = "1" ] || [ ! -t 0 ]; then + case "${OGSCOPE_CAMERA_DEFAULT:-imx327}" in + imx327 | IMX327) + echo "imx327" + ;; + skip | none | off | "") + echo "skip" + ;; + *) + echo "⚠️ 未知 OGSCOPE_CAMERA_DEFAULT=${OGSCOPE_CAMERA_DEFAULT},按 skip 处理 / Unknown OGSCOPE_CAMERA_DEFAULT; using skip" >&2 + echo "skip" + ;; + esac return 0 fi diff --git a/scripts/install.sh b/scripts/install.sh index f2d5c72..1e813bd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -13,7 +13,9 @@ # OGSCOPE_FORCE_PLATE_DB=1 — 若目标已存在仍覆盖 / Overwrite data/plate_solve/default_database.npz if present # OGSCOPE_DEVELOPMENT_MODE=1 — 启用开发模式(默认 OGSCOPE_LOG_LEVEL=DEBUG;也可显式设置 OGSCOPE_LOG_LEVEL)/ Dev mode (default DEBUG log level) # OGSCOPE_POETRY_INSTALLER_URL — 可选,覆盖 Poetry 引导脚本 URL(国内可自建镜像)/ Optional Poetry bootstrap URL mirror -# OGSCOPE_CAMERA=imx327|skip — 非交互指定摄像头 boot 配置(树莓派 config.txt)/ Boot camera preset (non-interactive) +# OGSCOPE_CAMERA=imx327|skip — 指定摄像头 boot 配置(树莓派 config.txt)/ Boot camera preset +# OGSCOPE_CAMERA_DEFAULT=imx327|skip — 无 TTY/非交互默认值,默认 imx327 / Default for non-TTY/non-interactive; default imx327 +# OGSCOPE_SKIP_CAMERA_STACK=1 — 不补装 Picamera2/libcamera 运行栈 / Skip Picamera2/libcamera runtime repair # OGSCOPE_SKIP_BOOT_CAMERA=1 — 不询问、不写入 /boot 摄像头配置 / Skip boot camera prompt and changes # OGSCOPE_SKIP_BOOT_I2C=1 — 不写入 /boot 中 dtparam=i2c_arm=on(仍会 apt 装 i2c-tools、仍将用户加入 i2c 组)/ Skip appending I2C dtparam; still installs i2c-tools and adds user to i2c group # OGSCOPE_SKIP_JOURNALD_PERSISTENT=1 — 不安装 journald 持久化 drop-in(默认安装)/ Skip persistent journald config @@ -140,13 +142,7 @@ else fi _apt_pause -# 树莓派常见;若无此包可忽略 / Common on Raspberry Pi OS; skip if unavailable -if apt-cache show python3-picamera2 >/dev/null 2>&1; then - echo "📦 安装 python3-picamera2..." - sudo apt install -y python3-picamera2 || echo "⚠️ picamera2 安装跳过 / picamera2 install skipped" -else - echo "ℹ️ 未找到 python3-picamera2 软件包,请按板卡文档安装相机栈 / No python3-picamera2 package" -fi +ogscope_install_camera_stack_if_needed _apt_pause PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" diff --git a/scripts/sync_board_code.sh b/scripts/sync_board_code.sh index ce159b6..a01d590 100755 --- a/scripts/sync_board_code.sh +++ b/scripts/sync_board_code.sh @@ -5,6 +5,8 @@ # 用法 / Usage: # export OGSCOPE_DEV_HOST=192.168.31.231 # export OGSCOPE_DEV_USER=ogscope +# # 可选:非 IMX327 板卡可跳过摄像头 boot 配置 / Optional: skip camera boot config on non-IMX327 boards +# # export OGSCOPE_CAMERA=skip # ./scripts/sync_board_code.sh # # 注意:勿对整仓使用 rsync --delete 且不排除 uploads/,否则会删除板上已上传的测试图片。 @@ -20,6 +22,25 @@ REMOTE="${DEV_USER}@${DEV_HOST}" RSYNC_SSH="ssh -o ConnectTimeout=15 -o BatchMode=yes" +# 仅透传部署相关开关,避免把开发机的整个环境泄露到板端。 +# Forward only deployment switches, not the whole dev-machine environment. +_remote_update_env="" +for _env_name in \ + OGSCOPE_CAMERA \ + OGSCOPE_CAMERA_DEFAULT \ + OGSCOPE_SKIP_BOOT_CAMERA \ + OGSCOPE_SKIP_CAMERA_STACK \ + OGSCOPE_MIRROR \ + OGSCOPE_NONINTERACTIVE \ + POETRY_INSTALLER_MAX_WORKERS \ + OGSCOPE_DEVELOPMENT_MODE +do + if [ -n "${!_env_name+x}" ]; then + printf -v _env_value_quoted '%q' "${!_env_name}" + _remote_update_env+="${_env_name}=${_env_value_quoted} " + fi +done + echo "== Sync OGScope code → ${REMOTE}:${DEV_PATH} (uploads/logs/data preserved) ==" rsync -avz --delete \ @@ -37,8 +58,9 @@ rsync -avz --delete \ "${ROOT}/" "${REMOTE}:${DEV_PATH}/" echo "== Remote board-update ==" +echo " Camera default: ${OGSCOPE_CAMERA:-${OGSCOPE_CAMERA_DEFAULT:-imx327}} (override with OGSCOPE_CAMERA=skip)" ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE}" \ - "cd '${DEV_PATH}' && bash scripts/board-update.sh" + "cd '${DEV_PATH}' && ${_remote_update_env}bash scripts/board-update.sh" echo "✅ OGScope sync complete" echo " Health: http://${DEV_HOST}:8000/health" From cdb9c9269e3d618cbdea56829062715267ded0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Thu, 23 Jul 2026 11:47:41 +0800 Subject: [PATCH 17/18] =?UTF-8?q?chore:=20format=20OGScope=20integration?= =?UTF-8?q?=20cleanup=20/=20=E6=A0=BC=E5=BC=8F=E5=8C=96=20OGScope=20?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../algorithms/plate_solve/sensor_context.py | 20 +++------ ogscope/config.py | 4 +- ogscope/core/application/core_service.py | 16 +++++-- ogscope/core/capabilities/registry.py | 2 +- ogscope/domain/camera/encoding.py | 8 +++- ogscope/domain/camera/streaming.py | 2 +- ogscope/platform/hardware/camera.py | 45 ++++++++++++++----- ogscope/web/api/analysis/services.py | 17 +++---- ogscope/web/api/models/schemas.py | 4 +- ogscope/web/camera_shared.py | 22 +++++---- scripts/diagnose_camera.py | 24 +++++----- scripts/spi_display_smoke_test.py | 5 ++- tests/unit/test_camera_flip.py | 4 +- tests/unit/test_plate_large_scale_bg.py | 2 +- 14 files changed, 103 insertions(+), 72 deletions(-) diff --git a/ogscope/algorithms/plate_solve/sensor_context.py b/ogscope/algorithms/plate_solve/sensor_context.py index b2edb7d..d5f2549 100644 --- a/ogscope/algorithms/plate_solve/sensor_context.py +++ b/ogscope/algorithms/plate_solve/sensor_context.py @@ -93,10 +93,9 @@ def horizontal_to_equatorial( lat_r = math.radians(latitude_deg) alt_r = math.radians(altitude_deg) az_r = math.radians(azimuth_deg) - sin_dec = ( - math.sin(alt_r) * math.sin(lat_r) - + math.cos(alt_r) * math.cos(lat_r) * math.cos(az_r) - ) + sin_dec = math.sin(alt_r) * math.sin(lat_r) + math.cos(alt_r) * math.cos( + lat_r + ) * math.cos(az_r) dec_r = math.asin(max(-1.0, min(1.0, sin_dec))) ha_r = math.atan2( -math.sin(az_r) * math.cos(alt_r), @@ -120,10 +119,9 @@ def angular_separation_deg( dec1 = math.radians(dec1_deg) ra2 = math.radians(ra2_deg) dec2 = math.radians(dec2_deg) - cos_sep = ( - math.sin(dec1) * math.sin(dec2) - + math.cos(dec1) * math.cos(dec2) * math.cos(ra1 - ra2) - ) + cos_sep = math.sin(dec1) * math.sin(dec2) + math.cos(dec1) * math.cos( + dec2 + ) * math.cos(ra1 - ra2) return math.degrees(math.acos(max(-1.0, min(1.0, cos_sep)))) @@ -169,11 +167,7 @@ def predict_from_solve_context( or az is None ): return {"sensor_status": "unavailable"} - if not ( - -90.0 <= lat <= 90.0 - and -180.0 <= lon <= 180.0 - and -90.0 <= alt <= 90.0 - ): + if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0 and -90.0 <= alt <= 90.0): return {"sensor_status": "unavailable"} if not (mount_valid or heading_valid): return {"sensor_status": "unavailable"} diff --git a/ogscope/config.py b/ogscope/config.py index a764ccc..9285ddc 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -94,9 +94,7 @@ class Settings(BaseSettings): camera_height: int = Field( default=720, description="图像高度 / Default capture height" ) - camera_fps: int = Field( - default=8, description="传感器目标帧率 / Target sensor FPS" - ) + camera_fps: int = Field(default=8, description="传感器目标帧率 / Target sensor FPS") camera_sampling_mode: str = Field( default="native", description="采样模式: supersample/native/crop" ) diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index 2e2a8fd..bd3db01 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -68,14 +68,20 @@ def _build_ambient_hint(info: dict[str, Any], *, streaming: bool) -> dict[str, A scores: list[float] = [] if lux is not None and lux >= 0: - scores.append(CoreContractService._clamp01(1.0 - math.log10(lux + 1.0) / 2.0)) + scores.append( + CoreContractService._clamp01(1.0 - math.log10(lux + 1.0) / 2.0) + ) if exposure_us is not None and exposure_us > 0: exposure_ceiling = max(max_exposure_us or 100_000.0, 1.0) - exposure_score = CoreContractService._clamp01(exposure_us / exposure_ceiling) + exposure_score = CoreContractService._clamp01( + exposure_us / exposure_ceiling + ) gain_score = 0.0 if digital_gain is not None: gain_score = CoreContractService._clamp01((digital_gain - 1.0) / 7.0) - scores.append(CoreContractService._clamp01(exposure_score * 0.75 + gain_score * 0.25)) + scores.append( + CoreContractService._clamp01(exposure_score * 0.75 + gain_score * 0.25) + ) dark_score = sum(scores) / len(scores) if scores else None return { @@ -150,7 +156,9 @@ def _build_network_status( "in_health_scope": in_health_scope, } if not in_health_scope: - managed_by = "external" if profile.get("subordinate_mode") else "unconfigured" + managed_by = ( + "external" if profile.get("subordinate_mode") else "unconfigured" + ) return { **base, "managed_by": managed_by, diff --git a/ogscope/core/capabilities/registry.py b/ogscope/core/capabilities/registry.py index 5866422..74fb62c 100644 --- a/ogscope/core/capabilities/registry.py +++ b/ogscope/core/capabilities/registry.py @@ -9,8 +9,8 @@ from typing import Any from ogscope.config import get_settings -from ogscope.platform.hardware_plane.runtime import describe_hardware_plane_profile from ogscope.platform.hardware.wifi_switch import wifi_switch_service +from ogscope.platform.hardware_plane.runtime import describe_hardware_plane_profile def _module_available(module_name: str) -> bool: diff --git a/ogscope/domain/camera/encoding.py b/ogscope/domain/camera/encoding.py index e5e1c81..579a651 100644 --- a/ogscope/domain/camera/encoding.py +++ b/ogscope/domain/camera/encoding.py @@ -28,7 +28,9 @@ def encode_jpeg( self, frame: Any, *, quality: int = 75, source_format: str = "RGB888" ) -> EncodedImage | None: ... - def encode_png(self, frame: Any, *, source_format: str = "RGB888") -> bytes | None: ... + def encode_png( + self, frame: Any, *, source_format: str = "RGB888" + ) -> bytes | None: ... class OpenCVEncoder: @@ -111,7 +113,9 @@ def encode_jpeg( ) -> EncodedImage | None: """编码 JPEG;TurboJPEG 支持直接输入 RGB/BGR / Encode JPEG from RGB/BGR.""" fmt = str(source_format or "RGB888").upper() - pixel_format = self._tjpf_bgr if fmt in {"BGR888", "BGR", "BGR24"} else self._tjpf_rgb + pixel_format = ( + self._tjpf_bgr if fmt in {"BGR888", "BGR", "BGR24"} else self._tjpf_rgb + ) try: data = self._jpeg.encode( frame, diff --git a/ogscope/domain/camera/streaming.py b/ogscope/domain/camera/streaming.py index c9ebe2c..dfe5df4 100644 --- a/ogscope/domain/camera/streaming.py +++ b/ogscope/domain/camera/streaming.py @@ -15,8 +15,8 @@ from ogscope.config import get_settings from ogscope.domain.camera.services import camera_domain_service from ogscope.domain.camera.stream_limiter import get_mjpeg_stream_limiter -from ogscope.web.mjpeg_stream_helpers import mjpeg_sleep_or_disconnect from ogscope.web.camera_shared import get_camera_manager +from ogscope.web.mjpeg_stream_helpers import mjpeg_sleep_or_disconnect async def build_camera_mjpeg_stream( diff --git a/ogscope/platform/hardware/camera.py b/ogscope/platform/hardware/camera.py index f0d28f0..076889d 100644 --- a/ogscope/platform/hardware/camera.py +++ b/ogscope/platform/hardware/camera.py @@ -343,7 +343,14 @@ def _normalize_noise_reduction_mode(value: Any) -> str: if isinstance(value, int): return "off" if value <= 0 else "fast" if value <= 2 else "high_quality" text = str(value or "fast").strip().lower().replace("-", "_") - aliases = {"0": "off", "1": "fast", "2": "fast", "3": "high_quality", "4": "high_quality", "hq": "high_quality"} + aliases = { + "0": "off", + "1": "fast", + "2": "fast", + "3": "high_quality", + "4": "high_quality", + "hq": "high_quality", + } text = aliases.get(text, text) return text if text in {"off", "fast", "high_quality"} else "fast" @@ -435,7 +442,9 @@ def _apply_ae_flicker_controls(self) -> None: # Picamera2/libcamera 版本间枚举名有差异;找不到枚举时用整数 fallback,禁止传 None。 # Enum names differ across Picamera2/libcamera versions; fall back to ints and never pass None. updates["AeFlickerMode"] = ( - enum_value if enum_value is not None else (1 if mode in {"50hz", "60hz"} else 0) + enum_value + if enum_value is not None + else (1 if mode in {"50hz", "60hz"} else 0) ) if mode in {"50hz", "60hz"} and self._control_supported("AeFlickerPeriod"): updates["AeFlickerPeriod"] = 10_000 if mode == "50hz" else 8_333 @@ -464,7 +473,9 @@ def _create_video_configuration(self) -> Any: return cfg except Exception as e: self._lores_available = False - logger.debug("lores 流不可用,回退主流配置 / Lores unavailable, fallback: %s", e) + logger.debug( + "lores 流不可用,回退主流配置 / Lores unavailable, fallback: %s", e + ) self._lores_available = False return self.camera.create_video_configuration( main=main, @@ -479,7 +490,10 @@ def _collect_lores_stats(self, request: Any) -> None: lores = request.make_array("lores") if lores is None: return - if len(getattr(lores, "shape", ())) == 2 and lores.shape[0] >= self.lores_height: + if ( + len(getattr(lores, "shape", ())) == 2 + and lores.shape[0] >= self.lores_height + ): y_plane = lores[: self.lores_height, :] elif len(getattr(lores, "shape", ())) >= 3: y_plane = lores[..., 0] @@ -1120,9 +1134,7 @@ def get_camera_info(self) -> dict[str, Any]: "actual_exposure_us": int( metadata.get("ExposureTime", self.exposure_us) or 0 ), - "frame_duration_us": int( - metadata.get("FrameDuration", 0) or 0 - ), + "frame_duration_us": int(metadata.get("FrameDuration", 0) or 0), "frame_duration_limits": list( self._frame_duration_limits or self._compute_frame_duration_limits() ), @@ -1245,7 +1257,9 @@ def get_image_quality_metrics(self) -> dict[str, Any]: def set_noise_reduction(self, level: int) -> bool: """兼容旧级别接口并映射到语义模式 / Compat level API mapped to semantic NR mode.""" - return self.set_noise_reduction_mode(self._normalize_noise_reduction_mode(level)) + return self.set_noise_reduction_mode( + self._normalize_noise_reduction_mode(level) + ) def set_noise_reduction_mode(self, mode: str) -> bool: """设置语义降噪模式 / Set semantic noise-reduction mode.""" @@ -1268,7 +1282,11 @@ def set_ae_flicker_mode(self, mode: str) -> bool: logger.error("相机未初始化") return False text = str(mode or "off").lower().replace("_", "") - self.ae_flicker_mode = "50hz" if text in {"50", "50hz"} else "60hz" if text in {"60", "60hz"} else "off" + self.ae_flicker_mode = ( + "50hz" + if text in {"50", "50hz"} + else "60hz" if text in {"60", "60hz"} else "off" + ) self._apply_ae_flicker_controls() return True @@ -1291,7 +1309,14 @@ def set_white_balance( try: mode = str(mode or "auto").lower() - if mode in {"auto", "daylight", "cloudy", "tungsten", "fluorescent", "indoor"}: + if mode in { + "auto", + "daylight", + "cloudy", + "tungsten", + "fluorescent", + "indoor", + }: self.white_balance_mode = mode self.white_balance_gain_r = 1.0 self.white_balance_gain_b = 1.0 diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index 70f589c..5667e3e 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -389,9 +389,7 @@ def _attach_overlay_ext( ) overlay_ext: dict[str, Any] = {} try: - overlay_ext["labels_topn"] = self._build_topn_labels( - row, topn_count=topn - ) + overlay_ext["labels_topn"] = self._build_topn_labels(row, topn_count=topn) except Exception: overlay_ext["labels_topn"] = [] if enable_polar: @@ -1040,8 +1038,7 @@ def _run() -> dict[str, Any]: "next_allowed_in_ms": max( 0, int( - effective_interval_ms - - (time.perf_counter() - t_total) * 1000.0 + effective_interval_ms - (time.perf_counter() - t_total) * 1000.0 ), ), } @@ -1063,8 +1060,7 @@ def _run() -> dict[str, Any]: "next_allowed_in_ms": max( 0, int( - effective_interval_ms - - (time.perf_counter() - t_total) * 1000.0 + effective_interval_ms - (time.perf_counter() - t_total) * 1000.0 ), ), } @@ -1417,9 +1413,7 @@ def _run() -> dict[str, Any]: ), "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, - "next_allowed_in_ms": max( - 0, int(effective_interval_ms - elapsed_ms) - ), + "next_allowed_in_ms": max(0, int(effective_interval_ms - elapsed_ms)), } except asyncio.TimeoutError: return { @@ -1442,8 +1436,7 @@ def _run() -> dict[str, Any]: "next_allowed_in_ms": max( 0, int( - effective_interval_ms - - (time.perf_counter() - t_total) * 1000.0 + effective_interval_ms - (time.perf_counter() - t_total) * 1000.0 ), ), } diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index 6de996f..9295ad9 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -21,7 +21,9 @@ class CameraSettings(BaseModel): noiseReduction: Optional[int] = 0 # 降噪级别 (0-4) / Noise reduction level (0-4) noiseReductionMode: Optional[str] = None # 语义降噪模式 / Semantic NR mode aeFlickerMode: Optional[str] = None # AE 防闪烁 / AE flicker mode - autoExposureMaxUs: Optional[int] = None # 自动曝光最长帧周期 / Max auto-exposure frame duration + autoExposureMaxUs: Optional[int] = ( + None # 自动曝光最长帧周期 / Max auto-exposure frame duration + ) whiteBalanceMode: Optional[str] = "auto" # 白平衡模式 / white balance mode whiteBalanceGainR: Optional[float] = 1.0 # 白平衡红色增益 / white balance red gain whiteBalanceGainB: Optional[float] = 1.0 # 白平衡蓝色增益 / white balance blue gain diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index acc46d5..9b94944 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -173,7 +173,9 @@ def _encode_preview_jpeg_sync(self, frame) -> EncodedImage | None: frame, quality=int(self._jpeg_quality), source_format=source_format ) except Exception as exc: - self._logger.debug("OpenCV 回退编码失败 / OpenCV fallback encode failed: %s", exc) + self._logger.debug( + "OpenCV 回退编码失败 / OpenCV fallback encode failed: %s", exc + ) return None def _read_frame_sync(self): @@ -288,7 +290,9 @@ def _schedule_idle_shutdown(self) -> None: if self._has_consumers(): return self._cancel_idle_shutdown() - self._idle_shutdown_task = asyncio.create_task(self._idle_shutdown_after_delay()) + self._idle_shutdown_task = asyncio.create_task( + self._idle_shutdown_after_delay() + ) async def _idle_shutdown_after_delay(self) -> None: """热驻留结束后释放相机 / Release camera after the warm-idle period.""" @@ -689,7 +693,9 @@ async def stream_metrics(self) -> dict[str, Any]: actual_capture_fps = self._rate(self._capture_timestamps) actual_preview_fps = self._rate(self._jpeg_timestamps) sensor_target_fps = float(info.get("fps", 0) or 0) - exposure_us = int(info.get("actual_exposure_us", info.get("exposure_us", 0)) or 0) + exposure_us = int( + info.get("actual_exposure_us", info.get("exposure_us", 0)) or 0 + ) frame_duration_us = int(info.get("frame_duration_us", 0) or 0) throttle_reason = None if ( @@ -710,11 +716,11 @@ async def stream_metrics(self) -> dict[str, Any]: "preview_consumers": int(self._preview_consumers), "analysis_consumers": int(self._analysis_consumers), "recording_consumers": int(self._recording_consumers), - "jpeg_average_encode_ms": round( - sum(self._jpeg_encode_ms) / len(self._jpeg_encode_ms), 2 - ) - if self._jpeg_encode_ms - else 0.0, + "jpeg_average_encode_ms": ( + round(sum(self._jpeg_encode_ms) / len(self._jpeg_encode_ms), 2) + if self._jpeg_encode_ms + else 0.0 + ), "jpeg_cached_bytes": len(self._latest_jpeg or b""), "preview_encoder": self._last_jpeg_encoder, "jpeg_encode_failures": int(self._jpeg_encode_failures), diff --git a/scripts/diagnose_camera.py b/scripts/diagnose_camera.py index bc7c8a7..0b1d027 100644 --- a/scripts/diagnose_camera.py +++ b/scripts/diagnose_camera.py @@ -4,11 +4,13 @@ 用于检查相机初始化、启动和运行状态 """ import asyncio -import httpx +import importlib.util import json import sys from pathlib import Path +import httpx + BASE_URL = "http://localhost:8000/api/debug/camera" @@ -76,7 +78,7 @@ async def test_preview(client): content_type = response.headers.get("content-type", "") content_length = len(response.content) - print(f"✅ 预览响应:") + print("✅ 预览响应:") print(f" - Content-Type: {content_type}") print(f" - Content-Length: {content_length} bytes") @@ -137,31 +139,25 @@ async def check_system_dependencies(): print("\n🔧 检查系统依赖...") # 检查 Picamera2 / Check Picamera2 - try: - import picamera2 - + if importlib.util.find_spec("picamera2") is not None: print("✅ Picamera2 已安装") - except ImportError: + else: print("❌ Picamera2 未安装") print(" 请运行: sudo apt install python3-picamera2") return False # 检查 OpenCV / Check OpenCV - try: - import cv2 - + if importlib.util.find_spec("cv2") is not None: print("✅ OpenCV 已安装") - except ImportError: + else: print("⚠️ OpenCV 未安装 (直方图功能需要)") print(" 请运行: sudo apt install python3-opencv") print(" 或: pip install opencv-python-headless") # 检查 NumPy / Check NumPy - try: - import numpy - + if importlib.util.find_spec("numpy") is not None: print("✅ NumPy 已安装") - except ImportError: + else: print("❌ NumPy 未安装") return False diff --git a/scripts/spi_display_smoke_test.py b/scripts/spi_display_smoke_test.py index 7bbbe54..cffaa0d 100644 --- a/scripts/spi_display_smoke_test.py +++ b/scripts/spi_display_smoke_test.py @@ -44,7 +44,10 @@ def main() -> int: try: from ogscope.platform.hardware.st7796_spi import ST7796SPI except ImportError as e: - print("缺少依赖:在树莓派上 poetry install(需 spidev、RPi.GPIO)/ Missing deps:", e) + print( + "缺少依赖:在树莓派上 poetry install(需 spidev、RPi.GPIO)/ Missing deps:", + e, + ) return 1 from PIL import Image, ImageDraw, ImageFont diff --git a/tests/unit/test_camera_flip.py b/tests/unit/test_camera_flip.py index e2d883b..43f714a 100644 --- a/tests/unit/test_camera_flip.py +++ b/tests/unit/test_camera_flip.py @@ -131,7 +131,9 @@ def test_encode_frame_preserves_rgb_channel_order() -> None: @pytest.mark.unit -def test_preview_encoder_falls_back_to_opencv_when_turbojpeg_missing(monkeypatch) -> None: +def test_preview_encoder_falls_back_to_opencv_when_turbojpeg_missing( + monkeypatch, +) -> None: """TurboJPEG 缺失时必须安全回退 / Missing TurboJPEG must safely fall back.""" monkeypatch.setitem(sys.modules, "turbojpeg", types.SimpleNamespace()) diff --git a/tests/unit/test_plate_large_scale_bg.py b/tests/unit/test_plate_large_scale_bg.py index f7d4eda..8676ff9 100644 --- a/tests/unit/test_plate_large_scale_bg.py +++ b/tests/unit/test_plate_large_scale_bg.py @@ -2,9 +2,9 @@ 大尺度背景减除单元测试 / Unit tests for large-scale background flattening. """ +import cv2 import numpy as np import pytest -import cv2 from ogscope.algorithms.plate_solve.solver import subtract_large_scale_background_bgr From 29befedb159571da88ff540c5ad65eaaa02a5ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Thu, 23 Jul 2026 11:51:50 +0800 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20support=20Python=203.10=20UTC=20ha?= =?UTF-8?q?ndling=20/=20=E5=85=BC=E5=AE=B9=20Python=203.10=20UTC=20?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ogscope/algorithms/plate_solve/sensor_context.py | 8 ++++---- tests/unit/test_sensor_solve_context.py | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ogscope/algorithms/plate_solve/sensor_context.py b/ogscope/algorithms/plate_solve/sensor_context.py index d5f2549..c86adab 100644 --- a/ogscope/algorithms/plate_solve/sensor_context.py +++ b/ogscope/algorithms/plate_solve/sensor_context.py @@ -3,7 +3,7 @@ from __future__ import annotations import math -from datetime import UTC, datetime +from datetime import datetime, timezone from typing import Any DEFAULT_SENSOR_MATCH_THRESHOLD_DEG = 25.0 @@ -41,13 +41,13 @@ def _parse_utc(value: Any) -> datetime | None: else: return None if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) - return dt.astimezone(UTC) + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) def julian_date(when_utc: datetime) -> float: """Julian date from UTC datetime / UTC 时间转儒略日.""" - dt = when_utc.astimezone(UTC) + dt = when_utc.astimezone(timezone.utc) year = dt.year month = dt.month day = dt.day diff --git a/tests/unit/test_sensor_solve_context.py b/tests/unit/test_sensor_solve_context.py index 87e3e1c..6a85fda 100644 --- a/tests/unit/test_sensor_solve_context.py +++ b/tests/unit/test_sensor_solve_context.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import datetime, timezone import pytest @@ -53,7 +53,9 @@ def test_sensor_prediction_matches_zenith_at_equator() -> None: """赤道天顶预测应落在赤纬 0 附近 / Equator zenith predicts near Dec 0.""" row = {"status": "MATCH_FOUND", "ra_deg": 0.0, "dec_deg": 0.0} context = _solve_context() - expected_ra = local_sidereal_time_deg(0.0, datetime(2000, 1, 1, 12, tzinfo=UTC)) + expected_ra = local_sidereal_time_deg( + 0.0, datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + ) row["ra_deg"] = expected_ra attach_sensor_prediction(row, context)