diff --git a/.coveragerc b/.coveragerc
index 398ff08..2626233 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,2 +1,3 @@
[run]
branch = True
+omit = app/src/web/templates/*.html.j2
diff --git a/.gitignore b/.gitignore
index 3e39de3..4fde1fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ Doku/**
.env
.venv
coverage.xml
+*.pot
+*.mo
+*.log
+*.log.*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31ac310..5e6e1ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased]
+- fix a lot of pytest-asyncio problems in the unit tests
+- Cleanup startup code for Quart and the Proxy
+- Redirect the hypercorn traces to a separate log-file
+- Configure the dashboard trace handler by the logging.ini file
+- Dashboard: add Notes page and table for important messages
+- Dashboard: add Log-File page
+- Dashboard: add Connection page
+- add web UI to add-on
+- allow `Y00` serial numbers for GEN3PLUS devices
+
+## [0.13.0] - 2025-04-13
+
- update dependency python to 3.13
- add initial support for TSUN MS-3000
- add initial apparmor support [#293](https://github.com/s-allius/tsun-gen3-proxy/issues/293)
diff --git a/Makefile b/Makefile
index 49be846..5964e6e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,21 @@
-.PHONY: build clean addon-dev addon-debug addon-rc addon-rel debug dev preview rc rel check-docker-compose install
+.PHONY: build babel clean addon-dev addon-debug addon-rc addon-rel debug dev preview rc rel check-docker-compose install
-debug dev preview rc rel:
+babel:
$(MAKE) -C app $@
-clean build:
+build:
$(MAKE) -C ha_addons $@
+clean:
+ $(MAKE) -C app $@
+ $(MAKE) -C ha_addons $@
+
+debug dev preview rc rel:
+ $(MAKE) -C app babel
+ $(MAKE) -C app $@
+
addon-dev addon-debug addon-rc addon-rel:
+ $(MAKE) -C app babel
$(MAKE) -C ha_addons $(patsubst addon-%,%,$@)
check-docker-compose:
diff --git a/README.md b/README.md
index 7e1102d..dfd39a8 100644
--- a/README.md
+++ b/README.md
@@ -141,7 +141,7 @@ No special configuration is required for the Docker container if it is built and
On the host, two directories (for log files and for config files) must be mapped. If necessary, the UID of the proxy process can be adjusted, which is also the owner of the log and configuration files.
-A description of the configuration parameters can be found [here](https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-toml#docker-compose-environment-variables).
+A description of the configuration parameters can be found [here](https://github.com/s-allius/tsun-gen3-proxy/wiki/configuration-env#docker-compose-environment-variables)
## Proxy Configuration
diff --git a/app/.dockerignore b/app/.dockerignore
index a746428..6348e0f 100644
--- a/app/.dockerignore
+++ b/app/.dockerignore
@@ -2,4 +2,6 @@ tests/
**/__pycache__
*.pyc
.DS_Store
-build.sh
\ No newline at end of file
+build.sh
+*.pot
+*.po
\ No newline at end of file
diff --git a/app/.version b/app/.version
index 51de330..0548fb4 100644
--- a/app/.version
+++ b/app/.version
@@ -1 +1 @@
-0.13.0
\ No newline at end of file
+0.14.0
\ No newline at end of file
diff --git a/app/Dockerfile b/app/Dockerfile
index b9c5362..44b0795 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -60,6 +60,7 @@ RUN python -m pip install --no-cache-dir --no-cache --no-index /root/wheels/* &&
# copy the content of the local src and config directory to the working directory
COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh
COPY src .
+COPY translations ./translations
RUN echo ${VERSION} > /proxy-version.txt \
&& date > /build-date.txt
EXPOSE 5005 8127 10000
diff --git a/app/Makefile b/app/Makefile
index dff4631..29be248 100644
--- a/app/Makefile
+++ b/app/Makefile
@@ -6,15 +6,23 @@ IMAGE = tsun-gen3-proxy
# Folders
-SRC=.
+APP=.
+SRC=$(APP)/src
+# Folders for Babel translation
+BABEL_INPUT_JINJA=$(SRC)/web/templates
+BABEL_INPUT= $(foreach dir,$(BABEL_INPUT_JINJA),$(wildcard $(dir)/*.html.j2)) \
+
+BABEL_TRANSLATIONS=$(APP)/translations
export BUILD_DATE := ${shell date -Iminutes}
-VERSION := $(shell cat $(SRC)/.version)
+VERSION := $(shell cat $(APP)/.version)
export MAJOR := $(shell echo $(VERSION) | cut -f1 -d.)
PUBLIC_URL := $(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f1 -d/)
PUBLIC_USER :=$(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f2 -d/)
+clean:
+ rm -f $(BABEL_TRANSLATIONS)/*.pot
dev debug:
@echo version: $(VERSION) build-date: $(BUILD_DATE) image: $(PRIVAT_CONTAINER_REGISTRY)$(IMAGE)
@@ -39,5 +47,17 @@ preview rel:
export IMAGE=$(PUBLIC_CONTAINER_REGISTRY)$(IMAGE) && \
docker buildx bake -f docker-bake.hcl $@
+babel: $(BABEL_TRANSLATIONS)/de/LC_MESSAGES/messages.mo $(BABEL_TRANSLATIONS)/de/LC_MESSAGES/messages.po $(BABEL_TRANSLATIONS)/messages.pot
-.PHONY: debug dev preview rc rel
+$(BABEL_TRANSLATIONS)/%.pot : $(SRC)/.babel.cfg $(BABEL_INPUT)
+ @mkdir -p $(@D)
+ @pybabel extract -F $< --project=$(IMAGE) --version=$(VERSION) -o $@ $(SRC)
+
+$(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.po : $(BABEL_TRANSLATIONS)/messages.pot
+ @mkdir -p $(@D)
+ @pybabel update --init-missing -i $< -d $(BABEL_TRANSLATIONS) -l $*
+
+$(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.mo : $(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.po
+ @pybabel compile -d $(BABEL_TRANSLATIONS) -l $*
+
+.PHONY: babel clean debug dev preview rc rel
diff --git a/app/entrypoint.sh b/app/entrypoint.sh
index 092ea51..30d77c8 100644
--- a/app/entrypoint.sh
+++ b/app/entrypoint.sh
@@ -23,7 +23,7 @@ if [ "$user" = '0' ]; then
echo "######################################################"
echo "#"
- exec su-exec $SERVICE_NAME "$@"
+ exec su-exec $SERVICE_NAME "$@" -tr './translations/'
else
exec "$@"
fi
diff --git a/app/requirements.txt b/app/requirements.txt
index 565a34d..2a100b6 100644
--- a/app/requirements.txt
+++ b/app/requirements.txt
@@ -1,4 +1,5 @@
- aiomqtt==2.3.1
+ aiomqtt==2.4.0
schema==0.7.7
aiocron==2.1
- aiohttp==3.11.16
\ No newline at end of file
+ quart==0.20
+ quart-babel==1.0.7
\ No newline at end of file
diff --git a/app/src/.babel.cfg b/app/src/.babel.cfg
new file mode 100644
index 0000000..a5c0c48
--- /dev/null
+++ b/app/src/.babel.cfg
@@ -0,0 +1,3 @@
+[python: **.py]
+[jinja2: web/templates/**.html]
+[jinja2: web/templates/**.html.j2]
\ No newline at end of file
diff --git a/app/src/async_stream.py b/app/src/async_stream.py
index ec060b2..59ebef4 100644
--- a/app/src/async_stream.py
+++ b/app/src/async_stream.py
@@ -327,8 +327,10 @@ class AsyncStreamServer(AsyncStream):
logger.info(f'[{self.node_id}:{self.conn_no}] '
f'Accept connection from {self.r_addr}')
Infos.inc_counter('Inverter_Cnt')
+ Infos.inc_counter('ServerMode_Cnt')
await self.publish_outstanding_mqtt()
await self.loop()
+ Infos.dec_counter('ServerMode_Cnt')
Infos.dec_counter('Inverter_Cnt')
await self.publish_outstanding_mqtt()
logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for'
@@ -359,9 +361,11 @@ class AsyncStreamServer(AsyncStream):
class AsyncStreamClient(AsyncStream):
def __init__(self, reader: StreamReader, writer: StreamWriter,
- rstream: "StreamPtr", close_cb) -> None:
+ rstream: "StreamPtr", close_cb,
+ use_emu: bool = False) -> None:
AsyncStream.__init__(self, reader, writer, rstream)
self.close_cb = close_cb
+ self.emu_mode = use_emu
async def disc(self) -> None:
logging.debug('AsyncStreamClient.disc()')
@@ -376,8 +380,16 @@ class AsyncStreamClient(AsyncStream):
async def client_loop(self, _: str) -> None:
'''Loop for receiving messages from the TSUN cloud (client-side)'''
Infos.inc_counter('Cloud_Conn_Cnt')
+ if self.emu_mode:
+ Infos.inc_counter('EmuMode_Cnt')
+ else:
+ Infos.inc_counter('ProxyMode_Cnt')
await self.publish_outstanding_mqtt()
await self.loop()
+ if self.emu_mode:
+ Infos.dec_counter('EmuMode_Cnt')
+ else:
+ Infos.dec_counter('ProxyMode_Cnt')
Infos.dec_counter('Cloud_Conn_Cnt')
await self.publish_outstanding_mqtt()
logger.info(f'[{self.node_id}:{self.conn_no}] '
diff --git a/app/src/cnf/config.py b/app/src/cnf/config.py
index 88a0ab0..5207c7d 100644
--- a/app/src/cnf/config.py
+++ b/app/src/cnf/config.py
@@ -162,12 +162,13 @@ class Config():
)
@classmethod
- def init(cls, def_reader: ConfigIfc) -> None | str:
+ def init(cls, def_reader: ConfigIfc, log_path: str = '') -> None | str:
'''Initialise the Proxy-Config
Copy the internal default config file into the config directory
and initialise the Config with the default configuration '''
cls.err = None
+ cls.log_path = log_path
cls.def_config = {}
try:
# make the default config transparaent by copying it
@@ -247,3 +248,7 @@ here. The default config reader is handled in the Config.init method'''
'''Check if the member is the default value'''
return cls.act_config.get(member) == cls.def_config.get(member)
+
+ @classmethod
+ def get_log_path(cls) -> str:
+ return cls.log_path
diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py
index 292dfdf..c025d70 100644
--- a/app/src/gen3/talent.py
+++ b/app/src/gen3/talent.py
@@ -100,7 +100,7 @@ class Talent(Message):
if serial_no in inverters:
inv = inverters[serial_no]
- self._set_config_parms(inv)
+ self._set_config_parms(inv, serial_no)
self.db.set_pv_module_details(inv)
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
else:
diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py
index 7c8b997..38d9eb9 100644
--- a/app/src/gen3plus/solarman_v5.py
+++ b/app/src/gen3plus/solarman_v5.py
@@ -393,7 +393,7 @@ class SolarmanV5(SolarmanBase):
def _set_config_parms(self, inv: dict, serial_no: str = ""):
'''init connection with params from the configuration'''
- super()._set_config_parms(inv)
+ super()._set_config_parms(inv, serial_no)
snr = serial_no[:3]
if '410' == snr:
self.db.set_db_def_value(Register.EQUIPMENT_MODEL,
diff --git a/app/src/infos.py b/app/src/infos.py
index e0d4226..59c5fbc 100644
--- a/app/src/infos.py
+++ b/app/src/infos.py
@@ -382,7 +382,7 @@ class Infos:
__mppt2_status_type_val_tpl = "{%set mppt_status = ['Standby', 'On', 'Off'] %}{{mppt_status[value_json['pv2']['MPPT-Status']|int(0)]|default(value_json['pv2']['MPPT-Status'])}}" # noqa: E501
__supply_status_type_val_tpl = "{%set supply_status = ['Idle', 'Power-Supply'] %}{{supply_status[value_json['out']['Suppl_State']|int(0)]|default(value_json['out']['Suppl_State'])}}" # noqa: E501
__batt_status_type_val_tpl = "{%set batt_status = ['Discharging', 'Static', 'Loading'] %}{{batt_status[value_json['batt']['Batt_State']|int(0)]|default(value_json['batt']['Batt_State'])}}" # noqa: E501
- __out_status_type_val_tpl = "{%set out_status = ['Standby', 'On'] %}{{out_status[value_json['out']['Out_Status']|int(0)]|default(value_json['out']['Out_Status'])}}" # noqa: E501
+ __out_status_type_val_tpl = "{%set out_status = ['Standby', 'On', 'Off'] %}{{out_status[value_json['out']['Out_Status']|int(0)]|default(value_json['out']['Out_Status'])}}" # noqa: E501
__rated_power_val_tpl = "{% if 'Rated_Power' in value_json and value_json['Rated_Power'] != None %}{{value_json['Rated_Power']|string() +' W'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501
__designed_power_val_tpl = '''
{% if 'Max_Designed_Power' in value_json and
@@ -838,7 +838,10 @@ class Infos:
def inc_counter(cls, counter: str) -> None:
'''inc proxy statistic counter'''
db_dict = cls.stat['proxy']
- db_dict[counter] += 1
+ try:
+ db_dict[counter] += 1
+ except Exception:
+ db_dict[counter] = 1
cls.new_stat_data['proxy'] = True
@classmethod
@@ -848,6 +851,15 @@ class Infos:
db_dict[counter] -= 1
cls.new_stat_data['proxy'] = True
+ @classmethod
+ def get_counter(cls, counter: str) -> int:
+ '''get proxy statistic counter'''
+ try:
+ db_dict = cls.stat['proxy']
+ return db_dict[counter]
+ except Exception:
+ return 0
+
def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \
-> Generator[tuple[str, str, str, str], None, None]:
'''Generator function yields json register struct for home-assistant
diff --git a/app/src/inverter_base.py b/app/src/inverter_base.py
index 18c8902..47d2478 100644
--- a/app/src/inverter_base.py
+++ b/app/src/inverter_base.py
@@ -28,11 +28,14 @@ class InverterBase(InverterIfc, Proxy):
Proxy.__init__(self)
self._registry.append(weakref.ref(self))
self.addr = writer.get_extra_info('peername')
+ self.client_mode = client_mode
self.config_id = config_id
if remote_prot_class:
self.prot_class = remote_prot_class
+ self.use_emulation = True
else:
self.prot_class = prot_class
+ self.use_emulation = False
self.__ha_restarts = -1
self.remote = StreamPtr(None)
ifc = AsyncStreamServer(reader, writer,
@@ -117,7 +120,8 @@ class InverterBase(InverterIfc, Proxy):
Config.act_config[self.config_id]['enabled'] = False
ifc = AsyncStreamClient(
- reader, writer, self.local, self.__del_remote)
+ reader, writer, self.local,
+ self.__del_remote, self.use_emulation)
self.remote.ifc = ifc
if hasattr(stream, 'id_str'):
diff --git a/app/src/logging.ini b/app/src/logging.ini
index 6be3905..fa84079 100644
--- a/app/src/logging.ini
+++ b/app/src/logging.ini
@@ -1,16 +1,15 @@
[loggers]
-keys=root,tracer,mesg,conn,data,mqtt,asyncio
+keys=root,tracer,mesg,conn,data,mqtt,asyncio,hypercorn_access,hypercorn_error
[handlers]
-keys=console_handler,file_handler_name1,file_handler_name2
+keys=console_handler,file_handler_name1,file_handler_name2,file_handler_name3,dashboard
[formatters]
keys=console_formatter,file_formatter
[logger_root]
level=DEBUG
-handlers=console_handler,file_handler_name1
-
+handlers=console_handler,file_handler_name1,dashboard
[logger_conn]
level=DEBUG
@@ -20,13 +19,13 @@ qualname=conn
[logger_mqtt]
level=INFO
-handlers=console_handler,file_handler_name1
+handlers=console_handler,file_handler_name1,dashboard
propagate=0
qualname=mqtt
[logger_asyncio]
level=INFO
-handlers=console_handler,file_handler_name1
+handlers=console_handler,file_handler_name1,dashboard
propagate=0
qualname=asyncio
@@ -49,6 +48,18 @@ handlers=file_handler_name2
propagate=0
qualname=tracer
+[logger_hypercorn_access]
+level=INFO
+handlers=file_handler_name3
+propagate=0
+qualname=hypercorn.access
+
+[logger_hypercorn_error]
+level=INFO
+handlers=file_handler_name1,dashboard
+propagate=0
+qualname=hypercorn.error
+
[handler_console_handler]
class=StreamHandler
level=DEBUG
@@ -66,6 +77,16 @@ level=NOTSET
formatter=file_formatter
args=(handlers.log_path + 'trace.log', when:='midnight', backupCount:=handlers.log_backups)
+[handler_file_handler_name3]
+class=handlers.TimedRotatingFileHandler
+level=NOTSET
+formatter=file_formatter
+args=(handlers.log_path + 'access.log', when:='midnight', backupCount:=handlers.log_backups)
+
+[handler_dashboard]
+level=WARNING
+class=web.log_handler.LogHandler
+
[formatter_console_formatter]
format=%(asctime)s %(levelname)5s | %(name)4s | %(message)s
datefmt=%Y-%m-%d %H:%M:%S
diff --git a/app/src/messages.py b/app/src/messages.py
index e067df6..7c04121 100644
--- a/app/src/messages.py
+++ b/app/src/messages.py
@@ -108,6 +108,7 @@ class Message(ProtocolIfc):
self.header_len = 0
self.data_len = 0
self.unique_id = 0
+ self.inv_serial = ''
self.sug_area = ''
self.new_data = {}
self.state = State.init
@@ -140,8 +141,9 @@ class Message(ProtocolIfc):
# to our _recv_buffer
return # pragma: no cover
- def _set_config_parms(self, inv: dict):
+ def _set_config_parms(self, inv: dict, inv_serial: str):
'''init connection with params from the configuration'''
+ self.inv_serial = inv_serial
self.node_id = inv['node_id']
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
diff --git a/app/src/modbus_tcp.py b/app/src/modbus_tcp.py
index 7ae635d..49786df 100644
--- a/app/src/modbus_tcp.py
+++ b/app/src/modbus_tcp.py
@@ -28,10 +28,12 @@ class ModbusConn():
logging.info(f'[{stream.node_id}:{stream.conn_no}] '
f'Connected to {self.addr}')
Infos.inc_counter('Inverter_Cnt')
+ Infos.inc_counter('ClientMode_Cnt')
await self.inverter.local.ifc.publish_outstanding_mqtt()
return self.inverter
async def __aexit__(self, exc_type, exc, tb):
+ Infos.dec_counter('ClientMode_Cnt')
Infos.dec_counter('Inverter_Cnt')
await self.inverter.local.ifc.publish_outstanding_mqtt()
self.inverter.__exit__(exc_type, exc, tb)
diff --git a/app/src/mqtt.py b/app/src/mqtt.py
index 0d33cac..cab0766 100644
--- a/app/src/mqtt.py
+++ b/app/src/mqtt.py
@@ -7,13 +7,18 @@ from modbus import Modbus
from messages import Message
from cnf.config import Config
from singleton import Singleton
+from datetime import datetime
+
logger_mqtt = logging.getLogger('mqtt')
class Mqtt(metaclass=Singleton):
- __client = None
+ __client: aiomqtt.Client = None
__cb_mqtt_is_up = None
+ ctime = None
+ published: int = 0
+ received: int = 0
def __init__(self, cb_mqtt_is_up):
logger_mqtt.debug('MQTT: __init__')
@@ -52,6 +57,7 @@ class Mqtt(metaclass=Singleton):
| int | float | None = None) -> None:
if self.__client:
await self.__client.publish(topic, payload)
+ self.published += 1
async def __loop(self) -> None:
mqtt = Config.get('mqtt')
@@ -69,6 +75,9 @@ class Mqtt(metaclass=Singleton):
try:
async with self.__client:
logger_mqtt.info('MQTT broker connection established')
+ self.ctime = datetime.now()
+ self.published = 0
+ self.received = 0
if self.__cb_mqtt_is_up:
await self.__cb_mqtt_is_up()
@@ -84,6 +93,8 @@ class Mqtt(metaclass=Singleton):
await self.dispatch_msg(message)
except aiomqtt.MqttError:
+ self.ctime = None
+
if Config.is_default('mqtt'):
logger_mqtt.info(
"MQTT is unconfigured; Check your config.toml!")
@@ -101,11 +112,14 @@ class Mqtt(metaclass=Singleton):
return
except Exception:
# self.inc_counter('SW_Exception') # fixme
+ self.ctime = None
logger_mqtt.error(
f"Exception:\n"
f"{traceback.format_exc()}")
async def dispatch_msg(self, message):
+ self.received += 1
+
if message.topic.matches(self.ha_status_topic):
status = message.payload.decode("UTF-8")
logger_mqtt.info('Home-Assistant Status:'
diff --git a/app/src/proxy.py b/app/src/proxy.py
index 8c935f7..afb06f3 100644
--- a/app/src/proxy.py
+++ b/app/src/proxy.py
@@ -96,8 +96,8 @@ class Proxy():
Infos.new_stat_data[key] = False
@classmethod
- def class_close(cls, loop) -> None: # pragma: no cover
+ async def class_close(cls, loop) -> None: # pragma: no cover
logging.debug('Proxy.class_close')
logging.info('Close MQTT Task')
- loop.run_until_complete(cls.mqtt.close())
+ await cls.mqtt.close()
cls.mqtt = None
diff --git a/app/src/server.py b/app/src/server.py
index 6056eb9..26ee093 100644
--- a/app/src/server.py
+++ b/app/src/server.py
@@ -1,93 +1,281 @@
import logging
-import asyncio
import logging.handlers
-import signal
+from logging import config # noqa F401
+import asyncio
+from asyncio import StreamReader, StreamWriter
import os
import argparse
-from asyncio import StreamReader, StreamWriter
-from aiohttp import web
-from logging import config # noqa F401
+from quart import Quart, Response
+
+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 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 modbus_tcp import ModbusTcp
-routes = web.RouteTableDef()
-proxy_is_up = False
+
+class Server():
+ serv_name = ''
+ version = ''
+ src_dir = ''
+
+ ####
+ # The following default values are used for the unit tests only, since
+ # `Server.parse_args()' will not be called during test setup.
+ # Ofcorse, we can call `Server.parse_args()' in a test case explicitly
+ # to overwrite this values
+ config_path = './config/'
+ json_config = ''
+ toml_config = ''
+ trans_path = '../translations/'
+ rel_urls = False
+ log_path = './log/'
+ log_backups = 0
+ log_level = None
+
+ def __init__(self, app, parse_args: bool):
+ ''' Applikation Setup
+
+ 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()
+
+ @app.context_processor
+ def utility_processor():
+ return dict(version=self.version)
+
+ def parse_args(self, arg_list: list[str] | None):
+ 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)
+
+ 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
+
+ def init_logging_system(self):
+ 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')
+
+ 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()
+
+ if config_err is not None:
+ logging.info(f'config_err: {config_err}')
+ return
+
+ logging.info('******')
+
+ 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}")
+
+ return switch.get(log_lvl, None)
-@routes.get('/')
-async def hello(request):
- return web.Response(text="Hello, world")
+class ProxyState:
+ _is_up = False
+
+ @staticmethod
+ def is_up() -> bool:
+ return ProxyState._is_up
+
+ @staticmethod
+ def set_up(value: bool):
+ ProxyState._is_up = value
-@routes.get('/-/ready')
-async def ready(request):
- if proxy_is_up:
+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)
+server = Server(app, __name__ == "__main__")
+Web(app, server.trans_path, server.rel_urls)
+
+
+@app.route('/-/ready')
+async def ready():
+ if ProxyState.is_up():
status = 200
text = 'Is ready'
else:
status = 503
text = 'Not ready'
- return web.Response(status=status, text=text)
+ return Response(status=status, response=text)
-@routes.get('/-/healthy')
-async def healthy(request):
+@app.route('/-/healthy')
+async def healthy():
- if proxy_is_up:
+ if ProxyState.is_up():
# logging.info('web reqeust healthy()')
for inverter in InverterIfc:
try:
res = inverter.healthy()
if not res:
- return web.Response(status=503, text="I have a problem")
+ return Response(status=503, response="I have a problem")
except Exception as err:
logging.info(f'Exception:{err}')
- return web.Response(status=200, text="I'm fine")
+ return Response(status=200, response="I'm fine")
-async def webserver(addr, port):
- '''coro running our webserver'''
- app = web.Application()
- app.add_routes(routes)
- runner = web.AppRunner(app)
-
- await runner.setup()
- site = web.TCPSite(runner, addr, port)
- await site.start()
- logging.info(f'HTTP server listen on port: {port}')
-
- try:
- # Normal interaction with aiohttp
- while True:
- await asyncio.sleep(3600) # sleep forever
- except asyncio.CancelledError:
- logging.info('HTTP server cancelled')
- await runner.cleanup()
- logging.debug('HTTP cleanup done')
-
-
-async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class):
+async def handle_client(reader: StreamReader,
+ writer: StreamWriter,
+ inv_class): # pragma: no cover
'''Handles a new incoming connection and starts an async loop'''
with inv_class(reader, writer) as inv:
await inv.local.ifc.server_loop()
-async def handle_shutdown(loop, web_task):
+@app.before_serving
+async def startup_app(): # pragma: no cover
+ 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')
- global proxy_is_up
- proxy_is_up = False
+ loop = asyncio.get_event_loop()
+ ProxyState.set_up(False)
#
# first, disc all open TCP connections gracefully
@@ -97,148 +285,21 @@ async def handle_shutdown(loop, web_task):
logging.info('Proxy disconnecting done')
- #
- # second, cancel the web server
- #
- web_task.cancel()
- await web_task
-
- #
- # now cancel all remaining (pending) tasks
- #
- pending = asyncio.all_tasks()
- for task in pending:
- task.cancel()
-
- #
- # at last, start a coro for stopping the loop
- #
- logging.debug("Stop event loop")
- loop.stop()
-
-
-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')
- args = parser.parse_args()
- #
- # Setup our daily, rotating logger
- #
- serv_name = os.getenv('SERVICE_NAME', 'proxy')
- version = os.getenv('VERSION', 'unknown')
-
- 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')
- 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"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"))
- 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)
-
- #
- # 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))
- web_task = loop.create_task(webserver('0.0.0.0', 8127))
-
- #
- # Register some UNIX Signal handler for a gracefully server shutdown
- # on Docker restart and stop
- #
- for signame in ('SIGINT', 'SIGTERM'):
- loop.add_signal_handler(getattr(signal, signame),
- lambda loop=loop: asyncio.create_task(
- handle_shutdown(loop, web_task)))
-
- loop.set_debug(log_level == logging.DEBUG)
- try:
- global proxy_is_up
- proxy_is_up = True
- loop.run_forever()
- except KeyboardInterrupt:
- pass
- finally:
- logging.info("Event loop is stopped")
- Proxy.class_close(loop)
- logging.debug('Close event loop')
- loop.close()
- logging.info(f'Finally, exit Server "{serv_name}"')
+ await Proxy.class_close(loop)
if __name__ == "__main__": # pragma: no cover
- main()
+
+ 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/utils/__init__.py b/app/src/utils/__init__.py
new file mode 100644
index 0000000..105f7ab
--- /dev/null
+++ b/app/src/utils/__init__.py
@@ -0,0 +1,25 @@
+import mimetypes
+from importlib import import_module
+from pathlib import Path
+from collections.abc import Callable
+
+
+class SourceFileLoader:
+ """ Represents a SouceFileLoader (__loader__)"""
+ name: str
+ get_resource_reader: Callable
+
+
+def load_modules(loader: SourceFileLoader):
+ """Load the entire modules from a SourceFileLoader (__loader__)"""
+ pkg = loader.name
+ for load in loader.get_resource_reader().contents():
+
+ if "python" not in str(mimetypes.guess_type(load)[0]):
+ continue
+
+ mod = Path(load).stem
+ if mod == "__init__":
+ continue
+
+ import_module(pkg + "." + mod, pkg)
diff --git a/app/src/web/__init__.py b/app/src/web/__init__.py
new file mode 100644
index 0000000..24b0e87
--- /dev/null
+++ b/app/src/web/__init__.py
@@ -0,0 +1,32 @@
+'''Quart blueprint for the proxy webserver with the dashboard
+
+Usage:
+ app = Quart(__name__, ...)
+ Web(app)
+'''
+from quart import Quart, Blueprint
+from quart_babel import Babel
+from utils import load_modules
+
+web = Blueprint('web', __name__)
+
+load_modules(__loader__)
+
+
+class Web:
+ '''Helper Class to register the Blueprint at Quart and
+ initializing Babel'''
+ def __init__(self,
+ app: Quart,
+ translation_directories: str | list[str],
+ rel_urls: bool):
+ web.build_relative_urls = rel_urls
+ app.register_blueprint(web)
+
+ from .i18n import get_locale, get_tz
+ global babel
+ babel = Babel(
+ app,
+ locale_selector=get_locale,
+ timezone_selector=get_tz,
+ default_translation_directories=translation_directories)
diff --git a/app/src/web/conn_table.py b/app/src/web/conn_table.py
new file mode 100644
index 0000000..4b81868
--- /dev/null
+++ b/app/src/web/conn_table.py
@@ -0,0 +1,87 @@
+from inverter_base import InverterBase
+from quart import render_template
+from quart_babel import format_datetime, _
+from infos import Infos
+
+from . import web
+from .log_handler import LogHandler
+
+
+def _get_device_icon(client_mode: bool):
+ '''returns the icon for the device conntection'''
+ if client_mode:
+ return 'fa-download fa-rotate-180'
+
+ return 'fa-upload fa-rotate-180'
+
+
+def _get_cloud_icon(emu_mode: bool):
+ '''returns the icon for the cloud conntection'''
+ if emu_mode:
+ return 'fa-cloud-arrow-up-alt'
+
+ return 'fa-cloud'
+
+
+def _get_row(inv: InverterBase):
+ '''build one row for the connection table'''
+ client_mode = inv.client_mode
+ inv_serial = inv.local.stream.inv_serial
+ icon1 = _get_device_icon(client_mode)
+ ip1, port1 = inv.addr
+ icon2 = ''
+ ip2 = '--'
+ port2 = '--'
+
+ if inv.remote.ifc:
+ ip2, port2 = inv.remote.ifc.r_addr
+ icon2 = _get_cloud_icon(client_mode)
+
+ row = []
+ row.append(f' {ip1}:{port1}')
+ row.append(f' {ip1}')
+ row.append(inv_serial)
+ row.append(f' {ip2}:{port2}')
+ row.append(f' {ip2}')
+ return row
+
+
+def get_table_data():
+ '''build the connection table'''
+ table = {
+ "headline": _('Connections'),
+ "col_classes": [
+ "w3-hide-small w3-hide-medium", "w3-hide-large",
+ "",
+ "w3-hide-small w3-hide-medium", "w3-hide-large",
+ ],
+ "thead": [[
+ _('Device-IP:Port'), _('Device-IP'),
+ _("Serial-No"),
+ _("Cloud-IP:Port"), _("Cloud-IP")
+ ]],
+ "tbody": []
+ }
+ for inverter in InverterBase:
+ table['tbody'].append(_get_row(inverter))
+
+ return table
+
+
+@web.route('/data-fetch')
+async def data_fetch():
+ data = {
+ "update-time": format_datetime(format="medium"),
+ "server-cnt": f"
{Infos.get_counter('ServerMode_Cnt')}
",
+ "client-cnt": f"{Infos.get_counter('ClientMode_Cnt')}
",
+ "proxy-cnt": f"{Infos.get_counter('ProxyMode_Cnt')}
",
+ "emulation-cnt": f"{Infos.get_counter('EmuMode_Cnt')}
",
+ }
+ data["conn-table"] = await render_template('templ_table.html.j2',
+ table=get_table_data())
+
+ data["notes-list"] = await render_template(
+ 'templ_notes_list.html.j2',
+ notes=LogHandler().get_buffer(3),
+ hide_if_empty=True)
+ return data
diff --git a/app/src/web/favicon.py b/app/src/web/favicon.py
new file mode 100644
index 0000000..f486be3
--- /dev/null
+++ b/app/src/web/favicon.py
@@ -0,0 +1,37 @@
+import os
+
+from quart import send_from_directory
+
+from . import web
+
+
+async def get_icon(file: str, mime: str = 'image/png'):
+ return await send_from_directory(
+ os.path.join(web.root_path, 'static/images'),
+ file,
+ mimetype=mime)
+
+
+@web.route('/favicon-96x96.png')
+async def favicon():
+ return await get_icon('favicon-96x96.png')
+
+
+@web.route('/favicon.ico')
+async def favicon_ico():
+ return await get_icon('favicon.ico', 'image/x-icon')
+
+
+@web.route('/favicon.svg')
+async def favicon_svg():
+ return await get_icon('favicon.svg', 'image/svg+xml')
+
+
+@web.route('/apple-touch-icon.png')
+async def apple_touch():
+ return await get_icon('apple-touch-icon.png')
+
+
+@web.route('/site.webmanifest')
+async def webmanifest():
+ return await get_icon('site.webmanifest', 'application/manifest+json')
diff --git a/app/src/web/i18n.py b/app/src/web/i18n.py
new file mode 100644
index 0000000..3520983
--- /dev/null
+++ b/app/src/web/i18n.py
@@ -0,0 +1,45 @@
+from quart import request, session, redirect, abort
+from quart_babel.locale import get_locale as babel_get_locale
+
+from . import web
+
+LANGUAGES = {
+ 'en': 'English',
+ 'de': 'Deutsch',
+ # 'fr': 'Français'
+}
+
+
+def get_locale():
+ try:
+ language = session['language']
+ except KeyError:
+ language = None
+ if language is not None:
+ return language
+
+ # check how to get the locale form for the add-on - hass.selectedLanguage
+ # logging.info("get_locale(%s)", request.accept_languages)
+ return request.accept_languages.best_match(LANGUAGES.keys())
+
+
+def get_tz():
+ return 'CET'
+
+
+@web.context_processor
+def utility_processor():
+ return dict(lang=babel_get_locale(),
+ lang_str=LANGUAGES.get(str(babel_get_locale()), "English"),
+ languages=LANGUAGES)
+
+
+@web.route('/language/')
+async def set_language(language=None):
+ if language in LANGUAGES:
+ session['language'] = language
+
+ rsp = redirect(request.referrer if request.referrer else '../#')
+ rsp.content_language = language
+ return rsp
+ return abort(404)
diff --git a/app/src/web/log_files.py b/app/src/web/log_files.py
new file mode 100644
index 0000000..772e292
--- /dev/null
+++ b/app/src/web/log_files.py
@@ -0,0 +1,60 @@
+from quart import render_template
+from quart_babel import format_datetime, format_decimal
+from quart.helpers import send_from_directory
+from werkzeug.utils import secure_filename
+from cnf.config import Config
+import os
+
+from . import web
+
+
+def _get_file(file):
+ '''build one row for the connection table'''
+ entry = {}
+ entry['name'] = file.name
+ stat = file.stat()
+ entry['size'] = format_decimal(stat.st_size)
+ entry['date'] = stat.st_mtime
+ entry['created'] = format_datetime(stat.st_ctime, format="short")
+ entry['modified'] = format_datetime(stat.st_mtime, format="short")
+ return entry
+
+
+def get_list_data():
+ '''build the connection table'''
+ file_list = []
+ with os.scandir(Config.get_log_path()) as it:
+ for entry in it:
+ if entry.is_file():
+ file_list.append(_get_file(entry))
+
+ file_list.sort(key=lambda x: x['date'], reverse=True)
+ return file_list
+
+
+@web.route('/file-fetch')
+async def file_fetch():
+ data = {
+ "update-time": format_datetime(format="medium"),
+ }
+ data["file-list"] = await render_template('templ_log_files_list.html.j2',
+ dir_list=get_list_data())
+
+ return data
+
+
+@web.route('/send-file/')
+async def send(file):
+ return await send_from_directory(
+ directory=Config.get_log_path(),
+ file_name=secure_filename(file),
+ as_attachment=True)
+
+
+@web.route('/del-file/', methods=['DELETE'])
+async def delete(file):
+ try:
+ os.remove(Config.get_log_path() + secure_filename(file))
+ except OSError:
+ return 'File not found', 404
+ return '', 204
diff --git a/app/src/web/log_handler.py b/app/src/web/log_handler.py
new file mode 100644
index 0000000..7565649
--- /dev/null
+++ b/app/src/web/log_handler.py
@@ -0,0 +1,24 @@
+from logging import Handler
+from logging import LogRecord
+import logging
+from collections import deque
+
+from singleton import Singleton
+
+
+class LogHandler(Handler, metaclass=Singleton):
+ def __init__(self, capacity=64):
+ super().__init__(logging.WARNING)
+ self.capacity = capacity
+ self.buffer = deque(maxlen=capacity)
+
+ def emit(self, record: LogRecord):
+ self.buffer.append({
+ 'ctime': record.created,
+ 'level': record.levelno,
+ 'lname': record.levelname,
+ 'msg': record.getMessage()
+ })
+
+ def get_buffer(self, elms=0) -> list:
+ return list(self.buffer)[-elms:]
diff --git a/app/src/web/mqtt_table.py b/app/src/web/mqtt_table.py
new file mode 100644
index 0000000..8370c17
--- /dev/null
+++ b/app/src/web/mqtt_table.py
@@ -0,0 +1,64 @@
+from inverter_base import InverterBase
+from quart import render_template
+from quart_babel import format_datetime, _
+from mqtt import Mqtt
+
+from . import web
+from .log_handler import LogHandler
+
+
+def _get_row(inv: InverterBase):
+ '''build one row for the connection table'''
+ entity_prfx = inv.entity_prfx
+ inv_serial = inv.local.stream.inv_serial
+ node_id = inv.local.stream.node_id
+ sug_area = inv.local.stream.sug_area
+
+ row = []
+ row.append(inv_serial)
+ row.append(entity_prfx+node_id)
+ row.append(sug_area)
+ return row
+
+
+def get_table_data():
+ '''build the connection table'''
+ table = {
+ "headline": _('MQTT devices'),
+ "col_classes": [
+ "",
+ "",
+ "",
+ ],
+ "thead": [[
+ _("Serial-No"),
+ _('Node-ID'),
+ _('HA-Area'),
+ ]],
+ "tbody": []
+ }
+ for inverter in InverterBase:
+ table['tbody'].append(_get_row(inverter))
+
+ return table
+
+
+@web.route('/mqtt-fetch')
+async def mqtt_fetch():
+ mqtt = Mqtt(None)
+ ctime = format_datetime(dt=mqtt.ctime, format='short')
+ data = {
+ "update-time": format_datetime(format="medium"),
+ "mqtt-ctime": f"{ctime}
",
+ "mqtt-tx": f"{mqtt.published}
",
+ "mqtt-rx": f"{mqtt.received}
",
+ }
+ data["mqtt-table"] = await render_template('templ_table.html.j2',
+ table=get_table_data())
+
+ data["notes-list"] = await render_template(
+ 'templ_notes_list.html.j2',
+ notes=LogHandler().get_buffer(3),
+ hide_if_empty=True)
+
+ return data
diff --git a/app/src/web/notes_list.py b/app/src/web/notes_list.py
new file mode 100644
index 0000000..e96c319
--- /dev/null
+++ b/app/src/web/notes_list.py
@@ -0,0 +1,19 @@
+from quart import render_template
+from quart_babel import format_datetime
+
+from . import web
+from .log_handler import LogHandler
+
+
+@web.route('/notes-fetch')
+async def notes_fetch():
+ data = {
+ "update-time": format_datetime(format="medium"),
+ }
+
+ data["notes-list"] = await render_template(
+ 'templ_notes_list.html.j2',
+ notes=LogHandler().get_buffer(),
+ hide_if_empty=False)
+
+ return data
diff --git a/app/src/web/pages.py b/app/src/web/pages.py
new file mode 100644
index 0000000..49d720a
--- /dev/null
+++ b/app/src/web/pages.py
@@ -0,0 +1,32 @@
+from quart import render_template
+from .wrapper import url_for
+
+from . import web
+
+
+@web.route('/')
+async def index():
+ return await render_template(
+ 'page_index.html.j2',
+ fetch_url=url_for('.data_fetch'))
+
+
+@web.route('/mqtt')
+async def mqtt():
+ return await render_template(
+ 'page_mqtt.html.j2',
+ fetch_url=url_for('.mqtt_fetch'))
+
+
+@web.route('/notes')
+async def notes():
+ return await render_template(
+ 'page_notes.html.j2',
+ fetch_url=url_for('.notes_fetch'))
+
+
+@web.route('/logging')
+async def logging():
+ return await render_template(
+ 'page_logging.html.j2',
+ fetch_url=url_for('.file_fetch'))
diff --git a/app/src/web/static/css/style.css b/app/src/web/static/css/style.css
new file mode 100644
index 0000000..f95ecef
--- /dev/null
+++ b/app/src/web/static/css/style.css
@@ -0,0 +1,251 @@
+/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
+html{box-sizing:border-box;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;overflow-x:hidden}*,*:before,*:after{box-sizing:inherit}
+/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
+body{margin:0}
+article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
+audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
+audio:not([controls]){display:none;height:0}[hidden],template{display:none}
+a{background-color:transparent;color:inherit}
+a:active,a:hover{outline-width:0}
+abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
+b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
+small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
+sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}
+code,kbd,pre,samp{font-family:monospace;font-size:1em}
+button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
+button,input{overflow:visible}button,select{text-transform:none}
+button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;appearance:button}
+button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
+button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
+fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
+legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
+[type=checkbox],[type=radio]{padding:0}
+[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
+[type=search]{-webkit-appearance:textfield;appearance:textfoeld;outline-offset:-2px}
+[type=search]::-webkit-search-decoration{-webkit-appearance:none}
+::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
+/* End extract */
+html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}
+h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
+.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
+h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
+hr{box-sizing:content-box;height:0;overflow:visible;border:0;border-top:1px solid #eee;margin:20px 0}
+.w3-image{max-width:100%;height:auto}img{border-style:none;vertical-align:middle}
+.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
+.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
+.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
+.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
+.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
+.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
+.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
+.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
+.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
+.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
+.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
+.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
+.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
+.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
+.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
+.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
+.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
+.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
+.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
+.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
+.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
+.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
+.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
+.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
+.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
+.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
+.w3-main,#main{transition:margin-left .4s}
+.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
+.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
+.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
+.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
+.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
+.w3-bar .w3-button{white-space:normal}
+.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
+.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
+.w3-responsive{display:block;overflow-x:auto}
+.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
+.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
+.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
+.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
+.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
+.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
+@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
+.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
+.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
+@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
+.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
+.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
+.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
+.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
+.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
+.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
+.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
+@media (max-width:1205px){.w3-auto{max-width:95%}}
+@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
+.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}
+.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
+.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
+@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
+@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
+@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
+@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
+.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
+.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
+.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
+.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
+.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
+.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
+.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
+.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
+.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
+.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
+.w3-display-position{position:absolute}
+.w3-circle{border-radius:50%}
+.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
+.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
+.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
+.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
+.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
+.w3-code,.w3-codespan{font-family:Consolas,"courier new",monospace;font-size:16px}
+.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
+.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
+.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
+.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
+.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
+.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
+.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
+.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
+.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
+.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
+.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
+.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
+.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
+.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
+.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
+.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
+.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
+.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
+.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
+.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
+.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
+.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
+.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
+.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
+.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
+.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
+.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
+.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
+.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
+.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
+.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
+.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
+.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
+.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
+.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
+.w3-left{float:left!important}.w3-right{float:right!important}
+.w3-button:hover{color:#000!important;background-color:#ccc!important}
+.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
+.w3-hover-none:hover{box-shadow:none!important}
+.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
+/* Colors */
+.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
+.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
+.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
+.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
+.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
+.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
+.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
+.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
+.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
+.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
+.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
+.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
+.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
+.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
+.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
+.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
+.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
+.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
+.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
+.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
+.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
+.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
+.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
+.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
+.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
+.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
+.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
+.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
+.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
+.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
+.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
+.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
+.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
+.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
+.w3-danger{color:#fff!important;background-color:#dd0000!important}
+.w3-note{color:#000!important;background-color:#fff599!important}
+.w3-info{color:#fff!important;background-color:#0a6fc2!important}
+.w3-warning{color:#000!important;background-color:#ffb305!important}
+.w3-success{color:#fff!important;background-color:#008a00!important}
+.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
+.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
+.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
+.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
+.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
+.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
+.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
+.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
+.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
+.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
+.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
+.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
+.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
+.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
+.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
+.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
+.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
+.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
+.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
+.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
+.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
+.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
+.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
+.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
+.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
+.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
+.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
+.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
+.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
+.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
+.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
+.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
+.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
+.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
+.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
+.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
+.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
+.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
+.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
+.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
+.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
+.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
+.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
+.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
+.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
+.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
+.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
+.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
+.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
+.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
+.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
+.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
+.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
+.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
+.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
+.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
+.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
+.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
\ No newline at end of file
diff --git a/app/src/web/static/font-awesome/css/all.min.css b/app/src/web/static/font-awesome/css/all.min.css
new file mode 100644
index 0000000..dd09622
--- /dev/null
+++ b/app/src/web/static/font-awesome/css/all.min.css
@@ -0,0 +1,9 @@
+/*!
+ * Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
+ * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
+ */
+.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-bacteria:before{content:"\e059"}.fa-bacterium:before{content:"\e05a"}.fa-bahai:before{content:"\f666"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-box-tissue:before{content:"\e05b"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudflare:before{content:"\e07d"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-alt:before{content:"\f422"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dailymotion:before{content:"\e052"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-deezer:before{content:"\e077"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-disease:before{content:"\f7fa"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edge-legacy:before{content:"\e078"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-alt:before{content:"\f424"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-faucet:before{content:"\e005"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\e007"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-pay:before{content:"\e079"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guilded:before{content:"\e07e"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-holding-water:before{content:"\f4c1"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-sparkles:before{content:"\e05d"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-hands-wash:before{content:"\e05e"}.fa-handshake:before{content:"\f2b5"}.fa-handshake-alt-slash:before{content:"\e05f"}.fa-handshake-slash:before{content:"\e060"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-hdd:before{content:"\f0a0"}.fa-head-side-cough:before{content:"\e061"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-head-side-mask:before{content:"\e063"}.fa-head-side-virus:before{content:"\e064"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hive:before{content:"\e07f"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hospital-user:before{content:"\f80d"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-house-user:before{content:"\e065"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-ideal:before{content:"\e013"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-innosoft:before{content:"\e080"}.fa-instagram:before{content:"\f16d"}.fa-instagram-square:before{content:"\e055"}.fa-instalod:before{content:"\e081"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-house:before{content:"\e066"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lungs:before{content:"\f604"}.fa-lungs-virus:before{content:"\e067"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microblog:before{content:"\e01a"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mixer:before{content:"\e056"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse:before{content:"\f8cc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-octopus-deploy:before{content:"\e082"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-arrows:before{content:"\e068"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-perbyte:before{content:"\e083"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\e01e"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-plane-slash:before{content:"\e069"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pump-medical:before{content:"\e06a"}.fa-pump-soap:before{content:"\e06b"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-rust:before{content:"\e07a"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-shield-virus:before{content:"\e06c"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopify:before{content:"\e057"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sink:before{content:"\e06d"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-soap:before{content:"\e06e"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-stopwatch-20:before{content:"\e06f"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-store-alt-slash:before{content:"\e070"}.fa-store-slash:before{content:"\e071"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swift:before{content:"\f8e1"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-tiktok:before{content:"\e07b"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\e041"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-uncharted:before{content:"\e084"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\e049"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-unsplash:before{content:"\e07c"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-users-slash:before{content:"\e073"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-vest:before{content:"\e085"}.fa-vest-patches:before{content:"\e086"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-virus:before{content:"\e074"}.fa-virus-slash:before{content:"\e075"}.fa-viruses:before{content:"\e076"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-watchman-monitoring:before{content:"\e087"}.fa-water:before{content:"\f773"}.fa-wave-square:before{content:"\f83e"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wodu:before{content:"\e088"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.fab,.far{font-weight:400}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fas{font-weight:900}
+/* additional styles */
+.fa-rotate-right:before{content:"\f01e"}
+.fa-cloud-arrow-down-alt:before{content:"\f381"}
+.fa-cloud-arrow-up-alt:before{content:"\f382"}
diff --git a/app/src/web/static/font-awesome/webfonts/fa-brands-400.eot b/app/src/web/static/font-awesome/webfonts/fa-brands-400.eot
new file mode 100644
index 0000000..cba6c6c
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-brands-400.eot differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-brands-400.svg b/app/src/web/static/font-awesome/webfonts/fa-brands-400.svg
new file mode 100644
index 0000000..b9881a4
--- /dev/null
+++ b/app/src/web/static/font-awesome/webfonts/fa-brands-400.svg
@@ -0,0 +1,3717 @@
+
+
+
diff --git a/app/src/web/static/font-awesome/webfonts/fa-brands-400.ttf b/app/src/web/static/font-awesome/webfonts/fa-brands-400.ttf
new file mode 100644
index 0000000..8d75ded
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-brands-400.ttf differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-brands-400.woff b/app/src/web/static/font-awesome/webfonts/fa-brands-400.woff
new file mode 100644
index 0000000..3375bef
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-brands-400.woff differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-brands-400.woff2 b/app/src/web/static/font-awesome/webfonts/fa-brands-400.woff2
new file mode 100644
index 0000000..402f81c
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-brands-400.woff2 differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-regular-400.eot b/app/src/web/static/font-awesome/webfonts/fa-regular-400.eot
new file mode 100644
index 0000000..a4e5989
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-regular-400.eot differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-regular-400.svg b/app/src/web/static/font-awesome/webfonts/fa-regular-400.svg
new file mode 100644
index 0000000..463af27
--- /dev/null
+++ b/app/src/web/static/font-awesome/webfonts/fa-regular-400.svg
@@ -0,0 +1,801 @@
+
+
+
diff --git a/app/src/web/static/font-awesome/webfonts/fa-regular-400.ttf b/app/src/web/static/font-awesome/webfonts/fa-regular-400.ttf
new file mode 100644
index 0000000..7157aaf
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-regular-400.ttf differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-regular-400.woff b/app/src/web/static/font-awesome/webfonts/fa-regular-400.woff
new file mode 100644
index 0000000..ad077c6
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-regular-400.woff differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-regular-400.woff2 b/app/src/web/static/font-awesome/webfonts/fa-regular-400.woff2
new file mode 100644
index 0000000..5632894
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-regular-400.woff2 differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-solid-900.eot b/app/src/web/static/font-awesome/webfonts/fa-solid-900.eot
new file mode 100644
index 0000000..e994171
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-solid-900.eot differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-solid-900.svg b/app/src/web/static/font-awesome/webfonts/fa-solid-900.svg
new file mode 100644
index 0000000..00296e9
--- /dev/null
+++ b/app/src/web/static/font-awesome/webfonts/fa-solid-900.svg
@@ -0,0 +1,5034 @@
+
+
+
diff --git a/app/src/web/static/font-awesome/webfonts/fa-solid-900.ttf b/app/src/web/static/font-awesome/webfonts/fa-solid-900.ttf
new file mode 100644
index 0000000..25abf38
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-solid-900.ttf differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-solid-900.woff b/app/src/web/static/font-awesome/webfonts/fa-solid-900.woff
new file mode 100644
index 0000000..23ee663
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-solid-900.woff differ
diff --git a/app/src/web/static/font-awesome/webfonts/fa-solid-900.woff2 b/app/src/web/static/font-awesome/webfonts/fa-solid-900.woff2
new file mode 100644
index 0000000..2217164
Binary files /dev/null and b/app/src/web/static/font-awesome/webfonts/fa-solid-900.woff2 differ
diff --git a/app/src/web/static/font/roboto-light.ttf b/app/src/web/static/font/roboto-light.ttf
new file mode 100644
index 0000000..6fcd5f9
Binary files /dev/null and b/app/src/web/static/font/roboto-light.ttf differ
diff --git a/app/src/web/static/images/apple-touch-icon.png b/app/src/web/static/images/apple-touch-icon.png
new file mode 100644
index 0000000..1957214
Binary files /dev/null and b/app/src/web/static/images/apple-touch-icon.png differ
diff --git a/app/src/web/static/images/favicon-96x96.png b/app/src/web/static/images/favicon-96x96.png
new file mode 100644
index 0000000..2894fa3
Binary files /dev/null and b/app/src/web/static/images/favicon-96x96.png differ
diff --git a/app/src/web/static/images/favicon.ico b/app/src/web/static/images/favicon.ico
new file mode 100644
index 0000000..c543bbd
Binary files /dev/null and b/app/src/web/static/images/favicon.ico differ
diff --git a/app/src/web/static/images/favicon.svg b/app/src/web/static/images/favicon.svg
new file mode 100644
index 0000000..a494bc6
--- /dev/null
+++ b/app/src/web/static/images/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src/web/static/images/logo.png b/app/src/web/static/images/logo.png
new file mode 100644
index 0000000..f11771f
Binary files /dev/null and b/app/src/web/static/images/logo.png differ
diff --git a/app/src/web/static/images/site.webmanifest b/app/src/web/static/images/site.webmanifest
new file mode 100644
index 0000000..cab5641
--- /dev/null
+++ b/app/src/web/static/images/site.webmanifest
@@ -0,0 +1,21 @@
+{
+ "name": "TSUN-Proxy",
+ "short_name": "Proxy",
+ "icons": [
+ {
+ "src": "/web-app-manifest-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/web-app-manifest-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/app/src/web/static/images/web-app-manifest-192x192.png b/app/src/web/static/images/web-app-manifest-192x192.png
new file mode 100644
index 0000000..3fcbdbe
Binary files /dev/null and b/app/src/web/static/images/web-app-manifest-192x192.png differ
diff --git a/app/src/web/static/images/web-app-manifest-512x512.png b/app/src/web/static/images/web-app-manifest-512x512.png
new file mode 100644
index 0000000..1ec9ad0
Binary files /dev/null and b/app/src/web/static/images/web-app-manifest-512x512.png differ
diff --git a/app/src/web/templates/base.html.j2 b/app/src/web/templates/base.html.j2
new file mode 100644
index 0000000..0ef1f7e
--- /dev/null
+++ b/app/src/web/templates/base.html.j2
@@ -0,0 +1,150 @@
+
+
+
+ {% block title %}{% endblock title %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if fetch_url is defined %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block header %}
+
+ {% block headline %}{% endblock headline %}
+
+ {% endblock header %}
+
+ {% block content %} {% endblock content%}
+
+
+ {% block footer %}
+
+ {% endblock footer %}
+
+
+
+
+ {% block trailer %}
+
+ {% endblock trailer %}
+
+
+
diff --git a/app/src/web/templates/page_index.html.j2 b/app/src/web/templates/page_index.html.j2
new file mode 100644
index 0000000..65cae0d
--- /dev/null
+++ b/app/src/web/templates/page_index.html.j2
@@ -0,0 +1,67 @@
+{% extends 'base.html.j2' %}
+
+{% block title %}{{_("TSUN Proxy - Connections")}}{% endblock title %}
+{% block menu1_class %}w3-blue{% endblock %}
+{% block headline %} {{_('Proxy Connection Overview')}}{% endblock headline %}
+
+{% block content %}
+
+
+
+
+
+
+
-
+
+
+
{{_('Server Mode')}}
+
{{_('Established from device to proxy')}}
+
+
+
+
+
+
+
+
+
-
+
+
+
{{_('Client Mode')}}
+
{{_('Established from proxy to device')}}
+
+
+
+
+
+
+
+
+
-
+
+
+
{{_('Proxy Mode')}}
+
{{_('Forwarding data to cloud')}}
+
+
+
+
+
+
+
+
+
-
+
+
+
{{_('Emu Mode')}}
+
{{_('Emulation sends data to cloud')}}
+
+
+
+
+
+
+
+{% endblock content%}
+
+{% block footer %}{% endblock footer %}
diff --git a/app/src/web/templates/page_logging.html.j2 b/app/src/web/templates/page_logging.html.j2
new file mode 100644
index 0000000..f80763c
--- /dev/null
+++ b/app/src/web/templates/page_logging.html.j2
@@ -0,0 +1,30 @@
+{% extends 'base.html.j2' %}
+
+{% block title %}{{_("TSUN Proxy - Log Files")}}{% endblock title %}
+{% block menu4_class %}w3-blue{% endblock %}
+{% block headline %} {{_('Log Files')}}{% endblock headline %}
+{% block content %}
+
+
+
+
{{_("Do you really want to delete the log file")}}:
?
+
+
+
+
+
+
+
+
+
+
+
+{% endblock content%}
+
+{% block footer %}{% endblock footer %}
diff --git a/app/src/web/templates/page_mqtt.html.j2 b/app/src/web/templates/page_mqtt.html.j2
new file mode 100644
index 0000000..0f23492
--- /dev/null
+++ b/app/src/web/templates/page_mqtt.html.j2
@@ -0,0 +1,52 @@
+{% extends 'base.html.j2' %}
+
+{% block title %}{{_("TSUN Proxy - MQTT Status")}}{% endblock title %}
+{% block menu2_class %}w3-blue{% endblock %}
+{% block headline %} {{_('MQTT Overview')}}{% endblock headline %}
+{% block content %}
+
+
+
+
+
+
+
-
+
+
+
{{_('Connection Time')}}
+
{{_('Time at which the connection was established')}}
+
+
+
+
+
+
+
+
+
-
+
+
+
{{_('Published Topics')}}
+
{{_('Number of published topics')}}
+
+
+
+
+
+
+
+
+
-
+
+
+
{{_('Received Topics')}}
+
{{_('Number of topics received')}}
+
+
+
+
+
+
+{% endblock content%}
+
+{% block footer %}{% endblock footer %}
diff --git a/app/src/web/templates/page_notes.html.j2 b/app/src/web/templates/page_notes.html.j2
new file mode 100644
index 0000000..495e5e2
--- /dev/null
+++ b/app/src/web/templates/page_notes.html.j2
@@ -0,0 +1,10 @@
+{% extends 'base.html.j2' %}
+
+{% block title %}{{_("TSUN Proxy - Important Messages")}}{% endblock title %}
+{% block menu3_class %}w3-blue{% endblock %}
+{% block headline %} {{_('Important Messages')}}{% endblock headline %}
+{% block content %}
+
+{% endblock content%}
+
+{% block footer %}{% endblock footer %}
diff --git a/app/src/web/templates/templ_log_files_list.html.j2 b/app/src/web/templates/templ_log_files_list.html.j2
new file mode 100644
index 0000000..ab98afb
--- /dev/null
+++ b/app/src/web/templates/templ_log_files_list.html.j2
@@ -0,0 +1,33 @@
+
+{% for file in dir_list %}
+
+
+
+
+
+
+ {% for idx, name in [('created',_('Created')), ('modified', _('Modified')), ('size', _('Size'))]%}
+
+ | {{_(name)}}: |
+ {{file[idx]}} |
+
+ {% endfor %}
+
+
+
+
+
+
+{% if 0 == (loop.index%4) and not last %}
+
+
+{% endif %}
+{% endfor %}
+
+
diff --git a/app/src/web/templates/templ_notes_list.html.j2 b/app/src/web/templates/templ_notes_list.html.j2
new file mode 100644
index 0000000..6d5d98e
--- /dev/null
+++ b/app/src/web/templates/templ_notes_list.html.j2
@@ -0,0 +1,23 @@
+{% if notes|length > 0 %}
+
+
{{_("Warnings and error messages")}}
+
+ {% for note in notes %}
+ -
+ {{note.ctime|datetimeformat(format='short')}}
+ {{note.lname|e}}
+ {{note.msg|e}}
+
+ {% endfor %}
+
+
+{% elif not hide_if_empty %}
+
+
+
+
{{_("Well done!")}}
+
{{_("No warnings or errors have been logged since the last proxy start.")}}
+
+
+
+{% endif %}
\ No newline at end of file
diff --git a/app/src/web/templates/templ_table.html.j2 b/app/src/web/templates/templ_table.html.j2
new file mode 100644
index 0000000..9c8f750
--- /dev/null
+++ b/app/src/web/templates/templ_table.html.j2
@@ -0,0 +1,42 @@
+{% macro table_elm(elm, col_class, tag='td') -%}
+ {% if elm is mapping %}
+ <{{tag}}
+ {% if (col_class|length) > 0 or elm.class is defined%}class="{{col_class}} {{elm.class}}"{% endif %}
+ {% if elm.colspan is defined %}colspan="{{elm.colspan}}"{% endif %}
+ {% if elm.rowspan is defined %}rowspan="{{elm.rowspan}}"{% endif %}
+ >{{elm.val}}{{tag}}>
+ {% else %}
+ <{{tag}}
+ {% if (col_class|length) > 0 %}class="{{col_class}}"{% endif %}
+ >{{elm}}{{tag}}>
+ {% endif %}
+{%- endmacro%}
+
+
+
{{table.headline}}
+
+
+
+ {% if table.thead is defined%}
+
+ {% for row in table.thead %}
+
+ {% for col in row %}
+ {{table_elm(col, table.col_classes[loop.index0], 'th')}}
+ {% endfor %}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% for row in table.tbody %}
+
+ {% for col in row %}
+ {{table_elm(col, table.col_classes[loop.index0])}}
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+
\ No newline at end of file
diff --git a/app/src/web/wrapper.py b/app/src/web/wrapper.py
new file mode 100644
index 0000000..58c6a46
--- /dev/null
+++ b/app/src/web/wrapper.py
@@ -0,0 +1,26 @@
+from quart import url_for as quart_url_for
+from . import web
+
+
+def url_for(*args, **kwargs):
+ """Return the url for a specific endpoint.
+
+ This wrapper optionally convert into a relative url.
+
+ This is most useful in templates and redirects to create a URL
+ that can be used in the browser.
+
+ Arguments:
+ endpoint: The endpoint to build a url for, if prefixed with
+ ``.`` it targets endpoint's in the current blueprint.
+ _anchor: Additional anchor text to append (i.e. #text).
+ _external: Return an absolute url for external (to app) usage.
+ _method: The method to consider alongside the endpoint.
+ _scheme: A specific scheme to use.
+ values: The values to build into the URL, as specified in
+ the endpoint rule.
+ """
+ url = quart_url_for(*args, **kwargs)
+ if '/' == url[0] and web.build_relative_urls:
+ url = '.' + url
+ return url
diff --git a/app/tests/cnf/invalid_config.toml b/app/tests/cnf/invalid_config.toml
new file mode 100644
index 0000000..80075f2
--- /dev/null
+++ b/app/tests/cnf/invalid_config.toml
@@ -0,0 +1 @@
+mqtt.port = ":1883"
diff --git a/app/tests/conftest.py b/app/tests/conftest.py
new file mode 100644
index 0000000..3c3ebef
--- /dev/null
+++ b/app/tests/conftest.py
@@ -0,0 +1,20 @@
+import pytest_asyncio
+import asyncio
+
+
+@pytest_asyncio.fixture
+async def my_loop():
+ event_loop = asyncio.get_running_loop()
+ yield event_loop
+
+ # Collect all tasks and cancel those that are not 'done'.
+ tasks = asyncio.all_tasks(event_loop)
+ tasks = [t for t in tasks if not t.done()]
+ for task in tasks:
+ task.cancel()
+
+ # Wait for all tasks to complete, ignoring any CancelledErrors
+ try:
+ await asyncio.wait(tasks)
+ except asyncio.exceptions.CancelledError:
+ pass
diff --git a/app/tests/log/sub_dir/not_reachable.txt b/app/tests/log/sub_dir/not_reachable.txt
new file mode 100644
index 0000000..e69de29
diff --git a/app/tests/log/test.txt b/app/tests/log/test.txt
new file mode 100644
index 0000000..d7c6d5e
--- /dev/null
+++ b/app/tests/log/test.txt
@@ -0,0 +1,19 @@
+2025-04-30 00:01:23 INFO | root | Server "proxy - unknown" will be started
+2025-04-30 00:01:23 INFO | root | current dir: /Users/sallius/tsun/tsun-gen3-proxy
+2025-04-30 00:01:23 INFO | root | config_path: ./config/
+2025-04-30 00:01:23 INFO | root | json_config: None
+2025-04-30 00:01:23 INFO | root | toml_config: None
+2025-04-30 00:01:23 INFO | root | trans_path: ../translations/
+2025-04-30 00:01:23 INFO | root | rel_urls: False
+2025-04-30 00:01:23 INFO | root | log_path: ./log/
+2025-04-30 00:01:23 INFO | root | log_backups: unlimited
+2025-04-30 00:01:23 INFO | root | LOG_LVL : None
+2025-04-30 00:01:23 INFO | root | ******
+2025-04-30 00:01:23 INFO | root | Read from /Users/sallius/tsun/tsun-gen3-proxy/app/src/cnf/default_config.toml => ok
+2025-04-30 00:01:23 INFO | root | Read from environment => ok
+2025-04-30 00:01:23 INFO | root | Read from ./config/config.json => n/a
+2025-04-30 00:01:23 INFO | root | Read from ./config/config.toml => n/a
+2025-04-30 00:01:23 INFO | root | ******
+2025-04-30 00:01:23 INFO | root | listen on port: 5005 for inverters
+2025-04-30 00:01:23 INFO | root | listen on port: 10000 for inverters
+2025-04-30 00:01:23 INFO | root | Start Quart
\ No newline at end of file
diff --git a/app/tests/test_async_stream.py b/app/tests/test_async_stream.py
index 8d0fa7f..1823aca 100644
--- a/app/tests/test_async_stream.py
+++ b/app/tests/test_async_stream.py
@@ -8,6 +8,7 @@ from infos import Infos
from inverter_base import InverterBase
from async_stream import AsyncStreamServer, AsyncStreamClient, StreamPtr
from messages import Message
+from mock import patch, call
from test_modbus_tcp import FakeReader, FakeWriter
from test_inverter_base import config_conn, patch_open_connection
@@ -74,6 +75,13 @@ def test_health():
cnt += 1
assert cnt == 0
+
+@pytest.fixture
+def spy_inc_cnt():
+ with patch.object(Infos, 'inc_counter', wraps=Infos.inc_counter) as infos:
+ yield infos
+
+
@pytest.mark.asyncio
async def test_close_cb():
assert asyncio.get_running_loop()
@@ -529,9 +537,10 @@ async def test_forward_runtime_error2():
del ifc
@pytest.mark.asyncio
-async def test_forward_runtime_error3():
+async def test_forward_runtime_error3(spy_inc_cnt):
assert asyncio.get_running_loop()
remote = StreamPtr(None)
+ spy = spy_inc_cnt
cnt = 0
async def _create_remote():
@@ -543,13 +552,17 @@ async def test_forward_runtime_error3():
ifc = AsyncStreamServer(fake_reader_fwd(), FakeWriter(), None, _create_remote, remote)
ifc.fwd_add(b'test-forward_msg')
await ifc.server_loop()
+ spy.assert_has_calls([call('Inverter_Cnt'), call('ServerMode_Cnt')])
+ assert Infos.get_counter('Inverter_Cnt') == 0
+ assert Infos.get_counter('ServerMode_Cnt') == 0
assert cnt == 1
del ifc
@pytest.mark.asyncio
-async def test_forward_resp():
+async def test_forward_resp(spy_inc_cnt):
assert asyncio.get_running_loop()
remote = StreamPtr(None)
+ spy = spy_inc_cnt
cnt = 0
def _close_cb():
@@ -557,27 +570,35 @@ async def test_forward_resp():
cnt += 1
cnt = 0
- ifc = AsyncStreamClient(fake_reader_fwd(), FakeWriter(), remote, _close_cb)
+ ifc = AsyncStreamClient(fake_reader_fwd(), FakeWriter(), remote, _close_cb, use_emu = True)
create_remote(remote, TestType.FWD_NO_EXCPT)
ifc.fwd_add(b'test-forward_msg')
await ifc.client_loop('')
+ spy.assert_has_calls([call('Cloud_Conn_Cnt'), call('EmuMode_Cnt')])
+ assert Infos.get_counter('Cloud_Conn_Cnt') == 0
+ assert Infos.get_counter('EmuMode_Cnt') == 0
assert cnt == 1
del ifc
+
@pytest.mark.asyncio
-async def test_forward_resp2():
+async def test_forward_resp2(spy_inc_cnt):
assert asyncio.get_running_loop()
remote = StreamPtr(None)
+ spy = spy_inc_cnt
cnt = 0
def _close_cb():
nonlocal cnt
cnt += 1
cnt = 0
- ifc = AsyncStreamClient(fake_reader_fwd(), FakeWriter(), None, _close_cb)
+ ifc = AsyncStreamClient(fake_reader_fwd(), FakeWriter(), None, _close_cb, use_emu = False)
create_remote(remote, TestType.FWD_NO_EXCPT)
ifc.fwd_add(b'test-forward_msg')
await ifc.client_loop('')
+ spy.assert_has_calls([call('Cloud_Conn_Cnt'), call('ProxyMode_Cnt'), call('SW_Exception')])
+ assert Infos.get_counter('Cloud_Conn_Cnt') == 0
+ assert Infos.get_counter('ProxyMode_Cnt') == 0
assert cnt == 1
del ifc
\ No newline at end of file
diff --git a/app/tests/test_inverter_base.py b/app/tests/test_inverter_base.py
index 0de04db..ee95209 100644
--- a/app/tests/test_inverter_base.py
+++ b/app/tests/test_inverter_base.py
@@ -113,7 +113,9 @@ def patch_unhealthy_remote():
with patch.object(AsyncStreamClient, 'healthy', new_healthy) as conn:
yield conn
-def test_inverter_iter():
+@pytest.mark.asyncio
+async def test_inverter_iter(my_loop):
+ _ = my_loop
InverterBase._registry.clear()
cnt = 0
reader = FakeReader()
@@ -216,7 +218,8 @@ def test_unhealthy_remote(patch_unhealthy_remote):
assert cnt == 0
@pytest.mark.asyncio
-async def test_remote_conn(config_conn, patch_open_connection):
+async def test_remote_conn(my_loop, config_conn, patch_open_connection):
+ _ = my_loop
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -242,8 +245,9 @@ async def test_remote_conn(config_conn, patch_open_connection):
assert cnt == 0
@pytest.mark.asyncio
-async def test_remote_conn_to_private(config_conn, patch_open_connection):
+async def test_remote_conn_to_private(my_loop, config_conn, patch_open_connection):
'''check DNS resolving of the TSUN FQDN to a local address'''
+ _ = my_loop
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -280,8 +284,9 @@ async def test_remote_conn_to_private(config_conn, patch_open_connection):
@pytest.mark.asyncio
-async def test_remote_conn_to_loopback(config_conn, patch_open_connection):
+async def test_remote_conn_to_loopback(my_loop, config_conn, patch_open_connection):
'''check DNS resolving of the TSUN FQDN to the loopback address'''
+ _ = my_loop
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -317,8 +322,9 @@ async def test_remote_conn_to_loopback(config_conn, patch_open_connection):
assert cnt == 0
@pytest.mark.asyncio
-async def test_remote_conn_to_none(config_conn, patch_open_connection):
+async def test_remote_conn_to_none(my_loop, config_conn, patch_open_connection):
'''check if get_extra_info() return None in case of an error'''
+ _ = my_loop
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -354,7 +360,8 @@ async def test_remote_conn_to_none(config_conn, patch_open_connection):
assert cnt == 0
@pytest.mark.asyncio
-async def test_unhealthy_remote(config_conn, patch_open_connection, patch_unhealthy_remote):
+async def test_unhealthy_remote(my_loop, config_conn, patch_open_connection, patch_unhealthy_remote):
+ _ = my_loop
_ = config_conn
_ = patch_open_connection
_ = patch_unhealthy_remote
@@ -391,10 +398,10 @@ async def test_unhealthy_remote(config_conn, patch_open_connection, patch_unheal
assert cnt == 0
@pytest.mark.asyncio
-async def test_remote_disc(config_conn, patch_open_connection):
+async def test_remote_disc(my_loop, config_conn, patch_open_connection):
+ _ = my_loop
_ = config_conn
_ = patch_open_connection
- assert asyncio.get_running_loop()
reader = FakeReader()
writer = FakeWriter()
diff --git a/app/tests/test_inverter_g3.py b/app/tests/test_inverter_g3.py
index 626ba7d..0addd32 100644
--- a/app/tests/test_inverter_g3.py
+++ b/app/tests/test_inverter_g3.py
@@ -99,7 +99,8 @@ def patch_healthy():
with patch.object(AsyncStream, 'healthy') as conn:
yield conn
-def test_method_calls(patch_healthy):
+@pytest.mark.asyncio
+async def test_method_calls(my_loop, patch_healthy):
spy = patch_healthy
reader = FakeReader()
writer = FakeWriter()
@@ -119,7 +120,7 @@ def test_method_calls(patch_healthy):
assert cnt == 0
@pytest.mark.asyncio
-async def test_remote_conn(config_conn, patch_open_connection):
+async def test_remote_conn(my_loop, config_conn, patch_open_connection):
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -137,7 +138,7 @@ async def test_remote_conn(config_conn, patch_open_connection):
assert cnt == 0
@pytest.mark.asyncio
-async def test_remote_except(config_conn, patch_open_connection):
+async def test_remote_except(my_loop, config_conn, patch_open_connection):
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -164,7 +165,7 @@ async def test_remote_except(config_conn, patch_open_connection):
assert cnt == 0
@pytest.mark.asyncio
-async def test_mqtt_publish(config_conn, patch_open_connection):
+async def test_mqtt_publish(my_loop, config_conn, patch_open_connection):
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -191,7 +192,7 @@ async def test_mqtt_publish(config_conn, patch_open_connection):
assert Infos.new_stat_data['proxy'] == False
@pytest.mark.asyncio
-async def test_mqtt_err(config_conn, patch_open_connection, patch_mqtt_err):
+async def test_mqtt_err(my_loop, config_conn, patch_open_connection, patch_mqtt_err):
_ = config_conn
_ = patch_open_connection
_ = patch_mqtt_err
@@ -208,7 +209,7 @@ async def test_mqtt_err(config_conn, patch_open_connection, patch_mqtt_err):
assert stream.new_data['inverter'] == True
@pytest.mark.asyncio
-async def test_mqtt_except(config_conn, patch_open_connection, patch_mqtt_except):
+async def test_mqtt_except(my_loop, config_conn, patch_open_connection, patch_mqtt_except):
_ = config_conn
_ = patch_open_connection
_ = patch_mqtt_except
diff --git a/app/tests/test_inverter_g3p.py b/app/tests/test_inverter_g3p.py
index f16a2d8..b9b7078 100644
--- a/app/tests/test_inverter_g3p.py
+++ b/app/tests/test_inverter_g3p.py
@@ -37,6 +37,7 @@ def config_conn():
},
'solarman':{'enabled': True, 'host': 'test_cloud.local', 'port': 1234}, 'inverters':{'allow_all':True}
}
+ Config.log_path='app/tests/log/'
@pytest.fixture(scope="module", autouse=True)
def module_init():
@@ -93,7 +94,8 @@ def patch_open_connection():
with patch.object(asyncio, 'open_connection', new_open) as conn:
yield conn
-def test_method_calls(config_conn):
+@pytest.mark.asyncio
+async def test_method_calls(my_loop, config_conn):
_ = config_conn
reader = FakeReader()
writer = FakeWriter()
@@ -104,7 +106,7 @@ def test_method_calls(config_conn):
assert inverter.local.ifc
@pytest.mark.asyncio
-async def test_remote_conn(config_conn, patch_open_connection):
+async def test_remote_conn(my_loop, config_conn, patch_open_connection):
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -115,7 +117,7 @@ async def test_remote_conn(config_conn, patch_open_connection):
assert inverter.remote.stream
@pytest.mark.asyncio
-async def test_remote_except(config_conn, patch_open_connection):
+async def test_remote_except(my_loop, config_conn, patch_open_connection):
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -137,7 +139,7 @@ async def test_remote_except(config_conn, patch_open_connection):
@pytest.mark.asyncio
-async def test_mqtt_publish(config_conn, patch_open_connection):
+async def test_mqtt_publish(my_loop, config_conn, patch_open_connection):
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -164,7 +166,7 @@ async def test_mqtt_publish(config_conn, patch_open_connection):
assert Infos.new_stat_data['proxy'] == False
@pytest.mark.asyncio
-async def test_mqtt_err(config_conn, patch_open_connection, patch_mqtt_err):
+async def test_mqtt_err(my_loop, config_conn, patch_open_connection, patch_mqtt_err):
_ = config_conn
_ = patch_open_connection
_ = patch_mqtt_err
@@ -181,7 +183,7 @@ async def test_mqtt_err(config_conn, patch_open_connection, patch_mqtt_err):
assert stream.new_data['inverter'] == True
@pytest.mark.asyncio
-async def test_mqtt_except(config_conn, patch_open_connection, patch_mqtt_except):
+async def test_mqtt_except(my_loop, config_conn, patch_open_connection, patch_mqtt_except):
_ = config_conn
_ = patch_open_connection
_ = patch_mqtt_except
diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py
index b6914b0..4e0c716 100644
--- a/app/tests/test_modbus.py
+++ b/app/tests/test_modbus.py
@@ -19,7 +19,8 @@ class ModbusTestHelper(Modbus):
def resp_handler(self):
self.recv_responses += 1
-def test_modbus_crc():
+@pytest.mark.asyncio
+async def test_modbus_crc():
'''Check CRC-16 calculation'''
mb = Modbus(None)
assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04')
@@ -37,7 +38,8 @@ def test_modbus_crc():
msg += b'\x00\x00\x00\x00\x00\x00\x00\xe6\xef'
assert 0 == mb._Modbus__calc_crc(msg)
-def test_build_modbus_pdu():
+@pytest.mark.asyncio
+async def test_build_modbus_pdu():
'''Check building and sending a MODBUS RTU'''
mb = ModbusTestHelper()
mb.build_msg(1,6,0x2000,0x12)
@@ -49,7 +51,8 @@ def test_build_modbus_pdu():
assert mb.last_len == 18
assert mb.err == 0
-def test_recv_req():
+@pytest.mark.asyncio
+async def test_recv_req():
'''Receive a valid request, which must transmitted'''
mb = ModbusTestHelper()
assert mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07')
@@ -58,7 +61,8 @@ def test_recv_req():
assert mb.last_len == 0x12
assert mb.err == 0
-def test_recv_req_crc_err():
+@pytest.mark.asyncio
+async def test_recv_req_crc_err():
'''Receive a request with invalid CRC, which must be dropped'''
mb = ModbusTestHelper()
assert not mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08')
@@ -68,7 +72,8 @@ def test_recv_req_crc_err():
assert mb.last_len == 0
assert mb.err == 1
-def test_recv_resp_crc_err():
+@pytest.mark.asyncio
+async def test_recv_resp_crc_err():
'''Receive a response with invalid CRC, which must be dropped'''
mb = ModbusTestHelper()
# simulate a transmitted request
@@ -89,7 +94,8 @@ def test_recv_resp_crc_err():
mb._Modbus__stop_timer()
assert not mb.req_pend
-def test_recv_resp_invalid_addr():
+@pytest.mark.asyncio
+async def test_recv_resp_invalid_addr():
'''Receive a response with wrong server addr, which must be dropped'''
mb = ModbusTestHelper()
mb.req_pend = True
@@ -113,7 +119,8 @@ def test_recv_resp_invalid_addr():
mb._Modbus__stop_timer()
assert not mb.req_pend
-def test_recv_recv_fcode():
+@pytest.mark.asyncio
+async def test_recv_recv_fcode():
'''Receive a response with wrong function code, which must be dropped'''
mb = ModbusTestHelper()
mb.build_msg(1,4,0x300e,2)
@@ -135,7 +142,8 @@ def test_recv_recv_fcode():
mb._Modbus__stop_timer()
assert not mb.req_pend
-def test_recv_resp_len():
+@pytest.mark.asyncio
+async def test_recv_resp_len():
'''Receive a response with wrong data length, which must be dropped'''
mb = ModbusTestHelper()
mb.build_msg(1,3,0x300e,3)
@@ -158,7 +166,8 @@ def test_recv_resp_len():
mb._Modbus__stop_timer()
assert not mb.req_pend
-def test_recv_unexpect_resp():
+@pytest.mark.asyncio
+async def test_recv_unexpect_resp():
'''Receive a response when we havb't sent a request'''
mb = ModbusTestHelper()
assert not mb.req_pend
@@ -174,7 +183,8 @@ def test_recv_unexpect_resp():
assert mb.req_pend == False
assert mb.que.qsize() == 0
-def test_parse_resp():
+@pytest.mark.asyncio
+async def test_parse_resp():
'''Receive matching response and parse the values'''
mb = ModbusTestHelper()
mb.build_msg(1,3,0x3007,6)
@@ -200,7 +210,8 @@ def test_parse_resp():
assert mb.que.qsize() == 0
assert not mb.req_pend
-def test_queue():
+@pytest.mark.asyncio
+async def test_queue():
mb = ModbusTestHelper()
mb.build_msg(1,3,0x3022,4)
assert mb.que.qsize() == 0
@@ -218,7 +229,8 @@ def test_queue():
mb._Modbus__stop_timer()
assert not mb.req_pend
-def test_queue2():
+@pytest.mark.asyncio
+async def test_queue2():
'''Check queue handling for build_msg() calls'''
mb = ModbusTestHelper()
mb.build_msg(1,3,0x3007,6)
@@ -267,7 +279,8 @@ def test_queue2():
assert mb.que.qsize() == 0
assert not mb.req_pend
-def test_queue3():
+@pytest.mark.asyncio
+async def test_queue3():
'''Check queue handling for recv_req() calls'''
mb = ModbusTestHelper()
assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t', mb.resp_handler)
@@ -324,7 +337,7 @@ def test_queue3():
assert not mb.req_pend
@pytest.mark.asyncio
-async def test_timeout():
+async def test_timeout(my_loop):
'''Test MODBUS response timeout and RTU retransmitting'''
assert asyncio.get_running_loop()
mb = ModbusTestHelper()
@@ -371,7 +384,8 @@ async def test_timeout():
assert mb.retry_cnt == 0
assert mb.send_calls == 4
-def test_recv_unknown_data():
+@pytest.mark.asyncio
+async def test_recv_unknown_data():
'''Receive a response with an unknwon register'''
mb = ModbusTestHelper()
assert 0x9000 not in mb.mb_reg_mapping
@@ -390,7 +404,8 @@ def test_recv_unknown_data():
del mb.mb_reg_mapping[0x9000]
-def test_close():
+@pytest.mark.asyncio
+async def test_close():
'''Check queue handling for build_msg() calls'''
mb = ModbusTestHelper()
mb.build_msg(1,3,0x3007,6)
diff --git a/app/tests/test_mqtt.py b/app/tests/test_mqtt.py
index 421fadb..c6f7f49 100644
--- a/app/tests/test_mqtt.py
+++ b/app/tests/test_mqtt.py
@@ -140,6 +140,7 @@ async def test_ha_reconnect(config_mqtt_conn):
assert on_connect.is_set()
finally:
+ assert m.received == 2
await m.close()
@pytest.mark.asyncio
diff --git a/app/tests/test_server.py b/app/tests/test_server.py
index 020c8c9..e13ee5f 100644
--- a/app/tests/test_server.py
+++ b/app/tests/test_server.py
@@ -3,30 +3,216 @@ import pytest
import logging
import os
from mock import patch
-from server import get_log_level
+from server import app, Server, ProxyState, HypercornLogHndl
-def test_get_log_level():
+pytest_plugins = ('pytest_asyncio',)
- with patch.dict(os.environ, {}):
- log_lvl = get_log_level()
- assert log_lvl == None
- with patch.dict(os.environ, {'LOG_LVL': 'DEBUG'}):
- log_lvl = get_log_level()
- assert log_lvl == logging.DEBUG
+class TestServerClass:
+ class FakeServer(Server):
+ def __init__(self):
+ pass # don't call the suoer(.__init__ for unit tests
- with patch.dict(os.environ, {'LOG_LVL': 'INFO'}):
- log_lvl = get_log_level()
- assert log_lvl == logging.INFO
+ def test_get_log_level(self):
+ s = self.FakeServer()
- with patch.dict(os.environ, {'LOG_LVL': 'WARN'}):
- log_lvl = get_log_level()
- assert log_lvl == logging.WARNING
+ with patch.dict(os.environ, {}):
+ log_lvl = s.get_log_level()
+ assert log_lvl == None
- with patch.dict(os.environ, {'LOG_LVL': 'ERROR'}):
- log_lvl = get_log_level()
- assert log_lvl == logging.ERROR
+ with patch.dict(os.environ, {'LOG_LVL': 'DEBUG'}):
+ log_lvl = s.get_log_level()
+ assert log_lvl == logging.DEBUG
+
+ with patch.dict(os.environ, {'LOG_LVL': 'INFO'}):
+ log_lvl = s.get_log_level()
+ assert log_lvl == logging.INFO
+
+ with patch.dict(os.environ, {'LOG_LVL': 'WARN'}):
+ log_lvl = s.get_log_level()
+ assert log_lvl == logging.WARNING
+
+ with patch.dict(os.environ, {'LOG_LVL': 'ERROR'}):
+ log_lvl = s.get_log_level()
+ assert log_lvl == logging.ERROR
+
+ with patch.dict(os.environ, {'LOG_LVL': 'UNKNOWN'}):
+ log_lvl = s.get_log_level()
+ assert log_lvl == None
+
+ def test_default_args(self):
+ s = self.FakeServer()
+ assert s.config_path == './config/'
+ assert s.json_config == ''
+ assert s.toml_config == ''
+ assert s.trans_path == '../translations/'
+ assert s.rel_urls == False
+ assert s.log_path == './log/'
+ assert s.log_backups == 0
+
+ def test_parse_args_empty(self):
+ s = self.FakeServer()
+ s.parse_args([])
+ assert s.config_path == './config/'
+ assert s.json_config == None
+ assert s.toml_config == None
+ assert s.trans_path == '../translations/'
+ assert s.rel_urls == False
+ assert s.log_path == './log/'
+ assert s.log_backups == 0
+
+ def test_parse_args_short(self):
+ s = self.FakeServer()
+ s.parse_args(['-r', '-c', '/tmp/my-config', '-j', 'cnf.jsn', '-t', 'cnf.tml', '-tr', '/my/trans/', '-l', '/my_logs/', '-b', '3'])
+ assert s.config_path == '/tmp/my-config'
+ assert s.json_config == 'cnf.jsn'
+ assert s.toml_config == 'cnf.tml'
+ assert s.trans_path == '/my/trans/'
+ assert s.rel_urls == True
+ assert s.log_path == '/my_logs/'
+ assert s.log_backups == 3
+
+ def test_parse_args_long(self):
+ s = self.FakeServer()
+ s.parse_args(['--rel_urls', '--config_path', '/tmp/my-config', '--json_config', 'cnf.jsn',
+ '--toml_config', 'cnf.tml', '--trans_path', '/my/trans/', '--log_path', '/my_logs/',
+ '--log_backups', '3'])
+ assert s.config_path == '/tmp/my-config'
+ assert s.json_config == 'cnf.jsn'
+ assert s.toml_config == 'cnf.tml'
+ assert s.trans_path == '/my/trans/'
+ assert s.rel_urls == True
+ assert s.log_path == '/my_logs/'
+ assert s.log_backups == 3
+
+ def test_parse_args_invalid(self):
+ s = self.FakeServer()
+ with pytest.raises(SystemExit) as exc_info:
+ s.parse_args(['--inalid', '/tmp/my-config'])
+ assert exc_info.value.code == 2
+
+ def test_init_logging_system(self):
+ s = self.FakeServer()
+ s.src_dir = 'app/src/'
+ s.init_logging_system()
+ assert s.log_backups == 0
+ assert s.log_level == None
+ assert logging.handlers.log_path == './log/'
+ assert logging.handlers.log_backups == 0
+ assert logging.getLogger().level == logging.DEBUG
+ assert logging.getLogger('msg').level == logging.DEBUG
+ assert logging.getLogger('conn').level == logging.DEBUG
+ assert logging.getLogger('data').level == logging.DEBUG
+ assert logging.getLogger('tracer').level == logging.INFO
+ assert logging.getLogger('asyncio').level == logging.INFO
+ assert logging.getLogger('hypercorn.access').level == logging.INFO
+ assert logging.getLogger('hypercorn.error').level == logging.INFO
+
+ os.environ["LOG_LVL"] = "WARN"
+ s.parse_args(['--log_backups', '3'])
+ s.init_logging_system()
+ assert s.log_backups == 3
+ assert s.log_level == logging.WARNING
+ assert logging.handlers.log_backups == 3
+ assert logging.getLogger().level == s.log_level
+ assert logging.getLogger('msg').level == s.log_level
+ assert logging.getLogger('conn').level == s.log_level
+ assert logging.getLogger('data').level == s.log_level
+ assert logging.getLogger('tracer').level == s.log_level
+ assert logging.getLogger('asyncio').level == s.log_level
+ assert logging.getLogger('hypercorn.access').level == logging.INFO
+ assert logging.getLogger('hypercorn.error').level == logging.INFO
+
+ def test_build_config_error(self, caplog):
+ s = self.FakeServer()
+ s.src_dir = 'app/src/'
+ s.toml_config = 'app/tests/cnf/invalid_config.toml'
+
+ with caplog.at_level(logging.ERROR):
+ s.build_config()
+ assert "Can't read from app/tests/cnf/invalid_config.toml" in caplog.text
+ assert "Key 'port' error:" in caplog.text
+
+
+class TestHypercornLogHndl:
+ class FakeServer(Server):
+ def __init__(self):
+ pass # don't call the suoer(.__init__ for unit tests
+
+ def test_save_and_restore(self, capsys):
+ s = self.FakeServer()
+ s.src_dir = 'app/src/'
+ s.init_logging_system()
+
+ h = HypercornLogHndl()
+ assert h.must_fix == False
+ assert len(h.access_hndl) == 0
+ assert len(h.error_hndl) == 0
+
+ h.save()
+ assert h.must_fix == True
+ assert len(h.access_hndl) == 1
+ assert len(h.error_hndl) == 2
+ assert h.access_hndl == logging.getLogger('hypercorn.access').handlers
+ assert h.error_hndl == logging.getLogger('hypercorn.error').handlers
+
+ logging.getLogger('hypercorn.access').handlers = []
+ logging.getLogger('hypercorn.error').handlers = []
+
+ h.restore()
+ assert h.must_fix == False
+ assert h.access_hndl == logging.getLogger('hypercorn.access').handlers
+ assert h.error_hndl == logging.getLogger('hypercorn.error').handlers
+ output = capsys.readouterr().out.rstrip()
+ assert "* Fix hypercorn.access setting" in output
+ assert "* Fix hypercorn.error setting" in output
+
+ h.restore() # second restore do nothing
+ assert h.must_fix == False
+ output = capsys.readouterr().out.rstrip()
+ assert output == ''
+
+ h.save() # save the same values second time
+ assert h.must_fix == True
+
+ h.restore() # restore without changing the handlers
+ assert h.must_fix == False
+ output = capsys.readouterr().out.rstrip()
+ assert output == ''
+
+
+class TestApp:
+ @pytest.mark.asyncio
+ async def test_ready(self):
+ """Test the ready route."""
+
+ ProxyState.set_up(False)
+ client = app.test_client()
+ response = await client.get('/-/ready')
+ assert response.status_code == 503
+ result = await response.get_data()
+ assert result == b"Not ready"
+
+ ProxyState.set_up(True)
+ response = await client.get('/-/ready')
+ assert response.status_code == 200
+ result = await response.get_data()
+ assert result == b"Is ready"
+
+ @pytest.mark.asyncio
+ async def test_healthy(self):
+ """Test the healthy route."""
+
+ ProxyState.set_up(False)
+ client = app.test_client()
+ response = await client.get('/-/healthy')
+ assert response.status_code == 200
+ result = await response.get_data()
+ assert result == b"I'm fine"
+
+ ProxyState.set_up(True)
+ response = await client.get('/-/healthy')
+ assert response.status_code == 200
+ result = await response.get_data()
+ assert result == b"I'm fine"
- with patch.dict(os.environ, {'LOG_LVL': 'UNKNOWN'}):
- log_lvl = get_log_level()
- assert log_lvl == None
diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py
index 0c3c86c..58da012 100644
--- a/app/tests/test_solarman.py
+++ b/app/tests/test_solarman.py
@@ -79,6 +79,7 @@ class MemoryStream(SolarmanV5):
self.key = ''
self.data = ''
self.msg_recvd = []
+
def write_cb(self):
if self.test_exception_async_write:
@@ -855,7 +856,8 @@ def config_tsun_scan_dcu():
def config_tsun_dcu1():
Config.act_config = {'solarman':{'enabled': True},'batteries':{'4100000000000001':{'monitor_sn': 2070233888, 'node_id':'inv1/', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}}
-def test_read_message(device_ind_msg):
+@pytest.mark.asyncio
+async def test_read_message(device_ind_msg):
Config.act_config = {'solarman':{'enabled': True}}
m = MemoryStream(device_ind_msg, (0,))
m.read() # read complete msg, and dispatch msg
@@ -873,10 +875,12 @@ def test_read_message(device_ind_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_invalid_start_byte(invalid_start_byte, device_ind_msg):
+@pytest.mark.asyncio
+async def test_invalid_start_byte(invalid_start_byte, device_ind_msg):
# received a message with wrong start byte plus an valid message
# the complete receive buffer must be cleared to
# find the next valid message
+ Config.act_config = {'solarman':{'enabled': True}}
m = MemoryStream(invalid_start_byte, (0,))
m.append_msg(device_ind_msg)
m.read() # read complete msg, and dispatch msg
@@ -894,10 +898,12 @@ def test_invalid_start_byte(invalid_start_byte, device_ind_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
m.close()
-def test_invalid_stop_byte(invalid_stop_byte):
+@pytest.mark.asyncio
+async def test_invalid_stop_byte(invalid_stop_byte):
# received a message with wrong stop byte
# the complete receive buffer must be cleared to
# find the next valid message
+ Config.act_config = {'solarman':{'enabled': True}}
m = MemoryStream(invalid_stop_byte, (0,))
m.read() # read complete msg, and dispatch msg
assert not m.header_valid # must be invalid, since start byte is wrong
@@ -914,9 +920,11 @@ def test_invalid_stop_byte(invalid_stop_byte):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
m.close()
-def test_invalid_stop_byte2(invalid_stop_byte, device_ind_msg):
+@pytest.mark.asyncio
+async def test_invalid_stop_byte2(invalid_stop_byte, device_ind_msg):
# received a message with wrong stop byte plus an valid message
# only the first message must be discarded
+ Config.act_config = {'solarman':{'enabled': True}}
m = MemoryStream(invalid_stop_byte, (0,))
m.append_msg(device_ind_msg)
@@ -939,11 +947,13 @@ def test_invalid_stop_byte2(invalid_stop_byte, device_ind_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
m.close()
-def test_invalid_stop_start_byte(invalid_stop_byte, invalid_start_byte):
+@pytest.mark.asyncio
+async def test_invalid_stop_start_byte(invalid_stop_byte, invalid_start_byte):
# received a message with wrong stop byte plus an invalid message
# with fron start byte
# the complete receive buffer must be cleared to
# find the next valid message
+ Config.act_config = {'solarman':{'enabled': True}}
m = MemoryStream(invalid_stop_byte, (0,))
m.append_msg(invalid_start_byte)
m.read() # read complete msg, and dispatch msg
@@ -961,9 +971,11 @@ def test_invalid_stop_start_byte(invalid_stop_byte, invalid_start_byte):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
m.close()
-def test_invalid_checksum(invalid_checksum, device_ind_msg):
+@pytest.mark.asyncio
+async def test_invalid_checksum(invalid_checksum, device_ind_msg):
# received a message with wrong checksum plus an valid message
# only the first message must be discarded
+ Config.act_config = {'solarman':{'enabled': True}}
m = MemoryStream(invalid_checksum, (0,))
m.append_msg(device_ind_msg)
@@ -985,7 +997,8 @@ def test_invalid_checksum(invalid_checksum, device_ind_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
m.close()
-def test_read_message_twice(config_no_tsun_inv1, device_ind_msg, device_rsp_msg):
+@pytest.mark.asyncio
+async def test_read_message_twice(config_no_tsun_inv1, device_ind_msg, device_rsp_msg):
_ = config_no_tsun_inv1
m = MemoryStream(device_ind_msg, (0,))
m.append_msg(device_ind_msg)
@@ -1006,7 +1019,9 @@ def test_read_message_twice(config_no_tsun_inv1, device_ind_msg, device_rsp_msg)
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_read_message_in_chunks(device_ind_msg):
+@pytest.mark.asyncio
+async def test_read_message_in_chunks(device_ind_msg):
+ Config.act_config = {'solarman':{'enabled': True}}
m = MemoryStream(device_ind_msg, (4,11,0))
m.read() # read 4 bytes, header incomplere
assert not m.header_valid # must be invalid, since header not complete
@@ -1027,7 +1042,8 @@ def test_read_message_in_chunks(device_ind_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_read_message_in_chunks2(config_tsun_inv1, device_ind_msg):
+@pytest.mark.asyncio
+async def test_read_message_in_chunks2(my_loop, config_tsun_inv1, device_ind_msg):
_ = config_tsun_inv1
m = MemoryStream(device_ind_msg, (4,10,0))
m.read() # read 4 bytes, header incomplere
@@ -1052,7 +1068,8 @@ def test_read_message_in_chunks2(config_tsun_inv1, device_ind_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_read_two_messages(config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg):
+@pytest.mark.asyncio
+async def test_read_two_messages(my_loop, config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg):
_ = config_tsun_allow_all
m = MemoryStream(device_ind_msg, (0,))
m.append_msg(inverter_ind_msg)
@@ -1080,7 +1097,8 @@ def test_read_two_messages(config_tsun_allow_all, device_ind_msg, device_rsp_msg
assert m.ifc.tx_fifo.get()==b''
m.close()
-def test_read_two_messages2(config_tsun_allow_all, inverter_ind_msg, inverter_ind_msg_81, inverter_rsp_msg, inverter_rsp_msg_81):
+@pytest.mark.asyncio
+async def test_read_two_messages2(my_loop, config_tsun_allow_all, inverter_ind_msg, inverter_ind_msg_81, inverter_rsp_msg, inverter_rsp_msg_81):
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg, (0,))
m.append_msg(inverter_ind_msg_81)
@@ -1105,7 +1123,8 @@ def test_read_two_messages2(config_tsun_allow_all, inverter_ind_msg, inverter_in
assert m.ifc.tx_fifo.get()==b''
m.close()
-def test_read_two_messages3(config_tsun_allow_all, device_ind_msg2, device_rsp_msg2, inverter_ind_msg, inverter_rsp_msg):
+@pytest.mark.asyncio
+async def test_read_two_messages3(my_loop, config_tsun_allow_all, device_ind_msg2, device_rsp_msg2, inverter_ind_msg, inverter_rsp_msg):
# test device message received after the inverter masg
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg, (0,))
@@ -1134,7 +1153,8 @@ def test_read_two_messages3(config_tsun_allow_all, device_ind_msg2, device_rsp_m
assert m.ifc.tx_fifo.get()==b''
m.close()
-def test_read_two_messages4(config_tsun_dcu1, dcu_dev_ind_msg, dcu_dev_rsp_msg, dcu_data_ind_msg, dcu_data_rsp_msg):
+@pytest.mark.asyncio
+async def test_read_two_messages4(my_loop, config_tsun_dcu1, dcu_dev_ind_msg, dcu_dev_rsp_msg, dcu_data_ind_msg, dcu_data_rsp_msg):
_ = config_tsun_dcu1
m = MemoryStream(dcu_dev_ind_msg, (0,))
m.append_msg(dcu_data_ind_msg)
@@ -1162,7 +1182,8 @@ def test_read_two_messages4(config_tsun_dcu1, dcu_dev_ind_msg, dcu_dev_rsp_msg,
assert m.ifc.tx_fifo.get()==b''
m.close()
-def test_unkown_frame_code(config_tsun_inv1, inverter_ind_msg_81, inverter_rsp_msg_81):
+@pytest.mark.asyncio
+async def test_unkown_frame_code(my_loop, config_tsun_inv1, inverter_ind_msg_81, inverter_rsp_msg_81):
_ = config_tsun_inv1
m = MemoryStream(inverter_ind_msg_81, (0,))
m.read() # read complete msg, and dispatch msg
@@ -1180,7 +1201,8 @@ def test_unkown_frame_code(config_tsun_inv1, inverter_ind_msg_81, inverter_rsp_m
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_unkown_message(config_tsun_inv1, unknown_msg):
+@pytest.mark.asyncio
+async def test_unkown_message(my_loop, config_tsun_inv1, unknown_msg):
_ = config_tsun_inv1
m = MemoryStream(unknown_msg, (0,))
m.read() # read complete msg, and dispatch msg
@@ -1198,7 +1220,8 @@ def test_unkown_message(config_tsun_inv1, unknown_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_device_rsp(config_tsun_inv1, device_rsp_msg):
+@pytest.mark.asyncio
+async def test_device_rsp(my_loop, config_tsun_inv1, device_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(device_rsp_msg, (0,), False)
m.read() # read complete msg, and dispatch msg
@@ -1216,7 +1239,8 @@ def test_device_rsp(config_tsun_inv1, device_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_inverter_rsp(config_tsun_inv1, inverter_rsp_msg):
+@pytest.mark.asyncio
+async def test_inverter_rsp(my_loop, config_tsun_inv1, inverter_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(inverter_rsp_msg, (0,), False)
m.read() # read complete msg, and dispatch msg
@@ -1234,7 +1258,8 @@ def test_inverter_rsp(config_tsun_inv1, inverter_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_heartbeat_ind(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
+@pytest.mark.asyncio
+async def test_heartbeat_ind(my_loop, config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(heartbeat_ind_msg, (0,))
m.read() # read complete msg, and dispatch msg
@@ -1251,7 +1276,8 @@ def test_heartbeat_ind(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_heartbeat_ind2(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
+@pytest.mark.asyncio
+async def test_heartbeat_ind2(my_loop, config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(heartbeat_ind_msg, (0,))
m.no_forwarding = True
@@ -1269,7 +1295,8 @@ def test_heartbeat_ind2(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_heartbeat_rsp(config_tsun_inv1, heartbeat_rsp_msg):
+@pytest.mark.asyncio
+async def test_heartbeat_rsp(my_loop, config_tsun_inv1, heartbeat_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(heartbeat_rsp_msg, (0,), False)
m.read() # read complete msg, and dispatch msg
@@ -1287,7 +1314,8 @@ def test_heartbeat_rsp(config_tsun_inv1, heartbeat_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_sync_start_ind(config_tsun_inv1, sync_start_ind_msg, sync_start_rsp_msg, sync_start_fwd_msg):
+@pytest.mark.asyncio
+async def test_sync_start_ind(my_loop, config_tsun_inv1, sync_start_ind_msg, sync_start_rsp_msg, sync_start_fwd_msg):
_ = config_tsun_inv1
m = MemoryStream(sync_start_ind_msg, (0,))
m.read() # read complete msg, and dispatch msg
@@ -1310,7 +1338,8 @@ def test_sync_start_ind(config_tsun_inv1, sync_start_ind_msg, sync_start_rsp_msg
m.close()
-def test_sync_start_rsp(config_tsun_inv1, sync_start_rsp_msg):
+@pytest.mark.asyncio
+async def test_sync_start_rsp(my_loop, config_tsun_inv1, sync_start_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(sync_start_rsp_msg, (0,), False)
m.read() # read complete msg, and dispatch msg
@@ -1328,7 +1357,8 @@ def test_sync_start_rsp(config_tsun_inv1, sync_start_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_sync_end_ind(config_tsun_inv1, sync_end_ind_msg, sync_end_rsp_msg):
+@pytest.mark.asyncio
+async def test_sync_end_ind(my_loop, config_tsun_inv1, sync_end_ind_msg, sync_end_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(sync_end_ind_msg, (0,))
m.read() # read complete msg, and dispatch msg
@@ -1345,7 +1375,8 @@ def test_sync_end_ind(config_tsun_inv1, sync_end_ind_msg, sync_end_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_sync_end_rsp(config_tsun_inv1, sync_end_rsp_msg):
+@pytest.mark.asyncio
+async def test_sync_end_rsp(my_loop, config_tsun_inv1, sync_end_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(sync_end_rsp_msg, (0,), False)
m.read() # read complete msg, and dispatch msg
@@ -1363,7 +1394,8 @@ def test_sync_end_rsp(config_tsun_inv1, sync_end_rsp_msg):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_build_modell_600(config_tsun_allow_all, inverter_ind_msg):
+@pytest.mark.asyncio
+async def test_build_modell_600(my_loop, config_tsun_allow_all, inverter_ind_msg):
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg, (0,))
assert 0 == m.sensor_list
@@ -1382,7 +1414,8 @@ def test_build_modell_600(config_tsun_allow_all, inverter_ind_msg):
assert m.ifc.tx_fifo.get()==b''
m.close()
-def test_build_modell_1600(config_tsun_allow_all, inverter_ind_msg1600):
+@pytest.mark.asyncio
+async def test_build_modell_1600(my_loop, config_tsun_allow_all, inverter_ind_msg1600):
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg1600, (0,))
assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
@@ -1394,7 +1427,8 @@ def test_build_modell_1600(config_tsun_allow_all, inverter_ind_msg1600):
assert 'TSOL-MS1600' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close()
-def test_build_modell_1800(config_tsun_allow_all, inverter_ind_msg1800):
+@pytest.mark.asyncio
+async def test_build_modell_1800(my_loop, config_tsun_allow_all, inverter_ind_msg1800):
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg1800, (0,))
assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
@@ -1406,7 +1440,8 @@ def test_build_modell_1800(config_tsun_allow_all, inverter_ind_msg1800):
assert 'TSOL-MS1800' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close()
-def test_build_modell_2000(config_tsun_allow_all, inverter_ind_msg2000):
+@pytest.mark.asyncio
+async def test_build_modell_2000(my_loop, config_tsun_allow_all, inverter_ind_msg2000):
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg2000, (0,))
assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
@@ -1418,7 +1453,8 @@ def test_build_modell_2000(config_tsun_allow_all, inverter_ind_msg2000):
assert 'TSOL-MS2000' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close()
-def test_build_modell_800(config_tsun_allow_all, inverter_ind_msg800):
+@pytest.mark.asyncio
+async def test_build_modell_800(my_loop, config_tsun_allow_all, inverter_ind_msg800):
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg800, (0,))
assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
@@ -1430,7 +1466,8 @@ def test_build_modell_800(config_tsun_allow_all, inverter_ind_msg800):
assert 'TSOL-MSxx00' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close()
-def test_build_logger_modell(config_tsun_allow_all, device_ind_msg):
+@pytest.mark.asyncio
+async def test_build_logger_modell(my_loop, config_tsun_allow_all, device_ind_msg):
_ = config_tsun_allow_all
m = MemoryStream(device_ind_msg, (0,))
assert 0 == m.db.get_db_value(Register.COLLECTOR_FW_VERSION, 0)
@@ -1441,7 +1478,8 @@ def test_build_logger_modell(config_tsun_allow_all, device_ind_msg):
assert 'V1.1.00.0B' == m.db.get_db_value(Register.COLLECTOR_FW_VERSION, 0).rstrip('\00')
m.close()
-def test_msg_iterator():
+@pytest.mark.asyncio
+async def test_msg_iterator(my_loop, config_tsun_inv1):
Message._registry.clear()
m1 = SolarmanV5(None, ('test1.local', 1234), ifc=AsyncIfcImpl(), server_side=True, client_mode=False)
m2 = SolarmanV5(None, ('test2.local', 1234), ifc=AsyncIfcImpl(), server_side=True, client_mode=False)
@@ -1462,7 +1500,8 @@ def test_msg_iterator():
assert test1 == 1
assert test2 == 1
-def test_proxy_counter():
+@pytest.mark.asyncio
+async def test_proxy_counter(my_loop, config_tsun_inv1):
m = SolarmanV5(None, ('test.local', 1234), ifc=AsyncIfcImpl(), server_side=True, client_mode=False)
assert m.new_data == {}
m.db.stat['proxy']['Unknown_Msg'] = 0
@@ -1481,7 +1520,7 @@ def test_proxy_counter():
m.close()
@pytest.mark.asyncio
-async def test_msg_build_modbus_req(config_tsun_inv1, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg, msg_modbus_cmd):
+async def test_msg_build_modbus_req(my_loop, config_tsun_inv1, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg, msg_modbus_cmd):
_ = config_tsun_inv1
m = MemoryStream(device_ind_msg, (0,), True)
m.read()
@@ -1516,7 +1555,7 @@ async def test_msg_build_modbus_req(config_tsun_inv1, device_ind_msg, device_rsp
m.close()
@pytest.mark.asyncio
-async def test_at_cmd(config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg, at_command_ind_msg, at_command_rsp_msg):
+async def test_at_cmd(my_loop, config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg, at_command_ind_msg, at_command_rsp_msg):
_ = config_tsun_allow_all
m = MemoryStream(device_ind_msg, (0,), True)
m.read() # read device ind
@@ -1576,7 +1615,7 @@ async def test_at_cmd(config_tsun_allow_all, device_ind_msg, device_rsp_msg, inv
m.close()
@pytest.mark.asyncio
-async def test_at_cmd_blocked(config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg, at_command_ind_msg):
+async def test_at_cmd_blocked(my_loop, config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg, at_command_ind_msg):
_ = config_tsun_allow_all
m = MemoryStream(device_ind_msg, (0,), True)
m.read()
@@ -1610,7 +1649,8 @@ async def test_at_cmd_blocked(config_tsun_allow_all, device_ind_msg, device_rsp_
assert Proxy.mqtt.data == "'AT+WEBU' is forbidden"
m.close()
-def test_at_cmd_ind(config_tsun_inv1, at_command_ind_msg, at_command_rsp_msg):
+@pytest.mark.asyncio
+async def test_at_cmd_ind(my_loop, config_tsun_inv1, at_command_ind_msg, at_command_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(at_command_ind_msg, (0,), False)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1645,7 +1685,8 @@ def test_at_cmd_ind(config_tsun_inv1, at_command_ind_msg, at_command_rsp_msg):
m.close()
-def test_at_cmd_ind_block(config_tsun_inv1, at_command_ind_msg_block):
+@pytest.mark.asyncio
+async def test_at_cmd_ind_block(my_loop, config_tsun_inv1, at_command_ind_msg_block):
_ = config_tsun_inv1
m = MemoryStream(at_command_ind_msg_block, (0,), False)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1673,7 +1714,8 @@ def test_at_cmd_ind_block(config_tsun_inv1, at_command_ind_msg_block):
assert Proxy.mqtt.data == ""
m.close()
-def test_msg_at_command_rsp1(config_tsun_inv1, at_command_rsp_msg):
+@pytest.mark.asyncio
+async def test_msg_at_command_rsp1(my_loop, config_tsun_inv1, at_command_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(at_command_rsp_msg)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1692,7 +1734,8 @@ def test_msg_at_command_rsp1(config_tsun_inv1, at_command_rsp_msg):
assert m.db.stat['proxy']['Modbus_Command'] == 0
m.close()
-def test_msg_at_command_rsp2(config_tsun_inv1, at_command_rsp_msg):
+@pytest.mark.asyncio
+async def test_msg_at_command_rsp2(my_loop, config_tsun_inv1, at_command_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(at_command_rsp_msg)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1713,7 +1756,8 @@ def test_msg_at_command_rsp2(config_tsun_inv1, at_command_rsp_msg):
assert Proxy.mqtt.data == "+ok"
m.close()
-def test_msg_at_command_rsp3(config_tsun_inv1, at_command_interim_rsp_msg):
+@pytest.mark.asyncio
+async def test_msg_at_command_rsp3(my_loop, config_tsun_inv1, at_command_interim_rsp_msg):
_ = config_tsun_inv1
m = MemoryStream(at_command_interim_rsp_msg)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1738,7 +1782,8 @@ def test_msg_at_command_rsp3(config_tsun_inv1, at_command_interim_rsp_msg):
assert Proxy.mqtt.data == ""
m.close()
-def test_msg_modbus_req(config_tsun_inv1, msg_modbus_cmd, msg_modbus_cmd_fwd):
+@pytest.mark.asyncio
+async def test_msg_modbus_req(my_loop, config_tsun_inv1, msg_modbus_cmd, msg_modbus_cmd_fwd):
_ = config_tsun_inv1
m = MemoryStream(b'')
m.snr = get_sn_int()
@@ -1766,7 +1811,8 @@ def test_msg_modbus_req(config_tsun_inv1, msg_modbus_cmd, msg_modbus_cmd_fwd):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_msg_modbus_req_seq(config_tsun_inv1, msg_modbus_cmd_seq):
+@pytest.mark.asyncio
+async def test_msg_modbus_req_seq(my_loop, config_tsun_inv1, msg_modbus_cmd_seq):
_ = config_tsun_inv1
m = MemoryStream(b'')
m.snr = get_sn_int()
@@ -1794,7 +1840,8 @@ def test_msg_modbus_req_seq(config_tsun_inv1, msg_modbus_cmd_seq):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_msg_modbus_req2(config_tsun_inv1, msg_modbus_cmd_crc_err):
+@pytest.mark.asyncio
+async def test_msg_modbus_req2(my_loop, config_tsun_inv1, msg_modbus_cmd_crc_err):
_ = config_tsun_inv1
m = MemoryStream(b'')
m.snr = get_sn_int()
@@ -1821,7 +1868,8 @@ def test_msg_modbus_req2(config_tsun_inv1, msg_modbus_cmd_crc_err):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
m.close()
-def test_msg_unknown_cmd_req(config_tsun_inv1, msg_unknown_cmd):
+@pytest.mark.asyncio
+async def test_msg_unknown_cmd_req(my_loop, config_tsun_inv1, msg_unknown_cmd):
_ = config_tsun_inv1
m = MemoryStream(msg_unknown_cmd, (0,), False)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1843,7 +1891,8 @@ def test_msg_unknown_cmd_req(config_tsun_inv1, msg_unknown_cmd):
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_msg_modbus_rsp1(config_tsun_inv1, msg_modbus_rsp):
+@pytest.mark.asyncio
+async def test_msg_modbus_rsp1(my_loop, config_tsun_inv1, msg_modbus_rsp):
'''Modbus response without a valid Modbus request must be dropped'''
_ = config_tsun_inv1
m = MemoryStream(msg_modbus_rsp)
@@ -1862,7 +1911,8 @@ def test_msg_modbus_rsp1(config_tsun_inv1, msg_modbus_rsp):
assert m.db.stat['proxy']['Modbus_Command'] == 0
m.close()
-def test_msg_modbus_rsp2(config_tsun_inv1, msg_modbus_rsp):
+@pytest.mark.asyncio
+async def test_msg_modbus_rsp2(my_loop, config_tsun_inv1, msg_modbus_rsp):
'''Modbus response with a valid Modbus request must be forwarded'''
_ = config_tsun_inv1 # setup config structure
m = MemoryStream(msg_modbus_rsp)
@@ -1899,7 +1949,8 @@ def test_msg_modbus_rsp2(config_tsun_inv1, msg_modbus_rsp):
m.close()
-def test_msg_modbus_rsp3(config_tsun_inv1, msg_modbus_rsp):
+@pytest.mark.asyncio
+async def test_msg_modbus_rsp3(my_loop, config_tsun_inv1, msg_modbus_rsp):
'''Modbus response with a valid Modbus request must be forwarded'''
_ = config_tsun_inv1
m = MemoryStream(msg_modbus_rsp)
@@ -1935,7 +1986,8 @@ def test_msg_modbus_rsp3(config_tsun_inv1, msg_modbus_rsp):
m.close()
-def test_msg_unknown_rsp(config_tsun_inv1, msg_unknown_cmd_rsp):
+@pytest.mark.asyncio
+async def test_msg_unknown_rsp(my_loop, config_tsun_inv1, msg_unknown_cmd_rsp):
_ = config_tsun_inv1
m = MemoryStream(msg_unknown_cmd_rsp)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1953,7 +2005,8 @@ def test_msg_unknown_rsp(config_tsun_inv1, msg_unknown_cmd_rsp):
assert m.db.stat['proxy']['Modbus_Command'] == 0
m.close()
-def test_msg_modbus_invalid(config_tsun_inv1, msg_modbus_invalid):
+@pytest.mark.asyncio
+async def test_msg_modbus_invalid(my_loop, config_tsun_inv1, msg_modbus_invalid):
_ = config_tsun_inv1
m = MemoryStream(msg_modbus_invalid, (0,), False)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1967,7 +2020,8 @@ def test_msg_modbus_invalid(config_tsun_inv1, msg_modbus_invalid):
assert m.db.stat['proxy']['Modbus_Command'] == 0
m.close()
-def test_msg_modbus_fragment(config_tsun_inv1, msg_modbus_rsp):
+@pytest.mark.asyncio
+async def test_msg_modbus_fragment(my_loop, config_tsun_inv1, msg_modbus_rsp):
_ = config_tsun_inv1
# receive more bytes than expected (7 bytes from the next msg)
m = MemoryStream(msg_modbus_rsp+b'\x00\x00\x00\x45\x10\x52\x31', (0,))
@@ -1993,7 +2047,7 @@ def test_msg_modbus_fragment(config_tsun_inv1, msg_modbus_rsp):
m.close()
@pytest.mark.asyncio
-async def test_modbus_polling(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
+async def test_modbus_polling(my_loop, config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp_msg):
_ = config_tsun_inv1
assert asyncio.get_running_loop()
m = MemoryStream(heartbeat_ind_msg, (0,))
@@ -2106,7 +2160,7 @@ async def test_modbus_scaning(config_tsun_scan, heartbeat_ind_msg, heartbeat_rsp
m.close()
@pytest.mark.asyncio
-async def test_start_client_mode(config_tsun_inv1, str_test_ip):
+async def test_start_client_mode(my_loop, config_tsun_inv1, str_test_ip):
_ = config_tsun_inv1
assert asyncio.get_running_loop()
m = MemoryStream(b'')
@@ -2210,7 +2264,8 @@ async def test_start_client_mode_scan(config_tsun_scan_dcu, str_test_ip, dcu_mod
m.close()
-def test_timeout(config_tsun_inv1):
+@pytest.mark.asyncio
+async def test_timeout(my_loop, config_tsun_inv1):
_ = config_tsun_inv1
m = MemoryStream(b'')
assert m.state == State.init
@@ -2223,7 +2278,8 @@ def test_timeout(config_tsun_inv1):
m.state = State.closed
m.close()
-def test_fnc_dispatch():
+@pytest.mark.asyncio
+async def test_fnc_dispatch(my_loop, config_tsun_inv1):
def msg():
return
@@ -2244,7 +2300,8 @@ def test_fnc_dispatch():
assert _obj == m.msg_unknown
assert _str == "'msg_unknown'"
-def test_timestamp():
+@pytest.mark.asyncio
+async def test_timestamp(my_loop, config_tsun_inv1):
m = MemoryStream(b'')
ts = m._timestamp()
ts_emu = m._emu_timestamp()
@@ -2271,7 +2328,7 @@ class InverterTest(InverterBase):
@pytest.mark.asyncio
-async def test_proxy_at_cmd(config_tsun_inv1, patch_open_connection, at_command_ind_msg, at_command_rsp_msg):
+async def test_proxy_at_cmd(my_loop, config_tsun_inv1, patch_open_connection, at_command_ind_msg, at_command_rsp_msg):
_ = config_tsun_inv1
_ = patch_open_connection
assert asyncio.get_running_loop()
@@ -2309,7 +2366,7 @@ async def test_proxy_at_cmd(config_tsun_inv1, patch_open_connection, at_command_
assert Proxy.mqtt.data == ""
@pytest.mark.asyncio
-async def test_proxy_at_blocked(config_tsun_inv1, patch_open_connection, at_command_ind_msg_block, at_command_rsp_msg):
+async def test_proxy_at_blocked(my_loop, config_tsun_inv1, patch_open_connection, at_command_ind_msg_block, at_command_rsp_msg):
_ = config_tsun_inv1
_ = patch_open_connection
assert asyncio.get_running_loop()
diff --git a/app/tests/test_solarman_emu.py b/app/tests/test_solarman_emu.py
index a62fbdc..a3d517c 100644
--- a/app/tests/test_solarman_emu.py
+++ b/app/tests/test_solarman_emu.py
@@ -9,6 +9,9 @@ from infos import Infos, Register
from test_solarman import FakeIfc, FakeInverter, MemoryStream, get_sn_int, get_sn, correct_checksum, config_tsun_inv1, msg_modbus_rsp
from test_infos_g3p import str_test_ip, bytes_test_ip
+
+pytest_plugins = ('pytest_asyncio',)
+
timestamp = 0x3224c8bc
class InvStream(MemoryStream):
@@ -125,17 +128,17 @@ def heartbeat_ind():
msg = b'\xa5\x01\x00\x10G\x00\x01\x00\x00\x00\x00\x00Y\x15'
return msg
-def test_emu_init_close():
- # received a message with wrong start byte plus an valid message
- # the complete receive buffer must be cleared to
- # find the next valid message
+@pytest.mark.asyncio
+async def test_emu_init_close(my_loop, config_tsun_inv1):
+ _ = config_tsun_inv1
+ assert asyncio.get_running_loop()
inv = InvStream()
cld = CldStream(inv)
cld.close()
@pytest.mark.asyncio
-async def test_emu_start(config_tsun_inv1, msg_modbus_rsp, str_test_ip, device_ind_msg):
+async def test_emu_start(my_loop, config_tsun_inv1, msg_modbus_rsp, str_test_ip, device_ind_msg):
_ = config_tsun_inv1
assert asyncio.get_running_loop()
inv = InvStream(msg_modbus_rsp)
@@ -152,7 +155,8 @@ async def test_emu_start(config_tsun_inv1, msg_modbus_rsp, str_test_ip, device_i
assert inv.ifc.fwd_fifo.peek() == device_ind_msg
cld.close()
-def test_snd_hb(config_tsun_inv1, heartbeat_ind):
+@pytest.mark.asyncio
+async def test_snd_hb(my_loop, config_tsun_inv1, heartbeat_ind):
_ = config_tsun_inv1
inv = InvStream()
cld = CldStream(inv)
@@ -163,7 +167,7 @@ def test_snd_hb(config_tsun_inv1, heartbeat_ind):
cld.close()
@pytest.mark.asyncio
-async def test_snd_inv_data(config_tsun_inv1, inverter_ind_msg, inverter_rsp_msg):
+async def test_snd_inv_data(my_loop, config_tsun_inv1, inverter_ind_msg, inverter_rsp_msg):
_ = config_tsun_inv1
inv = InvStream()
inv.db.set_db_def_value(Register.INVERTER_STATUS, 1)
@@ -205,7 +209,7 @@ async def test_snd_inv_data(config_tsun_inv1, inverter_ind_msg, inverter_rsp_msg
cld.close()
@pytest.mark.asyncio
-async def test_rcv_invalid(config_tsun_inv1, inverter_ind_msg, inverter_rsp_msg):
+async def test_rcv_invalid(my_loop, config_tsun_inv1, inverter_ind_msg, inverter_rsp_msg):
_ = config_tsun_inv1
inv = InvStream()
assert asyncio.get_running_loop() == inv.mb_timer.loop
diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py
index 225c38e..fa42eed 100644
--- a/app/tests/test_talent.py
+++ b/app/tests/test_talent.py
@@ -1048,7 +1048,8 @@ def msg_inverter_ms3000_ind(): # Data indication from the controller
msg += b'\x53\x00\x66' # | S.f'
return msg
-def test_read_message(msg_contact_info):
+@pytest.mark.asyncio
+async def test_read_message(msg_contact_info):
Config.act_config = {'tsun':{'enabled': True}}
m = MemoryStream(msg_contact_info, (0,))
m.read() # read complete msg, and dispatch msg
diff --git a/app/tests/test_web_route.py b/app/tests/test_web_route.py
new file mode 100644
index 0000000..86817ac
--- /dev/null
+++ b/app/tests/test_web_route.py
@@ -0,0 +1,272 @@
+# test_with_pytest.py
+import pytest
+from server import app
+from web import Web, web
+from async_stream import AsyncStreamClient
+from gen3plus.inverter_g3p import InverterG3P
+from test_inverter_g3p import FakeReader, FakeWriter, config_conn
+from cnf.config import Config
+from mock import patch
+from proxy import Proxy
+import os, errno
+
+pytest_plugins = ('pytest_asyncio',)
+
+@pytest.fixture(scope="session")
+def client():
+ app.secret_key = 'super secret key'
+ return app.test_client()
+
+@pytest.fixture
+def create_inverter(config_conn):
+ _ = config_conn
+ inv = InverterG3P(FakeReader(), FakeWriter(), client_mode=False)
+
+ return inv
+
+@pytest.fixture
+def create_inverter_server(config_conn):
+ _ = config_conn
+ inv = InverterG3P(FakeReader(), FakeWriter(), client_mode=False)
+ ifc = AsyncStreamClient(FakeReader(), FakeWriter(), inv.local,
+ None, inv.use_emulation)
+ inv.remote.ifc = ifc
+
+ return inv
+
+@pytest.fixture
+def create_inverter_client(config_conn):
+ _ = config_conn
+ inv = InverterG3P(FakeReader(), FakeWriter(), client_mode=True)
+ ifc = AsyncStreamClient(FakeReader(), FakeWriter(), inv.local,
+ None, inv.use_emulation)
+ inv.remote.ifc = ifc
+
+ return inv
+
+@pytest.mark.asyncio
+async def test_home(client):
+ """Test the home route."""
+ response = await client.get('/')
+ assert response.status_code == 200
+ assert response.mimetype == 'text/html'
+
+@pytest.mark.asyncio
+async def test_page(client):
+ """Test the mqtt page route."""
+ response = await client.get('/mqtt')
+ assert response.status_code == 200
+ assert response.mimetype == 'text/html'
+
+@pytest.mark.asyncio
+async def test_rel_page(client):
+ """Test the mqtt route."""
+ web.build_relative_urls = True
+ response = await client.get('/mqtt')
+ assert response.status_code == 200
+ assert response.mimetype == 'text/html'
+ web.build_relative_urls = False
+
+@pytest.mark.asyncio
+async def test_notes(client):
+ """Test the notes page route."""
+ response = await client.get('/notes')
+ assert response.status_code == 200
+ assert response.mimetype == 'text/html'
+
+@pytest.mark.asyncio
+async def test_logging(client):
+ """Test the logging page route."""
+ response = await client.get('/logging')
+ assert response.status_code == 200
+ assert response.mimetype == 'text/html'
+
+@pytest.mark.asyncio
+async def test_favicon96(client):
+ """Test the favicon-96x96.png route."""
+ response = await client.get('/favicon-96x96.png')
+ assert response.status_code == 200
+ assert response.mimetype == 'image/png'
+
+@pytest.mark.asyncio
+async def test_favicon(client):
+ """Test the favicon.ico route."""
+ response = await client.get('/favicon.ico')
+ assert response.status_code == 200
+ assert response.mimetype == 'image/x-icon'
+
+@pytest.mark.asyncio
+async def test_favicon_svg(client):
+ """Test the favicon.svg route."""
+ response = await client.get('/favicon.svg')
+ assert response.status_code == 200
+ assert response.mimetype == 'image/svg+xml'
+
+@pytest.mark.asyncio
+async def test_apple_touch_icon(client):
+ """Test the apple-touch-icon.png route."""
+ response = await client.get('/apple-touch-icon.png')
+ assert response.status_code == 200
+ assert response.mimetype == 'image/png'
+
+@pytest.mark.asyncio
+async def test_manifest(client):
+ """Test the site.webmanifest route."""
+ response = await client.get('/site.webmanifest')
+ assert response.status_code == 200
+ assert response.mimetype == 'application/manifest+json'
+
+@pytest.mark.asyncio
+async def test_data_fetch(create_inverter):
+ """Test the data-fetch route."""
+ _ = create_inverter
+ client = app.test_client()
+ response = await client.get('/data-fetch')
+ assert response.status_code == 200
+
+ response = await client.get('/data-fetch')
+ assert response.status_code == 200
+
+@pytest.mark.asyncio
+async def test_data_fetch1(create_inverter_server):
+ """Test the data-fetch route with server connection."""
+ _ = create_inverter_server
+ client = app.test_client()
+ response = await client.get('/data-fetch')
+ assert response.status_code == 200
+
+ response = await client.get('/data-fetch')
+ assert response.status_code == 200
+
+@pytest.mark.asyncio
+async def test_data_fetch2(create_inverter_client):
+ """Test the data-fetch route with client connection."""
+ _ = create_inverter_client
+ client = app.test_client()
+ response = await client.get('/data-fetch')
+ assert response.status_code == 200
+
+ response = await client.get('/data-fetch')
+ assert response.status_code == 200
+
+@pytest.mark.asyncio
+async def test_language_en(client):
+ """Test the language/en route and cookie."""
+ response = await client.get('/language/en', headers={'referer': '/index'})
+ assert response.status_code == 302
+ assert response.content_language.pop() == 'en'
+ assert response.location == '/index'
+ assert response.mimetype == 'text/html'
+
+ client.set_cookie('test', key='language', value='de')
+ response = await client.get('/mqtt')
+ assert response.status_code == 200
+ assert response.mimetype == 'text/html'
+
+@pytest.mark.asyncio
+async def test_language_de(client):
+ """Test the language/de route."""
+ response = await client.get('/language/de', headers={'referer': '/'})
+ assert response.status_code == 302
+ assert response.content_language.pop() == 'de'
+ assert response.location == '/'
+ assert response.mimetype == 'text/html'
+
+
+@pytest.mark.asyncio
+async def test_language_unknown(client):
+ """Test the language/unknown route."""
+ response = await client.get('/language/unknown')
+ assert response.status_code == 404
+ assert response.mimetype == 'text/html'
+
+
+@pytest.mark.asyncio
+async def test_mqtt_fetch(client, create_inverter):
+ """Test the mqtt-fetch route."""
+ _ = create_inverter
+ Proxy.class_init()
+
+ response = await client.get('/mqtt-fetch')
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_notes_fetch(client, config_conn):
+ """Test the notes-fetch route."""
+ _ = create_inverter
+
+ response = await client.get('/notes-fetch')
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_file_fetch(client, config_conn):
+ """Test the data-fetch route."""
+ _ = config_conn
+ assert Config.log_path == 'app/tests/log/'
+ response = await client.get('/file-fetch')
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_send_file(client, config_conn):
+ """Test the send-file route."""
+ _ = config_conn
+ assert Config.log_path == 'app/tests/log/'
+ response = await client.get('/send-file/test.txt')
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_missing_send_file(client, config_conn):
+ """Test the send-file route (file not found)."""
+ _ = config_conn
+ assert Config.log_path == 'app/tests/log/'
+ response = await client.get('/send-file/no_file.log')
+ assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_invalid_send_file(client, config_conn):
+ """Test the send-file route (invalid filename)."""
+ _ = config_conn
+ assert Config.log_path == 'app/tests/log/'
+ response = await client.get('/send-file/../test_web_route.py')
+ assert response.status_code == 404
+
+@pytest.fixture
+def patch_os_remove_err():
+ def new_remove(file_path: str):
+ raise OSError(errno.ENOENT, os.strerror(errno.ENOENT), file_path)
+
+
+ with patch.object(os, 'remove', new_remove) as wrapped_os:
+ yield wrapped_os
+
+@pytest.fixture
+def patch_os_remove_ok():
+ def new_remove(file_path: str):
+ return
+
+ with patch.object(os, 'remove', new_remove) as wrapped_os:
+ yield wrapped_os
+
+@pytest.mark.asyncio
+async def test_del_file_ok(client, config_conn, patch_os_remove_ok):
+ """Test the del-file route with no error."""
+ _ = config_conn
+ _ = patch_os_remove_ok
+ assert Config.log_path == 'app/tests/log/'
+ response = await client.delete ('/del-file/test.txt')
+ assert response.status_code == 204
+
+
+@pytest.mark.asyncio
+async def test_del_file_err(client, config_conn, patch_os_remove_err):
+ """Test the send-file route with OSError."""
+ _ = config_conn
+ _ = patch_os_remove_err
+ assert Config.log_path == 'app/tests/log/'
+ response = await client.delete ('/del-file/test.txt')
+ assert response.status_code == 404
diff --git a/app/translations/de/LC_MESSAGES/messages.po b/app/translations/de/LC_MESSAGES/messages.po
new file mode 100644
index 0000000..8daf333
--- /dev/null
+++ b/app/translations/de/LC_MESSAGES/messages.po
@@ -0,0 +1,195 @@
+# German translations for tsun-gen3-proxy.
+# Copyright (C) 2025 ORGANIZATION
+# This file is distributed under the same license as the tsun-gen3-proxy
+# project.
+# FIRST AUTHOR , 2025.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: tsun-gen3-proxy 0.14.0\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2025-05-04 18:16+0200\n"
+"PO-Revision-Date: 2025-04-18 16:24+0200\n"
+"Last-Translator: FULL NAME \n"
+"Language: de\n"
+"Language-Team: de \n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.17.0\n"
+
+#: src/web/conn_table.py:52 src/web/templates/base.html.j2:58
+msgid "Connections"
+msgstr "Verbindungen"
+
+#: src/web/conn_table.py:59
+msgid "Device-IP:Port"
+msgstr "Geräte-IP:Port"
+
+#: src/web/conn_table.py:59
+msgid "Device-IP"
+msgstr "Geräte-IP"
+
+#: src/web/conn_table.py:60 src/web/mqtt_table.py:34
+msgid "Serial-No"
+msgstr "Seriennummer"
+
+#: src/web/conn_table.py:61
+msgid "Cloud-IP:Port"
+msgstr "Cloud-IP:Port"
+
+#: src/web/conn_table.py:61
+msgid "Cloud-IP"
+msgstr "Cloud-IP"
+
+#: src/web/mqtt_table.py:27
+msgid "MQTT devices"
+msgstr "MQTT Geräte"
+
+#: src/web/mqtt_table.py:35
+msgid "Node-ID"
+msgstr ""
+
+#: src/web/mqtt_table.py:36
+msgid "HA-Area"
+msgstr ""
+
+#: src/web/templates/base.html.j2:37
+msgid "Updated:"
+msgstr "Aktualisiert:"
+
+#: src/web/templates/base.html.j2:49
+msgid "Version:"
+msgstr ""
+
+#: src/web/templates/base.html.j2:60 src/web/templates/page_notes.html.j2:5
+msgid "Important Messages"
+msgstr "Wichtige Hinweise"
+
+#: src/web/templates/base.html.j2:61 src/web/templates/page_logging.html.j2:5
+msgid "Log Files"
+msgstr "Log Dateien"
+
+#: src/web/templates/page_index.html.j2:3
+msgid "TSUN Proxy - Connections"
+msgstr "TSUN Proxy - Verbindungen"
+
+#: src/web/templates/page_index.html.j2:5
+msgid "Proxy Connection Overview"
+msgstr "Proxy Verbindungen"
+
+#: src/web/templates/page_index.html.j2:17
+msgid "Server Mode"
+msgstr "Server Modus"
+
+#: src/web/templates/page_index.html.j2:18
+msgid "Established from device to proxy"
+msgstr "Vom Gerät zum Proxy aufgebaut"
+
+#: src/web/templates/page_index.html.j2:30
+msgid "Client Mode"
+msgstr "Client Modus"
+
+#: src/web/templates/page_index.html.j2:31
+msgid "Established from proxy to device"
+msgstr "Vom Proxy zum Gerät aufgebaut"
+
+#: src/web/templates/page_index.html.j2:43
+msgid "Proxy Mode"
+msgstr "Proxy Modus"
+
+#: src/web/templates/page_index.html.j2:44
+msgid "Forwarding data to cloud"
+msgstr "Weiterleitung in die Cloud"
+
+#: src/web/templates/page_index.html.j2:56
+msgid "Emu Mode"
+msgstr "Emu Modus"
+
+#: src/web/templates/page_index.html.j2:57
+msgid "Emulation sends data to cloud"
+msgstr "Emulation sendet in die Cloud"
+
+#: src/web/templates/page_logging.html.j2:3
+msgid "TSUN Proxy - Log Files"
+msgstr "TSUN Proxy - Log Dateien"
+
+#: src/web/templates/page_logging.html.j2:10
+msgid "Do you really want to delete the log file"
+msgstr "Soll die Datei wirklich gelöscht werden"
+
+#: src/web/templates/page_logging.html.j2:12
+msgid "Delete File