From 0cd1967a893b809e46158908295031546bbe632c Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sun, 5 Mar 2023 18:12:33 -0500 Subject: [PATCH] Support retrying failed requests and unique cliend_id (#45) * Initial implementation of retrying requests * Fix logging * Use async_make_request * Retry tests * Expose retry count to gateway async_send_message * Set max_retries on gateway * Variable CLIENT_ID (#43) * Support specifying client_id, otherwise random * Take client_id at gateway instantiation not host * Update `MockConnectedGateway` to late connect * Update tests * Update to late connect * Allow setting max_retries at instantiate * Changes to instantiate. Formatting. * Make changelogs lists * Wording. * Move .decode into getString * Additional message code notes * Reorder get/set * Fixes for python 3.8 * Account for 0 being falsy * Updates * Bump version to v0.8.0 --- README.md | 93 +++++++------- examples/gateway.py | 4 +- notes/12510_get_circuit_def.txt | 71 ++++++++++ notes/12518_get_circuit_config.txt | 11 ++ notes/12561_get_circuit_names.txt | 30 +++++ screenlogicpy/__init__.py | 2 +- screenlogicpy/client.py | 37 ++++-- screenlogicpy/const.py | 6 +- screenlogicpy/gateway.py | 192 ++++++++++++++++------------ screenlogicpy/requests/button.py | 3 +- screenlogicpy/requests/chemistry.py | 8 +- screenlogicpy/requests/client.py | 20 ++- screenlogicpy/requests/color.py | 20 --- screenlogicpy/requests/config.py | 14 +- screenlogicpy/requests/equipment.py | 4 +- screenlogicpy/requests/gateway.py | 8 +- screenlogicpy/requests/heat.py | 14 +- screenlogicpy/requests/lights.py | 28 +++- screenlogicpy/requests/login.py | 70 +++++----- screenlogicpy/requests/ping.py | 7 +- screenlogicpy/requests/protocol.py | 34 ++--- screenlogicpy/requests/pump.py | 4 +- screenlogicpy/requests/request.py | 56 ++++++-- screenlogicpy/requests/scg.py | 10 +- screenlogicpy/requests/status.py | 6 +- screenlogicpy/requests/utility.py | 41 +++--- setup.py | 2 +- tests/conftest.py | 20 ++- tests/fake_gateway.py | 30 ++++- tests/test_client.py | 30 +++-- tests/test_gateway.py | 129 ++++++++++++++++--- tests/test_login.py | 76 ++++++++++- 32 files changed, 762 insertions(+), 318 deletions(-) create mode 100644 notes/12510_get_circuit_def.txt create mode 100644 notes/12518_get_circuit_config.txt create mode 100644 notes/12561_get_circuit_names.txt delete mode 100644 screenlogicpy/requests/color.py diff --git a/README.md b/README.md index 8a92cc8..119814a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ $ pip install screenlogicpy # Library usage -_New in v0.5.0: The screenlogicpy library has moved over to using asyncio for all network I/O. Relevant methods now require the `async`/`await` syntax._ +* _Changed in v0.5.0: The screenlogicpy library has moved over to using asyncio for all network I/O. Relevant methods now require the `async`/`await` syntax._ +* _**New in v0.8.0**: Support for Python 3.8 and 3.9 is being phased out across future releases. This will be the last version to support Python 3.8._ The `ScreenLogicGateway` class is the primary interface. @@ -24,24 +25,22 @@ from screenlogicpy import ScreenLogicGateway gateway = ScreenLogicGateway() ``` -_Changed in v0.5.0: Instantiating the gateway no longer automatically connects to the protocol adapter or performs an initial update._ - -_**Changed in v0.7.0:** Passing adapter connection info when instantiating the gateway is deprecated and will be removed in a future release. Connection info should be passed to `async_connect()` instead._ +* _Changed in v0.5.0: Instantiating the gateway no longer automatically connects to the protocol adapter or performs an initial update._ +* _Changed in v0.7.0: Passing adapter connection info when instantiating the gateway is deprecated and will be removed in a future release. Connection info should be passed to `async_connect()` instead._ +* _**Changed in v0.8.0:** Support for passing connection info to gateway constructor is fully deprecated and has been removed. Ability to specify client id used for push subscriptions, and to specify maximum number of times to retry a request has replaced it._ ## Connecting to a ScreenLogic Protocol Adapter -Once instantiated, use `async_connect()` to connect and logon to the ScreenLogic protocol adapter. +Once instantiated, use `async_connect()` to connect and login to the ScreenLogic protocol adapter, and gather the pool configuration. + +If disconnected, this method may be called without any parameters to reconnect with the previous connection info, or with new parameters to connect to a different host. ```python success = await gateway.async_connect("192.168.x.x") ``` -This method also performs the initial polling of the pool controller configuration. -**Note:** This is the preferred location to provide connection information. - -_New in v0.5.0._ - -_**Changed in v0.7.0:** `async_connect()` now accepts adapter connection info. This supports handling ip changes to the protocol adapter._ +* _New in v0.5.0._ +* _Changed in v0.7.0: `async_connect()` now accepts adapter connection info. This supports handling ip changes to the protocol adapter._ ## Polling the pool state @@ -60,7 +59,7 @@ This update consists of sending requests for: **Warning:** This method is not rate-limited. The calling application is responsible for maintaining reasonable intervals between updates. The ScreenLogic protocol adapter may respond with an error message if too many requests are made too quickly. -_Changed in v0.5.0: This method is now an async coroutine and no longer disconnects from the protocol adapter after polling the data._ +* _Changed in v0.5.0: This method is now an async coroutine and no longer disconnects from the protocol adapter after polling the data._ ## Subscribing to pool state updates @@ -68,7 +67,7 @@ The preferred method for retrieving updated pool data is to subscribe to updates To enable push updates, subscribe to a particular message code using `gateway.async_subscribe_client(callback, message_code)`, passing a callback method to be called when that message is received, and the [message code](#supported-subscribable-messages) to subscribe to. This function returns a callback that can be called to unsubscribe that particular subscription. -`screenlogicpy` will automatically handle subscribing and unsubscribing as a client to the ScreenLogic protocol adapter upon the first callback subscription and last unsub respectively. +The gateway's `ClientManager` will automatically handle subscribing and unsubscribing as a client to the ScreenLogic protocol adapter upon the first callback subscription and last unsub respectively. ```python from screenlogicpy.const import CODE @@ -81,24 +80,24 @@ unsub_method = await gateway.async_subscribe_client(status_updated, CODE.STATUS_ Example in `./examples/async_client.py` -Multiple callbacks can be subscribed to a single message code. Additionally, a single global callback may be subscribed to multiple message codes. +Multiple callbacks can be subscribed to a single message code. Additionally, a single global callback may be subscribed to multiple message codes. **Note:** Each combination of callback and code will result in a separate unique unsub callback. The calling application is responsible for managing and unsubing all subscribed callbacks as needed. ### Pushed data -The ScreenLogic system does not make all state information for all equipment available via push messages. The two main state update messages that can be subscribed to are: +While the ScreenLogic system does support some push updates, not all state information for all equipment available via push. The two main state update messages that can be subscribed to are: -- General status update containing - - Air and water temperature and heater states - - Basic status indicators such as Freeze mode and active delays - - Circuit states - - Basic chemistry information -- IntelliChem controller status update containing - - Detailed chemistry information +* General status update containing + * Air and water temperature and heater states + * Basic status indicators such as Freeze mode and active delays + * Circuit states + * Basic chemistry information +* IntelliChem controller status update containing + * Detailed chemistry information The status of any pumps or salt chlorine generators is not included in any push updates. To supplement this, the different data sets can now be requested individually. -**_New in v0.7.0._** +* _New in v0.7.0._ ## Polling specific data @@ -121,7 +120,7 @@ await gateway.async_get_scg() Push subscriptions and polling of all or specific data can be used on their own or at the same time. **Warning:** Some expected data keys may not be present until a full update has been performed. It is recommended that an initial full `async_update()` be preformed to ensure the gateway's data `dict` is fully primed. -**_New in v0.7.0._** +* _New in v0.7.0._ ## Using the data @@ -133,15 +132,13 @@ data = gateway.get_data() ## Disconnecting -When done, use `async_disconnect()` to close the connection to the protocol adapter. +When done, use `async_disconnect()` to unsubscribe from push updates and close the connection to the protocol adapter. ```python await gateway.async_disconnect() ``` -_New in v0.5.0._ - ---- +* _New in v0.5.0._ ## Gateway Discovery @@ -153,7 +150,7 @@ The `discovery` module's `async_discover()` function can be used to get a list o hosts = await discovery.async_discover() ``` -_Changed in v0.5.0: This method is now an async coroutine._ +* _Changed in v0.5.0: This method is now an async coroutine._ Example in `./examples/async_discovery.py` @@ -179,12 +176,12 @@ Full example in `./examples/gateway.py` The following actions can be performed with methods on the `ScreenLogicGateway` object: -- Set a specific circuit to on or off -- Set a heating mode for a specific body of water (spa/pool) -- Set a target heating temperature for a specific body of water (spa/pool) -- Select various color-enabled lighting options -- Set the chlorinator output levels -- Setting IntelliChem chemistry values +* Set a specific circuit to on or off +* Set a heating mode for a specific body of water (spa/pool) +* Set a target heating temperature for a specific body of water (spa/pool) +* Select various color-enabled lighting options +* Set the chlorinator output levels +* Setting IntelliChem chemistry values Each method will `return True` if the operation reported no exceptions. **Note:** The methods do not confirm the requested action is now in effect on the pool controller. @@ -197,9 +194,7 @@ A circuit can be requested to be turned on or off with the `async_set_circuit()` success = await gateway.async_set_circuit(circuitID, circuitState) ``` -_Changed in v0.5.0: This method is now an async coroutine._ - ---- +* _Changed in v0.5.0: This method is now an async coroutine._ ## Setting a heating mode @@ -209,7 +204,7 @@ The desired heating mode can be set per body of water (pool or spa) with `async_ success = await gateway.async_set_heat_mode(body, mode) ``` -_Changed in v0.5.0: This method is now an async coroutine._ +* _Changed in v0.5.0: This method is now an async coroutine._ ## Setting a target temperature @@ -229,7 +224,7 @@ Colors or color-shows can be set for compatible color-enable lighting with `asyn success = await gateway.async_set_color_lights(light_command) ``` -_Changed in v0.5.0: This method is now an async coroutine._ +* _Changed in v0.5.0: This method is now an async coroutine._ ## Setting chlorinator output levels @@ -239,7 +234,7 @@ Chlorinator output levels can be set with `async_set_scg_config()`. `async_set_ success = await gateway.async_set_scg_config(pool_output, spa_output) ``` -_New in v0.5.0._ +* _New in v0.5.0._ ## Setting IntelliChem Chemistry values @@ -267,7 +262,7 @@ success = await gateway.async_set_chem_data(ph, orp, ch, ta, ca, sa) **Note:** Only `ph_setpoint` and `orp_setpoint` are settable through the command line. -_New in v0.6.0._ +* _New in v0.6.0._ ## Handling unsolicited messages @@ -276,8 +271,8 @@ To do so, you need to tell the `ScreenLogicGateway` what message code to listen **Notes:** -- Currently the `ScreenLogicGateway` must be connected to the protocol adapter before registering a handler. -- Registering a handler in this way does not subscribe the gateway to state updates from the ScreenLogic system. +* Currently the `ScreenLogicGateway` must be connected to the protocol adapter before registering a handler. +* Registering a handler in this way does not subscribe the gateway to push state updates from the ScreenLogic system. **Example:** @@ -301,7 +296,7 @@ gateway.remove_async_message_handler(WEATHER_UPDATE_CODE) Example in `./examples/async_listen.py` -**_New in v0.7.0._** +* _New in v0.7.0._ ## Debug Information @@ -312,7 +307,7 @@ A debug function is available in the `ScreenLogicGateway` class: `get_debug`. Th last_responses = gateway.get_debug() ``` -_New in v0.5.5._ +* _New in v0.5.5._ # Command line @@ -513,7 +508,7 @@ screenlogicpy set color-lights [color mode] Sets a color mode for all color-capable lights configured on the pool controller. **Note:** `[color mode]` can be either the `int` or `string` representation of a [color mode](#color-modes). -_New in v0.3.0._ +* _New in v0.3.0._ #### set `salt-generator, scg` @@ -524,7 +519,7 @@ screenlogicpy set salt-generator [pool_pct] [spa_pct] Sets the chlorinator output levels for the pool and spa. Pentair treats spa output level as a percentage of the pool's output level. **Note:** `[pool_pct]` can be an `int` between `0`-`100`, or `*` to keep the current value. `[spa_pct]` can be an `int` between `0`-`100`, or `*` to keep the current value. -_New in v0.5.0._ +* _New in v0.5.0._ #### set `chem-data, ch` @@ -535,7 +530,7 @@ screenlogicpy set chem-data [ph_setpoint] [orp_setpoint] Sets the pH and/or ORP set points for the IntelliChem system. **Note:** `[ph_setpoint]` can be a `float` between `7.2`-`7.6`, or `*` to keep the current value. `[orp_setpoint]` can be an `int` between `400`-`800`, or `*` to keep the current value. -_New in v0.6.0._ +* _New in v0.6.0._ # Reference diff --git a/examples/gateway.py b/examples/gateway.py index 5a7fd33..659b7a9 100644 --- a/examples/gateway.py +++ b/examples/gateway.py @@ -8,8 +8,8 @@ async def main(): hosts = await discovery.async_discover() if len(hosts) > 0: - gateway = ScreenLogicGateway(**hosts[0]) - await gateway.async_connect() + gateway = ScreenLogicGateway() + await gateway.async_connect(**hosts[0]) await gateway.async_update() await gateway.async_disconnect() pprint.pprint(gateway.get_data()) diff --git a/notes/12510_get_circuit_def.txt b/notes/12510_get_circuit_def.txt new file mode 100644 index 0000000..f62a1a1 --- /dev/null +++ b/notes/12510_get_circuit_def.txt @@ -0,0 +1,71 @@ +Response: + +\x10\x00\x00\x00 +\x00\x00\x00\x00\x07\x00\x00\x00Generic\x00 +\x01\x00\x00\x00\x03\x00\x00\x00Spa\x00 +\x02\x00\x00\x00\x04\x00\x00\x00Pool +\x05\x00\x00\x00\x0e\x00\x00\x00Master Cleaner\x00\x00 +\x07\x00\x00\x00\x05\x00\x00\x00Light\x00\x00\x00 +\x08\x00\x00\x00\x06\x00\x00\x00Dimmer\x00\x00 +\t\x00\x00\x00\t\x00\x00\x00SAm Light\x00\x00\x00 +\n\x00\x00\x00\t\x00\x00\x00SAL Light\x00\x00\x00 +\x0b\x00\x00\x00\n\x00\x00\x00Photon Gen\x00\x00 +\x0c\x00\x00\x00\x0b\x00\x00\x00Color wheel\x00 +\r\x00\x00\x00\x05\x00\x00\x00Valve\x00\x00\x00 +\x0e\x00\x00\x00\x08\x00\x00\x00Spillway +\x0f\x00\x00\x00\r\x00\x00\x00Floor Cleaner\x00\x00\x00 +\x10\x00\x00\x00\x0c\x00\x00\x00IntelliBrite +\x11\x00\x00\x00\x0b\x00\x00\x00MagicStream\x00 +\x13\x00\x00\x00\n\x00\x00\x00[NOT USED]\x00\x00 + +10 00 00 00| +Def Count | +16 | +00 00 00 00|07 00 00 00|47 65 6e 65 72 69 63 00 +Code |Name Len |Name +0 |7 | G e n e r i c +01 00 00 00|03 00 00 00|53 70 61 00 +Code |Name Len |Name +1 |3 | S p a +02 00 00 00|04 00 00 00|50 6f 6f 6c +Code |Name Len |Name +2 |4 | P o o l +05 00 00 00|0e 00 00 00|4d 61 73 74 65 72 20 43 6c 65 61 6e 65 72 00 00 +Code |Name Len |Name +5 |14 | M a s t e r C l e a n e r +07 00 00 00|05 00 00 00|4c 69 67 68 74 00 00 00 +Code |Name Len |Name +7 |5 | L i g h t +08 00 00 00|06 00 00 00|44 69 6d 6d 65 72 00 00 +Code |Name Len |Name +8 |6 | D i m m e r +09 00 00 00|09 00 00 00|53 41 6d 20 4c 69 67 68 74 00 00 00 +Code |Name Len |Name +9 |9 | S A m L i g h t +0a 00 00 00|09 00 00 00|53 41 4c 20 4c 69 67 68 74 00 00 00 +Code |Name Len |Name +10 |9 | S A L L i g h t +0b 00 00 00|0a 00 00 00|50 68 6f 74 6f 6e 20 47 65 6e 00 00 +Code |Name Len |Name +11 |10 | P h o t o n G e n +0c 00 00 00|0b 00 00 00|43 6f 6c 6f 72 20 77 68 65 65 6c 00 +Code |Name Len |Name +12 | | C o l o r w h e e l +0d 00 00 00|05 00 00 00|56 61 6c 76 65 00 00 00 +Code |Name Len |Name +13 | | V a l v e +0e 00 00 00|08 00 00 00|53 70 69 6c 6c 77 61 79 +Code |Name Len |Name +14 | | S p i l l w a y +0f 00 00 00|0d 00 00 00|46 6c 6f 6f 72 20 43 6c 65 61 6e 65 72 00 00 00 +Code |Name Len |Name +15 | | F l o o r C l e a n e r +10 00 00 00|0c 00 00 00|49 6e 74 65 6c 6c 69 42 72 69 74 65 +Code |Name Len |Name +16 | | I n t e l l i B r i t e +11 00 00 00|0b 00 00 00|4d 61 67 69 63 53 74 72 65 61 6d 00 +Code |Name Len |Name +17 | | M a g i c S t r e a m +13 00 00 00|0a 00 00 00|5b 4e 4f 54 20 55 53 45 44 5d 00 00 +Code |Name Len |Name +19 | | [ N O T U S E D ] diff --git a/notes/12518_get_circuit_config.txt b/notes/12518_get_circuit_config.txt new file mode 100644 index 0000000..e83fa5c --- /dev/null +++ b/notes/12518_get_circuit_config.txt @@ -0,0 +1,11 @@ +Response: + +500 +47 00 00 00|01 00 00 00|01 00 00 00|01 00 00 00|01 00 00 00 + +501 +55 00 00 00|00 00 00 00|02 00 00 00|02 00 00 00|00 00 00 00 + +503 +49 00 00 00|10 00 00 00|04 00 00 00|03 00 00 00|00 00 00 00 +Name idx |Function |Device ID |Interface |Flags \ No newline at end of file diff --git a/notes/12561_get_circuit_names.txt b/notes/12561_get_circuit_names.txt new file mode 100644 index 0000000..da59712 --- /dev/null +++ b/notes/12561_get_circuit_names.txt @@ -0,0 +1,30 @@ +Request: + +00 00 00 00|00 00 00 00|19 00 00 00 +controller |start_idx |count +0 |0 |25 + +Official apps request small chunks to not overwhelm the buffers? +00 00 00 00|19 00 00 00|19 00 00 00 +controller |start_idx |count +0 |25 |25 + +00 00 00 00|32 00 00 00|19 00 00 00 +controller |start_idx |count +0 |50 |25 + +Response: +65 00 00 00 +count | + +07 00 00 00|41 65 72 61 74 6f 72 00 +name_size |name +7 | A e r a t o r +0a 00 00 00 41 69 72 20 42 6c 6f 77 65 72 00 00 +name_size |name +10 | A i r B l o w e r +05 00 00 00 41 75 78 20 31 00 00 00 +name_size |name +5 | A u x 1 + +etc... \ No newline at end of file diff --git a/screenlogicpy/__init__.py b/screenlogicpy/__init__.py index 8bd7861..db3b098 100644 --- a/screenlogicpy/__init__.py +++ b/screenlogicpy/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.7.2" +__version__ = "0.8.0" # flake8: noqa F401 from screenlogicpy.gateway import ScreenLogicGateway from screenlogicpy.const import ScreenLogicError diff --git a/screenlogicpy/client.py b/screenlogicpy/client.py index 57c3eab..8f75742 100644 --- a/screenlogicpy/client.py +++ b/screenlogicpy/client.py @@ -1,12 +1,13 @@ """Client manager for a connection to a ScreenLogic protocol adapter.""" import asyncio import logging +import random from typing import Callable -from .const import CODE, COM_KEEPALIVE, ScreenLogicWarning +from .const import CODE, COM_KEEPALIVE, MESSAGE, ScreenLogicWarning from .requests.chemistry import decode_chemistry from .requests.client import async_request_add_client, async_request_remove_client -from .requests.color import decode_color_update +from .requests.lights import decode_color_update from .requests.status import decode_pool_status from .requests.ping import async_request_ping from .requests.protocol import ScreenLogicProtocol @@ -17,27 +18,40 @@ class ClientManager: """Class to manage callback subscriptions to specific ScreenLogic messages.""" - def __init__(self) -> None: + def __init__(self, client_id: int = None) -> None: + self._client_id = ( + client_id if client_id is not None else random.randint(32767, 65535) + ) self._listeners = {} self._is_client = False self._client_sub_unsub_lock = asyncio.Lock() self._protocol = None self._data = None + self._max_retries = MESSAGE.COM_MAX_RETRIES @property - def is_client(self): + def is_client(self) -> bool: """Return if connected to ScreenLogic as a client.""" return self._is_client and self._protocol and self._protocol.is_connected @property - def client_needed(self): + def client_id(self) -> int: + return self._client_id + + @property + def client_needed(self) -> bool: """Return if desired to be a client.""" return self._listeners and not self._is_client - def _attached(self): + def _attached(self) -> bool: return self._protocol and self._protocol.is_connected - async def attach(self, protocol: ScreenLogicProtocol, data: dict): + async def attach( + self, + protocol: ScreenLogicProtocol, + data: dict, + max_retries: int = MESSAGE.COM_MAX_RETRIES, + ): """ Update protocol and data reference. @@ -47,6 +61,7 @@ async def attach(self, protocol: ScreenLogicProtocol, data: dict): """ self._protocol = protocol self._data = data + self._max_retries = max_retries self._is_client = False if self.client_needed: self._protocol.remove_all_async_message_callbacks() @@ -143,7 +158,9 @@ async def _async_add_client(self): "Not attached to protocol adapter. add_client failed." ) _LOGGER.debug("Requesting add client") - return await async_request_add_client(self._protocol) + return await async_request_add_client( + self._protocol, self._client_id, max_retries=self._max_retries + ) async def _async_remove_client(self): """Check connection before sending remove client request.""" @@ -152,7 +169,9 @@ async def _async_remove_client(self): "Not attached to protocol adapter. remove_client failed." ) _LOGGER.debug("Requesting remove client") - return await async_request_remove_client(self._protocol) + return await async_request_remove_client( + self._protocol, self._client_id, max_retries=self._max_retries + ) async def async_subscribe_gateway(self) -> bool: """ diff --git a/screenlogicpy/const.py b/screenlogicpy/const.py index 67acbcf..64e955f 100644 --- a/screenlogicpy/const.py +++ b/screenlogicpy/const.py @@ -23,6 +23,8 @@ class ScreenLogicWarning(Exception): class MESSAGE: + COM_MAX_RETRIES = 2 + COM_RETRY_WAIT = 2 COM_TIMEOUT = 5 HEADER_FORMAT = " str: @@ -84,6 +86,14 @@ def is_connected(self) -> bool: def is_client(self) -> bool: return self._client_manager.is_client + @property + def client_id(self) -> int: + return self._client_manager.client_id + + @property + def max_retries(self) -> int: + return self._max_retries + async def async_connect( self, ip=None, @@ -97,11 +107,11 @@ async def async_connect( if self.is_connected: return True - self._ip = ip if ip else self._ip - self._port = port if port else self._port - self._type = gtype if gtype else self._type - self._subtype = gsubtype if gsubtype else self._subtype - self._name = name if name else self._name + self._ip = ip if ip is not None else self._ip + self._port = port if port is not None else self._port + self._type = gtype if gtype is not None else self._type + self._subtype = gsubtype if gsubtype is not None else self._subtype + self._name = name if name is not None else self._name self._custom_connection_closed_callback = connection_closed_callback if not self._ip: @@ -109,15 +119,22 @@ async def async_connect( _LOGGER.debug("Beginning connection and login sequence") connectPkg = await async_connect_to_gateway( - self._ip, self._port, self._common_connection_closed_callback + self._ip, + self._port, + self._common_connection_closed_callback, + self._max_retries, ) if connectPkg: self._transport, self._protocol, self._mac = connectPkg - self._version = await async_request_gateway_version(self._protocol) + self._version = await async_request_gateway_version( + self._protocol, self._max_retries + ) if self._version: _LOGGER.debug("Login successful") await self.async_get_config() - await self._client_manager.attach(self._protocol, self.get_data()) + await self._client_manager.attach( + self._protocol, self.get_data(), self._max_retries + ) return True _LOGGER.debug("Login failed") return False @@ -151,6 +168,64 @@ async def async_update(self) -> bool: _LOGGER.debug("Update complete") return True + async def async_get_config(self): + """Request pool configuration data.""" + if not self.is_connected: + raise ScreenLogicWarning( + "Not connected to protocol adapter. get_config failed." + ) + _LOGGER.debug("Requesting config data") + self._last[DATA.KEY_CONFIG] = await async_request_pool_config( + self._protocol, self._data, self._max_retries + ) + + async def async_get_status(self): + """Request pool state data.""" + if not self.is_connected: + raise ScreenLogicWarning( + "Not connected to protocol adapter. get_status failed." + ) + _LOGGER.debug("Requesting pool status") + self._last["status"] = await async_request_pool_status( + self._protocol, self._data, self._max_retries + ) + + async def async_get_pumps(self): + """Request all pump state data.""" + if not self.is_connected: + raise ScreenLogicWarning( + "Not connected to protocol adapter. get_pumps failed." + ) + for pumpID in self._data[DATA.KEY_PUMPS]: + if self._data[DATA.KEY_PUMPS][pumpID]["data"] != 0: + _LOGGER.debug("Requesting pump %i data", pumpID) + last_pumps = self._last.setdefault(DATA.KEY_PUMPS, {}) + last_pumps[pumpID] = await async_request_pump_status( + self._protocol, self._data, pumpID, self._max_retries + ) + + async def async_get_chemistry(self): + """Request IntelliChem controller data.""" + if not self.is_connected: + raise ScreenLogicWarning( + "Not connected to protocol adapter. get_chemistry failed." + ) + _LOGGER.debug("Requesting chemistry data") + self._last[DATA.KEY_CHEMISTRY] = await async_request_chemistry( + self._protocol, self._data, self._max_retries + ) + + async def async_get_scg(self): + """Request salt chlorine generator state data.""" + if not self.is_connected: + raise ScreenLogicWarning( + "Not connected to protocol adapter. get_scg failed." + ) + _LOGGER.debug("Requesting scg data") + self._last[DATA.KEY_SCG] = await async_request_scg_config( + self._protocol, self._data, self._max_retries + ) + def get_data(self) -> dict: """Return the data.""" return self._data @@ -159,6 +234,12 @@ def get_debug(self) -> dict: """Return the debug last-received data.""" return self._last + def set_max_retries(self, max_retries: int = MESSAGE.COM_MAX_RETRIES) -> None: + if 0 < max_retries < 6: + self._max_retries = max_retries + else: + raise ValueError(f"Invalid max_retries: {max_retries}") + async def async_set_circuit(self, circuitID: int, circuitState: int): """Set the circuit state for the specified circuit.""" if not self._is_valid_circuit(circuitID): @@ -168,7 +249,7 @@ async def async_set_circuit(self, circuitID: int, circuitState: int): if await self.async_connect(): if await async_request_pool_button_press( - self._protocol, circuitID, circuitState + self._protocol, circuitID, circuitState, self._max_retries ): return True return False @@ -181,7 +262,9 @@ async def async_set_heat_temp(self, body: int, temp: int): raise ValueError(f"Invalid temp ({temp}) for body ({body})") if await self.async_connect(): - if await async_request_set_heat_setpoint(self._protocol, body, temp): + if await async_request_set_heat_setpoint( + self._protocol, body, temp, self._max_retries + ): return True return False @@ -193,7 +276,9 @@ async def async_set_heat_mode(self, body: int, mode: int): raise ValueError(f"Invalid mode: {mode}") if await self.async_connect(): - if await async_request_set_heat_mode(self._protocol, body, mode): + if await async_request_set_heat_mode( + self._protocol, body, mode, self._max_retries + ): return True return False @@ -203,7 +288,9 @@ async def async_set_color_lights(self, light_command: int): raise ValueError(f"Invalid light_command: {light_command}") if await self.async_connect(): - if await async_request_pool_lights_command(self._protocol, light_command): + if await async_request_pool_lights_command( + self._protocol, light_command, self._max_retries + ): return True return False @@ -216,7 +303,7 @@ async def async_set_scg_config(self, pool_output: int, spa_output: int): if await self.async_connect(): if await async_request_set_scg_config( - self._protocol, pool_output, spa_output + self._protocol, pool_output, spa_output, max_retries=self._max_retries ): return True return False @@ -249,6 +336,7 @@ async def async_set_chem_data( alkalinity, cyanuric, salt, + self._max_retries, ): return True return False @@ -295,64 +383,8 @@ async def async_send_message(self, message_code: int, message: bytes = b""): "Not connected to protocol adapter. send_message failed." ) _LOGGER.debug(f"User requesting {message_code}") - return await async_make_request(self._protocol, message_code, message) - - async def async_get_config(self): - """Request pool configuration data.""" - if not self.is_connected: - raise ScreenLogicWarning( - "Not connected to protocol adapter. get_config failed." - ) - _LOGGER.debug("Requesting config data") - self._last[DATA.KEY_CONFIG] = await async_request_pool_config( - self._protocol, self._data - ) - - async def async_get_status(self): - """Request pool state data.""" - if not self.is_connected: - raise ScreenLogicWarning( - "Not connected to protocol adapter. get_status failed." - ) - _LOGGER.debug("Requesting pool status") - self._last["status"] = await async_request_pool_status( - self._protocol, self._data - ) - - async def async_get_pumps(self): - """Request all pump state data.""" - if not self.is_connected: - raise ScreenLogicWarning( - "Not connected to protocol adapter. get_pumps failed." - ) - for pumpID in self._data[DATA.KEY_PUMPS]: - if self._data[DATA.KEY_PUMPS][pumpID]["data"] != 0: - _LOGGER.debug("Requesting pump %i data", pumpID) - last_pumps = self._last.setdefault(DATA.KEY_PUMPS, {}) - last_pumps[pumpID] = await async_request_pump_status( - self._protocol, self._data, pumpID - ) - - async def async_get_chemistry(self): - """Request IntelliChem controller data.""" - if not self.is_connected: - raise ScreenLogicWarning( - "Not connected to protocol adapter. get_chemistry failed." - ) - _LOGGER.debug("Requesting chemistry data") - self._last[DATA.KEY_CHEMISTRY] = await async_request_chemistry( - self._protocol, self._data - ) - - async def async_get_scg(self): - """Request salt chlorine generator state data.""" - if not self.is_connected: - raise ScreenLogicWarning( - "Not connected to protocol adapter. get_scg failed." - ) - _LOGGER.debug("Requesting scg data") - self._last[DATA.KEY_SCG] = await async_request_scg_config( - self._protocol, self._data + return await async_make_request( + self._protocol, message_code, message, self._max_retries ) def _common_connection_closed_callback(self): diff --git a/screenlogicpy/requests/button.py b/screenlogicpy/requests/button.py index 3e26a73..f623266 100644 --- a/screenlogicpy/requests/button.py +++ b/screenlogicpy/requests/button.py @@ -6,13 +6,14 @@ async def async_request_pool_button_press( - protocol: ScreenLogicProtocol, circuit_id: int, circuit_state: int + protocol: ScreenLogicProtocol, circuit_id: int, circuit_state: int, max_retries: int ) -> bool: return ( await async_make_request( protocol, CODE.BUTTONPRESS_QUERY, struct.pack(" bytes: +async def async_request_chemistry( + protocol: ScreenLogicProtocol, data: dict, max_retries: int +) -> bytes: if result := await async_make_request( - protocol, CODE.CHEMISTRY_QUERY, struct.pack(" bool: return ( await async_make_request( - protocol, CODE.ADD_CLIENT_QUERY, struct.pack(" bool: return ( await async_make_request( - protocol, CODE.REMOVE_CLIENT_QUERY, struct.pack(" bytes: +async def async_request_pool_config( + protocol: ScreenLogicProtocol, data: dict, max_retries: int +) -> bytes: if result := await async_make_request( protocol, CODE.CTRLCONFIG_QUERY, struct.pack("<2I", 0, 0), # 0,1 yields different return + max_retries, ): decode_pool_config(result, data) return result @@ -50,8 +53,7 @@ def decode_pool_config(buff: bytes, data: dict) -> dict: equipFlags, offset = getSome("I", buff, offset) config["equipment_flags"] = equipFlags - paddedGenName, offset = getString(buff, offset) - genCircuitName = paddedGenName.decode("utf-8").strip("\0") + genCircuitName, offset = getString(buff, offset) config["generic_circuit_name"] = { "name": "Default Circuit Name", "value": genCircuitName, @@ -70,8 +72,7 @@ def decode_pool_config(buff: bytes, data: dict) -> dict: currentCircuit["id"] = circuitID - paddedName, offset = getString(buff, offset) - circuitName = paddedName.decode("utf-8").strip("\0") + circuitName, offset = getString(buff, offset) currentCircuit["name"] = circuitName cNameIndex, offset = getSome("B", buff, offset) @@ -115,8 +116,7 @@ def decode_pool_config(buff: bytes, data: dict) -> dict: colors = config.setdefault(DATA.KEY_COLORS, [{} for x in range(colorCount)]) for i in range(colorCount): - paddedColorName, offset = getString(buff, offset) - colorName = paddedColorName.decode("utf-8").strip("\0") + colorName, offset = getString(buff, offset) rgbR, offset = getSome("I", buff, offset) rgbG, offset = getSome("I", buff, offset) rgbB, offset = getSome("I", buff, offset) diff --git a/screenlogicpy/requests/equipment.py b/screenlogicpy/requests/equipment.py index 74373c5..e72c1b1 100644 --- a/screenlogicpy/requests/equipment.py +++ b/screenlogicpy/requests/equipment.py @@ -7,10 +7,10 @@ async def async_request_equipment_config( - protocol: ScreenLogicProtocol, data: dict + protocol: ScreenLogicProtocol, data: dict, max_retries: int ) -> bytes: if result := await async_make_request( - protocol, CODE.EQUIPMENT_QUERY, struct.pack("<2I", 0, 0) + protocol, CODE.EQUIPMENT_QUERY, struct.pack("<2I", 0, 0), max_retries ): decode_equipment_config(result, data) return result diff --git a/screenlogicpy/requests/gateway.py b/screenlogicpy/requests/gateway.py index 9b5481a..ffa16da 100644 --- a/screenlogicpy/requests/gateway.py +++ b/screenlogicpy/requests/gateway.py @@ -4,6 +4,10 @@ from .utility import decodeMessageString -async def async_request_gateway_version(protocol: ScreenLogicProtocol): - if result := await async_make_request(protocol, CODE.VERSION_QUERY): +async def async_request_gateway_version( + protocol: ScreenLogicProtocol, max_retries: int +): + if result := await async_make_request( + protocol, CODE.VERSION_QUERY, max_retries=max_retries + ): return decodeMessageString(result) diff --git a/screenlogicpy/requests/heat.py b/screenlogicpy/requests/heat.py index 6b40e9e..9990bf0 100644 --- a/screenlogicpy/requests/heat.py +++ b/screenlogicpy/requests/heat.py @@ -6,22 +6,28 @@ async def async_request_set_heat_setpoint( - protocol: ScreenLogicProtocol, body: int, set_point: float + protocol: ScreenLogicProtocol, body: int, set_point: float, max_retries: int ) -> bool: return ( await async_make_request( - protocol, CODE.SETHEATTEMP_QUERY, struct.pack(" bool: return ( await async_make_request( - protocol, CODE.SETHEATMODE_QUERY, struct.pack(" bool: return ( await async_make_request( - protocol, CODE.LIGHTCOMMAND_QUERY, struct.pack(" bytes: # these constants are only for this message. schema = 348 connectionType = 0 @@ -18,11 +19,11 @@ def create_login_message(): pid = 2 password = "0000000000000000" # passwd must be <= 16 chars. empty is not OK. passwd = encodeMessageString(password) - fmt = " bytes: schema = 348 connectionType = 0 clientVersion = encodeMessageString("Local Config") @@ -35,18 +36,20 @@ def create_local_login_message(): ) -async def async_get_mac_address(gateway_ip, gateway_port): +async def async_get_mac_address( + gateway_ip: str, gateway_port: int, max_retries: int = MESSAGE.COM_MAX_RETRIES +) -> str: """Connect to a screenlogic gateway and return the mac address only.""" transport, protocol = await async_create_connection(gateway_ip, gateway_port) - mac = await async_gateway_connect(transport, protocol) + mac = await async_gateway_connect(transport, protocol, max_retries) if transport and not transport.is_closing(): transport.close() return mac async def async_create_connection( - gateway_ip, gateway_port, connection_lost_callback: Callable = None -): + gateway_ip: str, gateway_port: int, connection_lost_callback: Callable = None +) -> Tuple[asyncio.Transport, ScreenLogicProtocol]: try: loop = asyncio.get_running_loop() @@ -65,7 +68,7 @@ async def async_create_connection( async def async_gateway_connect( - transport: asyncio.Transport, protocol: ScreenLogicProtocol + transport: asyncio.Transport, protocol: ScreenLogicProtocol, max_retries: int ) -> str: connectString = b"CONNECTSERVERHOST\r\n\r\n" # as bytes, not string try: @@ -80,38 +83,41 @@ async def async_gateway_connect( raise ScreenLogicError("Host unexpectedly disconnected.") _LOGGER.debug("Sending challenge") - request = protocol.await_send_message(CODE.CHALLENGE_QUERY) - try: - - async with asyncio_timeout(MESSAGE.COM_TIMEOUT): - await request - except asyncio.TimeoutError: - raise ScreenLogicError("Host failed to respond to challenge") - - if not request.cancelled(): # mac address - return decodeMessageString(request.result()) + return decodeMessageString( + await async_make_request( + protocol, CODE.CHALLENGE_QUERY, max_retries=max_retries + ) + ) + except ScreenLogicWarning as warn: + raise ScreenLogicError( + f"Host failed to respond to challenge: : {warn.args[0]}" + ) from warn -async def async_gateway_login(protocol: ScreenLogicProtocol) -> bool: +async def async_gateway_login(protocol: ScreenLogicProtocol, max_retries: int) -> bool: _LOGGER.debug("Logging in") - request = protocol.await_send_message(CODE.LOCALLOGIN_QUERY, create_login_message()) try: - async with asyncio_timeout(MESSAGE.COM_TIMEOUT): - await request - except asyncio.TimeoutError: - raise ScreenLogicError("Failed to logon to gateway") - - return not request.cancelled() + return ( + await async_make_request( + protocol, CODE.LOCALLOGIN_QUERY, create_login_message(), max_retries + ) + is not None + ) + except ScreenLogicWarning as warn: + raise ScreenLogicError(f"Failed to logon to gateway: {warn.args[0]}") from warn async def async_connect_to_gateway( - gateway_ip, gateway_port, connection_lost_callback: Callable = None -): + gateway_ip, + gateway_port, + connection_lost_callback: Callable = None, + max_retries: int = MESSAGE.COM_MAX_RETRIES, +) -> Tuple[asyncio.Transport, ScreenLogicProtocol, str]: transport, protocol = await async_create_connection( gateway_ip, gateway_port, connection_lost_callback ) - mac_address = await async_gateway_connect(transport, protocol) - if await async_gateway_login(protocol): + mac_address = await async_gateway_connect(transport, protocol, max_retries) + if await async_gateway_login(protocol, max_retries): return transport, protocol, mac_address diff --git a/screenlogicpy/requests/ping.py b/screenlogicpy/requests/ping.py index 4e7f657..66e8443 100644 --- a/screenlogicpy/requests/ping.py +++ b/screenlogicpy/requests/ping.py @@ -3,5 +3,8 @@ from .request import async_make_request -async def async_request_ping(protocol: ScreenLogicProtocol) -> bytes: - return await async_make_request(protocol, CODE.PING_QUERY) == b"" +async def async_request_ping(protocol: ScreenLogicProtocol, max_retries: int) -> bytes: + return ( + await async_make_request(protocol, CODE.PING_QUERY, max_retries=max_retries) + == b"" + ) diff --git a/screenlogicpy/requests/protocol.py b/screenlogicpy/requests/protocol.py index 0269921..2d66593 100644 --- a/screenlogicpy/requests/protocol.py +++ b/screenlogicpy/requests/protocol.py @@ -6,7 +6,7 @@ import time from typing import Awaitable, Callable, Tuple, List -from ..const import CODE, MESSAGE, ScreenLogicError +from ..const import MESSAGE, ScreenLogicError from .utility import makeMessage, takeMessage _LOGGER = logging.getLogger(__name__) @@ -108,26 +108,19 @@ def complete_messages(data: bytes) -> List[Tuple[int, int, bytes]]: _LOGGER.debug(f"Buffer: {self._buff}") return complete - for messageID, messageCode, message in complete_messages(data): + for message in complete_messages(data): - if messageCode == CODE.UNKNOWN_ANSWER: - raise ScreenLogicError(f"Request explicitly rejected: {messageID}") - - if self._futures.mark_done(messageID, message): - _LOGGER.debug("Received: %i, %i, %s", messageID, messageCode, message) + if self._futures.mark_done(message): + _LOGGER.debug("Received: %i, %i, %s", *message) else: - _LOGGER.debug( - "Received async message: %i, %i, %s", - messageID, - messageCode, - message, - ) + _LOGGER.debug("Received async message: %i, %i, %s", *message) # Unsolicited message received. See if there's a callback registered # for the message code and create a task for it. - if messageCode in self._callbacks: - handler, args = self._callbacks[messageCode] + _, msgCode, msgData = message + if msgCode in self._callbacks: + handler, args = self._callbacks[msgCode] _LOGGER.debug(f"Calling {handler}") - self._loop.create_task(handler(message, *args)) + self._loop.create_task(handler(msgData, *args)) def connection_lost(self, exc) -> None: """Called when connection is closed/lost.""" @@ -218,12 +211,12 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._collection = {} self.loop = loop - def create(self, msgID) -> asyncio.Future: + def create(self, msgID: int) -> asyncio.Future: """Create future for response.""" self._collection[msgID] = self.loop.create_future() return self._collection[msgID] - def try_get(self, msgID) -> asyncio.Future: + def try_get(self, msgID: int) -> asyncio.Future: """Get response future for message ID.""" fut: asyncio.Future if (fut := self._collection.pop(msgID, None)) is not None: @@ -231,11 +224,12 @@ def try_get(self, msgID) -> asyncio.Future: return fut return None - def mark_done(self, msgID, result=True) -> bool: + def mark_done(self, message: Tuple[int, int, bytes]) -> bool: """Mark future done and add response.""" + msgID, _, _ = message if (fut := self.try_get(msgID)) is not None: try: - fut.set_result(result) + fut.set_result(message) except asyncio.exceptions.InvalidStateError as ise: raise ScreenLogicError( f"Attempted to set result on future {msgID} when result exists: {fut.result()}" diff --git a/screenlogicpy/requests/pump.py b/screenlogicpy/requests/pump.py index 261d5e5..8a148f2 100644 --- a/screenlogicpy/requests/pump.py +++ b/screenlogicpy/requests/pump.py @@ -7,10 +7,10 @@ async def async_request_pump_status( - protocol: ScreenLogicProtocol, data: dict, pumpID: int + protocol: ScreenLogicProtocol, data: dict, pumpID: int, max_retries: int ) -> bytes: if result := await async_make_request( - protocol, CODE.PUMPSTATUS_QUERY, struct.pack(" bytes: - request = protocol.await_send_message(messageCode, message) - try: - async with asyncio_timeout(MESSAGE.COM_TIMEOUT): - await request - except asyncio.TimeoutError: - raise ScreenLogicWarning( - f"Timeout waiting for response to message code '{messageCode}'" + for attempt in range(1, max_retries + 1): + request = protocol.await_send_message(requestCode, requestData) + try: + async with asyncio_timeout(MESSAGE.COM_TIMEOUT): + await request + except asyncio.TimeoutError: + error_message = ( + f"Timeout waiting for response to message code '{requestCode}'" + ) + except asyncio.CancelledError: + return + + if not request.cancelled(): + _, responseCode, responseData = request.result() + + if responseCode == requestCode + 1: + return responseData + elif responseCode == CODE.ERROR_LOGIN_REJECTED: + error_message = f"Login Rejected for request code: {requestCode}, request: {requestData}" + elif responseCode == CODE.ERROR_INVALID_REQUEST: + error_message = f"Invalid Request for request code: {requestCode}, request: {requestData}" + elif responseCode == CODE.ERROR_BAD_PARAMETER: + error_message = f"Bad Parameter for request code: {requestCode}, request: {requestData}" + else: + error_message = f"Unexpected response code '{responseCode}' for request code: {requestCode}, request: {requestData}" + + if attempt == max_retries: + raise ScreenLogicWarning(f"{error_message} after {max_retries} attempts") + + retry_delay = MESSAGE.COM_RETRY_WAIT * attempt + + _LOGGER.debug( + error_message + ". Will retry %i more time(s) in %i seconds", + max_retries - attempt, + retry_delay, ) - if not request.cancelled(): - return request.result() + + await asyncio.sleep(retry_delay) diff --git a/screenlogicpy/requests/scg.py b/screenlogicpy/requests/scg.py index 325cfbc..4292a17 100644 --- a/screenlogicpy/requests/scg.py +++ b/screenlogicpy/requests/scg.py @@ -1,14 +1,16 @@ import struct -from ..const import CODE, DATA, STATE_TYPE, UNIT +from ..const import CODE, DATA, MESSAGE, STATE_TYPE, UNIT from .protocol import ScreenLogicProtocol from .request import async_make_request from .utility import getSome -async def async_request_scg_config(protocol: ScreenLogicProtocol, data: dict) -> bytes: +async def async_request_scg_config( + protocol: ScreenLogicProtocol, data: dict, max_retries: int +) -> bytes: if result := await async_make_request( - protocol, CODE.SCGCONFIG_QUERY, struct.pack(" bool: return ( await async_make_request( protocol, CODE.SETSCG_QUERY, struct.pack(" bytes: +async def async_request_pool_status( + protocol: ScreenLogicProtocol, data: dict, max_retries: int +) -> bytes: if result := await async_make_request( - protocol, CODE.POOLSTATUS_QUERY, struct.pack(" Tuple[int, int, bytes]: """Return (messageID, messageCode, message) from raw ScreenLogic message bytes.""" messageBytes = len(data) - MESSAGE.HEADER_LENGTH - msgID, msgCode, msgLen, message = struct.unpack( - MESSAGE.HEADER_FORMAT + str(messageBytes) + "s", data + msgID, msgCode, msgLen, msgData = struct.unpack( + f"{MESSAGE.HEADER_FORMAT}{messageBytes}s", data ) if msgLen != messageBytes: raise ScreenLogicError( f"Response length invalid. Claimed: {msgLen}. Received: {messageBytes}. Message ID: {msgID}. Message Code: {msgCode}. Data: {data}" ) - if msgCode == CODE.UNKNOWN_ANSWER: - raise ScreenLogicError("Request rejected") - return msgID, msgCode, message # return raw data + return msgID, msgCode, msgData # return raw data def takeMessages(data: bytes) -> List[Tuple[int, int, bytes]]: @@ -53,24 +51,24 @@ def takeMessages(data: bytes) -> List[Tuple[int, int, bytes]]: ) from err -def encodeMessageString(string): +def encodeMessageString(string) -> bytes: data = string.encode() length = len(data) over = length % 4 pad = (4 - over) if over > 0 else 0 # pad string to multiple of 4 - fmt = " str: size = struct.unpack_from("") else "<" + want +def getSome(format, buff, offset) -> Tuple[Any, int]: + fmt = format if format.startswith(">") else f"<{format}" newoffset = offset + struct.calcsize(fmt) return struct.unpack_from(fmt, buff, offset)[0], newoffset @@ -95,16 +93,17 @@ def getValueAt(buff, offset, want, **kwargs): return data, newoffset -def getString(buff, offset): +def getString(buff, offset) -> Tuple[str, int]: fmtLen = " 3: + messageID, messageCode, _ = takeMessage(data) + if fail == 4: + if messageCode == CODE.LOCALLOGIN_QUERY: + return makeMessage(messageID, CODE.ERROR_LOGIN_REJECTED) + else: + return makeMessage(messageID, CODE.ERROR_BAD_PARAMETER) + else: + return None + else: + return super().process_request(data) + + class FakeScreenLogicUDPProtocol(asyncio.DatagramProtocol): def connection_made(self, transport: asyncio.DatagramTransport): self.transport = transport diff --git a/tests/test_client.py b/tests/test_client.py index 87631a8..b215553 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,26 +1,28 @@ import asyncio -import struct import pytest +import random +import struct from unittest.mock import patch from screenlogicpy import ScreenLogicGateway from screenlogicpy.client import ClientManager -from screenlogicpy.const import CLIENT_ID, CODE +from screenlogicpy.const import CODE from .const_data import ( - EXPECTED_STATUS_DATA, EXPECTED_CHEMISTRY_DATA, - FAKE_CONNECT_INFO, + EXPECTED_STATUS_DATA, FAKE_CHEMISTRY_RESPONSE, + FAKE_CONNECT_INFO, FAKE_STATUS_RESPONSE, ) +from .fake_gateway import expected_resp @pytest.mark.asyncio() async def test_sub_unsub(event_loop, MockProtocolAdapter): async with MockProtocolAdapter: - gateway = ScreenLogicGateway() + clientID = random.randint(32767, 65535) + gateway = ScreenLogicGateway(clientID) code = CODE.STATUS_CHANGED - clientID = CLIENT_ID def callback(): pass @@ -31,8 +33,10 @@ def callback(): await gateway.async_connect(**FAKE_CONNECT_INFO) - result = event_loop.create_future() - result.set_result(b"") + sub_code = 12522 + + result: asyncio.Future = event_loop.create_future() + result.set_result(expected_resp(sub_code)) with patch( "screenlogicpy.requests.client.ScreenLogicProtocol.await_send_message", return_value=result, @@ -51,12 +55,16 @@ def callback(): (code, gateway._data), ), } - assert mockSubRequest.call_args.args[0] == 12522 + assert mockSubRequest.call_args.args[0] == sub_code assert mockSubRequest.call_args.args[1] == struct.pack("