Compare commits

...
Author SHA1 Message Date
way 5402dddd15 chore: release v3.5.0 2026-09-27 10:07:59 +08:00
way 6ca19b362b feat: configure RGB colors and flash timing 2026-09-27 10:04:15 +08:00
way adfa5524b8 refactor: use calibrated RGB alert flashes 2026-09-27 10:00:05 +08:00
way ac9bc4befd fix: align RGB colors with fault domains 2026-09-27 09:54:45 +08:00
way ad22dcced7 feat: distinguish RGB alerts by fault type 2026-09-27 09:47:03 +08:00
way 249af5a3e3 fix: use calibrated MCU RGB effects 2026-09-27 09:25:19 +08:00
way a84f4895ef fix: arbitrate display requests atomically 2026-09-26 23:01:15 +08:00
way 6fc428ef18 refactor: add single-owner display arbitration 2026-09-26 22:57:10 +08:00
way 34f334dd23 fix: unify active alert and RGB behavior 2026-09-26 22:35:23 +08:00
way d3ccca4b37 fix: redesign RGB alert engine 2026-09-26 22:24:43 +08:00
way 3a302b9c78 fix: harden P0 health monitoring RC2 2026-09-26 17:10:53 +08:00
way 83ec3f4379 fix: restore OLED font asset 2026-09-26 16:41:59 +08:00
way 2ef8007ccd feat: add P0 system health monitoring 2026-09-26 16:36:45 +08:00
way f65499f34b Merge pull request 'feat: active network interface status' (#1) from feature/network-interface-status into main
Reviewed-on: way/pigway-pi-control#1
2026-09-26 14:49:25 +08:00
way 2b70db10fc fix: update IP4/IP6 installer self-check 2026-09-26 14:38:17 +08:00
way 62db207c45 fix: import regex module for network detection 2026-09-26 14:31:49 +08:00
way b28ee63f38 fix: correct default route regex parsing 2026-09-26 14:25:39 +08:00
way cb0eca7aab feat: add active network interface status 2026-09-26 14:20:36 +08:00
5 changed files with 798 additions and 555 deletions
+139 -335
View File
@@ -1,127 +1,159 @@
# Pigway Pi Control v3.1.0 — Release # Pigway Pi Control v3.5.0 System Health
适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。 Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
本包遵循一个原则: v3.5.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 告警引擎
RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭;没有 active alert 时 OFF。`[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.3.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 仍供电”的直观提示。
## 用户配置
正式配置只有一个:
```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 +161,25 @@ sudo ./install.sh
安装器会: 安装器会:
1. 检查 `/dev/i2c-1` - 安装必要依赖
2. 等待 APT/DPKG 锁 - 检查 I²C
3. 安装必要的小型系统依赖 - 禁用旧 `temp-control.service`
4. 检查 I²C 总线 - 安装字体、程序、配置和 systemd unit
5. 自动停止并禁用卖家旧 `temp-control.service` - 完整保留用户现有配置(包括所有 section、用户值和注释),仅向 `[led]` 补入缺失的 RGB 颜色与频闪选项,已有用户值优先
6. 安装正式程序 - 启动服务
7. 安装用户配置 - 执行版本、OLED、UI、P0健康监控和诊断入口自检
8. 安装 systemd 服务并开机自启
9. 执行安装后自检
安装成功后查看: ## 常用命令
```bash ```bash
systemctl status pigway-pi-control --no-pager -l 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.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 /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 告警**
``` - 后续:只读 `/api/v1/...` Agent API,再由 NAS 统一汇总多台设备
启动:
```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
```
完成。
+4 -4
View File
@@ -1,7 +1,7 @@
ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md dfe901d5f7e3a8ef897203c38cf9e4d47eca63e0d718f74b99102c874edfc3b3 README.md
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py 43aca7bda6e8edb59734e06ccd373a9426c73fbf359727a906b6e2ca405fc344 app/pigway_pi_control.py
48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf 02e67b147ae68378bed1dcb88245b636c72fba3dabe4c119aab61219eee89995 config/pigway-pi-control.conf
7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh d84774f2fe2bfe380b7b29d83d42ddc17938fb03f78a746df07fc2e49f635e64 install.sh
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
+523 -151
View File
@@ -1,7 +1,7 @@
#!/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
from pathlib import Path from pathlib import Path
APP_VERSION="3.1.0" APP_VERSION="3.5.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
@@ -14,19 +14,64 @@ 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 +110,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 +121,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 +168,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 _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:
for line in Path("/proc/net/wireless").read_text().splitlines():
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(): def get_ip():
try: return primary_network()[4]
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(("1.1.1.1",53));v=s.getsockname()[0];s.close();return v
except: return "NO IP"
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 +226,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 +238,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 +248,213 @@ 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)))
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 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 +471,269 @@ 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
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:
now=time.monotonic() now=time.monotonic()
self.lock=threading.RLock()
self.requests={"NORMAL_HOME":{
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":"OFF",
"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. def update_home(self,cpu,temp,mem,disk):
if now-last_sensor >= SENSOR_INTERVAL: with self.lock:
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct() request=self.requests["NORMAL_HOME"]
latest=(c,t,m,d); update_fan(t); last_sensor=now 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: def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
ip=get_ip(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False now=time.monotonic(); priority=display_priority(request_id)
last_net=now request={"id":request_id,"priority":priority,"severity":severity,
"rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21],
if now-last_watch >= SERVICE_INTERVAL: "l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True,
watch_state=[check_watch(w) for w in watches] "last_changed":now,"metadata":metadata or {}}
last_watch=now with self.lock:
current=self.requests.get(request_id)
c,t,m,d=latest if current is None:
active=make_alerts(c,t,m,d) request["first_seen"]=now; self.requests[request_id]=request
log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
# 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)
else: else:
a=find_live_alert(current,active) request["first_seen"]=current["first_seen"]
if a: changed=any(current.get(k)!=request.get(k) for k in
# Number refers to this cycle's alert slot, all using same 6x8 font. ("priority","severity","rgb_mode","title","l2","l3","l4","metadata"))
render_alert(a,cycle_pos,len(cycle_pages)-1) if not changed: request["last_changed"]=current["last_changed"]
else: self.requests[request_id]=request
render_home(c,t,m,d) if self.batch_depth==0:
last_render=now self._arbitrate_locked()
self._sync_rgb_locked()
# LED follows OLED page; HOME reflects highest currently active risk. def clear(self,request_id):
current=cycle_pages[cycle_pos] if request_id=="NORMAL_HOME": return
if current=="HOME": with self.lock:
led_for(active[0] if active else None) 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: else:
a=find_live_alert(current,active) alert_ids=[a["id"] for a in alerts]
led_for(a if a else (active[0] if active else None)) render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
time.sleep(0.20) def _sync_rgb_locked(self):
except Exception as e: # Alert pages show that alert's object color. HOME keeps the highest-
print("loop error:",e,flush=True); time.sleep(1) # 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 _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_flash: 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 "_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: 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))
# 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=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
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()
+64 -29
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# Pigway Pi Control v3.1.0 - 用户配置文件 # Pigway Pi Control v3.5.0 - 用户配置文件
# ============================================================================= # =============================================================================
# #
# 正式安装后的配置路径: # 正式安装后的配置路径:
@@ -36,8 +36,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 +48,11 @@ network_interval = 5
# 推荐:5。 # 推荐:5。
service_interval = 5 service_interval = 5
# 本机健康检查周期,单位:秒。
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。
health_interval = 1
[fan] [fan]
# 风扇温控档位,单位:摄氏度。 # 风扇温控档位,单位:摄氏度。
@@ -120,36 +125,66 @@ 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 规则: # 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
# NORMAL_HOME 为 owner 时 RGB OFF。
# #
# 当前颜色语义: # 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
# CPU负载 = 黄 # 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
# 温度/散热 = 红 write_delay_ms = 10
# 内存 = 紫
# 磁盘 = 橙
# 网络 = 蓝
# 服务/进程 = 青
#
# brightness 是 RGB 最大输出百分比(0~100)。
# 3颗 RGB 会一起亮,卧室建议 5~15。
brightness = 10
# Warning:慢闪。以下单位均为毫秒。 # v3.3.0 RC 期间使用过的 brightness、*_breathe_seconds、rgb_update_ms、
warning_on_ms = 300 # warning_color、critical_color、warning_flash_ms、critical_flash_ms 和
warning_off_ms = 2700 # emergency_flash_ms
# 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。
# Critical:中速闪烁。
critical_on_ms = 400
critical_off_ms = 1100
# Emergency:快速闪烁。
emergency_on_ms = 400
emergency_off_ms = 400
[dark_mode] [dark_mode]
+68 -36
View File
@@ -2,10 +2,17 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
echo "[1/7] 检查 Raspberry Pi I2C..." for required in app/pigway_pi_control.py app/oled_font_5x7.bin config/pigway-pi-control.conf systemd/pigway-pi-control.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..." 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,17 +20,17 @@ 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 if systemctl list-unit-files 2>/dev/null | grep '^temp-control.service' >/dev/null; then
echo "检测到旧 temp-control.service,正在停止并禁用..." echo "检测到旧 temp-control.service,正在停止并禁用..."
systemctl disable --now temp-control.service 2>/dev/null || true systemctl disable --now temp-control.service 2>/dev/null || true
fi fi
@@ -33,45 +40,70 @@ install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_fo
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
NEW=/tmp/pigway-pi-control.conf.new # Keep the complete existing configuration, including comments and custom sections.
cp config/pigway-pi-control.conf "$NEW" # New options use runtime defaults until the user chooses to add them.
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
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)
PY
fi fi
install -m 0644 "$NEW" /etc/pigway-pi-control.conf
rm -f "$NEW" # Preserve the existing file verbatim while exposing newly configurable RGB
# defaults. Missing keys are inserted into [led]; existing user 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()
defaults=(
("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),
)
missing=[(key,value) for key,value in defaults
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",text)]
if missing:
block="\n# RGB object colors (0..255) and flash timing (milliseconds).\n"
block+="".join(f"{key} = {value}\n" for key,value in missing)
match=re.search(r"(?m)^\[led\]\s*$",text)
if match:
next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():])
pos=match.end()+(next_section.start() if next_section else len(text)-match.end())
text=text[:pos].rstrip()+"\n"+block+"\n"+text[pos:].lstrip("\n")
else:
text=text.rstrip()+"\n\n[led]\n"+block
path.write_text(text)
PY
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
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 >/dev/null
systemctl restart pigway-pi-control.service systemctl restart pigway-pi-control.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.5.0" grep -Fq 'APP_VERSION="3.5.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" legacy_stopped() { ! systemctl is-active --quiet temp-control.service; }
check "Legacy service stopped" legacy_stopped
check "Service running" systemctl is-active --quiet pigway-pi-control.service
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] 状态..."