Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e357930bee | ||
|
|
5744fca1bd | ||
|
|
9c330d6327 | ||
|
|
ae3990b73e | ||
|
|
db1a636cfc | ||
|
|
faac732509 | ||
|
|
c1e8a0c563 | ||
|
|
4141c39cb7 | ||
|
|
3d2172b9c5 | ||
|
|
a520dcdcf4 | ||
|
|
0822789ea0 | ||
|
|
b86c76418f |
No files matched your search
@@ -0,0 +1,8 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.json text eol=lf
|
||||
*.service text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.logrotate text eol=lf
|
||||
+2
-2
@@ -2,9 +2,9 @@ __pycache__/
|
||||
*.pyc
|
||||
run.log
|
||||
captures/
|
||||
backups/
|
||||
.vscode/
|
||||
.codex
|
||||
.codex/
|
||||
|
||||
# Local SDK/cache artifacts
|
||||
oled_status.log
|
||||
oled/oled_status.log
|
||||
@@ -1,84 +1,165 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Local Environment
|
||||
这是 PGU5-Core 的现场操作规则。Orange Pi 上的 `/root/PGU5-Core` 是事实来源;本地文件、旧记录与现场冲突时,以现场为准,除非用户明确指定其他基线。
|
||||
|
||||
- Local Python should use miniconda environment `clear_conda_main`:
|
||||
`C:\Users\clear\.conda\envs\clear_conda_main\python.exe`
|
||||
## 环境
|
||||
|
||||
## Orange Pi 4 Pro
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| 本地 Python | `C:\Users\clear\.conda\envs\clear_conda_main\python.exe` |
|
||||
| 现场主机 | `192.168.101.172` |
|
||||
| SSH 用户 | `root` |
|
||||
| 现场目录 | `/root/PGU5-Core` |
|
||||
| Web | `http://192.168.101.172:8080/` |
|
||||
| 运行 Python | `/opt/miniconda3/envs/py310/bin/python3` |
|
||||
| Gitea | `http://107.173.157.41:3000/clear/PGU5-Core` |
|
||||
|
||||
- Device: `192.168.101.172`
|
||||
- SSH: `root / orangepi`
|
||||
- Hostname: `orangepi4pro`
|
||||
- Project directory: `/root/PGU5-Core`
|
||||
- Web URL: `http://192.168.101.172:8080/`
|
||||
- Runtime Python: `/opt/miniconda3/envs/py310/bin/python3`
|
||||
- Python version: `Python 3.10.20`
|
||||
SSH 密码、Gitea 密码和 AuthKey 由当前会话或外部安全方式提供,不写入仓库。
|
||||
|
||||
## Project Notes
|
||||
Windows 侧密码 SSH 自动化使用 Paramiko。编辑中文时必须保持 UTF-8,上传后检查 `U+FFFD`、连续问号和换行漂移。`.gitattributes` 对 Python、Markdown、JSON、systemd、Shell 和 logrotate 文件强制 LF。
|
||||
|
||||
- This is a new PGU5 project, not the old seed-germination phenotyping platform.
|
||||
- UI identity: PGU5A open management platform; browser title: `AOT-PGU5Base`.
|
||||
- Current version: `1.1.2`.
|
||||
- Removed domains: PLC/Modbus, watering-device communication, Hikrobot/MVS camera, and old project branding.
|
||||
- Current OTA support is present in the Admin page and uses Gitea releases from `clear/PGU5-Core`.
|
||||
- Main program: `/root/PGU5-Core/pgu5a_base.py`
|
||||
- Main config: `/root/PGU5-Core/pgu5a_config.json`
|
||||
- Logrotate config: `/root/PGU5-Core/pgu5a.logrotate`
|
||||
- Save root: `/nvme-data/pgu_data`
|
||||
- Runtime log: `/root/PGU5-Core/run.log`
|
||||
- Gitea repo: `http://107.173.157.41:3000/clear/PGU5-Core`
|
||||
- Git remote: `http://107.173.157.41:3000/clear/PGU5-Core.git`
|
||||
- Do not commit or push unless the user explicitly asks.
|
||||
## 项目边界
|
||||
|
||||
## Services
|
||||
- 当前应用版本和发布标签均为 `v2.0.5`。
|
||||
- 版本字符串始终保留前导 `v`。
|
||||
- 序列号使用 `PGU5A-` 加大写以太网 MAC。
|
||||
- Core 不包含 PLC/Modbus、运动、水肥、业务自动化、计划任务、业务路径、Hikrobot/MVS/GenICam 或任何应用专属身份。
|
||||
- 不把应用专属功能放回 `main`。
|
||||
|
||||
- `pgu5a_base.service`
|
||||
- Main web service.
|
||||
- Working directory: `/root/PGU5-Core`
|
||||
- Start command: `/opt/miniconda3/envs/py310/bin/python3 /root/PGU5-Core/pgu5a_base.py`
|
||||
- Log output: append to `/root/PGU5-Core/run.log`
|
||||
- `pgu5a_oled.service`
|
||||
- Independent OLED status display.
|
||||
- Start command: `/opt/miniconda3/envs/py310/bin/python3 /root/PGU5-Core/oled_status.py`
|
||||
- Reads local web API only; failure should not affect `pgu5a_base.service`.
|
||||
- OLED: SSD1306-compatible 128x64 on I2C bus 0 address `0x3c`.
|
||||
- Runtime dependency: `luma.oled==3.15.0`.
|
||||
- `ds3231-sync.service`
|
||||
- One-shot RTC/NTP sync service.
|
||||
- Start command: `/opt/miniconda3/envs/py310/bin/python3 /root/PGU5-Core/ds3231/sync_ds3231.py`
|
||||
- `nvme-data-auto.service`
|
||||
- NVMe data disk mount service.
|
||||
- Start command: `/root/PGU5-Core/nvme/auto-mount-nvme-data.sh`
|
||||
- Mount point: `/nvme-data`
|
||||
## 事实来源
|
||||
|
||||
## Operating Checklist
|
||||
- 代码:`/root/PGU5-Core`
|
||||
- 配置:`/root/PGU5-Core/pgu5a_config.json`
|
||||
- 保存目录:`/nvme-data/pgu_data`
|
||||
- 主日志:`/root/PGU5-Core/run.log`
|
||||
- API 文档:`API.md`
|
||||
- Git 远端必须保持无凭据:
|
||||
|
||||
Before editing remote code, inspect:
|
||||
|
||||
```bash
|
||||
cd /root/PGU5-Core && git status --short
|
||||
systemctl status pgu5a_base.service pgu5a_oled.service ds3231-sync.service nvme-data-auto.service --no-pager -l
|
||||
systemctl cat pgu5a_base.service pgu5a_oled.service ds3231-sync.service nvme-data-auto.service --no-pager
|
||||
tail -n 100 /root/PGU5-Core/run.log
|
||||
```text
|
||||
http://107.173.157.41:3000/clear/PGU5-Core.git
|
||||
```
|
||||
|
||||
After editing `pgu5a_base.py`, run:
|
||||
现场配置是实际设备配置。发布时如果用户要求提交当前现场状态,应保留并审查配置差异,不得直接丢弃。
|
||||
|
||||
## 服务
|
||||
|
||||
| 服务 | 正常状态 |
|
||||
| --- | --- |
|
||||
| `pgu5a_base.service` | `active` |
|
||||
| `pgu5a_oled.service` | `active` |
|
||||
| `ds3231-sync.service` | 一次性任务,完成后可为 `inactive` |
|
||||
| `nvme-data-auto.service` | `active (exited)` |
|
||||
| `syncthing@root.service` | `active` |
|
||||
|
||||
OLED 是独立服务,OLED 故障不得影响主服务。
|
||||
|
||||
## 代码规则
|
||||
|
||||
- `pgu5a_base.py` 内嵌 HTML、CSS 和 JavaScript,没有独立前端构建。
|
||||
- 前端修改后提取脚本做 JavaScript 语法检查;一个语法错误会导致时钟、按钮和状态刷新同时失效。
|
||||
- UI 保持紧凑、现场可操作,移动端不得横向溢出,DCL 必须完整显示画面。
|
||||
- 管理中心“多光谱控制”页面通过 `GET/POST /api/ms602/detection` 管理开流检测时长、抖动和撕裂检测;保存后必须持久化并立即更新运行中的 worker,不得重启整机或全部通道。
|
||||
- 配置写入保持格式化 UTF-8 JSON 和一个结尾换行。
|
||||
- 不进行与用户请求无关的大范围重构。
|
||||
|
||||
## 相机与电源
|
||||
|
||||
- MS602 和 DCL 立即启动。
|
||||
- MS602 六通道按 `open_stagger_sec` 错开启动,各通道独立重连。
|
||||
- MS602 连续读帧失败时始终允许独立重连;抖动和撕裂检测只在每次通道成功打开后的 `startup_detection_window_sec` 内运行。
|
||||
- 首次启动、休眠唤醒和错误重连都会为对应通道开启新检测窗口;窗口通过后必须停止昂贵图像分析,且不得由状态轮询或保存配置重新启动检测。
|
||||
- 每个通道的检测样本、截止时间和代次必须独立并受锁保护,旧开流代次的计算结果不得影响新通道实例。
|
||||
- 抖动位移是在 `160x100` 检测样本上计算,现场 720 上下抖动实测约为 `0.04..0.20` 像素;不得恢复按原始分辨率误设的 `0.4` 最小位移和 `2.0` 累计位移。
|
||||
- 撕裂检测同时使用跨帧低相关度大帧差和单帧横向/纵向梯度比例,避免已经撕裂且持续撕裂时因缺少连续切换而漏检。
|
||||
- `last_jitter_trigger_*` 和 `last_integrity_trigger_*` 必须跨通道重开保留,便于区分真实故障与阈值误判;保存检测参数不得清空已完成窗口的诊断证据。
|
||||
- M3Lite 使用 GPIO 8 异步唤醒;UVC-RGB 使用 GPIO 9 异步唤醒。
|
||||
- UVC-RGB 在视频节点消失、被替换或持续无新帧时重连。
|
||||
- 休眠递增 `sensor_wake_token`,取消旧延时唤醒线程。
|
||||
- 定时保存或保存队列工作时暂停自动休眠。
|
||||
|
||||
当前现场时序:
|
||||
|
||||
| 参数 | 值 |
|
||||
| --- | --- |
|
||||
| M3 startup NUC check | `3.0 s` |
|
||||
| M3 wake delay | `2.0 s` |
|
||||
| UVC-RGB wake delay | `1.0 s` |
|
||||
| MS602 open stagger | `0.5 s` |
|
||||
| MS602 startup detection | `5.0 s` |
|
||||
|
||||
## M3/L640 控制
|
||||
|
||||
- 使用 PyUSB bulk,不使用 `/dev/ttyACM0`。
|
||||
- 默认 VID:PID `0424:6000`,接口 `3`,OUT `0x05`,IN `0x85`。
|
||||
- 打开或关闭自动 NUC 后必须发送保存参数命令。
|
||||
- 设备缺失、休眠、PyUSB 缺失或 USB 错误必须返回 `ok=false`,不得影响主服务和其他预览。
|
||||
- 每次事务完成后释放 USB 接口和 PyUSB 资源。
|
||||
|
||||
## OTA
|
||||
|
||||
- 可见版本来自 `APP_VERSION` 和 `APP_RELEASE_TAG`。
|
||||
- `/api/system/version` 的 Git 字段只用于诊断。
|
||||
- 检查更新后只有 `update_available=true` 才能升级。
|
||||
- 状态:`IDLE`、`CHECKED`、`FETCHING`、`CHECKOUT`、`VERIFYING`、`RESTARTING`、`SUCCESS`、`ERROR`。
|
||||
- 强制刷新目标标签:
|
||||
|
||||
```bash
|
||||
/opt/miniconda3/envs/py310/bin/python3 -m py_compile /root/PGU5-Core/pgu5a_base.py
|
||||
git fetch --force origin refs/tags/<tag>:refs/tags/<tag>
|
||||
```
|
||||
|
||||
Restart and verify the web service:
|
||||
- 无手动回滚接口。
|
||||
- 不创建项目外 OTA 备份目录。
|
||||
- OTA 状态文件:`backups/ota_state.json`。
|
||||
|
||||
## 有副作用的接口
|
||||
|
||||
常规验证不得调用:
|
||||
|
||||
- `POST /api/system/reboot`
|
||||
- `POST /api/system/shutdown`
|
||||
- `POST /api/syncthing/identity-init/start`
|
||||
- `POST /api/files/delete`
|
||||
- `POST /api/system/update/apply`
|
||||
- `POST /api/m3/auto_nuc`
|
||||
|
||||
配置保存、网络修改、传感器电源、自动休眠和自动清理接口也会改变现场状态,调用前必须确认任务需要。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd /root/PGU5-Core
|
||||
git status --short
|
||||
git diff --check
|
||||
/opt/miniconda3/envs/py310/bin/python3 -m py_compile \
|
||||
pgu5a_base.py ota/ota_update.py oled/oled_status.py ds3231/sync_ds3231.py
|
||||
/opt/miniconda3/envs/py310/bin/python3 -m json.tool pgu5a_config.json >/dev/null
|
||||
systemctl restart pgu5a_base.service
|
||||
systemctl status pgu5a_base.service --no-pager -l
|
||||
curl -fsS --max-time 8 http://127.0.0.1:8080/api/status >/tmp/pgu5_status.json
|
||||
systemctl is-active pgu5a_base.service
|
||||
curl -fsS http://127.0.0.1:8080/api/system/version
|
||||
curl -fsS http://127.0.0.1:8080/api/status
|
||||
curl -fsS http://127.0.0.1:8080/api/network
|
||||
curl -fsS http://127.0.0.1:8080/api/ota/status
|
||||
curl -fsS http://127.0.0.1:8080/api/m3/control
|
||||
curl -fsS http://127.0.0.1:8080/api/syncthing/identity-init/status
|
||||
```
|
||||
|
||||
## Encoding Notes
|
||||
发布后必须确认:
|
||||
|
||||
- Avoid writing Chinese text to the Pi through a Windows shell directly; it can corrupt characters.
|
||||
- Prefer ASCII-only scripts, Unicode escapes, or Pi-side Python `chr(...)` construction when editing Chinese UI text.
|
||||
- Always verify changed Chinese strings by reading the file as UTF-8 on the Pi.
|
||||
- `main`、标签、Gitea Release 和运行时提交一致。
|
||||
- `/api/system/version` 中版本、标签正确且 `dirty=false`。
|
||||
- 主服务、OLED、Syncthing、Avahi 和 NVMe 正常。
|
||||
- MS602 六通道、M3Lite、DCL 和 UVC-RGB 恢复连接。
|
||||
- 实际服务页面 HTTP `200`,嵌入式 JavaScript 无语法错误。
|
||||
- Core 活动目录和配置中没有应用业务残余或旧工程标识。
|
||||
|
||||
## Git 与发布
|
||||
|
||||
- 未经用户明确要求,不提交、不推送、不打标签、不发布。
|
||||
- 发布前检查目标标签是否存在。
|
||||
- 用户明确要求同名修订发布时,才允许移动现有标签,并同时更新 Gitea Release。
|
||||
- 临时使用凭据后再次检查 `git remote -v`,确保远端地址无凭据。
|
||||
- 发布说明使用清晰 ASCII/English,避免 Gitea 编码问题。
|
||||
|
||||
## 密钥
|
||||
|
||||
不在源码、配置、Markdown、技能或提交中保存密码、AuthKey、令牌、Cookie 或带凭据的 Git URL。
|
||||
@@ -1,344 +1,743 @@
|
||||
# PGU5-Core HTTP API
|
||||
# PGU5-Core API
|
||||
|
||||
This document describes the HTTP API exposed by `pgu5a_base.py`.
|
||||
本文档对应 PGU5-Core `v2.0.5`,以 `pgu5a_base.py` 中的实际路由为准。
|
||||
|
||||
Base URL:
|
||||
## 基本约定
|
||||
|
||||
| 项目 | 说明 |
|
||||
| --- | --- |
|
||||
| 设备地址 | `http://192.168.101.172:8080` |
|
||||
| 本机地址 | `http://127.0.0.1:8080` |
|
||||
| JSON 编码 | UTF-8 |
|
||||
| POST 编码 | `application/x-www-form-urlencoded` |
|
||||
| 缓存 | 动态页面、JSON 和预览帧均返回禁止缓存响应头 |
|
||||
| 鉴权 | 管理 API 本身没有登录层,应只在可信管理网络中开放 |
|
||||
|
||||
大多数 JSON 接口即使业务失败也返回 HTTP `200`,调用方必须检查响应中的 `ok`。文件预览、原始文件和 ZIP 下载接口会按错误情况返回 HTTP `4xx`。预览帧暂时不可用时返回 HTTP `204`。
|
||||
|
||||
通用 JSON 成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "operation completed"
|
||||
}
|
||||
```
|
||||
|
||||
通用 JSON 失败响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"message": "failure reason"
|
||||
}
|
||||
```
|
||||
|
||||
## 接口总览
|
||||
|
||||
### GET
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `/api/status` | 运行状态、相机状态、FPS、保存队列和自动休眠 |
|
||||
| `/api/system/version` | 应用版本、Git 诊断信息和设备序列号 |
|
||||
| `/api/storage` | 存储总量、已用/剩余空间和自动清理状态 |
|
||||
| `/api/network` | AP、STA、以太网、Syncthing 和 HS 状态 |
|
||||
| `/api/network/headscale/task` | HS 后台任务进度 |
|
||||
| `/api/syncthing/identity-init/status` | Syncthing 身份初始化进度 |
|
||||
| `/api/ota/status` | OTA 当前状态 |
|
||||
| `/api/m3/control` | M3/L640 热成像 USB 控制状态 |
|
||||
| `/api/ms602/detection` | MS602 抖动、撕裂检测参数和六通道诊断 |
|
||||
| `/api/ssh/read` | 读取 Web SSH PTY 输出 |
|
||||
| `/api/config` | 读取当前 JSON 配置文本 |
|
||||
| `/api/logs` | 获取内存运行日志 |
|
||||
| `/api/files` | 分页列出保存目录 |
|
||||
| `/api/files/preview` | 获取图像预览 |
|
||||
| `/api/files/raw` | 获取原始文件 |
|
||||
| `/api/schedule` | 获取定时保存配置 |
|
||||
| `/frame/ms602/{band}.jpg` | MS602 单波段预览 |
|
||||
| `/frame/m3lite.jpg` | M3Lite 预览 |
|
||||
| `/frame/dcl.jpg` | DCL 预览 |
|
||||
| `/frame/uvc_rgb.jpg` | UVC-RGB 预览 |
|
||||
| `/assets/logo.png` | 产品 Logo |
|
||||
| `/assets/xterm.js` | Web SSH 终端脚本,按需加载 |
|
||||
| `/assets/xterm.css` | Web SSH 终端样式,按需加载 |
|
||||
| `/assets/xterm.LICENSE` | xterm.js 许可证文本 |
|
||||
|
||||
### POST
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `/api/save/{target}` | 立即保存指定目标 |
|
||||
| `/api/save` | 兼容形式的立即保存 |
|
||||
| `/api/schedule` | 修改定时保存 |
|
||||
| `/api/storage/cleanup` | 修改自动清理配置并执行空间检查 |
|
||||
| `/api/files/delete` | 后台永久删除文件或目录 |
|
||||
| `/api/files/download` | 打包下载文件或目录 |
|
||||
| `/api/sensors/power` | 唤醒或休眠传感器 |
|
||||
| `/api/sensors/auto_sleep` | 设置自动休眠时间 |
|
||||
| `/api/m3/auto_nuc` | 设置并保存自动 NUC |
|
||||
| `/api/ms602/detection` | 保存并立即应用 MS602 抖动、撕裂检测参数 |
|
||||
| `/api/network/scan` | 扫描 Wi-Fi |
|
||||
| `/api/network/ap` | 修改 AP |
|
||||
| `/api/network/sta/connect` | 连接 STA |
|
||||
| `/api/network/sta/disconnect` | 断开 STA |
|
||||
| `/api/network/headscale/install` | 安装 HS 组件 |
|
||||
| `/api/network/headscale/uninstall` | 卸载 HS 组件 |
|
||||
| `/api/network/headscale/login` | 登录 Headscale |
|
||||
| `/api/network/headscale/logout` | 退出 Headscale |
|
||||
| `/api/syncthing/identity-init/start` | 重新生成 Syncthing 设备身份 |
|
||||
| `/api/ssh/open` | 打开 Web SSH 会话 |
|
||||
| `/api/ssh/input` | 写入 Web SSH PTY |
|
||||
| `/api/ssh/close` | 关闭 Web SSH 会话 |
|
||||
| `/api/config/save` | 保存 JSON 配置并重启服务 |
|
||||
| `/api/system/update/check` | 检查 Gitea Release |
|
||||
| `/api/system/update/apply` | 应用 OTA 标签 |
|
||||
| `/api/service/restart` | 重启主服务 |
|
||||
| `/api/system/reboot` | 重启系统 |
|
||||
| `/api/system/shutdown` | 同步磁盘并关机 |
|
||||
|
||||
## 状态与版本
|
||||
|
||||
### `GET /api/status`
|
||||
|
||||
返回当前运行快照。主要字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `app_version` | `APP_VERSION` |
|
||||
| `server_time` / `server_time_text` | 设备时间 |
|
||||
| `save_root` | 保存根目录 |
|
||||
| `message` | 最近一条运行消息 |
|
||||
| `logs` | 有界内存日志 |
|
||||
| `sensor_power.awake` | 传感器是否处于唤醒状态 |
|
||||
| `sensor_power.auto_sleep` | 自动休眠配置、倒计时和暂停状态 |
|
||||
| `schedule` | 定时保存开关与间隔 |
|
||||
| `save_queue` | 保存队列占用情况 |
|
||||
| `refresh` | 前端刷新周期 |
|
||||
| `preview_cache_ts` | 各预览缓存更新时间 |
|
||||
| `stats.capture` | 采集 FPS |
|
||||
| `stats.preview` | 预览编码 FPS |
|
||||
| `stats.request` | 页面请求 FPS |
|
||||
| `ms602` | MS602 总体状态 |
|
||||
| `ms602_channels` | 六通道状态和恢复诊断 |
|
||||
| `m3lite` / `dcl` / `uvc_rgb` | 各相机状态 |
|
||||
| `m3_control` | 热成像控制状态 |
|
||||
| `dcl_frame` / `uvc_rgb_frame` | 原始帧尺寸 |
|
||||
|
||||
`ms602_channels` 的键为 `480`、`550`、`660`、`720`、`840`、`rgb`。每个通道包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "CONNECTED",
|
||||
"message": "Connected: /dev/video12",
|
||||
"device_index": 12,
|
||||
"restart_count": 0,
|
||||
"jitter_count": 0,
|
||||
"jitter_reversals": 0,
|
||||
"jitter_cancellation": 1.0,
|
||||
"jitter_total_shift": 0.0,
|
||||
"last_shift_x": 0.01,
|
||||
"last_shift_y": -0.02,
|
||||
"updated_at": 1785835208.1
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/system/version`
|
||||
|
||||
界面显示版本来自应用常量,不由 Git 自动覆盖。
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `version` | 当前发布标签 |
|
||||
| `app_version` | `APP_VERSION` |
|
||||
| `release_tag` | `APP_RELEASE_TAG` |
|
||||
| `current_version` | 当前应用版本 |
|
||||
| `current_tag` | 当前发布标签 |
|
||||
| `git_tag` | `git describe` 诊断值 |
|
||||
| `commit` | 短提交号 |
|
||||
| `branch` | 当前分支 |
|
||||
| `dirty` | 工作区是否有未提交改动 |
|
||||
| `serial` | `PGU5A-` 加以太网 MAC |
|
||||
|
||||
### `GET /api/logs`
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"time": 1785835208.1,
|
||||
"time_text": "2026-08-04 17:20:08",
|
||||
"message": "Ready"
|
||||
}
|
||||
],
|
||||
"message": "Ready"
|
||||
}
|
||||
```
|
||||
|
||||
## 预览接口
|
||||
|
||||
```http
|
||||
GET /frame/ms602/{band}.jpg
|
||||
GET /frame/m3lite.jpg
|
||||
GET /frame/dcl.jpg
|
||||
GET /frame/uvc_rgb.jpg
|
||||
```
|
||||
|
||||
MS602 `band` 允许值:
|
||||
|
||||
```text
|
||||
http://192.168.101.172:8080
|
||||
480 550 660 720 840 rgb
|
||||
```
|
||||
|
||||
Most control endpoints return JSON and accept `application/x-www-form-urlencoded` POST bodies. Boolean inputs treat `1`, `true`, `True`, and `on` as enabled.
|
||||
有帧时返回 `image/jpeg`;尚无缓存帧时返回 HTTP `204`。这些地址是轮询快照,不是 MJPEG 长连接。
|
||||
|
||||
## Pages And Static Files
|
||||
## 保存与定时任务
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/` | Web UI |
|
||||
| `GET` | `/assets/logo.png` | Logo image |
|
||||
### `POST /api/save/{target}`
|
||||
|
||||
## Status
|
||||
### `POST /api/save`
|
||||
|
||||
### Main Status
|
||||
两种形式都执行一次同步保存,文件写入 `${save_root}/Once`。
|
||||
|
||||
```http
|
||||
GET /api/status
|
||||
```
|
||||
`target` 允许值:
|
||||
|
||||
Common fields:
|
||||
|
||||
| Field | Description |
|
||||
| 值 | 行为 |
|
||||
| --- | --- |
|
||||
| `save_root` | Active save root |
|
||||
| `message` | Latest runtime message |
|
||||
| `logs` | In-memory recent logs |
|
||||
| `sensor_power` | Camera worker awake/sleep state and auto-sleep settings |
|
||||
| `server_time` / `server_time_text` | Device time |
|
||||
| `app_version` | Application version, currently `1.1.2` |
|
||||
| `schedule` | Timed-save state |
|
||||
| `save_queue` | Save queue state |
|
||||
| `refresh` | Frontend refresh intervals |
|
||||
| `preview_cache_ts` | Preview-cache timestamps |
|
||||
| `stats` | Capture/cache/request FPS values |
|
||||
| `ms602` / `m3lite` / `dcl` / `uvc_rgb` | Camera states |
|
||||
| `dcl_frame` / `uvc_rgb_frame` | Frame sizes where available |
|
||||
| `ms602` | 保存完整六波段 TIFF |
|
||||
| `m3lite` | 保存温度 CSV 和温度图 |
|
||||
| `dcl` | 保存 DCL JPEG |
|
||||
| `uvc_rgb` | 保存 UVC-RGB JPEG |
|
||||
| `all` | 要求全部相机保存,任一失败时 `ok=false` |
|
||||
| `online` | 只保存当前已连接的相机 |
|
||||
|
||||
### Storage
|
||||
路径形式示例:
|
||||
|
||||
```http
|
||||
GET /api/storage
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8080/api/save/all
|
||||
```
|
||||
|
||||
Returns save-root path, free space, app version, and auto-cleanup state. This endpoint also triggers the auto-cleanup check.
|
||||
兼容形式:
|
||||
|
||||
### Network
|
||||
|
||||
```http
|
||||
GET /api/network
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8080/api/save -d "target=all"
|
||||
```
|
||||
|
||||
Returns AP, STA, WiFi scan cache, and Syncthing state.
|
||||
|
||||
### Logs
|
||||
|
||||
```http
|
||||
GET /api/logs
|
||||
```
|
||||
|
||||
Returns in-memory logs, latest message, and the tail of `run.log`.
|
||||
|
||||
### Schedule
|
||||
|
||||
```http
|
||||
GET /api/schedule
|
||||
```
|
||||
|
||||
Example:
|
||||
### `GET /api/schedule`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"interval_sec": 1.0
|
||||
"interval_sec": 5.0
|
||||
}
|
||||
```
|
||||
|
||||
## Preview Frames
|
||||
### `POST /api/schedule`
|
||||
|
||||
Preview endpoints return JPEG data. If no frame is available, they may return `204 No Content`.
|
||||
|
||||
| Method | Path | Description |
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/frame/ms602/480.jpg` | MS602 480 nm preview |
|
||||
| `GET` | `/frame/ms602/550.jpg` | MS602 550 nm preview |
|
||||
| `GET` | `/frame/ms602/660.jpg` | MS602 660 nm preview |
|
||||
| `GET` | `/frame/ms602/720.jpg` | MS602 720 nm preview |
|
||||
| `GET` | `/frame/ms602/840.jpg` | MS602 840 nm preview |
|
||||
| `GET` | `/frame/ms602/rgb.jpg` | MS602 RGB preview |
|
||||
| `GET` | `/frame/m3lite.jpg` | Thermal preview |
|
||||
| `GET` | `/frame/dcl.jpg` | Passive 3D preview |
|
||||
| `GET` | `/frame/uvc_rgb.jpg` | High-resolution visible-light cached preview |
|
||||
| `interval_sec` | 否 | 最小为 `1.0` 秒 |
|
||||
| `enabled` | 否 | `1/true/on` 开启,其他值关闭 |
|
||||
|
||||
## Save
|
||||
开启时创建本次定时任务目录,并周期性将 `online` 目标加入异步保存队列。定时保存或保存队列有任务时,自动休眠暂停。
|
||||
|
||||
### Save By Target
|
||||
## 存储与文件管理
|
||||
|
||||
```http
|
||||
POST /api/save/<target>
|
||||
所有传入路径均为相对 `save_root` 的路径。服务会解析规范路径并拒绝越出保存根目录的访问。
|
||||
|
||||
### `GET /api/storage`
|
||||
|
||||
返回存储总量、已用/剩余空间、刷新周期、版本以及自动清理结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"save_root": "/nvme-data/pgu_data",
|
||||
"free_gb": 120.5,
|
||||
"used_gb": 118.0,
|
||||
"total_gb": 238.5,
|
||||
"refresh_ms": 5000,
|
||||
"app_version": "v2.0.5",
|
||||
"auto_cleanup": {}
|
||||
}
|
||||
```
|
||||
|
||||
| Target | Description |
|
||||
读取该接口会执行一次自动清理空间检查;只有配置已启用且空间低于阈值时才会删除候选采集目录。`Once` 目录受保护。
|
||||
|
||||
### `POST /api/storage/cleanup`
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | 是 | 自动清理开关 |
|
||||
| `min_free_gb` | 否 | 最低剩余空间,最小 `1 GB`,默认 `30` |
|
||||
|
||||
该接口会持久化配置,并立即执行一次空间检查。
|
||||
|
||||
### `GET /api/files`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `path` | 空 | 相对目录 |
|
||||
| `page` | `1` | 页码 |
|
||||
| `page_size` | `20` | 每页数量,最大 `20` |
|
||||
|
||||
目录在前、文件在后,名称按不区分大小写排序。隐藏项不会返回。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"root": "/nvme-data/pgu_data",
|
||||
"path": "Once",
|
||||
"parent": "",
|
||||
"items": [
|
||||
{
|
||||
"name": "capture.jpg",
|
||||
"path": "Once/capture.jpg",
|
||||
"is_dir": false,
|
||||
"size": 102400,
|
||||
"mtime": 1785835208.1,
|
||||
"mtime_text": "2026-08-04 17:20",
|
||||
"ext": ".jpg",
|
||||
"is_image": true,
|
||||
"inline_image": true,
|
||||
"child_count": 0
|
||||
}
|
||||
],
|
||||
"total_count": 1,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 1,
|
||||
"dir_count": 0,
|
||||
"file_count": 1,
|
||||
"total_size": 102400
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/files/preview`
|
||||
|
||||
```text
|
||||
/api/files/preview?path=Once/capture.tiff
|
||||
```
|
||||
|
||||
仅接受图像文件。JPEG、PNG、WebP、BMP 可直接返回;TIFF 等图像会缩放到最大 `1800 x 1800` 并转换为 JPEG 预览。
|
||||
|
||||
### `GET /api/files/raw`
|
||||
|
||||
```text
|
||||
/api/files/raw?path=Once/capture.jpg
|
||||
/api/files/raw?path=Once/capture.jpg&download=1
|
||||
```
|
||||
|
||||
`download=1` 时使用附件下载响应头,否则以内联方式返回。
|
||||
|
||||
### `POST /api/files/download`
|
||||
|
||||
`paths` 为换行分隔的相对路径,可同时包含文件和目录:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8080/api/files/download \
|
||||
--data-urlencode $'paths=Once/a.jpg\nOnce/session'
|
||||
```
|
||||
|
||||
返回 `application/zip`。隐藏文件、符号链接和越界路径不会加入压缩包。
|
||||
|
||||
### `POST /api/files/delete`
|
||||
|
||||
`paths` 同样为换行分隔。接口先校验路径,随后启动后台永久删除:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "已开始后台删除 2 项",
|
||||
"queued": true,
|
||||
"paths": [
|
||||
"Once/a.jpg",
|
||||
"Once/session"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
保存根目录不能删除。该操作没有回收站和撤销功能。
|
||||
|
||||
## 传感器与热成像控制
|
||||
|
||||
### `POST /api/sensors/power`
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `ms602` | Save all MS602 bands |
|
||||
| `m3lite` | Save thermal data |
|
||||
| `dcl` | Save passive-3D/DCL image |
|
||||
| `uvc_rgb` | Save high-resolution visible-light image and update preview cache |
|
||||
| `all` | Try all camera targets |
|
||||
| `online` | Save only currently `CONNECTED` targets |
|
||||
| `awake` | `1/true/on` 唤醒,否则休眠 |
|
||||
|
||||
### Compatibility Save Endpoint
|
||||
唤醒时 MS602 和 DCL 立即启动;M3Lite 与 UVC-RGB 按各自延迟异步上电。休眠会停止相机、断开继电器电源并清除预览缓存。
|
||||
|
||||
```http
|
||||
POST /api/save
|
||||
```
|
||||
### `POST /api/sensors/auto_sleep`
|
||||
|
||||
| Parameter | Description |
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `target` | One of `ms602`, `m3lite`, `dcl`, `uvc_rgb`, `all`, `online` |
|
||||
| `minutes` | 自动休眠分钟数;`0` 关闭 |
|
||||
|
||||
## Timed Save
|
||||
修改后立即写入 `pgu5a_config.json`。
|
||||
|
||||
```http
|
||||
POST /api/schedule
|
||||
`GET /api/status` 的 `sensor_power.auto_sleep` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"minutes": 15.0,
|
||||
"idle_for_sec": 120.0,
|
||||
"remaining_sec": 780.0,
|
||||
"enabled": true,
|
||||
"paused": false,
|
||||
"activity_running": false
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
定时采集开启或保存队列仍有任务时,`paused` 和 `activity_running` 为 `true`,自动休眠倒计时暂停。
|
||||
|
||||
### `GET /api/ms602/detection`
|
||||
|
||||
返回当前运行中的检测参数和六通道实时诊断:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"config": {
|
||||
"startup_detection_window_sec": 5.0,
|
||||
"jitter_detection_enabled": true,
|
||||
"jitter_shift_min": 0.04,
|
||||
"jitter_shift_max": 0.5,
|
||||
"jitter_horizontal_max": 0.25,
|
||||
"jitter_restart_reversals": 3,
|
||||
"jitter_window_sec": 3.0,
|
||||
"jitter_cancellation_ratio": 0.35,
|
||||
"jitter_total_shift_min": 0.35,
|
||||
"frame_integrity_detection_enabled": true,
|
||||
"frame_integrity_phase_response_min": 0.55,
|
||||
"frame_integrity_delta_min": 8.0,
|
||||
"frame_integrity_restart_events": 2,
|
||||
"frame_integrity_window_sec": 3.0,
|
||||
"frame_tearing_gradient_ratio_min": 2.0,
|
||||
"frame_tearing_row_diff_min": 5.5
|
||||
},
|
||||
"channels": {
|
||||
"480": {
|
||||
"status": "CONNECTED",
|
||||
"device_index": 20,
|
||||
"restart_count": 1,
|
||||
"jitter_count": 0,
|
||||
"frame_integrity_count": 1,
|
||||
"detection_active": false,
|
||||
"detection_remaining_sec": 0.0,
|
||||
"detection_result": "passed",
|
||||
"detection_run_count": 1,
|
||||
"detection_sample_count": 24,
|
||||
"last_phase_response": 0.98,
|
||||
"last_frame_delta": 1.2,
|
||||
"last_tearing_gradient_ratio": 1.31,
|
||||
"last_tearing_row_diff": 4.58,
|
||||
"last_integrity_trigger_events": 2,
|
||||
"last_integrity_trigger_gradient_ratio": 2.64,
|
||||
"last_integrity_trigger_row_diff": 8.01
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/ms602/detection`
|
||||
|
||||
表单字段与范围:
|
||||
|
||||
| 字段 | 范围 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `startup_detection_window_sec` | `1.0..60.0` | 每次成功打开通道后的检测时长,默认 `5.0` 秒 |
|
||||
| `jitter_detection_enabled` | 布尔值 | 开关小抖动检测 |
|
||||
| `jitter_shift_min` | `0.01..10.0` | `160x100` 检测样本上的最小上下位移 |
|
||||
| `jitter_shift_max` | `0.1..20.0` | 最大上下位移,必须不小于最小值 |
|
||||
| `jitter_horizontal_max` | `0.01..10.0` | 允许的最大左右位移 |
|
||||
| `jitter_restart_reversals` | `2..20` | 反向次数阈值 |
|
||||
| `jitter_window_sec` | `1.0..30.0` | 抖动统计窗口,秒 |
|
||||
| `jitter_cancellation_ratio` | `0.05..0.8` | 来回位移抵消比例阈值 |
|
||||
| `jitter_total_shift_min` | `0.05..50.0` | 累计位移阈值 |
|
||||
| `frame_integrity_detection_enabled` | 布尔值 | 开关撕裂检测 |
|
||||
| `frame_integrity_phase_response_min` | `0.1..0.95` | 相位相关度阈值;调高更敏感 |
|
||||
| `frame_integrity_delta_min` | `1.0..100.0` | 帧差阈值;调低更敏感 |
|
||||
| `frame_integrity_restart_events` | `2..20` | 窗口内异常次数阈值 |
|
||||
| `frame_integrity_window_sec` | `1.0..30.0` | 撕裂统计窗口,秒 |
|
||||
| `frame_tearing_gradient_ratio_min` | `1.1..5.0` | 单帧纵向变化与横向变化的比例阈值 |
|
||||
| `frame_tearing_row_diff_min` | `1.0..30.0` | 单帧横向断层强度阈值 |
|
||||
|
||||
保存成功后先写入 `pgu5a_config.json`,再更新运行中的 MS602 worker 并清空当前检测样本。参数立即用于仍在进行的开流自检;已经通过的通道不会因为保存配置而重新开始检测。该操作不重启服务、不重启整机,也不会主动重启正常通道。
|
||||
|
||||
每个通道只在首次启动、休眠唤醒或错误重连后开启独立检测窗口。窗口结束且未触发异常时,`detection_result` 变为 `passed`,后续预览帧在进入灰度转换、缩放、相位相关和帧差计算前直接返回。检测触发时只请求重启对应通道;通道重新打开后自动进入新一轮检测。
|
||||
|
||||
管理中心诊断表只显示 `device_index` 的数字节点编号,例如 `12`;API 仍返回完整的 `device_index` 和通道状态消息,后台设备路径与相机打开逻辑不变。
|
||||
|
||||
### `GET /api/m3/control`
|
||||
|
||||
返回低频轮询缓存:
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"ok": true,
|
||||
"status": "OK",
|
||||
"message": "热成像控制接口正常",
|
||||
"fpa_temp_c": 43.12,
|
||||
"core_temp_c": 43.13,
|
||||
"auto_nuc": true,
|
||||
"updated_at": 1785835205.2,
|
||||
"updated_at_text": "2026-08-04 17:20:05",
|
||||
"fpa_response_hex": "55 ... eb aa",
|
||||
"core_response_hex": "55 ... eb aa"
|
||||
}
|
||||
```
|
||||
|
||||
控制链路使用 PyUSB bulk,不使用 `/dev/ttyACM0`。默认 VID:PID 为 `0424:6000`,接口 `3`,OUT `0x05`,IN `0x85`。
|
||||
|
||||
### `POST /api/m3/auto_nuc`
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `interval_sec` | Save interval in seconds, minimum 1 |
|
||||
| `enabled` | `1` enables, `0` disables |
|
||||
| `enabled` | `1/true/on` 开启,否则关闭 |
|
||||
|
||||
Timed save uses the `online` save target internally. If the previous save round is still running, the current round is skipped.
|
||||
接口先发送自动 NUC 命令,再发送保存参数命令。成功响应包含 `response_hex`、`save_response_hex` 和更新后的 `m3_control`。传感器休眠、PyUSB 缺失或设备断开时返回 `ok=false`,不会影响主服务。
|
||||
|
||||
## Camera Power / Sleep
|
||||
## 网络、HS 与 Syncthing
|
||||
|
||||
### Wake Or Sleep
|
||||
### `GET /api/network`
|
||||
|
||||
```http
|
||||
POST /api/sensors/power
|
||||
```
|
||||
返回:
|
||||
|
||||
| Parameter | Description |
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `awake` | `1` wakes camera workers, `0` sleeps them |
|
||||
| `ap` | AP 开关、活动状态、接口、SSID、地址 |
|
||||
| `sta` | STA 配置和连接状态 |
|
||||
| `ethernet` | 以太网接口、IPv4、MAC |
|
||||
| `scan` | 最近一次 Wi-Fi 扫描结果 |
|
||||
| `syncthing` | 安装、服务和 GUI 可达状态 |
|
||||
| `headscale` | HS 状态与 IPv4 |
|
||||
| `serial` | 设备序列号 |
|
||||
|
||||
### Auto Sleep Timeout
|
||||
响应中可能包含 AP 密码,只能在可信管理网络中调用和记录。
|
||||
|
||||
### `POST /api/network/scan`
|
||||
|
||||
扫描 Wi-Fi 并更新 `/api/network` 的 `scan` 字段。
|
||||
|
||||
### `POST /api/network/ap`
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | 是 | AP 开关 |
|
||||
| `ssid` | 启用时是 | AP SSID |
|
||||
| `password` | 启用时是 | 至少 8 位 |
|
||||
|
||||
会修改 NetworkManager 连接并持久化到配置。
|
||||
|
||||
### `POST /api/network/sta/connect`
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `ssid` | 是 | 目标 SSID |
|
||||
| `password` | 否 | Wi-Fi 密码 |
|
||||
|
||||
连接成功后持久化 STA 配置。
|
||||
|
||||
### `POST /api/network/sta/disconnect`
|
||||
|
||||
断开 STA,并将配置中的 STA 开关设为关闭。
|
||||
|
||||
### HS 接口
|
||||
|
||||
```http
|
||||
POST /api/sensors/auto_sleep
|
||||
GET /api/network/headscale/task
|
||||
POST /api/network/headscale/install
|
||||
POST /api/network/headscale/uninstall
|
||||
POST /api/network/headscale/login
|
||||
POST /api/network/headscale/logout
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
安装、卸载、登录和退出都是后台任务。启动接口返回 `task`,前端轮询 `GET /api/network/headscale/task`。
|
||||
|
||||
登录参数:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `server` | 是 | Headscale 服务地址 |
|
||||
| `authkey` | 是 | 临时 AuthKey |
|
||||
|
||||
AuthKey 不应写入配置、日志或文档。任务正在运行时再次启动会返回 `ok=false`。
|
||||
|
||||
### Syncthing 身份初始化
|
||||
|
||||
```http
|
||||
GET /api/syncthing/identity-init/status
|
||||
POST /api/syncthing/identity-init/start
|
||||
```
|
||||
|
||||
启动后会:
|
||||
|
||||
1. 停止 `syncthing@root.service`。
|
||||
2. 只删除 `/root/.config/syncthing/cert.pem` 和 `key.pem`。
|
||||
3. 重新启动 Syncthing。
|
||||
4. 等待新身份文件生成。
|
||||
|
||||
保留 `config.xml`、文件夹配置、数据库和同步数据。状态接口返回 `running`、`ok`、`progress`、`stage`、`message` 和有界日志。
|
||||
|
||||
这是会改变设备身份的接口,常规健康检查不得调用。
|
||||
|
||||
## Web SSH
|
||||
|
||||
```http
|
||||
POST /api/ssh/open
|
||||
GET /api/ssh/read
|
||||
POST /api/ssh/input
|
||||
POST /api/ssh/close
|
||||
```
|
||||
|
||||
后端启动 PTY,并执行:
|
||||
|
||||
```text
|
||||
/usr/bin/ssh root@127.0.0.1
|
||||
```
|
||||
|
||||
| 接口 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/api/ssh/open` | `password` | 打开会话并完成本机 SSH 登录 |
|
||||
| `/api/ssh/input` | `text` | 写入终端文本或控制字符 |
|
||||
| `/api/ssh/read` | 无 | 读取增量输出和连接状态 |
|
||||
| `/api/ssh/close` | 无 | 关闭 PTY 和子进程 |
|
||||
|
||||
密码仅用于当前请求,不应持久化。前端关闭终端窗口时必须调用 `close`。
|
||||
|
||||
## 配置、服务与电源
|
||||
|
||||
### `GET /api/config`
|
||||
|
||||
返回当前 `pgu5a_config.json` 的原始文本:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "配置读取成功",
|
||||
"path": "/root/PGU5-Core/pgu5a_config.json",
|
||||
"text": "{\n ...\n}\n"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/config/save`
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `minutes` | Inactivity timeout in minutes; `0` disables auto sleep |
|
||||
| `config_text` | 完整 JSON 对象文本 |
|
||||
|
||||
## File Manager
|
||||
保存流程:
|
||||
|
||||
All file APIs are restricted to the configured `save_root`.
|
||||
1. 解析并验证根节点为 JSON 对象。
|
||||
2. 将旧配置备份到 `backups/config/`。
|
||||
3. 用 UTF-8、两空格缩进和一个结尾换行写入。
|
||||
4. 重新加载配置。
|
||||
5. 异步重启 `pgu5a_base.service`。
|
||||
|
||||
### List Files
|
||||
该接口只验证 JSON 结构,不验证每个业务字段的类型和范围。编辑前应保留现场配置。
|
||||
|
||||
```http
|
||||
GET /api/files?path=<relative_path>&page=1&page_size=20
|
||||
```
|
||||
### 配置结构
|
||||
|
||||
### Preview Image
|
||||
顶层键:
|
||||
|
||||
```http
|
||||
GET /api/files/preview?path=<relative_file_path>
|
||||
```
|
||||
|
||||
Supports `jpg`, `jpeg`, `png`, `webp`, `bmp`, `tif`, and `tiff`. TIFF files are converted to JPEG for preview.
|
||||
|
||||
### Raw File
|
||||
|
||||
```http
|
||||
GET /api/files/raw?path=<relative_file_path>&download=1
|
||||
```
|
||||
|
||||
### Batch Download
|
||||
|
||||
```http
|
||||
POST /api/files/download
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| 键 | 用途 |
|
||||
| --- | --- |
|
||||
| `paths` | Newline-separated relative paths |
|
||||
| `save_root` | 保存根目录 |
|
||||
| `network` | AP 和 STA |
|
||||
| `web` | Web、保存、刷新、休眠和清理 |
|
||||
| `ms602` | MS602 采集与通道恢复 |
|
||||
| `m3lite` | 热成像采集、上电和 USB 控制 |
|
||||
| `dcl` | DCL 相机 |
|
||||
| `uvc_rgb` | RGB 相机和继电器 |
|
||||
|
||||
Returns a zip file.
|
||||
`ms602` 恢复相关键:
|
||||
|
||||
### Batch Delete
|
||||
|
||||
```http
|
||||
POST /api/files/delete
|
||||
```json
|
||||
{
|
||||
"open_stagger_sec": 0.5,
|
||||
"read_fail_restart_count": 5,
|
||||
"channel_reconnect_delay_sec": 0.8,
|
||||
"startup_detection_window_sec": 5.0,
|
||||
"jitter_detection_enabled": true,
|
||||
"jitter_shift_min": 0.04,
|
||||
"jitter_shift_max": 0.5,
|
||||
"jitter_horizontal_max": 0.25,
|
||||
"jitter_restart_reversals": 3,
|
||||
"jitter_window_sec": 3.0,
|
||||
"jitter_cancellation_ratio": 0.35,
|
||||
"jitter_total_shift_min": 0.35,
|
||||
"frame_integrity_detection_enabled": true,
|
||||
"frame_integrity_phase_response_min": 0.55,
|
||||
"frame_integrity_delta_min": 8.0,
|
||||
"frame_integrity_restart_events": 2,
|
||||
"frame_integrity_window_sec": 3.0,
|
||||
"frame_tearing_gradient_ratio_min": 2.0,
|
||||
"frame_tearing_row_diff_min": 5.5
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
小幅反向垂直抖动继续使用位移方向、抵消比例和累计位移判断,位移值基于 `160x100` 检测样本。对于画面撕裂、错行等坏帧,检测器同时统计跨帧低相位相关度大帧差,以及单帧横向断层梯度;在开流检测窗口内达到阈值后只重启对应 MS602 通道。`ms602_channels` 会附带检测是否运行、剩余秒数、结果、实际分析帧数、恢复计数、相位相关度、帧差和断层指标等诊断字段。`last_jitter_trigger_*` 与 `last_integrity_trigger_*` 保留最近一次触发证据,通道重新打开和保存配置后仍可用于诊断。
|
||||
|
||||
### 服务和电源接口
|
||||
|
||||
| 接口 | 行为 |
|
||||
| --- | --- |
|
||||
| `paths` | Newline-separated relative paths |
|
||||
| `POST /api/service/restart` | 1 秒后重启主服务 |
|
||||
| `POST /api/system/reboot` | 1 秒后重启操作系统 |
|
||||
| `POST /api/system/shutdown` | 2 秒后执行 `sync` 和 `poweroff` |
|
||||
|
||||
Deletion is permanent and runs in the background. The save root itself cannot be deleted.
|
||||
|
||||
## Network And Sync
|
||||
|
||||
### Scan WiFi
|
||||
|
||||
```http
|
||||
POST /api/network/scan
|
||||
```
|
||||
|
||||
### Configure AP
|
||||
|
||||
```http
|
||||
POST /api/network/ap
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `enabled` | `1` enables AP, `0` disables AP |
|
||||
| `ssid` | AP SSID |
|
||||
| `password` | AP password, at least 8 characters |
|
||||
|
||||
### Connect STA
|
||||
|
||||
```http
|
||||
POST /api/network/sta/connect
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `ssid` | WiFi SSID |
|
||||
| `password` | WiFi password |
|
||||
|
||||
### Disconnect STA
|
||||
|
||||
```http
|
||||
POST /api/network/sta/disconnect
|
||||
```
|
||||
|
||||
Syncthing state is returned in `/api/network` under the `syncthing` field. The Syncthing web UI defaults to port `8384`.
|
||||
|
||||
## Config Editor
|
||||
|
||||
### Read Config
|
||||
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
|
||||
Returns the config path and raw `pgu5a_config.json` text.
|
||||
|
||||
### Save Config And Restart
|
||||
|
||||
```http
|
||||
POST /api/config/save
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `config_text` | Complete JSON config text |
|
||||
|
||||
The backend validates JSON, backs up the previous config to `/root/PGU5-Core_config_backups/`, and restarts `pgu5a_base.service` after saving.
|
||||
|
||||
## Storage Auto-Cleanup
|
||||
|
||||
```http
|
||||
POST /api/storage/cleanup
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `enabled` | `1` enables auto-cleanup, `0` disables it |
|
||||
| `min_free_gb` | Free-space threshold in GB |
|
||||
|
||||
Cleanup checks are triggered by `GET /api/storage`.
|
||||
这三个接口先返回 JSON,再执行后台动作。常规验证只允许调用服务重启;不得调用系统重启或关机。
|
||||
|
||||
## OTA
|
||||
|
||||
### Current Version
|
||||
|
||||
```http
|
||||
GET /api/system/version
|
||||
```
|
||||
|
||||
Returns git-derived version, app version, commit, branch, dirty state, repo root, and service name.
|
||||
|
||||
### Check Latest Release
|
||||
|
||||
```http
|
||||
GET /api/ota/status
|
||||
POST /api/system/update/check
|
||||
```
|
||||
|
||||
Checks the latest release from Gitea for `clear/PGU5-Core`.
|
||||
|
||||
### Apply Release
|
||||
|
||||
```http
|
||||
POST /api/system/update/apply
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `tag` | Release tag to apply. If omitted, latest release tag is used. |
|
||||
OTA 状态:
|
||||
|
||||
The backend fetches tags, backs up dirty diffs to `/root/PGU5-Core_ota_backups` when needed, checks out the tag, and restarts `pgu5a_base.service`.
|
||||
|
||||
## Service And System Control
|
||||
|
||||
### Restart Web Service
|
||||
|
||||
```http
|
||||
POST /api/service/restart
|
||||
```text
|
||||
IDLE CHECKED FETCHING CHECKOUT VERIFYING RESTARTING SUCCESS ERROR
|
||||
```
|
||||
|
||||
Runs `systemctl restart pgu5a_base.service` asynchronously.
|
||||
### `GET /api/ota/status`
|
||||
|
||||
### Reboot Device
|
||||
合并返回当前版本、最近一次远端检查结果和 `backups/ota_state.json` 中的任务状态。
|
||||
|
||||
```http
|
||||
POST /api/system/reboot
|
||||
### `POST /api/system/update/check`
|
||||
|
||||
查询 Gitea Release,返回 `latest` 和 `update_available`。只有 `update_available=true` 时前端才允许升级。
|
||||
|
||||
### `POST /api/system/update/apply`
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `tag` | 否 | 目标标签;空值时使用最近检查到的版本 |
|
||||
|
||||
标签必须包含前导 `v`。更新器强制刷新同名标签:
|
||||
|
||||
```bash
|
||||
git fetch --force origin refs/tags/<tag>:refs/tags/<tag>
|
||||
```
|
||||
|
||||
Reboots the device asynchronously.
|
||||
随后切换标签、执行 Python 编译验证并重启主服务。没有手动回滚接口,也不创建项目外 OTA 备份目录。
|
||||
|
||||
## Example
|
||||
## 排除的应用接口
|
||||
|
||||
```python
|
||||
import requests
|
||||
PGU5-Core 不提供应用层业务控制接口。以下路径应返回 HTTP `404`:
|
||||
|
||||
base = "http://192.168.101.172:8080"
|
||||
status = requests.get(f"{base}/api/status", timeout=3).json()
|
||||
print(status["server_time_text"], status["message"])
|
||||
|
||||
result = requests.post(f"{base}/api/save/online", timeout=30).json()
|
||||
print(result)
|
||||
```text
|
||||
/api/plc/*
|
||||
/api/automation/*
|
||||
/api/light_recipes
|
||||
```
|
||||
|
||||
PLC、运动、水肥、业务路径、计划任务和应用专属身份不得通过 Core API 重新引入。
|
||||
@@ -1,123 +1,154 @@
|
||||
# PGU5-Core
|
||||
|
||||
PGU5-Core is the web control application for the PGU5A open management platform running on Orange Pi 4 Pro.
|
||||
PGU5-Core 是运行在 Orange Pi 4 Pro 上的 PGU5 开放平台核心程序,提供相机采集、预览与保存、传感器电源管理、文件管理、网络配置、OTA、Web SSH、OLED、RTC 和 NVMe 集成。
|
||||
|
||||
This is a new PGU5 project. It is no longer the old seed-germination phenotyping platform. PLC/Modbus, watering-device communication, and Hikrobot/MVS camera support have been removed.
|
||||
## 项目标识
|
||||
|
||||
## Runtime
|
||||
|
||||
- Device: `192.168.101.172`
|
||||
- Web UI: `http://192.168.101.172:8080/`
|
||||
- Project directory: `/root/PGU5-Core`
|
||||
- Python: `/opt/miniconda3/envs/py310/bin/python3`
|
||||
- Main program: `pgu5a_base.py`
|
||||
- Main config: `pgu5a_config.json`
|
||||
- Save root: `/nvme-data/pgu_data`
|
||||
- Version: `1.1.2`
|
||||
- Gitea repo: `http://107.173.157.41:3000/clear/PGU5-Core.git`
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
| 项目 | 当前值 |
|
||||
| --- | --- |
|
||||
| `pgu5a_base.py` | Web UI, HTTP API, camera workers, save flow, network/admin tools, OTA |
|
||||
| `pgu5a_config.json` | Runtime configuration |
|
||||
| `assets/logo.png` | Web logo |
|
||||
| `pgu5a_base.service` | systemd unit copy for the web service |
|
||||
| `pgu5a.logrotate` | logrotate config for `run.log` |
|
||||
| `oled_status.py` | Independent 128x64 SSD1306 OLED status carousel |
|
||||
| `pgu5a_oled.service` | systemd unit copy for the OLED status process |
|
||||
| `ds3231/` | RTC/NTP time sync script and service |
|
||||
| `nvme/` | NVMe auto-mount script and service |
|
||||
| `README.md` | Project overview |
|
||||
| `API.md` | HTTP API reference |
|
||||
| `AGENTS.md` | Operational notes for coding agents |
|
||||
| 产品 | PGU5 开放平台 |
|
||||
| 应用版本 | `v2.0.5` |
|
||||
| 发布标签 | `v2.0.5` |
|
||||
| 设备序列号 | `PGU5A-` + 大写以太网 MAC |
|
||||
| 现场目录 | `/root/PGU5-Core` |
|
||||
| Web | `http://192.168.101.172:8080/` |
|
||||
| Gitea | `http://107.173.157.41:3000/clear/PGU5-Core` |
|
||||
|
||||
Runtime artifacts such as `run.log`, `__pycache__/`, `captures/`, and local editor/cache files are ignored by git.
|
||||
版本字符串在应用常量、API、Git 标签和 Gitea Release 中必须保留前导 `v`。
|
||||
|
||||
## Services
|
||||
## 功能范围
|
||||
|
||||
| Service | Expected state | Purpose |
|
||||
当前 Core 功能:
|
||||
|
||||
- MS602 六通道采集、预览、保存,以及开流自检阶段的小幅反向抖动、低相关度撕裂帧和读帧失败独立通道恢复。
|
||||
- M3Lite 热成像采集、温度数据保存和 PyUSB 控制。
|
||||
- DCL 与 UVC-RGB/IMX766 采集、预览和保存。
|
||||
- 单次保存、在线定时保存、保存队列和自动休眠。
|
||||
- 文件浏览、预览、ZIP 下载、后台永久删除和空间自动清理。
|
||||
- AP、STA、以太网、HS/Headscale 和 Syncthing 管理。
|
||||
- Web SSH、配置编辑、服务重启、安全关机和 OTA。
|
||||
- 管理中心提供多光谱控制页,可开关并调节小抖动与撕裂检测,保存后即时生效并持久化,同时查看六通道实时恢复诊断。
|
||||
- 独立 OLED、DS3231 和 NVMe 服务。
|
||||
|
||||
不属于 Core 的功能:
|
||||
|
||||
- PLC/Modbus、运动控制、水肥控制。
|
||||
- 业务自动化、计划任务和业务路径配置。
|
||||
- Hikrobot/MVS/GenICam。
|
||||
- 任何应用专属产品身份、业务页面或部署路径。
|
||||
|
||||
## v2.0.5
|
||||
|
||||
- 多光谱六通道诊断表的“设备”列仅显示视频节点编号,例如 `/dev/video12` 显示为 `12`,减少现场界面的实现细节。
|
||||
- 后端 `device_index`、设备路径、运行日志和通道打开逻辑保持不变,不影响设备识别、恢复和 API 诊断。
|
||||
|
||||
## v2.0.4
|
||||
|
||||
- 新增管理中心“多光谱控制”页面,可持久化调整 MS602 开流检测时长、小抖动和撕裂检测参数,并查看六通道诊断。
|
||||
- MS602 昂贵图像分析只在首次启动、休眠唤醒或错误重连后的短暂窗口内运行;通过后停止检测,读帧失败仍保持独立通道恢复。
|
||||
- 增强持续撕裂和 720 通道轻微上下抖动识别,使用通道代次、独立锁和诊断证据避免旧线程影响新实例。
|
||||
- 管理中心所有页面统一可滚动高度链;多光谱诊断表完整显示通道、设备节点和检测指标,移动端改为无横向溢出的分组布局。
|
||||
- 修复暗色主题移动端导航按钮、系统设置菜单、根页面白边和多处文字/组件样式,统一非业务管理界面。
|
||||
- 清理空响应式规则、Python 缓存和历史部署临时备份,保留现场配置、配置备份、运行日志和 OTA 状态。
|
||||
|
||||
## v2.0.3
|
||||
|
||||
- 全面升级桌面与移动端 Web UI,统一浅色/深色主题、弹窗、导航、文件管理和管理工具布局。
|
||||
- 增加中英文界面切换、商业化产品文案、现代化品牌区和紧凑设备状态展示。
|
||||
- 文件管理支持分页、图像预览、原始文件访问、ZIP 下载、后台永久删除和空间统计。
|
||||
- Web SSH 静态资源改为按需加载,并随仓库保留许可证文件。
|
||||
- 自动清理在用户编辑期间不覆盖表单,日志增加结构化时间信息和轻量提示反馈。
|
||||
- 保留多相机独立恢复、异步传感器上电、M3/L640 PyUSB 控制、OTA、Syncthing、OLED、RTC 和 NVMe 能力。
|
||||
- 清除旧工程标识、业务接口说明和不属于 Core 的历史残余。
|
||||
|
||||
完整接口、参数和副作用见 [API.md](API.md)。
|
||||
|
||||
## 运行结构
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `pgu5a_base.py` | Web、API、相机、保存、网络、OTA 和管理逻辑 |
|
||||
| `pgu5a_config.json` | 现场运行配置 |
|
||||
| `assets/` | Logo、按需加载的 xterm.js/xterm.css 及许可证 |
|
||||
| `ota/ota_update.py` | 独立 OTA 更新器 |
|
||||
| `oled/` | SSD1306 OLED 独立服务 |
|
||||
| `ds3231/` | RTC/NTP 同步 |
|
||||
| `nvme/` | NVMe 自动挂载 |
|
||||
| `backups/config/` | 配置编辑器备份,不提交 |
|
||||
| `backups/ota_state.json` | OTA 运行状态,不提交 |
|
||||
|
||||
现场 `pgu5a_config.json` 是配置事实来源,不应当作普通本地差异丢弃。
|
||||
|
||||
## 服务
|
||||
|
||||
| 服务 | 用途 | 正常状态 |
|
||||
| --- | --- | --- |
|
||||
| `pgu5a_base.service` | `enabled`, `active (running)` | Runs `pgu5a_base.py` on port 8080 |
|
||||
| `pgu5a_oled.service` | `enabled`, `active (running)` | Independent OLED status display, reads local web API |
|
||||
| `ds3231-sync.service` | `enabled`, oneshot then normally `inactive` | Restores/syncs system time with DS3231 and NTP |
|
||||
| `nvme-data-auto.service` | `enabled`, `active (exited)` | Mounts `/nvme-data` |
|
||||
| `pgu5a_base.service` | Web、API 和相机 | `active` |
|
||||
| `pgu5a_oled.service` | OLED | `active` |
|
||||
| `ds3231-sync.service` | RTC/NTP 一次性同步 | 完成后可为 `inactive` |
|
||||
| `nvme-data-auto.service` | NVMe 挂载 | `active (exited)` |
|
||||
| `syncthing@root.service` | 文件同步 | `active` |
|
||||
|
||||
Main web command:
|
||||
主服务和 OLED 互相独立,OLED 失败不得影响 Web、API 或相机。
|
||||
|
||||
```bash
|
||||
/opt/miniconda3/envs/py310/bin/python3 /root/PGU5-Core/pgu5a_base.py
|
||||
```
|
||||
## 相机与电源
|
||||
|
||||
Service output is appended to:
|
||||
|
||||
```text
|
||||
/root/PGU5-Core/run.log
|
||||
```
|
||||
|
||||
## Web UI
|
||||
|
||||
The UI title is `PGU5A Open Management Platform` in product meaning, displayed in Chinese in the browser. Navigation currently contains:
|
||||
|
||||
- Imaging unit: acquisition controls, previews, per-camera save, save all, timed save, FPS statistics.
|
||||
- Admin: OTA update, Syncthing, storage auto-cleanup, network settings, config editor.
|
||||
- File manager: browse, preview, download, zip download, and delete saved files under the save root.
|
||||
- Logs: runtime messages and recent log tail.
|
||||
|
||||
The footer displays `Powered by clear492@Digital Plant Lab` in Chinese on the web page.
|
||||
|
||||
## Camera Views
|
||||
|
||||
| Internal target | UI label | Save behavior |
|
||||
| --- | --- | --- |
|
||||
| `dcl` | Passive 3D | Saves JPEG from the DCL camera path |
|
||||
| `m3lite` | Thermal imaging | Saves temperature CSV and grayscale PNG |
|
||||
| `ms602` | MS602 480/550/660/720/840/RGB | Saves six-band TIFF data |
|
||||
| `uvc_rgb` | High-resolution visible light | Saves JPEG and refreshes the cached preview after saving |
|
||||
| `all` | Save all | Attempts all camera targets |
|
||||
| `online` | Online only | Saves only currently `CONNECTED` targets; timed save uses this target |
|
||||
|
||||
## Storage
|
||||
|
||||
Current save root:
|
||||
|
||||
```text
|
||||
/nvme-data/pgu_data
|
||||
```
|
||||
|
||||
File-management APIs are restricted to this root. Deletion is permanent. Storage auto-cleanup can be enabled from the Admin page. When enabled, cleanup is triggered by the storage refresh flow and removes the oldest save directories after free space falls below the configured threshold. The current timed-save session and `Once` are protected.
|
||||
|
||||
## Network And Sync
|
||||
|
||||
Default AP settings are stored in `pgu5a_config.json`:
|
||||
|
||||
- AP interface: `ap0`
|
||||
- Base interface: `wlan0`
|
||||
- AP connection: `PGU5C-AP`
|
||||
- STA connection: `PGU5C-STA`
|
||||
|
||||
The Admin page supports WiFi scanning, AP configuration, STA connect/disconnect, Syncthing status, and opening the Syncthing web UI on port `8384`.
|
||||
|
||||
## OLED Status Display
|
||||
|
||||
A 128x64 SSD1306-compatible OLED is available on `/dev/i2c-0` at address `0x3c`. `oled_status.py` runs as an independent process through `pgu5a_oled.service`; it reads local API data from `http://127.0.0.1:8080/api/status` and rotates through PGU5-BASE, PGU5-CAMERA, PGU5-DATA, PGU5-RESOURCE, and PGU5-SYSTEM pages. The display is rotated 180 degrees for the current physical mounting. The web service does not depend on this process.
|
||||
|
||||
Runtime dependency installed in py310: `luma.oled==3.15.0`.
|
||||
|
||||
The DCL web preview uses a fixed display aspect ratio so the passive-3D tile height stays visually aligned with other preview cards; saved DCL frames are not cropped by this UI-only change.
|
||||
- MS602 和 DCL 唤醒时立即启动。
|
||||
- MS602 六通道按 `ms602.open_stagger_sec` 间隔启动,当前默认 `0.5` 秒。
|
||||
- MS602 每个通道仅在首次启动、休眠唤醒或错误重连后执行开流自检,默认持续 `5.0` 秒;通过后停止灰度、缩放、相位相关和帧差分析,正常取流不再持续承担检测开销。
|
||||
- 开流自检期间分别判断小幅反向垂直抖动、跨帧低相关度大帧差和单帧横向断层;达到阈值时只重启受影响通道,并在重新打开后开始新一轮独立自检。
|
||||
- 管理中心的“多光谱控制”页面可调整两类检测器的全部判断参数;保存写入 `pgu5a_config.json`,无需重启服务或整机。
|
||||
- M3Lite 使用 GPIO 8 延时上电,当前唤醒延迟 `2.0` 秒。
|
||||
- UVC-RGB 使用 GPIO 9 延时上电,当前唤醒延迟 `1.0` 秒。
|
||||
- 休眠会递增唤醒令牌,取消尚未完成的延时启动。
|
||||
- 定时保存和保存队列工作期间暂停自动休眠。
|
||||
|
||||
## OTA
|
||||
|
||||
OTA is available in the Admin page. It checks the latest Gitea release for `clear/PGU5-Core`, can switch to the selected release tag, backs up local dirty diffs to `/root/PGU5-Core_ota_backups`, and restarts `pgu5a_base.service` after applying.
|
||||
界面当前版本来自:
|
||||
|
||||
## Useful Commands
|
||||
```python
|
||||
APP_VERSION = "v2.0.5"
|
||||
APP_RELEASE_TAG = "v2.0.5"
|
||||
```
|
||||
|
||||
Git 信息只用于诊断。在线升级先检查 Gitea Release,只有发现更新后才启用升级按钮。OTA 状态包括:
|
||||
|
||||
```text
|
||||
IDLE CHECKED FETCHING CHECKOUT VERIFYING RESTARTING SUCCESS ERROR
|
||||
```
|
||||
|
||||
更新目标标签使用强制刷新,以支持明确要求的同名标签修订发布:
|
||||
|
||||
```bash
|
||||
git fetch --force origin refs/tags/<tag>:refs/tags/<tag>
|
||||
```
|
||||
|
||||
项目没有手动回滚接口,也不创建项目外 OTA 备份目录。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd /root/PGU5-Core
|
||||
git status --short
|
||||
systemctl status pgu5a_base.service ds3231-sync.service nvme-data-auto.service --no-pager -l
|
||||
tail -n 100 run.log
|
||||
/opt/miniconda3/envs/py310/bin/python3 -m py_compile pgu5a_base.py
|
||||
/opt/miniconda3/envs/py310/bin/python3 -m py_compile \
|
||||
pgu5a_base.py ota/ota_update.py oled/oled_status.py ds3231/sync_ds3231.py
|
||||
/opt/miniconda3/envs/py310/bin/python3 -m json.tool pgu5a_config.json >/dev/null
|
||||
systemctl restart pgu5a_base.service
|
||||
systemctl is-active pgu5a_base.service
|
||||
curl -fsS http://127.0.0.1:8080/api/system/version
|
||||
curl -fsS http://127.0.0.1:8080/api/status
|
||||
curl -fsS http://127.0.0.1:8080/api/network
|
||||
curl -fsS http://127.0.0.1:8080/api/ota/status
|
||||
curl -fsS http://127.0.0.1:8080/api/m3/control
|
||||
curl -fsS http://127.0.0.1:8080/api/syncthing/identity-init/status
|
||||
```
|
||||
|
||||
常规验证不得调用系统重启、关机、Syncthing 身份初始化、文件删除或 M3 参数修改接口。
|
||||
|
||||
## 安全
|
||||
|
||||
- Git 远端不得包含用户名或密码。
|
||||
- 不在源码、配置、文档和提交中保存 SSH 密码、AuthKey、令牌或 Cookie。
|
||||
- 管理 API 没有独立登录层,只应在可信管理网络中开放。
|
||||
- 不提交日志、采集数据、`backups/` 或 `__pycache__/`。
|
||||
@@ -0,0 +1,21 @@
|
||||
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
|
||||
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shiftLine truncated
|
||||
//# sourceMappingURL=xterm.js.map
|
||||
+189
-189
@@ -1,190 +1,190 @@
|
||||
#!/opt/miniconda3/envs/py310/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import time
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from smbus2 import SMBus
|
||||
|
||||
I2C_BUS = 0
|
||||
DS3231_ADDR = 0x68
|
||||
|
||||
NTP_SERVERS = [
|
||||
"ntp.aliyun.com",
|
||||
"ntp.tencent.com",
|
||||
"cn.pool.ntp.org",
|
||||
"pool.ntp.org",
|
||||
"time.google.com",
|
||||
]
|
||||
|
||||
NTP_PORT = 123
|
||||
NTP_TIMEOUT = 3
|
||||
NTP_DELTA = 2208988800
|
||||
|
||||
# 启动后最多等多久给网络/NTP机会
|
||||
NTP_RETRY_SECONDS = 60
|
||||
NTP_RETRY_INTERVAL = 5
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def run_cmd(cmd, check=False):
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=check
|
||||
)
|
||||
|
||||
|
||||
def int_to_bcd(val: int) -> int:
|
||||
return ((val // 10) << 4) | (val % 10)
|
||||
|
||||
|
||||
def bcd_to_int(val: int) -> int:
|
||||
return ((val >> 4) * 10) + (val & 0x0F)
|
||||
|
||||
|
||||
def check_ds3231_present(bus_num: int) -> bool:
|
||||
try:
|
||||
with SMBus(bus_num) as bus:
|
||||
bus.read_byte_data(DS3231_ADDR, 0x00)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ds3231_write_datetime(bus_num: int, dt: datetime):
|
||||
data = [
|
||||
int_to_bcd(dt.second),
|
||||
int_to_bcd(dt.minute),
|
||||
int_to_bcd(dt.hour),
|
||||
int_to_bcd(dt.isoweekday()),
|
||||
int_to_bcd(dt.day),
|
||||
int_to_bcd(dt.month),
|
||||
int_to_bcd(dt.year - 2000),
|
||||
]
|
||||
with SMBus(bus_num) as bus:
|
||||
bus.write_i2c_block_data(DS3231_ADDR, 0x00, data)
|
||||
|
||||
|
||||
def ds3231_read_datetime(bus_num: int) -> datetime:
|
||||
with SMBus(bus_num) as bus:
|
||||
data = bus.read_i2c_block_data(DS3231_ADDR, 0x00, 7)
|
||||
|
||||
sec = bcd_to_int(data[0] & 0x7F)
|
||||
minute = bcd_to_int(data[1] & 0x7F)
|
||||
|
||||
hour_reg = data[2]
|
||||
if hour_reg & 0x40:
|
||||
hour = bcd_to_int(hour_reg & 0x1F)
|
||||
pm = bool(hour_reg & 0x20)
|
||||
if pm and hour != 12:
|
||||
hour += 12
|
||||
elif not pm and hour == 12:
|
||||
hour = 0
|
||||
else:
|
||||
hour = bcd_to_int(hour_reg & 0x3F)
|
||||
|
||||
day = bcd_to_int(data[4] & 0x3F)
|
||||
month = bcd_to_int(data[5] & 0x1F)
|
||||
year = 2000 + bcd_to_int(data[6])
|
||||
|
||||
return datetime(year, month, day, hour, minute, sec)
|
||||
|
||||
|
||||
def set_system_time(dt: datetime):
|
||||
time_str = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
run_cmd(["date", "-s", time_str], check=True)
|
||||
|
||||
|
||||
def is_rtc_time_reasonable(dt: datetime) -> bool:
|
||||
return 2024 <= dt.year <= 2099
|
||||
|
||||
|
||||
def query_ntp_time_once(server: str, timeout: int = 3):
|
||||
packet = b"\x1b" + 47 * b"\0"
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(timeout)
|
||||
s.sendto(packet, (server, NTP_PORT))
|
||||
data, _ = s.recvfrom(48)
|
||||
|
||||
if len(data) < 48:
|
||||
raise RuntimeError("NTP 响应长度不足")
|
||||
|
||||
transmit_timestamp = struct.unpack("!I", data[40:44])[0]
|
||||
unix_time = transmit_timestamp - NTP_DELTA
|
||||
return datetime.fromtimestamp(unix_time)
|
||||
|
||||
|
||||
def query_ntp_time():
|
||||
last_err = "未开始查询"
|
||||
for server in NTP_SERVERS:
|
||||
try:
|
||||
dt = query_ntp_time_once(server, timeout=NTP_TIMEOUT)
|
||||
return True, dt, server, f"NTP 查询成功: {server}"
|
||||
except Exception as e:
|
||||
last_err = f"{server} 查询失败: {e}"
|
||||
return False, None, None, last_err
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
log("[ERROR] 请以 root 运行")
|
||||
return 1
|
||||
|
||||
log(f"[INFO] 使用 I2C 总线: /dev/i2c-{I2C_BUS}")
|
||||
log(f"[INFO] DS3231 地址: 0x{DS3231_ADDR:02X}")
|
||||
|
||||
if not check_ds3231_present(I2C_BUS):
|
||||
log("[ERROR] 未检测到 DS3231")
|
||||
return 2
|
||||
|
||||
# 1) 启动早期先从 RTC 恢复
|
||||
rtc_dt = ds3231_read_datetime(I2C_BUS)
|
||||
log(f"[INFO] DS3231 当前时间: {rtc_dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
if is_rtc_time_reasonable(rtc_dt):
|
||||
set_system_time(rtc_dt)
|
||||
log("[INFO] 已先使用 DS3231 时间设置系统时间")
|
||||
else:
|
||||
log("[WARN] DS3231 时间无效,跳过 RTC 恢复")
|
||||
|
||||
# 2) 再给网络/NTP一个等待窗口
|
||||
deadline = time.time() + NTP_RETRY_SECONDS
|
||||
ntp_ok = False
|
||||
ntp_dt = None
|
||||
ntp_msg = ""
|
||||
|
||||
while time.time() < deadline:
|
||||
ntp_ok, ntp_dt, ntp_server, ntp_msg = query_ntp_time()
|
||||
log(f"[INFO] {ntp_msg}")
|
||||
if ntp_ok:
|
||||
break
|
||||
time.sleep(NTP_RETRY_INTERVAL)
|
||||
|
||||
log(f"[INFO] NTP最终判定: {'成功' if ntp_ok else '失败'}")
|
||||
|
||||
if ntp_ok:
|
||||
set_system_time(ntp_dt)
|
||||
log(f"[INFO] 已将 NTP 时间设置为系统时间: {ntp_dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
ds3231_write_datetime(I2C_BUS, ntp_dt)
|
||||
log("[INFO] 已将 NTP 时间写入 DS3231")
|
||||
else:
|
||||
log("[WARN] NTP 失败,保留当前系统时间不变")
|
||||
|
||||
verify_rtc = ds3231_read_datetime(I2C_BUS)
|
||||
verify_sys = datetime.now()
|
||||
log(f"[INFO] DS3231 最终时间: {verify_rtc.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
log(f"[INFO] 系统最终时间: {verify_sys.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
#!/opt/miniconda3/envs/py310/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import time
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from smbus2 import SMBus
|
||||
|
||||
I2C_BUS = 0
|
||||
DS3231_ADDR = 0x68
|
||||
|
||||
NTP_SERVERS = [
|
||||
"ntp.aliyun.com",
|
||||
"ntp.tencent.com",
|
||||
"cn.pool.ntp.org",
|
||||
"pool.ntp.org",
|
||||
"time.google.com",
|
||||
]
|
||||
|
||||
NTP_PORT = 123
|
||||
NTP_TIMEOUT = 3
|
||||
NTP_DELTA = 2208988800
|
||||
|
||||
# 启动后最多等多久给网络/NTP机会
|
||||
NTP_RETRY_SECONDS = 60
|
||||
NTP_RETRY_INTERVAL = 5
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def run_cmd(cmd, check=False):
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=check
|
||||
)
|
||||
|
||||
|
||||
def int_to_bcd(val: int) -> int:
|
||||
return ((val // 10) << 4) | (val % 10)
|
||||
|
||||
|
||||
def bcd_to_int(val: int) -> int:
|
||||
return ((val >> 4) * 10) + (val & 0x0F)
|
||||
|
||||
|
||||
def check_ds3231_present(bus_num: int) -> bool:
|
||||
try:
|
||||
with SMBus(bus_num) as bus:
|
||||
bus.read_byte_data(DS3231_ADDR, 0x00)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ds3231_write_datetime(bus_num: int, dt: datetime):
|
||||
data = [
|
||||
int_to_bcd(dt.second),
|
||||
int_to_bcd(dt.minute),
|
||||
int_to_bcd(dt.hour),
|
||||
int_to_bcd(dt.isoweekday()),
|
||||
int_to_bcd(dt.day),
|
||||
int_to_bcd(dt.month),
|
||||
int_to_bcd(dt.year - 2000),
|
||||
]
|
||||
with SMBus(bus_num) as bus:
|
||||
bus.write_i2c_block_data(DS3231_ADDR, 0x00, data)
|
||||
|
||||
|
||||
def ds3231_read_datetime(bus_num: int) -> datetime:
|
||||
with SMBus(bus_num) as bus:
|
||||
data = bus.read_i2c_block_data(DS3231_ADDR, 0x00, 7)
|
||||
|
||||
sec = bcd_to_int(data[0] & 0x7F)
|
||||
minute = bcd_to_int(data[1] & 0x7F)
|
||||
|
||||
hour_reg = data[2]
|
||||
if hour_reg & 0x40:
|
||||
hour = bcd_to_int(hour_reg & 0x1F)
|
||||
pm = bool(hour_reg & 0x20)
|
||||
if pm and hour != 12:
|
||||
hour += 12
|
||||
elif not pm and hour == 12:
|
||||
hour = 0
|
||||
else:
|
||||
hour = bcd_to_int(hour_reg & 0x3F)
|
||||
|
||||
day = bcd_to_int(data[4] & 0x3F)
|
||||
month = bcd_to_int(data[5] & 0x1F)
|
||||
year = 2000 + bcd_to_int(data[6])
|
||||
|
||||
return datetime(year, month, day, hour, minute, sec)
|
||||
|
||||
|
||||
def set_system_time(dt: datetime):
|
||||
time_str = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
run_cmd(["date", "-s", time_str], check=True)
|
||||
|
||||
|
||||
def is_rtc_time_reasonable(dt: datetime) -> bool:
|
||||
return 2024 <= dt.year <= 2099
|
||||
|
||||
|
||||
def query_ntp_time_once(server: str, timeout: int = 3):
|
||||
packet = b"\x1b" + 47 * b"\0"
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(timeout)
|
||||
s.sendto(packet, (server, NTP_PORT))
|
||||
data, _ = s.recvfrom(48)
|
||||
|
||||
if len(data) < 48:
|
||||
raise RuntimeError("NTP 响应长度不足")
|
||||
|
||||
transmit_timestamp = struct.unpack("!I", data[40:44])[0]
|
||||
unix_time = transmit_timestamp - NTP_DELTA
|
||||
return datetime.fromtimestamp(unix_time)
|
||||
|
||||
|
||||
def query_ntp_time():
|
||||
last_err = "未开始查询"
|
||||
for server in NTP_SERVERS:
|
||||
try:
|
||||
dt = query_ntp_time_once(server, timeout=NTP_TIMEOUT)
|
||||
return True, dt, server, f"NTP 查询成功: {server}"
|
||||
except Exception as e:
|
||||
last_err = f"{server} 查询失败: {e}"
|
||||
return False, None, None, last_err
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
log("[ERROR] 请以 root 运行")
|
||||
return 1
|
||||
|
||||
log(f"[INFO] 使用 I2C 总线: /dev/i2c-{I2C_BUS}")
|
||||
log(f"[INFO] DS3231 地址: 0x{DS3231_ADDR:02X}")
|
||||
|
||||
if not check_ds3231_present(I2C_BUS):
|
||||
log("[ERROR] 未检测到 DS3231")
|
||||
return 2
|
||||
|
||||
# 1) 启动早期先从 RTC 恢复
|
||||
rtc_dt = ds3231_read_datetime(I2C_BUS)
|
||||
log(f"[INFO] DS3231 当前时间: {rtc_dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
if is_rtc_time_reasonable(rtc_dt):
|
||||
set_system_time(rtc_dt)
|
||||
log("[INFO] 已先使用 DS3231 时间设置系统时间")
|
||||
else:
|
||||
log("[WARN] DS3231 时间无效,跳过 RTC 恢复")
|
||||
|
||||
# 2) 再给网络/NTP一个等待窗口
|
||||
deadline = time.time() + NTP_RETRY_SECONDS
|
||||
ntp_ok = False
|
||||
ntp_dt = None
|
||||
ntp_msg = ""
|
||||
|
||||
while time.time() < deadline:
|
||||
ntp_ok, ntp_dt, ntp_server, ntp_msg = query_ntp_time()
|
||||
log(f"[INFO] {ntp_msg}")
|
||||
if ntp_ok:
|
||||
break
|
||||
time.sleep(NTP_RETRY_INTERVAL)
|
||||
|
||||
log(f"[INFO] NTP最终判定: {'成功' if ntp_ok else '失败'}")
|
||||
|
||||
if ntp_ok:
|
||||
set_system_time(ntp_dt)
|
||||
log(f"[INFO] 已将 NTP 时间设置为系统时间: {ntp_dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
ds3231_write_datetime(I2C_BUS, ntp_dt)
|
||||
log("[INFO] 已将 NTP 时间写入 DS3231")
|
||||
else:
|
||||
log("[WARN] NTP 失败,保留当前系统时间不变")
|
||||
|
||||
verify_rtc = ds3231_read_datetime(I2C_BUS)
|
||||
verify_sys = datetime.now()
|
||||
log(f"[INFO] DS3231 最终时间: {verify_rtc.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
log(f"[INFO] 系统最终时间: {verify_sys.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File renamed without changes.
@@ -6,13 +6,13 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/PGU5-Core
|
||||
WorkingDirectory=/root/PGU5-Core/oled
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/opt/miniconda3/envs/py310/bin/python3 /root/PGU5-Core/oled_status.py
|
||||
ExecStart=/opt/miniconda3/envs/py310/bin/python3 /root/PGU5-Core/oled/oled_status.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
StandardOutput=append:/root/PGU5-Core/oled_status.log
|
||||
StandardError=append:/root/PGU5-Core/oled_status.log
|
||||
StandardOutput=append:/root/PGU5-Core/oled/oled_status.log
|
||||
StandardError=append:/root/PGU5-Core/oled/oled_status.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
STATE_FILE = REPO_ROOT / "backups" / "ota_state.json"
|
||||
SERVICE_NAME = "pgu5a_base.service"
|
||||
|
||||
|
||||
def run(args: list[str], timeout: float = 60.0) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
args,
|
||||
cwd=str(REPO_ROOT),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or result.stderr or "").strip()
|
||||
|
||||
|
||||
def clock_text() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def read_state() -> dict:
|
||||
if not STATE_FILE.exists():
|
||||
return {"state": "IDLE", "message": "\u672a\u68c0\u67e5\u66f4\u65b0", "logs": []}
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("logs", [])
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return {"state": "IDLE", "message": "OTA \u72b6\u6001\u8bfb\u53d6\u5931\u8d25", "logs": []}
|
||||
|
||||
|
||||
def write_state(**updates: object) -> dict:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
state = read_state()
|
||||
logs = list(state.get("logs", []))
|
||||
message = str(updates.get("message", "") or "")
|
||||
if message:
|
||||
logs.insert(0, {"time": time.time(), "time_text": clock_text(), "message": message})
|
||||
logs = logs[:8]
|
||||
state.update(updates)
|
||||
state["updated_at"] = time.time()
|
||||
state["updated_at_text"] = clock_text()
|
||||
state["logs"] = logs
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return state
|
||||
|
||||
|
||||
def ensure_clean_enough() -> None:
|
||||
result = run(["git", "status", "--porcelain", "--untracked-files=no"], timeout=10.0)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"\u8bfb\u53d6\u5de5\u4f5c\u533a\u72b6\u6001\u5931\u8d25: {output(result)}")
|
||||
allowed_prefixes = ("backups/", "__pycache__/", "ota/__pycache__/", "oled/__pycache__/", "ds3231/__pycache__/")
|
||||
allowed_suffixes = (".pyc",)
|
||||
unexpected: list[str] = []
|
||||
for raw in result.stdout.splitlines():
|
||||
path = (raw[3:] if len(raw) > 3 else raw).strip().strip('"').replace("\\", "/")
|
||||
if not path or path.startswith(allowed_prefixes) or path.endswith(allowed_suffixes):
|
||||
continue
|
||||
unexpected.append(raw)
|
||||
if unexpected:
|
||||
print("Working tree has local changes; OTA will overwrite application files: " + " | ".join(unexpected))
|
||||
|
||||
|
||||
def verify_program() -> None:
|
||||
result = run([sys.executable, "-m", "py_compile", str(REPO_ROOT / "pgu5a_base.py")], timeout=60.0)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"\u7a0b\u5e8f\u6821\u9a8c\u5931\u8d25: {output(result)}")
|
||||
|
||||
|
||||
def restart_service() -> None:
|
||||
subprocess.Popen(
|
||||
["/bin/bash", "-lc", f"sleep 1 && systemctl restart {SERVICE_NAME}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def upgrade(tag: str) -> int:
|
||||
target_tag = str(tag or "").strip()
|
||||
if not target_tag:
|
||||
write_state(state="ERROR", message="\u672a\u63d0\u4f9b\u76ee\u6807\u7248\u672c")
|
||||
return 2
|
||||
try:
|
||||
write_state(state="FETCHING", message=f"\u6b63\u5728\u4e0b\u8f7d\u7248\u672c\u4fe1\u606f: {target_tag}", target_tag=target_tag)
|
||||
ensure_clean_enough()
|
||||
tag_ref = f"refs/tags/{target_tag}:refs/tags/{target_tag}"
|
||||
result = run(["git", "fetch", "--force", "origin", tag_ref], timeout=180.0)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"\u62c9\u53d6\u7248\u672c\u5931\u8d25: {output(result)}")
|
||||
|
||||
result = run(["git", "rev-parse", "--verify", f"{target_tag}^{{commit}}"], timeout=20.0)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"\u672a\u627e\u5230\u7248\u672c\u6807\u7b7e: {target_tag}")
|
||||
|
||||
write_state(state="CHECKOUT", message=f"\u6b63\u5728\u5207\u6362\u7248\u672c: {target_tag}", target_tag=target_tag)
|
||||
result = run(["git", "checkout", "-f", target_tag], timeout=120.0)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"\u5207\u6362\u7248\u672c\u5931\u8d25: {output(result)}")
|
||||
|
||||
write_state(state="VERIFYING", message="\u6b63\u5728\u9a8c\u8bc1\u7a0b\u5e8f", target_tag=target_tag)
|
||||
verify_program()
|
||||
|
||||
write_state(state="RESTARTING", message="\u6b63\u5728\u91cd\u542f\u670d\u52a1", target_tag=target_tag)
|
||||
restart_service()
|
||||
write_state(state="SUCCESS", message=f"\u5347\u7ea7\u6210\u529f: {target_tag}", current_tag=target_tag, target_tag=target_tag)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
write_state(state="ERROR", message=f"\u5347\u7ea7\u5931\u8d25: {exc}", target_tag=target_tag)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="PGU5-Core OTA updater")
|
||||
parser.add_argument("tag", help="release tag, for example v1.1.8")
|
||||
args = parser.parse_args()
|
||||
return upgrade(args.tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+10260
-1363
File diff suppressed because it is too large.
Load diff
+37
-9
@@ -5,14 +5,15 @@
|
||||
"enabled": true,
|
||||
"ifname": "ap0",
|
||||
"base_ifname": "wlan0",
|
||||
"connection_name": "PGU5C-AP",
|
||||
"connection_name": "PGU5A-AP",
|
||||
"ssid": "PGU5A-Base",
|
||||
"password": "Bone1644"
|
||||
"password": "",
|
||||
"ipv4_address": "192.168.88.1/24"
|
||||
},
|
||||
"sta": {
|
||||
"enabled": false,
|
||||
"ifname": "wlan0",
|
||||
"connection_name": "PGU5C-STA",
|
||||
"connection_name": "PGU5A-STA",
|
||||
"ssid": "",
|
||||
"password": ""
|
||||
}
|
||||
@@ -37,13 +38,32 @@
|
||||
"save_max_pending_groups": 2,
|
||||
"sensor_auto_sleep_minutes": 0.0,
|
||||
"sensor_wake_wait_sec": 7.0,
|
||||
"auto_cleanup_enabled": false,
|
||||
"auto_cleanup_enabled": true,
|
||||
"auto_cleanup_min_free_gb": 30.0
|
||||
},
|
||||
"ms602": {
|
||||
"target_fps": 8,
|
||||
"gain": 8,
|
||||
"d12": 0
|
||||
"d12": 0,
|
||||
"open_stagger_sec": 0.5,
|
||||
"read_fail_restart_count": 5,
|
||||
"channel_reconnect_delay_sec": 0.8,
|
||||
"startup_detection_window_sec": 5.0,
|
||||
"jitter_detection_enabled": true,
|
||||
"jitter_shift_min": 0.04,
|
||||
"jitter_shift_max": 0.5,
|
||||
"jitter_horizontal_max": 0.25,
|
||||
"jitter_restart_reversals": 3,
|
||||
"jitter_window_sec": 3.0,
|
||||
"jitter_cancellation_ratio": 0.35,
|
||||
"jitter_total_shift_min": 0.35,
|
||||
"frame_integrity_detection_enabled": true,
|
||||
"frame_integrity_phase_response_min": 0.55,
|
||||
"frame_integrity_delta_min": 8.0,
|
||||
"frame_integrity_restart_events": 2,
|
||||
"frame_integrity_window_sec": 3.0,
|
||||
"frame_tearing_gradient_ratio_min": 2.0,
|
||||
"frame_tearing_row_diff_min": 5.5
|
||||
},
|
||||
"m3lite": {
|
||||
"width": 640,
|
||||
@@ -53,10 +73,17 @@
|
||||
"vid": "0x3474",
|
||||
"pid": "0x750e",
|
||||
"vid2": "0x0424",
|
||||
"pid2": "0x6000"
|
||||
"pid2": "0x6000",
|
||||
"startup_nuc_check_sec": 3.0,
|
||||
"wake_power_delay_sec": 2.0,
|
||||
"control_poll_sec": 5.0,
|
||||
"control_interface": 3,
|
||||
"control_ep_out": "0x05",
|
||||
"control_ep_in": "0x85",
|
||||
"control_timeout_ms": 2500
|
||||
},
|
||||
"dcl": {
|
||||
"camera_name": "DECXIN Camera",
|
||||
"camera_name": "ClearDCL00011",
|
||||
"width": 3840,
|
||||
"height": 1080,
|
||||
"fps": 30,
|
||||
@@ -69,9 +96,10 @@
|
||||
"fps": 30,
|
||||
"save_jpeg_quality": 95,
|
||||
"relay_gpio_pin": 9,
|
||||
"relay_power_cycle_sec": 3.0
|
||||
"relay_power_cycle_sec": 3.0,
|
||||
"wake_power_delay_sec": 1.0
|
||||
},
|
||||
"gui": {
|
||||
"disk_refresh_sec": 5.0
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user