diff --git a/README.md b/README.md index 5814c92..5f2e7d5 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,7 @@ This project targets Raspberry Pi. Other Linux platforms are not covered by its 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 秒。 diff --git a/SHA256SUMS b/SHA256SUMS index 9c10e52..2ac3944 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,20 @@ ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore -6fedfac0fd5c3d232fcd2a90c18baaffd02c42d00bb349208d6f19b799035261 README.md +948e700459c05a045234edfbbdd1efa240f9a0350221a40029b7416c0587e36c README.md 64a9708701f8c8d872a587e8a2845afff463797b06a2791319daadd705933d31 README.zh-CN.md c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py -439602f2f6188558f1f16b6630a65438806d98dabd4c6f7fa5270e9f3845bf54 app/pigway_pi_control.py +69877b4ad2b8307db69495d629988ff4bca838dea9e36d839165029fdb6ed4a8 app/pigway_pi_control.py 757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py 3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py +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 b54ec1f3e9fb15473910686c54142a85d387e44ea5df427a4cf1b06052e789b3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.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 1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service 8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py +f78e31e0eeee4e02532885ace5c8f6f7011d084e4c7440b704408bc88495e40e tests/test_storage_cache.py 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 2569ce1..27135bc 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform from pathlib import Path -APP_VERSION="3.9.0" +APP_VERSION="3.10.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 +from storage_cache import StorageCache +storage_cache=StorageCache("/run/pigway-pi-control/storage-cache.json") CFG="/etc/pigway-pi-control.conf" STATUS_PATH="/run/pigway-pi-control/status.json" @@ -144,7 +145,8 @@ def mem_pct(): k,v=l.split(":",1); vals[k]=int(v.split()[0]) return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"] 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): try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip() except Exception: return "" @@ -326,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") if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","") else: alert_manager.clear("MEMORY_HIGH") - if disk>=DISK_CRIT: - alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") - alert_manager.clear("DISK_HIGH") - elif disk>=DISK_HIGH: - alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE") - alert_manager.clear("DISK_CRITICAL") - else: - alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL") + if disk is not None: + if disk>=DISK_CRIT: + alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE") + alert_manager.clear("DISK_HIGH") + elif disk>=DISK_HIGH: + alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE") + alert_manager.clear("DISK_CRITICAL") + else: + alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL") if not link_ok: alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","") alert_manager.clear("INTERNET_DOWN") @@ -423,11 +426,18 @@ def cleanup(): def write_status(cpu,temp,mem,disk): display_state=alert_manager.status_snapshot() 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 state={ "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(), + "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()}, "device":DEVICE_INFO, "system":{"observed_at":sensor_observed_at,"load_average":load_average(),"cpu_percent":round(cpu,1), @@ -435,7 +445,8 @@ def write_status(cpu,temp,mem,disk): "memory_percent":round(memory_calculated if memory_total else mem,1), "memory_total_bytes":memory_total,"memory_used_bytes":memory_used, "memory_available_bytes":memory_available, - "disk_percent":round(disk,1),"disk_total_bytes":disk_total, + "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, "root_readonly":bool(fs_readonly)}, "power":health, diff --git a/app/storage_cache.py b/app/storage_cache.py new file mode 100644 index 0000000..48147d2 --- /dev/null +++ b/app/storage_cache.py @@ -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 diff --git a/install.sh b/install.sh index 187730a..3f02b1a 100755 --- a/install.sh +++ b/install.sh @@ -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/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; } 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/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 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control diff --git a/tests/test_storage_cache.py b/tests/test_storage_cache.py new file mode 100644 index 0000000..be1cfd0 --- /dev/null +++ b/tests/test_storage_cache.py @@ -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)