fix: keep local telemetry and display independent of monitor

This commit is contained in:
way
2026-09-27 20:54:05 +08:00
parent 72f305dd98
commit 1b9b61bcbc
6 changed files with 105 additions and 27 deletions
+2 -2
View File
@@ -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 curl --unix-socket /run/pigway-plugins/yahboom-cooling-hat/api.sock http://localhost/v1/descriptor
``` ```
未接入监控时,OLED 显示独立运行与本机温度,风扇执行本地温控,RGB 采用正常灯效配置,默认全灭。 无论是否接入监控,插件都自行采集 CPU、内存、温度、磁盘和主网络状态,OLED 主页显示本机数据,风扇执行本地温控,RGB 采用正常灯效配置,默认全灭。
温度传感器读取失败时请求风扇全速;停止插件时也请求全速并关闭 OLED/RGB,避免因退出显示程序而取消散热。硬件故障时不能保证执行成功,错误会记录日志。风扇无转速反馈,只报告设置档位,不伪造 RPM。 温度传感器读取失败时请求风扇全速;停止插件时也请求全速并关闭 OLED/RGB,避免因退出显示程序而取消散热。硬件故障时不能保证执行成功,错误会记录日志。风扇无转速反馈,只报告设置档位,不伪造 RPM。
## 接入监控 ## 接入监控
监控 Web → 系统配置 → 硬件插件中发现并手动接入。协议是本机 Unix socket 上的 HTTP API,默认不开放 TCP 端口。目录 0750、socket 0660,只有 root 和被明确加入 `pigway-hardware` 组的账户能访问。不要随意授予组成员资格。 监控 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 路径变化);其他不遵守此锁的软件仍需自行避免运行。 详情:[API v1](docs/HARDWARE_PLUGIN_API.md)。未来其他厂商或 GPIO/PWM 风扇应实现相同能力接口及独立驱动,不复用本板 I2C 寄存器。板上 MCU 的唯一进程所有权通过固定的物理设备文件锁 `/run/lock/pigway-i2c-1-mcu-0d.lock` 保证(不随 API socket 路径变化);其他不遵守此锁的软件仍需自行避免运行。
+5 -4
View File
@@ -1,12 +1,13 @@
f335c4bce30fa88ee7a0a2748c997bd67388f691e3260db581e0707536fdbd60 README.md 5666fc2dc544eaa2fb9e11452740d05d621a7982a6f7d258b563e38fdf04a19a README.md
fe61e7f12bfe36449a3a82969c8cdf3d79c2b5650cd292b5fc133fda0f6ed8d3 install.sh 30646b607d69dcbddbd5b41119a22f63ffb8de22929d1d0f9ff3715326a5bca6 install.sh
cd6bb5137dd5922e48866f6daf4b949a16f8fad321e77addd5b4f75181bc4248 uninstall.sh cd6bb5137dd5922e48866f6daf4b949a16f8fad321e77addd5b4f75181bc4248 uninstall.sh
1c7836f80b0845fbc5c4b8570b12d736e2a3c442b1ceb5d35d48cdd518e4ad50 app/drivers/yahboom.py 1c7836f80b0845fbc5c4b8570b12d736e2a3c442b1ceb5d35d48cdd518e4ad50 app/drivers/yahboom.py
215a62acc0a5c87303d6c52c0a2585b87dfb5cdbafbe9e9e733523d48a6a9812 app/migrate.py 215a62acc0a5c87303d6c52c0a2585b87dfb5cdbafbe9e9e733523d48a6a9812 app/migrate.py
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
93c8e57569d90cf34bc8844d8e7d951da0704189324eddcc65e4232487d362ce app/runtime.py 1b8d4cf4e4668a7b1f111114e58a72fe28008190798c6e7674fb6a3e6f18dcca app/runtime.py
e5153007a3562dc48468c6cee74de55de35844fc1e0e4d0cf00feb696991e14f app/service.py e5153007a3562dc48468c6cee74de55de35844fc1e0e4d0cf00feb696991e14f app/service.py
5b16bb49bca6d4a37730ff564143f0f4d5ee73ff391bd79c983c29bc92fdcf66 app/settings.py 5b16bb49bca6d4a37730ff564143f0f4d5ee73ff391bd79c983c29bc92fdcf66 app/settings.py
6440b23fb71a646ed7bb600471457147a6ed7bc850434dfff445c001c96a9480 config/pigway-cooling-hat.conf 6440b23fb71a646ed7bb600471457147a6ed7bc850434dfff445c001c96a9480 config/pigway-cooling-hat.conf
c373cdde2b937bbc58d4b227f3a1d2923f0816c13f1e34548ed1f3b26c101ed5 systemd/pigway-cooling-hat.service c373cdde2b937bbc58d4b227f3a1d2923f0816c13f1e34548ed1f3b26c101ed5 systemd/pigway-cooling-hat.service
fa7ab991a7a39cce72b218e407dcefbcede99c8d8eb3d9c161a5296d506c41a9 docs/HARDWARE_PLUGIN_API.md 73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
7ca33e7a4361673d153b5e6212447d53091e223ee05ec24411083d6c052f2894 app/local_state.py
+60
View File
@@ -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
+32 -18
View File
@@ -5,6 +5,7 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from settings import COLORS,EFFECTS,GROUPS from settings import COLORS,EFFECTS,GROUPS
from local_state import LocalState
def log(event,**fields): def log(event,**fields):
@@ -59,6 +60,7 @@ class Controller:
def __init__(self,config,driver=None,clock=time.monotonic,temp_reader=temperature): 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.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.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.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.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' 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'])) 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' 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 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: if owner!=self.owner:
log('DISPLAY_OWNER',old=self.owner,new=owner) log('DISPLAY_OWNER',old=self.owner,new=owner)
self.owner=owner;self.page=owner;self.page_since=now 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 self.page=pages[(pages.index(self.page)+1)%len(pages)];self.page_since=now
rgb=byid.get(self.page) or byid.get(owner) rgb=byid.get(self.page) or byid.get(owner)
return {'owner':owner,'page':self.page,'connected':connected,'alerts':alerts,'rgb':rgb, 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): def view(self):
with self.lock:return copy.deepcopy(self._view_locked()) 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'], 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, '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', '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): def error(self,worker,exc):
with self.lock: with self.lock:
@@ -124,9 +127,22 @@ class Controller:
def start(self): def start(self):
if self.driver is None:return if self.driver is None:return
self.driver.off();self.driver.oled_init() 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() 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): def fan_loop(self):
initialized=False initialized=False
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -155,20 +171,19 @@ class Controller:
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
v=self.view();page=v['page'];system=v['system'];network=v['network'];home=False 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','')] if page=='NORMAL_HOME':
elif page=='NORMAL_HOME':
home=True home=True
if not v['connected']: ip=network.get('ip','NO IP');label=network.get('ip_label','IP4')
lines=[('STANDALONE',''),(f"TMP {self.temp:.1f}C" if self.temp is not None else 'TMP UNKNOWN',''),('FAN LOCAL',''),('NO MONITOR','')] if label=='IP6' and len(ip)>18:ip=ip[:7]+'..'+ip[-7:]
else: metric=network.get('metric','--');kind=network.get('kind','NET')
ip=network.get('ip','NO IP');label=network.get('ip_label','IP4') try:signal=max(0,min(100,round((float(metric)+100)*2)))
if label=='IP6' and len(ip)>18:ip=ip[:7]+'..'+ip[-7:] except (ValueError,TypeError):signal=None
metric=network.get('metric','');kind=network.get('kind','NET') def value(key):
try:signal=max(0,min(100,round((float(metric)+100)*2))) n=system.get(key)
except ValueError:signal=None return f'{n:.1f}' if isinstance(n,(float,int)) and math.isfinite(n) else '--'
lines=[(f"CPU {system['cpu_percent']:.1f}%",f"MEM {system['memory_percent']:.1f}%"), lines=[('CPU '+value('cpu_percent')+'%','MEM '+value('memory_percent')+'%'),
(f"TMP {system['temperature_c']:.1f}C",'FAN '+('OFF','L1','L2','L3','L4','MAX')[self.fan_level]), ('TMP '+value('temperature_c')+'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,'')] ('DSK '+value('disk_percent')+'%',f'WIF {signal}%' if kind=='WIF' and signal is not None else f'{kind} {metric}'),(label+' '+ip,'')]
else: else:
a=next(a for a in v['alerts'] if a['id']==page);index=v['alerts'].index(a)+1 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'],'')] lines=[(a['title'],f"{index}/{len(v['alerts'])}"),(a['l2'],''),(a['l3'],''),(a['l4'],'')]
@@ -182,7 +197,6 @@ class Controller:
@staticmethod @staticmethod
def profile(view): def profile(view):
c=view['config']['rgb'];a=view['rgb'] c=view['config']['rgb'];a=view['rgb']
if view['owner']=='DISCONNECTED':return ('off',),'DISCONNECTED_OFF'
if a: if a:
group=a['category'];level=('warning','critical','emergency')[int(a['severity'])-1] group=a['category'];level=('warning','critical','emergency')[int(a['severity'])-1]
label=f"{group}_{level}_{c['alert_mode']}".upper() label=f"{group}_{level}_{c['alert_mode']}".upper()
+5 -2
View File
@@ -68,8 +68,11 @@ with a color picker. Plugins remain authoritative for all validation.
Monitoring continues without installed or reachable plugins. Hardware plugins Monitoring continues without installed or reachable plugins. Hardware plugins
continue local temperature control without the monitor. Expired snapshots must continue local temperature control without the monitor. Expired snapshots must
not keep showing stale alerts as live faults. The Yahboom implementation displays not keep showing stale alerts as live faults. The Yahboom implementation returns
MONITOR OFFLINE and requests RGB OFF; explicit detach returns to standalone mode. 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 Its thermal read failure and service shutdown request full fan speed as a cooling
fallback; actual execution still depends on functioning hardware. fallback; actual execution still depends on functioning hardware.
+1 -1
View File
@@ -11,7 +11,7 @@ while [ "$#" -gt 0 ]; do
*) echo "ERROR: unknown option: $1" >&2; exit 2 ;; *) echo "ERROR: unknown option: $1" >&2; exit 2 ;;
esac esac
done 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; } [ -f "$f" ] || { echo "ERROR: required file missing: $f" >&2; exit 1; }
done done
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run as root' >&2; exit 1; } [ "$(id -u)" -eq 0 ] || { echo 'ERROR: run as root' >&2; exit 1; }