From 1b9b61bcbce5d9ab6ead74348bd7b43bada6be29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 20:54:05 +0800 Subject: [PATCH] fix: keep local telemetry and display independent of monitor --- README.md | 4 +-- SHA256SUMS | 9 +++--- app/local_state.py | 60 +++++++++++++++++++++++++++++++++++++ app/runtime.py | 50 ++++++++++++++++++++----------- docs/HARDWARE_PLUGIN_API.md | 7 +++-- install.sh | 2 +- 6 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 app/local_state.py diff --git a/README.md b/README.md index 52ddb31..bea1bab 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,14 @@ sudo python3 /usr/local/lib/pigway-cooling-hat/service.py --check-config curl --unix-socket /run/pigway-plugins/yahboom-cooling-hat/api.sock http://localhost/v1/descriptor ``` -未接入监控时,OLED 显示独立运行与本机温度,风扇执行本地温控,RGB 采用正常灯效配置,默认全灭。 +无论是否接入监控,插件都自行采集 CPU、内存、温度、磁盘和主网络状态,OLED 主页显示本机数据,风扇执行本地温控,RGB 采用正常灯效配置,默认全灭。 温度传感器读取失败时请求风扇全速;停止插件时也请求全速并关闭 OLED/RGB,避免因退出显示程序而取消散热。硬件故障时不能保证执行成功,错误会记录日志。风扇无转速反馈,只报告设置档位,不伪造 RPM。 ## 接入监控 监控 Web → 系统配置 → 硬件插件中发现并手动接入。协议是本机 Unix socket 上的 HTTP API,默认不开放 TCP 端口。目录 0750、socket 0660,只有 root 和被明确加入 `pigway-hardware` 组的账户能访问。不要随意授予组成员资格。 -监控发送完整、带版本和有效期的状态;不发送动画帧或 MCU 指令。插件自己仲裁 OLED 页和 RGB 效果。告警页有页码并轮播主页,相同 RGB 效果不因切页重启。只有一个有效监控来源可以持有显示状态,重复/乱序版本会拒绝。状态超过 10 秒未更新(由客户端声明,允许3–30秒),旧告警失效,OLED 显示连接中断、RGB 请求关闭,风扇继续本地温控。主动断开则回独立运行。 +监控发送完整、带版本和有效期的状态;不发送动画帧或 MCU 指令。插件自己仲裁 OLED 页和 RGB 效果。告警页有页码并轮播主页,相同 RGB 效果不因切页重启。只有一个有效监控来源可以持有显示状态,重复/乱序版本会拒绝。状态超过 10 秒未更新(由客户端声明,允许3–30秒),旧告警失效,OLED 回到本机主页、RGB 回到正常灯效,风扇继续本地温控。主动断开行为相同。监控快照中的 system/network 字段保留 API v1 兼容,但不会覆盖插件的本地数据。采样失败显示 --,不伪装为零。 详情:[API v1](docs/HARDWARE_PLUGIN_API.md)。未来其他厂商或 GPIO/PWM 风扇应实现相同能力接口及独立驱动,不复用本板 I2C 寄存器。板上 MCU 的唯一进程所有权通过固定的物理设备文件锁 `/run/lock/pigway-i2c-1-mcu-0d.lock` 保证(不随 API socket 路径变化);其他不遵守此锁的软件仍需自行避免运行。 diff --git a/SHA256SUMS b/SHA256SUMS index fabba0f..23ebe01 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,12 +1,13 @@ -f335c4bce30fa88ee7a0a2748c997bd67388f691e3260db581e0707536fdbd60 README.md -fe61e7f12bfe36449a3a82969c8cdf3d79c2b5650cd292b5fc133fda0f6ed8d3 install.sh +5666fc2dc544eaa2fb9e11452740d05d621a7982a6f7d258b563e38fdf04a19a README.md +30646b607d69dcbddbd5b41119a22f63ffb8de22929d1d0f9ff3715326a5bca6 install.sh cd6bb5137dd5922e48866f6daf4b949a16f8fad321e77addd5b4f75181bc4248 uninstall.sh 1c7836f80b0845fbc5c4b8570b12d736e2a3c442b1ceb5d35d48cdd518e4ad50 app/drivers/yahboom.py 215a62acc0a5c87303d6c52c0a2585b87dfb5cdbafbe9e9e733523d48a6a9812 app/migrate.py 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -93c8e57569d90cf34bc8844d8e7d951da0704189324eddcc65e4232487d362ce app/runtime.py +1b8d4cf4e4668a7b1f111114e58a72fe28008190798c6e7674fb6a3e6f18dcca app/runtime.py e5153007a3562dc48468c6cee74de55de35844fc1e0e4d0cf00feb696991e14f app/service.py 5b16bb49bca6d4a37730ff564143f0f4d5ee73ff391bd79c983c29bc92fdcf66 app/settings.py 6440b23fb71a646ed7bb600471457147a6ed7bc850434dfff445c001c96a9480 config/pigway-cooling-hat.conf c373cdde2b937bbc58d4b227f3a1d2923f0816c13f1e34548ed1f3b26c101ed5 systemd/pigway-cooling-hat.service -fa7ab991a7a39cce72b218e407dcefbcede99c8d8eb3d9c161a5296d506c41a9 docs/HARDWARE_PLUGIN_API.md +73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md +7ca33e7a4361673d153b5e6212447d53091e223ee05ec24411083d6c052f2894 app/local_state.py diff --git a/app/local_state.py b/app/local_state.py new file mode 100644 index 0000000..8213bef --- /dev/null +++ b/app/local_state.py @@ -0,0 +1,60 @@ +"""Read-only local telemetry; no dependency on the monitoring application.""" +import re +import shutil +import subprocess +import time +from pathlib import Path + + +class LocalState: + def __init__(self): + self.previous_cpu=None + self.network_at=float('-inf') + self.network={'kind':'NET','metric':'--','ip_label':'IP4','ip':'NO IP'} + + def cpu(self): + values=list(map(int,Path('/proc/stat').read_text().splitlines()[0].split()[1:9])) + current=(sum(values),values[3]+values[4]);previous=self.previous_cpu;self.previous_cpu=current + if previous is None:return None + total=current[0]-previous[0] + return max(0,min(100,100*(1-(current[1]-previous[1])/total))) if total>0 else None + + @staticmethod + def memory(): + values={line.split(':')[0]:int(line.split()[1]) for line in Path('/proc/meminfo').read_text().splitlines()} + return 100*(1-values['MemAvailable']/values['MemTotal']) + + @staticmethod + def disk(): + usage=shutil.disk_usage('/') + return 100*usage.used/usage.total + + @staticmethod + def read_network(): + for family,address,label in [('-4','1.1.1.1','IP4'),('-6','2606:4700:4700::1111','IP6')]: + # Route lookup only: does not send packets or change network configuration. + result=subprocess.run(['ip',family,'route','get',address],capture_output=True,text=True,timeout=1) + dev=re.search(r'\bdev\s+(\S+)',result.stdout);src=re.search(r'\bsrc\s+(\S+)',result.stdout) + if not dev:continue + iface=dev.group(1);wireless=Path('/sys/class/net',iface,'wireless').exists();metric='--' + if wireless: + for line in Path('/proc/net/wireless').read_text().splitlines(): + if line.lstrip().startswith(iface+':'):metric=str(float(line.split()[3].rstrip('.')));break + else: + try:metric=Path('/sys/class/net',iface,'speed').read_text().strip() + except OSError:metric='UP' + return {'kind':'WIF' if wireless else 'ETH','metric':metric,'ip_label':label,'ip':src.group(1) if src else 'NO IP'} + return {'kind':'NET','metric':'DOWN','ip_label':'IP4','ip':'NO IP'} + + def sample(self): + system={};errors={} + for name,reader in [('cpu_percent',self.cpu),('memory_percent',self.memory),('disk_percent',self.disk), + ('temperature_c',lambda:float(Path('/sys/class/thermal/thermal_zone0/temp').read_text())/1000)]: + try:system[name]=reader() + except (OSError,ValueError,KeyError,IndexError,ZeroDivisionError) as exc:system[name]=None;errors[name]=str(exc) + if time.monotonic()-self.network_at>=5: + try:self.network=self.read_network() + except (OSError,ValueError,subprocess.SubprocessError) as exc: + self.network={'kind':'NET','metric':'--','ip_label':'IP4','ip':'UNKNOWN'};errors['network']=str(exc) + self.network_at=time.monotonic() + return {'system':system,'network':dict(self.network)},errors diff --git a/app/runtime.py b/app/runtime.py index db248c9..75cbbdb 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -5,6 +5,7 @@ import threading import time from pathlib import Path from settings import COLORS,EFFECTS,GROUPS +from local_state import LocalState def log(event,**fields): @@ -59,6 +60,7 @@ class Controller: def __init__(self,config,driver=None,clock=time.monotonic,temp_reader=temperature): self.config=config;self.driver=driver;self.clock=clock;self.temp_reader=temp_reader self.lock=threading.RLock();self.stop_event=threading.Event();self.threads=[] + self.local_reader=LocalState();self.local_state={'system':{},'network':{}};self.local_sampled_at=None self.state=None;self.expires=0;self.ever_connected=False;self.first_seen={} self.owner='NORMAL_HOME';self.page='NORMAL_HOME';self.page_since=clock() self.fan_level=0;self.temp=None;self.errors={};self.i2c_errors=0;self.rgb_mode='OFF' @@ -88,7 +90,7 @@ class Controller: alerts=sorted(data['alerts'],key=lambda a:(-a['priority'],self.first_seen.get(a['id'],now),a['id'])) byid={a['id']:a for a in alerts};owner=alerts[0]['id'] if alerts else 'NORMAL_HOME' if self.owner in byid and byid[self.owner]['priority']==byid[owner]['priority']:owner=self.owner - if not connected and self.ever_connected:owner='DISCONNECTED' + # External lease expiry clears only external alerts, never the local home. if owner!=self.owner: log('DISPLAY_OWNER',old=self.owner,new=owner) self.owner=owner;self.page=owner;self.page_since=now @@ -98,7 +100,7 @@ class Controller: self.page=pages[(pages.index(self.page)+1)%len(pages)];self.page_since=now rgb=byid.get(self.page) or byid.get(owner) return {'owner':owner,'page':self.page,'connected':connected,'alerts':alerts,'rgb':rgb, - 'system':data['system'],'network':data['network'],'config':self.config} + 'system':self.local_state['system'],'network':self.local_state['network'],'config':self.config} def view(self): with self.lock:return copy.deepcopy(self._view_locked()) @@ -109,7 +111,8 @@ class Controller: return {'available':self.driver is not None,'connected':v['connected'],'owner':v['owner'], 'oled_page':v['page'],'rgb_mode':self.rgb_mode,'fan_level':self.fan_level, 'fan_name':('OFF','L1','L2','L3','L4','MAX')[self.fan_level] if self.driver else 'UNAVAILABLE', - 'temperature_c':self.temp,'errors':dict(self.errors),'i2c_errors':self.i2c_errors} + 'temperature_c':self.temp,'local_state':copy.deepcopy(self.local_state), + 'local_sample_age_seconds':None if self.local_sampled_at is None else max(0,self.clock()-self.local_sampled_at),'errors':dict(self.errors),'i2c_errors':self.i2c_errors} def error(self,worker,exc): with self.lock: @@ -124,9 +127,22 @@ class Controller: def start(self): if self.driver is None:return self.driver.off();self.driver.oled_init() - for name,work in (('fan',self.fan_loop),('oled',self.oled_loop),('rgb',self.rgb_loop)): + for name,work in (('telemetry',self.telemetry_loop),('fan',self.fan_loop),('oled',self.oled_loop),('rgb',self.rgb_loop)): thread=threading.Thread(target=work,name=name,daemon=True);self.threads.append(thread);thread.start() + def telemetry_loop(self): + while not self.stop_event.is_set(): + try: + state,errors=self.local_reader.sample() + with self.lock: + self.local_state=state;self.local_sampled_at=self.clock() + for key in ('cpu_percent','memory_percent','disk_percent','temperature_c','network'): + if key in errors:self.error('telemetry_'+key,errors[key]) + else:self.recovered('telemetry_'+key) + self.recovered('telemetry') + except Exception as exc:self.error('telemetry',exc) + self.stop_event.wait(1) + def fan_loop(self): initialized=False while not self.stop_event.is_set(): @@ -155,20 +171,19 @@ class Controller: while not self.stop_event.is_set(): try: v=self.view();page=v['page'];system=v['system'];network=v['network'];home=False - if page=='DISCONNECTED':lines=[('MONITOR OFFLINE',''),('STATE EXPIRED',''),('LOCAL FAN ACTIVE',''),('CHECK MONITOR','')] - elif page=='NORMAL_HOME': + if page=='NORMAL_HOME': home=True - if not v['connected']: - lines=[('STANDALONE',''),(f"TMP {self.temp:.1f}C" if self.temp is not None else 'TMP UNKNOWN',''),('FAN LOCAL',''),('NO MONITOR','')] - else: - ip=network.get('ip','NO IP');label=network.get('ip_label','IP4') - if label=='IP6' and len(ip)>18:ip=ip[:7]+'..'+ip[-7:] - metric=network.get('metric','');kind=network.get('kind','NET') - try:signal=max(0,min(100,round((float(metric)+100)*2))) - except ValueError:signal=None - lines=[(f"CPU {system['cpu_percent']:.1f}%",f"MEM {system['memory_percent']:.1f}%"), - (f"TMP {system['temperature_c']:.1f}C",'FAN '+('OFF','L1','L2','L3','L4','MAX')[self.fan_level]), - (f"DSK {system['disk_percent']:.1f}%",f'WIF {signal}%' if kind=='WIF' and signal is not None else f'{kind} {metric}'),(label+' '+ip,'')] + ip=network.get('ip','NO IP');label=network.get('ip_label','IP4') + if label=='IP6' and len(ip)>18:ip=ip[:7]+'..'+ip[-7:] + metric=network.get('metric','--');kind=network.get('kind','NET') + try:signal=max(0,min(100,round((float(metric)+100)*2))) + except (ValueError,TypeError):signal=None + def value(key): + n=system.get(key) + return f'{n:.1f}' if isinstance(n,(float,int)) and math.isfinite(n) else '--' + lines=[('CPU '+value('cpu_percent')+'%','MEM '+value('memory_percent')+'%'), + ('TMP '+value('temperature_c')+'C','FAN '+('OFF','L1','L2','L3','L4','MAX')[self.fan_level]), + ('DSK '+value('disk_percent')+'%',f'WIF {signal}%' if kind=='WIF' and signal is not None else f'{kind} {metric}'),(label+' '+ip,'')] else: a=next(a for a in v['alerts'] if a['id']==page);index=v['alerts'].index(a)+1 lines=[(a['title'],f"{index}/{len(v['alerts'])}"),(a['l2'],''),(a['l3'],''),(a['l4'],'')] @@ -182,7 +197,6 @@ class Controller: @staticmethod def profile(view): c=view['config']['rgb'];a=view['rgb'] - if view['owner']=='DISCONNECTED':return ('off',),'DISCONNECTED_OFF' if a: group=a['category'];level=('warning','critical','emergency')[int(a['severity'])-1] label=f"{group}_{level}_{c['alert_mode']}".upper() diff --git a/docs/HARDWARE_PLUGIN_API.md b/docs/HARDWARE_PLUGIN_API.md index d0f9b89..f8b1301 100644 --- a/docs/HARDWARE_PLUGIN_API.md +++ b/docs/HARDWARE_PLUGIN_API.md @@ -68,8 +68,11 @@ with a color picker. Plugins remain authoritative for all validation. Monitoring continues without installed or reachable plugins. Hardware plugins continue local temperature control without the monitor. Expired snapshots must -not keep showing stale alerts as live faults. The Yahboom implementation displays -MONITOR OFFLINE and requests RGB OFF; explicit detach returns to standalone mode. +not keep showing stale alerts as live faults. The Yahboom implementation returns +to its locally sampled home and configured normal RGB mode on expiry or detach. +It samples CPU, memory, disk, temperature and primary network independently. +API v1 system/network fields remain accepted for compatibility but never override +local telemetry. Monitor connectivity only supplies additional alerts. Its thermal read failure and service shutdown request full fan speed as a cooling fallback; actual execution still depends on functioning hardware. diff --git a/install.sh b/install.sh index 1ba8dff..a23cf6c 100755 --- a/install.sh +++ b/install.sh @@ -11,7 +11,7 @@ while [ "$#" -gt 0 ]; do *) echo "ERROR: unknown option: $1" >&2; exit 2 ;; esac done -for f in app/migrate.py app/service.py app/runtime.py app/settings.py app/drivers/yahboom.py app/oled_font_5x7.bin config/pigway-cooling-hat.conf systemd/pigway-cooling-hat.service; do +for f in app/local_state.py app/migrate.py app/service.py app/runtime.py app/settings.py app/drivers/yahboom.py app/oled_font_5x7.bin config/pigway-cooling-hat.conf systemd/pigway-cooling-hat.service; do [ -f "$f" ] || { echo "ERROR: required file missing: $f" >&2; exit 1; } done [ "$(id -u)" -eq 0 ] || { echo 'ERROR: run as root' >&2; exit 1; }