diff --git a/.gitignore b/.gitignore index 94b2093..e6f85af 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ web/analysis-ui/node_modules/ # ==================== # Poetry # ==================== -poetry.lock # 在开发机和 Orange Pi 上可能不一致,建议忽略 +poetry.lock # 在开发机与树莓派上可能不一致,建议忽略 .poetry/ # ==================== diff --git a/CLAUDE.md b/CLAUDE.md index 3f7eed5..0227a99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ ## 项目概述 -OGScope 是一个基于 Orange Pi Zero 2W 的电子极轴镜系统,用于天文摄影中的精确极轴校准。 +OGScope 是一个基于 Raspberry Pi Zero 2W 的电子极轴镜系统,用于天文摄影中的精确极轴校准。 ## 技术栈 -- **硬件**: Orange Pi Zero 2W, IMX327 相机, 2.4寸 SPI LCD +- **硬件**: Raspberry Pi Zero 2W, IMX327 相机, 2.4寸 SPI LCD - **语言**: Python 3.9+ - **包管理**: Poetry - **Web 框架**: FastAPI + Uvicorn @@ -93,7 +93,7 @@ ogscope/ - **虚拟环境**: Poetry 管理 ### 部署配置 -- **生产环境**: Orange Pi Zero 2W 开发板 +- **生产环境**: Raspberry Pi Zero 2W 开发板 - **测试环境**: [与生产环境相同] - **虚拟环境目录**: [用户自定义] @@ -123,6 +123,13 @@ WantedBy=multi-user.target 在本地运行时,使用虚拟环境,因为有些硬件只能在开发板上调用,所以本地只是代码编写,远程测试 +### WiFi 与网络(NetworkManager) + +- **唯一详解**见 [`docs/development/wifi-nm.md`](docs/development/wifi-nm.md):热点默认密码、`network.env`、`/debug/system`、Web API、sudoers(`ogscope-wifi` / `ogscope-nmcli`)等。 +- **开机引导**:`ogscope-network-boot.service`(root oneshot,`Before=ogscope.service`)在冷启动时若 STA 长时间无可用 IPv4 则切 **AP**,不依赖 Python 进程。 +- **运行时 STA 回滚**:用户通过 Web/API 切 STA 成功后,应用内按 `wifi_sta_rollback_*` 超时无 IPv4 再切回 AP;与开机引导**分工不同、不冲突**(先 boot 完成再启动 `ogscope`)。 +- 二者均可能最终执行「切 AP」,行为**幂等**;不建议删除运行时回滚,否则仅冷启动有保障,**运行中**切 STA 失败后需手动恢复。 + **重要说明**: - 系统库(如 `libcamera`、`picamera2`)安装在系统环境中,通过 `PYTHONPATH` 环境变量注入到虚拟环境 - `LD_LIBRARY_PATH` 确保系统库的链接库路径正确 diff --git a/Makefile b/Makefile index 7b5509a..522dd3c 100644 --- a/Makefile +++ b/Makefile @@ -54,8 +54,8 @@ lock: ## 锁定依赖版本 build: ## 构建包 poetry build -deploy: ## 部署到 Orange Pi(需要配置 SSH) - @echo "同步代码到 Orange Pi..." +deploy: ## 部署到 Raspberry Pi(需要配置 SSH 主机别名) + @echo "同步代码到 Raspberry Pi..." rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \ --exclude '.venv' --exclude 'PiFinder-release' \ . orangepi:~/OGScope/ @@ -69,7 +69,7 @@ logs: ## 查看日志 status: ## 查看服务状态 ssh orangepi "sudo systemctl status ogscope" -ssh: ## SSH 到 Orange Pi +ssh: ## SSH 到 Raspberry Pi(主机名见 Makefile 中 rsync 目标) ssh orangepi docs: ## 生成文档(如果使用 Sphinx) diff --git a/README.md b/README.md index 154a7b2..3f4f0ee 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ poetry shell python -m ogscope.main ``` +开发板一键部署、WiFi 热点与 **`/debug/system`** 等以 [docs/development/wifi-nm.md](docs/development/wifi-nm.md) 与 [docs/development/README.md](docs/development/README.md) 为准。 + ### Web 界面访问 启动后访问: http://raspberrypi.local:8000 或 http://:8000 @@ -89,6 +91,7 @@ python -m ogscope.main ### 开发文档 - [开发指南(中文)](docs/development/README.md) - [开发指南(English)](docs/development/README_EN.md) +- [树莓派 WiFi / AP / STA / 调试页(NetworkManager)](docs/development/wifi-nm.md) - [FastAPI 开发](docs/development/fastapi-guide.md) - [测试指南](docs/development/testing-guide.md) diff --git a/README_EN.md b/README_EN.md index eb7a4b2..43d5d20 100644 --- a/README_EN.md +++ b/README_EN.md @@ -70,6 +70,8 @@ poetry shell python -m ogscope.main ``` +On the Raspberry Pi, one-shot deployment, WiFi hotspot, and **`/debug/system`** are documented in [docs/development/wifi-nm.md](docs/development/wifi-nm.md) and [docs/development/README_EN.md](docs/development/README_EN.md). + ### Web Interface Access After startup, visit: http://raspberrypi.local:8000 or http://:8000 @@ -89,6 +91,7 @@ After startup, visit: http://raspberrypi.local:8000 or http://:8000 ### Development Documentation - [Development Guide (English)](docs/development/README_EN.md) - [Development Guide (中文)](docs/development/README.md) +- [Raspberry Pi WiFi / AP / STA (NetworkManager)](docs/development/wifi-nm.md) - [FastAPI Development](docs/development/fastapi-guide.md) - [Testing Guide](docs/development/testing-guide.md) diff --git a/docs/development/README.md b/docs/development/README.md index dd98f80..c51d818 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -2,7 +2,7 @@ 中文 | [English](README_EN.md) -本文档面向项目成员与协作者,说明 OGScope 在开发板(Raspberry Pi / Orange Pi)环境中的实际运行方式、依赖要求与标准调试流程。 +本文档面向项目成员与协作者,说明 OGScope 在 **Raspberry Pi Zero 2W** 等开发板环境中的实际运行方式、依赖要求与标准调试流程。 测试实践请见:[测试指南](testing-guide.md)。 @@ -15,7 +15,7 @@ ### 0.1 系统要求 -- 单板:**ARM**(`aarch64` 或 `armhf`),如 Raspberry Pi / Orange Pi +- 单板:**ARM**(`aarch64` 或 `armhf`),推荐 **Raspberry Pi Zero 2W** - 系统:**Debian/apt** 系镜像(与 `picamera2`/`libcamera` 文档一致;脚本会读 `/etc/os-release`,见 **§1.4**) - Python:**3.10+**(以 `pyproject.toml` 为准) - 网络:首次安装需拉取依赖;浏览器访问 Web 需可达设备 **TCP 8000**(按需防火墙放行) @@ -30,11 +30,18 @@ chmod +x scripts/install.sh 说明摘要:默认 `poetry install --only main`;国内网络可 **`export OGSCOPE_MIRROR=cn`**;低配板可 **`OGSCOPE_APT_SLOW=1`**。完整选项见 **§1.4**。安装后:`sudo systemctl start ogscope`。 -### 0.3 星图解算数据 +### 0.3 网络与 WiFi(AP/STA) + +- **`install.sh`** 会安装 `network-manager`、`avahi-daemon`,并执行 **`ogscope-network-init.sh init`**(NM 连接、`/etc/ogscope/network.env`、sudoers、主机名与 `hosts` 等),除非 **`OGSCOPE_SKIP_NETWORK_INIT=1`**。 +- 同次安装会写入 **`ogscope-network-boot.service`**(开机无线网络引导:无可用 STA 则回 AP),除非 **`OGSCOPE_SKIP_NETWORK_BOOT=1`**。 +- **日常仅同步代码与依赖**优先 **`./scripts/board-update.sh`**;全量重装或改系统级依赖时再跑 **`install.sh`**(见 **§0.5**)。 +- 热点 SSID/密码、调试页 **`/debug/system`**、API、**开机引导与运行时 STA 回滚** 的分工:**唯一详解**见 **[wifi-nm.md](wifi-nm.md)**。 + +### 0.4 星图解算数据 将 **`default_database.npz`** 放到 **`data/plate_solve/`**(不随仓库分发)。放置与配置见 [plate-solve-data.md](plate-solve-data.md)。 -### 0.4 日常更新 +### 0.5 日常更新 ```bash cd /path/to/OGScope @@ -45,7 +52,7 @@ chmod +x scripts/board-update.sh 详情见 **§6.2**。 -### 0.5 卸载与健康检查 +### 0.6 卸载与健康检查 - 卸载服务与 `.venv`:见 **§6.3**(`scripts/uninstall.sh`) - 健康检查与日志: @@ -56,7 +63,7 @@ sudo systemctl status ogscope sudo journalctl -u ogscope -f ``` -### 0.6 常见故障(简表) +### 0.7 常见故障(简表) | 现象 | 处理方向 | |------|----------| @@ -99,7 +106,7 @@ poetry install 仓库提供 `scripts/install.sh`,用于在开发板执行一次性环境准备。脚本会: -- 读取 `/etc/os-release` 识别发行版,**仅支持 Debian/Ubuntu 系**(含 **Raspberry Pi OS**、Orange Pi Debian 等);非该系将退出,避免误改软件源 +- 读取 `/etc/os-release` 识别发行版,**仅支持 Debian/Ubuntu 系**(含 **Raspberry Pi OS**);非该系将退出,避免误改软件源 - 安装系统依赖与 Poetry - 配置 Poetry 使用项目 `.venv` 与 `system-site-packages`(Poetry 版本支持时) - 默认执行 `poetry install --only main`(设 `OGSCOPE_INSTALL_DEV=1` 可装 dev) @@ -270,6 +277,7 @@ sudo journalctl -u ogscope -f - 若仅前端模板/静态文件变更,通常不需要 `poetry install` - 若服务文件配置有改动,需先 `sudo systemctl daemon-reload` +- 脚本会同步主服务 `ExecStart` 与已安装的 **`ogscope-network-boot.service`** 内 `ExecStart`(项目目录变更时);未安装开机单元则跳过 ### 6.3 卸载服务与本地环境(`scripts/uninstall.sh`) @@ -278,7 +286,10 @@ sudo journalctl -u ogscope -f **会执行的操作 / What it does** - `systemctl stop` / `disable` `ogscope` -- 删除 `/etc/systemd/system/ogscope.service`(若存在),并 `daemon-reload` +- 删除 `/etc/systemd/system/ogscope.service`(若存在) +- 若存在 **`ogscope-network-boot.service`**:`stop` / `disable` 并删除该 unit(与 `install.sh` 安装的引导一致) +- 若存在 **`/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf`**:删除该 drop-in(空目录会尝试 `rmdir`) +- `daemon-reload` - 默认删除项目根目录下的 **`.venv`**(可用环境变量保留,见下) **默认保留 / Kept by default** diff --git a/docs/development/README_EN.md b/docs/development/README_EN.md index 8f830fc..8c545e9 100644 --- a/docs/development/README_EN.md +++ b/docs/development/README_EN.md @@ -3,7 +3,7 @@ [中文](README.md) | English This document explains how OGScope is actually run on development boards -(Raspberry Pi / Orange Pi), including dependency requirements, service startup, +(primarily **Raspberry Pi Zero 2W**), including dependency requirements, service startup, and the team-standard debug workflow. For testing workflow, see [Testing Guide](testing-guide.md). @@ -18,7 +18,7 @@ This section lists **common commands and checks only**. For Poetry/PEP 668, mirr ### 0.1 Requirements -- Board: **ARM** (`aarch64` or `armhf`), e.g. Raspberry Pi / Orange Pi +- Board: **ARM** (`aarch64` or `armhf`), e.g. Raspberry Pi Zero 2W - OS: **Debian/apt**-based images (compatible with `picamera2`/`libcamera`; install script reads `/etc/os-release`, see **§1.4**) - Python: **3.10+** (see `pyproject.toml`) - Network: first install downloads dependencies; Web UI needs **TCP 8000** reachable (configure firewall as needed) @@ -33,11 +33,18 @@ chmod +x scripts/install.sh Summary: default `poetry install --only main`; in mainland China use **`export OGSCOPE_MIRROR=cn`**; low-memory boards: **`OGSCOPE_APT_SLOW=1`**. Full options: **§1.4**. After install: `sudo systemctl start ogscope`. -### 0.3 Plate-solve data +### 0.3 Network and WiFi (AP/STA) + +- **`install.sh`** installs `network-manager`, `avahi-daemon`, and runs **`ogscope-network-init.sh init`** (NM profiles, `/etc/ogscope/network.env`, sudoers, hostname/`hosts`, …) unless **`OGSCOPE_SKIP_NETWORK_INIT=1`**. +- The same install writes **`ogscope-network-boot.service`** (boot-time WiFi: fall back to AP if STA has no usable IPv4) unless **`OGSCOPE_SKIP_NETWORK_BOOT=1`**. +- For **routine code and dependency sync**, prefer **`./scripts/board-update.sh`**; rerun **`install.sh`** only for full reinstall or system-level changes (see **§0.5**). +- Hotspot SSID/password, **`/debug/system`**, APIs, and **boot vs runtime STA rollback**: see **[wifi-nm.md](wifi-nm.md)** (authoritative). + +### 0.4 Plate-solve data Place **`default_database.npz`** under **`data/plate_solve/`** (not shipped in the repo). See [plate-solve-data.md](plate-solve-data.md). -### 0.4 Routine updates +### 0.5 Routine updates ```bash cd /path/to/OGScope @@ -48,7 +55,7 @@ chmod +x scripts/board-update.sh Details: **§6.2**. -### 0.5 Uninstall and health check +### 0.6 Uninstall and health check - Remove service and `.venv`: **§6.3** (`scripts/uninstall.sh`) - Health and logs: @@ -59,7 +66,7 @@ sudo systemctl status ogscope sudo journalctl -u ogscope -f ``` -### 0.6 Troubleshooting (short) +### 0.7 Troubleshooting (short) | Symptom | Where to look | |---------|----------------| @@ -279,15 +286,19 @@ 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 ### 6.3 Uninstall service and local environment (`scripts/uninstall.sh`) -Use `scripts/uninstall.sh` when you need to **remove the systemd unit**, delete the project **`.venv`**, or clean up before reinstalling in another directory. The script **does not** remove packages installed with `apt` (e.g. `python3-picamera2`) or the user-level **Poetry** installation; it only manages the OGScope service file and optional content under the project tree. +Use `scripts/uninstall.sh` when you need to **remove the systemd unit**, delete the project **`.venv`**, or clean up before reinstalling in another directory. The script **does not** remove packages installed with `apt` (e.g. `python3-picamera2`) or the user-level **Poetry** installation; it removes the OGScope main unit, the optional **network boot** unit and **drop-in** (if present), and optional content under the project tree as described below. **What it does** - `systemctl stop` / `disable` `ogscope` -- removes `/etc/systemd/system/ogscope.service` if present, then `daemon-reload` +- removes `/etc/systemd/system/ogscope.service` if present +- if **`ogscope-network-boot.service`** exists: `stop` / `disable` and remove that unit (matches what `install.sh` installs for boot-time WiFi) +- if **`/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf`** exists: remove that drop-in (empty `ogscope.service.d` is removed with `rmdir` when possible) +- `daemon-reload` - by default removes `.venv` at the project root (can be kept; see below) **Kept by default** diff --git a/docs/development/ogscope-service-hardening.md b/docs/development/ogscope-service-hardening.md new file mode 100644 index 0000000..4aa6eef --- /dev/null +++ b/docs/development/ogscope-service-hardening.md @@ -0,0 +1,36 @@ +# OGScope 服务稳定性与内存防护 / Service stability and memory guard + +## systemd 建议(示例,按板子内存调整)/ systemd suggestions (tune MemoryMax per board) + +在单元文件 `[Service]` 段可考虑: + +- `Restart=always`:进程异常退出后自动拉起 / Auto-restart after exit +- `RestartSec=3`:避免崩溃重启风暴 / Back off between restarts +- `MemoryMax=400M`(示例):限制单服务 RSS,降低拖死整机的概率(Zero2W 请按实际调)/ Cap service RSS to reduce OOM risk + +示例片段 / Example snippet: + +```ini +[Service] +Restart=always +RestartSec=3 +# MemoryMax=400M +``` + +## OOM 观测 / OOM observation + +在设备上可快速确认是否被 OOM killer 终止: + +```bash +sudo journalctl -k -b | grep -i -E 'oom|killed process|Out of memory' +sudo dmesg -T | grep -i -E 'oom|killed process' +``` + +## 与预览相关的环境变量 / Preview-related environment variables + +| 变量 / Variable | 含义 / Meaning | +|-----------------|----------------| +| `OGSCOPE_PREVIEW_JPEG_QUALITY` | 共享预览 JPEG 质量(与调试 MJPEG 默认质量一致)/ Shared preview JPEG quality | +| `OGSCOPE_SHARED_PREVIEW_FPS` | 共享抓帧与 MJPEG 推送目标帧率 / Shared grabber and MJPEG pacing FPS | +| `OGSCOPE_DEBUG_PREVIEW_MIN_INTERVAL_MS` | 调试「单帧预览」接口每客户端最小间隔(毫秒);过短返回 304 / Min interval for `/api/debug/camera/preview` per client | +| `OGSCOPE_KEEP_RAW_CACHE` | `1` 时在共享管理器中常驻 `_latest_raw`;默认 `0` 以省内存 / Retain raw frame cache when `1` | diff --git a/docs/development/plate-solve-data.md b/docs/development/plate-solve-data.md index 25caac8..bd8318d 100644 --- a/docs/development/plate-solve-data.md +++ b/docs/development/plate-solve-data.md @@ -14,6 +14,10 @@ ## 2. 获取图案库文件 / Obtaining `default_database.npz` +**自动复制(安装/更新脚本)**:若 `ogscope/vendor/tetra3/data/default_database.npz` 已存在,或 Poetry 解析到的 `tetra3` 包内带有该文件,`scripts/install.sh` 与 `scripts/board-update.sh` 会将其复制到 `data/plate_solve/default_database.npz`(目标已存在则跳过;覆盖用 `OGSCOPE_FORCE_PLATE_DB=1`;跳过整步用 `OGSCOPE_SKIP_PLATE_DB=1`)。 + +**Auto-copy (install/update scripts)**: If `ogscope/vendor/tetra3/data/default_database.npz` exists, or the resolved `tetra3` package ships it, `scripts/install.sh` and `scripts/board-update.sh` copy it to `data/plate_solve/default_database.npz` (skip if dest exists; `OGSCOPE_FORCE_PLATE_DB=1` to overwrite; `OGSCOPE_SKIP_PLATE_DB=1` to skip). + 任选其一: 1. **从 PyPI `cedar-solve` wheel 提取** @@ -55,5 +59,5 @@ ## 7. 性能提示 / Performance -- Orange Pi 等资源受限设备:可适当**降低分辨率**、限制 `solver_max_stars`、拉大 `solver_fullsolve_interval_frames`(实时模式)。 +- Raspberry Pi Zero 2W 等资源受限设备:可适当**降低分辨率**、限制 `solver_max_stars`、拉大 `solver_fullsolve_interval_frames`(实时模式)。 - Tetra 解算在后台线程执行,避免阻塞事件循环(见 `asyncio.to_thread`)。 diff --git a/docs/development/wifi-nm.md b/docs/development/wifi-nm.md new file mode 100644 index 0000000..3a3fe67 --- /dev/null +++ b/docs/development/wifi-nm.md @@ -0,0 +1,145 @@ +# WiFi:STA / AP 与 NetworkManager + +本文档为 **Raspberry Pi OS** 上 OGScope **网络能力的唯一详解**:热点/STA 密码与访问、`network.env`、sudoers、`/debug/system`、Web API,以及 **开机网络引导**(`ogscope-network-boot`)与 **运行时 STA 回滚**(`wifi_sta_rollback_*`)的分工。开发板 **Poetry、`install.sh` / `board-update.sh` 总流程**见 [开发指南(部署速查)](README.md) **§0.2**、**§0.5**;以下按「用户说明 → 初始化 → 环境变量 → sudoers → API → 验证」展开。 + +## 用户操作说明(密码、访问与安全) + +以下为 **终端用户 / 现场操作** 常用信息;技术细节见后文各节。 + +### 默认热点(AP 模式) + +| 项目 | 说明 | +|------|------| +| **SSID** | `OGScope_xxxx`,其中 `xxxx` 为 **wlan0 MAC 地址后 4 位**(十六进制,小写),每台设备不同。可在设备标签或执行 `init` 后的日志 / `diag` 中确认。 | +| **密码(PSK)** | 固定为 **`ogscopeadmin`**(由 [`ogscope-network-init.sh`](../../scripts/ogscope-network-init.sh) 写入 NetworkManager,**非随机**)。 | +| **网关 / 固定地址** | 热点模式下无线侧一般为 **`192.168.4.1/24`**;NM 中 `OGScope-AP` 使用 **`ipv4.method shared`**(dnsmasq DHCP),避免客户端仅有 **169.254.x.x** 而无法访问网关。 | +| **Web 访问** | 手机/电脑连上该热点后,浏览器打开 **`http://192.168.4.1:<端口>`**;HTTP 端口以设备上 OGScope 配置为准(常见为 **8000**,见应用或 `ogscope` 服务环境变量)。 | +| **mDNS 主机名** | 初始化后主机名形如 **`ogscope-xxxx`**(`xxxx` 与 SSID 后缀一致),局域网内可尝试 **`http://ogscope-xxxx.local:<端口>/debug/system`**(需 **Avahi** 与 DNS 解析正常)。 | + +### 连接家中路由器(STA 模式) + +1. 连上 OGScope 热点后打开 **`/debug/system`**(系统调试页)。 +2. 使用 **「扫描 WiFi」**(由设备执行 `nmcli`,列表可能为空,见下)或 **「手动输入 SSID + 密码」**。 +3. 提交后设备会切到 STA 并尝试连接;若超时未拿到局域网 IPv4,会按配置 **自动切回 AP**,避免彻底失联。 + +**说明**:浏览器 **不能** 读取你手机里的 WiFi 列表;列表必须由 **树莓派上的 NetworkManager** 生成,或你 **手动输入** 家中 SSID/密码。 + +### 已保存的 WiFi 列表与「激活」 + +- **作用**:列出 NetworkManager 里 **已保存的 WiFi 连接**(不含热点 `OGScope-AP`)。**「激活」** 表示对该 profile 执行 `nmcli connection up`,在 **不切热点脚本路径** 的情况下直接连上该 SSID(适合以前连过、密码已保存在系统中的网络)。 +- **与「手动连接 / 扫描后连接」的区别**:后者会改 **`OGScope-STA`** 的 SSID/密码再切 STA;「激活」用于 **其他已命名连接**(若存在)。 +- **报错 `Not authorized to control networking`**:服务用户(如 `ogstartech`)默认受 **polkit** 限制,不能直接 `nmcli connection up`。初始化脚本会写入 **`/etc/sudoers.d/ogscope-nmcli`**,允许该用户 **免密执行 `nmcli` 二进制**;应用内对 `nmcli` 使用 **`sudo -n`**(见 `OGSCOPE_WIFI_NMCLI_USE_SUDO`,默认开启)。若仍报错,请执行 + `sudo ./scripts/ogscope-network-init.sh ensure-systemd`(会补写 `ogscope-nmcli` sudoers)或对照后文 **§3** 手动添加,然后 **`sudo systemctl restart ogscope`**。 + +### 注意事项(必读) + +1. **默认热点密码是公开的**(`ogscopeadmin`),仅适合现场调试与封闭环境。若长期暴露热点,请在 NetworkManager 中 **修改 `OGScope-AP` 的 PSK**,并自行记录;修改后需与现场人员同步。 +2. **单频网卡在 AP 模式下**,往往 **无法同时列出周边 WiFi**(扫描结果可能为 0);此时请 **直接手动输入** 家中 SSID 与密码。 +3. **从 STA 切回 AP** 或 **换网** 后,当前浏览器会话可能断开;请重新连接热点 `OGScope_xxxx` 或在本机局域网用 **mDNS / 路由器管理页** 查找设备 IP。 +4. **HTTPS 页面无法混合访问 HTTP API**:若用纯 HTTPS 入口访问,设备上的 **`/health` 局域网探测** 可能受限;优先使用 **HTTP** 同网段访问调试页(见页面提示)。 +5. 更新代码后若 WiFi 相关异常,需同步 **`ogscope-wifi-switch` 脚本** 到 `/usr/local/bin` 并 **重启 `ogscope` 服务**(见后文「验证 nmcli」与 `board-update.sh`)。 +6. **连上热点后手机/电脑只有 169.254.x.x**:多为旧版 **`OGScope-AP`** 使用 `ipv4.method manual`、未向客户端发 DHCP。请同步最新 `ogscope-network-init.sh` 后执行 **`sudo ./scripts/ogscope-network-init.sh init --yes`**(复用设备后缀、重建 NM 连接);或仅改连接: + `sudo nmcli connection modify OGScope-AP wifi-sec.proto rsn wifi-sec.pairwise ccmp wifi-sec.group ccmp ipv4.method shared ipv4.addresses 192.168.4.1/24`,再 `sudo nmcli connection down OGScope-AP && sudo nmcli connection up OGScope-AP`(接口名一般为 `wlan0`)。 + +### 安全与隐私(简要) + +- **`/etc/ogscope/network.env`** 含连接名与设备后缀,权限为 **600**,勿提交版本库。 +- **sudoers**:仅允许指定用户免密运行 **`/usr/local/bin/ogscope-wifi-switch`** 与 **`nmcli` 绝对路径**(`/etc/sudoers.d/ogscope-wifi`、`ogscope-nmcli`);勿改为无限制 `NOPASSWD: ALL`。 +- 现场请勿在不可信网络中开启长期 AP;生产环境请结合路由器 ACL、强密码与固件更新策略。 + +--- + +## 1. 推荐:一键初始化(`ogscope-network-init.sh`) + +完整 **`install.sh` 行为与可选环境变量**摘要见 [开发指南 §0.2](README.md#02-首次安装)。 + +安装脚本 [scripts/install.sh](../../scripts/install.sh) 会在部署阶段安装 `network-manager`、`avahi-daemon`,并执行: + +```bash +sudo env OGSCOPE_SERVICE_USER="$USER" ./scripts/ogscope-network-init.sh init --yes +``` + +- 根据 **wlan0 MAC 后 4 位十六进制** 生成热点 SSID:`OGScope_xxxx`,密码固定 **`ogscopeadmin`**。 +- 创建 NM 连接 **`OGScope-STA`**(占位,供 Web 填写 SSID/密码)与 **`OGScope-AP`**(`192.168.4.1/24`、`ipv4.method shared`、WPA2-only)。 +- 写入 **`/etc/ogscope/network.env`**(供 systemd `EnvironmentFile` 加载)。 +- 安装 **`/usr/local/bin/ogscope-wifi-switch`** 并配置 **sudoers**(免密执行该脚本;另写入 **`ogscope-nmcli`**,供 Web API 调用 **`nmcli`**,避免 polkit 拒绝)。 +- 设置主机名为 **`ogscope-xxxx`**,并同步 **`/etc/hosts`** 中 `127.0.1.1`(减轻 `sudo: unable to resolve host`)。 +- 写入 **systemd drop-in**:`/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf`,使 **`ogscope` 服务加载 `/etc/ogscope/network.env`**(与新版 [`install.sh`](../../scripts/install.sh) 主 unit 中的 `EnvironmentFile=-/etc/ogscope/network.env` 一致;**仅跑过旧版 install、未含该行的部署**此前会在 Web/API 中看到 `wifi_not_configured`)。 +- 便于 **`http://ogscope-xxxx.local:端口`** 访问(需 Avahi)。 +- **[`install.sh`](../../scripts/install.sh)** 还会安装 **`ogscope-network-boot.service`**(**root**、`Type=oneshot`、`Before=ogscope.service`),执行 [`scripts/ogscope-network-boot.sh`](../../scripts/ogscope-network-boot.sh):开机后若 **默认 IPv4 路由已在非无线口**(例如有线已联网)则**跳过**;否则在 **`OGSCOPE_BOOT_STA_WAIT_SEC`**(默认 55)秒内轮询 **`wlan0` 是否获得非 169.254 的 IPv4**(与 Python 侧 `sta_interface_has_usable_ipv4` 语义一致);仍无则尝试 **`nmcli connection up` STA**(次数由 **`OGSCOPE_BOOT_STA_UP_RETRIES`** 等控制),最后仍失败则 **拉起 AP**,避免冷启动无网。跳过该单元:`OGSCOPE_SKIP_NETWORK_BOOT=1 ./scripts/install.sh`。日志:`journalctl -u ogscope-network-boot -b`。 +- **与进程内 STA 回滚的区别**:**开机引导**不依赖 `ogscope` 进程;应用内 **`wifi_sta_rollback_*`** 仅在用户通过 Web/API **切 STA 成功后** 监视超时并回滚 AP。 + +跳过初始化:`OGSCOPE_SKIP_NETWORK_INIT=1 ./scripts/install.sh` + +**`init` 与 SSH**:脚本会删除并重建 `wlan0` 上的 NetworkManager 连接;若你通过 **Wi‑Fi 上的 SSH** 执行,会话很可能在 `create_nm_connections` 阶段即断开,属正常现象。请改用 **有线网口**、**串口** 或 **直连键盘/显示器** 执行 `init`,或断线后连接热点 `OGScope_xxxx` 再访问 Web。 + +诊断与重置: + +```bash +sudo ./scripts/ogscope-network-init.sh diag +sudo ./scripts/ogscope-network-init.sh ensure-systemd # 补 systemd drop-in + /etc/hosts 127.0.1.1(需已有 network.env) +sudo ./scripts/ensure-ogscope-systemd-network-env.sh # 同上,薄封装 +sudo ./scripts/ogscope-network-init.sh reset # 交互确认 +sudo ./scripts/ogscope-network-init.sh reset --yes +``` + +### 已部署过旧代码、只缺 WiFi 或只缺 systemd 加载 env + +1. 同步代码后若 **`/etc/ogscope/network.env` 已存在**,但控制台仍显示 **`wifi_not_configured`**:先执行 + `sudo ./scripts/ogscope-network-init.sh ensure-systemd`,再 **`sudo systemctl restart ogscope`**。 + (`diag` 会检查 `systemctl cat ogscope` 是否包含对 `network.env` 的 `EnvironmentFile`。) +2. 若从未生成 `network.env`,仍用 **`init`** 完整初始化。 +3. **`/etc/hosts` 未随主机名更新** 时,`sudo` 可能出现 `unable to resolve host`;重新 **`init`** 会写入 `127.0.1.1 ogscope-xxxx`,或手动编辑 `/etc/hosts`。 + +## 2. 环境变量(`/etc/ogscope/network.env` 或 `.env`) + +与 [ogscope/config.py](../../ogscope/config.py) 中 `OGSCOPE_` 前缀一致: + +| 变量 | 含义 | +|------|------| +| `OGSCOPE_WIFI_STA_CONNECTION` | STA 连接名(默认 `OGScope-STA`) | +| `OGSCOPE_WIFI_AP_CONNECTION` | AP 连接名(默认 `OGScope-AP`) | +| `OGSCOPE_WIFI_INTERFACE` | 无线接口,默认 `wlan0` | +| `OGSCOPE_DEVICE_ID_SUFFIX` | 4 位 hex 后缀(与热点 SSID 一致) | +| `OGSCOPE_WIFI_AP_SSID` | 热点 SSID 全文(如 `OGScope_a1b2`) | +| `OGSCOPE_WIFI_NMCLI_USE_SUDO` | 默认 `true`:非 root 进程用 `sudo -n nmcli`;需 **`ogscope-nmcli` sudoers** | + +## 3. 手动安装切换脚本与 sudoers + +```bash +sudo install -m 755 scripts/ogscope-wifi-switch.sh /usr/local/bin/ogscope-wifi-switch +sudo visudo -f /etc/sudoers.d/ogscope-wifi +# Web 扫描 / 激活已保存 WiFi 需 nmcli 免密(路径以 which 为准): +# sudo visudo -f /etc/sudoers.d/ogscope-nmcli +``` + +示例: + +``` +ogstartech ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-wifi-switch +``` + +`nmcli`(与 `init` 脚本自动生成的一致,`which nmcli` 多为 `/usr/bin/nmcli`): + +``` +ogstartech ALL=(ALL) NOPASSWD: /usr/bin/nmcli +``` + +## 4. Web API 与 STA 回滚 + +- `GET /api/network/wifi/scan`:由设备执行 `nmcli` 扫描(**浏览器无法在客户端扫周围 WiFi**,无 Web API)。响应中 `networks` 为列表,**`hint` 可为空**;若当前为热点(AP)模式且列表为空,会给出说明(单频网卡常无法同时列出周边 BSS)。 +- `POST /api/network/wifi/sta/connect`:填写 SSID/密码后切 STA;若 **`wifi_sta_rollback_timeout_seconds`** 内未获得可用 IPv4,则**自动切回 AP**,防止失联。 +- `GET /api/network/wifi/profiles`:已保存的 WiFi 连接(NetworkManager 持久化)。 +- `POST /api/network/wifi/profile/activate`:**激活**某条已保存连接(`nmcli connection up`);需 **`ogscope-nmcli` sudoers** 或等价 polkit 授权,否则会报 **Not authorized**。 +- 系统调试页:`/debug/system`(引导、mDNS 提示、局域网 `/health` 探测)。 +- 环境变量 **`OGSCOPE_WIFI_NMCLI_USE_SUDO`**(默认 `true`):为 `false` 时直接调用 `nmcli`(仅当运行用户已被 polkit 允许控制 NM 时使用)。 + +## 5. 验证 nmcli + +```bash +sudo /usr/local/bin/ogscope-wifi-switch status +sudo /usr/local/bin/ogscope-wifi-switch ap +sudo /usr/local/bin/ogscope-wifi-switch sta +``` + +脚本在 **未继承环境变量** 时会自动 `source /etc/ogscope/network.env`(与 systemd 一致)。若仍用旧习惯 `sudo -E`,部分系统 sudoers 会报 **user not allowed to preserve the environment**,可省略 `-E`。 diff --git a/ogscope/__init__.py b/ogscope/__init__.py index 7508b6a..3657edc 100644 --- a/ogscope/__init__.py +++ b/ogscope/__init__.py @@ -1,7 +1,7 @@ """ OGScope - 电子极轴镜 -基于 Orange Pi Zero 2W 和 IMX327 的智能极轴校准系统 +基于 Raspberry Pi Zero 2W 和 IMX327 的智能极轴校准系统 """ # 使 vendored tetra3 可被 import / Make vendored tetra3 importable diff --git a/ogscope/config.py b/ogscope/config.py index 031da9f..502f3c7 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -42,6 +42,16 @@ class Settings(BaseSettings): ) camera_exposure: int = Field(default=10000, description="曝光时间(us)") camera_gain: float = Field(default=1.0, description="增益") + camera_ae_polar_preset: bool = Field( + default=True, + description="自动曝光时启用电子极轴镜 AE 预设 (Shadows/Matrix/Long+EV) / AE polar-scope preset", + ) + camera_ae_exposure_value: float = Field( + default=0.35, + ge=-2.0, + le=2.0, + description="AE 曝光补偿(档),与 camera_ae_polar_preset 联用 / AE exposure comp EV stops", + ) # 显示屏配置 / Display configuration display_enabled: bool = Field(default=False, description="启用 SPI 屏幕") @@ -166,6 +176,83 @@ class Settings(BaseSettings): description="实时解算慢请求阈值(毫秒)/ Slow realtime solve threshold in ms", ) + # WiFi(nmcli + scripts/ogscope-wifi-switch.sh)/ WiFi (NetworkManager helper script) + wifi_switch_script: Path = Field( + default=Path("/usr/local/bin/ogscope-wifi-switch"), + description="WiFi 切换脚本路径 / Path to ogscope-wifi-switch script", + ) + wifi_switch_use_sudo: bool = Field( + default=True, + description="调用脚本时是否使用 sudo -n / sudo -n when invoking script", + ) + wifi_switch_timeout_seconds: int = Field( + default=90, + ge=10, + le=600, + description="nmcli 切换超时(秒)/ Timeout for nmcli switch", + ) + wifi_nmcli_use_sudo: bool = Field( + default=True, + description=( + "非 root 时对 nmcli 使用 sudo -n(需 sudoers 放行 nmcli;" + "否则 polkit 会拒绝 connection up)/ sudo -n for nmcli when not root" + ), + ) + wifi_sta_connection: str = Field( + default="", + description="STA 模式 NM 连接名(空则禁用 WiFi API)/ STA connection name (empty disables API)", + ) + wifi_ap_connection: str = Field( + default="", + description="AP 模式 NM 连接名 / AP connection name", + ) + wifi_interface: str = Field( + default="wlan0", + description="无线接口名 / Wireless interface name", + ) + wifi_ap_url_host: str = Field( + 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", + ) + wifi_ap_ssid: str = Field( + default="", + description="AP 的 SSID(可选,来自 network.env)/ AP SSID from network.env", + ) + wifi_sta_rollback_timeout_seconds: int = Field( + default=90, + ge=20, + le=600, + description="切 STA 后无可用 IPv4 则回滚 AP 的超时(秒)/ Roll back to AP if no IPv4", + ) + wifi_sta_rollback_interval_seconds: int = Field( + default=5, + ge=2, + le=60, + description="STA 连通性轮询间隔(秒)/ Poll interval for STA rollback check", + ) + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/ogscope/core/realtime/service.py b/ogscope/core/realtime/service.py index 798ab86..033f27e 100644 --- a/ogscope/core/realtime/service.py +++ b/ogscope/core/realtime/service.py @@ -11,7 +11,7 @@ from ogscope.algorithms.plate_solve import PlateSolver, SolveResult from ogscope.algorithms.star_extract import StarExtractor, StarPoint from ogscope.config import get_settings -from ogscope.web.api.debug.services import DebugCameraService +from ogscope.web.camera_shared import get_camera_manager @dataclass(slots=True) @@ -98,11 +98,18 @@ async def _loop(self) -> None: """后台循环 / Background loop""" while self.state.running: try: - camera = DebugCameraService.get_camera_instance() - if not camera or not getattr(camera, "is_capturing", False): + manager = get_camera_manager() + cam = manager.get_camera_instance() + if not cam or not getattr(cam, "is_capturing", False): await asyncio.sleep(0.1) continue - frame = camera.get_video_frame() + # 必须与共享预览走同一套读锁 + 线程卸载,禁止在事件循环线程里直接 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() + except RuntimeError: + await asyncio.sleep(0.02) + continue if frame is None: await asyncio.sleep(0.02) continue diff --git a/ogscope/hardware/camera.py b/ogscope/hardware/camera.py index 3ad7931..419a053 100644 --- a/ogscope/hardware/camera.py +++ b/ogscope/hardware/camera.py @@ -89,7 +89,7 @@ def __init__(self, config: dict[str, Any]): self.white_balance_gain_b = config.get("white_balance_gain_b", 1.0) # 采样模式与尺寸(supersample: 采集分辨率可高于输出分辨率) / Sampling mode and size (supersample: acquisition resolution can be higher than output resolution) self.sampling_mode = config.get( - "sampling_mode", "supersample" + "sampling_mode", "native" ) # supersample | native | crop ( self.sampling_mode, @@ -99,6 +99,10 @@ def __init__(self, config: dict[str, Any]): self.output_height, ) = self._resolve_sampling_layout(self.sampling_mode, self.width, self.height) + # 电子极轴镜默认 AE 策略(约 16mm 广角、低帧率夜空);仍由 libcamera ISP 闭环 / Polar-scope AE defaults (16mm, dark sky; ISP AE loop). + self.ae_polar_preset = bool(config.get("ae_polar_preset", True)) + self.ae_exposure_value = float(config.get("ae_exposure_value", 0.35)) + logger.info( f"初始化 IMX327 MIPI 相机: {self.width}x{self.height}@{self.fps}fps" ) @@ -245,13 +249,22 @@ def _resolve_sampling_layout( ) if mode not in {"supersample", "native", "crop"}: mode = "native" - capture_w = self.SENSOR_MAX_WIDTH - capture_h = self.SENSOR_MAX_HEIGHT + if mode == "supersample": + capture_w = self.SENSOR_MAX_WIDTH + capture_h = self.SENSOR_MAX_HEIGHT + else: + # 关闭超采样时按输出尺寸采集,减少大帧常驻与重采样开销 + # Capture at output size when supersample is off to reduce RAM and resize cost. + capture_w = output_width + capture_h = output_height if mode == "supersample" and ( - output_width >= capture_w or output_height >= capture_h + output_width >= self.SENSOR_MAX_WIDTH + or output_height >= self.SENSOR_MAX_HEIGHT ): logger.warning("当前分辨率下超采样无有效增益,自动切换为 native 模式") mode = "native" + capture_w = output_width + capture_h = output_height return mode, capture_w, capture_h, output_width, output_height def _resize_preserve_fov( @@ -289,6 +302,52 @@ def _resize_preserve_fov( value=border_value, ) + def _apply_polar_auto_exposure_controls(self) -> None: + """libcamera AE 预设:暗部优先、矩阵测光、偏长曝光、EV;失败项跳过 / AE preset; skip unsupported controls.""" + if not self.camera or not self.auto_exposure: + return + if not self.ae_polar_preset: + try: + self.camera.set_controls({"AeEnable": True}) + except Exception as e: + logger.debug("AeEnable only: %s", e) + return + try: + from picamera2 import controls as pcc + except ImportError: + return + + cc = getattr(self.camera, "camera_controls", None) or {} + updates: dict[str, Any] = {"AeEnable": True} + if hasattr(pcc, "AeConstraintModeEnum"): + updates["AeConstraintMode"] = pcc.AeConstraintModeEnum.Shadows + if hasattr(pcc, "AeMeteringModeEnum"): + updates["AeMeteringMode"] = pcc.AeMeteringModeEnum.Matrix + if hasattr(pcc, "AeExposureModeEnum"): + updates["AeExposureMode"] = pcc.AeExposureModeEnum.Long + ev = float(self.ae_exposure_value) + if abs(ev) > 1e-6: + if "ExposureValue" in cc: + updates["ExposureValue"] = ev + elif "Brightness" in cc: + updates["Brightness"] = max(-1.0, min(1.0, ev * 0.2)) + + try: + self.camera.set_controls(updates) + logger.info( + "已应用电子极轴镜 AE 预设 (Shadows/Matrix/Long, EV≈%.2f)", + ev, + ) + except Exception as e: + logger.warning("AE 预设批量设置失败,逐项重试: %s", e) + for key, val in updates.items(): + if key != "AeEnable" and key not in cc: + continue + try: + self.camera.set_controls({key: val}) + except Exception as err: + logger.debug("AE 控制 %s 未生效: %s", key, err) + def initialize(self) -> bool: """初始化 MIPI 相机 / Initialize MIPI camera""" try: @@ -326,6 +385,9 @@ def initialize(self) -> bool: # DigitalGain 不被支持时,退化为不设置该项 / When DigitalGain is not supported, it will degenerate to not setting this item. self.camera.set_controls(controls) + if self.auto_exposure: + self._apply_polar_auto_exposure_controls() + self.is_initialized = True logger.info("IMX327 MIPI 相机初始化成功") return True @@ -367,7 +429,7 @@ def start_capture(self) -> bool: # 重新配置后重放曝光控制,避免状态漂移到驱动默认值 / Replay exposure control after reconfiguration to avoid state drift to driver defaults try: if self.auto_exposure: - self.camera.set_controls({"AeEnable": True}) + self._apply_polar_auto_exposure_controls() else: controls = { "AeEnable": False, @@ -425,7 +487,7 @@ def capture_image(self) -> Optional[np.ndarray]: # 暂时返回原始数据 / Temporarily return to original data pass - # 输出重采样(保持最大视野) / Output resampling (preserve maximum field of view) + # 输出重采样(仅当采集与输出不一致) / Output resampling only when capture/output differ try: if (self.output_width, self.output_height) != ( image.shape[1], @@ -637,8 +699,11 @@ def set_auto_exposure(self, enabled: bool) -> bool: return False try: - self.camera.set_controls({"AeEnable": enabled}) self.auto_exposure = enabled + if enabled: + self._apply_polar_auto_exposure_controls() + else: + self.camera.set_controls({"AeEnable": False}) # 关闭自动曝光时,立即重放当前手动参数,确保状态一致 / When auto-exposure is turned off, the current manual parameters are immediately replayed to ensure consistent status. if not enabled: @@ -772,6 +837,8 @@ def get_camera_info(self) -> dict[str, Any]: "white_balance_mode": self.white_balance_mode, "white_balance_gain_r": self.white_balance_gain_r, "white_balance_gain_b": self.white_balance_gain_b, + "ae_polar_preset": self.ae_polar_preset, + "ae_exposure_value": self.ae_exposure_value, "control_ranges": self.get_manual_control_ranges(), } except Exception as e: diff --git a/ogscope/hardware/gpio_config.py b/ogscope/hardware/gpio_config.py index c3b4041..956c19a 100644 --- a/ogscope/hardware/gpio_config.py +++ b/ogscope/hardware/gpio_config.py @@ -110,6 +110,13 @@ 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""" @@ -186,7 +193,11 @@ 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""" + """获取所有已使用的引脚 / 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. + """ used_pins = [] # 显示屏引脚 / Display pins diff --git a/ogscope/hardware/wifi_emergency_gpio.py b/ogscope/hardware/wifi_emergency_gpio.py new file mode 100644 index 0000000..8747e1b --- /dev/null +++ b/ogscope/hardware/wifi_emergency_gpio.py @@ -0,0 +1,130 @@ +""" +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.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/hardware/wifi_switch.py b/ogscope/hardware/wifi_switch.py new file mode 100644 index 0000000..1b44723 --- /dev/null +++ b/ogscope/hardware/wifi_switch.py @@ -0,0 +1,117 @@ +""" +WiFi 模式切换(调用 ogscope-wifi-switch.sh + nmcli) +WiFi mode switch via external NetworkManager helper script. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from pathlib import Path +from typing import Literal + +from loguru import logger + +from ogscope.config import Settings, get_settings + +WifiMode = Literal["ap", "sta", "unknown"] + + +def _wifi_env(settings: Settings) -> dict[str, str]: + """合并当前环境与 WiFi 相关 OGSCOPE_* 变量 / Merge env with WiFi OGSCOPE_* vars.""" + env = {k: v for k, v in os.environ.items() if v is not None} + env["OGSCOPE_WIFI_STA_CONNECTION"] = settings.wifi_sta_connection + env["OGSCOPE_WIFI_AP_CONNECTION"] = settings.wifi_ap_connection + env["OGSCOPE_WIFI_INTERFACE"] = settings.wifi_interface + return env + + +def _parse_status_output(text: str) -> dict[str, str]: + out: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if "=" in line: + k, _, v = line.partition("=") + out[k.strip()] = v.strip() + return out + + +class WifiSwitchService: + """封装脚本调用 / Script invocation wrapper.""" + + def __init__(self, settings: Settings | None = None) -> None: + self._settings = settings or get_settings() + + def is_configured(self) -> bool: + """是否已配置连接名与脚本 / Whether connection names and script are set.""" + s = self._settings + if not s.wifi_sta_connection or not s.wifi_ap_connection: + return False + p = Path(s.wifi_switch_script) + return p.is_file() + + def get_status(self) -> dict[str, str | None]: + """执行 status,返回解析后的键值 / Run status subcommand.""" + if not self.is_configured(): + return { + "MODE": "unknown", + "error": "wifi_not_configured", + } + raw = self._run_script("status", check=False) + data = _parse_status_output(raw) + if not data.get("MODE"): + data["MODE"] = "unknown" + return data + + def switch(self, mode: Literal["ap", "sta"]) -> None: + """切换模式;失败抛 subprocess.CalledProcessError / Switch mode.""" + if not self.is_configured(): + raise RuntimeError("wifi_not_configured") + self._run_script(mode, check=True) + + def _run_script(self, subcommand: str, *, check: bool) -> str: + s = self._settings + script = Path(s.wifi_switch_script) + cmd: list[str] = [] + if s.wifi_switch_use_sudo: + # 不用 sudo -E:多数 sudoers 禁止 preserve env;脚本会从 /etc/ogscope/network.env 读取 + # Avoid sudo -E (often blocked); switch script sources network.env when env is empty + cmd.extend(["sudo", "-n", str(script), subcommand]) + else: + cmd.extend([str(script), subcommand]) + logger.info( + "WiFi script: {} / Running WiFi script", + " ".join(shlex.quote(c) for c in cmd), + ) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=s.wifi_switch_timeout_seconds, + env=_wifi_env(s), + check=check, + ) + except subprocess.TimeoutExpired as e: + logger.error("WiFi 脚本超时 / WiFi script timeout: {}", e) + raise + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + combined = "\n".join(x for x in (out, err) if x) + if err: + logger.debug("WiFi script stderr / stderr: {}", err) + if proc.returncode != 0 and check: + logger.warning( + "WiFi 脚本失败 rc={} / script failed: {}\n{}", + proc.returncode, + out, + err, + ) + raise subprocess.CalledProcessError( + proc.returncode, cmd, output=out, stderr=err + ) + return combined + + +wifi_switch_service = WifiSwitchService() diff --git a/ogscope/vendor/tetra3/tetra3.py b/ogscope/vendor/tetra3/tetra3.py index ac381a3..9a0c53c 100644 --- a/ogscope/vendor/tetra3/tetra3.py +++ b/ogscope/vendor/tetra3/tetra3.py @@ -580,7 +580,9 @@ def load_database(self, path='default_database'): path = Path(path).with_suffix('.npz') self._logger.info('Loading database from: ' + str(path)) - with np.load(path) as data: + # NumPy 2+ 默认禁止 unpickle;官方 .npz 含 object 数组时需显式允许 / NumPy 2+ blocks + # unpickling by default; upstream Tetra3 DB may contain object arrays. + with np.load(path, allow_pickle=True) as data: self._logger.debug('Loaded database, unpack files') self._pattern_catalog = data['pattern_catalog'] diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index e00b402..e244442 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -158,9 +158,9 @@ def __init__(self) -> None: self.upload_root.mkdir(parents=True, exist_ok=True) self.jobs_root.mkdir(parents=True, exist_ok=True) self.results_root.mkdir(parents=True, exist_ok=True) - # 解算专用线程池(避免与相机预览等争用默认线程池)/ Dedicated executor for solving tasks + # 解算专用线程池(避免与相机预览等争用默认线程池);默认单 worker 降低 Zero 2W 等低内存设备上并发解算的内存峰值 / Dedicated executor for solving; default 1 worker to reduce peak RAM on low-memory boards self._solver_executor = ThreadPoolExecutor( - max_workers=2, thread_name_prefix="solver" + max_workers=1, thread_name_prefix="solver" ) self._solver_max_stars = settings.solver_max_stars self.extractor = StarExtractor(max_stars=settings.solver_max_stars) diff --git a/ogscope/web/api/debug/routes.py b/ogscope/web/api/debug/routes.py index d81614e..03079cc 100644 --- a/ogscope/web/api/debug/routes.py +++ b/ogscope/web/api/debug/routes.py @@ -3,9 +3,14 @@ """ import asyncio +import datetime as dt +import json +import os +import subprocess +import time -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import FileResponse, StreamingResponse +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import FileResponse, Response, StreamingResponse from ogscope.core.realtime import realtime_solve_service from ogscope.web.api.debug.services import ( @@ -17,6 +22,81 @@ router = APIRouter() +_DEFAULT_PREVIEW_JPEG_QUALITY = int(os.getenv("OGSCOPE_PREVIEW_JPEG_QUALITY", "75")) +_PREVIEW_CLIENT_LAST_TS: dict[str, float] = {} +_DEBUG_PREVIEW_MIN_INTERVAL_SEC = max( + 0.0, + float(os.getenv("OGSCOPE_DEBUG_PREVIEW_MIN_INTERVAL_MS", "150") or "150") / 1000.0, +) + + +def _journal_priority_to_level(priority: str | int | None) -> str: + try: + p = int(priority) if priority is not None else 6 + except (TypeError, ValueError): + p = 6 + if p <= 3: + return "ERROR" + if p == 4: + return "WARN" + return "INFO" + + +def _read_journal_logs( + service: str, + since_seconds: int, + limit: int, +) -> list[dict[str, str | int | None]]: + cmd = [ + "journalctl", + "--no-pager", + "-o", + "json", + "-u", + service, + "--since", + f"{since_seconds} seconds ago", + "-n", + str(limit), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "journalctl failed") + rows: list[dict[str, str | int | None]] = [] + for line in proc.stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + msg = str(item.get("MESSAGE", "")).strip() + if not msg: + continue + priority = item.get("PRIORITY") + level = _journal_priority_to_level(priority) + ts_iso: str | None = None + 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_iso = ts.isoformat() + except (ValueError, TypeError): + ts_iso = None + rows.append( + { + "ts": ts_iso, + "level": level, + "message": msg, + "source": str(item.get("_SYSTEMD_UNIT", service)), + "priority": int(priority) if str(priority).isdigit() else None, + } + ) + return rows + # ==================== 相机控制 ==================== / ==================== Camera Control ==================== @@ -67,24 +147,35 @@ async def start_debug_camera(): @router.get("/debug/camera/stream") -async def stream_debug_camera(quality: int = Query(70, ge=10, le=100)): +async def stream_debug_camera( + quality: int = Query(_DEFAULT_PREVIEW_JPEG_QUALITY, ge=10, le=100), +): """MJPEG 实时流 - 可配置压缩质量 / MJPEG live streaming - configurable compression quality""" try: boundary = "frame" + min_emit_interval = 1.0 / max( + 1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8") or "8") + ) async def frame_generator(): - last_frame_id = -1 + last_snap_frame_id = -1 + last_emit_mono = 0.0 while True: - code, data, frame_id = await DebugCameraService.get_stream_frame_bytes( - "jpeg", quality + code, data, snap_id = await DebugCameraService.get_stream_frame_bytes( + "jpeg", quality, since_frame_id=last_snap_frame_id ) + if code == 304: + await asyncio.sleep(0.03) + continue if code != 200 or data is None: await asyncio.sleep(0.05) continue - if frame_id == last_frame_id: - await asyncio.sleep(0.03) - continue - last_frame_id = frame_id + now = time.monotonic() + wait = last_emit_mono + min_emit_interval - now + if wait > 0: + await asyncio.sleep(wait) + last_snap_frame_id = snap_id + last_emit_mono = time.monotonic() yield ( b"--" + boundary.encode() + b"\r\n" b"Content-Type: image/jpeg\r\n" @@ -110,20 +201,29 @@ async def stream_debug_camera_lossless(): """无损质量实时流 - 使用PNG格式展示超采样效果 / Lossless quality live streaming - using PNG format to demonstrate supersampling effects""" try: boundary = "frame" + min_emit_interval = 1.0 / max( + 1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8") or "8") + ) async def frame_generator(): - last_frame_id = -1 + last_snap_frame_id = -1 + last_emit_mono = 0.0 while True: - code, data, frame_id = await DebugCameraService.get_stream_frame_bytes( - "png", 100 + code, data, snap_id = await DebugCameraService.get_stream_frame_bytes( + "png", 100, since_frame_id=last_snap_frame_id ) + if code == 304: + await asyncio.sleep(0.03) + continue if code != 200 or data is None: await asyncio.sleep(0.05) continue - if frame_id == last_frame_id: - await asyncio.sleep(0.03) - continue - last_frame_id = frame_id + now = time.monotonic() + wait = last_emit_mono + min_emit_interval - now + if wait > 0: + await asyncio.sleep(wait) + last_snap_frame_id = snap_id + last_emit_mono = time.monotonic() yield ( b"--" + boundary.encode() + b"\r\n" b"Content-Type: image/png\r\n" @@ -166,10 +266,22 @@ async def set_camera_rotation(rotation: int): @router.get("/debug/camera/preview") -async def get_debug_camera_preview(since_frame_id: int | None = Query(default=None)): +async def get_debug_camera_preview( + request: Request, + since_frame_id: int | None = Query(default=None), +): """获取调试相机预览 / Get debug camera preview""" try: + if _DEBUG_PREVIEW_MIN_INTERVAL_SEC > 0: + client_host = request.client.host if request.client else "unknown" + now = time.monotonic() + last = _PREVIEW_CLIENT_LAST_TS.get(client_host, 0.0) + if now - last < _DEBUG_PREVIEW_MIN_INTERVAL_SEC: + return Response(status_code=304) + _PREVIEW_CLIENT_LAST_TS[client_host] = now return await DebugCameraService.get_preview(since_frame_id=since_frame_id) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -418,6 +530,44 @@ async def delete_capture_file(filename: str): raise HTTPException(status_code=500, detail=str(e)) +# ==================== 系统日志 ==================== / ==================== System Logs ==================== + + +@router.get("/debug/logs/systemd") +async def get_systemd_logs( + service: str = Query(default="ogscope"), + since_seconds: int = Query(default=600, ge=10, le=86400), + limit: int = Query(default=120, ge=10, le=1000), + levels: str = Query(default="INFO,WARN,ERROR"), +): + """读取 systemd/journalctl 日志 / Read systemd journal logs.""" + level_set = { + part.strip().upper() + for part in levels.split(",") + if part.strip().upper() in {"INFO", "WARN", "ERROR"} + } + if not level_set: + level_set = {"INFO", "WARN", "ERROR"} + try: + rows = await asyncio.to_thread( + _read_journal_logs, + service, + since_seconds, + limit, + ) + filtered = [row for row in rows if str(row.get("level")) in level_set] + return { + "service": service, + "since_seconds": since_seconds, + "limit": limit, + "levels": sorted(level_set), + "items": filtered, + "count": len(filtered), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # ==================== 实时解算 ==================== / ==================== Realtime Solving ==================== diff --git a/ogscope/web/api/debug/services.py b/ogscope/web/api/debug/services.py index 2927f3a..57863d9 100644 --- a/ogscope/web/api/debug/services.py +++ b/ogscope/web/api/debug/services.py @@ -5,21 +5,20 @@ import asyncio import json import logging -import os import time -from concurrent.futures import ThreadPoolExecutor from datetime import datetime from pathlib import Path from typing import Any, Optional +from fastapi import HTTPException + from ogscope.web.camera_shared import get_camera_manager # 调试控制台相关 / Debug console related DEBUG_CAPTURES_DIR = Path.home() / "dev_captures" DEBUG_CAPTURES_DIR.mkdir(exist_ok=True) -# 全局变量存储相机状态 / Global variables store camera status -camera_instance = None +# 全局变量存储相机状态(相机单例在 CameraManager)/ Global state (camera singleton lives in CameraManager). is_recording = False recording_task = None recording_state_lock: Optional[asyncio.Lock] = None @@ -31,14 +30,6 @@ recording_codec_fourcc: str = "MJPG" recording_container: str = "AVI" -# 预览帧缓存与抓取任务 / Preview frame buffering and grabbing tasks -latest_preview_jpeg: Optional[bytes] = None -last_preview_time: Optional[float] = None -latest_preview_id: int = 0 -preview_grabber_task = None -PREVIEW_JPEG_QUALITY = int(os.getenv("OGSCOPE_PREVIEW_JPEG_QUALITY", "75")) -PREVIEW_PIPELINE_WORKERS = 2 - _CAMERA_ENV_KEY_MAP = { "width": "OGSCOPE_CAMERA_WIDTH", "height": "OGSCOPE_CAMERA_HEIGHT", @@ -333,21 +324,11 @@ async def get_preview(since_frame_id: int | None = None): if code == 304: return Response(status_code=304) if code != 200 or frame is None or frame.jpeg_frame is None: - # 首帧兜底:直接抓一帧并编码,避免前端启动后长时间黑屏 - # First-frame fallback: grab one frame immediately to avoid prolonged black screen. - raw, frame_id, frame_ts = await manager.get_raw_frame() - jpeg = await asyncio.to_thread(manager.encode_frame, raw, "jpeg", 75) - if jpeg is None: - raise Exception("暂无预览帧") - return Response( - content=jpeg, - media_type="image/jpeg", - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "X-Frame-Id": str(frame_id), - "X-Frame-Ts": str(frame_ts), - }, + # 预览仅消费共享 JPEG 缓存,避免触发 raw 抓取与二次编码导致内存尖峰 + # Preview consumes shared JPEG cache only; avoid raw grab + re-encode spikes. + raise HTTPException( + status_code=503, + detail="暂无预览帧,请稍后重试 / No preview frame yet, retry shortly", ) return Response( content=frame.jpeg_frame, @@ -364,39 +345,62 @@ async def get_preview(since_frame_id: int | None = None): @staticmethod async def get_stream_frame_bytes( - image_format: str = "jpeg", quality: int = 75 + image_format: str = "jpeg", + quality: int = 75, + *, + since_frame_id: int | None = None, ) -> tuple[int, bytes | None, int]: - """读取共享流帧并编码 / Read shared frame and encode.""" + """读取共享流帧并编码 / Read shared frame and encode. + + 返回 (code, data, snap_frame_id)。snap_frame_id 供 MJPEG 循环写入 since_frame_id, + 在共享缓存未前进时返回 304 以避免 PNG/自定义质量下的疯狂同步抓帧。 + Returns (code, data, snap_frame_id). snap_frame_id is fed back as since_frame_id for + the MJPEG loop; returns 304 when the shared cache has not advanced to avoid sync grabs. + """ manager = get_camera_manager() await manager.ensure_started() snap = await manager.get_cached_frame_snapshot() - if snap is None or snap.raw_frame is None: + if snap is None: return 503, None, 0 - if image_format.lower() == "jpeg" and snap.jpeg_frame is not None: + + fmt = image_format.lower() + q = int(max(10, min(100, int(quality)))) + default_q = int(manager.preview_jpeg_quality) + + if since_frame_id is not None and since_frame_id == snap.frame_id: + return 304, None, snap.frame_id + + if fmt == "jpeg" and q == default_q and snap.jpeg_frame is not None: return 200, snap.jpeg_frame, snap.frame_id - encoded = await asyncio.to_thread( - manager.encode_frame, snap.raw_frame, image_format, int(quality) - ) + + raw, _fid, _ts = await manager.get_raw_frame() + encoded = await asyncio.to_thread(manager.encode_frame, raw, image_format, q) if encoded is None: return 500, None, snap.frame_id - return 200, encoded, snap.frame_id + snap2 = await manager.get_cached_frame_snapshot() + sid = int(snap2.frame_id) if snap2 is not None else int(snap.frame_id) + return 200, encoded, sid @staticmethod async def capture_image(): """拍摄单张图片 / Take a single picture""" - camera = get_camera_instance() + manager = get_camera_manager() + await manager.ensure_started() + camera = manager.get_camera_instance() if not camera or not camera.is_capturing: raise Exception("相机未运行") try: import cv2 - # 捕获图像 / capture image - image = camera.capture_image() + try: + image, _rfid, _rts = await manager.get_raw_frame() + except RuntimeError as exc: + raise Exception("图像捕获失败") from exc if image is None: raise Exception("图像捕获失败") - camera_info = camera.get_camera_info() + camera_info = await asyncio.to_thread(camera.get_camera_info) expected_w = int( camera_info.get("output_width", camera_info.get("width", 0)) or 0 ) @@ -550,8 +554,12 @@ async def start_recording(): async def record_video(): nonlocal video_writer try: + mgr = get_camera_manager() while is_recording: - image = await asyncio.to_thread(camera.capture_image) + try: + image, _, _ = await mgr.get_raw_frame() + except RuntimeError: + image = None if image is not None: try: bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) @@ -844,77 +852,6 @@ async def _restart_preview_grabber(): await get_camera_manager().pause_grabber() await get_camera_manager().resume_grabber() - @staticmethod - def _capture_preview_frame(camera): - """抓取预览帧(线程池执行) / Capture preview frame (run in thread pool)""" - try: - return camera.get_video_frame() - except Exception: - return None - - @staticmethod - def _encode_preview_jpeg(image, quality: int) -> Optional[bytes]: - """编码 JPEG(线程池执行) / Encode JPEG (run in thread pool)""" - try: - import cv2 - - ok, buf = cv2.imencode( - ".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, int(quality)] - ) - if not ok: - return None - return buf.tobytes() - except Exception: - return None - - @staticmethod - async def _preview_grabber_loop(): - """后台抓取最新帧,编码为 JPEG 缓存,降低单次请求阻塞与抖动 / The latest frames are captured in the background and encoded into JPEG cache to reduce single request blocking and jitter.""" - global latest_preview_jpeg, last_preview_time, latest_preview_id - camera = get_camera_instance() - if not camera or not camera.is_capturing: - return - target_fps = max(1, int(camera.get_camera_info().get("fps", 5))) - interval = 1.0 / target_fps - loop = asyncio.get_running_loop() - # 使用双工人流水线:一个抓帧,一个编码,提升 Zero2W 下实时预览稳定性 / Use a two-worker pipeline: one captures frames and one encodes, improving preview stability on Zero2W. - executor = ThreadPoolExecutor( - max_workers=PREVIEW_PIPELINE_WORKERS, thread_name_prefix="preview-pipe" - ) - try: - capture_future = loop.run_in_executor( - executor, DebugCameraService._capture_preview_frame, camera - ) - while True: - start = time.time() - image = await asyncio.wrap_future(capture_future) - # 先提交下一帧抓取,让抓取与编码并行 / Submit next frame capture first to overlap capture and encoding - capture_future = loop.run_in_executor( - executor, DebugCameraService._capture_preview_frame, camera - ) - if image is not None: - jpeg_bytes = await loop.run_in_executor( - executor, - DebugCameraService._encode_preview_jpeg, - image, - PREVIEW_JPEG_QUALITY, - ) - if jpeg_bytes is not None: - latest_preview_jpeg = jpeg_bytes - last_preview_time = time.time() - latest_preview_id += 1 - # 按 fps 节流 / Throttle by fps - spent = time.time() - start - await asyncio.sleep(max(0.0, interval - spent)) - except asyncio.CancelledError: - # 正确处理取消信号 / Correctly handle cancellation signals - raise - except Exception as e: - # 记录其他异常 / Log other exceptions - logging.getLogger(__name__).error(f"预览抓取器异常: {e}") - finally: - executor.shutdown(wait=False, cancel_futures=True) - @staticmethod async def set_auto_exposure_mode(enabled: bool): """仅切换自动曝光模式 / Toggle auto-exposure mode only""" diff --git a/ogscope/web/api/main.py b/ogscope/web/api/main.py index 91b23f4..b0abaa3 100644 --- a/ogscope/web/api/main.py +++ b/ogscope/web/api/main.py @@ -9,6 +9,7 @@ from ogscope.web.api.analysis.routes import router as analysis_router from ogscope.web.api.camera.routes import router as camera_router from ogscope.web.api.debug.routes import router as debug_router +from ogscope.web.api.network.routes import router as network_router from ogscope.web.api.system.routes import router as system_router # 创建主路由器 / Create the main router @@ -18,5 +19,6 @@ router.include_router(camera_router, tags=["Camera - 相机"]) router.include_router(alignment_router, tags=["Alignment - 极轴校准"]) router.include_router(system_router, tags=["System - 系统"]) +router.include_router(network_router, tags=["Network - 网络"]) router.include_router(debug_router, tags=["Debug - 调试"]) router.include_router(analysis_router, tags=["Analysis - 分析"]) diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index e19c646..0bbc21e 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -77,6 +77,74 @@ class SystemInfo(BaseModel): load_average_1m: float = 0.0 +class WifiStatus(BaseModel): + """WiFi 模式状态 / WiFi mode status (STA vs AP).""" + + mode: Literal["ap", "sta", "unknown"] + active_connection: Optional[str] = None + wireless_interface: str = "wlan0" + sta_connection: str = "" + ap_connection: str = "" + ap_ipv4: Optional[str] = None + ap_url_hint: Optional[str] = None + configured: bool = True + message: Optional[str] = None + device_id_suffix: Optional[str] = None + ap_ssid: Optional[str] = None + mdns_hostname_hint: Optional[str] = None + + +class WifiModeRequest(BaseModel): + """切换 WiFi 模式 / Switch WiFi mode.""" + + mode: Literal["ap", "sta"] + + +class WifiNetworkScanEntry(BaseModel): + """扫描到的 WiFi / One scanned WiFi network.""" + + ssid: str + signal: Optional[int] = None + security: Optional[str] = None + + +class WifiScanResponse(BaseModel): + """WiFi 扫描结果 / WiFi scan response.""" + + networks: list[WifiNetworkScanEntry] + hint: Optional[str] = Field( + default=None, + description="空列表或降级时的说明(如 AP 模式无法扫描)/ UX hint when empty or degraded", + ) + + +class WifiProfileEntry(BaseModel): + """已保存的 WiFi 连接 / Saved WiFi connection profile.""" + + connection_name: str + ssid: str + autoconnect: bool + + +class WifiProfilesResponse(BaseModel): + """已保存配置列表 / Saved profiles list.""" + + profiles: list[WifiProfileEntry] + + +class WifiStaConnectRequest(BaseModel): + """连接外部 WiFi(切 STA)/ Connect to external WiFi (STA mode).""" + + ssid: str + password: Optional[str] = None + + +class WifiProfileActivateRequest(BaseModel): + """激活已保存连接 / Activate saved connection.""" + + connection_name: str + + class AlignmentStatus(BaseModel): """校准状态 / calibration status""" diff --git a/ogscope/web/api/network/__init__.py b/ogscope/web/api/network/__init__.py new file mode 100644 index 0000000..92b242c --- /dev/null +++ b/ogscope/web/api/network/__init__.py @@ -0,0 +1,3 @@ +""" +网络 API 模块 / Network API module. +""" diff --git a/ogscope/web/api/network/routes.py b/ogscope/web/api/network/routes.py new file mode 100644 index 0000000..e71a180 --- /dev/null +++ b/ogscope/web/api/network/routes.py @@ -0,0 +1,181 @@ +""" +网络相关 API 路由(WiFi AP/STA) / Network API routes for WiFi AP/STA. +""" + +from __future__ import annotations + +import asyncio +import subprocess + +from fastapi import APIRouter, HTTPException + +from ogscope.config import get_settings +from ogscope.hardware.wifi_switch import wifi_switch_service +from ogscope.web.api.models.schemas import ( + WifiModeRequest, + WifiNetworkScanEntry, + WifiProfileActivateRequest, + WifiProfileEntry, + WifiProfilesResponse, + WifiScanResponse, + WifiStaConnectRequest, + WifiStatus, +) +from ogscope.web.api.network import services as net_services + +router = APIRouter() + + +def _build_wifi_status() -> WifiStatus: + settings = get_settings() + configured = wifi_switch_service.is_configured() + data = wifi_switch_service.get_status() + mode = data.get("MODE", "unknown") + active_connection = data.get("ACTIVE_CONNECTION") or None + wireless_interface = data.get("WIRELESS_INTERFACE", settings.wifi_interface) + sta_connection = data.get("STA_CONNECTION", settings.wifi_sta_connection) + 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 + ) + message = data.get("error") + suffix = settings.device_id_suffix or None + ap_ssid = settings.wifi_ap_ssid or None + mdns_hint = f"ogscope-{suffix}.local" if suffix else None + return WifiStatus( + mode=mode if mode in {"ap", "sta"} else "unknown", + active_connection=active_connection, + wireless_interface=wireless_interface, + sta_connection=sta_connection, + ap_connection=ap_connection, + ap_ipv4=ap_ipv4, + ap_url_hint=ap_url_hint, + configured=configured, + message=message, + device_id_suffix=suffix, + ap_ssid=ap_ssid, + mdns_hostname_hint=mdns_hint, + ) + + +@router.get("/network/wifi", response_model=WifiStatus) +async def get_wifi_status() -> WifiStatus: + """获取 WiFi 模式状态 / Get WiFi mode status.""" + return _build_wifi_status() + + +@router.post("/network/wifi", response_model=WifiStatus) +async def switch_wifi_mode(payload: WifiModeRequest) -> WifiStatus: + """切换 WiFi 模式(AP/STA)/ Switch WiFi mode.""" + if payload.mode == "ap": + net_services.cancel_sta_rollback_watch() + try: + wifi_switch_service.switch(payload.mode) + if payload.mode == "sta": + net_services.schedule_sta_rollback_watch() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except subprocess.TimeoutExpired as e: + raise HTTPException(status_code=504, detail=f"wifi_switch_timeout: {e}") from e + except subprocess.CalledProcessError as e: + err = (e.stderr or e.output or str(e)).strip() + raise HTTPException(status_code=500, detail=f"wifi_switch_failed: {err}") from e + return _build_wifi_status() + + +@router.get("/network/wifi/scan", response_model=WifiScanResponse) +async def scan_wifi() -> WifiScanResponse: + """扫描附近 WiFi(由设备执行 nmcli)/ Scan WiFi (nmcli on device).""" + settings = get_settings() + try: + nets, scan_hint = await asyncio.to_thread( + net_services.nmcli_wifi_scan, + settings.wifi_interface, + ) + except subprocess.TimeoutExpired as e: + raise HTTPException(status_code=504, detail=f"wifi_scan_timeout: {e}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + return WifiScanResponse( + networks=[WifiNetworkScanEntry(**n) for n in nets], + hint=scan_hint, + ) + + +@router.get("/network/wifi/profiles", response_model=WifiProfilesResponse) +async def list_wifi_profiles() -> WifiProfilesResponse: + """列出已保存的 WiFi 连接(不含 AP)/ List saved WiFi profiles.""" + settings = get_settings() + try: + rows = await asyncio.to_thread( + net_services.nmcli_wifi_profiles, + settings, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + return WifiProfilesResponse( + profiles=[ + WifiProfileEntry( + connection_name=r["connection_name"], + ssid=r["ssid"], + autoconnect=r["autoconnect"], + ) + for r in rows + ], + ) + + +@router.post("/network/wifi/sta/connect", response_model=WifiStatus) +async def connect_sta_wifi(payload: WifiStaConnectRequest) -> WifiStatus: + """配置 STA 并切换到 STA,启动失败回滚监视 / Configure STA and switch, start rollback watch.""" + settings = get_settings() + if not wifi_switch_service.is_configured(): + raise HTTPException(status_code=503, detail="wifi_not_configured") + try: + await asyncio.to_thread( + net_services.nmcli_modify_sta_to_ssid, + settings, + payload.ssid, + payload.password, + ) + await asyncio.to_thread(wifi_switch_service.switch, "sta") + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except subprocess.CalledProcessError as e: + err = (e.stderr or e.output or str(e)).strip() + raise HTTPException(status_code=500, detail=f"sta_connect_failed: {err}") from e + net_services.schedule_sta_rollback_watch() + return _build_wifi_status() + + +@router.post("/network/wifi/profile/activate", response_model=WifiStatus) +async def activate_wifi_profile(payload: WifiProfileActivateRequest) -> WifiStatus: + """激活已保存连接并切 STA / Activate saved profile (STA).""" + settings = get_settings() + if not wifi_switch_service.is_configured(): + raise HTTPException(status_code=503, detail="wifi_not_configured") + name = payload.connection_name.strip() + if not name: + raise HTTPException(status_code=400, detail="empty_connection_name") + try: + if name == settings.wifi_sta_connection: + await asyncio.to_thread(wifi_switch_service.switch, "sta") + else: + await asyncio.to_thread( + net_services.nm_down_if_exists, settings.wifi_ap_connection + ) + await asyncio.to_thread( + net_services.nmcli_activate_connection, + settings, + name, + ) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + except subprocess.CalledProcessError as e: + err = (e.stderr or e.output or str(e)).strip() + raise HTTPException(status_code=500, detail=f"activate_failed: {err}") from e + net_services.schedule_sta_rollback_watch() + return _build_wifi_status() diff --git a/ogscope/web/api/network/services.py b/ogscope/web/api/network/services.py new file mode 100644 index 0000000..7b8fcd8 --- /dev/null +++ b/ogscope/web/api/network/services.py @@ -0,0 +1,405 @@ +""" +NetworkManager(nmcli)操作与 STA 回滚监视 / nmcli helpers and STA rollback watcher. +""" + +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import subprocess +import threading +import time +from typing import Any + +from loguru import logger + +from ogscope.config import Settings, get_settings +from ogscope.hardware.wifi_switch import wifi_switch_service + +_nm_lock = threading.Lock() + + +def _nm_executable() -> str: + """nmcli 绝对路径或回退名 / Resolved nmcli path.""" + return shutil.which("nmcli") or "nmcli" + + +def _run_nm(args: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]: + """执行 nmcli;非 root 时默认 sudo -n,避免 polkit Not authorized / Run nmcli; sudo when needed.""" + s = get_settings() + if s.wifi_nmcli_use_sudo and os.geteuid() != 0: + cmd = ["sudo", "-n", _nm_executable(), *args] + else: + cmd = [_nm_executable(), *args] + with _nm_lock: + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def nmcli_rescan(iface: str) -> None: + """触发 WiFi 重扫 / Trigger WiFi rescan.""" + try: + _run_nm(["device", "wifi", "rescan", "ifname", iface], timeout=30) + except (subprocess.TimeoutExpired, OSError) as e: + logger.warning("nmcli rescan 失败 / rescan failed: {}", e) + + +def _parse_nmcli_wifi_tab(stdout: str) -> list[dict[str, Any]]: + networks: list[dict[str, Any]] = [] + for line in stdout.splitlines(): + line = line.strip() + if not line or line.lower().startswith("ssid"): + continue + parts = line.split("\t") + if len(parts) < 2: + continue + ssid = parts[0].strip() + if not ssid or ssid == "--": + continue + sig: int | None = None + try: + if len(parts) > 1 and parts[1].strip(): + sig = int(parts[1].strip()) + except ValueError: + pass + sec = parts[2].strip() if len(parts) > 2 else "" + networks.append( + {"ssid": ssid, "signal": sig, "security": sec or None}, + ) + return networks + + +def _parse_nmcli_wifi_colon(stdout: str) -> list[dict[str, Any]]: + """-t 模式用冒号分隔;SSID 若含冒号则从右侧取两段 / Colon mode; rsplit for SSID with colons.""" + networks: list[dict[str, Any]] = [] + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + parts = line.rsplit(":", 2) + if len(parts) < 3: + continue + ssid, sig_s, sec = parts[0].strip(), parts[1].strip(), parts[2].strip() + if not ssid or ssid == "--": + continue + sig: int | None = None + try: + sig = int(sig_s) + except ValueError: + pass + networks.append({"ssid": ssid, "signal": sig, "security": sec or None}) + return networks + + +def _device_active_connection_name(iface: str) -> str: + """当前接口活动连接名 / Active connection name on iface.""" + proc = _run_nm( + ["-t", "-f", "GENERAL.CONNECTION", "device", "show", iface], timeout=15 + ) + if proc.returncode != 0: + return "" + line = (proc.stdout or "").strip().split("\n", 1)[0].strip() + return line if line and line != "--" else "" + + +def _merge_networks_by_ssid(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """同名 SSID 保留信号最强的一条 / Keep strongest signal per SSID.""" + best: dict[str, dict[str, Any]] = {} + for n in rows: + ssid = n.get("ssid") or "" + if not ssid: + continue + prev = best.get(ssid) + if prev is None: + best[ssid] = n + continue + s_new = n.get("signal") + s_old = prev.get("signal") + if isinstance(s_new, int) and (not isinstance(s_old, int) or s_new > s_old): + best[ssid] = n + return list(best.values()) + + +def _wifi_list_one( + iface: str | None, + *, + prefer_tab: bool, +) -> tuple[list[dict[str, Any]], int, str]: + """单次 list;返回 (解析结果, returncode, stderr) / Single list parse.""" + base_tab = ["-m", "tab", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"] + base_t = ["-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"] + if iface: + args_tab = [*base_tab, "ifname", iface] + args_t = [*base_t, "ifname", iface] + else: + args_tab = base_tab + args_t = base_t + if prefer_tab: + proc = _run_nm(args_tab, timeout=60) + if proc.returncode == 0 and (proc.stdout or "").strip(): + return ( + _parse_nmcli_wifi_tab(proc.stdout or ""), + proc.returncode, + proc.stderr or "", + ) + proc2 = _run_nm(args_t, timeout=60) + if proc2.returncode == 0: + return ( + _parse_nmcli_wifi_colon(proc2.stdout or ""), + proc2.returncode, + proc2.stderr or "", + ) + logger.debug( + "nmcli wifi list(tab+t) iface={} rc={} / {}", + iface, + proc2.returncode, + proc2.stderr, + ) + return [], proc2.returncode, proc2.stderr or "" + proc = _run_nm(args_t, timeout=60) + if proc.returncode == 0: + return ( + _parse_nmcli_wifi_colon(proc.stdout or ""), + proc.returncode, + proc.stderr or "", + ) + return [], proc.returncode, proc.stderr or "" + + +def nmcli_wifi_scan(iface: str) -> tuple[list[dict[str, Any]], str | None]: + """扫描可见 AP;返回 (列表, 可选提示) / List APs; returns (list, optional hint).""" + settings = get_settings() + last_err = "" + networks: list[dict[str, Any]] = [] + ap_name = settings.wifi_ap_connection + active = _device_active_connection_name(iface) + ap_mode = bool(ap_name and active == ap_name) + + for attempt in range(1, 3): + nmcli_rescan(iface) + time.sleep(1.5 if attempt == 1 else 2.5) + for use_iface in (iface, None): + nets, rc, err = _wifi_list_one(use_iface, prefer_tab=True) + last_err = err or last_err + if nets: + networks = _merge_networks_by_ssid(nets) + networks.sort( + key=lambda x: (-(x.get("signal") or -1000), x.get("ssid") or "") + ) + return networks, None + if rc != 0 and err: + logger.warning( + "nmcli wifi list 失败 attempt={} iface={!r} / failed: {}", + attempt, + use_iface, + err, + ) + time.sleep(1.0) + + hint: str | None = None + if ap_mode: + hint = ( + "当前为热点(AP)模式:单频网卡通常无法同时列出周边 WiFi。" + "请用下方「手动输入 SSID」连接;或先切到 STA 再试扫描(视驱动而定)。" + ) + elif not networks: + hint = ( + "未扫描到网络。请稍后重试、检查天线/区域码," + "或查看日志中 nmcli 错误。" + + (f" 末次: {last_err.strip()[:200]}" if last_err.strip() else "") + ) + return networks, hint + + +def _connection_mode(name: str) -> str: + proc = _run_nm( + ["-g", "802-11-wireless.mode", "connection", "show", name], timeout=15 + ) + if proc.returncode != 0: + return "" + return (proc.stdout or "").strip().split("\n", 1)[0].strip() + + +def _connection_ssid(name: str) -> str: + proc = _run_nm( + ["-g", "802-11-wireless.ssid", "connection", "show", name], timeout=15 + ) + if proc.returncode != 0: + return "" + return (proc.stdout or "").strip().split("\n", 1)[0].strip() + + +def _connection_autoconnect(name: str) -> bool: + proc = _run_nm( + ["-g", "connection.autoconnect", "connection", "show", name], timeout=15 + ) + if proc.returncode != 0: + return False + v = (proc.stdout or "").strip().lower() + return v in {"yes", "true", "1"} + + +def nmcli_wifi_profiles(settings: Settings) -> list[dict[str, Any]]: + """列出已保存的 WiFi 连接(不含 AP)/ List saved WiFi connections (exclude AP).""" + proc = _run_nm(["-t", "-f", "NAME,TYPE", "connection", "show"], timeout=30) + if proc.returncode != 0: + return [] + profiles: list[dict[str, Any]] = [] + ap_name = settings.wifi_ap_connection + for line in (proc.stdout or "").splitlines(): + if ":" not in line: + continue + name, typ = line.split(":", 1) + name = name.strip() + typ = typ.strip() + if typ != "802-11-wireless": + continue + if name == ap_name: + continue + mode = _connection_mode(name) + if mode == "ap": + continue + ssid = _connection_ssid(name) + profiles.append( + { + "connection_name": name, + "ssid": ssid or name, + "autoconnect": _connection_autoconnect(name), + }, + ) + return profiles + + +def nmcli_modify_sta_to_ssid( + settings: Settings, ssid: str, password: str | None +) -> None: + """将 STA 连接改为指定 SSID 与密码 / Point STA profile at SSID (WPA or open).""" + sta = settings.wifi_sta_connection + if not sta: + raise RuntimeError("wifi_sta_connection not configured") + proc = _run_nm(["connection", "modify", sta, "wifi.ssid", ssid], timeout=30) + if proc.returncode != 0: + raise RuntimeError(proc.stderr or proc.stdout or "nmcli modify ssid failed") + if password: + proc2 = _run_nm( + [ + "connection", + "modify", + sta, + "wifi-sec.key-mgmt", + "wpa-psk", + "wifi-sec.psk", + password, + ], + timeout=30, + ) + if proc2.returncode != 0: + raise RuntimeError( + proc2.stderr or proc2.stdout or "nmcli modify psk failed" + ) + else: + proc3 = _run_nm( + ["connection", "modify", sta, "wifi-sec.key-mgmt", "none"], + timeout=30, + ) + if proc3.returncode != 0: + raise RuntimeError( + proc3.stderr or proc3.stdout or "nmcli modify open failed" + ) + + +def nm_down_if_exists(conn_name: str) -> None: + """尝试 down 连接(忽略未激活)/ Try to bring connection down.""" + proc = _run_nm(["connection", "down", conn_name], timeout=30) + if proc.returncode != 0: + logger.debug("connection down {}: {} / {}", conn_name, proc.stdout, proc.stderr) + + +def nmcli_activate_connection(settings: Settings, connection_name: str) -> None: + """激活指定连接(STA 用)/ Bring up a saved connection.""" + proc = _run_nm( + ["connection", "up", connection_name, "ifname", settings.wifi_interface], + timeout=settings.wifi_switch_timeout_seconds, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr or proc.stdout or "nmcli connection up failed") + + +def sta_interface_has_usable_ipv4(settings: Settings) -> bool: + """STA 是否已获得非链路本地 IPv4 / Whether STA has non-link-local IPv4.""" + iface = settings.wifi_interface + proc = _run_nm(["-g", "IP4.ADDRESS", "device", "show", iface], timeout=15) + if proc.returncode != 0: + return False + text = (proc.stdout or "").strip() + if not text or text == "--": + return False + for line in text.split("\n"): + line = line.strip() + if not line: + continue + m = re.match(r"^([\d.]+)/", line) + if m and not m.group(1).startswith("169.254."): + return True + return False + + +_sta_rollback_task: asyncio.Task[None] | None = None + + +def cancel_sta_rollback_watch() -> None: + """取消 STA 回滚任务 / Cancel STA rollback task.""" + global _sta_rollback_task + if _sta_rollback_task and not _sta_rollback_task.done(): + _sta_rollback_task.cancel() + logger.info("已取消 STA 回滚监视 / STA rollback watchdog cancelled") + _sta_rollback_task = None + + +def schedule_sta_rollback_watch() -> None: + """STA 切换后启动回滚监视(超时切回 AP)/ Start rollback watchdog after STA switch.""" + global _sta_rollback_task + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + if _sta_rollback_task and not _sta_rollback_task.done(): + _sta_rollback_task.cancel() + _sta_rollback_task = loop.create_task(_sta_rollback_loop()) + + +async def _sta_rollback_loop() -> None: + settings = get_settings() + timeout_s = settings.wifi_sta_rollback_timeout_seconds + interval_s = settings.wifi_sta_rollback_interval_seconds + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_s + logger.info( + "STA 回滚监视启动,{}s 内无可用 IPv4 则切回 AP / STA rollback watchdog {}s", + timeout_s, + timeout_s, + ) + try: + while loop.time() < deadline: + await asyncio.sleep(interval_s) + ok = await asyncio.to_thread(sta_interface_has_usable_ipv4, settings) + if ok: + logger.info( + "STA 回滚监视:已获得 IPv4,停止 / STA watchdog: got IPv4, stop" + ) + return + logger.warning( + "STA 回滚监视:超时,切回 AP / STA watchdog: timeout, switching to AP" + ) + await asyncio.to_thread(wifi_switch_service.switch, "ap") + except asyncio.CancelledError: + logger.info("STA 回滚监视已取消 / STA rollback cancelled") + raise + except Exception as e: + logger.error("STA 回滚失败 / Rollback to AP failed: {}", e) diff --git a/ogscope/web/api/system/services.py b/ogscope/web/api/system/services.py index 616260f..6cca899 100644 --- a/ogscope/web/api/system/services.py +++ b/ogscope/web/api/system/services.py @@ -158,6 +158,8 @@ def _read_wifi_metrics(self) -> tuple[float | None, float | None, str | None]: except OSError: return None, None, None + # /proc/net/wireless 列顺序:iface、status、link、level、noise / Columns: iface, status, link, level, noise + # 示例 / Example: wlan0: 0000 45. -50. -256 (link 在 status 之后) for line in lines[2:]: if ":" not in line: continue @@ -166,8 +168,8 @@ def _read_wifi_metrics(self) -> tuple[float | None, float | None, str | None]: if len(values) < 3: continue try: - link_quality = float(values[0].rstrip(".")) - signal_level = float(values[1].rstrip(".")) + link_quality = float(values[1].rstrip(".")) + signal_level = float(values[2].rstrip(".")) quality_percent = max(0.0, min(100.0, (link_quality / 70.0) * 100.0)) return ( round(quality_percent, 2), diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 12fd1ca..bdbed7b 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -10,7 +10,7 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_redoc_html -from fastapi.responses import FileResponse, HTMLResponse +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from loguru import logger @@ -60,10 +60,23 @@ async def _warm_solver() -> None: # Warm the solver synchronously during startup to avoid first-request cold-start race. await _warm_solver() + try: + from ogscope.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("清理资源...") + try: + from ogscope.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 @@ -97,6 +110,10 @@ async def _warm_solver() -> None: "name": "Analysis - 分析", "description": "素材分析与任务管理 / Asset analysis and job management", }, + { + "name": "Network - 网络", + "description": "WiFi AP/STA 切换 / WiFi AP vs STA switching", + }, { "name": "Catalog - 星表", "description": "星表下载、索引与状态 / Catalog download, indexing, and status", @@ -164,7 +181,36 @@ async def root(request: Request): @app.get("/debug", response_class=HTMLResponse) async def debug_console(request: Request): - """调试控制台页面 / Debug console page""" + """统一调试后台入口 / Unified debug admin entry.""" + admin_index = settings.static_dir / "analysis-lab" / "system.html" + if admin_index.is_file(): + return FileResponse(admin_index) + ds_js = settings.static_dir / "js" / "debug-system.js" + return templates.TemplateResponse( + "debug_system.html", + { + "request": request, + "version": __version__, + "app_name": "OGScope System Debug", + "debug_system_assets_version": _asset_stamp(ds_js), + "http_port": settings.port, + }, + ) + + +@app.get("/debug/system", response_class=HTMLResponse) +async def debug_system_console(request: Request): + """兼容旧系统调试入口,重定向到新后台 / Legacy system debug entry.""" + _ = request + return RedirectResponse(url="/debug", status_code=307) + + +@app.get("/debug/camera", response_class=HTMLResponse) +async def debug_camera_console(request: Request): + """相机调试页入口(新 SPA 优先,旧页兜底)/ Camera debug entry (SPA first, legacy fallback).""" + camera_index = settings.static_dir / "analysis-lab" / "camera.html" + if camera_index.is_file(): + return FileResponse(camera_index) debug_js_path = settings.static_dir / "js" / "debug.js" return templates.TemplateResponse( "debug.html", @@ -209,6 +255,7 @@ async def api_root(): "camera": "/api/camera/", "alignment": "/api/alignment/", "system": "/api/system/", + "network": "/api/network/", "analysis": "/api/analysis/", }, } diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index a5d0ea2..28be919 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -43,8 +43,18 @@ def __init__(self) -> None: self._runtime_overrides: dict[str, Any] = {} self._jpeg_quality = int(os.getenv("OGSCOPE_PREVIEW_JPEG_QUALITY", "75")) self._target_fps = max(1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8"))) + # 是否常驻 raw 帧缓存;默认关闭以降低内存占用(分析路径可同步抓帧) + # Whether to retain raw frame cache; default off to reduce RAM (analysis can sync-grab). + self._keep_raw_cache = bool( + int(os.getenv("OGSCOPE_KEEP_RAW_CACHE", "0") or "0") + ) self._logger = logging.getLogger(__name__) + @property + def preview_jpeg_quality(self) -> int: + """共享抓帧 JPEG 质量(与缓存一致)/ Shared grabber JPEG quality (matches cache).""" + return int(self._jpeg_quality) + def _build_base_config(self) -> dict[str, Any]: from ogscope.config import get_settings @@ -57,6 +67,8 @@ def _build_base_config(self) -> dict[str, Any]: "exposure_us": settings.camera_exposure, "analogue_gain": settings.camera_gain, "auto_exposure": True, + "ae_polar_preset": settings.camera_ae_polar_preset, + "ae_exposure_value": settings.camera_ae_exposure_value, "rotation": 180, "sampling_mode": getattr(settings, "camera_sampling_mode", "native"), "noise_reduction": 0, @@ -203,7 +215,9 @@ async def _grabber_loop(self) -> None: w = int(getattr(frame, "shape", [0, 0])[1] or 0) with self._frame_lock: self._frame_id += 1 - self._latest_raw = frame + # 默认不保留 raw,避免与 JPEG 双份常驻;需要时设 OGSCOPE_KEEP_RAW_CACHE=1 + # By default do not retain raw to avoid dual large buffers; set env to keep. + self._latest_raw = frame if self._keep_raw_cache else None self._latest_jpeg = jpeg self._latest_ts = time.time() self._latest_w = w @@ -271,13 +285,25 @@ 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 None: - raise RuntimeError("无可用视频帧 / No frame available") - try: - frame = self._latest_raw.copy() - except Exception: - frame = self._latest_raw - return frame, self._frame_id, self._latest_ts + 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 + try: + out = frame.copy() + except Exception: + out = frame + return out, fid, ts async def get_cached_frame_snapshot(self) -> SharedFrame | None: """读取当前缓存帧快照(不触发 ensure)/ Read cached snapshot without ensure.""" diff --git a/scripts/board-update.sh b/scripts/board-update.sh index f013b88..7d371db 100755 --- a/scripts/board-update.sh +++ b/scripts/board-update.sh @@ -6,6 +6,9 @@ # OGSCOPE_INSTALL_DEV=1 — poetry install 时包含 dev 依赖 / Include dev dependency group # POETRY_INSTALLER_MAX_WORKERS — 默认 2,低配板可设为 1 / Default 2; set to 1 on low-RAM boards # OGSCOPE_MIRROR=auto|cn|international — 与 install.sh 相同 / Same as install.sh +# 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 set -euo pipefail @@ -84,6 +87,14 @@ VENV_PYTHON="$(poetry env info --path)/bin/python" SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service" ogscope_sync_systemd_execstart_if_needed "${SERVICE_PATH}" "${VENV_PYTHON}" +chmod +x "${PROJECT_DIR}/scripts/ogscope-network-boot.sh" 2>/dev/null || true +ogscope_sync_network_boot_unit_if_needed "${PROJECT_DIR}" + +ogscope_sync_network_board_artifacts_if_needed "${PROJECT_DIR}" + +ogscope_sync_plate_solve_database_if_needed "${PROJECT_DIR}" +ogscope_report_plate_solve_database_status "${PROJECT_DIR}" + echo "🔄 重启服务 / Restarting service..." sudo systemctl daemon-reload sudo systemctl restart "${SERVICE_NAME}" diff --git a/scripts/diagnose_camera.py b/scripts/diagnose_camera.py index 6dce2ab..bc7c8a7 100644 --- a/scripts/diagnose_camera.py +++ b/scripts/diagnose_camera.py @@ -11,6 +11,7 @@ BASE_URL = "http://localhost:8000/api/debug/camera" + async def check_camera_status(client): """检查相机状态 / Check camera status""" print("🔍 检查相机状态...") @@ -18,20 +19,20 @@ async def check_camera_status(client): response = await client.get(f"{BASE_URL}/status") response.raise_for_status() result = response.json() - + print(f"✅ 相机状态: {json.dumps(result, indent=2, ensure_ascii=False)}") - + if not result.get("connected", False): print("❌ 相机未连接") return False - + if not result.get("streaming", False): print("⚠️ 相机未在流式传输") return False - + print("✅ 相机状态正常") return True - + except httpx.HTTPStatusError as e: print(f"❌ HTTP错误: {e.response.status_code} - {e.response.text}") return False @@ -42,6 +43,7 @@ async def check_camera_status(client): print(f"❌ 意外错误: {e}") return False + async def start_camera(client): """启动相机 / Start camera""" print("\n🚀 尝试启动相机...") @@ -49,10 +51,10 @@ async def start_camera(client): response = await client.post(f"{BASE_URL}/start") response.raise_for_status() result = response.json() - + print(f"✅ 相机启动结果: {json.dumps(result, indent=2, ensure_ascii=False)}") return result.get("success", False) - + except httpx.HTTPStatusError as e: print(f"❌ 启动失败 - HTTP错误: {e.response.status_code} - {e.response.text}") return False @@ -63,27 +65,28 @@ async def start_camera(client): print(f"❌ 启动失败 - 意外错误: {e}") return False + async def test_preview(client): """测试预览功能 / Test preview functionality""" print("\n📷 测试预览功能...") try: response = await client.get(f"{BASE_URL}/preview") response.raise_for_status() - + content_type = response.headers.get("content-type", "") content_length = len(response.content) - + print(f"✅ 预览响应:") print(f" - Content-Type: {content_type}") print(f" - Content-Length: {content_length} bytes") - + if content_type.startswith("image/"): print("✅ 预览图像正常") return True else: print("❌ 预览不是图像格式") return False - + except httpx.HTTPStatusError as e: print(f"❌ 预览失败 - HTTP错误: {e.response.status_code} - {e.response.text}") return False @@ -94,6 +97,7 @@ async def test_preview(client): print(f"❌ 预览失败 - 意外错误: {e}") return False + async def test_histogram(client): """测试直方图功能 / Test the histogram function""" print("\n📈 测试直方图功能...") @@ -101,93 +105,98 @@ async def test_histogram(client): response = await client.get(f"{BASE_URL}/image-histogram") response.raise_for_status() result = response.json() - + if result.get("success"): histogram_data = result.get("histogram", {}) print("✅ 直方图数据获取成功") - + if "error" in histogram_data: print(f"⚠️ 直方图错误: {histogram_data['error']}") return False - + if "histogram" in histogram_data and histogram_data["histogram"]: print(f" - 灰度直方图数据点: {len(histogram_data['histogram'])}") - + if "statistics" in histogram_data: stats = histogram_data["statistics"] print(f" - 平均亮度: {stats.get('mean_brightness', 'N/A')}") print(f" - 暗部像素: {stats.get('dark_pixels_percent', 'N/A')}%") - + return True else: print(f"❌ 直方图获取失败: {result}") return False - + except Exception as e: print(f"❌ 直方图测试失败: {e}") return False + async def check_system_dependencies(): """检查系统依赖 / Check system dependencies""" print("\n🔧 检查系统依赖...") - + # 检查 Picamera2 / Check Picamera2 try: import picamera2 + print("✅ Picamera2 已安装") except ImportError: print("❌ Picamera2 未安装") print(" 请运行: sudo apt install python3-picamera2") return False - + # 检查 OpenCV / Check OpenCV try: import cv2 + print("✅ OpenCV 已安装") except ImportError: print("⚠️ OpenCV 未安装 (直方图功能需要)") print(" 请运行: sudo apt install python3-opencv") print(" 或: pip install opencv-python-headless") - + # 检查 NumPy / Check NumPy try: import numpy + print("✅ NumPy 已安装") except ImportError: print("❌ NumPy 未安装") return False - + return True + async def check_camera_hardware(): """检查相机硬件 / Check camera hardware""" print("\n📱 检查相机硬件...") - + # 检查相机设备 / Check camera equipment - camera_devices = [ - "/dev/video0", - "/dev/video1", - "/dev/video2", - "/dev/video3" - ] - + camera_devices = ["/dev/video0", "/dev/video1", "/dev/video2", "/dev/video3"] + found_devices = [] for device in camera_devices: if Path(device).exists(): found_devices.append(device) - + if found_devices: print(f"✅ 找到相机设备: {', '.join(found_devices)}") else: print("❌ 未找到相机设备") print(" 请检查相机连接和驱动") return False - + # 检查 libcamera / Check libcamera try: import subprocess - result = subprocess.run(["libcamera-hello", "--list-cameras"], - capture_output=True, text=True, timeout=10) + + result = subprocess.run( + ["libcamera-hello", "--list-cameras"], + capture_output=True, + text=True, + timeout=10, + ) if result.returncode == 0: print("✅ libcamera 可用") if result.stdout: @@ -196,49 +205,50 @@ async def check_camera_hardware(): print("⚠️ libcamera-hello 命令失败") except Exception as e: print(f"⚠️ 无法检查 libcamera: {e}") - + return True + async def main(): """主诊断函数 / Main diagnostic function""" print("🔍 OGScope 相机诊断工具") print("=" * 50) - + # 检查系统依赖 / Check system dependencies deps_ok = await check_system_dependencies() - + # 检查相机硬件 / Check camera hardware hw_ok = await check_camera_hardware() - + if not deps_ok or not hw_ok: print("\n❌ 系统检查失败,请先解决依赖问题") sys.exit(1) - + # 检查服务状态 / Check service status async with httpx.AsyncClient(timeout=30.0) as client: # 检查相机状态 / Check camera status status_ok = await check_camera_status(client) - + if not status_ok: # 尝试启动相机 / Try launching the camera start_ok = await start_camera(client) - + if start_ok: # 重新检查状态 / Recheck status status_ok = await check_camera_status(client) - + if status_ok: # 测试预览 / Test preview preview_ok = await test_preview(client) - + # 测试直方图 / Test histogram histogram_ok = await test_histogram(client) - + print("\n🎉 诊断完成!") print(f"相机状态: {'✅ 正常' if status_ok else '❌ 异常'}") print(f"预览功能: {'✅ 正常' if preview_ok else '❌ 异常'}") print(f"直方图功能: {'✅ 正常' if histogram_ok else '❌ 异常'}") - + if status_ok and preview_ok: print("\n✅ 相机系统运行正常!") if histogram_ok: @@ -253,5 +263,6 @@ async def main(): print("2. 系统服务状态: sudo systemctl status ogscope") print("3. 服务日志: sudo journalctl -u ogscope -f") + if __name__ == "__main__": asyncio.run(main()) diff --git a/scripts/ensure-ogscope-systemd-network-env.sh b/scripts/ensure-ogscope-systemd-network-env.sh new file mode 100755 index 0000000..dbcc878 --- /dev/null +++ b/scripts/ensure-ogscope-systemd-network-env.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# 为已存在 network.env 的老部署补 systemd drop-in,使 ogscope 加载 OGSCOPE_* 变量 +# Install systemd drop-in for existing deployments so ogscope loads network.env +# +# 用法 / Usage: +# sudo ./scripts/ensure-ogscope-systemd-network-env.sh +# +# 等价于 / Equivalent: sudo ./scripts/ogscope-network-init.sh ensure-systemd + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${EUID}" -ne 0 ]]; then + exec sudo "${SCRIPT_DIR}/ogscope-network-init.sh" ensure-systemd +fi +exec "${SCRIPT_DIR}/ogscope-network-init.sh" ensure-systemd diff --git a/scripts/install.sh b/scripts/install.sh index 2e15f5a..1dc0ba3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,11 +1,15 @@ #!/bin/bash # OGScope 安装脚本 / OGScope installation script -# 适用于 Raspberry Pi / Orange Pi 等嵌入式板 / For Raspberry Pi, Orange Pi, etc. +# 适用于 Raspberry Pi Zero 2W 等嵌入式板 / For Raspberry Pi Zero 2W, etc. # # 环境变量 / Environment: # OGSCOPE_INSTALL_DEV=1 — 安装含 dev 依赖(开发机);默认仅 main / Install dev deps; default main only -# OGSCOPE_APT_SLOW=1 — 分批安装 apt 包并在批次间暂停,减轻低配板内存压力 / Stagger apt for low-memory boards +# OGSCOPE_APT_SLOW=0|1|未设置 — 未设置且内存≤1GB 时自动开;1 强制开;0 强制关 / Auto on if RAM≤1GB; 1=force; 0=disable +# OGSCOPE_SKIP_OPENCV_APT=1 — 不 apt 安装 libopencv-dev(减轻 OOM;OpenCV 由 pip opencv-python-headless 提供)/ Skip libopencv-dev to avoid OOM # OGSCOPE_MIRROR=auto|cn|international — 软件源:auto 按语言/时区启发;中国大陆建议 cn 或保持 auto / Mirrors for CN vs abroad +# OGSCOPE_SKIP_NETWORK_BOOT=1 — 不安装开机 WiFi 引导单元 / Skip ogscope-network-boot.service +# OGSCOPE_SKIP_PLATE_DB=1 — 不自动复制 default_database.npz 到 data/plate_solve/ / Skip Tetra3 pattern DB copy +# OGSCOPE_FORCE_PLATE_DB=1 — 若目标已存在仍覆盖 / Overwrite data/plate_solve/default_database.npz if present # OGSCOPE_POETRY_INSTALLER_URL — 可选,覆盖 Poetry 引导脚本 URL(国内可自建镜像)/ Optional Poetry bootstrap URL mirror set -euo pipefail @@ -53,11 +57,25 @@ if [ "${OGSCOPE_MIRROR_RESOLVED}" = "cn" ]; then ogscope_apply_apt_mirror_cn fi -# 低配板可选在 apt 批次间暂停 / Optional pause between apt batches on low-RAM boards +# 低配板在 apt 批次间暂停,减轻 OOM(apt/dpkg 解压时)/ Pause between apt batches to reduce OOM risk +_mem_kb="$(grep '^MemTotal:' /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 9999999)" +if [ "${OGSCOPE_APT_SLOW:-}" = "1" ]; then + _apt_effective_slow="1" +elif [ "${OGSCOPE_APT_SLOW:-}" = "0" ]; then + _apt_effective_slow="0" +else + if [ "${_mem_kb}" -lt 1048576 ]; then + _apt_effective_slow="1" + echo "ℹ️ MemTotal≈$((_mem_kb / 1024)) MiB — 已自动启用 apt 分批间隔(关闭:OGSCOPE_APT_SLOW=0)/ Auto staggered apt (disable: OGSCOPE_APT_SLOW=0)" + else + _apt_effective_slow="0" + fi +fi + _apt_pause() { - if [ "${OGSCOPE_APT_SLOW:-}" = "1" ]; then - echo "⏳ 等待 3s 释放内存... / Waiting to free memory..." - sleep 3 + if [ "${_apt_effective_slow}" = "1" ]; then + echo "⏳ 等待 4s 释放内存... / Waiting to free memory..." + sleep 4 fi } @@ -73,18 +91,28 @@ sudo apt install -y \ python3-dev \ git \ curl \ - build-essential + build-essential \ + network-manager \ + avahi-daemon _apt_pause -echo "📦 安装图像与开发库 / Installing image and dev libraries..." +echo "📦 安装图像基础库(jpeg/png/freetype)/ Installing image base dev libraries..." sudo apt install -y \ - libopencv-dev \ libjpeg-dev \ libpng-dev \ libfreetype6-dev _apt_pause -# 树莓派常见;Orange Pi 若无此包可忽略 / Raspberry Pi; skip if unavailable on Orange Pi +if [ "${OGSCOPE_SKIP_OPENCV_APT:-}" = "1" ]; then + echo "ℹ️ 已跳过 apt 安装 libopencv-dev(OGSCOPE_SKIP_OPENCV_APT=1);OpenCV 由 pip opencv-python-headless 提供 / Skipped libopencv-dev; pip provides OpenCV" +else + echo "📦 安装 libopencv-dev(依赖多;若进程被 Killed 多为 OOM,可重试并加 OGSCOPE_SKIP_OPENCV_APT=1 或 swap)" + echo "📦 Installing libopencv-dev (--no-install-recommends); if Killed (OOM), retry with OGSCOPE_SKIP_OPENCV_APT=1 or add swap" + sudo apt install -y --no-install-recommends libopencv-dev +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" @@ -169,6 +197,8 @@ fi echo "📁 创建数据目录 / Creating data directories..." mkdir -p logs data uploads data/plate_solve data/analysis +ogscope_sync_plate_solve_database_if_needed "${PROJECT_DIR}" + # systemd 注入 PYTHONPATH,便于 venv 内 import apt 安装的包 / PYTHONPATH for apt-installed packages in venv PY_PATHS=() [ -d "/usr/lib/python3/dist-packages" ] && PY_PATHS+=("/usr/lib/python3/dist-packages") @@ -198,7 +228,7 @@ echo "⚙️ 写入 systemd: ${SERVICE_PATH}" sudo tee "${SERVICE_PATH}" >/dev/null </dev/null || true + echo "🌐 运行网络初始化(需 sudo)/ Running network bootstrap..." + sudo env OGSCOPE_SERVICE_USER="${USER}" "${SCRIPT_DIR}/ogscope-network-init.sh" init --yes \ + || echo "⚠️ 网络初始化失败,可稍后 sudo ${SCRIPT_DIR}/ogscope-network-init.sh diag / network init failed" +fi + +BOOT_UNIT="ogscope-network-boot" +BOOT_UNIT_PATH="/etc/systemd/system/${BOOT_UNIT}.service" +if [ "${OGSCOPE_SKIP_NETWORK_BOOT:-}" = "1" ]; then + echo "⏭️ 跳过开机网络引导单元(OGSCOPE_SKIP_NETWORK_BOOT=1)/ Skipping ${BOOT_UNIT}.service" +else + chmod +x "${SCRIPT_DIR}/ogscope-network-boot.sh" 2>/dev/null || true + echo "⚙️ 写入开机 WiFi 引导(root oneshot)/ Writing ${BOOT_UNIT}.service..." + sudo tee "${BOOT_UNIT_PATH}" >/dev/null </dev/null || true +fi echo "" echo "======================================" @@ -228,12 +296,14 @@ echo "虚拟环境 / venv: ${VENV_PATH}" echo "PYTHONPATH: ${PYTHONPATH_VALUE}" echo "LD_LIBRARY_PATH: ${LD_LIBRARY_PATH_VALUE}" echo "" -echo "请将 default_database.npz 放到 data/plate_solve/(星图解算)" -echo "Place default_database.npz under data/plate_solve/ for plate solving" +ogscope_report_plate_solve_database_status "${PROJECT_DIR}" echo "" echo "下一步 / Next:" echo " sudo systemctl start ${SERVICE_NAME}" echo " sudo systemctl status ${SERVICE_NAME}" echo " sudo journalctl -u ${SERVICE_NAME} -f" +if [ "${OGSCOPE_SKIP_NETWORK_BOOT:-}" != "1" ]; then + echo " 开机 WiFi 引导日志 / Boot WiFi: sudo journalctl -u ${BOOT_UNIT} -b" +fi echo " 日常更新可运行: ./scripts/board-update.sh" echo "" diff --git a/scripts/mirror.sh b/scripts/mirror.sh index 7deea83..9ba3033 100644 --- a/scripts/mirror.sh +++ b/scripts/mirror.sh @@ -50,7 +50,7 @@ ogscope_is_debian_family() { # 安装脚本入口:非 Debian 系则退出,避免误改软件源 / Abort install on non-Debian systems (safety) ogscope_require_debian_family_apt() { if ! ogscope_is_debian_family; then - echo "❌ 本脚本仅支持 Debian/Ubuntu 系发行版(含 Raspberry Pi OS、Orange Pi Debian 镜像)。" >&2 + echo "❌ 本脚本仅支持 Debian/Ubuntu 系发行版(含 Raspberry Pi OS)。" >&2 echo "❌ This installer only supports Debian/Ubuntu family (incl. Raspberry Pi OS, Armbian Debian)." >&2 echo " 当前 ID=${OGSCOPE_OS_ID:-?} ID_LIKE=${OGSCOPE_OS_ID_LIKE:-?} / Current OS ID shown above." >&2 return 1 @@ -208,3 +208,177 @@ ogscope_sync_systemd_execstart_if_needed() { sudo sed -i "s|^ExecStart=.*|${expected_line}|" "${unit_path}" echo "✅ 已更新 ${unit_path} / Unit updated" } + +# 若项目目录变更,同步 ogscope-network-boot.service 的 ExecStart(与 install.sh 一致) +# If project path changed, sync ExecStart in ogscope-network-boot.service (matches install.sh) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +ogscope_sync_network_boot_unit_if_needed() { + local project_dir="${1:?}" + local unit_path="/etc/systemd/system/ogscope-network-boot.service" + local script_path="${project_dir}/scripts/ogscope-network-boot.sh" + local expected_line="ExecStart=${script_path}" + + if [ ! -f "${unit_path}" ]; then + echo "ℹ️ 未找到 ${unit_path},跳过开机网络 boot 同步(未安装或已移除)/ No boot unit; skip sync" + return 0 + fi + if [ ! -f "${script_path}" ]; then + echo "⚠️ 项目内缺少 ${script_path},跳过 ExecStart 同步 / Script missing; skip ExecStart sync" >&2 + return 0 + fi + + local cur_line + cur_line="$(grep '^ExecStart=' "${unit_path}" | head -n1 || true)" + if [ -z "${cur_line}" ]; then + echo "⚠️ ${unit_path} 中无 ExecStart / No ExecStart in unit" >&2 + return 0 + fi + + if [ "${cur_line}" = "${expected_line}" ]; then + echo "✅ ogscope-network-boot ExecStart 与当前项目目录一致 / Boot unit ExecStart matches project" + return 0 + fi + + echo "⚙️ 修正 ogscope-network-boot ExecStart(项目目录可能已变更)/ Fixing boot unit ExecStart" + echo " 旧 / Old: ${cur_line}" + echo " 新 / New: ${expected_line}" + sudo sed -i "s|^ExecStart=.*|${expected_line}|" "${unit_path}" + echo "✅ 已更新 ${unit_path} / Unit updated" +} + +# 解析 vendored tetra3 内 default_database.npz 路径(不依赖 import ogscope,避免路径注入异常) +# Resolve path to default_database.npz in vendored tetra3 (no import ogscope; avoids path edge cases) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +# 标准输出:若存在则打印绝对路径,否则空 / Prints absolute path if file exists, else empty +ogscope_resolve_plate_solve_database_src_from_venv() { + local project_dir="${1:?}" + ( + cd "${project_dir}" && PROJECT_ROOT="${project_dir}" poetry run python - <<'PY' +import os +import sys +from pathlib import Path + +project = Path(os.environ["PROJECT_ROOT"]).resolve() +vendor = project / "ogscope" / "vendor" +if vendor.is_dir(): + sys.path.insert(0, str(vendor)) +import tetra3 # noqa: E402 — after vendor path + +p = Path(tetra3.__file__).resolve().parent / "data" / "default_database.npz" +print(p if p.is_file() else "", end="") +PY + ) +} + +# 将 default_database.npz 复制到 data/plate_solve/(与 solver 优先路径一致) +# Copy default_database.npz into data/plate_solve/ (matches solver resolution order) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +# 环境 / Env: OGSCOPE_SKIP_PLATE_DB=1 跳过;OGSCOPE_FORCE_PLATE_DB=1 覆盖已存在目标 / skip; overwrite dest +ogscope_sync_plate_solve_database_if_needed() { + local project_dir="${1:?}" + local dest="${project_dir}/data/plate_solve/default_database.npz" + local vendor_src="${project_dir}/ogscope/vendor/tetra3/data/default_database.npz" + + if [ "${OGSCOPE_SKIP_PLATE_DB:-}" = "1" ]; then + echo "⏭️ 跳过图案库复制(OGSCOPE_SKIP_PLATE_DB=1)/ Skipping plate DB copy" + return 0 + fi + + mkdir -p "${project_dir}/data/plate_solve" + + if [ -f "${dest}" ] && [ "${OGSCOPE_FORCE_PLATE_DB:-}" != "1" ]; then + echo "ℹ️ 已存在 ${dest},跳过复制(覆盖:OGSCOPE_FORCE_PLATE_DB=1)/ Already present; skip (overwrite: OGSCOPE_FORCE_PLATE_DB=1)" + return 0 + fi + + local src="" + if [ -f "${vendor_src}" ]; then + src="${vendor_src}" + else + src="$(ogscope_resolve_plate_solve_database_src_from_venv "${project_dir}" || true)" + fi + + if [ -z "${src}" ] || [ ! -f "${src}" ]; then + echo "⚠️ 未找到可复制的 default_database.npz(请放入 ogscope/vendor/tetra3/data/ 或手动复制到 data/plate_solve/)" + echo "⚠️ No default_database.npz to copy; see docs/development/plate-solve-data.md" + return 0 + fi + + echo "📋 复制 Tetra3 图案库到 data/plate_solve/default_database.npz ..." + echo "📋 Copying Tetra3 pattern database to data/plate_solve/default_database.npz ..." + cp -a "${src}" "${dest}" + echo "✅ 图案库已就绪 / Pattern database ready: ${dest}" +} + +# 安装/升级后校验 data/plate_solve/default_database.npz 是否存在(主动检查) +# Verify default_database.npz after install/update (explicit check) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +ogscope_report_plate_solve_database_status() { + local project_dir="${1:?}" + local dest="${project_dir}/data/plate_solve/default_database.npz" + + if [ "${OGSCOPE_SKIP_PLATE_DB:-}" = "1" ]; then + echo "ℹ️ 已跳过图案库步骤(OGSCOPE_SKIP_PLATE_DB=1);未校验 ${dest} / Skipped plate DB step" + return 0 + fi + + if [ -f "${dest}" ]; then + local sz + sz="$(du -h "${dest}" 2>/dev/null | awk '{print $1}' || echo "?")" + echo "✅ 星图解算图案库 / Plate DB: data/plate_solve/default_database.npz(${sz})" + echo "✅ Plate DB: data/plate_solve/default_database.npz (${sz})" + return 0 + fi + + echo "⚠️ 星图解算需 default_database.npz,但未在 data/plate_solve/ 找到。" + echo " 请将文件放入 ogscope/vendor/tetra3/data/ 后重装/重跑本脚本,或手动复制到 data/plate_solve/;见 docs/development/plate-solve-data.md" + 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 +# 环境 / Env: OGSCOPE_SKIP_NETWORK_SYNC=1 跳过;需 sudo(免密或交互)/ skip; requires sudo +ogscope_sync_network_board_artifacts_if_needed() { + local project_dir="${1:?}" + local init_script="${project_dir}/scripts/ogscope-network-init.sh" + local switch_src="${project_dir}/scripts/ogscope-wifi-switch.sh" + local switch_dst="/usr/local/bin/ogscope-wifi-switch" + + if [ "${OGSCOPE_SKIP_NETWORK_SYNC:-}" = "1" ]; then + echo "⏭️ 跳过网络工件同步(OGSCOPE_SKIP_NETWORK_SYNC=1)/ Skipping network artifact sync" + return 0 + fi + + chmod +x "${project_dir}/scripts/ogscope-network-boot.sh" 2>/dev/null || true + chmod +x "${init_script}" 2>/dev/null || true + + if [ -f "${switch_src}" ]; then + if [ ! -f "${switch_dst}" ] || ! cmp -s "${switch_src}" "${switch_dst}" 2>/dev/null; then + echo "📋 同步 WiFi 切换脚本 / Syncing WiFi switch script → ${switch_dst} ..." + if sudo -n true 2>/dev/null; then + sudo install -m 755 "${switch_src}" "${switch_dst}" + echo "✅ 已更新 ${switch_dst} / Updated" + else + echo "⚠️ 无法免密 sudo,未更新 ${switch_dst};请手动: sudo install -m 755 ${switch_src} ${switch_dst}" + echo "⚠️ Non-interactive sudo unavailable; install switch script manually (see wifi-nm.md)" + fi + else + echo "✅ ogscope-wifi-switch 已是最新 / WiFi switch script up to date" + fi + fi + + if [ ! -f "/etc/ogscope/network.env" ]; then + echo "ℹ️ 无 /etc/ogscope/network.env,跳过 ensure-systemd(首次部署请运行 install.sh)/ No network.env; skip ensure-systemd" + return 0 + fi + + echo "🌐 同步 systemd network.env 与 nmcli sudoers(ensure-systemd)/ Syncing ensure-systemd ..." + if sudo -n true 2>/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" + 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" + fi +} diff --git a/scripts/ogscope-network-boot.sh b/scripts/ogscope-network-boot.sh new file mode 100755 index 0000000..d9d57af --- /dev/null +++ b/scripts/ogscope-network-boot.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# OGScope 开机无线引导:等待 STA 获 IPv4,失败则切 AP(root / systemd oneshot) +# Boot WiFi: wait for STA IPv4, else bring up AP (root, independent of ogscope.service) +# +# 环境变量 / Environment (optional; defaults match ogscope-network-init): +# OGSCOPE_WIFI_STA_CONNECTION — STA 连接名 / STA profile name +# OGSCOPE_WIFI_AP_CONNECTION — AP 连接名 / AP profile name +# OGSCOPE_WIFI_INTERFACE — 无线接口 / Wireless iface (default wlan0) +# OGSCOPE_BOOT_STA_WAIT_SEC — 首轮等待 STA 获 IPv4 的总秒数 / First wait for IPv4 (default 55) +# OGSCOPE_BOOT_POLL_SEC — 轮询间隔 / Poll interval (default 3) +# OGSCOPE_BOOT_STA_UP_RETRIES — 尝试 connection up STA 次数 / nmcli up STA retries (default 2) +# OGSCOPE_BOOT_POST_UP_WAIT — 每次 up STA 后再等待秒数 / Wait after each up (default 20) + +set -euo pipefail + +ENV_FILE="/etc/ogscope/network.env" +if [[ -r "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1090 + source "${ENV_FILE}" + set +a +fi + +STA="${OGSCOPE_WIFI_STA_CONNECTION:-OGScope-STA}" +AP="${OGSCOPE_WIFI_AP_CONNECTION:-OGScope-AP}" +IFACE="${OGSCOPE_WIFI_INTERFACE:-wlan0}" +WAIT_TOTAL="${OGSCOPE_BOOT_STA_WAIT_SEC:-55}" +POLL="${OGSCOPE_BOOT_POLL_SEC:-3}" +RETRIES="${OGSCOPE_BOOT_STA_UP_RETRIES:-2}" +POST_UP="${OGSCOPE_BOOT_POST_UP_WAIT:-20}" + +log() { logger -t ogscope-network-boot -- "$@" || true; echo "[ogscope-network-boot] $*"; } + +has_usable_ipv4() { + local iface="$1" + local line ip + while IFS= read -r line; do + [[ -z "${line}" || "${line}" == "--" ]] && continue + ip="${line%%/*}" + if [[ "${ip}" =~ ^169\.254\. ]]; then + continue + fi + if [[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + return 0 + fi + done < <(nmcli -g IP4.ADDRESS device show "${iface}" 2>/dev/null || true) + return 1 +} + +wait_iface_and_nmcli() { + local n=0 + while [[ ! -r "/sys/class/net/${IFACE}/address" ]]; do + n=$((n + 1)) + if [[ "${n}" -gt 40 ]]; then + log "timeout waiting for ${IFACE}" + return 1 + fi + sleep 0.5 + done + n=0 + while ! command -v nmcli >/dev/null 2>&1; do + n=$((n + 1)) + if [[ "${n}" -gt 20 ]]; then + log "nmcli not found" + return 1 + fi + sleep 0.5 + done + return 0 +} + +skip_if_other_default_route() { + # 已有默认路由且不在无线口上则跳过(有线已联网)/ Skip if default route is not on wifi + if ip -4 route show default 2>/dev/null | grep -q "dev ${IFACE}"; then + return 1 + fi + if ip -4 route show default 2>/dev/null | grep -q .; then + log "skip: default IPv4 route uses another interface (not ${IFACE})" + return 0 + fi + return 1 +} + +main() { + if ! wait_iface_and_nmcli; then + log "iface/nmcli unavailable; exit" + exit 1 + fi + + if skip_if_other_default_route; then + exit 0 + fi + + local elapsed=0 + while [[ "${elapsed}" -lt "${WAIT_TOTAL}" ]]; do + if has_usable_ipv4 "${IFACE}"; then + log "STA has usable IPv4 on ${IFACE}, done" + exit 0 + fi + sleep "${POLL}" + elapsed=$((elapsed + POLL)) + done + + log "no IPv4 after ${WAIT_TOTAL}s, trying nmcli connection up ${STA}" + local r=0 + while [[ "${r}" -lt "${RETRIES}" ]]; do + r=$((r + 1)) + nmcli connection up "${STA}" ifname "${IFACE}" 2>/dev/null || log "nmcli up ${STA} attempt ${r} failed" + sleep "${POST_UP}" + if has_usable_ipv4 "${IFACE}"; then + log "STA connected after up retry ${r}" + exit 0 + fi + done + + log "fallback: switching to AP ${AP}" + nmcli connection down "${AP}" 2>/dev/null || true + nmcli connection down "${STA}" 2>/dev/null || true + nmcli connection up "${AP}" ifname "${IFACE}" || { + log "failed to bring up AP ${AP}" + exit 1 + } + log "AP ${AP} is up" + exit 0 +} + +main "$@" diff --git a/scripts/ogscope-network-init.sh b/scripts/ogscope-network-init.sh new file mode 100755 index 0000000..478e424 --- /dev/null +++ b/scripts/ogscope-network-init.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +# OGScope 树莓派网络环境初始化(NetworkManager + Avahi) +# Raspberry Pi network bootstrap for OGScope (NM + Avahi) +# +# 用法 / Usage: +# sudo ./ogscope-network-init.sh init [--yes] +# sudo ./ogscope-network-init.sh diag +# sudo ./ogscope-network-init.sh ensure-systemd +# sudo ./ogscope-network-init.sh reset [--yes] +# +# 环境 / Environment: +# OGSCOPE_SKIP_NETWORK_INIT — install.sh 可跳过 / skip when set by installer + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +STA_NAME="OGScope-STA" +AP_NAME="OGScope-AP" +IFACE="${OGSCOPE_WIFI_INTERFACE:-wlan0}" +AP_IPV4="192.168.4.1/24" +AP_PSK="ogscopeadmin" + +STATE_DIR="/var/lib/ogscope" +ENV_DIR="/etc/ogscope" +ENV_FILE="${ENV_DIR}/network.env" +ID_FILE="${STATE_DIR}/network-id.txt" +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" +# 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" + +die() { echo "❌ $*" >&2; exit 1; } +info() { echo "ℹ️ $*"; } +ok() { echo "✅ $*"; } + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + die "请使用 root 或 sudo 运行 / Run as root or sudo" + fi +} + +# wlan0 MAC 取后 4 位十六进制作为设备后缀 / Last 4 hex chars from wlan0 MAC +compute_suffix() { + local mac hex + if [[ ! -r "/sys/class/net/${IFACE}/address" ]]; then + die "未找到 ${IFACE},请连接网卡或设置 OGSCOPE_WIFI_INTERFACE / Interface not found" + fi + mac="$(tr -d ' \n' <"/sys/class/net/${IFACE}/address")" + hex="${mac//:/}" + if [[ ${#hex} -lt 4 ]]; then + die "MAC 地址无效 / Invalid MAC: ${mac}" + fi + echo "${hex: -4}" | tr '[:upper:]' '[:lower:]' +} + +ensure_dirs() { + mkdir -p "${STATE_DIR}" "${ENV_DIR}" + chmod 755 "${STATE_DIR}" "${ENV_DIR}" 2>/dev/null || true +} + +install_switch_script() { + if [[ ! -f "${SWITCH_SRC}" ]]; then + die "未找到 ${SWITCH_SRC} / Switch script missing" + fi + install -m 755 "${SWITCH_SRC}" "${SWITCH_DST}" + ok "已安装 ${SWITCH_DST}" +} + +write_sudoers() { + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" + if [[ -z "${run_user}" ]]; then + info "未设置 OGSCOPE_SERVICE_USER/SUDO_USER,跳过 sudoers(可手动添加 NOPASSWD ${SWITCH_DST})" + return 0 + fi + umask 077 + echo "${run_user} ALL=(ALL) NOPASSWD: ${SWITCH_DST}" >"${SUDOERS_D}.tmp" + chmod 440 "${SUDOERS_D}.tmp" + mv "${SUDOERS_D}.tmp" "${SUDOERS_D}" + ok "已写入 ${SUDOERS_D}(用户 ${run_user})" +} + +# Web API 直接调用 nmcli 时需免密,否则 polkit 报 Not authorized / NOPASSWD nmcli for API +write_sudoers_nmcli() { + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" + local nmcli_bin + nmcli_bin="$(command -v nmcli 2>/dev/null || true)" + if [[ -z "${run_user}" ]]; then + info "未设置 OGSCOPE_SERVICE_USER/SUDO_USER,跳过 nmcli sudoers / Skipping nmcli sudoers" + return 0 + fi + if [[ -z "${nmcli_bin}" ]]; then + info "未找到 nmcli,跳过 ogscope-nmcli sudoers / nmcli not found" + return 0 + fi + umask 077 + echo "${run_user} ALL=(ALL) NOPASSWD: ${nmcli_bin}" >"${SUDOERS_NMCLI}.tmp" + chmod 440 "${SUDOERS_NMCLI}.tmp" + mv "${SUDOERS_NMCLI}.tmp" "${SUDOERS_NMCLI}" + ok "已写入 ${SUDOERS_NMCLI}(免密 ${nmcli_bin},Web「激活」已保存 WiFi 等)" +} + +write_network_env() { + local suffix="$1" + umask 077 + cat >"${ENV_FILE}.tmp" </dev/null; then + grep -vE '^127\.0\.1\.1[[:space:]]' "${hosts}" >"${hosts}.ogscope.tmp" \ + && mv "${hosts}.ogscope.tmp" "${hosts}" + fi + printf '127.0.1.1\t%s\n' "${host}" >>"${hosts}" + ok "已同步 ${hosts}:127.0.1.1 -> ${host}" +} + +# 写入 systemd drop-in,使 ogscope 进程加载 network.env(与 install.sh 主 unit 等价) +# Install systemd drop-in so ogscope loads network.env (same as install.sh main unit) +ensure_ogscope_systemd_network_env() { + if ! command -v systemctl >/dev/null 2>&1; then + info "无 systemctl,跳过 drop-in / No systemctl; skipping drop-in" + return 0 + fi + mkdir -p "${SYSTEMD_DROPIN_DIR}" + umask 022 + cat >"${SYSTEMD_NETWORK_ENV_CONF}.tmp" <<'EOF' +[Service] +EnvironmentFile=-/etc/ogscope/network.env +EOF + mv "${SYSTEMD_NETWORK_ENV_CONF}.tmp" "${SYSTEMD_NETWORK_ENV_CONF}" + chmod 644 "${SYSTEMD_NETWORK_ENV_CONF}" + systemctl daemon-reload + ok "已写入 ${SYSTEMD_NETWORK_ENV_CONF} 并 systemctl daemon-reload" +} + +create_nm_connections() { + local suffix="$1" + local ssid="OGScope_${suffix}" + + if ! command -v nmcli >/dev/null 2>&1; then + die "未安装 nmcli,请 apt install network-manager / nmcli not found" + fi + + # 删除同名旧连接(若存在)/ Remove stale connections + nmcli connection delete "${AP_NAME}" 2>/dev/null || true + nmcli connection delete "${STA_NAME}" 2>/dev/null || true + + # AP:热点 + 共享 IPv4(dnsmasq DHCP,避免客户端仅 169.254)/ Shared IPv4 + DHCP for clients + # 仅 WPA2(RSN+CCMP),避免部分系统提示弱加密 / WPA2-only to avoid weak-security warnings + nmcli connection add \ + type wifi \ + ifname "${IFACE}" \ + con-name "${AP_NAME}" \ + autoconnect no \ + wifi.mode ap \ + wifi.ssid "${ssid}" \ + wifi-sec.key-mgmt wpa-psk \ + wifi-sec.psk "${AP_PSK}" \ + wifi-sec.proto rsn \ + wifi-sec.pairwise ccmp \ + wifi-sec.group ccmp \ + ipv4.method shared \ + ipv4.addresses "${AP_IPV4}" \ + ipv6.method ignore + + # STA:占位 SSID(开放),供后续 API 改为 WPA / Placeholder STA; API sets WPA-PSK + nmcli connection add \ + type wifi \ + ifname "${IFACE}" \ + con-name "${STA_NAME}" \ + autoconnect no \ + wifi.mode infrastructure \ + wifi.ssid "__ogscope_sta_pending__" \ + wifi-sec.key-mgmt none \ + ipv4.method auto \ + ipv6.method ignore + + ok "已创建 NM 连接 ${AP_NAME}(SSID ${ssid})与 ${STA_NAME}" +} + +set_hostname_avahi() { + local suffix="$1" + local host="ogscope-${suffix}" + if command -v hostnamectl >/dev/null 2>&1; then + hostnamectl set-hostname "${host}" || info "hostnamectl 失败,可忽略 / hostnamectl failed" + fi + sync_hosts_for_hostname "${host}" + if command -v avahi-daemon >/dev/null 2>&1; then + systemctl enable avahi-daemon 2>/dev/null || true + systemctl restart avahi-daemon 2>/dev/null || true + ok "Avahi 已启用;尝试访问 http://${host}.local / Avahi enabled" + else + info "未安装 avahi-daemon,mDNS 不可用 / Install avahi-daemon for .local" + fi +} + +cmd_init() { + local yes_flag="${1:-}" + require_root + ensure_dirs + + local suffix + if [[ -f "${ID_FILE}" ]]; then + suffix="$(tr -d ' \n' <"${ID_FILE}")" + info "复用已保存后缀 / Reusing suffix: ${suffix}" + else + suffix="$(compute_suffix)" + echo "${suffix}" >"${ID_FILE}" + chmod 644 "${ID_FILE}" + ok "新建设备后缀 / Device suffix: ${suffix}" + fi + + if [[ "${yes_flag}" != "--yes" ]]; then + echo "将创建 SSID: OGScope_${suffix},密码: ${AP_PSK}" + read -r -p "继续? [y/N] " confirm + if [[ ! "${confirm}" =~ ^[yY]$ ]]; then + die "已取消 / Aborted" + fi + fi + + # 重建 wlan0 的 NM 连接会中断当前 WiFi(含经 WiFi 的 SSH)/ Recreating NM WiFi drops link (SSH over Wi-Fi included) + info "⚠️ 若当前经 WiFi 连接本机(含 SSH),下面步骤会重建无线配置,SSH 可能立即断开;请优先用有线网口、串口或本地控制台执行。" + info " If connected via Wi-Fi (including SSH), the next steps may drop this session; prefer Ethernet, serial, or local console." + + install_switch_script + create_nm_connections "${suffix}" + write_network_env "${suffix}" + ensure_ogscope_systemd_network_env + write_sudoers + write_sudoers_nmcli + set_hostname_avahi "${suffix}" + + ok "init 完成。请 systemctl restart ogscope 并连接热点 OGScope_${suffix} / init done" +} + +cmd_ensure_systemd() { + require_root + if [[ ! -f "${ENV_FILE}" ]]; then + die "缺少 ${ENV_FILE},请先 init / Missing network.env; run init first" + fi + # 与 init 一致:补 127.0.1.1,减轻 sudo unable to resolve host(同次 sudo 首行仍可能先警告一次) + # Match init: fix 127.0.1.1 for sudo; first sudo line may still warn before this runs + local hn="" + if [[ -f "${ID_FILE}" ]]; then + hn="ogscope-$(tr -d ' \n' <"${ID_FILE}")" + fi + if [[ -z "${hn}" ]]; then + hn="$(hostname 2>/dev/null | tr -d ' \n' || true)" + fi + if [[ -n "${hn}" ]]; then + sync_hosts_for_hostname "${hn}" + fi + ensure_ogscope_systemd_network_env + write_sudoers_nmcli + info "请执行: sudo systemctl restart ogscope / Please run: sudo systemctl restart ogscope" +} + +cmd_diag() { + info "=== OGScope 网络诊断 / Network diagnostics ===" + command -v nmcli >/dev/null && ok "nmcli: OK" || echo "❌ nmcli 缺失" + [[ -r "/sys/class/net/${IFACE}/address" ]] && ok "${IFACE} 存在" || echo "❌ ${IFACE} 不存在" + [[ -f "${ENV_FILE}" ]] && ok "network.env 存在: ${ENV_FILE}" || echo "⚠️ 无 ${ENV_FILE}" + [[ -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)" + 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}" + nmcli connection show "${STA_NAME}" >/dev/null 2>&1 && ok "连接 ${STA_NAME} 存在" || echo "⚠️ 无 ${STA_NAME}" + if nmcli connection show "${AP_NAME}" >/dev/null 2>&1; then + local ap_method + ap_method="$(nmcli -g ipv4.method connection show "${AP_NAME}" 2>/dev/null | head -n1 || true)" + if [[ "${ap_method}" == "manual" ]]; then + echo "⚠️ ${AP_NAME} 为 ipv4.method manual:客户端可能仅有 169.254,无法访问 192.168.4.1" + echo " 请执行: sudo ${SCRIPT_DIR}/$(basename "$0") init --yes(重建 AP 为 shared + DHCP)" + echo " Or: sudo ${SCRIPT_DIR}/$(basename "$0") init --yes to recreate AP (shared + DHCP)" + fi + fi + fi + if [[ -f "${ID_FILE}" ]]; then + info "后缀 / Suffix: $(cat "${ID_FILE}")" + fi + if command -v systemctl >/dev/null 2>&1 && systemctl cat ogscope >/dev/null 2>&1; then + if systemctl cat ogscope 2>/dev/null | grep -qE 'EnvironmentFile=.*etc/ogscope/network\.env'; then + ok "ogscope.service 已加载 network.env(主 unit 或 drop-in)" + else + echo "⚠️ ogscope 未引用 /etc/ogscope/network.env;API 可能显示 wifi_not_configured" + echo " 可执行: sudo ${0} ensure-systemd && sudo systemctl restart ogscope" + fi + else + info "无 ogscope systemd 单元或未安装 systemctl / No ogscope unit or no systemctl" + fi + local hn + hn="$(hostname 2>/dev/null || true)" + if [[ -n "${hn}" ]] && grep -qE "^127\.0\.1\.1[[:space:]].*${hn}" /etc/hosts 2>/dev/null; then + ok "/etc/hosts 含 127.0.1.1 -> ${hn}" + elif [[ -n "${hn}" ]]; then + echo "⚠️ /etc/hosts 可能缺少 127.0.1.1 ${hn},sudo 或提示 unable to resolve host" + echo " 可重新 init 或手动编辑 /etc/hosts" + fi +} + +cmd_reset() { + local yes_flag="${1:-}" + require_root + if [[ "${yes_flag}" != "--yes" ]]; then + read -r -p "将删除 ${AP_NAME}、${STA_NAME} 与 ${ENV_FILE},确认? [y/N] " confirm + if [[ ! "${confirm}" =~ ^[yY]$ ]]; then + die "已取消" + fi + fi + nmcli connection delete "${AP_NAME}" 2>/dev/null || true + nmcli connection delete "${STA_NAME}" 2>/dev/null || true + rm -f "${ENV_FILE}" + ok "reset 完成 / reset done" +} + +main() { + local sub="${1:-}" + shift || true + case "${sub}" in + init) cmd_init "${1:-}" ;; + diag) cmd_diag ;; + ensure-systemd) cmd_ensure_systemd ;; + reset) cmd_reset "${1:-}" ;; + *) + echo "Usage: sudo $0 init [--yes] | diag | ensure-systemd | reset [--yes]" >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/ogscope-wifi-switch.sh b/scripts/ogscope-wifi-switch.sh new file mode 100755 index 0000000..1a0fe8c --- /dev/null +++ b/scripts/ogscope-wifi-switch.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# OGScope WiFi 模式切换(NetworkManager / nmcli) +# OGScope WiFi mode switch (NetworkManager) — STA (client) vs AP (hotspot) +# +# 用法 / Usage: +# ogscope-wifi-switch.sh status|ap|sta +# +# 环境变量(与 systemd 或 .env 中 OGSCOPE_* 对齐)/ Environment (match OGSCOPE_* in systemd/.env): +# OGSCOPE_WIFI_STA_CONNECTION — STA 连接名(连路由器)/ connection name for DHCP client mode +# OGSCOPE_WIFI_AP_CONNECTION — AP 热点连接名 / connection name for access point mode +# OGSCOPE_WIFI_INTERFACE — 无线接口,默认 wlan0 / wireless iface, default wlan0 +# +# 部署 / Deployment: +# sudo install -m 755 scripts/ogscope-wifi-switch.sh /usr/local/bin/ogscope-wifi-switch +# sudoers(将 %USER% 与路径替换为实际值)/ sudoers example: +# %USER% ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-wifi-switch +# +# 首次需在板上用 nmcli 创建两个 Profile(STA 与 AP),名称与上述变量一致。 +# Create both profiles once on the board with nmcli; names must match the variables above. + +set -euo pipefail + +# sudo 默认不传子进程环境(且 many sudoers 禁止 sudo -E),从 network.env 补全(与 systemd 同源) +# sudo often strips env; sudoers may reject -E — load network.env (same as systemd EnvironmentFile) +_DEFAULT_ENV="/etc/ogscope/network.env" +if [[ -r "${_DEFAULT_ENV}" ]] && { + [[ -z "${OGSCOPE_WIFI_STA_CONNECTION:-}" ]] || [[ -z "${OGSCOPE_WIFI_AP_CONNECTION:-}" ]]; +}; then + set -a + # shellcheck disable=SC1090 + source "${_DEFAULT_ENV}" + set +a +fi + +SCRIPT_NAME="$(basename "$0")" +CMD="${1:-}" + +STA="${OGSCOPE_WIFI_STA_CONNECTION:-}" +AP="${OGSCOPE_WIFI_AP_CONNECTION:-}" +IFACE="${OGSCOPE_WIFI_INTERFACE:-wlan0}" + +if ! command -v nmcli >/dev/null 2>&1; then + echo "ERROR=nmcli_not_found" >&2 + exit 127 +fi + +if [[ -z "$STA" || -z "$AP" ]]; then + echo "ERROR=missing_env_OGSCOPE_WIFI_STA_CONNECTION_or_OGSCOPE_WIFI_AP_CONNECTION" >&2 + exit 2 +fi + +# 当前活动连接名(指定接口)/ Active connection name for the wireless interface +_active_connection() { + nmcli -t -f GENERAL.CONNECTION device show "$IFACE" 2>/dev/null | sed -n 's/^GENERAL.CONNECTION://p' | head -n1 || true +} + +# AP 配置的 IPv4 地址(用于 status 展示)/ IPv4 addresses from AP profile +_ap_ipv4() { + nmcli -g ipv4.addresses connection show "$AP" 2>/dev/null | head -n1 || true +} + +case "$CMD" in +status) + ACTIVE="$(_active_connection)" + MODE="unknown" + if [[ "$ACTIVE" == "$AP" ]]; then + MODE="ap" + elif [[ "$ACTIVE" == "$STA" ]]; then + MODE="sta" + elif [[ -z "$ACTIVE" || "$ACTIVE" == "--" ]]; then + MODE="unknown" + fi + echo "MODE=${MODE}" + echo "ACTIVE_CONNECTION=${ACTIVE:-}" + echo "WIRELESS_INTERFACE=${IFACE}" + echo "STA_CONNECTION=${STA}" + echo "AP_CONNECTION=${AP}" + if [[ "$MODE" == "ap" ]]; then + echo "AP_IPV4=$(_ap_ipv4)" + else + echo "AP_IPV4=" + fi + ;; +ap) + nmcli connection down "$STA" 2>/dev/null || true + nmcli connection up "$AP" ifname "$IFACE" + ;; +sta) + nmcli connection down "$AP" 2>/dev/null || true + nmcli connection up "$STA" ifname "$IFACE" + ;; +*) + echo "Usage: ${SCRIPT_NAME} status|ap|sta" >&2 + exit 1 + ;; +esac diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 16a0ffa..51d69cb 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -1,6 +1,6 @@ #!/bin/bash # OGScope 卸载脚本 / OGScope uninstall script -# 从本机移除 systemd 服务与(可选)项目虚拟环境;不卸载 apt 包与全局 Poetry / Removes service and optional venv; does not remove apt packages or global Poetry +# 从本机移除 systemd 主服务、开机网络单元(若存在)与 drop-in,以及(可选)项目虚拟环境;不卸载 apt 包与全局 Poetry / Removes main service, ogscope-network-boot unit (if any), drop-in, and optional venv; does not remove apt packages or global Poetry # # 环境变量 / Environment: # OGSCOPE_UNINSTALL_CONFIRM=1 — 必须设置,否则脚本退出(防误删)/ Must be set to proceed (safety) @@ -19,6 +19,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" SERVICE_NAME="ogscope" SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service" +BOOT_SERVICE_NAME="ogscope-network-boot" +BOOT_SERVICE_PATH="/etc/systemd/system/${BOOT_SERVICE_NAME}.service" +NETWORK_DROPIN="/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf" echo "======================================" echo " OGScope 卸载 / OGScope uninstall" @@ -34,8 +37,8 @@ fi if [ "${OGSCOPE_UNINSTALL_CONFIRM:-}" != "1" ]; then if [ -t 0 ] && [ -t 1 ]; then echo "" - echo "⚠️ 将停止并移除 systemd 服务 ${SERVICE_NAME},并可选删除 .venv。" - echo "⚠️ Will stop and remove systemd service ${SERVICE_NAME}, and optionally remove .venv." + echo "⚠️ 将停止并移除 systemd 服务 ${SERVICE_NAME}、ogscope-network-boot(若存在)与相关 drop-in,并可选删除 .venv。" + echo "⚠️ Will stop and remove ${SERVICE_NAME}, ogscope-network-boot (if present), related drop-ins, and optionally remove .venv." echo " 数据目录默认保留;设 OGSCOPE_UNINSTALL_REMOVE_DATA=1 可删除 logs/uploads/data。" echo " Data dirs are kept by default; set OGSCOPE_UNINSTALL_REMOVE_DATA=1 to remove them." echo "" @@ -52,7 +55,7 @@ fi cd "${PROJECT_DIR}" -# 停止并禁用服务 / Stop and disable service +# 停止并禁用主服务 / Stop and disable main service echo "🛑 停止服务 / Stopping service..." sudo systemctl stop "${SERVICE_NAME}" 2>/dev/null || true sudo systemctl disable "${SERVICE_NAME}" 2>/dev/null || true @@ -60,13 +63,26 @@ sudo systemctl disable "${SERVICE_NAME}" 2>/dev/null || true if [ -f "${SERVICE_PATH}" ]; then echo "🗑️ 移除 unit 文件 / Removing unit file: ${SERVICE_PATH}" sudo rm -f "${SERVICE_PATH}" - sudo systemctl daemon-reload - echo "✅ systemd 已更新 / systemd reloaded" else - echo "ℹ️ 未找到 ${SERVICE_PATH},跳过删除 unit / Unit file not found, skipping" - sudo systemctl daemon-reload 2>/dev/null || true + echo "ℹ️ 未找到 ${SERVICE_PATH},跳过删除主 unit / Main unit file not found, skipping" fi +# 开机网络单元与 drop-in(与 install.sh 对应)/ Boot unit and drop-in (matches install.sh) +if [ -f "${BOOT_SERVICE_PATH}" ]; then + echo "🛑 禁用并移除 ${BOOT_SERVICE_NAME}.service ..." + sudo systemctl stop "${BOOT_SERVICE_NAME}.service" 2>/dev/null || true + sudo systemctl disable "${BOOT_SERVICE_NAME}.service" 2>/dev/null || true + sudo rm -f "${BOOT_SERVICE_PATH}" +fi +if [ -f "${NETWORK_DROPIN}" ]; then + echo "🗑️ 移除 systemd drop-in ${NETWORK_DROPIN} ..." + sudo rm -f "${NETWORK_DROPIN}" + sudo rmdir /etc/systemd/system/ogscope.service.d 2>/dev/null || true +fi + +sudo systemctl daemon-reload +echo "✅ systemd 已更新 / systemd reloaded" + # 虚拟环境 / Virtualenv if [ "${OGSCOPE_UNINSTALL_KEEP_VENV:-}" = "1" ]; then echo "ℹ️ 保留 .venv(OGSCOPE_UNINSTALL_KEEP_VENV=1)/ Keeping .venv" diff --git a/tests/conftest.py b/tests/conftest.py index 1d75ab7..60e996b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,6 @@ def temp_debug_dir(monkeypatch, tmp_path: Path): debug_root.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(debug_services, "DEBUG_CAPTURES_DIR", debug_root) - monkeypatch.setattr(debug_services, "camera_instance", None) monkeypatch.setattr(debug_services, "is_recording", False) monkeypatch.setattr(debug_services, "recording_task", None) monkeypatch.setattr(debug_services, "recording_stem", None) @@ -34,10 +33,6 @@ def temp_debug_dir(monkeypatch, tmp_path: Path): monkeypatch.setattr(debug_services, "recording_media_filename", None) monkeypatch.setattr(debug_services, "recording_codec_fourcc", "mp4v") monkeypatch.setattr(debug_services, "recording_container", "MP4") - monkeypatch.setattr(debug_services, "latest_preview_jpeg", None) - monkeypatch.setattr(debug_services, "last_preview_time", None) - monkeypatch.setattr(debug_services, "latest_preview_id", 0) - monkeypatch.setattr(debug_services, "preview_grabber_task", None) return debug_root diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py index 02132ec..f9ecaf1 100644 --- a/tests/unit/test_analysis_api.py +++ b/tests/unit/test_analysis_api.py @@ -423,22 +423,39 @@ def test_analysis_realtime_timeout_releases_gate( client, temp_analysis_dir, mock_plate_solve, monkeypatch, tmp_path: Path ): """超时后门禁释放,后续请求可恢复 / Timeout releases gate for following requests.""" - from ogscope.config import get_settings + from ogscope.config import get_settings as cfg_get_settings from ogscope.web.api.analysis.services import analysis_service image_path = tmp_path / "gate_timeout.jpg" _build_star_image(image_path) - settings = get_settings() - monkeypatch.setattr(settings, "star_analysis_request_timeout_ms", 80, raising=False) - monkeypatch.setattr(settings, "star_analysis_min_interval_ms", 50, raising=False) - monkeypatch.setattr(settings, "star_analysis_max_interval_ms", 20000, raising=False) + base = cfg_get_settings() + # model_copy 保证合法值;直接 setattr 可能被 Pydantic 模型拒绝 / model_copy applies validated values. + low_timeout_settings = base.model_copy( + update={ + "star_analysis_request_timeout_ms": 500, + "star_analysis_min_interval_ms": 500, + "star_analysis_max_interval_ms": 20000, + } + ) + + def _settings_for_timeout_test(): + return low_timeout_settings + + # 须 patch 本模块内名字:「from config import get_settings」不会随 config.get_settings 替换而更新 + # Must patch the name in this module: "from config import get_settings" does not track config replacement. + import ogscope.web.api.analysis.services as analysis_services_mod + + monkeypatch.setattr( + analysis_services_mod, "get_settings", _settings_for_timeout_test + ) gate = analysis_service._realtime_gate_states.get("file_upload") if gate is not None: gate.in_flight = False gate.last_finished_mono = 0.0 def _slow(*_args, **_kwargs): - time.sleep(0.2) + # hard_timeout = max(0.2, 500ms)=0.5s;须明显超过 / Must exceed 0.5s hard timeout. + time.sleep(0.65) return { "frame_index": 0, "status": "MATCH_FOUND", @@ -447,7 +464,7 @@ def _slow(*_args, **_kwargs): "solve_overlay": {}, } - monkeypatch.setattr(analysis_service, "_solve_bgr_to_row", _slow) + monkeypatch.setattr(type(analysis_service), "_solve_bgr_to_row", _slow) with image_path.open("rb") as f: resp = client.post( "/api/analysis/solve/frame_upload", @@ -458,6 +475,10 @@ def _slow(*_args, **_kwargs): data = resp.json() assert data.get("gate_status") == "TIMEOUT_RELEASED" + # 满足 effective_interval_ms(≥500)再发第二请求;勿再 patch time.monotonic(会破坏 asyncio 超时) + # Satisfy effective interval (≥500ms) before second request; do not patch time.monotonic (breaks asyncio timeouts). + time.sleep(0.55) + def _fast(*_args, **_kwargs): return { "frame_index": 0, @@ -467,26 +488,15 @@ def _fast(*_args, **_kwargs): "solve_overlay": {}, } - monkeypatch.setattr(analysis_service, "_solve_bgr_to_row", _fast) - deadline = time.time() + 1.0 - final_status = None - while time.time() < deadline: - with image_path.open("rb") as f: - resp2 = client.post( - "/api/analysis/solve/frame_upload", - files={"file": ("frame.jpg", f, "image/jpeg")}, - data={"payload": json.dumps({"solve_interval_ms": 50})}, - ) - assert resp2.status_code == 200 - final_status = resp2.json().get("gate_status") - if final_status == "SOLVED": - break - if final_status == "SKIPPED_INTERVAL": - time.sleep(0.02) - continue - break - - assert final_status == "SOLVED" + monkeypatch.setattr(type(analysis_service), "_solve_bgr_to_row", _fast) + with image_path.open("rb") as f: + resp2 = client.post( + "/api/analysis/solve/frame_upload", + files={"file": ("frame.jpg", f, "image/jpeg")}, + data={"payload": json.dumps({"solve_interval_ms": 50})}, + ) + assert resp2.status_code == 200 + assert resp2.json().get("gate_status") == "SOLVED" @pytest.mark.unit diff --git a/tests/unit/test_debug_camera_api.py b/tests/unit/test_debug_camera_api.py index 4b2c8aa..3346a4f 100644 --- a/tests/unit/test_debug_camera_api.py +++ b/tests/unit/test_debug_camera_api.py @@ -2,6 +2,7 @@ 调试相机 API 的第二层最小测试网(无真实硬件依赖)。 """ +import numpy as np import pytest @@ -103,10 +104,18 @@ def set_color_mode(self, color_mode): def set_night_mode(self, enabled): return True + def get_video_frame(self): + """与 CameraManager 抓帧路径一致 / Matches CameraManager grabber path.""" + return np.zeros((self.output_height, self.output_width, 3), dtype=np.uint8) + + def capture_image(self): + return self.get_video_frame() + @pytest.fixture def fake_camera_env(monkeypatch, temp_debug_dir): from ogscope.web.api.debug import services as debug_services + from ogscope.web.camera_shared import get_camera_manager camera = FakeCamera() @@ -116,6 +125,7 @@ def _get_camera_instance(): async def _noop(): return None + get_camera_manager().attach_camera_instance(camera) monkeypatch.setattr(debug_services, "get_camera_instance", _get_camera_instance) monkeypatch.setattr( debug_services.DebugCameraService, diff --git a/tests/unit/test_realtime_api.py b/tests/unit/test_realtime_api.py index bd9d90f..80c0ebf 100644 --- a/tests/unit/test_realtime_api.py +++ b/tests/unit/test_realtime_api.py @@ -26,14 +26,22 @@ def get_video_frame(self): def test_realtime_solver_status_endpoints(client, monkeypatch, mock_plate_solve): """测试实时解算启停接口 / Test realtime solver start and stop endpoints.""" from ogscope.web.api.debug import routes as debug_routes + from ogscope.web.camera_shared import CameraManager, get_camera_manager fake_camera = _FakeCamera() + get_camera_manager().attach_camera_instance(fake_camera) monkeypatch.setattr( debug_routes.DebugCameraService, "get_camera_instance", staticmethod(lambda: fake_camera), ) + async def _fake_get_raw_frame(_self): + frame = fake_camera.get_video_frame() + return frame, 1, 0.0 + + monkeypatch.setattr(CameraManager, "get_raw_frame", _fake_get_raw_frame) + start_resp = client.post( "/api/debug/analysis/realtime/start", params={"hint_ra_deg": 15.0, "hint_dec_deg": 85.0}, diff --git a/tests/unit/test_system_wifi_parse.py b/tests/unit/test_system_wifi_parse.py new file mode 100644 index 0000000..83571d8 --- /dev/null +++ b/tests/unit/test_system_wifi_parse.py @@ -0,0 +1,42 @@ +"""WiFi 指标解析:/proc/net/wireless 列顺序 / WiFi metrics column order.""" + +from pathlib import Path + +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 + face | tus | link level noise | nwid crypt frag + wlan0: 0000 45. -50. -256 0 0 0 +""" + + +@pytest.mark.unit +def test_read_wifi_metrics_link_not_status_column( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """第一列为 status(0000),link 在 values[1] / First column is status, not link quality.""" + real_path = Path + + def path_new(arg: str) -> object: + if arg == "/proc/net/wireless": + + class _W: + def exists(self) -> bool: + return True + + def read_text(self, encoding: str | None = None) -> str: + return _WIRELESS_SAMPLE + + return _W() + return real_path(arg) + + monkeypatch.setattr(system_services, "Path", path_new) + svc = SystemInfoService(cache_ttl_seconds=0.0) + q, sig, iface = svc._read_wifi_metrics() + assert iface == "wlan0" + assert sig == -50.0 + assert q is not None + assert abs(float(q) - (45.0 / 70.0) * 100.0) < 0.01 diff --git a/tests/unit/test_wifi_switch.py b/tests/unit/test_wifi_switch.py new file mode 100644 index 0000000..2b65a69 --- /dev/null +++ b/tests/unit/test_wifi_switch.py @@ -0,0 +1,163 @@ +""" +WiFi 切换服务与 API 单元测试 / Unit tests for WiFi switch service and API. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from ogscope.config import Settings +from ogscope.hardware.wifi_switch import WifiSwitchService, _parse_status_output + + +@pytest.mark.unit +def test_parse_status_output() -> None: + """解析脚本状态输出 / Parse key=value status output.""" + text = "\n".join( + [ + "MODE=ap", + "ACTIVE_CONNECTION=OGScope-AP", + "WIRELESS_INTERFACE=wlan0", + "AP_IPV4=192.168.4.1/24", + ] + ) + data = _parse_status_output(text) + assert data["MODE"] == "ap" + assert data["ACTIVE_CONNECTION"] == "OGScope-AP" + assert data["AP_IPV4"] == "192.168.4.1/24" + + +@pytest.mark.unit +def test_wifi_service_not_configured(tmp_path: Path) -> None: + """未配置时返回 unknown / Return unknown when not configured.""" + settings = Settings( + wifi_sta_connection="", + wifi_ap_connection="", + wifi_switch_script=tmp_path / "missing-script", + ) + service = WifiSwitchService(settings) + assert service.is_configured() is False + status = service.get_status() + assert status["MODE"] == "unknown" + assert status["error"] == "wifi_not_configured" + + +@pytest.mark.unit +def test_wifi_service_status_and_switch(monkeypatch, tmp_path: Path) -> None: + """配置后可执行 status/switch / Run status and switch when configured.""" + script = tmp_path / "ogscope-wifi-switch" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + settings = Settings( + wifi_sta_connection="HOME-STA", + wifi_ap_connection="OGScope-AP", + wifi_switch_script=script, + wifi_switch_use_sudo=False, + wifi_interface="wlan0", + ) + service = WifiSwitchService(settings) + + calls: list[list[str]] = [] + + def _fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd[-1] == "status": + return subprocess.CompletedProcess( + cmd, + 0, + stdout="\n".join( + [ + "MODE=sta", + "ACTIVE_CONNECTION=HOME-STA", + "WIRELESS_INTERFACE=wlan0", + "STA_CONNECTION=HOME-STA", + "AP_CONNECTION=OGScope-AP", + ] + ), + stderr="", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", _fake_run) + status = service.get_status() + assert status["MODE"] == "sta" + service.switch("ap") + service.switch("sta") + assert calls[0][-1] == "status" + assert calls[1][-1] == "ap" + assert calls[2][-1] == "sta" + + +@pytest.mark.unit +def test_network_api(client, monkeypatch) -> None: + """网络 API 状态与切换 / Network API status and switch.""" + from ogscope.web.api.network import routes as network_routes + + monkeypatch.setattr( + network_routes.wifi_switch_service, "is_configured", lambda: True + ) + monkeypatch.setattr( + network_routes.wifi_switch_service, + "get_status", + lambda: { + "MODE": "ap", + "ACTIVE_CONNECTION": "OGScope-AP", + "WIRELESS_INTERFACE": "wlan0", + "STA_CONNECTION": "HOME-STA", + "AP_CONNECTION": "OGScope-AP", + "AP_IPV4": "192.168.4.1/24", + }, + ) + monkeypatch.setattr(network_routes.wifi_switch_service, "switch", lambda mode: None) + + response = client.get("/api/network/wifi") + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "ap" + assert data["active_connection"] == "OGScope-AP" + + response2 = client.post("/api/network/wifi", json={"mode": "sta"}) + assert response2.status_code == 200 + + +@pytest.mark.unit +def test_network_wifi_scan_api(client, monkeypatch) -> None: + """WiFi 扫描 API / WiFi scan API.""" + from ogscope.web.api.network import services as net_services + + monkeypatch.setattr( + net_services, + "nmcli_wifi_scan", + lambda iface: ([{"ssid": "Home", "signal": 80, "security": "WPA2"}], None), + ) + + response = client.get("/api/network/wifi/scan") + assert response.status_code == 200 + data = response.json() + assert "networks" in data + assert len(data["networks"]) >= 1 + assert data["networks"][0]["ssid"] == "Home" + + +@pytest.mark.unit +def test_network_profiles_api(client, monkeypatch) -> None: + """WiFi profiles API / WiFi profiles API.""" + from ogscope.web.api.network import services as net_services + + def _fake_profiles(_settings): + return [ + { + "connection_name": "OGScope-STA", + "ssid": "MyWifi", + "autoconnect": True, + } + ] + + monkeypatch.setattr(net_services, "nmcli_wifi_profiles", _fake_profiles) + response = client.get("/api/network/wifi/profiles") + assert response.status_code == 200 + data = response.json() + assert data["profiles"][0]["connection_name"] == "OGScope-STA" diff --git a/web/analysis-ui/camera.html b/web/analysis-ui/camera.html new file mode 100644 index 0000000..f4a0653 --- /dev/null +++ b/web/analysis-ui/camera.html @@ -0,0 +1,12 @@ + + + + + + OGScope 相机调试控制台 + + +
+ + + diff --git a/web/analysis-ui/src/AnalysisLabPage.tsx b/web/analysis-ui/src/AnalysisLabPage.tsx new file mode 100644 index 0000000..2aa876a --- /dev/null +++ b/web/analysis-ui/src/AnalysisLabPage.tsx @@ -0,0 +1,2573 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Database, + Grid3x3, + History, + Home, + Loader2, + RefreshCw, + RotateCcw, + Trash2, + Upload, + ZoomIn, + ZoomOut, + ChevronDown, +} from "lucide-react"; +import { + BatchRun, + debugCaptureFileUrl, + deleteExperimentRecord, + deletePoolUpload, + experimentAssetUrl, + exportExperiments, + fetchDebugFileInfo, + fetchDebugFiles, + fetchExperiments, + fetchLabSettings, + fetchPresets, + fetchSystemInfo, + fetchUploadExperimentCount, + fetchUploadFileInfo, + fetchUploads, + importFromDebug, + replaceTranscodedVideo, + saveExperiment, + saveUserPreset, + solveBatch, + solveFrameFromBlob, + solveImage, + solveVideoFrame, + uploadFile, + uploadFileUrl, + type DebugFileRow, + type LabPublicSettings, + type SolveParams, + type UploadFileRow, +} from "./api"; +import { + drawSolveOverlay, + drawSolveOverlayVideo, + type SolveOverlay, +} from "./drawOverlay"; +import { transcodeAviToMp4 } from "./utils/transcode"; +import { useI18n } from "./i18n/I18nProvider"; +import { buildMetaCaptionRows } from "./utils/metaCaption"; +import { formatDateTime, formatFileSize } from "./utils/format"; +import { + formatAngleDeg, + formatProbLine, + parseSolveResult, +} from "./utils/solveDisplay"; + +type View = "lab_image" | "lab_video" | "pool" | "history"; + +const defaultParams = (): SolveParams => ({ + hint_ra_deg: 45, + hint_dec_deg: 80, + fov_estimate: 11, + fov_max_error: undefined, + solve_timeout_ms: 1500, + solve_profile: "balanced", + max_image_side: 1600, + large_scale_bg_subtract: false, + detail_level: "summary", + centroid: { + sigma: 2.5, + max_area: 400, + min_area: 5, + filtsize: 25, + binary_open: true, + max_axis_ratio: undefined, + }, +}); + +function metricsFromResult(result: Record | null): Record { + if (!result) return {}; + return { + matches: result.matches, + rmse_arcsec: result.rmse_arcsec, + status: result.status, + prob: result.prob, + t_solve_ms: result.t_solve_ms, + }; +} + +function countStarsFromOverlay( + result: Record | null, +): number | null { + if (!result) return null; + const ov = result.solve_overlay as SolveOverlay | undefined; + if (ov?.stars_matched?.length) return ov.stars_matched.length; + if (ov?.stars_all_centroids?.length) return ov.stars_all_centroids.length; + if (typeof result.matches === "number") return result.matches; + return null; +} + +const HISTORY_PAGE_SIZE = 30; +/** 调试控制台素材列表每页条数 / Items per page for debug media list */ +const DEBUG_PAGE_SIZE = 6; + +function isImageAsset(name: string): boolean { + return /\.(jpe?g|png|webp|bmp|gif|fits?)$/i.test(name); +} +function isVideoAsset(name: string): boolean { + return /\.(mp4|mov|webm|mkv|avi)$/i.test(name); +} + +function compactFilename(name: string, head = 16, tail = 14): string { + if (name.length <= head + tail + 1) return name; + return `${name.slice(0, head)}...${name.slice(-tail)}`; +} + +export function AnalysisLabPage() { + const { t, locale, setLocale } = useI18n(); + const [view, setView] = useState("lab_image"); + const [uploads, setUploads] = useState([]); + const [selected, setSelected] = useState(null); + const [params, setParams] = useState(defaultParams); + const [official, setOfficial] = useState< + Array<{ id: string; name: string; params: SolveParams }> + >([]); + const [userPresets, setUserPresets] = useState< + Array<{ id: string; name: string; params: SolveParams }> + >([]); + const [selectedPresetIds, setSelectedPresetIds] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [lastResult, setLastResult] = useState | null>(null); + const [batchPack, setBatchPack] = useState<{ + results: Array>; + } | null>(null); + const [historyQ, setHistoryQ] = useState(""); + const [historyPage, setHistoryPage] = useState(1); + const [historyData, setHistoryData] = useState<{ items: unknown[]; total: number } | null>( + null, + ); + const [historyExpandId, setHistoryExpandId] = useState(null); + const [newPresetName, setNewPresetName] = useState(""); + const [layers, setLayers] = useState({ matched: true, pattern: true, all: true }); + const [debugFiles, setDebugFiles] = useState([]); + const [debugPick, setDebugPick] = useState(null); + const [debugPage, setDebugPage] = useState(1); + const [gridOn, setGridOn] = useState(false); + const [zoom, setZoom] = useState(1); + const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 }); + /** 视频文件素材的像素尺寸 / Video file intrinsic size */ + const [videoNatural, setVideoNatural] = useState({ w: 0, h: 0 }); + /** 相机预览 JPEG 尺寸 / Live camera preview image size */ + const [cameraPreviewNatural, setCameraPreviewNatural] = useState({ w: 0, h: 0 }); + /** 视频台:文件预览或设备相机 / Video lab: pool file vs device camera */ + const [videoPreviewMode, setVideoPreviewMode] = useState<"file" | "camera">("file"); + /** MJPEG 流时间戳参数,与相机调试台 /api/debug/camera/stream 一致 / Same stream URL as debug console */ + const [cameraStreamNonce, setCameraStreamNonce] = useState(() => Date.now()); + const [videoPreviewError, setVideoPreviewError] = useState(null); + const [cameraSolveRunning, setCameraSolveRunning] = useState(false); + const [fileSolveRunning, setFileSolveRunning] = useState(false); + const [autoHoldEnabled, setAutoHoldEnabled] = useState(false); + const [isFrozen, setIsFrozen] = useState(false); + const [frozenFrameId, setFrozenFrameId] = useState(null); + const [frozenImageUrl, setFrozenImageUrl] = useState(null); + const [meta, setMeta] = useState | null>(null); + const [metaLoading, setMetaLoading] = useState(false); + const [batchRawOpen, setBatchRawOpen] = useState>({}); + const [singleFooterRawOpen, setSingleFooterRawOpen] = useState(false); + /** 最近一次请求全链路耗时(含网络与渲染)/ Last request round-trip (network + render) */ + const [lastRoundTripMs, setLastRoundTripMs] = useState(null); + /** 最近一次解算输入来源 / Last solve input source */ + const [lastSolveSource, setLastSolveSource] = useState<"file" | "camera" | null>(null); + /** 实时解算调度提示 / Realtime solve gate hint */ + const [lastGateHint, setLastGateHint] = useState(null); + /** 用户期望解算间隔(毫秒)/ User desired realtime solve interval in ms */ + const [desiredSolveIntervalMs, setDesiredSolveIntervalMs] = useState(null); + const [transcodeBusy, setTranscodeBusy] = useState(false); + const [transcodeProgress, setTranscodeProgress] = useState(null); + const [transcodeHint, setTranscodeHint] = useState(null); + const [debugImportBusy, setDebugImportBusy] = useState(false); + const [debugImportProgress, setDebugImportProgress] = useState(null); + const [debugImportStep, setDebugImportStep] = useState(null); + const [debugImportMessage, setDebugImportMessage] = useState(null); + + const imgRef = useRef(null); + const videoRef = useRef(null); + const cameraPreviewImgRef = useRef(null); + const cameraSolveTimerRef = useRef(null); + const fileSolveTimerRef = useRef(null); + const cameraSolveInFlightRef = useRef(false); + const fileSolveInFlightRef = useRef(false); + const cvRef = useRef(null); + + /** 从当前预览 img 截一帧为 JPEG blob URL(用于冻结与星点同帧)/ Snapshot current preview frame for freeze */ + const captureCameraFrameAsBlobUrl = useCallback(async (): Promise => { + const el = cameraPreviewImgRef.current; + if (!el || el.naturalWidth < 2) return null; + const canvas = document.createElement("canvas"); + canvas.width = el.naturalWidth; + canvas.height = el.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + try { + ctx.drawImage(el, 0, 0); + } catch { + return null; + } + return await new Promise((resolve) => { + canvas.toBlob( + (b) => resolve(b ? URL.createObjectURL(b) : null), + "image/jpeg", + 0.92, + ); + }); + }, []); + const [sysOverview, setSysOverview] = useState(null); + const [labSettings, setLabSettings] = useState(null); + + const intervalMinMs = labSettings?.star_analysis_min_interval_ms ?? 2000; + const intervalMaxMs = labSettings?.star_analysis_max_interval_ms ?? 12000; + const defaultIntervalMs = useMemo(() => { + const fps = labSettings?.star_analysis_target_fps ?? 2 / 3; + const clampedFps = Math.min(Math.max(fps, 0.2), 5.0); + return Math.round(1000 / clampedFps); + }, [labSettings]); + const starAnalysisIntervalMs = useMemo(() => { + const raw = desiredSolveIntervalMs ?? defaultIntervalMs; + return Math.min(Math.max(raw, intervalMinMs), intervalMaxMs); + }, [defaultIntervalMs, desiredSolveIntervalMs, intervalMaxMs, intervalMinMs]); + + const loadLists = useCallback(async () => { + const [u, o, usr] = await Promise.all([ + fetchUploads(), + fetchPresets("official"), + fetchPresets("user"), + ]); + setUploads(u.files); + setOfficial(o.presets as typeof official); + setUserPresets(usr.presets as typeof userPresets); + }, []); + + const loadDebugFiles = useCallback(() => { + fetchDebugFiles() + .then((r) => setDebugFiles(r.files)) + .catch(() => setDebugFiles([])); + }, []); + + useEffect(() => { + fetchLabSettings() + .then((s) => { + setLabSettings(s); + const initialMs = Math.round(1000 / Math.min(Math.max(s.star_analysis_target_fps, 0.2), 5.0)); + const bounded = Math.min( + Math.max(initialMs, s.star_analysis_min_interval_ms), + s.star_analysis_max_interval_ms, + ); + setDesiredSolveIntervalMs(bounded); + }) + .catch(() => setLabSettings(null)); + }, []); + + useEffect(() => { + loadLists().catch((e) => setErr(String(e))); + }, [loadLists]); + + useEffect(() => { + loadDebugFiles(); + }, [loadDebugFiles]); + + useEffect(() => { + if (view !== "history") return; + fetchExperiments(historyQ, historyPage, HISTORY_PAGE_SIZE) + .then(setHistoryData) + .catch((e) => setErr(String(e))); + }, [view, historyQ, historyPage]); + + useEffect(() => { + if (!selected) { + setMeta(null); + return; + } + let cancelled = false; + setMetaLoading(true); + (async () => { + try { + const u = await fetchUploadFileInfo(selected); + if (cancelled) return; + let merged: Record = { ...u }; + try { + const d = await fetchDebugFileInfo(selected); + merged = { ...d, ...u }; + } catch { + /* 仅上传素材时调试目录无同名文件 / No debug file */ + } + setMeta(merged); + } catch { + try { + const d = await fetchDebugFileInfo(selected); + if (!cancelled) setMeta(d); + } catch { + if (!cancelled) setMeta(null); + } + } finally { + if (!cancelled) setMetaLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [selected]); + + /** 切换素材时清空上一张的解算结果,避免误读 / Clear solve state when switching assets */ + useEffect(() => { + setLastResult(null); + setBatchPack(null); + setBatchRawOpen({}); + setSingleFooterRawOpen(false); + setLastRoundTripMs(null); + setLastSolveSource(null); + setVideoPreviewMode("file"); + setVideoPreviewError(null); + }, [selected]); + + const overlay = useMemo(() => { + const r = lastResult?.result as Record | undefined; + if (!r) return null; + const base = (r.solve_overlay || null) as SolveOverlay | null; + if (!base) return null; + const ext = (r.overlay_ext || null) as SolveOverlay["overlay_ext"] | null; + return { ...base, overlay_ext: ext || undefined }; + }, [lastResult]); + + const resultRow = useMemo(() => { + return (lastResult?.result as Record | undefined) ?? null; + }, [lastResult]); + + const starCount = useMemo(() => countStarsFromOverlay(resultRow), [resultRow]); + + /** 主预览区像素尺寸(图片 / 视频文件 / 相机 JPEG)/ Pixel size for metrics panel */ + const previewPixelDims = useMemo(() => { + if (view === "lab_image") return imgNatural; + if (view === "lab_video") { + return videoPreviewMode === "file" ? videoNatural : cameraPreviewNatural; + } + return { w: 0, h: 0 }; + }, [view, videoPreviewMode, imgNatural, videoNatural, cameraPreviewNatural]); + + const previewUrl = selected ? uploadFileUrl(selected) : ""; + + useEffect(() => { + if (view !== "lab_image") return; + const img = imgRef.current; + const cv = cvRef.current; + if (!img || !cv || !overlay || !selected) return; + const draw = () => drawSolveOverlay(cv, img, overlay, layers); + if (img.complete) draw(); + else img.onload = draw; + }, [overlay, selected, lastResult, layers, view]); + + /** 视频文件:解算叠加 / Video file: solve overlay on current frame */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "file") return; + const v = videoRef.current; + const cv = cvRef.current; + if (!v || !cv || !overlay || !selected) return; + const draw = () => drawSolveOverlayVideo(cv, v, overlay, layers); + v.addEventListener("loadeddata", draw); + v.addEventListener("seeked", draw); + if (v.readyState >= 2) draw(); + return () => { + v.removeEventListener("loadeddata", draw); + v.removeEventListener("seeked", draw); + }; + }, [overlay, layers, view, videoPreviewMode, selected, lastResult]); + + /** 设备相机预览:解算叠加在 JPEG 上 / Live camera JPEG + overlay */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") return; + const img = cameraPreviewImgRef.current; + const cv = cvRef.current; + if (!img || !cv || !overlay) return; + const draw = () => drawSolveOverlay(cv, img, overlay, layers); + if (img.complete) draw(); + else img.onload = draw; + }, [overlay, layers, view, videoPreviewMode, lastResult, cameraStreamNonce, isFrozen]); + + /** 进入设备相机模式时重连 MJPEG(与相机调试台同一路径)/ Reconnect MJPEG like debug console */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") return; + setCameraStreamNonce(Date.now()); + }, [view, videoPreviewMode]); + + useEffect(() => { + const img = imgRef.current; + if (!img) return; + const upd = () => + setImgNatural({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 }); + upd(); + img.addEventListener("load", upd); + return () => img.removeEventListener("load", upd); + }, [selected, previewUrl]); + + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const upd = () => + setVideoNatural({ w: v.videoWidth || 0, h: v.videoHeight || 0 }); + v.addEventListener("loadedmetadata", upd); + v.addEventListener("loadeddata", upd); + upd(); + return () => { + v.removeEventListener("loadedmetadata", upd); + v.removeEventListener("loadeddata", upd); + }; + }, [selected, previewUrl, view]); + + const applyPreset = (p: SolveParams) => { + setParams({ + ...defaultParams(), + ...p, + centroid: { ...defaultParams().centroid, ...p.centroid }, + }); + }; + + const onSingleSolve = async () => { + if (!selected) { + setErr(t("err.selectFile")); + return; + } + setErr(null); + setBusy(true); + const t0 = performance.now(); + try { + const out = (await solveImage(selected, params)) as { result?: Record }; + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + setBusy(false); + } + }; + + const onBatch = async () => { + if (!selected) { + setErr(t("err.selectFile")); + return; + } + const runs: BatchRun[] = []; + for (const id of selectedPresetIds) { + const all = [...official, ...userPresets]; + const pr = all.find((x) => x.id === id); + if (pr) + runs.push({ + label: pr.name, + params: structuredClone(pr.params) as SolveParams, + }); + } + if (runs.length === 0) { + setErr(t("err.selectPresets")); + return; + } + setErr(null); + setBusy(true); + const t0 = performance.now(); + try { + const pack = (await solveBatch(selected, runs)) as { results: unknown[] }; + setBatchPack({ + results: pack.results as Array>, + }); + setLastResult(null); + setBatchRawOpen({}); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + setBusy(false); + } + }; + + /** 设备相机当前帧解算(与素材池视频无关)/ Live camera frame solve */ + const onCameraSolve = async () => { + if (cameraSolveInFlightRef.current) return; + setErr(null); + cameraSolveInFlightRef.current = true; + const t0 = performance.now(); + try { + const out = await solveVideoFrame({ + source: "camera", + overlay_topn_count: 3, + enable_polar_guide: true, + solve_interval_ms: starAnalysisIntervalMs, + solve_timeout_ms: Math.min((labSettings?.solver_timeout_ms ?? 1500) * 0.6, 1200), + ...params, + }); + if (out.gate_status && out.gate_status !== "SOLVED") { + setLastGateHint(`${out.gate_status}${out.gate_reason ? `: ${out.gate_reason}` : ""}`); + } else { + setLastGateHint(null); + } + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("camera"); + setVideoPreviewMode("camera"); + const outResult = (out as { result?: Record }).result; + const solveStatus = + typeof outResult?.status === "string" ? String(outResult.status) : ""; + if (autoHoldEnabled && solveStatus === "MATCH_FOUND") { + setIsFrozen(true); + setFrozenFrameId( + (out as { frame_id?: number }).frame_id != null + ? String((out as { frame_id?: number }).frame_id) + : null, + ); + const snap = await captureCameraFrameAsBlobUrl(); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return snap; + }); + stopCameraSolveLoop(); + } + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + cameraSolveInFlightRef.current = false; + } + }; + + const onVideoFileSolve = async () => { + if (fileSolveInFlightRef.current) return; + if (!selected) return; + const vd = videoRef.current; + if (!vd || vd.videoWidth < 2 || vd.videoHeight < 2 || videoPreviewError) return; + setErr(null); + fileSolveInFlightRef.current = true; + const t0 = performance.now(); + try { + const canvas = document.createElement("canvas"); + canvas.width = vd.videoWidth; + canvas.height = vd.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("无法创建画布上下文 / Cannot create canvas context"); + } + ctx.drawImage(vd, 0, 0, canvas.width, canvas.height); + const frameBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error("帧编码失败 / Frame encode failed"))), + "image/jpeg", + 0.92, + ); + }); + const out = await solveFrameFromBlob(frameBlob, { + ...params, + solve_interval_ms: starAnalysisIntervalMs, + solve_timeout_ms: Math.min((labSettings?.solver_timeout_ms ?? 1500) * 0.6, 1200), + overlay_topn_count: 3, + enable_polar_guide: true, + }); + const gateStatus = (out as { gate_status?: string | null }).gate_status; + const gateReason = (out as { gate_reason?: string | null }).gate_reason; + if (gateStatus && gateStatus !== "SOLVED") { + setLastGateHint(`${gateStatus}${gateReason ? `: ${gateReason}` : ""}`); + } else { + setLastGateHint(null); + } + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + fileSolveInFlightRef.current = false; + } + }; + + const startFileSolveLoop = () => { + if (fileSolveRunning || !selected) return; + setFileSolveRunning(true); + void onVideoFileSolve(); + fileSolveTimerRef.current = window.setInterval(() => { + void onVideoFileSolve(); + }, starAnalysisIntervalMs); + }; + + const canSolveVideoFile = useMemo(() => { + if (view !== "lab_video" || videoPreviewMode !== "file") return false; + if (!selected) return false; + if (/\.avi$/i.test(selected)) return false; + if (videoPreviewError) return false; + return videoNatural.w > 1 && videoNatural.h > 1; + }, [view, videoPreviewMode, selected, videoPreviewError, videoNatural.w, videoNatural.h]); + + const isSelectedAvi = useMemo(() => !!selected && /\.avi$/i.test(selected), [selected]); + + const onTranscodeAndUploadAvi = async () => { + if (!selected || !isSelectedAvi || transcodeBusy) return; + setErr(null); + setTranscodeBusy(true); + setTranscodeProgress(0); + setTranscodeHint(t("lab.transcode.loading")); + try { + const srcUrl = uploadFileUrl(selected); + const res = await fetch(srcUrl, { cache: "no-store" }); + if (!res.ok) throw new Error(t("lab.transcode.fetchFailed")); + const blob = await res.blob(); + const aviFile = new File([blob], selected, { type: "video/x-msvideo" }); + const out = await transcodeAviToMp4(aviFile, (ratio, msg) => { + setTranscodeProgress(Math.max(0, Math.min(1, ratio))); + if (msg === "loading_ffmpeg") setTranscodeHint(t("lab.transcode.loading")); + else if (msg === "transcoding") setTranscodeHint(t("lab.transcode.running")); + else if (msg === "packing_output") setTranscodeHint(t("lab.transcode.packaging")); + }); + setTranscodeHint(t("lab.transcode.uploading")); + const upload = await uploadFile(out.file, "analysis_transcoded"); + await replaceTranscodedVideo({ + old_filename: selected, + new_filename: upload.filename, + duration_s: out.duration_s, + nominal_fps: null, + codec_fourcc: "libx264", + container: "MP4", + }); + await loadLists(); + setSelected(upload.filename); + setTranscodeProgress(1); + setTranscodeHint(t("lab.transcode.done")); + } catch (e) { + setErr(String(e)); + setTranscodeHint(t("lab.transcode.failed")); + } finally { + setTranscodeBusy(false); + } + }; + + const transcodePoolAviAndReplace = async (aviFilename: string) => { + const srcUrl = uploadFileUrl(aviFilename); + const res = await fetch(srcUrl, { cache: "no-store" }); + if (!res.ok) throw new Error(t("lab.transcode.fetchFailed")); + const blob = await res.blob(); + const aviFile = new File([blob], aviFilename, { type: "video/x-msvideo" }); + const out = await transcodeAviToMp4(aviFile, (ratio, msg) => { + const base = 0.2; + const span = 0.6; + setDebugImportProgress(base + Math.max(0, Math.min(1, ratio)) * span); + if (msg === "loading_ffmpeg") setDebugImportStep(t("sidebar.flowLoadingTranscoder")); + else if (msg === "transcoding") setDebugImportStep(t("sidebar.flowTranscoding")); + else if (msg === "packing_output") setDebugImportStep(t("sidebar.flowPackaging")); + }); + setDebugImportStep(t("sidebar.flowUploadingMp4")); + setDebugImportProgress(0.86); + const upload = await uploadFile(out.file, "debug_console_transcoded"); + setDebugImportStep(t("sidebar.flowReplacing")); + setDebugImportProgress(0.94); + await replaceTranscodedVideo({ + old_filename: aviFilename, + new_filename: upload.filename, + duration_s: out.duration_s, + nominal_fps: null, + codec_fourcc: "libx264", + container: "MP4", + }); + return upload.filename; + }; + + const runDebugImportFlow = async () => { + if (!debugPick || debugImportBusy) return; + const isAvi = /\.avi$/i.test(debugPick); + setErr(null); + setDebugImportBusy(true); + setDebugImportProgress(0.02); + setDebugImportMessage(null); + try { + setDebugImportStep(t("sidebar.flowImportingDebug")); + const imported = await importFromDebug(debugPick); + setDebugImportProgress(isAvi ? 0.18 : 1); + let target = imported.filename; + if (isAvi) { + target = await transcodePoolAviAndReplace(imported.filename); + } else { + setDebugImportStep(t("sidebar.flowNoTranscode")); + } + await loadLists(); + setSelected(target); + setView(isVideoAsset(target) ? "lab_video" : "lab_image"); + setDebugImportProgress(1); + setDebugImportStep(t("sidebar.flowDone")); + setDebugImportMessage(t("sidebar.flowDoneMsg", { name: compactFilename(target) })); + } catch (e) { + setErr(String(e)); + setDebugImportStep(t("sidebar.flowFailed")); + setDebugImportMessage(String(e)); + } finally { + setDebugImportBusy(false); + } + }; + + const stopFileSolveLoop = () => { + setFileSolveRunning(false); + if (fileSolveTimerRef.current != null) { + window.clearInterval(fileSolveTimerRef.current); + fileSolveTimerRef.current = null; + } + }; + + const startCameraSolveLoop = () => { + if (cameraSolveRunning) return; + if (isFrozen) return; + setCameraSolveRunning(true); + void onCameraSolve(); + cameraSolveTimerRef.current = window.setInterval(() => { + void onCameraSolve(); + }, starAnalysisIntervalMs); + }; + + const stopCameraSolveLoop = () => { + setCameraSolveRunning(false); + if (cameraSolveTimerRef.current != null) { + window.clearInterval(cameraSolveTimerRef.current); + cameraSolveTimerRef.current = null; + } + }; + + const stopCameraPreview = async () => { + try { + await fetch("/api/debug/camera/stop", { method: "POST" }); + setCameraStreamNonce(Date.now()); + if (videoPreviewMode === "camera") { + setVideoPreviewMode("file"); + } + } catch { + // no-op + } + }; + + const resumeLivePreview = () => { + setIsFrozen(false); + setFrozenFrameId(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + setCameraStreamNonce(Date.now()); + }; + + const togglePreset = (id: string) => { + setSelectedPresetIds((prev) => { + const n = new Set(prev); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + }; + + const tryDeleteUpload = async (filename: string) => { + if (!window.confirm(t("delete.uploadFirst", { name: filename }))) return; + let nexp = 0; + try { + const c = await fetchUploadExperimentCount(filename); + nexp = c.count; + } catch { + nexp = 0; + } + if (nexp > 0) { + const ok = window.confirm(t("delete.uploadCascade", { n: nexp })); + if (!ok) return; + } else if (!window.confirm(t("delete.uploadSecond"))) { + return; + } + setBusy(true); + setErr(null); + try { + await deletePoolUpload(filename, { deleteExperiments: nexp > 0 }); + await loadLists(); + if (selected === filename) setSelected(null); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const tryDeleteExperiment = async (id: string) => { + if (!window.confirm(t("delete.experimentFirst"))) return; + if (!window.confirm(t("delete.experimentSecond"))) return; + setBusy(true); + setErr(null); + try { + await deleteExperimentRecord(id); + if (historyExpandId === id) setHistoryExpandId(null); + const data = await fetchExperiments(historyQ, historyPage, HISTORY_PAGE_SIZE); + setHistoryData(data); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const setZoomClamped = (z: number) => setZoom(Math.min(4, Math.max(0.5, z))); + + const historyTotalPages = Math.max( + 1, + Math.ceil((historyData?.total ?? 0) / HISTORY_PAGE_SIZE), + ); + + /** 按当前页面只列出对应类型调试素材 / Filter debug captures by lab view */ + const debugFilesForView = useMemo(() => { + if (view === "lab_image") { + return debugFiles.filter((f) => f.type === "image" || isImageAsset(f.name)); + } + if (view === "lab_video") { + return debugFiles.filter((f) => f.type === "video" || isVideoAsset(f.name)); + } + return debugFiles; + }, [debugFiles, view]); + + const debugTotalPages = Math.max( + 1, + Math.ceil(debugFilesForView.length / DEBUG_PAGE_SIZE), + ); + const debugPagedFiles = useMemo(() => { + const start = (debugPage - 1) * DEBUG_PAGE_SIZE; + return debugFilesForView.slice(start, start + DEBUG_PAGE_SIZE); + }, [debugFilesForView, debugPage]); + + useEffect(() => { + setDebugPage(1); + }, [view]); + + useEffect(() => { + setDebugPage((p) => Math.min(p, debugTotalPages)); + }, [debugTotalPages]); + + useEffect(() => { + if (debugPick && !debugFilesForView.some((f) => f.name === debugPick)) { + setDebugPick(null); + } + }, [debugFilesForView, debugPick]); + + const metaCaptionRows = useMemo( + () => buildMetaCaptionRows(meta, locale), + [meta, locale], + ); + + const sidebarUploads = useMemo(() => { + if (view === "lab_image") return uploads.filter((u) => isImageAsset(u.filename)); + if (view === "lab_video") return uploads.filter((u) => isVideoAsset(u.filename)); + return uploads; + }, [uploads, view]); + + const solveHud = useMemo(() => parseSolveResult(resultRow), [resultRow]); + + useEffect(() => { + setSingleFooterRawOpen(false); + }, [lastResult]); + useEffect(() => { + if (view !== "lab_video") return; + let id: ReturnType | undefined; + const tick = () => { + fetchSystemInfo().then(setSysOverview).catch(() => {}); + }; + tick(); + id = setInterval(tick, 1500); + return () => { + if (id) clearInterval(id); + }; + }, [view]); + + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") { + stopCameraSolveLoop(); + setIsFrozen(false); + setFrozenFrameId(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + } + if (view !== "lab_video" || videoPreviewMode !== "file") { + stopFileSolveLoop(); + } + }, [view, videoPreviewMode]); + + useEffect(() => { + if (!selected) { + stopFileSolveLoop(); + } + }, [selected]); + + useEffect(() => { + return () => { + stopCameraSolveLoop(); + stopFileSolveLoop(); + }; + }, []); + + + return ( +
+
+
+ + {t("app.title")} + + +
+
+
+ + +
+ + {t("nav.systemAdmin")} + +
+
+ +
+ + + {(view === "lab_image" || view === "lab_video") && ( +
+
+ {err && ( +
+ {err} +
+ )} +
+ {lastGateHint && ( +
+ {lastGateHint} +
+ )} +
+ {view === "lab_video" && ( +
+

{t("lab.videoLiveIntro")}

+
+ + + +
+
+ )} +
+ {view === "lab_video" && videoPreviewMode === "camera" ? ( +
+
+ {!isFrozen || frozenImageUrl ? ( + + setCameraPreviewNatural({ + w: e.currentTarget.naturalWidth, + h: e.currentTarget.naturalHeight, + }) + } + /> + ) : ( +
+ + {t("lab.cameraPreviewLoading")} +
+ )} + +
+
+ ) : selected ? ( +
+ {view === "lab_video" ? ( + <> +
+ + + + +
+
+
+
+ {gridOn && ( +
+ )} +
+
+ {videoPreviewError && ( +
+ {videoPreviewError} +
+ )} + {isSelectedAvi && ( +
+
{t("lab.transcode.title")}
+
{t("lab.transcode.desc")}
+ {transcodeProgress != null && ( +
+
+ {transcodeHint || t("lab.transcode.running")} +
+
+
+
+
+ )} +
+ +
+
+ )} +
+ + ) : ( + <> +
+ + + + +
+
+
+ {gridOn && ( +
+ )} + preview + +
+
+ + )} +
+ ) : ( +
+ {view === "lab_video" ? t("lab.selectOrUploadVideo") : t("lab.selectOrUpload")} +
+ )} + {busy && ( +
+ +
+ )} +
+ {((selected && view === "lab_image") || + (view === "lab_video" && + ((videoPreviewMode === "file" && selected) || videoPreviewMode === "camera"))) && ( +
+ + {t("lab.layers")} + +
+ {(["matched", "pattern", "all"] as const).map((k) => ( + + ))} +
+
+ )} + {(selected || (view === "lab_video" && videoPreviewMode === "camera")) && ( + <> +
+
+ + {t("lab.solveSection")} + + +
+ {resultRow ? ( +
+ {solveHud.tSolveMs != null && ( +
+
+ + {t("lab.metric.solveComputeMs")} + + + {solveHud.tSolveMs.toFixed(0)} ms + +
+

+ {t("lab.metric.solveComputeHelp")} +

+
+ )} + {lastRoundTripMs != null && ( +
+
+ + {t("lab.metric.solveRoundTripMs")} + + + {lastRoundTripMs.toFixed(0)} ms + +
+

+ {t("lab.metric.solveRoundTripHelp")} +

+
+ )} + {(solveHud.tBackendTotalMs != null || + solveHud.tOpenDecodeMs != null || + solveHud.tPreprocessMs != null || + solveHud.tExtractMs != null || + solveHud.tSolveMs != null) && ( +
+
+ + {t("lab.metric.backendTotalMs")} + + + {solveHud.tBackendTotalMs != null + ? `${solveHud.tBackendTotalMs.toFixed(0)} ms` + : t("common.placeholder")} + +
+
+ + {t("lab.metric.openDecodeMs")}:{" "} + {solveHud.tOpenDecodeMs != null + ? `${solveHud.tOpenDecodeMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.preprocessMs")}:{" "} + {solveHud.tPreprocessMs != null + ? `${solveHud.tPreprocessMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.extractMs")}:{" "} + {solveHud.tExtractMs != null + ? `${solveHud.tExtractMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.solveOnlyMs")}:{" "} + {solveHud.tSolveMs != null + ? `${solveHud.tSolveMs.toFixed(0)} ms` + : t("common.placeholder")} + +
+
+ )} + {(solveHud.raDeg != null || solveHud.decDeg != null) && ( +
+ + {t("lab.metric.radec")} + +
+ α {formatAngleDeg(solveHud.raDeg)} · δ{" "} + {formatAngleDeg(solveHud.decDeg)} +
+
+ )} +
+ {solveHud.matches != null && ( + + + {t("lab.metric.matches")} + {" "} + {solveHud.matches} + + )} + {solveHud.rmseArcsec != null && ( + + + {t("lab.metric.rmse")} + {" "} + + {solveHud.rmseArcsec.toFixed(2)}″ + + + )} + {solveHud.prob != null && ( + + + + {t("lab.metric.prob")} + {" "} + + {formatProbLine(solveHud.prob, resultRow ?? undefined).line} + + + + {t("lab.metric.probHelp")} + + {formatProbLine(solveHud.prob, resultRow ?? undefined).rawLine && ( + <> + + {t("lab.metric.probRaw")}:{" "} + { + formatProbLine(solveHud.prob, resultRow ?? undefined) + .rawLine + } + + + {t("lab.metric.probRawHelp")} + + + )} + + )} +
+ {solveHud.status && ( +
+ + {t("lab.metric.status")}{" "} + + {solveHud.status} +
+ )} +
+ ) : ( +

{t("common.placeholder")}

+ )} +
+
+
+ + {t("lab.imageSection")} + + +
+
+ {t("lab.resolution")} + + {previewPixelDims.w > 0 + ? `${previewPixelDims.w}×${previewPixelDims.h}` + : t("common.placeholder")} + +
+
+ {t("lab.starsDetected")} + + {starCount != null ? starCount : t("common.placeholder")} + +
+
+ {t("lab.fwhm")} + {t("common.placeholder")} +
+
+
+
+ {selected && ( + <> +
+ + {t("lab.file")}:{" "} + + {compactFilename(selected, 22, 18)} + + + {uploads.find((u) => u.filename === selected)?.source && ( + + {t("lab.source")}:{" "} + {uploads.find((u) => u.filename === selected)?.source} + + )} +
+
+ + {t("lab.meta.title")} + {metaLoading && } + + +
+ {metaCaptionRows.length > 0 ? ( +
+ {metaCaptionRows.map((row) => ( +
+
{t(row.key)}
+
+ {row.value} +
+
+ ))} +
+ ) : meta && !metaLoading ? ( +

+ {t("lab.meta.partial")} +

+ ) : !metaLoading ? ( +

+ {t("lab.meta.noSidecar")} +

+ ) : null} +
+
+ + )} + + )} + {(selected || (view === "lab_video" && videoPreviewMode === "camera")) && + (lastResult || batchPack) && ( +
+
+ {t("results.title")} +
+ {batchPack?.results?.length ? ( + + ) : null} + {lastResult && ( + + )} +
+
+
+
+ {batchPack?.results.map((r, i) => { + const row = r.result as Record | undefined; + const rawOpen = batchRawOpen[i] ?? false; + return ( +
+
+ {String(r.label)} +
+ {r.success ? ( + <> + + + {rawOpen && ( +
+                                    {JSON.stringify(r, null, 2)}
+                                  
+ )} + + ) : ( +
{String(r.error)}
+ )} + {r.success === true && selected ? ( + + ) : null} +
+ ); + })} + {lastResult && !batchPack && ( +
+
+ {t("results.title")} +
+ | undefined} + t={t} + roundTripMs={lastRoundTripMs} + /> + + {singleFooterRawOpen && ( +
+                              {JSON.stringify(lastResult, null, 2)}
+                            
+ )} +
+ )} +
+
+
+ )} +
+ + +
+ )} + + {view === "pool" && ( +
+

+ {t("pool.title")} +

+ + + + + + + + + + + + {uploads.map((u) => ( + + + + + + + + ))} + +
{t("pool.col.name")}{t("pool.col.source")}{t("pool.col.size")}{t("pool.col.time")}{t("pool.delete")}
{u.filename}{u.source ?? t("common.placeholder")}{formatFileSize(u.size)}{formatDateTime(u.modified_at, locale)} + +
+
+ )} + + {view === "history" && ( +
+

+ {t("history.intro")} +

+
+

+ {t("history.title")} +

+ setHistoryQ(e.target.value)} + /> + + + +
+
+ {t("history.total", { n: historyData?.total ?? 0 })} + + + {historyPage} / {historyTotalPages} + + +
+
    + {(historyData?.items as Array>)?.map((row) => { + const id = String(row.id ?? ""); + const metrics = row.metrics as Record | undefined; + const open = historyExpandId === id; + return ( +
  • +
    +
    +
    + {formatDateTime(String(row.created_at ?? ""), locale)} —{" "} + {String(row.input_name)} — {String(row.preset_label)} +
    +
    + {t("history.preset")}: {String(row.preset_label)} · {t("history.metrics")}:{" "} + matches={String(metrics?.matches ?? "—")} rmse= + {String(metrics?.rmse_arcsec ?? "—")} +
    +
    +
    + + +
    +
    + {open && ( +
    + {row.asset_snapshot_relpath ? ( + + ) : null} +
    +                          {JSON.stringify(row.result_json, null, 2)}
    +                        
    +
    + )} +
  • + ); + })} +
+
+ )} +
+
+ ); +} + +function SolveFooterSummary({ + result, + t, + roundTripMs, +}: { + result: Record | null | undefined; + t: (key: string, vars?: Record) => string; + roundTripMs: number | null; +}) { + const s = parseSolveResult(result ?? undefined); + if (!result) { + return

; + } + return ( +
+
+ {s.tBackendTotalMs != null && ( +
+
{t("lab.metric.backendTotalMs")}
+
+ {s.tBackendTotalMs.toFixed(0)} ms +
+
+ )} + {s.tSolveMs != null && ( +
+
{t("lab.metric.solveComputeMs")}
+
+ {s.tSolveMs.toFixed(0)} ms +
+
+ )} + {s.tOpenDecodeMs != null && ( +
+
{t("lab.metric.openDecodeMs")}
+
+ {s.tOpenDecodeMs.toFixed(0)} ms +
+
+ )} + {s.tPreprocessMs != null && ( +
+
{t("lab.metric.preprocessMs")}
+
+ {s.tPreprocessMs.toFixed(0)} ms +
+
+ )} + {s.tExtractMs != null && ( +
+
{t("lab.metric.extractMs")}
+
+ {s.tExtractMs.toFixed(0)} ms +
+
+ )} + {roundTripMs != null && ( +
+
{t("lab.metric.solveRoundTripMs")}
+
+ {roundTripMs.toFixed(0)} ms +
+
+ )} + {s.matches != null && ( +
+
{t("lab.metric.matches")}
+
{s.matches}
+
+ )} + {s.rmseArcsec != null && ( +
+
{t("lab.metric.rmse")}
+
+ {s.rmseArcsec.toFixed(2)}″ +
+
+ )} + {s.prob != null && ( +
+
{t("lab.metric.prob")}
+
+ {formatProbLine(s.prob, result).line} +
+

+ {t("lab.metric.probHelp")} +

+ {formatProbLine(s.prob, result).rawLine && ( +
+ {t("lab.metric.probRaw")}: {formatProbLine(s.prob, result).rawLine} +

+ {t("lab.metric.probRawHelp")} +

+
+ )} +
+ )} +
+
+
{t("lab.metric.radec")}
+
+ α {formatAngleDeg(s.raDeg)} · δ {formatAngleDeg(s.decDeg)} +
+
+ {s.status && ( +
+ {t("lab.metric.status")}: {s.status} +
+ )} +
+ ); +} + +function Field({ + label, + helpKey, + value, + onChange, + type = "number", + step, +}: { + label: string; + helpKey?: string; + value: number | ""; + onChange: (v: number | undefined) => void; + type?: string; + step?: number; +}) { + const { t } = useI18n(); + const help = helpKey ? t(helpKey) : undefined; + return ( + + ); +} diff --git a/web/analysis-ui/src/App.tsx b/web/analysis-ui/src/App.tsx index 9b0c5f7..9957111 100644 --- a/web/analysis-ui/src/App.tsx +++ b/web/analysis-ui/src/App.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Database, - FlaskConical, Grid3x3, History, Home, @@ -159,12 +158,12 @@ export default function App() { const [cameraPreviewNatural, setCameraPreviewNatural] = useState({ w: 0, h: 0 }); /** 视频台:文件预览或设备相机 / Video lab: pool file vs device camera */ const [videoPreviewMode, setVideoPreviewMode] = useState<"file" | "camera">("file"); - /** 设备相机预览图 blob URL(与 X-Frame-Id 去重)/ Camera preview blob URL, deduped by frame id */ - const [cameraPreviewUrl, setCameraPreviewUrl] = useState(null); + /** MJPEG 流时间戳参数,与相机调试台 /api/debug/camera/stream 一致 / Same stream URL as debug console */ + const [cameraStreamNonce, setCameraStreamNonce] = useState(() => Date.now()); const [videoPreviewError, setVideoPreviewError] = useState(null); const [cameraSolveRunning, setCameraSolveRunning] = useState(false); const [fileSolveRunning, setFileSolveRunning] = useState(false); - const [autoHoldEnabled, setAutoHoldEnabled] = useState(true); + const [autoHoldEnabled, setAutoHoldEnabled] = useState(false); const [isFrozen, setIsFrozen] = useState(false); const [frozenFrameId, setFrozenFrameId] = useState(null); const [frozenImageUrl, setFrozenImageUrl] = useState(null); @@ -191,12 +190,34 @@ export default function App() { const imgRef = useRef(null); const videoRef = useRef(null); const cameraPreviewImgRef = useRef(null); - const lastCameraFrameIdRef = useRef(null); const cameraSolveTimerRef = useRef(null); const fileSolveTimerRef = useRef(null); const cameraSolveInFlightRef = useRef(false); const fileSolveInFlightRef = useRef(false); const cvRef = useRef(null); + + /** 从当前预览 img 截一帧为 JPEG blob URL(用于冻结与星点同帧)/ Snapshot current preview frame for freeze */ + const captureCameraFrameAsBlobUrl = useCallback(async (): Promise => { + const el = cameraPreviewImgRef.current; + if (!el || el.naturalWidth < 2) return null; + const canvas = document.createElement("canvas"); + canvas.width = el.naturalWidth; + canvas.height = el.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + try { + ctx.drawImage(el, 0, 0); + } catch { + return null; + } + return await new Promise((resolve) => { + canvas.toBlob( + (b) => resolve(b ? URL.createObjectURL(b) : null), + "image/jpeg", + 0.92, + ); + }); + }, []); const [sysOverview, setSysOverview] = useState(null); const [labSettings, setLabSettings] = useState(null); @@ -366,45 +387,13 @@ export default function App() { const draw = () => drawSolveOverlay(cv, img, overlay, layers); if (img.complete) draw(); else img.onload = draw; - }, [overlay, layers, view, videoPreviewMode, lastResult, cameraPreviewUrl]); + }, [overlay, layers, view, videoPreviewMode, lastResult, cameraStreamNonce, isFrozen]); - /** 共享预览缓存轮询:仅当 X-Frame-Id 变化时更新图像,减少解码与重绘 / Poll shared cache; update img only on new frame id */ + /** 进入设备相机模式时重连 MJPEG(与相机调试台同一路径)/ Reconnect MJPEG like debug console */ useEffect(() => { if (view !== "lab_video" || videoPreviewMode !== "camera") return; - let cancelled = false; - const poll = async () => { - if (cancelled || isFrozen) return; - try { - const qs = lastCameraFrameIdRef.current - ? `?since_frame_id=${encodeURIComponent(lastCameraFrameIdRef.current)}` - : ""; - const r = await fetch(`/api/camera/preview${qs}`, { cache: "no-store" }); - if (r.status === 304) return; - if (!r.ok) return; - const fid = r.headers.get("X-Frame-Id"); - if (fid != null) lastCameraFrameIdRef.current = fid; - const blob = await r.blob(); - const url = URL.createObjectURL(blob); - setCameraPreviewUrl((prev) => { - if (prev) URL.revokeObjectURL(prev); - return url; - }); - } catch { - /* 忽略单次失败 / Ignore transient errors */ - } - }; - void poll(); - const id = window.setInterval(() => void poll(), 180); - return () => { - cancelled = true; - clearInterval(id); - setCameraPreviewUrl((prev) => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); - lastCameraFrameIdRef.current = null; - }; - }, [view, videoPreviewMode, isFrozen]); + setCameraStreamNonce(Date.now()); + }, [view, videoPreviewMode]); useEffect(() => { const img = imgRef.current; @@ -533,7 +522,11 @@ export default function App() { ? String((out as { frame_id?: number }).frame_id) : null, ); - setFrozenImageUrl(cameraPreviewUrl); + const snap = await captureCameraFrameAsBlobUrl(); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return snap; + }); stopCameraSolveLoop(); } setLastRoundTripMs(performance.now() - t0); @@ -629,6 +622,7 @@ export default function App() { const out = await transcodeAviToMp4(aviFile, (ratio, msg) => { setTranscodeProgress(Math.max(0, Math.min(1, ratio))); if (msg === "loading_ffmpeg") setTranscodeHint(t("lab.transcode.loading")); + else if (msg === "writing_input") setTranscodeHint(t("lab.transcode.writingBuffer")); else if (msg === "transcoding") setTranscodeHint(t("lab.transcode.running")); else if (msg === "packing_output") setTranscodeHint(t("lab.transcode.packaging")); }); @@ -655,6 +649,8 @@ export default function App() { }; const transcodePoolAviAndReplace = async (aviFilename: string) => { + setDebugImportStep(t("sidebar.flowDownloadingPool")); + setDebugImportProgress(0.19); const srcUrl = uploadFileUrl(aviFilename); const res = await fetch(srcUrl, { cache: "no-store" }); if (!res.ok) throw new Error(t("lab.transcode.fetchFailed")); @@ -665,6 +661,7 @@ export default function App() { const span = 0.6; setDebugImportProgress(base + Math.max(0, Math.min(1, ratio)) * span); if (msg === "loading_ffmpeg") setDebugImportStep(t("sidebar.flowLoadingTranscoder")); + else if (msg === "writing_input") setDebugImportStep(t("sidebar.flowWritingBuffer")); else if (msg === "transcoding") setDebugImportStep(t("sidebar.flowTranscoding")); else if (msg === "packing_output") setDebugImportStep(t("sidebar.flowPackaging")); }); @@ -742,10 +739,26 @@ export default function App() { } }; + const stopCameraPreview = async () => { + try { + await fetch("/api/debug/camera/stop", { method: "POST" }); + setCameraStreamNonce(Date.now()); + if (videoPreviewMode === "camera") { + setVideoPreviewMode("file"); + } + } catch { + // no-op + } + }; + const resumeLivePreview = () => { setIsFrozen(false); setFrozenFrameId(null); - setFrozenImageUrl(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + setCameraStreamNonce(Date.now()); }; const togglePreset = (id: string) => { @@ -877,7 +890,10 @@ export default function App() { stopCameraSolveLoop(); setIsFrozen(false); setFrozenFrameId(null); - setFrozenImageUrl(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); } if (view !== "lab_video" || videoPreviewMode !== "file") { stopFileSolveLoop(); @@ -957,13 +973,7 @@ export default function App() { className="flex items-center gap-1 rounded border border-outline-variant/30 px-2 py-1 text-xs text-on-surface-variant hover:bg-surface-container" href="/debug" > - {t("nav.cameraDebug")} - - - {t("nav.home")} + {t("nav.systemAdmin")} @@ -1007,7 +1017,7 @@ export default function App() { > {t("sidebar.refresh")} -
+
{sidebarUploads.map((u) => (
{t("sidebar.debugEmpty")}

) : ( <> -
+
{debugPagedFiles.map((f) => ( +
)} @@ -1199,10 +1218,14 @@ export default function App() { {view === "lab_video" && videoPreviewMode === "camera" ? (
- {((isFrozen && frozenImageUrl) || cameraPreviewUrl) ? ( + {!isFrozen || frozenImageUrl ? ( @@ -1225,7 +1248,7 @@ export default function App() {
) : selected ? ( -
+
{view === "lab_video" ? ( <>
@@ -1756,8 +1779,8 @@ export default function App() { )}
-
-
+
+
{batchPack?.results.map((r, i) => { const row = r.result as Record | undefined; const rawOpen = batchRawOpen[i] ?? false; @@ -1785,7 +1808,7 @@ export default function App() { {rawOpen ? t("results.hideRaw") : t("results.viewRaw")} {rawOpen && ( -
+                                  
                                     {JSON.stringify(r, null, 2)}
                                   
)} @@ -1793,7 +1816,7 @@ export default function App() { ) : (
{String(r.error)}
)} - {r.success && selected && ( + {r.success === true && selected ? ( - )} + ) : null}
); })} @@ -1831,7 +1854,7 @@ export default function App() { {singleFooterRawOpen ? t("results.hideRaw") : t("results.viewRaw")} {singleFooterRawOpen && ( -
+                            
                               {JSON.stringify(lastResult, null, 2)}
                             
)} @@ -1954,7 +1977,7 @@ export default function App() {
)} -
+
{t("sidebar.batchHint")}

-
+
{[...official, ...userPresets].map((p) => (