From: Benjamin Braatz Date: Mon, 22 Mar 2021 14:35:13 +0000 (+0100) Subject: Adapt to changes in controlpi. X-Git-Tag: v0.3.0~31 X-Git-Url: http://git.graph-it.com/?a=commitdiff_plain;h=3f3a6bcad472604e4aeb5041129643b0b6de487e;p=graphit%2Fcontrolpi-wsserver.git Adapt to changes in controlpi. --- diff --git a/conf.json b/conf.json index 8e4c584..bb7087a 100644 --- a/conf.json +++ b/conf.json @@ -35,11 +35,5 @@ "command": "set state", "new state": false } - }, - "Debug Logger": { - "plugin": "Log", - "filter": [ - {} - ] } } diff --git a/controlpi-plugins/wsserver.py b/controlpi-plugins/wsserver.py deleted file mode 100644 index c19126b..0000000 --- a/controlpi-plugins/wsserver.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Provide … - -TODO: documentation, doctests, resilient conf-parsing -""" -import os -import json -from websockets import (WebSocketServerProtocol, ConnectionClosedOK, - ConnectionClosedError, serve) -from websockets.http import Headers -from http import HTTPStatus -from typing import Optional, Tuple - -from controlpi import BasePlugin, MessageBus, Message, PluginConfiguration - - -class Connection: - def __init__(self, bus: MessageBus, websocket: WebSocketServerProtocol, - path: str) -> None: - self._bus = bus - self._websocket = websocket - address = self._websocket.remote_address - self._address = address[0] - self._port = address[1] - self._name = f"{self._address}:{self._port}" - if path != '/': - self._name = path[1:] - self._bus.register(self._name, 'WSServer-Connection', - [{}], [{}], self._receive) - - async def _receive(self, message: Message) -> None: - json_message = json.dumps(message) - try: - await self._websocket.send(json_message) - except (ConnectionClosedOK, ConnectionClosedError): - pass - - async def run(self): - await self._bus.send({'sender': self._name, - 'event': 'connection opened', - 'address': self._address, - 'port': self._port}) - try: - async for json_message in self._websocket: - original_message = json.loads(json_message) - message = {'sender': self._name} - message.update(original_message) - await self._bus.send(message) - except (ConnectionClosedOK, ConnectionClosedError): - pass - await self._bus.send({'sender': self._name, - 'event': 'connection closed'}) - self._bus.unregister(self._name) - - -Response = Optional[Tuple[HTTPStatus, Headers, bytes]] - - -class WSServer(BasePlugin): - async def _handler(self, websocket: WebSocketServerProtocol, - path: str) -> None: - connection = Connection(self._bus, websocket, path) - await connection.run() - - async def _process_request(self, path: str, - request_headers: Headers) -> Response: - if 'Upgrade' in request_headers: - return None - if path == '/': - path = '/index.html' - response_headers = Headers() - response_headers['Server'] = 'controlpi-wsserver websocket server' - response_headers['Connection'] = 'close' - file_path = os.path.realpath(os.path.join(self._web_root, path[1:])) - if os.path.commonpath((self._web_root, file_path)) != self._web_root \ - or not os.path.exists(file_path) \ - or not os.path.isfile(file_path): - return (HTTPStatus.NOT_FOUND, response_headers, - f"File '{path}' not found!".encode()) - mime_type = 'application/octet-stream' - extension = file_path.split('.')[-1] - if extension == 'html': - mime_type = 'text/html' - elif extension == 'js': - mime_type = 'text/javascript' - elif extension == 'css': - mime_type = 'text/css' - response_headers['Content-Type'] = mime_type - body = open(file_path, 'rb').read() - response_headers['Content-Length'] = str(len(body)) - return HTTPStatus.OK, response_headers, body - - def _process_conf(self, conf: PluginConfiguration) -> None: - self._port = 80 - if 'port' in conf: - self._port = conf['port'] - else: - print(f"'port' not configured for WSServer '{self._name}'." - " Using 80.") - web_root = 'web' - if 'web root' in conf: - web_root = conf['web root'] - else: - print(f"'web root' not configured for WSServer '{self._name}'." - " Using 'web'.") - self._web_root = os.path.realpath(os.path.join(os.getcwd(), - web_root)) - super()._process_conf(conf) - - async def run(self) -> None: - await super().run() - await serve(self._handler, port=self._port, - process_request=self._process_request) - print(f"WSServer '{self._name}' serving on port {self._port}.") diff --git a/controlpi_plugins/wsserver.py b/controlpi_plugins/wsserver.py new file mode 100644 index 0000000..2a8d625 --- /dev/null +++ b/controlpi_plugins/wsserver.py @@ -0,0 +1,148 @@ +"""Provide websocket server plugin. + +… + +TODO: documentation, doctests +TODO: Mount multiple web apps from packages and file paths +TODO: Let Debug web app collapse/expand nested structures +TODO: Make Debug web app work with nested structures in commands +""" +import os +import json +from websockets import (WebSocketServerProtocol, ConnectionClosedOK, + ConnectionClosedError, serve) +from websockets.http import Headers +from http import HTTPStatus +from typing import Optional, Tuple + +from controlpi import BasePlugin, MessageBus, Message, MessageTemplate + + +class Connection: + """Connection to websocket. + + Instances are created on external connections to websocket. + """ + + def __init__(self, bus: MessageBus, + websocket: WebSocketServerProtocol) -> None: + """Initialise conncection. + + Message bus and websocket are set by websocket server when creating + the connection. + """ + self._bus = bus + self._websocket = websocket + address = self._websocket.remote_address + self._address = address[0] + self._port = address[1] + self._name = f"{self._address}:{self._port}" + self._bus.register(self._name, 'WSServer', + [MessageTemplate()], [MessageTemplate()], + self._receive) + + async def _receive(self, message: Message) -> None: + """Receive messages from bus and relay to websocket.""" + json_message = json.dumps(message) + try: + await self._websocket.send(json_message) + except (ConnectionClosedOK, ConnectionClosedError): + pass + + async def run(self): + """Listen on websocket and relay messages to bus.""" + await self._bus.send({'sender': self._name, + 'event': 'connection opened', + 'address': self._address, + 'port': self._port}) + try: + async for json_message in self._websocket: + original_message = json.loads(json_message) + message = {'sender': self._name} + message.update(original_message) + await self._bus.send(message) + except (ConnectionClosedOK, ConnectionClosedError): + pass + await self._bus.send({'sender': self._name, + 'event': 'connection closed'}) + self._bus.unregister(self._name) + + +Response = Optional[Tuple[HTTPStatus, Headers, bytes]] + + +class WSServer(BasePlugin): + """Websocket server as ControlPi plugin. + + Run websocket server on host and port given in configuration, serving + the contents in given web root. + """ + + CONF_SCHEMA = {'properties': {'host': {'type': 'string'}, + 'port': {'type': 'integer'}, + 'web root': {'type': 'string'}}} + """Schema for WServer plugin configuration. + + Optional configuration keys: + + - 'host': network interfaces to listen on (default: None, meaning all + interfaces) + - 'port': port to connect to (default: 80) + - 'web root': root of files to serve (default: 'web') + """ + + async def _handler(self, websocket: WebSocketServerProtocol, + path: str) -> None: + connection = Connection(self.bus, websocket) + await connection.run() + + async def _process_request(self, path: str, + request_headers: Headers) -> Response: + if 'Upgrade' in request_headers: + return None + if path == '/': + path = '/index.html' + response_headers = Headers() + response_headers['Server'] = 'controlpi-wsserver websocket server' + response_headers['Connection'] = 'close' + file_path = os.path.realpath(os.path.join(self._web_root, path[1:])) + if os.path.commonpath((self._web_root, file_path)) != self._web_root \ + or not os.path.exists(file_path) \ + or not os.path.isfile(file_path): + return (HTTPStatus.NOT_FOUND, response_headers, + f"File '{path}' not found!".encode()) + mime_type = 'application/octet-stream' + extension = file_path.split('.')[-1] + if extension == 'html': + mime_type = 'text/html' + elif extension == 'js': + mime_type = 'text/javascript' + elif extension == 'css': + mime_type = 'text/css' + response_headers['Content-Type'] = mime_type + body = open(file_path, 'rb').read() + response_headers['Content-Length'] = str(len(body)) + return HTTPStatus.OK, response_headers, body + + def process_conf(self) -> None: + """Get host, port and path settings from configuration.""" + self._port = 80 + if 'port' in self.conf: + self._port = self.conf['port'] + else: + print(f"'port' not configured for WSServer '{self.name}'." + " Using 80.") + web_root = 'web' + if 'web root' in self.conf: + web_root = self.conf['web root'] + else: + print(f"'web root' not configured for WSServer '{self.name}'." + " Using 'web'.") + self._web_root = os.path.realpath(os.path.join(os.getcwd(), + web_root)) + + async def run(self) -> None: + """Set up websocket server.""" + await serve(self._handler, port=self._port, + process_request=self._process_request) + print(f"WSServer '{self.name}' serving on port {self._port}.") diff --git a/setup.py b/setup.py index a62d71e..76aca3a 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setuptools.setup( long_description=long_description, long_description_content_type="text/markdown", url="http://docs.graph-it.com/graphit/controlpi-wsserver", - packages=["controlpi-plugins"], + packages=["controlpi_plugins"], install_requires=[ "websockets", "controlpi @ git+git://git.graph-it.com/graphit/controlpi.git",