From 993d95bab185f0f3498b3e616af5243ae7d8ad5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 20:24:34 +0800 Subject: [PATCH] refactor: separate monitoring from hardware plugins --- README.md | 283 ++---------- SHA256SUMS | 19 +- app/oled_font_5x7.bin | Bin 1275 -> 0 bytes app/pigway_pi_control.py | 595 ++++---------------------- app/pigway_pi_control_api.py | 88 +++- app/plugin_api.py | 109 +++++ config/pigway-pi-control.conf | 158 +------ docs/HARDWARE_PLUGIN_API.md | 78 ++++ install.sh | 203 ++------- systemd/pigway-pi-control-api.service | 2 +- systemd/pigway-pi-control.service | 2 +- web/index.html | 49 ++- 12 files changed, 458 insertions(+), 1128 deletions(-) delete mode 100644 app/oled_font_5x7.bin create mode 100644 app/plugin_api.py create mode 100644 docs/HARDWARE_PLUGIN_API.md diff --git a/README.md b/README.md index 6089752..3308988 100644 --- a/README.md +++ b/README.md @@ -1,275 +1,68 @@ # PIGWay Pi Control v3.7.0 -Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。 +独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理。无需购买散热板,无 I2C、Pillow、smbus2 或 OLED 字体依赖。 -v3.7.0 在现有本机硬件监控基础上增加多机状态、日志和配置管理;未登记其他节点时继续作为完整的单机版本运行。 +## 功能 -## 已有核心功能 +- CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。 +- systemd 服务及进程监控;暂停监控与启停服务是不同操作。 +- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。 +- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。 +- 事件日志按日期、严重程度、事件类型和机器查询、排序。 +- 多机状态、机器标识、配置与独立的批量配置同步。 +- 中英文、跟随系统/深色/浅色主题。 +- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。 -- 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 +sudo ./install.sh +# 仅安装文件,不改变服务运行状态: +sudo ./install.sh --no-start ``` -区分: +仅需要 Python 3。安装器不检查 I2C,不安装或启动任何散热板驱动。 +配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。 -- 当前欠压:立即 `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 与 RGB 分别由专属工作线程驱动,监控主循环只更新共享显示状态;OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即唤醒 OLED 线程。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。相邻 OLED 页面需要的 RGB mode 相同时,RGB 线程不写寄存器、不重置动画相位。 风扇与 RGB 共用 MCU 0x0D;RGB 正在告警闪烁时,风扇档位命令排到灭灯阶段写入,并在写入后重新确认 RGB OFF,以 `FAN_LEVEL` 事件记录实际写入。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 - -### 5. RGB 正常状态与告警引擎 - -没有 active alert 时,RGB 默认全灭。`[led] normal_mode` 可改为自定义颜色常亮/闪烁,或 MCU 已确认的流水、呼吸、跑马、彩虹和炫彩效果。常亮与闪烁使用 0..255 的自定义 R/G/B;内置流水/呼吸使用 MCU 固定的红、绿、蓝、黄、紫、青、白七色和慢/中/快三档速度,跑马/彩虹/炫彩由 MCU 自行配色。内置效果只在模式切换时按 speed → color → effect 的实机校准顺序写一次寄存器,不循环刷 I²C。 - -任何 active alert 都会立即抢占正常效果。RGB 告警模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 - -### 6. 结构化事件日志 - -日志继续交给 systemd journal,不额外制造长期 `.log` 文件。 - -只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如: - -```text -level=INFO event=START version=3.7.0 -level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ... -level=WARN event=NETWORK_DOWN ... -level=INFO event=NETWORK_RECOVERED ... -level=WARN event=WATCH_DOWN name=AWESUN ... -level=INFO event=WATCH_RECOVERED name=AWESUN ... -level=WARN event=POWER_LOW raw=0x1 -level=INFO event=POWER_RECOVERED raw=0x0 -level=WARN event=ALERT_ACTIVE alert=TEMP_HIGH ... -level=INFO event=ALERT_RECOVERED ... -``` - -实时查看: - -```bash -journalctl -u pigway-pi-control -f -``` - -只看本次启动: - -```bash -journalctl -u pigway-pi-control -b --no-pager -``` - -搜索某类事件: - -```bash -journalctl -u pigway-pi-control --no-pager | grep 'event=POWER' -``` - -### 7. 诊断命令 - -```bash -sudo /usr/local/sbin/pigway-pi-control --diagnose -``` - -输出 Agent 版本、系统 uptime、配置路径、load、CPU 频率、根文件系统、全部当前/历史 power 位、主网络接口、完整 IP 和 Internet 状态,以及所有 service/process watch 状态。 - -诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。 - -RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜色配合低频亮灭最稳定;每次点亮完整写入 selector/R/G/B,寄存器间隔默认10ms。旧版 `brightness`、呼吸周期和颜色选项可以继续留在用户配置中,但频闪引擎会忽略它们。 - -### 8. 关机收尾 - -收到 systemd SIGTERM / 系统关机时: - -1. 写 STOP 日志 -2. RGB OFF -3. 风扇 OFF -4. OLED 清屏并 Display OFF -5. 关闭 I²C handle - -Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。 - -## v3.7.0 多机监控 - -每台机器在 `[device]` 中设置稳定标识和显示名称;保留 `auto` 时读取本机 hostname。作为 Web 聚合入口的机器可在 `[hosts]` 中登记其他已安装 PIGWay Pi Control 的节点: - -```ini -[device] -identifier = living-room -name = 客厅树莓派 - -[hosts] -study = http://192.168.1.20:6001 -nas-room = http://192.168.1.21:6001 -``` - -未设置 `[hosts]` 时,API 和 Web 保持单机使用方式。聚合入口通过各节点现有 HTTP API 读取状态、journal 和配置;节点不可达时会在机器卡片上显示离线,不影响其他节点。 - -新增接口: - -```text -GET /api/v1/hosts -PUT /api/v1/hosts -GET /api/v1/fleet/status -GET /api/v1/fleet/logs -PUT /api/v1/fleet/config -``` - -运行状态按机器显示独立卡片,包括机器标识、hostname、型号、序列号、板卡版本、操作系统、内核、架构、CPU 核数和 Agent 版本。内存和根分区同时显示百分比、已用量、总量及可用/剩余量;Wi-Fi 以 0–100% 为主值,并保留原始 dBm。OLED 的 Wi-Fi 主值同样使用百分比。 - -事件日志增加机器列、机器筛选和机器排序。系统配置可选择目标机器;批量同步只发送当前已修改的配置项,并逐台返回成功或失败结果。远端 Bearer Token 只保存在当前浏览器的 localStorage 中,不写入聚合机器配置文件。 - -## v3.6.0 API 与 Web - -安装后访问: - -```text -http://树莓派IP:6001/ -``` - -Web 与 JSON API 由独立的 `pigway-pi-control-api.service` 提供。API 服务不访问 I²C;硬件 Agent 每秒原子更新 `/run/pigway-pi-control/status.json`,API 只读取该快照。默认监听地址和端口可在 `[api]` 中修改: - -```ini -[api] -bind = 0.0.0.0 -port = 6001 -log_limit = 200 -``` - -只读接口: - -```text -GET /api/v1/health -GET /api/v1/status -GET /api/v1/logs?limit=200 -GET /api/v1/config -GET /api/v1/services -``` - -日志接口支持 `since`、`until` Unix 时间戳,`severity`、`event`、`search` 筛选,`timestamp` / `severity` / `event` / `message` 排序,以及 `limit` / `offset` 分页。Web 提供今天、昨天、本周、本月和全部快捷范围。日志直接读取 systemd journal 的现有保留数据,不建立重复的 SQLite 日志副本。 - -Web 界面支持中文和 English 切换,并将结构化 journal 事件类型显示为当前界面的语言;journal 内部稳定的事件标识保持不变,便于脚本筛选。外观可选择自动、深色或浅色,自动模式跟随浏览器/操作系统配色。语言和外观偏好只保存在当前浏览器中。 - -配置写入接口: - -```text -PUT /api/v1/config -Authorization: Bearer -Content-Type: application/json - -{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}} -``` - -服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。API 服务不能通过自己的请求停止自身,其他服务由用户自行管理。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。 - -“暂停监控/恢复监控”只改变 PIGWay 是否检查该服务,不会启动或停止 systemd unit;暂停状态保存在 `[service_monitor_disabled]`。只有明确标注为“启动服务/停止服务”的按钮才会调用 systemctl。 - -读取 token: +API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器显示查询方法,不直接打印秘密: ```bash sudo cat /etc/pigway-pi-control-api.token ``` -配置更新仅允许 `[services]`、`[processes]`、`[alerts]`、`[fan]`、`[led]`、`[timing]`、`[dark_mode]`、`[display]` 和 `[api]`。每次写入先生成时间戳备份,再原地更新目标键并重启硬件 Agent;修改 `[api]` 时 API 服务也会自动重启。 +服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。 +Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。 -## 用户配置 +## 接入可选硬件 -正式配置只有一个: +[Yahboom 散热板插件](https://tea.pigway.com/way/pigway-cooling-hat) 是独立项目、独立安装包及 systemd 服务。 +先在目标机器安装并运行插件,再进入 Web → 系统配置 → 硬件插件 → 发现并接入。 +发现不等于接管;只有接入后才发送当前监控状态。无需开放新的网络端口。 -```text -/etc/pigway-pi-control.conf -``` +运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不停止插件本地温控。 -编辑: +默认 Unix socket:`/run/pigway-plugins//api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。当前只实现 Yahboom 驱动;GPIO/PWM、其他 I2C 板和系统风扇驱动需要各自适配,不假装兼容。 + +## 从旧的集成版本迁移 + +监控安装器保留原配置及注释;`[hardware]`、`[fan]`、`[led]`、`[display]` 和 OLED timing 参数不再由监控执行或显示。 +在散热板仓库中可显式迁移这些参数: ```bash -sudo nano /etc/pigway-pi-control.conf -sudo systemctl restart pigway-pi-control +sudo ./install.sh --import-monitor-config /etc/pigway-pi-control.conf ``` -配置文件内已经写明每项用途、单位和修改方式。 +不能让旧集成 Agent 和新插件同时控制硬件。迁移前停止旧 Agent,再安装独立监控和插件;新监控本身不会打开 I2C。字体原样迁入散热板仓库,不重新生成。原主板 PWR/ACT 配置继续作为监控本机设置兼容保留。 -## 安装 / 升级测试分支 +## 运维 ```bash -sudo ./install.sh -``` - -安装器会: - -- 安装必要依赖 -- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用 -- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务 -- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理 -- 安装字体、程序、配置和 systemd unit -- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先 -- 生成只读 root token,安装 API/Web 文件 -- 启动硬件 Agent 与 API/Web 服务 -- 执行版本、OLED、UI、P0健康监控和诊断入口自检 - -## 常用命令 - -```bash -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 systemctl status pigway-pi-control.service pigway-pi-control-api.service +sudo journalctl -u pigway-pi-control.service -f sudo /usr/local/sbin/pigway-pi-control --diagnose ``` -## 版本路线 +`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。 +卸载监控不会停止或删除散热板插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。 -- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换 -- v3.5.0:System Health、显示仲裁与可配置 RGB 告警 -- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面** -- **v3.7.0:多机状态卡片、聚合日志、机器配置选择与批量同步** +静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。 diff --git a/SHA256SUMS b/SHA256SUMS index a0a1902..220f776 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,10 +1,11 @@ -4ac63c19d60ed3bb9d804727fe3d373a338f8203a2730a63876fb1845574822c README.md -72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -e925e9b4720b24f2c802cdb746cfa9e2f5af1de84a7533acb09ab1efcce73a82 app/pigway_pi_control.py -034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py -ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf -f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh -33a681e896cae05b9e9d5f89c5387036c9c1ea48d4db937ae03e5cdb7322705d systemd/pigway-pi-control.service -aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service +ffd1790c882ca5dbcaebdfadc8f24c504159900213eebaa22e61fcdbf3facfe0 README.md +8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh -27454feccb83ad23342fa9d66caed60d568beeed4862852df98a3bc82bf5b6e3 web/index.html +8b9df5b92218ff1c809d570141b2568228a1a4bdcb041eca29fcc6b51c61889c app/pigway_pi_control.py +339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py +3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py +3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf +20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service +35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service +c953710a48728927c9327ba9f94440d0368233e4bd5562d4deb3b1adf0b51851 web/index.html +fa7ab991a7a39cce72b218e407dcefbcede99c8d8eb3d9c161a5296d506c41a9 docs/HARDWARE_PLUGIN_API.md diff --git a/app/oled_font_5x7.bin b/app/oled_font_5x7.bin deleted file mode 100644 index 71c77ba483e757b431dd66814b562a2c351d61bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1275 zcmXw3F>l*O6n>-_`DmmLqY^L;uo9&(s>BRt)IgvNn!0l+gB0whF36^GP|Jyf4E0jD zLuHrZWo$PO=@6g=k|0@({(_=IfiC?IUAlSIV8lQnfPH76-|?RA-rf7&$HxZ&Ac98^ z9tFYSgTufKvcNR_+&9dJ(}$+H$M;Myd;^N}KhFy=Y_a8MMVJ@T#+SN5$qI73k0krnNZ#eL(dDtu!W|@y(CEtAh{|VZ{Pkr;Y3j; z5KbxTqwB!6R*2=qGG&-!l8I9h>&*?@HpH9@ABm8~R+N5d`;=OliTX^Ry3`L_$gp*K zdYXk#ldTX)7e5bthm&;)2zj#Kyw&upuU|i|tYPd43Q9-yG(Bc@l*{TVYPOQ}m|m}b zepKnDX*%j*woxxlk}m-U83TDR-2iEj(OKJW&t|RLtyz|&1M$9Dk}aw{?uhzzqA2Qg zhEe3MF;+iW+o0A2etGq3+QT>Mtz(teeY>#jD39+yyR$l9y;G>|_tqajdTh&_k{ss76G>)S; z8feHFq;PhY;=ocAfDK;&Hk4QuP-xmro8fkjzDqpDr_l8aWv=VXT1~^JhNm?VHp6&8 zmvD-$Ciuka3dPkH8y5-0wPk?0av@lj1>c1@xI`*}%d6G0{y+T%u26;utBD+<{pV=V zhaxo;q!)PQ>JXiUz)S#t@hVamHw3U>yfAZ|K+k2lT$Xbj2WiX>@W5ASZdEPelwZC( zfA`XKV#nM%l8(0A;r6!cMw7_>{mJg;dVpULa!HbMM<5KxVMup|A*JDPhhj3R^v`q5 pOUU6N=~lXSw|qmrf&UwZfl@RLTv|vH)Fi12D<3Uaz()v`@E=5ID%Ai0 diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index e96f9ee..2bbb308 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json,os,platform +import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform from pathlib import Path APP_VERSION="3.7.0" -from smbus2 import SMBus -from PIL import Image +import sys +sys.path.append("/usr/local/share/pigway-pi-control") from shutil import disk_usage +from plugin_api import Publisher,registered,discover -OLED=0x3C; MCU=0x0D; W,H=128,32 CFG="/etc/pigway-pi-control.conf" STATUS_PATH="/run/pigway-pi-control/status.json" running=True @@ -84,23 +84,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 -fan_initialized=False -pending_fan=None -pending_fan_lock=threading.Lock() prev_cpu=None page=0; page_since=time.monotonic() cpu_hi=mem_hi=0 @@ -110,56 +93,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<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<fan_level: fan_level=want - elif want=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem=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: @@ -484,339 +327,56 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): elif rssi >= WIFI_RECOVER: wifi_weak_active=False; wifi_weak_since=None if wifi_weak_active: - display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL") - else: display.clear("WIFI_WEAK") + alert_manager.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL") + else: alert_manager.clear("WIFI_WEAK") else: - wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK") + wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK") if health.get("under_voltage_now"): - display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")}) - else: display.clear("POWER_UNDERVOLT") + alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")}) + else: alert_manager.clear("POWER_UNDERVOLT") if health.get("throttled_now"): - display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") - else: display.clear("THROTTLED") + alert_manager.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") + else: alert_manager.clear("THROTTLED") if health.get("freq_capped_now"): - display.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") - else: display.clear("FREQ_CAPPED") + alert_manager.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER") + else: alert_manager.clear("FREQ_CAPPED") if health.get("soft_temp_limit_now"): - display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING") - else: display.clear("SOFT_TEMP_LIMIT") - if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY") - else: display.clear("ROOT_READONLY") + alert_manager.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING") + else: alert_manager.clear("SOFT_TEMP_LIMIT") + if fs_readonly: alert_manager.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY") + else: alert_manager.clear("ROOT_READONLY") -def rgb_mode_for(request_id,severity): - if severity<=0: return "OFF" - alert_type=request_id.split("|",1)[0] - color=RGB_OBJECT_GROUPS[alert_type] - level="EMERGENCY" if severity>=3 else "CRITICAL" if severity>=2 else "WARNING" - return f"{color}_{level}_FLASH" - -def rgb_flash(mode,on): - global _last_rgb - color=mode.split("_",1)[0] - channels=RGB_GROUP_VALUES[color] - state=(mode,bool(on)) - if _last_rgb==state: return - if not on: - mcu(0x07,0x00) - else: - # Keep the seller's selector -> R -> G -> B protocol contiguous. A - # visible delay between these writes lets the MCU render stale partial - # channel data, which appears as a brief unrelated color or chase. - mcu(0x00,0xff) - mcu(0x01,channels[0]) - mcu(0x02,channels[1]) - 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) - mcu(0x00,0xff) - mcu(0x01,channels[0]) - mcu(0x02,channels[1]) - mcu(0x03,channels[2]) - _last_rgb=state - -def rgb_normal_effect(mode): - global _last_rgb - effect_name=mode.removeprefix("NORMAL_").lower() - state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR) - if _last_rgb==state: return - # Hardware-calibrated order: speed and color are parameters; the final - # effect register write commits and starts the selected MCU animation. - mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(RGB_WRITE_DELAY_MS/1000.0) - mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR]); time.sleep(RGB_WRITE_DELAY_MS/1000.0) - mcu(0x04,RGB_EFFECTS[effect_name]) - _last_rgb=state - -def render_home(cpu,temp,mem,disk): - # 128x32 fixed columns. Both columns are LEFT-aligned. - # Seller 5x7 font has 6px character pitch; right column starts at x=72. - im=Image.new("1",(W,H),0); pix=im.load() - display_ip=ip[:7]+".."+ip[-7:] if ip_label=="IP6" and len(ip)>18 else ip - left=[ - f"CPU {cpu:.1f}%", - f"TMP {temp:.1f}C", - f"DSK {disk:.1f}%", - ip_label+" "+display_ip, - ] - signal=wifi_percent(net_metric) if net_kind=="WIF" else None - right=[ - f"MEM {mem:.1f}%", - f"FAN {FAN_NAME[fan_level]}", - f"WIF {signal}%" if signal is not None else f"{net_kind} {net_metric}", - "", - ] - for row in range(4): - y=row*8 - draw_text_5x7(pix,0,y,left[row]) - if right[row]: - draw_text_5x7(pix,72,y,right[row]) - data=[] - for pg in range(4): - for x in range(W): - v=0 - for bit in range(8): - if pix[x,pg*8+bit]: v|=1<1 and time.monotonic()-self.page_started>=PAGE_INTERVAL: - pos=pages.index(self.current_oled_page_id) - self._select_page_locked(pages[(pos+1)%len(pages)],wake=False) - request=dict(self.requests[self.current_oled_page_id]) - alert_ids=[a["id"] for a in alerts] - if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"]) - else: 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 _oled_worker(self): - previous_error=None - while not self.stop_event.is_set(): - self.oled_wake.clear() - try: - self.refresh() - if previous_error is not None: log("INFO","OLED_RECOVERED") - previous_error=None - except Exception as e: - if repr(e)!=previous_error: log("ERROR","OLED_ERROR",error=repr(e)) - previous_error=repr(e) - self.oled_wake.wait(OLED_REFRESH) - - def _rgb_worker(self): - previous_error=None; applied_mode="OFF"; applied_flash=False - flash_on=None; animation_started=time.monotonic() - while not self.stop_event.is_set(): - try: - with self.lock: wanted_mode=self.current_rgb_mode - wanted_flash=wanted_mode.endswith("_FLASH") - if wanted_mode!=applied_mode: - with bus_lock: - if wanted_mode=="OFF": rgb_off() - elif wanted_mode=="NORMAL_SOLID": rgb_static(NORMAL_COLOR,"normal_solid") - elif wanted_mode=="NORMAL_FLASH": rgb_static(NORMAL_COLOR,"normal_flash_on") - elif wanted_mode.startswith("NORMAL_"): rgb_normal_effect(wanted_mode) - elif wanted_flash: - rgb_off() - rgb_flash(wanted_mode,True) - else: raise ValueError(f"unsupported RGB mode: {wanted_mode}") - log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) - applied_mode=wanted_mode; animation_started=time.monotonic() - flash_on=True if wanted_mode.endswith("_FLASH") else None - applied_flash=applied_mode.endswith("_FLASH") - if applied_flash: - elapsed_ms=(time.monotonic()-animation_started)*1000.0 - if applied_mode=="NORMAL_FLASH": on_ms,off_ms=NORMAL_FLASH - elif "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH - elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH - else: on_ms,off_ms=WARNING_FLASH - on=elapsed_ms%(on_ms+off_ms)= 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) @@ -912,10 +470,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() + update_active_alerts(c,t,m,d,sample_elapsed) if now-last_status >= 1.0: write_status(c,t,m,d); last_status=now diff --git a/app/pigway_pi_control_api.py b/app/pigway_pi_control_api.py index 6841ef6..08f8d14 100644 --- a/app/pigway_pi_control_api.py +++ b/app/pigway_pi_control_api.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +import sys +sys.path.append("/usr/local/share/pigway-pi-control") +from plugin_api import discover,registered,request as plugin_request,socket_path import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket from concurrent.futures import ThreadPoolExecutor,as_completed from http import HTTPStatus @@ -12,7 +15,7 @@ CFG=Path("/etc/pigway-pi-control.conf") STATUS=Path("/run/pigway-pi-control/status.json") TOKEN=Path("/etc/pigway-pi-control-api.token") WEB=Path("/usr/local/share/pigway-pi-control/web/index.html") -ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"} +ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"} NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$") UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$") RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$") @@ -21,6 +24,40 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"} PROTECTED_UNITS={"pigway-pi-control-api.service"} +def plugin_inventory(): + cfg=load_cfg();items=discover(cfg) + for item in items:item["paused"]=cfg.getboolean("plugin_paused",item["id"],fallback=False) + return items + + +def find_plugin(plugin_id): + for item in plugin_inventory(): + if item["id"]==plugin_id:return item + raise ValueError("unknown plugin") + + +def plugin_operation(body): + plugin_id=str(body.get("id","")) + if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id") + item=find_plugin(plugin_id);operation=body.get("operation") + if operation=="link": + if not item["online"]:raise ValueError("plugin is offline") + text=replace_ini_value("plugins",plugin_id,socket_path(item["socket"])) + save_ini_text(text,"plugin-link") + text=replace_ini_value("plugin_paused",plugin_id,"false") + elif operation in {"pause","resume","unlink"}: + if not item["linked"]:raise ValueError("plugin is not linked") + text=replace_ini_value("plugins",plugin_id,None) if operation=="unlink" else replace_ini_value("plugin_paused",plugin_id,"true" if operation=="pause" else "false") + else:raise ValueError("invalid plugin operation") + backup=save_ini_text(text,"plugin") + # Only reload a monitor that is already running. Never start hardware here. + subprocess.run(["systemctl","try-restart","pigway-pi-control.service"],check=True,timeout=15) + if operation in {"pause","unlink"}: + try:plugin_request(item["socket"],"DELETE","/v1/state",{}) + except Exception:pass # An offline plugin expires its lease independently. + return {"ok":True,"backup":backup} + + def machine_identity(): cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip() if not identifier or identifier.lower()=="auto": identifier=socket.gethostname() @@ -92,12 +129,19 @@ 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=="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","hosts"} 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): @@ -124,14 +168,14 @@ def validate_value(section,key,value,existing): def replace_ini_value(section,key,value): text=CFG.read_text() - section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text) + section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text) if not section_match: if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" return text - next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():]) + next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():]) end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end()) chunk=text[section_match.end():end] - key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk) + key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk) if key_match: start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end() return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:] @@ -169,11 +213,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)^\[.+\][ \t]*$",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) @@ -308,14 +352,14 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""): text=CFG.read_text() def change(section,key,value): nonlocal text - section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text) + section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text) if not section_match: if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" return - next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():]) + next_section=re.search(r"(?m)^\[.+\][ \t]*$",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:] @@ -408,6 +452,16 @@ class Handler(BaseHTTPRequestHandler): if parsed.path=="/api/v1/fleet/logs": self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return + if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}: + query=parse_qs(parsed.query);host_id=query.get("host",[""])[0] + plugin_id=query.get("id",[""])[0] + if host_id and host_id!=machine_identity()["id"]: + host=find_host(host_id) + self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return + if parsed.path.endswith("/plugins"): + self.send_json(200,{"plugins":plugin_inventory()});return + item=find_plugin(plugin_id) + self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return if parsed.path=="/api/v1/config": query=parse_qs(parsed.query); host_id=query.get("host",[""])[0] if host_id and host_id!=machine_identity()["id"]: @@ -424,10 +478,20 @@ class Handler(BaseHTTPRequestHandler): def do_PUT(self): path=urlparse(self.path).path - if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return + if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: 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 host_id and host_id!=machine_identity()["id"]: + host=find_host(host_id);token=str(body.get("target_token","")) + if not token:raise ValueError("remote bearer token required") + payload={k:v for k,v in body.items() if k not in {"host","target_token"}} + self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return + 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/hosts": backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url",""))) self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return diff --git a/app/plugin_api.py b/app/plugin_api.py new file mode 100644 index 0000000..317eee3 --- /dev/null +++ b/app/plugin_api.py @@ -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//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() diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index c0c1716..f1ae863 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -1,33 +1,12 @@ -# ============================================================================= -# PIGWay Pi Control v3.7.0 - 用户配置文件 -# ============================================================================= -# -# 正式安装后的配置路径: -# /etc/pigway-pi-control.conf -# -# 编辑: -# sudo nano /etc/pigway-pi-control.conf -# -# 保存后让配置生效: -# sudo systemctl restart pigway-pi-control -# -# 查看运行状态: -# systemctl status pigway-pi-control --no-pager -l -# -# 查看实时日志: -# journalctl -u pigway-pi-control -f -# -# 重要: -# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。 -# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。 -# 3. 数值单位和建议范围见每项上方注释。 -# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。 -# ============================================================================= - +# PIGWay system monitoring. Hardware plugins are separately installed. +# 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。 +[plugins] +# No automatic attachment. Example (prefer Web discovery): +# yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock [device] # 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。 -# 多台机器的 identifier 必须唯一;修改后重启硬件 Agent 和 API 服务。 +# 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。 identifier = auto # Web 页面显示名称。auto 表示使用 identifier。 @@ -51,19 +30,10 @@ log_limit = 200 [timing] # 传感器采样周期,单位:秒。 -# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。 +# CPU / 内存 / 温度 / 磁盘按此周期更新。 # 推荐:1。通常不要低于 1。 sensor_interval = 1 -# OLED 当前页面刷新周期,单位:秒。 -# 这只是刷新当前页面数据,不负责切换页面。 -# 推荐:1。 -oled_refresh_interval = 1 - -# OLED 在主页和当前告警页之间轮播的周期,单位:秒。 -# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。 -page_interval = 10 - # 网络状态检查周期,单位:秒。 # 推荐:5。太短会产生不必要的网络探测。 network_interval = 5 @@ -74,45 +44,10 @@ service_interval = 5 # 本机健康检查周期,单位:秒。 # 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。 -# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。 +# 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。 health_interval = 1 -[fan] -# 风扇温控档位,单位:摄氏度。 -# -# 默认曲线: -# < 50°C OFF -# 50~53°C L1 -# 53~56°C L2 -# 56~59°C L3 -# 59~62°C L4 -# >= 62°C MAX -# -# 如希望更安静,可适当提高阈值;如更重视散热,可降低。 -# 建议保持 start < level2 < level3 < level4 < max。 - -# 低于此温度,风扇完全停止。 -start = 50 - -# L2 起始温度;start 到 level2 之间为 L1。 -level2 = 53 - -# L3 起始温度。 -level3 = 56 - -# L4 起始温度。 -level4 = 59 - -# MAX 全速起始温度。 -max = 62 - -# 降档回差,单位:°C。 -# 防止温度在临界值附近上下波动时风扇频繁升降档。 -# 推荐:1.0~2.0。 -hysteresis = 1.5 - - [alerts] # ---------------- CPU ---------------- # CPU 使用率达到此百分比后开始累计高负载时间。 @@ -152,7 +87,7 @@ disk_critical = 95 # ---------------- Wi-Fi 信号 ---------------- # 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。 # RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。 -# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。 +# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发弱信号告警。 wifi_weak = -75 # 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。 @@ -162,81 +97,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 调暗。 diff --git a/docs/HARDWARE_PLUGIN_API.md b/docs/HARDWARE_PLUGIN_API.md new file mode 100644 index 0000000..d0f9b89 --- /dev/null +++ b/docs/HARDWARE_PLUGIN_API.md @@ -0,0 +1,78 @@ +# 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//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 displays +MONITOR OFFLINE and requests RGB OFF; explicit detach returns to standalone mode. +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. diff --git a/install.sh b/install.sh index 9a3842d..acb20c5 100755 --- a/install.sh +++ b/install.sh @@ -1,197 +1,54 @@ #!/bin/bash set -euo pipefail cd "$(dirname "$0")" - -FORCE_I2C_CONFLICT=0 +NO_START=0 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 ;; + --no-start) NO_START=1 ;; + -h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; 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 web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do + [ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; } 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] 安装程序与配置..." -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 +[ "$(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/web +install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html - install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api - -# Keep the complete existing configuration, including comments and custom sections. -# The merge below adds only missing documented options. if [ -f /etc/pigway-pi-control.conf ]; then cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)" else install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf fi - -# Preserve the existing file while exposing new defaults. Existing values win. -python3 - /etc/pigway-pi-control.conf <<'PY' -import re,sys +# Legacy hardware sections are deliberately preserved for explicit migration, +# but ignored by the monitor and never exposed as monitor configuration. +python3 - <<'PY' from pathlib import Path - -path=Path(sys.argv[1]) -text=path.read_text() -sections={ -"device":(("identifier","auto"),("name","auto")), -"hosts":(), -"led":( - ("normal_mode","off"), - ("normal_r",0),("normal_g",0),("normal_b",0), - ("normal_flash_on_ms",1000),("normal_flash_off_ms",1000), - ("normal_effect_speed",2),("normal_effect_color","green"), - ("write_delay_ms",10), - ("cpu_r",255),("cpu_g",0),("cpu_b",0), - ("power_r",255),("power_g",40),("power_b",0), - ("memory_r",255),("memory_g",0),("memory_b",255), - ("storage_r",255),("storage_g",255),("storage_b",255), - ("network_r",0),("network_g",0),("network_b",255), - ("service_r",0),("service_g",255),("service_b",96), - ("warning_flash_on_ms",1500),("warning_flash_off_ms",1500), - ("critical_flash_on_ms",750),("critical_flash_off_ms",750), - ("emergency_flash_on_ms",300),("emergency_flash_off_ms",300), -), -"api":(("bind","0.0.0.0"),("port",6001),("log_limit",200)), -} -for section,defaults in sections.items(): - match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text) - if match: - next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():]) - end=match.end()+(next_section.start() if next_section else len(text)-match.end()) - chunk=text[match.end():end] - else: - text=text.rstrip()+f"\n\n[{section}]\n" - match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text) - end=len(text); chunk="" - missing=[(key,value) for key,value in defaults - if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)] - if missing: - note="# Added by installer; existing user values are preserved.\n" - if section=="led" and any(key.startswith("normal_") for key,_ in missing): - note+=("# Normal RGB: normal_mode = off/solid/flash/flow/breathe/marquee/rainbow/colorful.\n" - "# normal_r/g/b = 0..255; normal_flash_*_ms = 50..60000; effect speed = 1..3.\n" - "# Effect color = red/green/blue/yellow/purple/cyan/white (flow/breathe only).\n") - block=note+"".join(f"{key} = {value}\n" for key,value in missing) - text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n") -path.write_text(text) +import configparser +p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p) +with p.open('a') as f: + for section,values in {'device':{'identifier':'auto','name':'auto'},'hosts':{},'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] 启用/重启服务..." +install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/ +install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/ systemctl daemon-reload -systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null -systemctl restart pigway-pi-control.service -systemctl restart pigway-pi-control-api.service - -echo "[7/8] 安装后自检..." -FAIL=0 -check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; } -APP=/usr/local/sbin/pigway-pi-control -check "Version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' "$APP" -check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP" -check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$APP" -check "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP" -check "UI CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP" -check "UI IP4/IP6" grep -Fq 'ip_label' "$APP" -check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP" -if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then - printf " %-30s WARN (forced)\n" "I2C owner conflict" -else - no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; } - check "No other I2C owner" no_other_i2c_owner +if [ "$NO_START" -eq 0 ]; then + systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null + systemctl restart pigway-pi-control.service pigway-pi-control-api.service + systemctl is-active --quiet pigway-pi-control.service + systemctl is-active --quiet pigway-pi-control-api.service fi -check "Service running" systemctl is-active --quiet pigway-pi-control.service -check "API/Web service running" systemctl is-active --quiet pigway-pi-control-api.service -check "API/Web version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' /usr/local/sbin/pigway-pi-control-api -check "Web UI installed" test -s /usr/local/share/pigway-pi-control/web/index.html -check "P0 health monitor" grep -Fq 'throttled_state' "$APP" -check "Diagnostic command" grep -Fq -- '--diagnose' "$APP" -if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi -echo "[8/8] 状态..." - - -systemctl --no-pager --full status pigway-pi-control.service || true -systemctl --no-pager --full status pigway-pi-control-api.service || true -echo -echo "完成。程序:/usr/local/sbin/pigway-pi-control" -echo "配置:/etc/pigway-pi-control.conf" -echo "Web/API:http://$(hostname -I | awk '{print $1}'):$(awk -F= '/^port[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}' /etc/pigway-pi-control.conf)" -echo "配置写入 token:sudo cat /etc/pigway-pi-control-api.token" +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' diff --git a/systemd/pigway-pi-control-api.service b/systemd/pigway-pi-control-api.service index 4370f6e..4379837 100644 --- a/systemd/pigway-pi-control-api.service +++ b/systemd/pigway-pi-control-api.service @@ -1,7 +1,7 @@ [Unit] Description=PIGWay Pi Control API and Web After=network.target pigway-pi-control.service -Wants=network.target pigway-pi-control.service +Wants=network.target [Service] Type=simple diff --git a/systemd/pigway-pi-control.service b/systemd/pigway-pi-control.service index 2c7654e..6117fd1 100644 --- a/systemd/pigway-pi-control.service +++ b/systemd/pigway-pi-control.service @@ -1,5 +1,5 @@ [Unit] -Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor +Description=PIGWay system monitor After=local-fs.target systemd-modules-load.service Before=multi-user.target diff --git a/web/index.html b/web/index.html index 62fbfc2..43c8c88 100644 --- a/web/index.html +++ b/web/index.html @@ -16,14 +16,14 @@
正在读取主机状态…
- +