diff --git a/README.md b/README.md index 5b51d59..2dbaeab 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,159 @@ -# Pigway Pi Control v3.2.0 — Release +# Pigway Pi Control v3.5.0 System Health -适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。 +Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。 -本包遵循一个原则: +v3.5.0 完成本地检测、事件状态、日志、诊断、显示仲裁、可配置 RGB 告警和关机收尾,为后续轻量 API/Web 管理界面提供稳定基础。 -> **一个程序 + 一个用户配置文件 + 一个说明文档。** +## 已有核心功能 -正常使用时,不需要修改源码。 +- SSD1306 128×32 OLED,四行固定主页 +- CPU / 内存 / 温度 / 磁盘监控 +- Wi-Fi RSSI / Ethernet 链路速率 +- 当前默认路由与 IP 同步切换 +- 风扇自动温控、回差防抖 +- RGB:内置灯效的颜色与速度表示严重程度 +- systemd 服务与普通进程监控 +- Raspberry Pi PWR / ACT 运行时 Dark Mode ---- - -## 1. 功能 - -### OLED - -配套 OLED 按卖家原生参数锁定为: - -- SSD1306 -- 128×32 -- I²C `0x3C` -- MUX `0x1F` -- COM Pins `0x02` -- Page `0..3` -- 卖家同源 5×7 点阵字体 -- 4行:`0 / 8 / 16 / 24` - -主页: +主页示例: ```text CPU 18.6% MEM 16.2% TMP 46.3C FAN OFF -DSK 22.4% NET OK +DSK 22.4% WIF -32 IP4 192.168.89.130 ``` -IPv6 地址时自动显示 `IP6`。 +## v3.5.0 System Health -主页左右两栏都是固定位置、左对齐。数据默认每秒刷新。 +### 1. 供电与降频健康 -有告警时,OLED 会在 HOME 与所有当前告警之间轮播;默认每页停留10秒。 - -### 风扇 - -默认: - -```text -< 50°C OFF -50~53°C L1 -53~56°C L2 -56~59°C L3 -59~62°C L4 ->= 62°C MAX -``` - -带降档回差,避免临界温度反复升降档。 - -### RGB 告警 - -正常状态 RGB 完全熄灭。 - -颜色表示“什么对象出问题”: - -```text -CPU 黄 -温度 红 -内存 紫 -磁盘 橙 -网络 蓝 -服务/进程 青 -``` - -所有告警都使用闪烁;闪烁速度表示严重程度: - -```text -Warning 慢 -Critical 中 -Emergency 快 -``` - -亮度可配置,默认10%,适合暗环境。 - -### Dark Mode - -默认关闭 Raspberry Pi 自身: - -- PWR LED -- ACT LED - -散热板 D1/D8 黄色硬件状态灯和独立电源模块绿色灯不属于已确认的软件控制接口,本程序不操作。 - ---- - -## 2. 全新 Raspberry Pi OS 安装 - -### 第一步:启用 I²C +周期读取: ```bash -sudo raspi-config +vcgencmd get_throttled ``` -进入: +区分: + +- 当前欠压:立即 `POWER LOW / UNDERVOLT` 告警 +- 当前 CPU throttled / frequency capped:立即告警 +- 本次启动历史上曾欠压/降频:只写一次历史事件日志,不持续 RGB 告警 +- 状态恢复:写 `RECOVERED` 类事件 + +注意:这里检测的是 **Pi 的供电质量**,不是 UPS 电池百分比。 + +### 2. 文件系统健康 + +检测根文件系统 `/` 是否被内核切成只读。如果出现只读状态,产生严重告警: ```text -Interface Options -→ I2C -→ Enable +FILESYSTEM RO +ROOT READ ONLY +CHECK STORAGE ``` -然后: +### 3. 本机诊断信息 + +内部维护: + +- uptime +- load average +- CPU 当前频率 +- 当前/历史 throttled flags +- 当前主网络接口 / IP +- 根文件系统只读状态 +- sensor / network / service / health 最近成功时间 +- 主循环异常计数 +- I²C相关异常计数基础字段 + +### 4. DisplayController、OLED 与 RGB + +每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个最高优先级 owner;同一 ID 更新原请求而不重复入队,恢复时删除请求,同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 + +### 5. RGB 告警引擎 + +RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭;没有 active alert 时 OFF。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 + +### 6. 结构化事件日志 + +日志继续交给 systemd journal,不额外制造长期 `.log` 文件。 + +只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如: + +```text +level=INFO event=START version=3.3.0 +level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ... +level=WARN event=NETWORK_DOWN ... +level=INFO event=NETWORK_RECOVERED ... +level=WARN event=WATCH_DOWN name=AWESUN ... +level=INFO event=WATCH_RECOVERED name=AWESUN ... +level=WARN event=POWER_LOW raw=0x1 +level=INFO event=POWER_RECOVERED raw=0x0 +level=WARN event=ALERT_ACTIVE alert=TEMP_HIGH ... +level=INFO event=ALERT_RECOVERED ... +``` + +实时查看: ```bash -sudo reboot +journalctl -u pigway-pi-control -f ``` -### 第二步:解压本安装包 - -进入解压后的目录: +只看本次启动: ```bash -cd pigway-pi-control-v3.2.0 +journalctl -u pigway-pi-control -b --no-pager ``` -### 第三步:安装 +搜索某类事件: + +```bash +journalctl -u pigway-pi-control --no-pager | grep 'event=POWER' +``` + +### 7. 诊断命令 + +```bash +sudo /usr/local/sbin/pigway-pi-control --diagnose +``` + +输出 Agent 版本、系统 uptime、配置路径、load、CPU 频率、根文件系统、全部当前/历史 power 位、主网络接口、完整 IP 和 Internet 状态,以及所有 service/process watch 状态。 + +诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。 + +RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜色配合低频亮灭最稳定;每次点亮完整写入 selector/R/G/B,寄存器间隔默认10ms。旧版 `brightness`、呼吸周期和颜色选项可以继续留在用户配置中,但频闪引擎会忽略它们。 + +### 8. 关机收尾 + +收到 systemd SIGTERM / 系统关机时: + +1. 写 STOP 日志 +2. RGB OFF +3. 风扇 OFF +4. OLED 清屏并 Display OFF +5. 关闭 I²C handle + +Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。 + +## 用户配置 + +正式配置只有一个: + +```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 +161,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 +- 完整保留用户现有配置(包括所有 section、用户值和注释),仅向 `[led]` 补入缺失的 RGB 颜色与频闪选项,已有用户值优先 +- 启动服务 +- 执行版本、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.5.0:System Health、显示仲裁与可配置 RGB 告警** +- 后续:只读 `/api/v1/...` Agent API,再由 NAS 统一汇总多台设备 diff --git a/SHA256SUMS b/SHA256SUMS index f4f13d3..63e3e8a 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md +dfe901d5f7e3a8ef897203c38cf9e4d47eca63e0d718f74b99102c874edfc3b3 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py -48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf -7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c 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 d266471..0b91c5e 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 time,socket,subprocess,configparser,signal,re,errno,threading,argparse from pathlib import Path -APP_VERSION="3.2.0" +APP_VERSION="3.5.0" from smbus2 import SMBus from PIL import Image from shutil import disk_usage @@ -15,9 +14,53 @@ def stop(*_): global running; running=False signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop) -bus=SMBus(1) +def log(level,event,**fields): + parts=[f"level={level}",f"event={event}"] + for k,v in fields.items(): parts.append(f"{k}={str(v).replace(chr(32),'_')}") + print(" ".join(parts),flush=True) + +def uptime_seconds(): + try:return int(float(Path("/proc/uptime").read_text().split()[0])) + except Exception:return 0 + +def load_average(): + try: + x=Path("/proc/loadavg").read_text().split();return (float(x[0]),float(x[1]),float(x[2])) + except Exception:return (0.0,0.0,0.0) + +def cpu_freq_mhz(): + for q in ("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq","/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq"): + try:return int(Path(q).read_text().strip())/1000.0 + except Exception:pass + return 0.0 + +def root_readonly(): + try: + for line in Path("/proc/mounts").read_text().splitlines(): + a=line.split() + if len(a)>=4 and a[1]=="/":return "ro" in a[3].split(",") + except Exception:pass + return False + +def throttled_state(): + raw=_run(["vcgencmd","get_throttled"]) + m=re.search(r"0x([0-9a-fA-F]+)",raw);v=int(m.group(1),16) if m else 0 + if not m: return {"raw":"UNKNOWN","error":"vcgencmd get_throttled unavailable or invalid"} + return {"raw":f"0x{v:x}","under_voltage_now":bool(v&1),"freq_capped_now":bool(v&2),"throttled_now":bool(v&4),"soft_temp_limit_now":bool(v&8),"under_voltage_occurred":bool(v&(1<<16)),"freq_capped_occurred":bool(v&(1<<17)),"throttled_occurred":bool(v&(1<<18)),"soft_temp_limit_occurred":bool(v&(1<<19))} + +bus=None +bus_lock=threading.RLock() FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin" -FONT5=Path(FONT_PATH).read_bytes() +FONT5=b"" +agent={"loop_errors":0,"i2c_errors":0} + +def bus_write(method,*args): + with bus_lock: + try: return getattr(bus,method)(*args) + except OSError: + agent["i2c_errors"]+=1 + raise + fan_level=0 prev_cpu=None page=0; page_since=time.monotonic() @@ -28,7 +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, @@ -67,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 @@ -78,16 +121,6 @@ def rgb_off(): mcu(0x07,0x00) # seller closeRGB(): true OFF _last_rgb=("off",) -def rgb(r,g,b): - global _last_rgb - scale=max(0,min(100,LED_BRIGHT))/100.0 - state=(int(r*scale),int(g*scale),int(b*scale)) - if _last_rgb==state: return - mcu(0x07,0x00) # stop built-in effect first - mcu(0x00,0xff) # seller protocol: >=3 => all three LEDs - mcu(0x01,state[0]); mcu(0x02,state[1]); mcu(0x03,state[2]) - _last_rgb=state - def set_pi_led(name,off=True): base=Path("/sys/class/leds")/name try: @@ -135,8 +168,8 @@ def mem_pct(): return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"] def disk_pct(): d=disk_usage("/"); return 100*d.used/d.total -def _run(args): - try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=2).strip() +def _run(args,timeout=2): + try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip() except Exception: return "" def primary_network(): @@ -160,7 +193,6 @@ def primary_network(): n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip()) metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n) except Exception: pass - if label=="IP6" and len(ipval)>18: ipval=ipval[:7]+".."+ipval[-7:] return iface,kind,metric,label,(ipval or "NO IP") def get_ip(): @@ -194,6 +226,7 @@ OLED_REFRESH=cf("timing","oled_refresh_interval",1.0) PAGE_INTERVAL=cf("timing","page_interval",10.0) NETWORK_INTERVAL=cf("timing","network_interval",5.0) SERVICE_INTERVAL=cf("timing","service_interval",5.0) +HEALTH_INTERVAL=cf("timing","health_interval",1.0) CPU_HIGH=cf("alerts","cpu_high",90.0) CPU_RECOVER=cf("alerts","cpu_recover",75.0) @@ -215,39 +248,147 @@ FAN_HYST=cf("fan","hysteresis",1.5) ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","row3_y",16,int),cf("display","row4_y",24,int)] DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no")) DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no")) -LED_BRIGHT=cf("led","brightness",10,int) -LED_WARN_ON=cf("led","warning_on_ms",300,int); LED_WARN_OFF=cf("led","warning_off_ms",2700,int) -LED_CRIT_ON=cf("led","critical_on_ms",400,int); LED_CRIT_OFF=cf("led","critical_off_ms",1100,int) -LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emergency_off_ms",400,int) +RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) +WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int))) +CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,int))) +EMERGENCY_FLASH=(max(50,cf("led","emergency_flash_on_ms",300,int)),max(50,cf("led","emergency_flash_off_ms",300,int))) + +def rgb_cfg(name,default): + return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int))) + for channel,value in zip(("r","g","b"),default)) + +RGB_GROUP_VALUES={ + "CPU":rgb_cfg("cpu",(255,0,0)), + "POWER":rgb_cfg("power",(255,40,0)), + "MEMORY":rgb_cfg("memory",(255,0,255)), + "STORAGE":rgb_cfg("storage",(255,255,255)), + "NETWORK":rgb_cfg("network",(0,0,255)), + "SERVICE":rgb_cfg("service",(0,255,96)), +} +RGB_OBJECT_GROUPS={ + "ROOT_READONLY":"STORAGE", + "TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU", + "THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU", + "POWER_UNDERVOLT":"POWER", + "MEMORY_HIGH":"MEMORY", + "DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE", + "NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK", + "SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE", +} def check_watch(w): typ,label,target=w - if typ=="service": - r=subprocess.run(["systemctl","is-active","--quiet",target]) - else: - r=subprocess.run(["pgrep","-f",target],stdout=subprocess.DEVNULL) - return r.returncode==0 + args=["systemctl","is-active","--quiet",target] if typ=="service" else ["pgrep","-f","--",target] + try: + r=subprocess.run(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=2) + return r.returncode==0 + except (OSError,subprocess.TimeoutExpired): + return False -def alert(title,l2="",l3="",l4="",sev=1,color=(20,16,0),blink=0): - return {"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21], - "sev":sev,"color":color,"blink":blink} -def make_alerts(cpu,temp,mem,disk): +def diagnose(): + print("Agent:") + print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}") + print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process") + print("System:") + print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}") + print("Power:") + power=throttled_state() + print("throttled="+power["raw"]) + for key in ("under_voltage_now","freq_capped_now","throttled_now","soft_temp_limit_now", + "under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"): + print(f"{key}={power.get(key,'UNKNOWN')}") + if "error" in power: print("error="+power["error"]) + iface,kind,metric,label,address=primary_network() + print("Network:") + print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} ip_label={label} IP={address} internet={internet()}") + print("I2C:") + # SMBus receive-byte performs a read only; no register selection or display commands. + try: + with SMBus(1) as probe: + for address,name in ((MCU,"MCU"),(OLED,"OLED")): + try: + probe.read_byte(address) + print(f"0x{address:02X} {name}=PRESENT") + except OSError as e: + status="MISSING" if e.errno in (errno.ENXIO,getattr(errno,"EREMOTEIO",121)) else "ERROR" + print(f"0x{address:02X} {name}={status} error={e}") + except OSError as e: + for address,name in ((MCU,"MCU"),(OLED,"OLED")): + print(f"0x{address:02X} {name}=ERROR error={e}") + print("Watches:") + for w in watches: + print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}") + return 0 + + +DISPLAY_PRIORITIES={ + "ROOT_READONLY":100, + "TEMP_CRITICAL":90, + "SOFT_TEMP_LIMIT":85, + "POWER_UNDERVOLT":80, + "THROTTLED":75, + "FREQ_CAPPED":70, + "NETWORK_DOWN":65, + "INTERNET_DOWN":65, + "SERVICE_DOWN":60, + "PROCESS_DOWN":60, + "DISK_CRITICAL":55, + "TEMP_HIGH":50, + "CPU_HIGH":45, + "MEMORY_HIGH":45, + "DISK_HIGH":40, + "WIFI_WEAK":30, + "NORMAL_HOME":0, +} + +def display_priority(request_id): + return DISPLAY_PRIORITIES[request_id.split("|",1)[0]] + +def transition(key,active,on_event,off_event,**fields): + old=event_state.get(key,False) + if active!=old: + event_state[key]=active + log("WARN" if active else "INFO",on_event if active else off_event,**fields) + +def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): global wifi_weak_since,wifi_weak_active global cpu_hi,mem_hi - a=[] - cpu_hi=cpu_hi+1 if cpu>=CPU_HIGH else 0 if cpu=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)) + cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem=CPU_HIGH_SEC: display.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD") + else: display.clear("CPU_HIGH") + if temp>=TEMP_CRIT: + display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") + display.clear("TEMP_HIGH") + elif temp>=TEMP_HIGH: + display.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") + display.clear("TEMP_CRITICAL") + else: + display.clear("TEMP_HIGH"); display.clear("TEMP_CRITICAL") + if mem_hi>=MEM_HIGH_SEC: display.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","") + else: display.clear("MEMORY_HIGH") + if disk>=DISK_CRIT: + display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") + display.clear("DISK_HIGH") + elif disk>=DISK_HIGH: + display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE") + display.clear("DISK_CRITICAL") + else: + display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL") + if not link_ok: + display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","") + display.clear("INTERNET_DOWN") + elif not net_ok: + display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"") + display.clear("NETWORK_DOWN") + else: + display.clear("NETWORK_DOWN"); display.clear("INTERNET_DOWN") for w,ok in zip(watches,watch_state): - if not ok:a.append(alert("SERVICE DOWN",w[1],"CHECK / RESTART","",2,(0,24,24),1)) + alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1] + if not ok: + display.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]}) + else: display.clear(alert_id) if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit(): rssi=float(net_metric) if not wifi_weak_active: @@ -258,33 +399,57 @@ def make_alerts(cpu,temp,mem,disk): elif rssi >= WIFI_RECOVER: wifi_weak_active=False; wifi_weak_since=None if wifi_weak_active: - a.append(alert("WIFI SIGNAL WEAK",f"{rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL",1,(0,0,28),1)) + display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL") + else: display.clear("WIFI_WEAK") else: - wifi_weak_since=None; wifi_weak_active=False - a.sort(key=lambda x:x["sev"],reverse=True) - return a + wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK") + if health.get("under_voltage_now"): + display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")}) + else: display.clear("POWER_UNDERVOLT") + if health.get("throttled_now"): + display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") + else: display.clear("THROTTLED") + if health.get("freq_capped_now"): + display.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") + else: display.clear("FREQ_CAPPED") + if health.get("soft_temp_limit_now"): + display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING") + else: display.clear("SOFT_TEMP_LIMIT") + if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY") + else: display.clear("ROOT_READONLY") -def led_for(a): - # Color = fault object. Blink frequency = severity. All alerts blink. - if not a: - rgb_off(); return - if a["sev"]>=3: on_ms,off_ms=LED_EMERG_ON,LED_EMERG_OFF - elif a["sev"]>=2: on_ms,off_ms=LED_CRIT_ON,LED_CRIT_OFF - else: on_ms,off_ms=LED_WARN_ON,LED_WARN_OFF - cycle=max(1,on_ms+off_ms) - phase=int(time.monotonic()*1000)%cycle - rgb(*a["color"]) if phase=3 else "CRITICAL" if severity>=2 else "WARNING" + return f"{color}_{level}_FLASH" +def rgb_flash(mode,on): + global _last_rgb + color=mode.split("_",1)[0] + channels=RGB_GROUP_VALUES[color] + state=(mode,bool(on)) + if _last_rgb==state: return + if not on: + mcu(0x07,0x00) + else: + mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x03,channels[2]) + _last_rgb=state def render_home(cpu,temp,mem,disk): # 128x32 fixed columns. Both columns are LEFT-aligned. # Seller 5x7 font has 6px character pitch; right column starts at x=72. im=Image.new("1",(W,H),0); pix=im.load() + display_ip=ip[:7]+".."+ip[-7:] if ip_label=="IP6" and len(ip)>18 else ip left=[ f"CPU {cpu:.1f}%", f"TMP {temp:.1f}C", f"DSK {disk:.1f}%", - ip_label+" "+ip, + ip_label+" "+display_ip, ] right=[ f"MEM {mem:.1f}%", @@ -306,108 +471,269 @@ def render_home(cpu,temp,mem,disk): data.append(v) cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3) for i in range(0,len(data),16): - bus.write_i2c_block_data(OLED,0x40,data[i:i+16]) + bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16]) -def render_alert(a,idx,total): - oled_show([(a["title"],f"{idx}/{total}"), +def render_alert(a,index,total): + oled_show([(a["title"],f"{index}/{total}"), (a["l2"],""),(a["l3"],""),(a["l4"],"")]) -oled_init(); rgb_off(); apply_dark_mode() -watch_state=[check_watch(w) for w in watches] -last_sensor=last_render=0.0 -last_net=last_watch=time.monotonic() -latest=(0.0,0.0,0.0,0.0) -active=[] - -# Page state is a snapshot queue. Live sensor refreshes NEVER choose another page. -# Each cycle is HOME + every alert captured at the cycle boundary. -cycle_pages=["HOME"] -cycle_pos=0 -page_started=time.monotonic() -current_alert_key=None - -def alert_key(a): - # Stable identity: title + monitored object/primary detail. - return f"{a['title']}|{a['l2']}" - -def find_live_alert(key, live): - for a in live: - if alert_key(a)==key: return a - return None - -while running: - try: +class DisplayController: + def __init__(self): now=time.monotonic() + self.lock=threading.RLock() + self.requests={"NORMAL_HOME":{ + "id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":"OFF", + "title":"","l2":"","l3":"","l4":"","active":True, + "first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)}, + }} + self.current_owner_id="NORMAL_HOME" + self.current_priority=0 + self.current_rgb_mode="OFF" + self.owner_since=now + self.current_oled_page_id="NORMAL_HOME" + self.page_started=now + self.batch_depth=0 + self.stop_event=threading.Event() + self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True) + def start(self): + oled_init(); self.refresh(); rgb_off(); self.rgb_thread.start() - # Independent clocks: update data/state only. - if now-last_sensor >= SENSOR_INTERVAL: - c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct() - latest=(c,t,m,d); update_fan(t); last_sensor=now + def update_home(self,cpu,temp,mem,disk): + with self.lock: + request=self.requests["NORMAL_HOME"] + values=(cpu,temp,mem,disk) + if request["metadata"].get("home")!=values: + request["metadata"]={"home":values}; request["last_changed"]=time.monotonic() - if now-last_net >= NETWORK_INTERVAL: - net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False - last_net=now - - if now-last_watch >= SERVICE_INTERVAL: - watch_state=[check_watch(w) for w in watches] - last_watch=now - - c,t,m,d=latest - active=make_alerts(c,t,m,d) - - # If the currently displayed alert truly disappeared, skip it immediately. - if cycle_pages[cycle_pos]!="HOME": - key=cycle_pages[cycle_pos] - if find_live_alert(key,active) is None: - cycle_pos += 1 - if cycle_pos>=len(cycle_pages): - # End of cycle: capture a fresh snapshot. - cycle_pages=["HOME"]+[alert_key(a) for a in active] - cycle_pos=0 - page_started=now - - # ONLY this timer performs normal page changes. - if now-page_started >= PAGE_INTERVAL: - cycle_pos += 1 - if cycle_pos>=len(cycle_pages): - # A complete cycle ended. Snapshot all currently active alerts. - cycle_pages=["HOME"]+[alert_key(a) for a in active] - cycle_pos=0 - page_started=now - - # Special case: while idle on HOME with no alert pages queued, begin a new - # alert cycle immediately when the first alert appears. HOME keeps its - # current 10-second slot; alerts follow it and cannot be kicked out by refresh. - if cycle_pages==["HOME"] and active: - cycle_pages=["HOME"]+[alert_key(a) for a in active] - - # Render CURRENT page only. 1-second refresh does not affect cycle_pos. - if now-last_render >= OLED_REFRESH: - current=cycle_pages[cycle_pos] - if current=="HOME": - render_home(c,t,m,d) + def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None): + now=time.monotonic(); priority=display_priority(request_id) + request={"id":request_id,"priority":priority,"severity":severity, + "rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21], + "l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True, + "last_changed":now,"metadata":metadata or {}} + with self.lock: + current=self.requests.get(request_id) + if current is None: + request["first_seen"]=now; self.requests[request_id]=request + log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2) else: - a=find_live_alert(current,active) - if a: - # Number refers to this cycle's alert slot, all using same 6x8 font. - render_alert(a,cycle_pos,len(cycle_pages)-1) - else: - render_home(c,t,m,d) - last_render=now + request["first_seen"]=current["first_seen"] + changed=any(current.get(k)!=request.get(k) for k in + ("priority","severity","rgb_mode","title","l2","l3","l4","metadata")) + if not changed: request["last_changed"]=current["last_changed"] + self.requests[request_id]=request + if self.batch_depth==0: + self._arbitrate_locked() + self._sync_rgb_locked() - # LED follows OLED page; HOME reflects highest currently active risk. - current=cycle_pages[cycle_pos] - if current=="HOME": - led_for(active[0] if active else None) + def clear(self,request_id): + if request_id=="NORMAL_HOME": return + with self.lock: + if self.requests.pop(request_id,None) is not None: + log("INFO","ALERT_RECOVERED",alert=request_id) + if self.batch_depth==0: + self._arbitrate_locked() + if self.current_oled_page_id not in self.requests: + self._show_page_locked(self.current_owner_id) + else: self._sync_rgb_locked() + + def begin_update(self): + with self.lock: self.batch_depth+=1 + + def end_update(self): + with self.lock: + if self.batch_depth<=0: raise RuntimeError("display update batch imbalance") + self.batch_depth-=1 + if self.batch_depth==0: + self._arbitrate_locked() + if self.current_oled_page_id not in self.requests: + self._show_page_locked(self.current_owner_id) + else: self._sync_rgb_locked() + + def _arbitrate_locked(self): + current=self.requests.get(self.current_owner_id) + ordered=sorted(self.requests.values(),key=lambda r:(-r["priority"],r["first_seen"],r["id"])) + candidate=ordered[0] + if current is not None and candidate["priority"]<=current["priority"]: return + self._change_owner_locked(candidate) + + def _change_owner_locked(self,request): + old=self.current_owner_id + if request["id"]==old: return + self.current_owner_id=request["id"] + self.current_priority=request["priority"] + self.current_rgb_mode=request["rgb_mode"] + self.owner_since=time.monotonic() + log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"]) + self._show_page_locked(request["id"]) + + def _ordered_alerts_locked(self): + return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"), + key=lambda r:(-r["priority"],r["first_seen"],r["id"])) + + def _show_page_locked(self,page_id): + alerts=self._ordered_alerts_locked() + request=self.requests.get(page_id,self.requests["NORMAL_HOME"]) + self.current_oled_page_id=request["id"] + self.page_started=time.monotonic() + self._sync_rgb_locked() + if request["id"]=="NORMAL_HOME": + render_home(*request["metadata"]["home"]) else: - a=find_live_alert(current,active) - led_for(a if a else (active[0] if active else None)) + alert_ids=[a["id"] for a in alerts] + render_alert(request,alert_ids.index(request["id"])+1,len(alerts)) - time.sleep(0.20) - except Exception as e: - print("loop error:",e,flush=True); time.sleep(1) + def _sync_rgb_locked(self): + # Alert pages show that alert's object color. HOME keeps the highest- + # priority active owner visible instead of briefly switching RGB off. + rgb_request=self.requests.get(self.current_oled_page_id) + if self.current_oled_page_id=="NORMAL_HOME": + rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"]) + if rgb_request is None: + rgb_request=self.requests["NORMAL_HOME"] + self.current_rgb_mode=rgb_request["rgb_mode"] -rgb_off(); mcu(0x08,0x00); bus.close() + def refresh(self): + with self.lock: + alerts=self._ordered_alerts_locked() + pages=["NORMAL_HOME"]+[a["id"] for a in alerts] + if self.current_oled_page_id not in pages: + self._show_page_locked(self.current_owner_id) + return + if len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL: + pos=pages.index(self.current_oled_page_id) + self._show_page_locked(pages[(pos+1)%len(pages)]) + return + request=self.requests[self.current_oled_page_id] + if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"]) + else: + alert_ids=[a["id"] for a in alerts] + render_alert(request,alert_ids.index(request["id"])+1,len(alerts)) + + def owner_snapshot(self): + with self.lock: return dict(self.requests[self.current_owner_id]) + + def _rgb_worker(self): + previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic() + while not self.stop_event.is_set(): + try: + with self.lock: wanted_mode=self.current_rgb_mode + wanted_flash=wanted_mode.endswith("_FLASH") + if wanted_mode!=applied_mode: + with bus_lock: + if wanted_mode=="OFF": rgb_off() + elif wanted_flash: rgb_flash(wanted_mode,True) + else: raise ValueError(f"unsupported RGB mode: {wanted_mode}") + log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) + applied_mode=wanted_mode; animation_started=time.monotonic() + applied_flash=applied_mode.endswith("_FLASH") + if applied_flash: + elapsed_ms=(time.monotonic()-animation_started)*1000.0 + if "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH + elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH + else: on_ms,off_ms=WARNING_FLASH + on=elapsed_ms%(on_ms+off_ms)= SENSOR_INTERVAL: + sample_elapsed=now-last_sensor if last_sensor else 0.0 + c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct() + latest=(c,t,m,d); update_fan(t); last_sensor=now; agent["last_sensor_ok"]=now + + if now-last_net >= NETWORK_INTERVAL: + old_id=(net_iface,ip) + net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False + new_id=(net_iface,ip) + if new_id!=old_id: + log("INFO","NET_SWITCH",old_if=old_id[0],old_ip=old_id[1],new_if=net_iface,new_ip=ip) + transition("network_down",not link_ok,"NETWORK_DOWN","NETWORK_RECOVERED",iface=net_iface,ip=ip) + transition("internet_down",link_ok and not net_ok,"INTERNET_DOWN","INTERNET_RECOVERED",iface=net_iface,ip=ip) + last_net=now; agent["last_network_ok"]=now + + if now-last_watch >= SERVICE_INTERVAL: + watch_state=[check_watch(w) for w in watches] + for w,ok in zip(watches,watch_state): + transition("watch:"+w[0]+":"+w[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2]) + last_watch=now; agent["last_watch_ok"]=now + + if now-last_health >= HEALTH_INTERVAL: + power_sample=throttled_state(); fs_readonly=root_readonly() + transition("power_check", "error" in power_sample,"POWER_CHECK_ERROR","POWER_CHECK_RECOVERED") + # An unavailable reading is not evidence that an existing fault recovered. + if "error" not in power_sample: health=power_sample + transition("undervoltage",health.get("under_voltage_now",False),"POWER_LOW","POWER_RECOVERED",raw=health.get("raw")) + transition("throttled",health.get("throttled_now",False) or health.get("freq_capped_now",False),"THROTTLED","THROTTLE_RECOVERED",raw=health.get("raw"),freq_mhz=f"{cpu_freq_mhz():.0f}") + transition("soft_temp",health.get("soft_temp_limit_now",False),"SOFT_TEMP_LIMIT","SOFT_TEMP_RECOVERED",raw=health.get("raw")) + transition("root_ro",fs_readonly,"FILESYSTEM_RO","FILESYSTEM_RW") + for k in ("under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"): + if health.get(k) and k not in history_logged: + history_logged.add(k); log("WARN","HISTORY_FLAG",flag=k,raw=health.get("raw")) + agent["last_health_ok"]=now; last_health=now + + c,t,m,d=latest + display.update_home(c,t,m,d) + display.begin_update() + try: update_active_alerts(c,t,m,d,sample_elapsed) + finally: display.end_update() + if now-last_render >= OLED_REFRESH: + display.refresh() + last_render=now + + time.sleep(0.20) + except Exception as e: + agent["loop_errors"]+=1; log("ERROR","LOOP_ERROR",count=agent["loop_errors"],error=repr(e)); time.sleep(1) +finally: + cleanup() diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 8ce3a7f..3561691 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.5.0 - 用户配置文件 # ============================================================================= # # 正式安装后的配置路径: @@ -36,8 +36,8 @@ sensor_interval = 1 # 推荐:1。 oled_refresh_interval = 1 -# HOME / 各告警页面的轮播停留时间,单位:秒。 -# 推荐:10。觉得切换太慢可改为 5~8。 +# OLED 在主页和当前告警页之间轮播的周期,单位:秒。 +# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。 page_interval = 10 # 网络状态检查周期,单位:秒。 @@ -48,6 +48,11 @@ network_interval = 5 # 推荐:5。 service_interval = 5 +# 本机健康检查周期,单位:秒。 +# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。 +# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。 +health_interval = 1 + [fan] # 风扇温控档位,单位:摄氏度。 @@ -121,54 +126,65 @@ disk_high = 90 disk_critical = 95 # ---------------- Wi-Fi 信号 ---------------- -# 仅当 Wi-Fi 是 Linux 当前默认路由/主网络接口时生效。 -# -# RSSI 单位为 dBm,越接近 0 表示信号越强: -# -50 左右:很好 -# -60 左右:良好 -# -70 左右:一般 -# -80 以下:较弱 -# -# 连续低于该值达到 wifi_weak_seconds 后触发蓝色网络告警。 +# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。 +# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。 +# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。 wifi_weak = -75 -# 已触发弱信号告警后,必须恢复到该值以上才解除。 -# 与 wifi_weak 留出差值可避免信号在临界值附近反复报警。 +# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。 wifi_recover = -68 -# 弱信号必须持续多少秒才报警,避免瞬时 Wi-Fi 波动产生误报。 +# 弱信号持续多少秒才正式告警。 wifi_weak_seconds = 30 [led] -# RGB 规则: -# 正常状态 = 完全熄灭 -# 颜色 = 哪一类对象发生故障 -# 闪烁频率 = 严重程度,越严重闪得越快 +# DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示 +# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级: +# CPU/频率/温度/散热 = 红色 +# 供电/欠压 = 橙色闪烁(实机校准,避免内置黄色偏绿) +# 内存 = 紫色 +# 磁盘 = 白色 +# 网络/Wi-Fi = 蓝色 +# 服务/进程 = 青色 +# 颜色通道范围为 0..255。以下是本机实测后的默认值。 +cpu_r = 255 +cpu_g = 0 +cpu_b = 0 +power_r = 255 +power_g = 40 +power_b = 0 +memory_r = 255 +memory_g = 0 +memory_b = 255 +storage_r = 255 +storage_g = 255 +storage_b = 255 +network_r = 0 +network_g = 0 +network_b = 255 +service_r = 0 +service_g = 255 +service_b = 96 + +# 各严重等级可分别设置亮灯和灭灯时长,单位:毫秒,最小值 50。 +# 默认完整周期:Warning 3秒、Critical 1.5秒、Emergency 0.6秒。 +warning_flash_on_ms = 1500 +warning_flash_off_ms = 1500 +critical_flash_on_ms = 750 +critical_flash_off_ms = 750 +emergency_flash_on_ms = 300 +emergency_flash_off_ms = 300 +# NORMAL_HOME 为 owner 时 RGB OFF。 # -# 当前颜色语义: -# CPU负载 = 黄 -# 温度/散热 = 红 -# 内存 = 紫 -# 磁盘 = 橙 -# 网络 = 蓝 -# 服务/进程 = 青 -# -# brightness 是 RGB 最大输出百分比(0~100)。 -# 3颗 RGB 会一起亮,卧室建议 5~15。 -brightness = 10 +# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序, +# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。 +write_delay_ms = 10 -# Warning:慢闪。以下单位均为毫秒。 -warning_on_ms = 300 -warning_off_ms = 2700 - -# Critical:中速闪烁。 -critical_on_ms = 400 -critical_off_ms = 1100 - -# Emergency:快速闪烁。 -emergency_on_ms = 400 -emergency_off_ms = 400 +# v3.3.0 RC 期间使用过的 brightness、*_breathe_seconds、rgb_update_ms、 +# warning_color、critical_color、warning_flash_ms、critical_flash_ms 和 +# emergency_flash_ms +# 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。 [dark_mode] diff --git a/install.sh b/install.sh index 5253e37..db09a74 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,45 +40,70 @@ install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_fo install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control -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" + +# 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/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.2.0" "grep -q 'APP_VERSION=\"3.2.0\"' /usr/local/sbin/pigway-pi-control" -check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control" -check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control" -check "OLED pages 0..3" "grep -q 'cmd(0x22);cmd(0);cmd(3)' /usr/local/sbin/pigway-pi-control" -check "UI CPU/TMP/DSK" "grep -q 'f\"TMP {temp:.1f}C\"' /usr/local/sbin/pigway-pi-control" -check "UI IP4/IP6" "grep -q 'ip_label.*IP4' /usr/local/sbin/pigway-pi-control && grep -q 'label=\"IP6\"' /usr/local/sbin/pigway-pi-control" -check "UI right column x=72" "grep -q \"draw_text_5x7(pix,72,y,right\\[row\\])\" /usr/local/sbin/pigway-pi-control" -check "Legacy service stopped" "! systemctl is-active --quiet temp-control.service" -check "Service running" "systemctl is-active --quiet pigway-pi-control.service" +check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; } +APP=/usr/local/sbin/pigway-pi-control +check "Version 3.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" +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] 状态..."