Compare commits

...
8 Commits
14 changed files with 466 additions and 58 deletions
+33 -18
View File
@@ -1,50 +1,65 @@
# PIGWay Device Agent v3.7.0
# PIGWay Raspberry Pi Agent v3.7.1
极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
[简体中文](README.zh-CN.md) | English
## 安装
PIGWay Raspberry Pi Agent is a lightweight local monitoring service for Raspberry Pi. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. Ethernet monitoring reads the negotiated link speed and can alert when it remains below the configured minimum. It has no embedded Web UI and does not require a hardware plugin or Device Console.
### Installation
See the [Trixie fresh-install guide](docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md) for Raspberry Pi OS Lite 64-bit based on Debian 13. The Agent itself does not access GPIO or I2C; OLED, RGB, and fan control belong to the independent hardware plugin.
```bash
sudo ./install.sh
# 显式启用 API:
# Explicitly enable the API:
sudo ./install.sh --enable-api
# 不启用 API:
# Keep the API disabled:
sudo ./install.sh --disable-api
```
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
Interactive installation asks whether to enable the remote API and defaults to no. Non-interactive installation also keeps it disabled unless explicitly enabled. `--no-start` only updates files and configuration; it does not change the current service state.
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
The configuration file is `/etc/pigway-pi-control.conf`. Restart the Agent after changing monitoring settings:
```bash
sudo systemctl restart pigway-pi-control.service
```
## 可选 API
### Optional API
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
The default is `[api] enabled = false`, with no network listener. To use centralized management, enable it and configure the bind address and port, then run:
```bash
sudo systemctl enable --now pigway-pi-control-api.service
sudo cat /etc/pigway-pi-control-api.token
```
默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
The default port is 6001. Every read and write request requires `Authorization: Bearer <token>`. The token belongs only to this device and is not printed directly by the installer. The API exposes health, status, logs, configuration, services, plugins, and plugin configuration under `/api/v1`. It does not serve a Web UI, register multiple machines, or proxy remote requests.
API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
### Console and hardware plugins
## 独立管理中心与插件
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) can run on a NAS or remote server for dashboards, configuration, and batch synchronization.
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat) independently provides local telemetry, OLED, fan control, and RGB effects.
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat):独立本地采集、OLED、风扇与 RGB。
The remote API and local plugin integration are independent. After enabling plugin integration, register `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` under `[plugins]` and restart the Agent, or attach it through the Console. Discovery never binds a plugin automatically. Pausing or disconnecting integration does not stop the plugin's local functions.
远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
## 运维
### Operations
```bash
sudo /usr/local/sbin/pigway-pi-control --diagnose
sudo journalctl -u pigway-pi-control.service -f
```
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
Logs follow the system journal retention policy; no additional database is created. Uninstalling one project never uninstalls either of the others. See [Plugin API v1](docs/HARDWARE_PLUGIN_API.md).
## Naming and compatibility
This project targets Raspberry Pi. Other Linux platforms are not covered by its support claim and may have separately maintained adapters. Repository naming does not change the existing `pigway-agent` adapter ID or `pigway-pi-control` service, configuration and API identifiers.
## v3.8.0 网络流量
主接口每个网络采集周期读取 sysfs 的 rx_bytes/tx_bytes,以单调时间差计算字节/秒,API 新增 rx_bytes_per_second 和 tx_bytes_per_second。首次采样、接口切换、计数器重置或读取失败返回空值;不修改监控告警、硬件插件控制或 API 可选安装行为。2026-10-14 已在 Gateway 部署,实机上下行速率与 Console 标准历史指标验证通过。
## v3.9.0 采样时间
API 状态新增 system.observed_at、network.observed_at 和 collection.interval_seconds。Console 可每秒读取缓存,网络等低频指标保留实际采样时间,供历史去重。数据来源与原网络采样周期保持。
+59
View File
@@ -0,0 +1,59 @@
# PIGWay Raspberry Pi Agent v3.7.1
简体中文 | [English](README.md)
极轻的树莓派本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。有线网络会读取真实协商速率,并可在持续低于配置阈值时告警。无 Web 界面;无需硬件插件,也无需管理中心。
## 安装
Raspberry Pi OS Lite 64-bit(Debian 13 Trixie)的全新安装与兼容性说明见 [Trixie 全新安装指南](docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)。监控 Agent 本身不访问 GPIO/I²C;OLED、RGB 和风扇由独立硬件插件负责。
```bash
sudo ./install.sh
# 显式启用 API:
sudo ./install.sh --enable-api
# 不启用 API:
sudo ./install.sh --disable-api
```
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
```bash
sudo systemctl restart pigway-pi-control.service
```
## 可选 API
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
```bash
sudo systemctl enable --now pigway-pi-control-api.service
sudo cat /etc/pigway-pi-control-api.token
```
默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
## 独立管理中心与插件
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat):独立本地采集、OLED、风扇与 RGB。
远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
## 运维
```bash
sudo /usr/local/sbin/pigway-pi-control --diagnose
sudo journalctl -u pigway-pi-control.service -f
```
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
## 名称与兼容性
本项目支持范围明确为树莓派;其他 Linux 平台以后独立维护项目。仓库改名不改变现有 `pigway-pi-control` 服务名、配置路径和 API。远程 API 仍可选择启用,默认关闭。
+15 -8
View File
@@ -1,10 +1,17 @@
9c1fe8e9860aa4e5c9cd3ceb2199cdd118dd246a3b0f255f540976cf1877eef4 README.md
5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
f91af58e7dd752fa97c168ae419e14e6ee5099ea58d8252568f7ce72affd219f app/pigway_pi_control_api.py
ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore
ba2329dc02e9e09f5989c0dc6f174e0ce36b126b94249092a7ec97eb0e2a1d9d README.md
64a9708701f8c8d872a587e8a2845afff463797b06a2791319daadd705933d31 README.zh-CN.md
c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py
439602f2f6188558f1f16b6630a65438806d98dabd4c6f7fa5270e9f3845bf54 app/pigway_pi_control.py
757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf
5e16c1d5d592fa3e129cde0077da6b089784c82f452205a349d4824f48eb3a66 systemd/pigway-pi-control-api.service
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
d5a783b64a10fa43191a813ef73ef8f762e359b0bd42ec155724fa46216298b0 config/pigway-pi-control.conf
9d45f22221012a7fdb3406ce74a6113f618e9930fe460efb3869efd099e3b1d8 docs/DEPLOYMENT-v3.8.0-2026-10-14.md
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
b54ec1f3e9fb15473910686c54142a85d387e44ea5df427a4cf1b06052e789b3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md
9222a2b6a38f6b6d7d88b30c05413c68c05f69ee499c8479eca05ee7c62e87c3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md
c104443a4c951ea7916f74473510362f684df7d916bf2a436b779513ba3fe396 install.sh
8a593c290da5bd0d037aee86c35264512afd574a2a0f60f2c29e9db63e0109bd systemd/pigway-pi-control-api.service
1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service
8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
+29
View File
@@ -0,0 +1,29 @@
"""Sample interface counters without summing physical and virtual interfaces."""
import re
from pathlib import Path
class NetworkRates:
def __init__(self, root='/sys/class/net'):
self.root = Path(root)
self.previous = None
def sample(self, interface, now):
try:
if not re.fullmatch(r'[\w.:-]{1,64}', interface):
raise ValueError('invalid interface')
directory = self.root / interface / 'statistics'
rx = int((directory / 'rx_bytes').read_text())
tx = int((directory / 'tx_bytes').read_text())
if rx < 0 or tx < 0:
raise ValueError('negative counter')
except (OSError, ValueError, TypeError):
self.previous = None
return None, None
previous = self.previous
self.previous = interface, now, rx, tx
if previous is None or previous[0] != interface or now <= previous[1]:
return None, None
elapsed = now - previous[1]
return (None if rx < previous[2] else (rx - previous[2]) / elapsed,
None if tx < previous[3] else (tx - previous[3]) / elapsed)
+66 -16
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
from pathlib import Path
APP_VERSION="3.7.0"
APP_VERSION="3.9.0"
import sys
sys.path.append("/usr/local/share/pigway-pi-control")
from shutil import disk_usage
from plugin_api import Publisher,registered,discover
from network_rates import NetworkRates
CFG="/etc/pigway-pi-control.conf"
STATUS_PATH="/run/pigway-pi-control/status.json"
@@ -104,8 +105,14 @@ prev_cpu=None
page=0; page_since=time.monotonic()
cpu_hi=mem_hi=0
net_ok=link_ok=True
net_iface=""; net_kind="NET"; net_metric="DOWN"; ip_label="IP4"; ip="NO IP"
sensor_observed_at=None
network_observed_at=None
network_rates=NetworkRates()
net_rx_bytes_per_second=None; net_tx_bytes_per_second=None
net_iface=""; net_kind="NET"; net_metric="DOWN"; net_speed_mbps=None; ip_label="IP4"; ip="NO IP"
link_failure_since=internet_failure_since=None
wifi_weak_since=None; wifi_weak_active=False
ethernet_slow_since=None; ethernet_slow_active=False
last_net=last_watch=0
watch_state=[]
@@ -148,8 +155,9 @@ def primary_network():
if not dev:
out=_run(["ip","-6","route","get","2606:4700:4700::1111"])
dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP6"
if not dev: return "","NET","DOWN","IP4","NO IP"
if not dev: return "","NET","DOWN",None,"IP4","NO IP"
iface=dev.group(1); ipval=src.group(1) if src else ""
speed_mbps=None
if Path(f"/sys/class/net/{iface}/wireless").exists():
kind="WIF"; metric="--"
try:
@@ -161,12 +169,13 @@ def primary_network():
kind="ETH"; metric="UP"
try:
n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip())
speed_mbps=n if n>0 else None
metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n)
except Exception: pass
return iface,kind,metric,label,(ipval or "NO IP")
return iface,kind,metric,speed_mbps,label,(ipval or "NO IP")
def get_ip():
return primary_network()[4]
return primary_network()[5]
def internet():
try:
@@ -202,6 +211,8 @@ SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
STARTUP_GRACE_SECONDS=max(0.0,cf("timing","startup_grace_seconds",15.0))
NETWORK_FAILURE_SECONDS=max(0.0,cf("timing","network_failure_seconds",15.0))
CPU_HIGH=cf("alerts","cpu_high",90.0)
CPU_RECOVER=cf("alerts","cpu_recover",75.0)
@@ -216,6 +227,8 @@ DISK_CRIT=cf("alerts","disk_critical",95.0)
WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
ETHERNET_MIN_SPEED=cf("alerts","ethernet_min_speed_mbps",1000,int)
ETHERNET_SPEED_SECONDS=cf("alerts","ethernet_speed_seconds",15.0)
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
@@ -226,7 +239,7 @@ ALERT_CATEGORIES={
"POWER_UNDERVOLT":"POWER",
"MEMORY_HIGH":"MEMORY",
"DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE",
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK",
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK","ETHERNET_SPEED_LOW":"NETWORK",
"SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE",
}
@@ -254,9 +267,9 @@ def diagnose():
"under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"):
print(f"{key}={power.get(key,'UNKNOWN')}")
if "error" in power: print("error="+power["error"])
iface,kind,metric,label,address=primary_network()
iface,kind,metric,speed,label,address=primary_network()
print("Network:")
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} link_speed_mbps={speed if speed is not None else 'N/A'} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
print("Plugins (read-only API discovery; no hardware access):")
for plugin in discover(cfg):
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
@@ -283,6 +296,7 @@ ALERT_PRIORITIES={
"MEMORY_HIGH":45,
"DISK_HIGH":40,
"WIFI_WEAK":30,
"ETHERNET_SPEED_LOW":35,
"NORMAL_HOME":0,
}
@@ -296,7 +310,7 @@ def transition(key,active,on_event,off_event,**fields):
log("WARN" if active else "INFO",on_event if active else off_event,**fields)
def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
global wifi_weak_since,wifi_weak_active
global wifi_weak_since,wifi_weak_active,ethernet_slow_since,ethernet_slow_active
global cpu_hi,mem_hi
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
@@ -347,6 +361,21 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
else: alert_manager.clear("WIFI_WEAK")
else:
wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
if net_kind=="ETH" and net_speed_mbps is not None and ETHERNET_MIN_SPEED>0:
if not ethernet_slow_active:
if net_speed_mbps < ETHERNET_MIN_SPEED:
if ethernet_slow_since is None: ethernet_slow_since=time.monotonic()
if time.monotonic()-ethernet_slow_since >= ETHERNET_SPEED_SECONDS: ethernet_slow_active=True
else: ethernet_slow_since=None
elif net_speed_mbps >= ETHERNET_MIN_SPEED:
ethernet_slow_active=False; ethernet_slow_since=None
if ethernet_slow_active:
alert_manager.request("ETHERNET_SPEED_LOW",1,"ETHERNET SLOW",f"LINK {net_speed_mbps} Mbps",f"EXPECT {ETHERNET_MIN_SPEED}+","CHECK CABLE")
else: alert_manager.clear("ETHERNET_SPEED_LOW")
else:
ethernet_slow_since=None; ethernet_slow_active=False; alert_manager.clear("ETHERNET_SPEED_LOW")
transition("ethernet_speed_low",ethernet_slow_active,"ETHERNET_SPEED_LOW","ETHERNET_SPEED_RECOVERED",
iface=net_iface,speed_mbps=net_speed_mbps,minimum_mbps=ETHERNET_MIN_SPEED)
if health.get("under_voltage_now"):
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
else: alert_manager.clear("POWER_UNDERVOLT")
@@ -397,10 +426,11 @@ def write_status(cpu,temp,mem,disk):
disk_total,disk_used,disk_free=disk_usage("/")
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
state={
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
"version":APP_VERSION,"timestamp":int(time.time()),
"collection":{"interval_seconds":min(1.0,SENSOR_INTERVAL,NETWORK_INTERVAL,SERVICE_INTERVAL,HEALTH_INTERVAL)},"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":{"observed_at":sensor_observed_at,"load_average":load_average(),"cpu_percent":round(cpu,1),
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
"memory_percent":round(memory_calculated if memory_total else mem,1),
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
@@ -409,7 +439,10 @@ def write_status(cpu,temp,mem,disk):
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
"root_readonly":bool(fs_readonly)},
"power":health,
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
"network":{"observed_at":network_observed_at,"interface":net_iface,"kind":net_kind,"metric":net_metric,
"link_speed_mbps":net_speed_mbps,
"rx_bytes_per_second":net_rx_bytes_per_second,
"tx_bytes_per_second":net_tx_bytes_per_second,
"signal_percent":signal,
"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)}
@@ -433,6 +466,7 @@ if args.diagnose: raise SystemExit(diagnose())
watch_state=[]
last_sensor=last_status=0.0
last_net=last_watch=last_health=0.0
monitor_ready_at=time.monotonic()+STARTUP_GRACE_SECONDS
health={}; fs_readonly=False
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0}
event_state={}
@@ -453,11 +487,27 @@ try:
if now-last_sensor >= SENSOR_INTERVAL:
sample_elapsed=now-last_sensor if last_sensor else 0.0
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
latest=(c,t,m,d); last_sensor=now; agent["last_sensor_ok"]=now
latest=(c,t,m,d); sensor_observed_at=int(time.time()); last_sensor=now; agent["last_sensor_ok"]=now
if now-last_net >= NETWORK_INTERVAL:
if now>=monitor_ready_at and now-last_net >= NETWORK_INTERVAL:
old_id=(net_iface,ip)
net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False
net_iface,net_kind,net_metric,net_speed_mbps,ip_label,ip=primary_network()
net_rx_bytes_per_second,net_tx_bytes_per_second=network_rates.sample(net_iface,now)
network_observed_at=int(time.time())
raw_link_ok=ip!="NO IP"; raw_net_ok=internet() if raw_link_ok else False
if raw_link_ok:
link_failure_since=None;link_ok=True
else:
if link_failure_since is None:link_failure_since=now
link_ok=now-link_failure_since>=NETWORK_FAILURE_SECONDS
link_ok=not link_ok
if not raw_link_ok:
internet_failure_since=None;net_ok=True
elif raw_net_ok:
internet_failure_since=None;net_ok=True
else:
if internet_failure_since is None:internet_failure_since=now
net_ok=not (now-internet_failure_since>=NETWORK_FAILURE_SECONDS)
new_id=(net_iface,ip)
if new_id!=old_id:
log("INFO","NET_SWITCH",old_if=old_id[0],old_ip=old_id[1],new_if=net_iface,new_ip=ip)
@@ -465,7 +515,7 @@ try:
transition("internet_down",link_ok and not net_ok,"INTERNET_DOWN","INTERNET_RECOVERED",iface=net_iface,ip=ip)
last_net=now; agent["last_network_ok"]=now
if now-last_watch >= SERVICE_INTERVAL:
if now>=monitor_ready_at and now-last_watch >= SERVICE_INTERVAL:
watch_state=[check_watch(w) for w in watches]
for w,ok in zip(watches,watch_state):
transition("watch:"+w[0]+":"+w[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2])
+40 -3
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from urllib.parse import parse_qs,urlencode,urlparse
from urllib.request import Request,urlopen
APP_VERSION="3.7.0"
APP_VERSION="3.7.2"
CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token")
@@ -114,7 +114,9 @@ def validate_value(section,key,value,existing):
def replace_ini_value(section,key,value):
text=CFG.read_text()
return replace_ini_text(CFG.read_text(),section,key,value)
def replace_ini_text(text,section,key,value):
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"
@@ -278,6 +280,34 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return str(backup)
def process_monitors():
cfg=load_cfg(); result=[]
for name,target in cfg.items("processes"):
target=target.strip()
if not target: continue
up=subprocess.run(["pgrep","-f","--",target],stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,timeout=2).returncode==0
result.append({"name":name.upper(),"target":target,"up":up})
return result
def edit_process_monitor(operation,name,target="",previous_name=""):
if operation not in {"save","delete"}: raise ValueError("invalid process monitor operation")
if not NAME_RE.fullmatch(name): raise ValueError("invalid process monitor name")
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous process monitor name")
if operation=="save" and (not target or len(target)>256 or any(c in target for c in "\r\n\x00")):
raise ValueError("invalid process match expression")
text=CFG.read_text()
if operation=="delete":
if not load_cfg().has_option("processes",name): raise ValueError("process monitor not found")
text=replace_ini_text(text,"processes",name,None)
else:
if previous_name and previous_name.lower()!=name.lower(): text=replace_ini_text(text,"processes",previous_name,None)
text=replace_ini_text(text,"processes",name,target)
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=save_ini_text(text,"api")
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return backup
def control_service(unit,action):
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")
@@ -350,13 +380,15 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path=="/api/v1/services":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
if parsed.path=="/api/v1/processes":
self.send_json(200,{"processes":process_monitors(),"machine":machine_identity()}); return
self.send_json(404,{"error":"not found"})
except ValueError as e: self.send_json(400,{"error":str(e)})
except Exception as e: self.send_json(500,{"error":str(e)})
def do_PUT(self):
path=urlparse(self.path).path
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control"}: self.send_json(404,{"error":"not found"}); return
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/processes"}: 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()
@@ -375,6 +407,11 @@ class Handler(BaseHTTPRequestHandler):
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
if path=="/api/v1/processes":
backup=edit_process_monitor(str(body.get("operation","")),str(body.get("name","")),
str(body.get("target","")),str(body.get("previous_name","")))
print(f"PROCESS_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
self.send_json(200,{"ok":True,"backup":backup}); return
updates=body.get("updates")
backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
+16 -2
View File
@@ -42,6 +42,14 @@ service_interval = 5
# 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。
health_interval = 1
# 开机后等待网络与被监控服务完成启动,再开始网络/服务告警检查。
# 这不会延迟温度、电源、磁盘等本机健康检查。
startup_grace_seconds = 15
# 网络或互联网必须连续失败达到此时间才产生告警。
# 成功样本会立即清除等待状态,避免开机和链路切换瞬间误报。
network_failure_seconds = 15
[alerts]
# ---------------- CPU ----------------
@@ -91,6 +99,11 @@ wifi_recover = -68
# 弱信号持续多少秒才正式告警。
wifi_weak_seconds = 30
# 有线网卡协商速率低于此值并持续 ethernet_speed_seconds 后告警,单位 Mbps。
# Raspberry Pi 4/5 的千兆网口建议保持 1000;设为 0 可关闭速率告警。
ethernet_min_speed_mbps = 1000
ethernet_speed_seconds = 15
[dark_mode]
# Raspberry Pi 4 板载 PWR / ACT LED。
@@ -131,11 +144,12 @@ pi_act_led = off
# 修改后:
# sudo systemctl restart pigway-pi-control
#
# 默认监控 SSH 和向日葵核心后台服务。
# 默认只监控 Raspberry Pi OS 自带的 SSH 服务。
# 向日葵、Tailscale 等可选服务应在实际安装后再显式添加,避免全新系统误报。
SSH = ssh.service
AWESUN = runawesun.service
# 以下仅为示例,默认不启用:
# AWESUN = runawesun.service
# TAILSCALE = tailscaled.service
# DOCKER = docker.service
# SAMBA = smbd.service
+5
View File
@@ -0,0 +1,5 @@
# Agent v3.8.0 网络流量
2026-10-14 Gateway 已部署。单独的 NetworkRates 模块读取主接口 sysfs 计数器;网络更新周期计算速率,写入已有状态/API。首次采样、接口切换、复位、读取失败不伪造零值。idle 返回真实 0。安装器加入模块依赖,保持 API 可选安装。采样测试覆盖初始、零流量、传输、复位、切换及失效;Python 编译、安装器语法检查通过。实机服务 active,状态版本 3.8.0、eth0 上传/下载数值有效,未发现本轮循环错误;Console 两设备在线,接入插件生成对应历史指标。
旧程序备份 `/home/way/pigway-agent-backup-v3.8.0-20261014/pigway-pi-control`,目录 0700。仅更新监控程序和新增模块并重启监控服务,没有变更硬件插件或 API 配置。
@@ -0,0 +1,71 @@
# Raspberry Pi OS Lite (Debian 13 Trixie) fresh installation
English | [简体中文](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)
## Compatibility
PIGWay Raspberry Pi Agent v3.7.1 supports Raspberry Pi OS Lite 64-bit based on Debian 13 Trixie and Python 3.13. The Agent reads Linux `/proc`, `/sys`, systemd, network state, and journal data. It has no GPIO, I2C, Pillow, or third-party Python dependency and does not need a virtual environment.
The Yahboom RGB Cooling HAT is an independent plugin. It uses Debian packages `python3-smbus2` and `python3-pil`, avoiding pip changes to the PEP 668 managed system Python. Its verified interface remains unchanged:
- I2C bus `/dev/i2c-1`; I2C must be enabled. SPI is not required.
- MCU `0x0D`; 128x32 SSD1306-compatible OLED `0x3C`.
- The fan uses six levels through MCU register `0x08`. It is not a Raspberry Pi GPIO PWM fan and has no verified RPM feedback.
- The plugin is the sole process owner of the MCU, OLED, RGB, and fan.
Do not guess or change GPIO pins for this board. Other GPIO/PWM fans require their own driver.
## Fresh installation
Check the system first:
```bash
cat /etc/os-release
python3 --version
raspi-config nonint get_i2c
ls -l /dev/i2c-1
ss -ltnp
```
`raspi-config nonint get_i2c` must return `0`. Enabling I2C changes boot configuration and normally requires a reboot, so confirm SSH key access and stable power first:
```bash
sudo raspi-config nonint do_i2c 0
sudo reboot
```
Install the Agent first. Its API is opt-in:
```bash
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
cd pigway-raspberry-pi-agent
sudo ./install.sh --disable-api
```
To use Device Console, verify the port and explicitly enable the API:
```bash
sudo ./install.sh --enable-api
sudo ss -ltnp | grep ':6001 '
sudo cat /etc/pigway-pi-control-api.token
```
Install the hardware plugin after `/dev/i2c-1` is available:
```bash
git clone https://tea.pigway.com/way/pigway-yahboom-cooling-hat.git
cd pigway-yahboom-cooling-hat
sudo ./install.sh --enable-integration
```
Register its Unix socket under `[plugins]` in `/etc/pigway-pi-control.conf`, then restart only the Agent:
```ini
yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
```
## Validation
Validate both systemd services, Agent diagnostics, boot journal, and plugin status. Physically verify the four-line OLED and 10-second rotation, RGB alerts, and temperature-dependent fan levels. Plugin shutdown requests full fan speed and driver close never writes fan OFF. Since the board has no tachometer feedback, requested level is not measured RPM.
The Agent API is disabled by default and the plugin uses a local Unix socket, so the default installation opens no TCP port and does not conflict with Tailscale or an API gateway.
@@ -0,0 +1,88 @@
# Raspberry Pi OS Lite(Debian 13 Trixie)全新安装
[English](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md) | 简体中文
## 兼容性结论
PIGWay Raspberry Pi Agent v3.7.1 兼容 Raspberry Pi OS Lite 64-bit、Debian 13 Trixie 与 Python 3.13。Agent 只读取 Linux 的 `/proc`、`/sys`、systemd、网络状态和 journal,不依赖 GPIO、I²C、Pillow 或第三方 Python 包,也不需要 venv。
Yahboom RGB Cooling HAT 已拆分为独立插件。插件使用 Debian 系统包 `python3-smbus2` 与 `python3-pil`,不会通过 pip 修改受 PEP 668 管理的系统 Python。硬件接口保持原实现:
- I²C 总线:`/dev/i2c-1`,需要启用 I²C;不需要 SPI。
- 控制 MCU:`0x0D`;OLED:`0x3C`,SSD1306 兼容 128×32。
- 风扇通过 MCU 寄存器 `0x08` 的六档控制,不使用 Raspberry Pi GPIO PWM 引脚,也没有可验证的 RPM 反馈。
- RGB 由 MCU 寄存器控制;插件是 MCU、OLED、RGB 与风扇的唯一进程所有者。
因此不能把这块散热板当作直连 GPIO 风扇,也不应猜测或修改 GPIO 引脚。其他 GPIO/PWM 风扇需要独立驱动。
## 安装前检查
```bash
cat /etc/os-release
python3 --version
raspi-config nonint get_i2c
ls -l /dev/i2c-1
ss -ltnp
```
`raspi-config nonint get_i2c` 返回 `0` 才表示 I²C 已启用。启用 I²C 会修改启动配置,并且通常需要重启;远程操作前应确认 SSH 公钥登录与供电稳定。
```bash
sudo raspi-config nonint do_i2c 0
sudo reboot
```
重连后确认 `/dev/i2c-1` 存在。散热板插件安装器会安装诊断工具并检查 `0x0D`、`0x3C`,不会改 SSH、网络或 Wi-Fi。
## 安装顺序
先安装通用监控 Agent:
```bash
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
cd pigway-raspberry-pi-agent
sudo ./install.sh --disable-api
```
默认只监控 `ssh.service`,不会假定已安装向日葵或 Tailscale。需要 NAS 上的 Device Console 时,先确认端口未占用,再显式开启 API:
```bash
sudo ./install.sh --enable-api
sudo ss -ltnp | grep ':6001 '
sudo cat /etc/pigway-pi-control-api.token
```
再安装独立硬件插件:
```bash
git clone https://tea.pigway.com/way/pigway-yahboom-cooling-hat.git
cd pigway-yahboom-cooling-hat
sudo ./install.sh --enable-integration
```
最后在 `/etc/pigway-pi-control.conf` 的 `[plugins]` 中登记:
```ini
yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
```
然后仅重启 Agent:
```bash
sudo systemctl restart pigway-pi-control.service
```
## 验收
```bash
systemctl --no-pager --full status pigway-pi-control.service
systemctl --no-pager --full status pigway-cooling-hat.service
sudo /usr/local/sbin/pigway-pi-control --diagnose
sudo journalctl -u pigway-pi-control.service -b --no-pager
sudo journalctl -u pigway-cooling-hat.service -b --no-pager
curl --unix-socket /run/pigway-plugins/yahboom-cooling-hat/api.sock http://localhost/v1/status
```
实机还需肉眼确认 OLED 四行主页与10秒轮播、RGB 告警颜色/效果,并通过温度变化确认风扇档位。停止插件时会请求风扇全速,驱动关闭时不会写风扇 OFF;硬件无转速反馈,因此只能确认请求档位,不能声称测得真实 RPM。
API 默认关闭,不占用 TCP 端口。插件只使用本机 Unix socket,不会与 Tailscale 或 API 网关争用端口。两个服务都由 systemd 独立管理,异常退出自动重启,任一项目停止不会停止另一个项目。
+17 -7
View File
@@ -12,13 +12,14 @@ for arg in "$@"; do
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
esac
done
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
for required in app/pigway_pi_control.py app/plugin_api.py app/network_rates.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
done
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
mkdir -p /usr/local/share/pigway-pi-control
install -m 0644 app/network_rates.py /usr/local/share/pigway-pi-control/network_rates.py
install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
@@ -31,12 +32,21 @@ fi
# but ignored by the monitor and never exposed as monitor configuration.
python3 - <<'PY'
from pathlib import Path
import configparser
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
with p.open('a') as f:
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
if not c.has_section(section):
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
import configparser,re
p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser();c.read_string(text)
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},
'alerts':{'ethernet_min_speed_mbps':'1000','ethernet_speed_seconds':'15'},
'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
if not c.has_section(section):
text=text.rstrip()+'\n\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items())
else:
missing={k:v for k,v in values.items() if not c.has_option(section,k)}
if missing:
start=re.search(rf'(?mi)^\[{re.escape(section)}\][ \t]*$',text).end()
following=re.search(r'(?m)^\[[^\]\r\n]+\]',text[start:])
end=start+(following.start() if following else len(text)-start)
text=text[:end].rstrip()+'\n'+''.join(k+' = '+v+'\n' for k,v in missing.items())+'\n'+text[end:].lstrip('\n')
p.write_text(text)
PY
if [ ! -s /etc/pigway-pi-control-api.token ]; then
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=PIGWay Device Agent optional local API
Description=PIGWay Raspberry Pi Agent optional local API
After=network.target pigway-pi-control.service
Wants=network.target
+3 -3
View File
@@ -1,7 +1,7 @@
[Unit]
Description=PIGWay system monitor
After=local-fs.target systemd-modules-load.service
Before=multi-user.target
Description=PIGWay Raspberry Pi Agent system monitor
After=local-fs.target systemd-modules-load.service network-online.target ssh.service
Wants=network-online.target
[Service]
Type=simple
+23
View File
@@ -0,0 +1,23 @@
import sys,unittest,tempfile
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'app'))
from network_rates import NetworkRates
class RatesTests(unittest.TestCase):
def test_initial_idle_transfer_reset_and_switch(self):
with tempfile.TemporaryDirectory() as root:
def write(interface,rx,tx):
p=Path(root)/interface/'statistics';p.mkdir(parents=True,exist_ok=True)
(p/'rx_bytes').write_text(str(rx));(p/'tx_bytes').write_text(str(tx))
write('eth0',1000,2000);sampler=NetworkRates(root)
self.assertEqual(sampler.sample('eth0',10),(None,None))
self.assertEqual(sampler.sample('eth0',15),(0,0))
write('eth0',1500,3000)
self.assertEqual(sampler.sample('eth0',20),(100,200))
write('eth0',10,20)
self.assertEqual(sampler.sample('eth0',25),(None,None))
write('wlan0',100,200)
self.assertEqual(sampler.sample('wlan0',30),(None,None))
self.assertEqual(sampler.sample('missing',35),(None,None))
self.assertEqual(sampler.sample('eth0',40),(None,None))
self.assertEqual(sampler.sample('../eth0',45),(None,None))