python-omnilogic-local 0.27.0__tar.gz → 0.27.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/PKG-INFO +1 -1
  2. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/api/api.py +2 -8
  3. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/api/constants.py +2 -2
  4. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/api/protocol.py +42 -16
  5. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyproject.toml +1 -1
  6. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/LICENSE +0 -0
  7. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/README.md +0 -0
  8. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/__init__.py +0 -0
  9. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/_base.py +0 -0
  10. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/api/__init__.py +0 -0
  11. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/api/exceptions.py +0 -0
  12. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/api/mock_api.py +0 -0
  13. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/backyard.py +0 -0
  14. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/bow.py +0 -0
  15. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/chlorinator.py +0 -0
  16. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/chlorinator_equip.py +0 -0
  17. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/__init__.py +0 -0
  18. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/cli.py +0 -0
  19. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/debug/__init__.py +0 -0
  20. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/debug/commands.py +0 -0
  21. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/__init__.py +0 -0
  22. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/backyard.py +0 -0
  23. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/bows.py +0 -0
  24. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/chlorinators.py +0 -0
  25. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/commands.py +0 -0
  26. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/csads.py +0 -0
  27. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/filters.py +0 -0
  28. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/groups.py +0 -0
  29. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/heaters.py +0 -0
  30. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/lights.py +0 -0
  31. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/pumps.py +0 -0
  32. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/relays.py +0 -0
  33. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/schedules.py +0 -0
  34. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/sensors.py +0 -0
  35. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/get/valves.py +0 -0
  36. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/pcap_utils.py +0 -0
  37. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/cli/utils.py +0 -0
  38. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/collections.py +0 -0
  39. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/colorlogiclight.py +0 -0
  40. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/csad.py +0 -0
  41. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/csad_equip.py +0 -0
  42. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/decorators.py +0 -0
  43. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/filter.py +0 -0
  44. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/groups.py +0 -0
  45. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/heater.py +0 -0
  46. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/heater_equip.py +0 -0
  47. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/models/__init__.py +0 -0
  48. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/models/const.py +0 -0
  49. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/models/exceptions.py +0 -0
  50. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/models/filter_diagnostics.py +0 -0
  51. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/models/leadmessage.py +0 -0
  52. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/models/mspconfig.py +0 -0
  53. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/models/telemetry.py +0 -0
  54. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/omnilogic.py +0 -0
  55. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/omnitypes.py +0 -0
  56. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/pump.py +0 -0
  57. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/py.typed +0 -0
  58. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/relay.py +0 -0
  59. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/schedule.py +0 -0
  60. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/sensor.py +0 -0
  61. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/system.py +0 -0
  62. {python_omnilogic_local-0.27.0 → python_omnilogic_local-0.27.1}/pyomnilogic_local/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-omnilogic-local
3
- Version: 0.27.0
3
+ Version: 0.27.1
4
4
  Summary: A library for local control of Hayward OmniHub/OmniLogic pool controllers using their local API
5
5
  Author: Chris Jowett, djtimca, garionphx
6
6
  Author-email: Chris Jowett <421501+cryptk@users.noreply.github.com>
@@ -8,11 +8,7 @@ from typing import TYPE_CHECKING, Literal, overload
8
8
  from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics
9
9
  from pyomnilogic_local.models.mspconfig import MSPConfig
10
10
  from pyomnilogic_local.models.telemetry import Telemetry
11
- from pyomnilogic_local.omnitypes import (
12
- ColorLogicBrightness,
13
- ColorLogicSpeed,
14
- MessageType,
15
- )
11
+ from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicSpeed, MessageType
16
12
 
17
13
  from .constants import (
18
14
  DEFAULT_CONTROLLER_PORT,
@@ -117,10 +113,8 @@ class OmniLogicAPI:
117
113
 
118
114
  @overload
119
115
  async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[True]) -> str: ...
120
-
121
116
  @overload
122
117
  async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[False]) -> None: ...
123
-
124
118
  async def async_send_message(self, message_type: MessageType, message: str | None, need_response: bool = False) -> str | None:
125
119
  """Send a message via the Hayward Omni UDP protocol along with properly handling timeouts and responses.
126
120
 
@@ -138,7 +132,7 @@ class OmniLogicAPI:
138
132
  resp: str | None = None
139
133
  try:
140
134
  if need_response:
141
- resp = await protocol.send_and_receive(message_type, message)
135
+ resp = await protocol.send_and_receive(message_type, message, response_timeout=self.response_timeout)
142
136
  else:
143
137
  await protocol.send_message(message_type, message)
144
138
  finally:
@@ -13,8 +13,8 @@ BLOCK_MESSAGE_HEADER_OFFSET = 8 # Offset to skip block message header and get t
13
13
  # Timing Constants (in seconds)
14
14
  OMNI_RETRANSMIT_TIME = 2.1 # Time Omni waits before retransmitting a packet
15
15
  OMNI_RETRANSMIT_COUNT = 5 # Number of retransmit attempts (6 total including initial)
16
- ACK_WAIT_TIMEOUT = 0.5 # Timeout waiting for ACK response
17
- DEFAULT_RESPONSE_TIMEOUT = 5.0 # Default timeout for receiving responses
16
+ ACK_WAIT_TIMEOUT = 1 # Timeout waiting for ACK response, 0.5 showed to be just a tad too short in some cases.
17
+ DEFAULT_RESPONSE_TIMEOUT = OMNI_RETRANSMIT_TIME * OMNI_RETRANSMIT_COUNT # Default timeout for receiving responses
18
18
 
19
19
  # Network Constants
20
20
  DEFAULT_CONTROLLER_PORT = 10444 # Default UDP port for OmniLogic communication
@@ -15,6 +15,7 @@ from pyomnilogic_local.omnitypes import ClientType, MessageType
15
15
  from .constants import (
16
16
  ACK_WAIT_TIMEOUT,
17
17
  BLOCK_MESSAGE_HEADER_OFFSET,
18
+ DEFAULT_RESPONSE_TIMEOUT,
18
19
  MAX_FRAGMENT_WAIT_TIME,
19
20
  MAX_QUEUE_SIZE,
20
21
  OMNI_RETRANSMIT_COUNT,
@@ -25,11 +26,7 @@ from .constants import (
25
26
  XML_ENCODING,
26
27
  XML_NAMESPACE,
27
28
  )
28
- from .exceptions import (
29
- OmniFragmentationError,
30
- OmniMessageFormatError,
31
- OmniTimeoutError,
32
- )
29
+ from .exceptions import OmniFragmentationError, OmniMessageFormatError, OmniTimeoutError
33
30
 
34
31
  _LOGGER = logging.getLogger(__name__)
35
32
 
@@ -225,21 +222,34 @@ class OmniLogicProtocol(asyncio.DatagramProtocol):
225
222
  Exception: If a protocol error occurs.
226
223
  """
227
224
  # Wait for either an ACK message or an error
228
- while True:
225
+ # Race condition: datagram_received() calls put_nowait() synchronously, so data_task may
226
+ # already be done when wait_for fires its timeout CancelledError. In that case we catch
227
+ # the cancellation, skip re-looping, and fall through to check the result below. If the
228
+ # result is our ACK we return normally, suppressing the CancelledError so wait_for treats
229
+ # the call as successful. If it isn't, we re-raise after the loop.
230
+ cancelled: asyncio.CancelledError | None = None
231
+ retry = True
232
+ while retry:
229
233
  # Wait for either a message or an error
230
234
  data_task = asyncio.create_task(self.data_queue.get())
231
235
  error_task = asyncio.create_task(self.error_queue.get())
232
- done, pending = await asyncio.wait([data_task, error_task], return_when=asyncio.FIRST_COMPLETED)
236
+ try:
237
+ done, pending = await asyncio.wait([data_task, error_task], return_when=asyncio.FIRST_COMPLETED)
238
+ except asyncio.CancelledError as exc:
239
+ retry = False
240
+ cancelled = exc
241
+ done = {t for t in (data_task, error_task) if t.done()}
242
+ pending = {t for t in (data_task, error_task) if not t.done()}
233
243
 
234
244
  # Cancel any pending tasks to avoid "Task was destroyed but it is pending" warnings
235
245
  for task in pending:
236
246
  task.cancel()
237
247
 
238
248
  if error_task in done:
239
- exc = error_task.result()
240
- if isinstance(exc, Exception):
241
- raise exc
242
- _LOGGER.error("Unknown error occurred during communication with OmniLogic: %s", exc)
249
+ err = error_task.result()
250
+ if isinstance(err, Exception):
251
+ raise err
252
+ _LOGGER.error("Unknown error occurred during communication with OmniLogic: %s", err)
243
253
  if data_task in done:
244
254
  message = data_task.result()
245
255
  if message.id == ack_id:
@@ -251,6 +261,9 @@ class OmniLogicProtocol(asyncio.DatagramProtocol):
251
261
  await self.data_queue.put(message)
252
262
  return
253
263
 
264
+ if cancelled is not None:
265
+ raise cancelled
266
+
254
267
  async def _ensure_sent(
255
268
  self,
256
269
  message: OmniLogicMessage,
@@ -299,6 +312,7 @@ class OmniLogicProtocol(asyncio.DatagramProtocol):
299
312
  msg_type: MessageType,
300
313
  payload: str | None,
301
314
  msg_id: int | None = None,
315
+ response_timeout: float = DEFAULT_RESPONSE_TIMEOUT,
302
316
  ) -> str:
303
317
  """Send a message and wait for a response, returning the response payload as a string.
304
318
 
@@ -306,12 +320,13 @@ class OmniLogicProtocol(asyncio.DatagramProtocol):
306
320
  msg_type: Type of message to send.
307
321
  payload: Optional payload string.
308
322
  msg_id: Optional message ID.
323
+ response_timeout: Timeout in seconds to wait for the response.
309
324
 
310
325
  Returns:
311
326
  Response payload as a string.
312
327
  """
313
328
  await self.send_message(msg_type, payload, msg_id)
314
- return await self._receive_file()
329
+ return await self._receive_file(response_timeout=response_timeout)
315
330
 
316
331
  # Send a message that you do NOT need a response to
317
332
  async def send_message(
@@ -346,11 +361,14 @@ class OmniLogicProtocol(asyncio.DatagramProtocol):
346
361
  req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING)
347
362
  await self.send_message(MessageType.XML_ACK, req_body, msg_id)
348
363
 
349
- async def _receive_file(self) -> str:
364
+ async def _receive_file(self, response_timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> str:
350
365
  """Wait for and reassemble a full response from the controller.
351
366
 
352
367
  Handles single and multi-block (LeadMessage/BlockMessage) responses.
353
368
 
369
+ Args:
370
+ response_timeout: Timeout in seconds to wait for the initial response.
371
+
354
372
  Returns:
355
373
  Response payload as a string.
356
374
 
@@ -359,13 +377,21 @@ class OmniLogicProtocol(asyncio.DatagramProtocol):
359
377
  OmniFragmentationException: If fragment reassembly fails.
360
378
  """
361
379
  # wait for the initial packet.
362
- message = await self.data_queue.get()
380
+ try:
381
+ message = await asyncio.wait_for(self.data_queue.get(), response_timeout)
382
+ except TimeoutError as exc:
383
+ msg = f"Timeout waiting for response from controller: {exc}"
384
+ raise OmniTimeoutError(msg) from exc
363
385
 
364
386
  # If messages have to be re-transmitted, we can sometimes receive multiple ACKs. The first one would be handled by
365
387
  # self._ensure_sent, but if any subsequent ACKs are sent to us, we need to dump them and wait for a "real" message.
366
388
  while message.type in [MessageType.ACK, MessageType.XML_ACK]:
367
389
  _LOGGER.debug("Skipping duplicate ACK message")
368
- message = await self.data_queue.get()
390
+ try:
391
+ message = await asyncio.wait_for(self.data_queue.get(), response_timeout)
392
+ except TimeoutError as exc:
393
+ msg = f"Timeout waiting for response from controller: {exc}"
394
+ raise OmniTimeoutError(msg) from exc
369
395
 
370
396
  await self._send_ack(message.id)
371
397
 
@@ -403,7 +429,7 @@ class OmniLogicProtocol(asyncio.DatagramProtocol):
403
429
 
404
430
  # We need to wait long enough for the Omni to get through all of it's retries before we bail out.
405
431
  try:
406
- resp = await asyncio.wait_for(self.data_queue.get(), self._omni_retransmit_time * self._omni_retransmit_count)
432
+ resp = await asyncio.wait_for(self.data_queue.get(), response_timeout)
407
433
  except TimeoutError as exc:
408
434
  msg = f"Timeout receiving fragment: got {len(data_fragments)}/{leadmsg.msg_block_count} fragments: {exc}"
409
435
  raise OmniFragmentationError(msg) from exc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-omnilogic-local"
3
- version = "0.27.0"
3
+ version = "0.27.1"
4
4
  description = "A library for local control of Hayward OmniHub/OmniLogic pool controllers using their local API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14.2"