feat: add P0 system health monitoring

This commit is contained in:
way
2026-09-26 16:36:45 +08:00
parent f65499f34b
commit 2ef8007ccd
5 changed files with 251 additions and 367 deletions
+127 -339
View File
@@ -1,127 +1,147 @@
# Pigway Pi Control v3.2.0 — Release
# Pigway Pi Control v3.3.0 P0 System Health
适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
本包遵循一个原则:
v3.3.0 的目标不是增加 Web/API,而是先把 **P0 本地检测、事件状态、日志、诊断和关机收尾**补完整,为后续多机 NAS 监控 API 打基础。
> **一个程序 + 一个用户配置文件 + 一个说明文档。**
## 已有核心功能
正常使用时,不需要修改源码。
- 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.3.0 P0 新增
主页左右两栏都是固定位置、左对齐。数据默认每秒刷新。
### 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. 结构化事件日志
日志继续交给 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
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'
```
### 5. 诊断命令
```bash
sudo /usr/local/sbin/pigway-pi-control --diagnose
```
用于快速查看 uptime、load、CPU频率、throttled、根文件系统、当前网络和 Internet 状态。后续排障优先使用该命令,不再反复手工拼大量 grep。
### 6. 关机收尾
收到 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
sudo ./install.sh
@@ -129,257 +149,25 @@ 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
- 禁用旧 `temp-control.service`
- 安装字体、程序、配置和 systemd unit
- 保留用户已有 `[services]` / `[processes]`
- 启动服务
- 执行版本、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 -f
```
重启:
```bash
sudo systemctl restart pigway-pi-control
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.3.0:P0 System Health,本地检测与日志完善**
- 后续:只读 `/api/v1/...` Agent API,再由 NAS 统一汇总多台设备
Binary file not shown.
+109 -13
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env python3
import re
import os,time,socket,subprocess,configparser,signal
import os,time,socket,subprocess,configparser,signal,re,sys,json
from pathlib import Path
APP_VERSION="3.2.0"
APP_VERSION="3.3.0"
from smbus2 import SMBus
from PIL import Image
from shutil import disk_usage
@@ -15,6 +14,47 @@ def stop(*_):
global running; running=False
signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop)
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
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_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_occurred":bool(v&(1<<19))}
def diagnose():
print(f"Pigway Pi Control {APP_VERSION} diagnostic")
print("uptime_seconds=",uptime_seconds());print("load_average=",load_average());print("cpu_freq_mhz=",round(cpu_freq_mhz()))
print("root_readonly=",root_readonly());print("throttled=",json.dumps(throttled_state()))
print("network=",primary_network());print("internet=",internet())
out=_run(["i2cdetect","-y","1"],4) if False else ""
return 0
bus=SMBus(1)
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
FONT5=Path(FONT_PATH).read_bytes()
@@ -135,8 +175,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():
@@ -194,6 +234,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",5.0)
CPU_HIGH=cf("alerts","cpu_high",90.0)
CPU_RECOVER=cf("alerts","cpu_recover",75.0)
@@ -232,6 +273,12 @@ 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 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 make_alerts(cpu,temp,mem,disk):
global wifi_weak_since,wifi_weak_active
global cpu_hi,mem_hi
@@ -247,7 +294,7 @@ def make_alerts(cpu,temp,mem,disk):
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))
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))
if not ok:a.append(alert("SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","",2,(0,24,24),1))
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
rssi=float(net_metric)
if not wifi_weak_active:
@@ -261,6 +308,12 @@ def make_alerts(cpu,temp,mem,disk):
a.append(alert("WIFI SIGNAL WEAK",f"{rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL",1,(0,0,28),1))
else:
wifi_weak_since=None; wifi_weak_active=False
if health.get("under_voltage_now"):
a.append(alert("POWER LOW","UNDERVOLT",health.get("raw",""),"CHECK 5V",3,(28,18,0),2))
if health.get("throttled_now") or health.get("freq_capped_now"):
a.append(alert("CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"TEMP / POWER",2,(28,18,0),1))
if fs_readonly:
a.append(alert("FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","",3,(28,6,0),2))
a.sort(key=lambda x:x["sev"],reverse=True)
return a
@@ -314,10 +367,20 @@ def render_alert(a,idx,total):
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
oled_init(); rgb_off(); apply_dark_mode()
if "--diagnose" in sys.argv:
raise SystemExit(diagnose())
oled_init(); rgb_off(); apply_dark_mode(); log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
watch_state=[check_watch(w) for w in watches]
last_sensor=last_render=0.0
last_net=last_watch=time.monotonic()
last_net=last_watch=last_health=time.monotonic()
health=throttled_state(); fs_readonly=root_readonly()
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={}
prev_net_identity=None
prev_watch_map={w[1]:ok for w,ok in zip(watches,watch_state)}
history_logged=set()
prev_alert_keys=set()
latest=(0.0,0.0,0.0,0.0)
active=[]
@@ -345,18 +408,45 @@ while running:
# 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
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
last_net=now
new_id=(net_iface,ip)
if old_id!=("","NO IP") and 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]
last_watch=now
for w,ok in zip(watches,watch_state):
transition("watch:"+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:
health=throttled_state(); fs_readonly=root_readonly()
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("root_ro",fs_readonly,"FILESYSTEM_RO","FILESYSTEM_RW")
for k in ("under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_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"))
la=load_average()
agent["last_health_ok"]=now; last_health=now
c,t,m,d=latest
active=make_alerts(c,t,m,d)
new_alert_keys={alert_key(a) for a in active}
for a in active:
k=alert_key(a)
if k not in prev_alert_keys:
log("WARN","ALERT_ACTIVE",alert=a["title"],detail=a["l2"],severity=a["sev"])
for k in prev_alert_keys-new_alert_keys:
log("INFO","ALERT_RECOVERED",alert=k)
prev_alert_keys=new_alert_keys
# If the currently displayed alert truly disappeared, skip it immediately.
if cycle_pages[cycle_pos]!="HOME":
@@ -408,6 +498,12 @@ while running:
time.sleep(0.20)
except Exception as e:
print("loop error:",e,flush=True); time.sleep(1)
agent["loop_errors"]+=1; log("ERROR","LOOP_ERROR",count=agent["loop_errors"],error=repr(e)); time.sleep(1)
rgb_off(); mcu(0x08,0x00); bus.close()
log("INFO","STOP",reason="signal")
try:
rgb_off(); mcu(0x08,0x00)
oled_show([("",""),("",""),("",""),("","")]); cmd(0xAE)
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",error=repr(e))
try: bus.close()
except Exception: pass
+11 -13
View File
@@ -1,5 +1,5 @@
# =============================================================================
# Pigway Pi Control v3.2.0 - 用户配置文件
# Pigway Pi Control v3.3.0 - 用户配置文件
# =============================================================================
#
# 正式安装后的配置路径:
@@ -48,6 +48,11 @@ network_interval = 5
# 推荐:5。
service_interval = 5
# 本机健康检查周期,单位:秒。
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
# 推荐:5。
health_interval = 5
[fan]
# 风扇温控档位,单位:摄氏度。
@@ -121,22 +126,15 @@ 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
+4 -2
View File
@@ -63,15 +63,17 @@ systemctl restart pigway-pi-control.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 "Version 3.3.0" "grep -q 'APP_VERSION=\"3.3.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 IP4/IP6" "grep -q 'ip_label+" "+ip' /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 "P0 health monitor" "grep -q 'throttled_state' /usr/local/sbin/pigway-pi-control"
check "Diagnostic command" "grep -q -- '--diagnose' /usr/local/sbin/pigway-pi-control"
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
echo "[8/8] 状态..."