Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f505f3c23b | ||
|
|
b7a77a4661 | ||
|
|
9824c54d6b | ||
|
|
974121de99 | ||
|
|
5d744824a8 | ||
|
|
b08721fd15 | ||
|
|
cd9bdd5970 | ||
|
|
08c00aa370 | ||
|
|
13f6155096 | ||
|
|
97598a48ad | ||
|
|
01c07658c0 | ||
|
|
4ba4d461db | ||
|
|
45c7b8c168 | ||
|
|
b39c7e5279 | ||
|
|
2e5562fb3e | ||
|
|
03fd6d1cd8 | ||
|
|
8cb71cbe9f | ||
|
|
73907320c8 | ||
|
|
ad25c08eb1 | ||
|
|
5d541d5ef6 | ||
|
|
a8ca071b56 | ||
|
|
419d27b570 | ||
|
|
73a60b4866 | ||
|
|
576006b2fd |
@@ -1,8 +1,8 @@
|
|||||||
# Pigway Pi Control v3.6.0
|
# PIGWay Pi Control v3.7.0
|
||||||
|
|
||||||
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
||||||
|
|
||||||
v3.6.0 在本地硬件监控、显示仲裁和可配置 RGB 告警基础上,增加轻量状态/日志/配置 API 与同源 Web 管理界面。
|
v3.7.0 在现有本机硬件监控基础上增加多机状态、日志和配置管理;未登记其他节点时继续作为完整的单机版本运行。
|
||||||
|
|
||||||
## 已有核心功能
|
## 已有核心功能
|
||||||
|
|
||||||
@@ -71,9 +71,11 @@ CHECK STORAGE
|
|||||||
|
|
||||||
每个当前故障都是 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 记录。
|
每个当前故障都是 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 告警引擎
|
### 5. RGB 正常状态与告警引擎
|
||||||
|
|
||||||
RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭;没有 active alert 时 OFF。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
|
没有 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. 结构化事件日志
|
### 6. 结构化事件日志
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高
|
|||||||
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
level=INFO event=START version=3.6.0
|
level=INFO event=START version=3.7.0
|
||||||
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
|
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
|
||||||
level=WARN event=NETWORK_DOWN ...
|
level=WARN event=NETWORK_DOWN ...
|
||||||
level=INFO event=NETWORK_RECOVERED ...
|
level=INFO event=NETWORK_RECOVERED ...
|
||||||
@@ -136,6 +138,36 @@ RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜
|
|||||||
|
|
||||||
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
|
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
|
## v3.6.0 API 与 Web
|
||||||
|
|
||||||
安装后访问:
|
安装后访问:
|
||||||
@@ -160,8 +192,13 @@ GET /api/v1/health
|
|||||||
GET /api/v1/status
|
GET /api/v1/status
|
||||||
GET /api/v1/logs?limit=200
|
GET /api/v1/logs?limit=200
|
||||||
GET /api/v1/config
|
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
|
```text
|
||||||
@@ -172,6 +209,10 @@ Content-Type: application/json
|
|||||||
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
|
{"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:
|
读取 token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -208,6 +249,7 @@ sudo ./install.sh
|
|||||||
- 安装必要依赖
|
- 安装必要依赖
|
||||||
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
|
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
|
||||||
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
|
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
|
||||||
|
- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理
|
||||||
- 安装字体、程序、配置和 systemd unit
|
- 安装字体、程序、配置和 systemd unit
|
||||||
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
|
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
|
||||||
- 生成只读 root token,安装 API/Web 文件
|
- 生成只读 root token,安装 API/Web 文件
|
||||||
@@ -230,3 +272,4 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose
|
|||||||
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
||||||
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
||||||
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
||||||
|
- **v3.7.0:多机状态卡片、聚合日志、机器配置选择与批量同步**
|
||||||
|
|||||||
+8
-8
@@ -1,10 +1,10 @@
|
|||||||
0960943a15ae23cd067f1169df6640723500107e8c41af070b0c9b889f29914e README.md
|
96dfd20c3ad16ca62a46d66eb5ffb82b1f7f987e66c4dae5310609b1989b7dae README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||||
29f59e41052e1e251bf2f38274cdd321fc195602220b61ac8890202128d7c50a app/pigway_pi_control.py
|
d0fa68951097a90eb6f51b36e56dc935d8fd7632c3de0ad6bff0b448960a7c03 app/pigway_pi_control.py
|
||||||
f32be5b63fe2be51e39c106c232ae3cc14dab7c0c3719e2609b9e93a7802b6c3 app/pigway_pi_control_api.py
|
034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py
|
||||||
9f3b2be557b3cd93c309f8a1cadce7d34b23ca06c1ff69231d934e8180fefb48 config/pigway-pi-control.conf
|
ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf
|
||||||
15b431e62b00886b6561597db059f01f116b5173bdae7a5817f617f7bc73a3ec install.sh
|
f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh
|
||||||
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
||||||
5d7562c496de597f61b62af48f8f6350ad270e1ebf725426d9f1e5b5d698055e systemd/pigway-pi-control-api.service
|
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
||||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
4b4066d104202f664f5df07c97d4e256dde32b5f4426e6a955a3ecef5e08df60 web/index.html
|
3a40109f11bea2d44e9d61bed4a90499a5047c4fc534f97aaa90becbf9e57017 web/index.html
|
||||||
|
|||||||
+116
-11
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json
|
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json,os,platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
APP_VERSION="3.6.0"
|
APP_VERSION="3.7.0"
|
||||||
from smbus2 import SMBus
|
from smbus2 import SMBus
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from shutil import disk_usage
|
from shutil import disk_usage
|
||||||
@@ -29,6 +29,41 @@ def load_average():
|
|||||||
x=Path("/proc/loadavg").read_text().split();return (float(x[0]),float(x[1]),float(x[2]))
|
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)
|
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
|
||||||
|
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():
|
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"):
|
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
|
try:return int(Path(q).read_text().strip())/1000.0
|
||||||
@@ -210,7 +245,9 @@ def load_cfg():
|
|||||||
if c.has_section("services"):
|
if c.has_section("services"):
|
||||||
for label,target in c["services"].items():
|
for label,target in c["services"].items():
|
||||||
target=target.strip()
|
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"):
|
if c.has_section("processes"):
|
||||||
for label,target in c["processes"].items():
|
for label,target in c["processes"].items():
|
||||||
target=target.strip()
|
target=target.strip()
|
||||||
@@ -222,6 +259,12 @@ def cf(section,key,default,cast=float):
|
|||||||
try: return cast(cfg.get(section,key))
|
try: return cast(cfg.get(section,key))
|
||||||
except: return default
|
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)
|
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||||
@@ -253,6 +296,20 @@ 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)))
|
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)))
|
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)))
|
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):
|
def rgb_cfg(name,default):
|
||||||
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
|
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
|
||||||
@@ -290,6 +347,7 @@ def check_watch(w):
|
|||||||
def diagnose():
|
def diagnose():
|
||||||
print("Agent:")
|
print("Agent:")
|
||||||
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||||
|
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
|
||||||
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||||
print("System:")
|
print("System:")
|
||||||
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
||||||
@@ -302,7 +360,7 @@ def diagnose():
|
|||||||
if "error" in power: print("error="+power["error"])
|
if "error" in power: print("error="+power["error"])
|
||||||
iface,kind,metric,label,address=primary_network()
|
iface,kind,metric,label,address=primary_network()
|
||||||
print("Network:")
|
print("Network:")
|
||||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} ip_label={label} IP={address} internet={internet()}")
|
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||||
print("I2C:")
|
print("I2C:")
|
||||||
# SMBus receive-byte performs a read only; no register selection or display commands.
|
# SMBus receive-byte performs a read only; no register selection or display commands.
|
||||||
try:
|
try:
|
||||||
@@ -441,6 +499,29 @@ def rgb_flash(mode,on):
|
|||||||
mcu(0x03,channels[2])
|
mcu(0x03,channels[2])
|
||||||
_last_rgb=state
|
_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):
|
def render_home(cpu,temp,mem,disk):
|
||||||
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
||||||
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
||||||
@@ -452,10 +533,11 @@ def render_home(cpu,temp,mem,disk):
|
|||||||
f"DSK {disk:.1f}%",
|
f"DSK {disk:.1f}%",
|
||||||
ip_label+" "+display_ip,
|
ip_label+" "+display_ip,
|
||||||
]
|
]
|
||||||
|
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||||
right=[
|
right=[
|
||||||
f"MEM {mem:.1f}%",
|
f"MEM {mem:.1f}%",
|
||||||
f"FAN {FAN_NAME[fan_level]}",
|
f"FAN {FAN_NAME[fan_level]}",
|
||||||
f"{net_kind} {net_metric}",
|
f"WIF {signal}%" if signal is not None else f"{net_kind} {net_metric}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for row in range(4):
|
for row in range(4):
|
||||||
@@ -485,7 +567,7 @@ class DisplayController:
|
|||||||
now=time.monotonic()
|
now=time.monotonic()
|
||||||
self.lock=threading.RLock()
|
self.lock=threading.RLock()
|
||||||
self.requests={"NORMAL_HOME":{
|
self.requests={"NORMAL_HOME":{
|
||||||
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":"OFF",
|
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":NORMAL_RGB_MODE,
|
||||||
"title":"","l2":"","l3":"","l4":"","active":True,
|
"title":"","l2":"","l3":"","l4":"","active":True,
|
||||||
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
|
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
|
||||||
}}
|
}}
|
||||||
@@ -629,7 +711,8 @@ class DisplayController:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _rgb_worker(self):
|
def _rgb_worker(self):
|
||||||
previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic()
|
previous_error=None; applied_mode="OFF"; applied_flash=False
|
||||||
|
flash_on=None; animation_started=time.monotonic()
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
with self.lock: wanted_mode=self.current_rgb_mode
|
with self.lock: wanted_mode=self.current_rgb_mode
|
||||||
@@ -637,18 +720,30 @@ class DisplayController:
|
|||||||
if wanted_mode!=applied_mode:
|
if wanted_mode!=applied_mode:
|
||||||
with bus_lock:
|
with bus_lock:
|
||||||
if wanted_mode=="OFF": rgb_off()
|
if wanted_mode=="OFF": rgb_off()
|
||||||
elif wanted_flash: rgb_flash(wanted_mode,True)
|
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}")
|
else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
|
||||||
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
|
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
|
||||||
applied_mode=wanted_mode; animation_started=time.monotonic()
|
applied_mode=wanted_mode; animation_started=time.monotonic()
|
||||||
|
flash_on=True if wanted_mode.endswith("_FLASH") else None
|
||||||
applied_flash=applied_mode.endswith("_FLASH")
|
applied_flash=applied_mode.endswith("_FLASH")
|
||||||
if applied_flash:
|
if applied_flash:
|
||||||
elapsed_ms=(time.monotonic()-animation_started)*1000.0
|
elapsed_ms=(time.monotonic()-animation_started)*1000.0
|
||||||
if "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH
|
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
|
elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
|
||||||
else: on_ms,off_ms=WARNING_FLASH
|
else: on_ms,off_ms=WARNING_FLASH
|
||||||
on=elapsed_ms%(on_ms+off_ms)<on_ms
|
on=elapsed_ms%(on_ms+off_ms)<on_ms
|
||||||
with bus_lock: rgb_flash(applied_mode,on)
|
if on!=flash_on:
|
||||||
|
with bus_lock:
|
||||||
|
if applied_mode=="NORMAL_FLASH":
|
||||||
|
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
|
||||||
|
else: rgb_flash(applied_mode,on)
|
||||||
|
flash_on=on
|
||||||
if previous_error is not None: log("INFO","RGB_RECOVERED")
|
if previous_error is not None: log("INFO","RGB_RECOVERED")
|
||||||
previous_error=None
|
previous_error=None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -677,14 +772,24 @@ def cleanup():
|
|||||||
|
|
||||||
def write_status(cpu,temp,mem,disk):
|
def write_status(cpu,temp,mem,disk):
|
||||||
display_state=display.status_snapshot()
|
display_state=display.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={
|
state={
|
||||||
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
|
"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),
|
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
|
||||||
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
||||||
"memory_percent":round(mem,1),"disk_percent":round(disk,1),
|
"memory_percent":round(memory_calculated if memory_total else mem,1),
|
||||||
|
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
|
||||||
|
"memory_available_bytes":memory_available,
|
||||||
|
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
||||||
|
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||||
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
||||||
"power":health,
|
"power":health,
|
||||||
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
"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)},
|
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
|
||||||
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||||
for w,ok in zip(watches,watch_state)],
|
for w,ok in zip(watches,watch_state)],
|
||||||
|
|||||||
+337
-20
@@ -1,19 +1,86 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time
|
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||||
|
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs,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")
|
CFG=Path("/etc/pigway-pi-control.conf")
|
||||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
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","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
||||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||||
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service)_[rgb]$")
|
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||||
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency)_flash_(on|off)_ms$")
|
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||||
|
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
|
||||||
|
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
|
||||||
|
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
||||||
|
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
||||||
|
|
||||||
|
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 normalize_url(value):
|
||||||
|
parsed=urlparse(str(value).strip())
|
||||||
|
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise ValueError("host URL must be an http(s) URL without credentials")
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||||
|
|
||||||
|
def host_registry():
|
||||||
|
local=machine_identity(); result=[local]
|
||||||
|
cfg=load_cfg()
|
||||||
|
if cfg.has_section("hosts"):
|
||||||
|
for host_id,url in cfg.items("hosts"):
|
||||||
|
try: base=normalize_url(url)
|
||||||
|
except ValueError: continue
|
||||||
|
if host_id==local["id"]: continue
|
||||||
|
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def find_host(host_id):
|
||||||
|
for host in host_registry():
|
||||||
|
if host["id"]==host_id:return host
|
||||||
|
raise ValueError("unknown host")
|
||||||
|
|
||||||
|
def remote_json(base,path,method="GET",payload=None,token=""):
|
||||||
|
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
|
||||||
|
headers={"Accept":"application/json"}
|
||||||
|
if data is not None: headers["Content-Type"]="application/json"
|
||||||
|
if token: headers["Authorization"]=f"Bearer {token}"
|
||||||
|
request=Request(base+path,data=data,headers=headers,method=method)
|
||||||
|
with urlopen(request,timeout=5) as response:
|
||||||
|
return json.loads(response.read())
|
||||||
|
|
||||||
|
def fleet_status():
|
||||||
|
hosts=host_registry(); results=[]
|
||||||
|
try:
|
||||||
|
status=json.loads(STATUS.read_text())
|
||||||
|
status.setdefault("machine",machine_identity())
|
||||||
|
results.append({"host":hosts[0],"online":True,"status":status})
|
||||||
|
except Exception as exc:
|
||||||
|
results.append({"host":hosts[0],"online":False,"error":str(exc)})
|
||||||
|
def fetch(host):
|
||||||
|
try:
|
||||||
|
status=remote_json(host["url"],"/api/v1/status")
|
||||||
|
machine=status.get("machine",{})
|
||||||
|
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
|
||||||
|
return {"host":merged,"online":True,"status":status}
|
||||||
|
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
|
||||||
|
peers=hosts[1:]
|
||||||
|
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||||
|
futures=[pool.submit(fetch,h) for h in peers]
|
||||||
|
for future in as_completed(futures): results.append(future.result())
|
||||||
|
order={h["id"]:i for i,h in enumerate(hosts)}
|
||||||
|
results.sort(key=lambda x:order.get(x["host"]["id"],999))
|
||||||
|
return {"hosts":results,"timestamp":int(time.time())}
|
||||||
|
|
||||||
def load_cfg():
|
def load_cfg():
|
||||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||||
@@ -28,7 +95,7 @@ def config_json():
|
|||||||
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||||
|
|
||||||
def validate_value(section,key,value,existing):
|
def validate_value(section,key,value,existing):
|
||||||
if section not in {"services","processes"} and key not in existing.get(section,set()):
|
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||||
raise ValueError(f"unsupported option: {section}.{key}")
|
raise ValueError(f"unsupported option: {section}.{key}")
|
||||||
if RGB_KEY_RE.fullmatch(key):
|
if RGB_KEY_RE.fullmatch(key):
|
||||||
number=int(value)
|
number=int(value)
|
||||||
@@ -36,6 +103,12 @@ def validate_value(section,key,value,existing):
|
|||||||
elif FLASH_KEY_RE.fullmatch(key):
|
elif FLASH_KEY_RE.fullmatch(key):
|
||||||
number=int(value)
|
number=int(value)
|
||||||
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
|
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
|
||||||
|
elif section=="led" and key=="normal_mode":
|
||||||
|
if value.lower() not in NORMAL_MODES: raise ValueError("normal_mode must be off, solid, flash, flow, breathe, marquee, rainbow or colorful")
|
||||||
|
elif section=="led" and key=="normal_effect_speed":
|
||||||
|
if int(value) not in (1,2,3): raise ValueError("normal_effect_speed must be 1, 2 or 3")
|
||||||
|
elif section=="led" and key=="normal_effect_color":
|
||||||
|
if value.lower() not in EFFECT_COLORS: raise ValueError("normal_effect_color must be red, green, blue, yellow, purple, cyan or white")
|
||||||
elif section=="api" and key=="port":
|
elif section=="api" and key=="port":
|
||||||
number=int(value)
|
number=int(value)
|
||||||
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
|
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
|
||||||
@@ -44,6 +117,42 @@ def validate_value(section,key,value,existing):
|
|||||||
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
|
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
|
||||||
elif section=="api" and key=="bind":
|
elif section=="api" and key=="bind":
|
||||||
ipaddress.ip_address(value)
|
ipaddress.ip_address(value)
|
||||||
|
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
|
||||||
|
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
|
||||||
|
elif section=="hosts":
|
||||||
|
normalize_url(value)
|
||||||
|
|
||||||
|
def replace_ini_value(section,key,value):
|
||||||
|
text=CFG.read_text()
|
||||||
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||||
|
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():])
|
||||||
|
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)
|
||||||
|
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 edit_host(operation,host_id,url=""):
|
||||||
|
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
|
||||||
|
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
|
||||||
|
local=machine_identity()["id"]
|
||||||
|
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
|
||||||
|
if operation=="save": url=normalize_url(url)
|
||||||
|
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
|
||||||
|
return save_ini_text(text,"hosts")
|
||||||
|
|
||||||
def update_ini(updates):
|
def update_ini(updates):
|
||||||
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||||
@@ -81,21 +190,165 @@ def update_ini(updates):
|
|||||||
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||||
return str(backup)
|
return str(backup)
|
||||||
|
|
||||||
def journal(limit):
|
def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0):
|
||||||
result=subprocess.run(["journalctl","-u","pigway-pi-control.service","-n",str(limit),
|
command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
|
||||||
"--no-pager","-o","json"],capture_output=True,text=True,timeout=5)
|
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")
|
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
|
||||||
rows=[]
|
rows=[]
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
try:
|
try:
|
||||||
item=json.loads(line)
|
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
|
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 fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
|
||||||
|
sort="timestamp",order="desc",limit=100,offset=0):
|
||||||
|
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
|
||||||
|
def decorate(payload,host):
|
||||||
|
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
|
||||||
|
host_id=machine_info.get("id") or host["id"]
|
||||||
|
host_name=machine_info.get("name") or host["name"]
|
||||||
|
decorated=[]
|
||||||
|
for row in payload.get("logs",[]):
|
||||||
|
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
|
||||||
|
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
|
||||||
|
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
|
||||||
|
local_payload["machine"]=machine_identity()
|
||||||
|
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
|
||||||
|
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
|
||||||
|
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
|
||||||
|
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
|
||||||
|
if value not in (None,""): query[key]=value
|
||||||
|
def fetch(host):
|
||||||
|
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
|
||||||
|
try:
|
||||||
|
status=remote_json(host["url"],"/api/v1/status")
|
||||||
|
payload["machine"]=status.get("machine",{})
|
||||||
|
except Exception: pass
|
||||||
|
return decorate(payload,host)
|
||||||
|
peers=hosts[1:]
|
||||||
|
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||||
|
futures=[pool.submit(fetch,h) for h in peers]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
try:
|
||||||
|
remote_rows,remote_events,remote_machine=future.result()
|
||||||
|
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
|
||||||
|
except Exception: pass
|
||||||
|
if machine: rows=[row for row in rows if row["machine_id"]==machine]
|
||||||
|
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
|
||||||
|
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
|
||||||
|
"event":lambda x:x["event"],"message":lambda x:x["message"],
|
||||||
|
"machine":lambda x:(x["machine_name"],x["machine_id"])}
|
||||||
|
rows.sort(key=keys[sort],reverse=order=="desc")
|
||||||
|
total=len(rows)
|
||||||
|
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||||
|
"event_types":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
|
||||||
|
"retention":"systemd-journal"}
|
||||||
|
|
||||||
|
def service_inventory():
|
||||||
|
cfg=load_cfg()
|
||||||
|
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
||||||
|
notes=dict(cfg.items("service_notes")) if cfg.has_section("service_notes") else {}
|
||||||
|
files=subprocess.run(["systemctl","list-unit-files","--type=service","--no-legend","--no-pager"],
|
||||||
|
capture_output=True,text=True,timeout=10,check=True)
|
||||||
|
units={}
|
||||||
|
for line in files.stdout.splitlines():
|
||||||
|
parts=line.split()
|
||||||
|
if len(parts)>=2 and UNIT_RE.fullmatch(parts[0]):
|
||||||
|
units[parts[0]]={"unit":parts[0],"enabled":parts[1],"active":"inactive","description":""}
|
||||||
|
states=subprocess.run(["systemctl","list-units","--all","--type=service","--no-legend","--no-pager","--plain"],
|
||||||
|
capture_output=True,text=True,timeout=10,check=True)
|
||||||
|
for line in states.stdout.splitlines():
|
||||||
|
parts=line.split(None,4)
|
||||||
|
if len(parts)>=4 and UNIT_RE.fullmatch(parts[0]):
|
||||||
|
item=units.setdefault(parts[0],{"unit":parts[0],"enabled":"unknown"})
|
||||||
|
item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""})
|
||||||
|
for name,target in monitored.values():
|
||||||
|
item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""})
|
||||||
|
paused=cfg.getboolean("service_monitor_disabled",name,fallback=False)
|
||||||
|
item.update({"monitored":True,"paused":paused,"monitor_name":name.upper(),"note":notes.get(name,"")})
|
||||||
|
for item in units.values():
|
||||||
|
item.setdefault("monitored",False); item.setdefault("paused",False)
|
||||||
|
item.setdefault("monitor_name",""); item.setdefault("note","")
|
||||||
|
item["protected"]=item["unit"] in PROTECTED_UNITS
|
||||||
|
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
|
||||||
|
|
||||||
|
def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||||
|
if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation")
|
||||||
|
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
|
||||||
|
if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name")
|
||||||
|
if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note")
|
||||||
|
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name")
|
||||||
|
cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None)
|
||||||
|
previous=previous_name or name
|
||||||
|
was_paused=cfg.getboolean("service_monitor_disabled",previous,fallback=False)
|
||||||
|
text=CFG.read_text()
|
||||||
|
def change(section,key,value):
|
||||||
|
nonlocal text
|
||||||
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",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():])
|
||||||
|
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)
|
||||||
|
if key_match:
|
||||||
|
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||||
|
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||||
|
elif value is not None:
|
||||||
|
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||||
|
if operation=="delete":
|
||||||
|
if old_target is None: raise ValueError("monitor not found")
|
||||||
|
change("services",name,None); change("service_notes",name,None); change("service_monitor_disabled",name,None)
|
||||||
|
elif operation in {"pause","resume"}:
|
||||||
|
if old_target is None: raise ValueError("monitor not found")
|
||||||
|
change("service_monitor_disabled",name,"true" if operation=="pause" else None)
|
||||||
|
else:
|
||||||
|
if previous_name and previous_name.lower()!=name.lower():
|
||||||
|
change("services",previous_name,None); change("service_notes",previous_name,None)
|
||||||
|
change("service_monitor_disabled",previous_name,None)
|
||||||
|
change("services",name,target); change("service_notes",name,note or None)
|
||||||
|
if was_paused: change("service_monitor_disabled",name,"true")
|
||||||
|
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)
|
||||||
|
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||||
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
|
return str(backup)
|
||||||
|
|
||||||
|
def control_service(unit,action):
|
||||||
|
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name")
|
||||||
|
if action not in {"start","stop"}: raise ValueError("action must be start or stop")
|
||||||
|
if unit in PROTECTED_UNITS and action=="stop": raise ValueError("protected remote-management service cannot be stopped here")
|
||||||
|
result=subprocess.run(["systemctl",action,unit],capture_output=True,text=True,timeout=20)
|
||||||
|
if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed")
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version="PigwayAPI/3.6"
|
server_version="PIGWayAPI/3.7"
|
||||||
|
|
||||||
def log_message(self,fmt,*args):
|
def log_message(self,fmt,*args):
|
||||||
return
|
return
|
||||||
@@ -130,22 +383,86 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
||||||
if parsed.path=="/api/v1/health":
|
if parsed.path=="/api/v1/health":
|
||||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
||||||
|
if parsed.path=="/api/v1/hosts":
|
||||||
|
self.send_json(200,{"hosts":host_registry()}); return
|
||||||
|
if parsed.path=="/api/v1/fleet/status":
|
||||||
|
self.send_json(200,fleet_status()); return
|
||||||
if parsed.path=="/api/v1/status":
|
if parsed.path=="/api/v1/status":
|
||||||
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||||
self.send_json(200,json.loads(STATUS.read_text())); return
|
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||||
if parsed.path=="/api/v1/logs":
|
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}:
|
||||||
limit=min(max(int(parse_qs(parsed.query).get("limit",[api_setting("log_limit",200,int)])[0]),1),1000)
|
query=parse_qs(parsed.query)
|
||||||
self.send_json(200,{"logs":journal(limit)}); return
|
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 parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
|
||||||
|
if sort not in allowed_sort: raise ValueError("invalid log sort")
|
||||||
|
if order not in {"asc","desc"}: raise ValueError("invalid log order")
|
||||||
|
severity=value("severity").upper(); event=value("event"); search=value("search")
|
||||||
|
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
|
||||||
|
if parsed.path=="/api/v1/fleet/logs":
|
||||||
|
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
|
||||||
|
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||||
if parsed.path=="/api/v1/config":
|
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]
|
||||||
|
if host_id and host_id!=machine_identity()["id"]:
|
||||||
|
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
|
||||||
|
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
|
||||||
|
if parsed.path=="/api/v1/services":
|
||||||
|
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||||
|
if host_id and host_id!=machine_identity()["id"]:
|
||||||
|
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
|
||||||
|
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
|
||||||
self.send_json(404,{"error":"not found"})
|
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)})
|
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||||
|
|
||||||
def do_PUT(self):
|
def do_PUT(self):
|
||||||
if urlparse(self.path).path!="/api/v1/config": self.send_json(404,{"error":"not found"}); return
|
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 not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||||
try:
|
try:
|
||||||
body=self.read_json(); updates=body.get("updates")
|
body=self.read_json()
|
||||||
|
if path=="/api/v1/hosts":
|
||||||
|
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
|
||||||
|
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
|
||||||
|
if path=="/api/v1/fleet/config":
|
||||||
|
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{})
|
||||||
|
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
|
||||||
|
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
|
||||||
|
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
|
||||||
|
for host_id in dict.fromkeys(str(x) for x in targets):
|
||||||
|
try:
|
||||||
|
if host_id==local_id:
|
||||||
|
backup=update_ini(updates)
|
||||||
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
|
restart_local_api="api" in updates
|
||||||
|
results.append({"id":host_id,"ok":True,"backup":backup})
|
||||||
|
else:
|
||||||
|
host=find_host(host_id); token=str(tokens.get(host_id,""))
|
||||||
|
if not token: raise ValueError("remote bearer token required")
|
||||||
|
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
|
||||||
|
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
|
||||||
|
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
|
||||||
|
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
|
||||||
|
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
|
||||||
|
return
|
||||||
|
if path=="/api/v1/services/control":
|
||||||
|
control_service(str(body.get("unit","")),str(body.get("action","")))
|
||||||
|
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
||||||
|
self.send_json(200,{"ok":True}); return
|
||||||
|
if path=="/api/v1/services":
|
||||||
|
backup=edit_service_monitor(str(body.get("operation","")),str(body.get("name","")),
|
||||||
|
str(body.get("target","")),str(body.get("note","")),
|
||||||
|
str(body.get("previous_name","")))
|
||||||
|
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
|
||||||
|
self.send_json(200,{"ok":True,"backup":backup}); return
|
||||||
|
updates=body.get("updates")
|
||||||
backup=update_ini(updates)
|
backup=update_ini(updates)
|
||||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
restart_api="api" in updates
|
restart_api="api" in updates
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Pigway Pi Control v3.6.0 - 用户配置文件
|
# PIGWay Pi Control v3.7.0 - 用户配置文件
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# 正式安装后的配置路径:
|
# 正式安装后的配置路径:
|
||||||
@@ -25,6 +25,22 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
[device]
|
||||||
|
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
|
||||||
|
# 多台机器的 identifier 必须唯一;修改后重启硬件 Agent 和 API 服务。
|
||||||
|
identifier = auto
|
||||||
|
|
||||||
|
# Web 页面显示名称。auto 表示使用 identifier。
|
||||||
|
name = auto
|
||||||
|
|
||||||
|
|
||||||
|
[hosts]
|
||||||
|
# 在作为聚合入口的机器上登记其他节点,每行格式:
|
||||||
|
# 唯一标识 = http://节点IP:6001
|
||||||
|
# 示例:
|
||||||
|
# living-room = http://192.168.1.20:6001
|
||||||
|
|
||||||
|
|
||||||
[api]
|
[api]
|
||||||
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
||||||
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||||
@@ -147,6 +163,34 @@ wifi_weak_seconds = 30
|
|||||||
|
|
||||||
|
|
||||||
[led]
|
[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 同步显示
|
# DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示
|
||||||
# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级:
|
# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级:
|
||||||
# CPU/频率/温度/散热 = 红色
|
# CPU/频率/温度/散热 = 红色
|
||||||
@@ -183,8 +227,6 @@ critical_flash_on_ms = 750
|
|||||||
critical_flash_off_ms = 750
|
critical_flash_off_ms = 750
|
||||||
emergency_flash_on_ms = 300
|
emergency_flash_on_ms = 300
|
||||||
emergency_flash_off_ms = 300
|
emergency_flash_off_ms = 300
|
||||||
# NORMAL_HOME 为 owner 时 RGB OFF。
|
|
||||||
#
|
|
||||||
# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
|
# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
|
||||||
# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
|
# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
|
||||||
write_delay_ms = 10
|
write_delay_ms = 10
|
||||||
@@ -199,7 +241,7 @@ write_delay_ms = 10
|
|||||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||||
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
||||||
#
|
#
|
||||||
# off = Pigway Pi Control 启动时关闭该灯。
|
# off = PIGWay Pi Control 启动时关闭该灯。
|
||||||
# 若不希望程序干预,可改为 on。
|
# 若不希望程序干预,可改为 on。
|
||||||
#
|
#
|
||||||
# 注意:
|
# 注意:
|
||||||
@@ -245,6 +287,12 @@ AWESUN = runawesun.service
|
|||||||
# NGINX = nginx.service
|
# NGINX = nginx.service
|
||||||
|
|
||||||
|
|
||||||
|
[service_monitor_disabled]
|
||||||
|
# Web 中“暂停监控”的状态保存在这里。
|
||||||
|
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
|
||||||
|
# 通常由 Web 自动维护,无需手工编辑。
|
||||||
|
|
||||||
|
|
||||||
[processes]
|
[processes]
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 普通进程监控
|
# 普通进程监控
|
||||||
|
|||||||
+44
-7
@@ -2,6 +2,19 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
FORCE_I2C_CONFLICT=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 ;;
|
||||||
|
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
|
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
|
if [ ! -f "$required" ]; then
|
||||||
echo "ERROR: required file missing: $required" >&2
|
echo "ERROR: required file missing: $required" >&2
|
||||||
@@ -30,12 +43,21 @@ i2c_conflict_pids() {
|
|||||||
|
|
||||||
I2C_CONFLICTS="$(i2c_conflict_pids)"
|
I2C_CONFLICTS="$(i2c_conflict_pids)"
|
||||||
if [ -n "$I2C_CONFLICTS" ]; then
|
if [ -n "$I2C_CONFLICTS" ]; then
|
||||||
echo "ERROR: /dev/i2c-1 is already in use by another process:" >&2
|
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
|
while read -r pid; do
|
||||||
ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true
|
ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true
|
||||||
done <<< "$I2C_CONFLICTS"
|
done <<< "$I2C_CONFLICTS"
|
||||||
echo "Stop or reconfigure the conflicting OLED/LED controller, then run the installer again." >&2
|
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
||||||
exit 1
|
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
|
fi
|
||||||
|
|
||||||
echo "[2/8] 等待 APT/DPKG..."
|
echo "[2/8] 等待 APT/DPKG..."
|
||||||
@@ -80,7 +102,14 @@ from pathlib import Path
|
|||||||
path=Path(sys.argv[1])
|
path=Path(sys.argv[1])
|
||||||
text=path.read_text()
|
text=path.read_text()
|
||||||
sections={
|
sections={
|
||||||
|
"device":(("identifier","auto"),("name","auto")),
|
||||||
|
"hosts":(),
|
||||||
"led":(
|
"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),
|
("cpu_r",255),("cpu_g",0),("cpu_b",0),
|
||||||
("power_r",255),("power_g",40),("power_b",0),
|
("power_r",255),("power_g",40),("power_b",0),
|
||||||
("memory_r",255),("memory_g",0),("memory_b",255),
|
("memory_r",255),("memory_g",0),("memory_b",255),
|
||||||
@@ -107,6 +136,10 @@ for section,defaults in sections.items():
|
|||||||
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)]
|
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)]
|
||||||
if missing:
|
if missing:
|
||||||
note="# Added by installer; existing user values are preserved.\n"
|
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)
|
block=note+"".join(f"{key} = {value}\n" for key,value in missing)
|
||||||
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
|
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
|
||||||
path.write_text(text)
|
path.write_text(text)
|
||||||
@@ -132,18 +165,22 @@ echo "[7/8] 安装后自检..."
|
|||||||
FAIL=0
|
FAIL=0
|
||||||
check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; }
|
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
|
APP=/usr/local/sbin/pigway-pi-control
|
||||||
check "Version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' "$APP"
|
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 physical 128x32" grep -Fq 'W,H=128,32' "$APP"
|
||||||
check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$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 "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 CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP"
|
||||||
check "UI IP4/IP6" grep -Fq 'ip_label' "$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"
|
check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP"
|
||||||
no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; }
|
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
||||||
check "No other I2C owner" no_other_i2c_owner
|
printf " %-30s WARN (forced)\n" "I2C owner conflict"
|
||||||
|
else
|
||||||
|
no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; }
|
||||||
|
check "No other I2C owner" no_other_i2c_owner
|
||||||
|
fi
|
||||||
check "Service running" systemctl is-active --quiet pigway-pi-control.service
|
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 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 "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 "Web UI installed" test -s /usr/local/share/pigway-pi-control/web/index.html
|
||||||
check "P0 health monitor" grep -Fq 'throttled_state' "$APP"
|
check "P0 health monitor" grep -Fq 'throttled_state' "$APP"
|
||||||
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
|
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Pigway Pi Control API and Web
|
Description=PIGWay Pi Control API and Web
|
||||||
After=network.target pigway-pi-control.service
|
After=network.target pigway-pi-control.service
|
||||||
Wants=network.target pigway-pi-control.service
|
Wants=network.target pigway-pi-control.service
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor
|
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
|
||||||
After=multi-user.target network.target
|
After=multi-user.target network.target
|
||||||
Wants=network.target
|
Wants=network.target
|
||||||
|
|
||||||
|
|||||||
+87
-31
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user