Compare commits

...
Author SHA1 Message Date
way c7c2b9e656 release: v3.11.4 official PIGWay branding
Verify / test (push) Successful in 7s
2026-10-19 19:22:35 +08:00
way fa7cc0705b feat: integrate official PIGWay v1.1 brand assets
Verify / test (push) Successful in 5s
2026-10-19 19:03:09 +08:00
way 8c23ef7aac ci: use verified official actions on the shared Server runner
Verify / test (push) Successful in 6s
2026-10-18 22:18:59 +08:00
way 5912a55215 release: v3.11.3 open-source delivery and audit fixes
Verify / test (push) Failing after 5m3s
2026-10-18 18:45:02 +08:00
way 9b83937d60 fix: validate atomic configuration updates and expire stale monitoring snapshots 2026-10-18 18:15:22 +08:00
way c82d9ab3c2 docs: organize project guides and consolidate historical records 2026-10-18 17:59:18 +08:00
way 8b7100daa5 docs: align repository names and bilingual entry points; remove obsolete reports 2026-10-18 17:53:43 +08:00
way 747503d86d feat: add manual disk discovery and weekly capacity cache 2026-10-15 23:30:06 +08:00
way 174ac747c5 feat: expose per-group refresh plans and cache storage daily 2026-10-15 09:17:20 +08:00
way b63fa39f32 docs: confirm sampling timestamps production deployment 2026-10-15 00:51:47 +08:00
way e828fd11ee feat: expose true sampling timestamps and cache read cadence 2026-10-15 00:35:32 +08:00
way 7aa8f838a6 feat: sample Raspberry Pi primary interface traffic rates 2026-10-14 23:43:40 +08:00
way e55ad3217f docs: clarify Raspberry Pi project names and integration scope 2026-10-11 16:29:26 +08:00
way 26c313df46 feat: monitor ethernet link speed 2026-10-11 00:34:21 +08:00
way fc27079126 release: support Raspberry Pi OS Trixie 2026-10-11 00:06:22 +08:00
way b0998555d9 docs: refine language switch links 2026-09-28 21:28:27 +08:00
way f735c3fb4e docs: split English and Chinese readmes 2026-09-28 20:03:13 +08:00
way 436b87f40e docs: add bilingual project documentation 2026-09-27 23:31:25 +08:00
way 302c38986e release: merge Device Agent v3.7.0 2026-09-27 23:17:45 +08:00
way 72a3a2e0f3 docs: align project names and repository links 2026-09-27 22:37:54 +08:00
way 95494aa894 refactor: separate fleet manager from local monitoring 2026-09-27 22:28:03 +08:00
way 10f57a5699 style: simplify fan speed labels 2026-09-27 21:50:05 +08:00
way 11f8ffd5f6 style: unify preference icon and text spacing 2026-09-27 21:48:18 +08:00
way 75d0ce2d85 style: align fan status with CPU frequency 2026-09-27 21:47:01 +08:00
way 38382ba61c style: integrate globe icon into language selector 2026-09-27 21:44:03 +08:00
way da5f53ad99 fix: show fan telemetry with CPU and space action rows 2026-09-27 21:42:57 +08:00
way 1087ec828f style: increase save status spacing 2026-09-27 21:22:16 +08:00
way ece74e0a3f style: space plugin save actions below settings 2026-09-27 21:21:36 +08:00
way b60928c30f feat: add machine management and resolved device identity 2026-09-27 21:20:44 +08:00
way 5f3e9353ed docs: clarify API token retrieval wording 2026-09-27 21:11:20 +08:00
way 510f90288a Merge standalone monitoring and plugin architecture for v3.7.0 2026-09-27 21:06:03 +08:00
way 52b21db59e docs: present standalone monitoring and connected plugins 2026-09-27 21:05:43 +08:00
way 99ae7af238 docs: generalize plugin capability guidance 2026-09-27 21:02:52 +08:00
way a01b34af6b docs: clarify autonomous plugin telemetry and lease expiry 2026-09-27 20:54:06 +08:00
way 33e632e4b9 fix: show plugin refresh results and startup guidance 2026-09-27 20:33:28 +08:00
way 3ef26a76fc fix: restore CPU meter and explain unavailable fan status 2026-09-27 20:31:29 +08:00
way bef4f72b44 refactor: separate monitoring from hardware plugins 2026-09-27 20:24:34 +08:00
way c03f481dd4 fix: quench RGB after fan register writes 2026-09-27 15:19:05 +08:00
way 8b90dc9612 fix: isolate fan writes from active RGB frames 2026-09-27 15:16:10 +08:00
way a4d9d9aba7 refactor: isolate OLED and RGB workers 2026-09-27 15:07:16 +08:00
way cc31f71c7b fix: stabilize RGB startup and color transitions 2026-09-27 15:03:15 +08:00
way 83d1d9b8c3 style: reorganize event log filters 2026-09-27 14:58:21 +08:00
way 2e9acee06e style: refine language and appearance controls 2026-09-27 14:55:03 +08:00
way af03722fe3 fix: preserve device hover across status refreshes 2026-09-27 14:50:35 +08:00
way ded4cb514c feat: add independent batch configuration sync 2026-09-27 14:48:03 +08:00
way 2515c64c22 fix: align configuration target controls 2026-09-27 14:39:21 +08:00
way 4bcc12f91c style: flatten inner cards and lift devices on hover 2026-09-27 14:37:36 +08:00
way b957c63847 merge: multi-host monitoring and configuration 2026-09-27 14:34:46 +08:00
35 changed files with 1670 additions and 1403 deletions
+22
View File
@@ -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: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
- run: python -m unittest discover -s tests -v
- run: git diff --check
+7
View File
@@ -3,3 +3,10 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
*.log *.log
.data/
dist/
/storage-release.json
/ops/deploy-storage.py
/ops/release-storage.py
.pytest_cache/
.ruff_cache/
+9
View File
@@ -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.
+21
View File
@@ -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.
+45 -257
View File
@@ -1,275 +1,63 @@
# PIGWay Pi Control v3.7.0 <p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。 # PIGWay Raspberry Pi Agent v3.11.4
v3.7.0 在现有本机硬件监控基础上增加多机状态、日志和配置管理;未登记其他节点时继续作为完整的单机版本运行。 [简体中文](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).
- SSD1306 128×32 OLED,四行固定主页
- CPU / 内存 / 温度 / 磁盘监控
- Wi-Fi RSSI / Ethernet 链路速率
- 当前默认路由与 IP 同步切换
- 风扇自动温控、回差防抖
- RGB:自定义颜色表示故障对象,频闪速度表示严重程度
- systemd 服务与普通进程监控
- Raspberry Pi PWR / ACT 运行时 Dark Mode
主页示例: 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.
```text ### Installation
CPU 18.6% MEM 16.2%
TMP 46.3C FAN OFF
DSK 22.4% WIF -32
IP4 192.168.1.100
```
## v3.5.0 System Health 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.
### 1. 供电与降频健康
周期读取:
```bash
vcgencmd get_throttled
```
区分:
- 当前欠压:立即 `POWER LOW / UNDERVOLT` 告警
- 当前 CPU throttled / frequency capped:立即告警
- 本次启动历史上曾欠压/降频:只写一次历史事件日志,不持续 RGB 告警
- 状态恢复:写 `RECOVERED` 类事件
注意:这里检测的是 **Pi 的供电质量**,不是 UPS 电池百分比。
### 2. 文件系统健康
检测根文件系统 `/` 是否被内核切成只读。如果出现只读状态,产生严重告警:
```text
FILESYSTEM RO
ROOT READ ONLY
CHECK STORAGE
```
### 3. 本机诊断信息
内部维护:
- uptime
- load average
- CPU 当前频率
- 当前/历史 throttled flags
- 当前主网络接口 / IP
- 根文件系统只读状态
- sensor / network / service / health 最近成功时间
- 主循环异常计数
- I²C相关异常计数基础字段
### 4. DisplayController、OLED 与 RGB
每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个最高优先级 owner;同一 ID 更新原请求而不重复入队,恢复时删除请求,同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。历史 power flags 只进入 diagnose 和一次性 Journal 记录。
### 5. RGB 正常状态与告警引擎
没有 active alert 时,RGB 默认全灭。`[led] normal_mode` 可改为自定义颜色常亮/闪烁,或 MCU 已确认的流水、呼吸、跑马、彩虹和炫彩效果。常亮与闪烁使用 0..255 的自定义 R/G/B;内置流水/呼吸使用 MCU 固定的红、绿、蓝、黄、紫、青、白七色和慢/中/快三档速度,跑马/彩虹/炫彩由 MCU 自行配色。内置效果只在模式切换时按 speed → color → effect 的实机校准顺序写一次寄存器,不循环刷 I²C。
任何 active alert 都会立即抢占正常效果。RGB 告警模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
### 6. 结构化事件日志
日志继续交给 systemd journal,不额外制造长期 `.log` 文件。
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
```text
level=INFO event=START version=3.7.0
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
level=WARN event=NETWORK_DOWN ...
level=INFO event=NETWORK_RECOVERED ...
level=WARN event=WATCH_DOWN name=AWESUN ...
level=INFO event=WATCH_RECOVERED name=AWESUN ...
level=WARN event=POWER_LOW raw=0x1
level=INFO event=POWER_RECOVERED raw=0x0
level=WARN event=ALERT_ACTIVE alert=TEMP_HIGH ...
level=INFO event=ALERT_RECOVERED ...
```
实时查看:
```bash
journalctl -u pigway-pi-control -f
```
只看本次启动:
```bash
journalctl -u pigway-pi-control -b --no-pager
```
搜索某类事件:
```bash
journalctl -u pigway-pi-control --no-pager | grep 'event=POWER'
```
### 7. 诊断命令
```bash
sudo /usr/local/sbin/pigway-pi-control --diagnose
```
输出 Agent 版本、系统 uptime、配置路径、load、CPU 频率、根文件系统、全部当前/历史 power 位、主网络接口、完整 IP 和 Internet 状态,以及所有 service/process watch 状态。
诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。
RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜色配合低频亮灭最稳定;每次点亮完整写入 selector/R/G/B,寄存器间隔默认10ms。旧版 `brightness`、呼吸周期和颜色选项可以继续留在用户配置中,但频闪引擎会忽略它们。
### 8. 关机收尾
收到 systemd SIGTERM / 系统关机时:
1. 写 STOP 日志
2. RGB OFF
3. 风扇 OFF
4. OLED 清屏并 Display OFF
5. 关闭 I²C handle
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
## v3.7.0 多机监控
每台机器在 `[device]` 中设置稳定标识和显示名称;保留 `auto` 时读取本机 hostname。作为 Web 聚合入口的机器可在 `[hosts]` 中登记其他已安装 PIGWay Pi Control 的节点:
```ini
[device]
identifier = living-room
name = 客厅树莓派
[hosts]
study = http://192.168.1.20:6001
nas-room = http://192.168.1.21:6001
```
未设置 `[hosts]` 时,API 和 Web 保持单机使用方式。聚合入口通过各节点现有 HTTP API 读取状态、journal 和配置;节点不可达时会在机器卡片上显示离线,不影响其他节点。
新增接口:
```text
GET /api/v1/hosts
PUT /api/v1/hosts
GET /api/v1/fleet/status
GET /api/v1/fleet/logs
PUT /api/v1/fleet/config
```
运行状态按机器显示独立卡片,包括机器标识、hostname、型号、序列号、板卡版本、操作系统、内核、架构、CPU 核数和 Agent 版本。内存和根分区同时显示百分比、已用量、总量及可用/剩余量;Wi-Fi 以 0–100% 为主值,并保留原始 dBm。OLED 的 Wi-Fi 主值同样使用百分比。
事件日志增加机器列、机器筛选和机器排序。系统配置可选择目标机器;批量同步只发送当前已修改的配置项,并逐台返回成功或失败结果。远端 Bearer Token 只保存在当前浏览器的 localStorage 中,不写入聚合机器配置文件。
## v3.6.0 API 与 Web
安装后访问:
```text
http://树莓派IP:6001/
```
Web 与 JSON API 由独立的 `pigway-pi-control-api.service` 提供。API 服务不访问 I²C;硬件 Agent 每秒原子更新 `/run/pigway-pi-control/status.json`,API 只读取该快照。默认监听地址和端口可在 `[api]` 中修改:
```ini
[api]
bind = 0.0.0.0
port = 6001
log_limit = 200
```
只读接口:
```text
GET /api/v1/health
GET /api/v1/status
GET /api/v1/logs?limit=200
GET /api/v1/config
GET /api/v1/services
```
日志接口支持 `since`、`until` Unix 时间戳,`severity`、`event`、`search` 筛选,`timestamp` / `severity` / `event` / `message` 排序,以及 `limit` / `offset` 分页。Web 提供今天、昨天、本周、本月和全部快捷范围。日志直接读取 systemd journal 的现有保留数据,不建立重复的 SQLite 日志副本。
Web 界面支持中文和 English 切换,并将结构化 journal 事件类型显示为当前界面的语言;journal 内部稳定的事件标识保持不变,便于脚本筛选。外观可选择自动、深色或浅色,自动模式跟随浏览器/操作系统配色。语言和外观偏好只保存在当前浏览器中。
配置写入接口:
```text
PUT /api/v1/config
Authorization: Bearer <token>
Content-Type: application/json
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
```
服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。API 服务不能通过自己的请求停止自身,其他服务由用户自行管理。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。
“暂停监控/恢复监控”只改变 PIGWay 是否检查该服务,不会启动或停止 systemd unit;暂停状态保存在 `[service_monitor_disabled]`。只有明确标注为“启动服务/停止服务”的按钮才会调用 systemctl。
读取 token:
```bash
sudo cat /etc/pigway-pi-control-api.token
```
配置更新仅允许 `[services]`、`[processes]`、`[alerts]`、`[fan]`、`[led]`、`[timing]`、`[dark_mode]`、`[display]` 和 `[api]`。每次写入先生成时间戳备份,再原地更新目标键并重启硬件 Agent;修改 `[api]` 时 API 服务也会自动重启。
## 用户配置
正式配置只有一个:
```text
/etc/pigway-pi-control.conf
```
编辑:
```bash
sudo nano /etc/pigway-pi-control.conf
sudo systemctl restart pigway-pi-control
```
配置文件内已经写明每项用途、单位和修改方式。
## 安装 / 升级测试分支
```bash ```bash
sudo ./install.sh 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.
- 安装必要依赖 The configuration file is `/etc/pigway-pi-control.conf`. Restart the Agent after changing monitoring settings:
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理
- 安装字体、程序、配置和 systemd unit
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
- 生成只读 root token,安装 API/Web 文件
- 启动硬件 Agent 与 API/Web 服务
- 执行版本、OLED、UI、P0健康监控和诊断入口自检
## 常用命令
```bash ```bash
systemctl status pigway-pi-control --no-pager -l sudo systemctl restart pigway-pi-control.service
journalctl -u pigway-pi-control-api -f
journalctl -u pigway-pi-control -f
sudo systemctl restart pigway-pi-control
sudo systemctl restart pigway-pi-control-api
sudo /usr/local/sbin/pigway-pi-control --diagnose
``` ```
## 版本路线 ### Optional API
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换 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:
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面** ```bash
- **v3.7.0:多机状态卡片、聚合日志、机器配置选择与批量同步** 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 /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
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.
[Documentation](docs/README.md)
+65
View File
@@ -0,0 +1,65 @@
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
# PIGWay Raspberry Pi Agent v3.11.4
简体中文 | [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)
+5
View File
@@ -0,0 +1,5 @@
# pigway-raspberry-pi-agent v3.11.4
正式 PIGWAY Brand Production v1.1 图形标、项目头像与来源说明随项目交付;中英文项目介绍页使用统一品牌图。本版以品牌资源和项目文档交付为主。
软件业务逻辑和设备控制边界沿用上一版本。Logo 使用已发布的 v1.1 原文件,不重新生成或修改颜色、比例。源码包保留 LICENSE 及原有第三方许可说明;插件 ZIP 保留对应许可证。
+7
View File
@@ -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.
+32 -10
View File
@@ -1,10 +1,32 @@
857d0dc4c6281a2e4285a9e1efb89e666c27ff0f3a6a8d021a55e18bbee06e39 README.md 6635c6d8b53076d7fd9fe2c3382469420ada532ce010970644e912b877495151 .github/workflows/verify.yml
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin 4035aba62136040755ffdf2faf1556a49215e829c16ee44263e0ab7530fbf521 .gitignore
d0fa68951097a90eb6f51b36e56dc935d8fd7632c3de0ad6bff0b448960a7c03 app/pigway_pi_control.py 79f34290cfa1a6f0928162909848e6597d46183d0de6b213bf52b12546f5fc33 CONTRIBUTING.md
034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf 9fe9181f11649cc72a2e3fc9f0edff4df8b57f2e9408e28c44628c7eaa298311 README.md
f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh 8d1ca6252993fb50e29b0540c4418f2f58ea0be112d398dbd69ace86bff0f1bc README.zh-CN.md
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service 3ab9fe57e5dd6caa2ecb6651557f64a328a6395a63a7795db0471f3d166cb1da RELEASE_NOTES.md
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service 6cead6c1378cd840f655adc3d467e2cd6fd3ab368d7200a3afbfe85d9ed159be SECURITY.md
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py
3a40109f11bea2d44e9d61bed4a90499a5047c4fc534f97aaa90becbf9e57017 web/index.html f28ac6ca73f94dba9190ff815e8de77714ddcbf46b84546fb8f6378545db54df app/pigway_pi_control.py
36da02aac8edce97be94e2c3942bb23ffff0d23023b76efd5073d0876b32d9c9 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
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
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
+29
View File
@@ -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.
+183 -499
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json,os,platform import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
from pathlib import Path from pathlib import Path
APP_VERSION="3.7.0" APP_VERSION="3.11.4"
from smbus2 import SMBus import sys
from PIL import Image sys.path.append("/usr/local/share/pigway-pi-control")
from shutil import disk_usage from 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" CFG="/etc/pigway-pi-control.conf"
STATUS_PATH="/run/pigway-pi-control/status.json" STATUS_PATH="/run/pigway-pi-control/status.json"
running=True running=True
@@ -53,6 +55,22 @@ def device_details():
except Exception: pass except Exception: pass
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode() try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
except Exception: pass 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: try:
os_release={} os_release={}
for line in Path("/etc/os-release").read_text().splitlines(): for line in Path("/etc/os-release").read_text().splitlines():
@@ -84,79 +102,21 @@ def throttled_state():
if not m: return {"raw":"UNKNOWN","error":"vcgencmd get_throttled unavailable or invalid"} 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))} return {"raw":f"0x{v:x}","under_voltage_now":bool(v&1),"freq_capped_now":bool(v&2),"throttled_now":bool(v&4),"soft_temp_limit_now":bool(v&8),"under_voltage_occurred":bool(v&(1<<16)),"freq_capped_occurred":bool(v&(1<<17)),"throttled_occurred":bool(v&(1<<18)),"soft_temp_limit_occurred":bool(v&(1<<19))}
bus=None
bus_lock=threading.RLock()
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
FONT5=b""
agent={"loop_errors":0,"i2c_errors":0}
def bus_write(method,*args):
with bus_lock:
try: return getattr(bus,method)(*args)
except OSError:
agent["i2c_errors"]+=1
raise
fan_level=0
prev_cpu=None prev_cpu=None
page=0; page_since=time.monotonic() page=0; page_since=time.monotonic()
cpu_hi=mem_hi=0 cpu_hi=mem_hi=0
net_ok=link_ok=True net_ok=link_ok=True
net_iface=""; net_kind="NET"; net_metric="DOWN"; ip_label="IP4"; 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 wifi_weak_since=None; wifi_weak_active=False
ethernet_slow_since=None; ethernet_slow_active=False
last_net=last_watch=0 last_net=last_watch=0
watch_state=[] watch_state=[]
def cmd(c): bus_write("write_byte_data",OLED,0x00,c)
def oled_init():
# Seller-native physical 128x32 configuration.
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
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("write_i2c_block_data",OLED,0x40,data[i:i+16])
def mcu(reg,val): bus_write("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 set_pi_led(name,off=True): def set_pi_led(name,off=True):
base=Path("/sys/class/leds")/name base=Path("/sys/class/leds")/name
try: try:
@@ -170,24 +130,6 @@ def apply_dark_mode():
if DARK_PWR: set_pi_led("PWR",True) if DARK_PWR: set_pi_led("PWR",True)
if DARK_ACT: set_pi_led("ACT",True) if DARK_ACT: set_pi_led("ACT",True)
FAN_REG=[0x00,0x04,0x06,0x08,0x09,0x01]
FAN_NAME=["OFF","L1","L2","L3","L4","MAX"]
def fan_target(t):
if t<FAN_T[0]: return 0
if t<FAN_T[1]: return 1
if t<FAN_T[2]: return 2
if t<FAN_T[3]: return 3
if t<FAN_T[4]: return 4
return 5
def update_fan(t):
global fan_level
want=fan_target(t); old=fan_level
if want>fan_level: fan_level=want
elif want<fan_level:
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
if t < boundary: fan_level-=1
if old!=fan_level: mcu(0x08,FAN_REG[fan_level])
def cpu_pct(): def cpu_pct():
global prev_cpu global prev_cpu
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9])) p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
@@ -203,7 +145,8 @@ def mem_pct():
k,v=l.split(":",1); vals[k]=int(v.split()[0]) k,v=l.split(":",1); vals[k]=int(v.split()[0])
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"] return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
def disk_pct(): def disk_pct():
d=disk_usage("/"); return 100*d.used/d.total d=storage_cache.sample()
return 100*d["used"]/d["total"] if d else None
def _run(args,timeout=2): def _run(args,timeout=2):
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip() try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip()
except Exception: return "" except Exception: return ""
@@ -214,8 +157,9 @@ def primary_network():
if not dev: if not dev:
out=_run(["ip","-6","route","get","2606:4700:4700::1111"]) 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" dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP6"
if not dev: return "","NET","DOWN","IP4","NO IP" if not dev: return "","NET","DOWN",None,"IP4","NO IP"
iface=dev.group(1); ipval=src.group(1) if src else "" iface=dev.group(1); ipval=src.group(1) if src else ""
speed_mbps=None
if Path(f"/sys/class/net/{iface}/wireless").exists(): if Path(f"/sys/class/net/{iface}/wireless").exists():
kind="WIF"; metric="--" kind="WIF"; metric="--"
try: try:
@@ -227,12 +171,13 @@ def primary_network():
kind="ETH"; metric="UP" kind="ETH"; metric="UP"
try: try:
n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip()) 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) metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n)
except Exception: pass except Exception: pass
return iface,kind,metric,label,(ipval or "NO IP") return iface,kind,metric,speed_mbps,label,(ipval or "NO IP")
def get_ip(): def get_ip():
return primary_network()[4] return primary_network()[5]
def internet(): def internet():
try: try:
@@ -240,7 +185,7 @@ def internet():
except: return False except: return False
def load_cfg(): def load_cfg():
c=configparser.ConfigParser(); c.read(CFG) c=configparser.ConfigParser(interpolation=None); c.read(CFG)
watches=[] watches=[]
if c.has_section("services"): if c.has_section("services"):
for label,target in c["services"].items(): for label,target in c["services"].items():
@@ -254,6 +199,7 @@ def load_cfg():
if target and not target.startswith("#"): watches.append(("process",label.upper(),target)) if target and not target.startswith("#"): watches.append(("process",label.upper(),target))
return c,watches return c,watches
cfg,watches=load_cfg() 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): def cf(section,key,default,cast=float):
try: return cast(cfg.get(section,key)) try: return cast(cfg.get(section,key))
@@ -264,13 +210,12 @@ 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 DEVICE_NAME=cf("device","name",DEVICE_ID,str).strip() or DEVICE_ID
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
DEVICE_INFO=device_details() DEVICE_INFO=device_details()
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0) SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
PAGE_INTERVAL=cf("timing","page_interval",10.0)
NETWORK_INTERVAL=cf("timing","network_interval",5.0) NETWORK_INTERVAL=cf("timing","network_interval",5.0)
SERVICE_INTERVAL=cf("timing","service_interval",5.0) SERVICE_INTERVAL=cf("timing","service_interval",5.0)
HEALTH_INTERVAL=cf("timing","health_interval",1.0) 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_HIGH=cf("alerts","cpu_high",90.0)
CPU_RECOVER=cf("alerts","cpu_recover",75.0) CPU_RECOVER=cf("alerts","cpu_recover",75.0)
@@ -285,52 +230,19 @@ DISK_CRIT=cf("alerts","disk_critical",95.0)
WIFI_WEAK=cf("alerts","wifi_weak",-75.0) WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0) WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0) WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
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_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no")) DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) ALERT_CATEGORIES={
WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int)))
CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,int)))
EMERGENCY_FLASH=(max(50,cf("led","emergency_flash_on_ms",300,int)),max(50,cf("led","emergency_flash_off_ms",300,int)))
NORMAL_MODE=cf("led","normal_mode","off",str).strip().lower()
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
if NORMAL_MODE not in NORMAL_MODES: NORMAL_MODE="off"
NORMAL_COLOR=tuple(max(0,min(255,cf("led",f"normal_{channel}",value,int)))
for channel,value in zip(("r","g","b"),(0,0,0)))
NORMAL_FLASH=(max(50,cf("led","normal_flash_on_ms",1000,int)),
max(50,cf("led","normal_flash_off_ms",1000,int)))
NORMAL_EFFECT_SPEED=max(1,min(3,cf("led","normal_effect_speed",2,int)))
NORMAL_EFFECT_COLOR=cf("led","normal_effect_color","green",str).strip().lower()
RGB_EFFECT_COLORS={"red":0,"green":1,"blue":2,"yellow":3,"purple":4,"cyan":5,"white":6}
if NORMAL_EFFECT_COLOR not in RGB_EFFECT_COLORS: NORMAL_EFFECT_COLOR="green"
RGB_EFFECTS={"flow":0,"breathe":1,"marquee":2,"rainbow":3,"colorful":4}
NORMAL_RGB_MODE={"off":"OFF","solid":"NORMAL_SOLID","flash":"NORMAL_FLASH",
**{name:f"NORMAL_{name.upper()}" for name in RGB_EFFECTS}}[NORMAL_MODE]
def rgb_cfg(name,default):
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
for channel,value in zip(("r","g","b"),default))
RGB_GROUP_VALUES={
"CPU":rgb_cfg("cpu",(255,0,0)),
"POWER":rgb_cfg("power",(255,40,0)),
"MEMORY":rgb_cfg("memory",(255,0,255)),
"STORAGE":rgb_cfg("storage",(255,255,255)),
"NETWORK":rgb_cfg("network",(0,0,255)),
"SERVICE":rgb_cfg("service",(0,255,96)),
}
RGB_OBJECT_GROUPS={
"ROOT_READONLY":"STORAGE", "ROOT_READONLY":"STORAGE",
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU", "TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU", "THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
"POWER_UNDERVOLT":"POWER", "POWER_UNDERVOLT":"POWER",
"MEMORY_HIGH":"MEMORY", "MEMORY_HIGH":"MEMORY",
"DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE", "DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE",
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK", "NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK","ETHERNET_SPEED_LOW":"NETWORK",
"SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE", "SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE",
} }
@@ -348,7 +260,7 @@ def diagnose():
print("Agent:") print("Agent:")
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}") 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(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process") print("loop_errors=UNAVAILABLE scope=separate_diagnostic_process")
print("System:") print("System:")
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}") print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
print("Power:") print("Power:")
@@ -358,30 +270,19 @@ def diagnose():
"under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"): "under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"):
print(f"{key}={power.get(key,'UNKNOWN')}") print(f"{key}={power.get(key,'UNKNOWN')}")
if "error" in power: print("error="+power["error"]) if "error" in power: print("error="+power["error"])
iface,kind,metric,label,address=primary_network() iface,kind,metric,speed,label,address=primary_network()
print("Network:") print("Network:")
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}") print(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("I2C:") print("Plugins (read-only API discovery; no hardware access):")
# SMBus receive-byte performs a read only; no register selection or display commands. for plugin in discover(cfg):
try: print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
with SMBus(1) as probe:
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
try:
probe.read_byte(address)
print(f"0x{address:02X} {name}=PRESENT")
except OSError as e:
status="MISSING" if e.errno in (errno.ENXIO,getattr(errno,"EREMOTEIO",121)) else "ERROR"
print(f"0x{address:02X} {name}={status} error={e}")
except OSError as e:
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
print(f"0x{address:02X} {name}=ERROR error={e}")
print("Watches:") print("Watches:")
for w in watches: for w in watches:
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}") print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
return 0 return 0
DISPLAY_PRIORITIES={ ALERT_PRIORITIES={
"ROOT_READONLY":100, "ROOT_READONLY":100,
"TEMP_CRITICAL":90, "TEMP_CRITICAL":90,
"SOFT_TEMP_LIMIT":85, "SOFT_TEMP_LIMIT":85,
@@ -398,11 +299,12 @@ DISPLAY_PRIORITIES={
"MEMORY_HIGH":45, "MEMORY_HIGH":45,
"DISK_HIGH":40, "DISK_HIGH":40,
"WIFI_WEAK":30, "WIFI_WEAK":30,
"ETHERNET_SPEED_LOW":35,
"NORMAL_HOME":0, "NORMAL_HOME":0,
} }
def display_priority(request_id): def alert_priority(request_id):
return DISPLAY_PRIORITIES[request_id.split("|",1)[0]] return ALERT_PRIORITIES[request_id.split("|",1)[0]]
def transition(key,active,on_event,off_event,**fields): def transition(key,active,on_event,off_event,**fields):
old=event_state.get(key,False) old=event_state.get(key,False)
@@ -411,43 +313,44 @@ def transition(key,active,on_event,off_event,**fields):
log("WARN" if active else "INFO",on_event if active else off_event,**fields) 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): def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
global wifi_weak_since,wifi_weak_active global wifi_weak_since,wifi_weak_active,ethernet_slow_since,ethernet_slow_active
global cpu_hi,mem_hi global cpu_hi,mem_hi
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi 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 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: display.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD") 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: display.clear("CPU_HIGH") else: alert_manager.clear("CPU_HIGH")
if temp>=TEMP_CRIT: if temp>=TEMP_CRIT:
display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") alert_manager.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
display.clear("TEMP_HIGH") alert_manager.clear("TEMP_HIGH")
elif temp>=TEMP_HIGH: elif temp>=TEMP_HIGH:
display.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING") alert_manager.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
display.clear("TEMP_CRITICAL") alert_manager.clear("TEMP_CRITICAL")
else: else:
display.clear("TEMP_HIGH"); display.clear("TEMP_CRITICAL") alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
if mem_hi>=MEM_HIGH_SEC: display.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","") if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
else: display.clear("MEMORY_HIGH") else: alert_manager.clear("MEMORY_HIGH")
if disk>=DISK_CRIT: if disk is not None:
display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") if disk>=DISK_CRIT:
display.clear("DISK_HIGH") alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%",f"USED >= {DISK_CRIT:g}%","CHECK STORAGE")
elif disk>=DISK_HIGH: alert_manager.clear("DISK_HIGH")
display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE") elif disk>=DISK_HIGH:
display.clear("DISK_CRITICAL") alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%",f"USED >= {DISK_HIGH:g}%","CHECK STORAGE")
else: alert_manager.clear("DISK_CRITICAL")
display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL") else:
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
if not link_ok: if not link_ok:
display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","") alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
display.clear("INTERNET_DOWN") alert_manager.clear("INTERNET_DOWN")
elif not net_ok: elif not net_ok:
display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"") alert_manager.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
display.clear("NETWORK_DOWN") alert_manager.clear("NETWORK_DOWN")
else: else:
display.clear("NETWORK_DOWN"); display.clear("INTERNET_DOWN") alert_manager.clear("NETWORK_DOWN"); alert_manager.clear("INTERNET_DOWN")
for w,ok in zip(watches,watch_state): for w,ok in zip(watches,watch_state):
alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1] alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
if not ok: if not ok:
display.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]}) alert_manager.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
else: display.clear(alert_id) else: alert_manager.clear(alert_id)
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit(): if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
rssi=float(net_metric) rssi=float(net_metric)
if not wifi_weak_active: if not wifi_weak_active:
@@ -458,344 +361,115 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
elif rssi >= WIFI_RECOVER: elif rssi >= WIFI_RECOVER:
wifi_weak_active=False; wifi_weak_since=None wifi_weak_active=False; wifi_weak_since=None
if wifi_weak_active: if wifi_weak_active:
display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL") alert_manager.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
else: display.clear("WIFI_WEAK") else: alert_manager.clear("WIFI_WEAK")
else: else:
wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK") 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"): if health.get("under_voltage_now"):
display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")}) alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
else: display.clear("POWER_UNDERVOLT") else: alert_manager.clear("POWER_UNDERVOLT")
if health.get("throttled_now"): if health.get("throttled_now"):
display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") alert_manager.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
else: display.clear("THROTTLED") else: alert_manager.clear("THROTTLED")
if health.get("freq_capped_now"): if health.get("freq_capped_now"):
display.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") alert_manager.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
else: display.clear("FREQ_CAPPED") else: alert_manager.clear("FREQ_CAPPED")
if health.get("soft_temp_limit_now"): if health.get("soft_temp_limit_now"):
display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING") alert_manager.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
else: display.clear("SOFT_TEMP_LIMIT") else: alert_manager.clear("SOFT_TEMP_LIMIT")
if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY") if fs_readonly: alert_manager.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
else: display.clear("ROOT_READONLY") else: alert_manager.clear("ROOT_READONLY")
def rgb_mode_for(request_id,severity): class AlertManager:
if severity<=0: return "OFF" """Current monitoring facts. Hardware plugins own all display decisions."""
alert_type=request_id.split("|",1)[0]
color=RGB_OBJECT_GROUPS[alert_type]
level="EMERGENCY" if severity>=3 else "CRITICAL" if severity>=2 else "WARNING"
return f"{color}_{level}_FLASH"
def rgb_flash(mode,on):
global _last_rgb
color=mode.split("_",1)[0]
channels=RGB_GROUP_VALUES[color]
state=(mode,bool(on))
if _last_rgb==state: return
if not on:
mcu(0x07,0x00)
else:
mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x03,channels[2])
_last_rgb=state
def rgb_static(channels,state_name):
global _last_rgb
state=(state_name,channels)
if _last_rgb==state: return
mcu(0x07,0x00); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x03,channels[2])
_last_rgb=state
def rgb_normal_effect(mode):
global _last_rgb
effect_name=mode.removeprefix("NORMAL_").lower()
state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR)
if _last_rgb==state: return
# Hardware-calibrated order: speed and color are parameters; the final
# effect register write commits and starts the selected MCU animation.
mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x04,RGB_EFFECTS[effect_name])
_last_rgb=state
def render_home(cpu,temp,mem,disk):
# 128x32 fixed columns. Both columns are LEFT-aligned.
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
im=Image.new("1",(W,H),0); pix=im.load()
display_ip=ip[:7]+".."+ip[-7:] if ip_label=="IP6" and len(ip)>18 else ip
left=[
f"CPU {cpu:.1f}%",
f"TMP {temp:.1f}C",
f"DSK {disk:.1f}%",
ip_label+" "+display_ip,
]
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
right=[
f"MEM {mem:.1f}%",
f"FAN {FAN_NAME[fan_level]}",
f"WIF {signal}%" if signal is not None else f"{net_kind} {net_metric}",
"",
]
for row in range(4):
y=row*8
draw_text_5x7(pix,0,y,left[row])
if right[row]:
draw_text_5x7(pix,72,y,right[row])
data=[]
for pg in range(4):
for x in range(W):
v=0
for bit in range(8):
if pix[x,pg*8+bit]: v|=1<<bit
data.append(v)
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
for i in range(0,len(data),16):
bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16])
def render_alert(a,index,total):
oled_show([(a["title"],f"{index}/{total}"),
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
class DisplayController:
def __init__(self): def __init__(self):
now=time.monotonic() self.requests={}
self.lock=threading.RLock()
self.requests={"NORMAL_HOME":{
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":NORMAL_RGB_MODE,
"title":"","l2":"","l3":"","l4":"","active":True,
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
}}
self.current_owner_id="NORMAL_HOME"
self.current_priority=0
self.current_rgb_mode="OFF"
self.owner_since=now
self.current_oled_page_id="NORMAL_HOME"
self.page_started=now
self.batch_depth=0
self.stop_event=threading.Event()
self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True)
def start(self):
oled_init(); self.refresh(); rgb_off(); self.rgb_thread.start()
def update_home(self,cpu,temp,mem,disk):
with self.lock:
request=self.requests["NORMAL_HOME"]
values=(cpu,temp,mem,disk)
if request["metadata"].get("home")!=values:
request["metadata"]={"home":values}; request["last_changed"]=time.monotonic()
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None): def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
now=time.monotonic(); priority=display_priority(request_id) now=time.monotonic();old=self.requests.get(request_id)
request={"id":request_id,"priority":priority,"severity":severity, value={"id":request_id,"priority":alert_priority(request_id),"severity":severity,
"rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21], "category":ALERT_CATEGORIES[request_id.split("|",1)[0]].lower(),
"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True, "title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
"last_changed":now,"metadata":metadata or {}} "first_seen":old["first_seen"] if old else now,"metadata":metadata or {}}
with self.lock: value["last_changed"]=old["last_changed"] if old and all(old[k]==v for k,v in value.items()) else now
current=self.requests.get(request_id) self.requests[request_id]=value
if current is None: if old is None or old["severity"]!=severity:
request["first_seen"]=now; self.requests[request_id]=request log({1:"WARN",2:"CRITICAL",3:"EMERGENCY"}[severity],"ALERT_ACTIVE" if old is None else "ALERT_CHANGED",alert=request_id,severity=severity,detail=l2)
log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
else:
request["first_seen"]=current["first_seen"]
changed=any(current.get(k)!=request.get(k) for k in
("priority","severity","rgb_mode","title","l2","l3","l4","metadata"))
if not changed: request["last_changed"]=current["last_changed"]
self.requests[request_id]=request
if self.batch_depth==0:
self._arbitrate_locked()
self._sync_rgb_locked()
def clear(self,request_id): def clear(self,request_id):
if request_id=="NORMAL_HOME": return if self.requests.pop(request_id,None) is not None:log("INFO","ALERT_RECOVERED",alert=request_id)
with self.lock:
if self.requests.pop(request_id,None) is not None:
log("INFO","ALERT_RECOVERED",alert=request_id)
if self.batch_depth==0:
self._arbitrate_locked()
if self.current_oled_page_id not in self.requests:
self._show_page_locked(self.current_owner_id)
else: self._sync_rgb_locked()
def begin_update(self):
with self.lock: self.batch_depth+=1
def end_update(self):
with self.lock:
if self.batch_depth<=0: raise RuntimeError("display update batch imbalance")
self.batch_depth-=1
if self.batch_depth==0:
self._arbitrate_locked()
if self.current_oled_page_id not in self.requests:
self._show_page_locked(self.current_owner_id)
else: self._sync_rgb_locked()
def _arbitrate_locked(self):
current=self.requests.get(self.current_owner_id)
ordered=sorted(self.requests.values(),key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
candidate=ordered[0]
if current is not None and candidate["priority"]<=current["priority"]: return
self._change_owner_locked(candidate)
def _change_owner_locked(self,request):
old=self.current_owner_id
if request["id"]==old: return
self.current_owner_id=request["id"]
self.current_priority=request["priority"]
self.current_rgb_mode=request["rgb_mode"]
self.owner_since=time.monotonic()
log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"])
self._show_page_locked(request["id"])
def _ordered_alerts_locked(self):
return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"),
key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
def _show_page_locked(self,page_id):
alerts=self._ordered_alerts_locked()
request=self.requests.get(page_id,self.requests["NORMAL_HOME"])
self.current_oled_page_id=request["id"]
self.page_started=time.monotonic()
self._sync_rgb_locked()
if request["id"]=="NORMAL_HOME":
render_home(*request["metadata"]["home"])
else:
alert_ids=[a["id"] for a in alerts]
render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
def _sync_rgb_locked(self):
# Alert pages show that alert's object color. HOME keeps the highest-
# priority active owner visible instead of briefly switching RGB off.
rgb_request=self.requests.get(self.current_oled_page_id)
if self.current_oled_page_id=="NORMAL_HOME":
rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"])
if rgb_request is None:
rgb_request=self.requests["NORMAL_HOME"]
self.current_rgb_mode=rgb_request["rgb_mode"]
def refresh(self):
with self.lock:
alerts=self._ordered_alerts_locked()
pages=["NORMAL_HOME"]+[a["id"] for a in alerts]
if self.current_oled_page_id not in pages:
self._show_page_locked(self.current_owner_id)
return
if len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL:
pos=pages.index(self.current_oled_page_id)
self._show_page_locked(pages[(pos+1)%len(pages)])
return
request=self.requests[self.current_oled_page_id]
if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"])
else:
alert_ids=[a["id"] for a in alerts]
render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
def owner_snapshot(self):
with self.lock: return dict(self.requests[self.current_owner_id])
def status_snapshot(self): def status_snapshot(self):
with self.lock: values=sorted(self.requests.values(),key=lambda a:(-a["severity"],-a["priority"],a["first_seen"],a["id"]))
alerts=self._ordered_alerts_locked() return {"owner":values[0]["id"] if values else "NORMAL_HOME","alerts":values}
return {
"owner":self.current_owner_id,
"oled_page":self.current_oled_page_id,
"rgb_mode":self.current_rgb_mode,
"alerts":[{k:a[k] for k in ("id","priority","severity","title","l2","l3","l4","first_seen","last_changed")}
for a in alerts],
}
def _rgb_worker(self):
previous_error=None; applied_mode="OFF"; applied_flash=False
flash_on=None; animation_started=time.monotonic()
while not self.stop_event.is_set():
try:
with self.lock: wanted_mode=self.current_rgb_mode
wanted_flash=wanted_mode.endswith("_FLASH")
if wanted_mode!=applied_mode:
with bus_lock:
if wanted_mode=="OFF": rgb_off()
elif wanted_mode=="NORMAL_SOLID": rgb_static(NORMAL_COLOR,"normal_solid")
elif wanted_mode=="NORMAL_FLASH": rgb_static(NORMAL_COLOR,"normal_flash_on")
elif wanted_mode.startswith("NORMAL_"): rgb_normal_effect(wanted_mode)
elif wanted_flash:
rgb_off()
rgb_flash(wanted_mode,True)
else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
applied_mode=wanted_mode; animation_started=time.monotonic()
flash_on=True if wanted_mode.endswith("_FLASH") else None
applied_flash=applied_mode.endswith("_FLASH")
if applied_flash:
elapsed_ms=(time.monotonic()-animation_started)*1000.0
if applied_mode=="NORMAL_FLASH": on_ms,off_ms=NORMAL_FLASH
elif "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH
elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
else: on_ms,off_ms=WARNING_FLASH
on=elapsed_ms%(on_ms+off_ms)<on_ms
if on!=flash_on:
with bus_lock:
if applied_mode=="NORMAL_FLASH":
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
else: rgb_flash(applied_mode,on)
flash_on=on
if previous_error is not None: log("INFO","RGB_RECOVERED")
previous_error=None
except Exception as e:
if repr(e)!=previous_error: log("ERROR","RGB_ERROR",error=repr(e))
previous_error=repr(e)
delay=0.04 if applied_flash else 0.25
self.stop_event.wait(delay)
def shutdown(self):
self.stop_event.set()
if self.rgb_thread.is_alive(): self.rgb_thread.join()
for name,action in (("rgb",rgb_off),("oled_clear",lambda:oled_show([("","")]*4)),
("oled_off",lambda:cmd(0xAE))):
try: action()
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
def cleanup(): def cleanup():
log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"]) publisher.close()
if bus is None: return log("INFO","STOP",loop_errors=agent["loop_errors"])
display.shutdown() try:Path(STATUS_PATH).unlink(missing_ok=True)
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)): except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
try: action()
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
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): def write_status(cpu,temp,mem,disk):
display_state=display.status_snapshot() display_state=alert_manager.status_snapshot()
memory_total,memory_used,memory_available,memory_calculated=memory_usage() memory_total,memory_used,memory_available,memory_calculated=memory_usage()
disk_total,disk_used,disk_free=disk_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 signal=wifi_percent(net_metric) if net_kind=="WIF" else None
state={ state={
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(), "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()}, "machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
"device":DEVICE_INFO, "device":DEVICE_INFO,
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1), "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), "cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
"memory_percent":round(memory_calculated if memory_total else mem,1), "memory_percent":round(memory_calculated if memory_total else mem,1),
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used, "memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
"memory_available_bytes":memory_available, "memory_available_bytes":memory_available,
"disk_percent":round(disk,1),"disk_total_bytes":disk_total, "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, "disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]}, "root_readonly":bool(fs_readonly)},
"power":health, "power":health,
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric, "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, "signal_percent":signal,
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)}, "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)} "watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
for w,ok in zip(watches,watch_state)], for w,ok in zip(watches,watch_state)],
"display":display_state, "display":display_state,
"agent":{"loop_errors":agent["loop_errors"],"i2c_errors":agent["i2c_errors"]}, "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) path=Path(STATUS_PATH); path.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
temporary=path.with_suffix(".tmp") temporary=path.with_suffix(".tmp")
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":"))) temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
@@ -808,18 +482,18 @@ args=parser.parse_args()
if args.diagnose: raise SystemExit(diagnose()) if args.diagnose: raise SystemExit(diagnose())
watch_state=[] watch_state=[]
last_sensor=last_render=last_status=0.0 last_sensor=last_status=0.0
last_net=last_watch=last_health=0.0 last_net=last_watch=last_health=0.0
monitor_ready_at=time.monotonic()+STARTUP_GRACE_SECONDS
health={}; fs_readonly=False health={}; fs_readonly=False
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0} 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={} event_state={}
history_logged=set() history_logged=set()
latest=(0.0,0.0,0.0,0.0) latest=(0.0,0.0,0.0,0.0)
display=DisplayController() 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: try:
bus=SMBus(1) apply_dark_mode()
FONT5=Path(FONT_PATH).read_bytes()
display.start(); apply_dark_mode()
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds()) log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
while running: while running:
try: try:
@@ -831,11 +505,27 @@ try:
if now-last_sensor >= SENSOR_INTERVAL: if now-last_sensor >= SENSOR_INTERVAL:
sample_elapsed=now-last_sensor if last_sensor else 0.0 sample_elapsed=now-last_sensor if last_sensor else 0.0
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct() c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
latest=(c,t,m,d); update_fan(t); last_sensor=now; agent["last_sensor_ok"]=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: if now>=monitor_ready_at and now-last_net >= NETWORK_INTERVAL:
old_id=(net_iface,ip) old_id=(net_iface,ip)
net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False net_iface,net_kind,net_metric,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) new_id=(net_iface,ip)
if new_id!=old_id: 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) log("INFO","NET_SWITCH",old_if=old_id[0],old_ip=old_id[1],new_if=net_iface,new_ip=ip)
@@ -843,7 +533,7 @@ try:
transition("internet_down",link_ok and not net_ok,"INTERNET_DOWN","INTERNET_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 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] watch_state=[check_watch(w) for w in watches]
for w,ok in zip(watches,watch_state): 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]) transition("watch:"+w[0]+":"+w[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2])
@@ -864,13 +554,7 @@ try:
agent["last_health_ok"]=now; last_health=now agent["last_health_ok"]=now; last_health=now
c,t,m,d=latest c,t,m,d=latest
display.update_home(c,t,m,d) update_active_alerts(c,t,m,d,sample_elapsed)
display.begin_update()
try: update_active_alerts(c,t,m,d,sample_elapsed)
finally: display.end_update()
if now-last_render >= OLED_REFRESH:
display.refresh()
last_render=now
if now-last_status >= 1.0: if now-last_status >= 1.0:
write_status(c,t,m,d); last_status=now write_status(c,t,m,d); last_status=now
+191 -206
View File
@@ -1,26 +1,68 @@
#!/usr/bin/env python3 #!/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 import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
from concurrent.futures import ThreadPoolExecutor,as_completed
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs,urlencode,urlparse from urllib.parse import parse_qs,urlparse
from urllib.request import Request,urlopen
APP_VERSION="3.7.0" 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.4"
CFG=Path("/etc/pigway-pi-control.conf") CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json") STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token") TOKEN=Path("/etc/pigway-pi-control-api.token")
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html") ALLOWED_SECTIONS={"device","services","processes","alerts","timing","dark_mode","api","storage_names","storage_visible"}
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$") NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$") UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
PROTECTED_UNITS={"pigway-pi-control-api.service"} 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(): def machine_identity():
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip() cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname() if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
@@ -28,62 +70,8 @@ def machine_identity():
if name.lower()=="auto": name=identifier if name.lower()=="auto": name=identifier
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""} return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
def normalize_url(value):
parsed=urlparse(str(value).strip())
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("host URL must be an http(s) URL without credentials")
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
def host_registry():
local=machine_identity(); result=[local]
cfg=load_cfg()
if cfg.has_section("hosts"):
for host_id,url in cfg.items("hosts"):
try: base=normalize_url(url)
except ValueError: continue
if host_id==local["id"]: continue
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
return result
def find_host(host_id):
for host in host_registry():
if host["id"]==host_id:return host
raise ValueError("unknown host")
def remote_json(base,path,method="GET",payload=None,token=""):
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
headers={"Accept":"application/json"}
if data is not None: headers["Content-Type"]="application/json"
if token: headers["Authorization"]=f"Bearer {token}"
request=Request(base+path,data=data,headers=headers,method=method)
with urlopen(request,timeout=5) as response:
return json.loads(response.read())
def fleet_status():
hosts=host_registry(); results=[]
try:
status=json.loads(STATUS.read_text())
status.setdefault("machine",machine_identity())
results.append({"host":hosts[0],"online":True,"status":status})
except Exception as exc:
results.append({"host":hosts[0],"online":False,"error":str(exc)})
def fetch(host):
try:
status=remote_json(host["url"],"/api/v1/status")
machine=status.get("machine",{})
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
return {"host":merged,"online":True,"status":status}
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures): results.append(future.result())
order={h["id"]:i for i,h in enumerate(hosts)}
results.sort(key=lambda x:order.get(x["host"]["id"],999))
return {"hosts":results,"timestamp":int(time.time())}
def load_cfg(): def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG) cfg=configparser.ConfigParser(interpolation=None); cfg.read(CFG)
return cfg return cfg
def api_setting(key,default,cast=str): def api_setting(key,default,cast=str):
@@ -92,68 +80,90 @@ def api_setting(key,default,cast=str):
def config_json(): def config_json():
cfg=load_cfg() cfg=load_cfg()
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS} 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): def validate_value(section,key,value,existing):
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()): 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}") raise ValueError(f"unsupported option: {section}.{key}")
if RGB_KEY_RE.fullmatch(key): if section in {"alerts","timing"}:
number=int(value) number=float(value)
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255") if not math.isfinite(number):raise ValueError(f"{section}.{key} must be finite")
elif FLASH_KEY_RE.fullmatch(key): low,high=(0,86400)
number=int(value) if section=="timing":
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000") low=0 if key in {"startup_grace_seconds","network_failure_seconds"} else 0.2
elif section=="led" and key=="normal_mode": high=3600
if value.lower() not in NORMAL_MODES: raise ValueError("normal_mode must be off, solid, flash, flow, breathe, marquee, rainbow or colorful") elif key in {"cpu_high","cpu_recover","memory_high","memory_recover","disk_high","disk_critical"}:low,high=0,100
elif section=="led" and key=="normal_effect_speed": elif key in {"temp_high","temp_critical"}:low,high=-50,150
if int(value) not in (1,2,3): raise ValueError("normal_effect_speed must be 1, 2 or 3") elif key in {"wifi_weak","wifi_recover"}:low,high=-120,0
elif section=="led" and key=="normal_effect_color": elif key=="ethernet_min_speed_mbps":low,high=0,1000000
if value.lower() not in EFFECT_COLORS: raise ValueError("normal_effect_color must be red, green, blue, yellow, purple, cyan or white") if not low<=number<=high:raise ValueError(f"{section}.{key} must be {low}..{high}")
elif section=="api" and key=="port": if key in {"cpu_high_seconds","memory_high_seconds","ethernet_min_speed_mbps"} and not value.isdecimal():
number=int(value) raise ValueError(f"{key} must be a nonnegative integer")
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535") elif section=="dark_mode" and value.lower() not in {"on","off","true","false","0","1","yes","no"}:
elif section=="api" and key=="log_limit": raise ValueError("LED setting must be on or off")
number=int(value) elif section=="services" and not UNIT_RE.fullmatch(value):raise ValueError("invalid systemd service name")
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000") elif section=="api" and key in {"port","log_limit"}:
elif section=="api" and key=="bind": limit=65535 if key=="port" else 1000
ipaddress.ip_address(value) 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): 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") raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
elif section=="hosts":
normalize_url(value)
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): def replace_ini_value(section,key,value):
text=CFG.read_text() return replace_ini_text(CFG.read_text(),section,key,value)
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
def replace_ini_text(text,section,key,value):
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
if not section_match: if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return text return text
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():]) 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()) end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end] chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk) key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
if key_match: if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end() 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:] 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") if value is not None:return text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
return text return text
@config_transaction
def save_ini_text(text,prefix="api"): def save_ini_text(text,prefix="api"):
parsed=configparser.ConfigParser(); parsed.read_string(text) parsed=configparser.ConfigParser(interpolation=None); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.strftime('%Y%m%d-%H%M%S')}") validate_candidate(parsed)
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.time_ns()}")
shutil.copy2(CFG,backup) shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG) 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) return str(backup)
def edit_host(operation,host_id,url=""):
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
local=machine_identity()["id"]
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
if operation=="save": url=normalize_url(url)
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
return save_ini_text(text,"hosts")
@config_transaction
def update_ini(updates): def update_ini(updates):
if not isinstance(updates,dict): raise ValueError("updates must be an object") if not isinstance(updates,dict): raise ValueError("updates must be an object")
text=CFG.read_text() text=CFG.read_text()
@@ -169,11 +179,11 @@ def update_ini(updates):
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"): 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}") raise ValueError(f"invalid value: {section}.{key}")
validate_value(section,str(key),value,existing) validate_value(section,str(key),value,existing)
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text) section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
if not section_match: if not section_match:
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
continue continue
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():]) 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()) end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end] chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk) key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
@@ -183,12 +193,7 @@ def update_ini(updates):
text=text[:start]+replacement+text[stop:] text=text[:start]+replacement+text[stop:]
else: else:
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n") text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
parsed=configparser.ConfigParser(); parsed.read_string(text) return save_ini_text(text,"api")
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp")
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup)
def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0): 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"] command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
@@ -224,50 +229,6 @@ def journal_query(since=None,until=None,severity="",event="",search="",sort="tim
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit, return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":event_types,"retention":"systemd-journal"} "event_types":event_types,"retention":"systemd-journal"}
def fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
sort="timestamp",order="desc",limit=100,offset=0):
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
def decorate(payload,host):
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
host_id=machine_info.get("id") or host["id"]
host_name=machine_info.get("name") or host["name"]
decorated=[]
for row in payload.get("logs",[]):
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
local_payload["machine"]=machine_identity()
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
if value not in (None,""): query[key]=value
def fetch(host):
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
try:
status=remote_json(host["url"],"/api/v1/status")
payload["machine"]=status.get("machine",{})
except Exception: pass
return decorate(payload,host)
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures):
try:
remote_rows,remote_events,remote_machine=future.result()
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
except Exception: pass
if machine: rows=[row for row in rows if row["machine_id"]==machine]
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
"event":lambda x:x["event"],"message":lambda x:x["message"],
"machine":lambda x:(x["machine_name"],x["machine_id"])}
rows.sort(key=keys[sort],reverse=order=="desc")
total=len(rows)
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
"retention":"systemd-journal"}
def service_inventory(): def service_inventory():
cfg=load_cfg() cfg=load_cfg()
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {} monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
@@ -296,6 +257,7 @@ def service_inventory():
item["protected"]=item["unit"] in PROTECTED_UNITS item["protected"]=item["unit"] in PROTECTED_UNITS
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"])) return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
@config_transaction
def edit_service_monitor(operation,name,target,note="",previous_name=""): def edit_service_monitor(operation,name,target,note="",previous_name=""):
if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation") 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 NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
@@ -308,14 +270,14 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
text=CFG.read_text() text=CFG.read_text()
def change(section,key,value): def change(section,key,value):
nonlocal text nonlocal text
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text) section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
if not section_match: if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return return
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():]) 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()) end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end] chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk) key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
if key_match: if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end() 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:] text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
@@ -333,13 +295,39 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
change("service_monitor_disabled",previous_name,None) change("service_monitor_disabled",previous_name,None)
change("services",name,target); change("service_notes",name,note or None) change("services",name,target); change("service_notes",name,note or None)
if was_paused: change("service_monitor_disabled",name,"true") if was_paused: change("service_monitor_disabled",name,"true")
parsed=configparser.ConfigParser(); parsed.read_string(text) backup=save_ini_text(text,"api")
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return str(backup) 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): def control_service(unit,action):
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name") 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 action not in {"start","stop"}: raise ValueError("action must be start or stop")
@@ -378,19 +366,16 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
parsed=urlparse(self.path) parsed=urlparse(self.path)
try: try:
if parsed.path=="/": if not self.authorized():self.send_json(401,{"error":"bearer token required"});return
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
if parsed.path=="/api/v1/health": if parsed.path=="/api/v1/health":
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()});return
if parsed.path=="/api/v1/hosts":
self.send_json(200,{"hosts":host_registry()}); return
if parsed.path=="/api/v1/fleet/status":
self.send_json(200,fleet_status()); return
if parsed.path=="/api/v1/status": if parsed.path=="/api/v1/status":
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
self.send_json(200,json.loads(STATUS.read_text())); return state=json.loads(STATUS.read_text())
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}: 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) query=parse_qs(parsed.query)
value=lambda key,default="":query.get(key,[default])[0] value=lambda key,default="":query.get(key,[default])[0]
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000) limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
@@ -400,58 +385,51 @@ class Handler(BaseHTTPRequestHandler):
if since is not None and until is not None and since>until: raise ValueError("since must not be after until") 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") sort=value("sort","timestamp"); order=value("order","desc")
allowed_sort={"timestamp","severity","event","message"} allowed_sort={"timestamp","severity","event","message"}
if parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
if sort not in allowed_sort: raise ValueError("invalid log sort") if sort not in allowed_sort: raise ValueError("invalid log sort")
if order not in {"asc","desc"}: raise ValueError("invalid log order") if order not in {"asc","desc"}: raise ValueError("invalid log order")
severity=value("severity").upper(); event=value("event"); search=value("search") severity=value("severity").upper(); event=value("event"); search=value("search")
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long") if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
if parsed.path=="/api/v1/fleet/logs":
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return 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": if parsed.path=="/api/v1/config":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0] query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
if parsed.path=="/api/v1/services": if parsed.path=="/api/v1/services":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0] query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return self.send_json(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"}) self.send_json(404,{"error":"not found"})
except ValueError as e: self.send_json(400,{"error":str(e)}) except ValueError as e: self.send_json(400,{"error":str(e)})
except Exception as e: self.send_json(500,{"error":str(e)}) except Exception as e: self.send_json(500,{"error":str(e)})
def do_PUT(self): def do_PUT(self):
path=urlparse(self.path).path path=urlparse(self.path).path
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return if 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 if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
try: try:
body=self.read_json() body=self.read_json()
if path=="/api/v1/hosts": if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url",""))) host_id=str(body.get("host",""))
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
if path=="/api/v1/fleet/config": item=find_plugin(str(body.get("id","")))
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{}) self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
for host_id in dict.fromkeys(str(x) for x in targets):
try:
if host_id==local_id:
backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
restart_local_api="api" in updates
results.append({"id":host_id,"ok":True,"backup":backup})
else:
host=find_host(host_id); token=str(tokens.get(host_id,""))
if not token: raise ValueError("remote bearer token required")
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
return
if path=="/api/v1/services/control": if path=="/api/v1/services/control":
control_service(str(body.get("unit","")),str(body.get("action",""))) control_service(str(body.get("unit","")),str(body.get("action","")))
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True) print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
@@ -462,6 +440,11 @@ class Handler(BaseHTTPRequestHandler):
str(body.get("previous_name",""))) str(body.get("previous_name","")))
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True) 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 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") updates=body.get("updates")
backup=update_ini(updates) backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
@@ -473,6 +456,8 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e: self.send_json(500,{"error":str(e)}) except Exception as e: self.send_json(500,{"error":str(e)})
def main(): 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") host=api_setting("bind","0.0.0.0")
port=api_setting("port",6001,int) port=api_setting("port",6001,int)
server=ThreadingHTTPServer((host,port),Handler) server=ThreadingHTTPServer((host,port),Handler)
+114
View File
@@ -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()
+210
View File
@@ -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
+31 -156
View File
@@ -1,48 +1,22 @@
# ============================================================================= # PIGWay system monitoring. Hardware plugins are separately installed.
# PIGWay Pi Control v3.7.0 - 用户配置文件 # 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。
# ============================================================================= [plugins]
# # No automatic attachment. Example (prefer Web discovery):
# 正式安装后的配置路径: # yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
# /etc/pigway-pi-control.conf
#
# 编辑:
# 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 日志并修正本文件。
# =============================================================================
[device] [device]
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。 # 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
# 多台机器的 identifier 必须唯一;修改后重启硬件 Agent 和 API 服务。 # 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。
identifier = auto identifier = auto
# Web 页面显示名称。auto 表示使用 identifier。 # Web 页面显示名称。auto 表示使用 identifier。
name = auto name = auto
[hosts]
# 在作为聚合入口的机器上登记其他节点,每行格式:
# 唯一标识 = http://节点IP:6001
# 示例:
# living-room = http://192.168.1.20:6001
[api] [api]
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问; # 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
enabled = false
# API 监听地址和端口。0.0.0.0 允许局域网访问;
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。 # 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
bind = 0.0.0.0 bind = 0.0.0.0
port = 6001 port = 6001
@@ -51,19 +25,10 @@ log_limit = 200
[timing] [timing]
# 传感器采样周期,单位:秒。 # 传感器采样周期,单位:秒。
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。 # CPU / 内存 / 温度 / 磁盘按此周期更新。
# 推荐:1。通常不要低于 1。 # 推荐:1。通常不要低于 1。
sensor_interval = 1 sensor_interval = 1
# OLED 当前页面刷新周期,单位:秒。
# 这只是刷新当前页面数据,不负责切换页面。
# 推荐:1。
oled_refresh_interval = 1
# OLED 在主页和当前告警页之间轮播的周期,单位:秒。
# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。
page_interval = 10
# 网络状态检查周期,单位:秒。 # 网络状态检查周期,单位:秒。
# 推荐:5。太短会产生不必要的网络探测。 # 推荐:5。太短会产生不必要的网络探测。
network_interval = 5 network_interval = 5
@@ -74,43 +39,16 @@ service_interval = 5
# 本机健康检查周期,单位:秒。 # 本机健康检查周期,单位:秒。
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。 # 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。 # 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。
health_interval = 1 health_interval = 1
# 开机后等待网络与被监控服务完成启动,再开始网络/服务告警检查。
# 这不会延迟温度、电源、磁盘等本机健康检查。
startup_grace_seconds = 15
[fan] # 网络或互联网必须连续失败达到此时间才产生告警。
# 风扇温控档位,单位:摄氏度。 # 成功样本会立即清除等待状态,避免开机和链路切换瞬间误报。
# network_failure_seconds = 15
# 默认曲线:
# < 50°C OFF
# 50~53°C L1
# 53~56°C L2
# 56~59°C L3
# 59~62°C L4
# >= 62°C MAX
#
# 如希望更安静,可适当提高阈值;如更重视散热,可降低。
# 建议保持 start < level2 < level3 < level4 < max。
# 低于此温度,风扇完全停止。
start = 50
# L2 起始温度;start 到 level2 之间为 L1。
level2 = 53
# L3 起始温度。
level3 = 56
# L4 起始温度。
level4 = 59
# MAX 全速起始温度。
max = 62
# 降档回差,单位:°C。
# 防止温度在临界值附近上下波动时风扇频繁升降档。
# 推荐:1.0~2.0。
hysteresis = 1.5
[alerts] [alerts]
@@ -152,7 +90,7 @@ disk_critical = 95
# ---------------- Wi-Fi 信号 ---------------- # ---------------- Wi-Fi 信号 ----------------
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。 # 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。 # RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。 # 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发弱信号告警。
wifi_weak = -75 wifi_weak = -75
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。 # 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
@@ -161,80 +99,10 @@ wifi_recover = -68
# 弱信号持续多少秒才正式告警。 # 弱信号持续多少秒才正式告警。
wifi_weak_seconds = 30 wifi_weak_seconds = 30
# 有线网卡协商速率低于此值并持续 ethernet_speed_seconds 后告警,单位 Mbps。
[led] # Raspberry Pi 4/5 的千兆网口建议保持 1000;设为 0 可关闭速率告警。
# 正常运行、没有任何 active alert 时的 RGB 表现: ethernet_min_speed_mbps = 1000
# off = 全灭(默认,最安静且 I2C 写入最少) ethernet_speed_seconds = 15
# solid = 三灯使用 normal_r/g/b 自定义颜色常亮
# flash = 三灯使用 normal_r/g/b 和 normal_flash_*_ms 自定义颜色闪烁
# flow = MCU 内置流水灯;使用有限颜色和速度
# breathe = MCU 内置呼吸灯;使用有限颜色和速度
# marquee = MCU 内置跑马灯;MCU 自行决定颜色变化
# rainbow = MCU 内置彩虹灯;MCU 自行决定颜色变化
# colorful = MCU 内置炫彩灯;MCU 自行决定颜色变化
# 任何 active alert 都会立即抢占正常效果;全部恢复后才重新应用这里的模式。
normal_mode = off
# solid / flash 自定义颜色,三通道范围均为 0..255。
normal_r = 0
normal_g = 0
normal_b = 0
# flash 亮灯/灭灯时间,单位毫秒;范围 50..60000。
normal_flash_on_ms = 1000
normal_flash_off_ms = 1000
# MCU 内置效果速度:1=慢,2=中(默认),3=快。
normal_effect_speed = 2
# flow / breathe 可使用的 MCU 内置有限颜色:
# red / green / blue / yellow / purple / cyan / white
# marquee / rainbow / colorful 会忽略该颜色值。
normal_effect_color = green
# DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示
# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级:
# CPU/频率/温度/散热 = 红色
# 供电/欠压 = 橙色闪烁(实机校准,避免内置黄色偏绿)
# 内存 = 紫色
# 磁盘 = 白色
# 网络/Wi-Fi = 蓝色
# 服务/进程 = 青色
# 颜色通道范围为 0..255。以下是本机实测后的默认值。
cpu_r = 255
cpu_g = 0
cpu_b = 0
power_r = 255
power_g = 40
power_b = 0
memory_r = 255
memory_g = 0
memory_b = 255
storage_r = 255
storage_g = 255
storage_b = 255
network_r = 0
network_g = 0
network_b = 255
service_r = 0
service_g = 255
service_b = 96
# 各严重等级可分别设置亮灯和灭灯时长,单位:毫秒,最小值 50。
# 默认完整周期:Warning 3秒、Critical 1.5秒、Emergency 0.6秒。
warning_flash_on_ms = 1500
warning_flash_off_ms = 1500
critical_flash_on_ms = 750
critical_flash_off_ms = 750
emergency_flash_on_ms = 300
emergency_flash_off_ms = 300
# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
write_delay_ms = 10
# v3.3.0 RC 期间使用过的 brightness、*_breathe_seconds、rgb_update_ms、
# warning_color、critical_color、warning_flash_ms、critical_flash_ms 和
# emergency_flash_ms
# 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。
[dark_mode] [dark_mode]
@@ -276,11 +144,12 @@ pi_act_led = off
# 修改后: # 修改后:
# sudo systemctl restart pigway-pi-control # sudo systemctl restart pigway-pi-control
# #
# 默认监控 SSH 和向日葵核心后台服务。 # 默认只监控 Raspberry Pi OS 自带的 SSH 服务。
# 向日葵、Tailscale 等可选服务应在实际安装后再显式添加,避免全新系统误报。
SSH = ssh.service SSH = ssh.service
AWESUN = runawesun.service
# 以下仅为示例,默认不启用: # 以下仅为示例,默认不启用:
# AWESUN = runawesun.service
# TAILSCALE = tailscaled.service # TAILSCALE = tailscaled.service
# DOCKER = docker.service # DOCKER = docker.service
# SAMBA = smbd.service # SAMBA = smbd.service
@@ -314,3 +183,9 @@ AWESUN = runawesun.service
# #
# 示例默认全部注释,不会监控任何普通进程。 # 示例默认全部注释,不会监控任何普通进程。
# MYAPP = /opt/myapp/server # 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.
+91
View File
@@ -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 独立管理,异常退出自动重启,任一项目停止不会停止另一个项目。
+10
View File
@@ -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)
+21
View File
@@ -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)
+15
View File
@@ -0,0 +1,15 @@
# PIGWAY 品牌资源 / Brand assets
来源:PIGWAY Brand Production v1.1 正式交付包。图形、颜色与比例保持原文件,不使用重绘、滤镜或替代字标。
- `pigway-logo.svg`:正式 Web 图形标。
- `pigway-logo.png`:正式 256px 透明图形标,用于项目介绍页。
- `pigway-avatar.png`:正式 512px 平台图标,用于 Tea 项目头像。
本项目的产品名称保持独立。品牌物料版本不跟随软件版本变化;网页小图标使用同一正式包的 Favicon 和 Apple Touch Icon。
## SHA-256
- `0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210` `pigway-logo.svg`
- `da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7` `pigway-logo.png`
- `f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3` `pigway-avatar.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+66 -174
View File
@@ -1,197 +1,89 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
NO_START=0
FORCE_I2C_CONFLICT=0 API_ENABLED=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--force-i2c-conflict) FORCE_I2C_CONFLICT=1 ;; --enable-api) API_ENABLED=true ;;
-h|--help) --disable-api) API_ENABLED=false ;;
echo "Usage: sudo ./install.sh [--force-i2c-conflict]" --no-start) NO_START=1 ;;
echo " --force-i2c-conflict Continue even when another process owns /dev/i2c-1." -h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
exit 0 *) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
;;
*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
esac esac
done done
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
for required in app/pigway_pi_control.py app/pigway_pi_control_api.py app/oled_font_5x7.bin web/index.html 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; }
if [ ! -f "$required" ]; then
echo "ERROR: required file missing: $required" >&2
exit 1
fi
done done
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
echo "[1/8] 检查 Raspberry Pi I2C..." # The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; } 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; }
# A running copy of this project is expected during an upgrade. Any other
# process holding the shared bus could race OLED/MCU writes, so report it and
# let the user decide how that process should be managed.
i2c_conflict_pids() {
local allowed_pid pid
allowed_pid="$(systemctl show -p MainPID --value pigway-pi-control.service 2>/dev/null || true)"
for pid in $(fuser /dev/i2c-1 2>/dev/null || true); do
case "$pid" in
''|*[!0-9]*) continue ;;
esac
[ "$pid" = "$$" ] && continue
[ "$pid" = "$allowed_pid" ] && continue
kill -0 "$pid" 2>/dev/null && echo "$pid"
done
}
I2C_CONFLICTS="$(i2c_conflict_pids)"
if [ -n "$I2C_CONFLICTS" ]; then
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
echo "WARNING: /dev/i2c-1 is already in use by another process:" >&2
else
echo "ERROR: /dev/i2c-1 is already in use by another process:" >&2
fi
while read -r pid; do
ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true
done <<< "$I2C_CONFLICTS"
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
echo "WARNING: continuing because --force-i2c-conflict was specified; no conflicting process will be stopped." >&2
else
echo "Stop or reconfigure the conflicting OLED/LED controller, then run the installer again." >&2
echo "If concurrent control is intentional, rerun with --force-i2c-conflict." >&2
exit 1
fi
fi
echo "[2/8] 等待 APT/DPKG..."
for i in $(seq 1 60); do
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
echo "软件包管理器忙,等待 5 秒... ($i/60)"
sleep 5
done
echo "[3/8] 安装依赖..."
apt-get update
apt-get install -y python3 python3-pil python3-smbus2 i2c-tools
echo "[4/8] 检查硬件地址..."
SCAN="$(i2cdetect -y 1)"; echo "$SCAN"
echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C"
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D"
echo "[5/8] 安装程序与配置..."
mkdir -p /usr/local/share/pigway-pi-control mkdir -p /usr/local/share/pigway-pi-control
install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_font_5x7.bin install -m 0644 app/storage_cache.py /usr/local/share/pigway-pi-control/storage_cache.py
mkdir -p /usr/local/share/pigway-pi-control/web install -m 0644 app/network_rates.py /usr/local/share/pigway-pi-control/network_rates.py
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
# Keep the complete existing configuration, including comments and custom sections.
# The merge below adds only missing documented options.
if [ -f /etc/pigway-pi-control.conf ]; then if [ -f /etc/pigway-pi-control.conf ]; then
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)" cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
else else
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
fi fi
# Legacy hardware sections are deliberately preserved for explicit migration,
# Preserve the existing file while exposing new defaults. Existing values win. # but ignored by the monitor and never exposed as monitor configuration.
python3 - /etc/pigway-pi-control.conf <<'PY' python3 - <<'PY'
import re,sys
from pathlib import Path from pathlib import Path
import configparser,re
path=Path(sys.argv[1]) p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser(interpolation=None);c.read_string(text)
text=path.read_text() for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},
sections={ 'alerts':{'ethernet_min_speed_mbps':'1000','ethernet_speed_seconds':'15'},
"device":(("identifier","auto"),("name","auto")), 'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
"hosts":(), if not c.has_section(section):
"led":( text=text.rstrip()+'\n\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items())
("normal_mode","off"),
("normal_r",0),("normal_g",0),("normal_b",0),
("normal_flash_on_ms",1000),("normal_flash_off_ms",1000),
("normal_effect_speed",2),("normal_effect_color","green"),
("write_delay_ms",10),
("cpu_r",255),("cpu_g",0),("cpu_b",0),
("power_r",255),("power_g",40),("power_b",0),
("memory_r",255),("memory_g",0),("memory_b",255),
("storage_r",255),("storage_g",255),("storage_b",255),
("network_r",0),("network_g",0),("network_b",255),
("service_r",0),("service_g",255),("service_b",96),
("warning_flash_on_ms",1500),("warning_flash_off_ms",1500),
("critical_flash_on_ms",750),("critical_flash_off_ms",750),
("emergency_flash_on_ms",300),("emergency_flash_off_ms",300),
),
"api":(("bind","0.0.0.0"),("port",6001),("log_limit",200)),
}
for section,defaults in sections.items():
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
if match:
next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():])
end=match.end()+(next_section.start() if next_section else len(text)-match.end())
chunk=text[match.end():end]
else: else:
text=text.rstrip()+f"\n\n[{section}]\n" missing={k:v for k,v in values.items() if not c.has_option(section,k)}
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text) if missing:
end=len(text); chunk="" start=re.search(rf'(?mi)^\[{re.escape(section)}\][ \t]*$',text).end()
missing=[(key,value) for key,value in defaults following=re.search(r'(?m)^\[[^\]\r\n]+\]',text[start:])
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)] end=start+(following.start() if following else len(text)-start)
if missing: text=text[:end].rstrip()+'\n'+''.join(k+' = '+v+'\n' for k,v in missing.items())+'\n'+text[end:].lstrip('\n')
note="# Added by installer; existing user values are preserved.\n" p.write_text(text)
if section=="led" and any(key.startswith("normal_") for key,_ in missing):
note+=("# Normal RGB: normal_mode = off/solid/flash/flow/breathe/marquee/rainbow/colorful.\n"
"# normal_r/g/b = 0..255; normal_flash_*_ms = 50..60000; effect speed = 1..3.\n"
"# Effect color = red/green/blue/yellow/purple/cyan/white (flow/breathe only).\n")
block=note+"".join(f"{key} = {value}\n" for key,value in missing)
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
path.write_text(text)
PY PY
if [ ! -s /etc/pigway-pi-control-api.token ]; then if [ ! -s /etc/pigway-pi-control-api.token ]; then
umask 077 (umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
python3 - <<'PY' > /etc/pigway-pi-control-api.token
import secrets
print(secrets.token_urlsafe(32))
PY
fi fi
chmod 0600 /etc/pigway-pi-control-api.token chmod 0600 /etc/pigway-pi-control-api.token
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/pigway-pi-control-api.service install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
if [ -z "$API_ENABLED" ]; then
echo "[6/8] 启用/重启服务..." API_ENABLED=false
systemctl daemon-reload if [ -t 0 ]; then
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null read -r -p 'Enable remote management API? [y/N] ' answer
systemctl restart pigway-pi-control.service case "$answer" in y|Y|yes|YES) API_ENABLED=true ;; esac
systemctl restart pigway-pi-control-api.service fi
echo "[7/8] 安装后自检..."
FAIL=0
check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; }
APP=/usr/local/sbin/pigway-pi-control
check "Version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' "$APP"
check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP"
check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$APP"
check "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP"
check "UI CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP"
check "UI IP4/IP6" grep -Fq 'ip_label' "$APP"
check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP"
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
printf " %-30s WARN (forced)\n" "I2C owner conflict"
else
no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; }
check "No other I2C owner" no_other_i2c_owner
fi fi
check "Service running" systemctl is-active --quiet pigway-pi-control.service API_ENABLED="$API_ENABLED" python3 - <<'PYAPI'
check "API/Web service running" systemctl is-active --quiet pigway-pi-control-api.service import os,sys
check "API/Web version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' /usr/local/sbin/pigway-pi-control-api sys.path.insert(0,'app')
check "Web UI installed" test -s /usr/local/share/pigway-pi-control/web/index.html import pigway_pi_control_api as api
check "P0 health monitor" grep -Fq 'throttled_state' "$APP" api.save_ini_text(api.replace_ini_value('api','enabled',os.environ['API_ENABLED']),'install')
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP" PYAPI
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi install -d -m 0755 /usr/local/share/doc/pigway-raspberry-pi-agent
echo "[8/8] 状态..." install -m 0644 LICENSE /usr/local/share/doc/pigway-raspberry-pi-agent/
systemctl daemon-reload
if [ "$NO_START" -eq 0 ]; then
systemctl --no-pager --full status pigway-pi-control.service || true systemctl enable pigway-pi-control.service >/dev/null
systemctl --no-pager --full status pigway-pi-control-api.service || true systemctl restart pigway-pi-control.service
echo systemctl is-active --quiet pigway-pi-control.service
echo "完成。程序:/usr/local/sbin/pigway-pi-control" if [ "$API_ENABLED" = true ]; then
echo "配置:/etc/pigway-pi-control.conf" systemctl enable pigway-pi-control-api.service >/dev/null
echo "Web/API:http://$(hostname -I | awk '{print $1}'):$(awk -F= '/^port[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}' /etc/pigway-pi-control.conf)" systemctl restart pigway-pi-control-api.service
echo "配置写入 token:sudo cat /etc/pigway-pi-control-api.token" 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'
+4 -2
View File
@@ -1,12 +1,14 @@
[Unit] [Unit]
Description=PIGWay Pi Control API and Web Description=PIGWay Raspberry Pi Agent optional local API
After=network.target pigway-pi-control.service After=network.target pigway-pi-control.service
Wants=network.target pigway-pi-control.service Wants=network.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/sbin/pigway-pi-control-api ExecStart=/usr/local/sbin/pigway-pi-control-api
Restart=always Restart=always
RestartPreventExitStatus=78
SuccessExitStatus=78
RestartSec=2 RestartSec=2
User=root User=root
NoNewPrivileges=true NoNewPrivileges=true
+3 -3
View File
@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor Description=PIGWay Raspberry Pi Agent system monitor
After=multi-user.target network.target After=local-fs.target systemd-modules-load.service network-online.target ssh.service
Wants=network.target Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
+132
View File
@@ -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%')
+23
View File
@@ -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))
+41
View File
@@ -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)
+35
View File
@@ -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)
+97
View File
@@ -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()
+1
View File
@@ -7,6 +7,7 @@ rm -f /usr/local/sbin/pigway-pi-control
rm -f /usr/local/sbin/pigway-pi-control-api rm -f /usr/local/sbin/pigway-pi-control-api
rm -f /etc/pigway-pi-control-api.token rm -f /etc/pigway-pi-control-api.token
rm -rf /usr/local/share/pigway-pi-control rm -rf /usr/local/share/pigway-pi-control
rm -rf /usr/local/share/doc/pigway-raspberry-pi-agent
systemctl daemon-reload systemctl daemon-reload
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。" echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
echo "如确定不要配置,可手工:sudo rm /etc/pigway-pi-control.conf" echo "如确定不要配置,可手工:sudo rm /etc/pigway-pi-control.conf"
-96
View File
File diff suppressed because one or more lines are too long