From d9c494b714e29b3b4c3df105b4c7ae111e8d2c01 Mon Sep 17 00:00:00 2001 From: Gazoodle Date: Thu, 17 Mar 2022 06:46:20 +0000 Subject: [PATCH 1/3] Bump version --- README.md | 2 ++ src/geckolib/_version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 753ed23..fe20de7 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,8 @@ https://www.gnu.org/licenses/gpl-3.0.html is busy and the CUI won't exit until the timeout has been reached (this can be reproduced by making the simulator stop responding to watercare requests) +## Done/Fixed in 0.4.4 + ## Done/Fixed in 0.4.2 - Fixed processor getting pegged at 100% but not using asyncio.sleep(0) diff --git a/src/geckolib/_version.py b/src/geckolib/_version.py index eb626ae..626ddeb 100644 --- a/src/geckolib/_version.py +++ b/src/geckolib/_version.py @@ -1,3 +1,3 @@ """ Single module version """ -VERSION = "0.4.2" +VERSION = "0.4.4" From f0dcd4e6bcc6c83ec32643ec62ffbe0d82bdcb87 Mon Sep 17 00:00:00 2001 From: Gazoodle Date: Thu, 17 Mar 2022 07:57:03 +0000 Subject: [PATCH 2/3] Move some const to config class --- sample/abstract_display.py | 5 +-- sample/complete.py | 2 +- sample/{context.py => context_sample.py} | 0 sample/cui.py | 5 ++- src/geckolib/async_locator.py | 5 +-- src/geckolib/async_spa.py | 19 +++++----- src/geckolib/async_spa_manager.py | 2 +- src/geckolib/async_tasks.py | 9 ++--- src/geckolib/automation/async_facade.py | 5 +-- src/geckolib/automation/facade.py | 8 ++--- src/geckolib/config.py | 46 ++++++++++++++++++++++++ src/geckolib/const.py | 14 +------- src/geckolib/driver/protocol/ping.py | 2 +- src/geckolib/locator.py | 7 ++-- src/geckolib/spa.py | 5 +-- 15 files changed, 90 insertions(+), 44 deletions(-) rename sample/{context.py => context_sample.py} (100%) create mode 100644 src/geckolib/config.py diff --git a/sample/abstract_display.py b/sample/abstract_display.py index 5a07e5d..00a460e 100644 --- a/sample/abstract_display.py +++ b/sample/abstract_display.py @@ -1,9 +1,10 @@ -""" Abstract curses display class for use in asyncio app - Thanks to https://gist.github.com/davesteele/8838f03e0594ef11c89f77a7bca91206 """ +""" Abstract curses display class for use in asyncio app - Thanks +to https://gist.github.com/davesteele/8838f03e0594ef11c89f77a7bca91206 """ import asyncio from abc import ABC, abstractmethod from curses import ERR, KEY_RESIZE, curs_set -from context import GeckoConstants # type: ignore +from context_sample import GeckoConstants # type: ignore import _curses diff --git a/sample/complete.py b/sample/complete.py index 8928470..bc20342 100755 --- a/sample/complete.py +++ b/sample/complete.py @@ -20,7 +20,7 @@ from cui import CUI from curses import wrapper -from context import GeckoConstants # type: ignore +from context_sample import GeckoConstants # type: ignore _LOGGER = logging.getLogger(__name__) diff --git a/sample/context.py b/sample/context_sample.py similarity index 100% rename from sample/context.py rename to sample/context_sample.py diff --git a/sample/cui.py b/sample/cui.py index 6657a36..c094579 100644 --- a/sample/cui.py +++ b/sample/cui.py @@ -15,7 +15,7 @@ from datetime import datetime from abstract_display import AbstractDisplay from config import Config -from context import ( # type: ignore +from context_sample import ( # type: ignore GeckoAsyncSpaMan, GeckoSpaEvent, GeckoAsyncSpaDescriptor, @@ -94,6 +94,8 @@ async def _clear_spa(self): await self.async_set_spa_info(None, None, None) async def _select_next_watercare_mode(self) -> None: + assert self.facade is not None + assert self.facade.water_care is not None new_mode = (self.facade.water_care.active_mode + 1) % len( GeckoConstants.WATERCARE_MODE ) @@ -126,6 +128,7 @@ def make_display(self) -> None: if self._can_use_facade: + assert self.facade is not None lines.append(f"{self.facade.name} is ready") lines.append("") lines.append(f"{self.facade.water_heater}") diff --git a/src/geckolib/async_locator.py b/src/geckolib/async_locator.py index f4c8171..c6357dc 100644 --- a/src/geckolib/async_locator.py +++ b/src/geckolib/async_locator.py @@ -11,6 +11,7 @@ GeckoAsyncUdpProtocol, ) from .const import GeckoConstants +from .config import GeckoConfig from .async_spa_descriptor import GeckoAsyncSpaDescriptor from .spa_events import GeckoSpaEvent from .driver import Observable @@ -95,7 +96,7 @@ def spas(self) -> Optional[List[GeckoAsyncSpaDescriptor]]: @property def has_had_enough_time(self) -> bool: - return self.age > GeckoConstants.DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS + return self.age > GeckoConfig.DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS @property def is_running(self) -> bool: @@ -142,7 +143,7 @@ async def discover(self) -> None: self._started = time.monotonic() self._on_change(self) - while self.age < GeckoConstants.DISCOVERY_TIMEOUT_IN_SECONDS: + while self.age < GeckoConfig.DISCOVERY_TIMEOUT_IN_SECONDS: if self.has_had_enough_time: if len(self._spas) > 0: _LOGGER.info("Found %d spas ... %s", len(self._spas), self._spas) diff --git a/src/geckolib/async_spa.py b/src/geckolib/async_spa.py index 34419bd..d084c80 100644 --- a/src/geckolib/async_spa.py +++ b/src/geckolib/async_spa.py @@ -13,6 +13,7 @@ from .spa_events import GeckoSpaEvent from .const import GeckoConstants +from .config import GeckoConfig from .driver import ( GeckoAsyncUdpProtocol, GeckoPacketProtocolHandler, @@ -389,7 +390,7 @@ def is_responding_to_pings(self) -> bool: # are successful return ( time.monotonic() - self._last_ping - ) < GeckoConstants.PING_FREQUENCY_IN_SECONDS * 2 + ) < GeckoConfig.PING_FREQUENCY_IN_SECONDS * 2 async def _async_on_packet( self, handler: GeckoPacketProtocolHandler, sender: tuple @@ -419,10 +420,7 @@ async def _ping_loop(self) -> None: assert self._protocol is not None ping_handler = await self._protocol.get( - lambda: GeckoPingProtocolHandler.request( - parms=self.sendparms, - timeout=GeckoConstants.PING_FREQUENCY_IN_SECONDS, - ), + lambda: GeckoPingProtocolHandler.request(parms=self.sendparms), None, 1, ) @@ -442,14 +440,19 @@ async def _ping_loop(self) -> None: ) if ( time.monotonic() - self._last_ping - > GeckoConstants.PING_DEVICE_NOT_RESPONDING_TIMEOUT + > GeckoConfig.PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS ): await self._event_handler( GeckoSpaEvent.RUNNING_PING_NO_RESPONSE, last_ping_at=self._last_ping_at, ) - await asyncio.sleep(GeckoConstants.PING_FREQUENCY_IN_SECONDS) + # Ping every couple of seconds until we have had a response + await asyncio.sleep( + 2 + if self._last_ping_at is None + else GeckoConfig.PING_FREQUENCY_IN_SECONDS + ) except asyncio.CancelledError: _LOGGER.debug("Ping loop cancelled") @@ -476,7 +479,7 @@ async def _refresh_loop(self) -> None: _LOGGER.debug("Refresh loop started") while self.isopen: - await asyncio.sleep(GeckoConstants.SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS) + await asyncio.sleep(GeckoConfig.SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS) if not self.is_connected: continue if not self.is_responding_to_pings: diff --git a/src/geckolib/async_spa_manager.py b/src/geckolib/async_spa_manager.py index 18d915a..3b8c136 100644 --- a/src/geckolib/async_spa_manager.py +++ b/src/geckolib/async_spa_manager.py @@ -48,7 +48,7 @@ def __init__(self, spaman: GeckoAsyncSpaMan) -> None: self._spaman: GeckoAsyncSpaMan = spaman assert self._spaman._spa is not None self._spaman._spa.watch(self._on_spa_change) - self._last_ping_at: Optional[datetime] = None + self._last_ping_at: Optional[datetime] = self._spaman._spa.last_ping_at @property def state(self): diff --git a/src/geckolib/async_tasks.py b/src/geckolib/async_tasks.py index 710385a..3c901a2 100644 --- a/src/geckolib/async_tasks.py +++ b/src/geckolib/async_tasks.py @@ -2,7 +2,7 @@ import logging import asyncio -from .const import GeckoConstants +from .config import GeckoConfig _LOGGER = logging.getLogger(__name__) @@ -41,8 +41,7 @@ async def gather(self) -> None: async def _tidy(self) -> None: try: while True: - # Run every five seconds - await asyncio.sleep(GeckoConstants.TASK_TIDY_FREQUENCY_IN_SECONDS) + await asyncio.sleep(GeckoConfig.TASK_TIDY_FREQUENCY_IN_SECONDS) if _LOGGER.isEnabledFor(logging.DEBUG): for task in self._tasks: if task.done(): @@ -54,10 +53,12 @@ async def _tidy(self) -> None: @property def unique_id(self) -> str: - """Dummy function designed to be overridden""" + """Dummy function designed to be overridden.""" + # TODO: Find a better way, this isn't functionality that the task manager needs return "" @property def spa_name(self) -> str: """Dummy function designed to be overridden""" + # TODO: Find a better way, this isn't functionality that the task manager needs return "" diff --git a/src/geckolib/automation/async_facade.py b/src/geckolib/automation/async_facade.py index e95d3f2..37b5727 100644 --- a/src/geckolib/automation/async_facade.py +++ b/src/geckolib/automation/async_facade.py @@ -9,6 +9,7 @@ from .blower import GeckoBlower from ..const import GeckoConstants +from ..config import GeckoConfig from .heater import GeckoWaterHeater from .keypad import GeckoKeypad from .light import GeckoLight @@ -82,9 +83,9 @@ async def _facade_update(self) -> None: finally: wait_time = ( - GeckoConstants.FACADE_UPDATE_FREQUENCY_IN_SECONDS + GeckoConfig.FACADE_UPDATE_FREQUENCY_IN_SECONDS if self._spa.is_responding_to_pings - else GeckoConstants.PING_FREQUENCY_IN_SECONDS + else GeckoConfig.PING_FREQUENCY_IN_SECONDS ) await asyncio.sleep(wait_time) diff --git a/src/geckolib/automation/facade.py b/src/geckolib/automation/facade.py index ba8bb1d..bc3d259 100644 --- a/src/geckolib/automation/facade.py +++ b/src/geckolib/automation/facade.py @@ -5,6 +5,7 @@ from .blower import GeckoBlower from ..const import GeckoConstants +from ..config import GeckoConfig from .heater import GeckoWaterHeater from .keypad import GeckoKeypad from .light import GeckoLight @@ -170,8 +171,7 @@ def scan_outputs(self): ] self._lights = [ - GeckoLight(self, device["device"], - GeckoConstants.DEVICES[device["device"]]) + GeckoLight(self, device["device"], GeckoConstants.DEVICES[device["device"]]) for device in self.actual_user_devices if GeckoConstants.DEVICES[device["device"]][3] == GeckoConstants.DEVICE_CLASS_LIGHT @@ -298,7 +298,7 @@ def devices(self): @property def reminders(self): - """ Get the reminders list """ + """Get the reminders list""" remi = self._reminders.reminders if remi is None: return [] @@ -319,6 +319,6 @@ def _update_thread_func(self): self.spa.wait(0.1) continue - self.spa.wait(GeckoConstants.FACADE_UPDATE_FREQUENCY_IN_SECONDS) + self.spa.wait(GeckoConfig.FACADE_UPDATE_FREQUENCY_IN_SECONDS) logger.info("Facade update thread finished") diff --git a/src/geckolib/config.py b/src/geckolib/config.py new file mode 100644 index 0000000..1099006 --- /dev/null +++ b/src/geckolib/config.py @@ -0,0 +1,46 @@ +"""Configuration management for geckolib""" + + +class _GeckoConfigManager: + """Configuration manangement class""" + + @property + def DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS(self): + """Mininum time in seconds to wait for initial spa discovery even + if one spa has responded""" + return 4 + + @property + def DISCOVERY_TIMEOUT_IN_SECONDS(self): + """Maximum time in seconds to wait for full discovery if no spas + have responded""" + return 10 + + @property + def TASK_TIDY_FREQUENCY_IN_SECONDS(self): + """Time in seconds between task tidyup checks""" + return 5 + + @property + def PING_FREQUENCY_IN_SECONDS(self): + """Frequency in seconds to ping the spa to ensure it is still available""" + return 10 + + @property + def PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS(self): + """Time after which a spa is deemed to be not responding to pings""" + return 10 + + @property + def FACADE_UPDATE_FREQUENCY_IN_SECONDS(self): + """Frequency in seconds to update facade data""" + return 30 + + @property + def SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS(self): + """Frequency in seconds to request all LOG data from spa""" + return 30 + + +# Root config +GeckoConfig = _GeckoConfigManager() diff --git a/src/geckolib/const.py b/src/geckolib/const.py index c6e530a..a206e69 100644 --- a/src/geckolib/const.py +++ b/src/geckolib/const.py @@ -10,21 +10,9 @@ class GeckoConstants: """ INTOUCH2_PORT = 10022 - MAX_PACKET_SIZE = 8192 - # Mininum time to wait for initial spa discovery even if one spa has responded - DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS = 4 - # Maximum time to wait for full discovery if no spas have responded - DISCOVERY_TIMEOUT_IN_SECONDS = 10 # Maximum time to wait for full connection for a responding spa CONNECTION_TIMEOUT_IN_SECONDS = 45 - # Time between task tidyup checks - TASK_TIDY_FREQUENCY_IN_SECONDS = 5 - PING_TIMEOUT_IN_SECONDS = 4 - PING_FREQUENCY_IN_SECONDS = 2 - PING_DEVICE_NOT_RESPONDING_TIMEOUT = 10 - FACADE_UPDATE_FREQUENCY_IN_SECONDS = 30 - FACADE_HEALTH_MONITOR_DUTY_CYCLE_IN_SECONDS = 10 - SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS = 30 + CONNECTION_STEP_PAUSE_IN_SECONDS = 0 # Time between connection steps MAX_RF_ERRORS_BEFORE_HALT = 50 ASYNCIO_SLEEP_TIMEOUT_FOR_YIELD = 0.001 diff --git a/src/geckolib/driver/protocol/ping.py b/src/geckolib/driver/protocol/ping.py index a526658..4b8756b 100644 --- a/src/geckolib/driver/protocol/ping.py +++ b/src/geckolib/driver/protocol/ping.py @@ -13,7 +13,7 @@ class GeckoPingProtocolHandler(GeckoPacketProtocolHandler): @staticmethod def request(**kwargs): - return GeckoPingProtocolHandler(content=PING_VERB, **kwargs) + return GeckoPingProtocolHandler(content=PING_VERB, timeout=2, **kwargs) @staticmethod def response(**kwargs): diff --git a/src/geckolib/locator.py b/src/geckolib/locator.py index ce0ee68..397076e 100644 --- a/src/geckolib/locator.py +++ b/src/geckolib/locator.py @@ -9,6 +9,7 @@ GeckoHelloProtocolHandler, ) from .const import GeckoConstants +from .config import GeckoConfig from .spa_descriptor import GeckoSpaDescriptor @@ -79,7 +80,7 @@ def age(self): @property def has_had_enough_time(self): - return self.age > GeckoConstants.DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS + return self.age > GeckoConfig.DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS def wait(self, timeout): self._socket.wait(timeout) @@ -97,7 +98,7 @@ def start_discovery(self, should_wait=False): if should_wait: try: - while self.age < GeckoConstants.DISCOVERY_TIMEOUT_IN_SECONDS: + while self.age < GeckoConfig.DISCOVERY_TIMEOUT_IN_SECONDS: if self.has_had_enough_time: if len(self.spas) > 0: _LOGGER.info( @@ -114,7 +115,7 @@ def _retry_thread_func(self): _LOGGER.debug("Locator retry thread started") while self._socket.isopen: # Only broadcast for the full discovery time - if self.age < GeckoConstants.DISCOVERY_TIMEOUT_IN_SECONDS: + if self.age < GeckoConfig.DISCOVERY_TIMEOUT_IN_SECONDS: self._socket.queue_send( GeckoHelloProtocolHandler.broadcast(), GeckoHelloProtocolHandler.broadcast_address( diff --git a/src/geckolib/spa.py b/src/geckolib/spa.py index f44331e..54254d7 100644 --- a/src/geckolib/spa.py +++ b/src/geckolib/spa.py @@ -6,6 +6,7 @@ import importlib from .const import GeckoConstants +from .config import GeckoConfig from .driver import ( GeckoUdpSocket, GeckoHelloProtocolHandler, @@ -273,10 +274,10 @@ def _ping_thread_func(self): while self.isopen: self.queue_send(self._ping_handler, self.sendparms) self.refresh() - self.wait(GeckoConstants.PING_FREQUENCY_IN_SECONDS) + self.wait(GeckoConfig.PING_FREQUENCY_IN_SECONDS) if ( time.monotonic() - self._last_ping - > GeckoConstants.PING_DEVICE_NOT_RESPONDING_TIMEOUT + > GeckoConfig.PING_DEVICE_NOT_RESPONDING_TIMEOUT ): logger.warning( # TODO From 77cfaa7df799733bc5a7bcbd1cd37cfbd9a0c6a3 Mon Sep 17 00:00:00 2001 From: Gazoodle Date: Wed, 23 Mar 2022 09:04:50 +0000 Subject: [PATCH 3/3] Active & Idle configuration settings added Facade controls which config mode to use --- README.md | 10 +++ sample/cui.py | 25 ++++-- src/geckolib/__init__.py | 3 + src/geckolib/async_spa.py | 6 +- src/geckolib/async_tasks.py | 4 +- src/geckolib/automation/async_facade.py | 22 ++++- src/geckolib/automation/pump.py | 18 ++-- src/geckolib/config.py | 108 ++++++++++++++++-------- src/geckolib/spa.py | 2 +- 9 files changed, 146 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index fe20de7..fda9955 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,16 @@ https://www.gnu.org/licenses/gpl-3.0.html ## Done/Fixed in 0.4.4 +- Moved config settings out of const class into their own class +- Added idle/active config settings and task loop for library to switch between + idle and active config settings. An idle spa is one that is currently not processing + any user demands, an active spa is one that has received a client request such as to + change temp or turn on a pump. Active mode will stay on while any user demand is + currently live. +- Replaced asyncio.sleep in various places with config_sleep which is aware of when + the configuration values have changed which means that the loops currently waiting on + these values stop waiting and can collect the new values. + ## Done/Fixed in 0.4.2 - Fixed processor getting pegged at 100% but not using asyncio.sleep(0) diff --git a/sample/cui.py b/sample/cui.py index c094579..cc53af2 100644 --- a/sample/cui.py +++ b/sample/cui.py @@ -20,6 +20,7 @@ GeckoSpaEvent, GeckoAsyncSpaDescriptor, GeckoConstants, + GeckoConfig, ) from typing import Optional @@ -189,11 +190,25 @@ def make_display(self) -> None: lines.append("") if self._can_use_facade: - lines.append("Press 'b' to toggle blower") - if self.facade.blowers[0].is_on: - self._commands["b"] = self.facade.blowers[0].async_turn_off - else: - self._commands["b"] = self.facade.blowers[0].async_turn_on + assert self.facade is not None + if self.facade.blowers: + lines.append("Press 'b' to toggle blower") + if self.facade.blowers[0].is_on: + self._commands["b"] = self.facade.blowers[0].async_turn_off + else: + self._commands["b"] = self.facade.blowers[0].async_turn_on + if self.facade.pumps: + lines.append("Press 'p' to toggle pump 1") + if self.facade.pumps[0].mode == "OFF": + self._commands["p"] = ( + self.facade.pumps[0].async_set_mode, + "HI", + ) + else: + self._commands["p"] = ( + self.facade.pumps[0].async_set_mode, + "OFF", + ) lines.append("Press '+' to increase setpoint") self._commands["+"] = self.increase_temp diff --git a/src/geckolib/__init__.py b/src/geckolib/__init__.py index 252feba..b26c80a 100644 --- a/src/geckolib/__init__.py +++ b/src/geckolib/__init__.py @@ -4,6 +4,7 @@ from .async_tasks import AsyncTasks from .const import GeckoConstants +from .config import GeckoConfig from .automation import ( GeckoAutomationBase, GeckoAutomationFacadeBase, @@ -84,6 +85,8 @@ "GeckoReminders", # From constants "GeckoConstants", + # From config + "GeckoConfig", # From facade "GeckoFacade", # From locator diff --git a/src/geckolib/async_spa.py b/src/geckolib/async_spa.py index d084c80..da42a2d 100644 --- a/src/geckolib/async_spa.py +++ b/src/geckolib/async_spa.py @@ -13,7 +13,7 @@ from .spa_events import GeckoSpaEvent from .const import GeckoConstants -from .config import GeckoConfig +from .config import GeckoConfig, config_sleep from .driver import ( GeckoAsyncUdpProtocol, GeckoPacketProtocolHandler, @@ -448,7 +448,7 @@ async def _ping_loop(self) -> None: ) # Ping every couple of seconds until we have had a response - await asyncio.sleep( + await config_sleep( 2 if self._last_ping_at is None else GeckoConfig.PING_FREQUENCY_IN_SECONDS @@ -479,7 +479,7 @@ async def _refresh_loop(self) -> None: _LOGGER.debug("Refresh loop started") while self.isopen: - await asyncio.sleep(GeckoConfig.SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS) + await config_sleep(GeckoConfig.SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS) if not self.is_connected: continue if not self.is_responding_to_pings: diff --git a/src/geckolib/async_tasks.py b/src/geckolib/async_tasks.py index 3c901a2..101b509 100644 --- a/src/geckolib/async_tasks.py +++ b/src/geckolib/async_tasks.py @@ -2,7 +2,7 @@ import logging import asyncio -from .config import GeckoConfig +from .config import GeckoConfig, config_sleep _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ async def gather(self) -> None: async def _tidy(self) -> None: try: while True: - await asyncio.sleep(GeckoConfig.TASK_TIDY_FREQUENCY_IN_SECONDS) + await config_sleep(GeckoConfig.TASK_TIDY_FREQUENCY_IN_SECONDS) if _LOGGER.isEnabledFor(logging.DEBUG): for task in self._tasks: if task.done(): diff --git a/src/geckolib/automation/async_facade.py b/src/geckolib/automation/async_facade.py index 37b5727..834f026 100644 --- a/src/geckolib/automation/async_facade.py +++ b/src/geckolib/automation/async_facade.py @@ -9,7 +9,7 @@ from .blower import GeckoBlower from ..const import GeckoConstants -from ..config import GeckoConfig +from ..config import GeckoConfig, config_sleep, set_config_mode from .heater import GeckoWaterHeater from .keypad import GeckoKeypad from .light import GeckoLight @@ -58,10 +58,20 @@ def __init__(self, spa: GeckoAsyncSpa, taskman: AsyncTasks, **kwargs: str) -> No # Install change notifications for device in self.all_automation_devices: device.watch(self._on_change) + # And notifications for active/idle items + for device in self.all_config_change_devices: + device.watch(self._on_config_device_change) self._taskman.add_task(self._facade_update(), "Facade update", "FACADE") self._ready = False + def _on_config_device_change(self, *args) -> None: + active_mode = False + for device in self.all_config_change_devices: + if device.is_on: # type: ignore + active_mode = True + set_config_mode(active_mode) + async def _facade_update(self) -> None: _LOGGER.debug("Facade update task started") try: @@ -77,6 +87,7 @@ async def _facade_update(self) -> None: self._reminders_manager.change_reminders( await self._spa.async_get_reminders() ) + self._on_config_device_change() # After we've been round here at least once, we're ready self._ready = True @@ -87,7 +98,7 @@ async def _facade_update(self) -> None: if self._spa.is_responding_to_pings else GeckoConfig.PING_FREQUENCY_IN_SECONDS ) - await asyncio.sleep(wait_time) + await config_sleep(wait_time) except asyncio.CancelledError: _LOGGER.debug("Facade update loop cancelled") @@ -282,7 +293,12 @@ def eco_mode(self) -> Optional[GeckoSwitch]: @property def all_user_devices(self) -> List[GeckoAutomationBase]: """Get all the user controllable devices as a list""" - return self._pumps + self._blowers + self._lights # type:ignore + return self._pumps + self._blowers + self._lights # type: ignore + + @property + def all_config_change_devices(self) -> List[GeckoAutomationBase]: + """Get all devices that can cause config change""" + return self._pumps + self._blowers # type: ignore @property def all_automation_devices(self) -> List[GeckoAutomationBase]: diff --git a/src/geckolib/automation/pump.py b/src/geckolib/automation/pump.py index 9af3033..9929ee3 100644 --- a/src/geckolib/automation/pump.py +++ b/src/geckolib/automation/pump.py @@ -2,10 +2,11 @@ import logging +from ..const import GeckoConstants from .base import GeckoAutomationFacadeBase from .sensors import GeckoSensor -logger = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) class GeckoPump(GeckoAutomationFacadeBase): @@ -23,6 +24,13 @@ def __init__(self, facade, key, props, user_demand): self.device_class = props[3] self._user_demand = user_demand + @property + def is_on(self): + """True if the device is running, False otherwise""" + if self._state_sensor.accessor.type == GeckoConstants.SPA_PACK_STRUCT_BOOL_TYPE: + return self._state_sensor.state + return self._state_sensor.state != "OFF" + @property def modes(self): return self._user_demand["options"] @@ -33,21 +41,21 @@ def mode(self): def set_mode(self, mode): try: - logger.debug("%s set mode %s", self.name, mode) + _LOGGER.debug("%s set mode %s", self.name, mode) self.facade.spa.accessors[self._user_demand["demand"]].value = mode except Exception: # pylint: disable=broad-except - logger.exception( + _LOGGER.exception( "Exception handling setting %s=%s", self._user_demand["demand"], mode ) async def async_set_mode(self, mode): try: - logger.debug("%s async set mode %s", self.name, mode) + _LOGGER.debug("%s async set mode %s", self.name, mode) await self.facade.spa.accessors[ self._user_demand["demand"] ].async_set_value(mode) except Exception: # pylint: disable=broad-except - logger.exception( + _LOGGER.exception( "Exception handling setting %s=%s", self._user_demand["demand"], mode ) diff --git a/src/geckolib/config.py b/src/geckolib/config.py index 1099006..b1a950c 100644 --- a/src/geckolib/config.py +++ b/src/geckolib/config.py @@ -1,46 +1,88 @@ """Configuration management for geckolib""" +import asyncio +from dataclasses import dataclass +import logging +from typing import Optional -class _GeckoConfigManager: - """Configuration manangement class""" +_LOGGER = logging.getLogger(__name__) - @property - def DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS(self): - """Mininum time in seconds to wait for initial spa discovery even - if one spa has responded""" - return 4 - @property - def DISCOVERY_TIMEOUT_IN_SECONDS(self): - """Maximum time in seconds to wait for full discovery if no spas - have responded""" - return 10 +@dataclass +class _GeckoConfig: + DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS = 1 + """Mininum time in seconds to wait for initial spa discovery even + if one spa has responded""" - @property - def TASK_TIDY_FREQUENCY_IN_SECONDS(self): - """Time in seconds between task tidyup checks""" - return 5 + DISCOVERY_TIMEOUT_IN_SECONDS = 1 + """Maximum time in seconds to wait for full discovery if no spas + have responded""" - @property - def PING_FREQUENCY_IN_SECONDS(self): - """Frequency in seconds to ping the spa to ensure it is still available""" - return 10 + TASK_TIDY_FREQUENCY_IN_SECONDS = 1 + """Time in seconds between task tidyup checks""" - @property - def PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS(self): - """Time after which a spa is deemed to be not responding to pings""" - return 10 + PING_FREQUENCY_IN_SECONDS = 1 + """Frequency in seconds to ping the spa to ensure it is still available""" - @property - def FACADE_UPDATE_FREQUENCY_IN_SECONDS(self): - """Frequency in seconds to update facade data""" - return 30 + PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS = 1 + """Time after which a spa is deemed to be not responding to pings""" - @property - def SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS(self): - """Frequency in seconds to request all LOG data from spa""" - return 30 + FACADE_UPDATE_FREQUENCY_IN_SECONDS = 1 + """Frequency in seconds to update facade data""" + + SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS = 1 + """Frequency in seconds to request all LOG data from spa""" + + +CONFIG_MEMBERS = [ + attr + for attr in dir(_GeckoConfig) + if not callable(getattr(_GeckoConfig, attr)) and not attr.startswith("__") +] + + +@dataclass +class _GeckoActiveConfig(_GeckoConfig): + """Gecko active configuration""" + + DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS = 4 + DISCOVERY_TIMEOUT_IN_SECONDS = 10 + TASK_TIDY_FREQUENCY_IN_SECONDS = 5 + PING_FREQUENCY_IN_SECONDS = 2 + PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS = 10 + FACADE_UPDATE_FREQUENCY_IN_SECONDS = 30 + SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS = 30 + + +@dataclass +class _GeckoIdleConfig(_GeckoConfig): + """Gecko idle configuration""" + + DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS = 4 + DISCOVERY_TIMEOUT_IN_SECONDS = 10 + TASK_TIDY_FREQUENCY_IN_SECONDS = 60 + PING_FREQUENCY_IN_SECONDS = 60 + PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS = 120 + FACADE_UPDATE_FREQUENCY_IN_SECONDS = 120 + SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS = 120 # Root config -GeckoConfig = _GeckoConfigManager() +GeckoConfig: _GeckoConfig = _GeckoIdleConfig() +ConfigChange: Optional[asyncio.Future] = None + + +def set_config_mode(active: bool) -> None: + """Set config mode to active (true) or idle (false).""" + new_config = _GeckoActiveConfig() if active else _GeckoIdleConfig() + for member in CONFIG_MEMBERS: + setattr(GeckoConfig, member, getattr(new_config, member)) + assert ConfigChange is not None + ConfigChange.set_result(True) + + +async def config_sleep(delay: float) -> None: + global ConfigChange + if ConfigChange is None or ConfigChange.done(): + ConfigChange = asyncio.get_running_loop().create_future() + await asyncio.wait([ConfigChange], timeout=delay) diff --git a/src/geckolib/spa.py b/src/geckolib/spa.py index 54254d7..e314cf3 100644 --- a/src/geckolib/spa.py +++ b/src/geckolib/spa.py @@ -277,7 +277,7 @@ def _ping_thread_func(self): self.wait(GeckoConfig.PING_FREQUENCY_IN_SECONDS) if ( time.monotonic() - self._last_ping - > GeckoConfig.PING_DEVICE_NOT_RESPONDING_TIMEOUT + > GeckoConfig.PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS ): logger.warning( # TODO