Files

110 lines
4.8 KiB
Python

"""Hardware-independent client for the versioned local plugin API."""
import copy
from contextlib import closing
import http.client
import json
import socket
import threading
import uuid
from pathlib import Path
PLUGIN_ROOT=Path('/run/pigway-plugins')
def socket_path(value):
path=Path(value)
if not path.is_absolute() or '..' in path.parts or path.name!='api.sock' or PLUGIN_ROOT not in path.parents:
raise ValueError('plugin socket must be /run/pigway-plugins/<plugin>/api.sock')
return str(path)
class Connection(http.client.HTTPConnection):
def __init__(self,path):
super().__init__('localhost',timeout=.8);self.path=socket_path(path)
def connect(self):
self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
try:self.sock.connect(self.path)
except Exception:self.sock.close();raise
def request(path,method,endpoint,payload=None):
with closing(Connection(path)) as conn:
body=None if payload is None else json.dumps(payload,allow_nan=False).encode()
conn.request(method,endpoint,body,{'Content-Type':'application/json'})
response=conn.getresponse();raw=response.read(262145)
if len(raw)>262144:raise ValueError('plugin response too large')
value=json.loads(raw)
if response.status>=400:raise ValueError(value.get('error','plugin request failed'))
return value
def registered(cfg):
if not cfg.has_section('plugins'):return {}
return {k:socket_path(v) for k,v in cfg.items('plugins') if v.strip()}
def discover(cfg):
linked=registered(cfg);paths={v for v in linked.values()}
paths.update(str(p) for p in PLUGIN_ROOT.glob('*/api.sock'))
result=[]
for path in sorted(paths)[:32]:
key=next((k for k,v in linked.items() if v==path),None)
try:
d=request(path,'GET','/v1/descriptor')
if d.get('api_version')!=1:raise ValueError('incompatible plugin API version')
status=request(path,'GET','/v1/status')
result.append({'id':key or d['id'],'socket':path,'linked':key is not None,'online':True,'descriptor':d,'status':status})
except Exception as exc:
result.append({'id':key or Path(path).parent.name,'socket':path,'linked':key is not None,'online':False,'error':str(exc)})
return result
class Publisher:
"""One replaceable snapshot, not an unbounded queue of animation commands."""
def __init__(self,plugins,source,log):
self.plugins=plugins;self.source=source;self.log=log
self.session=str(uuid.uuid4());self.revision=0;self.snapshot=None
self.lock=threading.Lock();self.stop_event=threading.Event();self.status={}
self.threads=[threading.Thread(target=self.run,args=(key,path),name='plugin-'+key,daemon=True)
for key,path in plugins.items()]
for thread in self.threads:thread.start()
def publish(self,state):
with self.lock:self.snapshot=copy.deepcopy(state)
def current_status(self):
with self.lock:return copy.deepcopy(self.status)
def run(self,key,path):
previous_error=None;revision=0;descriptor=None
while not self.stop_event.is_set():
with self.lock:state=self.snapshot
if state is not None:
revision+=1
payload={'api_version':1,'source':self.source,'session':self.session,'revision':revision,
'ttl_seconds':10,'system':state['system'],'network':state['network'],'alerts':state['display']['alerts']}
try:
if descriptor is None:
descriptor=request(path,'GET','/v1/descriptor')
if descriptor.get('api_version')!=1:raise ValueError('incompatible plugin API version')
request(path,'PUT','/v1/state',payload)
status=request(path,'GET','/v1/status')
with self.lock:self.status[key]={'online':True,'status':status,'name':descriptor.get('name',key),'capabilities':descriptor.get('capabilities',{})}
if previous_error is not None:self.log('INFO','PLUGIN_RECOVERED',plugin=key)
previous_error=None
except Exception as exc:
descriptor=None
error=str(exc)
with self.lock:self.status[key]={'online':False,'error':error}
if previous_error!=error:self.log('WARN','PLUGIN_UNAVAILABLE',plugin=key,error=error)
previous_error=error
self.stop_event.wait(1)
try:request(path,'DELETE','/v1/state',{'source':self.source,'session':self.session})
except Exception:pass # Server-side leases expire if transport is unavailable.
def close(self):
self.stop_event.set()
for thread in self.threads:thread.join()