Files
pigway-yahboom-cooling-hat/app/service.py
T

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