Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cb71cbe9f | ||
|
|
73907320c8 | ||
|
|
ad25c08eb1 | ||
|
|
5d541d5ef6 | ||
|
|
a8ca071b56 | ||
|
|
419d27b570 | ||
|
|
73a60b4866 | ||
|
|
576006b2fd | ||
|
|
671fde1bfc | ||
|
|
99e086bd25 | ||
|
|
f42ea9d4bd | ||
|
|
fd4e2bf0f4 | ||
|
|
5402dddd15 | ||
|
|
6ca19b362b | ||
|
|
adfa5524b8 | ||
|
|
ac9bc4befd | ||
|
|
ad22dcced7 | ||
|
|
249af5a3e3 | ||
|
|
a84f4895ef | ||
|
|
6fc428ef18 | ||
|
|
34f334dd23 | ||
|
|
d3ccca4b37 | ||
|
|
3a302b9c78 | ||
|
|
83ec3f4379 | ||
|
|
2ef8007ccd | ||
|
|
f65499f34b | ||
|
|
2b70db10fc | ||
|
|
62db207c45 | ||
|
|
b28ee63f38 | ||
|
|
cb0eca7aab |
@@ -1,127 +1,205 @@
|
|||||||
# Pigway Pi Control v3.1.0 — Release
|
# PIGWay Pi Control v3.6.0
|
||||||
|
|
||||||
适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。
|
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
||||||
|
|
||||||
本包遵循一个原则:
|
v3.6.0 在本地硬件监控、显示仲裁和可配置 RGB 告警基础上,增加轻量状态/日志/配置 API 与同源 Web 管理界面。
|
||||||
|
|
||||||
> **一个程序 + 一个用户配置文件 + 一个说明文档。**
|
## 已有核心功能
|
||||||
|
|
||||||
正常使用时,不需要修改源码。
|
- SSD1306 128×32 OLED,四行固定主页
|
||||||
|
- CPU / 内存 / 温度 / 磁盘监控
|
||||||
|
- Wi-Fi RSSI / Ethernet 链路速率
|
||||||
|
- 当前默认路由与 IP 同步切换
|
||||||
|
- 风扇自动温控、回差防抖
|
||||||
|
- RGB:自定义颜色表示故障对象,频闪速度表示严重程度
|
||||||
|
- systemd 服务与普通进程监控
|
||||||
|
- Raspberry Pi PWR / ACT 运行时 Dark Mode
|
||||||
|
|
||||||
---
|
主页示例:
|
||||||
|
|
||||||
## 1. 功能
|
|
||||||
|
|
||||||
### OLED
|
|
||||||
|
|
||||||
配套 OLED 按卖家原生参数锁定为:
|
|
||||||
|
|
||||||
- SSD1306
|
|
||||||
- 128×32
|
|
||||||
- I²C `0x3C`
|
|
||||||
- MUX `0x1F`
|
|
||||||
- COM Pins `0x02`
|
|
||||||
- Page `0..3`
|
|
||||||
- 卖家同源 5×7 点阵字体
|
|
||||||
- 4行:`0 / 8 / 16 / 24`
|
|
||||||
|
|
||||||
主页:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CPU 18.6% MEM 16.2%
|
CPU 18.6% MEM 16.2%
|
||||||
TMP 46.3C FAN OFF
|
TMP 46.3C FAN OFF
|
||||||
DSK 22.4% NET OK
|
DSK 22.4% WIF -32
|
||||||
IP4 192.168.89.130
|
IP4 192.168.89.130
|
||||||
```
|
```
|
||||||
|
|
||||||
IPv6 地址时自动显示 `IP6`。
|
## v3.5.0 System Health
|
||||||
|
|
||||||
主页左右两栏都是固定位置、左对齐。数据默认每秒刷新。
|
### 1. 供电与降频健康
|
||||||
|
|
||||||
有告警时,OLED 会在 HOME 与所有当前告警之间轮播;默认每页停留10秒。
|
周期读取:
|
||||||
|
|
||||||
### 风扇
|
|
||||||
|
|
||||||
默认:
|
|
||||||
|
|
||||||
```text
|
|
||||||
< 50°C OFF
|
|
||||||
50~53°C L1
|
|
||||||
53~56°C L2
|
|
||||||
56~59°C L3
|
|
||||||
59~62°C L4
|
|
||||||
>= 62°C MAX
|
|
||||||
```
|
|
||||||
|
|
||||||
带降档回差,避免临界温度反复升降档。
|
|
||||||
|
|
||||||
### RGB 告警
|
|
||||||
|
|
||||||
正常状态 RGB 完全熄灭。
|
|
||||||
|
|
||||||
颜色表示“什么对象出问题”:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CPU 黄
|
|
||||||
温度 红
|
|
||||||
内存 紫
|
|
||||||
磁盘 橙
|
|
||||||
网络 蓝
|
|
||||||
服务/进程 青
|
|
||||||
```
|
|
||||||
|
|
||||||
所有告警都使用闪烁;闪烁速度表示严重程度:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Warning 慢
|
|
||||||
Critical 中
|
|
||||||
Emergency 快
|
|
||||||
```
|
|
||||||
|
|
||||||
亮度可配置,默认10%,适合暗环境。
|
|
||||||
|
|
||||||
### Dark Mode
|
|
||||||
|
|
||||||
默认关闭 Raspberry Pi 自身:
|
|
||||||
|
|
||||||
- PWR LED
|
|
||||||
- ACT LED
|
|
||||||
|
|
||||||
散热板 D1/D8 黄色硬件状态灯和独立电源模块绿色灯不属于已确认的软件控制接口,本程序不操作。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 全新 Raspberry Pi OS 安装
|
|
||||||
|
|
||||||
### 第一步:启用 I²C
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo raspi-config
|
vcgencmd get_throttled
|
||||||
```
|
```
|
||||||
|
|
||||||
进入:
|
区分:
|
||||||
|
|
||||||
|
- 当前欠压:立即 `POWER LOW / UNDERVOLT` 告警
|
||||||
|
- 当前 CPU throttled / frequency capped:立即告警
|
||||||
|
- 本次启动历史上曾欠压/降频:只写一次历史事件日志,不持续 RGB 告警
|
||||||
|
- 状态恢复:写 `RECOVERED` 类事件
|
||||||
|
|
||||||
|
注意:这里检测的是 **Pi 的供电质量**,不是 UPS 电池百分比。
|
||||||
|
|
||||||
|
### 2. 文件系统健康
|
||||||
|
|
||||||
|
检测根文件系统 `/` 是否被内核切成只读。如果出现只读状态,产生严重告警:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Interface Options
|
FILESYSTEM RO
|
||||||
→ I2C
|
ROOT READ ONLY
|
||||||
→ Enable
|
CHECK STORAGE
|
||||||
```
|
```
|
||||||
|
|
||||||
然后:
|
### 3. 本机诊断信息
|
||||||
|
|
||||||
|
内部维护:
|
||||||
|
|
||||||
|
- uptime
|
||||||
|
- load average
|
||||||
|
- CPU 当前频率
|
||||||
|
- 当前/历史 throttled flags
|
||||||
|
- 当前主网络接口 / IP
|
||||||
|
- 根文件系统只读状态
|
||||||
|
- sensor / network / service / health 最近成功时间
|
||||||
|
- 主循环异常计数
|
||||||
|
- I²C相关异常计数基础字段
|
||||||
|
|
||||||
|
### 4. DisplayController、OLED 与 RGB
|
||||||
|
|
||||||
|
每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个最高优先级 owner;同一 ID 更新原请求而不重复入队,恢复时删除请求,同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。历史 power flags 只进入 diagnose 和一次性 Journal 记录。
|
||||||
|
|
||||||
|
### 5. RGB 正常状态与告警引擎
|
||||||
|
|
||||||
|
没有 active alert 时,RGB 默认全灭。`[led] normal_mode` 可改为自定义颜色常亮/闪烁,或 MCU 已确认的流水、呼吸、跑马、彩虹和炫彩效果。常亮与闪烁使用 0..255 的自定义 R/G/B;内置流水/呼吸使用 MCU 固定的红、绿、蓝、黄、紫、青、白七色和慢/中/快三档速度,跑马/彩虹/炫彩由 MCU 自行配色。内置效果只在模式切换时按 speed → color → effect 的实机校准顺序写一次寄存器,不循环刷 I²C。
|
||||||
|
|
||||||
|
任何 active alert 都会立即抢占正常效果。RGB 告警模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
|
||||||
|
|
||||||
|
### 6. 结构化事件日志
|
||||||
|
|
||||||
|
日志继续交给 systemd journal,不额外制造长期 `.log` 文件。
|
||||||
|
|
||||||
|
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
level=INFO event=START version=3.6.0
|
||||||
|
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
|
||||||
|
level=WARN event=NETWORK_DOWN ...
|
||||||
|
level=INFO event=NETWORK_RECOVERED ...
|
||||||
|
level=WARN event=WATCH_DOWN name=AWESUN ...
|
||||||
|
level=INFO event=WATCH_RECOVERED name=AWESUN ...
|
||||||
|
level=WARN event=POWER_LOW raw=0x1
|
||||||
|
level=INFO event=POWER_RECOVERED raw=0x0
|
||||||
|
level=WARN event=ALERT_ACTIVE alert=TEMP_HIGH ...
|
||||||
|
level=INFO event=ALERT_RECOVERED ...
|
||||||
|
```
|
||||||
|
|
||||||
|
实时查看:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo reboot
|
journalctl -u pigway-pi-control -f
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第二步:解压本安装包
|
只看本次启动:
|
||||||
|
|
||||||
进入解压后的目录:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd pigway-pi-control-v3.1.0
|
journalctl -u pigway-pi-control -b --no-pager
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三步:安装
|
搜索某类事件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u pigway-pi-control --no-pager | grep 'event=POWER'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 诊断命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出 Agent 版本、系统 uptime、配置路径、load、CPU 频率、根文件系统、全部当前/历史 power 位、主网络接口、完整 IP 和 Internet 状态,以及所有 service/process watch 状态。
|
||||||
|
|
||||||
|
诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。
|
||||||
|
|
||||||
|
RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜色配合低频亮灭最稳定;每次点亮完整写入 selector/R/G/B,寄存器间隔默认10ms。旧版 `brightness`、呼吸周期和颜色选项可以继续留在用户配置中,但频闪引擎会忽略它们。
|
||||||
|
|
||||||
|
### 8. 关机收尾
|
||||||
|
|
||||||
|
收到 systemd SIGTERM / 系统关机时:
|
||||||
|
|
||||||
|
1. 写 STOP 日志
|
||||||
|
2. RGB OFF
|
||||||
|
3. 风扇 OFF
|
||||||
|
4. OLED 清屏并 Display OFF
|
||||||
|
5. 关闭 I²C handle
|
||||||
|
|
||||||
|
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
|
||||||
|
|
||||||
|
## v3.6.0 API 与 Web
|
||||||
|
|
||||||
|
安装后访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://树莓派IP:6001/
|
||||||
|
```
|
||||||
|
|
||||||
|
Web 与 JSON API 由独立的 `pigway-pi-control-api.service` 提供。API 服务不访问 I²C;硬件 Agent 每秒原子更新 `/run/pigway-pi-control/status.json`,API 只读取该快照。默认监听地址和端口可在 `[api]` 中修改:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[api]
|
||||||
|
bind = 0.0.0.0
|
||||||
|
port = 6001
|
||||||
|
log_limit = 200
|
||||||
|
```
|
||||||
|
|
||||||
|
只读接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/health
|
||||||
|
GET /api/v1/status
|
||||||
|
GET /api/v1/logs?limit=200
|
||||||
|
GET /api/v1/config
|
||||||
|
```
|
||||||
|
|
||||||
|
配置写入接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PUT /api/v1/config
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
读取 token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cat /etc/pigway-pi-control-api.token
|
||||||
|
```
|
||||||
|
|
||||||
|
配置更新仅允许 `[services]`、`[processes]`、`[alerts]`、`[fan]`、`[led]`、`[timing]`、`[dark_mode]`、`[display]` 和 `[api]`。每次写入先生成时间戳备份,再原地更新目标键并重启硬件 Agent;修改 `[api]` 时 API 服务也会自动重启。
|
||||||
|
|
||||||
|
## 用户配置
|
||||||
|
|
||||||
|
正式配置只有一个:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/pigway-pi-control.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/pigway-pi-control.conf
|
||||||
|
sudo systemctl restart pigway-pi-control
|
||||||
|
```
|
||||||
|
|
||||||
|
配置文件内已经写明每项用途、单位和修改方式。
|
||||||
|
|
||||||
|
## 安装 / 升级测试分支
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh
|
sudo ./install.sh
|
||||||
@@ -129,253 +207,29 @@ sudo ./install.sh
|
|||||||
|
|
||||||
安装器会:
|
安装器会:
|
||||||
|
|
||||||
1. 检查 `/dev/i2c-1`
|
- 安装必要依赖
|
||||||
2. 等待 APT/DPKG 锁
|
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
|
||||||
3. 安装必要的小型系统依赖
|
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
|
||||||
4. 检查 I²C 总线
|
- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理
|
||||||
5. 自动停止并禁用卖家旧 `temp-control.service`
|
- 安装字体、程序、配置和 systemd unit
|
||||||
6. 安装正式程序
|
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
|
||||||
7. 安装用户配置
|
- 生成只读 root token,安装 API/Web 文件
|
||||||
8. 安装 systemd 服务并开机自启
|
- 启动硬件 Agent 与 API/Web 服务
|
||||||
9. 执行安装后自检
|
- 执行版本、OLED、UI、P0健康监控和诊断入口自检
|
||||||
|
|
||||||
安装成功后查看:
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status pigway-pi-control --no-pager -l
|
systemctl status pigway-pi-control --no-pager -l
|
||||||
```
|
journalctl -u pigway-pi-control-api -f
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 安装后的文件在哪里
|
|
||||||
|
|
||||||
### 用户应该修改
|
|
||||||
|
|
||||||
只有:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
编辑:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
保存后:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart pigway-pi-control
|
|
||||||
```
|
|
||||||
|
|
||||||
### 用户通常不要修改
|
|
||||||
|
|
||||||
程序:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/usr/local/sbin/pigway-pi-control
|
|
||||||
```
|
|
||||||
|
|
||||||
systemd:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/etc/systemd/system/pigway-pi-control.service
|
|
||||||
```
|
|
||||||
|
|
||||||
这些由安装包维护。
|
|
||||||
|
|
||||||
因此安装成功后,解压出来的 `pigway-pi-control-v3.1.0/` 文件夹可以删除。
|
|
||||||
|
|
||||||
建议把 ZIP 保存到 Mac / NAS,供以后重装。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 用户可以自定义什么
|
|
||||||
|
|
||||||
全部集中在:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
其中包括:
|
|
||||||
|
|
||||||
- 数据采样周期
|
|
||||||
- OLED刷新周期
|
|
||||||
- 告警页面停留时间
|
|
||||||
- 网络检查周期
|
|
||||||
- 服务检查周期
|
|
||||||
- 风扇温度曲线
|
|
||||||
- 风扇降档回差
|
|
||||||
- CPU告警阈值和持续时间
|
|
||||||
- 内存告警阈值和持续时间
|
|
||||||
- 温度普通/严重告警
|
|
||||||
- 磁盘普通/严重告警
|
|
||||||
- RGB亮度
|
|
||||||
- Warning/Critical/Emergency 闪烁周期
|
|
||||||
- Pi PWR/ACT灯
|
|
||||||
- 要监控的systemd服务
|
|
||||||
- 要监控的普通进程
|
|
||||||
|
|
||||||
**每个参数在配置文件中都已经写有详细中文注释。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 增加一个服务监控
|
|
||||||
|
|
||||||
编辑:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
找到:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[services]
|
|
||||||
SSH = ssh.service
|
|
||||||
AWESUN = runawesun.service
|
|
||||||
```
|
|
||||||
|
|
||||||
例如安装了 Tailscale:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
TAILSCALE = tailscaled.service
|
|
||||||
```
|
|
||||||
|
|
||||||
保存后:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart pigway-pi-control
|
|
||||||
```
|
|
||||||
|
|
||||||
先确认服务真实名称:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status tailscaled.service
|
|
||||||
```
|
|
||||||
|
|
||||||
暂时不想监控,行首加 `#`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# TAILSCALE = tailscaled.service
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 监控没有 systemd service 的程序
|
|
||||||
|
|
||||||
配置:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[processes]
|
|
||||||
MYAPP = /opt/myapp/server
|
|
||||||
```
|
|
||||||
|
|
||||||
先测试:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pgrep -af '/opt/myapp/server'
|
|
||||||
```
|
|
||||||
|
|
||||||
能找到进程再加入监控。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 常用维护
|
|
||||||
|
|
||||||
状态:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status pigway-pi-control --no-pager -l
|
|
||||||
```
|
|
||||||
|
|
||||||
日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u pigway-pi-control -f
|
journalctl -u pigway-pi-control -f
|
||||||
```
|
|
||||||
|
|
||||||
重启:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart pigway-pi-control
|
sudo systemctl restart pigway-pi-control
|
||||||
|
sudo systemctl restart pigway-pi-control-api
|
||||||
|
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||||
```
|
```
|
||||||
|
|
||||||
停止:
|
## 版本路线
|
||||||
|
|
||||||
```bash
|
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
||||||
sudo systemctl stop pigway-pi-control
|
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
||||||
```
|
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
||||||
|
|
||||||
启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl start pigway-pi-control
|
|
||||||
```
|
|
||||||
|
|
||||||
查看配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat /etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 升级
|
|
||||||
|
|
||||||
新版安装包直接:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
安装器会保留用户已有的 `[services]` 和 `[processes]` 监控列表,并备份旧配置。
|
|
||||||
|
|
||||||
升级后检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status pigway-pi-control --no-pager -l
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 卸载
|
|
||||||
|
|
||||||
在安装包目录执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./uninstall.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
卸载程序默认保留:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
避免误删用户自定义配置。
|
|
||||||
|
|
||||||
确认以后不再使用时可手工删除:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo rm /etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 重装树莓派时最短流程
|
|
||||||
|
|
||||||
以后即使完全忘记之前怎么安装,只需要:
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. 安装 Raspberry Pi OS
|
|
||||||
2. raspi-config 开启 I²C
|
|
||||||
3. 重启
|
|
||||||
4. 解压本 ZIP
|
|
||||||
5. cd pigway-pi-control-v3.1.0
|
|
||||||
6. sudo ./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
完成。
|
|
||||||
|
|||||||
+9
-6
@@ -1,7 +1,10 @@
|
|||||||
ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md
|
a182abe8c1f188b9e9b47fdc24802fedd6a569b462a85254185521fe68936a76 README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||||
0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py
|
d71d949643d31461f2e62ded3818e1a0f1711d1420756ad0cc32eb04cde0fad3 app/pigway_pi_control.py
|
||||||
48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf
|
3a13b4e8f9d306e77f82eba78b78fe9b1932488884d6f3f976754bfa94f0153d app/pigway_pi_control_api.py
|
||||||
7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh
|
75fa662ed21138a8e394de83e46281dab039433cc0d7a8f787e1a049f4b47ec8 config/pigway-pi-control.conf
|
||||||
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh
|
||||||
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
|
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
||||||
|
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
||||||
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
|
53a1cbbef49d1300c5d79ab2dda9d57d6743fe8d34cbae60d0c81d7d6e6f561c web/index.html
|
||||||
|
|||||||
+593
-138
@@ -1,32 +1,78 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import os,time,socket,subprocess,configparser,signal
|
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
APP_VERSION="3.1.0"
|
APP_VERSION="3.6.0"
|
||||||
from smbus2 import SMBus
|
from smbus2 import SMBus
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from shutil import disk_usage
|
from shutil import disk_usage
|
||||||
|
|
||||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
OLED=0x3C; MCU=0x0D; W,H=128,32
|
||||||
CFG="/etc/pigway-pi-control.conf"
|
CFG="/etc/pigway-pi-control.conf"
|
||||||
|
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||||
running=True
|
running=True
|
||||||
|
|
||||||
def stop(*_):
|
def stop(*_):
|
||||||
global running; running=False
|
global running; running=False
|
||||||
signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop)
|
signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop)
|
||||||
|
|
||||||
bus=SMBus(1)
|
def log(level,event,**fields):
|
||||||
|
parts=[f"level={level}",f"event={event}"]
|
||||||
|
for k,v in fields.items(): parts.append(f"{k}={str(v).replace(chr(32),'_')}")
|
||||||
|
print(" ".join(parts),flush=True)
|
||||||
|
|
||||||
|
def uptime_seconds():
|
||||||
|
try:return int(float(Path("/proc/uptime").read_text().split()[0]))
|
||||||
|
except Exception:return 0
|
||||||
|
|
||||||
|
def load_average():
|
||||||
|
try:
|
||||||
|
x=Path("/proc/loadavg").read_text().split();return (float(x[0]),float(x[1]),float(x[2]))
|
||||||
|
except Exception:return (0.0,0.0,0.0)
|
||||||
|
|
||||||
|
def cpu_freq_mhz():
|
||||||
|
for q in ("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq","/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq"):
|
||||||
|
try:return int(Path(q).read_text().strip())/1000.0
|
||||||
|
except Exception:pass
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def root_readonly():
|
||||||
|
try:
|
||||||
|
for line in Path("/proc/mounts").read_text().splitlines():
|
||||||
|
a=line.split()
|
||||||
|
if len(a)>=4 and a[1]=="/":return "ro" in a[3].split(",")
|
||||||
|
except Exception:pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def throttled_state():
|
||||||
|
raw=_run(["vcgencmd","get_throttled"])
|
||||||
|
m=re.search(r"0x([0-9a-fA-F]+)",raw);v=int(m.group(1),16) if m else 0
|
||||||
|
if not m: return {"raw":"UNKNOWN","error":"vcgencmd get_throttled unavailable or invalid"}
|
||||||
|
return {"raw":f"0x{v:x}","under_voltage_now":bool(v&1),"freq_capped_now":bool(v&2),"throttled_now":bool(v&4),"soft_temp_limit_now":bool(v&8),"under_voltage_occurred":bool(v&(1<<16)),"freq_capped_occurred":bool(v&(1<<17)),"throttled_occurred":bool(v&(1<<18)),"soft_temp_limit_occurred":bool(v&(1<<19))}
|
||||||
|
|
||||||
|
bus=None
|
||||||
|
bus_lock=threading.RLock()
|
||||||
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
|
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
|
||||||
FONT5=Path(FONT_PATH).read_bytes()
|
FONT5=b""
|
||||||
|
agent={"loop_errors":0,"i2c_errors":0}
|
||||||
|
|
||||||
|
def bus_write(method,*args):
|
||||||
|
with bus_lock:
|
||||||
|
try: return getattr(bus,method)(*args)
|
||||||
|
except OSError:
|
||||||
|
agent["i2c_errors"]+=1
|
||||||
|
raise
|
||||||
|
|
||||||
fan_level=0
|
fan_level=0
|
||||||
prev_cpu=None
|
prev_cpu=None
|
||||||
page=0; page_since=time.monotonic()
|
page=0; page_since=time.monotonic()
|
||||||
cpu_hi=mem_hi=0
|
cpu_hi=mem_hi=0
|
||||||
net_ok=link_ok=True
|
net_ok=link_ok=True
|
||||||
ip="NO IP"
|
net_iface=""; net_kind="NET"; net_metric="DOWN"; ip_label="IP4"; ip="NO IP"
|
||||||
|
wifi_weak_since=None; wifi_weak_active=False
|
||||||
last_net=last_watch=0
|
last_net=last_watch=0
|
||||||
watch_state=[]
|
watch_state=[]
|
||||||
|
|
||||||
def cmd(c): bus.write_byte_data(OLED,0x00,c)
|
def cmd(c): bus_write("write_byte_data",OLED,0x00,c)
|
||||||
def oled_init():
|
def oled_init():
|
||||||
# Seller-native physical 128x32 configuration.
|
# Seller-native physical 128x32 configuration.
|
||||||
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
|
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
|
||||||
@@ -65,10 +111,10 @@ def oled_show(lines_lr, title_zh=False):
|
|||||||
data.append(v)
|
data.append(v)
|
||||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||||
for i in range(0,len(data),16):
|
for i in range(0,len(data),16):
|
||||||
bus.write_i2c_block_data(OLED,0x40,data[i:i+16])
|
bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16])
|
||||||
|
|
||||||
|
|
||||||
def mcu(reg,val): bus.write_byte_data(MCU,reg,val)
|
def mcu(reg,val): bus_write("write_byte_data",MCU,reg,val)
|
||||||
_last_rgb=None
|
_last_rgb=None
|
||||||
def rgb_off():
|
def rgb_off():
|
||||||
global _last_rgb
|
global _last_rgb
|
||||||
@@ -76,16 +122,6 @@ def rgb_off():
|
|||||||
mcu(0x07,0x00) # seller closeRGB(): true OFF
|
mcu(0x07,0x00) # seller closeRGB(): true OFF
|
||||||
_last_rgb=("off",)
|
_last_rgb=("off",)
|
||||||
|
|
||||||
def rgb(r,g,b):
|
|
||||||
global _last_rgb
|
|
||||||
scale=max(0,min(100,LED_BRIGHT))/100.0
|
|
||||||
state=(int(r*scale),int(g*scale),int(b*scale))
|
|
||||||
if _last_rgb==state: return
|
|
||||||
mcu(0x07,0x00) # stop built-in effect first
|
|
||||||
mcu(0x00,0xff) # seller protocol: >=3 => all three LEDs
|
|
||||||
mcu(0x01,state[0]); mcu(0x02,state[1]); mcu(0x03,state[2])
|
|
||||||
_last_rgb=state
|
|
||||||
|
|
||||||
def set_pi_led(name,off=True):
|
def set_pi_led(name,off=True):
|
||||||
base=Path("/sys/class/leds")/name
|
base=Path("/sys/class/leds")/name
|
||||||
try:
|
try:
|
||||||
@@ -133,10 +169,36 @@ def mem_pct():
|
|||||||
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
||||||
def disk_pct():
|
def disk_pct():
|
||||||
d=disk_usage("/"); return 100*d.used/d.total
|
d=disk_usage("/"); return 100*d.used/d.total
|
||||||
def get_ip():
|
def _run(args,timeout=2):
|
||||||
|
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip()
|
||||||
|
except Exception: return ""
|
||||||
|
|
||||||
|
def primary_network():
|
||||||
|
out=_run(["ip","-4","route","get","1.1.1.1"])
|
||||||
|
dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP4"
|
||||||
|
if not dev:
|
||||||
|
out=_run(["ip","-6","route","get","2606:4700:4700::1111"])
|
||||||
|
dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP6"
|
||||||
|
if not dev: return "","NET","DOWN","IP4","NO IP"
|
||||||
|
iface=dev.group(1); ipval=src.group(1) if src else ""
|
||||||
|
if Path(f"/sys/class/net/{iface}/wireless").exists():
|
||||||
|
kind="WIF"; metric="--"
|
||||||
try:
|
try:
|
||||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(("1.1.1.1",53));v=s.getsockname()[0];s.close();return v
|
for line in Path("/proc/net/wireless").read_text().splitlines():
|
||||||
except: return "NO IP"
|
if line.lstrip().startswith(iface+":"):
|
||||||
|
metric=str(int(float(line.split()[3].rstrip(".")))); break
|
||||||
|
except Exception: pass
|
||||||
|
else:
|
||||||
|
kind="ETH"; metric="UP"
|
||||||
|
try:
|
||||||
|
n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip())
|
||||||
|
metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n)
|
||||||
|
except Exception: pass
|
||||||
|
return iface,kind,metric,label,(ipval or "NO IP")
|
||||||
|
|
||||||
|
def get_ip():
|
||||||
|
return primary_network()[4]
|
||||||
|
|
||||||
def internet():
|
def internet():
|
||||||
try:
|
try:
|
||||||
s=socket.create_connection(("1.1.1.1",53),0.5);s.close();return True
|
s=socket.create_connection(("1.1.1.1",53),0.5);s.close();return True
|
||||||
@@ -165,6 +227,7 @@ OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
|||||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||||
|
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
||||||
|
|
||||||
CPU_HIGH=cf("alerts","cpu_high",90.0)
|
CPU_HIGH=cf("alerts","cpu_high",90.0)
|
||||||
CPU_RECOVER=cf("alerts","cpu_recover",75.0)
|
CPU_RECOVER=cf("alerts","cpu_recover",75.0)
|
||||||
@@ -176,6 +239,9 @@ TEMP_HIGH=cf("alerts","temp_high",68.0)
|
|||||||
TEMP_CRIT=cf("alerts","temp_critical",75.0)
|
TEMP_CRIT=cf("alerts","temp_critical",75.0)
|
||||||
DISK_HIGH=cf("alerts","disk_high",90.0)
|
DISK_HIGH=cf("alerts","disk_high",90.0)
|
||||||
DISK_CRIT=cf("alerts","disk_critical",95.0)
|
DISK_CRIT=cf("alerts","disk_critical",95.0)
|
||||||
|
WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
||||||
|
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
||||||
|
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
|
||||||
|
|
||||||
FAN_T=[cf("fan","start",50.0),cf("fan","level2",53.0),cf("fan","level3",56.0),
|
FAN_T=[cf("fan","start",50.0),cf("fan","level2",53.0),cf("fan","level3",56.0),
|
||||||
cf("fan","level4",59.0),cf("fan","max",62.0)]
|
cf("fan","level4",59.0),cf("fan","max",62.0)]
|
||||||
@@ -183,67 +249,250 @@ FAN_HYST=cf("fan","hysteresis",1.5)
|
|||||||
ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","row3_y",16,int),cf("display","row4_y",24,int)]
|
ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","row3_y",16,int),cf("display","row4_y",24,int)]
|
||||||
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||||
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||||
LED_BRIGHT=cf("led","brightness",10,int)
|
RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int))
|
||||||
LED_WARN_ON=cf("led","warning_on_ms",300,int); LED_WARN_OFF=cf("led","warning_off_ms",2700,int)
|
WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int)))
|
||||||
LED_CRIT_ON=cf("led","critical_on_ms",400,int); LED_CRIT_OFF=cf("led","critical_off_ms",1100,int)
|
CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,int)))
|
||||||
LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emergency_off_ms",400,int)
|
EMERGENCY_FLASH=(max(50,cf("led","emergency_flash_on_ms",300,int)),max(50,cf("led","emergency_flash_off_ms",300,int)))
|
||||||
|
NORMAL_MODE=cf("led","normal_mode","off",str).strip().lower()
|
||||||
|
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
|
||||||
|
if NORMAL_MODE not in NORMAL_MODES: NORMAL_MODE="off"
|
||||||
|
NORMAL_COLOR=tuple(max(0,min(255,cf("led",f"normal_{channel}",value,int)))
|
||||||
|
for channel,value in zip(("r","g","b"),(0,0,0)))
|
||||||
|
NORMAL_FLASH=(max(50,cf("led","normal_flash_on_ms",1000,int)),
|
||||||
|
max(50,cf("led","normal_flash_off_ms",1000,int)))
|
||||||
|
NORMAL_EFFECT_SPEED=max(1,min(3,cf("led","normal_effect_speed",2,int)))
|
||||||
|
NORMAL_EFFECT_COLOR=cf("led","normal_effect_color","green",str).strip().lower()
|
||||||
|
RGB_EFFECT_COLORS={"red":0,"green":1,"blue":2,"yellow":3,"purple":4,"cyan":5,"white":6}
|
||||||
|
if NORMAL_EFFECT_COLOR not in RGB_EFFECT_COLORS: NORMAL_EFFECT_COLOR="green"
|
||||||
|
RGB_EFFECTS={"flow":0,"breathe":1,"marquee":2,"rainbow":3,"colorful":4}
|
||||||
|
NORMAL_RGB_MODE={"off":"OFF","solid":"NORMAL_SOLID","flash":"NORMAL_FLASH",
|
||||||
|
**{name:f"NORMAL_{name.upper()}" for name in RGB_EFFECTS}}[NORMAL_MODE]
|
||||||
|
|
||||||
|
def rgb_cfg(name,default):
|
||||||
|
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
|
||||||
|
for channel,value in zip(("r","g","b"),default))
|
||||||
|
|
||||||
|
RGB_GROUP_VALUES={
|
||||||
|
"CPU":rgb_cfg("cpu",(255,0,0)),
|
||||||
|
"POWER":rgb_cfg("power",(255,40,0)),
|
||||||
|
"MEMORY":rgb_cfg("memory",(255,0,255)),
|
||||||
|
"STORAGE":rgb_cfg("storage",(255,255,255)),
|
||||||
|
"NETWORK":rgb_cfg("network",(0,0,255)),
|
||||||
|
"SERVICE":rgb_cfg("service",(0,255,96)),
|
||||||
|
}
|
||||||
|
RGB_OBJECT_GROUPS={
|
||||||
|
"ROOT_READONLY":"STORAGE",
|
||||||
|
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
|
||||||
|
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
|
||||||
|
"POWER_UNDERVOLT":"POWER",
|
||||||
|
"MEMORY_HIGH":"MEMORY",
|
||||||
|
"DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE",
|
||||||
|
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK",
|
||||||
|
"SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE",
|
||||||
|
}
|
||||||
|
|
||||||
def check_watch(w):
|
def check_watch(w):
|
||||||
typ,label,target=w
|
typ,label,target=w
|
||||||
if typ=="service":
|
args=["systemctl","is-active","--quiet",target] if typ=="service" else ["pgrep","-f","--",target]
|
||||||
r=subprocess.run(["systemctl","is-active","--quiet",target])
|
try:
|
||||||
else:
|
r=subprocess.run(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=2)
|
||||||
r=subprocess.run(["pgrep","-f",target],stdout=subprocess.DEVNULL)
|
|
||||||
return r.returncode==0
|
return r.returncode==0
|
||||||
|
except (OSError,subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
def alert(title,l2="",l3="",l4="",sev=1,color=(20,16,0),blink=0):
|
|
||||||
return {"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
|
|
||||||
"sev":sev,"color":color,"blink":blink}
|
|
||||||
|
|
||||||
def make_alerts(cpu,temp,mem,disk):
|
def diagnose():
|
||||||
|
print("Agent:")
|
||||||
|
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||||
|
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||||
|
print("System:")
|
||||||
|
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
||||||
|
print("Power:")
|
||||||
|
power=throttled_state()
|
||||||
|
print("throttled="+power["raw"])
|
||||||
|
for key in ("under_voltage_now","freq_capped_now","throttled_now","soft_temp_limit_now",
|
||||||
|
"under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"):
|
||||||
|
print(f"{key}={power.get(key,'UNKNOWN')}")
|
||||||
|
if "error" in power: print("error="+power["error"])
|
||||||
|
iface,kind,metric,label,address=primary_network()
|
||||||
|
print("Network:")
|
||||||
|
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} ip_label={label} IP={address} internet={internet()}")
|
||||||
|
print("I2C:")
|
||||||
|
# SMBus receive-byte performs a read only; no register selection or display commands.
|
||||||
|
try:
|
||||||
|
with SMBus(1) as probe:
|
||||||
|
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
||||||
|
try:
|
||||||
|
probe.read_byte(address)
|
||||||
|
print(f"0x{address:02X} {name}=PRESENT")
|
||||||
|
except OSError as e:
|
||||||
|
status="MISSING" if e.errno in (errno.ENXIO,getattr(errno,"EREMOTEIO",121)) else "ERROR"
|
||||||
|
print(f"0x{address:02X} {name}={status} error={e}")
|
||||||
|
except OSError as e:
|
||||||
|
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
||||||
|
print(f"0x{address:02X} {name}=ERROR error={e}")
|
||||||
|
print("Watches:")
|
||||||
|
for w in watches:
|
||||||
|
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
DISPLAY_PRIORITIES={
|
||||||
|
"ROOT_READONLY":100,
|
||||||
|
"TEMP_CRITICAL":90,
|
||||||
|
"SOFT_TEMP_LIMIT":85,
|
||||||
|
"POWER_UNDERVOLT":80,
|
||||||
|
"THROTTLED":75,
|
||||||
|
"FREQ_CAPPED":70,
|
||||||
|
"NETWORK_DOWN":65,
|
||||||
|
"INTERNET_DOWN":65,
|
||||||
|
"SERVICE_DOWN":60,
|
||||||
|
"PROCESS_DOWN":60,
|
||||||
|
"DISK_CRITICAL":55,
|
||||||
|
"TEMP_HIGH":50,
|
||||||
|
"CPU_HIGH":45,
|
||||||
|
"MEMORY_HIGH":45,
|
||||||
|
"DISK_HIGH":40,
|
||||||
|
"WIFI_WEAK":30,
|
||||||
|
"NORMAL_HOME":0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def display_priority(request_id):
|
||||||
|
return DISPLAY_PRIORITIES[request_id.split("|",1)[0]]
|
||||||
|
|
||||||
|
def transition(key,active,on_event,off_event,**fields):
|
||||||
|
old=event_state.get(key,False)
|
||||||
|
if active!=old:
|
||||||
|
event_state[key]=active
|
||||||
|
log("WARN" if active else "INFO",on_event if active else off_event,**fields)
|
||||||
|
|
||||||
|
def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||||
|
global wifi_weak_since,wifi_weak_active
|
||||||
global cpu_hi,mem_hi
|
global cpu_hi,mem_hi
|
||||||
a=[]
|
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
||||||
cpu_hi=cpu_hi+1 if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
||||||
mem_hi=mem_hi+1 if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
if cpu_hi>=CPU_HIGH_SEC: display.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD")
|
||||||
if cpu_hi>=CPU_HIGH_SEC:a.append(alert("CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","FOR >=30 SEC",1,(24,18,0)))
|
else: display.clear("CPU_HIGH")
|
||||||
if temp>=TEMP_CRIT:a.append(alert("TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",3,(32,0,0),2))
|
if temp>=TEMP_CRIT:
|
||||||
elif temp>=TEMP_HIGH:a.append(alert("TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",1,(24,0,0)))
|
display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||||
if mem_hi>=MEM_HIGH_SEC:a.append(alert("MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","",1,(18,0,24)))
|
display.clear("TEMP_HIGH")
|
||||||
if disk>=DISK_CRIT:a.append(alert("DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE",2,(28,6,0),2))
|
elif temp>=TEMP_HIGH:
|
||||||
elif disk>=DISK_HIGH:a.append(alert("DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE",1,(28,8,0)))
|
display.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||||
if not link_ok:a.append(alert("NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","",2,(0,8,28),1))
|
display.clear("TEMP_CRITICAL")
|
||||||
elif not net_ok:a.append(alert("INTERNET DOWN","LOCAL LINK OK",ip,"",2,(0,8,28),1))
|
else:
|
||||||
|
display.clear("TEMP_HIGH"); display.clear("TEMP_CRITICAL")
|
||||||
|
if mem_hi>=MEM_HIGH_SEC: display.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||||
|
else: display.clear("MEMORY_HIGH")
|
||||||
|
if disk>=DISK_CRIT:
|
||||||
|
display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||||
|
display.clear("DISK_HIGH")
|
||||||
|
elif disk>=DISK_HIGH:
|
||||||
|
display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
||||||
|
display.clear("DISK_CRITICAL")
|
||||||
|
else:
|
||||||
|
display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL")
|
||||||
|
if not link_ok:
|
||||||
|
display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
||||||
|
display.clear("INTERNET_DOWN")
|
||||||
|
elif not net_ok:
|
||||||
|
display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
|
||||||
|
display.clear("NETWORK_DOWN")
|
||||||
|
else:
|
||||||
|
display.clear("NETWORK_DOWN"); display.clear("INTERNET_DOWN")
|
||||||
for w,ok in zip(watches,watch_state):
|
for w,ok in zip(watches,watch_state):
|
||||||
if not ok:a.append(alert("SERVICE DOWN",w[1],"CHECK / RESTART","",2,(0,24,24),1))
|
alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
|
||||||
a.sort(key=lambda x:x["sev"],reverse=True)
|
if not ok:
|
||||||
return a
|
display.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
|
||||||
|
else: display.clear(alert_id)
|
||||||
|
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
|
||||||
|
rssi=float(net_metric)
|
||||||
|
if not wifi_weak_active:
|
||||||
|
if rssi <= WIFI_WEAK:
|
||||||
|
if wifi_weak_since is None: wifi_weak_since=time.monotonic()
|
||||||
|
if time.monotonic()-wifi_weak_since >= WIFI_WEAK_SECONDS: wifi_weak_active=True
|
||||||
|
else: wifi_weak_since=None
|
||||||
|
elif rssi >= WIFI_RECOVER:
|
||||||
|
wifi_weak_active=False; wifi_weak_since=None
|
||||||
|
if wifi_weak_active:
|
||||||
|
display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
|
||||||
|
else: display.clear("WIFI_WEAK")
|
||||||
|
else:
|
||||||
|
wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK")
|
||||||
|
if health.get("under_voltage_now"):
|
||||||
|
display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||||
|
else: display.clear("POWER_UNDERVOLT")
|
||||||
|
if health.get("throttled_now"):
|
||||||
|
display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||||
|
else: display.clear("THROTTLED")
|
||||||
|
if health.get("freq_capped_now"):
|
||||||
|
display.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||||
|
else: display.clear("FREQ_CAPPED")
|
||||||
|
if health.get("soft_temp_limit_now"):
|
||||||
|
display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
|
||||||
|
else: display.clear("SOFT_TEMP_LIMIT")
|
||||||
|
if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
|
||||||
|
else: display.clear("ROOT_READONLY")
|
||||||
|
|
||||||
def led_for(a):
|
def rgb_mode_for(request_id,severity):
|
||||||
# Color = fault object. Blink frequency = severity. All alerts blink.
|
if severity<=0: return "OFF"
|
||||||
if not a:
|
alert_type=request_id.split("|",1)[0]
|
||||||
rgb_off(); return
|
color=RGB_OBJECT_GROUPS[alert_type]
|
||||||
if a["sev"]>=3: on_ms,off_ms=LED_EMERG_ON,LED_EMERG_OFF
|
level="EMERGENCY" if severity>=3 else "CRITICAL" if severity>=2 else "WARNING"
|
||||||
elif a["sev"]>=2: on_ms,off_ms=LED_CRIT_ON,LED_CRIT_OFF
|
return f"{color}_{level}_FLASH"
|
||||||
else: on_ms,off_ms=LED_WARN_ON,LED_WARN_OFF
|
|
||||||
cycle=max(1,on_ms+off_ms)
|
|
||||||
phase=int(time.monotonic()*1000)%cycle
|
|
||||||
rgb(*a["color"]) if phase<on_ms else rgb_off()
|
|
||||||
|
|
||||||
|
def rgb_flash(mode,on):
|
||||||
|
global _last_rgb
|
||||||
|
color=mode.split("_",1)[0]
|
||||||
|
channels=RGB_GROUP_VALUES[color]
|
||||||
|
state=(mode,bool(on))
|
||||||
|
if _last_rgb==state: return
|
||||||
|
if not on:
|
||||||
|
mcu(0x07,0x00)
|
||||||
|
else:
|
||||||
|
mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x03,channels[2])
|
||||||
|
_last_rgb=state
|
||||||
|
|
||||||
|
def rgb_static(channels,state_name):
|
||||||
|
global _last_rgb
|
||||||
|
state=(state_name,channels)
|
||||||
|
if _last_rgb==state: return
|
||||||
|
mcu(0x07,0x00); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x03,channels[2])
|
||||||
|
_last_rgb=state
|
||||||
|
|
||||||
|
def rgb_normal_effect(mode):
|
||||||
|
global _last_rgb
|
||||||
|
effect_name=mode.removeprefix("NORMAL_").lower()
|
||||||
|
state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR)
|
||||||
|
if _last_rgb==state: return
|
||||||
|
# Hardware-calibrated order: speed and color are parameters; the final
|
||||||
|
# effect register write commits and starts the selected MCU animation.
|
||||||
|
mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
|
mcu(0x04,RGB_EFFECTS[effect_name])
|
||||||
|
_last_rgb=state
|
||||||
|
|
||||||
def render_home(cpu,temp,mem,disk):
|
def render_home(cpu,temp,mem,disk):
|
||||||
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
||||||
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
||||||
im=Image.new("1",(W,H),0); pix=im.load()
|
im=Image.new("1",(W,H),0); pix=im.load()
|
||||||
|
display_ip=ip[:7]+".."+ip[-7:] if ip_label=="IP6" and len(ip)>18 else ip
|
||||||
left=[
|
left=[
|
||||||
f"CPU {cpu:.1f}%",
|
f"CPU {cpu:.1f}%",
|
||||||
f"TMP {temp:.1f}C",
|
f"TMP {temp:.1f}C",
|
||||||
f"DSK {disk:.1f}%",
|
f"DSK {disk:.1f}%",
|
||||||
("IP6 " if ":" in ip else "IP4 ")+ip,
|
ip_label+" "+display_ip,
|
||||||
]
|
]
|
||||||
right=[
|
right=[
|
||||||
f"MEM {mem:.1f}%",
|
f"MEM {mem:.1f}%",
|
||||||
f"FAN {FAN_NAME[fan_level]}",
|
f"FAN {FAN_NAME[fan_level]}",
|
||||||
f"NET {'OK' if net_ok else 'DOWN'}",
|
f"{net_kind} {net_metric}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for row in range(4):
|
for row in range(4):
|
||||||
@@ -260,108 +509,314 @@ def render_home(cpu,temp,mem,disk):
|
|||||||
data.append(v)
|
data.append(v)
|
||||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||||
for i in range(0,len(data),16):
|
for i in range(0,len(data),16):
|
||||||
bus.write_i2c_block_data(OLED,0x40,data[i:i+16])
|
bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16])
|
||||||
|
|
||||||
|
|
||||||
def render_alert(a,idx,total):
|
def render_alert(a,index,total):
|
||||||
oled_show([(a["title"],f"{idx}/{total}"),
|
oled_show([(a["title"],f"{index}/{total}"),
|
||||||
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
|
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
|
||||||
|
|
||||||
|
|
||||||
oled_init(); rgb_off(); apply_dark_mode()
|
class DisplayController:
|
||||||
watch_state=[check_watch(w) for w in watches]
|
def __init__(self):
|
||||||
last_sensor=last_render=0.0
|
now=time.monotonic()
|
||||||
last_net=last_watch=time.monotonic()
|
self.lock=threading.RLock()
|
||||||
|
self.requests={"NORMAL_HOME":{
|
||||||
|
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":NORMAL_RGB_MODE,
|
||||||
|
"title":"","l2":"","l3":"","l4":"","active":True,
|
||||||
|
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
|
||||||
|
}}
|
||||||
|
self.current_owner_id="NORMAL_HOME"
|
||||||
|
self.current_priority=0
|
||||||
|
self.current_rgb_mode="OFF"
|
||||||
|
self.owner_since=now
|
||||||
|
self.current_oled_page_id="NORMAL_HOME"
|
||||||
|
self.page_started=now
|
||||||
|
self.batch_depth=0
|
||||||
|
self.stop_event=threading.Event()
|
||||||
|
self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
oled_init(); self.refresh(); rgb_off(); self.rgb_thread.start()
|
||||||
|
|
||||||
|
def update_home(self,cpu,temp,mem,disk):
|
||||||
|
with self.lock:
|
||||||
|
request=self.requests["NORMAL_HOME"]
|
||||||
|
values=(cpu,temp,mem,disk)
|
||||||
|
if request["metadata"].get("home")!=values:
|
||||||
|
request["metadata"]={"home":values}; request["last_changed"]=time.monotonic()
|
||||||
|
|
||||||
|
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
|
||||||
|
now=time.monotonic(); priority=display_priority(request_id)
|
||||||
|
request={"id":request_id,"priority":priority,"severity":severity,
|
||||||
|
"rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21],
|
||||||
|
"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True,
|
||||||
|
"last_changed":now,"metadata":metadata or {}}
|
||||||
|
with self.lock:
|
||||||
|
current=self.requests.get(request_id)
|
||||||
|
if current is None:
|
||||||
|
request["first_seen"]=now; self.requests[request_id]=request
|
||||||
|
log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
|
||||||
|
else:
|
||||||
|
request["first_seen"]=current["first_seen"]
|
||||||
|
changed=any(current.get(k)!=request.get(k) for k in
|
||||||
|
("priority","severity","rgb_mode","title","l2","l3","l4","metadata"))
|
||||||
|
if not changed: request["last_changed"]=current["last_changed"]
|
||||||
|
self.requests[request_id]=request
|
||||||
|
if self.batch_depth==0:
|
||||||
|
self._arbitrate_locked()
|
||||||
|
self._sync_rgb_locked()
|
||||||
|
|
||||||
|
def clear(self,request_id):
|
||||||
|
if request_id=="NORMAL_HOME": return
|
||||||
|
with self.lock:
|
||||||
|
if self.requests.pop(request_id,None) is not None:
|
||||||
|
log("INFO","ALERT_RECOVERED",alert=request_id)
|
||||||
|
if self.batch_depth==0:
|
||||||
|
self._arbitrate_locked()
|
||||||
|
if self.current_oled_page_id not in self.requests:
|
||||||
|
self._show_page_locked(self.current_owner_id)
|
||||||
|
else: self._sync_rgb_locked()
|
||||||
|
|
||||||
|
def begin_update(self):
|
||||||
|
with self.lock: self.batch_depth+=1
|
||||||
|
|
||||||
|
def end_update(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.batch_depth<=0: raise RuntimeError("display update batch imbalance")
|
||||||
|
self.batch_depth-=1
|
||||||
|
if self.batch_depth==0:
|
||||||
|
self._arbitrate_locked()
|
||||||
|
if self.current_oled_page_id not in self.requests:
|
||||||
|
self._show_page_locked(self.current_owner_id)
|
||||||
|
else: self._sync_rgb_locked()
|
||||||
|
|
||||||
|
def _arbitrate_locked(self):
|
||||||
|
current=self.requests.get(self.current_owner_id)
|
||||||
|
ordered=sorted(self.requests.values(),key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
||||||
|
candidate=ordered[0]
|
||||||
|
if current is not None and candidate["priority"]<=current["priority"]: return
|
||||||
|
self._change_owner_locked(candidate)
|
||||||
|
|
||||||
|
def _change_owner_locked(self,request):
|
||||||
|
old=self.current_owner_id
|
||||||
|
if request["id"]==old: return
|
||||||
|
self.current_owner_id=request["id"]
|
||||||
|
self.current_priority=request["priority"]
|
||||||
|
self.current_rgb_mode=request["rgb_mode"]
|
||||||
|
self.owner_since=time.monotonic()
|
||||||
|
log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"])
|
||||||
|
self._show_page_locked(request["id"])
|
||||||
|
|
||||||
|
def _ordered_alerts_locked(self):
|
||||||
|
return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"),
|
||||||
|
key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
||||||
|
|
||||||
|
def _show_page_locked(self,page_id):
|
||||||
|
alerts=self._ordered_alerts_locked()
|
||||||
|
request=self.requests.get(page_id,self.requests["NORMAL_HOME"])
|
||||||
|
self.current_oled_page_id=request["id"]
|
||||||
|
self.page_started=time.monotonic()
|
||||||
|
self._sync_rgb_locked()
|
||||||
|
if request["id"]=="NORMAL_HOME":
|
||||||
|
render_home(*request["metadata"]["home"])
|
||||||
|
else:
|
||||||
|
alert_ids=[a["id"] for a in alerts]
|
||||||
|
render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
||||||
|
|
||||||
|
def _sync_rgb_locked(self):
|
||||||
|
# Alert pages show that alert's object color. HOME keeps the highest-
|
||||||
|
# priority active owner visible instead of briefly switching RGB off.
|
||||||
|
rgb_request=self.requests.get(self.current_oled_page_id)
|
||||||
|
if self.current_oled_page_id=="NORMAL_HOME":
|
||||||
|
rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"])
|
||||||
|
if rgb_request is None:
|
||||||
|
rgb_request=self.requests["NORMAL_HOME"]
|
||||||
|
self.current_rgb_mode=rgb_request["rgb_mode"]
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
with self.lock:
|
||||||
|
alerts=self._ordered_alerts_locked()
|
||||||
|
pages=["NORMAL_HOME"]+[a["id"] for a in alerts]
|
||||||
|
if self.current_oled_page_id not in pages:
|
||||||
|
self._show_page_locked(self.current_owner_id)
|
||||||
|
return
|
||||||
|
if len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL:
|
||||||
|
pos=pages.index(self.current_oled_page_id)
|
||||||
|
self._show_page_locked(pages[(pos+1)%len(pages)])
|
||||||
|
return
|
||||||
|
request=self.requests[self.current_oled_page_id]
|
||||||
|
if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"])
|
||||||
|
else:
|
||||||
|
alert_ids=[a["id"] for a in alerts]
|
||||||
|
render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
||||||
|
|
||||||
|
def owner_snapshot(self):
|
||||||
|
with self.lock: return dict(self.requests[self.current_owner_id])
|
||||||
|
|
||||||
|
def status_snapshot(self):
|
||||||
|
with self.lock:
|
||||||
|
alerts=self._ordered_alerts_locked()
|
||||||
|
return {
|
||||||
|
"owner":self.current_owner_id,
|
||||||
|
"oled_page":self.current_oled_page_id,
|
||||||
|
"rgb_mode":self.current_rgb_mode,
|
||||||
|
"alerts":[{k:a[k] for k in ("id","priority","severity","title","l2","l3","l4","first_seen","last_changed")}
|
||||||
|
for a in alerts],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rgb_worker(self):
|
||||||
|
previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic()
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
with self.lock: wanted_mode=self.current_rgb_mode
|
||||||
|
wanted_flash=wanted_mode.endswith("_FLASH")
|
||||||
|
if wanted_mode!=applied_mode:
|
||||||
|
with bus_lock:
|
||||||
|
if wanted_mode=="OFF": rgb_off()
|
||||||
|
elif wanted_mode=="NORMAL_SOLID": rgb_static(NORMAL_COLOR,"normal_solid")
|
||||||
|
elif wanted_mode=="NORMAL_FLASH": rgb_static(NORMAL_COLOR,"normal_flash_on")
|
||||||
|
elif wanted_mode.startswith("NORMAL_"): rgb_normal_effect(wanted_mode)
|
||||||
|
elif wanted_flash:
|
||||||
|
rgb_off()
|
||||||
|
rgb_flash(wanted_mode,True)
|
||||||
|
else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
|
||||||
|
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
|
||||||
|
applied_mode=wanted_mode; animation_started=time.monotonic()
|
||||||
|
applied_flash=applied_mode.endswith("_FLASH")
|
||||||
|
if applied_flash:
|
||||||
|
elapsed_ms=(time.monotonic()-animation_started)*1000.0
|
||||||
|
if applied_mode=="NORMAL_FLASH": on_ms,off_ms=NORMAL_FLASH
|
||||||
|
elif "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH
|
||||||
|
elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
|
||||||
|
else: on_ms,off_ms=WARNING_FLASH
|
||||||
|
on=elapsed_ms%(on_ms+off_ms)<on_ms
|
||||||
|
with bus_lock:
|
||||||
|
if applied_mode=="NORMAL_FLASH":
|
||||||
|
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
|
||||||
|
else: rgb_flash(applied_mode,on)
|
||||||
|
if previous_error is not None: log("INFO","RGB_RECOVERED")
|
||||||
|
previous_error=None
|
||||||
|
except Exception as e:
|
||||||
|
if repr(e)!=previous_error: log("ERROR","RGB_ERROR",error=repr(e))
|
||||||
|
previous_error=repr(e)
|
||||||
|
delay=0.04 if applied_flash else 0.25
|
||||||
|
self.stop_event.wait(delay)
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.stop_event.set()
|
||||||
|
if self.rgb_thread.is_alive(): self.rgb_thread.join()
|
||||||
|
for name,action in (("rgb",rgb_off),("oled_clear",lambda:oled_show([("","")]*4)),
|
||||||
|
("oled_off",lambda:cmd(0xAE))):
|
||||||
|
try: action()
|
||||||
|
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"])
|
||||||
|
if bus is None: return
|
||||||
|
display.shutdown()
|
||||||
|
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)):
|
||||||
|
try: action()
|
||||||
|
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
|
||||||
|
try: Path(STATUS_PATH).unlink(missing_ok=True)
|
||||||
|
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
|
||||||
|
|
||||||
|
def write_status(cpu,temp,mem,disk):
|
||||||
|
display_state=display.status_snapshot()
|
||||||
|
state={
|
||||||
|
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
|
||||||
|
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
|
||||||
|
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
||||||
|
"memory_percent":round(mem,1),"disk_percent":round(disk,1),
|
||||||
|
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
||||||
|
"power":health,
|
||||||
|
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||||
|
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
|
||||||
|
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||||
|
for w,ok in zip(watches,watch_state)],
|
||||||
|
"display":display_state,
|
||||||
|
"agent":{"loop_errors":agent["loop_errors"],"i2c_errors":agent["i2c_errors"]},
|
||||||
|
}
|
||||||
|
path=Path(STATUS_PATH); path.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
|
||||||
|
temporary=path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
|
||||||
|
temporary.chmod(0o644); temporary.replace(path)
|
||||||
|
|
||||||
|
# Dispatch only after all shared checks and configuration have been initialized.
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--diagnose",action="store_true")
|
||||||
|
args=parser.parse_args()
|
||||||
|
if args.diagnose: raise SystemExit(diagnose())
|
||||||
|
|
||||||
|
watch_state=[]
|
||||||
|
last_sensor=last_render=last_status=0.0
|
||||||
|
last_net=last_watch=last_health=0.0
|
||||||
|
health={}; fs_readonly=False
|
||||||
|
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
|
||||||
|
event_state={}
|
||||||
|
history_logged=set()
|
||||||
latest=(0.0,0.0,0.0,0.0)
|
latest=(0.0,0.0,0.0,0.0)
|
||||||
active=[]
|
display=DisplayController()
|
||||||
|
try:
|
||||||
# Page state is a snapshot queue. Live sensor refreshes NEVER choose another page.
|
bus=SMBus(1)
|
||||||
# Each cycle is HOME + every alert captured at the cycle boundary.
|
FONT5=Path(FONT_PATH).read_bytes()
|
||||||
cycle_pages=["HOME"]
|
display.start(); apply_dark_mode()
|
||||||
cycle_pos=0
|
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
||||||
page_started=time.monotonic()
|
|
||||||
current_alert_key=None
|
|
||||||
|
|
||||||
def alert_key(a):
|
|
||||||
# Stable identity: title + monitored object/primary detail.
|
|
||||||
return f"{a['title']}|{a['l2']}"
|
|
||||||
|
|
||||||
def find_live_alert(key, live):
|
|
||||||
for a in live:
|
|
||||||
if alert_key(a)==key: return a
|
|
||||||
return None
|
|
||||||
|
|
||||||
while running:
|
while running:
|
||||||
try:
|
try:
|
||||||
now=time.monotonic()
|
now=time.monotonic()
|
||||||
|
sample_elapsed=0.0
|
||||||
|
|
||||||
|
|
||||||
# Independent clocks: update data/state only.
|
# Independent clocks: update data/state only.
|
||||||
if now-last_sensor >= SENSOR_INTERVAL:
|
if now-last_sensor >= SENSOR_INTERVAL:
|
||||||
|
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
||||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||||
latest=(c,t,m,d); update_fan(t); last_sensor=now
|
latest=(c,t,m,d); update_fan(t); last_sensor=now; agent["last_sensor_ok"]=now
|
||||||
|
|
||||||
if now-last_net >= NETWORK_INTERVAL:
|
if now-last_net >= NETWORK_INTERVAL:
|
||||||
ip=get_ip(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False
|
old_id=(net_iface,ip)
|
||||||
last_net=now
|
net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False
|
||||||
|
new_id=(net_iface,ip)
|
||||||
|
if new_id!=old_id:
|
||||||
|
log("INFO","NET_SWITCH",old_if=old_id[0],old_ip=old_id[1],new_if=net_iface,new_ip=ip)
|
||||||
|
transition("network_down",not link_ok,"NETWORK_DOWN","NETWORK_RECOVERED",iface=net_iface,ip=ip)
|
||||||
|
transition("internet_down",link_ok and not net_ok,"INTERNET_DOWN","INTERNET_RECOVERED",iface=net_iface,ip=ip)
|
||||||
|
last_net=now; agent["last_network_ok"]=now
|
||||||
|
|
||||||
if now-last_watch >= SERVICE_INTERVAL:
|
if now-last_watch >= SERVICE_INTERVAL:
|
||||||
watch_state=[check_watch(w) for w in watches]
|
watch_state=[check_watch(w) for w in watches]
|
||||||
last_watch=now
|
for w,ok in zip(watches,watch_state):
|
||||||
|
transition("watch:"+w[0]+":"+w[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2])
|
||||||
|
last_watch=now; agent["last_watch_ok"]=now
|
||||||
|
|
||||||
|
if now-last_health >= HEALTH_INTERVAL:
|
||||||
|
power_sample=throttled_state(); fs_readonly=root_readonly()
|
||||||
|
transition("power_check", "error" in power_sample,"POWER_CHECK_ERROR","POWER_CHECK_RECOVERED")
|
||||||
|
# An unavailable reading is not evidence that an existing fault recovered.
|
||||||
|
if "error" not in power_sample: health=power_sample
|
||||||
|
transition("undervoltage",health.get("under_voltage_now",False),"POWER_LOW","POWER_RECOVERED",raw=health.get("raw"))
|
||||||
|
transition("throttled",health.get("throttled_now",False) or health.get("freq_capped_now",False),"THROTTLED","THROTTLE_RECOVERED",raw=health.get("raw"),freq_mhz=f"{cpu_freq_mhz():.0f}")
|
||||||
|
transition("soft_temp",health.get("soft_temp_limit_now",False),"SOFT_TEMP_LIMIT","SOFT_TEMP_RECOVERED",raw=health.get("raw"))
|
||||||
|
transition("root_ro",fs_readonly,"FILESYSTEM_RO","FILESYSTEM_RW")
|
||||||
|
for k in ("under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"):
|
||||||
|
if health.get(k) and k not in history_logged:
|
||||||
|
history_logged.add(k); log("WARN","HISTORY_FLAG",flag=k,raw=health.get("raw"))
|
||||||
|
agent["last_health_ok"]=now; last_health=now
|
||||||
|
|
||||||
c,t,m,d=latest
|
c,t,m,d=latest
|
||||||
active=make_alerts(c,t,m,d)
|
display.update_home(c,t,m,d)
|
||||||
|
display.begin_update()
|
||||||
# If the currently displayed alert truly disappeared, skip it immediately.
|
try: update_active_alerts(c,t,m,d,sample_elapsed)
|
||||||
if cycle_pages[cycle_pos]!="HOME":
|
finally: display.end_update()
|
||||||
key=cycle_pages[cycle_pos]
|
|
||||||
if find_live_alert(key,active) is None:
|
|
||||||
cycle_pos += 1
|
|
||||||
if cycle_pos>=len(cycle_pages):
|
|
||||||
# End of cycle: capture a fresh snapshot.
|
|
||||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
|
||||||
cycle_pos=0
|
|
||||||
page_started=now
|
|
||||||
|
|
||||||
# ONLY this timer performs normal page changes.
|
|
||||||
if now-page_started >= PAGE_INTERVAL:
|
|
||||||
cycle_pos += 1
|
|
||||||
if cycle_pos>=len(cycle_pages):
|
|
||||||
# A complete cycle ended. Snapshot all currently active alerts.
|
|
||||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
|
||||||
cycle_pos=0
|
|
||||||
page_started=now
|
|
||||||
|
|
||||||
# Special case: while idle on HOME with no alert pages queued, begin a new
|
|
||||||
# alert cycle immediately when the first alert appears. HOME keeps its
|
|
||||||
# current 10-second slot; alerts follow it and cannot be kicked out by refresh.
|
|
||||||
if cycle_pages==["HOME"] and active:
|
|
||||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
|
||||||
|
|
||||||
# Render CURRENT page only. 1-second refresh does not affect cycle_pos.
|
|
||||||
if now-last_render >= OLED_REFRESH:
|
if now-last_render >= OLED_REFRESH:
|
||||||
current=cycle_pages[cycle_pos]
|
display.refresh()
|
||||||
if current=="HOME":
|
|
||||||
render_home(c,t,m,d)
|
|
||||||
else:
|
|
||||||
a=find_live_alert(current,active)
|
|
||||||
if a:
|
|
||||||
# Number refers to this cycle's alert slot, all using same 6x8 font.
|
|
||||||
render_alert(a,cycle_pos,len(cycle_pages)-1)
|
|
||||||
else:
|
|
||||||
render_home(c,t,m,d)
|
|
||||||
last_render=now
|
last_render=now
|
||||||
|
if now-last_status >= 1.0:
|
||||||
# LED follows OLED page; HOME reflects highest currently active risk.
|
write_status(c,t,m,d); last_status=now
|
||||||
current=cycle_pages[cycle_pos]
|
|
||||||
if current=="HOME":
|
|
||||||
led_for(active[0] if active else None)
|
|
||||||
else:
|
|
||||||
a=find_live_alert(current,active)
|
|
||||||
led_for(a if a else (active[0] if active else None))
|
|
||||||
|
|
||||||
time.sleep(0.20)
|
time.sleep(0.20)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("loop error:",e,flush=True); time.sleep(1)
|
agent["loop_errors"]+=1; log("ERROR","LOOP_ERROR",count=agent["loop_errors"],error=repr(e)); time.sleep(1)
|
||||||
|
finally:
|
||||||
rgb_off(); mcu(0x08,0x00); bus.close()
|
cleanup()
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs,urlparse
|
||||||
|
|
||||||
|
APP_VERSION="3.6.0"
|
||||||
|
CFG=Path("/etc/pigway-pi-control.conf")
|
||||||
|
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||||
|
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||||
|
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||||
|
ALLOWED_SECTIONS={"services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
||||||
|
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||||
|
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||||
|
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
|
||||||
|
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
|
||||||
|
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
||||||
|
|
||||||
|
def load_cfg():
|
||||||
|
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
def api_setting(key,default,cast=str):
|
||||||
|
try:return cast(load_cfg().get("api",key))
|
||||||
|
except Exception:return default
|
||||||
|
|
||||||
|
def config_json():
|
||||||
|
cfg=load_cfg()
|
||||||
|
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||||
|
|
||||||
|
def validate_value(section,key,value,existing):
|
||||||
|
if section not in {"services","processes"} and key not in existing.get(section,set()):
|
||||||
|
raise ValueError(f"unsupported option: {section}.{key}")
|
||||||
|
if RGB_KEY_RE.fullmatch(key):
|
||||||
|
number=int(value)
|
||||||
|
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
|
||||||
|
elif FLASH_KEY_RE.fullmatch(key):
|
||||||
|
number=int(value)
|
||||||
|
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
|
||||||
|
elif section=="led" and key=="normal_mode":
|
||||||
|
if value.lower() not in NORMAL_MODES: raise ValueError("normal_mode must be off, solid, flash, flow, breathe, marquee, rainbow or colorful")
|
||||||
|
elif section=="led" and key=="normal_effect_speed":
|
||||||
|
if int(value) not in (1,2,3): raise ValueError("normal_effect_speed must be 1, 2 or 3")
|
||||||
|
elif section=="led" and key=="normal_effect_color":
|
||||||
|
if value.lower() not in EFFECT_COLORS: raise ValueError("normal_effect_color must be red, green, blue, yellow, purple, cyan or white")
|
||||||
|
elif section=="api" and key=="port":
|
||||||
|
number=int(value)
|
||||||
|
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
|
||||||
|
elif section=="api" and key=="log_limit":
|
||||||
|
number=int(value)
|
||||||
|
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
|
||||||
|
elif section=="api" and key=="bind":
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
|
||||||
|
def update_ini(updates):
|
||||||
|
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||||
|
text=CFG.read_text()
|
||||||
|
current=load_cfg(); existing={section:set(current.options(section)) for section in current.sections()}
|
||||||
|
for section,values in updates.items():
|
||||||
|
if section not in ALLOWED_SECTIONS or not isinstance(values,dict):
|
||||||
|
raise ValueError(f"unsupported section: {section}")
|
||||||
|
for key,value in values.items():
|
||||||
|
if not NAME_RE.fullmatch(str(key)): raise ValueError(f"invalid key: {key}")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"null is not allowed: {section}.{key}")
|
||||||
|
value=str(value).strip()
|
||||||
|
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
|
||||||
|
raise ValueError(f"invalid value: {section}.{key}")
|
||||||
|
validate_value(section,str(key),value,existing)
|
||||||
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||||
|
if not section_match:
|
||||||
|
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||||
|
continue
|
||||||
|
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
||||||
|
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||||
|
chunk=text[section_match.end():end]
|
||||||
|
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
||||||
|
if key_match:
|
||||||
|
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||||
|
replacement=key_match.group(1)+value
|
||||||
|
text=text[:start]+replacement+text[stop:]
|
||||||
|
else:
|
||||||
|
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||||
|
parsed=configparser.ConfigParser(); parsed.read_string(text)
|
||||||
|
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||||
|
shutil.copy2(CFG,backup)
|
||||||
|
temporary=CFG.with_suffix(".tmp")
|
||||||
|
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||||
|
return str(backup)
|
||||||
|
|
||||||
|
def journal(limit):
|
||||||
|
result=subprocess.run(["journalctl","-u","pigway-pi-control.service","-n",str(limit),
|
||||||
|
"--no-pager","-o","json"],capture_output=True,text=True,timeout=5)
|
||||||
|
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
|
||||||
|
rows=[]
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
try:
|
||||||
|
item=json.loads(line)
|
||||||
|
rows.append({"timestamp":item.get("__REALTIME_TIMESTAMP"),"priority":item.get("PRIORITY"),
|
||||||
|
"message":item.get("MESSAGE","")})
|
||||||
|
except json.JSONDecodeError: pass
|
||||||
|
return rows
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version="PIGWayAPI/3.6"
|
||||||
|
|
||||||
|
def log_message(self,fmt,*args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def send_json(self,status,payload):
|
||||||
|
data=json.dumps(payload,ensure_ascii=False,separators=(",",":")).encode()
|
||||||
|
self.send_response(status); self.send_header("Content-Type","application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data)
|
||||||
|
|
||||||
|
def security_headers(self):
|
||||||
|
self.send_header("Cache-Control","no-store")
|
||||||
|
self.send_header("X-Content-Type-Options","nosniff")
|
||||||
|
self.send_header("X-Frame-Options","DENY")
|
||||||
|
self.send_header("Content-Security-Policy","default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||||
|
|
||||||
|
def authorized(self):
|
||||||
|
try: expected=TOKEN.read_text().strip()
|
||||||
|
except OSError: return False
|
||||||
|
supplied=self.headers.get("Authorization","")
|
||||||
|
return supplied.startswith("Bearer ") and secrets.compare_digest(supplied[7:],expected)
|
||||||
|
|
||||||
|
def read_json(self):
|
||||||
|
length=int(self.headers.get("Content-Length","0"))
|
||||||
|
if length<=0 or length>65536: raise ValueError("invalid request size")
|
||||||
|
return json.loads(self.rfile.read(length))
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed=urlparse(self.path)
|
||||||
|
try:
|
||||||
|
if parsed.path=="/":
|
||||||
|
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
||||||
|
if parsed.path=="/api/v1/health":
|
||||||
|
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
||||||
|
if parsed.path=="/api/v1/status":
|
||||||
|
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||||
|
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||||
|
if parsed.path=="/api/v1/logs":
|
||||||
|
limit=min(max(int(parse_qs(parsed.query).get("limit",[api_setting("log_limit",200,int)])[0]),1),1000)
|
||||||
|
self.send_json(200,{"logs":journal(limit)}); return
|
||||||
|
if parsed.path=="/api/v1/config":
|
||||||
|
self.send_json(200,{"config":config_json(),"write_requires_token":True}); return
|
||||||
|
self.send_json(404,{"error":"not found"})
|
||||||
|
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
if urlparse(self.path).path!="/api/v1/config": self.send_json(404,{"error":"not found"}); return
|
||||||
|
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||||
|
try:
|
||||||
|
body=self.read_json(); updates=body.get("updates")
|
||||||
|
backup=update_ini(updates)
|
||||||
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
|
restart_api="api" in updates
|
||||||
|
print(f"CONFIG_UPDATED sections={','.join(sorted(updates))} backup={backup}",flush=True)
|
||||||
|
self.send_json(200,{"ok":True,"backup":backup,"api_restart":restart_api})
|
||||||
|
if restart_api: threading.Timer(0.2,self.server.shutdown).start()
|
||||||
|
except (ValueError,json.JSONDecodeError) as e: self.send_json(400,{"error":str(e)})
|
||||||
|
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||||
|
|
||||||
|
def main():
|
||||||
|
host=api_setting("bind","0.0.0.0")
|
||||||
|
port=api_setting("port",6001,int)
|
||||||
|
server=ThreadingHTTPServer((host,port),Handler)
|
||||||
|
print(f"API_START version={APP_VERSION} bind={host} port={port}",flush=True)
|
||||||
|
try: server.serve_forever()
|
||||||
|
finally: server.server_close()
|
||||||
|
|
||||||
|
if __name__=="__main__": main()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Pigway Pi Control v3.1.0 - 用户配置文件
|
# PIGWay Pi Control v3.6.0 - 用户配置文件
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# 正式安装后的配置路径:
|
# 正式安装后的配置路径:
|
||||||
@@ -25,6 +25,14 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
[api]
|
||||||
|
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
||||||
|
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||||
|
bind = 0.0.0.0
|
||||||
|
port = 6001
|
||||||
|
log_limit = 200
|
||||||
|
|
||||||
|
|
||||||
[timing]
|
[timing]
|
||||||
# 传感器采样周期,单位:秒。
|
# 传感器采样周期,单位:秒。
|
||||||
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
||||||
@@ -36,8 +44,8 @@ sensor_interval = 1
|
|||||||
# 推荐:1。
|
# 推荐:1。
|
||||||
oled_refresh_interval = 1
|
oled_refresh_interval = 1
|
||||||
|
|
||||||
# HOME / 各告警页面的轮播停留时间,单位:秒。
|
# OLED 在主页和当前告警页之间轮播的周期,单位:秒。
|
||||||
# 推荐:10。觉得切换太慢可改为 5~8。
|
# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。
|
||||||
page_interval = 10
|
page_interval = 10
|
||||||
|
|
||||||
# 网络状态检查周期,单位:秒。
|
# 网络状态检查周期,单位:秒。
|
||||||
@@ -48,6 +56,11 @@ network_interval = 5
|
|||||||
# 推荐:5。
|
# 推荐:5。
|
||||||
service_interval = 5
|
service_interval = 5
|
||||||
|
|
||||||
|
# 本机健康检查周期,单位:秒。
|
||||||
|
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
|
||||||
|
# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。
|
||||||
|
health_interval = 1
|
||||||
|
|
||||||
|
|
||||||
[fan]
|
[fan]
|
||||||
# 风扇温控档位,单位:摄氏度。
|
# 风扇温控档位,单位:摄氏度。
|
||||||
@@ -120,43 +133,99 @@ disk_high = 90
|
|||||||
# 根分区使用率达到此百分比触发严重告警。
|
# 根分区使用率达到此百分比触发严重告警。
|
||||||
disk_critical = 95
|
disk_critical = 95
|
||||||
|
|
||||||
|
# ---------------- Wi-Fi 信号 ----------------
|
||||||
|
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
|
||||||
|
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
|
||||||
|
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。
|
||||||
|
wifi_weak = -75
|
||||||
|
|
||||||
|
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
|
||||||
|
wifi_recover = -68
|
||||||
|
|
||||||
|
# 弱信号持续多少秒才正式告警。
|
||||||
|
wifi_weak_seconds = 30
|
||||||
|
|
||||||
|
|
||||||
[led]
|
[led]
|
||||||
# RGB 规则:
|
# 正常运行、没有任何 active alert 时的 RGB 表现:
|
||||||
# 正常状态 = 完全熄灭
|
# off = 全灭(默认,最安静且 I2C 写入最少)
|
||||||
# 颜色 = 哪一类对象发生故障
|
# solid = 三灯使用 normal_r/g/b 自定义颜色常亮
|
||||||
# 闪烁频率 = 严重程度,越严重闪得越快
|
# flash = 三灯使用 normal_r/g/b 和 normal_flash_*_ms 自定义颜色闪烁
|
||||||
#
|
# flow = MCU 内置流水灯;使用有限颜色和速度
|
||||||
# 当前颜色语义:
|
# breathe = MCU 内置呼吸灯;使用有限颜色和速度
|
||||||
# CPU负载 = 黄
|
# marquee = MCU 内置跑马灯;MCU 自行决定颜色变化
|
||||||
# 温度/散热 = 红
|
# rainbow = MCU 内置彩虹灯;MCU 自行决定颜色变化
|
||||||
# 内存 = 紫
|
# colorful = MCU 内置炫彩灯;MCU 自行决定颜色变化
|
||||||
# 磁盘 = 橙
|
# 任何 active alert 都会立即抢占正常效果;全部恢复后才重新应用这里的模式。
|
||||||
# 网络 = 蓝
|
normal_mode = off
|
||||||
# 服务/进程 = 青
|
|
||||||
#
|
|
||||||
# brightness 是 RGB 最大输出百分比(0~100)。
|
|
||||||
# 3颗 RGB 会一起亮,卧室建议 5~15。
|
|
||||||
brightness = 10
|
|
||||||
|
|
||||||
# Warning:慢闪。以下单位均为毫秒。
|
# solid / flash 自定义颜色,三通道范围均为 0..255。
|
||||||
warning_on_ms = 300
|
normal_r = 0
|
||||||
warning_off_ms = 2700
|
normal_g = 0
|
||||||
|
normal_b = 0
|
||||||
|
|
||||||
# Critical:中速闪烁。
|
# flash 亮灯/灭灯时间,单位毫秒;范围 50..60000。
|
||||||
critical_on_ms = 400
|
normal_flash_on_ms = 1000
|
||||||
critical_off_ms = 1100
|
normal_flash_off_ms = 1000
|
||||||
|
|
||||||
# Emergency:快速闪烁。
|
# MCU 内置效果速度:1=慢,2=中(默认),3=快。
|
||||||
emergency_on_ms = 400
|
normal_effect_speed = 2
|
||||||
emergency_off_ms = 400
|
# flow / breathe 可使用的 MCU 内置有限颜色:
|
||||||
|
# red / green / blue / yellow / purple / cyan / white
|
||||||
|
# marquee / rainbow / colorful 会忽略该颜色值。
|
||||||
|
normal_effect_color = green
|
||||||
|
|
||||||
|
# DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示
|
||||||
|
# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级:
|
||||||
|
# CPU/频率/温度/散热 = 红色
|
||||||
|
# 供电/欠压 = 橙色闪烁(实机校准,避免内置黄色偏绿)
|
||||||
|
# 内存 = 紫色
|
||||||
|
# 磁盘 = 白色
|
||||||
|
# 网络/Wi-Fi = 蓝色
|
||||||
|
# 服务/进程 = 青色
|
||||||
|
# 颜色通道范围为 0..255。以下是本机实测后的默认值。
|
||||||
|
cpu_r = 255
|
||||||
|
cpu_g = 0
|
||||||
|
cpu_b = 0
|
||||||
|
power_r = 255
|
||||||
|
power_g = 40
|
||||||
|
power_b = 0
|
||||||
|
memory_r = 255
|
||||||
|
memory_g = 0
|
||||||
|
memory_b = 255
|
||||||
|
storage_r = 255
|
||||||
|
storage_g = 255
|
||||||
|
storage_b = 255
|
||||||
|
network_r = 0
|
||||||
|
network_g = 0
|
||||||
|
network_b = 255
|
||||||
|
service_r = 0
|
||||||
|
service_g = 255
|
||||||
|
service_b = 96
|
||||||
|
|
||||||
|
# 各严重等级可分别设置亮灯和灭灯时长,单位:毫秒,最小值 50。
|
||||||
|
# 默认完整周期:Warning 3秒、Critical 1.5秒、Emergency 0.6秒。
|
||||||
|
warning_flash_on_ms = 1500
|
||||||
|
warning_flash_off_ms = 1500
|
||||||
|
critical_flash_on_ms = 750
|
||||||
|
critical_flash_off_ms = 750
|
||||||
|
emergency_flash_on_ms = 300
|
||||||
|
emergency_flash_off_ms = 300
|
||||||
|
# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
|
||||||
|
# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
|
||||||
|
write_delay_ms = 10
|
||||||
|
|
||||||
|
# v3.3.0 RC 期间使用过的 brightness、*_breathe_seconds、rgb_update_ms、
|
||||||
|
# warning_color、critical_color、warning_flash_ms、critical_flash_ms 和
|
||||||
|
# emergency_flash_ms
|
||||||
|
# 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。
|
||||||
|
|
||||||
|
|
||||||
[dark_mode]
|
[dark_mode]
|
||||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||||
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
||||||
#
|
#
|
||||||
# off = Pigway Pi Control 启动时关闭该灯。
|
# off = PIGWay Pi Control 启动时关闭该灯。
|
||||||
# 若不希望程序干预,可改为 on。
|
# 若不希望程序干预,可改为 on。
|
||||||
#
|
#
|
||||||
# 注意:
|
# 注意:
|
||||||
|
|||||||
+153
-40
@@ -2,10 +2,65 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
echo "[1/7] 检查 Raspberry Pi I2C..."
|
FORCE_I2C_CONFLICT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force-i2c-conflict) FORCE_I2C_CONFLICT=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: sudo ./install.sh [--force-i2c-conflict]"
|
||||||
|
echo " --force-i2c-conflict Continue even when another process owns /dev/i2c-1."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
for required in app/pigway_pi_control.py app/pigway_pi_control_api.py app/oled_font_5x7.bin web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||||
|
if [ ! -f "$required" ]; then
|
||||||
|
echo "ERROR: required file missing: $required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[1/8] 检查 Raspberry Pi I2C..."
|
||||||
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
|
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
|
||||||
|
|
||||||
echo "[2/7] 等待 APT/DPKG..."
|
# A running copy of this project is expected during an upgrade. Any other
|
||||||
|
# process holding the shared bus could race OLED/MCU writes, so report it and
|
||||||
|
# let the user decide how that process should be managed.
|
||||||
|
i2c_conflict_pids() {
|
||||||
|
local allowed_pid pid
|
||||||
|
allowed_pid="$(systemctl show -p MainPID --value pigway-pi-control.service 2>/dev/null || true)"
|
||||||
|
for pid in $(fuser /dev/i2c-1 2>/dev/null || true); do
|
||||||
|
case "$pid" in
|
||||||
|
''|*[!0-9]*) continue ;;
|
||||||
|
esac
|
||||||
|
[ "$pid" = "$$" ] && continue
|
||||||
|
[ "$pid" = "$allowed_pid" ] && continue
|
||||||
|
kill -0 "$pid" 2>/dev/null && echo "$pid"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
I2C_CONFLICTS="$(i2c_conflict_pids)"
|
||||||
|
if [ -n "$I2C_CONFLICTS" ]; then
|
||||||
|
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
||||||
|
echo "WARNING: /dev/i2c-1 is already in use by another process:" >&2
|
||||||
|
else
|
||||||
|
echo "ERROR: /dev/i2c-1 is already in use by another process:" >&2
|
||||||
|
fi
|
||||||
|
while read -r pid; do
|
||||||
|
ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true
|
||||||
|
done <<< "$I2C_CONFLICTS"
|
||||||
|
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
||||||
|
echo "WARNING: continuing because --force-i2c-conflict was specified; no conflicting process will be stopped." >&2
|
||||||
|
else
|
||||||
|
echo "Stop or reconfigure the conflicting OLED/LED controller, then run the installer again." >&2
|
||||||
|
echo "If concurrent control is intentional, rerun with --force-i2c-conflict." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[2/8] 等待 APT/DPKG..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi
|
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi
|
||||||
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
|
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
|
||||||
@@ -13,70 +68,128 @@ for i in $(seq 1 60); do
|
|||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "[3/7] 安装依赖..."
|
echo "[3/8] 安装依赖..."
|
||||||
apt update
|
apt-get update
|
||||||
apt install -y python3 python3-pil python3-smbus2 i2c-tools
|
apt-get install -y python3 python3-pil python3-smbus2 i2c-tools
|
||||||
|
|
||||||
echo "[4/7] 检查硬件地址..."
|
echo "[4/8] 检查硬件地址..."
|
||||||
SCAN="$(i2cdetect -y 1)"; echo "$SCAN"
|
SCAN="$(i2cdetect -y 1)"; echo "$SCAN"
|
||||||
echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C"
|
echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C"
|
||||||
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D"
|
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D"
|
||||||
|
|
||||||
echo "[5/7] 安装程序与配置..."
|
echo "[5/8] 安装程序与配置..."
|
||||||
if systemctl list-unit-files 2>/dev/null | grep -q '^temp-control.service'; then
|
|
||||||
echo "检测到旧 temp-control.service,正在停止并禁用..."
|
|
||||||
systemctl disable --now temp-control.service 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
pkill -f '/temp_control/temp_control' 2>/dev/null || true
|
|
||||||
mkdir -p /usr/local/share/pigway-pi-control
|
mkdir -p /usr/local/share/pigway-pi-control
|
||||||
install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_font_5x7.bin
|
install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_font_5x7.bin
|
||||||
|
mkdir -p /usr/local/share/pigway-pi-control/web
|
||||||
|
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
|
||||||
|
|
||||||
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
||||||
|
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
||||||
|
|
||||||
NEW=/tmp/pigway-pi-control.conf.new
|
# Keep the complete existing configuration, including comments and custom sections.
|
||||||
cp config/pigway-pi-control.conf "$NEW"
|
# The merge below adds only missing documented options.
|
||||||
if [ -f /etc/pigway-pi-control.conf ]; then
|
if [ -f /etc/pigway-pi-control.conf ]; then
|
||||||
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
# Preserve user's [services] and [processes] sections; adopt new defaults elsewhere.
|
else
|
||||||
python3 - "$NEW" /etc/pigway-pi-control.conf <<'PY'
|
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
||||||
import configparser,sys
|
fi
|
||||||
newf,oldf=sys.argv[1:]
|
|
||||||
n=configparser.ConfigParser(); n.optionxform=str; n.read(newf)
|
# Preserve the existing file while exposing new defaults. Existing values win.
|
||||||
o=configparser.ConfigParser(); o.optionxform=str; o.read(oldf)
|
python3 - /etc/pigway-pi-control.conf <<'PY'
|
||||||
for sec in ("services","processes"):
|
import re,sys
|
||||||
if o.has_section(sec):
|
from pathlib import Path
|
||||||
if n.has_section(sec): n.remove_section(sec)
|
|
||||||
n.add_section(sec)
|
path=Path(sys.argv[1])
|
||||||
for k,v in o.items(sec): n.set(sec,k,v)
|
text=path.read_text()
|
||||||
with open(newf,"w") as f:n.write(f)
|
sections={
|
||||||
|
"led":(
|
||||||
|
("normal_mode","off"),
|
||||||
|
("normal_r",0),("normal_g",0),("normal_b",0),
|
||||||
|
("normal_flash_on_ms",1000),("normal_flash_off_ms",1000),
|
||||||
|
("normal_effect_speed",2),("normal_effect_color","green"),
|
||||||
|
("write_delay_ms",10),
|
||||||
|
("cpu_r",255),("cpu_g",0),("cpu_b",0),
|
||||||
|
("power_r",255),("power_g",40),("power_b",0),
|
||||||
|
("memory_r",255),("memory_g",0),("memory_b",255),
|
||||||
|
("storage_r",255),("storage_g",255),("storage_b",255),
|
||||||
|
("network_r",0),("network_g",0),("network_b",255),
|
||||||
|
("service_r",0),("service_g",255),("service_b",96),
|
||||||
|
("warning_flash_on_ms",1500),("warning_flash_off_ms",1500),
|
||||||
|
("critical_flash_on_ms",750),("critical_flash_off_ms",750),
|
||||||
|
("emergency_flash_on_ms",300),("emergency_flash_off_ms",300),
|
||||||
|
),
|
||||||
|
"api":(("bind","0.0.0.0"),("port",6001),("log_limit",200)),
|
||||||
|
}
|
||||||
|
for section,defaults in sections.items():
|
||||||
|
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
|
||||||
|
if match:
|
||||||
|
next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():])
|
||||||
|
end=match.end()+(next_section.start() if next_section else len(text)-match.end())
|
||||||
|
chunk=text[match.end():end]
|
||||||
|
else:
|
||||||
|
text=text.rstrip()+f"\n\n[{section}]\n"
|
||||||
|
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
|
||||||
|
end=len(text); chunk=""
|
||||||
|
missing=[(key,value) for key,value in defaults
|
||||||
|
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)]
|
||||||
|
if missing:
|
||||||
|
note="# Added by installer; existing user values are preserved.\n"
|
||||||
|
if section=="led" and any(key.startswith("normal_") for key,_ in missing):
|
||||||
|
note+=("# Normal RGB: normal_mode = off/solid/flash/flow/breathe/marquee/rainbow/colorful.\n"
|
||||||
|
"# normal_r/g/b = 0..255; normal_flash_*_ms = 50..60000; effect speed = 1..3.\n"
|
||||||
|
"# Effect color = red/green/blue/yellow/purple/cyan/white (flow/breathe only).\n")
|
||||||
|
block=note+"".join(f"{key} = {value}\n" for key,value in missing)
|
||||||
|
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
|
||||||
|
path.write_text(text)
|
||||||
|
PY
|
||||||
|
if [ ! -s /etc/pigway-pi-control-api.token ]; then
|
||||||
|
umask 077
|
||||||
|
python3 - <<'PY' > /etc/pigway-pi-control-api.token
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_urlsafe(32))
|
||||||
PY
|
PY
|
||||||
fi
|
fi
|
||||||
install -m 0644 "$NEW" /etc/pigway-pi-control.conf
|
chmod 0600 /etc/pigway-pi-control-api.token
|
||||||
rm -f "$NEW"
|
|
||||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service
|
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service
|
||||||
|
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/pigway-pi-control-api.service
|
||||||
|
|
||||||
echo "[6/7] 启用/重启服务..."
|
echo "[6/8] 启用/重启服务..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable pigway-pi-control.service >/dev/null
|
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
|
||||||
systemctl restart pigway-pi-control.service
|
systemctl restart pigway-pi-control.service
|
||||||
|
systemctl restart pigway-pi-control-api.service
|
||||||
|
|
||||||
echo "[7/8] 安装后自检..."
|
echo "[7/8] 安装后自检..."
|
||||||
FAIL=0
|
FAIL=0
|
||||||
check() { if eval "$2"; then printf " %-30s PASS\n" "$1"; else printf " %-30s FAIL\n" "$1"; FAIL=1; fi; }
|
check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; }
|
||||||
check "Version 3.1.0" "grep -q 'APP_VERSION=\"3.1.0\"' /usr/local/sbin/pigway-pi-control"
|
APP=/usr/local/sbin/pigway-pi-control
|
||||||
check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control"
|
check "Version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' "$APP"
|
||||||
check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control"
|
check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP"
|
||||||
check "OLED pages 0..3" "grep -q 'cmd(0x22);cmd(0);cmd(3)' /usr/local/sbin/pigway-pi-control"
|
check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$APP"
|
||||||
check "UI CPU/TMP/DSK" "grep -q 'f\"TMP {temp:.1f}C\"' /usr/local/sbin/pigway-pi-control"
|
check "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP"
|
||||||
check "UI IP4/IP6" "grep -q 'IP6 .*IP4 ' /usr/local/sbin/pigway-pi-control"
|
check "UI CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP"
|
||||||
check "UI right column x=72" "grep -q \"draw_text_5x7(pix,72,y,right\\[row\\])\" /usr/local/sbin/pigway-pi-control"
|
check "UI IP4/IP6" grep -Fq 'ip_label' "$APP"
|
||||||
check "Legacy service stopped" "! systemctl is-active --quiet temp-control.service"
|
check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP"
|
||||||
check "Service running" "systemctl is-active --quiet pigway-pi-control.service"
|
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
||||||
|
printf " %-30s WARN (forced)\n" "I2C owner conflict"
|
||||||
|
else
|
||||||
|
no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; }
|
||||||
|
check "No other I2C owner" no_other_i2c_owner
|
||||||
|
fi
|
||||||
|
check "Service running" systemctl is-active --quiet pigway-pi-control.service
|
||||||
|
check "API/Web service running" systemctl is-active --quiet pigway-pi-control-api.service
|
||||||
|
check "API/Web version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' /usr/local/sbin/pigway-pi-control-api
|
||||||
|
check "Web UI installed" test -s /usr/local/share/pigway-pi-control/web/index.html
|
||||||
|
check "P0 health monitor" grep -Fq 'throttled_state' "$APP"
|
||||||
|
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
|
||||||
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
||||||
echo "[8/8] 状态..."
|
echo "[8/8] 状态..."
|
||||||
|
|
||||||
|
|
||||||
systemctl --no-pager --full status pigway-pi-control.service || true
|
systemctl --no-pager --full status pigway-pi-control.service || true
|
||||||
|
systemctl --no-pager --full status pigway-pi-control-api.service || true
|
||||||
echo
|
echo
|
||||||
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
||||||
echo "配置:/etc/pigway-pi-control.conf"
|
echo "配置:/etc/pigway-pi-control.conf"
|
||||||
|
echo "Web/API:http://$(hostname -I | awk '{print $1}'):$(awk -F= '/^port[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}' /etc/pigway-pi-control.conf)"
|
||||||
|
echo "配置写入 token:sudo cat /etc/pigway-pi-control-api.token"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PIGWay Pi Control API and Web
|
||||||
|
After=network.target pigway-pi-control.service
|
||||||
|
Wants=network.target pigway-pi-control.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/sbin/pigway-pi-control-api
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
User=root
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor
|
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
|
||||||
After=multi-user.target network.target
|
After=multi-user.target network.target
|
||||||
Wants=network.target
|
Wants=network.target
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,8 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
systemctl disable --now pigway-pi-control.service 2>/dev/null || true
|
systemctl disable --now pigway-pi-control-api.service pigway-pi-control.service 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/pigway-pi-control-api.service
|
||||||
rm -f /etc/systemd/system/pigway-pi-control.service
|
rm -f /etc/systemd/system/pigway-pi-control.service
|
||||||
rm -f /usr/local/sbin/pigway-pi-control
|
rm -f /usr/local/sbin/pigway-pi-control
|
||||||
|
rm -f /usr/local/sbin/pigway-pi-control-api
|
||||||
|
rm -f /etc/pigway-pi-control-api.token
|
||||||
rm -rf /usr/local/share/pigway-pi-control
|
rm -rf /usr/local/share/pigway-pi-control
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
|
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>PIGWay Pi Control</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark;--bg:#0b1118;--panel:#121b25;--panel2:#0f1822;--line:#263546;--text:#e7edf5;--muted:#91a4b8;--good:#50d890;--bad:#ff6b6b;--blue:#58a6ff}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top right,#14263b 0,#0b1118 42%);color:var(--text);font:14px/1.45 system-ui,-apple-system,sans-serif}main{max-width:1180px;margin:auto;padding:28px 20px 70px}header{display:flex;justify-content:space-between;align-items:end;gap:18px;margin-bottom:22px}h1{font-size:27px;margin:0}h2{font-size:18px;margin:0 0 8px}h3{font-size:15px;margin:0}.sub,.muted,.hint{color:var(--muted)}.hint{font-size:12px;margin-top:4px}.live{display:flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--bad)}.dot.ok{background:var(--good);box-shadow:0 0 12px var(--good)}nav{display:flex;gap:8px;margin:18px 0;position:sticky;top:0;z-index:5;padding:8px 0;background:#0b1118e8;backdrop-filter:blur(8px)}button{border:1px solid var(--line);background:#162230;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover,button.active{border-color:var(--blue);background:#1b3047}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.card,.panel,.group{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 12px 28px #0004}.group{background:var(--panel2);box-shadow:none;margin-top:12px}.group-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.value{font-size:23px;font-weight:700;margin-top:4px}.wide{grid-column:span 2}.panel{margin-top:12px}.rows{display:grid;gap:8px}.row{display:flex;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:1px solid #203040}.row:last-child{border:0}.badge{border-radius:999px;padding:3px 8px;background:#243447}.badge.good{color:var(--good)}.badge.bad{color:var(--bad)}.hidden{display:none!important}.log{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;padding:8px 0;border-bottom:1px solid #203040}.log-label{font:12px system-ui;color:var(--blue);margin-right:8px}.config-shell{display:grid;grid-template-columns:210px minmax(0,1fr);gap:14px;margin-top:14px}.config-menu{display:flex;flex-direction:column;gap:7px;align-self:start;position:sticky;top:66px}.config-menu button{text-align:left}.config-page{display:none}.config-page.active{display:block}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field label{display:block;color:var(--muted);font-size:12px}.field input,.field select{width:100%;margin-top:5px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.field input:focus,.field select:focus{outline:1px solid var(--blue);border-color:var(--blue)}.span2{grid-column:span 2}.rgb-row{display:grid;grid-template-columns:48px repeat(3,1fr);gap:9px;align-items:end}.swatch{width:38px;height:38px;border-radius:50%;border:2px solid #fff3;box-shadow:0 0 12px #0008;margin-bottom:2px}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:12px 0}.toolbar input{min-width:280px;flex:1;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.savebar{position:sticky;bottom:10px;z-index:4;background:#101b27ef;border:1px solid var(--line);border-radius:10px;padding:10px;box-shadow:0 8px 30px #0008}.error{color:var(--bad)}.success{color:var(--good)}@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}.fields{grid-template-columns:1fr}.span2{grid-column:span 1}.wide{grid-column:span 2}.config-shell{grid-template-columns:1fr}.config-menu{position:static;display:grid;grid-template-columns:repeat(2,1fr)}.rgb-row{grid-template-columns:38px 1fr}.rgb-row .field{grid-column:2}.swatch{grid-row:1/span 3}}@media(max-width:470px){.grid{grid-template-columns:1fr}.wide{grid-column:span 1}header{align-items:start;flex-direction:column}.config-menu{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<header><div><h1>PIGWay Pi Control</h1><div class="sub" id="identity">正在连接树莓派…</div></div><div class="live"><span class="dot" id="dot"></span><span id="liveText">离线</span></div></header>
|
||||||
|
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
|
||||||
|
<section id="overview"><div class="grid">
|
||||||
|
<div class="card"><div class="muted">处理器负载</div><div class="value" id="cpu">--</div><div class="muted" id="freq">--</div></div><div class="card"><div class="muted">处理器温度</div><div class="value" id="temp">--</div><div class="muted" id="fan">--</div></div><div class="card"><div class="muted">内存占用</div><div class="value" id="memory">--</div></div><div class="card"><div class="muted">磁盘占用</div><div class="value" id="disk">--</div></div><div class="card wide"><div class="muted">主网络</div><div class="value" id="network">--</div><div class="muted" id="address">--</div></div><div class="card wide"><div class="muted">显示状态</div><div class="value" id="owner">--</div><div class="muted" id="rgb">--</div></div>
|
||||||
|
</div><div class="panel"><h2>当前告警</h2><div class="rows" id="alerts"><div class="muted">无告警</div></div></div><div class="panel"><h2>服务与进程监控</h2><div class="rows" id="watches"></div></div></section>
|
||||||
|
<section id="logs" class="hidden"><div class="panel"><div class="toolbar"><button id="refreshLogs">刷新日志</button><span class="muted">最近 200 条硬件 Agent 事件</span></div><div id="logRows"></div></div></section>
|
||||||
|
<section id="config" class="hidden"><div class="panel"><h2>系统配置</h2><p class="muted">配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。</p><div class="toolbar"><input id="token" type="password" placeholder="配置写入令牌(Bearer Token)"><button id="toggleToken">显示令牌</button></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar"><button id="saveConfig">保存并应用</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
|
||||||
|
</main><script>
|
||||||
|
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={};const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const sectionMeta={led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量']};
|
||||||
|
const labels={'led.normal_mode':'正常状态灯效','led.normal_r':'红色通道','led.normal_g':'绿色通道','led.normal_b':'蓝色通道','led.normal_flash_on_ms':'亮灯时间','led.normal_flash_off_ms':'灭灯时间','led.normal_effect_speed':'内置效果速度','led.normal_effect_color':'内置效果颜色','led.warning_flash_on_ms':'普通告警亮灯','led.warning_flash_off_ms':'普通告警灭灯','led.critical_flash_on_ms':'严重告警亮灯','led.critical_flash_off_ms':'严重告警灭灯','led.emergency_flash_on_ms':'紧急告警亮灯','led.emergency_flash_off_ms':'紧急告警灭灯','led.write_delay_ms':'RGB 寄存器写入间隔','alerts.cpu_high':'处理器高负载阈值','alerts.cpu_recover':'处理器恢复阈值','alerts.cpu_high_seconds':'处理器持续时间','alerts.memory_high':'内存高占用阈值','alerts.memory_recover':'内存恢复阈值','alerts.memory_high_seconds':'内存持续时间','alerts.temp_high':'高温告警阈值','alerts.temp_critical':'严重高温阈值','alerts.disk_high':'磁盘高占用阈值','alerts.disk_critical':'磁盘严重阈值','alerts.wifi_weak':'Wi-Fi 弱信号阈值','alerts.wifi_recover':'Wi-Fi 恢复阈值','alerts.wifi_weak_seconds':'弱信号持续时间','fan.start':'风扇启动温度','fan.level2':'二档起始温度','fan.level3':'三档起始温度','fan.level4':'四档起始温度','fan.max':'全速起始温度','fan.hysteresis':'降档温度回差','timing.sensor_interval':'传感器采样周期','timing.oled_refresh_interval':'OLED 刷新周期','timing.page_interval':'OLED 页面轮播周期','timing.network_interval':'网络检查周期','timing.service_interval':'服务检查周期','timing.health_interval':'健康检查周期','display.row1_y':'第一行纵坐标','display.row2_y':'第二行纵坐标','display.row3_y':'第三行纵坐标','display.row4_y':'第四行纵坐标','dark_mode.pi_pwr_led':'树莓派电源灯','dark_mode.pi_act_led':'树莓派活动灯','api.bind':'监听地址','api.port':'监听端口','api.log_limit':'默认日志条数'};
|
||||||
|
['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>labels[`led.${g}_${c}`]=`${({r:'红',g:'绿',b:'蓝'})[c]}色通道`));
|
||||||
|
const hints={'led.normal_mode':'仅在没有当前告警时生效;告警会立即抢占。','led.normal_r':'范围 0–255。','led.normal_g':'范围 0–255。','led.normal_b':'范围 0–255。','led.normal_flash_on_ms':'范围 50–60000 毫秒。','led.normal_flash_off_ms':'范围 50–60000 毫秒。','led.normal_effect_speed':'MCU 内置速度档位。','led.normal_effect_color':'仅流水灯和呼吸灯使用。','led.write_delay_ms':'硬件校准项,通常保持 10 毫秒。','alerts.wifi_weak':'单位 dBm,数值越接近 0 信号越强。','alerts.wifi_recover':'单位 dBm,应高于弱信号阈值。','fan.hysteresis':'避免临界温度附近频繁切换档位。','api.bind':'0.0.0.0 允许局域网访问;127.0.0.1 仅本机。','api.port':'范围 1–65535;保存后 API 自动重启。','dark_mode.pi_pwr_led':'选择“关闭”时由 PIGWay 熄灭板载灯。','dark_mode.pi_act_led':'选择“关闭”时由 PIGWay 熄灭板载灯。'};
|
||||||
|
const options={'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]};
|
||||||
|
const numberRules={'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1]));
|
||||||
|
const sectionOrder=['led','alerts','fan','timing','display','dark_mode','services','processes','api'],alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
|
||||||
|
const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只读',TEMP_CRITICAL:'处理器温度严重',POWER_UNDERVOLT:'供电欠压',THROTTLED:'处理器降频',FREQ_CAPPED:'频率受限',NETWORK_DOWN:'网络断开',INTERNET_DOWN:'互联网断开',SERVICE_DOWN:'服务停止',PROCESS_DOWN:'进程停止',DISK_CRITICAL:'磁盘严重不足',TEMP_HIGH:'处理器温度偏高',CPU_HIGH:'处理器负载过高',MEMORY_HIGH:'内存占用过高',DISK_HIGH:'磁盘空间不足',WIFI_WEAK:'Wi-Fi 信号弱'},rgbNames={OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',RGB_MODE:'RGB 模式切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复'};
|
||||||
|
function ownerName(id){const [type,suffix]=String(id||'').split('|',2);return(ownerNames[type]||type)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return rgbNames[mode];const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${groups[p[0]]||p[0]} · ${levels[p[1]]||p[1]}闪烁`}
|
||||||
|
function field(section,key,value,extra=''){const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=labels[id]||key,help=hints[id]||'';const control=opts?`<select data-section="${esc(section)}" data-key="${esc(key)}">${opts.map(([v,l])=>`<option value="${v}" ${String(value)===v?'selected':''}>${l}</option>`).join('')}</select>`:`<input ${rule?'type="number"':''} ${rule?`min="${rule[0]}" max="${rule[1]}" step="${rule[2]}"`:''} data-section="${esc(section)}" data-key="${esc(key)}" value="${esc(value)}">`;return`<div class="field ${extra}"><label>${esc(label)}${control}</label>${help?`<div class="hint">${esc(help)}</div>`:''}</div>`}
|
||||||
|
function rgbGroup(values,prefix,title){const r=values[`${prefix}_r`],g=values[`${prefix}_g`],b=values[`${prefix}_b`];return`<div class="group"><div class="group-head"><h3>${title}</h3><span class="muted">RGB 自定义颜色</span></div><div class="rgb-row"><span class="swatch" data-swatch="${prefix}" style="background:rgb(${r},${g},${b})"></span>${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}</div></div>`}
|
||||||
|
function normalGroup(v){return`<div class="group"><div class="group-head"><div><h3>正常运行灯效</h3><div class="hint">没有告警时显示;告警出现后自动让位。</div></div></div><div class="fields">${field('led','normal_mode',v.normal_mode,'span2')}<div class="normal-field span2" data-modes="solid flash">${rgbGroup(v,'normal','自定义颜色')}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_on_ms',v.normal_flash_on_ms)}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_off_ms',v.normal_flash_off_ms)}</div><div class="normal-field" data-modes="flow breathe marquee rainbow colorful">${field('led','normal_effect_speed',v.normal_effect_speed)}</div><div class="normal-field" data-modes="flow breathe">${field('led','normal_effect_color',v.normal_effect_color)}</div></div></div>`}
|
||||||
|
function renderLed(v){const flash=['warning','critical','emergency'].map((x,i)=>`<div class="group"><h3>${['普通告警','严重告警','紧急告警'][i]}闪烁节奏</h3><div class="fields">${field('led',`${x}_flash_on_ms`,v[`${x}_flash_on_ms`])}${field('led',`${x}_flash_off_ms`,v[`${x}_flash_off_ms`])}</div></div>`).join(''),advanced=v.write_delay_ms===undefined?'':`<div class="group"><h3>硬件高级参数</h3><div class="fields">${field('led','write_delay_ms',v.write_delay_ms)}</div></div>`;return normalGroup(v)+`<div class="group"><div class="group-head"><div><h3>告警对象颜色</h3><div class="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div>${alertColorGroups.map(([p,t])=>rgbGroup(v,p,t)).join('')}</div>`+flash+advanced}
|
||||||
|
function renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`<div class="group"><div class="group-head"><div><h3>${t}</h3><div class="hint">${d}</div></div></div><div class="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<div class="muted">暂无配置项</div>'}</div></div>`}
|
||||||
|
function updateConditional(){const mode=document.querySelector('[data-section="led"][data-key="normal_mode"]')?.value||'off';document.querySelectorAll('.normal-field').forEach(x=>x.classList.toggle('hidden',!x.dataset.modes.split(' ').includes(mode)));updateSwatches()}function updateSwatches(){document.querySelectorAll('[data-swatch]').forEach(s=>{const p=s.dataset.swatch,read=c=>Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0);s.style.background=`rgb(${read('r')},${read('g')},${read('b')})`})}
|
||||||
|
function renderConfig(){const ss=sectionOrder.filter(s=>original[s]);$('configMenu').innerHTML=ss.map((s,i)=>`<button class="${i===0?'active':''}" data-config-section="${s}">${sectionMeta[s]?.[0]||s}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<section class="config-page ${i===0?'active':''}" data-config-page="${s}"><h2>${sectionMeta[s]?.[0]||s}</h2><p class="muted">${sectionMeta[s]?.[1]||''}</p>${s==='led'?renderLed(original[s]):renderGeneral(s,original[s])}</section>`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection))};$('configRows').oninput=e=>{if(e.target.matches('[data-section="led"]'))updateConditional();markChanged()};updateConditional()}
|
||||||
|
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?`有 ${n} 项尚未保存`:'尚未修改';$('saveState').className=n?'':'muted'}
|
||||||
|
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(`服务器返回异常 (${r.status})`)}if(!r.ok)throw Error(j.error||`请求失败 (${r.status})`);return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
|
||||||
|
async function loadStatus(){try{const s=await get('/api/v1/status'),y=s.system,n=s.network,d=s.display;$('dot').classList.add('ok');$('liveText').textContent='在线';$('identity').textContent=`版本 ${s.version} · 已运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('cpu').textContent=`${y.cpu_percent}%`;$('freq').textContent=`频率 ${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;$('fan').textContent=`风扇 ${({OFF:'关闭',L1:'一档',L2:'二档',L3:'三档',L4:'四档',MAX:'全速'})[y.fan_name]||y.fan_name}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${n.kind==='WIF'?'无线网络':n.kind==='ETH'?'有线网络':n.kind} ${n.metric}`;$('address').textContent=`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · 互联网${n.internet?'正常':'断开'}`;$('owner').textContent=ownerName(d.owner);$('rgb').textContent=`OLED:${ownerName(d.oled_page)} · RGB:${rgbName(d.rgb_mode)}`;rows('alerts',d.alerts.map(a=>`<div class="row"><span><b>${esc(ownerName(a.id))}</b><br><span class="muted">${esc(a.l2)} ${esc(a.l3)}</span></span><span class="badge bad">等级 ${a.severity} · 优先级 ${a.priority}</span></div>`),'当前没有告警');rows('watches',s.watches.map(w=>`<div class="row"><span><b>${esc(w.name)}</b><br><span class="muted">${w.type==='service'?'系统服务':'普通进程'} · ${esc(w.target)}</span></span><span class="badge ${w.up?'good':'bad'}">${w.up?'正常':'停止'}</span></div>`))}catch(e){$('dot').classList.remove('ok');$('liveText').textContent='离线';$('identity').textContent=e.message}}
|
||||||
|
async function loadLogs(){try{const d=await get('/api/v1/logs?limit=200');rows('logRows',d.logs.reverse().map(x=>{const m=String(x.message).match(/event=([^ ]+)/),label=m?(eventNames[m[1]]||m[1]):'系统事件';return`<div class="log"><span class="log-label">${esc(label)}</span>${esc(x.message)}</div>`}))}catch(e){$('logRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
||||||
|
$('refreshLogs').onclick=loadLogs;$('token').value=localStorage.getItem('pigwayToken')||'';$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=show?'隐藏令牌':'显示令牌'};$('saveConfig').onclick=async()=>{const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});if(!Object.keys(updates).length){$('saveState').textContent='没有需要保存的修改';return}try{$('saveConfig').disabled=true;$('saveState').textContent='正在保存并应用…';const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=`保存成功,已创建备份 ${d.backup}`;$('saveState').className='success';setTimeout(loadConfig,1500)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false}};loadStatus();setInterval(loadStatus,3000);
|
||||||
|
</script></body></html>
|
||||||
Reference in New Issue
Block a user