Compare commits
30
Commits
v3.2.0
..
4ba4d461db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba4d461db | ||
|
|
45c7b8c168 | ||
|
|
b39c7e5279 | ||
|
|
2e5562fb3e | ||
|
|
03fd6d1cd8 | ||
|
|
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 |
@@ -1,127 +1,208 @@
|
||||
# Pigway Pi Control v3.2.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
|
||||
CPU 18.6% MEM 16.2%
|
||||
TMP 46.3C FAN OFF
|
||||
DSK 22.4% NET OK
|
||||
DSK 22.4% WIF -32
|
||||
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
|
||||
sudo raspi-config
|
||||
vcgencmd get_throttled
|
||||
```
|
||||
|
||||
进入:
|
||||
区分:
|
||||
|
||||
- 当前欠压:立即 `POWER LOW / UNDERVOLT` 告警
|
||||
- 当前 CPU throttled / frequency capped:立即告警
|
||||
- 本次启动历史上曾欠压/降频:只写一次历史事件日志,不持续 RGB 告警
|
||||
- 状态恢复:写 `RECOVERED` 类事件
|
||||
|
||||
注意:这里检测的是 **Pi 的供电质量**,不是 UPS 电池百分比。
|
||||
|
||||
### 2. 文件系统健康
|
||||
|
||||
检测根文件系统 `/` 是否被内核切成只读。如果出现只读状态,产生严重告警:
|
||||
|
||||
```text
|
||||
Interface Options
|
||||
→ I2C
|
||||
→ Enable
|
||||
FILESYSTEM RO
|
||||
ROOT READ ONLY
|
||||
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
|
||||
sudo reboot
|
||||
journalctl -u pigway-pi-control -f
|
||||
```
|
||||
|
||||
### 第二步:解压本安装包
|
||||
|
||||
进入解压后的目录:
|
||||
只看本次启动:
|
||||
|
||||
```bash
|
||||
cd pigway-pi-control-v3.2.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
|
||||
GET /api/v1/services
|
||||
```
|
||||
|
||||
配置写入接口:
|
||||
|
||||
```text
|
||||
PUT /api/v1/config
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
|
||||
```
|
||||
|
||||
服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。SSH、向日葵、Tailscale 和 PIGWay 自身服务不能从 Web 停止,以免中断远程管理或控制进程。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。
|
||||
|
||||
读取 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
|
||||
sudo ./install.sh
|
||||
@@ -129,257 +210,29 @@ sudo ./install.sh
|
||||
|
||||
安装器会:
|
||||
|
||||
1. 检查 `/dev/i2c-1`
|
||||
2. 等待 APT/DPKG 锁
|
||||
3. 安装必要的小型系统依赖
|
||||
4. 检查 I²C 总线
|
||||
5. 自动停止并禁用卖家旧 `temp-control.service`
|
||||
6. 安装正式程序
|
||||
7. 安装用户配置
|
||||
8. 安装 systemd 服务并开机自启
|
||||
9. 执行安装后自检
|
||||
- 安装必要依赖
|
||||
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
|
||||
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
|
||||
- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理
|
||||
- 安装字体、程序、配置和 systemd unit
|
||||
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
|
||||
- 生成只读 root token,安装 API/Web 文件
|
||||
- 启动硬件 Agent 与 API/Web 服务
|
||||
- 执行版本、OLED、UI、P0健康监控和诊断入口自检
|
||||
|
||||
安装成功后查看:
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
systemctl status pigway-pi-control --no-pager -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.2.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-api -f
|
||||
journalctl -u pigway-pi-control -f
|
||||
```
|
||||
|
||||
重启:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control
|
||||
sudo systemctl restart pigway-pi-control-api
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
```
|
||||
|
||||
停止:
|
||||
## 版本路线
|
||||
|
||||
```bash
|
||||
sudo systemctl stop pigway-pi-control
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```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.2.0
|
||||
6. sudo ./install.sh
|
||||
```
|
||||
|
||||
完成。
|
||||
|
||||
## v3.2.0 网络状态增强
|
||||
|
||||
主页显示当前 Linux 默认路由接口:Wi-Fi 为 `WIF -52`(RSSI),有线为 `ETH 1G`(链路速率)。双网同时连接时跟随内核默认路由,IP 与该接口保持一致。新增 Wi-Fi 弱信号持续告警。
|
||||
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
||||
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
||||
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
||||
|
||||
+9
-6
@@ -1,7 +1,10 @@
|
||||
ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md
|
||||
5442d660875ec28909b1183e4e2aee604e9f2e82d229c3806498392e7a4546e3 README.md
|
||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||
0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py
|
||||
48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf
|
||||
7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh
|
||||
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
||||
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
|
||||
d71d949643d31461f2e62ded3818e1a0f1711d1420756ad0cc32eb04cde0fad3 app/pigway_pi_control.py
|
||||
bf52a12d3208b5330f3e2980187e70330b930e950cfd5165b4e6605febfef65d app/pigway_pi_control_api.py
|
||||
75fa662ed21138a8e394de83e46281dab039433cc0d7a8f787e1a049f4b47ec8 config/pigway-pi-control.conf
|
||||
f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh
|
||||
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
||||
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||
50ff3f73a16747233aa8400201f55b3b3eb579767e39af49d2fd0f6433718360 web/index.html
|
||||
|
||||
+561
-152
@@ -1,23 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
import os,time,socket,subprocess,configparser,signal
|
||||
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json
|
||||
from pathlib import Path
|
||||
APP_VERSION="3.2.0"
|
||||
APP_VERSION="3.6.0"
|
||||
from smbus2 import SMBus
|
||||
from PIL import Image
|
||||
from shutil import disk_usage
|
||||
|
||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
||||
CFG="/etc/pigway-pi-control.conf"
|
||||
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||
running=True
|
||||
|
||||
def stop(*_):
|
||||
global running; running=False
|
||||
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"
|
||||
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
|
||||
prev_cpu=None
|
||||
page=0; page_since=time.monotonic()
|
||||
@@ -28,7 +72,7 @@ wifi_weak_since=None; wifi_weak_active=False
|
||||
last_net=last_watch=0
|
||||
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():
|
||||
# Seller-native physical 128x32 configuration.
|
||||
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
|
||||
@@ -67,10 +111,10 @@ def oled_show(lines_lr, title_zh=False):
|
||||
data.append(v)
|
||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||
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
|
||||
def rgb_off():
|
||||
global _last_rgb
|
||||
@@ -78,16 +122,6 @@ def rgb_off():
|
||||
mcu(0x07,0x00) # seller closeRGB(): true 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):
|
||||
base=Path("/sys/class/leds")/name
|
||||
try:
|
||||
@@ -135,8 +169,8 @@ def mem_pct():
|
||||
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
||||
def disk_pct():
|
||||
d=disk_usage("/"); return 100*d.used/d.total
|
||||
def _run(args):
|
||||
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=2).strip()
|
||||
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():
|
||||
@@ -160,7 +194,6 @@ def primary_network():
|
||||
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
|
||||
if label=="IP6" and len(ipval)>18: ipval=ipval[:7]+".."+ipval[-7:]
|
||||
return iface,kind,metric,label,(ipval or "NO IP")
|
||||
|
||||
def get_ip():
|
||||
@@ -194,6 +227,7 @@ OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||
NETWORK_INTERVAL=cf("timing","network_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_RECOVER=cf("alerts","cpu_recover",75.0)
|
||||
@@ -215,39 +249,161 @@ 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)]
|
||||
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"))
|
||||
LED_BRIGHT=cf("led","brightness",10,int)
|
||||
LED_WARN_ON=cf("led","warning_on_ms",300,int); LED_WARN_OFF=cf("led","warning_off_ms",2700,int)
|
||||
LED_CRIT_ON=cf("led","critical_on_ms",400,int); LED_CRIT_OFF=cf("led","critical_off_ms",1100,int)
|
||||
LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emergency_off_ms",400,int)
|
||||
RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int))
|
||||
WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int)))
|
||||
CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,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):
|
||||
typ,label,target=w
|
||||
if typ=="service":
|
||||
r=subprocess.run(["systemctl","is-active","--quiet",target])
|
||||
else:
|
||||
r=subprocess.run(["pgrep","-f",target],stdout=subprocess.DEVNULL)
|
||||
return r.returncode==0
|
||||
args=["systemctl","is-active","--quiet",target] if typ=="service" else ["pgrep","-f","--",target]
|
||||
try:
|
||||
r=subprocess.run(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=2)
|
||||
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
|
||||
a=[]
|
||||
cpu_hi=cpu_hi+1 if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_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:a.append(alert("CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","FOR >=30 SEC",1,(24,18,0)))
|
||||
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))
|
||||
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)))
|
||||
if mem_hi>=MEM_HIGH_SEC:a.append(alert("MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","",1,(18,0,24)))
|
||||
if disk>=DISK_CRIT:a.append(alert("DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE",2,(28,6,0),2))
|
||||
elif disk>=DISK_HIGH:a.append(alert("DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE",1,(28,8,0)))
|
||||
if not link_ok:a.append(alert("NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","",2,(0,8,28),1))
|
||||
elif not net_ok:a.append(alert("INTERNET DOWN","LOCAL LINK OK",ip,"",2,(0,8,28),1))
|
||||
cpu_hi=cpu_hi+sample_elapsed 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
|
||||
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")
|
||||
else: display.clear("CPU_HIGH")
|
||||
if temp>=TEMP_CRIT:
|
||||
display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||
display.clear("TEMP_HIGH")
|
||||
elif temp>=TEMP_HIGH:
|
||||
display.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||
display.clear("TEMP_CRITICAL")
|
||||
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):
|
||||
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]
|
||||
if not ok:
|
||||
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:
|
||||
@@ -258,33 +414,80 @@ def make_alerts(cpu,temp,mem,disk):
|
||||
elif rssi >= WIFI_RECOVER:
|
||||
wifi_weak_active=False; wifi_weak_since=None
|
||||
if wifi_weak_active:
|
||||
a.append(alert("WIFI SIGNAL WEAK",f"{rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL",1,(0,0,28),1))
|
||||
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
|
||||
a.sort(key=lambda x:x["sev"],reverse=True)
|
||||
return a
|
||||
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):
|
||||
# Color = fault object. Blink frequency = severity. All alerts blink.
|
||||
if not a:
|
||||
rgb_off(); return
|
||||
if a["sev"]>=3: on_ms,off_ms=LED_EMERG_ON,LED_EMERG_OFF
|
||||
elif a["sev"]>=2: on_ms,off_ms=LED_CRIT_ON,LED_CRIT_OFF
|
||||
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_mode_for(request_id,severity):
|
||||
if severity<=0: return "OFF"
|
||||
alert_type=request_id.split("|",1)[0]
|
||||
color=RGB_OBJECT_GROUPS[alert_type]
|
||||
level="EMERGENCY" if severity>=3 else "CRITICAL" if severity>=2 else "WARNING"
|
||||
return f"{color}_{level}_FLASH"
|
||||
|
||||
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):
|
||||
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
||||
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
||||
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=[
|
||||
f"CPU {cpu:.1f}%",
|
||||
f"TMP {temp:.1f}C",
|
||||
f"DSK {disk:.1f}%",
|
||||
ip_label+" "+ip,
|
||||
ip_label+" "+display_ip,
|
||||
]
|
||||
right=[
|
||||
f"MEM {mem:.1f}%",
|
||||
@@ -306,108 +509,314 @@ def render_home(cpu,temp,mem,disk):
|
||||
data.append(v)
|
||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||
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):
|
||||
oled_show([(a["title"],f"{idx}/{total}"),
|
||||
def render_alert(a,index,total):
|
||||
oled_show([(a["title"],f"{index}/{total}"),
|
||||
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
|
||||
|
||||
|
||||
oled_init(); rgb_off(); apply_dark_mode()
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
last_sensor=last_render=0.0
|
||||
last_net=last_watch=time.monotonic()
|
||||
latest=(0.0,0.0,0.0,0.0)
|
||||
active=[]
|
||||
|
||||
# Page state is a snapshot queue. Live sensor refreshes NEVER choose another page.
|
||||
# Each cycle is HOME + every alert captured at the cycle boundary.
|
||||
cycle_pages=["HOME"]
|
||||
cycle_pos=0
|
||||
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:
|
||||
try:
|
||||
class DisplayController:
|
||||
def __init__(self):
|
||||
now=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()
|
||||
|
||||
# Independent clocks: update data/state only.
|
||||
if now-last_sensor >= SENSOR_INTERVAL:
|
||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||
latest=(c,t,m,d); update_fan(t); last_sensor=now
|
||||
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()
|
||||
|
||||
if now-last_net >= NETWORK_INTERVAL:
|
||||
net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False
|
||||
last_net=now
|
||||
|
||||
if now-last_watch >= SERVICE_INTERVAL:
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
last_watch=now
|
||||
|
||||
c,t,m,d=latest
|
||||
active=make_alerts(c,t,m,d)
|
||||
|
||||
# If the currently displayed alert truly disappeared, skip it immediately.
|
||||
if cycle_pages[cycle_pos]!="HOME":
|
||||
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:
|
||||
current=cycle_pages[cycle_pos]
|
||||
if current=="HOME":
|
||||
render_home(c,t,m,d)
|
||||
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:
|
||||
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
|
||||
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()
|
||||
|
||||
# LED follows OLED page; HOME reflects highest currently active risk.
|
||||
current=cycle_pages[cycle_pos]
|
||||
if current=="HOME":
|
||||
led_for(active[0] if active else None)
|
||||
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:
|
||||
a=find_live_alert(current,active)
|
||||
led_for(a if a else (active[0] if active else None))
|
||||
alert_ids=[a["id"] for a in alerts]
|
||||
render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
||||
|
||||
time.sleep(0.20)
|
||||
except Exception as e:
|
||||
print("loop error:",e,flush=True); time.sleep(1)
|
||||
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"]
|
||||
|
||||
rgb_off(); mcu(0x08,0x00); bus.close()
|
||||
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)
|
||||
display=DisplayController()
|
||||
try:
|
||||
bus=SMBus(1)
|
||||
FONT5=Path(FONT_PATH).read_bytes()
|
||||
display.start(); apply_dark_mode()
|
||||
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
||||
while running:
|
||||
try:
|
||||
now=time.monotonic()
|
||||
sample_elapsed=0.0
|
||||
|
||||
|
||||
# Independent clocks: update data/state only.
|
||||
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()
|
||||
latest=(c,t,m,d); update_fan(t); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
|
||||
if now-last_net >= NETWORK_INTERVAL:
|
||||
old_id=(net_iface,ip)
|
||||
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:
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
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
|
||||
display.update_home(c,t,m,d)
|
||||
display.begin_update()
|
||||
try: update_active_alerts(c,t,m,d,sample_elapsed)
|
||||
finally: display.end_update()
|
||||
if now-last_render >= OLED_REFRESH:
|
||||
display.refresh()
|
||||
last_render=now
|
||||
if now-last_status >= 1.0:
|
||||
write_status(c,t,m,d); last_status=now
|
||||
|
||||
time.sleep(0.20)
|
||||
except Exception as e:
|
||||
agent["loop_errors"]+=1; log("ERROR","LOOP_ERROR",count=agent["loop_errors"],error=repr(e)); time.sleep(1)
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/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}$")
|
||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||
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"}
|
||||
PROTECTED_UNITS={"ssh.service","sshd.service","runawesun.service","tailscaled.service",
|
||||
"pigway-pi-control.service","pigway-pi-control-api.service"}
|
||||
|
||||
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
|
||||
|
||||
def service_inventory():
|
||||
cfg=load_cfg()
|
||||
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
||||
notes=dict(cfg.items("service_notes")) if cfg.has_section("service_notes") else {}
|
||||
files=subprocess.run(["systemctl","list-unit-files","--type=service","--no-legend","--no-pager"],
|
||||
capture_output=True,text=True,timeout=10,check=True)
|
||||
units={}
|
||||
for line in files.stdout.splitlines():
|
||||
parts=line.split()
|
||||
if len(parts)>=2 and UNIT_RE.fullmatch(parts[0]):
|
||||
units[parts[0]]={"unit":parts[0],"enabled":parts[1],"active":"inactive","description":""}
|
||||
states=subprocess.run(["systemctl","list-units","--all","--type=service","--no-legend","--no-pager","--plain"],
|
||||
capture_output=True,text=True,timeout=10,check=True)
|
||||
for line in states.stdout.splitlines():
|
||||
parts=line.split(None,4)
|
||||
if len(parts)>=4 and UNIT_RE.fullmatch(parts[0]):
|
||||
item=units.setdefault(parts[0],{"unit":parts[0],"enabled":"unknown"})
|
||||
item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""})
|
||||
for name,target in monitored.values():
|
||||
item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""})
|
||||
item.update({"monitored":True,"monitor_name":name.upper(),"note":notes.get(name,"")})
|
||||
for item in units.values():
|
||||
item.setdefault("monitored",False); item.setdefault("monitor_name",""); item.setdefault("note","")
|
||||
item["protected"]=item["unit"] in PROTECTED_UNITS
|
||||
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
|
||||
|
||||
def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
if operation not in {"save","delete"}: raise ValueError("operation must be save or delete")
|
||||
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
|
||||
if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name")
|
||||
if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note")
|
||||
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name")
|
||||
cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None)
|
||||
text=CFG.read_text()
|
||||
def change(section,key,value):
|
||||
nonlocal text
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return
|
||||
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(key)}\s*=.*(?:\n|$)",chunk)
|
||||
if key_match:
|
||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||
elif value is not None:
|
||||
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||
if operation=="delete":
|
||||
if old_target is None: raise ValueError("monitor not found")
|
||||
change("services",name,None); change("service_notes",name,None)
|
||||
else:
|
||||
if previous_name and previous_name.lower()!=name.lower():
|
||||
change("services",previous_name,None); change("service_notes",previous_name,None)
|
||||
change("services",name,target); change("service_notes",name,note or None)
|
||||
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)
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
return str(backup)
|
||||
|
||||
def control_service(unit,action):
|
||||
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name")
|
||||
if action not in {"start","stop"}: raise ValueError("action must be start or stop")
|
||||
if unit in PROTECTED_UNITS and action=="stop": raise ValueError("protected remote-management service cannot be stopped here")
|
||||
result=subprocess.run(["systemctl",action,unit],capture_output=True,text=True,timeout=20)
|
||||
if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed")
|
||||
|
||||
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
|
||||
if parsed.path=="/api/v1/services":
|
||||
self.send_json(200,{"services":service_inventory()}); return
|
||||
self.send_json(404,{"error":"not found"})
|
||||
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||
|
||||
def do_PUT(self):
|
||||
path=urlparse(self.path).path
|
||||
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control"}: 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()
|
||||
if path=="/api/v1/services/control":
|
||||
control_service(str(body.get("unit","")),str(body.get("action","")))
|
||||
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
||||
self.send_json(200,{"ok":True}); return
|
||||
if path=="/api/v1/services":
|
||||
backup=edit_service_monitor(str(body.get("operation","")),str(body.get("name","")),
|
||||
str(body.get("target","")),str(body.get("note","")),
|
||||
str(body.get("previous_name","")))
|
||||
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
|
||||
self.send_json(200,{"ok":True,"backup":backup}); return
|
||||
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.2.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]
|
||||
# 传感器采样周期,单位:秒。
|
||||
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
||||
@@ -36,8 +44,8 @@ sensor_interval = 1
|
||||
# 推荐:1。
|
||||
oled_refresh_interval = 1
|
||||
|
||||
# HOME / 各告警页面的轮播停留时间,单位:秒。
|
||||
# 推荐:10。觉得切换太慢可改为 5~8。
|
||||
# OLED 在主页和当前告警页之间轮播的周期,单位:秒。
|
||||
# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。
|
||||
page_interval = 10
|
||||
|
||||
# 网络状态检查周期,单位:秒。
|
||||
@@ -48,6 +56,11 @@ network_interval = 5
|
||||
# 推荐:5。
|
||||
service_interval = 5
|
||||
|
||||
# 本机健康检查周期,单位:秒。
|
||||
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
|
||||
# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。
|
||||
health_interval = 1
|
||||
|
||||
|
||||
[fan]
|
||||
# 风扇温控档位,单位:摄氏度。
|
||||
@@ -121,61 +134,98 @@ disk_high = 90
|
||||
disk_critical = 95
|
||||
|
||||
# ---------------- Wi-Fi 信号 ----------------
|
||||
# 仅当 Wi-Fi 是 Linux 当前默认路由/主网络接口时生效。
|
||||
#
|
||||
# RSSI 单位为 dBm,越接近 0 表示信号越强:
|
||||
# -50 左右:很好
|
||||
# -60 左右:良好
|
||||
# -70 左右:一般
|
||||
# -80 以下:较弱
|
||||
#
|
||||
# 连续低于该值达到 wifi_weak_seconds 后触发蓝色网络告警。
|
||||
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
|
||||
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
|
||||
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。
|
||||
wifi_weak = -75
|
||||
|
||||
# 已触发弱信号告警后,必须恢复到该值以上才解除。
|
||||
# 与 wifi_weak 留出差值可避免信号在临界值附近反复报警。
|
||||
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
|
||||
wifi_recover = -68
|
||||
|
||||
# 弱信号必须持续多少秒才报警,避免瞬时 Wi-Fi 波动产生误报。
|
||||
# 弱信号持续多少秒才正式告警。
|
||||
wifi_weak_seconds = 30
|
||||
|
||||
|
||||
[led]
|
||||
# RGB 规则:
|
||||
# 正常状态 = 完全熄灭
|
||||
# 颜色 = 哪一类对象发生故障
|
||||
# 闪烁频率 = 严重程度,越严重闪得越快
|
||||
#
|
||||
# 当前颜色语义:
|
||||
# CPU负载 = 黄
|
||||
# 温度/散热 = 红
|
||||
# 内存 = 紫
|
||||
# 磁盘 = 橙
|
||||
# 网络 = 蓝
|
||||
# 服务/进程 = 青
|
||||
#
|
||||
# brightness 是 RGB 最大输出百分比(0~100)。
|
||||
# 3颗 RGB 会一起亮,卧室建议 5~15。
|
||||
brightness = 10
|
||||
# 正常运行、没有任何 active alert 时的 RGB 表现:
|
||||
# off = 全灭(默认,最安静且 I2C 写入最少)
|
||||
# solid = 三灯使用 normal_r/g/b 自定义颜色常亮
|
||||
# flash = 三灯使用 normal_r/g/b 和 normal_flash_*_ms 自定义颜色闪烁
|
||||
# flow = MCU 内置流水灯;使用有限颜色和速度
|
||||
# breathe = MCU 内置呼吸灯;使用有限颜色和速度
|
||||
# marquee = MCU 内置跑马灯;MCU 自行决定颜色变化
|
||||
# rainbow = MCU 内置彩虹灯;MCU 自行决定颜色变化
|
||||
# colorful = MCU 内置炫彩灯;MCU 自行决定颜色变化
|
||||
# 任何 active alert 都会立即抢占正常效果;全部恢复后才重新应用这里的模式。
|
||||
normal_mode = off
|
||||
|
||||
# Warning:慢闪。以下单位均为毫秒。
|
||||
warning_on_ms = 300
|
||||
warning_off_ms = 2700
|
||||
# solid / flash 自定义颜色,三通道范围均为 0..255。
|
||||
normal_r = 0
|
||||
normal_g = 0
|
||||
normal_b = 0
|
||||
|
||||
# Critical:中速闪烁。
|
||||
critical_on_ms = 400
|
||||
critical_off_ms = 1100
|
||||
# flash 亮灯/灭灯时间,单位毫秒;范围 50..60000。
|
||||
normal_flash_on_ms = 1000
|
||||
normal_flash_off_ms = 1000
|
||||
|
||||
# Emergency:快速闪烁。
|
||||
emergency_on_ms = 400
|
||||
emergency_off_ms = 400
|
||||
# MCU 内置效果速度:1=慢,2=中(默认),3=快。
|
||||
normal_effect_speed = 2
|
||||
# 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]
|
||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
||||
#
|
||||
# off = Pigway Pi Control 启动时关闭该灯。
|
||||
# off = PIGWay Pi Control 启动时关闭该灯。
|
||||
# 若不希望程序干预,可改为 on。
|
||||
#
|
||||
# 注意:
|
||||
|
||||
+153
-40
@@ -2,10 +2,65 @@
|
||||
set -euo pipefail
|
||||
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; }
|
||||
|
||||
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
|
||||
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; }
|
||||
@@ -13,70 +68,128 @@ for i in $(seq 1 60); do
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "[3/7] 安装依赖..."
|
||||
apt update
|
||||
apt install -y python3 python3-pil python3-smbus2 i2c-tools
|
||||
echo "[3/8] 安装依赖..."
|
||||
apt-get update
|
||||
apt-get install -y python3 python3-pil python3-smbus2 i2c-tools
|
||||
|
||||
echo "[4/7] 检查硬件地址..."
|
||||
echo "[4/8] 检查硬件地址..."
|
||||
SCAN="$(i2cdetect -y 1)"; echo "$SCAN"
|
||||
echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C"
|
||||
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D"
|
||||
|
||||
echo "[5/7] 安装程序与配置..."
|
||||
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
|
||||
echo "[5/8] 安装程序与配置..."
|
||||
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
|
||||
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_api.py /usr/local/sbin/pigway-pi-control-api
|
||||
|
||||
NEW=/tmp/pigway-pi-control.conf.new
|
||||
cp config/pigway-pi-control.conf "$NEW"
|
||||
# Keep the complete existing configuration, including comments and custom sections.
|
||||
# The merge below adds only missing documented options.
|
||||
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)"
|
||||
# Preserve user's [services] and [processes] sections; adopt new defaults elsewhere.
|
||||
python3 - "$NEW" /etc/pigway-pi-control.conf <<'PY'
|
||||
import configparser,sys
|
||||
newf,oldf=sys.argv[1:]
|
||||
n=configparser.ConfigParser(); n.optionxform=str; n.read(newf)
|
||||
o=configparser.ConfigParser(); o.optionxform=str; o.read(oldf)
|
||||
for sec in ("services","processes"):
|
||||
if o.has_section(sec):
|
||||
if n.has_section(sec): n.remove_section(sec)
|
||||
n.add_section(sec)
|
||||
for k,v in o.items(sec): n.set(sec,k,v)
|
||||
with open(newf,"w") as f:n.write(f)
|
||||
else
|
||||
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
||||
fi
|
||||
|
||||
# Preserve the existing file while exposing new defaults. Existing values win.
|
||||
python3 - /etc/pigway-pi-control.conf <<'PY'
|
||||
import re,sys
|
||||
from pathlib import Path
|
||||
|
||||
path=Path(sys.argv[1])
|
||||
text=path.read_text()
|
||||
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
|
||||
fi
|
||||
install -m 0644 "$NEW" /etc/pigway-pi-control.conf
|
||||
rm -f "$NEW"
|
||||
chmod 0600 /etc/pigway-pi-control-api.token
|
||||
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 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-api.service
|
||||
|
||||
echo "[7/8] 安装后自检..."
|
||||
FAIL=0
|
||||
check() { if eval "$2"; then printf " %-30s PASS\n" "$1"; else printf " %-30s FAIL\n" "$1"; FAIL=1; fi; }
|
||||
check "Version 3.2.0" "grep -q 'APP_VERSION=\"3.2.0\"' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED pages 0..3" "grep -q 'cmd(0x22);cmd(0);cmd(3)' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI CPU/TMP/DSK" "grep -q 'f\"TMP {temp:.1f}C\"' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI IP4/IP6" "grep -q 'ip_label.*IP4' /usr/local/sbin/pigway-pi-control && grep -q 'label=\"IP6\"' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI right column x=72" "grep -q \"draw_text_5x7(pix,72,y,right\\[row\\])\" /usr/local/sbin/pigway-pi-control"
|
||||
check "Legacy service stopped" "! systemctl is-active --quiet temp-control.service"
|
||||
check "Service running" "systemctl is-active --quiet pigway-pi-control.service"
|
||||
check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; }
|
||||
APP=/usr/local/sbin/pigway-pi-control
|
||||
check "Version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' "$APP"
|
||||
check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP"
|
||||
check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$APP"
|
||||
check "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP"
|
||||
check "UI CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP"
|
||||
check "UI IP4/IP6" grep -Fq 'ip_label' "$APP"
|
||||
check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP"
|
||||
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
|
||||
echo "[8/8] 状态..."
|
||||
|
||||
|
||||
systemctl --no-pager --full status pigway-pi-control.service || true
|
||||
systemctl --no-pager --full status pigway-pi-control-api.service || true
|
||||
echo
|
||||
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
||||
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]
|
||||
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor
|
||||
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
|
||||
After=multi-user.target network.target
|
||||
Wants=network.target
|
||||
|
||||
|
||||
+4
-1
@@ -1,8 +1,11 @@
|
||||
#!/bin/bash
|
||||
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 /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
|
||||
systemctl daemon-reload
|
||||
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user