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