Compare commits

...
Author SHA1 Message Date
way 2ec34bbd57 docs: present standalone monitoring and connected plugins 2026-09-27 21:05:43 +08:00
way 33e90eb535 docs: generalize plugin capability guidance 2026-09-27 21:02:52 +08:00
way c106b4c264 docs: clarify autonomous plugin telemetry and lease expiry 2026-09-27 20:54:06 +08:00
way 52f56eefc5 fix: show plugin refresh results and startup guidance 2026-09-27 20:33:28 +08:00
way 849fd016b6 fix: restore CPU meter and explain unavailable fan status 2026-09-27 20:31:29 +08:00
way 993d95bab1 refactor: separate monitoring from hardware plugins 2026-09-27 20:24:34 +08:00
way a976dfa3fa fix: quench RGB after fan register writes 2026-09-27 15:19:05 +08:00
way ae8f756749 fix: isolate fan writes from active RGB frames 2026-09-27 15:16:10 +08:00
way c3ed599198 refactor: isolate OLED and RGB workers 2026-09-27 15:07:16 +08:00
way 38362be824 fix: stabilize RGB startup and color transitions 2026-09-27 15:03:15 +08:00
way 2f82cc36b4 style: reorganize event log filters 2026-09-27 14:58:21 +08:00
way 260d8fad8f style: refine language and appearance controls 2026-09-27 14:55:03 +08:00
way 9babcfabc1 fix: preserve device hover across status refreshes 2026-09-27 14:50:35 +08:00
way 152ecc6e2b feat: add independent batch configuration sync 2026-09-27 14:48:03 +08:00
way a96cdb859d fix: align configuration target controls 2026-09-27 14:39:21 +08:00
way e2cec738c8 style: flatten inner cards and lift devices on hover 2026-09-27 14:37:36 +08:00
way f505f3c23b merge: multi-host monitoring and configuration 2026-09-27 14:34:46 +08:00
way b7a77a4661 fix: polish themes and language consistency 2026-09-27 14:29:20 +08:00
way 9824c54d6b feat: add multi-host monitoring and configuration 2026-09-27 14:20:38 +08:00
way 974121de99 merge: bilingual interface and appearance modes 2026-09-27 13:59:12 +08:00
way 5d744824a8 feat: add bilingual interface and appearance modes 2026-09-27 13:59:11 +08:00
way b08721fd15 Merge fix/monitor-pause-rgb-edges 2026-09-27 13:49:10 +08:00
way cd9bdd5970 fix: separate monitor pause and stabilize RGB edges 2026-09-27 13:49:09 +08:00
way 08c00aa370 Merge feature/log-query 2026-09-27 13:42:59 +08:00
way 13f6155096 feat: add searchable event log history 2026-09-27 13:42:58 +08:00
way 97598a48ad Merge fix/service-controls-layout 2026-09-27 13:29:12 +08:00
way 01c07658c0 fix: refine service monitor controls 2026-09-27 13:29:11 +08:00
way 4ba4d461db Merge fix/web-rgb-alignment 2026-09-27 13:24:07 +08:00
way 45c7b8c168 fix: align RGB color controls 2026-09-27 13:24:06 +08:00
way b39c7e5279 Merge feature/web-controls 2026-09-27 11:20:46 +08:00
way 2e5562fb3e feat: improve web configuration controls 2026-09-27 11:20:45 +08:00
way 03fd6d1cd8 Merge feature/web-config-ui 2026-09-27 11:00:42 +08:00
way 8cb71cbe9f feat: reorganize and localize web configuration 2026-09-27 11:00:41 +08:00
way 73907320c8 Merge fix/rgb-effect-commit-order 2026-09-27 10:46:20 +08:00
way ad25c08eb1 fix: commit MCU RGB effects in calibrated order 2026-09-27 10:46:18 +08:00
way 5d541d5ef6 Merge feature/normal-rgb 2026-09-27 10:36:33 +08:00
way a8ca071b56 feat: add configurable normal RGB effects 2026-09-27 10:36:32 +08:00
way 419d27b570 Merge fix/pigway-brand-force-install 2026-09-27 10:29:02 +08:00
way 73a60b4866 fix: standardize PIGWay branding and conflict override 2026-09-27 10:29:01 +08:00
way 576006b2fd Merge fix/i2c-owner-preflight 2026-09-27 10:25:57 +08:00
way 671fde1bfc fix: detect conflicting I2C controllers 2026-09-27 10:25:56 +08:00
way 99e086bd25 Merge feature/api-web for v3.6.0 2026-09-27 10:22:51 +08:00
way f42ea9d4bd feat: add lightweight API and web console 2026-09-27 10:21:12 +08:00
way fd4e2bf0f4 Merge feature/system-health for v3.5.0 2026-09-27 10:08:09 +08:00
way 5402dddd15 chore: release v3.5.0 2026-09-27 10:07:59 +08:00
way 6ca19b362b feat: configure RGB colors and flash timing 2026-09-27 10:04:15 +08:00
way adfa5524b8 refactor: use calibrated RGB alert flashes 2026-09-27 10:00:05 +08:00
way ac9bc4befd fix: align RGB colors with fault domains 2026-09-27 09:54:45 +08:00
way ad22dcced7 feat: distinguish RGB alerts by fault type 2026-09-27 09:47:03 +08:00
way 249af5a3e3 fix: use calibrated MCU RGB effects 2026-09-27 09:25:19 +08:00
way a84f4895ef fix: arbitrate display requests atomically 2026-09-26 23:01:15 +08:00
way 6fc428ef18 refactor: add single-owner display arbitration 2026-09-26 22:57:10 +08:00
way 34f334dd23 fix: unify active alert and RGB behavior 2026-09-26 22:35:23 +08:00
way d3ccca4b37 fix: redesign RGB alert engine 2026-09-26 22:24:43 +08:00
way 3a302b9c78 fix: harden P0 health monitoring RC2 2026-09-26 17:10:53 +08:00
way 83ec3f4379 fix: restore OLED font asset 2026-09-26 16:41:59 +08:00
way 2ef8007ccd feat: add P0 system health monitoring 2026-09-26 16:36:45 +08:00
13 changed files with 1349 additions and 819 deletions
+41 -361
View File
@@ -1,385 +1,65 @@
# Pigway Pi Control v3.2.0 — Release # PIGWay Pi Control v3.7.0
适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。 独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理平台。提供本机状态、告警、事件日志和多机配置管理,可按需接入独立插件。
本包遵循一个原则: ## 功能
> **一个程序 + 一个用户配置文件 + 一个说明文档。** - CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。
- systemd 服务及进程监控;暂停监控与启停服务是不同操作。
- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。
- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。
- 事件日志按日期、严重程度、事件类型和机器查询、排序。
- 多机状态、机器标识、配置与独立的批量配置同步。
- 中英文、跟随系统/深色/浅色主题。
- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。
正常使用时,不需要修改源码。 ## 安装
---
## 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
IP4 192.168.89.130
```
IPv6 地址时自动显示 `IP6`。
主页左右两栏都是固定位置、左对齐。数据默认每秒刷新。
有告警时,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
```
进入:
```text
Interface Options
→ I2C
→ Enable
```
然后:
```bash
sudo reboot
```
### 第二步:解压本安装包
进入解压后的目录:
```bash
cd pigway-pi-control-v3.2.0
```
### 第三步:安装
```bash ```bash
sudo ./install.sh sudo ./install.sh
# 仅安装文件,不改变服务运行状态:
sudo ./install.sh --no-start
``` ```
安装器会: 仅需要 Python 3。插件独立安装与运行,主程序不会自动安装或启动插件。
配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。
1. 检查 `/dev/i2c-1` API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器显示查询方法,不直接打印秘密:
2. 等待 APT/DPKG 锁
3. 安装必要的小型系统依赖
4. 检查 I²C 总线
5. 自动停止并禁用卖家旧 `temp-control.service`
6. 安装正式程序
7. 安装用户配置
8. 安装 systemd 服务并开机自启
9. 执行安装后自检
安装成功后查看:
```bash ```bash
systemctl status pigway-pi-control --no-pager -l sudo cat /etc/pigway-pi-control-api.token
``` ```
--- 服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。
Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。
## 3. 安装后的文件在哪里 ## 已接入插件与扩展
### 用户应该修改 [Yahboom 散热板插件](https://tea.pigway.com/way/pigway-cooling-hat) 是独立项目、独立安装包及 systemd 服务。
先在目标机器安装并运行插件,再进入 Web → 系统配置 → 硬件插件 → 发现并接入。
仅发现插件不会接管插件的设备能力。接入后发送当前监控状态,无需开放新的网络端口。
只有: 运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不会停止插件的本地功能。
```text 默认 Unix socket:`/run/pigway-plugins/<id>/api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。插件声明自身能力,Web 据此展示状态和配置。其他设备可通过独立插件实现此协议。
/etc/pigway-pi-control.conf
```
编辑: 监控和插件互不依赖:插件离线不影响监控、日志和 Web;监控离线不停止插件的本地功能。通信恢复后同步最新状态,过期告警不会继续显示。
当前可用插件:
| 插件 | 独立功能 | 仓库 |
| --- | --- | --- |
| PIGWay Cooling HAT | 本机状态采集、OLED 展示、风扇温控、RGB 灯效 | [pigway-cooling-hat](https://tea.pigway.com/way/pigway-cooling-hat) |
## 运维
```bash ```bash
sudo nano /etc/pigway-pi-control.conf sudo systemctl status pigway-pi-control.service pigway-pi-control-api.service
sudo journalctl -u pigway-pi-control.service -f
sudo /usr/local/sbin/pigway-pi-control --diagnose
``` ```
保存后: `--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。
卸载监控不会停止或删除独立插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。
```bash 静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。
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
```
停止:
```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 弱信号持续告警。
+11 -7
View File
@@ -1,7 +1,11 @@
ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md f6bab4766d2b14b14572f25bdf98f2e95ae2759c6b96837343f0713b7e137dea README.md
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin 8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh
0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf 8b9df5b92218ff1c809d570141b2568228a1a4bdcb041eca29fcc6b51c61889c app/pigway_pi_control.py
7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh 339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service 3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh 3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf
20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
64c21a4e4d93a68fcca1d0b0126655a56f306a290cced6eed5350ab9d5b5004f web/index.html
73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
Binary file not shown.
+317 -249
View File
@@ -1,24 +1,89 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import re import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
import os,time,socket,subprocess,configparser,signal
from pathlib import Path from pathlib import Path
APP_VERSION="3.2.0" APP_VERSION="3.7.0"
from smbus2 import SMBus import sys
from PIL import Image sys.path.append("/usr/local/share/pigway-pi-control")
from shutil import disk_usage from shutil import disk_usage
from plugin_api import Publisher,registered,discover
OLED=0x3C; MCU=0x0D; W,H=128,32
CFG="/etc/pigway-pi-control.conf" CFG="/etc/pigway-pi-control.conf"
STATUS_PATH="/run/pigway-pi-control/status.json"
running=True running=True
def stop(*_): def stop(*_):
global running; running=False global running; running=False
signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop) signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop)
bus=SMBus(1) def log(level,event,**fields):
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin" parts=[f"level={level}",f"event={event}"]
FONT5=Path(FONT_PATH).read_bytes() for k,v in fields.items(): parts.append(f"{k}={str(v).replace(chr(32),'_')}")
fan_level=0 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 memory_usage():
try:
values={}
for line in Path("/proc/meminfo").read_text().splitlines():
key,value=line.split(":",1); values[key]=int(value.split()[0])*1024
total=values["MemTotal"]; available=values.get("MemAvailable",values.get("MemFree",0))
used=max(0,total-available)
return total,used,available,(used*100.0/total if total else 0.0)
except Exception:return 0,0,0,0.0
def wifi_percent(metric):
try:return max(0,min(100,round((float(metric)+100.0)*2.0)))
except Exception:return None
def device_details():
info={}
try:
for line in Path("/proc/cpuinfo").read_text().splitlines():
if ":" in line:
key,value=(x.strip() for x in line.split(":",1))
if key in ("Serial","Revision","Hardware","Model"): info[key.lower()]=value
except Exception: pass
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
except Exception: pass
try:
os_release={}
for line in Path("/etc/os-release").read_text().splitlines():
if "=" in line:
key,value=line.split("=",1); os_release[key]=value.strip().strip('"')
info["os"]=os_release.get("PRETTY_NAME",platform.platform())
except Exception: info["os"]=platform.platform()
info.update({"hostname":socket.gethostname(),"kernel":platform.release(),
"architecture":platform.machine(),"cpu_count":os.cpu_count() or 0})
return info
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))}
prev_cpu=None prev_cpu=None
page=0; page_since=time.monotonic() page=0; page_since=time.monotonic()
cpu_hi=mem_hi=0 cpu_hi=mem_hi=0
@@ -28,66 +93,6 @@ wifi_weak_since=None; wifi_weak_active=False
last_net=last_watch=0 last_net=last_watch=0
watch_state=[] watch_state=[]
def cmd(c): bus.write_byte_data(OLED,0x00,c)
def oled_init():
# Seller-native physical 128x32 configuration.
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x02,0x81,0x7F,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF]:
cmd(c)
def draw_text_5x7(pix,x,y,text):
for ch in text:
code=ord(ch)
if code<0 or code>255: code=ord("?")
off=code*5
if off+5>len(FONT5): code=ord("?"); off=code*5
for cx in range(5):
col=FONT5[off+cx]
for cy in range(7):
if col & (1<<cy):
xx=x+cx; yy=y+cy
if 0<=xx<W and 0<=yy<H: pix[xx,yy]=1
x+=6
if x>W-6: break
def oled_show(lines_lr, title_zh=False):
im=Image.new("1",(W,H),0); pix=im.load()
for row,(left,right) in enumerate(lines_lr[:4]):
y=row*8
draw_text_5x7(pix,0,y,left)
if right:
draw_text_5x7(pix,max(0,W-len(right)*6),y,right)
data=[]
for pg in range(4):
for x in range(W):
v=0
for bit in range(8):
if pix[x,pg*8+bit]: v|=1<<bit
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])
def mcu(reg,val): bus.write_byte_data(MCU,reg,val)
_last_rgb=None
def rgb_off():
global _last_rgb
if _last_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): def set_pi_led(name,off=True):
base=Path("/sys/class/leds")/name base=Path("/sys/class/leds")/name
try: try:
@@ -101,24 +106,6 @@ def apply_dark_mode():
if DARK_PWR: set_pi_led("PWR",True) if DARK_PWR: set_pi_led("PWR",True)
if DARK_ACT: set_pi_led("ACT",True) if DARK_ACT: set_pi_led("ACT",True)
FAN_REG=[0x00,0x04,0x06,0x08,0x09,0x01]
FAN_NAME=["OFF","L1","L2","L3","L4","MAX"]
def fan_target(t):
if t<FAN_T[0]: return 0
if t<FAN_T[1]: return 1
if t<FAN_T[2]: return 2
if t<FAN_T[3]: return 3
if t<FAN_T[4]: return 4
return 5
def update_fan(t):
global fan_level
want=fan_target(t); old=fan_level
if want>fan_level: fan_level=want
elif want<fan_level:
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
if t < boundary: fan_level-=1
if old!=fan_level: mcu(0x08,FAN_REG[fan_level])
def cpu_pct(): def cpu_pct():
global prev_cpu global prev_cpu
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9])) p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
@@ -135,8 +122,8 @@ def mem_pct():
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"] return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
def disk_pct(): def disk_pct():
d=disk_usage("/"); return 100*d.used/d.total d=disk_usage("/"); return 100*d.used/d.total
def _run(args): def _run(args,timeout=2):
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=2).strip() try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip()
except Exception: return "" except Exception: return ""
def primary_network(): def primary_network():
@@ -160,7 +147,6 @@ def primary_network():
n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip()) 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) metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n)
except Exception: pass except Exception: pass
if label=="IP6" and len(ipval)>18: ipval=ipval[:7]+".."+ipval[-7:]
return iface,kind,metric,label,(ipval or "NO IP") return iface,kind,metric,label,(ipval or "NO IP")
def get_ip(): def get_ip():
@@ -177,7 +163,9 @@ def load_cfg():
if c.has_section("services"): if c.has_section("services"):
for label,target in c["services"].items(): for label,target in c["services"].items():
target=target.strip() target=target.strip()
if target and not target.startswith("#"): watches.append(("service",label.upper(),target)) paused=c.getboolean("service_monitor_disabled",label,fallback=False)
if target and not target.startswith("#") and not paused:
watches.append(("service",label.upper(),target))
if c.has_section("processes"): if c.has_section("processes"):
for label,target in c["processes"].items(): for label,target in c["processes"].items():
target=target.strip() target=target.strip()
@@ -189,11 +177,15 @@ def cf(section,key,default,cast=float):
try: return cast(cfg.get(section,key)) try: return cast(cfg.get(section,key))
except: return default except: return default
DEVICE_ID=cf("device","identifier","auto",str).strip()
if not DEVICE_ID or DEVICE_ID.lower()=="auto": DEVICE_ID=socket.gethostname()
DEVICE_NAME=cf("device","name",DEVICE_ID,str).strip() or DEVICE_ID
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
DEVICE_INFO=device_details()
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0) SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
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) NETWORK_INTERVAL=cf("timing","network_interval",5.0)
SERVICE_INTERVAL=cf("timing","service_interval",5.0) SERVICE_INTERVAL=cf("timing","service_interval",5.0)
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
CPU_HIGH=cf("alerts","cpu_high",90.0) CPU_HIGH=cf("alerts","cpu_high",90.0)
CPU_RECOVER=cf("alerts","cpu_recover",75.0) CPU_RECOVER=cf("alerts","cpu_recover",75.0)
@@ -209,45 +201,122 @@ WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0) WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0) WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
FAN_T=[cf("fan","start",50.0),cf("fan","level2",53.0),cf("fan","level3",56.0),
cf("fan","level4",59.0),cf("fan","max",62.0)]
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_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no")) DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
LED_BRIGHT=cf("led","brightness",10,int) ALERT_CATEGORIES={
LED_WARN_ON=cf("led","warning_on_ms",300,int); LED_WARN_OFF=cf("led","warning_off_ms",2700,int) "ROOT_READONLY":"STORAGE",
LED_CRIT_ON=cf("led","critical_on_ms",400,int); LED_CRIT_OFF=cf("led","critical_off_ms",1100,int) "TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emergency_off_ms",400,int) "THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
"POWER_UNDERVOLT":"POWER",
"MEMORY_HIGH":"MEMORY",
"DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE",
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK",
"SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE",
}
def check_watch(w): def check_watch(w):
typ,label,target=w typ,label,target=w
if typ=="service": args=["systemctl","is-active","--quiet",target] if typ=="service" else ["pgrep","-f","--",target]
r=subprocess.run(["systemctl","is-active","--quiet",target]) try:
else: r=subprocess.run(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=2)
r=subprocess.run(["pgrep","-f",target],stdout=subprocess.DEVNULL)
return r.returncode==0 return r.returncode==0
except (OSError,subprocess.TimeoutExpired):
return False
def alert(title,l2="",l3="",l4="",sev=1,color=(20,16,0),blink=0):
return {"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
"sev":sev,"color":color,"blink":blink}
def make_alerts(cpu,temp,mem,disk): def diagnose():
print("Agent:")
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
print("loop_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} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
print("Plugins (read-only API discovery; no hardware access):")
for plugin in discover(cfg):
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
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
ALERT_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 alert_priority(request_id):
return ALERT_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 wifi_weak_since,wifi_weak_active
global cpu_hi,mem_hi global cpu_hi,mem_hi
a=[] cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
cpu_hi=cpu_hi+1 if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
mem_hi=mem_hi+1 if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi if cpu_hi>=CPU_HIGH_SEC: alert_manager.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD")
if cpu_hi>=CPU_HIGH_SEC:a.append(alert("CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","FOR >=30 SEC",1,(24,18,0))) else: alert_manager.clear("CPU_HIGH")
if temp>=TEMP_CRIT:a.append(alert("TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",3,(32,0,0),2)) if temp>=TEMP_CRIT:
elif temp>=TEMP_HIGH:a.append(alert("TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",1,(24,0,0))) alert_manager.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
if mem_hi>=MEM_HIGH_SEC:a.append(alert("MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","",1,(18,0,24))) alert_manager.clear("TEMP_HIGH")
if disk>=DISK_CRIT:a.append(alert("DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE",2,(28,6,0),2)) elif temp>=TEMP_HIGH:
elif disk>=DISK_HIGH:a.append(alert("DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE",1,(28,8,0))) alert_manager.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
if not link_ok:a.append(alert("NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","",2,(0,8,28),1)) alert_manager.clear("TEMP_CRITICAL")
elif not net_ok:a.append(alert("INTERNET DOWN","LOCAL LINK OK",ip,"",2,(0,8,28),1)) else:
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
else: alert_manager.clear("MEMORY_HIGH")
if disk>=DISK_CRIT:
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
alert_manager.clear("DISK_HIGH")
elif disk>=DISK_HIGH:
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
alert_manager.clear("DISK_CRITICAL")
else:
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
if not link_ok:
alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
alert_manager.clear("INTERNET_DOWN")
elif not net_ok:
alert_manager.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
alert_manager.clear("NETWORK_DOWN")
else:
alert_manager.clear("NETWORK_DOWN"); alert_manager.clear("INTERNET_DOWN")
for w,ok in zip(watches,watch_state): for w,ok in zip(watches,watch_state):
if not ok:a.append(alert("SERVICE DOWN",w[1],"CHECK / RESTART","",2,(0,24,24),1)) alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
if not ok:
alert_manager.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
else: alert_manager.clear(alert_id)
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit(): if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
rssi=float(net_metric) rssi=float(net_metric)
if not wifi_weak_active: if not wifi_weak_active:
@@ -258,156 +327,155 @@ def make_alerts(cpu,temp,mem,disk):
elif rssi >= WIFI_RECOVER: elif rssi >= WIFI_RECOVER:
wifi_weak_active=False; wifi_weak_since=None wifi_weak_active=False; wifi_weak_since=None
if wifi_weak_active: 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)) alert_manager.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
else: alert_manager.clear("WIFI_WEAK")
else: else:
wifi_weak_since=None; wifi_weak_active=False wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
a.sort(key=lambda x:x["sev"],reverse=True) if health.get("under_voltage_now"):
return a alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
else: alert_manager.clear("POWER_UNDERVOLT")
if health.get("throttled_now"):
alert_manager.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
else: alert_manager.clear("THROTTLED")
if health.get("freq_capped_now"):
alert_manager.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
else: alert_manager.clear("FREQ_CAPPED")
if health.get("soft_temp_limit_now"):
alert_manager.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
else: alert_manager.clear("SOFT_TEMP_LIMIT")
if fs_readonly: alert_manager.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
else: alert_manager.clear("ROOT_READONLY")
def led_for(a): class AlertManager:
# Color = fault object. Blink frequency = severity. All alerts blink. """Current monitoring facts. Hardware plugins own all display decisions."""
if not a: def __init__(self):
rgb_off(); return self.requests={}
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 def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
else: on_ms,off_ms=LED_WARN_ON,LED_WARN_OFF now=time.monotonic();old=self.requests.get(request_id)
cycle=max(1,on_ms+off_ms) value={"id":request_id,"priority":alert_priority(request_id),"severity":severity,
phase=int(time.monotonic()*1000)%cycle "category":ALERT_CATEGORIES[request_id.split("|",1)[0]].lower(),
rgb(*a["color"]) if phase<on_ms else rgb_off() "title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
"first_seen":old["first_seen"] if old else now,"metadata":metadata or {}}
value["last_changed"]=old["last_changed"] if old and all(old[k]==v for k,v in value.items()) else now
self.requests[request_id]=value
if old is None:log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
def clear(self,request_id):
if self.requests.pop(request_id,None) is not None:log("INFO","ALERT_RECOVERED",alert=request_id)
def status_snapshot(self):
values=sorted(self.requests.values(),key=lambda a:(-a["priority"],a["first_seen"],a["id"]))
return {"owner":values[0]["id"] if values else "NORMAL_HOME","alerts":values}
def render_home(cpu,temp,mem,disk): def cleanup():
# 128x32 fixed columns. Both columns are LEFT-aligned. publisher.close()
# Seller 5x7 font has 6px character pitch; right column starts at x=72. log("INFO","STOP",loop_errors=agent["loop_errors"])
im=Image.new("1",(W,H),0); pix=im.load() try:Path(STATUS_PATH).unlink(missing_ok=True)
left=[ except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
f"CPU {cpu:.1f}%",
f"TMP {temp:.1f}C",
f"DSK {disk:.1f}%",
ip_label+" "+ip,
]
right=[
f"MEM {mem:.1f}%",
f"FAN {FAN_NAME[fan_level]}",
f"{net_kind} {net_metric}",
"",
]
for row in range(4):
y=row*8
draw_text_5x7(pix,0,y,left[row])
if right[row]:
draw_text_5x7(pix,72,y,right[row])
data=[]
for pg in range(4):
for x in range(W):
v=0
for bit in range(8):
if pix[x,pg*8+bit]: v|=1<<bit
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])
def write_status(cpu,temp,mem,disk):
display_state=alert_manager.status_snapshot()
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
disk_total,disk_used,disk_free=disk_usage("/")
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
state={
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
"machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
"device":DEVICE_INFO,
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
"memory_percent":round(memory_calculated if memory_total else mem,1),
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
"memory_available_bytes":memory_available,
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
"root_readonly":bool(fs_readonly)},
"power":health,
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
"signal_percent":signal,
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
for w,ok in zip(watches,watch_state)],
"display":display_state,
"agent":{"loop_errors":agent["loop_errors"]},
"plugins":publisher.current_status(),
}
publisher.publish(state)
path=Path(STATUS_PATH); path.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
temporary=path.with_suffix(".tmp")
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
temporary.chmod(0o644); temporary.replace(path)
def render_alert(a,idx,total): # Dispatch only after all shared checks and configuration have been initialized.
oled_show([(a["title"],f"{idx}/{total}"), parser=argparse.ArgumentParser()
(a["l2"],""),(a["l3"],""),(a["l4"],"")]) parser.add_argument("--diagnose",action="store_true")
args=parser.parse_args()
if args.diagnose: raise SystemExit(diagnose())
watch_state=[]
oled_init(); rgb_off(); apply_dark_mode() last_sensor=last_status=0.0
watch_state=[check_watch(w) for w in watches] last_net=last_watch=last_health=0.0
last_sensor=last_render=0.0 health={}; fs_readonly=False
last_net=last_watch=time.monotonic() agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0}
event_state={}
history_logged=set()
latest=(0.0,0.0,0.0,0.0) latest=(0.0,0.0,0.0,0.0)
active=[] alert_manager=AlertManager()
publisher=Publisher({k:v for k,v in registered(cfg).items() if not cfg.getboolean("plugin_paused",k,fallback=False)},DEVICE_ID,log)
# Page state is a snapshot queue. Live sensor refreshes NEVER choose another page. try:
# Each cycle is HOME + every alert captured at the cycle boundary. apply_dark_mode()
cycle_pages=["HOME"] log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
cycle_pos=0 while running:
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: try:
now=time.monotonic() now=time.monotonic()
sample_elapsed=0.0
# Independent clocks: update data/state only. # Independent clocks: update data/state only.
if now-last_sensor >= SENSOR_INTERVAL: 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() 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); last_sensor=now; agent["last_sensor_ok"]=now
if now-last_net >= NETWORK_INTERVAL: 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 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 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: if now-last_watch >= SERVICE_INTERVAL:
watch_state=[check_watch(w) for w in watches] watch_state=[check_watch(w) for w in watches]
last_watch=now 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 c,t,m,d=latest
active=make_alerts(c,t,m,d) update_active_alerts(c,t,m,d,sample_elapsed)
if now-last_status >= 1.0:
# If the currently displayed alert truly disappeared, skip it immediately. write_status(c,t,m,d); last_status=now
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)
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) time.sleep(0.20)
except Exception as e: 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)
finally:
rgb_off(); mcu(0x08,0x00); bus.close() cleanup()
+547
View File
@@ -0,0 +1,547 @@
#!/usr/bin/env python3
import sys
sys.path.append("/usr/local/share/pigway-pi-control")
from plugin_api import discover,registered,request as plugin_request,socket_path
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
from concurrent.futures import ThreadPoolExecutor,as_completed
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs,urlencode,urlparse
from urllib.request import Request,urlopen
APP_VERSION="3.7.0"
CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token")
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
PROTECTED_UNITS={"pigway-pi-control-api.service"}
def plugin_inventory():
cfg=load_cfg();items=discover(cfg)
for item in items:item["paused"]=cfg.getboolean("plugin_paused",item["id"],fallback=False)
return items
def find_plugin(plugin_id):
for item in plugin_inventory():
if item["id"]==plugin_id:return item
raise ValueError("unknown plugin")
def plugin_operation(body):
plugin_id=str(body.get("id",""))
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
item=find_plugin(plugin_id);operation=body.get("operation")
if operation=="link":
if not item["online"]:raise ValueError("plugin is offline")
text=replace_ini_value("plugins",plugin_id,socket_path(item["socket"]))
save_ini_text(text,"plugin-link")
text=replace_ini_value("plugin_paused",plugin_id,"false")
elif operation in {"pause","resume","unlink"}:
if not item["linked"]:raise ValueError("plugin is not linked")
text=replace_ini_value("plugins",plugin_id,None) if operation=="unlink" else replace_ini_value("plugin_paused",plugin_id,"true" if operation=="pause" else "false")
else:raise ValueError("invalid plugin operation")
backup=save_ini_text(text,"plugin")
# Only reload a monitor that is already running. Never start hardware here.
subprocess.run(["systemctl","try-restart","pigway-pi-control.service"],check=True,timeout=15)
if operation in {"pause","unlink"}:
try:plugin_request(item["socket"],"DELETE","/v1/state",{})
except Exception:pass # An offline plugin expires its lease independently.
return {"ok":True,"backup":backup}
def machine_identity():
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
name=cfg.get("device","name",fallback=identifier).strip() or identifier
if name.lower()=="auto": name=identifier
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
def normalize_url(value):
parsed=urlparse(str(value).strip())
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("host URL must be an http(s) URL without credentials")
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
def host_registry():
local=machine_identity(); result=[local]
cfg=load_cfg()
if cfg.has_section("hosts"):
for host_id,url in cfg.items("hosts"):
try: base=normalize_url(url)
except ValueError: continue
if host_id==local["id"]: continue
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
return result
def find_host(host_id):
for host in host_registry():
if host["id"]==host_id:return host
raise ValueError("unknown host")
def remote_json(base,path,method="GET",payload=None,token=""):
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
headers={"Accept":"application/json"}
if data is not None: headers["Content-Type"]="application/json"
if token: headers["Authorization"]=f"Bearer {token}"
request=Request(base+path,data=data,headers=headers,method=method)
with urlopen(request,timeout=5) as response:
return json.loads(response.read())
def fleet_status():
hosts=host_registry(); results=[]
try:
status=json.loads(STATUS.read_text())
status.setdefault("machine",machine_identity())
results.append({"host":hosts[0],"online":True,"status":status})
except Exception as exc:
results.append({"host":hosts[0],"online":False,"error":str(exc)})
def fetch(host):
try:
status=remote_json(host["url"],"/api/v1/status")
machine=status.get("machine",{})
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
return {"host":merged,"online":True,"status":status}
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures): results.append(future.result())
order={h["id"]:i for i,h in enumerate(hosts)}
results.sort(key=lambda x:order.get(x["host"]["id"],999))
return {"hosts":results,"timestamp":int(time.time())}
def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG)
return cfg
def api_setting(key,default,cast=str):
try:return cast(load_cfg().get("api",key))
except Exception:return default
def config_json():
cfg=load_cfg()
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
def validate_value(section,key,value,existing):
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
raise ValueError(f"unsupported option: {section}.{key}")
if section=="hardware" and key=="cooling_hat_enabled":
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
elif section=="led" and key=="alert_mode":
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
elif section=="led" and key=="custom_min_hold_ms":
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
elif RGB_KEY_RE.fullmatch(key):
number=int(value)
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
elif FLASH_KEY_RE.fullmatch(key):
number=int(value)
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
elif section=="led" and key=="normal_mode":
if value.lower() not in NORMAL_MODES: raise ValueError("normal_mode must be off, solid, flash, flow, breathe, marquee, rainbow or colorful")
elif section=="led" and key=="normal_effect_speed":
if int(value) not in (1,2,3): raise ValueError("normal_effect_speed must be 1, 2 or 3")
elif section=="led" and key=="normal_effect_color":
if value.lower() not in EFFECT_COLORS: raise ValueError("normal_effect_color must be red, green, blue, yellow, purple, cyan or white")
elif section=="api" and key=="port":
number=int(value)
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
elif section=="api" and key=="log_limit":
number=int(value)
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
elif section=="api" and key=="bind":
ipaddress.ip_address(value)
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
elif section=="hosts":
normalize_url(value)
def replace_ini_value(section,key,value):
text=CFG.read_text()
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return text
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
if value is not None:return text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
return text
def save_ini_text(text,prefix="api"):
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup)
def edit_host(operation,host_id,url=""):
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
local=machine_identity()["id"]
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
if operation=="save": url=normalize_url(url)
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
return save_ini_text(text,"hosts")
def update_ini(updates):
if not isinstance(updates,dict): raise ValueError("updates must be an object")
text=CFG.read_text()
current=load_cfg(); existing={section:set(current.options(section)) for section in current.sections()}
for section,values in updates.items():
if section not in ALLOWED_SECTIONS or not isinstance(values,dict):
raise ValueError(f"unsupported section: {section}")
for key,value in values.items():
if not NAME_RE.fullmatch(str(key)): raise ValueError(f"invalid key: {key}")
if value is None:
raise ValueError(f"null is not allowed: {section}.{key}")
value=str(value).strip()
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
raise ValueError(f"invalid value: {section}.{key}")
validate_value(section,str(key),value,existing)
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
if not section_match:
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
continue
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
replacement=key_match.group(1)+value
text=text[:start]+replacement+text[stop:]
else:
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp")
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup)
def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0):
command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
if since is not None: command.extend(["--since",f"@{since}"])
if until is not None: command.extend(["--until",f"@{until}"])
result=subprocess.run(command,capture_output=True,text=True,timeout=15)
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
rows=[]
for line in result.stdout.splitlines():
try:
item=json.loads(line)
message=item.get("MESSAGE","")
level_match=re.search(r"(?:^|\s)level=([A-Z]+)(?:\s|$)",message)
event_match=re.search(r"(?:^|\s)event=([^ ]+)",message)
priority=str(item.get("PRIORITY","6"))
level=(level_match.group(1) if level_match else
{"0":"EMERGENCY","1":"ALERT","2":"CRITICAL","3":"ERROR","4":"WARN","5":"NOTICE","6":"INFO","7":"DEBUG"}.get(priority,"INFO"))
rows.append({"timestamp":int(item.get("__REALTIME_TIMESTAMP",0)),"priority":priority,
"severity":level,"event":event_match.group(1) if event_match else "SYSTEM",
"message":message})
except json.JSONDecodeError: pass
severities={x.strip().upper() for x in severity.split(",") if x.strip()}
if severities: rows=[x for x in rows if x["severity"] in severities]
event_types=sorted({x["event"] for x in rows})
if event: rows=[x for x in rows if x["event"]==event]
if search:
needle=search.casefold(); rows=[x for x in rows if needle in x["message"].casefold()]
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
"event":lambda x:x["event"],"message":lambda x:x["message"]}
rows.sort(key=keys[sort],reverse=order=="desc")
total=len(rows)
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":event_types,"retention":"systemd-journal"}
def fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
sort="timestamp",order="desc",limit=100,offset=0):
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
def decorate(payload,host):
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
host_id=machine_info.get("id") or host["id"]
host_name=machine_info.get("name") or host["name"]
decorated=[]
for row in payload.get("logs",[]):
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
local_payload["machine"]=machine_identity()
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
if value not in (None,""): query[key]=value
def fetch(host):
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
try:
status=remote_json(host["url"],"/api/v1/status")
payload["machine"]=status.get("machine",{})
except Exception: pass
return decorate(payload,host)
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures):
try:
remote_rows,remote_events,remote_machine=future.result()
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
except Exception: pass
if machine: rows=[row for row in rows if row["machine_id"]==machine]
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
"event":lambda x:x["event"],"message":lambda x:x["message"],
"machine":lambda x:(x["machine_name"],x["machine_id"])}
rows.sort(key=keys[sort],reverse=order=="desc")
total=len(rows)
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
"retention":"systemd-journal"}
def service_inventory():
cfg=load_cfg()
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
notes=dict(cfg.items("service_notes")) if cfg.has_section("service_notes") else {}
files=subprocess.run(["systemctl","list-unit-files","--type=service","--no-legend","--no-pager"],
capture_output=True,text=True,timeout=10,check=True)
units={}
for line in files.stdout.splitlines():
parts=line.split()
if len(parts)>=2 and UNIT_RE.fullmatch(parts[0]):
units[parts[0]]={"unit":parts[0],"enabled":parts[1],"active":"inactive","description":""}
states=subprocess.run(["systemctl","list-units","--all","--type=service","--no-legend","--no-pager","--plain"],
capture_output=True,text=True,timeout=10,check=True)
for line in states.stdout.splitlines():
parts=line.split(None,4)
if len(parts)>=4 and UNIT_RE.fullmatch(parts[0]):
item=units.setdefault(parts[0],{"unit":parts[0],"enabled":"unknown"})
item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""})
for name,target in monitored.values():
item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""})
paused=cfg.getboolean("service_monitor_disabled",name,fallback=False)
item.update({"monitored":True,"paused":paused,"monitor_name":name.upper(),"note":notes.get(name,"")})
for item in units.values():
item.setdefault("monitored",False); item.setdefault("paused",False)
item.setdefault("monitor_name",""); item.setdefault("note","")
item["protected"]=item["unit"] in PROTECTED_UNITS
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
def edit_service_monitor(operation,name,target,note="",previous_name=""):
if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation")
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name")
if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note")
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name")
cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None)
previous=previous_name or name
was_paused=cfg.getboolean("service_monitor_disabled",previous,fallback=False)
text=CFG.read_text()
def change(section,key,value):
nonlocal text
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
elif value is not None:
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
if operation=="delete":
if old_target is None: raise ValueError("monitor not found")
change("services",name,None); change("service_notes",name,None); change("service_monitor_disabled",name,None)
elif operation in {"pause","resume"}:
if old_target is None: raise ValueError("monitor not found")
change("service_monitor_disabled",name,"true" if operation=="pause" else None)
else:
if previous_name and previous_name.lower()!=name.lower():
change("services",previous_name,None); change("service_notes",previous_name,None)
change("service_monitor_disabled",previous_name,None)
change("services",name,target); change("service_notes",name,note or None)
if was_paused: change("service_monitor_disabled",name,"true")
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return str(backup)
def control_service(unit,action):
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name")
if action not in {"start","stop"}: raise ValueError("action must be start or stop")
if unit in PROTECTED_UNITS and action=="stop": raise ValueError("protected remote-management service cannot be stopped here")
result=subprocess.run(["systemctl",action,unit],capture_output=True,text=True,timeout=20)
if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed")
class Handler(BaseHTTPRequestHandler):
server_version="PIGWayAPI/3.7"
def log_message(self,fmt,*args):
return
def send_json(self,status,payload):
data=json.dumps(payload,ensure_ascii=False,separators=(",",":")).encode()
self.send_response(status); self.send_header("Content-Type","application/json; charset=utf-8")
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data)
def security_headers(self):
self.send_header("Cache-Control","no-store")
self.send_header("X-Content-Type-Options","nosniff")
self.send_header("X-Frame-Options","DENY")
self.send_header("Content-Security-Policy","default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
def authorized(self):
try: expected=TOKEN.read_text().strip()
except OSError: return False
supplied=self.headers.get("Authorization","")
return supplied.startswith("Bearer ") and secrets.compare_digest(supplied[7:],expected)
def read_json(self):
length=int(self.headers.get("Content-Length","0"))
if length<=0 or length>65536: raise ValueError("invalid request size")
return json.loads(self.rfile.read(length))
def do_GET(self):
parsed=urlparse(self.path)
try:
if parsed.path=="/":
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
if parsed.path=="/api/v1/health":
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
if parsed.path=="/api/v1/hosts":
self.send_json(200,{"hosts":host_registry()}); return
if parsed.path=="/api/v1/fleet/status":
self.send_json(200,fleet_status()); return
if parsed.path=="/api/v1/status":
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
self.send_json(200,json.loads(STATUS.read_text())); return
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}:
query=parse_qs(parsed.query)
value=lambda key,default="":query.get(key,[default])[0]
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
offset=max(int(value("offset",0)),0)
since=int(value("since")) if value("since") else None
until=int(value("until")) if value("until") else None
if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
sort=value("sort","timestamp"); order=value("order","desc")
allowed_sort={"timestamp","severity","event","message"}
if parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
if sort not in allowed_sort: raise ValueError("invalid log sort")
if order not in {"asc","desc"}: raise ValueError("invalid log order")
severity=value("severity").upper(); event=value("event"); search=value("search")
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
if parsed.path=="/api/v1/fleet/logs":
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
plugin_id=query.get("id",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id)
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
if parsed.path.endswith("/plugins"):
self.send_json(200,{"plugins":plugin_inventory()});return
item=find_plugin(plugin_id)
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
if parsed.path=="/api/v1/config":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
if parsed.path=="/api/v1/services":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
self.send_json(404,{"error":"not found"})
except ValueError as e: self.send_json(400,{"error":str(e)})
except Exception as e: self.send_json(500,{"error":str(e)})
def do_PUT(self):
path=urlparse(self.path).path
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
try:
body=self.read_json()
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
host_id=str(body.get("host",""))
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id);token=str(body.get("target_token",""))
if not token:raise ValueError("remote bearer token required")
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
item=find_plugin(str(body.get("id","")))
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
if path=="/api/v1/hosts":
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
if path=="/api/v1/fleet/config":
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{})
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
for host_id in dict.fromkeys(str(x) for x in targets):
try:
if host_id==local_id:
backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
restart_local_api="api" in updates
results.append({"id":host_id,"ok":True,"backup":backup})
else:
host=find_host(host_id); token=str(tokens.get(host_id,""))
if not token: raise ValueError("remote bearer token required")
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
return
if path=="/api/v1/services/control":
control_service(str(body.get("unit","")),str(body.get("action","")))
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
self.send_json(200,{"ok":True}); return
if path=="/api/v1/services":
backup=edit_service_monitor(str(body.get("operation","")),str(body.get("name","")),
str(body.get("target","")),str(body.get("note","")),
str(body.get("previous_name","")))
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
self.send_json(200,{"ok":True,"backup":backup}); return
updates=body.get("updates")
backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
restart_api="api" in updates
print(f"CONFIG_UPDATED sections={','.join(sorted(updates))} backup={backup}",flush=True)
self.send_json(200,{"ok":True,"backup":backup,"api_restart":restart_api})
if restart_api: threading.Timer(0.2,self.server.shutdown).start()
except (ValueError,json.JSONDecodeError) as e: self.send_json(400,{"error":str(e)})
except Exception as e: self.send_json(500,{"error":str(e)})
def main():
host=api_setting("bind","0.0.0.0")
port=api_setting("port",6001,int)
server=ThreadingHTTPServer((host,port),Handler)
print(f"API_START version={APP_VERSION} bind={host} port={port}",flush=True)
try: server.serve_forever()
finally: server.server_close()
if __name__=="__main__": main()
+109
View File
@@ -0,0 +1,109 @@
"""Hardware-independent client for the versioned local plugin API."""
import copy
from contextlib import closing
import http.client
import json
import socket
import threading
import uuid
from pathlib import Path
PLUGIN_ROOT=Path('/run/pigway-plugins')
def socket_path(value):
path=Path(value)
if not path.is_absolute() or '..' in path.parts or path.name!='api.sock' or PLUGIN_ROOT not in path.parents:
raise ValueError('plugin socket must be /run/pigway-plugins/<plugin>/api.sock')
return str(path)
class Connection(http.client.HTTPConnection):
def __init__(self,path):
super().__init__('localhost',timeout=.8);self.path=socket_path(path)
def connect(self):
self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
try:self.sock.connect(self.path)
except Exception:self.sock.close();raise
def request(path,method,endpoint,payload=None):
with closing(Connection(path)) as conn:
body=None if payload is None else json.dumps(payload,allow_nan=False).encode()
conn.request(method,endpoint,body,{'Content-Type':'application/json'})
response=conn.getresponse();raw=response.read(262145)
if len(raw)>262144:raise ValueError('plugin response too large')
value=json.loads(raw)
if response.status>=400:raise ValueError(value.get('error','plugin request failed'))
return value
def registered(cfg):
if not cfg.has_section('plugins'):return {}
return {k:socket_path(v) for k,v in cfg.items('plugins') if v.strip()}
def discover(cfg):
linked=registered(cfg);paths={v for v in linked.values()}
paths.update(str(p) for p in PLUGIN_ROOT.glob('*/api.sock'))
result=[]
for path in sorted(paths)[:32]:
key=next((k for k,v in linked.items() if v==path),None)
try:
d=request(path,'GET','/v1/descriptor')
if d.get('api_version')!=1:raise ValueError('incompatible plugin API version')
status=request(path,'GET','/v1/status')
result.append({'id':key or d['id'],'socket':path,'linked':key is not None,'online':True,'descriptor':d,'status':status})
except Exception as exc:
result.append({'id':key or Path(path).parent.name,'socket':path,'linked':key is not None,'online':False,'error':str(exc)})
return result
class Publisher:
"""One replaceable snapshot, not an unbounded queue of animation commands."""
def __init__(self,plugins,source,log):
self.plugins=plugins;self.source=source;self.log=log
self.session=str(uuid.uuid4());self.revision=0;self.snapshot=None
self.lock=threading.Lock();self.stop_event=threading.Event();self.status={}
self.threads=[threading.Thread(target=self.run,args=(key,path),name='plugin-'+key,daemon=True)
for key,path in plugins.items()]
for thread in self.threads:thread.start()
def publish(self,state):
with self.lock:self.snapshot=copy.deepcopy(state)
def current_status(self):
with self.lock:return copy.deepcopy(self.status)
def run(self,key,path):
previous_error=None;revision=0;descriptor=None
while not self.stop_event.is_set():
with self.lock:state=self.snapshot
if state is not None:
revision+=1
payload={'api_version':1,'source':self.source,'session':self.session,'revision':revision,
'ttl_seconds':10,'system':state['system'],'network':state['network'],'alerts':state['display']['alerts']}
try:
if descriptor is None:
descriptor=request(path,'GET','/v1/descriptor')
if descriptor.get('api_version')!=1:raise ValueError('incompatible plugin API version')
request(path,'PUT','/v1/state',payload)
status=request(path,'GET','/v1/status')
with self.lock:self.status[key]={'online':True,'status':status,'name':descriptor.get('name',key),'capabilities':descriptor.get('capabilities',{})}
if previous_error is not None:self.log('INFO','PLUGIN_RECOVERED',plugin=key)
previous_error=None
except Exception as exc:
descriptor=None
error=str(exc)
with self.lock:self.status[key]={'online':False,'error':error}
if previous_error!=error:self.log('WARN','PLUGIN_UNAVAILABLE',plugin=key,error=error)
previous_error=error
self.stop_event.wait(1)
try:request(path,'DELETE','/v1/state',{'source':self.source,'session':self.session})
except Exception:pass # Server-side leases expire if transport is unavailable.
def close(self):
self.stop_event.set()
for thread in self.threads:thread.join()
+45 -113
View File
@@ -1,45 +1,39 @@
# ============================================================================= # PIGWay system monitoring. Hardware plugins are separately installed.
# Pigway Pi Control v3.2.0 - 用户配置文件 # 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。
# ============================================================================= [plugins]
# # No automatic attachment. Example (prefer Web discovery):
# 正式安装后的配置路径: # yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
# /etc/pigway-pi-control.conf
# [device]
# 编辑: # 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
# sudo nano /etc/pigway-pi-control.conf # 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。
# identifier = auto
# 保存后让配置生效:
# sudo systemctl restart pigway-pi-control # Web 页面显示名称。auto 表示使用 identifier。
# name = auto
# 查看运行状态:
# systemctl status pigway-pi-control --no-pager -l
# [hosts]
# 查看实时日志: # 在作为聚合入口的机器上登记其他节点,每行格式:
# journalctl -u pigway-pi-control -f # 唯一标识 = http://节点IP:6001
# # 示例:
# 重要: # living-room = http://192.168.1.20:6001
# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。
# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。
# 3. 数值单位和建议范围见每项上方注释。 [api]
# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。 # API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
# ============================================================================= # 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
bind = 0.0.0.0
port = 6001
log_limit = 200
[timing] [timing]
# 传感器采样周期,单位:秒。 # 传感器采样周期,单位:秒。
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。 # CPU / 内存 / 温度 / 磁盘按此周期更新。
# 推荐:1。通常不要低于 1。 # 推荐:1。通常不要低于 1。
sensor_interval = 1 sensor_interval = 1
# OLED 当前页面刷新周期,单位:秒。
# 这只是刷新当前页面数据,不负责切换页面。
# 推荐:1。
oled_refresh_interval = 1
# HOME / 各告警页面的轮播停留时间,单位:秒。
# 推荐:10。觉得切换太慢可改为 5~8。
page_interval = 10
# 网络状态检查周期,单位:秒。 # 网络状态检查周期,单位:秒。
# 推荐:5。太短会产生不必要的网络探测。 # 推荐:5。太短会产生不必要的网络探测。
network_interval = 5 network_interval = 5
@@ -48,40 +42,10 @@ network_interval = 5
# 推荐:5。 # 推荐:5。
service_interval = 5 service_interval = 5
# 本机健康检查周期,单位:秒。
[fan] # 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
# 风扇温控档位,单位:摄氏度。 # 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。
# health_interval = 1
# 默认曲线:
# < 50°C OFF
# 50~53°C L1
# 53~56°C L2
# 56~59°C L3
# 59~62°C L4
# >= 62°C MAX
#
# 如希望更安静,可适当提高阈值;如更重视散热,可降低。
# 建议保持 start < level2 < level3 < level4 < max。
# 低于此温度,风扇完全停止。
start = 50
# L2 起始温度;start 到 level2 之间为 L1。
level2 = 53
# L3 起始温度。
level3 = 56
# L4 起始温度。
level4 = 59
# MAX 全速起始温度。
max = 62
# 降档回差,单位:°C。
# 防止温度在临界值附近上下波动时风扇频繁升降档。
# 推荐:1.0~2.0。
hysteresis = 1.5
[alerts] [alerts]
@@ -121,61 +85,23 @@ disk_high = 90
disk_critical = 95 disk_critical = 95
# ---------------- Wi-Fi 信号 ---------------- # ---------------- Wi-Fi 信号 ----------------
# 仅当 Wi-Fi 是 Linux 当前默认路由/主网络接口时生效。 # 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
# # RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
# RSSI 单位为 dBm,越接近 0 表示信号越强: # 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发弱信号告警。
# -50 左右:很好
# -60 左右:良好
# -70 左右:一般
# -80 以下:较弱
#
# 连续低于该值达到 wifi_weak_seconds 后触发蓝色网络告警。
wifi_weak = -75 wifi_weak = -75
# 已触发弱信号告警后,必须恢复到该值以上才解除。 # 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
# 与 wifi_weak 留出差值可避免信号在临界值附近反复报警。
wifi_recover = -68 wifi_recover = -68
# 弱信号必须持续多少秒才报警,避免瞬时 Wi-Fi 波动产生误报。 # 弱信号持续多少秒才正式告警。
wifi_weak_seconds = 30 wifi_weak_seconds = 30
[led]
# RGB 规则:
# 正常状态 = 完全熄灭
# 颜色 = 哪一类对象发生故障
# 闪烁频率 = 严重程度,越严重闪得越快
#
# 当前颜色语义:
# CPU负载 = 黄
# 温度/散热 = 红
# 内存 = 紫
# 磁盘 = 橙
# 网络 = 蓝
# 服务/进程 = 青
#
# brightness 是 RGB 最大输出百分比(0~100)。
# 3颗 RGB 会一起亮,卧室建议 5~15。
brightness = 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
[dark_mode] [dark_mode]
# Raspberry Pi 4 板载 PWR / ACT LED。 # Raspberry Pi 4 板载 PWR / ACT LED。
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。 # 本机硬件只支持开/关,不支持真正的 PWM 调暗。
# #
# off = Pigway Pi Control 启动时关闭该灯。 # off = PIGWay Pi Control 启动时关闭该灯。
# 若不希望程序干预,可改为 on。 # 若不希望程序干预,可改为 on。
# #
# 注意: # 注意:
@@ -221,6 +147,12 @@ AWESUN = runawesun.service
# NGINX = nginx.service # NGINX = nginx.service
[service_monitor_disabled]
# Web 中“暂停监控”的状态保存在这里。
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
# 通常由 Web 自动维护,无需手工编辑。
[processes] [processes]
# ============================================================================= # =============================================================================
# 普通进程监控 # 普通进程监控
+81
View File
@@ -0,0 +1,81 @@
# Local hardware plugin API v1
The monitor and plugins are separate applications. The monitor has no hardware
library dependency. Plugins alone own their hardware. Installing or discovering
a plugin does not connect it or start another service.
## Transport and authorization
HTTP/1.0 or HTTP/1.1 over `/run/pigway-plugins/<id>/api.sock`. No TCP listener.
The parent directory is root-owned, mode 0750, and the socket is 0660 with group
`pigway-hardware`. Root and deliberately enrolled group members may control the
plugin. The monitor Web API authenticates mutations with its existing bearer
token before proxying local requests. Remote machine operations require that
machine's token too. API v1 clients reject incompatible descriptor versions.
## Endpoints
- `GET /v1/descriptor`: `api_version`, `id`, `name`, `version`, `capabilities`, `schema`.
- `GET /v1/status`: availability, connection state and capability-specific actual
or commanded state. A missing tachometer must never be presented as measured RPM.
- `GET /v1/config`: `config` plus `schema`.
- `PUT /v1/config`: `{"updates":{"section":{"key":"value"}}}`; validate the complete
candidate before persisting or applying anything. Does not restart the monitor.
- `PUT /v1/state`: replace the complete monitoring snapshot, never append commands.
- `DELETE /v1/state`: release a lease. Publisher passes `source` and `session`;
authorized local administration may use `{}` to detach the current source.
Example snapshot:
```json
{
"api_version": 1,
"source": "raspberrypi",
"session": "a-new-uuid-for-each-monitor-process",
"revision": 1,
"ttl_seconds": 10,
"system": {"cpu_percent": 12, "temperature_c": 50, "memory_percent": 20, "disk_percent": 30},
"network": {"kind": "WIF", "metric": "-60", "ip_label": "IP4", "ip": "192.168.1.2"},
"alerts": [{"id":"SERVICE_DOWN|APP","category":"service","severity":2,"priority":60,
"title":"SERVICE DOWN","l2":"APP","l3":"CHECK SERVICE","l4":"CRITICAL"}]
}
```
Alert categories: `cpu`, `power`, `memory`, `storage`, `network`, `service`.
The producer sends only current alerts, never historical power flags. Empty
`alerts` clears all previous alerts. A source/session owns its lease until
release or expiry; a competing source and repeated/out-of-order revisions are
rejected. Leases use the receiving process's monotonic clock, 3–30 seconds.
A new monitor session retries until the old lease expires if the old process
could not release it. Each plugin has an independent bounded-time publisher and
one replaceable latest snapshot, so an offline plugin cannot queue stale frames
or block monitoring or other plugins.
## Capability-driven UI
`fan` declares levels or PWM, RPM availability, autonomous operation and ownership.
`display` declares dimensions and pages. `rgb` declares modes, preset colors and
experimental custom flashing. Absent capability means absent controls. A plugin
with only a fan does not imply a screen or lighting device. New hardware needs
its own driver; I2C, PWM GPIO and power-only fans are not interchangeable.
`schema` groups fields by section. Each field provides bilingual `label`, `type`
(`number`/`enum`), numeric limits or choices, unit and optional visibility rules.
Optional `color_group` and `channel` (`r`/`g`/`b`) associate three numeric fields
with a color picker. Plugins remain authoritative for all validation.
## Failures and independent cooling
Monitoring continues without installed or reachable plugins. Hardware plugins
continue local temperature control without the monitor. Expired snapshots must
not keep showing stale alerts as live faults. The Yahboom implementation returns
to its locally sampled home and configured normal RGB mode on expiry or detach.
It samples CPU, memory, disk, temperature and primary network independently.
API v1 system/network fields remain accepted for compatibility but never override
local telemetry. Monitor connectivity only supplies additional alerts.
Its thermal read failure and service shutdown request full fan speed as a cooling
fallback; actual execution still depends on functioning hardware.
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
plugin service. No API/Web service has a systemd Wants dependency that starts the
other application. Do not run two drivers for the same MCU, GPIO or fan.
+45 -73
View File
@@ -1,82 +1,54 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
NO_START=0
echo "[1/7] 检查 Raspberry Pi I2C..." for arg in "$@"; do
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; } case "$arg" in
--no-start) NO_START=1 ;;
echo "[2/7] 等待 APT/DPKG..." -h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; exit 0 ;;
for i in $(seq 1 60); do *) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi esac
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
echo "软件包管理器忙,等待 5 秒... ($i/60)"
sleep 5
done done
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
echo "[3/7] 安装依赖..." [ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
apt update done
apt install -y python3 python3-pil python3-smbus2 i2c-tools [ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
echo "[4/7] 检查硬件地址..." command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
SCAN="$(i2cdetect -y 1)"; echo "$SCAN" mkdir -p /usr/local/share/pigway-pi-control/web
echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C" install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D" install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
echo "[5/7] 安装程序与配置..."
if systemctl list-unit-files 2>/dev/null | grep -q '^temp-control.service'; then
echo "检测到旧 temp-control.service,正在停止并禁用..."
systemctl disable --now temp-control.service 2>/dev/null || true
fi
pkill -f '/temp_control/temp_control' 2>/dev/null || true
mkdir -p /usr/local/share/pigway-pi-control
install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_font_5x7.bin
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
NEW=/tmp/pigway-pi-control.conf.new
cp config/pigway-pi-control.conf "$NEW"
if [ -f /etc/pigway-pi-control.conf ]; then if [ -f /etc/pigway-pi-control.conf ]; then
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)" cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
# Preserve user's [services] and [processes] sections; adopt new defaults elsewhere. else
python3 - "$NEW" /etc/pigway-pi-control.conf <<'PY' install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
import configparser,sys
newf,oldf=sys.argv[1:]
n=configparser.ConfigParser(); n.optionxform=str; n.read(newf)
o=configparser.ConfigParser(); o.optionxform=str; o.read(oldf)
for sec in ("services","processes"):
if o.has_section(sec):
if n.has_section(sec): n.remove_section(sec)
n.add_section(sec)
for k,v in o.items(sec): n.set(sec,k,v)
with open(newf,"w") as f:n.write(f)
PY
fi fi
install -m 0644 "$NEW" /etc/pigway-pi-control.conf # Legacy hardware sections are deliberately preserved for explicit migration,
rm -f "$NEW" # but ignored by the monitor and never exposed as monitor configuration.
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service python3 - <<'PY'
from pathlib import Path
echo "[6/7] 启用/重启服务..." import configparser
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
with p.open('a') as f:
for section,values in {'device':{'identifier':'auto','name':'auto'},'hosts':{},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
if not c.has_section(section):
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
PY
if [ ! -s /etc/pigway-pi-control-api.token ]; then
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
fi
chmod 0600 /etc/pigway-pi-control-api.token
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
systemctl enable pigway-pi-control.service >/dev/null if [ "$NO_START" -eq 0 ]; then
systemctl restart pigway-pi-control.service systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
systemctl restart pigway-pi-control.service pigway-pi-control-api.service
echo "[7/8] 安装后自检..." systemctl is-active --quiet pigway-pi-control.service
FAIL=0 systemctl is-active --quiet pigway-pi-control-api.service
check() { if eval "$2"; then printf " %-30s PASS\n" "$1"; else printf " %-30s FAIL\n" "$1"; FAIL=1; fi; } fi
check "Version 3.2.0" "grep -q 'APP_VERSION=\"3.2.0\"' /usr/local/sbin/pigway-pi-control" echo 'Monitor installed. Hardware plugins are installed and connected separately.'
check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control" echo 'Config: /etc/pigway-pi-control.conf'
check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control" echo 'API token: sudo cat /etc/pigway-pi-control-api.token'
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"
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
echo "[8/8] 状态..."
systemctl --no-pager --full status pigway-pi-control.service || true
echo
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
echo "配置:/etc/pigway-pi-control.conf"
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=PIGWay Pi Control API and Web
After=network.target pigway-pi-control.service
Wants=network.target
[Service]
Type=simple
ExecStart=/usr/local/sbin/pigway-pi-control-api
Restart=always
RestartSec=2
User=root
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
[Install]
WantedBy=multi-user.target
+3 -3
View File
@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor Description=PIGWay system monitor
After=multi-user.target network.target After=local-fs.target systemd-modules-load.service
Wants=network.target Before=multi-user.target
[Service] [Service]
Type=simple Type=simple
+4 -1
View File
@@ -1,8 +1,11 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
systemctl disable --now pigway-pi-control.service 2>/dev/null || true systemctl disable --now pigway-pi-control-api.service pigway-pi-control.service 2>/dev/null || true
rm -f /etc/systemd/system/pigway-pi-control-api.service
rm -f /etc/systemd/system/pigway-pi-control.service rm -f /etc/systemd/system/pigway-pi-control.service
rm -f /usr/local/sbin/pigway-pi-control rm -f /usr/local/sbin/pigway-pi-control
rm -f /usr/local/sbin/pigway-pi-control-api
rm -f /etc/pigway-pi-control-api.token
rm -rf /usr/local/share/pigway-pi-control rm -rf /usr/local/share/pigway-pi-control
systemctl daemon-reload systemctl daemon-reload
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。" echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
+117
View File
File diff suppressed because one or more lines are too long