Compare commits
29
Commits
f505f3c23b
..
v3.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a6e115f5 | ||
|
|
ccf1e22514 | ||
|
|
c8c12b7b70 | ||
|
|
ced0140450 | ||
|
|
ddce873745 | ||
|
|
324390f647 | ||
|
|
1f581087b2 | ||
|
|
0dac42cd64 | ||
|
|
0d7a686c65 | ||
|
|
630a6a98da | ||
|
|
7befb325fb | ||
|
|
f0bef66651 | ||
|
|
7c4de5843d | ||
|
|
2ec34bbd57 | ||
|
|
33e90eb535 | ||
|
|
c106b4c264 | ||
|
|
52f56eefc5 | ||
|
|
849fd016b6 | ||
|
|
993d95bab1 | ||
|
|
a976dfa3fa | ||
|
|
ae8f756749 | ||
|
|
c3ed599198 | ||
|
|
38362be824 | ||
|
|
2f82cc36b4 | ||
|
|
260d8fad8f | ||
|
|
9babcfabc1 | ||
|
|
152ecc6e2b | ||
|
|
a96cdb859d | ||
|
|
e2cec738c8 |
@@ -1,275 +1,50 @@
|
||||
# PIGWay Pi Control v3.7.0
|
||||
# PIGWay Device Agent v3.7.0
|
||||
|
||||
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
||||
极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
|
||||
|
||||
v3.7.0 在现有本机硬件监控基础上增加多机状态、日志和配置管理;未登记其他节点时继续作为完整的单机版本运行。
|
||||
|
||||
## 已有核心功能
|
||||
|
||||
- SSD1306 128×32 OLED,四行固定主页
|
||||
- CPU / 内存 / 温度 / 磁盘监控
|
||||
- Wi-Fi RSSI / Ethernet 链路速率
|
||||
- 当前默认路由与 IP 同步切换
|
||||
- 风扇自动温控、回差防抖
|
||||
- RGB:自定义颜色表示故障对象,频闪速度表示严重程度
|
||||
- systemd 服务与普通进程监控
|
||||
- Raspberry Pi PWR / ACT 运行时 Dark Mode
|
||||
|
||||
主页示例:
|
||||
|
||||
```text
|
||||
CPU 18.6% MEM 16.2%
|
||||
TMP 46.3C FAN OFF
|
||||
DSK 22.4% WIF -32
|
||||
IP4 192.168.89.130
|
||||
```
|
||||
|
||||
## v3.5.0 System Health
|
||||
|
||||
### 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
|
||||
sudo ./install.sh
|
||||
# 显式启用 API:
|
||||
sudo ./install.sh --enable-api
|
||||
# 不启用 API:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
安装器会:
|
||||
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
|
||||
|
||||
- 安装必要依赖
|
||||
- 检查 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健康监控和诊断入口自检
|
||||
|
||||
## 常用命令
|
||||
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
|
||||
|
||||
```bash
|
||||
systemctl status pigway-pi-control --no-pager -l
|
||||
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
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
## 版本路线
|
||||
## 可选 API
|
||||
|
||||
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
||||
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
||||
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
||||
- **v3.7.0:多机状态卡片、聚合日志、机器配置选择与批量同步**
|
||||
默认 `[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)。
|
||||
|
||||
+9
-9
@@ -1,10 +1,10 @@
|
||||
96dfd20c3ad16ca62a46d66eb5ffb82b1f7f987e66c4dae5310609b1989b7dae README.md
|
||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||
d0fa68951097a90eb6f51b36e56dc935d8fd7632c3de0ad6bff0b448960a7c03 app/pigway_pi_control.py
|
||||
034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py
|
||||
ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf
|
||||
f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh
|
||||
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
||||
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
||||
9c1fe8e9860aa4e5c9cd3ceb2199cdd118dd246a3b0f255f540976cf1877eef4 README.md
|
||||
5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
|
||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||
3a40109f11bea2d44e9d61bed4a90499a5047c4fc534f97aaa90becbf9e57017 web/index.html
|
||||
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
|
||||
f91af58e7dd752fa97c168ae419e14e6ee5099ea58d8252568f7ce72affd219f app/pigway_pi_control_api.py
|
||||
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||
60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf
|
||||
5e16c1d5d592fa3e129cde0077da6b089784c82f452205a349d4824f48eb3a66 systemd/pigway-pi-control-api.service
|
||||
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
|
||||
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
||||
|
||||
Binary file not shown.
+92
-476
@@ -1,12 +1,12 @@
|
||||
#!/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
|
||||
APP_VERSION="3.7.0"
|
||||
from smbus2 import SMBus
|
||||
from PIL import Image
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from shutil import disk_usage
|
||||
from plugin_api import Publisher,registered,discover
|
||||
|
||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
||||
CFG="/etc/pigway-pi-control.conf"
|
||||
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||
running=True
|
||||
@@ -53,6 +53,22 @@ def device_details():
|
||||
except Exception: pass
|
||||
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
|
||||
except Exception: pass
|
||||
# Device tree identifies ARM boards; DMI covers PCs and many other vendors.
|
||||
for key,files in {
|
||||
"manufacturer":["/sys/class/dmi/id/sys_vendor"],
|
||||
"model":["/sys/firmware/devicetree/base/model","/sys/class/dmi/id/product_name"],
|
||||
"revision":["/sys/class/dmi/id/product_version","/sys/class/dmi/id/board_version"],
|
||||
"serial":["/sys/class/dmi/id/product_serial"],
|
||||
}.items():
|
||||
if info.get(key):continue
|
||||
for filename in files:
|
||||
try:value=Path(filename).read_text().strip().strip("\0")
|
||||
except (OSError,UnicodeError):continue
|
||||
if value and value.lower() not in ("none","unknown","default string","to be filled by o.e.m."):
|
||||
info[key]=value;break
|
||||
if not info.get("manufacturer"):
|
||||
try:info["manufacturer"]=Path("/sys/firmware/devicetree/base/compatible").read_bytes().split(b"\0")[0].decode().split(',')[0]
|
||||
except (OSError,UnicodeError):pass
|
||||
try:
|
||||
os_release={}
|
||||
for line in Path("/etc/os-release").read_text().splitlines():
|
||||
@@ -84,20 +100,6 @@ def throttled_state():
|
||||
if not m: return {"raw":"UNKNOWN","error":"vcgencmd get_throttled unavailable or invalid"}
|
||||
return {"raw":f"0x{v:x}","under_voltage_now":bool(v&1),"freq_capped_now":bool(v&2),"throttled_now":bool(v&4),"soft_temp_limit_now":bool(v&8),"under_voltage_occurred":bool(v&(1<<16)),"freq_capped_occurred":bool(v&(1<<17)),"throttled_occurred":bool(v&(1<<18)),"soft_temp_limit_occurred":bool(v&(1<<19))}
|
||||
|
||||
bus=None
|
||||
bus_lock=threading.RLock()
|
||||
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
|
||||
FONT5=b""
|
||||
agent={"loop_errors":0,"i2c_errors":0}
|
||||
|
||||
def bus_write(method,*args):
|
||||
with bus_lock:
|
||||
try: return getattr(bus,method)(*args)
|
||||
except OSError:
|
||||
agent["i2c_errors"]+=1
|
||||
raise
|
||||
|
||||
fan_level=0
|
||||
prev_cpu=None
|
||||
page=0; page_since=time.monotonic()
|
||||
cpu_hi=mem_hi=0
|
||||
@@ -107,56 +109,6 @@ wifi_weak_since=None; wifi_weak_active=False
|
||||
last_net=last_watch=0
|
||||
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):
|
||||
base=Path("/sys/class/leds")/name
|
||||
try:
|
||||
@@ -170,24 +122,6 @@ def apply_dark_mode():
|
||||
if DARK_PWR: set_pi_led("PWR",True)
|
||||
if DARK_ACT: set_pi_led("ACT",True)
|
||||
|
||||
FAN_REG=[0x00,0x04,0x06,0x08,0x09,0x01]
|
||||
FAN_NAME=["OFF","L1","L2","L3","L4","MAX"]
|
||||
def fan_target(t):
|
||||
if t<FAN_T[0]: return 0
|
||||
if t<FAN_T[1]: return 1
|
||||
if t<FAN_T[2]: return 2
|
||||
if t<FAN_T[3]: return 3
|
||||
if t<FAN_T[4]: return 4
|
||||
return 5
|
||||
def update_fan(t):
|
||||
global fan_level
|
||||
want=fan_target(t); old=fan_level
|
||||
if want>fan_level: fan_level=want
|
||||
elif want<fan_level:
|
||||
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
|
||||
if t < boundary: fan_level-=1
|
||||
if old!=fan_level: mcu(0x08,FAN_REG[fan_level])
|
||||
|
||||
def cpu_pct():
|
||||
global prev_cpu
|
||||
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
|
||||
@@ -264,10 +198,7 @@ if not DEVICE_ID or DEVICE_ID.lower()=="auto": DEVICE_ID=socket.gethostname()
|
||||
DEVICE_NAME=cf("device","name",DEVICE_ID,str).strip() or DEVICE_ID
|
||||
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
|
||||
DEVICE_INFO=device_details()
|
||||
|
||||
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
||||
@@ -286,44 +217,9 @@ WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
||||
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
||||
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
|
||||
|
||||
FAN_T=[cf("fan","start",50.0),cf("fan","level2",53.0),cf("fan","level3",56.0),
|
||||
cf("fan","level4",59.0),cf("fan","max",62.0)]
|
||||
FAN_HYST=cf("fan","hysteresis",1.5)
|
||||
ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","row3_y",16,int),cf("display","row4_y",24,int)]
|
||||
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int))
|
||||
WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int)))
|
||||
CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,int)))
|
||||
EMERGENCY_FLASH=(max(50,cf("led","emergency_flash_on_ms",300,int)),max(50,cf("led","emergency_flash_off_ms",300,int)))
|
||||
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={
|
||||
ALERT_CATEGORIES={
|
||||
"ROOT_READONLY":"STORAGE",
|
||||
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
|
||||
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
|
||||
@@ -348,7 +244,7 @@ def diagnose():
|
||||
print("Agent:")
|
||||
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
|
||||
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||
print("loop_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||
print("System:")
|
||||
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
||||
print("Power:")
|
||||
@@ -361,27 +257,16 @@ def diagnose():
|
||||
iface,kind,metric,label,address=primary_network()
|
||||
print("Network:")
|
||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||
print("I2C:")
|
||||
# SMBus receive-byte performs a read only; no register selection or display commands.
|
||||
try:
|
||||
with SMBus(1) as probe:
|
||||
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
||||
try:
|
||||
probe.read_byte(address)
|
||||
print(f"0x{address:02X} {name}=PRESENT")
|
||||
except OSError as e:
|
||||
status="MISSING" if e.errno in (errno.ENXIO,getattr(errno,"EREMOTEIO",121)) else "ERROR"
|
||||
print(f"0x{address:02X} {name}={status} error={e}")
|
||||
except OSError as e:
|
||||
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
||||
print(f"0x{address:02X} {name}=ERROR error={e}")
|
||||
print("Plugins (read-only API discovery; no hardware access):")
|
||||
for plugin in discover(cfg):
|
||||
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
|
||||
print("Watches:")
|
||||
for w in watches:
|
||||
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
|
||||
return 0
|
||||
|
||||
|
||||
DISPLAY_PRIORITIES={
|
||||
ALERT_PRIORITIES={
|
||||
"ROOT_READONLY":100,
|
||||
"TEMP_CRITICAL":90,
|
||||
"SOFT_TEMP_LIMIT":85,
|
||||
@@ -401,8 +286,8 @@ DISPLAY_PRIORITIES={
|
||||
"NORMAL_HOME":0,
|
||||
}
|
||||
|
||||
def display_priority(request_id):
|
||||
return DISPLAY_PRIORITIES[request_id.split("|",1)[0]]
|
||||
def alert_priority(request_id):
|
||||
return ALERT_PRIORITIES[request_id.split("|",1)[0]]
|
||||
|
||||
def transition(key,active,on_event,off_event,**fields):
|
||||
old=event_state.get(key,False)
|
||||
@@ -415,39 +300,39 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||
global cpu_hi,mem_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
|
||||
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")
|
||||
else: display.clear("CPU_HIGH")
|
||||
if cpu_hi>=CPU_HIGH_SEC: alert_manager.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD")
|
||||
else: alert_manager.clear("CPU_HIGH")
|
||||
if temp>=TEMP_CRIT:
|
||||
display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||
display.clear("TEMP_HIGH")
|
||||
alert_manager.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||
alert_manager.clear("TEMP_HIGH")
|
||||
elif temp>=TEMP_HIGH:
|
||||
display.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||
display.clear("TEMP_CRITICAL")
|
||||
alert_manager.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||
alert_manager.clear("TEMP_CRITICAL")
|
||||
else:
|
||||
display.clear("TEMP_HIGH"); display.clear("TEMP_CRITICAL")
|
||||
if mem_hi>=MEM_HIGH_SEC: display.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||
else: display.clear("MEMORY_HIGH")
|
||||
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
|
||||
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||
else: alert_manager.clear("MEMORY_HIGH")
|
||||
if disk>=DISK_CRIT:
|
||||
display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||
display.clear("DISK_HIGH")
|
||||
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||
alert_manager.clear("DISK_HIGH")
|
||||
elif disk>=DISK_HIGH:
|
||||
display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
||||
display.clear("DISK_CRITICAL")
|
||||
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
||||
alert_manager.clear("DISK_CRITICAL")
|
||||
else:
|
||||
display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL")
|
||||
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
|
||||
if not link_ok:
|
||||
display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
||||
display.clear("INTERNET_DOWN")
|
||||
alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
||||
alert_manager.clear("INTERNET_DOWN")
|
||||
elif not net_ok:
|
||||
display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
|
||||
display.clear("NETWORK_DOWN")
|
||||
alert_manager.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
|
||||
alert_manager.clear("NETWORK_DOWN")
|
||||
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):
|
||||
alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
|
||||
if not ok:
|
||||
display.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
|
||||
else: display.clear(alert_id)
|
||||
alert_manager.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
|
||||
else: alert_manager.clear(alert_id)
|
||||
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
|
||||
rssi=float(net_metric)
|
||||
if not wifi_weak_active:
|
||||
@@ -458,320 +343,56 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||
elif rssi >= WIFI_RECOVER:
|
||||
wifi_weak_active=False; wifi_weak_since=None
|
||||
if wifi_weak_active:
|
||||
display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
|
||||
else: display.clear("WIFI_WEAK")
|
||||
alert_manager.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
|
||||
else: alert_manager.clear("WIFI_WEAK")
|
||||
else:
|
||||
wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK")
|
||||
wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
|
||||
if health.get("under_voltage_now"):
|
||||
display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||
else: display.clear("POWER_UNDERVOLT")
|
||||
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||
else: alert_manager.clear("POWER_UNDERVOLT")
|
||||
if health.get("throttled_now"):
|
||||
display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: display.clear("THROTTLED")
|
||||
alert_manager.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: alert_manager.clear("THROTTLED")
|
||||
if health.get("freq_capped_now"):
|
||||
display.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: display.clear("FREQ_CAPPED")
|
||||
alert_manager.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: alert_manager.clear("FREQ_CAPPED")
|
||||
if health.get("soft_temp_limit_now"):
|
||||
display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
|
||||
else: display.clear("SOFT_TEMP_LIMIT")
|
||||
if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
|
||||
else: display.clear("ROOT_READONLY")
|
||||
alert_manager.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
|
||||
else: alert_manager.clear("SOFT_TEMP_LIMIT")
|
||||
if fs_readonly: alert_manager.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
|
||||
else: alert_manager.clear("ROOT_READONLY")
|
||||
|
||||
def rgb_mode_for(request_id,severity):
|
||||
if severity<=0: return "OFF"
|
||||
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:
|
||||
class AlertManager:
|
||||
"""Current monitoring facts. Hardware plugins own all display decisions."""
|
||||
def __init__(self):
|
||||
now=time.monotonic()
|
||||
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()
|
||||
self.requests={}
|
||||
|
||||
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
|
||||
now=time.monotonic(); priority=display_priority(request_id)
|
||||
request={"id":request_id,"priority":priority,"severity":severity,
|
||||
"rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21],
|
||||
"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True,
|
||||
"last_changed":now,"metadata":metadata or {}}
|
||||
with self.lock:
|
||||
current=self.requests.get(request_id)
|
||||
if current is None:
|
||||
request["first_seen"]=now; self.requests[request_id]=request
|
||||
log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
|
||||
else:
|
||||
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()
|
||||
now=time.monotonic();old=self.requests.get(request_id)
|
||||
value={"id":request_id,"priority":alert_priority(request_id),"severity":severity,
|
||||
"category":ALERT_CATEGORIES[request_id.split("|",1)[0]].lower(),
|
||||
"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
|
||||
"first_seen":old["first_seen"] if old else now,"metadata":metadata or {}}
|
||||
value["last_changed"]=old["last_changed"] if old and all(old[k]==v for k,v in value.items()) else now
|
||||
self.requests[request_id]=value
|
||||
if old is None:log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
|
||||
|
||||
def clear(self,request_id):
|
||||
if request_id=="NORMAL_HOME": return
|
||||
with self.lock:
|
||||
if self.requests.pop(request_id,None) is not None:
|
||||
log("INFO","ALERT_RECOVERED",alert=request_id)
|
||||
if self.batch_depth==0:
|
||||
self._arbitrate_locked()
|
||||
if self.current_oled_page_id not in self.requests:
|
||||
self._show_page_locked(self.current_owner_id)
|
||||
else: self._sync_rgb_locked()
|
||||
|
||||
def begin_update(self):
|
||||
with self.lock: self.batch_depth+=1
|
||||
|
||||
def end_update(self):
|
||||
with self.lock:
|
||||
if self.batch_depth<=0: raise RuntimeError("display update batch imbalance")
|
||||
self.batch_depth-=1
|
||||
if self.batch_depth==0:
|
||||
self._arbitrate_locked()
|
||||
if self.current_oled_page_id not in self.requests:
|
||||
self._show_page_locked(self.current_owner_id)
|
||||
else: self._sync_rgb_locked()
|
||||
|
||||
def _arbitrate_locked(self):
|
||||
current=self.requests.get(self.current_owner_id)
|
||||
ordered=sorted(self.requests.values(),key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
||||
candidate=ordered[0]
|
||||
if current is not None and candidate["priority"]<=current["priority"]: return
|
||||
self._change_owner_locked(candidate)
|
||||
|
||||
def _change_owner_locked(self,request):
|
||||
old=self.current_owner_id
|
||||
if request["id"]==old: return
|
||||
self.current_owner_id=request["id"]
|
||||
self.current_priority=request["priority"]
|
||||
self.current_rgb_mode=request["rgb_mode"]
|
||||
self.owner_since=time.monotonic()
|
||||
log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"])
|
||||
self._show_page_locked(request["id"])
|
||||
|
||||
def _ordered_alerts_locked(self):
|
||||
return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"),
|
||||
key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
||||
|
||||
def _show_page_locked(self,page_id):
|
||||
alerts=self._ordered_alerts_locked()
|
||||
request=self.requests.get(page_id,self.requests["NORMAL_HOME"])
|
||||
self.current_oled_page_id=request["id"]
|
||||
self.page_started=time.monotonic()
|
||||
self._sync_rgb_locked()
|
||||
if request["id"]=="NORMAL_HOME":
|
||||
render_home(*request["metadata"]["home"])
|
||||
else:
|
||||
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])
|
||||
if self.requests.pop(request_id,None) is not None:log("INFO","ALERT_RECOVERED",alert=request_id)
|
||||
|
||||
def status_snapshot(self):
|
||||
with self.lock:
|
||||
alerts=self._ordered_alerts_locked()
|
||||
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],
|
||||
}
|
||||
values=sorted(self.requests.values(),key=lambda a:(-a["priority"],a["first_seen"],a["id"]))
|
||||
return {"owner":values[0]["id"] if values else "NORMAL_HOME","alerts":values}
|
||||
|
||||
def _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():
|
||||
log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"])
|
||||
if bus is None: return
|
||||
display.shutdown()
|
||||
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)):
|
||||
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))
|
||||
publisher.close()
|
||||
log("INFO","STOP",loop_errors=agent["loop_errors"])
|
||||
try:Path(STATUS_PATH).unlink(missing_ok=True)
|
||||
except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
|
||||
|
||||
def write_status(cpu,temp,mem,disk):
|
||||
display_state=display.status_snapshot()
|
||||
display_state=alert_manager.status_snapshot()
|
||||
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
||||
disk_total,disk_used,disk_free=disk_usage("/")
|
||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||
@@ -786,7 +407,7 @@ def write_status(cpu,temp,mem,disk):
|
||||
"memory_available_bytes":memory_available,
|
||||
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
||||
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
||||
"root_readonly":bool(fs_readonly)},
|
||||
"power":health,
|
||||
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||
"signal_percent":signal,
|
||||
@@ -794,8 +415,10 @@ def write_status(cpu,temp,mem,disk):
|
||||
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||
for w,ok in zip(watches,watch_state)],
|
||||
"display":display_state,
|
||||
"agent":{"loop_errors":agent["loop_errors"],"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)
|
||||
temporary=path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
|
||||
@@ -808,18 +431,17 @@ args=parser.parse_args()
|
||||
if args.diagnose: raise SystemExit(diagnose())
|
||||
|
||||
watch_state=[]
|
||||
last_sensor=last_render=last_status=0.0
|
||||
last_sensor=last_status=0.0
|
||||
last_net=last_watch=last_health=0.0
|
||||
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={}
|
||||
history_logged=set()
|
||||
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:
|
||||
bus=SMBus(1)
|
||||
FONT5=Path(FONT_PATH).read_bytes()
|
||||
display.start(); apply_dark_mode()
|
||||
apply_dark_mode()
|
||||
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
||||
while running:
|
||||
try:
|
||||
@@ -831,7 +453,7 @@ try:
|
||||
if now-last_sensor >= SENSOR_INTERVAL:
|
||||
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||
latest=(c,t,m,d); update_fan(t); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
latest=(c,t,m,d); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
|
||||
if now-last_net >= NETWORK_INTERVAL:
|
||||
old_id=(net_iface,ip)
|
||||
@@ -864,13 +486,7 @@ try:
|
||||
agent["last_health_ok"]=now; last_health=now
|
||||
|
||||
c,t,m,d=latest
|
||||
display.update_home(c,t,m,d)
|
||||
display.begin_update()
|
||||
try: update_active_alerts(c,t,m,d,sample_elapsed)
|
||||
finally: display.end_update()
|
||||
if now-last_render >= OLED_REFRESH:
|
||||
display.refresh()
|
||||
last_render=now
|
||||
update_active_alerts(c,t,m,d,sample_elapsed)
|
||||
if now-last_status >= 1.0:
|
||||
write_status(c,t,m,d); last_status=now
|
||||
|
||||
|
||||
+78
-163
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from plugin_api import discover,registered,request as plugin_request,socket_path
|
||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||
from http import HTTPStatus
|
||||
@@ -11,8 +14,7 @@ APP_VERSION="3.7.0"
|
||||
CFG=Path("/etc/pigway-pi-control.conf")
|
||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
||||
ALLOWED_SECTIONS={"device","services","processes","alerts","timing","dark_mode","api"}
|
||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||
@@ -21,6 +23,40 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf
|
||||
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
||||
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
||||
|
||||
def plugin_inventory():
|
||||
cfg=load_cfg();items=discover(cfg)
|
||||
for item in items:item["paused"]=cfg.getboolean("plugin_paused",item["id"],fallback=False)
|
||||
return items
|
||||
|
||||
|
||||
def find_plugin(plugin_id):
|
||||
for item in plugin_inventory():
|
||||
if item["id"]==plugin_id:return item
|
||||
raise ValueError("unknown plugin")
|
||||
|
||||
|
||||
def plugin_operation(body):
|
||||
plugin_id=str(body.get("id",""))
|
||||
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
|
||||
item=find_plugin(plugin_id);operation=body.get("operation")
|
||||
if operation=="link":
|
||||
if not item["online"]:raise ValueError("plugin is offline")
|
||||
text=replace_ini_value("plugins",plugin_id,socket_path(item["socket"]))
|
||||
save_ini_text(text,"plugin-link")
|
||||
text=replace_ini_value("plugin_paused",plugin_id,"false")
|
||||
elif operation in {"pause","resume","unlink"}:
|
||||
if not item["linked"]:raise ValueError("plugin is not linked")
|
||||
text=replace_ini_value("plugins",plugin_id,None) if operation=="unlink" else replace_ini_value("plugin_paused",plugin_id,"true" if operation=="pause" else "false")
|
||||
else:raise ValueError("invalid plugin operation")
|
||||
backup=save_ini_text(text,"plugin")
|
||||
# Only reload a monitor that is already running. Never start hardware here.
|
||||
subprocess.run(["systemctl","try-restart","pigway-pi-control.service"],check=True,timeout=15)
|
||||
if operation in {"pause","unlink"}:
|
||||
try:plugin_request(item["socket"],"DELETE","/v1/state",{})
|
||||
except Exception:pass # An offline plugin expires its lease independently.
|
||||
return {"ok":True,"backup":backup}
|
||||
|
||||
|
||||
def machine_identity():
|
||||
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
|
||||
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
|
||||
@@ -28,60 +64,6 @@ def machine_identity():
|
||||
if name.lower()=="auto": name=identifier
|
||||
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
|
||||
|
||||
def normalize_url(value):
|
||||
parsed=urlparse(str(value).strip())
|
||||
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError("host URL must be an http(s) URL without credentials")
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
|
||||
def host_registry():
|
||||
local=machine_identity(); result=[local]
|
||||
cfg=load_cfg()
|
||||
if cfg.has_section("hosts"):
|
||||
for host_id,url in cfg.items("hosts"):
|
||||
try: base=normalize_url(url)
|
||||
except ValueError: continue
|
||||
if host_id==local["id"]: continue
|
||||
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
|
||||
return result
|
||||
|
||||
def find_host(host_id):
|
||||
for host in host_registry():
|
||||
if host["id"]==host_id:return host
|
||||
raise ValueError("unknown host")
|
||||
|
||||
def remote_json(base,path,method="GET",payload=None,token=""):
|
||||
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
|
||||
headers={"Accept":"application/json"}
|
||||
if data is not None: headers["Content-Type"]="application/json"
|
||||
if token: headers["Authorization"]=f"Bearer {token}"
|
||||
request=Request(base+path,data=data,headers=headers,method=method)
|
||||
with urlopen(request,timeout=5) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
def fleet_status():
|
||||
hosts=host_registry(); results=[]
|
||||
try:
|
||||
status=json.loads(STATUS.read_text())
|
||||
status.setdefault("machine",machine_identity())
|
||||
results.append({"host":hosts[0],"online":True,"status":status})
|
||||
except Exception as exc:
|
||||
results.append({"host":hosts[0],"online":False,"error":str(exc)})
|
||||
def fetch(host):
|
||||
try:
|
||||
status=remote_json(host["url"],"/api/v1/status")
|
||||
machine=status.get("machine",{})
|
||||
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
|
||||
return {"host":merged,"online":True,"status":status}
|
||||
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
|
||||
peers=hosts[1:]
|
||||
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||
futures=[pool.submit(fetch,h) for h in peers]
|
||||
for future in as_completed(futures): results.append(future.result())
|
||||
order={h["id"]:i for i,h in enumerate(hosts)}
|
||||
results.sort(key=lambda x:order.get(x["host"]["id"],999))
|
||||
return {"hosts":results,"timestamp":int(time.time())}
|
||||
|
||||
def load_cfg():
|
||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||
return cfg
|
||||
@@ -92,12 +74,22 @@ def api_setting(key,default,cast=str):
|
||||
|
||||
def config_json():
|
||||
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) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
|
||||
def validate_value(section,key,value,existing):
|
||||
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||
if section=="api" and key=="enabled":
|
||||
if value.lower() not in {"true","false"}:raise ValueError("enabled must be true or false")
|
||||
return
|
||||
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
|
||||
if section not in {"services","processes"} and key not in existing.get(section,set()):
|
||||
raise ValueError(f"unsupported option: {section}.{key}")
|
||||
if RGB_KEY_RE.fullmatch(key):
|
||||
if section=="hardware" and key=="cooling_hat_enabled":
|
||||
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
|
||||
elif section=="led" and key=="alert_mode":
|
||||
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
|
||||
elif section=="led" and key=="custom_min_hold_ms":
|
||||
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
|
||||
elif RGB_KEY_RE.fullmatch(key):
|
||||
number=int(value)
|
||||
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
|
||||
elif FLASH_KEY_RE.fullmatch(key):
|
||||
@@ -119,19 +111,18 @@ def validate_value(section,key,value,existing):
|
||||
ipaddress.ip_address(value)
|
||||
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
|
||||
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
|
||||
elif section=="hosts":
|
||||
normalize_url(value)
|
||||
|
||||
|
||||
def replace_ini_value(section,key,value):
|
||||
text=CFG.read_text()
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return text
|
||||
next_section=re.search(r"(?m)^\[.+\]\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())
|
||||
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:
|
||||
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:]
|
||||
@@ -145,15 +136,6 @@ def save_ini_text(text,prefix="api"):
|
||||
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||
return str(backup)
|
||||
|
||||
def edit_host(operation,host_id,url=""):
|
||||
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
|
||||
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
|
||||
local=machine_identity()["id"]
|
||||
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
|
||||
if operation=="save": url=normalize_url(url)
|
||||
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
|
||||
return save_ini_text(text,"hosts")
|
||||
|
||||
def update_ini(updates):
|
||||
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||
text=CFG.read_text()
|
||||
@@ -169,11 +151,11 @@ def update_ini(updates):
|
||||
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
|
||||
raise ValueError(f"invalid value: {section}.{key}")
|
||||
validate_value(section,str(key),value,existing)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
continue
|
||||
next_section=re.search(r"(?m)^\[.+\]\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())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
||||
@@ -224,50 +206,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,
|
||||
"event_types":event_types,"retention":"systemd-journal"}
|
||||
|
||||
def fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
|
||||
sort="timestamp",order="desc",limit=100,offset=0):
|
||||
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
|
||||
def decorate(payload,host):
|
||||
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
|
||||
host_id=machine_info.get("id") or host["id"]
|
||||
host_name=machine_info.get("name") or host["name"]
|
||||
decorated=[]
|
||||
for row in payload.get("logs",[]):
|
||||
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
|
||||
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
|
||||
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
|
||||
local_payload["machine"]=machine_identity()
|
||||
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
|
||||
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
|
||||
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
|
||||
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
|
||||
if value not in (None,""): query[key]=value
|
||||
def fetch(host):
|
||||
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
|
||||
try:
|
||||
status=remote_json(host["url"],"/api/v1/status")
|
||||
payload["machine"]=status.get("machine",{})
|
||||
except Exception: pass
|
||||
return decorate(payload,host)
|
||||
peers=hosts[1:]
|
||||
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||
futures=[pool.submit(fetch,h) for h in peers]
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
remote_rows,remote_events,remote_machine=future.result()
|
||||
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
|
||||
except Exception: pass
|
||||
if machine: rows=[row for row in rows if row["machine_id"]==machine]
|
||||
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
|
||||
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
|
||||
"event":lambda x:x["event"],"message":lambda x:x["message"],
|
||||
"machine":lambda x:(x["machine_name"],x["machine_id"])}
|
||||
rows.sort(key=keys[sort],reverse=order=="desc")
|
||||
total=len(rows)
|
||||
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||
"event_types":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
|
||||
"retention":"systemd-journal"}
|
||||
|
||||
def service_inventory():
|
||||
cfg=load_cfg()
|
||||
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
||||
@@ -308,14 +246,14 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
text=CFG.read_text()
|
||||
def change(section,key,value):
|
||||
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 value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
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())
|
||||
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:
|
||||
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:]
|
||||
@@ -378,19 +316,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed=urlparse(self.path)
|
||||
try:
|
||||
if parsed.path=="/":
|
||||
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
|
||||
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
||||
if not self.authorized():self.send_json(401,{"error":"bearer token required"});return
|
||||
if parsed.path=="/api/v1/health":
|
||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
||||
if parsed.path=="/api/v1/hosts":
|
||||
self.send_json(200,{"hosts":host_registry()}); return
|
||||
if parsed.path=="/api/v1/fleet/status":
|
||||
self.send_json(200,fleet_status()); return
|
||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()});return
|
||||
if parsed.path=="/api/v1/status":
|
||||
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}:
|
||||
if parsed.path=="/api/v1/logs":
|
||||
query=parse_qs(parsed.query)
|
||||
value=lambda key,default="":query.get(key,[default])[0]
|
||||
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
|
||||
@@ -400,23 +332,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
|
||||
sort=value("sort","timestamp"); order=value("order","desc")
|
||||
allowed_sort={"timestamp","severity","event","message"}
|
||||
if parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
|
||||
if sort not in allowed_sort: raise ValueError("invalid log sort")
|
||||
if order not in {"asc","desc"}: raise ValueError("invalid log order")
|
||||
severity=value("severity").upper(); event=value("event"); search=value("search")
|
||||
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
|
||||
if parsed.path=="/api/v1/fleet/logs":
|
||||
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
|
||||
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
|
||||
plugin_id=query.get("id",[""])[0]
|
||||
if parsed.path.endswith("/plugins"):
|
||||
self.send_json(200,{"plugins":plugin_inventory()});return
|
||||
item=find_plugin(plugin_id)
|
||||
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
|
||||
if parsed.path=="/api/v1/config":
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
|
||||
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
|
||||
if parsed.path=="/api/v1/services":
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
|
||||
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
|
||||
self.send_json(404,{"error":"not found"})
|
||||
except ValueError as e: self.send_json(400,{"error":str(e)})
|
||||
@@ -424,34 +356,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_PUT(self):
|
||||
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 not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control"}: self.send_json(404,{"error":"not found"}); return
|
||||
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||
try:
|
||||
body=self.read_json()
|
||||
if path=="/api/v1/hosts":
|
||||
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
|
||||
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
|
||||
if path=="/api/v1/fleet/config":
|
||||
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{})
|
||||
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
|
||||
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
|
||||
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
|
||||
for host_id in dict.fromkeys(str(x) for x in targets):
|
||||
try:
|
||||
if host_id==local_id:
|
||||
backup=update_ini(updates)
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
restart_local_api="api" in updates
|
||||
results.append({"id":host_id,"ok":True,"backup":backup})
|
||||
else:
|
||||
host=find_host(host_id); token=str(tokens.get(host_id,""))
|
||||
if not token: raise ValueError("remote bearer token required")
|
||||
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
|
||||
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
|
||||
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
|
||||
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
|
||||
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
|
||||
return
|
||||
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
host_id=str(body.get("host",""))
|
||||
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
|
||||
item=find_plugin(str(body.get("id","")))
|
||||
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
|
||||
if path=="/api/v1/services/control":
|
||||
control_service(str(body.get("unit","")),str(body.get("action","")))
|
||||
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
||||
@@ -473,6 +386,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||
|
||||
def main():
|
||||
if not load_cfg().getboolean("api","enabled",fallback=False):
|
||||
print("API_DISABLED",flush=True);sys.exit(78)
|
||||
host=api_setting("bind","0.0.0.0")
|
||||
port=api_setting("port",6001,int)
|
||||
server=ThreadingHTTPServer((host,port),Handler)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Hardware-independent client for the versioned local plugin API."""
|
||||
import copy
|
||||
from contextlib import closing
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_ROOT=Path('/run/pigway-plugins')
|
||||
|
||||
|
||||
def socket_path(value):
|
||||
path=Path(value)
|
||||
if not path.is_absolute() or '..' in path.parts or path.name!='api.sock' or PLUGIN_ROOT not in path.parents:
|
||||
raise ValueError('plugin socket must be /run/pigway-plugins/<plugin>/api.sock')
|
||||
return str(path)
|
||||
|
||||
|
||||
class Connection(http.client.HTTPConnection):
|
||||
def __init__(self,path):
|
||||
super().__init__('localhost',timeout=.8);self.path=socket_path(path)
|
||||
|
||||
def connect(self):
|
||||
self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
||||
self.sock.settimeout(self.timeout)
|
||||
try:self.sock.connect(self.path)
|
||||
except Exception:self.sock.close();raise
|
||||
|
||||
|
||||
def request(path,method,endpoint,payload=None):
|
||||
with closing(Connection(path)) as conn:
|
||||
body=None if payload is None else json.dumps(payload,allow_nan=False).encode()
|
||||
conn.request(method,endpoint,body,{'Content-Type':'application/json'})
|
||||
response=conn.getresponse();raw=response.read(262145)
|
||||
if len(raw)>262144:raise ValueError('plugin response too large')
|
||||
value=json.loads(raw)
|
||||
if response.status>=400:raise ValueError(value.get('error','plugin request failed'))
|
||||
return value
|
||||
|
||||
|
||||
def registered(cfg):
|
||||
if not cfg.has_section('plugins'):return {}
|
||||
return {k:socket_path(v) for k,v in cfg.items('plugins') if v.strip()}
|
||||
|
||||
|
||||
def discover(cfg):
|
||||
linked=registered(cfg);paths={v for v in linked.values()}
|
||||
paths.update(str(p) for p in PLUGIN_ROOT.glob('*/api.sock'))
|
||||
result=[]
|
||||
for path in sorted(paths)[:32]:
|
||||
key=next((k for k,v in linked.items() if v==path),None)
|
||||
try:
|
||||
d=request(path,'GET','/v1/descriptor')
|
||||
if d.get('api_version')!=1:raise ValueError('incompatible plugin API version')
|
||||
status=request(path,'GET','/v1/status')
|
||||
result.append({'id':key or d['id'],'socket':path,'linked':key is not None,'online':True,'descriptor':d,'status':status})
|
||||
except Exception as exc:
|
||||
result.append({'id':key or Path(path).parent.name,'socket':path,'linked':key is not None,'online':False,'error':str(exc)})
|
||||
return result
|
||||
|
||||
|
||||
class Publisher:
|
||||
"""One replaceable snapshot, not an unbounded queue of animation commands."""
|
||||
def __init__(self,plugins,source,log):
|
||||
self.plugins=plugins;self.source=source;self.log=log
|
||||
self.session=str(uuid.uuid4());self.revision=0;self.snapshot=None
|
||||
self.lock=threading.Lock();self.stop_event=threading.Event();self.status={}
|
||||
self.threads=[threading.Thread(target=self.run,args=(key,path),name='plugin-'+key,daemon=True)
|
||||
for key,path in plugins.items()]
|
||||
for thread in self.threads:thread.start()
|
||||
|
||||
def publish(self,state):
|
||||
with self.lock:self.snapshot=copy.deepcopy(state)
|
||||
|
||||
def current_status(self):
|
||||
with self.lock:return copy.deepcopy(self.status)
|
||||
|
||||
def run(self,key,path):
|
||||
previous_error=None;revision=0;descriptor=None
|
||||
while not self.stop_event.is_set():
|
||||
with self.lock:state=self.snapshot
|
||||
if state is not None:
|
||||
revision+=1
|
||||
payload={'api_version':1,'source':self.source,'session':self.session,'revision':revision,
|
||||
'ttl_seconds':10,'system':state['system'],'network':state['network'],'alerts':state['display']['alerts']}
|
||||
try:
|
||||
if descriptor is None:
|
||||
descriptor=request(path,'GET','/v1/descriptor')
|
||||
if descriptor.get('api_version')!=1:raise ValueError('incompatible plugin API version')
|
||||
request(path,'PUT','/v1/state',payload)
|
||||
status=request(path,'GET','/v1/status')
|
||||
with self.lock:self.status[key]={'online':True,'status':status,'name':descriptor.get('name',key),'capabilities':descriptor.get('capabilities',{})}
|
||||
if previous_error is not None:self.log('INFO','PLUGIN_RECOVERED',plugin=key)
|
||||
previous_error=None
|
||||
except Exception as exc:
|
||||
descriptor=None
|
||||
error=str(exc)
|
||||
with self.lock:self.status[key]={'online':False,'error':error}
|
||||
if previous_error!=error:self.log('WARN','PLUGIN_UNAVAILABLE',plugin=key,error=error)
|
||||
previous_error=error
|
||||
self.stop_event.wait(1)
|
||||
try:request(path,'DELETE','/v1/state',{'source':self.source,'session':self.session})
|
||||
except Exception:pass # Server-side leases expire if transport is unavailable.
|
||||
|
||||
def close(self):
|
||||
self.stop_event.set()
|
||||
for thread in self.threads:thread.join()
|
||||
+12
-157
@@ -1,48 +1,22 @@
|
||||
# =============================================================================
|
||||
# PIGWay Pi Control v3.7.0 - 用户配置文件
|
||||
# =============================================================================
|
||||
#
|
||||
# 正式安装后的配置路径:
|
||||
# /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 编辑:
|
||||
# sudo nano /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 保存后让配置生效:
|
||||
# sudo systemctl restart pigway-pi-control
|
||||
#
|
||||
# 查看运行状态:
|
||||
# systemctl status pigway-pi-control --no-pager -l
|
||||
#
|
||||
# 查看实时日志:
|
||||
# journalctl -u pigway-pi-control -f
|
||||
#
|
||||
# 重要:
|
||||
# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。
|
||||
# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。
|
||||
# 3. 数值单位和建议范围见每项上方注释。
|
||||
# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。
|
||||
# =============================================================================
|
||||
|
||||
# PIGWay system monitoring. Hardware plugins are separately installed.
|
||||
# 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。
|
||||
[plugins]
|
||||
# No automatic attachment. Example (prefer Web discovery):
|
||||
# yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||
|
||||
[device]
|
||||
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
|
||||
# 多台机器的 identifier 必须唯一;修改后重启硬件 Agent 和 API 服务。
|
||||
# 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。
|
||||
identifier = auto
|
||||
|
||||
# Web 页面显示名称。auto 表示使用 identifier。
|
||||
name = auto
|
||||
|
||||
|
||||
[hosts]
|
||||
# 在作为聚合入口的机器上登记其他节点,每行格式:
|
||||
# 唯一标识 = http://节点IP:6001
|
||||
# 示例:
|
||||
# living-room = http://192.168.1.20:6001
|
||||
|
||||
|
||||
[api]
|
||||
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
||||
# 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
|
||||
enabled = false
|
||||
# API 监听地址和端口。0.0.0.0 允许局域网访问;
|
||||
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||
bind = 0.0.0.0
|
||||
port = 6001
|
||||
@@ -51,19 +25,10 @@ log_limit = 200
|
||||
|
||||
[timing]
|
||||
# 传感器采样周期,单位:秒。
|
||||
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
||||
# CPU / 内存 / 温度 / 磁盘按此周期更新。
|
||||
# 推荐:1。通常不要低于 1。
|
||||
sensor_interval = 1
|
||||
|
||||
# OLED 当前页面刷新周期,单位:秒。
|
||||
# 这只是刷新当前页面数据,不负责切换页面。
|
||||
# 推荐:1。
|
||||
oled_refresh_interval = 1
|
||||
|
||||
# OLED 在主页和当前告警页之间轮播的周期,单位:秒。
|
||||
# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。
|
||||
page_interval = 10
|
||||
|
||||
# 网络状态检查周期,单位:秒。
|
||||
# 推荐:5。太短会产生不必要的网络探测。
|
||||
network_interval = 5
|
||||
@@ -74,45 +39,10 @@ service_interval = 5
|
||||
|
||||
# 本机健康检查周期,单位:秒。
|
||||
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
|
||||
# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。
|
||||
# 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。
|
||||
health_interval = 1
|
||||
|
||||
|
||||
[fan]
|
||||
# 风扇温控档位,单位:摄氏度。
|
||||
#
|
||||
# 默认曲线:
|
||||
# < 50°C OFF
|
||||
# 50~53°C L1
|
||||
# 53~56°C L2
|
||||
# 56~59°C L3
|
||||
# 59~62°C L4
|
||||
# >= 62°C MAX
|
||||
#
|
||||
# 如希望更安静,可适当提高阈值;如更重视散热,可降低。
|
||||
# 建议保持 start < level2 < level3 < level4 < max。
|
||||
|
||||
# 低于此温度,风扇完全停止。
|
||||
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]
|
||||
# ---------------- CPU ----------------
|
||||
# CPU 使用率达到此百分比后开始累计高负载时间。
|
||||
@@ -152,7 +82,7 @@ disk_critical = 95
|
||||
# ---------------- Wi-Fi 信号 ----------------
|
||||
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
|
||||
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
|
||||
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。
|
||||
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发弱信号告警。
|
||||
wifi_weak = -75
|
||||
|
||||
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
|
||||
@@ -162,81 +92,6 @@ wifi_recover = -68
|
||||
wifi_weak_seconds = 30
|
||||
|
||||
|
||||
[led]
|
||||
# 正常运行、没有任何 active alert 时的 RGB 表现:
|
||||
# off = 全灭(默认,最安静且 I2C 写入最少)
|
||||
# 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]
|
||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
||||
|
||||
@@ -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.
|
||||
+53
-175
@@ -1,197 +1,75 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
FORCE_I2C_CONFLICT=0
|
||||
NO_START=0
|
||||
API_ENABLED=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force-i2c-conflict) FORCE_I2C_CONFLICT=1 ;;
|
||||
-h|--help)
|
||||
echo "Usage: sudo ./install.sh [--force-i2c-conflict]"
|
||||
echo " --force-i2c-conflict Continue even when another process owns /dev/i2c-1."
|
||||
exit 0
|
||||
;;
|
||||
*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
|
||||
--enable-api) API_ENABLED=true ;;
|
||||
--disable-api) API_ENABLED=false ;;
|
||||
--no-start) NO_START=1 ;;
|
||||
-h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
|
||||
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
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
|
||||
if [ ! -f "$required" ]; then
|
||||
echo "ERROR: required file missing: $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
|
||||
done
|
||||
|
||||
echo "[1/8] 检查 Raspberry Pi I2C..."
|
||||
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
|
||||
|
||||
# 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] 安装程序与配置..."
|
||||
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
|
||||
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
|
||||
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
|
||||
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
|
||||
mkdir -p /usr/local/share/pigway-pi-control/web
|
||||
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_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
|
||||
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
else
|
||||
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
||||
fi
|
||||
|
||||
# Preserve the existing file while exposing new defaults. Existing values win.
|
||||
python3 - /etc/pigway-pi-control.conf <<'PY'
|
||||
import re,sys
|
||||
# Legacy hardware sections are deliberately preserved for explicit migration,
|
||||
# but ignored by the monitor and never exposed as monitor configuration.
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path=Path(sys.argv[1])
|
||||
text=path.read_text()
|
||||
sections={
|
||||
"device":(("identifier","auto"),("name","auto")),
|
||||
"hosts":(),
|
||||
"led":(
|
||||
("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:
|
||||
text=text.rstrip()+f"\n\n[{section}]\n"
|
||||
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
|
||||
end=len(text); chunk=""
|
||||
missing=[(key,value) for key,value in defaults
|
||||
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)]
|
||||
if missing:
|
||||
note="# Added by installer; existing user values are preserved.\n"
|
||||
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)
|
||||
import configparser
|
||||
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
|
||||
with p.open('a') as f:
|
||||
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
||||
if not c.has_section(section):
|
||||
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
|
||||
PY
|
||||
if [ ! -s /etc/pigway-pi-control-api.token ]; then
|
||||
umask 077
|
||||
python3 - <<'PY' > /etc/pigway-pi-control-api.token
|
||||
import secrets
|
||||
print(secrets.token_urlsafe(32))
|
||||
PY
|
||||
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
|
||||
fi
|
||||
chmod 0600 /etc/pigway-pi-control-api.token
|
||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service
|
||||
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/pigway-pi-control-api.service
|
||||
|
||||
echo "[6/8] 启用/重启服务..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service
|
||||
systemctl restart pigway-pi-control-api.service
|
||||
|
||||
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
|
||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
|
||||
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
|
||||
if [ -z "$API_ENABLED" ]; then
|
||||
API_ENABLED=false
|
||||
if [ -t 0 ]; then
|
||||
read -r -p 'Enable remote management API? [y/N] ' answer
|
||||
case "$answer" in y|Y|yes|YES) API_ENABLED=true ;; esac
|
||||
fi
|
||||
fi
|
||||
check "Service running" systemctl is-active --quiet pigway-pi-control.service
|
||||
check "API/Web service running" systemctl is-active --quiet pigway-pi-control-api.service
|
||||
check "API/Web version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' /usr/local/sbin/pigway-pi-control-api
|
||||
check "Web UI installed" test -s /usr/local/share/pigway-pi-control/web/index.html
|
||||
check "P0 health monitor" grep -Fq 'throttled_state' "$APP"
|
||||
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
|
||||
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
||||
echo "[8/8] 状态..."
|
||||
|
||||
|
||||
systemctl --no-pager --full status pigway-pi-control.service || true
|
||||
systemctl --no-pager --full status pigway-pi-control-api.service || true
|
||||
echo
|
||||
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
||||
echo "配置:/etc/pigway-pi-control.conf"
|
||||
echo "Web/API:http://$(hostname -I | awk '{print $1}'):$(awk -F= '/^port[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}' /etc/pigway-pi-control.conf)"
|
||||
echo "配置写入 token:sudo cat /etc/pigway-pi-control-api.token"
|
||||
API_ENABLED="$API_ENABLED" python3 - <<'PYAPI'
|
||||
import os,sys
|
||||
sys.path.insert(0,'app')
|
||||
import pigway_pi_control_api as api
|
||||
api.save_ini_text(api.replace_ini_value('api','enabled',os.environ['API_ENABLED']),'install')
|
||||
PYAPI
|
||||
systemctl daemon-reload
|
||||
if [ "$NO_START" -eq 0 ]; then
|
||||
systemctl enable pigway-pi-control.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service
|
||||
systemctl is-active --quiet pigway-pi-control.service
|
||||
if [ "$API_ENABLED" = true ]; then
|
||||
systemctl enable pigway-pi-control-api.service >/dev/null
|
||||
systemctl restart pigway-pi-control-api.service
|
||||
systemctl is-active --quiet pigway-pi-control-api.service
|
||||
else
|
||||
systemctl disable --now pigway-pi-control-api.service
|
||||
fi
|
||||
fi
|
||||
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
|
||||
echo 'Config: /etc/pigway-pi-control.conf'
|
||||
echo 'API token: sudo cat /etc/pigway-pi-control-api.token'
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
[Unit]
|
||||
Description=PIGWay Pi Control API and Web
|
||||
Description=PIGWay Device Agent optional local API
|
||||
After=network.target pigway-pi-control.service
|
||||
Wants=network.target pigway-pi-control.service
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/pigway-pi-control-api
|
||||
Restart=always
|
||||
RestartPreventExitStatus=78
|
||||
SuccessExitStatus=78
|
||||
RestartSec=2
|
||||
User=root
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[Unit]
|
||||
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
|
||||
After=multi-user.target network.target
|
||||
Wants=network.target
|
||||
Description=PIGWay system monitor
|
||||
After=local-fs.target systemd-modules-load.service
|
||||
Before=multi-user.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user