From 0a61a4eea946bf81c420a8f28ac779be675a513d Mon Sep 17 00:00:00 2001 From: MAKOMO Date: Wed, 15 Jan 2025 19:17:52 +0100 Subject: [PATCH] - split WebLCD implementation - use hostname instead of host IP in WebLCD URLs - adds duplicate lines log filter - retry MODBUS connect on failure - incr. MODBUS TCP timeout for Lorings --- src/artisanlib/canvas.py | 42 ++++---- src/artisanlib/curves.py | 13 ++- src/artisanlib/main.py | 28 ++++- src/artisanlib/modbusport.py | 2 +- src/artisanlib/weblcds.py | 100 +++++++++++------- src/includes/Machines/Loring/Smart_Roast.aset | 2 + .../Machines/Loring/Smart_Roast_Auto.aset | 2 + src/requirements-dev.txt | 2 +- 8 files changed, 122 insertions(+), 69 deletions(-) diff --git a/src/artisanlib/canvas.py b/src/artisanlib/canvas.py index 5ef27b483..84b94063c 100644 --- a/src/artisanlib/canvas.py +++ b/src/artisanlib/canvas.py @@ -3290,29 +3290,29 @@ def event_popup_action(self, action:QAction) -> None: pass def updateWebLCDs(self, bt:Optional[str] = None, et:Optional[str] = None, time:Optional[str] = None, alertTitle:Optional[str] = None, alertText:Optional[str] = None, alertTimeout:Optional[int] = None) -> None: - try: - payload:Dict[str,Dict[str,Union[str,int]]] = {'data': {}} - if not (bt is None and et is None) and self.flagon and not self.flagstart: - # in monitoring only mode, timer might be set by PID RS - time = None - if bt is not None: - payload['data']['bt'] = bt - if et is not None: - payload['data']['et'] = et - if time is not None: - payload['data']['time'] = time - if alertText is not None: - payload['alert'] = {} - payload['alert']['text'] = alertText - if alertTitle: - payload['alert']['title'] = alertTitle - if alertTimeout: - payload['alert']['timeout'] = alertTimeout - if self.aw.weblcds_server is not None: + if self.aw.weblcds_server is not None: + try: + payload:Dict[str,Dict[str,Union[str,int]]] = {'data': {}} + if not (bt is None and et is None) and self.flagon and not self.flagstart: + # in monitoring only mode, timer might be set by PID RS + time = None + if bt is not None: + payload['data']['bt'] = bt + if et is not None: + payload['data']['et'] = et + if time is not None: + payload['data']['time'] = time + if alertText is not None: + payload['alert'] = {} + payload['alert']['text'] = alertText + if alertTitle: + payload['alert']['title'] = alertTitle + if alertTimeout: + payload['alert']['timeout'] = alertTimeout from json import dumps as json_dumps self.aw.weblcds_server.send_msg(json_dumps(payload, indent=None, separators=(',', ':'))) - except Exception as e: # pylint: disable=broad-except - _log.exception(e) + except Exception as e: # pylint: disable=broad-except + _log.exception(e) # note that partial values might be given here (time might update, but not the values) @pyqtSlot(str,str,str) diff --git a/src/artisanlib/curves.py b/src/artisanlib/curves.py index a03c3e6a1..671472260 100644 --- a/src/artisanlib/curves.py +++ b/src/artisanlib/curves.py @@ -1565,11 +1565,14 @@ def setWebLCDsURL(self) -> None: def getWebLCDsURL(self) -> str: import socket - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(('8.8.8.8', 80)) - localIP = s.getsockname()[0] - s.close() - return f'http://{str(localIP)}:{str(self.aw.WebLCDsPort)}/artisan' + # use Artisan's host IP address +# s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +# s.connect(('8.8.8.8', 80)) +# localIP = s.getsockname()[0] +# s.close() +# return f'http://{str(localIP)}:{str(self.aw.WebLCDsPort)}/artisan' + # use Artisan's host name (more stable over DHCP updates) + return f'http://{socket.gethostname().casefold()}:{str(self.aw.WebLCDsPort)}/artisan' @pyqtSlot(bool) def toggleWebLCDs(self, b:bool = False) -> None: diff --git a/src/artisanlib/main.py b/src/artisanlib/main.py index 40be21409..a210cca41 100644 --- a/src/artisanlib/main.py +++ b/src/artisanlib/main.py @@ -74,9 +74,10 @@ import signal signal.signal(signal.SIGINT, signal.SIG_DFL) +import zlib import logging.config from yaml import safe_load as yaml_load -from typing import Final, Optional, List, Dict, Tuple, Union, cast, Any, Callable, TYPE_CHECKING #for Python >= 3.9: can remove 'List' since type hints can now use the generic 'list' +from typing import Final, Optional, Mapping, List, Dict, Tuple, Union, cast, Any, Callable, TYPE_CHECKING #for Python >= 3.9: can remove 'List' since type hints can now use the generic 'list' from functools import reduce as freduce @@ -653,6 +654,7 @@ def permissionUpdated(permission:'QPermission') -> None: pass # configure logging + try: with open(os.path.join(getResourcePath(),'logging.yaml'), encoding='utf-8') as logging_conf: conf = yaml_load(logging_conf) @@ -669,6 +671,29 @@ def permissionUpdated(permission:'QPermission') -> None: logging.config.dictConfig(conf) except Exception: # pylint: disable=broad-except pass +class FilteredLogger(logging.Logger): + + def __init__(self, name:str, level:Any=logging.NOTSET) -> None: + super().__init__(name, level) + self._message_lockup: Dict[int,int] = {} + + def _log(self, level:int, msg:Any, args:Any, exc_info:Any=None, extra:Optional[Mapping[str, object]]=None, + stack_info:bool=False, stacklevel: int = 1) -> None: +# don't change signature for typing, but fix to log_interval=10 +# log_interval:Optional[int]=None) -> None: +# if log_interval is None or log_interval == 1: +# super()._log(level, msg, args, exc_info, extra, stack_info, stacklevel) +# else: + log_interval = 10 + message_Id = zlib.crc32(msg.encode('utf-8')) + if message_Id not in self._message_lockup: + self._message_lockup[message_Id] = 0 + super()._log(level, msg, args, exc_info, extra, stack_info, stacklevel) + elif self._message_lockup[message_Id] % log_interval == 0: + msg += f' -- Suppressed {log_interval} equal messages' + super()._log(level, msg, args, exc_info, extra, stack_info, stacklevel) + self._message_lockup[message_Id] += 1 +logging.setLoggerClass(FilteredLogger) _log: Final[logging.Logger] = logging.getLogger(__name__) @@ -4187,7 +4212,6 @@ def blockTicks(self) -> int: def setSamplingRate(self, rate:int) -> None: self.qmc.delay = max(self.qmc.min_delay, rate) self.sampling_ticks_to_block_quantifiction = self.blockTicks() # we update the quantification block ticks - _log.info('setSamplingRate(%s)', self.qmc.delay) @pyqtSlot() def updateMessageLog(self) -> None: diff --git a/src/artisanlib/modbusport.py b/src/artisanlib/modbusport.py index 8c065ca9c..60abc0d23 100644 --- a/src/artisanlib/modbusport.py +++ b/src/artisanlib/modbusport.py @@ -250,7 +250,7 @@ def formatMS(start:float, end:float) -> str: def connect(self) -> None: if self._asyncLoopThread is None: self._asyncLoopThread = AsyncLoopThread() - asyncio.run_coroutine_threadsafe(self.connect_async(), self._asyncLoopThread.loop).result() + asyncio.run_coroutine_threadsafe(self.connect_async(), self._asyncLoopThread.loop).result() async def connect_async(self) -> None: diff --git a/src/artisanlib/weblcds.py b/src/artisanlib/weblcds.py index cf3521cbe..02e6c7c41 100644 --- a/src/artisanlib/weblcds.py +++ b/src/artisanlib/weblcds.py @@ -31,14 +31,11 @@ _log: Final[logging.Logger] = logging.getLogger(__name__) -class WebLCDs: +class WebView: - __slots__ = [ '_loop', '_thread', '_app', '_port', '_last_send', '_min_send_interval', '_resource_path', '_nonesymbol', '_timecolor', - '_timebackground', '_btcolor', '_btbackground', '_etcolor', '_etbackground', - '_showetflag', '_showbtflag' ] + __slots__ = [ '_loop', '_thread', '_app', '_port', '_last_send', '_min_send_interval', '_resource_path', '_index_path', '_websocket_path' ] - def __init__(self, port:int, resource_path:str, nonesymbol:str, timecolor:str, timebackground:str, btcolor:str, - btbackground:str, etcolor:str, etbackground:str, showetflag:bool, showbtflag:bool) -> None: + def __init__(self, port:int, resource_path:str, index_path:str, websocket_path:str) -> None: self._loop: Optional[asyncio.AbstractEventLoop] = None # the asyncio loop self._thread: Optional[Thread] = None # the thread running the asyncio loop @@ -49,45 +46,25 @@ def __init__(self, port:int, resource_path:str, nonesymbol:str, timecolor:str, t self._last_send:float = time.time() # timestamp of the last message send to the clients self._min_send_interval:float = 0.03 + self._port: int = port + self._resource_path:str = resource_path + self._index_path:str = index_path + self._websocket_path:str = websocket_path + aiohttp_jinja2.setup(self._app, loader=jinja2.FileSystemLoader(resource_path)) self._app.add_routes([ - web.get('/artisan', self.index), - web.get('/websocket', self.websocket_handler), + web.get(f'/{self._index_path}', self.index), + web.get(f'/{self._websocket_path}', self.websocket_handler), web.static('/', resource_path, append_version=True) ]) - self._port: int = port - self._resource_path:str = resource_path - self._nonesymbol:str = nonesymbol - self._timecolor:str = timecolor - self._timebackground:str = timebackground - self._btcolor:str = btcolor - self._btbackground:str = btbackground - self._etcolor:str = etcolor - self._etbackground:str = etbackground - self._showetflag:bool = showetflag - self._showbtflag:bool = showbtflag - @aiohttp_jinja2.template('artisan.tpl') - async def index(self, _request: 'Request') -> Dict[str,str]: - showspace_str = 'inline' if not (self._showbtflag and self._showetflag) else 'none' - showbt_str = 'inline' if self._showbtflag else 'none' - showet_str = 'inline' if self._showetflag else 'none' - return { - 'port': str(self._port), - 'nonesymbol': self._nonesymbol, - 'timecolor': self._timecolor, - 'timebackground': self._timebackground, - 'btcolor': self._btcolor, - 'btbackground': self._btbackground, - 'etcolor': self._etcolor, - 'etbackground': self._etbackground, - 'showbt': showbt_str, - 'showet': showet_str, - 'showspace': showspace_str - } +# needs to be defined in subclass + @aiohttp_jinja2.template('empty.tpl') + async def index(self, _request: 'Request') -> Dict[str,str]: # pylint:disable=no-self-use + return {} async def send_msg_to_all(self, message:str) -> None: if 'websockets' in self._app and self._app['websockets'] is not None: @@ -160,7 +137,6 @@ def start_background_loop(loop: asyncio.AbstractEventLoop) -> None: loop.close() def startWeb(self) -> bool: - _log.info('start WebLCDs on port %s', self._port) try: self._loop = asyncio.new_event_loop() self._thread = Thread(target=self.start_background_loop, args=(self._loop,), daemon=True) @@ -173,7 +149,6 @@ def startWeb(self) -> bool: return False def stopWeb(self) -> None: - _log.info('stop WebLCDs') # _loop.stop() needs to be called as follows as the event loop class is not thread safe if self._loop is not None: self._loop.call_soon_threadsafe(self._loop.stop) @@ -182,3 +157,50 @@ def stopWeb(self) -> None: if self._thread is not None: self._thread.join() self._thread = None + + +class WebLCDs(WebView): + + __slots__ = [ '_nonesymbol', '_timecolor', '_timebackground', '_btcolor', '_btbackground', '_etcolor', '_etbackground', + '_showetflag', '_showbtflag' ] + + def __init__(self, port:int, resource_path:str, nonesymbol:str, timecolor:str, timebackground:str, btcolor:str, + btbackground:str, etcolor:str, etbackground:str, showetflag:bool, showbtflag:bool) -> None: + super().__init__(port, resource_path, 'artisan', 'websocket') + + self._nonesymbol:str = nonesymbol + self._timecolor:str = timecolor + self._timebackground:str = timebackground + self._btcolor:str = btcolor + self._btbackground:str = btbackground + self._etcolor:str = etcolor + self._etbackground:str = etbackground + self._showetflag:bool = showetflag + self._showbtflag:bool = showbtflag + + @aiohttp_jinja2.template('artisan.tpl') + async def index(self, _request: 'Request') -> Dict[str,str]: + showspace_str = 'inline' if not (self._showbtflag and self._showetflag) else 'none' + showbt_str = 'inline' if self._showbtflag else 'none' + showet_str = 'inline' if self._showetflag else 'none' + return { + 'port': str(self._port), + 'nonesymbol': self._nonesymbol, + 'timecolor': self._timecolor, + 'timebackground': self._timebackground, + 'btcolor': self._btcolor, + 'btbackground': self._btbackground, + 'etcolor': self._etcolor, + 'etbackground': self._etbackground, + 'showbt': showbt_str, + 'showet': showet_str, + 'showspace': showspace_str + } + + def startWeb(self) -> bool: + _log.info('start WebLCDs on port %s', self._port) + return super().startWeb() + + def stopWeb(self) -> None: + _log.info('stop WebLCDs') + super().stopWeb() diff --git a/src/includes/Machines/Loring/Smart_Roast.aset b/src/includes/Machines/Loring/Smart_Roast.aset index 12420e5c6..526d83f9d 100644 --- a/src/includes/Machines/Loring/Smart_Roast.aset +++ b/src/includes/Machines/Loring/Smart_Roast.aset @@ -138,6 +138,8 @@ type=3 wordorderLittle=true optimizer=true fetch_max_blocks=true +IP_retries=1 +IP_timeout=0.3 [Quantifiers] clusterEventsFlag=false diff --git a/src/includes/Machines/Loring/Smart_Roast_Auto.aset b/src/includes/Machines/Loring/Smart_Roast_Auto.aset index bbfd73a22..5a9673e5d 100644 --- a/src/includes/Machines/Loring/Smart_Roast_Auto.aset +++ b/src/includes/Machines/Loring/Smart_Roast_Auto.aset @@ -139,6 +139,8 @@ type=3 wordorderLittle=true optimizer=true fetch_max_blocks=true +IP_timeout=0.3 +IP_retries=1 [Quantifiers] clusterEventsFlag=false diff --git a/src/requirements-dev.txt b/src/requirements-dev.txt index 910719054..e36114f34 100644 --- a/src/requirements-dev.txt +++ b/src/requirements-dev.txt @@ -25,7 +25,7 @@ pytest-cov==6.0.0 #pytest-bdd==6.1.1 #pytest-benchmark==4.0.0 #pytest-mock==3.11.1 -hypothesis>=6.123.13 +hypothesis>=6.123.17 coverage>=7.6.10 coverage-badge==1.1.2 codespell==2.3.0