Compare commits

...

16 Commits

Author SHA1 Message Date
Stefan Allius
d0563200ce update changelog 2025-05-01 19:28:21 +02:00
Stefan Allius
94f3b67cb2 set quart debug flag for debug versions 2025-05-01 17:55:22 +02:00
Stefan Allius
a2da9d255f add german translations 2025-05-01 17:54:28 +02:00
Stefan Allius
27ae3132c2 add sub_dir to test log path
- non files must be skipped. To test this we add
  a sub directory to the test log directory
2025-05-01 17:40:42 +02:00
Stefan Allius
e74f9e0848 add test log file to project 2025-05-01 17:29:40 +02:00
Stefan Allius
bb4640a623 initialize config structure for log-file tests 2025-05-01 17:22:51 +02:00
Stefan Allius
78a7a7577d improve dashboard unit tests
- add log file tests
 - check content-languages after language switch
2025-05-01 17:11:08 +02:00
Stefan Allius
d7c003cb15 initialize log-path in test config 2025-05-01 17:09:23 +02:00
Stefan Allius
8db0f18c05 rename Download page into Log files 2025-05-01 17:08:41 +02:00
Stefan Allius
f3c4fdb093 build and send list of log-files 2025-05-01 17:08:01 +02:00
Stefan Allius
59e8508f4c jump to referer page
- after changing the language, we jump to
  the referer page, if the attribute exists
2025-05-01 17:05:49 +02:00
Stefan Allius
24950244c6 rename page files 2025-05-01 17:05:03 +02:00
Stefan Allius
36dfdd3d41 rename template files 2025-05-01 17:02:38 +02:00
Stefan Allius
035f96461a store logging path in Config class 2025-05-01 16:58:48 +02:00
Stefan Allius
7542c112f7 S allius/issue395 (#399)
* add button for languages setting

* build a web module for the dashboard

- load all python module from local dir
- initialize Blueprint and Babel

* set a default key for secure cookies

* add translations to docker container

* improve translation build  

- clean target erases the *.pot
- don't modify the resurt of url_for() calls
- don't translate the language description

* translate connection table, fix icon

* build relative urls for HA ingress

* fix unit test, increase coverage
2025-04-29 00:07:59 +02:00
Stefan Allius
093ec03d60 S allius/issue391 (#392)
* design counter on connection board

* display time of last update and add reload button

* chance `Updated` field to a real button

* Provide counter values for the dashboard

* change background color ot the top branch

- use dark-grey instead of black to reduce the contrast

* change color of counter tiles

* test proxy connection counter handling

* prepare conn-table and notes list building

* remove obsolete menue points

* store client mode for dashboard

* store inverters serial number for the dashboard

* store inverters serial number

* build connection table for dashboard

* add connection table to dashboard

* fix responsiveness of the tiles

* adapt unit tests

* remove test fake code

* increase test coverage, remove obsolete if statement
2025-04-24 23:12:26 +02:00
39 changed files with 853 additions and 337 deletions

View File

@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- Dashboard: add Log-File page
- Dashboard: add Connection page
- add web UI to add-on - add web UI to add-on
- allow `Y00` serial numbers for GEN3PLUS devices - allow `Y00` serial numbers for GEN3PLUS devices

View File

@@ -1,11 +1,19 @@
.PHONY: build babel 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
babel debug dev preview rc rel: babel:
$(MAKE) -C app $@ $(MAKE) -C app $@
clean build: build:
$(MAKE) -C ha_addons $@ $(MAKE) -C ha_addons $@
clean build:
$(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: addon-dev addon-debug addon-rc addon-rel:
$(MAKE) -C app babel $(MAKE) -C app babel
$(MAKE) -C ha_addons $(patsubst addon-%,%,$@) $(MAKE) -C ha_addons $(patsubst addon-%,%,$@)

View File

@@ -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 the content of the local src and config directory to the working directory
COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh
COPY src . COPY src .
COPY translations ./translations
RUN echo ${VERSION} > /proxy-version.txt \ RUN echo ${VERSION} > /proxy-version.txt \
&& date > /build-date.txt && date > /build-date.txt
EXPOSE 5005 8127 10000 EXPOSE 5005 8127 10000

View File

@@ -21,6 +21,8 @@ export MAJOR := $(shell echo $(VERSION) | cut -f1 -d.)
PUBLIC_URL := $(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f1 -d/) PUBLIC_URL := $(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f1 -d/)
PUBLIC_USER :=$(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f2 -d/) PUBLIC_USER :=$(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f2 -d/)
clean:
rm -f $(BABEL_TRANSLATIONS)/*.pot
dev debug: dev debug:
@echo version: $(VERSION) build-date: $(BUILD_DATE) image: $(PRIVAT_CONTAINER_REGISTRY)$(IMAGE) @echo version: $(VERSION) build-date: $(BUILD_DATE) image: $(PRIVAT_CONTAINER_REGISTRY)$(IMAGE)
@@ -58,4 +60,4 @@ $(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.po : $(BABEL_TRANSLATIONS)/messages
$(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.mo : $(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.po $(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.mo : $(BABEL_TRANSLATIONS)/%/LC_MESSAGES/messages.po
@pybabel compile -d $(BABEL_TRANSLATIONS) -l $* @pybabel compile -d $(BABEL_TRANSLATIONS) -l $*
.PHONY: babel debug dev preview rc rel .PHONY: babel clean debug dev preview rc rel

View File

@@ -23,7 +23,7 @@ if [ "$user" = '0' ]; then
echo "######################################################" echo "######################################################"
echo "#" echo "#"
exec su-exec $SERVICE_NAME "$@" exec su-exec $SERVICE_NAME "$@" -tr './translations/'
else else
exec "$@" exec "$@"
fi fi

View File

@@ -327,8 +327,10 @@ class AsyncStreamServer(AsyncStream):
logger.info(f'[{self.node_id}:{self.conn_no}] ' logger.info(f'[{self.node_id}:{self.conn_no}] '
f'Accept connection from {self.r_addr}') f'Accept connection from {self.r_addr}')
Infos.inc_counter('Inverter_Cnt') Infos.inc_counter('Inverter_Cnt')
Infos.inc_counter('ServerMode_Cnt')
await self.publish_outstanding_mqtt() await self.publish_outstanding_mqtt()
await self.loop() await self.loop()
Infos.dec_counter('ServerMode_Cnt')
Infos.dec_counter('Inverter_Cnt') Infos.dec_counter('Inverter_Cnt')
await self.publish_outstanding_mqtt() await self.publish_outstanding_mqtt()
logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for' logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for'
@@ -359,9 +361,11 @@ class AsyncStreamServer(AsyncStream):
class AsyncStreamClient(AsyncStream): class AsyncStreamClient(AsyncStream):
def __init__(self, reader: StreamReader, writer: StreamWriter, 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) AsyncStream.__init__(self, reader, writer, rstream)
self.close_cb = close_cb self.close_cb = close_cb
self.emu_mode = use_emu
async def disc(self) -> None: async def disc(self) -> None:
logging.debug('AsyncStreamClient.disc()') logging.debug('AsyncStreamClient.disc()')
@@ -376,8 +380,16 @@ class AsyncStreamClient(AsyncStream):
async def client_loop(self, _: str) -> None: async def client_loop(self, _: str) -> None:
'''Loop for receiving messages from the TSUN cloud (client-side)''' '''Loop for receiving messages from the TSUN cloud (client-side)'''
Infos.inc_counter('Cloud_Conn_Cnt') 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.publish_outstanding_mqtt()
await self.loop() await self.loop()
if self.emu_mode:
Infos.dec_counter('EmuMode_Cnt')
else:
Infos.dec_counter('ProxyMode_Cnt')
Infos.dec_counter('Cloud_Conn_Cnt') Infos.dec_counter('Cloud_Conn_Cnt')
await self.publish_outstanding_mqtt() await self.publish_outstanding_mqtt()
logger.info(f'[{self.node_id}:{self.conn_no}] ' logger.info(f'[{self.node_id}:{self.conn_no}] '

View File

@@ -162,12 +162,13 @@ class Config():
) )
@classmethod @classmethod
def init(cls, def_reader: ConfigIfc) -> None | str: def init(cls, def_reader: ConfigIfc, log_path: str = '') -> None | str:
'''Initialise the Proxy-Config '''Initialise the Proxy-Config
Copy the internal default config file into the config directory Copy the internal default config file into the config directory
and initialise the Config with the default configuration ''' and initialise the Config with the default configuration '''
cls.err = None cls.err = None
cls.log_path = log_path
cls.def_config = {} cls.def_config = {}
try: try:
# make the default config transparaent by copying it # 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''' '''Check if the member is the default value'''
return cls.act_config.get(member) == cls.def_config.get(member) return cls.act_config.get(member) == cls.def_config.get(member)
@classmethod
def get_log_path(cls) -> str:
return cls.log_path

View File

@@ -100,7 +100,7 @@ class Talent(Message):
if serial_no in inverters: if serial_no in inverters:
inv = inverters[serial_no] inv = inverters[serial_no]
self._set_config_parms(inv) self._set_config_parms(inv, serial_no)
self.db.set_pv_module_details(inv) self.db.set_pv_module_details(inv)
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
else: else:

View File

@@ -393,7 +393,7 @@ class SolarmanV5(SolarmanBase):
def _set_config_parms(self, inv: dict, serial_no: str = ""): def _set_config_parms(self, inv: dict, serial_no: str = ""):
'''init connection with params from the configuration''' '''init connection with params from the configuration'''
super()._set_config_parms(inv) super()._set_config_parms(inv, serial_no)
snr = serial_no[:3] snr = serial_no[:3]
if '410' == snr: if '410' == snr:
self.db.set_db_def_value(Register.EQUIPMENT_MODEL, self.db.set_db_def_value(Register.EQUIPMENT_MODEL,

View File

@@ -838,7 +838,10 @@ class Infos:
def inc_counter(cls, counter: str) -> None: def inc_counter(cls, counter: str) -> None:
'''inc proxy statistic counter''' '''inc proxy statistic counter'''
db_dict = cls.stat['proxy'] 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 cls.new_stat_data['proxy'] = True
@classmethod @classmethod
@@ -848,6 +851,15 @@ class Infos:
db_dict[counter] -= 1 db_dict[counter] -= 1
cls.new_stat_data['proxy'] = True 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) \ def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \
-> Generator[tuple[str, str, str, str], None, None]: -> Generator[tuple[str, str, str, str], None, None]:
'''Generator function yields json register struct for home-assistant '''Generator function yields json register struct for home-assistant

View File

@@ -28,11 +28,14 @@ class InverterBase(InverterIfc, Proxy):
Proxy.__init__(self) Proxy.__init__(self)
self._registry.append(weakref.ref(self)) self._registry.append(weakref.ref(self))
self.addr = writer.get_extra_info('peername') self.addr = writer.get_extra_info('peername')
self.client_mode = client_mode
self.config_id = config_id self.config_id = config_id
if remote_prot_class: if remote_prot_class:
self.prot_class = remote_prot_class self.prot_class = remote_prot_class
self.use_emulation = True
else: else:
self.prot_class = prot_class self.prot_class = prot_class
self.use_emulation = False
self.__ha_restarts = -1 self.__ha_restarts = -1
self.remote = StreamPtr(None) self.remote = StreamPtr(None)
ifc = AsyncStreamServer(reader, writer, ifc = AsyncStreamServer(reader, writer,
@@ -117,7 +120,8 @@ class InverterBase(InverterIfc, Proxy):
Config.act_config[self.config_id]['enabled'] = False Config.act_config[self.config_id]['enabled'] = False
ifc = AsyncStreamClient( ifc = AsyncStreamClient(
reader, writer, self.local, self.__del_remote) reader, writer, self.local,
self.__del_remote, self.use_emulation)
self.remote.ifc = ifc self.remote.ifc = ifc
if hasattr(stream, 'id_str'): if hasattr(stream, 'id_str'):

View File

@@ -108,6 +108,7 @@ class Message(ProtocolIfc):
self.header_len = 0 self.header_len = 0
self.data_len = 0 self.data_len = 0
self.unique_id = 0 self.unique_id = 0
self.inv_serial = ''
self.sug_area = '' self.sug_area = ''
self.new_data = {} self.new_data = {}
self.state = State.init self.state = State.init
@@ -140,8 +141,9 @@ class Message(ProtocolIfc):
# to our _recv_buffer # to our _recv_buffer
return # pragma: no cover 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''' '''init connection with params from the configuration'''
self.inv_serial = inv_serial
self.node_id = inv['node_id'] self.node_id = inv['node_id']
self.sug_area = inv['suggested_area'] self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling'] self.modbus_polling = inv['modbus_polling']

View File

@@ -28,10 +28,12 @@ class ModbusConn():
logging.info(f'[{stream.node_id}:{stream.conn_no}] ' logging.info(f'[{stream.node_id}:{stream.conn_no}] '
f'Connected to {self.addr}') f'Connected to {self.addr}')
Infos.inc_counter('Inverter_Cnt') Infos.inc_counter('Inverter_Cnt')
Infos.inc_counter('ClientMode_Cnt')
await self.inverter.local.ifc.publish_outstanding_mqtt() await self.inverter.local.ifc.publish_outstanding_mqtt()
return self.inverter return self.inverter
async def __aexit__(self, exc_type, exc, tb): async def __aexit__(self, exc_type, exc, tb):
Infos.dec_counter('ClientMode_Cnt')
Infos.dec_counter('Inverter_Cnt') Infos.dec_counter('Inverter_Cnt')
await self.inverter.local.ifc.publish_outstanding_mqtt() await self.inverter.local.ifc.publish_outstanding_mqtt()
self.inverter.__exit__(exc_type, exc, tb) self.inverter.__exit__(exc_type, exc, tb)

View File

@@ -4,9 +4,7 @@ import logging.handlers
import os import os
import argparse import argparse
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
from quart import Quart, Response, request from quart import Quart, Response
from quart_babel import Babel
from quart_babel.locale import get_locale
from logging import config # noqa F401 from logging import config # noqa F401
from proxy import Proxy from proxy import Proxy
from inverter_ifc import InverterIfc from inverter_ifc import InverterIfc
@@ -17,7 +15,9 @@ from cnf.config import Config
from cnf.config_read_env import ConfigReadEnv from cnf.config_read_env import ConfigReadEnv
from cnf.config_read_toml import ConfigReadToml from cnf.config_read_toml import ConfigReadToml
from cnf.config_read_json import ConfigReadJson from cnf.config_read_json import ConfigReadJson
from web.routes import web_routes from web import Web
from web.wrapper import url_for
from modbus_tcp import ModbusTcp from modbus_tcp import ModbusTcp
@@ -33,26 +33,11 @@ class ProxyState:
ProxyState._is_up = value ProxyState._is_up = value
def my_get_locale():
# 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(
['de', 'en']
)
app = Quart(__name__, app = Quart(__name__,
template_folder='web/templates', template_folder='web/templates',
static_folder='web/static') static_folder='web/static')
babel = Babel(app, app.secret_key = 'JKLdks.dajlKKKdladkflKwolafallsdfl'
locale_selector=my_get_locale, app.jinja_env.globals.update(url_for=url_for)
default_translation_directories='../translations')
app.register_blueprint(web_routes)
@app.context_processor
def utility_processor():
return dict(lang=get_locale())
@app.route('/-/ready') @app.route('/-/ready')
@@ -147,6 +132,12 @@ def main(): # pragma: no cover
parser.add_argument('-b', '--log_backups', type=int, parser.add_argument('-b', '--log_backups', type=int,
default=0, default=0,
help='set max number of daily log-files') help='set max number of daily log-files')
parser.add_argument('-tr', '--trans_path', type=str,
default='../translations/',
help='set path for the translations files')
parser.add_argument('-r', '--rel_urls', type=bool,
default=False,
help='use relative dashboard urls')
args = parser.parse_args() args = parser.parse_args()
# #
# Setup our daily, rotating logger # Setup our daily, rotating logger
@@ -165,6 +156,8 @@ def main(): # pragma: no cover
logging.info(f"config_path: {args.config_path}") logging.info(f"config_path: {args.config_path}")
logging.info(f"json_config: {args.json_config}") logging.info(f"json_config: {args.json_config}")
logging.info(f"toml_config: {args.toml_config}") logging.info(f"toml_config: {args.toml_config}")
logging.info(f"trans_path: {args.trans_path}")
logging.info(f"rel_urls: {args.rel_urls}")
logging.info(f"log_path: {args.log_path}") logging.info(f"log_path: {args.log_path}")
if args.log_backups == 0: if args.log_backups == 0:
logging.info("log_backups: unlimited") logging.info("log_backups: unlimited")
@@ -186,7 +179,8 @@ def main(): # pragma: no cover
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
# read config file # read config file
Config.init(ConfigReadToml(src_dir + "cnf/default_config.toml")) Config.init(ConfigReadToml(src_dir + "cnf/default_config.toml"),
log_path=args.log_path)
ConfigReadEnv() ConfigReadEnv()
ConfigReadJson(args.config_path + "config.json") ConfigReadJson(args.config_path + "config.json")
ConfigReadToml(args.config_path + "config.toml") ConfigReadToml(args.config_path + "config.toml")
@@ -203,6 +197,7 @@ def main(): # pragma: no cover
Proxy.class_init() Proxy.class_init()
Schedule.start() Schedule.start()
ModbusTcp(loop) ModbusTcp(loop)
Web(app, args.trans_path, args.rel_urls)
# #
# Create tasks for our listening servers. These must be tasks! If we call # Create tasks for our listening servers. These must be tasks! If we call
@@ -219,7 +214,8 @@ def main(): # pragma: no cover
try: try:
ProxyState.set_up(True) ProxyState.set_up(True)
logging.info("Start Quart") logging.info("Start Quart")
app.run(host='0.0.0.0', port=8127, use_reloader=False, loop=loop) app.run(host='0.0.0.0', port=8127, use_reloader=False, loop=loop,
debug=log_level == logging.DEBUG)
logging.info("Quart stopped") logging.info("Quart stopped")
except KeyboardInterrupt: except KeyboardInterrupt:

25
app/src/utils/__init__.py Normal file
View File

@@ -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)

32
app/src/web/__init__.py Normal file
View File

@@ -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)

82
app/src/web/conn_table.py Normal file
View File

@@ -0,0 +1,82 @@
from inverter_base import InverterBase
from quart import render_template
from quart_babel import format_datetime, _
from infos import Infos
from . import web
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'<i class="fa {icon1}"></i> {ip1}:{port1}')
row.append(f'<i class="fa {icon1}"></i> {ip1}')
row.append(inv_serial)
row.append(f'<i class="fa {icon2}"></i> {ip2}:{port2}')
row.append(f'<i class="fa {icon2}"></i> {ip2}')
return row
def get_table_data():
'''build the connection table'''
table = {
"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"<h3>{Infos.get_counter('ServerMode_Cnt')}</h3>",
"client-cnt": f"<h3>{Infos.get_counter('ClientMode_Cnt')}</h3>",
"proxy-cnt": f"<h3>{Infos.get_counter('ProxyMode_Cnt')}</h3>",
"emulation-cnt": f"<h3>{Infos.get_counter('EmuMode_Cnt')}</h3>",
}
data["conn-table"] = await render_template('templ_conn_table.html.j2',
table=get_table_data())
data["notes-list"] = await render_template('templ_notes_list.html.j2')
return data

37
app/src/web/favicon.py Normal file
View File

@@ -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')

45
app/src/web/i18n.py Normal file
View File

@@ -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/<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)

52
app/src/web/log_files.py Normal file
View File

@@ -0,0 +1,52 @@
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())
data["notes-list"] = await render_template('templ_notes_list.html.j2')
return data
@web.route('/send-file/<file>')
async def send(file):
return await send_from_directory(
directory=Config.get_log_path(),
file_name=secure_filename(file),
as_attachment=True)

23
app/src/web/pages.py Normal file
View File

@@ -0,0 +1,23 @@
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('/page')
async def empty():
return await render_template('empty.html.j2')
@web.route('/logging')
async def logging():
return await render_template(
'page_logging.html.j2',
fetch_url=url_for('.file_fetch'))

View File

@@ -1,78 +0,0 @@
from quart import Blueprint
from quart import render_template, url_for
from quart import send_from_directory
import os
web_routes = Blueprint('web_routes', __name__)
async def get_icon(file: str, mime: str = 'image/png'):
return await send_from_directory(
os.path.join(web_routes.root_path, 'static/images'),
file,
mimetype=mime)
def get_inv_count():
return 1234
TsunCnt = 0
def get_tsun_count():
global TsunCnt
TsunCnt += 1
return TsunCnt
@web_routes.context_processor
def utility_processor():
return dict(inv_count=get_inv_count(),
tsun_count=get_tsun_count())
@web_routes.route('/')
async def index():
return await render_template(
'index.html.j2',
fetch_url='.'+url_for('web_routes.data_fetch'))
@web_routes.route('/page')
async def empty():
return await render_template('empty.html.j2')
@web_routes.route('/data-fetch')
async def data_fetch():
global TsunCnt
TsunCnt += 1
return {
"geology-fact": f"<h3>{TsunCnt}</h3>",
}
@web_routes.route('/favicon-96x96.png')
async def favicon():
return await get_icon('favicon-96x96.png')
@web_routes.route('/favicon.ico')
async def favicon_ico():
return await get_icon('favicon.ico', 'image/x-icon')
@web_routes.route('/favicon.svg')
async def favicon_svg():
return await get_icon('favicon.svg', 'image/svg+xml')
@web_routes.route('/apple-touch-icon.png')
async def apple_touch():
return await get_icon('apple-touch-icon.png')
@web_routes.route('/site.webmanifest')
async def webmanifest():
return await get_icon('site.webmanifest', 'application/manifest+json')

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<title>{% block title %}{% endblock title %}</title> <title>{% block title %}{% endblock title %}</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href=".{{ url_for('static', filename= 'css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename= 'css/style.css') }}">
<link rel="stylesheet" href=".{{ url_for('static', filename= 'font-awesome/css/all.min.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename= 'font-awesome/css/all.min.css') }}">
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
@@ -14,7 +14,7 @@
<style> <style>
@font-face { @font-face {
font-family: Roboto; font-family: Roboto;
src: url(".{{ url_for('static', filename= 'font/roboto-light.ttf') }}"); src: url("{{ url_for('static', filename= 'font/roboto-light.ttf') }}");
} }
html,body,h1,h2,h3,h4,h5 {font-family: Roboto, sans-serif} html,body,h1,h2,h3,h4,h5 {font-family: Roboto, sans-serif}
</style> </style>
@@ -22,16 +22,28 @@
<body class="w3-light-grey"> <body class="w3-light-grey">
<!-- Top container --> <!-- Top container -->
<div class="w3-bar w3-top w3-black w3-large" style="z-index:4"> <div class="w3-bar w3-dark-grey w3-large" style="z-index:4">
<button class="w3-bar-item w3-button w3-hide-large w3-hover-none w3-hover-text-light-grey" onclick="w3_open();"><i class="fa fa-bars"></i>  Menu</button> <button class="w3-bar-item w3-button w3-hide-large" onclick="w3_open();"><i class="fa fa-bars"></i>  Menu</button>
<span class="w3-bar-item w3-right">Logo</span> <div class="w3-dropdown-hover w3-right">
<button class="w3-button">{{lang_str}}</button>
<div class="w3-dropdown-content w3-bar-block w3-card-4" style="right:0">
{% for language in languages %}
<a href="{{url_for('web.set_language', language=language)}}" class="w3-bar-item w3-button">{{languages[language]}}</a>
{% endfor %}
</div>
</div>
{% if fetch_url is defined %}
<button class="w3-bar-item w3-button w3-right" onclick="fetch_data();"><span class="w3-hide-small">{{_('Updated:')}}  </span><span id="update-time"></span>  <i class="w3-hover fa fa-rotate-right w3-medium"></i></button>
{% endif %}
<div class="w3-clear"></div>
</div> </div>
<!-- Sidebar/menu --> <!-- Sidebar/menu -->
<nav class="w3-sidebar w3-collapse w3-white w3-animate-left" style="z-index:3;width:250px;" id="mySidebar"><br> <nav class="w3-sidebar w3-collapse w3-white" style="z-index:3;width:250px;" id="mySidebar"><br>
<div class="w3-container w3-row"> <div class="w3-container w3-row">
<div class="w3-col s4"> <div class="w3-col s4">
<img src=".{{ url_for('static', filename= 'images/favicon.svg') }}" alt="" class="w3-circle w3-margin-right" style="width:60px"> <img src="{{url_for('static', filename= 'images/favicon.svg') }}" alt="" class="w3-circle w3-margin-right" style="width:60px">
</div> </div>
<div class="w3-col s8 w3-bar"> <div class="w3-col s8 w3-bar">
<h3>TSUN-Proxy</h3><br> <h3>TSUN-Proxy</h3><br>
@@ -43,22 +55,16 @@
</div> </div>
<div class="w3-bar-block"> <div class="w3-bar-block">
<button href="#" class="w3-bar-item w3-button w3-padding-16 w3-hide-large w3-dark-grey w3-hover-black" onclick="w3_close()" title="close menu"><i class="fa fa-remove fa-fw"></i>  Close Menu</button> <button href="#" class="w3-bar-item w3-button w3-padding-16 w3-hide-large w3-dark-grey w3-hover-black" onclick="w3_close()" title="close menu"><i class="fa fa-remove fa-fw"></i>  Close Menu</button>
<a href=".{{ url_for('web_routes.index')}}" class="w3-bar-item w3-button w3-padding {% block menu1_class %}{% endblock %}"><i class="fa fa-users fa-fw"></i>  Overview</a> <a href="{{ url_for('.index')}}" class="w3-bar-item w3-button w3-padding {% block menu1_class %}{% endblock %}"><i class="fa fa-network-wired fa-fw"></i>  {{_('Connections')}}</a>
<a href=".{{ url_for('web_routes.empty')}}" class="w3-bar-item w3-button w3-padding {% block menu2_class %}{% endblock %}"><i class="fa fa-eye fa-fw"></i>  Views</a> <a href="{{ url_for('.empty')}}" class="w3-bar-item w3-button w3-padding {% block menu2_class %}{% endblock %}"><i class="fa fa-database fa-fw"></i>  MQTT</a>
<a href="#" class="w3-bar-item w3-button w3-padding"><i class="fa fa-users fa-fw {% block menu3_class %}{% endblock %}"></i>  Traffic</a> <a href="{{ url_for('.logging')}}" class="w3-bar-item w3-button w3-padding {% block menu3_class %}{% endblock %}"><i class="fa fa-file-export fa-fw"></i>  {{_('Log Files')}}</a>
<a href="#" class="w3-bar-item w3-button w3-padding"><i class="fa fa-bullseye fa-fw {% block menu4_class %}{% endblock %}"></i>  Geo</a>
<a href="#" class="w3-bar-item w3-button w3-padding"><i class="fa fa-gem fa-fw {% block menu5_class %}{% endblock %}"></i>  Orders</a>
<a href="#" class="w3-bar-item w3-button w3-padding"><i class="fa fa-bell fa-fw {% block menu6_class %}{% endblock %}"></i>  News</a>
<a href="#" class="w3-bar-item w3-button w3-padding"><i class="fa fa-university fa-fw {% block menu7_class %}{% endblock %}"></i>  General</a>
<a href="#" class="w3-bar-item w3-button w3-padding"><i class="fa fa-history fa-fw {% block menu8_class %}{% endblock %}"></i>  History</a>
<a href="#" class="w3-bar-item w3-button w3-padding"><i class="fa fa-cog fa-fw {% block menu9_class %}{% endblock %}"></i>  Settings</a><br><br>
</div> </div>
</nav> </nav>
<!-- Overlay effect when opening sidebar on small screens --> <!-- Overlay effect when opening sidebar on small screens -->
<button class="w3-overlay w3-hide-large w3-animate-opacity" onclick="w3_close()" style="cursor:pointer" title="close side menu" id="myOverlay"></button> <button class="w3-overlay w3-hide-large w3-animate-opacity" onclick="w3_close()" style="cursor:pointer" title="close side menu" id="myOverlay"></button>
<!-- !PAGE CONTENT! --> <!-- !PAGE CONTENT! -->
<div class="w3-main" style="margin-left:250px;margin-top:43px;"> <div class="w3-main" style="margin-left:250px;margin-top:43px;">
@@ -130,7 +136,7 @@
window.addEventListener('load', function () { window.addEventListener('load', function () {
// Your document is loaded. // Your document is loaded.
var fetchInterval = 5000; // 5 seconds. var fetchInterval = 5000; // 5 seconds.
fetch_data()
// Invoke the request every 5 seconds. // Invoke the request every 5 seconds.
setInterval(fetch_data, fetchInterval); setInterval(fetch_data, fetchInterval);
}); });

View File

@@ -1,179 +0,0 @@
{% extends 'base.html.j2' %}
{% block title %} TSUN Proxy - Dashboard {% endblock title%}
{% block menu1_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-dashboard"></i> {{_('My Dashboard')}}{% endblock headline %}
{% block content %}
<div class="w3-row-padding w3-margin-bottom">
<div class="w3-quarter">
<div class="w3-container w3-red w3-padding-16">
<div class="w3-left"><i class="fa fa-comment w3-xxxlarge"></i></div>
<div class="w3-right">
<h3>{{inv_count}}</h3>
</div>
<div class="w3-clear"></div>
<h4>Messages</h4>
</div>
</div>
<div class="w3-quarter">
<div class="w3-container w3-blue w3-padding-16">
<div class="w3-left"><i class="fa fa-eye w3-xxxlarge"></i></div>
<div class="w3-right">
<h3>{{tsun_count}}</h3>
</div>
<div class="w3-clear"></div>
<h4>Views</h4>
</div>
</div>
<div class="w3-quarter">
<div class="w3-container w3-teal w3-padding-16">
<div class="w3-left"><i class="fa fa-share-alt w3-xxxlarge"></i></div>
<div id = "geology-fact" class="w3-right">
<h3>23</h3>
</div>
<div class="w3-clear"></div>
<h4>Shares</h4>
</div>
</div>
<div class="w3-quarter">
<div class="w3-container w3-orange w3-text-white w3-padding-16">
<div class="w3-left"><i class="fa fa-users w3-xxxlarge"></i></div>
<div class="w3-right">
<h3>50</h3>
</div>
<div class="w3-clear"></div>
<h4>Users</h4>
</div>
</div>
</div>
<div class="w3-panel">
<div class="w3-row-padding" style="margin:0 -16px">
<div class="w3-third">
<h5>Regions</h5>
</div>
<div class="w3-twothird">
<h5>Feeds</h5>
<table class="w3-table w3-striped w3-white">
<tr>
<th class="w3-hide-large"></th>
</tr>
<tr>
<td><i class="fa fa-user w3-text-blue w3-large"></i></td>
<td>New record, over 90 views.</td>
<td><i>10 mins</i></td>
</tr>
<tr>
<td><i class="fa fa-bell w3-text-red w3-large"></i></td>
<td>Database error.</td>
<td><i>15 mins</i></td>
</tr>
<tr>
<td><i class="fa fa-users w3-text-yellow w3-large"></i></td>
<td>New record, over 40 users.</td>
<td><i>17 mins</i></td>
</tr>
<tr>
<td><i class="fa fa-comment w3-text-red w3-large"></i></td>
<td>New comments.</td>
<td><i>25 mins</i></td>
</tr>
<tr>
<td><i class="fa fa-bookmark w3-text-blue w3-large"></i></td>
<td>Check transactions.</td>
<td><i>28 mins</i></td>
</tr>
<tr>
<td><i class="fa fa-laptop w3-text-red w3-large"></i></td>
<td>CPU overload.</td>
<td><i>35 mins</i></td>
</tr>
<tr>
<td><i class="fa fa-share-alt w3-text-green w3-large"></i></td>
<td>New shares.</td>
<td><i>39 mins</i></td>
</tr>
</table>
</div>
</div>
</div>
<hr>
<div class="w3-container">
<h5>General Stats</h5>
<p>New Visitors</p>
<div class="w3-grey">
<div class="w3-container w3-center w3-padding w3-green" style="width:25%">+25%</div>
</div>
<p>New Users</p>
<div class="w3-grey">
<div class="w3-container w3-center w3-padding w3-orange" style="width:50%">50%</div>
</div>
<p>Bounce Rate</p>
<div class="w3-grey">
<div class="w3-container w3-center w3-padding w3-red" style="width:75%">75%</div>
</div>
</div>
<hr>
<div class="w3-container">
<h5>Countries</h5>
<table class="w3-table w3-striped w3-bordered w3-border w3-hoverable w3-white">
<tr>
<th class="w3-hide-large"></th>
</tr>
<tr>
<td>United States</td>
<td>65%</td>
</tr>
<tr>
<td>UK</td>
<td>15.7%</td>
</tr>
<tr>
<td>Russia</td>
<td>5.6%</td>
</tr>
<tr>
<td>Spain</td>
<td>2.1%</td>
</tr>
<tr>
<td>India</td>
<td>1.9%</td>
</tr>
<tr>
<td>France</td>
<td>1.5%</td>
</tr>
</table><br>
<button class="w3-button w3-dark-grey">More Countries  <i class="fa fa-arrow-right"></i></button>
</div>
<br>
<div class="w3-container w3-dark-grey w3-padding-32">
<div class="w3-row">
<div class="w3-container w3-third">
<h5 class="w3-bottombar w3-border-green">Demographic</h5>
<p>Language</p>
<p>Country</p>
<p>City</p>
</div>
<div class="w3-container w3-third">
<h5 class="w3-bottombar w3-border-red">System</h5>
<p>Browser</p>
<p>OS</p>
<p>More</p>
</div>
<div class="w3-container w3-third">
<h5 class="w3-bottombar w3-border-orange">Target</h5>
<p>Users</p>
<p>Active</p>
<p>Geo</p>
<p>Interests</p>
</div>
</div>
</div>
{% endblock content%}

View File

@@ -0,0 +1,57 @@
{% extends 'base.html.j2' %}
{% block title %} TSUN Proxy - Connections {% endblock title%}
{% block menu1_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-network-wired"></i>  {{_('Proxy Connection Overview')}}{% endblock headline %}
{% block content %}
<div class="w3-row-padding w3-margin-bottom">
<div class="w3-quarter">
<div class="w3-container w3-indigo w3-padding-16">
<div class="w3-left"><i class="fa fa-upload w3-xxxlarge fa-rotate-180"></i></div>
<div id = "server-cnt" class="w3-right">
<h3>-</h3>
</div>
<div class="w3-clear"></div>
<h4>{{_('Server Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Established from device to proxy')}}</div>
</div>
</div>
<div class="w3-quarter">
<div class="w3-container w3-purple w3-padding-16">
<div class="w3-left"><i class="fa fa-download w3-xxxlarge fa-rotate-180"></i></div>
<div id = "client-cnt" class="w3-right">
<h3>-</h3>
</div>
<div class="w3-clear"></div>
<h4>{{_('Client Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Established from proxy to device')}}</div>
</div>
</div>
<div class="w3-quarter">
<div class="w3-container w3-orange w3-text-white w3-padding-16">
<div class="w3-left"><i class="fa fa-cloud w3-xxxlarge"></i></div>
<div id = "proxy-cnt" class="w3-right">
<h3>-</h3>
</div>
<div class="w3-clear"></div>
<h4>{{_('Proxy Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Forwarding data to cloud')}}</div>
</div>
</div>
<div class="w3-quarter">
<div class="w3-container w3-teal w3-padding-16">
<div class="w3-left"><i class="fa fa-cloud-arrow-up-alt w3-xxxlarge"></i></div>
<div id = "emulation-cnt" class="w3-right">
<h3>-</h3>
</div>
<div class="w3-clear"></div>
<h4>{{_('Emu Mode')}}</h4>
<div class="w3-hide-small w3-hide-medium" style="min-height:50px">{{_('Emulation sends data to cloud')}}</div>
</div>
</div>
</div>
<div class="w3-container" id="notes-list"></div>
<div class="w3-container" id="conn-table"></div>
{% endblock content%}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html.j2' %}
{% block title %} TSUN Proxy - Downloads {% endblock title%}
{% block menu3_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-file-export fa-fw"></i>  {{_('Log Files')}}{% endblock headline %}
{% block content %}
<div class="w3-container" id="file-list"></div>
{% endblock content%}
{% block footer %}{% endblock footer %}

View File

@@ -0,0 +1,37 @@
{% 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%}
<h5>{{_('Connections')}}</h5>
<table class="w3-table w3-striped w3-bordered w3-border w3-hoverable w3-white">
{% if table.thead is defined%}
<thead>
{% for row in table.thead %}
<tr>
{% for col in row %}
{{table_elm(col, table.col_classes[loop.index0], 'th')}}
{% endfor %}
</tr>
{% endfor %}
</thead>
{% endif %}
<tbody>
{% for row in table.tbody %}
<tr>
{% for col in row %}
{{table_elm(col, table.col_classes[loop.index0])}}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,31 @@
<div class="w3-row-padding w3-margin-bottom">
{% for file in dir_list %}
<div class="w3-quarter w3-margin-bottom">
<div class="w3-card-4">
<header class="w3-container w3-blue">
<h4>{{file.name}}</h4>
</header>
<table class="w3-table">
{% for idx, name in [('created',_('Created')), ('modified', _('Modified')), ('size', _('Size'))]%}
<tr>
<td>{{_(name)}}:</td>
<td>{{file[idx]}}</td>
</tr>
{% endfor %}
</table>
<footer class="w3-blue">
<a href="{{ url_for('web.send',file=file.name)}}" class="w3-button w3-hover-blue w3-hover-text-black"><i class="fa fa-file-export"></i>  {{_('Download File')}}</a>
</footer>
</div>
</div>
{% if 0 == (loop.index%4) and not last %}
</div>
<div class="w3-row-padding w3-margin-bottom">
{% endif %}
{% endfor %}
</div>

26
app/src/web/wrapper.py Normal file
View File

@@ -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

View File

19
app/tests/log/test.txt Normal file
View File

@@ -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

View File

@@ -8,6 +8,7 @@ from infos import Infos
from inverter_base import InverterBase from inverter_base import InverterBase
from async_stream import AsyncStreamServer, AsyncStreamClient, StreamPtr from async_stream import AsyncStreamServer, AsyncStreamClient, StreamPtr
from messages import Message from messages import Message
from mock import patch, call
from test_modbus_tcp import FakeReader, FakeWriter from test_modbus_tcp import FakeReader, FakeWriter
from test_inverter_base import config_conn, patch_open_connection from test_inverter_base import config_conn, patch_open_connection
@@ -74,6 +75,13 @@ def test_health():
cnt += 1 cnt += 1
assert cnt == 0 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 @pytest.mark.asyncio
async def test_close_cb(): async def test_close_cb():
assert asyncio.get_running_loop() assert asyncio.get_running_loop()
@@ -529,9 +537,10 @@ async def test_forward_runtime_error2():
del ifc del ifc
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_forward_runtime_error3(): async def test_forward_runtime_error3(spy_inc_cnt):
assert asyncio.get_running_loop() assert asyncio.get_running_loop()
remote = StreamPtr(None) remote = StreamPtr(None)
spy = spy_inc_cnt
cnt = 0 cnt = 0
async def _create_remote(): 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 = AsyncStreamServer(fake_reader_fwd(), FakeWriter(), None, _create_remote, remote)
ifc.fwd_add(b'test-forward_msg') ifc.fwd_add(b'test-forward_msg')
await ifc.server_loop() 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 assert cnt == 1
del ifc del ifc
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_forward_resp(): async def test_forward_resp(spy_inc_cnt):
assert asyncio.get_running_loop() assert asyncio.get_running_loop()
remote = StreamPtr(None) remote = StreamPtr(None)
spy = spy_inc_cnt
cnt = 0 cnt = 0
def _close_cb(): def _close_cb():
@@ -557,27 +570,35 @@ async def test_forward_resp():
cnt += 1 cnt += 1
cnt = 0 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) create_remote(remote, TestType.FWD_NO_EXCPT)
ifc.fwd_add(b'test-forward_msg') ifc.fwd_add(b'test-forward_msg')
await ifc.client_loop('') 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 assert cnt == 1
del ifc del ifc
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_forward_resp2(): async def test_forward_resp2(spy_inc_cnt):
assert asyncio.get_running_loop() assert asyncio.get_running_loop()
remote = StreamPtr(None) remote = StreamPtr(None)
spy = spy_inc_cnt
cnt = 0 cnt = 0
def _close_cb(): def _close_cb():
nonlocal cnt nonlocal cnt
cnt += 1 cnt += 1
cnt = 0 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) create_remote(remote, TestType.FWD_NO_EXCPT)
ifc.fwd_add(b'test-forward_msg') ifc.fwd_add(b'test-forward_msg')
await ifc.client_loop('') 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 assert cnt == 1
del ifc del ifc

View File

@@ -37,6 +37,7 @@ def config_conn():
}, },
'solarman':{'enabled': True, 'host': 'test_cloud.local', 'port': 1234}, 'inverters':{'allow_all':True} '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) @pytest.fixture(scope="module", autouse=True)
def module_init(): def module_init():

View File

@@ -1,73 +1,208 @@
# test_with_pytest.py # test_with_pytest.py
import pytest import pytest
from server import app 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
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ('pytest_asyncio',)
@pytest.fixture(scope="session")
def client():
app.secret_key = 'super secret key'
Web(app, '../transfer', False)
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 @pytest.mark.asyncio
async def test_home(): async def test_home(client):
"""Test the home route.""" """Test the home route."""
client = app.test_client()
response = await client.get('/') response = await client.get('/')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'text/html' assert response.mimetype == 'text/html'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_page(): async def test_page(client):
"""Test the empty page route.""" """Test the empty page route."""
client = app.test_client()
response = await client.get('/page') response = await client.get('/page')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'text/html' assert response.mimetype == 'text/html'
@pytest.mark.asyncio
async def test_rel_page(client):
"""Test the empty page route."""
web.build_relative_urls = True
response = await client.get('/page')
assert response.status_code == 200
assert response.mimetype == 'text/html'
web.build_relative_urls = False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_favicon96(): 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.""" """Test the favicon-96x96.png route."""
client = app.test_client()
response = await client.get('/favicon-96x96.png') response = await client.get('/favicon-96x96.png')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'image/png' assert response.mimetype == 'image/png'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_favicon(): async def test_favicon(client):
"""Test the favicon.ico route.""" """Test the favicon.ico route."""
client = app.test_client()
response = await client.get('/favicon.ico') response = await client.get('/favicon.ico')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'image/x-icon' assert response.mimetype == 'image/x-icon'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_favicon_svg(): async def test_favicon_svg(client):
"""Test the favicon.svg route.""" """Test the favicon.svg route."""
client = app.test_client()
response = await client.get('/favicon.svg') response = await client.get('/favicon.svg')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'image/svg+xml' assert response.mimetype == 'image/svg+xml'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_apple_touch_icon(): async def test_apple_touch_icon(client):
"""Test the apple-touch-icon.png route.""" """Test the apple-touch-icon.png route."""
client = app.test_client()
response = await client.get('/apple-touch-icon.png') response = await client.get('/apple-touch-icon.png')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'image/png' assert response.mimetype == 'image/png'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_manifest(): async def test_manifest(client):
"""Test the site.webmanifest route.""" """Test the site.webmanifest route."""
client = app.test_client()
response = await client.get('/site.webmanifest') response = await client.get('/site.webmanifest')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'application/manifest+json' assert response.mimetype == 'application/manifest+json'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_data_fetch(): async def test_data_fetch(create_inverter):
"""Test the healthy route.""" """Test the data-fetch route."""
_ = create_inverter
client = app.test_client() client = app.test_client()
response = await client.get('/data-fetch') response = await client.get('/data-fetch')
assert response.status_code == 200 assert response.status_code == 200
response = await client.get('/data-fetch') response = await client.get('/data-fetch')
assert response.status_code == 200 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('/page')
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_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

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: tsun-gen3-proxy 0.14.0\n" "Project-Id-Version: tsun-gen3-proxy 0.14.0\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-04-20 00:01+0200\n" "POT-Creation-Date: 2025-05-01 17:48+0200\n"
"PO-Revision-Date: 2025-04-18 16:24+0200\n" "PO-Revision-Date: 2025-04-18 16:24+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n" "Language: de\n"
@@ -19,7 +19,89 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
#: src/web/templates/index.html.j2:5 #: src/web/conn_table.py:57
msgid "My Dashboard" msgid "Device-IP:Port"
msgstr "Mein Dashboard" msgstr "Geräte-IP:Port"
#: src/web/conn_table.py:57
msgid "Device-IP"
msgstr "Geräte-IP"
#: src/web/conn_table.py:58
msgid "Serial-No"
msgstr "Seriennummer"
#: src/web/conn_table.py:59
msgid "Cloud-IP:Port"
msgstr "Cloud-IP:Port"
#: src/web/conn_table.py:59
msgid "Cloud-IP"
msgstr "Cloud-IP"
#: src/web/templates/base.html.j2:37
msgid "Updated:"
msgstr "Aktualisiert:"
#: src/web/templates/base.html.j2:58
#: src/web/templates/templ_conn_table.html.j2:15
msgid "Connections"
msgstr "Verbindungen"
#: src/web/templates/base.html.j2:60 src/web/templates/page_logging.html.j2:5
msgid "Log Files"
msgstr "Log Dateien"
#: src/web/templates/page_index.html.j2:5
msgid "Proxy Connection Overview"
msgstr "Proxy Verbindungen"
#: src/web/templates/page_index.html.j2:16
msgid "Server Mode"
msgstr "Server Modus"
#: src/web/templates/page_index.html.j2:17
msgid "Established from device to proxy"
msgstr "Vom Gerät zum Proxy aufgebaut"
#: src/web/templates/page_index.html.j2:27
msgid "Client Mode"
msgstr "Client Modus"
#: src/web/templates/page_index.html.j2:28
msgid "Established from proxy to device"
msgstr "Vom Proxy zum Gerät aufgebaut"
#: src/web/templates/page_index.html.j2:38
msgid "Proxy Mode"
msgstr "Proxy Modus"
#: src/web/templates/page_index.html.j2:39
msgid "Forwarding data to cloud"
msgstr "Weiterleitung in die Cloud"
#: src/web/templates/page_index.html.j2:49
msgid "Emu Mode"
msgstr "Emu Modus"
#: src/web/templates/page_index.html.j2:50
msgid "Emulation sends data to cloud"
msgstr "Emulation sendet in die Cloud"
#: src/web/templates/templ_log_files_list.html.j2:11
msgid "Created"
msgstr "Erzeugt"
#: src/web/templates/templ_log_files_list.html.j2:11
msgid "Modified"
msgstr "Modifiziert"
#: src/web/templates/templ_log_files_list.html.j2:11
msgid "Size"
msgstr "Größe"
#: src/web/templates/templ_log_files_list.html.j2:20
msgid "Download File"
msgstr "Datei Download"

View File

@@ -87,6 +87,7 @@ SRC_FILES := $(wildcard $(SRC_PROXY)/*.py)\
$(wildcard $(SRC_PROXY)/cnf/*.toml)\ $(wildcard $(SRC_PROXY)/cnf/*.toml)\
$(wildcard $(SRC_PROXY)/gen3/*.py)\ $(wildcard $(SRC_PROXY)/gen3/*.py)\
$(wildcard $(SRC_PROXY)/gen3plus/*.py)\ $(wildcard $(SRC_PROXY)/gen3plus/*.py)\
$(wildcard $(SRC_PROXY)/utils/*.py)\
$(wildcard $(SRC_PROXY)/web/*.py)\ $(wildcard $(SRC_PROXY)/web/*.py)\
$(wildcard $(SRC_PROXY)/web/templates/*.html.j2)\ $(wildcard $(SRC_PROXY)/web/templates/*.html.j2)\
$(wildcard $(SRC_PROXY)/web/static/css/*.css)\ $(wildcard $(SRC_PROXY)/web/static/css/*.css)\

View File

@@ -30,4 +30,4 @@ cd /home/proxy || exit
export VERSION=$(cat /proxy-version.txt) export VERSION=$(cat /proxy-version.txt)
echo "Start Proxyserver..." echo "Start Proxyserver..."
python3 server.py --json_config=/data/options.json --log_path=/homeassistant/tsun-proxy/logs/ --config_path=/homeassistant/tsun-proxy/ --log_backups=2 python3 server.py --rel_urls=True --json_config=/data/options.json --log_path=/homeassistant/tsun-proxy/logs/ --config_path=/homeassistant/tsun-proxy/ --log_backups=2