feat: add standalone capability-based cooling plugin
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user