From 08ae2b8456348802962296d4954d27df72f96d9c Mon Sep 17 00:00:00 2001 From: IgnacioHR Date: Sat, 2 Apr 2022 17:50:42 +0200 Subject: [PATCH] bump version to 2.0. Web server is now aiohttp --- diematic_server/boiler.py | 3 +- diematic_server/diematicd.py | 105 ++++--- diematic_server/webserver.py | 295 ++++++++++---------- requirements.txt | 14 +- setup.py | 26 +- diematicd.service => unit/diematicd.service | 0 6 files changed, 221 insertions(+), 222 deletions(-) rename diematicd.service => unit/diematicd.service (100%) diff --git a/diematic_server/boiler.py b/diematic_server/boiler.py index ac0cd69..2cd24bd 100644 --- a/diematic_server/boiler.py +++ b/diematic_server/boiler.py @@ -1,5 +1,4 @@ import logging -import json from datetime import datetime @@ -321,7 +320,7 @@ def dump(self): return output def toJSON(self): - return json.dumps(self.fetch_data()) + return self.fetch_data() def set_write_pending(self, varname, newvalue): value = getattr(self, varname, None) diff --git a/diematic_server/diematicd.py b/diematic_server/diematicd.py index 16a0c6c..a829ad4 100644 --- a/diematic_server/diematicd.py +++ b/diematic_server/diematicd.py @@ -9,14 +9,12 @@ """ import logging import yaml -import json import os import signal import time import threading import argparse import sys -import concurrent.futures from lockfile import pidlockfile from boiler import Boiler @@ -25,8 +23,10 @@ from influxdb.exceptions import InfluxDBClientError from daemon import DaemonContext from daemon import pidfile -from http.server import ThreadingHTTPServer -from webserver import MakeDiematicWebRequestHandler +from aiohttp import web +import asyncio + +from webserver import DiematicWebRequestHandler """ @@ -114,12 +114,12 @@ def __init__(self): return def _get_context(self): - """ Returns or create and retuen the self.context that is used by the surrounding daemon """ + """ Returns or create and return the self.context that is used by the surrounding daemon """ try: return self.context except AttributeError: self.context = DaemonContext( - pidfile=pidlockfile.PIDLockFile('/var/run/diematic/diematicd.pid'), + pidfile=pidlockfile.PIDLockFile('/run/diematic/diematicd.pid'), working_directory="/etc/diematic" ) @@ -134,13 +134,13 @@ def _get_context(self): # self._open_streams_from_app_stream_paths() return self.context - def _get_executor(self): - """ create the executor pool or return if already created """ - try: - return self.executor - except AttributeError: - self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) - return self.executor + # def _get_executor(self): + # """ create the executor pool or return if already created """ + # try: + # return self.executor + # except AttributeError: + # self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + # return self.executor def _value_writer(self): """ consumes a job writing to the boiler """ @@ -194,25 +194,45 @@ def _value_writer(self): def run(self): self._reload_configuration(None,None) + if self.args.server == 'both' or self.args.server == 'loop': + self.start_main_program() + if self.args.server == 'both' or self.args.server == 'web': self.startWebServer() - - if self.args.server == 'both' or self.args.server == 'loop': - while(True): - self.do_main_program() - time.sleep(60) # a minute - def startWebServer(self): - self._create_boiler() - self.webServer = ThreadingHTTPServer((self.args.hostname, self.args.port), MakeDiematicWebRequestHandler(self)) - x = threading.Thread(target=self.startWebServerInThread) + if self.args.server == 'both': + loop = asyncio.get_event_loop() + loop.run_forever() + + + def start_main_program(self): + x = threading.Thread(target=self.main_program_loop) x.setDaemon(True) x.start() - if self.args.server == 'web': + if self.args.server == 'loop': x.join() - def startWebServerInThread(self): - self.webServer.serve_forever() + def main_program_loop(self): + while True: + self.do_main_program() + time.sleep(60) # a minute + + def startWebServer(self): + self._create_boiler() + + handler = DiematicWebRequestHandler(self.MyBoiler) + + loop = asyncio.get_event_loop() + self.webServer = web.Application() + self.webServer["mainapp"] = self + self.webServer.add_routes(handler.routes) + + runner = web.AppRunner(self.webServer) + loop.run_until_complete(runner.setup()) + site = web.TCPSite(runner, self.args.hostname, self.args.port) + loop.run_until_complete(site.start()) + if self.args.server == 'web': + loop.run_forever() def check_pending_writes(self): self._get_executor().submit(self._value_writer) @@ -310,7 +330,7 @@ def read_config_file(self): def toJSON(self): """Return the configuration file in json format""" - return json.dumps(self.cfg["registers"]) + return self.cfg["registers"] def set_logging_level(self): # --------------------------------------------------------------------------- # @@ -376,7 +396,10 @@ def _start(self): def _runonce(self): self._reload_configuration(None,None) - self.do_main_program() + if self.args.server == 'web': + self.startWebServer() + else: + self.do_main_program() def _readregister(self): """ Read the content of the indicated register and shows the @@ -508,8 +531,7 @@ def _status(self): emit_message(message, sys.stdout) def _reload(self): - """ Send a SIGUSR1 to the running process so it is forced to reload configuration file. - """ + """ Send a SIGUSR1 to the running process so it is forced to reload configuration file.""" if not self._get_context().pidfile.is_locked(): error = DaemonRunnerReloadFailureError( "PID file {pidfile.path!r} not locked".format( @@ -522,9 +544,7 @@ def _reload(self): os.kill(pid, signal.SIGUSR1) def _reload_configuration(self, _signal, _stack): - """ Reload the configuration from the configuration file - _signal and _stack are required because this function is a signal handler - """ + """ Reload the configuration from the configuration file.""" if self.args.device: self.MODBUS_DEVICE = self.args.device @@ -559,9 +579,7 @@ def _restart(self): self._start() def _test(self): - """ - For testing purposes only, not documented - """ + """ For testing purposes only, not documented.""" self._reload_configuration(None,None) self._create_boiler() testValues = (0x28, 0x8001, 0xc8) @@ -573,15 +591,16 @@ def _test(self): else: emit_message('test FAIL') + """Action functions dictionary.""" action_funcs = { - 'start': _start, - 'stop': _stop, - 'restart': _restart, - 'status': _status, - 'reload': _reload, - 'runonce': _runonce, - 'readregister': _readregister - } + 'start': _start, + 'stop': _stop, + 'restart': _restart, + 'status': _status, + 'reload': _reload, + 'runonce': _runonce, + 'readregister': _readregister + } def _get_action_func(self): """ Get the function for the specified action. diff --git a/diematic_server/webserver.py b/diematic_server/webserver.py index bf34ac2..9bb1b7f 100644 --- a/diematic_server/webserver.py +++ b/diematic_server/webserver.py @@ -1,8 +1,14 @@ -from http.server import BaseHTTPRequestHandler +""" +We will use aiohttp for the new web server. +This shall provide http 1.1 support and hopefully shall not produce +aiohttp.client_exceptions.ClientPayloadError: Response payload is not completed +problems on the client side. Let's see how it works! +""" + import json -from io import BytesIO +from aiohttp import web -def _parameter_names(boiler): +def _parameter_names(boiler) -> list: parameter_names = [] for register in boiler.index: if register['type'] == 'bits': @@ -14,32 +20,41 @@ def _parameter_names(boiler): parameter_names.sort() return parameter_names -def MakeDiematicWebRequestHandler(param): - class DiematicWebRequestHandler(DiematicLocalWebRequestHandler): - def __init__(self, *args, **kwargs): - super(DiematicWebRequestHandler, self).__init__(*args, **kwargs) - app = param - boiler = app.MyBoiler - parameter_names = _parameter_names(boiler) - return DiematicWebRequestHandler -class DiematicLocalWebRequestHandler(BaseHTTPRequestHandler): +class DiematicWebRequestHandler: """ - This class is a web server that provides GET and POST + This class implements the web server that provides GET and POST requests for the parameters of the boiler The parameters are defined in the same diematic.yaml file URL format: - GET http://{host}/diematic/parameters + GET http://{host:port}/diematic/parameters returns a list of known parameters from the diematic.yaml - GET http://{host}/diematic/parameters/{parameterName} + GET http://{host:port}/diematic/parameters/{parameterName} return a JSON { - "paremeterName": value + "name": "parameterName", + "status": "read", + "value": 34, + "id": 680, + "influx": true, + "read": "2022-04-02T17:21:32.751479" } + name: is the parameter name + status: can be: + "init": the value has not been read, the record is initialized + "read": the value has been read + "writepending": there is a new value pending to be written + "checking": the value has been written, the boiler is pending reading to check if the new value has been successfully set + "error": a problem occurred while setting the value + value: the parameter value + read: the last time the value was set + newvalue: when status is "writepending" this record holds the value to be written + error: the error message when status is "error" + POST http://{host}/diematic/parameters/{parameterName} body must contain a json { @@ -47,135 +62,121 @@ class DiematicLocalWebRequestHandler(BaseHTTPRequestHandler): } """ - def _set_headers_json(self, contentLength): - self.send_response(200) - self.send_header('Content-type', 'application/json') - self.send_header('Content-length', contentLength) - self.end_headers() - - def _set_headers_ok(self): - self.send_response(200) - self.end_headers() - - def _set_headers_html(self, contentLength): - self.send_response(200) - self.send_header('Content-type', 'text/html; charset=utf-8') - self.send_header('Content-length', contentLength) - self.end_headers() - - def _set_error(self, message): - self.send_response(404) - self.send_header('Content-type', 'text/html') - self.end_headers() - self.wfile.write(bytes("Diematic REST controller by IHR at home (Ignacio Hernández-Ros)", "utf-8")) - self.wfile.write(bytes("", "utf-8")) - self.wfile.write(bytes("

Request: %s

" % self.path, "utf-8")) - self.wfile.write(bytes("

NOT FOUND {message!r}!

".format(message = message), "utf-8")) - self.wfile.write(bytes("", "utf-8")) - - def do_GET(self): - """ returns parameters list or one parameter data in json format - http://.../diematic/parameters - http://.../diematic/parameters/{parameter_name} - """ - pathParts = self.path.split('/') - if len(pathParts) < 3 or pathParts[1] != 'diematic': - self._set_error('GET request FAILED. Try http://.../diematic/parameters to obtain the list of known parameter names') - if len(pathParts) == 3 and 'parameters' == pathParts[2]: - self.send_list() - elif len(pathParts) == 3 and 'json' == pathParts[2]: - self.send_json() - elif len(pathParts) == 3 and 'config' == pathParts[2]: - self.send_config() - elif len(pathParts) == 4 and 'parameters' == pathParts[2] and pathParts[3] in self.parameter_names: - self.send_param(pathParts[3]) - else: - self._set_error('{path!r} is not a known request'.format(path=self.path)) - - def do_POST(self): - """ updates a value of one parameter in the boiler - http://.../diematic/parameters/{parameter_name} - the body shall contain json like this { "value": "12345" } - """ - pathParts = self.path.split('/') - if len(pathParts) < 4: - self._set_error('POST request FAILED. Try http://.../diematic/parameters to obtain the list of known parameter names') - return - valid1 = 'diematic' == pathParts[1] - if not valid1: - self._set_error('POST request FAILED. Try http://.../diematic/parameters/\{parameter_name\} in url and \{ "value": value \} in body. 1st path must be \'diematic\'') - return - valid2 = 'parameters' == pathParts[2] - if not valid2: - self._set_error('POST request FAILED. Try http://.../diematic/parameters/\{parameter_name\} in url and \{ "value": value \} in body. 2nd path must be \'parameters\'') - return - valid3 = pathParts[3] in self.parameter_names - if not valid3: - self._set_error('POST request FAILED. Try http://.../diematic/parameters/\{parameter_name\} in url and \{ "value": value \} in body. 3rd path must be one parameter name that is defined in the diematic.yaml file') - return - - try: - if len(pathParts) == 4: - content_len = int(self.headers.get('content-length', 0)) - post_body_json = self.rfile.read(content_len).decode('utf8') - jsoninput = json.loads(post_body_json) - value = jsoninput['value'] - self.set_param(pathParts[3], value) - self._set_headers_ok() - elif len(pathParts) == 5 and 'resume' == pathParts[4]: - """ clear previous error, no body is required """ - self.boiler.clear_error(pathParts[3]) - self._set_headers_ok() + routes = web.RouteTableDef() + parameter_names = [] + + def __init__(self, boiler) -> None: + DiematicWebRequestHandler.parameter_names.clear() + for register in boiler.index: + if register['type'] == 'bits': + for bit in register['bits']: + if bit != "io_unused": + DiematicWebRequestHandler.parameter_names.append(bit) else: - self._set_error('in order to clear an error, path must be http://.../diematic/parameters/\{parameter_name\}/resume ') - except BaseException as error: - self._set_error('POST request FAILED. Error {error}'.format(error=error)) - - def set_param(self, paramName, paramValue): - """ generates a request to update the value by writing in the registers - """ - self.boiler.set_write_pending(paramName, paramValue) - self.app.check_pending_writes() - - def send_list(self): - """ produces a list of well known register names - """ - with BytesIO() as bio: - bio.write(bytes("Diematic REST controller by IHR at home (Ignacio Hernández-Ros)", "utf-8")) - bio.write(bytes("", "utf-8")) - bio.write(bytes("

Recognized parameters list

", "utf-8")) - bio.write(bytes("", "utf-8")) - bio.write(bytes("", "utf-8")) - contentLength = len(bio.getvalue()) - self._set_headers_html(contentLength) - self.wfile.write(bio.getvalue()) - - def send_param(self, param_name): - """ send only one parameter value - """ - with BytesIO() as bio: - bio.write(bytes(json.dumps(getattr(self.boiler, param_name)), "utf-8")) - contentLength = len(bio.getvalue()) - self._set_headers_json(contentLength) - self.wfile.write(bio.getvalue()) - - def send_json(self): - """ send all values as a big json - """ - with BytesIO() as bio: - bio.write(bytes(self.boiler.toJSON(),"utf-8")) - contentLength = len(bio.getvalue()) - self._set_headers_json(contentLength) - self.wfile.write(bio.getvalue()) - - def send_config(self): - """ send configuration file in json format - """ - with BytesIO() as bio: - bio.write(bytes(self.app.toJSON(),"utf-8")) - contentLength = len(bio.getvalue()) - self._set_headers_json(contentLength) - self.wfile.write(bio.getvalue()) \ No newline at end of file + DiematicWebRequestHandler.parameter_names.append(register['name']) + DiematicWebRequestHandler.parameter_names.sort() + + @routes.get('/diematic/parameters') + async def send_list(request): + """ produces a list of well known register names.""" + scheme = request.scheme + host = request.host + document = f""" + + + Diematic REST controller by IHR at home (Ignacio Hernández-Ros) + + + + + + + + + + + + + + + +
Usage:
GET{scheme}://{host}/diematic/parametersreturns this page
GET{scheme}://{host}/diematic/parameter/{{name}}returns json with parameter information
GET{scheme}://{host}/diematic/configreturns boiler configuration parameters
GET{scheme}://{host}/diematic/jsonreturns all boiler parameters in single json
POST{scheme}://{host}/diematic/parameter/{{name}}Set a parameter value. The body must be a json of this shape {{"value": XX}}. After the POST, the parameters may take some time to be written to the boiler. Use GET with the parameter name for information about the write operation status.
POST{scheme}://{host}/diematic/parameter/{{name}}/resumeIf, for any reason, a write operation fails, a post like this will reset parameter to normal status.
+

Recognized parameters list

+ """ + return web.Response(text=document, content_type='text/html') + + @routes.get('/diematic/parameters/{paramName}') + async def send_param(request): + param_name = request.match_info.get('paramName') + if not param_name in DiematicWebRequestHandler.parameter_names: + return web.Response(status=422, reason=f'\'{param_name}\' is an invalid parameter') + boiler = request.app["mainapp"].MyBoiler + value = getattr(boiler, param_name) + return web.json_response(value) + + @routes.post('/diematic/parameters/{paramName}') + async def set_param(request): + param_name = request.match_info.get('paramName') + if not param_name in DiematicWebRequestHandler.parameter_names: + return web.Response(status=422, reason=f'\'{param_name}\' is an invalid parameter') + content_len = request.content_length + if not content_len is None: + data = await request.content.read(content_len) + else: + data = await request.content.read() + jsoninput = json.loads(data.decode('utf8')) + value = jsoninput['value'] + mainapp = request.app["mainapp"] + mainapp.MyBoiler.set_write_pending(param_name, value) + return web.Response() + + @routes.post('/diematic/parameters/{paramName}/resume') + async def set_param(request): + param_name = request.match_info.get('paramName') + if not param_name in DiematicWebRequestHandler.parameter_names: + return web.Response(status=422, reason=f'\'{param_name}\' is an invalid parameter') + mainapp = request.app["mainapp"] + mainapp.MyBoiler.clear_error(param_name) + return web.Response() + + @routes.get('/diematic/json') + async def send_json(request): + config = request.app["mainapp"].MyBoiler.toJSON() + return web.json_response(config) + + @routes.get('/diematic/config') + async def send_config(request): + config = request.app["mainapp"].toJSON() + return web.json_response(config) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cfdacca..b7bf235 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,6 @@ -certifi==2020.12.5 -chardet==4.0.0 +aiohttp==3.8.1 daemon==1.2 -docutils==0.18 -idna==2.10 influxdb==5.2.3 -lockfile==0.12.2 pymodbus==2.2.0 -pyserial==3.5 python-daemon==2.3.0 -python-dateutil==2.8.1 -pytz==2021.1 -PyYAML==5.4 -requests==2.25.1 -six==1.15.0 -urllib3==1.26.4 +PyYAML==5.4 \ No newline at end of file diff --git a/setup.py b/setup.py index 306fabc..12c7a36 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setuptools.setup( name = 'diematic_server', - version = '1.2', + version = '2.0', description = 'Unix daemon and supporting models for publishing data from Diematic DeDietrich boiler', long_description = long_description, long_description_content_type = 'text/markdown; charset=UTF-8', @@ -16,25 +16,15 @@ packages = ['diematic_server'], license='MIT', url = 'https://github.com/IgnacioHR/diematic_server', - download_url = 'https://github.com/IgnacioHR/diematic_server/archive/refs/tags/v1.2.tar.gz', + download_url = 'https://github.com/IgnacioHR/diematic_server/archive/refs/tags/v2.0.tar.gz', keywords = ['python', 'home-automation', 'iot', 'influxdb', 'restful', 'modbus', 'de-dietrich', 'diematic'], install_requires=[ - 'certifi', - 'chardet', - 'daemon', - 'docutils', - 'idna', - 'influxdb', - 'lockfile', - 'pymodbus', - 'pyserial', - 'python-daemon', - 'python-dateutil', - 'pytz', - 'PyYAML', - 'requests', - 'six', - 'urllib3' + 'daemon==1.2', + 'influxdb==5.2.3', + 'pymodbus==2.2.0', + 'python-daemon==2.3.0', + 'PyYAML==5.4', + 'aiohttp==3.8.1', ], classifiers=[ 'Development Status :: 5 - Production/Stable', diff --git a/diematicd.service b/unit/diematicd.service similarity index 100% rename from diematicd.service rename to unit/diematicd.service