From 089fb92a43275e4890ec083afda8aa3bd9d62cbb Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 5 May 2025 23:35:13 +0200 Subject: [PATCH] change proxy into a ASGI application - move Quart init from server.py into app.py - create Server class for config and loggin setup - restore hypercorn logging configuration after start of Quart/Hypercorn --- app/Dockerfile | 2 +- app/src/app.py | 164 ++++++++++++++ app/src/server.py | 370 ++++++++++--------------------- ha_addons/ha_addon/rootfs/run.sh | 2 +- 4 files changed, 281 insertions(+), 257 deletions(-) create mode 100644 app/src/app.py diff --git a/app/Dockerfile b/app/Dockerfile index 44b0795..895d13e 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -67,4 +67,4 @@ EXPOSE 5005 8127 10000 # command to run on container start ENTRYPOINT ["/root/entrypoint.sh"] -CMD [ "python3", "./server.py" ] +CMD [ "python3", "./app.py" ] diff --git a/app/src/app.py b/app/src/app.py new file mode 100644 index 0000000..515ca45 --- /dev/null +++ b/app/src/app.py @@ -0,0 +1,164 @@ +import logging +import asyncio +import logging.handlers +from asyncio import StreamReader, StreamWriter +from quart import Quart, Response +from logging import config # noqa F401 +from proxy import Proxy +from inverter_ifc import InverterIfc +from gen3.inverter_g3 import InverterG3 +from gen3plus.inverter_g3p import InverterG3P +from scheduler import Schedule +from server import Server +from web import Web +from web.wrapper import url_for + +from modbus_tcp import ModbusTcp + + +class ProxyState: + _is_up = False + + @staticmethod + def is_up() -> bool: + return ProxyState._is_up + + @staticmethod + def set_up(value: bool): + ProxyState._is_up = value + + +class HypercornLogHndl: + access_hndl = [] + error_hndl = [] + must_fix = False + HYPERC_ERR = 'hypercorn.error' + HYPERC_ACC = 'hypercorn.access' + + @classmethod + def save(cls): + cls.access_hndl = logging.getLogger( + cls.HYPERC_ACC).handlers + cls.error_hndl = logging.getLogger( + cls.HYPERC_ERR).handlers + cls.must_fix = True + + @classmethod + def restore(cls): + if not cls.must_fix: + return + cls.must_fix = False + access_hndl = logging.getLogger( + cls.HYPERC_ACC).handlers + if access_hndl != cls.access_hndl: + print(' * Fix hypercorn.access setting') + logging.getLogger( + cls.HYPERC_ACC).handlers = cls.access_hndl + + error_hndl = logging.getLogger( + cls.HYPERC_ERR).handlers + if error_hndl != cls.error_hndl: + print(' * Fix hypercorn.error setting') + logging.getLogger( + cls.HYPERC_ERR).handlers = cls.error_hndl + + +app = Quart(__name__, + template_folder='web/templates', + static_folder='web/static') +app.secret_key = 'JKLdks.dajlKKKdladkflKwolafallsdfl' +app.jinja_env.globals.update(url_for=url_for) + + +@app.route('/-/ready') +async def ready(): + if ProxyState.is_up(): + status = 200 + text = 'Is ready' + else: + status = 503 + text = 'Not ready' + return Response(status=status, response=text) + + +@app.route('/-/healthy') +async def healthy(): + + if ProxyState.is_up(): + # logging.info('web reqeust healthy()') + for inverter in InverterIfc: + try: + res = inverter.healthy() + if not res: + return Response(status=503, response="I have a problem") + except Exception as err: + logging.info(f'Exception:{err}') + + return Response(status=200, response="I'm fine") + + +async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class): + '''Handles a new incoming connection and starts an async loop''' + + with inv_class(reader, writer) as inv: + await inv.local.ifc.server_loop() + + +@app.before_serving +async def startup_app(): + HypercornLogHndl.save() + loop = asyncio.get_event_loop() + Proxy.class_init() + Schedule.start() + ModbusTcp(loop) + + for inv_class, port in [(InverterG3, 5005), (InverterG3P, 10000)]: + logging.info(f'listen on port: {port} for inverters') + loop.create_task(asyncio.start_server(lambda r, w, i=inv_class: + handle_client(r, w, i), + '0.0.0.0', port)) + ProxyState.set_up(True) + + +@app.before_request +async def startup_request(): + HypercornLogHndl.restore() + + +@app.after_serving +async def handle_shutdown(): # pragma: no cover + '''Close all TCP connections and stop the event loop''' + + logging.info('Shutdown due to SIGTERM') + loop = asyncio.get_event_loop() + ProxyState.set_up(False) + + # + # first, disc all open TCP connections gracefully + # + for inverter in InverterIfc: + await inverter.disc(True) + + logging.info('Proxy disconnecting done') + + await Proxy.class_close(loop) + + +server = Server(app, __name__ == "__main__") +Web(app, server.trans_path, server.rel_urls) + +if __name__ == "__main__": # pragma: no cover + + try: + logging.info("Start Quart") + app.run(host='0.0.0.0', port=8127, use_reloader=False, + debug=Server.log_level == logging.DEBUG) + logging.info("Quart stopped") + + except KeyboardInterrupt: + pass + except asyncio.exceptions.CancelledError: + logging.info("Quart cancelled") + + finally: + logging.info(f'Finally, exit Server "{Server.serv_name}"') diff --git a/app/src/server.py b/app/src/server.py index 4512c19..5318907 100644 --- a/app/src/server.py +++ b/app/src/server.py @@ -1,279 +1,139 @@ import logging -import asyncio import logging.handlers import os import argparse -from asyncio import StreamReader, StreamWriter -from quart import Quart, Response -from logging import config # noqa F401 -from proxy import Proxy -from inverter_ifc import InverterIfc -from gen3.inverter_g3 import InverterG3 -from gen3plus.inverter_g3p import InverterG3P -from scheduler import Schedule from cnf.config import Config from cnf.config_read_env import ConfigReadEnv from cnf.config_read_toml import ConfigReadToml from cnf.config_read_json import ConfigReadJson -from web import Web -from web.wrapper import url_for - -from modbus_tcp import ModbusTcp -class HypercornLogHndl: - access_hndl = [] - error_hndl = [] - must_fix = False - HYPERC_ERR = 'hypercorn.error' - HYPERC_ACC = 'hypercorn.access' +class Server(): + serv_name = '' + version = '' + src_dir = '' + config_path = './config/' + json_config = '' + toml_config = '' + trans_path = '../translations/' + rel_urls = False + log_path = './log/' + log_backups = 0 + log_level = None - @classmethod - def save(cls): - cls.access_hndl = logging.getLogger( - cls.HYPERC_ACC).handlers - cls.error_hndl = logging.getLogger( - cls.HYPERC_ERR).handlers - cls.must_fix = True + def __init__(self, app, parse_args: bool): + ''' Applikation Setup - @classmethod - def restore(cls): - if not cls.must_fix: - return - cls.must_fix = False - access_hndl = logging.getLogger( - cls.HYPERC_ACC).handlers - if access_hndl != cls.access_hndl: - print(' * Fix hypercorn.access setting') - logging.getLogger( - cls.HYPERC_ACC).handlers = cls.access_hndl + 1. Read cli arguments + 2. Init the logging system by the ini file + 3. Log the config parms + 4. Set the log-levels + 5. Read the build the config for the app + ''' + self.serv_name = os.getenv('SERVICE_NAME', 'proxy') + self.version = os.getenv('VERSION', 'unknown') + self.src_dir = os.path.dirname(__file__) + '/' + if parse_args: # pragma: no cover + self.parse_args(None) + self.init_logging_system() + self.build_config() - error_hndl = logging.getLogger( - cls.HYPERC_ERR).handlers - if error_hndl != cls.error_hndl: - print(' * Fix hypercorn.error setting') - logging.getLogger( - cls.HYPERC_ERR).handlers = cls.error_hndl + @app.context_processor + def utility_processor(): + return dict(version=self.version) + def parse_args(self, arg_list: list[str] | None): + print("in Server.read_cli1") + parser = argparse.ArgumentParser() + parser.add_argument('-c', '--config_path', type=str, + default='./config/', + help='set path for the configuration files') + parser.add_argument('-j', '--json_config', type=str, + help='read user config from json-file') + parser.add_argument('-t', '--toml_config', type=str, + help='read user config from toml-file') + parser.add_argument('-l', '--log_path', type=str, + default='./log/', + help='set path for the logging files') + parser.add_argument('-b', '--log_backups', type=int, + default=0, + help='set max number of daily log-files') + parser.add_argument('-tr', '--trans_path', type=str, + default='../translations/', + help='set path for the translations files') + parser.add_argument('-r', '--rel_urls', action="store_true", + help='use relative dashboard urls') + args = parser.parse_args(arg_list) -class ProxyState: - _is_up = False + print(f"rel_urls-1: {args.rel_urls}") + self.config_path = args.config_path + self.json_config = args.json_config + self.toml_config = args.toml_config + self.trans_path = args.trans_path + self.rel_urls = args.rel_urls + self.log_path = args.log_path + self.log_backups = args.log_backups - @staticmethod - def is_up() -> bool: - return ProxyState._is_up + def init_logging_system(self): # pragma: no cover - @staticmethod - def set_up(value: bool): - ProxyState._is_up = value + setattr(logging.handlers, "log_path", self.log_path) + setattr(logging.handlers, "log_backups", self.log_backups) + os.makedirs(self.log_path, exist_ok=True) + logging.config.fileConfig(self.src_dir + 'logging.ini') -app = Quart(__name__, - template_folder='web/templates', - static_folder='web/static') -app.secret_key = 'JKLdks.dajlKKKdladkflKwolafallsdfl' -app.jinja_env.globals.update(url_for=url_for) + logging.info( + f'Server "{self.serv_name} - {self.version}" will be started') + logging.info(f'current dir: {os.getcwd()}') + logging.info(f"config_path: {self.config_path}") + logging.info(f"json_config: {self.json_config}") + logging.info(f"toml_config: {self.toml_config}") + logging.info(f"trans_path: {self.trans_path}") + logging.info(f"rel_urls: {self.rel_urls}") + logging.info(f"log_path: {self.log_path}") + if self.log_backups == 0: + logging.info("log_backups: unlimited") + else: + logging.info(f"log_backups: {self.log_backups} days") + self.log_level = self.get_log_level() + logging.info('******') + if self.log_level: + # set lowest-severity for 'root', 'msg', 'conn' and 'data' logger + logging.getLogger().setLevel(self.log_level) + logging.getLogger('msg').setLevel(self.log_level) + logging.getLogger('conn').setLevel(self.log_level) + logging.getLogger('data').setLevel(self.log_level) + logging.getLogger('tracer').setLevel(self.log_level) + logging.getLogger('asyncio').setLevel(self.log_level) + # logging.getLogger('mqtt').setLevel(self.log_level) + def build_config(self): + # read config file + Config.init(ConfigReadToml(self.src_dir + "cnf/default_config.toml"), + log_path=self.log_path) + ConfigReadEnv() + ConfigReadJson(self.config_path + "config.json") + ConfigReadToml(self.config_path + "config.toml") + ConfigReadJson(self.json_config) + ConfigReadToml(self.toml_config) + config_err = Config.get_error() -@app.route('/-/ready') -async def ready(): - if ProxyState.is_up(): - status = 200 - text = 'Is ready' - else: - status = 503 - text = 'Not ready' - return Response(status=status, response=text) + if config_err is not None: + logging.info(f'config_err: {config_err}') + return # fixme raise an exception + logging.info('******') -@app.route('/-/healthy') -async def healthy(): + def get_log_level(self) -> int | None: + '''checks if LOG_LVL is set in the environment and returns the + corresponding logging.LOG_LEVEL''' + switch = { + 'DEBUG': logging.DEBUG, + 'WARN': logging.WARNING, + 'INFO': logging.INFO, + 'ERROR': logging.ERROR, + } + log_lvl = os.getenv('LOG_LVL', None) + logging.info(f"LOG_LVL : {log_lvl}") - if ProxyState.is_up(): - # logging.info('web reqeust healthy()') - for inverter in InverterIfc: - try: - res = inverter.healthy() - if not res: - return Response(status=503, response="I have a problem") - except Exception as err: - logging.info(f'Exception:{err}') - - return Response(status=200, response="I'm fine") - - -async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class): - '''Handles a new incoming connection and starts an async loop''' - - with inv_class(reader, writer) as inv: - await inv.local.ifc.server_loop() - - -@app.before_request -async def startup_request(): - HypercornLogHndl.restore() - - -@app.after_serving -async def handle_shutdown(): # pragma: no cover - '''Close all TCP connections and stop the event loop''' - - logging.info('Shutdown due to SIGTERM') - loop = asyncio.get_event_loop() - ProxyState.set_up(False) - - # - # first, disc all open TCP connections gracefully - # - for inverter in InverterIfc: - await inverter.disc(True) - - logging.info('Proxy disconnecting done') - - # - # now cancel all remaining (pending) tasks - # - for task in asyncio.all_tasks(): - if task == asyncio.current_task(): - continue - task.cancel() - logging.info('Proxy cancelling done') - - await Proxy.class_close(loop) - - -def get_log_level() -> int | None: - '''checks if LOG_LVL is set in the environment and returns the - corresponding logging.LOG_LEVEL''' - switch = { - 'DEBUG': logging.DEBUG, - 'WARN': logging.WARNING, - 'INFO': logging.INFO, - 'ERROR': logging.ERROR, - } - log_level = os.getenv('LOG_LVL', None) - logging.info(f"LOG_LVL : {log_level}") - - return switch.get(log_level, None) - - -def main(): # pragma: no cover - parser = argparse.ArgumentParser() - parser.add_argument('-c', '--config_path', type=str, - default='./config/', - help='set path for the configuration files') - parser.add_argument('-j', '--json_config', type=str, - help='read user config from json-file') - parser.add_argument('-t', '--toml_config', type=str, - help='read user config from toml-file') - parser.add_argument('-l', '--log_path', type=str, - default='./log/', - help='set path for the logging files') - parser.add_argument('-b', '--log_backups', type=int, - default=0, - help='set max number of daily log-files') - parser.add_argument('-tr', '--trans_path', type=str, - default='../translations/', - help='set path for the translations files') - parser.add_argument('-r', '--rel_urls', type=bool, - default=False, - help='use relative dashboard urls') - args = parser.parse_args() - # - # Setup our daily, rotating logger - # - serv_name = os.getenv('SERVICE_NAME', 'proxy') - version = os.getenv('VERSION', 'unknown') - - @app.context_processor - def utility_processor(): - return dict(version=version) - - setattr(logging.handlers, "log_path", args.log_path) - setattr(logging.handlers, "log_backups", args.log_backups) - os.makedirs(args.log_path, exist_ok=True) - - src_dir = os.path.dirname(__file__) + '/' - logging.config.fileConfig(src_dir + 'logging.ini') - HypercornLogHndl.save() - - logging.info(f'Server "{serv_name} - {version}" will be started') - logging.info(f'current dir: {os.getcwd()}') - logging.info(f"config_path: {args.config_path}") - logging.info(f"json_config: {args.json_config}") - logging.info(f"toml_config: {args.toml_config}") - logging.info(f"trans_path: {args.trans_path}") - logging.info(f"rel_urls: {args.rel_urls}") - logging.info(f"log_path: {args.log_path}") - if args.log_backups == 0: - logging.info("log_backups: unlimited") - else: - logging.info(f"log_backups: {args.log_backups} days") - log_level = get_log_level() - logging.info('******') - if log_level: - # set lowest-severity for 'root', 'msg', 'conn' and 'data' logger - logging.getLogger().setLevel(log_level) - logging.getLogger('msg').setLevel(log_level) - logging.getLogger('conn').setLevel(log_level) - logging.getLogger('data').setLevel(log_level) - logging.getLogger('tracer').setLevel(log_level) - logging.getLogger('asyncio').setLevel(log_level) - # logging.getLogger('mqtt').setLevel(log_level) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # read config file - Config.init(ConfigReadToml(src_dir + "cnf/default_config.toml"), - log_path=args.log_path) - ConfigReadEnv() - ConfigReadJson(args.config_path + "config.json") - ConfigReadToml(args.config_path + "config.toml") - ConfigReadJson(args.json_config) - ConfigReadToml(args.toml_config) - config_err = Config.get_error() - - if config_err is not None: - logging.info(f'config_err: {config_err}') - return - - logging.info('******') - - Proxy.class_init() - Schedule.start() - ModbusTcp(loop) - Web(app, args.trans_path, args.rel_urls) - - # - # Create tasks for our listening servers. These must be tasks! If we call - # start_server directly out of our main task, the eventloop will be blocked - # and we can't receive and handle the UNIX signals! - # - for inv_class, port in [(InverterG3, 5005), (InverterG3P, 10000)]: - logging.info(f'listen on port: {port} for inverters') - loop.create_task(asyncio.start_server(lambda r, w, i=inv_class: - handle_client(r, w, i), - '0.0.0.0', port)) - - loop.set_debug(log_level == logging.DEBUG) - try: - ProxyState.set_up(True) - logging.info("Start Quart") - app.run(host='0.0.0.0', port=8127, use_reloader=False, loop=loop, - debug=log_level == logging.DEBUG) - logging.info("Quart stopped") - - except KeyboardInterrupt: - pass - except asyncio.exceptions.CancelledError: - logging.info("Quart cancelled") - - finally: - logging.debug('Close event loop') - loop.close() - logging.info(f'Finally, exit Server "{serv_name}"') - - -if __name__ == "__main__": # pragma: no cover - main() + return switch.get(log_lvl, None) diff --git a/ha_addons/ha_addon/rootfs/run.sh b/ha_addons/ha_addon/rootfs/run.sh index f146a70..5f6ad0f 100755 --- a/ha_addons/ha_addon/rootfs/run.sh +++ b/ha_addons/ha_addon/rootfs/run.sh @@ -30,4 +30,4 @@ cd /home/proxy || exit export VERSION=$(cat /proxy-version.txt) echo "Start Proxyserver..." -python3 server.py --rel_urls=True --json_config=/data/options.json --log_path=/homeassistant/tsun-proxy/logs/ --config_path=/homeassistant/tsun-proxy/ --log_backups=2 +python3 app.py --rel_urls --json_config=/data/options.json --log_path=/homeassistant/tsun-proxy/logs/ --config_path=/homeassistant/tsun-proxy/ --log_backups=2