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