Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add4891e27 | ||
|
|
d789e34ab6 | ||
|
|
f5c01407d5 | ||
|
|
52a6e115f5 | ||
|
|
ccf1e22514 | ||
|
|
c8c12b7b70 | ||
|
|
ced0140450 | ||
|
|
ddce873745 | ||
|
|
324390f647 | ||
|
|
1f581087b2 | ||
|
|
0dac42cd64 | ||
|
|
0d7a686c65 | ||
|
|
630a6a98da | ||
|
|
7befb325fb | ||
|
|
f0bef66651 | ||
|
|
7c4de5843d | ||
|
|
2ec34bbd57 | ||
|
|
33e90eb535 | ||
|
|
c106b4c264 | ||
|
|
52f56eefc5 | ||
|
|
849fd016b6 | ||
|
|
993d95bab1 | ||
|
|
a976dfa3fa | ||
|
|
ae8f756749 | ||
|
|
c3ed599198 | ||
|
|
38362be824 | ||
|
|
2f82cc36b4 | ||
|
|
260d8fad8f | ||
|
|
9babcfabc1 | ||
|
|
152ecc6e2b | ||
|
|
a96cdb859d | ||
|
|
e2cec738c8 | ||
|
|
f505f3c23b | ||
|
|
b7a77a4661 | ||
|
|
9824c54d6b | ||
|
|
974121de99 | ||
|
|
5d744824a8 | ||
|
|
b08721fd15 | ||
|
|
cd9bdd5970 | ||
|
|
08c00aa370 | ||
|
|
13f6155096 | ||
|
|
97598a48ad |
@@ -1,238 +1,51 @@
|
||||
# PIGWay Pi Control v3.6.0
|
||||
# PIGWay Device Agent v3.7.0
|
||||
|
||||
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
v3.6.0 在本地硬件监控、显示仲裁和可配置 RGB 告警基础上,增加轻量状态/日志/配置 API 与同源 Web 管理界面。
|
||||
|
||||
## 已有核心功能
|
||||
PIGWay Device Agent is a lightweight local monitoring service for Raspberry Pi and Linux. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. It has no embedded Web UI and does not require a hardware plugin or Device Console.
|
||||
|
||||
- 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.6.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.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
|
||||
```
|
||||
|
||||
配置写入接口:
|
||||
|
||||
```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 的监控判断。
|
||||
|
||||
读取 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
|
||||
```
|
||||
|
||||
配置文件内已经写明每项用途、单位和修改方式。
|
||||
|
||||
## 安装 / 升级测试分支
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# Explicitly enable the API:
|
||||
sudo ./install.sh --enable-api
|
||||
# Keep the API disabled:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
安装器会:
|
||||
Interactive installation asks whether to enable the remote API and defaults to no. Non-interactive installation also keeps it disabled unless explicitly enabled. `--no-start` only updates files and configuration; it does not change the current service state.
|
||||
|
||||
- 安装必要依赖
|
||||
- 检查 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健康监控和诊断入口自检
|
||||
|
||||
## 常用命令
|
||||
The configuration file is `/etc/pigway-pi-control.conf`. Restart the Agent after changing monitoring settings:
|
||||
|
||||
```bash
|
||||
systemctl status pigway-pi-control --no-pager -l
|
||||
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
|
||||
```
|
||||
|
||||
## 版本路线
|
||||
### Optional API
|
||||
|
||||
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
||||
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
||||
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
||||
The default is `[api] enabled = false`, with no network listener. To use centralized management, enable it and configure the bind address and port, then run:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now pigway-pi-control-api.service
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
The default port is 6001. Every read and write request requires `Authorization: Bearer <token>`. The token belongs only to this device and is not printed directly by the installer. The API exposes health, status, logs, configuration, services, plugins, and plugin configuration under `/api/v1`. It does not serve a Web UI, register multiple machines, or proxy remote requests.
|
||||
|
||||
### Console and hardware plugins
|
||||
|
||||
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) can run on a NAS or remote server for dashboards, configuration, and batch synchronization.
|
||||
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat) independently provides local telemetry, OLED, fan control, and RGB effects.
|
||||
|
||||
The remote API and local plugin integration are independent. After enabling plugin integration, register `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` under `[plugins]` and restart the Agent, or attach it through the Console. Discovery never binds a plugin automatically. Pausing or disconnecting integration does not stop the plugin's local functions.
|
||||
|
||||
### Operations
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
```
|
||||
|
||||
Logs follow the system journal retention policy; no additional database is created. Uninstalling one project never uninstalls either of the others. See [Plugin API v1](docs/HARDWARE_PLUGIN_API.md).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# PIGWay Device Agent v3.7.0
|
||||
|
||||
简体中文 | [English](README.md)
|
||||
|
||||
|
||||
极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# 显式启用 API:
|
||||
sudo ./install.sh --enable-api
|
||||
# 不启用 API:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
|
||||
|
||||
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
## 可选 API
|
||||
|
||||
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now pigway-pi-control-api.service
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
|
||||
|
||||
API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
|
||||
|
||||
## 独立管理中心与插件
|
||||
|
||||
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
|
||||
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat):独立本地采集、OLED、风扇与 RGB。
|
||||
|
||||
远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
|
||||
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
```
|
||||
|
||||
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
|
||||
+10
-9
@@ -1,10 +1,11 @@
|
||||
8e3d96c25cf6d6cd7d6dbbdf22829226ae29db5844a6c6d5867d81863538b3b0 README.md
|
||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||
d71d949643d31461f2e62ded3818e1a0f1711d1420756ad0cc32eb04cde0fad3 app/pigway_pi_control.py
|
||||
7dc6c796febc012fa02e3ba9d66f151f585d8f77d4dabf9ca62c2b82423f92da app/pigway_pi_control_api.py
|
||||
75fa662ed21138a8e394de83e46281dab039433cc0d7a8f787e1a049f4b47ec8 config/pigway-pi-control.conf
|
||||
f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh
|
||||
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
||||
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
||||
7ed31dd9c626c8d3b576fb7976e7c75f2ed1e6f32cbc73c517e608831e440c6e README.md
|
||||
ddb6c2fb89001f0a7c2736534cc0b125ee4563ea2fc4f8d001752f5866f81e6f README.zh-CN.md
|
||||
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
|
||||
f91af58e7dd752fa97c168ae419e14e6ee5099ea58d8252568f7ce72affd219f app/pigway_pi_control_api.py
|
||||
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||
60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf
|
||||
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
||||
5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
|
||||
5e16c1d5d592fa3e129cde0077da6b089784c82f452205a349d4824f48eb3a66 systemd/pigway-pi-control-api.service
|
||||
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
|
||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||
08f3e276b44d615c18c394bf0508d7134f69d272e9f0e48ce5572cb5d4515dcd web/index.html
|
||||
|
||||
Binary file not shown.
+149
-474
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json
|
||||
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
|
||||
from pathlib import Path
|
||||
APP_VERSION="3.6.0"
|
||||
from smbus2 import SMBus
|
||||
from PIL import Image
|
||||
APP_VERSION="3.7.0"
|
||||
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
|
||||
@@ -29,6 +29,57 @@ def load_average():
|
||||
x=Path("/proc/loadavg").read_text().split();return (float(x[0]),float(x[1]),float(x[2]))
|
||||
except Exception:return (0.0,0.0,0.0)
|
||||
|
||||
def memory_usage():
|
||||
try:
|
||||
values={}
|
||||
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||
key,value=line.split(":",1); values[key]=int(value.split()[0])*1024
|
||||
total=values["MemTotal"]; available=values.get("MemAvailable",values.get("MemFree",0))
|
||||
used=max(0,total-available)
|
||||
return total,used,available,(used*100.0/total if total else 0.0)
|
||||
except Exception:return 0,0,0,0.0
|
||||
|
||||
def wifi_percent(metric):
|
||||
try:return max(0,min(100,round((float(metric)+100.0)*2.0)))
|
||||
except Exception:return None
|
||||
|
||||
def device_details():
|
||||
info={}
|
||||
try:
|
||||
for line in Path("/proc/cpuinfo").read_text().splitlines():
|
||||
if ":" in line:
|
||||
key,value=(x.strip() for x in line.split(":",1))
|
||||
if key in ("Serial","Revision","Hardware","Model"): info[key.lower()]=value
|
||||
except Exception: pass
|
||||
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
|
||||
except Exception: pass
|
||||
# Device tree identifies ARM boards; DMI covers PCs and many other vendors.
|
||||
for key,files in {
|
||||
"manufacturer":["/sys/class/dmi/id/sys_vendor"],
|
||||
"model":["/sys/firmware/devicetree/base/model","/sys/class/dmi/id/product_name"],
|
||||
"revision":["/sys/class/dmi/id/product_version","/sys/class/dmi/id/board_version"],
|
||||
"serial":["/sys/class/dmi/id/product_serial"],
|
||||
}.items():
|
||||
if info.get(key):continue
|
||||
for filename in files:
|
||||
try:value=Path(filename).read_text().strip().strip("\0")
|
||||
except (OSError,UnicodeError):continue
|
||||
if value and value.lower() not in ("none","unknown","default string","to be filled by o.e.m."):
|
||||
info[key]=value;break
|
||||
if not info.get("manufacturer"):
|
||||
try:info["manufacturer"]=Path("/sys/firmware/devicetree/base/compatible").read_bytes().split(b"\0")[0].decode().split(',')[0]
|
||||
except (OSError,UnicodeError):pass
|
||||
try:
|
||||
os_release={}
|
||||
for line in Path("/etc/os-release").read_text().splitlines():
|
||||
if "=" in line:
|
||||
key,value=line.split("=",1); os_release[key]=value.strip().strip('"')
|
||||
info["os"]=os_release.get("PRETTY_NAME",platform.platform())
|
||||
except Exception: info["os"]=platform.platform()
|
||||
info.update({"hostname":socket.gethostname(),"kernel":platform.release(),
|
||||
"architecture":platform.machine(),"cpu_count":os.cpu_count() or 0})
|
||||
return info
|
||||
|
||||
def cpu_freq_mhz():
|
||||
for q in ("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq","/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq"):
|
||||
try:return int(Path(q).read_text().strip())/1000.0
|
||||
@@ -49,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
|
||||
@@ -72,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:
|
||||
@@ -135,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]))
|
||||
@@ -210,7 +179,9 @@ def load_cfg():
|
||||
if c.has_section("services"):
|
||||
for label,target in c["services"].items():
|
||||
target=target.strip()
|
||||
if target and not target.startswith("#"): watches.append(("service",label.upper(),target))
|
||||
paused=c.getboolean("service_monitor_disabled",label,fallback=False)
|
||||
if target and not target.startswith("#") and not paused:
|
||||
watches.append(("service",label.upper(),target))
|
||||
if c.has_section("processes"):
|
||||
for label,target in c["processes"].items():
|
||||
target=target.strip()
|
||||
@@ -222,9 +193,12 @@ def cf(section,key,default,cast=float):
|
||||
try: return cast(cfg.get(section,key))
|
||||
except: return default
|
||||
|
||||
DEVICE_ID=cf("device","identifier","auto",str).strip()
|
||||
if not DEVICE_ID or DEVICE_ID.lower()=="auto": DEVICE_ID=socket.gethostname()
|
||||
DEVICE_NAME=cf("device","name",DEVICE_ID,str).strip() or DEVICE_ID
|
||||
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
|
||||
DEVICE_INFO=device_details()
|
||||
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
||||
@@ -243,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",
|
||||
@@ -304,7 +243,8 @@ def check_watch(w):
|
||||
def diagnose():
|
||||
print("Agent:")
|
||||
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
|
||||
print("loop_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||
print("System:")
|
||||
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
||||
print("Power:")
|
||||
@@ -316,28 +256,17 @@ def diagnose():
|
||||
if "error" in power: print("error="+power["error"])
|
||||
iface,kind,metric,label,address=primary_network()
|
||||
print("Network:")
|
||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} 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(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||
print("Plugins (read-only API discovery; no hardware access):")
|
||||
for plugin in discover(cfg):
|
||||
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
|
||||
print("Watches:")
|
||||
for w in watches:
|
||||
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
|
||||
return 0
|
||||
|
||||
|
||||
DISPLAY_PRIORITIES={
|
||||
ALERT_PRIORITIES={
|
||||
"ROOT_READONLY":100,
|
||||
"TEMP_CRITICAL":90,
|
||||
"SOFT_TEMP_LIMIT":85,
|
||||
@@ -357,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)
|
||||
@@ -371,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:
|
||||
@@ -414,329 +343,82 @@ 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,
|
||||
]
|
||||
right=[
|
||||
f"MEM {mem:.1f}%",
|
||||
f"FAN {FAN_NAME[fan_level]}",
|
||||
f"{net_kind} {net_metric}",
|
||||
"",
|
||||
]
|
||||
for row in range(4):
|
||||
y=row*8
|
||||
draw_text_5x7(pix,0,y,left[row])
|
||||
if right[row]:
|
||||
draw_text_5x7(pix,72,y,right[row])
|
||||
data=[]
|
||||
for pg in range(4):
|
||||
for x in range(W):
|
||||
v=0
|
||||
for bit in range(8):
|
||||
if pix[x,pg*8+bit]: v|=1<<bit
|
||||
data.append(v)
|
||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||
for i in range(0,len(data),16):
|
||||
bus_write("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; 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()
|
||||
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
|
||||
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)
|
||||
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
|
||||
state={
|
||||
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
|
||||
"machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
|
||||
"device":DEVICE_INFO,
|
||||
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
|
||||
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
||||
"memory_percent":round(mem,1),"disk_percent":round(disk,1),
|
||||
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
||||
"memory_percent":round(memory_calculated if memory_total else mem,1),
|
||||
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
|
||||
"memory_available_bytes":memory_available,
|
||||
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
||||
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||
"root_readonly":bool(fs_readonly)},
|
||||
"power":health,
|
||||
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||
"signal_percent":signal,
|
||||
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
|
||||
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||
for w,ok in zip(watches,watch_state)],
|
||||
"display":display_state,
|
||||
"agent":{"loop_errors":agent["loop_errors"],"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=(",",":")))
|
||||
@@ -749,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:
|
||||
@@ -772,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)
|
||||
@@ -805,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
|
||||
|
||||
|
||||
+170
-32
@@ -1,16 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from plugin_api import discover,registered,request as plugin_request,socket_path
|
||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs,urlparse
|
||||
from urllib.parse import parse_qs,urlencode,urlparse
|
||||
from urllib.request import Request,urlopen
|
||||
|
||||
APP_VERSION="3.6.0"
|
||||
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={"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]$")
|
||||
@@ -19,6 +23,47 @@ 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()
|
||||
name=cfg.get("device","name",fallback=identifier).strip() or identifier
|
||||
if name.lower()=="auto": name=identifier
|
||||
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
|
||||
|
||||
def load_cfg():
|
||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||
return cfg
|
||||
@@ -29,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=="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):
|
||||
@@ -54,6 +109,32 @@ def validate_value(section,key,value,existing):
|
||||
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
|
||||
elif section=="api" and key=="bind":
|
||||
ipaddress.ip_address(value)
|
||||
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
|
||||
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
|
||||
|
||||
|
||||
def replace_ini_value(section,key,value):
|
||||
text=CFG.read_text()
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return text
|
||||
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
if key_match:
|
||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||
return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||
if value is not None:return text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||
return text
|
||||
|
||||
def save_ini_text(text,prefix="api"):
|
||||
parsed=configparser.ConfigParser(); parsed.read_string(text)
|
||||
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||
shutil.copy2(CFG,backup)
|
||||
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||
return str(backup)
|
||||
|
||||
def update_ini(updates):
|
||||
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||
@@ -70,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)
|
||||
@@ -91,18 +172,39 @@ def update_ini(updates):
|
||||
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||
return str(backup)
|
||||
|
||||
def journal(limit):
|
||||
result=subprocess.run(["journalctl","-u","pigway-pi-control.service","-n",str(limit),
|
||||
"--no-pager","-o","json"],capture_output=True,text=True,timeout=5)
|
||||
def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0):
|
||||
command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
|
||||
if since is not None: command.extend(["--since",f"@{since}"])
|
||||
if until is not None: command.extend(["--until",f"@{until}"])
|
||||
result=subprocess.run(command,capture_output=True,text=True,timeout=15)
|
||||
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
|
||||
rows=[]
|
||||
for line in result.stdout.splitlines():
|
||||
try:
|
||||
item=json.loads(line)
|
||||
rows.append({"timestamp":item.get("__REALTIME_TIMESTAMP"),"priority":item.get("PRIORITY"),
|
||||
"message":item.get("MESSAGE","")})
|
||||
message=item.get("MESSAGE","")
|
||||
level_match=re.search(r"(?:^|\s)level=([A-Z]+)(?:\s|$)",message)
|
||||
event_match=re.search(r"(?:^|\s)event=([^ ]+)",message)
|
||||
priority=str(item.get("PRIORITY","6"))
|
||||
level=(level_match.group(1) if level_match else
|
||||
{"0":"EMERGENCY","1":"ALERT","2":"CRITICAL","3":"ERROR","4":"WARN","5":"NOTICE","6":"INFO","7":"DEBUG"}.get(priority,"INFO"))
|
||||
rows.append({"timestamp":int(item.get("__REALTIME_TIMESTAMP",0)),"priority":priority,
|
||||
"severity":level,"event":event_match.group(1) if event_match else "SYSTEM",
|
||||
"message":message})
|
||||
except json.JSONDecodeError: pass
|
||||
return rows
|
||||
severities={x.strip().upper() for x in severity.split(",") if x.strip()}
|
||||
if severities: rows=[x for x in rows if x["severity"] in severities]
|
||||
event_types=sorted({x["event"] for x in rows})
|
||||
if event: rows=[x for x in rows if x["event"]==event]
|
||||
if search:
|
||||
needle=search.casefold(); rows=[x for x in rows if needle in x["message"].casefold()]
|
||||
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
|
||||
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
|
||||
"event":lambda x:x["event"],"message":lambda x:x["message"]}
|
||||
rows.sort(key=keys[sort],reverse=order=="desc")
|
||||
total=len(rows)
|
||||
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||
"event_types":event_types,"retention":"systemd-journal"}
|
||||
|
||||
def service_inventory():
|
||||
cfg=load_cfg()
|
||||
@@ -124,30 +226,34 @@ def service_inventory():
|
||||
item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""})
|
||||
for name,target in monitored.values():
|
||||
item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""})
|
||||
item.update({"monitored":True,"monitor_name":name.upper(),"note":notes.get(name,"")})
|
||||
paused=cfg.getboolean("service_monitor_disabled",name,fallback=False)
|
||||
item.update({"monitored":True,"paused":paused,"monitor_name":name.upper(),"note":notes.get(name,"")})
|
||||
for item in units.values():
|
||||
item.setdefault("monitored",False); item.setdefault("monitor_name",""); item.setdefault("note","")
|
||||
item.setdefault("monitored",False); item.setdefault("paused",False)
|
||||
item.setdefault("monitor_name",""); item.setdefault("note","")
|
||||
item["protected"]=item["unit"] in PROTECTED_UNITS
|
||||
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
|
||||
|
||||
def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
if operation not in {"save","delete"}: raise ValueError("operation must be save or delete")
|
||||
if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation")
|
||||
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
|
||||
if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name")
|
||||
if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note")
|
||||
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name")
|
||||
cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None)
|
||||
previous=previous_name or name
|
||||
was_paused=cfg.getboolean("service_monitor_disabled",previous,fallback=False)
|
||||
text=CFG.read_text()
|
||||
def change(section,key,value):
|
||||
nonlocal text
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\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:]
|
||||
@@ -155,11 +261,16 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||
if operation=="delete":
|
||||
if old_target is None: raise ValueError("monitor not found")
|
||||
change("services",name,None); change("service_notes",name,None)
|
||||
change("services",name,None); change("service_notes",name,None); change("service_monitor_disabled",name,None)
|
||||
elif operation in {"pause","resume"}:
|
||||
if old_target is None: raise ValueError("monitor not found")
|
||||
change("service_monitor_disabled",name,"true" if operation=="pause" else None)
|
||||
else:
|
||||
if previous_name and previous_name.lower()!=name.lower():
|
||||
change("services",previous_name,None); change("service_notes",previous_name,None)
|
||||
change("service_monitor_disabled",previous_name,None)
|
||||
change("services",name,target); change("service_notes",name,note or None)
|
||||
if was_paused: change("service_monitor_disabled",name,"true")
|
||||
parsed=configparser.ConfigParser(); parsed.read_string(text)
|
||||
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||
shutil.copy2(CFG,backup)
|
||||
@@ -175,7 +286,7 @@ def control_service(unit,action):
|
||||
if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed")
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version="PIGWayAPI/3.6"
|
||||
server_version="PIGWayAPI/3.7"
|
||||
|
||||
def log_message(self,fmt,*args):
|
||||
return
|
||||
@@ -205,30 +316,55 @@ 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
|
||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()});return
|
||||
if parsed.path=="/api/v1/status":
|
||||
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||
if parsed.path=="/api/v1/logs":
|
||||
limit=min(max(int(parse_qs(parsed.query).get("limit",[api_setting("log_limit",200,int)])[0]),1),1000)
|
||||
self.send_json(200,{"logs":journal(limit)}); return
|
||||
query=parse_qs(parsed.query)
|
||||
value=lambda key,default="":query.get(key,[default])[0]
|
||||
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
|
||||
offset=max(int(value("offset",0)),0)
|
||||
since=int(value("since")) if value("since") else None
|
||||
until=int(value("until")) if value("until") else None
|
||||
if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
|
||||
sort=value("sort","timestamp"); order=value("order","desc")
|
||||
allowed_sort={"timestamp","severity","event","message"}
|
||||
if sort not in allowed_sort: raise ValueError("invalid log sort")
|
||||
if order not in {"asc","desc"}: raise ValueError("invalid log order")
|
||||
severity=value("severity").upper(); event=value("event"); search=value("search")
|
||||
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
|
||||
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
|
||||
plugin_id=query.get("id",[""])[0]
|
||||
if parsed.path.endswith("/plugins"):
|
||||
self.send_json(200,{"plugins":plugin_inventory()});return
|
||||
item=find_plugin(plugin_id)
|
||||
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
|
||||
if parsed.path=="/api/v1/config":
|
||||
self.send_json(200,{"config":config_json(),"write_requires_token":True}); return
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
|
||||
if parsed.path=="/api/v1/services":
|
||||
self.send_json(200,{"services":service_inventory()}); return
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
|
||||
self.send_json(404,{"error":"not found"})
|
||||
except ValueError as e: self.send_json(400,{"error":str(e)})
|
||||
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||
|
||||
def do_PUT(self):
|
||||
path=urlparse(self.path).path
|
||||
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control"}: 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 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)
|
||||
@@ -250,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()
|
||||
+25
-148
@@ -1,32 +1,22 @@
|
||||
# =============================================================================
|
||||
# PIGWay Pi Control v3.6.0 - 用户配置文件
|
||||
# =============================================================================
|
||||
#
|
||||
# 正式安装后的配置路径:
|
||||
# /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 编辑:
|
||||
# sudo nano /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 保存后让配置生效:
|
||||
# sudo systemctl restart pigway-pi-control
|
||||
#
|
||||
# 查看运行状态:
|
||||
# systemctl status pigway-pi-control --no-pager -l
|
||||
#
|
||||
# 查看实时日志:
|
||||
# journalctl -u pigway-pi-control -f
|
||||
#
|
||||
# 重要:
|
||||
# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。
|
||||
# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。
|
||||
# 3. 数值单位和建议范围见每项上方注释。
|
||||
# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。
|
||||
# =============================================================================
|
||||
# PIGWay system monitoring. Hardware plugins are separately installed.
|
||||
# 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。
|
||||
[plugins]
|
||||
# No automatic attachment. Example (prefer Web discovery):
|
||||
# yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||
|
||||
[device]
|
||||
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
|
||||
# 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。
|
||||
identifier = auto
|
||||
|
||||
# Web 页面显示名称。auto 表示使用 identifier。
|
||||
name = auto
|
||||
|
||||
|
||||
[api]
|
||||
# API 与 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
|
||||
@@ -35,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
|
||||
@@ -58,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 使用率达到此百分比后开始累计高负载时间。
|
||||
@@ -136,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
|
||||
|
||||
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
|
||||
@@ -146,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 调暗。
|
||||
@@ -271,6 +142,12 @@ AWESUN = runawesun.service
|
||||
# NGINX = nginx.service
|
||||
|
||||
|
||||
[service_monitor_disabled]
|
||||
# Web 中“暂停监控”的状态保存在这里。
|
||||
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
|
||||
# 通常由 Web 自动维护,无需手工编辑。
|
||||
|
||||
|
||||
[processes]
|
||||
# =============================================================================
|
||||
# 普通进程监控
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Local hardware plugin API v1
|
||||
|
||||
The monitor and plugins are separate applications. The monitor has no hardware
|
||||
library dependency. Plugins alone own their hardware. Installing or discovering
|
||||
a plugin does not connect it or start another service.
|
||||
|
||||
## Transport and authorization
|
||||
|
||||
HTTP/1.0 or HTTP/1.1 over `/run/pigway-plugins/<id>/api.sock`. No TCP listener.
|
||||
The parent directory is root-owned, mode 0750, and the socket is 0660 with group
|
||||
`pigway-hardware`. Root and deliberately enrolled group members may control the
|
||||
plugin. The monitor Web API authenticates mutations with its existing bearer
|
||||
token before proxying local requests. Remote machine operations require that
|
||||
machine's token too. API v1 clients reject incompatible descriptor versions.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /v1/descriptor`: `api_version`, `id`, `name`, `version`, `capabilities`, `schema`.
|
||||
- `GET /v1/status`: availability, connection state and capability-specific actual
|
||||
or commanded state. A missing tachometer must never be presented as measured RPM.
|
||||
- `GET /v1/config`: `config` plus `schema`.
|
||||
- `PUT /v1/config`: `{"updates":{"section":{"key":"value"}}}`; validate the complete
|
||||
candidate before persisting or applying anything. Does not restart the monitor.
|
||||
- `PUT /v1/state`: replace the complete monitoring snapshot, never append commands.
|
||||
- `DELETE /v1/state`: release a lease. Publisher passes `source` and `session`;
|
||||
authorized local administration may use `{}` to detach the current source.
|
||||
|
||||
Example snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 1,
|
||||
"source": "raspberrypi",
|
||||
"session": "a-new-uuid-for-each-monitor-process",
|
||||
"revision": 1,
|
||||
"ttl_seconds": 10,
|
||||
"system": {"cpu_percent": 12, "temperature_c": 50, "memory_percent": 20, "disk_percent": 30},
|
||||
"network": {"kind": "WIF", "metric": "-60", "ip_label": "IP4", "ip": "192.168.1.2"},
|
||||
"alerts": [{"id":"SERVICE_DOWN|APP","category":"service","severity":2,"priority":60,
|
||||
"title":"SERVICE DOWN","l2":"APP","l3":"CHECK SERVICE","l4":"CRITICAL"}]
|
||||
}
|
||||
```
|
||||
|
||||
Alert categories: `cpu`, `power`, `memory`, `storage`, `network`, `service`.
|
||||
The producer sends only current alerts, never historical power flags. Empty
|
||||
`alerts` clears all previous alerts. A source/session owns its lease until
|
||||
release or expiry; a competing source and repeated/out-of-order revisions are
|
||||
rejected. Leases use the receiving process's monotonic clock, 3–30 seconds.
|
||||
A new monitor session retries until the old lease expires if the old process
|
||||
could not release it. Each plugin has an independent bounded-time publisher and
|
||||
one replaceable latest snapshot, so an offline plugin cannot queue stale frames
|
||||
or block monitoring or other plugins.
|
||||
|
||||
## Capability-driven UI
|
||||
|
||||
`fan` declares levels or PWM, RPM availability, autonomous operation and ownership.
|
||||
`display` declares dimensions and pages. `rgb` declares modes, preset colors and
|
||||
experimental custom flashing. Absent capability means absent controls. A plugin
|
||||
with only a fan does not imply a screen or lighting device. New hardware needs
|
||||
its own driver; I2C, PWM GPIO and power-only fans are not interchangeable.
|
||||
|
||||
`schema` groups fields by section. Each field provides bilingual `label`, `type`
|
||||
(`number`/`enum`), numeric limits or choices, unit and optional visibility rules.
|
||||
Optional `color_group` and `channel` (`r`/`g`/`b`) associate three numeric fields
|
||||
with a color picker. Plugins remain authoritative for all validation.
|
||||
|
||||
## Failures and independent cooling
|
||||
|
||||
Monitoring continues without installed or reachable plugins. Hardware plugins
|
||||
continue local temperature control without the monitor. Expired snapshots must
|
||||
not keep showing stale alerts as live faults. The Yahboom implementation returns
|
||||
to its locally sampled home and configured normal RGB mode on expiry or detach.
|
||||
It samples CPU, memory, disk, temperature and primary network independently.
|
||||
API v1 system/network fields remain accepted for compatibility but never override
|
||||
local telemetry. Monitor connectivity only supplies additional alerts.
|
||||
Its thermal read failure and service shutdown request full fan speed as a cooling
|
||||
fallback; actual execution still depends on functioning hardware.
|
||||
|
||||
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
|
||||
plugin service. No API/Web service has a systemd Wants dependency that starts the
|
||||
other application. Do not run two drivers for the same MCU, GPIO or fan.
|
||||
|
||||
## Optional listeners
|
||||
|
||||
Agent network API defaults to disabled (`[api] enabled=false`) and has no Web UI.
|
||||
When enabled, every request requires a device bearer token.
|
||||
Hardware plugin integration defaults to disabled (`[integration] enabled=false`):
|
||||
no Unix API socket is created, while local telemetry and hardware workers continue.
|
||||
Change integration locally and restart the plugin. Monitor-to-plugin linkage does
|
||||
not require the Agent network API. The independent Web Manager stores registered
|
||||
device addresses and tokens; it is not implicitly a monitored device.
|
||||
+53
-173
@@ -1,195 +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={
|
||||
"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.6.0" grep -Fq 'APP_VERSION="3.6.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.6.0" grep -Fq 'APP_VERSION="3.6.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