Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5912a55215 | ||
|
|
9b83937d60 | ||
|
|
c82d9ab3c2 | ||
|
|
8b7100daa5 | ||
|
|
747503d86d | ||
|
|
174ac747c5 | ||
|
|
b63fa39f32 | ||
|
|
e828fd11ee | ||
|
|
7aa8f838a6 | ||
|
|
e55ad3217f | ||
|
|
26c313df46 | ||
|
|
fc27079126 | ||
|
|
b0998555d9 | ||
|
|
f735c3fb4e | ||
|
|
436b87f40e | ||
|
|
302c38986e | ||
|
|
72a3a2e0f3 | ||
|
|
95494aa894 | ||
|
|
10f57a5699 | ||
|
|
11f8ffd5f6 | ||
|
|
75d0ce2d85 | ||
|
|
38382ba61c | ||
|
|
da5f53ad99 | ||
|
|
1087ec828f | ||
|
|
ece74e0a3f | ||
|
|
b60928c30f | ||
|
|
5f3e9353ed | ||
|
|
510f90288a | ||
|
|
52b21db59e | ||
|
|
99ae7af238 | ||
|
|
a01b34af6b | ||
|
|
33e632e4b9 | ||
|
|
3ef26a76fc | ||
|
|
bef4f72b44 | ||
|
|
c03f481dd4 | ||
|
|
8b90dc9612 | ||
|
|
a4d9d9aba7 | ||
|
|
cc31f71c7b | ||
|
|
83d1d9b8c3 | ||
|
|
2e9acee06e | ||
|
|
af03722fe3 | ||
|
|
ded4cb514c | ||
|
|
2515c64c22 | ||
|
|
4bcc12f91c | ||
|
|
b957c63847 | ||
|
|
389f02a42e | ||
|
|
451ecebfd1 | ||
|
|
4086e80a4b | ||
|
|
f109b5eaa7 | ||
|
|
9ffc6a2b18 | ||
|
|
0b0ec5a23d | ||
|
|
f5c0c4c6b5 | ||
|
|
65939cec4b | ||
|
|
8a31ccb211 | ||
|
|
02656afbe2 | ||
|
|
6336509630 | ||
|
|
efd5226ccc | ||
|
|
a3c37b3e95 | ||
|
|
2fed6ddfd7 | ||
|
|
7e770fc655 | ||
|
|
552863704c | ||
|
|
a312bcf12b | ||
|
|
636e498de3 | ||
|
|
36008558fc | ||
|
|
20f7898bfa | ||
|
|
75d73d009a | ||
|
|
33e9525f10 | ||
|
|
0f3a8a17a5 | ||
|
|
67d7d1ca5c | ||
|
|
66248a22ea | ||
|
|
6a0ae4ce28 | ||
|
|
05490bac5d | ||
|
|
703d62fa46 | ||
|
|
68defc6270 | ||
|
|
b406e73d29 | ||
|
|
5976764bb7 | ||
|
|
4c2c0f5ddf | ||
|
|
ab652cc2a1 | ||
|
|
9ad5cc03b3 | ||
|
|
91ac32117a | ||
|
|
910e0dcc61 | ||
|
|
5279dbe70f | ||
|
|
4826055f12 | ||
|
|
99375cd1c7 | ||
|
|
a041aead42 | ||
|
|
1441376358 | ||
|
|
7ca5d6483b | ||
|
|
387cba3907 | ||
|
|
7dadb6b6e5 | ||
|
|
9d4afd18ab |
@@ -0,0 +1,22 @@
|
||||
name: Verify
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
# Fork code may run on GitHub-hosted runners, never the trusted Gitea runner.
|
||||
if: github.server_url == 'https://github.com' || github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- run: python -m unittest discover -s tests -v
|
||||
- run: git diff --check
|
||||
@@ -3,3 +3,10 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
.data/
|
||||
dist/
|
||||
/storage-release.json
|
||||
/ops/deploy-storage.py
|
||||
/ops/release-storage.py
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 贡献指南 / Contributing
|
||||
|
||||
安装和运行步骤见项目首页;使用 Python 3.13 或 3.14,修改保持小而集中。运行 `python3 -m unittest discover -s tests -v`,Shell 脚本使用 `bash -n`,提交前运行 `git diff --check`。
|
||||
|
||||
测试只能使用临时目录、模拟服务和设备桩;不得访问真实设备、执行生产写入或提交密钥、令牌、数据库、照片及部署回执。真实设备验证由维护者另行执行。
|
||||
|
||||
请通过仓库 Pull Request 提交,说明问题、行为变化和验证结果。行为修复应覆盖对应回归风险。新增代码按本项目 MIT 许可证提供,保留第三方许可及来源。
|
||||
|
||||
See README.md for installation. Keep changes focused, test with mocks, and submit a pull request describing the behavior and checks. Never include credentials or production data. Contributions use the MIT license; preserve third-party notices.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 PIGWay
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,381 +1,61 @@
|
||||
# Pigway Pi Control v3.1.0 — Release
|
||||
# PIGWay Raspberry Pi Agent v3.11.3
|
||||
|
||||
适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
本包遵循一个原则:
|
||||
Disk discovery is manual: Device Console → System configuration → select the device → Storage settings → Scan disks. The inventory persists across restarts; known filesystem capacity updates weekly. Names and visibility belong to the Agent. See [deployment and validation](docs/STORAGE.md).
|
||||
|
||||
> **一个程序 + 一个用户配置文件 + 一个说明文档。**
|
||||
|
||||
正常使用时,不需要修改源码。
|
||||
PIGWay Raspberry Pi Agent is a lightweight local monitoring service for Raspberry Pi. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. Ethernet monitoring reads the negotiated link speed and can alert when it remains below the configured minimum. It has no embedded Web UI and does not require a hardware plugin or Device Console.
|
||||
|
||||
---
|
||||
### Installation
|
||||
|
||||
## 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.1.100
|
||||
```
|
||||
|
||||
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.1.0
|
||||
```
|
||||
|
||||
### 第三步:安装
|
||||
See the [Trixie fresh-install guide](docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md) for Raspberry Pi OS Lite 64-bit based on Debian 13. The Agent itself does not access GPIO or I2C; OLED, RGB, and fan control belong to the independent hardware plugin.
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# Explicitly enable the API:
|
||||
sudo ./install.sh --enable-api
|
||||
# Keep the API disabled:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
安装器会:
|
||||
Interactive installation asks whether to enable the remote API and defaults to no. Non-interactive installation also keeps it disabled unless explicitly enabled. `--no-start` only updates files and configuration; it does not change the current service state.
|
||||
|
||||
1. 检查 `/dev/i2c-1`
|
||||
2. 等待 APT/DPKG 锁
|
||||
3. 安装必要的小型系统依赖
|
||||
4. 检查 I²C 总线
|
||||
5. 自动停止并禁用卖家旧 `temp-control.service`
|
||||
6. 安装正式程序
|
||||
7. 安装用户配置
|
||||
8. 安装 systemd 服务并开机自启
|
||||
9. 执行安装后自检
|
||||
|
||||
安装成功后查看:
|
||||
The configuration file is `/etc/pigway-pi-control.conf`. Restart the Agent after changing monitoring settings:
|
||||
|
||||
```bash
|
||||
systemctl status pigway-pi-control --no-pager -l
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
---
|
||||
### Optional API
|
||||
|
||||
## 3. 安装后的文件在哪里
|
||||
|
||||
### 用户应该修改
|
||||
|
||||
只有:
|
||||
|
||||
```text
|
||||
/etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
编辑:
|
||||
The default is `[api] enabled = false`, with no network listener. To use centralized management, enable it and configure the bind address and port, then run:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
保存后:
|
||||
The default port is 6001. Every read and write request requires `Authorization: Bearer <token>`. The token belongs only to this device and is not printed directly by the installer. The API exposes health, status, logs, configuration, services, plugins, and plugin configuration under `/api/v1`. It does not serve a Web UI, register multiple machines, or proxy remote requests.
|
||||
|
||||
### Console and hardware plugins
|
||||
|
||||
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) can run on a NAS or remote server for dashboards, configuration, and batch synchronization.
|
||||
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat) independently provides local telemetry, OLED, fan control, and RGB effects.
|
||||
|
||||
The remote API and local plugin integration are independent. After enabling plugin integration, register `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` under `[plugins]` and restart the Agent, or attach it through the Console. Discovery never binds a plugin automatically. Pausing or disconnecting integration does not stop the plugin's local functions.
|
||||
|
||||
### Operations
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
```
|
||||
|
||||
### 用户通常不要修改
|
||||
Logs follow the system journal retention policy; no additional database is created. Uninstalling one project never uninstalls either of the others. See [Plugin API v1](docs/HARDWARE_PLUGIN_API.md).
|
||||
|
||||
程序:
|
||||
## Naming and compatibility
|
||||
|
||||
```text
|
||||
/usr/local/sbin/pigway-pi-control
|
||||
```
|
||||
This project targets Raspberry Pi. Other Linux platforms are not covered by its support claim and may have separately maintained adapters. Repository naming does not change the existing `pigway-agent` adapter ID or `pigway-pi-control` service, configuration and API identifiers.
|
||||
|
||||
systemd:
|
||||
|
||||
```text
|
||||
/etc/systemd/system/pigway-pi-control.service
|
||||
```
|
||||
|
||||
这些由安装包维护。
|
||||
|
||||
因此安装成功后,解压出来的 `pigway-pi-control-v3.1.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.1.0
|
||||
6. sudo ./install.sh
|
||||
```
|
||||
|
||||
完成。
|
||||
[Documentation](docs/README.md)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# PIGWay Raspberry Pi Agent v3.11.3
|
||||
|
||||
简体中文 | [English](README.md)
|
||||
|
||||
新增磁盘由 Console → 系统配置 → 选择设备 → 磁盘设置 → 扫描磁盘手动发现;已保存清单跨重启保留,已知磁盘容量每周刷新。支持在 Agent 保存名称和隐藏设置。见 [部署与验证](docs/STORAGE.md)。
|
||||
|
||||
|
||||
极轻的树莓派本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。有线网络会读取真实协商速率,并可在持续低于配置阈值时告警。无 Web 界面;无需硬件插件,也无需管理中心。
|
||||
|
||||
## 安装
|
||||
|
||||
Raspberry Pi OS Lite 64-bit(Debian 13 Trixie)的全新安装与兼容性说明见 [Trixie 全新安装指南](docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)。监控 Agent 本身不访问 GPIO/I²C;OLED、RGB 和风扇由独立硬件插件负责。
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# 显式启用 API:
|
||||
sudo ./install.sh --enable-api
|
||||
# 不启用 API:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
|
||||
|
||||
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
## 可选 API
|
||||
|
||||
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
|
||||
|
||||
```bash
|
||||
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
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
```
|
||||
|
||||
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
|
||||
|
||||
## 名称与兼容性
|
||||
|
||||
本项目支持范围明确为树莓派;其他 Linux 平台以后独立维护项目。仓库改名不改变现有 `pigway-pi-control` 服务名、配置路径和 API。远程 API 仍可选择启用,默认关闭。
|
||||
|
||||
[文档目录](docs/README.md)
|
||||
@@ -0,0 +1,13 @@
|
||||
# pigway-raspberry-pi-agent v3.11.3
|
||||
|
||||
- 监控快照超过三秒停止续期硬件插件租约,状态 API 对超过十秒的状态返回 503,避免采集停滞仍显示为新状态。
|
||||
- 配置 API 校验有限数值、范围、阈值关系和服务目标;保留名称及进程表达式中的字面百分号。
|
||||
- 配置写入使用进程内事务锁、唯一备份及原子替换,避免并发覆盖。
|
||||
- 告警日志按严重程度记录,严重程度变化单独记事件;主要告警优先按严重程度排序,磁盘提示使用实际阈值。
|
||||
- 根分区缓存恢复期限与七天采集周期一致。
|
||||
|
||||
本地 17 项回归测试通过,覆盖并发配置、旧状态拒绝与恢复、快照停滞、告警和缓存。本记录不作为当前生产状态的证明。
|
||||
|
||||
统一开源许可证、贡献和安全反馈入口;完善当前版本的文档导航、源码分发和校验和。插件 ZIP 携带许可证。运行功能边界保持项目首页所述。
|
||||
|
||||
本轮 17 项 Python 测试通过。源码语法、文档入口、打包和校验和检查通过;生产运行状态以实际部署验收为准。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 安全反馈 / Security
|
||||
|
||||
仅支持当前发布版本。请勿在公开 Issue 中发布密钥、令牌、个人数据或可直接利用的完整漏洞细节。
|
||||
|
||||
请通过仓库维护者 [way](https://tea.pigway.com/way) 的私有联系渠道报告,包含受影响版本、复现条件、影响和最小化示例。若尚未建立私有渠道,先在 Issue 请求私下联系,不附敏感细节。
|
||||
|
||||
Only the current release is supported. Contact the maintainer privately before disclosing exploit details; include the version, impact and a minimal reproduction. Do not attach credentials or private data to public issues.
|
||||
+28
-7
@@ -1,7 +1,28 @@
|
||||
58f7c0cc3fe0b76a3d0717914b3e1c70fb9068e6719f9f60bf573a48ed2cbc41 README.md
|
||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||
0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py
|
||||
48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf
|
||||
7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh
|
||||
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
||||
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
|
||||
1db9d786fdc6c2ae84b7d317e981b23e303a5bc0b72704d43558b29373d97ade .github/workflows/verify.yml
|
||||
4035aba62136040755ffdf2faf1556a49215e829c16ee44263e0ab7530fbf521 .gitignore
|
||||
79f34290cfa1a6f0928162909848e6597d46183d0de6b213bf52b12546f5fc33 CONTRIBUTING.md
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
9c90aa203d27dea1ff6493ff401ce252daa50b340672aa02b84e6729387ffe78 README.md
|
||||
cf797200392a367fbe2f4fc79deabcd430249299947bd2d1d8d8cace49bfb97b README.zh-CN.md
|
||||
54fa1c8f12d277d3309d02885d83bb317cdb0eda0ead1f9e16ccf2960c01b552 RELEASE_NOTES.md
|
||||
6cead6c1378cd840f655adc3d467e2cd6fd3ab368d7200a3afbfe85d9ed159be SECURITY.md
|
||||
c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py
|
||||
5f553c03839a2acb86265b24db656b358b4cde6b3c617eef97c9316868578e1b app/pigway_pi_control.py
|
||||
c3278a0c09f7529b50460755eed5470893d6598002ac0e0d47bf6247b100aa77 app/pigway_pi_control_api.py
|
||||
9e011761ddb9583c26505fd85d6b484c440b649cb6169922512020f95a5a7d42 app/plugin_api.py
|
||||
6429ba4e65baae379e892b8c2f15a5a158924b72060a692f0a3b447f8bebfc80 app/storage_cache.py
|
||||
14e0e3337dde66309418658d14d1c61f1742c0f327b76a57126c72f006448165 config/pigway-pi-control.conf
|
||||
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
||||
b54ec1f3e9fb15473910686c54142a85d387e44ea5df427a4cf1b06052e789b3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md
|
||||
9222a2b6a38f6b6d7d88b30c05413c68c05f69ee499c8479eca05ee7c62e87c3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md
|
||||
3041278ff521c303d95711d38f31f43dfd38c8ed94999a7a86514b1fa3e09f60 docs/README.md
|
||||
6552741bb5639520c331a8c38d5d99c90f0ab3d9b1f11e571cb789c386bd0577 docs/STORAGE.md
|
||||
a38fae07a9151923706ff708598bf24e89ef4ed7fb9449c3f7b4d013d6e1be93 install.sh
|
||||
8a593c290da5bd0d037aee86c35264512afd574a2a0f60f2c29e9db63e0109bd systemd/pigway-pi-control-api.service
|
||||
1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service
|
||||
a58ff2e2ae49f481618cbb8d83b7cfbaa08ed4361eecbcab54755c886a6163eb tests/test_audit_regressions.py
|
||||
8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py
|
||||
56a7b171f7b6d93721b4888908929b36095a4cb73700a041e37f9bc796309c5e tests/test_storage_cache.py
|
||||
ab555730f3f3955a1338d5d4282addfb2ea1703f6a50541146dc49adbca5694b tests/test_storage_config.py
|
||||
7ef40a31988bc258eb490d39732a1790e6e533b045c3f3ff277d2e0fd274dfa1 tests/test_storage_inventory.py
|
||||
273fa86cdb77a0610ef64a3ad1788fecbe465bfbdf9568fa8928304b6be2cfaa uninstall.sh
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Sample interface counters without summing physical and virtual interfaces."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class NetworkRates:
|
||||
def __init__(self, root='/sys/class/net'):
|
||||
self.root = Path(root)
|
||||
self.previous = None
|
||||
|
||||
def sample(self, interface, now):
|
||||
try:
|
||||
if not re.fullmatch(r'[\w.:-]{1,64}', interface):
|
||||
raise ValueError('invalid interface')
|
||||
directory = self.root / interface / 'statistics'
|
||||
rx = int((directory / 'rx_bytes').read_text())
|
||||
tx = int((directory / 'tx_bytes').read_text())
|
||||
if rx < 0 or tx < 0:
|
||||
raise ValueError('negative counter')
|
||||
except (OSError, ValueError, TypeError):
|
||||
self.previous = None
|
||||
return None, None
|
||||
previous = self.previous
|
||||
self.previous = interface, now, rx, tx
|
||||
if previous is None or previous[0] != interface or now <= previous[1]:
|
||||
return None, None
|
||||
elapsed = now - previous[1]
|
||||
return (None if rx < previous[2] else (rx - previous[2]) / elapsed,
|
||||
None if tx < previous[3] else (tx - previous[3]) / elapsed)
|
||||
Binary file not shown.
+450
-252
@@ -1,91 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
import os,time,socket,subprocess,configparser,signal
|
||||
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
|
||||
from pathlib import Path
|
||||
APP_VERSION="3.1.0"
|
||||
from smbus2 import SMBus
|
||||
from PIL import Image
|
||||
from shutil import disk_usage
|
||||
APP_VERSION="3.11.3"
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from plugin_api import Publisher,registered,discover
|
||||
from network_rates import NetworkRates
|
||||
from storage_cache import StorageCache,StorageInventory
|
||||
storage_cache=StorageCache("/run/pigway-pi-control/storage-cache.json")
|
||||
|
||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
||||
CFG="/etc/pigway-pi-control.conf"
|
||||
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||
running=True
|
||||
|
||||
def stop(*_):
|
||||
global running; running=False
|
||||
signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop)
|
||||
|
||||
bus=SMBus(1)
|
||||
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
|
||||
FONT5=Path(FONT_PATH).read_bytes()
|
||||
fan_level=0
|
||||
def log(level,event,**fields):
|
||||
parts=[f"level={level}",f"event={event}"]
|
||||
for k,v in fields.items(): parts.append(f"{k}={str(v).replace(chr(32),'_')}")
|
||||
print(" ".join(parts),flush=True)
|
||||
|
||||
def uptime_seconds():
|
||||
try:return int(float(Path("/proc/uptime").read_text().split()[0]))
|
||||
except Exception:return 0
|
||||
|
||||
def load_average():
|
||||
try:
|
||||
x=Path("/proc/loadavg").read_text().split();return (float(x[0]),float(x[1]),float(x[2]))
|
||||
except Exception:return (0.0,0.0,0.0)
|
||||
|
||||
def 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
|
||||
page=0; page_since=time.monotonic()
|
||||
cpu_hi=mem_hi=0
|
||||
net_ok=link_ok=True
|
||||
ip="NO IP"
|
||||
sensor_observed_at=None
|
||||
network_observed_at=None
|
||||
network_rates=NetworkRates()
|
||||
net_rx_bytes_per_second=None; net_tx_bytes_per_second=None
|
||||
net_iface=""; net_kind="NET"; net_metric="DOWN"; net_speed_mbps=None; ip_label="IP4"; ip="NO IP"
|
||||
link_failure_since=internet_failure_since=None
|
||||
wifi_weak_since=None; wifi_weak_active=False
|
||||
ethernet_slow_since=None; ethernet_slow_active=False
|
||||
last_net=last_watch=0
|
||||
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):
|
||||
base=Path("/sys/class/leds")/name
|
||||
try:
|
||||
@@ -99,24 +130,6 @@ def apply_dark_mode():
|
||||
if DARK_PWR: set_pi_led("PWR",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():
|
||||
global prev_cpu
|
||||
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
|
||||
@@ -132,39 +145,77 @@ def mem_pct():
|
||||
k,v=l.split(":",1); vals[k]=int(v.split()[0])
|
||||
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
||||
def disk_pct():
|
||||
d=disk_usage("/"); return 100*d.used/d.total
|
||||
def get_ip():
|
||||
d=storage_cache.sample()
|
||||
return 100*d["used"]/d["total"] if d else None
|
||||
def _run(args,timeout=2):
|
||||
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip()
|
||||
except Exception: return ""
|
||||
|
||||
def primary_network():
|
||||
out=_run(["ip","-4","route","get","1.1.1.1"])
|
||||
dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP4"
|
||||
if not dev:
|
||||
out=_run(["ip","-6","route","get","2606:4700:4700::1111"])
|
||||
dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP6"
|
||||
if not dev: return "","NET","DOWN",None,"IP4","NO IP"
|
||||
iface=dev.group(1); ipval=src.group(1) if src else ""
|
||||
speed_mbps=None
|
||||
if Path(f"/sys/class/net/{iface}/wireless").exists():
|
||||
kind="WIF"; metric="--"
|
||||
try:
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(("1.1.1.1",53));v=s.getsockname()[0];s.close();return v
|
||||
except: return "NO IP"
|
||||
for line in Path("/proc/net/wireless").read_text().splitlines():
|
||||
if line.lstrip().startswith(iface+":"):
|
||||
metric=str(int(float(line.split()[3].rstrip(".")))); break
|
||||
except Exception: pass
|
||||
else:
|
||||
kind="ETH"; metric="UP"
|
||||
try:
|
||||
n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip())
|
||||
speed_mbps=n if n>0 else None
|
||||
metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n)
|
||||
except Exception: pass
|
||||
return iface,kind,metric,speed_mbps,label,(ipval or "NO IP")
|
||||
|
||||
def get_ip():
|
||||
return primary_network()[5]
|
||||
|
||||
def internet():
|
||||
try:
|
||||
s=socket.create_connection(("1.1.1.1",53),0.5);s.close();return True
|
||||
except: return False
|
||||
|
||||
def load_cfg():
|
||||
c=configparser.ConfigParser(); c.read(CFG)
|
||||
c=configparser.ConfigParser(interpolation=None); c.read(CFG)
|
||||
watches=[]
|
||||
if c.has_section("services"):
|
||||
for label,target in c["services"].items():
|
||||
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"):
|
||||
for label,target in c["processes"].items():
|
||||
target=target.strip()
|
||||
if target and not target.startswith("#"): watches.append(("process",label.upper(),target))
|
||||
return c,watches
|
||||
cfg,watches=load_cfg()
|
||||
storage_inventory=StorageInventory(cache_path=Path(CFG).with_suffix(".storage.json"),capacity_path="/run/pigway-pi-control/storage-inventory-usage.json")
|
||||
|
||||
def cf(section,key,default,cast=float):
|
||||
try: return cast(cfg.get(section,key))
|
||||
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)
|
||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
||||
STARTUP_GRACE_SECONDS=max(0.0,cf("timing","startup_grace_seconds",15.0))
|
||||
NETWORK_FAILURE_SECONDS=max(0.0,cf("timing","network_failure_seconds",15.0))
|
||||
|
||||
CPU_HIGH=cf("alerts","cpu_high",90.0)
|
||||
CPU_RECOVER=cf("alerts","cpu_recover",75.0)
|
||||
@@ -176,192 +227,339 @@ TEMP_HIGH=cf("alerts","temp_high",68.0)
|
||||
TEMP_CRIT=cf("alerts","temp_critical",75.0)
|
||||
DISK_HIGH=cf("alerts","disk_high",90.0)
|
||||
DISK_CRIT=cf("alerts","disk_critical",95.0)
|
||||
WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
||||
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
||||
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
|
||||
ETHERNET_MIN_SPEED=cf("alerts","ethernet_min_speed_mbps",1000,int)
|
||||
ETHERNET_SPEED_SECONDS=cf("alerts","ethernet_speed_seconds",15.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_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
LED_BRIGHT=cf("led","brightness",10,int)
|
||||
LED_WARN_ON=cf("led","warning_on_ms",300,int); LED_WARN_OFF=cf("led","warning_off_ms",2700,int)
|
||||
LED_CRIT_ON=cf("led","critical_on_ms",400,int); LED_CRIT_OFF=cf("led","critical_off_ms",1100,int)
|
||||
LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emergency_off_ms",400,int)
|
||||
ALERT_CATEGORIES={
|
||||
"ROOT_READONLY":"STORAGE",
|
||||
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
|
||||
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
|
||||
"POWER_UNDERVOLT":"POWER",
|
||||
"MEMORY_HIGH":"MEMORY",
|
||||
"DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE",
|
||||
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK","ETHERNET_SPEED_LOW":"NETWORK",
|
||||
"SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE",
|
||||
}
|
||||
|
||||
def check_watch(w):
|
||||
typ,label,target=w
|
||||
if typ=="service":
|
||||
r=subprocess.run(["systemctl","is-active","--quiet",target])
|
||||
else:
|
||||
r=subprocess.run(["pgrep","-f",target],stdout=subprocess.DEVNULL)
|
||||
args=["systemctl","is-active","--quiet",target] if typ=="service" else ["pgrep","-f","--",target]
|
||||
try:
|
||||
r=subprocess.run(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=2)
|
||||
return r.returncode==0
|
||||
except (OSError,subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
def alert(title,l2="",l3="",l4="",sev=1,color=(20,16,0),blink=0):
|
||||
return {"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
|
||||
"sev":sev,"color":color,"blink":blink}
|
||||
|
||||
def make_alerts(cpu,temp,mem,disk):
|
||||
def diagnose():
|
||||
print("Agent:")
|
||||
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||
print(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,speed,label,address=primary_network()
|
||||
print("Network:")
|
||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} link_speed_mbps={speed if speed is not None else 'N/A'} 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,
|
||||
"ETHERNET_SPEED_LOW":35,
|
||||
"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,ethernet_slow_since,ethernet_slow_active
|
||||
global cpu_hi,mem_hi
|
||||
a=[]
|
||||
cpu_hi=cpu_hi+1 if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_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:a.append(alert("CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","FOR >=30 SEC",1,(24,18,0)))
|
||||
if temp>=TEMP_CRIT:a.append(alert("TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",3,(32,0,0),2))
|
||||
elif temp>=TEMP_HIGH:a.append(alert("TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",1,(24,0,0)))
|
||||
if mem_hi>=MEM_HIGH_SEC:a.append(alert("MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","",1,(18,0,24)))
|
||||
if disk>=DISK_CRIT:a.append(alert("DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE",2,(28,6,0),2))
|
||||
elif disk>=DISK_HIGH:a.append(alert("DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE",1,(28,8,0)))
|
||||
if not link_ok:a.append(alert("NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","",2,(0,8,28),1))
|
||||
elif not net_ok:a.append(alert("INTERNET DOWN","LOCAL LINK OK",ip,"",2,(0,8,28),1))
|
||||
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
||||
mem_hi=mem_hi+sample_elapsed 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")
|
||||
else: alert_manager.clear("CPU_HIGH")
|
||||
if temp>=TEMP_CRIT:
|
||||
alert_manager.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||
alert_manager.clear("TEMP_HIGH")
|
||||
elif temp>=TEMP_HIGH:
|
||||
alert_manager.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||
alert_manager.clear("TEMP_CRITICAL")
|
||||
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 is not None:
|
||||
if disk>=DISK_CRIT:
|
||||
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%",f"USED >= {DISK_CRIT:g}%","CHECK STORAGE")
|
||||
alert_manager.clear("DISK_HIGH")
|
||||
elif disk>=DISK_HIGH:
|
||||
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%",f"USED >= {DISK_HIGH:g}%","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):
|
||||
if not ok:a.append(alert("SERVICE DOWN",w[1],"CHECK / RESTART","",2,(0,24,24),1))
|
||||
a.sort(key=lambda x:x["sev"],reverse=True)
|
||||
return a
|
||||
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():
|
||||
rssi=float(net_metric)
|
||||
if not wifi_weak_active:
|
||||
if rssi <= WIFI_WEAK:
|
||||
if wifi_weak_since is None: wifi_weak_since=time.monotonic()
|
||||
if time.monotonic()-wifi_weak_since >= WIFI_WEAK_SECONDS: wifi_weak_active=True
|
||||
else: wifi_weak_since=None
|
||||
elif rssi >= WIFI_RECOVER:
|
||||
wifi_weak_active=False; wifi_weak_since=None
|
||||
if wifi_weak_active:
|
||||
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:
|
||||
wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
|
||||
if net_kind=="ETH" and net_speed_mbps is not None and ETHERNET_MIN_SPEED>0:
|
||||
if not ethernet_slow_active:
|
||||
if net_speed_mbps < ETHERNET_MIN_SPEED:
|
||||
if ethernet_slow_since is None: ethernet_slow_since=time.monotonic()
|
||||
if time.monotonic()-ethernet_slow_since >= ETHERNET_SPEED_SECONDS: ethernet_slow_active=True
|
||||
else: ethernet_slow_since=None
|
||||
elif net_speed_mbps >= ETHERNET_MIN_SPEED:
|
||||
ethernet_slow_active=False; ethernet_slow_since=None
|
||||
if ethernet_slow_active:
|
||||
alert_manager.request("ETHERNET_SPEED_LOW",1,"ETHERNET SLOW",f"LINK {net_speed_mbps} Mbps",f"EXPECT {ETHERNET_MIN_SPEED}+","CHECK CABLE")
|
||||
else: alert_manager.clear("ETHERNET_SPEED_LOW")
|
||||
else:
|
||||
ethernet_slow_since=None; ethernet_slow_active=False; alert_manager.clear("ETHERNET_SPEED_LOW")
|
||||
transition("ethernet_speed_low",ethernet_slow_active,"ETHERNET_SPEED_LOW","ETHERNET_SPEED_RECOVERED",
|
||||
iface=net_iface,speed_mbps=net_speed_mbps,minimum_mbps=ETHERNET_MIN_SPEED)
|
||||
if health.get("under_voltage_now"):
|
||||
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):
|
||||
# Color = fault object. Blink frequency = severity. All alerts blink.
|
||||
if not a:
|
||||
rgb_off(); return
|
||||
if a["sev"]>=3: on_ms,off_ms=LED_EMERG_ON,LED_EMERG_OFF
|
||||
elif a["sev"]>=2: on_ms,off_ms=LED_CRIT_ON,LED_CRIT_OFF
|
||||
else: on_ms,off_ms=LED_WARN_ON,LED_WARN_OFF
|
||||
cycle=max(1,on_ms+off_ms)
|
||||
phase=int(time.monotonic()*1000)%cycle
|
||||
rgb(*a["color"]) if phase<on_ms else rgb_off()
|
||||
class AlertManager:
|
||||
"""Current monitoring facts. Hardware plugins own all display decisions."""
|
||||
def __init__(self):
|
||||
self.requests={}
|
||||
|
||||
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
|
||||
now=time.monotonic();old=self.requests.get(request_id)
|
||||
value={"id":request_id,"priority":alert_priority(request_id),"severity":severity,
|
||||
"category":ALERT_CATEGORIES[request_id.split("|",1)[0]].lower(),
|
||||
"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 or old["severity"]!=severity:
|
||||
log({1:"WARN",2:"CRITICAL",3:"EMERGENCY"}[severity],"ALERT_ACTIVE" if old is None else "ALERT_CHANGED",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["severity"],-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):
|
||||
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
||||
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
||||
im=Image.new("1",(W,H),0); pix=im.load()
|
||||
left=[
|
||||
f"CPU {cpu:.1f}%",
|
||||
f"TMP {temp:.1f}C",
|
||||
f"DSK {disk:.1f}%",
|
||||
("IP6 " if ":" in ip else "IP4 ")+ip,
|
||||
]
|
||||
right=[
|
||||
f"MEM {mem:.1f}%",
|
||||
f"FAN {FAN_NAME[fan_level]}",
|
||||
f"NET {'OK' if net_ok else 'DOWN'}",
|
||||
"",
|
||||
]
|
||||
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 cleanup():
|
||||
publisher.close()
|
||||
log("INFO","STOP",loop_errors=agent["loop_errors"])
|
||||
try:Path(STATUS_PATH).unlink(missing_ok=True)
|
||||
except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
|
||||
|
||||
def write_status(cpu,temp,mem,disk):
|
||||
display_state=alert_manager.status_snapshot()
|
||||
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
||||
storage=storage_cache.sample()
|
||||
disks=storage_inventory.sample(cfg)
|
||||
disk_total,disk_used,disk_free=(storage[k] for k in ("total","used","free")) if storage else (None,None,None)
|
||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||
state={
|
||||
"version":APP_VERSION,"timestamp":int(time.time()),
|
||||
"collection":{"interval_seconds":min(1.0,SENSOR_INTERVAL,NETWORK_INTERVAL,SERVICE_INTERVAL,HEALTH_INTERVAL),
|
||||
"refresh":{
|
||||
"storage_inventory":{"interval_seconds":604800,"observed_at":storage_inventory.observed_at,"next_update_at":storage_inventory.next_update_at,"error":storage_inventory.error},
|
||||
"system":{"interval_seconds":SENSOR_INTERVAL,"observed_at":sensor_observed_at,"next_update_at":sensor_observed_at+SENSOR_INTERVAL if sensor_observed_at else None},
|
||||
"network":{"interval_seconds":NETWORK_INTERVAL,"observed_at":network_observed_at,"next_update_at":network_observed_at+NETWORK_INTERVAL if network_observed_at else None},
|
||||
"storage":{"interval_seconds":604800,"observed_at":storage_cache.observed_at,"next_update_at":storage_cache.next_update_at,"error":storage_cache.error},
|
||||
"display":{"interval_seconds":1,"next_update_at":time.time()+1},
|
||||
"alerts":{"interval_seconds":1,"next_update_at":time.time()+1}}},"uptime_seconds":uptime_seconds(),
|
||||
"machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
|
||||
"device":DEVICE_INFO,
|
||||
"system":{"observed_at":sensor_observed_at,"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,
|
||||
"storage_observed_at":storage_cache.observed_at,
|
||||
"storage_inventory":disks,"storage_inventory_observed_at":storage_inventory.observed_at,
|
||||
"storage_inventory_error":storage_inventory.error,
|
||||
"storage_scanned_at":storage_inventory.scanned_at,"storage_discovery_mode":"manual",
|
||||
"disk_percent":round(disk,1) if disk is not None else None,"disk_total_bytes":disk_total,
|
||||
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||
"root_readonly":bool(fs_readonly)},
|
||||
"power":health,
|
||||
"network":{"observed_at":network_observed_at,"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||
"link_speed_mbps":net_speed_mbps,
|
||||
"rx_bytes_per_second":net_rx_bytes_per_second,
|
||||
"tx_bytes_per_second":net_tx_bytes_per_second,
|
||||
"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):
|
||||
oled_show([(a["title"],f"{idx}/{total}"),
|
||||
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
|
||||
# Dispatch only after all shared checks and configuration have been initialized.
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument("--diagnose",action="store_true")
|
||||
args=parser.parse_args()
|
||||
if args.diagnose: raise SystemExit(diagnose())
|
||||
|
||||
|
||||
oled_init(); rgb_off(); apply_dark_mode()
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
last_sensor=last_render=0.0
|
||||
last_net=last_watch=time.monotonic()
|
||||
watch_state=[]
|
||||
last_sensor=last_status=0.0
|
||||
last_net=last_watch=last_health=0.0
|
||||
monitor_ready_at=time.monotonic()+STARTUP_GRACE_SECONDS
|
||||
health={}; fs_readonly=False
|
||||
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0}
|
||||
event_state={}
|
||||
history_logged=set()
|
||||
latest=(0.0,0.0,0.0,0.0)
|
||||
active=[]
|
||||
|
||||
# Page state is a snapshot queue. Live sensor refreshes NEVER choose another page.
|
||||
# Each cycle is HOME + every alert captured at the cycle boundary.
|
||||
cycle_pages=["HOME"]
|
||||
cycle_pos=0
|
||||
page_started=time.monotonic()
|
||||
current_alert_key=None
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
try:
|
||||
apply_dark_mode()
|
||||
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
||||
while running:
|
||||
try:
|
||||
now=time.monotonic()
|
||||
sample_elapsed=0.0
|
||||
|
||||
|
||||
# Independent clocks: update data/state only.
|
||||
if now-last_sensor >= SENSOR_INTERVAL:
|
||||
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||
latest=(c,t,m,d); update_fan(t); last_sensor=now
|
||||
latest=(c,t,m,d); sensor_observed_at=int(time.time()); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
|
||||
if now-last_net >= NETWORK_INTERVAL:
|
||||
ip=get_ip(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False
|
||||
last_net=now
|
||||
if now>=monitor_ready_at and now-last_net >= NETWORK_INTERVAL:
|
||||
old_id=(net_iface,ip)
|
||||
net_iface,net_kind,net_metric,net_speed_mbps,ip_label,ip=primary_network()
|
||||
net_rx_bytes_per_second,net_tx_bytes_per_second=network_rates.sample(net_iface,now)
|
||||
network_observed_at=int(time.time())
|
||||
raw_link_ok=ip!="NO IP"; raw_net_ok=internet() if raw_link_ok else False
|
||||
if raw_link_ok:
|
||||
link_failure_since=None;link_ok=True
|
||||
else:
|
||||
if link_failure_since is None:link_failure_since=now
|
||||
link_ok=now-link_failure_since>=NETWORK_FAILURE_SECONDS
|
||||
link_ok=not link_ok
|
||||
if not raw_link_ok:
|
||||
internet_failure_since=None;net_ok=True
|
||||
elif raw_net_ok:
|
||||
internet_failure_since=None;net_ok=True
|
||||
else:
|
||||
if internet_failure_since is None:internet_failure_since=now
|
||||
net_ok=not (now-internet_failure_since>=NETWORK_FAILURE_SECONDS)
|
||||
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>=monitor_ready_at and now-last_watch >= SERVICE_INTERVAL:
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
last_watch=now
|
||||
for w,ok in zip(watches,watch_state):
|
||||
transition("watch:"+w[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
|
||||
active=make_alerts(c,t,m,d)
|
||||
|
||||
# If the currently displayed alert truly disappeared, skip it immediately.
|
||||
if cycle_pages[cycle_pos]!="HOME":
|
||||
key=cycle_pages[cycle_pos]
|
||||
if find_live_alert(key,active) is None:
|
||||
cycle_pos += 1
|
||||
if cycle_pos>=len(cycle_pages):
|
||||
# End of cycle: capture a fresh snapshot.
|
||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
||||
cycle_pos=0
|
||||
page_started=now
|
||||
|
||||
# ONLY this timer performs normal page changes.
|
||||
if now-page_started >= PAGE_INTERVAL:
|
||||
cycle_pos += 1
|
||||
if cycle_pos>=len(cycle_pages):
|
||||
# A complete cycle ended. Snapshot all currently active alerts.
|
||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
||||
cycle_pos=0
|
||||
page_started=now
|
||||
|
||||
# Special case: while idle on HOME with no alert pages queued, begin a new
|
||||
# alert cycle immediately when the first alert appears. HOME keeps its
|
||||
# current 10-second slot; alerts follow it and cannot be kicked out by refresh.
|
||||
if cycle_pages==["HOME"] and active:
|
||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
||||
|
||||
# Render CURRENT page only. 1-second refresh does not affect cycle_pos.
|
||||
if now-last_render >= OLED_REFRESH:
|
||||
current=cycle_pages[cycle_pos]
|
||||
if current=="HOME":
|
||||
render_home(c,t,m,d)
|
||||
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))
|
||||
update_active_alerts(c,t,m,d,sample_elapsed)
|
||||
if now-last_status >= 1.0:
|
||||
write_status(c,t,m,d); last_status=now
|
||||
|
||||
time.sleep(0.20)
|
||||
except Exception as e:
|
||||
print("loop error:",e,flush=True); time.sleep(1)
|
||||
|
||||
rgb_off(); mcu(0x08,0x00); bus.close()
|
||||
agent["loop_errors"]+=1; log("ERROR","LOOP_ERROR",count=agent["loop_errors"],error=repr(e)); time.sleep(1)
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/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 math,functools,tempfile
|
||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs,urlparse
|
||||
|
||||
STORAGE_SCAN_LOCK=threading.Lock()
|
||||
CONFIG_LOCK=threading.RLock()
|
||||
|
||||
|
||||
def config_transaction(function):
|
||||
@functools.wraps(function)
|
||||
def locked(*args,**kwargs):
|
||||
with CONFIG_LOCK:
|
||||
return function(*args,**kwargs)
|
||||
return locked
|
||||
|
||||
APP_VERSION="3.11.3"
|
||||
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","storage_names","storage_visible"}
|
||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||
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")
|
||||
|
||||
|
||||
@config_transaction
|
||||
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(interpolation=None); 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,raw=True) 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 in {"storage_names","storage_visible"}:
|
||||
if not re.fullmatch(r"[a-f0-9]{32}",key):raise ValueError("invalid storage identifier")
|
||||
if section=="storage_visible":
|
||||
if value.lower() not in {"true","false"}:raise ValueError("visibility must be true or false")
|
||||
elif len(value)>64 or any(ord(c)<32 for c in value):
|
||||
raise ValueError("storage name must be at most 64 characters without control characters")
|
||||
return
|
||||
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 in {"alerts","timing"}:
|
||||
number=float(value)
|
||||
if not math.isfinite(number):raise ValueError(f"{section}.{key} must be finite")
|
||||
low,high=(0,86400)
|
||||
if section=="timing":
|
||||
low=0 if key in {"startup_grace_seconds","network_failure_seconds"} else 0.2
|
||||
high=3600
|
||||
elif key in {"cpu_high","cpu_recover","memory_high","memory_recover","disk_high","disk_critical"}:low,high=0,100
|
||||
elif key in {"temp_high","temp_critical"}:low,high=-50,150
|
||||
elif key in {"wifi_weak","wifi_recover"}:low,high=-120,0
|
||||
elif key=="ethernet_min_speed_mbps":low,high=0,1000000
|
||||
if not low<=number<=high:raise ValueError(f"{section}.{key} must be {low}..{high}")
|
||||
if key in {"cpu_high_seconds","memory_high_seconds","ethernet_min_speed_mbps"} and not value.isdecimal():
|
||||
raise ValueError(f"{key} must be a nonnegative integer")
|
||||
elif section=="dark_mode" and value.lower() not in {"on","off","true","false","0","1","yes","no"}:
|
||||
raise ValueError("LED setting must be on or off")
|
||||
elif section=="services" and not UNIT_RE.fullmatch(value):raise ValueError("invalid systemd service name")
|
||||
elif section=="api" and key in {"port","log_limit"}:
|
||||
limit=65535 if key=="port" else 1000
|
||||
if not 1<=int(value)<=limit:raise ValueError(f"api.{key} must be 1..{limit}")
|
||||
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 validate_candidate(parsed):
|
||||
for lower,upper in (("cpu_recover","cpu_high"),("memory_recover","memory_high"),
|
||||
("temp_high","temp_critical"),("disk_high","disk_critical"),("wifi_weak","wifi_recover")):
|
||||
if parsed.has_option("alerts",lower) and parsed.has_option("alerts",upper):
|
||||
if not parsed.getfloat("alerts",lower)<parsed.getfloat("alerts",upper):
|
||||
raise ValueError(f"alerts.{lower} must be less than alerts.{upper}")
|
||||
|
||||
|
||||
def replace_ini_value(section,key,value):
|
||||
return replace_ini_text(CFG.read_text(),section,key,value)
|
||||
|
||||
def replace_ini_text(text,section,key,value):
|
||||
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
|
||||
|
||||
@config_transaction
|
||||
def save_ini_text(text,prefix="api"):
|
||||
parsed=configparser.ConfigParser(interpolation=None); parsed.read_string(text)
|
||||
validate_candidate(parsed)
|
||||
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.time_ns()}")
|
||||
shutil.copy2(CFG,backup)
|
||||
temporary=None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w",dir=CFG.parent,prefix=CFG.name+".",delete=False) as file:
|
||||
temporary=Path(file.name);file.write(text)
|
||||
temporary.chmod(0o644);temporary.replace(CFG)
|
||||
finally:
|
||||
if temporary is not None:temporary.unlink(missing_ok=True)
|
||||
return str(backup)
|
||||
|
||||
|
||||
@config_transaction
|
||||
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")
|
||||
return save_ini_text(text,"api")
|
||||
|
||||
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"]))
|
||||
|
||||
@config_transaction
|
||||
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")
|
||||
backup=save_ini_text(text,"api")
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
return str(backup)
|
||||
|
||||
def process_monitors():
|
||||
cfg=load_cfg(); result=[]
|
||||
for name,target in cfg.items("processes"):
|
||||
target=target.strip()
|
||||
if not target: continue
|
||||
up=subprocess.run(["pgrep","-f","--",target],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=2).returncode==0
|
||||
result.append({"name":name.upper(),"target":target,"up":up})
|
||||
return result
|
||||
|
||||
@config_transaction
|
||||
def edit_process_monitor(operation,name,target="",previous_name=""):
|
||||
if operation not in {"save","delete"}: raise ValueError("invalid process monitor operation")
|
||||
if not NAME_RE.fullmatch(name): raise ValueError("invalid process monitor name")
|
||||
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous process monitor name")
|
||||
if operation=="save" and (not target or len(target)>256 or any(c in target for c in "\r\n\x00")):
|
||||
raise ValueError("invalid process match expression")
|
||||
text=CFG.read_text()
|
||||
if operation=="delete":
|
||||
if not load_cfg().has_option("processes",name): raise ValueError("process monitor not found")
|
||||
text=replace_ini_text(text,"processes",name,None)
|
||||
else:
|
||||
if previous_name and previous_name.lower()!=name.lower(): text=replace_ini_text(text,"processes",previous_name,None)
|
||||
text=replace_ini_text(text,"processes",name,target)
|
||||
parsed=configparser.ConfigParser(interpolation=None); parsed.read_string(text)
|
||||
backup=save_ini_text(text,"api")
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
return 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
|
||||
state=json.loads(STATUS.read_text())
|
||||
if not 0<=time.time()-state.get("timestamp",0)<=10:
|
||||
self.send_json(503,{"error":"agent status stale"});return
|
||||
self.send_json(200,state); 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
|
||||
if parsed.path=="/api/v1/processes":
|
||||
self.send_json(200,{"processes":process_monitors(),"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=="/api/v1/storage/scan":
|
||||
if not self.authorized():self.send_json(401,{"error":"bearer token required"});return
|
||||
try:
|
||||
from storage_cache import StorageInventory
|
||||
with STORAGE_SCAN_LOCK:
|
||||
result=StorageInventory(cache_path=CFG.with_suffix(".storage.json")).scan()
|
||||
self.send_json(200,result)
|
||||
except Exception:
|
||||
self.send_json(503,{"error":"storage scan failed; previous inventory retained"})
|
||||
return
|
||||
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/processes"}: 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
|
||||
if path=="/api/v1/processes":
|
||||
backup=edit_process_monitor(str(body.get("operation","")),str(body.get("name","")),
|
||||
str(body.get("target","")),str(body.get("previous_name","")))
|
||||
print(f"PROCESS_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,114 @@
|
||||
"""Hardware-independent client for the versioned local plugin API."""
|
||||
import copy
|
||||
from contextlib import closing
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
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.published_at=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);self.published_at=time.monotonic()
|
||||
|
||||
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
|
||||
fresh=self.published_at is not None and time.monotonic()-self.published_at<3
|
||||
# Never renew a hardware lease from a stalled monitoring loop.
|
||||
if state is not None and fresh:
|
||||
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()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Daily filesystem capacity cache, persisted in RAM across monitor restarts."""
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from shutil import disk_usage
|
||||
|
||||
class StorageInventory:
|
||||
"""Read host block devices without mounting, probing SMART, or writing disks."""
|
||||
|
||||
def __init__(self, clock=time.time, runner=None, mounts_path="/proc/1/mountinfo", usage=None, cache_path=None, capacity_path=None):
|
||||
import subprocess
|
||||
self.clock = clock
|
||||
self.runner = runner or (lambda: subprocess.check_output(
|
||||
["lsblk", "--json", "--bytes", "--paths", "--output",
|
||||
"NAME,KNAME,TYPE,SIZE,FSTYPE,UUID,LABEL,MODEL,SERIAL,MAJ:MIN"],
|
||||
text=True, timeout=5, stderr=subprocess.DEVNULL))
|
||||
self.mounts_path = Path(mounts_path)
|
||||
self.usage = usage or (lambda mount: disk_usage("/proc/1/root" + mount))
|
||||
self.next_update_at = 0
|
||||
self.observed_at = None
|
||||
self.error = False
|
||||
self.devices = []
|
||||
self.cache_path = Path(cache_path) if cache_path else None
|
||||
self.scanned_at = None
|
||||
self.capacity_path = Path(capacity_path) if capacity_path else None
|
||||
if self.capacity_path:
|
||||
try:
|
||||
saved=json.loads(self.capacity_path.read_text())
|
||||
at=saved['observed_at']
|
||||
if isinstance(at,(int,float)) and 0 <= self.clock()-at < 604800 and isinstance(saved['devices'],list):
|
||||
self.devices=saved['devices'];self.observed_at=at
|
||||
self.scanned_at=saved['scanned_at'];self.next_update_at=at+604800
|
||||
except (OSError,ValueError,KeyError,TypeError):pass
|
||||
|
||||
def scan(self):
|
||||
if self.cache_path is None:
|
||||
raise ValueError("storage inventory cache path required")
|
||||
try:
|
||||
previous = json.loads(self.cache_path.read_text())
|
||||
if 0 <= self.clock() - previous["scanned_at"] < 60:
|
||||
return {"accepted": True, "scanned_at": previous["scanned_at"], "cooldown": True}
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
data = json.loads(self.runner())
|
||||
self.discover(data)
|
||||
document = {"scanned_at": self.clock(), "inventory": data}
|
||||
temporary = self.cache_path.with_suffix(".tmp")
|
||||
with open(temporary,"w",opener=lambda path, flags: __import__("os").open(path, flags, 0o600)) as file:
|
||||
json.dump(document, file)
|
||||
temporary.replace(self.cache_path)
|
||||
return {"accepted": True, "scanned_at": document["scanned_at"], "cooldown": False}
|
||||
|
||||
def sample(self, config=None):
|
||||
data = None
|
||||
changed = False
|
||||
if self.cache_path is not None:
|
||||
try:
|
||||
document = json.loads(self.cache_path.read_text())
|
||||
changed = document["scanned_at"] != self.scanned_at
|
||||
data = document["inventory"]
|
||||
self.scanned_at = document["scanned_at"]
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
self.error = True
|
||||
if data is not None and (changed or self.clock() >= self.next_update_at):
|
||||
try:
|
||||
self.devices = self.discover(data)
|
||||
self.observed_at = self.clock()
|
||||
self.error = False
|
||||
except (OSError, ValueError, TypeError, KeyError, __import__("subprocess").SubprocessError):
|
||||
self.error = True
|
||||
self.next_update_at = self.clock() + (3600 if self.error else 604800)
|
||||
if self.capacity_path and not self.error:
|
||||
try:
|
||||
self.capacity_path.write_text(json.dumps(dict(devices=self.devices,observed_at=self.observed_at,scanned_at=self.scanned_at)))
|
||||
except OSError:pass
|
||||
names = dict(config.items("storage_names", raw=True)) if config is not None and config.has_section("storage_names") else {}
|
||||
visible = dict(config.items("storage_visible", raw=True)) if config is not None and config.has_section("storage_visible") else {}
|
||||
result = []
|
||||
for device in self.devices:
|
||||
item = dict(device)
|
||||
item["name"] = names.get(item["id"], "auto")
|
||||
if item["name"].lower() == "auto":
|
||||
item["name"] = item["label"] or item["mountpoint"] or item["device"]
|
||||
item["visible"] = visible.get(item["id"], "true").lower() != "false"
|
||||
if self.error:
|
||||
item["state"] = "unknown"
|
||||
result.append(item)
|
||||
known = {d["id"] for d in result}
|
||||
for identifier in sorted((names.keys() | visible.keys()) - known):
|
||||
result.append(dict(id=identifier, name=names.get(identifier, identifier),
|
||||
visible=visible.get(identifier, "true").lower() != "false",
|
||||
state="unknown" if self.error else "missing", device="", mountpoint="",
|
||||
label="", capacity_bytes=None, used_bytes=None, free_bytes=None,
|
||||
used_percent=None))
|
||||
return result
|
||||
|
||||
def discover(self, data):
|
||||
import hashlib
|
||||
import re
|
||||
mounts, sources = {}, {}
|
||||
for line in self.mounts_path.read_text().splitlines():
|
||||
left, right = line.split(" - ", 1)
|
||||
fields = left.split()
|
||||
# Bind mounts and Btrfs subvolumes must not duplicate filesystem capacity.
|
||||
mount = re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4])
|
||||
row = (fields[3] != "/", len(mount), mount)
|
||||
mounts.setdefault(fields[2], []).append(row)
|
||||
source = right.split()[1]
|
||||
if source.startswith("/dev/"):
|
||||
sources.setdefault(str(Path(source).resolve()), []).append(row)
|
||||
devices = data["blockdevices"]
|
||||
if not isinstance(devices, list):
|
||||
raise ValueError("invalid block device inventory")
|
||||
result, seen = [], set()
|
||||
|
||||
def walk(device, serial="", parent=""):
|
||||
kind = device.get("type")
|
||||
if kind in {"loop", "rom", "zram"}:
|
||||
return
|
||||
serial = str(device.get("serial") or serial).strip()
|
||||
children = device.get("children") or []
|
||||
uuid = str(device.get("uuid") or "").strip()
|
||||
fs = str(device.get("fstype") or "")
|
||||
# A parent disk with partitions/RAID/LVM is represented by its usable leaves.
|
||||
if not children and kind not in {"loop", "rom"} and fs not in {"swap", "linux_raid_member", "LVM2_member", "crypto_LUKS"}:
|
||||
partition = re.search(r"(\d+)$", str(device.get("kname") or device.get("name") or ""))
|
||||
suffix = ":" + partition[1] if kind == "part" and partition else ""
|
||||
source = "uuid:" + uuid if uuid else "serial:" + serial + suffix if serial else "device:" + str(device.get("name"))
|
||||
identifier = hashlib.sha256(source.encode()).hexdigest()[:32]
|
||||
if identifier not in seen:
|
||||
seen.add(identifier)
|
||||
candidates = mounts.get(device.get("maj:min"), []) or sources.get(str(Path(device["name"]).resolve()), [])
|
||||
mount = sorted(candidates)[0][2] if candidates else ""
|
||||
item = dict(id=identifier, device=str(device.get("name") or ""),
|
||||
label=str(device.get("label") or ""), uuid=uuid,
|
||||
stable_identity=bool(uuid or serial), model=str(device.get("model") or "").strip(),
|
||||
mountpoint=mount, filesystem=fs, state="mounted" if mount else "unmounted",
|
||||
capacity_bytes=int(device.get("size") or 0), used_bytes=None,
|
||||
free_bytes=None, used_percent=None)
|
||||
if mount:
|
||||
try:
|
||||
usage = self.usage(mount)
|
||||
if usage.total <= 0:
|
||||
raise ValueError("invalid filesystem capacity")
|
||||
item.update(capacity_bytes=usage.total, used_bytes=usage.used,
|
||||
free_bytes=usage.free, used_percent=round(100 * usage.used / usage.total, 1))
|
||||
except (OSError, ValueError):
|
||||
item["state"] = "unknown"
|
||||
result.append(item)
|
||||
for child in children:
|
||||
walk(child, serial, str(device.get("name") or parent))
|
||||
|
||||
for device in devices:
|
||||
walk(device)
|
||||
return sorted(result, key=lambda d: (d["mountpoint"] != "/", d["mountpoint"], d["device"]))
|
||||
|
||||
|
||||
|
||||
class StorageCache:
|
||||
def __init__(self, path, reader=disk_usage, clock=time.time):
|
||||
self.path, self.reader, self.clock = Path(path), reader, clock
|
||||
self.values = None
|
||||
self.observed_at = None
|
||||
self.next_update_at = 0
|
||||
self.error = False
|
||||
try:
|
||||
data = json.loads(self.path.read_text())
|
||||
if not isinstance(data,dict):raise ValueError('invalid cache')
|
||||
retry = data.get('next_update_at')
|
||||
if data.get('error') is True and isinstance(retry,(int,float)) and self.clock() < retry <= self.clock()+3600:
|
||||
self.error, self.next_update_at = True, retry
|
||||
values = data['values']
|
||||
if all(isinstance(values[k], int) and not isinstance(values[k], bool) and values[k] >= 0 for k in ('total', 'used', 'free')) and values['total'] > 0 and values['used'] <= values['total']:
|
||||
at = data['observed_at']
|
||||
if isinstance(at, (int, float)) and 0 <= self.clock() - at < 604800:
|
||||
self.values, self.observed_at = values, at
|
||||
if not self.error:self.next_update_at = at + 604800
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
|
||||
def sample(self):
|
||||
now = self.clock()
|
||||
if now < self.next_update_at:
|
||||
return self.values
|
||||
try:
|
||||
usage = self.reader('/')
|
||||
if usage.total <= 0 or not 0 <= usage.used <= usage.total or usage.free < 0:
|
||||
raise ValueError('invalid capacity')
|
||||
except (OSError, ValueError):
|
||||
self.error = True
|
||||
self.next_update_at = now + 3600
|
||||
self.persist()
|
||||
return self.values
|
||||
self.values = dict(total=usage.total, used=usage.used, free=usage.free)
|
||||
self.observed_at = now
|
||||
self.next_update_at = now + 604800
|
||||
self.error = False
|
||||
self.persist()
|
||||
return self.values
|
||||
|
||||
def persist(self):
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(dict(values=self.values, observed_at=self.observed_at,
|
||||
next_update_at=self.next_update_at, error=self.error)))
|
||||
except OSError:
|
||||
# The in-memory sample remains valid even if the RAM cache cannot be persisted.
|
||||
pass
|
||||
@@ -1,45 +1,34 @@
|
||||
# =============================================================================
|
||||
# Pigway Pi Control v3.1.0 - 用户配置文件
|
||||
# =============================================================================
|
||||
#
|
||||
# 正式安装后的配置路径:
|
||||
# /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 编辑:
|
||||
# sudo nano /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 保存后让配置生效:
|
||||
# sudo systemctl restart pigway-pi-control
|
||||
#
|
||||
# 查看运行状态:
|
||||
# systemctl status pigway-pi-control --no-pager -l
|
||||
#
|
||||
# 查看实时日志:
|
||||
# journalctl -u pigway-pi-control -f
|
||||
#
|
||||
# 重要:
|
||||
# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。
|
||||
# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。
|
||||
# 3. 数值单位和建议范围见每项上方注释。
|
||||
# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。
|
||||
# =============================================================================
|
||||
# PIGWay system monitoring. Hardware plugins are separately installed.
|
||||
# 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。
|
||||
[plugins]
|
||||
# No automatic attachment. Example (prefer Web discovery):
|
||||
# yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||
|
||||
[device]
|
||||
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
|
||||
# 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。
|
||||
identifier = auto
|
||||
|
||||
# Web 页面显示名称。auto 表示使用 identifier。
|
||||
name = auto
|
||||
|
||||
|
||||
[api]
|
||||
# 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
|
||||
enabled = false
|
||||
# API 监听地址和端口。0.0.0.0 允许局域网访问;
|
||||
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||
bind = 0.0.0.0
|
||||
port = 6001
|
||||
log_limit = 200
|
||||
|
||||
|
||||
[timing]
|
||||
# 传感器采样周期,单位:秒。
|
||||
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
||||
# CPU / 内存 / 温度 / 磁盘按此周期更新。
|
||||
# 推荐:1。通常不要低于 1。
|
||||
sensor_interval = 1
|
||||
|
||||
# OLED 当前页面刷新周期,单位:秒。
|
||||
# 这只是刷新当前页面数据,不负责切换页面。
|
||||
# 推荐:1。
|
||||
oled_refresh_interval = 1
|
||||
|
||||
# HOME / 各告警页面的轮播停留时间,单位:秒。
|
||||
# 推荐:10。觉得切换太慢可改为 5~8。
|
||||
page_interval = 10
|
||||
|
||||
# 网络状态检查周期,单位:秒。
|
||||
# 推荐:5。太短会产生不必要的网络探测。
|
||||
network_interval = 5
|
||||
@@ -48,40 +37,18 @@ network_interval = 5
|
||||
# 推荐:5。
|
||||
service_interval = 5
|
||||
|
||||
# 本机健康检查周期,单位:秒。
|
||||
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
|
||||
# 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。
|
||||
health_interval = 1
|
||||
|
||||
[fan]
|
||||
# 风扇温控档位,单位:摄氏度。
|
||||
#
|
||||
# 默认曲线:
|
||||
# < 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。
|
||||
# 开机后等待网络与被监控服务完成启动,再开始网络/服务告警检查。
|
||||
# 这不会延迟温度、电源、磁盘等本机健康检查。
|
||||
startup_grace_seconds = 15
|
||||
|
||||
# 低于此温度,风扇完全停止。
|
||||
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
|
||||
# 网络或互联网必须连续失败达到此时间才产生告警。
|
||||
# 成功样本会立即清除等待状态,避免开机和链路切换瞬间误报。
|
||||
network_failure_seconds = 15
|
||||
|
||||
|
||||
[alerts]
|
||||
@@ -120,43 +87,29 @@ disk_high = 90
|
||||
# 根分区使用率达到此百分比触发严重告警。
|
||||
disk_critical = 95
|
||||
|
||||
# ---------------- Wi-Fi 信号 ----------------
|
||||
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
|
||||
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
|
||||
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发弱信号告警。
|
||||
wifi_weak = -75
|
||||
|
||||
[led]
|
||||
# RGB 规则:
|
||||
# 正常状态 = 完全熄灭
|
||||
# 颜色 = 哪一类对象发生故障
|
||||
# 闪烁频率 = 严重程度,越严重闪得越快
|
||||
#
|
||||
# 当前颜色语义:
|
||||
# CPU负载 = 黄
|
||||
# 温度/散热 = 红
|
||||
# 内存 = 紫
|
||||
# 磁盘 = 橙
|
||||
# 网络 = 蓝
|
||||
# 服务/进程 = 青
|
||||
#
|
||||
# brightness 是 RGB 最大输出百分比(0~100)。
|
||||
# 3颗 RGB 会一起亮,卧室建议 5~15。
|
||||
brightness = 10
|
||||
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
|
||||
wifi_recover = -68
|
||||
|
||||
# Warning:慢闪。以下单位均为毫秒。
|
||||
warning_on_ms = 300
|
||||
warning_off_ms = 2700
|
||||
# 弱信号持续多少秒才正式告警。
|
||||
wifi_weak_seconds = 30
|
||||
|
||||
# Critical:中速闪烁。
|
||||
critical_on_ms = 400
|
||||
critical_off_ms = 1100
|
||||
|
||||
# Emergency:快速闪烁。
|
||||
emergency_on_ms = 400
|
||||
emergency_off_ms = 400
|
||||
# 有线网卡协商速率低于此值并持续 ethernet_speed_seconds 后告警,单位 Mbps。
|
||||
# Raspberry Pi 4/5 的千兆网口建议保持 1000;设为 0 可关闭速率告警。
|
||||
ethernet_min_speed_mbps = 1000
|
||||
ethernet_speed_seconds = 15
|
||||
|
||||
|
||||
[dark_mode]
|
||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
||||
#
|
||||
# off = Pigway Pi Control 启动时关闭该灯。
|
||||
# off = PIGWay Pi Control 启动时关闭该灯。
|
||||
# 若不希望程序干预,可改为 on。
|
||||
#
|
||||
# 注意:
|
||||
@@ -191,17 +144,24 @@ pi_act_led = off
|
||||
# 修改后:
|
||||
# sudo systemctl restart pigway-pi-control
|
||||
#
|
||||
# 默认监控 SSH 和向日葵核心后台服务。
|
||||
# 默认只监控 Raspberry Pi OS 自带的 SSH 服务。
|
||||
# 向日葵、Tailscale 等可选服务应在实际安装后再显式添加,避免全新系统误报。
|
||||
SSH = ssh.service
|
||||
AWESUN = runawesun.service
|
||||
|
||||
# 以下仅为示例,默认不启用:
|
||||
# AWESUN = runawesun.service
|
||||
# TAILSCALE = tailscaled.service
|
||||
# DOCKER = docker.service
|
||||
# SAMBA = smbd.service
|
||||
# NGINX = nginx.service
|
||||
|
||||
|
||||
[service_monitor_disabled]
|
||||
# Web 中“暂停监控”的状态保存在这里。
|
||||
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
|
||||
# 通常由 Web 自动维护,无需手工编辑。
|
||||
|
||||
|
||||
[processes]
|
||||
# =============================================================================
|
||||
# 普通进程监控
|
||||
@@ -223,3 +183,9 @@ AWESUN = runawesun.service
|
||||
#
|
||||
# 示例默认全部注释,不会监控任何普通进程。
|
||||
# MYAPP = /opt/myapp/server
|
||||
|
||||
# Use status.system.storage_inventory IDs, or edit through Console.
|
||||
# auto uses the filesystem label, mountpoint, or device path.
|
||||
[storage_names]
|
||||
[storage_visible]
|
||||
# <32-character ID> = false hides a disk while retaining collection.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Raspberry Pi OS Lite (Debian 13 Trixie) fresh installation
|
||||
|
||||
English | [简体中文](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)
|
||||
|
||||
## Compatibility
|
||||
|
||||
PIGWay Raspberry Pi Agent v3.7.1 supports Raspberry Pi OS Lite 64-bit based on Debian 13 Trixie and Python 3.13. The Agent reads Linux `/proc`, `/sys`, systemd, network state, and journal data. It has no GPIO, I2C, Pillow, or third-party Python dependency and does not need a virtual environment.
|
||||
|
||||
The Yahboom RGB Cooling HAT is an independent plugin. It uses Debian packages `python3-smbus2` and `python3-pil`, avoiding pip changes to the PEP 668 managed system Python. Its verified interface remains unchanged:
|
||||
|
||||
- I2C bus `/dev/i2c-1`; I2C must be enabled. SPI is not required.
|
||||
- MCU `0x0D`; 128x32 SSD1306-compatible OLED `0x3C`.
|
||||
- The fan uses six levels through MCU register `0x08`. It is not a Raspberry Pi GPIO PWM fan and has no verified RPM feedback.
|
||||
- The plugin is the sole process owner of the MCU, OLED, RGB, and fan.
|
||||
|
||||
Do not guess or change GPIO pins for this board. Other GPIO/PWM fans require their own driver.
|
||||
|
||||
## Fresh installation
|
||||
|
||||
Check the system first:
|
||||
|
||||
```bash
|
||||
cat /etc/os-release
|
||||
python3 --version
|
||||
raspi-config nonint get_i2c
|
||||
ls -l /dev/i2c-1
|
||||
ss -ltnp
|
||||
```
|
||||
|
||||
`raspi-config nonint get_i2c` must return `0`. Enabling I2C changes boot configuration and normally requires a reboot, so confirm SSH key access and stable power first:
|
||||
|
||||
```bash
|
||||
sudo raspi-config nonint do_i2c 0
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Install the Agent first. Its API is opt-in:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
|
||||
cd pigway-raspberry-pi-agent
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
To use Device Console, verify the port and explicitly enable the API:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh --enable-api
|
||||
sudo ss -ltnp | grep ':6001 '
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
Install the hardware plugin after `/dev/i2c-1` is available:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-yahboom-cooling-hat.git
|
||||
cd pigway-yahboom-cooling-hat
|
||||
sudo ./install.sh --enable-integration
|
||||
```
|
||||
|
||||
Register its Unix socket under `[plugins]` in `/etc/pigway-pi-control.conf`, then restart only the Agent:
|
||||
|
||||
```ini
|
||||
yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Validate both systemd services, Agent diagnostics, boot journal, and plugin status. Physically verify the four-line OLED and 10-second rotation, RGB alerts, and temperature-dependent fan levels. Plugin shutdown requests full fan speed and driver close never writes fan OFF. Since the board has no tachometer feedback, requested level is not measured RPM.
|
||||
|
||||
The Agent API is disabled by default and the plugin uses a local Unix socket, so the default installation opens no TCP port and does not conflict with Tailscale or an API gateway.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Raspberry Pi OS Lite(Debian 13 Trixie)全新安装
|
||||
|
||||
[English](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md) | 简体中文
|
||||
|
||||
## 兼容性结论
|
||||
|
||||
PIGWay Raspberry Pi Agent v3.7.1 兼容 Raspberry Pi OS Lite 64-bit、Debian 13 Trixie 与 Python 3.13。Agent 只读取 Linux 的 `/proc`、`/sys`、systemd、网络状态和 journal,不依赖 GPIO、I²C、Pillow 或第三方 Python 包,也不需要 venv。
|
||||
|
||||
Yahboom RGB Cooling HAT 已拆分为独立插件。插件使用 Debian 系统包 `python3-smbus2` 与 `python3-pil`,不会通过 pip 修改受 PEP 668 管理的系统 Python。硬件接口保持原实现:
|
||||
|
||||
- I²C 总线:`/dev/i2c-1`,需要启用 I²C;不需要 SPI。
|
||||
- 控制 MCU:`0x0D`;OLED:`0x3C`,SSD1306 兼容 128×32。
|
||||
- 风扇通过 MCU 寄存器 `0x08` 的六档控制,不使用 Raspberry Pi GPIO PWM 引脚,也没有可验证的 RPM 反馈。
|
||||
- RGB 由 MCU 寄存器控制;插件是 MCU、OLED、RGB 与风扇的唯一进程所有者。
|
||||
|
||||
因此不能把这块散热板当作直连 GPIO 风扇,也不应猜测或修改 GPIO 引脚。其他 GPIO/PWM 风扇需要独立驱动。
|
||||
|
||||
## 安装前检查
|
||||
|
||||
```bash
|
||||
cat /etc/os-release
|
||||
python3 --version
|
||||
raspi-config nonint get_i2c
|
||||
ls -l /dev/i2c-1
|
||||
ss -ltnp
|
||||
```
|
||||
|
||||
`raspi-config nonint get_i2c` 返回 `0` 才表示 I²C 已启用。启用 I²C 会修改启动配置,并且通常需要重启;远程操作前应确认 SSH 公钥登录与供电稳定。
|
||||
|
||||
```bash
|
||||
sudo raspi-config nonint do_i2c 0
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
重连后确认 `/dev/i2c-1` 存在。散热板插件安装器会安装诊断工具并检查 `0x0D`、`0x3C`,不会改 SSH、网络或 Wi-Fi。
|
||||
|
||||
## 安装顺序
|
||||
|
||||
先安装通用监控 Agent:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
|
||||
cd pigway-raspberry-pi-agent
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
默认只监控 `ssh.service`,不会假定已安装向日葵或 Tailscale。需要 NAS 上的 Device Console 时,先确认端口未占用,再显式开启 API:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh --enable-api
|
||||
sudo ss -ltnp | grep ':6001 '
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
再安装独立硬件插件:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-yahboom-cooling-hat.git
|
||||
cd pigway-yahboom-cooling-hat
|
||||
sudo ./install.sh --enable-integration
|
||||
```
|
||||
|
||||
最后在 `/etc/pigway-pi-control.conf` 的 `[plugins]` 中登记:
|
||||
|
||||
```ini
|
||||
yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||
```
|
||||
|
||||
然后仅重启 Agent:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
systemctl --no-pager --full status pigway-pi-control.service
|
||||
systemctl --no-pager --full status pigway-cooling-hat.service
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -b --no-pager
|
||||
sudo journalctl -u pigway-cooling-hat.service -b --no-pager
|
||||
curl --unix-socket /run/pigway-plugins/yahboom-cooling-hat/api.sock http://localhost/v1/status
|
||||
```
|
||||
|
||||
实机还需肉眼确认 OLED 四行主页与10秒轮播、RGB 告警颜色/效果,并通过温度变化确认风扇档位。停止插件时会请求风扇全速,驱动关闭时不会写风扇 OFF;硬件无转速反馈,因此只能确认请求档位,不能声称测得真实 RPM。
|
||||
|
||||
API 默认关闭,不占用 TCP 端口。插件只使用本机 Unix socket,不会与 Tailscale 或 API 网关争用端口。两个服务都由 systemd 独立管理,异常退出自动重启,任一项目停止不会停止另一个项目。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 文档目录
|
||||
|
||||
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
|
||||
|
||||
- [本地硬件插件协议](HARDWARE_PLUGIN_API.md)
|
||||
- [Raspberry Pi OS Lite (Debian 13 Trixie) fresh installation](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md)
|
||||
- [Raspberry Pi OS Lite(Debian 13 Trixie)全新安装](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)
|
||||
- [磁盘发现、刷新与显示](STORAGE.md)
|
||||
|
||||
[贡献指南](../CONTRIBUTING.md) · [安全反馈](../SECURITY.md) · [许可证](../LICENSE)
|
||||
@@ -0,0 +1,21 @@
|
||||
# 磁盘发现与显示
|
||||
|
||||
## 使用
|
||||
|
||||
在 Console → 系统配置 → 选择设备 → 磁盘设置中点击“扫描磁盘”,显式发现新增磁盘。打开页面、普通刷新和重启不执行新增磁盘发现。扫描失败保留已有清单;不执行挂载、格式化或分区操作。
|
||||
|
||||
已知磁盘容量每周刷新,CPU、内存与网络按自身周期采集。摘要显示实际采集时间;缺失指标不补零,采集失败不据此确认告警恢复。
|
||||
|
||||
显示名称、同组顺序与隐藏偏好统一保存到 Console,首页与配置页共用;Agent 已有名称和隐藏定义作为默认值。隐藏只影响显示,不停止采集或删除历史。
|
||||
|
||||
## Agent 行为
|
||||
|
||||
认证接口 `PUT /api/v1/storage/scan` 读取块设备和挂载点,持久化清单到对应配置旁的 `.storage.json`。监控服务读取清单;新增磁盘须再次手动扫描。根分区指标保留兼容,服务重启可复用容量缓存,整机重启后的容量初始化不自动发现新磁盘。
|
||||
|
||||
UUID 优先作为标识,其次为设备序列号和分区编号,再退回路径并标记 `stable_identity=false`。重复挂载及 loop/swap 等伪设备不展示;未挂载设备不伪造使用率。配置过的磁盘在扫描未发现时保留为断开状态。
|
||||
|
||||
磁盘扫描不执行 SMART 或自检;SMART 能力以本项目的独立说明为准。
|
||||
|
||||
## 相关说明
|
||||
|
||||
[中文首页](../README.zh-CN.md) · [文档目录](README.md)
|
||||
+77
-70
@@ -1,82 +1,89 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "[1/7] 检查 Raspberry Pi I2C..."
|
||||
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
|
||||
|
||||
echo "[2/7] 等待 APT/DPKG..."
|
||||
for i in $(seq 1 60); do
|
||||
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi
|
||||
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
|
||||
echo "软件包管理器忙,等待 5 秒... ($i/60)"
|
||||
sleep 5
|
||||
NO_START=0
|
||||
API_ENABLED=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--enable-api) API_ENABLED=true ;;
|
||||
--disable-api) API_ENABLED=false ;;
|
||||
--no-start) NO_START=1 ;;
|
||||
-h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
|
||||
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "[3/7] 安装依赖..."
|
||||
apt update
|
||||
apt install -y python3 python3-pil python3-smbus2 i2c-tools
|
||||
|
||||
echo "[4/7] 检查硬件地址..."
|
||||
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
|
||||
for required in app/pigway_pi_control.py app/plugin_api.py app/network_rates.py app/storage_cache.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
|
||||
done
|
||||
[ "$(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.
|
||||
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
|
||||
command -v lsblk >/dev/null || { apt-get update; apt-get install -y util-linux; }
|
||||
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/storage_cache.py /usr/local/share/pigway-pi-control/storage_cache.py
|
||||
install -m 0644 app/network_rates.py /usr/local/share/pigway-pi-control/network_rates.py
|
||||
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
|
||||
|
||||
NEW=/tmp/pigway-pi-control.conf.new
|
||||
cp config/pigway-pi-control.conf "$NEW"
|
||||
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
||||
if [ -f /etc/pigway-pi-control.conf ]; then
|
||||
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
# Preserve user's [services] and [processes] sections; adopt new defaults elsewhere.
|
||||
python3 - "$NEW" /etc/pigway-pi-control.conf <<'PY'
|
||||
import configparser,sys
|
||||
newf,oldf=sys.argv[1:]
|
||||
n=configparser.ConfigParser(); n.optionxform=str; n.read(newf)
|
||||
o=configparser.ConfigParser(); o.optionxform=str; o.read(oldf)
|
||||
for sec in ("services","processes"):
|
||||
if o.has_section(sec):
|
||||
if n.has_section(sec): n.remove_section(sec)
|
||||
n.add_section(sec)
|
||||
for k,v in o.items(sec): n.set(sec,k,v)
|
||||
with open(newf,"w") as f:n.write(f)
|
||||
PY
|
||||
else
|
||||
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
||||
fi
|
||||
install -m 0644 "$NEW" /etc/pigway-pi-control.conf
|
||||
rm -f "$NEW"
|
||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service
|
||||
|
||||
echo "[6/7] 启用/重启服务..."
|
||||
# Legacy hardware sections are deliberately preserved for explicit migration,
|
||||
# but ignored by the monitor and never exposed as monitor configuration.
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import configparser,re
|
||||
p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser(interpolation=None);c.read_string(text)
|
||||
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},
|
||||
'alerts':{'ethernet_min_speed_mbps':'1000','ethernet_speed_seconds':'15'},
|
||||
'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
||||
if not c.has_section(section):
|
||||
text=text.rstrip()+'\n\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items())
|
||||
else:
|
||||
missing={k:v for k,v in values.items() if not c.has_option(section,k)}
|
||||
if missing:
|
||||
start=re.search(rf'(?mi)^\[{re.escape(section)}\][ \t]*$',text).end()
|
||||
following=re.search(r'(?m)^\[[^\]\r\n]+\]',text[start:])
|
||||
end=start+(following.start() if following else len(text)-start)
|
||||
text=text[:end].rstrip()+'\n'+''.join(k+' = '+v+'\n' for k,v in missing.items())+'\n'+text[end:].lstrip('\n')
|
||||
p.write_text(text)
|
||||
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
|
||||
install -d -m 0755 /usr/local/share/doc/pigway-raspberry-pi-agent
|
||||
install -m 0644 LICENSE /usr/local/share/doc/pigway-raspberry-pi-agent/
|
||||
systemctl daemon-reload
|
||||
if [ "$NO_START" -eq 0 ]; then
|
||||
systemctl enable pigway-pi-control.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service
|
||||
|
||||
echo "[7/8] 安装后自检..."
|
||||
FAIL=0
|
||||
check() { if eval "$2"; then printf " %-30s PASS\n" "$1"; else printf " %-30s FAIL\n" "$1"; FAIL=1; fi; }
|
||||
check "Version 3.1.0" "grep -q 'APP_VERSION=\"3.1.0\"' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED pages 0..3" "grep -q 'cmd(0x22);cmd(0);cmd(3)' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI CPU/TMP/DSK" "grep -q 'f\"TMP {temp:.1f}C\"' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI IP4/IP6" "grep -q 'IP6 .*IP4 ' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI right column x=72" "grep -q \"draw_text_5x7(pix,72,y,right\\[row\\])\" /usr/local/sbin/pigway-pi-control"
|
||||
check "Legacy service stopped" "! systemctl is-active --quiet temp-control.service"
|
||||
check "Service running" "systemctl is-active --quiet pigway-pi-control.service"
|
||||
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
||||
echo "[8/8] 状态..."
|
||||
|
||||
|
||||
systemctl --no-pager --full status pigway-pi-control.service || true
|
||||
echo
|
||||
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
||||
echo "配置:/etc/pigway-pi-control.conf"
|
||||
systemctl is-active --quiet pigway-pi-control.service
|
||||
if [ "$API_ENABLED" = true ]; then
|
||||
systemctl enable pigway-pi-control-api.service >/dev/null
|
||||
systemctl restart pigway-pi-control-api.service
|
||||
systemctl is-active --quiet pigway-pi-control-api.service
|
||||
else
|
||||
systemctl disable --now pigway-pi-control-api.service
|
||||
fi
|
||||
fi
|
||||
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
|
||||
echo 'Config: /etc/pigway-pi-control.conf'
|
||||
echo 'API token: sudo cat /etc/pigway-pi-control-api.token'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=PIGWay Raspberry Pi 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]
|
||||
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor
|
||||
After=multi-user.target network.target
|
||||
Wants=network.target
|
||||
Description=PIGWay Raspberry Pi Agent system monitor
|
||||
After=local-fs.target systemd-modules-load.service network-online.target ssh.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Regression checks for configuration, alert truth and stale snapshot handling."""
|
||||
import ast
|
||||
import concurrent.futures
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/'app'))
|
||||
import pigway_pi_control_api as api
|
||||
import plugin_api
|
||||
from storage_cache import StorageCache
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory=tempfile.TemporaryDirectory();self.addCleanup(self.directory.cleanup)
|
||||
self.config=Path(self.directory.name)/'agent.conf'
|
||||
self.config.write_text((ROOT/'config/pigway-pi-control.conf').read_text())
|
||||
self.override=patch.object(api,'CFG',self.config);self.override.start();self.addCleanup(self.override.stop)
|
||||
|
||||
def test_invalid_values_and_relationships_leave_configuration_unchanged(self):
|
||||
original=self.config.read_text()
|
||||
for updates in [
|
||||
{'timing':{'sensor_interval':'-1'}},{'timing':{'sensor_interval':'0'}},
|
||||
{'alerts':{'cpu_high':'nan'}},{'alerts':{'memory_high':'inf'}},
|
||||
{'alerts':{'cpu_high':'101'}},{'alerts':{'cpu_high':'50'}},
|
||||
{'alerts':{'temp_high':'80'}},{'alerts':{'disk_high':'99'}},
|
||||
{'alerts':{'wifi_weak':'-60'}},{'alerts':{'cpu_high_seconds':'1.5'}},
|
||||
{'services':{'BAD':'not-a-unit'}},
|
||||
]:
|
||||
with self.subTest(updates=updates):
|
||||
with self.assertRaises(ValueError):api.update_ini(updates)
|
||||
self.assertEqual(self.config.read_text(),original)
|
||||
api.update_ini({'alerts':{'cpu_high':'60','cpu_recover':'50'}})
|
||||
self.assertEqual(api.load_cfg().getfloat('alerts','cpu_high'),60)
|
||||
|
||||
def test_literal_percent_in_identity_and_process_matches(self):
|
||||
api.update_ini({'device':{'name':'Office 100%'},'processes':{'APP':'worker --ratio=100%'}})
|
||||
self.assertEqual(api.machine_identity()['name'],'Office 100%')
|
||||
self.assertEqual(api.load_cfg().get('processes','app'),'worker --ratio=100%')
|
||||
|
||||
def test_parallel_writes_preserve_every_edit_and_backup(self):
|
||||
def save(index):return api.update_ini({'processes':{f'APP{index}':f'worker-{index}'}})
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
backups=list(pool.map(save,range(20)))
|
||||
self.assertEqual(len(set(backups)),20)
|
||||
cfg=api.load_cfg()
|
||||
for index in range(20):self.assertEqual(cfg.get('processes',f'app{index}'),f'worker-{index}')
|
||||
self.assertFalse(list(self.config.parent.glob('agent.conf.*tmp*')))
|
||||
|
||||
|
||||
class StateTests(unittest.TestCase):
|
||||
def test_weekly_root_cache_survives_restart_after_first_day(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
reads=[]
|
||||
def reader(_):reads.append(1);return SimpleNamespace(total=100,used=20,free=80)
|
||||
path=Path(directory)/'cache'
|
||||
StorageCache(path,reader,lambda:1000).sample()
|
||||
restored=StorageCache(path,reader,lambda:101000)
|
||||
restored.sample();self.assertEqual(len(reads),1)
|
||||
self.assertEqual(restored.next_update_at,605800)
|
||||
StorageCache(path,reader,lambda:605800).sample();self.assertEqual(len(reads),2)
|
||||
|
||||
def test_publisher_stops_renewing_stale_data_and_recovers(self):
|
||||
now=[0];calls=[]
|
||||
publisher=plugin_api.Publisher({},'test',lambda *a,**k:None)
|
||||
class Event:
|
||||
def is_set(self):return now[0]>=8
|
||||
def wait(self,_):
|
||||
now[0]+=1
|
||||
if now[0]==6:publisher.publish(state)
|
||||
publisher.stop_event=Event()
|
||||
state={'system':{},'network':{},'display':{'alerts':[]}}
|
||||
def request(path,method,endpoint,payload=None):
|
||||
if method=='PUT':calls.append((now[0],payload['revision']))
|
||||
return {'api_version':1}
|
||||
with patch.object(plugin_api.time,'monotonic',lambda:now[0]),patch.object(plugin_api,'request',request):
|
||||
publisher.publish(state);publisher.run('test','unused')
|
||||
self.assertEqual(calls,[(0,1),(1,2),(2,3),(6,4),(7,5)])
|
||||
|
||||
def test_status_api_rejects_stale_agent_then_accepts_fresh_data(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'status.json';path.write_text(json.dumps({'timestamp':time.time()-60}))
|
||||
token=Path(directory)/'token';token.write_text('test-token')
|
||||
with patch.object(api,'STATUS',path),patch.object(api,'TOKEN',token):
|
||||
server=ThreadingHTTPServer(('127.0.0.1',0),api.Handler)
|
||||
worker=threading.Thread(target=server.serve_forever);worker.start()
|
||||
try:
|
||||
req=Request(f'http://127.0.0.1:{server.server_port}/api/v1/status',headers={'Authorization':'Bearer test-token'})
|
||||
with self.assertRaises(HTTPError) as caught:urlopen(req)
|
||||
self.assertEqual(caught.exception.code,503)
|
||||
caught.exception.close()
|
||||
path.write_text(json.dumps({'timestamp':time.time()}))
|
||||
with urlopen(req) as response:self.assertEqual(response.status,200)
|
||||
finally:server.shutdown();worker.join();server.server_close()
|
||||
|
||||
def test_alert_severity_owner_changes_and_configured_disk_text(self):
|
||||
tree=ast.parse((ROOT/'app/pigway_pi_control.py').read_text())
|
||||
names={'AlertManager','alert_priority','update_active_alerts'}
|
||||
nodes=[n for n in tree.body if isinstance(n,(ast.ClassDef,ast.FunctionDef)) and n.name in names]
|
||||
logs=[]
|
||||
env={'time':time,'log':lambda *args,**kwargs:logs.append((args,kwargs)),
|
||||
'ALERT_PRIORITIES':{'CPU_HIGH':45,'DISK_CRITICAL':55,'DISK_HIGH':40,'TEMP_HIGH':50,'TEMP_CRITICAL':90},
|
||||
'ALERT_CATEGORIES':{'CPU_HIGH':'CPU','DISK_CRITICAL':'STORAGE','DISK_HIGH':'STORAGE','TEMP_HIGH':'CPU','TEMP_CRITICAL':'CPU'}}
|
||||
exec(compile(ast.Module(body=nodes,type_ignores=[]),'monitor-functions','exec'),env)
|
||||
manager=env['AlertManager']()
|
||||
manager.request('CPU_HIGH',1,'CPU');manager.request('DISK_CRITICAL',2,'DISK')
|
||||
self.assertEqual(logs[-1][0][0],'CRITICAL')
|
||||
manager.request('TEMP_HIGH',3,'TEMP')
|
||||
self.assertEqual(manager.status_snapshot()['owner'],'TEMP_HIGH')
|
||||
self.assertEqual(logs[-1][0][0],'EMERGENCY')
|
||||
before=len(logs);manager.request('TEMP_HIGH',3,'TEMP');self.assertEqual(len(logs),before)
|
||||
manager.request('TEMP_HIGH',1,'TEMP');self.assertEqual(logs[-1][0][1],'ALERT_CHANGED')
|
||||
env.update(alert_manager=manager,cpu_hi=0,mem_hi=0,CPU_HIGH=90,CPU_RECOVER=75,CPU_HIGH_SEC=30,
|
||||
MEM_HIGH=90,MEM_RECOVER=85,MEM_HIGH_SEC=30,TEMP_HIGH=68,TEMP_CRIT=75,
|
||||
DISK_HIGH=70,DISK_CRIT=80,link_ok=True,net_ok=True,watches=[],watch_state=[],
|
||||
net_kind='NET',health={},fs_readonly=False,ethernet_slow_active=False,
|
||||
transition=lambda *a,**k:None,net_iface='',net_speed_mbps=None,ETHERNET_MIN_SPEED=1000)
|
||||
env['update_active_alerts'](10,40,20,85)
|
||||
self.assertEqual(manager.requests['DISK_CRITICAL']['l3'],'USED >= 80%')
|
||||
env['update_active_alerts'](10,40,20,75)
|
||||
self.assertEqual(manager.requests['DISK_HIGH']['l3'],'USED >= 70%')
|
||||
@@ -0,0 +1,23 @@
|
||||
import sys,unittest,tempfile
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'app'))
|
||||
from network_rates import NetworkRates
|
||||
|
||||
class RatesTests(unittest.TestCase):
|
||||
def test_initial_idle_transfer_reset_and_switch(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
def write(interface,rx,tx):
|
||||
p=Path(root)/interface/'statistics';p.mkdir(parents=True,exist_ok=True)
|
||||
(p/'rx_bytes').write_text(str(rx));(p/'tx_bytes').write_text(str(tx))
|
||||
write('eth0',1000,2000);sampler=NetworkRates(root)
|
||||
self.assertEqual(sampler.sample('eth0',10),(None,None))
|
||||
self.assertEqual(sampler.sample('eth0',15),(0,0))
|
||||
write('eth0',1500,3000)
|
||||
self.assertEqual(sampler.sample('eth0',20),(100,200))
|
||||
write('eth0',10,20)
|
||||
self.assertEqual(sampler.sample('eth0',25),(None,None))
|
||||
write('wlan0',100,200)
|
||||
self.assertEqual(sampler.sample('wlan0',30),(None,None))
|
||||
self.assertEqual(sampler.sample('missing',35),(None,None))
|
||||
self.assertEqual(sampler.sample('eth0',40),(None,None))
|
||||
self.assertEqual(sampler.sample('../eth0',45),(None,None))
|
||||
@@ -0,0 +1,41 @@
|
||||
import sys,tempfile,unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'app'))
|
||||
from storage_cache import StorageCache
|
||||
|
||||
|
||||
class StorageTests(unittest.TestCase):
|
||||
def test_weekly_reads_and_restart_cache(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
now=[1000];reads=[]
|
||||
def reader(path):
|
||||
reads.append(path);return SimpleNamespace(total=1000,used=200,free=800)
|
||||
p=Path(directory)/'cache.json'
|
||||
cache=StorageCache(p,reader,lambda:now[0])
|
||||
self.assertEqual(cache.sample()['used'],200)
|
||||
for i in range(20):now[0]+=1;cache.sample()
|
||||
self.assertEqual(len(reads),1)
|
||||
restored=StorageCache(p,reader,lambda:now[0]);restored.sample()
|
||||
self.assertEqual(len(reads),1)
|
||||
now[0]=605800;restored.sample()
|
||||
self.assertEqual(len(reads),2)
|
||||
|
||||
def test_failure_does_not_fabricate_zero_or_retry_each_second(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
now=[1000];reads=[]
|
||||
def fail(path):reads.append(path);raise OSError('unavailable')
|
||||
cache=StorageCache(Path(directory)/'cache.json',fail,lambda:now[0])
|
||||
self.assertIsNone(cache.sample());self.assertTrue(cache.error)
|
||||
now[0]+=5;self.assertIsNone(cache.sample())
|
||||
self.assertEqual(len(reads),1)
|
||||
|
||||
def test_failed_read_cooldown_survives_restart(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
reads=[]
|
||||
def fail(path):reads.append(path);raise OSError('unavailable')
|
||||
p=Path(directory)/'cache.json'
|
||||
StorageCache(p,fail,lambda:1000).sample()
|
||||
restored=StorageCache(p,fail,lambda:1005)
|
||||
self.assertTrue(restored.error);self.assertIsNone(restored.sample())
|
||||
self.assertEqual(len(reads),1)
|
||||
@@ -0,0 +1,35 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "app"))
|
||||
stem = "pigway_ubuntu_agent_api" if (ROOT / "app/pigway_ubuntu_agent_api.py").exists() else "pigway_pi_control_api"
|
||||
spec = importlib.util.spec_from_file_location("storage_api_under_test", ROOT / "app" / (stem + ".py"))
|
||||
api = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(api)
|
||||
|
||||
|
||||
class StorageConfigTests(unittest.TestCase):
|
||||
def test_config_round_trip_and_invalid_edits_are_atomic(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / "agent.conf"
|
||||
config.write_text("[device]\nname = auto\n")
|
||||
identifier = "a" * 32
|
||||
with patch.object(api, "CFG", config):
|
||||
api.update_ini({"storage_names": {identifier: "媒体盘 100%"}, "storage_visible": {identifier: "false"}})
|
||||
self.assertEqual(api.config_json()["storage_names"][identifier], "媒体盘 100%")
|
||||
self.assertEqual(api.config_json()["storage_visible"][identifier], "false")
|
||||
original = config.read_text()
|
||||
for updates in [
|
||||
{"storage_visible": {identifier: "yes"}},
|
||||
{"storage_names": {"invalid-id": "Bad"}},
|
||||
{"storage_names": {identifier: "x" * 65}},
|
||||
{"storage_names": {identifier: "bad\tname"}},
|
||||
]:
|
||||
with self.assertRaises(ValueError):
|
||||
api.update_ini(updates)
|
||||
self.assertEqual(config.read_text(), original)
|
||||
@@ -0,0 +1,97 @@
|
||||
import configparser
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app"))
|
||||
from storage_cache import StorageInventory
|
||||
|
||||
Usage = namedtuple("Usage", "total used free")
|
||||
|
||||
|
||||
class StorageInventoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.mounts = Path(self.tmp.name) / "mountinfo"
|
||||
self.mounts.write_text("1 0 8:1 / / rw - ext4 /dev/sda1 rw\n2 0 8:2 / /media/My\\040Disk rw - ext4 /dev/sda2 rw\n3 0 8:2 /folder /bind rw - ext4 /dev/sda2 rw\n")
|
||||
self.data = {"blockdevices": [
|
||||
{"name": "/dev/sda", "type": "disk", "serial": "serial", "children": [
|
||||
{"name": "/dev/sda1", "type": "part", "uuid": "root", "maj:min": "8:1", "fstype": "ext4", "size": 1000},
|
||||
{"name": "/dev/sda2", "type": "part", "uuid": "data", "maj:min": "8:2", "fstype": "ext4", "size": 2000},
|
||||
]},
|
||||
{"name": "/dev/sdb", "type": "disk", "size": 3000},
|
||||
{"name": "/dev/loop0", "type": "loop", "size": 4000},
|
||||
{"name": "/dev/zram0", "type": "disk", "fstype": "swap"},
|
||||
]}
|
||||
self.now = 100
|
||||
self.reader = StorageInventory(clock=lambda: self.now, runner=lambda: json.dumps(self.data),
|
||||
mounts_path=self.mounts, usage=lambda p: Usage(1000, 0, 1000),
|
||||
cache_path=Path(self.tmp.name)/"inventory.json")
|
||||
self.reader.scan()
|
||||
|
||||
def test_weekly_usage_cache_survives_service_restart(self):
|
||||
capacity=Path(self.tmp.name)/'usage.json'
|
||||
self.reader.capacity_path=capacity
|
||||
self.reader.sample()
|
||||
def forbidden(*args):raise AssertionError('Not due; should not read capacity or discover disks')
|
||||
restarted=StorageInventory(clock=lambda:self.now+100,runner=forbidden,mounts_path=self.mounts,
|
||||
usage=forbidden,cache_path=self.reader.cache_path,capacity_path=capacity)
|
||||
self.assertEqual(len(restarted.sample()),3)
|
||||
self.assertEqual(restarted.observed_at,self.now)
|
||||
self.assertEqual(restarted.next_update_at,self.now+604800)
|
||||
|
||||
def test_mounts_raw_disk_and_pseudo_filters(self):
|
||||
disks = self.reader.sample()
|
||||
self.assertEqual(len(disks), 3)
|
||||
self.assertEqual(disks[0]["mountpoint"], "/")
|
||||
self.assertEqual(next(d for d in disks if d.get("uuid") == "data")["mountpoint"], "/media/My Disk")
|
||||
self.assertEqual(disks[0]["used_percent"], 0)
|
||||
raw = next(d for d in disks if d["device"] == "/dev/sdb")
|
||||
self.assertEqual(raw["state"], "unmounted")
|
||||
self.assertIsNone(raw["used_bytes"])
|
||||
|
||||
def test_uuid_survives_renumber_and_configuration(self):
|
||||
disk = self.reader.sample()[0]
|
||||
self.data["blockdevices"][0]["children"][0]["name"] = "/dev/sdz1"
|
||||
self.now += 61
|
||||
self.reader.scan()
|
||||
config = configparser.ConfigParser()
|
||||
config.read_string(f'[storage_names]\n{disk["id"]} = 数据盘 100%\n[storage_visible]\n{disk["id"]} = false\n')
|
||||
result = next(d for d in self.reader.sample(config) if d["id"] == disk["id"])
|
||||
self.assertEqual(result["name"], "数据盘 100%")
|
||||
self.assertFalse(result["visible"])
|
||||
|
||||
def test_removal_and_discovery_error_are_distinct(self):
|
||||
disk = self.reader.sample()[0]
|
||||
config = configparser.ConfigParser()
|
||||
config.read_dict({"storage_names": {disk["id"]: "System"}})
|
||||
self.reader.runner = lambda: "invalid json"
|
||||
self.now += 61
|
||||
with self.assertRaises(ValueError):self.reader.scan()
|
||||
self.assertEqual(self.reader.sample(config)[0]["state"], "mounted")
|
||||
self.reader.runner = lambda: '{"blockdevices":[]}'
|
||||
self.now += 61
|
||||
self.reader.scan()
|
||||
self.assertEqual(self.reader.sample(config)[0]["state"], "missing")
|
||||
self.assertFalse(self.reader.error)
|
||||
|
||||
def test_hotplug_requires_manual_scan_and_inventory_survives_restart(self):
|
||||
self.assertEqual(len(self.reader.sample()), 3)
|
||||
self.data["blockdevices"].append({"name": "/dev/sdc", "type": "disk", "uuid": "new", "size": 9000})
|
||||
self.now += 29
|
||||
self.assertEqual(len(self.reader.sample()), 3)
|
||||
self.now += 100000
|
||||
self.assertEqual(len(self.reader.sample()), 3)
|
||||
self.reader.scan()
|
||||
self.assertEqual(len(self.reader.sample()), 4)
|
||||
def forbidden():raise AssertionError("automatic enumeration is forbidden")
|
||||
restored=StorageInventory(clock=lambda:self.now,runner=forbidden,mounts_path=self.mounts,
|
||||
usage=lambda p:Usage(1000,0,1000),cache_path=self.reader.cache_path)
|
||||
self.assertEqual(len(restored.sample()),4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
#!/bin/bash
|
||||
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 /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/doc/pigway-raspberry-pi-agent
|
||||
systemctl daemon-reload
|
||||
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
|
||||
echo "如确定不要配置,可手工:sudo rm /etc/pigway-pi-control.conf"
|
||||
|
||||
Reference in New Issue
Block a user