128 lines
5.8 KiB
Python
128 lines
5.8 KiB
Python
#!/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':{k:v for k,v in c.config.items() if k!='integration'},'schema':{k:v for k,v in descriptor()['schema'].items() if k!='integration'},'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')
|
|
if 'integration' in updates:raise ValueError('change integration locally and restart the plugin')
|
|
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)
|
|
if config['integration']['enabled']!='true':
|
|
stopped=threading.Event()
|
|
signal.signal(signal.SIGTERM,lambda *_:stopped.set())
|
|
signal.signal(signal.SIGINT,lambda *_:stopped.set())
|
|
log('START',plugin=descriptor()['id'],integration=False,available=controller.driver is not None)
|
|
try:stopped.wait()
|
|
finally:controller.stop();log('STOP',i2c_errors=controller.i2c_errors)
|
|
return
|
|
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()
|