feat: add standalone capability-based cooling plugin

This commit is contained in:
way
2026-09-27 20:24:15 +08:00
commit 1e05ccd08d
13 changed files with 899 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# PIGWay Cooling HAT
独立运行的本机硬件插件。当前驱动:Yahboom RGB Cooling HAT(MCU 0x0D、OLED 0x3C、128×32)。不需要安装 PIGWay Pi Control;也不会自动启动它。
## 安装与独立运行
```bash
sudo ./install.sh
# 只安装,不启动/启用新服务:
sudo ./install.sh --no-start
# 显式迁移旧集成版的硬件设置:
sudo ./install.sh --import-monitor-config /etc/pigway-pi-control.conf
```
启用 I2C 后安装;需要 python3-smbus2、python3-pil。安装器检查文件及总线占用,不停止其他进程。配置:`/etc/pigway-cooling-hat.conf`。
```bash
sudo systemctl start pigway-cooling-hat.service
sudo journalctl -u pigway-cooling-hat.service -f
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 采用正常灯效配置,默认全灭。
温度传感器读取失败时请求风扇全速;停止插件时也请求全速并关闭 OLED/RGB,避免因退出显示程序而取消散热。硬件故障时不能保证执行成功,错误会记录日志。风扇无转速反馈,只报告设置档位,不伪造 RPM。
## 接入监控
监控 Web → 系统配置 → 硬件插件中发现并手动接入。协议是本机 Unix socket 上的 HTTP API,默认不开放 TCP 端口。目录 0750、socket 0660,只有 root 和被明确加入 `pigway-hardware` 组的账户能访问。不要随意授予组成员资格。
监控发送完整、带版本和有效期的状态;不发送动画帧或 MCU 指令。插件自己仲裁 OLED 页和 RGB 效果。告警页有页码并轮播主页,相同 RGB 效果不因切页重启。只有一个有效监控来源可以持有显示状态,重复/乱序版本会拒绝。状态超过 10 秒未更新(由客户端声明,允许3–30秒),旧告警失效,OLED 显示连接中断、RGB 请求关闭,风扇继续本地温控。主动断开则回独立运行。
详情:[API v1](docs/HARDWARE_PLUGIN_API.md)。未来其他厂商或 GPIO/PWM 风扇应实现相同能力接口及独立驱动,不复用本板 I2C 寄存器。板上 MCU 的唯一进程所有权通过固定的物理设备文件锁 `/run/lock/pigway-i2c-1-mcu-0d.lock` 保证(不随 API socket 路径变化);其他不遵守此锁的软件仍需自行避免运行。
## RGB 与已知限制
默认 MCU 内置呼吸:CPU红、电源黄、内存紫、存储白、网络蓝、服务青;严重等级使用速度档1/2/3,可配置。速度档不是精确秒数,预置色不支持任意 RGB 或橙色呼吸。相同 effect/speed/color 不重复下发,无周期性“预热”。
自定义颜色常亮和闪烁仍保留。闪烁是实验性兼容路径:全选→R→全选→G→全选→B,每条间隔5ms;亮灭均完整写三个通道,灭灯写 RGB=0;实际每段至少 `custom_min_hold_ms`(最低1500ms),从指令完成后计时,不追赶延迟边沿。既往实机曾出现异色/漏执行,未证明存在完全稳定的自定义协议,不宣称这些限制能修好 MCU。
OLED 与 RGB 各自一个线程,风扇有独立温控线程;访问同一总线时串行,完整 RGB 写入不能被风扇插入。风扇仅写0x08,不补写RGB。OLED字体逐字节从原项目迁入,未重新生成或替换。
## 卸载
`sudo ./uninstall.sh` 只移除此插件,保留配置,不修改监控项目或其他服务。先在监控 Web 断开插件可避免离线条目。
+12
View File
@@ -0,0 +1,12 @@
f335c4bce30fa88ee7a0a2748c997bd67388f691e3260db581e0707536fdbd60 README.md
fe61e7f12bfe36449a3a82969c8cdf3d79c2b5650cd292b5fc133fda0f6ed8d3 install.sh
cd6bb5137dd5922e48866f6daf4b949a16f8fad321e77addd5b4f75181bc4248 uninstall.sh
1c7836f80b0845fbc5c4b8570b12d736e2a3c442b1ceb5d35d48cdd518e4ad50 app/drivers/yahboom.py
215a62acc0a5c87303d6c52c0a2585b87dfb5cdbafbe9e9e733523d48a6a9812 app/migrate.py
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
93c8e57569d90cf34bc8844d8e7d951da0704189324eddcc65e4232487d362ce app/runtime.py
e5153007a3562dc48468c6cee74de55de35844fc1e0e4d0cf00feb696991e14f app/service.py
5b16bb49bca6d4a37730ff564143f0f4d5ee73ff391bd79c983c29bc92fdcf66 app/settings.py
743195ac2ca2b7656388ba0b461072fd91bad5492264a79c2466a40e839de36d config/pigway-cooling-hat.conf
c373cdde2b937bbc58d4b227f3a1d2923f0816c13f1e34548ed1f3b26c101ed5 systemd/pigway-cooling-hat.service
fa7ab991a7a39cce72b218e407dcefbcede99c8d8eb3d9c161a5296d506c41a9 docs/HARDWARE_PLUGIN_API.md
+127
View File
@@ -0,0 +1,127 @@
"""Optional Yahboom cooling HAT transport; importing it never opens I2C.
Built-in effects avoid continuous host writes. Custom RGB remains experimental:
selecting all LEDs before each channel helped one slow calibration run, but did
not establish reliable operation across runs or prove any MCU timing guarantee.
"""
import time
import fcntl
class Yahboom:
def __init__(self, lock, on_error, font):
from smbus2 import SMBus
# Ownership belongs to the physical device, not to an API socket path.
self.ownership = open('/run/lock/pigway-i2c-1-mcu-0d.lock', 'a')
try:
fcntl.flock(self.ownership, fcntl.LOCK_EX | fcntl.LOCK_NB)
self.bus = SMBus(1)
except Exception:
self.ownership.close()
raise
self.lock = lock
self.on_error = on_error
self.state = None
self.font = font
self.fan_level = None
def write(self, method, *args):
with self.lock:
try:
return getattr(self.bus, method)(*args)
except OSError:
self.state = None
self.on_error()
raise
def _mcu(self, register, value):
self.write("write_byte_data", 0x0D, register, value)
def off(self):
with self.lock:
if self.state == ("off",):
return
self._mcu(0x07, 0)
self.state = ("off",)
def effect(self, effect, speed, color, delay_ms=10):
state = ("effect", effect, speed, color)
with self.lock:
if self.state == state:
return
# Parameters first, then the documented effect selector. Never
# restart an unchanged effect when OLED pages or owners change.
self._mcu(0x05, speed)
time.sleep(delay_ms / 1000)
self._mcu(0x06, color)
time.sleep(delay_ms / 1000)
self._mcu(0x04, effect)
self.state = state
def color(self, channels):
channels = tuple(channels)
if len(channels) != 3 or any(not 0 <= x <= 255 for x in channels):
raise ValueError("RGB channels must be 0..255")
state = ("color", channels)
with self.lock:
if self.state == state:
return
if self.state is None or self.state[0] != "color":
self._mcu(0x07, 0)
time.sleep(0.005)
# One complete RGB update, in fixed R/G/B order. No guessed block
# write, clear register, or MCU effect command on each flash edge.
for register, value in enumerate(channels, 1):
self._mcu(0x00, 0xFF)
time.sleep(0.005)
self._mcu(register, value)
time.sleep(0.005)
self.state = state
def fan(self, level):
values = (0, 4, 6, 8, 9, 1)
with self.lock:
if level != self.fan_level:
self._mcu(0x08, values[level])
self.fan_level = level
def oled_init(self):
for c in (0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x02,0x81,0x7F,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF):
self.write("write_byte_data", 0x3C, 0, c)
def oled(self, lines, home=False):
# Same 5x7 font and page layout as the verified 128x32 implementation.
from PIL import Image
im = Image.new("1", (128,32), 0)
pix = im.load()
def text(x,y,value):
for ch in value:
code=ord(ch)
if code*5+5>len(self.font): code=ord("?")
for cx in range(5):
col=self.font[code*5+cx]
for cy in range(7):
if col & (1<<cy) and 0<=x+cx<128 and 0<=y+cy<32:
pix[x+cx,y+cy]=1
x+=6
if x>122: break
for row,(left,right) in enumerate(lines[:4]):
text(0,row*8,str(left))
if right: text(72 if home else max(0,128-len(str(right))*6),row*8,str(right))
data=[sum(pix[x,pg*8+bit]<<bit for bit in range(8)) for pg in range(4) for x in range(128)]
with self.lock:
for c in (0x21,0,127,0x22,0,3): self.write("write_byte_data",0x3C,0,c)
for i in range(0,len(data),16): self.write("write_i2c_block_data",0x3C,0x40,data[i:i+16])
def close(self):
errors=[]
# No fan OFF on shutdown: keep the last cooling level rather than
# removing cooling merely because the display service is stopping.
for name,action in (("rgb",self.off),("oled_clear",lambda:self.oled([])),
("oled_off",lambda:self.write("write_byte_data",0x3C,0,0xAE)),
("bus",self.bus.close),("ownership",self.ownership.close)):
try: action()
except Exception as exc: errors.append((name,str(exc)))
return errors
+25
View File
@@ -0,0 +1,25 @@
"""Explicit one-time migration; runtime never reads the monitor configuration."""
import argparse
import configparser
from pathlib import Path
from settings import SCHEMA,load,validated,check,save
def migrate(source):
c=configparser.ConfigParser()
if not c.read(source):raise ValueError('monitor config missing')
result={s:{k:v['default'] for k,v in spec.items()} for s,spec in SCHEMA.items()}
for section,old in (('fan','fan'),('rgb','led')):
if c.has_section(old):
values={k:v for k,v in c.items(old) if k in SCHEMA[section]}
result[section].update(validated({section:values})[section])
for old,key in (('oled_refresh_interval','refresh_seconds'),('page_interval','page_seconds')):
if c.has_option('timing',old):result['oled'].update(validated({'oled':{key:c.get('timing',old)}})['oled'])
check(result)
return result
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('source');p.add_argument('--check',action='store_true');p.add_argument('--output');a=p.parse_args()
result=migrate(a.source)
if a.output:save(a.output,result)
else:print('migration valid; no files changed')
Binary file not shown.
+229
View File
@@ -0,0 +1,229 @@
"""Local display arbitration and autonomous cooling, independent of the monitor."""
import copy
import math
import threading
import time
from pathlib import Path
from settings import COLORS,EFFECTS,GROUPS
def log(event,**fields):
print('event='+event+' '+ ' '.join(f'{k}={str(v).replace(chr(32),"_")}' for k,v in fields.items()),flush=True)
def temperature():
return float(Path('/sys/class/thermal/thermal_zone0/temp').read_text())/1000
def finite(value,low,high):
if isinstance(value,bool) or not isinstance(value,(int,float)) or not math.isfinite(value) or not low<=value<=high:
raise ValueError('invalid numeric state')
return value
def validate_state(payload):
if payload.get('api_version')!=1:raise ValueError('unsupported API version')
for key in ('source','session'):
if not isinstance(payload.get(key),str) or not 1<=len(payload[key])<=128:raise ValueError('invalid '+key)
revision=payload.get('revision')
if type(revision) is not int or revision<1:raise ValueError('invalid revision')
ttl=finite(payload.get('ttl_seconds',10),3,30)
alerts=payload.get('alerts',[])
if not isinstance(alerts,list) or len(alerts)>128:raise ValueError('too many alerts')
clean=[];ids=set()
for item in alerts:
if not isinstance(item,dict):raise ValueError('invalid alert')
a={}
for k in ('id','title','l2','l3','l4'):
v=item.get(k,'')
if not isinstance(v,str) or len(v)>128:raise ValueError('invalid alert text')
a[k]=v
if not a['id'] or a['id'] in ids:raise ValueError('duplicate or empty alert id')
ids.add(a['id'])
a['severity']=finite(item.get('severity'),1,3)
if int(a['severity'])!=a['severity']:raise ValueError('invalid severity')
a['priority']=finite(item.get('priority'),1,1000)
a['category']=item.get('category')
if a['category'] not in GROUPS:raise ValueError('invalid alert category')
clean.append(a)
system=payload.get('system',{});network=payload.get('network',{})
if not isinstance(system,dict) or not isinstance(network,dict):raise ValueError('invalid home state')
system={k:finite(system.get(k,0),-50 if k=='temperature_c' else 0,150 if k=='temperature_c' else 100)
for k in ('cpu_percent','temperature_c','memory_percent','disk_percent')}
network={k:str(network.get(k,''))[:128] for k in ('kind','metric','ip_label','ip')}
return {"source":payload['source'],"session":payload['session'],"revision":revision,"ttl_seconds":ttl,
"alerts":clean,"system":system,"network":network}
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.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'
def accept(self,payload):
value=validate_state(payload);now=self.clock()
with self.lock:
old=self.state
if old and now<self.expires:
if (old['source'],old['session'])!=(value['source'],value['session']):raise ValueError('another source holds the display lease')
if value['revision']<=old['revision']:raise ValueError('stale revision')
ids={a['id'] for a in value['alerts']}
self.first_seen={i:self.first_seen.get(i,now) for i in ids}
self.state=value;self.expires=now+value['ttl_seconds'];self.ever_connected=True
self._view_locked()
def release(self,source=None,session=None):
with self.lock:
if source is not None and self.state and (source,session)!=(self.state['source'],self.state['session']):
raise ValueError('display lease belongs to another source')
self.state=None;self.expires=0;self.first_seen={};self.ever_connected=False
self._view_locked()
def _view_locked(self):
now=self.clock();connected=self.state is not None and now<self.expires
data=self.state if connected else {'alerts':[],'system':{},'network':{}}
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'
if owner!=self.owner:
log('DISPLAY_OWNER',old=self.owner,new=owner)
self.owner=owner;self.page=owner;self.page_since=now
pages=['NORMAL_HOME']+[a['id'] for a in alerts] if connected else [owner]
if self.page not in pages:self.page=owner;self.page_since=now
if len(pages)>1 and now-self.page_since>=self.config['oled']['page_seconds']:
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}
def view(self):
with self.lock:return copy.deepcopy(self._view_locked())
def status(self):
v=self.view()
with self.lock:
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}
def error(self,worker,exc):
with self.lock:
value=str(exc)
if self.errors.get(worker)!=value:log('HARDWARE_ERROR',worker=worker,error=value)
self.errors[worker]=value
def recovered(self,worker):
with self.lock:
if self.errors.pop(worker,None) is not None:log('HARDWARE_RECOVERED',worker=worker)
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)):
thread=threading.Thread(target=work,name=name,daemon=True);self.threads.append(thread);thread.start()
def fan_loop(self):
initialized=False
while not self.stop_event.is_set():
try:
temp=self.temp_reader()
if not math.isfinite(temp):raise ValueError('temperature unavailable')
self.recovered('temperature')
except Exception as exc:
self.error('temperature',exc);temp=None
with self.lock:
c=dict(self.config['fan']);old=self.fan_level
curve=[c[k] for k in ('start','level2','level3','level4','max')]
wanted=5 if temp is None else sum(temp>=x for x in curve)
if initialized and wanted<old:
wanted=old-1 if temp<curve[max(0,old-1)]-c['hysteresis'] else old
try:
self.driver.fan(wanted)
with self.lock:self.fan_level=wanted;self.temp=temp
if not initialized or wanted!=old:log('FAN_LEVEL',old=old,new=wanted)
initialized=True;self.recovered('fan')
except Exception as exc:self.error('fan',exc)
self.stop_event.wait(1)
def oled_loop(self):
last=None;last_time=0
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':
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,'')]
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'],'')]
state=(lines,home)
if state!=last or self.clock()-last_time>=v['config']['oled']['refresh_seconds']:
self.driver.oled(lines,home);last=state;last_time=self.clock()
self.recovered('oled')
except Exception as exc:self.error('oled',exc)
self.stop_event.wait(.1)
@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()
if c['alert_mode']=='breathe':return ('effect',1,c[level+'_speed'],COLORS[GROUPS[group]],c['write_delay_ms']),label
mode='flash';prefix=level
else:
group='normal';prefix='normal';mode=c['normal_mode'];label='NORMAL_'+mode.upper()
if mode=='off':return ('off',),'OFF'
if mode in EFFECTS:return ('effect',EFFECTS[mode],c['normal_effect_speed'],COLORS[c['normal_effect_color']],c['write_delay_ms']),label
channels=tuple(c[group+'_'+k] for k in 'rgb')
if mode=='solid':return ('solid',channels),label
return ('flash',channels,max(c['custom_min_hold_ms'],c[prefix+'_flash_on_ms'])/1000,
max(c['custom_min_hold_ms'],c[prefix+'_flash_off_ms'])/1000),label
def rgb_loop(self):
applied=None;next_edge=0;on=True
while not self.stop_event.is_set():
try:
wanted,label=self.profile(self.view())
if wanted!=applied:
if wanted[0]=='off':self.driver.off()
elif wanted[0]=='effect':self.driver.effect(*wanted[1:])
else:self.driver.color(wanted[1])
applied=wanted;on=True
if wanted[0]=='flash':next_edge=self.clock()+wanted[2]
log('RGB_MODE',old=self.rgb_mode,new=label)
self.rgb_mode=label
if applied[0]=='flash' and self.clock()>=next_edge:
self.driver.color((0,0,0) if on else applied[1]);on=not on
next_edge=self.clock()+applied[2 if on else 3]
self.recovered('rgb')
except Exception as exc:
self.error('rgb',exc)
# Do not spin writes at 20Hz on an I2C error.
self.stop_event.wait(1)
self.stop_event.wait(.05)
def stop(self):
self.stop_event.set()
for thread in self.threads:thread.join()
if self.driver:
try:self.driver.fan(5)
except Exception as exc:self.error('shutdown_fan',exc)
for name,error in self.driver.close():self.error(name,error)
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Versioned HTTP API over a local Unix socket; no TCP listener."""
import argparse
import fcntl
import json
import os
import signal
import socketserver
import threading
import time
from http.server import BaseHTTPRequestHandler
from pathlib import Path
from settings import descriptor,load,validated,check,save
from runtime import Controller,log
class Server(socketserver.ThreadingMixIn,socketserver.UnixStreamServer):
daemon_threads=True
class Handler(BaseHTTPRequestHandler):
def setup(self):
super().setup();self.connection.settimeout(3)
def log_message(self,*args):
pass
def send(self,code,value):
data=json.dumps(value,allow_nan=False).encode()
self.send_response(code);self.send_header('Content-Type','application/json')
self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
def body(self):
size=int(self.headers.get('Content-Length','0'))
if not 0<size<=65536:raise ValueError('body must be 1..65536 bytes')
data=json.loads(self.rfile.read(size))
if not isinstance(data,dict):raise ValueError('JSON object required')
return data
def do_GET(self):
c=self.server.controller
if self.path=='/v1/descriptor':self.send(200,descriptor())
elif self.path=='/v1/status':self.send(200,c.status())
elif self.path=='/v1/config':
with c.lock:self.send(200,{'config':c.config,'schema':descriptor()['schema'],'notes':descriptor()['notes']})
else:self.send(404,{'error':'not found'})
def do_PUT(self):
try:
body=self.body();c=self.server.controller
if self.path=='/v1/state':
c.accept(body);self.send(200,{'ok':True,'revision':body['revision']})
elif self.path=='/v1/config':
updates=validated(body.get('updates',{}))
if not updates:raise ValueError('no configuration updates')
with c.lock:
value={s:dict(v) for s,v in c.config.items()}
for section,fields in updates.items():value[section].update(fields)
check(value)
path=self.server.config_path
if path.exists():
backup=path.with_name(path.name+'.bak.'+str(time.time_ns()))
backup.write_bytes(path.read_bytes());backup.chmod(0o600)
save(path,value);c.config=value
log('CONFIG_UPDATED',sections=','.join(updates));self.send(200,{'ok':True})
else:self.send(404,{'error':'not found'})
except (ValueError,TypeError,KeyError) as exc:self.send(400,{'error':str(exc)})
except Exception as exc:self.send(500,{'error':str(exc)})
def do_DELETE(self):
try:
if self.path!='/v1/state':self.send(404,{'error':'not found'});return
body=self.body()
self.server.controller.release(body.get('source'),body.get('session'))
self.send(200,{'ok':True})
except ValueError as exc:self.send(409,{'error':str(exc)})
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--config',default='/etc/pigway-cooling-hat.conf')
parser.add_argument('--socket',default='/run/pigway-plugins/yahboom-cooling-hat/api.sock')
parser.add_argument('--describe',action='store_true')
parser.add_argument('--check-config',action='store_true')
args=parser.parse_args()
if args.describe:print(json.dumps(descriptor(),ensure_ascii=False));return
config=load(args.config)
if args.check_config:print('configuration OK');return
path=Path(args.socket);path.parent.mkdir(parents=True,exist_ok=True,mode=0o750)
# Retain the fd for the lifetime of the daemon; prevents a second writer.
with (path.parent/'owner.lock').open('w') as ownership:
fcntl.flock(ownership,fcntl.LOCK_EX|fcntl.LOCK_NB)
controller=Controller(config)
try:
from drivers.yahboom import Yahboom
font=(Path(__file__).parent/'oled_font_5x7.bin').read_bytes()
if len(font)!=1275:raise ValueError('invalid font length')
def error():
with controller.lock:controller.i2c_errors+=1
controller.driver=Yahboom(threading.RLock(),error,font)
controller.start()
except Exception as exc:
controller.error('initialization',exc)
controller.stop()
controller.driver=None
path.unlink(missing_ok=True)
try:
with Server(str(path),Handler) as server:
path.chmod(0o660);server.controller=controller;server.config_path=Path(args.config)
def stop(*_):threading.Thread(target=server.shutdown,daemon=True).start()
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
log('START',plugin=descriptor()['id'],available=controller.driver is not None)
try:server.serve_forever(poll_interval=.2)
finally:controller.stop();log('STOP',i2c_errors=controller.i2c_errors)
finally:path.unlink(missing_ok=True)
if __name__=='__main__':main()
+94
View File
@@ -0,0 +1,94 @@
"""Configuration and public capability schema; no hardware imports."""
import configparser
import math
from pathlib import Path
VERSION = "1.0.0"
PLUGIN_ID = "yahboom-cooling-hat"
COLORS = {"red":0,"green":1,"blue":2,"yellow":3,"purple":4,"cyan":5,"white":6}
EFFECTS = {"flow":0,"breathe":1,"marquee":2,"rainbow":3,"colorful":4}
GROUPS = {"cpu":"red","power":"yellow","memory":"purple","storage":"white","network":"blue","service":"cyan"}
DEFAULT_RGB = {"cpu":(255,0,0),"power":(255,40,0),"memory":(255,0,255),"storage":(255,255,255),"network":(0,0,255),"service":(0,255,96),"normal":(0,0,0)}
SCHEMA = {}
def option(section,key,default,zh,en,kind="number",minimum=None,maximum=None,choices=None,unit="",visible=None):
SCHEMA.setdefault(section,{})[key] = dict(default=default,label={"zh":zh,"en":en},type=kind,
min=minimum,max=maximum,choices=choices,unit=unit,visible=visible)
for key,value,label in (("start",50,"启动温度"),("level2",53,"二档温度"),("level3",56,"三档温度"),("level4",59,"四档温度"),("max",62,"全速温度"),("hysteresis",1.5,"降档回差")):
option("fan",key,value,label,key.replace('_',' '),minimum=0,maximum=100 if key!="hysteresis" else 20,unit="°C")
option("oled","refresh_seconds",1,"刷新周期","Refresh interval",minimum=.2,maximum=10,unit="s")
option("oled","page_seconds",10,"轮播周期","Page interval",minimum=1,maximum=120,unit="s")
option("rgb","alert_mode","breathe","告警模式","Alert mode","enum",choices=["breathe","flash"])
option("rgb","normal_mode","off","正常状态灯效","Normal lighting","enum",choices=["off","solid","flash",*EFFECTS])
option("rgb","normal_effect_color","green","正常灯效颜色","Normal effect color","enum",choices=list(COLORS),visible={"normal_mode":["flow","breathe"]})
option("rgb","normal_effect_speed",2,"正常灯效速度档","Normal effect speed",minimum=1,maximum=3,visible={"normal_mode":list(EFFECTS)})
option("rgb","custom_min_hold_ms",1500,"自定义闪烁最小保持","Minimum custom flash hold",minimum=1500,maximum=60000,unit="ms")
option("rgb","write_delay_ms",10,"内置效果指令间隔","Built-in effect command gap",minimum=1,maximum=1000,unit="ms")
for level,value in (("warning",1),("critical",2),("emergency",3)):
option("rgb",level+"_speed",value,{"warning":"普通","critical":"严重","emergency":"紧急"}[level]+"呼吸速度档",level.title()+" breathing speed",minimum=1,maximum=3,visible={"alert_mode":["breathe"]})
for edge in ("on","off"):
option("rgb",f"{level}_flash_{edge}_ms",1500,f"{ {'warning':'普通','critical':'严重','emergency':'紧急'}[level]}告警{'亮灯' if edge=='on' else '灭灯'}时间",f"{level.title()} flash {edge}",minimum=50,maximum=60000,unit="ms",visible={"alert_mode":["flash"]})
for edge in ("on","off"):
option("rgb",f"normal_flash_{edge}_ms",1500,"正常闪烁"+("亮灯" if edge=="on" else "灭灯")+"时间",f"Normal flash {edge}",minimum=50,maximum=60000,unit="ms",visible={"normal_mode":["flash"]})
for group,values in DEFAULT_RGB.items():
for channel,value in zip("rgb",values):
title={"cpu":"处理器","power":"电源","memory":"内存","storage":"存储","network":"网络","service":"服务","normal":"正常"}[group]
option("rgb",f"{group}_{channel}",value,title+" · "+channel.upper(),group.title()+" · "+channel.upper(),minimum=0,maximum=255,
visible={"normal_mode":["solid","flash"]} if group=="normal" else {"alert_mode":["flash"]})
for group in DEFAULT_RGB:
for channel in "rgb":
SCHEMA["rgb"][group+"_"+channel].update(color_group=group,channel=channel)
def validated(data):
if not isinstance(data,dict):raise ValueError("configuration object required")
result={}
for section,values in data.items():
if section not in SCHEMA or not isinstance(values,dict): raise ValueError("unknown configuration section")
result[section]={}
for key,value in values.items():
if key not in SCHEMA[section]: raise ValueError("unknown option: "+key)
spec=SCHEMA[section][key]
if spec["type"]=="enum":
if value not in spec["choices"]: raise ValueError("invalid choice: "+key)
else:
if isinstance(value,bool): raise ValueError("invalid number: "+key)
value=float(value)
if not math.isfinite(value) or not spec["min"]<=value<=spec["max"]: raise ValueError("out of range: "+key)
if section=="rgb":
if not value.is_integer(): raise ValueError("integer required: "+key)
value=int(value)
result[section][key]=value
return result
def load(path):
result={s:{k:v["default"] for k,v in options.items()} for s,options in SCHEMA.items()}
cfg=configparser.ConfigParser();cfg.read(path)
raw={s:dict(cfg.items(s)) for s in cfg.sections()}
for section,values in validated(raw).items():result[section].update(values)
check(result)
return result
def check(config):
curve=[config["fan"][k] for k in ("start","level2","level3","level4","max")]
if any(a>=b for a,b in zip(curve,curve[1:])):raise ValueError("fan thresholds must strictly increase")
def save(path,config):
check(config)
parser=configparser.ConfigParser();parser.read_dict(config)
path=Path(path); temporary=path.with_suffix('.tmp')
with temporary.open('w') as f:parser.write(f)
temporary.chmod(0o640);temporary.replace(path)
def descriptor():
return {"api_version":1,"id":PLUGIN_ID,"name":"Yahboom RGB Cooling HAT","version":VERSION,
"capabilities":{
"fan":{"control":"levels","levels":["OFF","L1","L2","L3","L4","MAX"],"rpm":False,"autonomous":True,"owner":"plugin"},
"display":{"width":128,"height":32,"pages":True},
"rgb":{"modes":list(dict.fromkeys(["off","breathe","flash","solid",*EFFECTS])),"preset_colors":list(COLORS),"custom_flash_experimental":True}},
"notes":[{"zh":"内置呼吸使用预置颜色:CPU红、电源黄、内存紫、存储白、网络蓝、服务青。自定义闪烁仍可能异色。",
"en":"Built-in breathing uses presets: CPU red, power yellow, memory purple, storage white, network blue, services cyan. Custom flashing may still show incorrect colors."},
{"zh":"自定义闪烁的实际亮灭保持时间不低于最小保持时间。停止插件会请求风扇全速;风扇没有RPM反馈。",
"en":"Custom flash phases last at least the minimum hold time. Stopping the plugin requests full fan speed; RPM feedback is unavailable."}],
"schema":SCHEMA}
+101
View File
@@ -0,0 +1,101 @@
# Independent hardware plugin. / 独立硬件插件,不读取监控程序配置。
# RGB flash is experimental; built-in breathing is the default.
# 自定义闪烁仍可能异色,实际每段至少 custom_min_hold_ms。
[fan]
# 启动温度 / start; 0..100 °C
start = 50
# 二档温度 / level2; 0..100 °C
level2 = 53
# 三档温度 / level3; 0..100 °C
level3 = 56
# 四档温度 / level4; 0..100 °C
level4 = 59
# 全速温度 / max; 0..100 °C
max = 62
# 降档回差 / hysteresis; 0..20 °C
hysteresis = 1.5
[oled]
# 刷新周期 / Refresh interval; 0.2..10 s
refresh_seconds = 1
# 轮播周期 / Page interval; 1..120 s
page_seconds = 10
[rgb]
# 告警模式 / Alert mode; breathe/flash
alert_mode = breathe
# 正常状态灯效 / Normal lighting; off/solid/flash/flow/breathe/marquee/rainbow/colorful
normal_mode = off
# 正常灯效颜色 / Normal effect color; red/green/blue/yellow/purple/cyan/white
normal_effect_color = green
# 正常灯效速度档 / Normal effect speed; 1..3
normal_effect_speed = 2
# 自定义闪烁最小保持 / Minimum custom flash hold; 1500..60000 ms
custom_min_hold_ms = 1500
# 内置效果指令间隔 / Built-in effect command gap; 1..1000 ms
write_delay_ms = 10
# 普通呼吸速度档 / Warning breathing speed; 1..3
warning_speed = 1
# 普通告警亮灯时间 / Warning flash on; 50..60000 ms
warning_flash_on_ms = 1500
# 普通告警灭灯时间 / Warning flash off; 50..60000 ms
warning_flash_off_ms = 1500
# 严重呼吸速度档 / Critical breathing speed; 1..3
critical_speed = 2
# 严重告警亮灯时间 / Critical flash on; 50..60000 ms
critical_flash_on_ms = 1500
# 严重告警灭灯时间 / Critical flash off; 50..60000 ms
critical_flash_off_ms = 1500
# 紧急呼吸速度档 / Emergency breathing speed; 1..3
emergency_speed = 3
# 紧急告警亮灯时间 / Emergency flash on; 50..60000 ms
emergency_flash_on_ms = 1500
# 紧急告警灭灯时间 / Emergency flash off; 50..60000 ms
emergency_flash_off_ms = 1500
# 正常闪烁亮灯时间 / Normal flash on; 50..60000 ms
normal_flash_on_ms = 1500
# 正常闪烁灭灯时间 / Normal flash off; 50..60000 ms
normal_flash_off_ms = 1500
# 处理器 · R / Cpu · R; 0..255
cpu_r = 255
# 处理器 · G / Cpu · G; 0..255
cpu_g = 0
# 处理器 · B / Cpu · B; 0..255
cpu_b = 0
# 电源 · R / Power · R; 0..255
power_r = 255
# 电源 · G / Power · G; 0..255
power_g = 40
# 电源 · B / Power · B; 0..255
power_b = 0
# 内存 · R / Memory · R; 0..255
memory_r = 255
# 内存 · G / Memory · G; 0..255
memory_g = 0
# 内存 · B / Memory · B; 0..255
memory_b = 255
# 存储 · R / Storage · R; 0..255
storage_r = 255
# 存储 · G / Storage · G; 0..255
storage_g = 255
# 存储 · B / Storage · B; 0..255
storage_b = 255
# 网络 · R / Network · R; 0..255
network_r = 0
# 网络 · G / Network · G; 0..255
network_g = 0
# 网络 · B / Network · B; 0..255
network_b = 255
# 服务 · R / Service · R; 0..255
service_r = 0
# 服务 · G / Service · G; 0..255
service_g = 255
# 服务 · B / Service · B; 0..255
service_b = 96
# 正常 · R / Normal · R; 0..255
normal_r = 0
# 正常 · G / Normal · G; 0..255
normal_g = 0
# 正常 · B / Normal · B; 0..255
normal_b = 0
+78
View File
@@ -0,0 +1,78 @@
# Local hardware plugin API v1
The monitor and plugins are separate applications. The monitor has no hardware
library dependency. Plugins alone own their hardware. Installing or discovering
a plugin does not connect it or start another service.
## Transport and authorization
HTTP/1.0 or HTTP/1.1 over `/run/pigway-plugins/<id>/api.sock`. No TCP listener.
The parent directory is root-owned, mode 0750, and the socket is 0660 with group
`pigway-hardware`. Root and deliberately enrolled group members may control the
plugin. The monitor Web API authenticates mutations with its existing bearer
token before proxying local requests. Remote machine operations require that
machine's token too. API v1 clients reject incompatible descriptor versions.
## Endpoints
- `GET /v1/descriptor`: `api_version`, `id`, `name`, `version`, `capabilities`, `schema`.
- `GET /v1/status`: availability, connection state and capability-specific actual
or commanded state. A missing tachometer must never be presented as measured RPM.
- `GET /v1/config`: `config` plus `schema`.
- `PUT /v1/config`: `{"updates":{"section":{"key":"value"}}}`; validate the complete
candidate before persisting or applying anything. Does not restart the monitor.
- `PUT /v1/state`: replace the complete monitoring snapshot, never append commands.
- `DELETE /v1/state`: release a lease. Publisher passes `source` and `session`;
authorized local administration may use `{}` to detach the current source.
Example snapshot:
```json
{
"api_version": 1,
"source": "raspberrypi",
"session": "a-new-uuid-for-each-monitor-process",
"revision": 1,
"ttl_seconds": 10,
"system": {"cpu_percent": 12, "temperature_c": 50, "memory_percent": 20, "disk_percent": 30},
"network": {"kind": "WIF", "metric": "-60", "ip_label": "IP4", "ip": "192.168.1.2"},
"alerts": [{"id":"SERVICE_DOWN|APP","category":"service","severity":2,"priority":60,
"title":"SERVICE DOWN","l2":"APP","l3":"CHECK SERVICE","l4":"CRITICAL"}]
}
```
Alert categories: `cpu`, `power`, `memory`, `storage`, `network`, `service`.
The producer sends only current alerts, never historical power flags. Empty
`alerts` clears all previous alerts. A source/session owns its lease until
release or expiry; a competing source and repeated/out-of-order revisions are
rejected. Leases use the receiving process's monotonic clock, 3–30 seconds.
A new monitor session retries until the old lease expires if the old process
could not release it. Each plugin has an independent bounded-time publisher and
one replaceable latest snapshot, so an offline plugin cannot queue stale frames
or block monitoring or other plugins.
## Capability-driven UI
`fan` declares levels or PWM, RPM availability, autonomous operation and ownership.
`display` declares dimensions and pages. `rgb` declares modes, preset colors and
experimental custom flashing. Absent capability means absent controls. A plugin
with only a fan does not imply a screen or lighting device. New hardware needs
its own driver; I2C, PWM GPIO and power-only fans are not interchangeable.
`schema` groups fields by section. Each field provides bilingual `label`, `type`
(`number`/`enum`), numeric limits or choices, unit and optional visibility rules.
Optional `color_group` and `channel` (`r`/`g`/`b`) associate three numeric fields
with a color picker. Plugins remain authoritative for all validation.
## Failures and independent cooling
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.
Its thermal read failure and service shutdown request full fan speed as a cooling
fallback; actual execution still depends on functioning hardware.
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
plugin service. No API/Web service has a systemd Wants dependency that starts the
other application. Do not run two drivers for the same MCU, GPIO or fan.
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
NO_START=0
IMPORT=''
while [ "$#" -gt 0 ]; do
case "$1" in
--no-start) NO_START=1; shift ;;
--import-monitor-config) [ "$#" -ge 2 ] || exit 2; IMPORT="$2"; shift 2 ;;
-h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--import-monitor-config /etc/pigway-pi-control.conf]'; exit 0 ;;
*) 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
[ -f "$f" ] || { echo "ERROR: required file missing: $f" >&2; exit 1; }
done
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run as root' >&2; exit 1; }
[ -e /dev/i2c-1 ] || { echo 'ERROR: /dev/i2c-1 missing; configure I2C before installing this driver' >&2; exit 1; }
# Never stop unrelated I2C users. A previous plugin instance is allowed on upgrade.
owner="$(systemctl show -p MainPID --value pigway-cooling-hat.service 2>/dev/null || true)"
for pid in $(fuser /dev/i2c-1 2>/dev/null || true); do
[ "$pid" = "$owner" ] || { echo "ERROR: I2C already owned by PID $pid" >&2; exit 1; }
done
python3 -c 'import smbus2, PIL' 2>/dev/null || { apt-get update; apt-get install -y python3 python3-smbus2 python3-pil; }
# Validate a possible migration before changing installed files.
if [ -n "$IMPORT" ]; then PYTHONDONTWRITEBYTECODE=1 python3 app/migrate.py "$IMPORT" --check; fi
getent group pigway-hardware >/dev/null || groupadd --system pigway-hardware
mkdir -p /usr/local/lib/pigway-cooling-hat/drivers
install -m 0644 app/*.py app/oled_font_5x7.bin /usr/local/lib/pigway-cooling-hat/
install -m 0644 app/drivers/*.py /usr/local/lib/pigway-cooling-hat/drivers/
if [ ! -f /etc/pigway-cooling-hat.conf ]; then
install -m 0640 -g pigway-hardware config/pigway-cooling-hat.conf /etc/pigway-cooling-hat.conf
fi
if [ -n "$IMPORT" ]; then
cp /etc/pigway-cooling-hat.conf "/etc/pigway-cooling-hat.conf.bak.$(date +%Y%m%d-%H%M%S)"
PYTHONDONTWRITEBYTECODE=1 python3 app/migrate.py "$IMPORT" --output /etc/pigway-cooling-hat.conf
fi
install -m 0644 systemd/pigway-cooling-hat.service /etc/systemd/system/
systemctl daemon-reload
if [ "$NO_START" -eq 0 ]; then
systemctl enable pigway-cooling-hat.service >/dev/null
systemctl restart pigway-cooling-hat.service
systemctl is-active --quiet pigway-cooling-hat.service
fi
echo 'Hardware plugin installed independently. No monitor service was changed.'
echo 'Start if needed: sudo systemctl start pigway-cooling-hat.service'
echo 'Discover and connect from the monitor Web hardware plugins page.'
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=PIGWay optional Yahboom cooling HAT plugin
After=local-fs.target systemd-modules-load.service
[Service]
Type=simple
User=root
Group=pigway-hardware
ExecStart=/usr/bin/python3 /usr/local/lib/pigway-cooling-hat/service.py
Restart=on-failure
RestartSec=5
RuntimeDirectory=pigway-plugins/yahboom-cooling-hat
RuntimeDirectoryMode=0750
UMask=0027
[Install]
WantedBy=multi-user.target
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -euo pipefail
systemctl disable --now pigway-cooling-hat.service || true
rm -f /etc/systemd/system/pigway-cooling-hat.service
rm -rf /usr/local/lib/pigway-cooling-hat
systemctl daemon-reload
echo 'Plugin removed; config preserved. Monitor installation is unchanged.'