python-omnilogic-local 1.1.1__tar.gz → 2.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 (64) hide show
  1. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/PKG-INFO +1 -1
  2. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/api/api.py +1 -1
  3. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/bow.py +5 -5
  4. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/debug/commands.py +1 -1
  5. python_omnilogic_local-2.1.0/pyomnilogic_local/cli/get/bows.py +53 -0
  6. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/csad.py +58 -77
  7. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/models/mspconfig.py +18 -6
  8. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/omnilogic.py +1 -3
  9. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/omnitypes.py +7 -7
  10. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/schedule.py +5 -0
  11. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyproject.toml +1 -1
  12. python_omnilogic_local-1.1.1/pyomnilogic_local/cli/get/bows.py +0 -100
  13. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/LICENSE +0 -0
  14. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/README.md +0 -0
  15. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/__init__.py +0 -0
  16. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/_base.py +0 -0
  17. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/api/__init__.py +0 -0
  18. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/api/constants.py +0 -0
  19. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/api/exceptions.py +0 -0
  20. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/api/message.py +0 -0
  21. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/api/mock_api.py +0 -0
  22. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/api/protocol.py +0 -0
  23. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/backyard.py +0 -0
  24. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/chlorinator.py +0 -0
  25. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/chlorinator_equip.py +0 -0
  26. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/__init__.py +0 -0
  27. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/cli.py +0 -0
  28. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/debug/__init__.py +0 -0
  29. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/__init__.py +0 -0
  30. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/backyard.py +0 -0
  31. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/chlorinators.py +0 -0
  32. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/commands.py +0 -0
  33. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/csads.py +0 -0
  34. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/filters.py +0 -0
  35. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/groups.py +0 -0
  36. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/heaters.py +0 -0
  37. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/lights.py +0 -0
  38. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/pumps.py +0 -0
  39. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/relays.py +0 -0
  40. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/schedules.py +0 -0
  41. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/sensors.py +0 -0
  42. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/get/valves.py +0 -0
  43. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/pcap_utils.py +0 -0
  44. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/cli/utils.py +0 -0
  45. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/collections.py +0 -0
  46. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/colorlogiclight.py +0 -0
  47. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/csad_equip.py +0 -0
  48. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/decorators.py +0 -0
  49. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/filter.py +0 -0
  50. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/groups.py +0 -0
  51. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/heater.py +0 -0
  52. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/heater_equip.py +0 -0
  53. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/models/__init__.py +0 -0
  54. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/models/const.py +0 -0
  55. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/models/exceptions.py +0 -0
  56. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/models/filter_diagnostics.py +0 -0
  57. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/models/leadmessage.py +0 -0
  58. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/models/telemetry.py +0 -0
  59. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/pump.py +0 -0
  60. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/py.typed +0 -0
  61. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/relay.py +0 -0
  62. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/sensor.py +0 -0
  63. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.1.0}/pyomnilogic_local/system.py +0 -0
  64. {python_omnilogic_local-1.1.1 → python_omnilogic_local-2.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.1.1
3
+ Version: 2.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>
@@ -599,7 +599,7 @@ class OmniLogicAPI:
599
599
  return await self.async_send(MessageType.SET_CSAD_ORP_TARGET, req_body)
600
600
 
601
601
  # This is used to set the pH target value on a CSAD
602
- async def async_set_csad_target_value(
602
+ async def async_set_csad_ph_target_value(
603
603
  self,
604
604
  pool_id: int,
605
605
  csad_id: int,
@@ -139,7 +139,7 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]):
139
139
  lights: EquipmentDict[ColorLogicLight] = EquipmentDict()
140
140
  pumps: EquipmentDict[Pump] = EquipmentDict()
141
141
  chlorinator: Chlorinator | None = None
142
- csads: EquipmentDict[CSAD] = EquipmentDict()
142
+ csad: CSAD | None = None
143
143
 
144
144
  def __init__(self, omni: OmniLogic, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
145
145
  super().__init__(omni, mspconfig, telemetry)
@@ -164,8 +164,8 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]):
164
164
  parts.append("heater=True")
165
165
  if self.chlorinator is not None:
166
166
  parts.append("chlorinator=True")
167
- if len(self.csads) > 0:
168
- parts.append(f"csads={len(self.csads)}")
167
+ if self.csad is not None:
168
+ parts.append("csad=True")
169
169
 
170
170
  return f"Bow({', '.join(parts)})"
171
171
 
@@ -277,10 +277,10 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]):
277
277
  def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
278
278
  """Update the CSADs based on the MSP configuration."""
279
279
  if mspconfig.csad is None:
280
- self.csads = EquipmentDict()
280
+ self.csad = None
281
281
  return
282
282
 
283
- self.csads = EquipmentDict([CSAD(self._omni, csad, telemetry) for csad in mspconfig.csad])
283
+ self.csad = CSAD(self._omni, mspconfig.csad, telemetry)
284
284
 
285
285
  def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
286
286
  """Update the filters based on the MSP configuration."""
@@ -306,7 +306,7 @@ def set_csad_ph(ctx: click.Context, bow_id: int, csad_id: int, target: float) ->
306
306
 
307
307
  # Execute the command
308
308
  try:
309
- asyncio.run(omnilogic._api.async_set_csad_target_value(pool_id=bow_id, csad_id=csad_id, ph_target=target))
309
+ asyncio.run(omnilogic._api.async_set_csad_ph_target_value(pool_id=bow_id, csad_id=csad_id, ph_target=target))
310
310
  click.echo(f"Successfully set CSAD {csad_id} in BOW {bow_id} pH target to {target}")
311
311
  except Exception as e:
312
312
  click.echo(f"Error setting CSAD pH target: {e}", err=True)
@@ -0,0 +1,53 @@
1
+ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators
2
+ # mypy: disable-error-code="misc"
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ import click
9
+
10
+ from pyomnilogic_local.cli.utils import echo_properties
11
+
12
+ if TYPE_CHECKING:
13
+ from pyomnilogic_local import OmniLogic
14
+
15
+
16
+ @click.command()
17
+ @click.pass_context
18
+ def bows(ctx: click.Context) -> None:
19
+ """List all Bodies of Water (BOWs) and their current status.
20
+
21
+ Displays information about all bodies of water including their system IDs,
22
+ names, types (pool/spa), water temperature, flow status, and attached equipment.
23
+
24
+ Example:
25
+ omnilogic get bows
26
+ """
27
+ omnilogic: OmniLogic = ctx.obj["OMNILOGIC"]
28
+ all_bows = omnilogic.all_bows
29
+ for bow in all_bows:
30
+ echo_properties(bow)
31
+ click.echo("\n Equipment Counts:")
32
+
33
+ _print_equipment_count("Filter", len(bow.filters))
34
+ _print_equipment_count("Pump", len(bow.pumps))
35
+ _print_equipment_count("Heater (virtual)", 1 if bow.heater else 0)
36
+ _print_equipment_count("Heaters (physical)", len(bow.heater.heater_equipment) if bow.heater else 0)
37
+ _print_equipment_count("Sensors", len(bow.sensors))
38
+ _print_equipment_count("Lights", len(bow.lights))
39
+ _print_equipment_count("Relays", len(bow.relays))
40
+ _print_equipment_count("Chlorinator (virtual)", 1 if bow.chlorinator else 0)
41
+ _print_equipment_count("Chlorinators (physical)", len(bow.chlorinator.chlorinator_equipment) if bow.chlorinator else 0)
42
+ _print_equipment_count("CSAD (virtual)", 1 if bow.csad else 0)
43
+ _print_equipment_count("CSADs (physical)", len(bow.csad.csad_equipment) if bow.csad else 0)
44
+
45
+ click.echo("=" * 60)
46
+
47
+ if len(all_bows) == 0:
48
+ click.echo("No Bodies of Water found in the system configuration.")
49
+
50
+
51
+ def _print_equipment_count(name: str, count: int) -> None:
52
+ """Helper function to print equipment counts with styling."""
53
+ click.echo(f" - {click.style(name, fg='green')}: {count}")
@@ -5,9 +5,11 @@ from typing import TYPE_CHECKING
5
5
  from pyomnilogic_local._base import OmniEquipment
6
6
  from pyomnilogic_local.collections import EquipmentDict
7
7
  from pyomnilogic_local.csad_equip import CSADEquipment
8
+ from pyomnilogic_local.decorators import control_method
8
9
  from pyomnilogic_local.models.mspconfig import MSPCSAD
9
10
  from pyomnilogic_local.models.telemetry import TelemetryCSAD
10
11
  from pyomnilogic_local.omnitypes import CSADMode, CSADStatus
12
+ from pyomnilogic_local.util import OmniEquipmentNotInitializedError
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from pyomnilogic_local.models.telemetry import Telemetry
@@ -74,30 +76,45 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]):
74
76
  return self.mspconfig.equip_type
75
77
 
76
78
  @property
77
- def target_ph(self) -> float:
79
+ def ph_target_level(self) -> float:
78
80
  """Target pH level that the CSAD aims to maintain."""
79
81
  return self.mspconfig.target_value
80
82
 
81
83
  @property
82
- def calibration_value(self) -> float:
84
+ def ph_current_value(self) -> float:
85
+ """Current pH level reading from the sensor, including calibration offset."""
86
+ return self.telemetry.ph + self.ph_calibration_value
87
+
88
+ @property
89
+ def ph_current_value_raw(self) -> float:
90
+ """Current pH level reading from the sensor without calibration offset."""
91
+ return self.telemetry.ph
92
+
93
+ @property
94
+ def ph_calibration_value(self) -> float:
83
95
  """Calibration offset value for pH sensor."""
84
96
  return self.mspconfig.calibration_value
85
97
 
86
98
  @property
87
- def ph_low_alarm(self) -> float:
99
+ def ph_low_alarm_level(self) -> float:
88
100
  """Low pH threshold for triggering an alarm."""
89
- return self.mspconfig.ph_low_alarm_value
101
+ return self.mspconfig.ph_low_alarm_level
90
102
 
91
103
  @property
92
- def ph_high_alarm(self) -> float:
104
+ def ph_high_alarm_level(self) -> float:
93
105
  """High pH threshold for triggering an alarm."""
94
- return self.mspconfig.ph_high_alarm_value
106
+ return self.mspconfig.ph_high_alarm_level
95
107
 
96
108
  @property
97
109
  def orp_target_level(self) -> int:
98
110
  """Target ORP (Oxidation-Reduction Potential) level in millivolts."""
99
111
  return self.mspconfig.orp_target_level
100
112
 
113
+ @property
114
+ def orp_current_level(self) -> int:
115
+ """Current ORP (Oxidation-Reduction Potential) reading in millivolts."""
116
+ return self.telemetry.orp
117
+
101
118
  @property
102
119
  def orp_runtime_level(self) -> int:
103
120
  """ORP runtime level threshold in millivolts."""
@@ -129,36 +146,6 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]):
129
146
  """Raw status value from telemetry."""
130
147
  return self.telemetry.status
131
148
 
132
- @property
133
- def current_ph(self) -> float:
134
- """Current pH level reading from the sensor.
135
-
136
- Returns:
137
- Current pH level (typically 0-14, where 7 is neutral)
138
-
139
- Example:
140
- >>> print(f"pH: {csad.current_ph:.2f}")
141
- """
142
- return self.telemetry.ph
143
-
144
- @property
145
- def current_orp(self) -> int:
146
- """Current ORP (Oxidation-Reduction Potential) reading in millivolts.
147
-
148
- Note:
149
- ORP readings in CSAD telemetry are provided for monitoring purposes to
150
- assess chlorinator effectiveness. The ORP sensor output is primarily
151
- used by the chlorinator function for ORP-based automatic chlorine
152
- generation control. The CSAD system focuses on pH control.
153
-
154
- Returns:
155
- Current ORP level in mV (typically 400-800 mV for pools)
156
-
157
- Example:
158
- >>> print(f"ORP: {csad.current_orp} mV")
159
- """
160
- return self.telemetry.orp
161
-
162
149
  @property
163
150
  def mode(self) -> CSADMode:
164
151
  """Current operating mode of the CSAD.
@@ -234,8 +221,8 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]):
234
221
  >>> if csad.has_alert:
235
222
  ... print(f"Alert! {csad.alert_status}")
236
223
  """
237
- ph_alert = self.current_ph < self.ph_low_alarm or self.current_ph > self.ph_high_alarm
238
- orp_alert = self.current_orp < self.orp_low_alarm_level or self.current_orp > self.orp_high_alarm_level
224
+ ph_alert = self.ph_current_value < self.ph_low_alarm_level or self.ph_current_value > self.ph_high_alarm_level
225
+ orp_alert = self.orp_current_level < self.orp_low_alarm_level or self.orp_current_level > self.orp_high_alarm_level
239
226
  return ph_alert or orp_alert
240
227
 
241
228
  @property
@@ -252,54 +239,48 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]):
252
239
  """
253
240
  alerts = []
254
241
 
255
- if self.current_ph < self.ph_low_alarm:
256
- alerts.append(f"pH too low ({self.current_ph:.2f} < {self.ph_low_alarm:.2f})")
257
- elif self.current_ph > self.ph_high_alarm:
258
- alerts.append(f"pH too high ({self.current_ph:.2f} > {self.ph_high_alarm:.2f})")
242
+ if self.ph_current_value < self.ph_low_alarm_level:
243
+ alerts.append(f"pH too low ({self.ph_current_value:.2f} < {self.ph_low_alarm_level:.2f})")
244
+ elif self.ph_current_value > self.ph_high_alarm_level:
245
+ alerts.append(f"pH too high ({self.ph_current_value:.2f} > {self.ph_high_alarm_level:.2f})")
259
246
 
260
- if self.current_orp < self.orp_low_alarm_level:
261
- alerts.append(f"ORP too low ({self.current_orp} < {self.orp_low_alarm_level} mV)")
262
- elif self.current_orp > self.orp_high_alarm_level:
263
- alerts.append(f"ORP too high ({self.current_orp} > {self.orp_high_alarm_level} mV)")
247
+ if self.orp_current_level < self.orp_low_alarm_level:
248
+ alerts.append(f"ORP too low ({self.orp_current_level} < {self.orp_low_alarm_level} mV)")
249
+ elif self.orp_current_level > self.orp_high_alarm_level:
250
+ alerts.append(f"ORP too high ({self.orp_current_level} > {self.orp_high_alarm_level} mV)")
264
251
 
265
252
  return "; ".join(alerts) if alerts else "OK"
266
253
 
267
- @property
268
- def current_value(self) -> float:
269
- """Get the primary current value being monitored (pH for most CSAD systems).
270
-
271
- Returns:
272
- Current pH level
254
+ @control_method
255
+ async def set_ph_target(self, ph_target: float) -> None:
256
+ """Set the target pH for the CSAD.
273
257
 
274
- Note:
275
- For ACID type CSAD, this returns pH. For CO2 type, this also returns pH.
276
- Use current_orp property for ORP readings.
258
+ Raises:
259
+ OmniEquipmentNotInitializedError: If bow_id or system_id is None.
277
260
  """
278
- return self.current_ph
261
+ if self.bow_id is None or self.system_id is None:
262
+ msg = "Cannot set pH: bow_id or system_id is None"
263
+ raise OmniEquipmentNotInitializedError(msg)
279
264
 
280
- @property
281
- def target_value(self) -> float:
282
- """Get the target value the CSAD is trying to maintain (pH target).
265
+ if not 7.0 <= ph_target <= 8.0:
266
+ msg = f"Invalid pH target: {ph_target}. Target pH must be between 7.0 and 8.0"
267
+ raise ValueError(msg)
283
268
 
284
- Returns:
285
- Target pH level
269
+ await self._api.async_set_csad_ph_target_value(pool_id=self.bow_id, csad_id=self.system_id, ph_target=ph_target)
286
270
 
287
- Note:
288
- This is an alias for target_ph for convenience and consistency
289
- with the task requirements.
290
- """
291
- return self.target_ph
271
+ @control_method
272
+ async def set_orp_target(self, orp_target: int) -> None:
273
+ """Set the target ORP for the CSAD.
292
274
 
293
- @property
294
- def ph_offset(self) -> float:
295
- """Calculate how far the current pH is from the target.
275
+ Raises:
276
+ OmniEquipmentNotInitializedError: If bow_id or system_id is None.
277
+ """
278
+ if self.bow_id is None or self.system_id is None:
279
+ msg = "Cannot set ORP: bow_id or system_id is None"
280
+ raise OmniEquipmentNotInitializedError(msg)
296
281
 
297
- Returns:
298
- Difference between current and target pH (positive = too high, negative = too low)
282
+ if not 400 <= orp_target <= 900:
283
+ msg = f"Invalid ORP target: {orp_target}. Target ORP must be between 400 and 900 mV"
284
+ raise ValueError(msg)
299
285
 
300
- Example:
301
- >>> offset = csad.ph_offset
302
- >>> if offset > 0:
303
- ... print(f"pH is {offset:.2f} points above target")
304
- """
305
- return self.current_ph - self.target_ph
286
+ await self._api.async_set_csad_orp_target_level(pool_id=self.bow_id, csad_id=self.system_id, orp_target=orp_target)
@@ -53,6 +53,7 @@ class OmniBase(BaseModel):
53
53
  model_config = ConfigDict(from_attributes=True)
54
54
 
55
55
  _sub_devices: set[str] | None = None
56
+ _propagate_bow_id_devices: set[str] | None = None
56
57
  system_id: int = Field(alias="System-Id")
57
58
  name: str | None = Field(alias="Name", default=None)
58
59
  bow_id: int = -1
@@ -68,7 +69,9 @@ class OmniBase(BaseModel):
68
69
  # If we have no devices under us, we have nothing to do
69
70
  if self._sub_devices is None:
70
71
  return
71
- for subdevice_name in self._sub_devices:
72
+ # If we have a specific list of devices that should receive the bow_id, use that, otherwise propagate to all sub_devices
73
+ propagate_devices = self._propagate_bow_id_devices if self._propagate_bow_id_devices is not None else self._sub_devices
74
+ for subdevice_name in propagate_devices:
72
75
  subdevice = getattr(self, subdevice_name)
73
76
  # If our subdevice is a list of subdevices ...
74
77
  if isinstance(subdevice, list):
@@ -263,8 +266,8 @@ class MSPCSAD(OmniBase):
263
266
  equip_type: CSADType = Field(alias="Type")
264
267
  target_value: float = Field(alias="TargetValue")
265
268
  calibration_value: float = Field(alias="CalibrationValue")
266
- ph_low_alarm_value: float = Field(alias="PHLowAlarmLevel")
267
- ph_high_alarm_value: float = Field(alias="PHHighAlarmLevel")
269
+ ph_low_alarm_level: float = Field(alias="PHLowAlarmLevel")
270
+ ph_high_alarm_level: float = Field(alias="PHHighAlarmLevel")
268
271
  orp_target_level: int = Field(alias="ORP-Target-Level")
269
272
  orp_runtime_level: int = Field(alias="ORP-Runtime-Level")
270
273
  orp_low_alarm_level: int = Field(alias="ORP-Low-Alarm-Level")
@@ -333,7 +336,7 @@ class MSPBoW(OmniBase):
333
336
  colorlogic_light: list[MSPColorLogicLight] | None = Field(alias="ColorLogic-Light", default=None)
334
337
  pump: list[MSPPump] | None = Field(alias="Pump", default=None)
335
338
  chlorinator: MSPChlorinator | None = Field(alias="Chlorinator", default=None)
336
- csad: list[MSPCSAD] | None = Field(alias="CSAD", default=None)
339
+ csad: MSPCSAD | None = Field(alias="CSAD", default=None)
337
340
 
338
341
  # We override the __init__ here so that we can trigger the propagation of the bow_id down to all of it's sub devices after the bow
339
342
  # itself is initialized
@@ -346,7 +349,9 @@ class MSPBoW(OmniBase):
346
349
 
347
350
  class MSPBackyard(OmniBase):
348
351
  _sub_devices = {"sensor", "bow", "colorlogic_light", "relay"}
349
- bow_id: int = -1
352
+ # Only propagate bow_id to sub-devices that are not BoWs as they have their own bow_id that should be used instead
353
+ _propagate_bow_id_devices = {"sensor", "colorlogic_light", "relay"}
354
+ bow_id: int = 0
350
355
 
351
356
  omni_type: OmniType = OmniType.BACKYARD
352
357
 
@@ -355,6 +360,14 @@ class MSPBackyard(OmniBase):
355
360
  relay: list[MSPRelay] | None = Field(alias="Relay", default=None)
356
361
  sensor: list[MSPSensor] | None = Field(alias="Sensor", default=None)
357
362
 
363
+ # We override the __init__ here so that we can trigger the propagation of the bow_id down to all of it's sub devices after the backyard
364
+ # itself is initialized
365
+ def __init__(self, **data: Any) -> None:
366
+ # As we are requiring a bow_id on everything in OmniBase, we need to propagate it down now
367
+ # before calling super().__init__() so that it will be present for validation.
368
+ super().__init__(**data)
369
+ self.propagate_bow_id(self.system_id)
370
+
358
371
 
359
372
  class MSPSchedule(OmniBase):
360
373
  omni_type: OmniType = OmniType.SCHEDULE
@@ -449,7 +462,6 @@ class MSPConfig(BaseModel):
449
462
  # everything that *could* be a list into a list to make the parsing more consistent.
450
463
  force_list=(
451
464
  OmniType.BOW_MSP,
452
- OmniType.CSAD,
453
465
  OmniType.CL_LIGHT,
454
466
  OmniType.FAVORITES,
455
467
  OmniType.FILTER,
@@ -271,9 +271,7 @@ class OmniLogic:
271
271
  @property
272
272
  def all_csads(self) -> EquipmentDict[CSAD]:
273
273
  """Returns all CSAD instances across all bows in the backyard."""
274
- csads: list[CSAD] = []
275
- for bow in self.backyard.bow.values():
276
- csads.extend(bow.csads.values())
274
+ csads = [bow.csad for bow in self.backyard.bow.values() if bow.csad is not None]
277
275
  return EquipmentDict(csads)
278
276
 
279
277
  @property
@@ -90,7 +90,7 @@ class BodyOfWaterType(PrettyEnum, StrEnum):
90
90
 
91
91
 
92
92
  # Chlorinators
93
- class ChlorinatorStatus(Flag):
93
+ class ChlorinatorStatus(PrettyEnum, Flag):
94
94
  """Chlorinator status flags.
95
95
 
96
96
  These flags represent the current operational state of the chlorinator
@@ -107,7 +107,7 @@ class ChlorinatorStatus(Flag):
107
107
  K2_ACTIVE = 1 << 7 # K2 relay is active
108
108
 
109
109
 
110
- class ChlorinatorAlert(Flag):
110
+ class ChlorinatorAlert(PrettyEnum, Flag):
111
111
  """Chlorinator alert flags.
112
112
 
113
113
  Multi-bit fields are represented by their individual values.
@@ -126,7 +126,7 @@ class ChlorinatorAlert(Flag):
126
126
  CELL_CLEAN = 1 << 11 # Cell cleaning runtime alert
127
127
 
128
128
 
129
- class ChlorinatorError(Flag):
129
+ class ChlorinatorError(PrettyEnum, Flag):
130
130
  """Chlorinator error flags.
131
131
 
132
132
  Multi-bit fields are represented by their individual values.
@@ -419,8 +419,8 @@ class FilterWhyOn(PrettyEnum, IntEnum):
419
419
  GROUP_COMMAND = 18
420
420
  SPILLOVER_INTERLOCK = 19
421
421
  MAX_VALUE = 20
422
- UNKNOWN_1 = 21
423
- UNKNOWN_2 = 22
422
+ UNKNOWN_1 = 21 # https://github.com/cryptk/python-omnilogic-local/issues/101
423
+ UNKNOWN_2 = 22 # https://github.com/cryptk/python-omnilogic-local/issues/101
424
424
 
425
425
 
426
426
  class FilterSpeedPresets(PrettyEnum, StrEnum):
@@ -457,7 +457,7 @@ class HeaterMode(PrettyEnum, IntEnum):
457
457
  HEAT = 0
458
458
  COOL = 1
459
459
  AUTO = 2
460
- UNKNOWN_1 = 3
460
+ UNKNOWN_1 = 3 # https://github.com/cryptk/haomnilogic-local/issues/172
461
461
 
462
462
 
463
463
  # Pumps
@@ -465,7 +465,7 @@ class PumpState(PrettyEnum, IntEnum):
465
465
  OFF = 0
466
466
  ON = 1
467
467
  FREEZE_PROTECT = 2 # This is an assumption that 2 means freeze protect, ref: https://github.com/cryptk/haomnilogic-local/issues/147
468
- UNKNOWN_1 = 3
468
+ UNKNOWN_1 = 3 # We assume this value exists as we have evidence of a state of 4 existing
469
469
  PRIMING = 4 # https://github.com/cryptk/haomnilogic-local/issues/223
470
470
 
471
471
 
@@ -87,6 +87,11 @@ class Schedule(OmniEquipment[MSPSchedule, None]):
87
87
  """Returns the data value for the scheduled action."""
88
88
  return self.mspconfig.data
89
89
 
90
+ @property
91
+ def is_on(self) -> bool:
92
+ """Convenience alias for self.enabled."""
93
+ return self.enabled
94
+
90
95
  @property
91
96
  def enabled(self) -> bool:
92
97
  """Returns whether the schedule is currently enabled."""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-omnilogic-local"
3
- version = "1.1.1"
3
+ version = "2.1.0"
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"
@@ -1,100 +0,0 @@
1
- # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators
2
- # mypy: disable-error-code="misc"
3
-
4
- from __future__ import annotations
5
-
6
- from typing import TYPE_CHECKING, Any
7
-
8
- import click
9
-
10
- from pyomnilogic_local.cli.utils import echo_properties
11
- from pyomnilogic_local.omnitypes import BodyOfWaterType
12
-
13
- if TYPE_CHECKING:
14
- from pyomnilogic_local import OmniLogic
15
- from pyomnilogic_local.models.mspconfig import MSPBoW
16
- from pyomnilogic_local.models.telemetry import TelemetryType
17
-
18
-
19
- @click.command()
20
- @click.pass_context
21
- def bows(ctx: click.Context) -> None:
22
- """List all Bodies of Water (BOWs) and their current status.
23
-
24
- Displays information about all bodies of water including their system IDs,
25
- names, types (pool/spa), water temperature, flow status, and attached equipment.
26
-
27
- Example:
28
- omnilogic get bows
29
- """
30
- omnilogic: OmniLogic = ctx.obj["OMNILOGIC"]
31
- all_bows = omnilogic.all_bows
32
- for bow in all_bows:
33
- echo_properties(bow)
34
-
35
- if len(all_bows) == 0:
36
- click.echo("No Bodies of Water found in the system configuration.")
37
-
38
-
39
- def _print_bow_info(bow: MSPBoW, telemetry: TelemetryType | None) -> None:
40
- """Format and print Body of Water information in a nice table format.
41
-
42
- Args:
43
- bow: BOW object from MSPConfig with attributes to display
44
- telemetry: Telemetry object containing current state information
45
- """
46
- click.echo("\n" + "=" * 60)
47
- click.echo("BODY OF WATER")
48
- click.echo("=" * 60)
49
-
50
- # Combine config and telemetry data
51
- bow_data: dict[Any, Any] = {**dict(bow), **dict(telemetry)} if telemetry else dict(bow)
52
-
53
- # Fields to exclude from main display (we'll show equipment counts instead)
54
- exclude_fields = {"filter", "relay", "heater", "sensor", "colorlogic_light", "pump", "chlorinator", "csad"}
55
-
56
- for attr_name, value in bow_data.items():
57
- if attr_name in exclude_fields:
58
- continue
59
-
60
- if attr_name == "type":
61
- value = str(BodyOfWaterType(value))
62
- elif isinstance(value, list):
63
- # Format lists nicely
64
- value = ", ".join(str(v) for v in value) if value else "None"
65
-
66
- # Format the attribute name to be more readable
67
- display_name = attr_name.replace("_", " ").title()
68
- click.echo(f"{display_name:20} : {value}")
69
-
70
- # Show equipment summary
71
- click.echo("\nAttached Equipment:")
72
- click.echo("-" * 60)
73
-
74
- equipment_counts = []
75
- if bow.filter:
76
- equipment_counts.append(f"Filters: {len(bow.filter)}")
77
- if bow.pump:
78
- equipment_counts.append(f"Pumps: {len(bow.pump)}")
79
- if bow.heater:
80
- equipment_counts.append("Heater: 1 (virtual)")
81
- if bow.heater.heater_equipment:
82
- equipment_counts.append(f" - Physical Heaters: {len(bow.heater.heater_equipment)}")
83
- if bow.sensor:
84
- equipment_counts.append(f"Sensors: {len(bow.sensor)}")
85
- if bow.colorlogic_light:
86
- equipment_counts.append(f"ColorLogic Lights: {len(bow.colorlogic_light)}")
87
- if bow.relay:
88
- equipment_counts.append(f"Relays: {len(bow.relay)}")
89
- if bow.chlorinator:
90
- equipment_counts.append("Chlorinator: 1")
91
- if bow.csad:
92
- equipment_counts.append(f"CSADs: {len(bow.csad)}")
93
-
94
- if equipment_counts:
95
- for count in equipment_counts:
96
- click.echo(f" {count}")
97
- else:
98
- click.echo(" None")
99
-
100
- click.echo("=" * 60)