python-omnilogic-local 1.0.0__tar.gz → 1.1.0__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 (65) hide show
  1. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/PKG-INFO +1 -1
  2. python_omnilogic_local-1.1.0/pyomnilogic_local/api/__init__.py +36 -0
  3. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/api/api.py +41 -32
  4. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/api/constants.py +2 -2
  5. python_omnilogic_local-1.1.0/pyomnilogic_local/api/message.py +143 -0
  6. python_omnilogic_local-1.1.0/pyomnilogic_local/api/protocol.py +348 -0
  7. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/chlorinator.py +3 -6
  8. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/pcap_utils.py +1 -1
  9. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/models/leadmessage.py +4 -1
  10. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/omnitypes.py +2 -1
  11. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyproject.toml +1 -1
  12. python_omnilogic_local-1.0.0/pyomnilogic_local/api/__init__.py +0 -14
  13. python_omnilogic_local-1.0.0/pyomnilogic_local/api/protocol.py +0 -473
  14. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/LICENSE +0 -0
  15. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/README.md +0 -0
  16. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/__init__.py +0 -0
  17. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/_base.py +0 -0
  18. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/api/exceptions.py +0 -0
  19. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/api/mock_api.py +0 -0
  20. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/backyard.py +0 -0
  21. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/bow.py +0 -0
  22. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/chlorinator_equip.py +0 -0
  23. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/__init__.py +0 -0
  24. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/cli.py +0 -0
  25. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/debug/__init__.py +0 -0
  26. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/debug/commands.py +0 -0
  27. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/__init__.py +0 -0
  28. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/backyard.py +0 -0
  29. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/bows.py +0 -0
  30. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/chlorinators.py +0 -0
  31. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/commands.py +0 -0
  32. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/csads.py +0 -0
  33. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/filters.py +0 -0
  34. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/groups.py +0 -0
  35. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/heaters.py +0 -0
  36. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/lights.py +0 -0
  37. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/pumps.py +0 -0
  38. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/relays.py +0 -0
  39. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/schedules.py +0 -0
  40. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/sensors.py +0 -0
  41. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/get/valves.py +0 -0
  42. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/cli/utils.py +0 -0
  43. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/collections.py +0 -0
  44. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/colorlogiclight.py +0 -0
  45. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/csad.py +0 -0
  46. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/csad_equip.py +0 -0
  47. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/decorators.py +0 -0
  48. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/filter.py +0 -0
  49. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/groups.py +0 -0
  50. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/heater.py +0 -0
  51. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/heater_equip.py +0 -0
  52. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/models/__init__.py +0 -0
  53. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/models/const.py +0 -0
  54. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/models/exceptions.py +0 -0
  55. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/models/filter_diagnostics.py +0 -0
  56. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/models/mspconfig.py +0 -0
  57. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/models/telemetry.py +0 -0
  58. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/omnilogic.py +0 -0
  59. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/pump.py +0 -0
  60. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/py.typed +0 -0
  61. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/relay.py +0 -0
  62. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/schedule.py +0 -0
  63. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/sensor.py +0 -0
  64. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/system.py +0 -0
  65. {python_omnilogic_local-1.0.0 → python_omnilogic_local-1.1.0}/pyomnilogic_local/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-omnilogic-local
3
- Version: 1.0.0
3
+ Version: 1.1.0
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>
@@ -0,0 +1,36 @@
1
+ """API module for interacting with Hayward OmniLogic pool controllers.
2
+
3
+ This module provides the OmniLogicAPI class, which allows for local
4
+ control and monitoring of Hayward OmniLogic and OmniHub pool controllers
5
+ over a local network connection via the UDP XML API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .api import OmniLogicAPI
11
+ from .exceptions import (
12
+ OmniCommandError,
13
+ OmniConnectionError,
14
+ OmniFragmentationError,
15
+ OmniLogicError,
16
+ OmniMessageFormatError,
17
+ OmniProtocolError,
18
+ OmniTimeoutError,
19
+ OmniValidationError,
20
+ )
21
+ from .message import OmniLogicMessage
22
+ from .protocol import OmniLogicProtocol
23
+
24
+ __all__ = [
25
+ "OmniCommandError",
26
+ "OmniConnectionError",
27
+ "OmniFragmentationError",
28
+ "OmniLogicAPI",
29
+ "OmniLogicError",
30
+ "OmniLogicMessage",
31
+ "OmniLogicProtocol",
32
+ "OmniMessageFormatError",
33
+ "OmniProtocolError",
34
+ "OmniTimeoutError",
35
+ "OmniValidationError",
36
+ ]
@@ -111,30 +111,39 @@ class OmniLogicAPI:
111
111
  self.controller_port = controller_port
112
112
  self.response_timeout = response_timeout
113
113
 
114
- @overload
115
- async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[True]) -> str: ...
116
- @overload
117
- async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[False]) -> None: ...
118
- async def async_send_message(self, message_type: MessageType, message: str | None, need_response: bool = False) -> str | None:
114
+ async def async_send(self, message_type: MessageType, message: str) -> None:
119
115
  """Send a message via the Hayward Omni UDP protocol along with properly handling timeouts and responses.
120
116
 
121
117
  Args:
122
118
  message_type (MessageType): A selection from MessageType indicating what type of communication you are sending
123
- message (str | None): The XML body of the message to deliver
124
- need_response (bool, optional): Should a response be received and returned to the caller. Defaults to False.
119
+ message (str): The XML body of the message to deliver
120
+
121
+ Returns:
122
+ None
123
+ """
124
+ loop = asyncio.get_running_loop()
125
+ transport, protocol = await loop.create_datagram_endpoint(OmniLogicProtocol, remote_addr=(self.controller_ip, self.controller_port))
126
+
127
+ try:
128
+ await protocol.async_send(message_type, message)
129
+ finally:
130
+ transport.close()
131
+
132
+ async def async_send_and_receive(self, message_type: MessageType, message: str) -> str:
133
+ """Convenience method to send a message and receive a response without needing to specify need_response every time.
134
+
135
+ Args:
136
+ message_type (MessageType): A selection from MessageType indicating what type of communication you are sending
137
+ message (str): The message payload to send.
125
138
 
126
139
  Returns:
127
- str | None: The response body sent from the Omni if need_response indicates that a response will be sent
140
+ str: The response body sent from the Omni
128
141
  """
129
142
  loop = asyncio.get_running_loop()
130
143
  transport, protocol = await loop.create_datagram_endpoint(OmniLogicProtocol, remote_addr=(self.controller_ip, self.controller_port))
131
144
 
132
- resp: str | None = None
133
145
  try:
134
- if need_response:
135
- resp = await protocol.send_and_receive(message_type, message, response_timeout=self.response_timeout)
136
- else:
137
- await protocol.send_message(message_type, message)
146
+ resp = await protocol.async_send_and_receive(message_type, message)
138
147
  finally:
139
148
  transport.close()
140
149
 
@@ -164,7 +173,7 @@ class OmniLogicAPI:
164
173
 
165
174
  _LOGGER.debug("Sending RequestConfiguration with body: %s", req_body)
166
175
 
167
- resp = await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True)
176
+ resp = await self.async_send_and_receive(MessageType.REQUEST_CONFIGURATION, req_body)
168
177
 
169
178
  _LOGGER.debug("Received response for RequestConfiguration: %s", resp)
170
179
 
@@ -206,7 +215,7 @@ class OmniLogicAPI:
206
215
 
207
216
  _LOGGER.debug("Sending GetUIFilterDiagnosticInfo with body: %s", req_body)
208
217
 
209
- resp = await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True)
218
+ resp = await self.async_send_and_receive(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body)
210
219
 
211
220
  _LOGGER.debug("Received response for GetUIFilterDiagnosticInfo: %s", resp)
212
221
 
@@ -235,7 +244,7 @@ class OmniLogicAPI:
235
244
 
236
245
  _LOGGER.debug("Sending RequestTelemetryData with body: %s", req_body)
237
246
 
238
- resp = await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True)
247
+ resp = await self.async_send_and_receive(MessageType.GET_TELEMETRY, req_body)
239
248
 
240
249
  _LOGGER.debug("Received response for RequestTelemetryData: %s", resp)
241
250
 
@@ -276,7 +285,7 @@ class OmniLogicAPI:
276
285
 
277
286
  _LOGGER.debug("Sending SetUIHeaterCmd with body: %s", req_body)
278
287
 
279
- return await self.async_send_message(MessageType.SET_HEATER_COMMAND, req_body, False)
288
+ return await self.async_send(MessageType.SET_HEATER_COMMAND, req_body)
280
289
 
281
290
  async def async_set_solar_heater(
282
291
  self,
@@ -311,7 +320,7 @@ class OmniLogicAPI:
311
320
 
312
321
  _LOGGER.debug("Sending SetUISolarSetPointCmd with body: %s", req_body)
313
322
 
314
- return await self.async_send_message(MessageType.SET_SOLAR_SET_POINT_COMMAND, req_body, False)
323
+ return await self.async_send(MessageType.SET_SOLAR_SET_POINT_COMMAND, req_body)
315
324
 
316
325
  async def async_set_heater_mode(
317
326
  self,
@@ -346,7 +355,7 @@ class OmniLogicAPI:
346
355
 
347
356
  _LOGGER.debug("Sending SetUIHeaterModeCmd with body: %s", req_body)
348
357
 
349
- return await self.async_send_message(MessageType.SET_HEATER_MODE_COMMAND, req_body, False)
358
+ return await self.async_send(MessageType.SET_HEATER_MODE_COMMAND, req_body)
350
359
 
351
360
  async def async_set_heater_enable(
352
361
  self,
@@ -381,7 +390,7 @@ class OmniLogicAPI:
381
390
 
382
391
  _LOGGER.debug("Sending SetHeaterEnable with body: %s", req_body)
383
392
 
384
- return await self.async_send_message(MessageType.SET_HEATER_ENABLED, req_body, False)
393
+ return await self.async_send(MessageType.SET_HEATER_ENABLED, req_body)
385
394
 
386
395
  async def async_set_equipment(
387
396
  self,
@@ -443,7 +452,7 @@ class OmniLogicAPI:
443
452
 
444
453
  _LOGGER.debug("Sending SetUIEquipmentCmd with body: %s", req_body)
445
454
 
446
- return await self.async_send_message(MessageType.SET_EQUIPMENT, req_body, False)
455
+ return await self.async_send(MessageType.SET_EQUIPMENT, req_body)
447
456
 
448
457
  async def async_set_filter_speed(self, pool_id: int, equipment_id: int, speed: int) -> None:
449
458
  """Set the speed for a variable speed filter/pump.
@@ -471,7 +480,7 @@ class OmniLogicAPI:
471
480
 
472
481
  _LOGGER.debug("Sending SetUIFilterSpeedCmd with body: %s", req_body)
473
482
 
474
- return await self.async_send_message(MessageType.SET_FILTER_SPEED, req_body, False)
483
+ return await self.async_send(MessageType.SET_FILTER_SPEED, req_body)
475
484
 
476
485
  async def async_set_light_show(
477
486
  self,
@@ -543,7 +552,7 @@ class OmniLogicAPI:
543
552
 
544
553
  _LOGGER.debug("Sending SetStandAloneLightShow with body: %s", req_body)
545
554
 
546
- return await self.async_send_message(MessageType.SET_STANDALONE_LIGHT_SHOW, req_body, False)
555
+ return await self.async_send(MessageType.SET_STANDALONE_LIGHT_SHOW, req_body)
547
556
 
548
557
  async def async_set_chlorinator_enable(self, pool_id: int, enabled: int | bool) -> None:
549
558
  body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE})
@@ -561,7 +570,7 @@ class OmniLogicAPI:
561
570
 
562
571
  _LOGGER.debug("Sending SetCHLOREnable with body: %s", req_body)
563
572
 
564
- return await self.async_send_message(MessageType.SET_CHLOR_ENABLED, req_body, False)
573
+ return await self.async_send(MessageType.SET_CHLOR_ENABLED, req_body)
565
574
 
566
575
  # This is used to set the ORP target value on a CSAD
567
576
  async def async_set_csad_orp_target_level(
@@ -587,7 +596,7 @@ class OmniLogicAPI:
587
596
 
588
597
  _LOGGER.debug("Sending SetUICSADORPTargetLevel with body: %s", req_body)
589
598
 
590
- return await self.async_send_message(MessageType.SET_CSAD_ORP_TARGET, req_body, False)
599
+ return await self.async_send(MessageType.SET_CSAD_ORP_TARGET, req_body)
591
600
 
592
601
  # This is used to set the pH target value on a CSAD
593
602
  async def async_set_csad_target_value(
@@ -613,7 +622,7 @@ class OmniLogicAPI:
613
622
 
614
623
  _LOGGER.debug("Sending UISetCSADTargetValue with body: %s", req_body)
615
624
 
616
- return await self.async_send_message(MessageType.SET_CSAD_TARGET_VALUE, req_body, False)
625
+ return await self.async_send(MessageType.SET_CSAD_TARGET_VALUE, req_body)
617
626
 
618
627
  async def async_set_chlorinator_params(
619
628
  self,
@@ -656,7 +665,7 @@ class OmniLogicAPI:
656
665
 
657
666
  _LOGGER.debug("Sending SetCHLORParams with body: %s", req_body)
658
667
 
659
- return await self.async_send_message(MessageType.SET_CHLOR_PARAMS, req_body, False)
668
+ return await self.async_send(MessageType.SET_CHLOR_PARAMS, req_body)
660
669
 
661
670
  async def async_set_chlorinator_superchlorinate(
662
671
  self,
@@ -681,7 +690,7 @@ class OmniLogicAPI:
681
690
 
682
691
  _LOGGER.debug("Sending SetUISuperCHLORCmd with body: %s", req_body)
683
692
 
684
- return await self.async_send_message(MessageType.SET_SUPERCHLORINATE, req_body, False)
693
+ return await self.async_send(MessageType.SET_SUPERCHLORINATE, req_body)
685
694
 
686
695
  async def async_restore_idle_state(self) -> None:
687
696
  body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE})
@@ -695,7 +704,7 @@ class OmniLogicAPI:
695
704
 
696
705
  _LOGGER.debug("Sending RestoreIdleState with body: %s", req_body)
697
706
 
698
- return await self.async_send_message(MessageType.RESTORE_IDLE_STATE, req_body, False)
707
+ return await self.async_send(MessageType.RESTORE_IDLE_STATE, req_body)
699
708
 
700
709
  async def async_set_spillover(
701
710
  self,
@@ -738,7 +747,7 @@ class OmniLogicAPI:
738
747
 
739
748
  _LOGGER.debug("Sending SetUISpilloverCmd with body: %s", req_body)
740
749
 
741
- return await self.async_send_message(MessageType.SET_SPILLOVER, req_body, False)
750
+ return await self.async_send(MessageType.SET_SPILLOVER, req_body)
742
751
 
743
752
  async def async_set_group_enable(
744
753
  self,
@@ -781,7 +790,7 @@ class OmniLogicAPI:
781
790
 
782
791
  _LOGGER.debug("Sending RunGroupCmd with body: %s", req_body)
783
792
 
784
- return await self.async_send_message(MessageType.RUN_GROUP_CMD, req_body, False)
793
+ return await self.async_send(MessageType.RUN_GROUP_CMD, req_body)
785
794
 
786
795
  async def async_edit_schedule(
787
796
  self,
@@ -854,4 +863,4 @@ class OmniLogicAPI:
854
863
 
855
864
  _LOGGER.debug("Sending EditUIScheduleCmd with body: %s", req_body)
856
865
 
857
- return await self.async_send_message(MessageType.EDIT_SCHEDULE, req_body, False)
866
+ return await self.async_send(MessageType.EDIT_SCHEDULE, req_body)
@@ -11,9 +11,9 @@ PROTOCOL_VERSION = "1.19" # Current protocol version
11
11
  BLOCK_MESSAGE_HEADER_OFFSET = 8 # Offset to skip block message header and get to payload
12
12
 
13
13
  # Timing Constants (in seconds)
14
- OMNI_RETRANSMIT_TIME = 2.1 # Time Omni waits before retransmitting a packet
14
+ OMNI_RETRANSMIT_TIME = 2 # 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 = 1 # Timeout waiting for ACK response, 0.5 showed to be just a tad too short in some cases.
16
+ ACK_WAIT_TIMEOUT = OMNI_RETRANSMIT_TIME * 2 # Timeout waiting for ACK response, 0.5 showed to be just a tad too short in some cases.
17
17
  DEFAULT_RESPONSE_TIMEOUT = OMNI_RETRANSMIT_TIME * OMNI_RETRANSMIT_COUNT # Default timeout for receiving responses
18
18
 
19
19
  # Network Constants
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import struct
5
+ import time
6
+ from typing import Self
7
+
8
+ from pyomnilogic_local.omnitypes import ClientType, MessageType
9
+
10
+ from .constants import (
11
+ PROTOCOL_HEADER_FORMAT,
12
+ PROTOCOL_HEADER_SIZE,
13
+ PROTOCOL_VERSION,
14
+ )
15
+ from .exceptions import OmniMessageFormatError
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ class OmniLogicMessage:
21
+ """A protocol message for communication with the OmniLogic controller.
22
+
23
+ Handles serialization and deserialization of message headers and payloads.
24
+ """
25
+
26
+ header_format = PROTOCOL_HEADER_FORMAT
27
+ id: int
28
+ type: MessageType
29
+ payload: bytes
30
+ client_type: ClientType = ClientType.SIMPLE
31
+ version: str = PROTOCOL_VERSION
32
+ timestamp: int
33
+ reserved_1: int = 0
34
+ compressed: bool = False
35
+ reserved_2: int = 0
36
+
37
+ def __init__(
38
+ self,
39
+ msg_id: int,
40
+ msg_type: MessageType,
41
+ payload: str | None = None,
42
+ version: str = PROTOCOL_VERSION,
43
+ timestamp: int | None = None,
44
+ ) -> None:
45
+ """Initialize a new OmniLogicMessage.
46
+
47
+ Args:
48
+ msg_id: Unique message identifier.
49
+ msg_type: Type of message being sent.
50
+ payload: Optional string payload (XML or command body).
51
+ version: Protocol version string.
52
+ timestamp: Optional timestamp for the message.
53
+ """
54
+ self.id = msg_id
55
+ self.type = msg_type
56
+ # If we are speaking the XML API, it seems like we need client_type 0, otherwise we need client_type 1
57
+ self.client_type = ClientType.XML if payload is not None else ClientType.SIMPLE
58
+ # The Hayward API terminates it's messages with a null character
59
+ payload = f"{payload}\x00" if payload is not None else ""
60
+ self.payload = bytes(payload, "utf-8")
61
+
62
+ self.version = version
63
+ self.timestamp = timestamp if timestamp is not None else int(time.time())
64
+
65
+ def __bytes__(self) -> bytes:
66
+ """Serialize the message to bytes for UDP transmission.
67
+
68
+ Returns:
69
+ Byte representation of the message.
70
+ """
71
+ header = struct.pack(
72
+ self.header_format,
73
+ self.id, # Msg id
74
+ self.timestamp,
75
+ bytes(self.version, "ascii"), # version string
76
+ self.type.value, # OpID/msgType
77
+ self.client_type.value, # Client type
78
+ 0, # reserved
79
+ self.compressed, # compressed
80
+ 0, # reserved
81
+ )
82
+ return header + self.payload
83
+
84
+ def __repr__(self) -> str:
85
+ """Return a string representation of the message for debugging."""
86
+ if self.compressed or self.type is MessageType.MSP_BLOCKMESSAGE:
87
+ return f"ID: {self.id}, Type: {self.type.name}, Compressed: {self.compressed}, Client: {self.client_type.name}"
88
+ return (
89
+ f"ID: {self.id}, Type: {self.type.name}, Compressed: {self.compressed}, Client: {self.client_type.name}, "
90
+ f"Body: {self.payload[:-1].decode('utf-8')}"
91
+ )
92
+
93
+ @classmethod
94
+ def from_bytes(cls, data: bytes) -> Self:
95
+ """Parse a message from its byte representation.
96
+
97
+ Args:
98
+ data: Byte data received from the controller.
99
+
100
+ Returns:
101
+ OmniLogicMessage instance.
102
+
103
+ Raises:
104
+ OmniMessageFormatException: If the message format is invalid.
105
+ """
106
+ if len(data) < PROTOCOL_HEADER_SIZE:
107
+ msg = f"Message too short: {len(data)} bytes, expected at least {PROTOCOL_HEADER_SIZE}"
108
+ raise OmniMessageFormatError(msg)
109
+
110
+ # split the header and data
111
+ header = data[:PROTOCOL_HEADER_SIZE]
112
+ rdata: bytes = data[PROTOCOL_HEADER_SIZE:]
113
+
114
+ try:
115
+ (msg_id, tstamp, vers, msg_type, client_type, res1, compressed, res2) = struct.unpack(cls.header_format, header)
116
+ except struct.error as exc:
117
+ msg = f"Failed to unpack message header: {exc}"
118
+ raise OmniMessageFormatError(msg) from exc
119
+
120
+ # Validate message type
121
+ try:
122
+ message_type_enum = MessageType(msg_type)
123
+ except ValueError as exc:
124
+ msg = f"Unknown message type: {msg_type}: {exc}"
125
+ raise OmniMessageFormatError(msg) from exc
126
+
127
+ # Validate client type
128
+ try:
129
+ client_type_enum = ClientType(int(client_type))
130
+ except ValueError as exc:
131
+ msg = f"Unknown client type: {client_type}: {exc}"
132
+ raise OmniMessageFormatError(msg) from exc
133
+
134
+ message = cls(msg_id=msg_id, msg_type=message_type_enum, version=vers.decode("utf-8"))
135
+ message.timestamp = tstamp
136
+ message.client_type = client_type_enum
137
+ message.reserved_1 = res1
138
+ # There are some messages that are ALWAYS compressed although they do not return a 1 in their LeadMessage
139
+ message.compressed = compressed == 1 or message.type in [MessageType.MSP_TELEMETRY_UPDATE]
140
+ message.reserved_2 = res2
141
+ message.payload = rdata
142
+
143
+ return message