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
+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}