Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174ac747c5 | ||
|
|
b63fa39f32 | ||
|
|
e828fd11ee | ||
|
|
7aa8f838a6 | ||
|
|
e55ad3217f | ||
|
|
26c313df46 |
@@ -1,9 +1,9 @@
|
|||||||
# PIGWay Device Agent v3.7.1
|
# PIGWay Raspberry Pi Agent v3.7.1
|
||||||
|
|
||||||
[简体中文](README.zh-CN.md) | English
|
[简体中文](README.zh-CN.md) | English
|
||||||
|
|
||||||
|
|
||||||
PIGWay Device Agent is a lightweight local monitoring service for Raspberry Pi and Linux. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. It has no embedded Web UI and does not require a hardware plugin or Device Console.
|
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
|
### Installation
|
||||||
|
|
||||||
@@ -51,3 +51,21 @@ sudo journalctl -u pigway-pi-control.service -f
|
|||||||
```
|
```
|
||||||
|
|
||||||
Logs follow the system journal retention policy; no additional database is created. Uninstalling one project never uninstalls either of the others. See [Plugin API v1](docs/HARDWARE_PLUGIN_API.md).
|
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 可每秒读取缓存,网络等低频指标保留实际采样时间,供历史去重。数据来源与原网络采样周期保持。
|
||||||
|
|
||||||
|
生产 v3.9.0 与按设备周期采集的核验见 [部署记录](docs/DEPLOYMENT-v3.9.0-2026-10-15.md)。
|
||||||
|
|
||||||
|
## v3.10.0 独立采集周期
|
||||||
|
|
||||||
|
实时状态提供分组采样时间与下一次更新计划。容量每天读取一次,RAM 缓存跨服务重启恢复;读取失败延后一小时重试,避免反复查询存储。网络周期可独立配置,生产计划调整为 1 秒。
|
||||||
|
|||||||
+6
-2
@@ -1,9 +1,9 @@
|
|||||||
# PIGWay Device Agent v3.7.1
|
# PIGWay Raspberry Pi Agent v3.7.1
|
||||||
|
|
||||||
简体中文 | [English](README.md)
|
简体中文 | [English](README.md)
|
||||||
|
|
||||||
|
|
||||||
极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
|
极轻的树莓派本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。有线网络会读取真实协商速率,并可在持续低于配置阈值时告警。无 Web 界面;无需硬件插件,也无需管理中心。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -53,3 +53,7 @@ sudo journalctl -u pigway-pi-control.service -f
|
|||||||
```
|
```
|
||||||
|
|
||||||
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
|
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
|
||||||
|
|
||||||
|
## 名称与兼容性
|
||||||
|
|
||||||
|
本项目支持范围明确为树莓派;其他 Linux 平台以后独立维护项目。仓库改名不改变现有 `pigway-pi-control` 服务名、配置路径和 API。远程 API 仍可选择启用,默认关闭。
|
||||||
|
|||||||
+16
-10
@@ -1,14 +1,20 @@
|
|||||||
ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore
|
ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore
|
||||||
8e5eee4f796e1dcf9a9b4960541672fc10aae6615e96e7e2296297dcab1535d7 README.md
|
948e700459c05a045234edfbbdd1efa240f9a0350221a40029b7416c0587e36c README.md
|
||||||
94851d7e4a635ae41f41d7b7e89ddba6c0b2c53e201415fc3e3f233a82a9eb8e README.zh-CN.md
|
64a9708701f8c8d872a587e8a2845afff463797b06a2791319daadd705933d31 README.zh-CN.md
|
||||||
aeb34068c0b9dd3afc8f1c3dd388cb658b010df37f171643d2f508fbbf853980 app/pigway_pi_control.py
|
c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py
|
||||||
641591fd59c71dc22b54e0b844d3192e46b1f8a924b59f32f5ec9c5354f9a75a app/pigway_pi_control_api.py
|
69877b4ad2b8307db69495d629988ff4bca838dea9e36d839165029fdb6ed4a8 app/pigway_pi_control.py
|
||||||
|
757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py
|
||||||
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||||
11a75ded35d7d16512a734814d85fe2f61101044d9de9352679be73355772008 config/pigway-pi-control.conf
|
81ca44b8bdc384c7f27a39da8e785af2450241c65200edee61db95f48d67f058 app/storage_cache.py
|
||||||
|
d5a783b64a10fa43191a813ef73ef8f762e359b0bd42ec155724fa46216298b0 config/pigway-pi-control.conf
|
||||||
|
9d45f22221012a7fdb3406ce74a6113f618e9930fe460efb3869efd099e3b1d8 docs/DEPLOYMENT-v3.8.0-2026-10-14.md
|
||||||
|
2a3cd7f58c46b7f864affd7ec64fbec38ea2d6306160cd8d4f0075a779251b5f docs/DEPLOYMENT-v3.9.0-2026-10-15.md
|
||||||
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
||||||
2030b90b8c01d02c414a1fb44c96c84527b6cafcc8a6ead5634c5e9dcd5dad4b docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md
|
b54ec1f3e9fb15473910686c54142a85d387e44ea5df427a4cf1b06052e789b3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md
|
||||||
75984d9dea668c04d35f79d7c454d93b835825ef2eff607b760294237016aa8f docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md
|
9222a2b6a38f6b6d7d88b30c05413c68c05f69ee499c8479eca05ee7c62e87c3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md
|
||||||
5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
|
1b0aeaf4997bdb02b967d538cf276ad7462a3e22ec35fc0d046d779ef56cd1f8 install.sh
|
||||||
5e16c1d5d592fa3e129cde0077da6b089784c82f452205a349d4824f48eb3a66 systemd/pigway-pi-control-api.service
|
8a593c290da5bd0d037aee86c35264512afd574a2a0f60f2c29e9db63e0109bd systemd/pigway-pi-control-api.service
|
||||||
272545c6f821a850caa545361f4f93e3218436c7fbc1b3dd01b83ac442e7793e systemd/pigway-pi-control.service
|
1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service
|
||||||
|
8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py
|
||||||
|
f78e31e0eeee4e02532885ace5c8f6f7011d084e4c7440b704408bc88495e40e tests/test_storage_cache.py
|
||||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
|
|||||||
@@ -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)
|
||||||
+69
-26
@@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
|
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
APP_VERSION="3.7.1"
|
APP_VERSION="3.10.0"
|
||||||
import sys
|
import sys
|
||||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||||
from shutil import disk_usage
|
|
||||||
from plugin_api import Publisher,registered,discover
|
from plugin_api import Publisher,registered,discover
|
||||||
|
from network_rates import NetworkRates
|
||||||
|
from storage_cache import StorageCache
|
||||||
|
storage_cache=StorageCache("/run/pigway-pi-control/storage-cache.json")
|
||||||
|
|
||||||
CFG="/etc/pigway-pi-control.conf"
|
CFG="/etc/pigway-pi-control.conf"
|
||||||
STATUS_PATH="/run/pigway-pi-control/status.json"
|
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||||
@@ -104,9 +106,14 @@ prev_cpu=None
|
|||||||
page=0; page_since=time.monotonic()
|
page=0; page_since=time.monotonic()
|
||||||
cpu_hi=mem_hi=0
|
cpu_hi=mem_hi=0
|
||||||
net_ok=link_ok=True
|
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
|
link_failure_since=internet_failure_since=None
|
||||||
wifi_weak_since=None; wifi_weak_active=False
|
wifi_weak_since=None; wifi_weak_active=False
|
||||||
|
ethernet_slow_since=None; ethernet_slow_active=False
|
||||||
last_net=last_watch=0
|
last_net=last_watch=0
|
||||||
watch_state=[]
|
watch_state=[]
|
||||||
|
|
||||||
@@ -138,7 +145,8 @@ def mem_pct():
|
|||||||
k,v=l.split(":",1); vals[k]=int(v.split()[0])
|
k,v=l.split(":",1); vals[k]=int(v.split()[0])
|
||||||
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
||||||
def disk_pct():
|
def disk_pct():
|
||||||
d=disk_usage("/"); return 100*d.used/d.total
|
d=storage_cache.sample()
|
||||||
|
return 100*d["used"]/d["total"] if d else None
|
||||||
def _run(args,timeout=2):
|
def _run(args,timeout=2):
|
||||||
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip()
|
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip()
|
||||||
except Exception: return ""
|
except Exception: return ""
|
||||||
@@ -149,8 +157,9 @@ def primary_network():
|
|||||||
if not dev:
|
if not dev:
|
||||||
out=_run(["ip","-6","route","get","2606:4700:4700::1111"])
|
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"
|
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 ""
|
iface=dev.group(1); ipval=src.group(1) if src else ""
|
||||||
|
speed_mbps=None
|
||||||
if Path(f"/sys/class/net/{iface}/wireless").exists():
|
if Path(f"/sys/class/net/{iface}/wireless").exists():
|
||||||
kind="WIF"; metric="--"
|
kind="WIF"; metric="--"
|
||||||
try:
|
try:
|
||||||
@@ -162,12 +171,13 @@ def primary_network():
|
|||||||
kind="ETH"; metric="UP"
|
kind="ETH"; metric="UP"
|
||||||
try:
|
try:
|
||||||
n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip())
|
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)
|
metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n)
|
||||||
except Exception: pass
|
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():
|
def get_ip():
|
||||||
return primary_network()[4]
|
return primary_network()[5]
|
||||||
|
|
||||||
def internet():
|
def internet():
|
||||||
try:
|
try:
|
||||||
@@ -219,6 +229,8 @@ DISK_CRIT=cf("alerts","disk_critical",95.0)
|
|||||||
WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
||||||
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
||||||
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.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_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"))
|
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||||
@@ -229,7 +241,7 @@ ALERT_CATEGORIES={
|
|||||||
"POWER_UNDERVOLT":"POWER",
|
"POWER_UNDERVOLT":"POWER",
|
||||||
"MEMORY_HIGH":"MEMORY",
|
"MEMORY_HIGH":"MEMORY",
|
||||||
"DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE",
|
"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",
|
"SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,9 +269,9 @@ def diagnose():
|
|||||||
"under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"):
|
"under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"):
|
||||||
print(f"{key}={power.get(key,'UNKNOWN')}")
|
print(f"{key}={power.get(key,'UNKNOWN')}")
|
||||||
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,speed,label,address=primary_network()
|
||||||
print("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):")
|
print("Plugins (read-only API discovery; no hardware access):")
|
||||||
for plugin in discover(cfg):
|
for plugin in discover(cfg):
|
||||||
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
|
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
|
||||||
@@ -286,6 +298,7 @@ ALERT_PRIORITIES={
|
|||||||
"MEMORY_HIGH":45,
|
"MEMORY_HIGH":45,
|
||||||
"DISK_HIGH":40,
|
"DISK_HIGH":40,
|
||||||
"WIFI_WEAK":30,
|
"WIFI_WEAK":30,
|
||||||
|
"ETHERNET_SPEED_LOW":35,
|
||||||
"NORMAL_HOME":0,
|
"NORMAL_HOME":0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +312,7 @@ def transition(key,active,on_event,off_event,**fields):
|
|||||||
log("WARN" if active else "INFO",on_event if active else 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):
|
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
|
global cpu_hi,mem_hi
|
||||||
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_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
|
mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
||||||
@@ -315,14 +328,15 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
|||||||
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
|
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
|
||||||
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||||
else: alert_manager.clear("MEMORY_HIGH")
|
else: alert_manager.clear("MEMORY_HIGH")
|
||||||
if disk>=DISK_CRIT:
|
if disk is not None:
|
||||||
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
if disk>=DISK_CRIT:
|
||||||
alert_manager.clear("DISK_HIGH")
|
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||||
elif disk>=DISK_HIGH:
|
alert_manager.clear("DISK_HIGH")
|
||||||
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
elif disk>=DISK_HIGH:
|
||||||
alert_manager.clear("DISK_CRITICAL")
|
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
||||||
else:
|
alert_manager.clear("DISK_CRITICAL")
|
||||||
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
|
else:
|
||||||
|
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
|
||||||
if not link_ok:
|
if not link_ok:
|
||||||
alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
||||||
alert_manager.clear("INTERNET_DOWN")
|
alert_manager.clear("INTERNET_DOWN")
|
||||||
@@ -350,6 +364,21 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
|||||||
else: alert_manager.clear("WIFI_WEAK")
|
else: alert_manager.clear("WIFI_WEAK")
|
||||||
else:
|
else:
|
||||||
wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
|
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"):
|
if health.get("under_voltage_now"):
|
||||||
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||||
else: alert_manager.clear("POWER_UNDERVOLT")
|
else: alert_manager.clear("POWER_UNDERVOLT")
|
||||||
@@ -397,22 +426,34 @@ def cleanup():
|
|||||||
def write_status(cpu,temp,mem,disk):
|
def write_status(cpu,temp,mem,disk):
|
||||||
display_state=alert_manager.status_snapshot()
|
display_state=alert_manager.status_snapshot()
|
||||||
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
||||||
disk_total,disk_used,disk_free=disk_usage("/")
|
storage=storage_cache.sample()
|
||||||
|
disk_total,disk_used,disk_free=(storage[k] for k in ("total","used","free")) if storage else (None,None,None)
|
||||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
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()),
|
||||||
|
"collection":{"interval_seconds":min(1.0,SENSOR_INTERVAL,NETWORK_INTERVAL,SERVICE_INTERVAL,HEALTH_INTERVAL),
|
||||||
|
"refresh":{
|
||||||
|
"system":{"interval_seconds":SENSOR_INTERVAL,"observed_at":sensor_observed_at,"next_update_at":sensor_observed_at+SENSOR_INTERVAL if sensor_observed_at else None},
|
||||||
|
"network":{"interval_seconds":NETWORK_INTERVAL,"observed_at":network_observed_at,"next_update_at":network_observed_at+NETWORK_INTERVAL if network_observed_at else None},
|
||||||
|
"storage":{"interval_seconds":86400,"observed_at":storage_cache.observed_at,"next_update_at":storage_cache.next_update_at,"error":storage_cache.error},
|
||||||
|
"display":{"interval_seconds":1,"next_update_at":time.time()+1},
|
||||||
|
"alerts":{"interval_seconds":1,"next_update_at":time.time()+1}}},"uptime_seconds":uptime_seconds(),
|
||||||
"machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
|
"machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
|
||||||
"device":DEVICE_INFO,
|
"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),
|
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
||||||
"memory_percent":round(memory_calculated if memory_total else mem,1),
|
"memory_percent":round(memory_calculated if memory_total else mem,1),
|
||||||
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
|
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
|
||||||
"memory_available_bytes":memory_available,
|
"memory_available_bytes":memory_available,
|
||||||
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
"storage_observed_at":storage_cache.observed_at,
|
||||||
|
"disk_percent":round(disk,1) if disk is not None else None,"disk_total_bytes":disk_total,
|
||||||
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||||
"root_readonly":bool(fs_readonly)},
|
"root_readonly":bool(fs_readonly)},
|
||||||
"power":health,
|
"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,
|
"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)}
|
||||||
@@ -457,11 +498,13 @@ try:
|
|||||||
if now-last_sensor >= SENSOR_INTERVAL:
|
if now-last_sensor >= SENSOR_INTERVAL:
|
||||||
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
||||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
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>=monitor_ready_at and now-last_net >= NETWORK_INTERVAL:
|
if now>=monitor_ready_at and now-last_net >= NETWORK_INTERVAL:
|
||||||
old_id=(net_iface,ip)
|
old_id=(net_iface,ip)
|
||||||
net_iface,net_kind,net_metric,ip_label,ip=primary_network()
|
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
|
raw_link_ok=ip!="NO IP"; raw_net_ok=internet() if raw_link_ok else False
|
||||||
if raw_link_ok:
|
if raw_link_ok:
|
||||||
link_failure_since=None;link_ok=True
|
link_failure_since=None;link_ok=True
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
from urllib.parse import parse_qs,urlencode,urlparse
|
from urllib.parse import parse_qs,urlencode,urlparse
|
||||||
from urllib.request import Request,urlopen
|
from urllib.request import Request,urlopen
|
||||||
|
|
||||||
APP_VERSION="3.7.1"
|
APP_VERSION="3.7.2"
|
||||||
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")
|
||||||
@@ -114,7 +114,9 @@ def validate_value(section,key,value,existing):
|
|||||||
|
|
||||||
|
|
||||||
def replace_ini_value(section,key,value):
|
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)
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||||
if not section_match:
|
if not section_match:
|
||||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
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)
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
return str(backup)
|
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):
|
def control_service(unit,action):
|
||||||
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name")
|
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 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":
|
if parsed.path=="/api/v1/services":
|
||||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||||
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
|
self.send_json(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"})
|
self.send_json(404,{"error":"not found"})
|
||||||
except ValueError as e: self.send_json(400,{"error":str(e)})
|
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):
|
||||||
path=urlparse(self.path).path
|
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
|
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||||
try:
|
try:
|
||||||
body=self.read_json()
|
body=self.read_json()
|
||||||
@@ -375,6 +407,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
str(body.get("previous_name","")))
|
str(body.get("previous_name","")))
|
||||||
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
|
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
|
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")
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Daily filesystem capacity cache, persisted in RAM across monitor restarts."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import disk_usage
|
||||||
|
|
||||||
|
|
||||||
|
class StorageCache:
|
||||||
|
def __init__(self, path, reader=disk_usage, clock=time.time):
|
||||||
|
self.path, self.reader, self.clock = Path(path), reader, clock
|
||||||
|
self.values = None
|
||||||
|
self.observed_at = None
|
||||||
|
self.next_update_at = 0
|
||||||
|
self.error = False
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text())
|
||||||
|
if not isinstance(data,dict):raise ValueError('invalid cache')
|
||||||
|
retry = data.get('next_update_at')
|
||||||
|
if data.get('error') is True and isinstance(retry,(int,float)) and self.clock() < retry <= self.clock()+3600:
|
||||||
|
self.error, self.next_update_at = True, retry
|
||||||
|
values = data['values']
|
||||||
|
if all(isinstance(values[k], int) and not isinstance(values[k], bool) and values[k] >= 0 for k in ('total', 'used', 'free')) and values['total'] > 0 and values['used'] <= values['total']:
|
||||||
|
at = data['observed_at']
|
||||||
|
if isinstance(at, (int, float)) and 0 <= self.clock() - at < 90000:
|
||||||
|
self.values, self.observed_at = values, at
|
||||||
|
if not self.error:self.next_update_at = at + 86400
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def sample(self):
|
||||||
|
now = self.clock()
|
||||||
|
if now < self.next_update_at:
|
||||||
|
return self.values
|
||||||
|
try:
|
||||||
|
usage = self.reader('/')
|
||||||
|
if usage.total <= 0 or not 0 <= usage.used <= usage.total or usage.free < 0:
|
||||||
|
raise ValueError('invalid capacity')
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self.error = True
|
||||||
|
self.next_update_at = now + 3600
|
||||||
|
self.persist()
|
||||||
|
return self.values
|
||||||
|
self.values = dict(total=usage.total, used=usage.used, free=usage.free)
|
||||||
|
self.observed_at = now
|
||||||
|
self.next_update_at = now + 86400
|
||||||
|
self.error = False
|
||||||
|
self.persist()
|
||||||
|
return self.values
|
||||||
|
|
||||||
|
def persist(self):
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path.write_text(json.dumps(dict(values=self.values, observed_at=self.observed_at,
|
||||||
|
next_update_at=self.next_update_at, error=self.error)))
|
||||||
|
except OSError:
|
||||||
|
# The in-memory sample remains valid even if the RAM cache cannot be persisted.
|
||||||
|
pass
|
||||||
@@ -99,6 +99,11 @@ wifi_recover = -68
|
|||||||
# 弱信号持续多少秒才正式告警。
|
# 弱信号持续多少秒才正式告警。
|
||||||
wifi_weak_seconds = 30
|
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]
|
[dark_mode]
|
||||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||||
|
|||||||
@@ -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,5 @@
|
|||||||
|
# pigway-raspberry-pi-agent v3.9.0
|
||||||
|
|
||||||
|
2026-10-15 已与 Console v2.4.0 部署。API 状态提供 collection.interval_seconds、system.observed_at 和 network.observed_at。Gateway 实际版本 3.9.0,状态采样时间与 1 秒/5 秒更新实测通过。旧程序在 /home/way/pigway-agent-backup-v3.9.0-20261015,目录 0700。
|
||||||
|
|
||||||
|
生产两设备在线。认证 API 连续观测确认树莓派缓存每秒、网络 5 秒、NAS 实时约 10 秒;最近三个网络 10 秒历史桶各 2 个样本,没有每秒重复入库。Console 保留原名称、证书、存储缓存和一年历史策略。部署详情见 Console [生产记录](https://tea.pigway.com/way/pigway-device-console/src/branch/main/docs/DEPLOYMENT-v2.4.0-2026-10-15.md)。
|
||||||
@@ -4,7 +4,7 @@ English | [简体中文](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)
|
|||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
PIGWay Device 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.
|
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:
|
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:
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ sudo reboot
|
|||||||
Install the Agent first. Its API is opt-in:
|
Install the Agent first. Its API is opt-in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://tea.pigway.com/way/pigway-device-agent.git
|
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
|
||||||
cd pigway-device-agent
|
cd pigway-raspberry-pi-agent
|
||||||
sudo ./install.sh --disable-api
|
sudo ./install.sh --disable-api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 兼容性结论
|
## 兼容性结论
|
||||||
|
|
||||||
PIGWay Device 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。
|
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。硬件接口保持原实现:
|
Yahboom RGB Cooling HAT 已拆分为独立插件。插件使用 Debian 系统包 `python3-smbus2` 与 `python3-pil`,不会通过 pip 修改受 PEP 668 管理的系统 Python。硬件接口保持原实现:
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ sudo reboot
|
|||||||
先安装通用监控 Agent:
|
先安装通用监控 Agent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://tea.pigway.com/way/pigway-device-agent.git
|
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
|
||||||
cd pigway-device-agent
|
cd pigway-raspberry-pi-agent
|
||||||
sudo ./install.sh --disable-api
|
sudo ./install.sh --disable-api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+18
-7
@@ -12,13 +12,15 @@ for arg in "$@"; do
|
|||||||
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
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/storage_cache.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; }
|
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
|
[ "$(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.
|
# 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; }
|
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
|
||||||
mkdir -p /usr/local/share/pigway-pi-control
|
mkdir -p /usr/local/share/pigway-pi-control
|
||||||
|
install -m 0644 app/storage_cache.py /usr/local/share/pigway-pi-control/storage_cache.py
|
||||||
|
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 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.py /usr/local/sbin/pigway-pi-control
|
||||||
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
||||||
@@ -31,12 +33,21 @@ fi
|
|||||||
# but ignored by the monitor and never exposed as monitor configuration.
|
# but ignored by the monitor and never exposed as monitor configuration.
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import configparser
|
import configparser,re
|
||||||
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
|
p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser();c.read_string(text)
|
||||||
with p.open('a') as f:
|
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},
|
||||||
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
'alerts':{'ethernet_min_speed_mbps':'1000','ethernet_speed_seconds':'15'},
|
||||||
if not c.has_section(section):
|
'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
||||||
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.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
|
PY
|
||||||
if [ ! -s /etc/pigway-pi-control-api.token ]; then
|
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)
|
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=PIGWay Device Agent optional local API
|
Description=PIGWay Raspberry Pi Agent optional local API
|
||||||
After=network.target pigway-pi-control.service
|
After=network.target pigway-pi-control.service
|
||||||
Wants=network.target
|
Wants=network.target
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=PIGWay system monitor
|
Description=PIGWay Raspberry Pi Agent system monitor
|
||||||
After=local-fs.target systemd-modules-load.service network-online.target ssh.service
|
After=local-fs.target systemd-modules-load.service network-online.target ssh.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import sys,tempfile,unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'app'))
|
||||||
|
from storage_cache import StorageCache
|
||||||
|
|
||||||
|
|
||||||
|
class StorageTests(unittest.TestCase):
|
||||||
|
def test_daily_reads_and_restart_cache(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
now=[1000];reads=[]
|
||||||
|
def reader(path):
|
||||||
|
reads.append(path);return SimpleNamespace(total=1000,used=200,free=800)
|
||||||
|
p=Path(directory)/'cache.json'
|
||||||
|
cache=StorageCache(p,reader,lambda:now[0])
|
||||||
|
self.assertEqual(cache.sample()['used'],200)
|
||||||
|
for i in range(20):now[0]+=1;cache.sample()
|
||||||
|
self.assertEqual(len(reads),1)
|
||||||
|
restored=StorageCache(p,reader,lambda:now[0]);restored.sample()
|
||||||
|
self.assertEqual(len(reads),1)
|
||||||
|
now[0]=87400;restored.sample()
|
||||||
|
self.assertEqual(len(reads),2)
|
||||||
|
|
||||||
|
def test_failure_does_not_fabricate_zero_or_retry_each_second(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
now=[1000];reads=[]
|
||||||
|
def fail(path):reads.append(path);raise OSError('unavailable')
|
||||||
|
cache=StorageCache(Path(directory)/'cache.json',fail,lambda:now[0])
|
||||||
|
self.assertIsNone(cache.sample());self.assertTrue(cache.error)
|
||||||
|
now[0]+=5;self.assertIsNone(cache.sample())
|
||||||
|
self.assertEqual(len(reads),1)
|
||||||
|
|
||||||
|
def test_failed_read_cooldown_survives_restart(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
reads=[]
|
||||||
|
def fail(path):reads.append(path);raise OSError('unavailable')
|
||||||
|
p=Path(directory)/'cache.json'
|
||||||
|
StorageCache(p,fail,lambda:1000).sample()
|
||||||
|
restored=StorageCache(p,fail,lambda:1005)
|
||||||
|
self.assertTrue(restored.error);self.assertIsNone(restored.sample())
|
||||||
|
self.assertEqual(len(reads),1)
|
||||||
Reference in New Issue
Block a user