From 2ef8007ccd18a5db2886dc4ec6dc21be50c023f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 26 Sep 2026 16:36:45 +0800 Subject: [PATCH 01/13] feat: add P0 system health monitoring --- README.md | 466 +++++++++------------------------- app/oled_font_5x7.bin | Bin 1275 -> 0 bytes app/pigway_pi_control.py | 122 ++++++++- config/pigway-pi-control.conf | 24 +- install.sh | 6 +- 5 files changed, 251 insertions(+), 367 deletions(-) delete mode 100644 app/oled_font_5x7.bin diff --git a/README.md b/README.md index 5b51d59..ed5285a 100644 --- a/README.md +++ b/README.md @@ -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 统一汇总多台设备 diff --git a/app/oled_font_5x7.bin b/app/oled_font_5x7.bin deleted file mode 100644 index 71c77ba483e757b431dd66814b562a2c351d61bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1275 zcmXw3F>l*O6n>-_`DmmLqY^L;uo9&(s>BRt)IgvNn!0l+gB0whF36^GP|Jyf4E0jD zLuHrZWo$PO=@6g=k|0@({(_=IfiC?IUAlSIV8lQnfPH76-|?RA-rf7&$HxZ&Ac98^ z9tFYSgTufKvcNR_+&9dJ(}$+H$M;Myd;^N}KhFy=Y_a8MMVJ@T#+SN5$qI73k0krnNZ#eL(dDtu!W|@y(CEtAh{|VZ{Pkr;Y3j; z5KbxTqwB!6R*2=qGG&-!l8I9h>&*?@HpH9@ABm8~R+N5d`;=OliTX^Ry3`L_$gp*K zdYXk#ldTX)7e5bthm&;)2zj#Kyw&upuU|i|tYPd43Q9-yG(Bc@l*{TVYPOQ}m|m}b zepKnDX*%j*woxxlk}m-U83TDR-2iEj(OKJW&t|RLtyz|&1M$9Dk}aw{?uhzzqA2Qg zhEe3MF;+iW+o0A2etGq3+QT>Mtz(teeY>#jD39+yyR$l9y;G>|_tqajdTh&_k{ss76G>)S; z8feHFq;PhY;=ocAfDK;&Hk4QuP-xmro8fkjzDqpDr_l8aWv=VXT1~^JhNm?VHp6&8 zmvD-$Ciuka3dPkH8y5-0wPk?0av@lj1>c1@xI`*}%d6G0{y+T%u26;utBD+<{pV=V zhaxo;q!)PQ>JXiUz)S#t@hVamHw3U>yfAZ|K+k2lT$Xbj2WiX>@W5ASZdEPelwZC( zfA`XKV#nM%l8(0A;r6!cMw7_>{mJg;dVpULa!HbMM<5KxVMup|A*JDPhhj3R^v`q5 pOUU6N=~lXSw|qmrf&UwZfl@RLTv|vH)Fi12D<3Uaz()v`@E=5ID%Ai0 diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index d266471..7182490 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -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 diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 8ce3a7f..089653b 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -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 diff --git a/install.sh b/install.sh index 5253e37..c6ba75e 100755 --- a/install.sh +++ b/install.sh @@ -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] 状态..." From 83ec3f43799eb3945c6f07a1365b4f1da5d2ba70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 26 Sep 2026 16:41:59 +0800 Subject: [PATCH 02/13] fix: restore OLED font asset --- app/oled_font_5x7.bin | Bin 0 -> 1275 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/oled_font_5x7.bin diff --git a/app/oled_font_5x7.bin b/app/oled_font_5x7.bin new file mode 100644 index 0000000000000000000000000000000000000000..71c77ba483e757b431dd66814b562a2c351d61bc GIT binary patch literal 1275 zcmXw3F>l*O6n>-_`DmmLqY^L;uo9&(s>BRt)IgvNn!0l+gB0whF36^GP|Jyf4E0jD zLuHrZWo$PO=@6g=k|0@({(_=IfiC?IUAlSIV8lQnfPH76-|?RA-rf7&$HxZ&Ac98^ z9tFYSgTufKvcNR_+&9dJ(}$+H$M;Myd;^N}KhFy=Y_a8MMVJ@T#+SN5$qI73k0krnNZ#eL(dDtu!W|@y(CEtAh{|VZ{Pkr;Y3j; z5KbxTqwB!6R*2=qGG&-!l8I9h>&*?@HpH9@ABm8~R+N5d`;=OliTX^Ry3`L_$gp*K zdYXk#ldTX)7e5bthm&;)2zj#Kyw&upuU|i|tYPd43Q9-yG(Bc@l*{TVYPOQ}m|m}b zepKnDX*%j*woxxlk}m-U83TDR-2iEj(OKJW&t|RLtyz|&1M$9Dk}aw{?uhzzqA2Qg zhEe3MF;+iW+o0A2etGq3+QT>Mtz(teeY>#jD39+yyR$l9y;G>|_tqajdTh&_k{ss76G>)S; z8feHFq;PhY;=ocAfDK;&Hk4QuP-xmro8fkjzDqpDr_l8aWv=VXT1~^JhNm?VHp6&8 zmvD-$Ciuka3dPkH8y5-0wPk?0av@lj1>c1@xI`*}%d6G0{y+T%u26;utBD+<{pV=V zhaxo;q!)PQ>JXiUz)S#t@hVamHw3U>yfAZ|K+k2lT$Xbj2WiX>@W5ASZdEPelwZC( zfA`XKV#nM%l8(0A;r6!cMw7_>{mJg;dVpULa!HbMM<5KxVMup|A*JDPhhj3R^v`q5 pOUU6N=~lXSw|qmrf&UwZfl@RLTv|vH)Fi12D<3Uaz()v`@E=5ID%Ai0 literal 0 HcmV?d00001 From 3a302b9c781da47b7689e744ea97c1c1c8ae656d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 26 Sep 2026 17:10:53 +0800 Subject: [PATCH 03/13] fix: harden P0 health monitoring RC2 --- README.md | 8 +- SHA256SUMS | 8 +- app/pigway_pi_control.py | 334 +++++++++++++++++++++++---------------- install.sh | 72 ++++----- 4 files changed, 245 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index ed5285a..f7a0ced 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,11 @@ journalctl -u pigway-pi-control --no-pager | grep 'event=POWER' sudo /usr/local/sbin/pigway-pi-control --diagnose ``` -用于快速查看 uptime、load、CPU频率、throttled、根文件系统、当前网络和 Internet 状态。后续排障优先使用该命令,不再反复手工拼大量 grep。 +输出 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 事件包含该次运行计数。 + +RC2 保留 v3.2.0 的 RGB MCU 寄存器写入顺序;独立闪烁时钟使用所有有效告警的最高 severity,不受 OLED 页面及网络/watch 检查等待影响。服务恢复后自动切换到下一有效告警,没有告警时 OFF。 ### 6. 关机收尾 @@ -153,7 +157,7 @@ sudo ./install.sh - 检查 I²C - 禁用旧 `temp-control.service` - 安装字体、程序、配置和 systemd unit -- 保留用户已有 `[services]` / `[processes]` +- 完整保留用户现有配置(包括所有 section、用户值和注释),新选项使用运行时默认值 - 启动服务 - 执行版本、OLED、UI、P0健康监控和诊断入口自检 diff --git a/SHA256SUMS b/SHA256SUMS index f4f13d3..c9e3df1 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md +192d29003ffd867d440753c1903d194a51ec9c2cadbc5442d0e8d1977364217d README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py -48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf -7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh +1445d390eef0a66c14d827f8468064f5a09ae2f005e04a636ae38f77dd0bb5d7 app/pigway_pi_control.py +7d64d7fcf2b17580b4565b99e290a85e4317dbf2cc71224adcacb56489fcf1b3 config/pigway-pi-control.conf +4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 7182490..7a095ee 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import os,time,socket,subprocess,configparser,signal,re,sys,json +import time,socket,subprocess,configparser,signal,re,errno,threading,argparse from pathlib import Path APP_VERSION="3.3.0" from smbus2 import SMBus @@ -45,19 +45,22 @@ def root_readonly(): 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))} + 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))} -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) +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() @@ -68,7 +71,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, @@ -107,10 +110,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 @@ -200,7 +203,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(): @@ -263,11 +265,49 @@ LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emerge 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 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 + 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], @@ -279,12 +319,12 @@ def transition(key,active,on_event,off_event,**fields): 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): +def make_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=MEM_HIGH else 0 if mem=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem=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))) @@ -333,11 +373,12 @@ 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}%", @@ -359,7 +400,7 @@ 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): @@ -367,18 +408,55 @@ def render_alert(a,idx,total): (a["l2"],""),(a["l3"],""),(a["l4"],"")]) -if "--diagnose" in sys.argv: - raise SystemExit(diagnose()) +def alert_key(a): + # Sensor values change without changing the identity of a fault. + return a["title"]+("|"+a["l2"] if a["title"] in ("SERVICE DOWN","PROCESS DOWN") else "") -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] +def find_live_alert(key, live): + for a in live: + if alert_key(a)==key: return a + return None + +# RGB has its own clock so slow network/watch checks cannot stretch blink pulses. +led_stop=threading.Event() +led_alert=None + +def led_worker(): + previous_error=None + while not led_stop.is_set(): + try: + with bus_lock: led_for(led_alert) + 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) + led_stop.wait(0.02) + +def cleanup(): + log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"]) + led_stop.set() + if led_thread.is_alive(): led_thread.join() + if bus is None: return + # Attempt every step even when a previous device write fails. + for name,action in (("rgb",rgb_off),("fan",lambda:mcu(0x08,0x00)), + ("oled_clear",lambda:oled_show([("","")]*4)), + ("oled_off",lambda:cmd(0xAE)),("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=time.monotonic() -health=throttled_state(); fs_readonly=root_readonly() +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={} -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) @@ -391,119 +469,109 @@ 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() +led_thread=threading.Thread(target=led_worker,name="rgb",daemon=True) +try: + bus=SMBus(1) + FONT5=Path(FONT_PATH).read_bytes() + oled_init(); rgb_off(); apply_dark_mode() + log("INFO","START",version=APP_VERSION,uptime=uptime_seconds()) + led_thread.start() + while running: + try: + now=time.monotonic() + sample_elapsed=0.0 - # 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; agent["last_sensor_ok"]=now + # 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 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_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[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2]) - last_watch=now; agent["last_watch_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: - 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 + 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 - 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 + c,t,m,d=latest + active=make_alerts(c,t,m,d,sample_elapsed) + led_alert=max(active,key=lambda a:a["sev"],default=None) + 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": - key=cycle_pages[cycle_pos] - if find_live_alert(key,active) is None: + # 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): - # End of cycle: capture a fresh snapshot. + # 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 - # 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. + # 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] - 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: - 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 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) - last_render=now + 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 - # 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) - else: - a=find_live_alert(current,active) - led_for(a if a else (active[0] if active else None)) - - 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) - -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 + 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() diff --git a/install.sh b/install.sh index c6ba75e..a32369c 100755 --- a/install.sh +++ b/install.sh @@ -2,10 +2,17 @@ set -euo pipefail 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; } -echo "[2/7] 等待 APT/DPKG..." +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,17 +20,17 @@ 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 "[5/8] 安装程序与配置..." +if systemctl list-unit-files 2>/dev/null | grep '^temp-control.service' >/dev/null; then echo "检测到旧 temp-control.service,正在停止并禁用..." systemctl disable --now temp-control.service 2>/dev/null || true fi @@ -33,47 +40,36 @@ 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 -NEW=/tmp/pigway-pi-control.conf.new -cp config/pigway-pi-control.conf "$NEW" +# Keep the complete existing configuration, including comments and custom sections. +# New options use runtime defaults until the user chooses to add them. 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) -PY +else + install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf fi -install -m 0644 "$NEW" /etc/pigway-pi-control.conf -rm -f "$NEW" 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 enable pigway-pi-control.service >/dev/null 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.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+" "+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" +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.3.0" grep -Fq 'APP_VERSION="3.3.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" +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 echo "[8/8] 状态..." From d3ccca4b37bbc5ebfc8dd147321f15508dccddf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 26 Sep 2026 22:24:43 +0800 Subject: [PATCH 04/13] fix: redesign RGB alert engine --- README.md | 10 ++++-- SHA256SUMS | 6 ++-- app/pigway_pi_control.py | 61 ++++++++++++++++++++++++----------- config/pigway-pi-control.conf | 47 +++++++++++++-------------- 4 files changed, 76 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index f7a0ced..0fc1188 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,11 @@ CHECK STORAGE - 主循环异常计数 - I²C相关异常计数基础字段 -### 4. 结构化事件日志 +### 4. RGB 告警引擎 + +RGB 与 OLED 页面完全解耦,只使用所有 active alert 的最高 severity。Warning 为黄色 3 秒呼吸,Critical 为橙红色 1.5 秒呼吸,Emergency 为纯红快速频闪;无 active alert 时 OFF。动画不写循环日志,模式切换仅记录一次 `RGB_MODE`。 + +### 5. 结构化事件日志 日志继续交给 systemd journal,不额外制造长期 `.log` 文件。 @@ -104,7 +108,7 @@ journalctl -u pigway-pi-control -b --no-pager journalctl -u pigway-pi-control --no-pager | grep 'event=POWER' ``` -### 5. 诊断命令 +### 6. 诊断命令 ```bash sudo /usr/local/sbin/pigway-pi-control --diagnose @@ -116,7 +120,7 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose RC2 保留 v3.2.0 的 RGB MCU 寄存器写入顺序;独立闪烁时钟使用所有有效告警的最高 severity,不受 OLED 页面及网络/watch 检查等待影响。服务恢复后自动切换到下一有效告警,没有告警时 OFF。 -### 6. 关机收尾 +### 7. 关机收尾 收到 systemd SIGTERM / 系统关机时: diff --git a/SHA256SUMS b/SHA256SUMS index c9e3df1..540855b 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -192d29003ffd867d440753c1903d194a51ec9c2cadbc5442d0e8d1977364217d README.md +55fe2917b92a896fb346fb3c9c515fafcf47d7ea095c0d92dfea66a7b3edfc19 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -1445d390eef0a66c14d827f8468064f5a09ae2f005e04a636ae38f77dd0bb5d7 app/pigway_pi_control.py -7d64d7fcf2b17580b4565b99e290a85e4317dbf2cc71224adcacb56489fcf1b3 config/pigway-pi-control.conf +75138ce957a501dc34bf2d886476b92e897d16d42c06ccbffda27524f164cce4 app/pigway_pi_control.py +fbd8829929bc755cef6beeded475ac5eaf1a5b9152b4294bb8d169e224e3bc9c config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 7a095ee..cc7ade8 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import time,socket,subprocess,configparser,signal,re,errno,threading,argparse +import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,math from pathlib import Path APP_VERSION="3.3.0" from smbus2 import SMBus @@ -236,7 +236,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) +HEALTH_INTERVAL=cf("timing","health_interval",1.0) CPU_HIGH=cf("alerts","cpu_high",90.0) CPU_RECOVER=cf("alerts","cpu_recover",75.0) @@ -259,9 +259,13 @@ ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","r 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) +WARNING_BREATHE_SECONDS=cf("led","warning_breathe_seconds",3.0) +CRITICAL_BREATHE_SECONDS=cf("led","critical_breathe_seconds",1.5) +EMERGENCY_FLASH_MS=cf("led","emergency_flash_ms",300,int) +RGB_UPDATE_MS=cf("led","rgb_update_ms",40,int) +RGB_WARNING=(cf("led","warning_r",255,int),cf("led","warning_g",255,int),cf("led","warning_b",0,int)) +RGB_CRITICAL=(cf("led","critical_r",255,int),cf("led","critical_g",72,int),cf("led","critical_b",0,int)) +RGB_EMERGENCY=(cf("led","emergency_r",255,int),cf("led","emergency_g",0,int),cf("led","emergency_b",0,int)) def check_watch(w): typ,label,target=w @@ -357,16 +361,29 @@ def make_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): a.sort(key=lambda x:x["sev"],reverse=True) return a -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=3: return "EMERGENCY_FLASH" + if a["sev"]>=2: return "CRITICAL_BREATHE" + return "WARNING_BREATHE" + +def breathe(level_seconds,elapsed): + # A full sine-derived ease in/out cycle gives continuous light transitions. + phase=(elapsed/max(0.1,level_seconds))%1.0 + return 0.5-0.5*math.cos(2.0*math.pi*phase) + +def rgb_engine(mode,elapsed): + if mode=="OFF": + rgb_off() + elif mode=="WARNING_BREATHE": + level=breathe(WARNING_BREATHE_SECONDS,elapsed) + rgb(*(channel*level for channel in RGB_WARNING)) + elif mode=="CRITICAL_BREATHE": + level=breathe(CRITICAL_BREATHE_SECONDS,elapsed) + rgb(*(channel*level for channel in RGB_CRITICAL)) + else: + on=int(elapsed*1000/max(50,EMERGENCY_FLASH_MS))%2==0 + rgb(RGB_EMERGENCY[0] if on else 0,0,0) def render_home(cpu,temp,mem,disk): @@ -417,21 +434,29 @@ def find_live_alert(key, live): if alert_key(a)==key: return a return None -# RGB has its own clock so slow network/watch checks cannot stretch blink pulses. +# RGB has its own clock and consumes only the highest-severity active alert. +# OLED rendering never reads or writes RGB state. led_stop=threading.Event() led_alert=None def led_worker(): previous_error=None + current_mode="OFF" + mode_started=time.monotonic() while not led_stop.is_set(): try: - with bus_lock: led_for(led_alert) + new_mode=rgb_mode_for(led_alert) + if new_mode!=current_mode: + log("INFO","RGB_MODE",old=current_mode,new=new_mode) + current_mode=new_mode; mode_started=time.monotonic() + # Keep the verified five-write MCU RGB transaction contiguous. + with bus_lock: rgb_engine(current_mode,time.monotonic()-mode_started) 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) - led_stop.wait(0.02) + led_stop.wait(max(0.02,min(0.05,RGB_UPDATE_MS/1000.0))) def cleanup(): log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"]) diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 089653b..d13d20c 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -50,8 +50,8 @@ service_interval = 5 # 本机健康检查周期,单位:秒。 # 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。 -# 推荐:5。 -health_interval = 5 +# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。 +health_interval = 1 [fan] @@ -139,34 +139,33 @@ wifi_weak_seconds = 30 [led] -# RGB 规则: -# 正常状态 = 完全熄灭 -# 颜色 = 哪一类对象发生故障 -# 闪烁频率 = 严重程度,越严重闪得越快 -# -# 当前颜色语义: -# CPU负载 = 黄 -# 温度/散热 = 红 -# 内存 = 紫 -# 磁盘 = 橙 -# 网络 = 蓝 -# 服务/进程 = 青 +# RGB 只由所有 active alert 中最高 severity 决定,与 OLED 当前页面完全无关: +# Warning (1) = 黄色慢呼吸 +# Critical (2) = 橙红色快呼吸 +# Emergency (3) = 纯红快速频闪 +# 无 active alert 时 RGB OFF。每次写入明确设置 R/G/B,绝不沿用旧颜色。 # # brightness 是 RGB 最大输出百分比(0~100)。 # 3颗 RGB 会一起亮,卧室建议 5~15。 brightness = 10 -# Warning:慢闪。以下单位均为毫秒。 -warning_on_ms = 300 -warning_off_ms = 2700 +# 旧 warning_on_ms / warning_off_ms / critical_on_ms / critical_off_ms / +# emergency_on_ms / emergency_off_ms 仍可保留,RC3 忽略它们以兼容旧配置。 +warning_breathe_seconds = 3.0 +critical_breathe_seconds = 1.5 +emergency_flash_ms = 300 +rgb_update_ms = 40 -# Critical:中速闪烁。 -critical_on_ms = 400 -critical_off_ms = 1100 - -# Emergency:快速闪烁。 -emergency_on_ms = 400 -emergency_off_ms = 400 +# RGB 基色(0~255)。安全默认值严格不含蓝色,避免紫色/蓝色残留。 +warning_r = 255 +warning_g = 255 +warning_b = 0 +critical_r = 255 +critical_g = 72 +critical_b = 0 +emergency_r = 255 +emergency_g = 0 +emergency_b = 0 [dark_mode] From 34f334dd235906b94ba640382b8054f6aa9ef17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 26 Sep 2026 22:35:23 +0800 Subject: [PATCH 05/13] fix: unify active alert and RGB behavior --- README.md | 14 ++-- SHA256SUMS | 6 +- app/pigway_pi_control.py | 121 +++++++++++++++++++++++----------- config/pigway-pi-control.conf | 2 +- 4 files changed, 97 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 0fc1188..736a81e 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,15 @@ CHECK STORAGE - 主循环异常计数 - I²C相关异常计数基础字段 -### 4. RGB 告警引擎 +### 4. Active Alert、OLED 与 RGB -RGB 与 OLED 页面完全解耦,只使用所有 active alert 的最高 severity。Warning 为黄色 3 秒呼吸,Critical 为橙红色 1.5 秒呼吸,Emergency 为纯红快速频闪;无 active alert 时 OFF。动画不写循环日志,模式切换仅记录一次 `RGB_MODE`。 +每个当前故障都由统一的 Active Alert 管理,包含稳定 ID、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只更新 Alert;OLED 为每个 active alert 保留独立轮播页,RGB 只读取最高 severity,Journal 只记录状态转换。历史 power flags 只进入 diagnose 和一次性 Journal 记录,绝不产生 OLED/RGB 告警。 -### 5. 结构化事件日志 +### 5. RGB 告警引擎 + +RGB 与 OLED 页面完全解耦,只使用所有 active alert 的最高 severity。Warning 为黄色 3 秒呼吸,Critical 为橙红色 1.5 秒呼吸,Emergency 为纯红快速频闪;无 active alert 时 OFF。呼吸最低亮度为 5%,默认每 80ms 更新。动画不写循环日志,模式切换仅记录一次 `RGB_MODE`。 + +### 6. 结构化事件日志 日志继续交给 systemd journal,不额外制造长期 `.log` 文件。 @@ -108,7 +112,7 @@ journalctl -u pigway-pi-control -b --no-pager journalctl -u pigway-pi-control --no-pager | grep 'event=POWER' ``` -### 6. 诊断命令 +### 7. 诊断命令 ```bash sudo /usr/local/sbin/pigway-pi-control --diagnose @@ -120,7 +124,7 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose RC2 保留 v3.2.0 的 RGB MCU 寄存器写入顺序;独立闪烁时钟使用所有有效告警的最高 severity,不受 OLED 页面及网络/watch 检查等待影响。服务恢复后自动切换到下一有效告警,没有告警时 OFF。 -### 7. 关机收尾 +### 8. 关机收尾 收到 systemd SIGTERM / 系统关机时: diff --git a/SHA256SUMS b/SHA256SUMS index 540855b..28d8f7c 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -55fe2917b92a896fb346fb3c9c515fafcf47d7ea095c0d92dfea66a7b3edfc19 README.md +e3dea3247111f46f87a8b62b020124639e4e7a1911d6c21534694d3c32e41e15 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -75138ce957a501dc34bf2d886476b92e897d16d42c06ccbffda27524f164cce4 app/pigway_pi_control.py -fbd8829929bc755cef6beeded475ac5eaf1a5b9152b4294bb8d169e224e3bc9c config/pigway-pi-control.conf +388d1f7c3a329af6580945227ae4c601f97ce30f2788ccadc14b81644f300e25 app/pigway_pi_control.py +16649f17170d8aea354387e2d37a83398ccb661e4f7666a3d0e47f18f189ea8d config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index cc7ade8..48f8d0d 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -262,7 +262,7 @@ LED_BRIGHT=cf("led","brightness",10,int) WARNING_BREATHE_SECONDS=cf("led","warning_breathe_seconds",3.0) CRITICAL_BREATHE_SECONDS=cf("led","critical_breathe_seconds",1.5) EMERGENCY_FLASH_MS=cf("led","emergency_flash_ms",300,int) -RGB_UPDATE_MS=cf("led","rgb_update_ms",40,int) +RGB_UPDATE_MS=cf("led","rgb_update_ms",80,int) RGB_WARNING=(cf("led","warning_r",255,int),cf("led","warning_g",255,int),cf("led","warning_b",0,int)) RGB_CRITICAL=(cf("led","critical_r",255,int),cf("led","critical_g",72,int),cf("led","critical_b",0,int)) RGB_EMERGENCY=(cf("led","emergency_r",255,int),cf("led","emergency_g",0,int),cf("led","emergency_b",0,int)) @@ -317,28 +317,76 @@ 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} +class AlertManager: + def __init__(self): + self._active={} + + def set_alert(self,alert_id,severity,title,l2="",l3="",l4="",metadata=None): + now=time.monotonic() + display={"id":alert_id,"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21], + "sev":severity,"metadata":metadata or {}} + current=self._active.get(alert_id) + if current is None: + display["active"]=True; display["first_seen"]=now; display["last_changed"]=now + self._active[alert_id]=display + log("WARN","ALERT_ACTIVE",alert=alert_id,severity=severity,detail=l2) + else: + changed=any(current.get(k)!=display.get(k) for k in ("sev","title","l2","l3","l4","metadata")) + current.update(display) + if changed: current["last_changed"]=now + + def clear_alert(self,alert_id): + if alert_id in self._active: + self._active.pop(alert_id) + log("INFO","ALERT_RECOVERED",alert=alert_id) + + def active_alerts(self): + return sorted(self._active.values(),key=lambda a:(-a["sev"],a["first_seen"],a["id"])) + 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,sample_elapsed=0.0): +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+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem=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)) + if cpu_hi>=CPU_HIGH_SEC: alerts.set_alert("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD") + else: alerts.clear_alert("CPU_HIGH") + if temp>=TEMP_CRIT: + alerts.set_alert("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") + alerts.clear_alert("TEMP_HIGH") + elif temp>=TEMP_HIGH: + alerts.set_alert("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") + alerts.clear_alert("TEMP_CRITICAL") + else: + alerts.clear_alert("TEMP_HIGH"); alerts.clear_alert("TEMP_CRITICAL") + if mem_hi>=MEM_HIGH_SEC: alerts.set_alert("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","") + else: alerts.clear_alert("MEMORY_HIGH") + if disk>=DISK_CRIT: + alerts.set_alert("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") + alerts.clear_alert("DISK_HIGH") + elif disk>=DISK_HIGH: + alerts.set_alert("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE") + alerts.clear_alert("DISK_CRITICAL") + else: + alerts.clear_alert("DISK_HIGH"); alerts.clear_alert("DISK_CRITICAL") + if not link_ok: + alerts.set_alert("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","") + alerts.clear_alert("INTERNET_DOWN") + elif not net_ok: + alerts.set_alert("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"") + alerts.clear_alert("NETWORK_DOWN") + else: + alerts.clear_alert("NETWORK_DOWN"); alerts.clear_alert("INTERNET_DOWN") for w,ok in zip(watches,watch_state): - if not ok:a.append(alert("SERVICE DOWN" if w[0]=="service" else "PROCESS 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: + alerts.set_alert(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]}) + else: alerts.clear_alert(alert_id) if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit(): rssi=float(net_metric) if not wifi_weak_active: @@ -349,17 +397,24 @@ def make_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): 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)) + alerts.set_alert("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL") + else: alerts.clear_alert("WIFI_WEAK") else: - wifi_weak_since=None; wifi_weak_active=False + wifi_weak_since=None; wifi_weak_active=False; alerts.clear_alert("WIFI_WEAK") 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 + alerts.set_alert("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")}) + else: alerts.clear_alert("POWER_UNDERVOLT") + if health.get("throttled_now"): + alerts.set_alert("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") + else: alerts.clear_alert("THROTTLED") + if health.get("freq_capped_now"): + alerts.set_alert("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") + else: alerts.clear_alert("FREQ_CAPPED") + if health.get("soft_temp_limit_now"): + alerts.set_alert("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING") + else: alerts.clear_alert("SOFT_TEMP_LIMIT") + if fs_readonly: alerts.set_alert("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY") + else: alerts.clear_alert("ROOT_READONLY") def rgb_mode_for(a): if not a: return "OFF" @@ -370,7 +425,7 @@ def rgb_mode_for(a): def breathe(level_seconds,elapsed): # A full sine-derived ease in/out cycle gives continuous light transitions. phase=(elapsed/max(0.1,level_seconds))%1.0 - return 0.5-0.5*math.cos(2.0*math.pi*phase) + return 0.05+0.95*(0.5-0.5*math.cos(2.0*math.pi*phase)) def rgb_engine(mode,elapsed): if mode=="OFF": @@ -426,8 +481,7 @@ def render_alert(a,idx,total): def alert_key(a): - # Sensor values change without changing the identity of a fault. - return a["title"]+("|"+a["l2"] if a["title"] in ("SERVICE DOWN","PROCESS DOWN") else "") + return a["id"] def find_live_alert(key, live): for a in live: @@ -442,15 +496,15 @@ led_alert=None def led_worker(): previous_error=None current_mode="OFF" - mode_started=time.monotonic() + animation_started=time.monotonic() while not led_stop.is_set(): try: new_mode=rgb_mode_for(led_alert) if new_mode!=current_mode: log("INFO","RGB_MODE",old=current_mode,new=new_mode) - current_mode=new_mode; mode_started=time.monotonic() + current_mode=new_mode # Keep the verified five-write MCU RGB transaction contiguous. - with bus_lock: rgb_engine(current_mode,time.monotonic()-mode_started) + with bus_lock: rgb_engine(current_mode,time.monotonic()-animation_started) if previous_error is not None: log("INFO","RGB_RECOVERED") previous_error=None except Exception as e: @@ -481,9 +535,9 @@ 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} +alerts=AlertManager() event_state={} history_logged=set() -prev_alert_keys=set() latest=(0.0,0.0,0.0,0.0) active=[] @@ -544,16 +598,9 @@ try: agent["last_health_ok"]=now; last_health=now c,t,m,d=latest - active=make_alerts(c,t,m,d,sample_elapsed) + update_active_alerts(c,t,m,d,sample_elapsed) + active=alerts.active_alerts() led_alert=max(active,key=lambda a:a["sev"],default=None) - 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": diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index d13d20c..17546d1 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -154,7 +154,7 @@ brightness = 10 warning_breathe_seconds = 3.0 critical_breathe_seconds = 1.5 emergency_flash_ms = 300 -rgb_update_ms = 40 +rgb_update_ms = 80 # RGB 基色(0~255)。安全默认值严格不含蓝色,避免紫色/蓝色残留。 warning_r = 255 From 6fc428ef18d5c1c8ca93b103837fbada9bb0611a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 26 Sep 2026 22:57:10 +0800 Subject: [PATCH 06/13] refactor: add single-owner display arbitration --- README.md | 8 +- SHA256SUMS | 6 +- app/pigway_pi_control.py | 335 ++++++++++++++++++---------------- config/pigway-pi-control.conf | 9 +- 4 files changed, 188 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index 736a81e..f619660 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,13 @@ CHECK STORAGE - 主循环异常计数 - I²C相关异常计数基础字段 -### 4. Active Alert、OLED 与 RGB +### 4. DisplayController、OLED 与 RGB -每个当前故障都由统一的 Active Alert 管理,包含稳定 ID、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只更新 Alert;OLED 为每个 active alert 保留独立轮播页,RGB 只读取最高 severity,Journal 只记录状态转换。历史 power flags 只进入 diagnose 和一次性 Journal 记录,绝不产生 OLED/RGB 告警。 +每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request。DisplayController 始终只选一个 owner,OLED 和 RGB 同步表达该 owner;`NORMAL_HOME` 是 priority 0 的常驻 request,有任何故障 owner 时不会抢回显示权。同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 ### 5. RGB 告警引擎 -RGB 与 OLED 页面完全解耦,只使用所有 active alert 的最高 severity。Warning 为黄色 3 秒呼吸,Critical 为橙红色 1.5 秒呼吸,Emergency 为纯红快速频闪;无 active alert 时 OFF。呼吸最低亮度为 5%,默认每 80ms 更新。动画不写循环日志,模式切换仅记录一次 `RGB_MODE`。 +RGB 模式的唯一来源是 current Display Owner 的 severity。Warning 为黄色 3 秒呼吸,Critical 为橙红色 1.5 秒呼吸,Emergency 为纯红快速频闪;`NORMAL_HOME` 为 owner 时 OFF。呼吸最低亮度为 5%,默认每 80ms 更新。owner 变化但 RGB mode 相同时不关闭、不重置相位、不重写初始化序列。动画帧不写日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 ### 6. 结构化事件日志 @@ -122,7 +122,7 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose 诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。 -RC2 保留 v3.2.0 的 RGB MCU 寄存器写入顺序;独立闪烁时钟使用所有有效告警的最高 severity,不受 OLED 页面及网络/watch 检查等待影响。服务恢复后自动切换到下一有效告警,没有告警时 OFF。 +RGB 保留 v3.2.0 已验证的 MCU 寄存器协议与 R/G/B 写入顺序。RC5 只在实际 RGB mode 切换时停止旧模式,动画帧仅完整写 selector/R/G/B,避免每帧反复触发 OFF 寄存器。 ### 8. 关机收尾 diff --git a/SHA256SUMS b/SHA256SUMS index 28d8f7c..de90aa8 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -e3dea3247111f46f87a8b62b020124639e4e7a1911d6c21534694d3c32e41e15 README.md +162b3c8dee7576d0cd81ac9b4cd5c7f1051f8d1dc7f35fd8b3eeb2a932662d95 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -388d1f7c3a329af6580945227ae4c601f97ce30f2788ccadc14b81644f300e25 app/pigway_pi_control.py -16649f17170d8aea354387e2d37a83398ccb661e4f7666a3d0e47f18f189ea8d config/pigway-pi-control.conf +f750ec8189c14fb8a4cdadc691752ce757b9ab4e26784197ba09883aaa630b4c app/pigway_pi_control.py +dfb1d083c9d64a5e0d6ccfa60a4ef5f71e83ea137ebc53b169a69f6b1458883f config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 48f8d0d..114f281 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -121,12 +121,16 @@ def rgb_off(): mcu(0x07,0x00) # seller closeRGB(): true OFF _last_rgb=("off",) -def rgb(r,g,b): +def rgb_begin_mode(): + global _last_rgb + mcu(0x07,0x00) # stop a previous mode once, never once per animation frame + _last_rgb=None + +def rgb_frame(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 @@ -313,35 +317,28 @@ def diagnose(): return 0 -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} +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, +} -class AlertManager: - def __init__(self): - self._active={} - - def set_alert(self,alert_id,severity,title,l2="",l3="",l4="",metadata=None): - now=time.monotonic() - display={"id":alert_id,"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21], - "sev":severity,"metadata":metadata or {}} - current=self._active.get(alert_id) - if current is None: - display["active"]=True; display["first_seen"]=now; display["last_changed"]=now - self._active[alert_id]=display - log("WARN","ALERT_ACTIVE",alert=alert_id,severity=severity,detail=l2) - else: - changed=any(current.get(k)!=display.get(k) for k in ("sev","title","l2","l3","l4","metadata")) - current.update(display) - if changed: current["last_changed"]=now - - def clear_alert(self,alert_id): - if alert_id in self._active: - self._active.pop(alert_id) - log("INFO","ALERT_RECOVERED",alert=alert_id) - - def active_alerts(self): - return sorted(self._active.values(),key=lambda a:(-a["sev"],a["first_seen"],a["id"])) +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) @@ -354,39 +351,39 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): global cpu_hi,mem_hi cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem=CPU_HIGH_SEC: alerts.set_alert("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD") - else: alerts.clear_alert("CPU_HIGH") + 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: - alerts.set_alert("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") - alerts.clear_alert("TEMP_HIGH") + 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: - alerts.set_alert("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") - alerts.clear_alert("TEMP_CRITICAL") + 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: - alerts.clear_alert("TEMP_HIGH"); alerts.clear_alert("TEMP_CRITICAL") - if mem_hi>=MEM_HIGH_SEC: alerts.set_alert("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","") - else: alerts.clear_alert("MEMORY_HIGH") + 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: - alerts.set_alert("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") - alerts.clear_alert("DISK_HIGH") + display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") + display.clear("DISK_HIGH") elif disk>=DISK_HIGH: - alerts.set_alert("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE") - alerts.clear_alert("DISK_CRITICAL") + display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE") + display.clear("DISK_CRITICAL") else: - alerts.clear_alert("DISK_HIGH"); alerts.clear_alert("DISK_CRITICAL") + display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL") if not link_ok: - alerts.set_alert("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","") - alerts.clear_alert("INTERNET_DOWN") + display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","") + display.clear("INTERNET_DOWN") elif not net_ok: - alerts.set_alert("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"") - alerts.clear_alert("NETWORK_DOWN") + display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"") + display.clear("NETWORK_DOWN") else: - alerts.clear_alert("NETWORK_DOWN"); alerts.clear_alert("INTERNET_DOWN") + display.clear("NETWORK_DOWN"); display.clear("INTERNET_DOWN") for w,ok in zip(watches,watch_state): alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1] if not ok: - alerts.set_alert(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]}) - else: alerts.clear_alert(alert_id) + 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: @@ -397,24 +394,24 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): elif rssi >= WIFI_RECOVER: wifi_weak_active=False; wifi_weak_since=None if wifi_weak_active: - alerts.set_alert("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL") - else: alerts.clear_alert("WIFI_WEAK") + 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; alerts.clear_alert("WIFI_WEAK") + wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK") if health.get("under_voltage_now"): - alerts.set_alert("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")}) - else: alerts.clear_alert("POWER_UNDERVOLT") + 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"): - alerts.set_alert("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") - else: alerts.clear_alert("THROTTLED") + 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"): - alerts.set_alert("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") - else: alerts.clear_alert("FREQ_CAPPED") + 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"): - alerts.set_alert("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING") - else: alerts.clear_alert("SOFT_TEMP_LIMIT") - if fs_readonly: alerts.set_alert("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY") - else: alerts.clear_alert("ROOT_READONLY") + 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 rgb_mode_for(a): if not a: return "OFF" @@ -428,17 +425,15 @@ def breathe(level_seconds,elapsed): return 0.05+0.95*(0.5-0.5*math.cos(2.0*math.pi*phase)) def rgb_engine(mode,elapsed): - if mode=="OFF": - rgb_off() - elif mode=="WARNING_BREATHE": + if mode=="WARNING_BREATHE": level=breathe(WARNING_BREATHE_SECONDS,elapsed) - rgb(*(channel*level for channel in RGB_WARNING)) + rgb_frame(*(channel*level for channel in RGB_WARNING)) elif mode=="CRITICAL_BREATHE": level=breathe(CRITICAL_BREATHE_SECONDS,elapsed) - rgb(*(channel*level for channel in RGB_CRITICAL)) + rgb_frame(*(channel*level for channel in RGB_CRITICAL)) else: on=int(elapsed*1000/max(50,EMERGENCY_FLASH_MS))%2==0 - rgb(RGB_EMERGENCY[0] if on else 0,0,0) + rgb_frame(RGB_EMERGENCY[0] if on else 0,0,0) def render_home(cpu,temp,mem,disk): @@ -475,52 +470,123 @@ def render_home(cpu,temp,mem,disk): 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): + oled_show([(a["title"],""), (a["l2"],""),(a["l3"],""),(a["l4"],"")]) -def alert_key(a): - return a["id"] +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":"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.stop_event=threading.Event() + self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True) -def find_live_alert(key, live): - for a in live: - if alert_key(a)==key: return a - return None + def start(self): + oled_init(); rgb_off(); self.refresh(); self.rgb_thread.start() -# RGB has its own clock and consumes only the highest-severity active alert. -# OLED rendering never reads or writes RGB state. -led_stop=threading.Event() -led_alert=None + def update_home(self,cpu,temp,mem,disk): + with self.lock: + request=self.requests["NORMAL_HOME"] + values=(cpu,temp,mem,disk) + if request["metadata"].get("home")!=values: + request["metadata"]={"home":values}; request["last_changed"]=time.monotonic() -def led_worker(): - previous_error=None - current_mode="OFF" - animation_started=time.monotonic() - while not led_stop.is_set(): - try: - new_mode=rgb_mode_for(led_alert) - if new_mode!=current_mode: - log("INFO","RGB_MODE",old=current_mode,new=new_mode) - current_mode=new_mode - # Keep the verified five-write MCU RGB transaction contiguous. - with bus_lock: rgb_engine(current_mode,time.monotonic()-animation_started) - 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) - led_stop.wait(max(0.02,min(0.05,RGB_UPDATE_MS/1000.0))) + 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({"sev":severity}),"title":title[:21], + "l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True, + "last_changed":now,"metadata":metadata or {}} + with self.lock: + current=self.requests.get(request_id) + if current is None: + request["first_seen"]=now; self.requests[request_id]=request + log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2) + else: + request["first_seen"]=current["first_seen"] + changed=any(current.get(k)!=request.get(k) for k in + ("priority","severity","rgb_mode","title","l2","l3","l4","metadata")) + if not changed: request["last_changed"]=current["last_changed"] + self.requests[request_id]=request + self._arbitrate_locked() + + def clear(self,request_id): + if request_id=="NORMAL_HOME": return + with self.lock: + if self.requests.pop(request_id,None) is not None: + log("INFO","ALERT_RECOVERED",alert=request_id) + self._arbitrate_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._render_locked(request) + + def _render_locked(self,request): + if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"]) + else: render_alert(request) + + def refresh(self): + with self.lock: self._render_locked(self.requests[self.current_owner_id]) + + 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"; animation_started=time.monotonic() + while not self.stop_event.is_set(): + try: + with self.lock: wanted_mode=self.current_rgb_mode + if wanted_mode!=applied_mode: + with bus_lock: + if wanted_mode=="OFF": rgb_off() + else: rgb_begin_mode() + log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) + applied_mode=wanted_mode + if applied_mode!="OFF": + with bus_lock: rgb_engine(applied_mode,time.monotonic()-animation_started) + 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) + self.stop_event.wait(max(0.04,min(0.5,RGB_UPDATE_MS/1000.0))) + + 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"]) - led_stop.set() - if led_thread.is_alive(): led_thread.join() if bus is None: return - # Attempt every step even when a previous device write fails. - for name,action in (("rgb",rgb_off),("fan",lambda:mcu(0x08,0x00)), - ("oled_clear",lambda:oled_show([("","")]*4)), - ("oled_off",lambda:cmd(0xAE)),("bus_close",bus.close)): + 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)) @@ -535,26 +601,15 @@ 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} -alerts=AlertManager() event_state={} history_logged=set() 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 - -led_thread=threading.Thread(target=led_worker,name="rgb",daemon=True) +display=DisplayController() try: bus=SMBus(1) FONT5=Path(FONT_PATH).read_bytes() - oled_init(); rgb_off(); apply_dark_mode() + display.start(); apply_dark_mode() log("INFO","START",version=APP_VERSION,uptime=uptime_seconds()) - led_thread.start() while running: try: now=time.monotonic() @@ -598,48 +653,10 @@ try: agent["last_health_ok"]=now; last_health=now c,t,m,d=latest + display.update_home(c,t,m,d) update_active_alerts(c,t,m,d,sample_elapsed) - active=alerts.active_alerts() - led_alert=max(active,key=lambda a:a["sev"],default=None) - - # 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: - 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) + display.refresh() last_render=now time.sleep(0.20) diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 17546d1..6fc4496 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -36,8 +36,8 @@ sensor_interval = 1 # 推荐:1。 oled_refresh_interval = 1 -# HOME / 各告警页面的轮播停留时间,单位:秒。 -# 推荐:10。觉得切换太慢可改为 5~8。 +# 为兼容 v3.2.x/v3.3.0 RC4 旧配置保留。 +# RC5 使用单一 Display Owner,不再定时轮播 HOME/告警页,因此该值不参与运行时仲裁。 page_interval = 10 # 网络状态检查周期,单位:秒。 @@ -139,11 +139,12 @@ wifi_weak_seconds = 30 [led] -# RGB 只由所有 active alert 中最高 severity 决定,与 OLED 当前页面完全无关: +# OLED 和 RGB 由同一 Display Owner 同步决定。 +# DisplayController 按集中定义的 priority 仲裁 owner,RGB 再使用该 owner 的 severity: # Warning (1) = 黄色慢呼吸 # Critical (2) = 橙红色快呼吸 # Emergency (3) = 纯红快速频闪 -# 无 active alert 时 RGB OFF。每次写入明确设置 R/G/B,绝不沿用旧颜色。 +# NORMAL_HOME 为 owner 时 RGB OFF。每次写入明确设置 R/G/B,绝不沿用旧颜色。 # # brightness 是 RGB 最大输出百分比(0~100)。 # 3颗 RGB 会一起亮,卧室建议 5~15。 From a84f4895ef5941002a36cad2947aa901f3fc5df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 26 Sep 2026 23:01:15 +0800 Subject: [PATCH 07/13] fix: arbitrate display requests atomically --- README.md | 2 +- SHA256SUMS | 4 ++-- app/pigway_pi_control.py | 18 +++++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f619660..b1c39eb 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ CHECK STORAGE ### 4. DisplayController、OLED 与 RGB -每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request。DisplayController 始终只选一个 owner,OLED 和 RGB 同步表达该 owner;`NORMAL_HOME` 是 priority 0 的常驻 request,有任何故障 owner 时不会抢回显示权。同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 +每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个 owner,OLED 和 RGB 同步表达该 owner;`NORMAL_HOME` 是 priority 0 的常驻 request,有任何故障 owner 时不会抢回显示权。同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 ### 5. RGB 告警引擎 diff --git a/SHA256SUMS b/SHA256SUMS index de90aa8..0cb430c 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,6 +1,6 @@ -162b3c8dee7576d0cd81ac9b4cd5c7f1051f8d1dc7f35fd8b3eeb2a932662d95 README.md +72e5eabe3093fde436d9bbef417660ada81a52164f9697486dcbd73f5322c205 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -f750ec8189c14fb8a4cdadc691752ce757b9ab4e26784197ba09883aaa630b4c app/pigway_pi_control.py +319ee4dbeed007df5ff3ce60f5a4b24b950f797f595848917542862bcbcbbd7d app/pigway_pi_control.py dfb1d083c9d64a5e0d6ccfa60a4ef5f71e83ea137ebc53b169a69f6b1458883f config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 114f281..e9a5b07 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -488,6 +488,7 @@ class DisplayController: self.current_priority=0 self.current_rgb_mode="OFF" self.owner_since=now + self.batch_depth=0 self.stop_event=threading.Event() self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True) @@ -518,14 +519,23 @@ class DisplayController: ("priority","severity","rgb_mode","title","l2","l3","l4","metadata")) if not changed: request["last_changed"]=current["last_changed"] self.requests[request_id]=request - self._arbitrate_locked() + if self.batch_depth==0: self._arbitrate_locked() def clear(self,request_id): if request_id=="NORMAL_HOME": return with self.lock: if self.requests.pop(request_id,None) is not None: log("INFO","ALERT_RECOVERED",alert=request_id) - self._arbitrate_locked() + if self.batch_depth==0: self._arbitrate_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() def _arbitrate_locked(self): current=self.requests.get(self.current_owner_id) @@ -654,7 +664,9 @@ try: c,t,m,d=latest display.update_home(c,t,m,d) - update_active_alerts(c,t,m,d,sample_elapsed) + 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 From 249af5a3e3c419a00ebc6d03669e29f6c33959c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 09:25:19 +0800 Subject: [PATCH 08/13] fix: use calibrated MCU RGB effects --- README.md | 8 +- SHA256SUMS | 6 +- app/pigway_pi_control.py | 133 +++++++++++++++++++++------------- config/pigway-pi-control.conf | 41 ++++------- 4 files changed, 106 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index b1c39eb..d600781 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ v3.3.0 的目标不是增加 Web/API,而是先把 **P0 本地检测、事件 - Wi-Fi RSSI / Ethernet 链路速率 - 当前默认路由与 IP 同步切换 - 风扇自动温控、回差防抖 -- RGB:颜色表示故障对象,闪烁速度表示严重程度 +- RGB:内置灯效的颜色与速度表示严重程度 - systemd 服务与普通进程监控 - Raspberry Pi PWR / ACT 运行时 Dark Mode @@ -69,11 +69,11 @@ CHECK STORAGE ### 4. DisplayController、OLED 与 RGB -每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个 owner,OLED 和 RGB 同步表达该 owner;`NORMAL_HOME` 是 priority 0 的常驻 request,有任何故障 owner 时不会抢回显示权。同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 +每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个 owner;同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。OLED 页面游标不参与 RGB 决策,因此切回主页或其他告警页不会关闭、重启或改变灯效。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 ### 5. RGB 告警引擎 -RGB 模式的唯一来源是 current Display Owner 的 severity。Warning 为黄色 3 秒呼吸,Critical 为橙红色 1.5 秒呼吸,Emergency 为纯红快速频闪;`NORMAL_HOME` 为 owner 时 OFF。呼吸最低亮度为 5%,默认每 80ms 更新。owner 变化但 RGB mode 相同时不关闭、不重置相位、不重写初始化序列。动画帧不写日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 +RGB 模式的唯一来源是 current Display Owner 的 severity。Warning 使用 MCU 内置黄色慢呼吸,Critical 使用 MCU 内置红色快呼吸,Emergency 为纯红快速频闪;`NORMAL_HOME` 为 owner 时 OFF。仅在 RGB mode 实际变化时按 `speed → color → effect` 写入。呼吸动画完全由 MCU 生成,不再使用软件 PWM 或逐帧 R/G/B 写入。owner 变化但 RGB mode 相同时不关闭、不重启灯效。动画不写日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 ### 6. 结构化事件日志 @@ -122,7 +122,7 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose 诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。 -RGB 保留 v3.2.0 已验证的 MCU 寄存器协议与 R/G/B 写入顺序。RC5 只在实际 RGB mode 切换时停止旧模式,动画帧仅完整写 selector/R/G/B,避免每帧反复触发 OFF 寄存器。 +RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认呼吸应使用 MCU 内置效果,模式切换间隔默认 10ms,并由正常 OLED 刷新保持共享总线活动。旧版 `brightness`、软件呼吸周期、更新频率和 R/G/B 配置继续允许保留在用户配置中,但硬件灯效引擎会忽略它们。 ### 8. 关机收尾 diff --git a/SHA256SUMS b/SHA256SUMS index 0cb430c..18c8a71 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -72e5eabe3093fde436d9bbef417660ada81a52164f9697486dcbd73f5322c205 README.md +e197d388988c8fa69adc1995de66d0dafc63d8ebf24326ad2e59743c201315d1 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -319ee4dbeed007df5ff3ce60f5a4b24b950f797f595848917542862bcbcbbd7d app/pigway_pi_control.py -dfb1d083c9d64a5e0d6ccfa60a4ef5f71e83ea137ebc53b169a69f6b1458883f config/pigway-pi-control.conf +e530efb5a08b0413836bb91dd08651cfe724b1cca616794cabd5b69ae7ec83cd app/pigway_pi_control.py +5c39712ddca8a9846e7cde81b91d3af2399a326c1fe437c66050d140b3f37fb6 config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index e9a5b07..922ed6f 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,math +import time,socket,subprocess,configparser,signal,re,errno,threading,argparse from pathlib import Path APP_VERSION="3.3.0" from smbus2 import SMBus @@ -121,19 +121,21 @@ def rgb_off(): mcu(0x07,0x00) # seller closeRGB(): true OFF _last_rgb=("off",) -def rgb_begin_mode(): +def rgb_mode(mode): global _last_rgb - mcu(0x07,0x00) # stop a previous mode once, never once per animation frame - _last_rgb=None - -def rgb_frame(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(0x00,0xff) # seller protocol: >=3 => all three LEDs - mcu(0x01,state[0]); mcu(0x02,state[1]); mcu(0x03,state[2]) - _last_rgb=state + settings={ + "WARNING_BREATHE":(RGB_WARNING_SPEED,RGB_WARNING_COLOR,0x01), + "CRITICAL_BREATHE":(RGB_CRITICAL_SPEED,RGB_CRITICAL_COLOR,0x01), + } + state=settings.get(mode) + if state is None: raise ValueError(f"unsupported RGB mode: {mode}") + if _last_rgb==(mode,): return + speed,color,effect=state + # Hardware-calibrated MCU order. The final effect write commits the mode. + mcu(0x05,speed); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x06,color); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x04,effect) + _last_rgb=(mode,) def set_pi_led(name,off=True): base=Path("/sys/class/leds")/name @@ -262,14 +264,12 @@ 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) -WARNING_BREATHE_SECONDS=cf("led","warning_breathe_seconds",3.0) -CRITICAL_BREATHE_SECONDS=cf("led","critical_breathe_seconds",1.5) EMERGENCY_FLASH_MS=cf("led","emergency_flash_ms",300,int) -RGB_UPDATE_MS=cf("led","rgb_update_ms",80,int) -RGB_WARNING=(cf("led","warning_r",255,int),cf("led","warning_g",255,int),cf("led","warning_b",0,int)) -RGB_CRITICAL=(cf("led","critical_r",255,int),cf("led","critical_g",72,int),cf("led","critical_b",0,int)) -RGB_EMERGENCY=(cf("led","emergency_r",255,int),cf("led","emergency_g",0,int),cf("led","emergency_b",0,int)) +RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) +RGB_WARNING_SPEED=max(1,min(3,cf("led","warning_speed",1,int))) +RGB_WARNING_COLOR=max(0,min(6,cf("led","warning_color",3,int))) +RGB_CRITICAL_SPEED=max(1,min(3,cf("led","critical_speed",3,int))) +RGB_CRITICAL_COLOR=max(0,min(6,cf("led","critical_color",0,int))) def check_watch(w): typ,label,target=w @@ -419,22 +419,18 @@ def rgb_mode_for(a): if a["sev"]>=2: return "CRITICAL_BREATHE" return "WARNING_BREATHE" -def breathe(level_seconds,elapsed): - # A full sine-derived ease in/out cycle gives continuous light transitions. - phase=(elapsed/max(0.1,level_seconds))%1.0 - return 0.05+0.95*(0.5-0.5*math.cos(2.0*math.pi*phase)) - -def rgb_engine(mode,elapsed): - if mode=="WARNING_BREATHE": - level=breathe(WARNING_BREATHE_SECONDS,elapsed) - rgb_frame(*(channel*level for channel in RGB_WARNING)) - elif mode=="CRITICAL_BREATHE": - level=breathe(CRITICAL_BREATHE_SECONDS,elapsed) - rgb_frame(*(channel*level for channel in RGB_CRITICAL)) +def rgb_emergency(on): + global _last_rgb + state=("EMERGENCY_FLASH",bool(on)) + if _last_rgb==state: return + if not on: + mcu(0x07,0x00) else: - on=int(elapsed*1000/max(50,EMERGENCY_FLASH_MS))%2==0 - rgb_frame(RGB_EMERGENCY[0] if on else 0,0,0) - + mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x01,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x02,0x00); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x03,0x00) + _last_rgb=state def render_home(cpu,temp,mem,disk): # 128x32 fixed columns. Both columns are LEFT-aligned. @@ -470,8 +466,8 @@ def render_home(cpu,temp,mem,disk): bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16]) -def render_alert(a): - oled_show([(a["title"],""), +def render_alert(a,index,total): + oled_show([(a["title"],f"{index}/{total}"), (a["l2"],""),(a["l3"],""),(a["l4"],"")]) @@ -488,12 +484,14 @@ class DisplayController: 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(); rgb_off(); self.refresh(); self.rgb_thread.start() + oled_init(); self.refresh(); rgb_off(); self.rgb_thread.start() def update_home(self,cpu,temp,mem,disk): with self.lock: @@ -526,7 +524,10 @@ class DisplayController: 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.batch_depth==0: + self._arbitrate_locked() + if self.current_oled_page_id not in self.requests: + self._show_page_locked(self.current_owner_id) def begin_update(self): with self.lock: self.batch_depth+=1 @@ -535,7 +536,10 @@ class DisplayController: 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.batch_depth==0: + self._arbitrate_locked() + if self.current_oled_page_id not in self.requests: + self._show_page_locked(self.current_owner_id) def _arbitrate_locked(self): current=self.requests.get(self.current_owner_id) @@ -552,14 +556,39 @@ class DisplayController: 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._render_locked(request) + self._show_page_locked(request["id"]) - def _render_locked(self,request): - if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"]) - else: render_alert(request) + 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() + 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 refresh(self): - with self.lock: self._render_locked(self.requests[self.current_owner_id]) + 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]) @@ -572,17 +601,21 @@ class DisplayController: if wanted_mode!=applied_mode: with bus_lock: if wanted_mode=="OFF": rgb_off() - else: rgb_begin_mode() + elif wanted_mode=="EMERGENCY_FLASH": rgb_emergency(True) + else: rgb_mode(wanted_mode) log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) - applied_mode=wanted_mode - if applied_mode!="OFF": - with bus_lock: rgb_engine(applied_mode,time.monotonic()-animation_started) + applied_mode=wanted_mode; animation_started=time.monotonic() + if applied_mode=="EMERGENCY_FLASH": + elapsed=time.monotonic()-animation_started + on=int(elapsed*1000/max(50,EMERGENCY_FLASH_MS))%2==0 + with bus_lock: rgb_emergency(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) - self.stop_event.wait(max(0.04,min(0.5,RGB_UPDATE_MS/1000.0))) + delay=0.04 if applied_mode=="EMERGENCY_FLASH" else 0.25 + self.stop_event.wait(delay) def shutdown(self): self.stop_event.set() diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 6fc4496..9b43e61 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -36,8 +36,8 @@ sensor_interval = 1 # 推荐:1。 oled_refresh_interval = 1 -# 为兼容 v3.2.x/v3.3.0 RC4 旧配置保留。 -# RC5 使用单一 Display Owner,不再定时轮播 HOME/告警页,因此该值不参与运行时仲裁。 +# OLED 在主页和当前告警页之间轮播的周期,单位:秒。 +# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。 page_interval = 10 # 网络状态检查周期,单位:秒。 @@ -139,34 +139,25 @@ wifi_weak_seconds = 30 [led] -# OLED 和 RGB 由同一 Display Owner 同步决定。 -# DisplayController 按集中定义的 priority 仲裁 owner,RGB 再使用该 owner 的 severity: +# DisplayController 按集中定义的 priority 仲裁 owner,RGB 使用该 owner 的 severity。 +# OLED 独立轮播主页与全部当前告警页;切页不会关闭、重启或改变 RGB: # Warning (1) = 黄色慢呼吸 -# Critical (2) = 橙红色快呼吸 +# Critical (2) = 红色快呼吸 # Emergency (3) = 纯红快速频闪 -# NORMAL_HOME 为 owner 时 RGB OFF。每次写入明确设置 R/G/B,绝不沿用旧颜色。 +# NORMAL_HOME 为 owner 时 RGB OFF。 # -# brightness 是 RGB 最大输出百分比(0~100)。 -# 3颗 RGB 会一起亮,卧室建议 5~15。 -brightness = 10 - -# 旧 warning_on_ms / warning_off_ms / critical_on_ms / critical_off_ms / -# emergency_on_ms / emergency_off_ms 仍可保留,RC3 忽略它们以兼容旧配置。 -warning_breathe_seconds = 3.0 -critical_breathe_seconds = 1.5 +# 该旧版 MCU 由内置灯效产生呼吸。仅在模式变化时按 +# speed -> color -> effect 写入,禁止软件 PWM 和循环帧日志。 +# 卖家协议:speed 1=慢、2=中、3=快;color 0=红、3=黄。 +warning_speed = 1 +warning_color = 3 +critical_speed = 3 +critical_color = 0 +write_delay_ms = 10 emergency_flash_ms = 300 -rgb_update_ms = 80 -# RGB 基色(0~255)。安全默认值严格不含蓝色,避免紫色/蓝色残留。 -warning_r = 255 -warning_g = 255 -warning_b = 0 -critical_r = 255 -critical_g = 72 -critical_b = 0 -emergency_r = 255 -emergency_g = 0 -emergency_b = 0 +# v3.3.0 之前的 brightness、*_breathe_seconds、rgb_update_ms 和 *_r/g/b +# 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。 [dark_mode] From ad22dcced780ae8c4b54975b4f43ede906755175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 09:47:03 +0800 Subject: [PATCH 09/13] feat: distinguish RGB alerts by fault type --- README.md | 4 +-- SHA256SUMS | 6 ++-- app/pigway_pi_control.py | 54 ++++++++++++++++++++++++----------- config/pigway-pi-control.conf | 21 ++++++++------ 4 files changed, 55 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d600781..c8f68ea 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,11 @@ CHECK STORAGE ### 4. DisplayController、OLED 与 RGB -每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个 owner;同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。OLED 页面游标不参与 RGB 决策,因此切回主页或其他告警页不会关闭、重启或改变灯效。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 +每个当前故障都是 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 模式的唯一来源是 current Display Owner 的 severity。Warning 使用 MCU 内置黄色慢呼吸,Critical 使用 MCU 内置红色快呼吸,Emergency 为纯红快速频闪;`NORMAL_HOME` 为 owner 时 OFF。仅在 RGB mode 实际变化时按 `speed → color → effect` 写入。呼吸动画完全由 MCU 生成,不再使用软件 PWM 或逐帧 R/G/B 写入。owner 变化但 RGB mode 相同时不关闭、不重启灯效。动画不写日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 +RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU/供电/降频为黄色,温度为红色,内存为紫色,磁盘为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。速度表示严重等级:Warning 慢呼吸、Critical 快呼吸、Emergency 纯红快速频闪;没有 active alert 时 OFF。模式变化时按 `speed → color → effect` 写入,呼吸动画完全由 MCU 生成,不使用软件 PWM 或逐帧 R/G/B 写入。相邻页面 RGB mode 相同时不关闭、不重启灯效。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 ### 6. 结构化事件日志 diff --git a/SHA256SUMS b/SHA256SUMS index 18c8a71..9ac4ab8 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -e197d388988c8fa69adc1995de66d0dafc63d8ebf24326ad2e59743c201315d1 README.md +1dd807bcc8e6711fed829f42e84f8baf4d644e7248a5a39bd17f65d14090e35e README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -e530efb5a08b0413836bb91dd08651cfe724b1cca616794cabd5b69ae7ec83cd app/pigway_pi_control.py -5c39712ddca8a9846e7cde81b91d3af2399a326c1fe437c66050d140b3f37fb6 config/pigway-pi-control.conf +e04cd1b7940ce5fa0539586dbaaac654a3234aa6e0306d77d1e4b2b1c918ab9c app/pigway_pi_control.py +7155b4585c8caf60ef8d9a0c0676229b742d86bd938fbd9303ca9bfbb8964be6 config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 922ed6f..1f483f3 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -123,18 +123,15 @@ def rgb_off(): def rgb_mode(mode): global _last_rgb - settings={ - "WARNING_BREATHE":(RGB_WARNING_SPEED,RGB_WARNING_COLOR,0x01), - "CRITICAL_BREATHE":(RGB_CRITICAL_SPEED,RGB_CRITICAL_COLOR,0x01), - } - state=settings.get(mode) - if state is None: raise ValueError(f"unsupported RGB mode: {mode}") + match=re.fullmatch(r"(RED|GREEN|BLUE|YELLOW|PURPLE|CYAN|WHITE)_(SLOW|FAST)_BREATHE",mode) + if match is None: raise ValueError(f"unsupported RGB mode: {mode}") if _last_rgb==(mode,): return - speed,color,effect=state + color=RGB_COLOR_VALUES[match.group(1)] + speed=RGB_WARNING_SPEED if match.group(2)=="SLOW" else RGB_CRITICAL_SPEED # Hardware-calibrated MCU order. The final effect write commits the mode. mcu(0x05,speed); time.sleep(RGB_WRITE_DELAY_MS/1000.0) mcu(0x06,color); time.sleep(RGB_WRITE_DELAY_MS/1000.0) - mcu(0x04,effect) + mcu(0x04,0x01) _last_rgb=(mode,) def set_pi_led(name,off=True): @@ -267,9 +264,17 @@ DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in (" EMERGENCY_FLASH_MS=cf("led","emergency_flash_ms",300,int) RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) RGB_WARNING_SPEED=max(1,min(3,cf("led","warning_speed",1,int))) -RGB_WARNING_COLOR=max(0,min(6,cf("led","warning_color",3,int))) RGB_CRITICAL_SPEED=max(1,min(3,cf("led","critical_speed",3,int))) -RGB_CRITICAL_COLOR=max(0,min(6,cf("led","critical_color",0,int))) +RGB_COLOR_VALUES={"RED":0,"GREEN":1,"BLUE":2,"YELLOW":3,"PURPLE":4,"CYAN":5,"WHITE":6} +RGB_OBJECT_COLORS={ + "ROOT_READONLY":"RED", + "TEMP_CRITICAL":"RED","TEMP_HIGH":"RED","SOFT_TEMP_LIMIT":"RED", + "POWER_UNDERVOLT":"YELLOW","THROTTLED":"YELLOW","FREQ_CAPPED":"YELLOW","CPU_HIGH":"YELLOW", + "MEMORY_HIGH":"PURPLE", + "DISK_CRITICAL":"WHITE","DISK_HIGH":"WHITE", + "NETWORK_DOWN":"BLUE","INTERNET_DOWN":"BLUE","WIFI_WEAK":"BLUE", + "SERVICE_DOWN":"CYAN","PROCESS_DOWN":"CYAN", +} def check_watch(w): typ,label,target=w @@ -413,11 +418,11 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY") else: display.clear("ROOT_READONLY") -def rgb_mode_for(a): - if not a: return "OFF" - if a["sev"]>=3: return "EMERGENCY_FLASH" - if a["sev"]>=2: return "CRITICAL_BREATHE" - return "WARNING_BREATHE" +def rgb_mode_for(request_id,severity): + if severity<=0: return "OFF" + color=RGB_OBJECT_COLORS[request_id.split("|",1)[0]] + if severity>=3: return "EMERGENCY_FLASH" + return f"{color}_{'FAST' if severity>=2 else 'SLOW'}_BREATHE" def rgb_emergency(on): global _last_rgb @@ -503,7 +508,7 @@ class DisplayController: 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({"sev":severity}),"title":title[:21], + "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: @@ -517,7 +522,9 @@ class DisplayController: ("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() + if self.batch_depth==0: + self._arbitrate_locked() + self._sync_rgb_locked() def clear(self,request_id): if request_id=="NORMAL_HOME": return @@ -528,6 +535,7 @@ class DisplayController: 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 @@ -540,6 +548,7 @@ class DisplayController: 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) @@ -567,12 +576,23 @@ class DisplayController: request=self.requests.get(page_id,self.requests["NORMAL_HOME"]) self.current_oled_page_id=request["id"] self.page_started=time.monotonic() + self._sync_rgb_locked() if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"]) else: alert_ids=[a["id"] for a in alerts] render_alert(request,alert_ids.index(request["id"])+1,len(alerts)) + def _sync_rgb_locked(self): + # Alert pages show that alert's object color. HOME keeps the highest- + # priority active owner visible instead of briefly switching RGB off. + rgb_request=self.requests.get(self.current_oled_page_id) + if self.current_oled_page_id=="NORMAL_HOME": + rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"]) + if rgb_request is None: + rgb_request=self.requests["NORMAL_HOME"] + self.current_rgb_mode=rgb_request["rgb_mode"] + def refresh(self): with self.lock: alerts=self._ordered_alerts_locked() diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 9b43e61..0961665 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -139,24 +139,29 @@ wifi_weak_seconds = 30 [led] -# DisplayController 按集中定义的 priority 仲裁 owner,RGB 使用该 owner 的 severity。 -# OLED 独立轮播主页与全部当前告警页;切页不会关闭、重启或改变 RGB: -# Warning (1) = 黄色慢呼吸 -# Critical (2) = 红色快呼吸 +# DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示 +# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级: +# CPU/供电/降频 = 黄色 +# 温度 = 红色 +# 内存 = 紫色 +# 磁盘 = 白色 +# 网络/Wi-Fi = 蓝色 +# 服务/进程 = 青色 +# Warning (1) = 对象颜色慢呼吸 +# Critical (2) = 对象颜色快呼吸 # Emergency (3) = 纯红快速频闪 # NORMAL_HOME 为 owner 时 RGB OFF。 # # 该旧版 MCU 由内置灯效产生呼吸。仅在模式变化时按 # speed -> color -> effect 写入,禁止软件 PWM 和循环帧日志。 -# 卖家协议:speed 1=慢、2=中、3=快;color 0=红、3=黄。 +# 卖家协议:speed 1=慢、2=中、3=快;color 0..6=红/绿/蓝/黄/紫/青/白。 warning_speed = 1 -warning_color = 3 critical_speed = 3 -critical_color = 0 write_delay_ms = 10 emergency_flash_ms = 300 -# v3.3.0 之前的 brightness、*_breathe_seconds、rgb_update_ms 和 *_r/g/b +# v3.3.0 之前的 brightness、*_breathe_seconds、rgb_update_ms、warning_color、 +# critical_color 和 *_r/g/b # 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。 From ac9bc4befd6e42f6ce615251b404452c542a5d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 09:54:45 +0800 Subject: [PATCH 10/13] fix: align RGB colors with fault domains --- README.md | 2 +- SHA256SUMS | 6 ++--- app/pigway_pi_control.py | 44 +++++++++++++++++++++++------------ config/pigway-pi-control.conf | 7 +++--- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c8f68ea..55620a9 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ CHECK STORAGE ### 5. RGB 告警引擎 -RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU/供电/降频为黄色,温度为红色,内存为紫色,磁盘为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。速度表示严重等级:Warning 慢呼吸、Critical 快呼吸、Emergency 纯红快速频闪;没有 active alert 时 OFF。模式变化时按 `speed → color → effect` 写入,呼吸动画完全由 MCU 生成,不使用软件 PWM 或逐帧 R/G/B 写入。相邻页面 RGB mode 相同时不关闭、不重启灯效。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 +RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压使用实机校准的橙色闪烁,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。通常 Warning 慢呼吸、Critical 快呼吸、Emergency 使用对象颜色快速频闪;供电告警例外使用 750ms 亮/灭,避开实机偏绿的 MCU 内置黄色。没有 active alert 时 OFF。模式变化时按已校准的 MCU 协议写入,内置颜色的呼吸完全由 MCU 生成,不使用软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启灯效。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 ### 6. 结构化事件日志 diff --git a/SHA256SUMS b/SHA256SUMS index 9ac4ab8..a6d7d65 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -1dd807bcc8e6711fed829f42e84f8baf4d644e7248a5a39bd17f65d14090e35e README.md +80ac1d84bb2daff8bb4a1182aad9198726f25624e8d253065e71a4eb062857e2 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -e04cd1b7940ce5fa0539586dbaaac654a3234aa6e0306d77d1e4b2b1c918ab9c app/pigway_pi_control.py -7155b4585c8caf60ef8d9a0c0676229b742d86bd938fbd9303ca9bfbb8964be6 config/pigway-pi-control.conf +621889210c68d4eba96d79bd8299ae435fe8f2a057ffc77c3cf86fc8c22a3060 app/pigway_pi_control.py +403f73cd12ea87a3ac08b447d5438e9ceeef0fbde1e54af40304167bde470f1e config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 1f483f3..48cd595 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -262,14 +262,21 @@ ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","r 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")) EMERGENCY_FLASH_MS=cf("led","emergency_flash_ms",300,int) +POWER_BLINK_MS=cf("led","power_blink_ms",750,int) RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) RGB_WARNING_SPEED=max(1,min(3,cf("led","warning_speed",1,int))) RGB_CRITICAL_SPEED=max(1,min(3,cf("led","critical_speed",3,int))) RGB_COLOR_VALUES={"RED":0,"GREEN":1,"BLUE":2,"YELLOW":3,"PURPLE":4,"CYAN":5,"WHITE":6} +RGB_STATIC_VALUES={ + "RED":(0xff,0x00,0x00),"GREEN":(0x00,0xff,0x00),"BLUE":(0x00,0x00,0xff), + "YELLOW":(0xff,0xff,0x00),"PURPLE":(0xff,0x00,0xff),"CYAN":(0x00,0xff,0xff), + "WHITE":(0xff,0xff,0xff),"ORANGE":(0xff,0x28,0x00), +} RGB_OBJECT_COLORS={ - "ROOT_READONLY":"RED", + "ROOT_READONLY":"WHITE", "TEMP_CRITICAL":"RED","TEMP_HIGH":"RED","SOFT_TEMP_LIMIT":"RED", - "POWER_UNDERVOLT":"YELLOW","THROTTLED":"YELLOW","FREQ_CAPPED":"YELLOW","CPU_HIGH":"YELLOW", + "THROTTLED":"RED","FREQ_CAPPED":"RED","CPU_HIGH":"RED", + "POWER_UNDERVOLT":"YELLOW", "MEMORY_HIGH":"PURPLE", "DISK_CRITICAL":"WHITE","DISK_HIGH":"WHITE", "NETWORK_DOWN":"BLUE","INTERNET_DOWN":"BLUE","WIFI_WEAK":"BLUE", @@ -420,21 +427,25 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): def rgb_mode_for(request_id,severity): if severity<=0: return "OFF" - color=RGB_OBJECT_COLORS[request_id.split("|",1)[0]] - if severity>=3: return "EMERGENCY_FLASH" + alert_type=request_id.split("|",1)[0] + if alert_type=="POWER_UNDERVOLT": return "ORANGE_CRITICAL_BLINK" + color=RGB_OBJECT_COLORS[alert_type] + if severity>=3: return f"{color}_EMERGENCY_FLASH" return f"{color}_{'FAST' if severity>=2 else 'SLOW'}_BREATHE" -def rgb_emergency(on): +def rgb_flash(mode,on): global _last_rgb - state=("EMERGENCY_FLASH",bool(on)) + color=mode.split("_",1)[0] + channels=RGB_STATIC_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,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0) - mcu(0x02,0x00); time.sleep(RGB_WRITE_DELAY_MS/1000.0) - mcu(0x03,0x00) + 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): @@ -614,27 +625,30 @@ class DisplayController: with self.lock: return dict(self.requests[self.current_owner_id]) def _rgb_worker(self): - previous_error=None; applied_mode="OFF"; animation_started=time.monotonic() + 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(("_EMERGENCY_FLASH","_CRITICAL_BLINK")) if wanted_mode!=applied_mode: with bus_lock: if wanted_mode=="OFF": rgb_off() - elif wanted_mode=="EMERGENCY_FLASH": rgb_emergency(True) + elif wanted_flash: rgb_flash(wanted_mode,True) else: rgb_mode(wanted_mode) log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) applied_mode=wanted_mode; animation_started=time.monotonic() - if applied_mode=="EMERGENCY_FLASH": + applied_flash=applied_mode.endswith(("_EMERGENCY_FLASH","_CRITICAL_BLINK")) + if applied_flash: elapsed=time.monotonic()-animation_started - on=int(elapsed*1000/max(50,EMERGENCY_FLASH_MS))%2==0 - with bus_lock: rgb_emergency(on) + interval=POWER_BLINK_MS if applied_mode=="ORANGE_CRITICAL_BLINK" else EMERGENCY_FLASH_MS + on=int(elapsed*1000/max(50,interval))%2==0 + 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_mode=="EMERGENCY_FLASH" else 0.25 + delay=0.04 if applied_flash else 0.25 self.stop_event.wait(delay) def shutdown(self): diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 0961665..fda14fc 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -141,15 +141,15 @@ wifi_weak_seconds = 30 [led] # DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示 # 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级: -# CPU/供电/降频 = 黄色 -# 温度 = 红色 +# CPU/频率/温度/散热 = 红色 +# 供电/欠压 = 橙色闪烁(实机校准,避免内置黄色偏绿) # 内存 = 紫色 # 磁盘 = 白色 # 网络/Wi-Fi = 蓝色 # 服务/进程 = 青色 # Warning (1) = 对象颜色慢呼吸 # Critical (2) = 对象颜色快呼吸 -# Emergency (3) = 纯红快速频闪 +# Emergency (3) = 对象颜色快速频闪 # NORMAL_HOME 为 owner 时 RGB OFF。 # # 该旧版 MCU 由内置灯效产生呼吸。仅在模式变化时按 @@ -159,6 +159,7 @@ warning_speed = 1 critical_speed = 3 write_delay_ms = 10 emergency_flash_ms = 300 +power_blink_ms = 750 # v3.3.0 之前的 brightness、*_breathe_seconds、rgb_update_ms、warning_color、 # critical_color 和 *_r/g/b From adfa5524b89c133ff7a4790685c036ef947688d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 10:00:05 +0800 Subject: [PATCH 11/13] refactor: use calibrated RGB alert flashes --- README.md | 2 +- SHA256SUMS | 6 +++--- app/pigway_pi_control.py | 40 ++++++++++++----------------------- config/pigway-pi-control.conf | 14 ++++++------ 4 files changed, 23 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 55620a9..d74c432 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ CHECK STORAGE ### 5. RGB 告警引擎 -RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压使用实机校准的橙色闪烁,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。通常 Warning 慢呼吸、Critical 快呼吸、Emergency 使用对象颜色快速频闪;供电告警例外使用 750ms 亮/灭,避开实机偏绿的 MCU 内置黄色。没有 active alert 时 OFF。模式变化时按已校准的 MCU 协议写入,内置颜色的呼吸完全由 MCU 生成,不使用软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启灯效。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 +RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭;没有 active alert 时 OFF。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 ### 6. 结构化事件日志 diff --git a/SHA256SUMS b/SHA256SUMS index a6d7d65..a8bb7c7 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -80ac1d84bb2daff8bb4a1182aad9198726f25624e8d253065e71a4eb062857e2 README.md +f3438c9868b0d7bd98c995908186ae21cd6580c1511265b6f7556961a4d78e30 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -621889210c68d4eba96d79bd8299ae435fe8f2a057ffc77c3cf86fc8c22a3060 app/pigway_pi_control.py -403f73cd12ea87a3ac08b447d5438e9ceeef0fbde1e54af40304167bde470f1e config/pigway-pi-control.conf +fd70b204dea22eb2f3c60345bc24ffcc286903603e02c7f708ccc301eba2cbc3 app/pigway_pi_control.py +57602d3ad8f543c63504942c8fa726c61cf37beb95e58178a8f618ef35a6e51d config/pigway-pi-control.conf 4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 48cd595..ed509bb 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -121,19 +121,6 @@ def rgb_off(): mcu(0x07,0x00) # seller closeRGB(): true OFF _last_rgb=("off",) -def rgb_mode(mode): - global _last_rgb - match=re.fullmatch(r"(RED|GREEN|BLUE|YELLOW|PURPLE|CYAN|WHITE)_(SLOW|FAST)_BREATHE",mode) - if match is None: raise ValueError(f"unsupported RGB mode: {mode}") - if _last_rgb==(mode,): return - color=RGB_COLOR_VALUES[match.group(1)] - speed=RGB_WARNING_SPEED if match.group(2)=="SLOW" else RGB_CRITICAL_SPEED - # Hardware-calibrated MCU order. The final effect write commits the mode. - mcu(0x05,speed); time.sleep(RGB_WRITE_DELAY_MS/1000.0) - mcu(0x06,color); time.sleep(RGB_WRITE_DELAY_MS/1000.0) - mcu(0x04,0x01) - _last_rgb=(mode,) - def set_pi_led(name,off=True): base=Path("/sys/class/leds")/name try: @@ -261,15 +248,13 @@ 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")) -EMERGENCY_FLASH_MS=cf("led","emergency_flash_ms",300,int) -POWER_BLINK_MS=cf("led","power_blink_ms",750,int) RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) -RGB_WARNING_SPEED=max(1,min(3,cf("led","warning_speed",1,int))) -RGB_CRITICAL_SPEED=max(1,min(3,cf("led","critical_speed",3,int))) -RGB_COLOR_VALUES={"RED":0,"GREEN":1,"BLUE":2,"YELLOW":3,"PURPLE":4,"CYAN":5,"WHITE":6} +WARNING_FLASH_MS=max(50,cf("led","warning_flash_ms",1500,int)) +CRITICAL_FLASH_MS=max(50,cf("led","critical_flash_ms",750,int)) +EMERGENCY_FLASH_MS=max(50,cf("led","emergency_flash_ms",300,int)) RGB_STATIC_VALUES={ "RED":(0xff,0x00,0x00),"GREEN":(0x00,0xff,0x00),"BLUE":(0x00,0x00,0xff), - "YELLOW":(0xff,0xff,0x00),"PURPLE":(0xff,0x00,0xff),"CYAN":(0x00,0xff,0xff), + "YELLOW":(0xff,0xff,0x00),"PURPLE":(0xff,0x00,0xff),"CYAN":(0x00,0xff,0x60), "WHITE":(0xff,0xff,0xff),"ORANGE":(0xff,0x28,0x00), } RGB_OBJECT_COLORS={ @@ -428,10 +413,9 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): def rgb_mode_for(request_id,severity): if severity<=0: return "OFF" alert_type=request_id.split("|",1)[0] - if alert_type=="POWER_UNDERVOLT": return "ORANGE_CRITICAL_BLINK" - color=RGB_OBJECT_COLORS[alert_type] - if severity>=3: return f"{color}_EMERGENCY_FLASH" - return f"{color}_{'FAST' if severity>=2 else 'SLOW'}_BREATHE" + color="ORANGE" if alert_type=="POWER_UNDERVOLT" else RGB_OBJECT_COLORS[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 @@ -629,18 +613,20 @@ class DisplayController: while not self.stop_event.is_set(): try: with self.lock: wanted_mode=self.current_rgb_mode - wanted_flash=wanted_mode.endswith(("_EMERGENCY_FLASH","_CRITICAL_BLINK")) + 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: rgb_mode(wanted_mode) + 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(("_EMERGENCY_FLASH","_CRITICAL_BLINK")) + applied_flash=applied_mode.endswith("_FLASH") if applied_flash: elapsed=time.monotonic()-animation_started - interval=POWER_BLINK_MS if applied_mode=="ORANGE_CRITICAL_BLINK" else EMERGENCY_FLASH_MS + if "_EMERGENCY_" in applied_mode: interval=EMERGENCY_FLASH_MS + elif "_CRITICAL_" in applied_mode: interval=CRITICAL_FLASH_MS + else: interval=WARNING_FLASH_MS on=int(elapsed*1000/max(50,interval))%2==0 with bus_lock: rgb_flash(applied_mode,on) if previous_error is not None: log("INFO","RGB_RECOVERED") diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index fda14fc..ac015f2 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -147,19 +147,17 @@ wifi_weak_seconds = 30 # 磁盘 = 白色 # 网络/Wi-Fi = 蓝色 # 服务/进程 = 青色 -# Warning (1) = 对象颜色慢呼吸 -# Critical (2) = 对象颜色快呼吸 +# Warning (1) = 1500ms 亮 / 1500ms 灭 +# Critical (2) = 750ms 亮 / 750ms 灭 # Emergency (3) = 对象颜色快速频闪 # NORMAL_HOME 为 owner 时 RGB OFF。 # -# 该旧版 MCU 由内置灯效产生呼吸。仅在模式变化时按 -# speed -> color -> effect 写入,禁止软件 PWM 和循环帧日志。 -# 卖家协议:speed 1=慢、2=中、3=快;color 0..6=红/绿/蓝/黄/紫/青/白。 -warning_speed = 1 -critical_speed = 3 +# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序, +# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。 write_delay_ms = 10 +warning_flash_ms = 1500 +critical_flash_ms = 750 emergency_flash_ms = 300 -power_blink_ms = 750 # v3.3.0 之前的 brightness、*_breathe_seconds、rgb_update_ms、warning_color、 # critical_color 和 *_r/g/b From 6ca19b362b983b0b96105c18fec3baed863b7c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 10:04:15 +0800 Subject: [PATCH 12/13] feat: configure RGB colors and flash timing --- README.md | 6 ++-- SHA256SUMS | 8 +++--- app/pigway_pi_control.py | 54 ++++++++++++++++++++--------------- config/pigway-pi-control.conf | 39 +++++++++++++++++++------ install.sh | 34 ++++++++++++++++++++++ 5 files changed, 103 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index d74c432..e4844e7 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ CHECK STORAGE ### 5. RGB 告警引擎 -RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭;没有 active alert 时 OFF。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 +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. 结构化事件日志 @@ -122,7 +122,7 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose 诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。 -RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认呼吸应使用 MCU 内置效果,模式切换间隔默认 10ms,并由正常 OLED 刷新保持共享总线活动。旧版 `brightness`、软件呼吸周期、更新频率和 R/G/B 配置继续允许保留在用户配置中,但硬件灯效引擎会忽略它们。 +RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜色配合低频亮灭最稳定;每次点亮完整写入 selector/R/G/B,寄存器间隔默认10ms。旧版 `brightness`、呼吸周期和颜色选项可以继续留在用户配置中,但频闪引擎会忽略它们。 ### 8. 关机收尾 @@ -165,7 +165,7 @@ sudo ./install.sh - 检查 I²C - 禁用旧 `temp-control.service` - 安装字体、程序、配置和 systemd unit -- 完整保留用户现有配置(包括所有 section、用户值和注释),新选项使用运行时默认值 +- 完整保留用户现有配置(包括所有 section、用户值和注释),仅向 `[led]` 补入缺失的 RGB 颜色与频闪选项,已有用户值优先 - 启动服务 - 执行版本、OLED、UI、P0健康监控和诊断入口自检 diff --git a/SHA256SUMS b/SHA256SUMS index a8bb7c7..3e86753 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -f3438c9868b0d7bd98c995908186ae21cd6580c1511265b6f7556961a4d78e30 README.md +1b9aaee5c830cab22aa4791d47f45cb4afcc5b5b08854b6ea609d1edc59911c3 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -fd70b204dea22eb2f3c60345bc24ffcc286903603e02c7f708ccc301eba2cbc3 app/pigway_pi_control.py -57602d3ad8f543c63504942c8fa726c61cf37beb95e58178a8f618ef35a6e51d config/pigway-pi-control.conf -4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh +06c50cec0cc29e0e6acda3db3e5749b165c5f419dbd28fafddffcf6130fce531 app/pigway_pi_control.py +1e5d079c31a250067e88962373a5d58f16f114a83db4a4125110b5a14bdf84a1 config/pigway-pi-control.conf +c347333198a9e371e4441f416e247fd1bce542a3cff999947adfe11a0a7044e0 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index ed509bb..06fb663 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -249,23 +249,31 @@ ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","r 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")) RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) -WARNING_FLASH_MS=max(50,cf("led","warning_flash_ms",1500,int)) -CRITICAL_FLASH_MS=max(50,cf("led","critical_flash_ms",750,int)) -EMERGENCY_FLASH_MS=max(50,cf("led","emergency_flash_ms",300,int)) -RGB_STATIC_VALUES={ - "RED":(0xff,0x00,0x00),"GREEN":(0x00,0xff,0x00),"BLUE":(0x00,0x00,0xff), - "YELLOW":(0xff,0xff,0x00),"PURPLE":(0xff,0x00,0xff),"CYAN":(0x00,0xff,0x60), - "WHITE":(0xff,0xff,0xff),"ORANGE":(0xff,0x28,0x00), +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))) + +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_COLORS={ - "ROOT_READONLY":"WHITE", - "TEMP_CRITICAL":"RED","TEMP_HIGH":"RED","SOFT_TEMP_LIMIT":"RED", - "THROTTLED":"RED","FREQ_CAPPED":"RED","CPU_HIGH":"RED", - "POWER_UNDERVOLT":"YELLOW", - "MEMORY_HIGH":"PURPLE", - "DISK_CRITICAL":"WHITE","DISK_HIGH":"WHITE", - "NETWORK_DOWN":"BLUE","INTERNET_DOWN":"BLUE","WIFI_WEAK":"BLUE", - "SERVICE_DOWN":"CYAN","PROCESS_DOWN":"CYAN", +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): @@ -413,14 +421,14 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): def rgb_mode_for(request_id,severity): if severity<=0: return "OFF" alert_type=request_id.split("|",1)[0] - color="ORANGE" if alert_type=="POWER_UNDERVOLT" else RGB_OBJECT_COLORS[alert_type] + 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_STATIC_VALUES[color] + channels=RGB_GROUP_VALUES[color] state=(mode,bool(on)) if _last_rgb==state: return if not on: @@ -623,11 +631,11 @@ class DisplayController: applied_mode=wanted_mode; animation_started=time.monotonic() applied_flash=applied_mode.endswith("_FLASH") if applied_flash: - elapsed=time.monotonic()-animation_started - if "_EMERGENCY_" in applied_mode: interval=EMERGENCY_FLASH_MS - elif "_CRITICAL_" in applied_mode: interval=CRITICAL_FLASH_MS - else: interval=WARNING_FLASH_MS - on=int(elapsed*1000/max(50,interval))%2==0 + 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) R -> G -> B 完整写入顺序, # 只在亮/灭边沿访问 MCU,不做高频软件 PWM。 write_delay_ms = 10 -warning_flash_ms = 1500 -critical_flash_ms = 750 -emergency_flash_ms = 300 -# v3.3.0 之前的 brightness、*_breathe_seconds、rgb_update_ms、warning_color、 -# critical_color 和 *_r/g/b +# v3.3.0 RC 期间使用过的 brightness、*_breathe_seconds、rgb_update_ms、 +# warning_color、critical_color、warning_flash_ms、critical_flash_ms 和 +# emergency_flash_ms # 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。 diff --git a/install.sh b/install.sh index a32369c..bf71040 100755 --- a/install.sh +++ b/install.sh @@ -47,6 +47,40 @@ if [ -f /etc/pigway-pi-control.conf ]; then else install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf fi + +# 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 echo "[6/8] 启用/重启服务..." From 5402dddd157a0809f3b854390f8997e9b7253dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 10:07:59 +0800 Subject: [PATCH 13/13] chore: release v3.5.0 --- README.md | 8 ++++---- SHA256SUMS | 8 ++++---- app/pigway_pi_control.py | 2 +- config/pigway-pi-control.conf | 2 +- install.sh | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e4844e7..2dbaeab 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Pigway Pi Control v3.3.0 P0 System Health +# Pigway Pi Control v3.5.0 System Health Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。 -v3.3.0 的目标不是增加 Web/API,而是先把 **P0 本地检测、事件状态、日志、诊断和关机收尾**补完整,为后续多机 NAS 监控 API 打基础。 +v3.5.0 完成本地检测、事件状态、日志、诊断、显示仲裁、可配置 RGB 告警和关机收尾,为后续轻量 API/Web 管理界面提供稳定基础。 ## 已有核心功能 @@ -24,7 +24,7 @@ DSK 22.4% WIF -32 IP4 192.168.89.130 ``` -## v3.3.0 P0 新增 +## v3.5.0 System Health ### 1. 供电与降频健康 @@ -181,5 +181,5 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose ## 版本路线 - v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换 -- **v3.3.0:P0 System Health,本地检测与日志完善** +- **v3.5.0:System Health、显示仲裁与可配置 RGB 告警** - 后续:只读 `/api/v1/...` Agent API,再由 NAS 统一汇总多台设备 diff --git a/SHA256SUMS b/SHA256SUMS index 3e86753..63e3e8a 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -1b9aaee5c830cab22aa4791d47f45cb4afcc5b5b08854b6ea609d1edc59911c3 README.md +dfe901d5f7e3a8ef897203c38cf9e4d47eca63e0d718f74b99102c874edfc3b3 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -06c50cec0cc29e0e6acda3db3e5749b165c5f419dbd28fafddffcf6130fce531 app/pigway_pi_control.py -1e5d079c31a250067e88962373a5d58f16f114a83db4a4125110b5a14bdf84a1 config/pigway-pi-control.conf -c347333198a9e371e4441f416e247fd1bce542a3cff999947adfe11a0a7044e0 install.sh +43aca7bda6e8edb59734e06ccd373a9426c73fbf359727a906b6e2ca405fc344 app/pigway_pi_control.py +02e67b147ae68378bed1dcb88245b636c72fba3dabe4c119aab61219eee89995 config/pigway-pi-control.conf +d84774f2fe2bfe380b7b29d83d42ddc17938fb03f78a746df07fc2e49f635e64 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 06fb663..0b91c5e 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import time,socket,subprocess,configparser,signal,re,errno,threading,argparse from pathlib import Path -APP_VERSION="3.3.0" +APP_VERSION="3.5.0" from smbus2 import SMBus from PIL import Image from shutil import disk_usage diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 4f7bdfd..3561691 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -1,5 +1,5 @@ # ============================================================================= -# Pigway Pi Control v3.3.0 - 用户配置文件 +# Pigway Pi Control v3.5.0 - 用户配置文件 # ============================================================================= # # 正式安装后的配置路径: diff --git a/install.sh b/install.sh index bf71040..db09a74 100755 --- a/install.sh +++ b/install.sh @@ -92,7 +92,7 @@ echo "[7/8] 安装后自检..." FAIL=0 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.3.0" grep -Fq 'APP_VERSION="3.3.0"' "$APP" +check "Version 3.5.0" grep -Fq 'APP_VERSION="3.5.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"