Compare commits
70
Commits
v3.2.0
..
52a6e115f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a6e115f5 | ||
|
|
ccf1e22514 | ||
|
|
c8c12b7b70 | ||
|
|
ced0140450 | ||
|
|
ddce873745 | ||
|
|
324390f647 | ||
|
|
1f581087b2 | ||
|
|
0dac42cd64 | ||
|
|
0d7a686c65 | ||
|
|
630a6a98da | ||
|
|
7befb325fb | ||
|
|
f0bef66651 | ||
|
|
7c4de5843d | ||
|
|
2ec34bbd57 | ||
|
|
33e90eb535 | ||
|
|
c106b4c264 | ||
|
|
52f56eefc5 | ||
|
|
849fd016b6 | ||
|
|
993d95bab1 | ||
|
|
a976dfa3fa | ||
|
|
ae8f756749 | ||
|
|
c3ed599198 | ||
|
|
38362be824 | ||
|
|
2f82cc36b4 | ||
|
|
260d8fad8f | ||
|
|
9babcfabc1 | ||
|
|
152ecc6e2b | ||
|
|
a96cdb859d | ||
|
|
e2cec738c8 | ||
|
|
f505f3c23b | ||
|
|
b7a77a4661 | ||
|
|
9824c54d6b | ||
|
|
974121de99 | ||
|
|
5d744824a8 | ||
|
|
b08721fd15 | ||
|
|
cd9bdd5970 | ||
|
|
08c00aa370 | ||
|
|
13f6155096 | ||
|
|
97598a48ad | ||
|
|
01c07658c0 | ||
|
|
4ba4d461db | ||
|
|
45c7b8c168 | ||
|
|
b39c7e5279 | ||
|
|
2e5562fb3e | ||
|
|
03fd6d1cd8 | ||
|
|
8cb71cbe9f | ||
|
|
73907320c8 | ||
|
|
ad25c08eb1 | ||
|
|
5d541d5ef6 | ||
|
|
a8ca071b56 | ||
|
|
419d27b570 | ||
|
|
73a60b4866 | ||
|
|
576006b2fd | ||
|
|
671fde1bfc | ||
|
|
99e086bd25 | ||
|
|
f42ea9d4bd | ||
|
|
fd4e2bf0f4 | ||
|
|
5402dddd15 | ||
|
|
6ca19b362b | ||
|
|
adfa5524b8 | ||
|
|
ac9bc4befd | ||
|
|
ad22dcced7 | ||
|
|
249af5a3e3 | ||
|
|
a84f4895ef | ||
|
|
6fc428ef18 | ||
|
|
34f334dd23 | ||
|
|
d3ccca4b37 | ||
|
|
3a302b9c78 | ||
|
|
83ec3f4379 | ||
|
|
2ef8007ccd |
@@ -1,385 +1,50 @@
|
|||||||
# Pigway Pi Control v3.2.0 — Release
|
# PIGWay Device Agent v3.7.0
|
||||||
|
|
||||||
适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。
|
极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
|
||||||
|
|
||||||
本包遵循一个原则:
|
## 安装
|
||||||
|
|
||||||
> **一个程序 + 一个用户配置文件 + 一个说明文档。**
|
|
||||||
|
|
||||||
正常使用时,不需要修改源码。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
||||||
|
# 显式启用 API:
|
||||||
|
sudo ./install.sh --enable-api
|
||||||
|
# 不启用 API:
|
||||||
|
sudo ./install.sh --disable-api
|
||||||
```
|
```
|
||||||
|
|
||||||
安装器会:
|
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
|
||||||
|
|
||||||
1. 检查 `/dev/i2c-1`
|
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
|
||||||
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 systemctl restart pigway-pi-control.service
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 可选 API
|
||||||
|
|
||||||
## 3. 安装后的文件在哪里
|
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
|
||||||
|
|
||||||
### 用户应该修改
|
|
||||||
|
|
||||||
只有:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
编辑:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nano /etc/pigway-pi-control.conf
|
sudo systemctl enable --now pigway-pi-control-api.service
|
||||||
|
sudo cat /etc/pigway-pi-control-api.token
|
||||||
```
|
```
|
||||||
|
|
||||||
保存后:
|
默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
|
||||||
|
|
||||||
|
API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
|
||||||
|
|
||||||
|
## 独立管理中心与插件
|
||||||
|
|
||||||
|
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
|
||||||
|
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat):独立本地采集、OLED、风扇与 RGB。
|
||||||
|
|
||||||
|
远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
|
||||||
|
|
||||||
|
## 运维
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl restart pigway-pi-control
|
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||||
|
sudo journalctl -u pigway-pi-control.service -f
|
||||||
```
|
```
|
||||||
|
|
||||||
### 用户通常不要修改
|
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
|
||||||
|
|
||||||
程序:
|
|
||||||
|
|
||||||
```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 弱信号持续告警。
|
|
||||||
|
|||||||
+10
-7
@@ -1,7 +1,10 @@
|
|||||||
ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md
|
9c1fe8e9860aa4e5c9cd3ceb2199cdd118dd246a3b0f255f540976cf1877eef4 README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
|
||||||
0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf
|
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
|
||||||
7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh
|
f91af58e7dd752fa97c168ae419e14e6ee5099ea58d8252568f7ce72affd219f app/pigway_pi_control_api.py
|
||||||
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||||
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
|
60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf
|
||||||
|
5e16c1d5d592fa3e129cde0077da6b089784c82f452205a349d4824f48eb3a66 systemd/pigway-pi-control-api.service
|
||||||
|
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
|
||||||
|
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
||||||
|
|||||||
Binary file not shown.
+333
-249
@@ -1,24 +1,105 @@
|
|||||||
#!/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
|
||||||
|
# Device tree identifies ARM boards; DMI covers PCs and many other vendors.
|
||||||
|
for key,files in {
|
||||||
|
"manufacturer":["/sys/class/dmi/id/sys_vendor"],
|
||||||
|
"model":["/sys/firmware/devicetree/base/model","/sys/class/dmi/id/product_name"],
|
||||||
|
"revision":["/sys/class/dmi/id/product_version","/sys/class/dmi/id/board_version"],
|
||||||
|
"serial":["/sys/class/dmi/id/product_serial"],
|
||||||
|
}.items():
|
||||||
|
if info.get(key):continue
|
||||||
|
for filename in files:
|
||||||
|
try:value=Path(filename).read_text().strip().strip("\0")
|
||||||
|
except (OSError,UnicodeError):continue
|
||||||
|
if value and value.lower() not in ("none","unknown","default string","to be filled by o.e.m."):
|
||||||
|
info[key]=value;break
|
||||||
|
if not info.get("manufacturer"):
|
||||||
|
try:info["manufacturer"]=Path("/sys/firmware/devicetree/base/compatible").read_bytes().split(b"\0")[0].decode().split(',')[0]
|
||||||
|
except (OSError,UnicodeError):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 +109,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 +122,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 +138,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 +163,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 +179,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 +193,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 +217,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 +343,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()
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
#!/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")
|
||||||
|
ALLOWED_SECTIONS={"device","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 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=="api" and key=="enabled":
|
||||||
|
if value.lower() not in {"true","false"}:raise ValueError("enabled must be true or false")
|
||||||
|
return
|
||||||
|
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"} 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")
|
||||||
|
|
||||||
|
|
||||||
|
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)^\[[^\]\r\n]+\]",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 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)^\[[^\]\r\n]+\]",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 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)^\[[^\]\r\n]+\]",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 not self.authorized():self.send_json(401,{"error":"bearer token required"});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/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=="/api/v1/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 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")
|
||||||
|
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 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]
|
||||||
|
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]
|
||||||
|
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"}: 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 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/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():
|
||||||
|
if not load_cfg().getboolean("api","enabled",fallback=False):
|
||||||
|
print("API_DISABLED",flush=True);sys.exit(78)
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
+40
-113
@@ -1,45 +1,34 @@
|
|||||||
# =============================================================================
|
# 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
|
|
||||||
#
|
[api]
|
||||||
# 查看实时日志:
|
# 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
|
||||||
# journalctl -u pigway-pi-control -f
|
enabled = false
|
||||||
#
|
# API 监听地址和端口。0.0.0.0 允许局域网访问;
|
||||||
# 重要:
|
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||||
# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。
|
bind = 0.0.0.0
|
||||||
# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。
|
port = 6001
|
||||||
# 3. 数值单位和建议范围见每项上方注释。
|
log_limit = 200
|
||||||
# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
[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 +37,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 +80,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 +142,12 @@ AWESUN = runawesun.service
|
|||||||
# NGINX = nginx.service
|
# NGINX = nginx.service
|
||||||
|
|
||||||
|
|
||||||
|
[service_monitor_disabled]
|
||||||
|
# Web 中“暂停监控”的状态保存在这里。
|
||||||
|
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
|
||||||
|
# 通常由 Web 自动维护,无需手工编辑。
|
||||||
|
|
||||||
|
|
||||||
[processes]
|
[processes]
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 普通进程监控
|
# 普通进程监控
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Optional listeners
|
||||||
|
|
||||||
|
Agent network API defaults to disabled (`[api] enabled=false`) and has no Web UI.
|
||||||
|
When enabled, every request requires a device bearer token.
|
||||||
|
Hardware plugin integration defaults to disabled (`[integration] enabled=false`):
|
||||||
|
no Unix API socket is created, while local telemetry and hardware workers continue.
|
||||||
|
Change integration locally and restart the plugin. Monitor-to-plugin linkage does
|
||||||
|
not require the Agent network API. The independent Web Manager stores registered
|
||||||
|
device addresses and tokens; it is not implicitly a monitored device.
|
||||||
+65
-72
@@ -1,82 +1,75 @@
|
|||||||
#!/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..."
|
API_ENABLED=""
|
||||||
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
echo "[2/7] 等待 APT/DPKG..."
|
--enable-api) API_ENABLED=true ;;
|
||||||
for i in $(seq 1 60); do
|
--disable-api) API_ENABLED=false ;;
|
||||||
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi
|
--no-start) NO_START=1 ;;
|
||||||
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
|
-h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
|
||||||
echo "软件包管理器忙,等待 5 秒... ($i/60)"
|
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
||||||
sleep 5
|
esac
|
||||||
done
|
done
|
||||||
|
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py 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"
|
|
||||||
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 "检测到旧 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
|
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 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
|
||||||
|
|
||||||
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'},'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/
|
||||||
|
if [ -z "$API_ENABLED" ]; then
|
||||||
|
API_ENABLED=false
|
||||||
|
if [ -t 0 ]; then
|
||||||
|
read -r -p 'Enable remote management API? [y/N] ' answer
|
||||||
|
case "$answer" in y|Y|yes|YES) API_ENABLED=true ;; esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
API_ENABLED="$API_ENABLED" python3 - <<'PYAPI'
|
||||||
|
import os,sys
|
||||||
|
sys.path.insert(0,'app')
|
||||||
|
import pigway_pi_control_api as api
|
||||||
|
api.save_ini_text(api.replace_ini_value('api','enabled',os.environ['API_ENABLED']),'install')
|
||||||
|
PYAPI
|
||||||
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 >/dev/null
|
||||||
|
systemctl restart pigway-pi-control.service
|
||||||
echo "[7/8] 安装后自检..."
|
systemctl is-active --quiet pigway-pi-control.service
|
||||||
FAIL=0
|
if [ "$API_ENABLED" = true ]; then
|
||||||
check() { if eval "$2"; then printf " %-30s PASS\n" "$1"; else printf " %-30s FAIL\n" "$1"; FAIL=1; fi; }
|
systemctl enable pigway-pi-control-api.service >/dev/null
|
||||||
check "Version 3.2.0" "grep -q 'APP_VERSION=\"3.2.0\"' /usr/local/sbin/pigway-pi-control"
|
systemctl restart pigway-pi-control-api.service
|
||||||
check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control"
|
systemctl is-active --quiet pigway-pi-control-api.service
|
||||||
check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control"
|
else
|
||||||
check "OLED pages 0..3" "grep -q 'cmd(0x22);cmd(0);cmd(3)' /usr/local/sbin/pigway-pi-control"
|
systemctl disable --now pigway-pi-control-api.service
|
||||||
check "UI CPU/TMP/DSK" "grep -q 'f\"TMP {temp:.1f}C\"' /usr/local/sbin/pigway-pi-control"
|
fi
|
||||||
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"
|
fi
|
||||||
check "UI right column x=72" "grep -q \"draw_text_5x7(pix,72,y,right\\[row\\])\" /usr/local/sbin/pigway-pi-control"
|
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
|
||||||
check "Legacy service stopped" "! systemctl is-active --quiet temp-control.service"
|
echo 'Config: /etc/pigway-pi-control.conf'
|
||||||
check "Service running" "systemctl is-active --quiet pigway-pi-control.service"
|
echo 'API token: sudo cat /etc/pigway-pi-control-api.token'
|
||||||
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"
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PIGWay Device Agent optional local API
|
||||||
|
After=network.target pigway-pi-control.service
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/sbin/pigway-pi-control-api
|
||||||
|
Restart=always
|
||||||
|
RestartPreventExitStatus=78
|
||||||
|
SuccessExitStatus=78
|
||||||
|
RestartSec=2
|
||||||
|
User=root
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -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
@@ -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 已保留。"
|
||||||
|
|||||||
Reference in New Issue
Block a user