Compare commits

...
3 Commits
7 changed files with 153 additions and 21 deletions
+10
View File
@@ -59,3 +59,13 @@ This project targets Raspberry Pi. Other Linux platforms are not covered by its
## v3.8.0 网络流量 ## v3.8.0 网络流量
主接口每个网络采集周期读取 sysfs 的 rx_bytes/tx_bytes,以单调时间差计算字节/秒,API 新增 rx_bytes_per_second 和 tx_bytes_per_second。首次采样、接口切换、计数器重置或读取失败返回空值;不修改监控告警、硬件插件控制或 API 可选安装行为。2026-10-14 已在 Gateway 部署,实机上下行速率与 Console 标准历史指标验证通过。 主接口每个网络采集周期读取 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 -3
View File
@@ -1,17 +1,20 @@
ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore
56b7a3ed6a6a8dbbcf02a80b6219a5e6aab0df2ef04cdff90895784c311e5cfa README.md 948e700459c05a045234edfbbdd1efa240f9a0350221a40029b7416c0587e36c README.md
64a9708701f8c8d872a587e8a2845afff463797b06a2791319daadd705933d31 README.zh-CN.md 64a9708701f8c8d872a587e8a2845afff463797b06a2791319daadd705933d31 README.zh-CN.md
c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py
003e6766552823e8cd8ec56888c385d0607df0c14ab7dff166472fcf6914ff9c app/pigway_pi_control.py 69877b4ad2b8307db69495d629988ff4bca838dea9e36d839165029fdb6ed4a8 app/pigway_pi_control.py
757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py 757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py 3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
81ca44b8bdc384c7f27a39da8e785af2450241c65200edee61db95f48d67f058 app/storage_cache.py
d5a783b64a10fa43191a813ef73ef8f762e359b0bd42ec155724fa46216298b0 config/pigway-pi-control.conf d5a783b64a10fa43191a813ef73ef8f762e359b0bd42ec155724fa46216298b0 config/pigway-pi-control.conf
9d45f22221012a7fdb3406ce74a6113f618e9930fe460efb3869efd099e3b1d8 docs/DEPLOYMENT-v3.8.0-2026-10-14.md 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
b54ec1f3e9fb15473910686c54142a85d387e44ea5df427a4cf1b06052e789b3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md b54ec1f3e9fb15473910686c54142a85d387e44ea5df427a4cf1b06052e789b3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md
9222a2b6a38f6b6d7d88b30c05413c68c05f69ee499c8479eca05ee7c62e87c3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md 9222a2b6a38f6b6d7d88b30c05413c68c05f69ee499c8479eca05ee7c62e87c3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md
c104443a4c951ea7916f74473510362f684df7d916bf2a436b779513ba3fe396 install.sh 1b0aeaf4997bdb02b967d538cf276ad7462a3e22ec35fc0d046d779ef56cd1f8 install.sh
8a593c290da5bd0d037aee86c35264512afd574a2a0f60f2c29e9db63e0109bd systemd/pigway-pi-control-api.service 8a593c290da5bd0d037aee86c35264512afd574a2a0f60f2c29e9db63e0109bd systemd/pigway-pi-control-api.service
1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service 1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service
8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py 8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py
f78e31e0eeee4e02532885ace5c8f6f7011d084e4c7440b704408bc88495e40e tests/test_storage_cache.py
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
+24 -9
View File
@@ -1,12 +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.8.0" 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 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"
@@ -105,6 +106,8 @@ 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
sensor_observed_at=None
network_observed_at=None
network_rates=NetworkRates() network_rates=NetworkRates()
net_rx_bytes_per_second=None; net_tx_bytes_per_second=None 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" net_iface=""; net_kind="NET"; net_metric="DOWN"; net_speed_mbps=None; ip_label="IP4"; ip="NO IP"
@@ -142,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 ""
@@ -324,6 +328,7 @@ 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 is not None:
if disk>=DISK_CRIT: if disk>=DISK_CRIT:
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
alert_manager.clear("DISK_HIGH") alert_manager.clear("DISK_HIGH")
@@ -421,22 +426,31 @@ 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, "link_speed_mbps":net_speed_mbps,
"rx_bytes_per_second":net_rx_bytes_per_second, "rx_bytes_per_second":net_rx_bytes_per_second,
"tx_bytes_per_second":net_tx_bytes_per_second, "tx_bytes_per_second":net_tx_bytes_per_second,
@@ -484,12 +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,net_speed_mbps,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) 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
+57
View File
@@ -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
+5
View File
@@ -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)。
+2 -1
View File
@@ -12,13 +12,14 @@ 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/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 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/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
+41
View File
@@ -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)