Skip to content

Commit 808b955

Browse files
committed
feat: begin major refactor of codebase
- provide abstraction layer within library - allows interacting with omnilogic in a more pythonic way
1 parent 13e98ab commit 808b955

24 files changed

Lines changed: 525 additions & 153 deletions

pyomnilogic_local/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .omnilogic import OmniLogic
2+
3+
__all__ = ["OmniLogic"]

pyomnilogic_local/_base.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from typing import Any
2+
3+
from pyomnilogic_local.models import MSPEquipmentType, Telemetry
4+
5+
6+
class OmniEquipment:
7+
"""Base class for OmniLogic equipment."""
8+
9+
def __init__(self, mspconfig: MSPEquipmentType, telemetry: Telemetry | None = None) -> None:
10+
"""Initialize the equipment with configuration and telemetry data."""
11+
# If the Equipment has subdevices, we don't store those as part of this device's config
12+
# They will get parsed and stored as their own equipment instances
13+
try:
14+
self.mspconfig = mspconfig.without_subdevices()
15+
except AttributeError:
16+
self.mspconfig = mspconfig
17+
18+
if hasattr(self, "telemetry") and telemetry is not None:
19+
self.telemetry = telemetry.get_telem_by_systemid(self.mspconfig.system_id)
20+
21+
# Populate fields from MSP configuration and telemetry
22+
# This is some moderate magic to avoid having to manually set each field
23+
# The TL;DR is that we loop over all fields defined in the MSPConfig and Telemetry models
24+
# and set the corresponding attributes on this equipment instance.
25+
for field in self.mspconfig.__class__.model_fields:
26+
if getattr(self.mspconfig, field, None) is not None:
27+
setattr(self, field, self._from_mspconfig(field))
28+
for field in self.mspconfig.__class__.model_computed_fields:
29+
if getattr(self.mspconfig, field, None) is not None:
30+
setattr(self, field, self._from_mspconfig(field))
31+
if hasattr(self, "telemetry") and self.telemetry is not None:
32+
for field in self.telemetry.__class__.model_fields:
33+
if getattr(self.telemetry, field, None) is not None:
34+
setattr(self, field, self._from_telemetry(field))
35+
for field in self.telemetry.__class__.model_computed_fields:
36+
if getattr(self.telemetry, field, None) is not None:
37+
setattr(self, field, self._from_telemetry(field))
38+
39+
def update_config(self, mspconfig: MSPEquipmentType) -> None:
40+
"""Update the configuration data for the equipment."""
41+
if hasattr(self, "mspconfig"):
42+
self.mspconfig = mspconfig.without_subdevices()
43+
else:
44+
raise NotImplementedError("This equipment does not have MSP configuration.")
45+
46+
def update_telemetry(self, telemetry: Telemetry) -> None:
47+
"""Update the telemetry data for the equipment."""
48+
if hasattr(self, "telemetry"):
49+
self.telemetry = telemetry.get_telem_by_systemid(self.mspconfig.system_id)
50+
else:
51+
raise NotImplementedError("This equipment does not have telemetry data.")
52+
53+
def _from_mspconfig(self, attribute: str) -> Any:
54+
"""Helper method to get a value from the MSP configuration."""
55+
return getattr(self.mspconfig, attribute, None)
56+
57+
def _from_telemetry(self, attribute: str) -> Any:
58+
"""Helper method to get a value from the telemetry data."""
59+
if hasattr(self, "telemetry"):
60+
return getattr(self.telemetry, attribute, None)
61+
return None

pyomnilogic_local/api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .api import OmniLogicAPI
2+
3+
__all__ = [
4+
"OmniLogicAPI",
5+
]
Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
import xml.etree.ElementTree as ET
77
from typing import Literal, overload
88

9-
from .models.filter_diagnostics import FilterDiagnostics
10-
from .models.mspconfig import MSPConfig
11-
from .models.telemetry import Telemetry
12-
from .models.util import to_pydantic
13-
from .omnitypes import (
9+
from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics
10+
from pyomnilogic_local.models.mspconfig import MSPConfig
11+
from pyomnilogic_local.models.telemetry import Telemetry
12+
13+
from ..omnitypes import (
1414
ColorLogicBrightness,
15-
ColorLogicShow,
15+
ColorLogicShow25,
16+
ColorLogicShow40,
17+
ColorLogicShowUCL,
18+
ColorLogicShowUCLV2,
1619
ColorLogicSpeed,
1720
HeaterMode,
1821
MessageType,
@@ -23,12 +26,12 @@
2326

2427

2528
class OmniLogicAPI:
26-
def __init__(self, controller_ip: str, controller_port: int, response_timeout: float) -> None:
29+
def __init__(self, controller_ip: str, controller_port: int, response_timeout: float = 5.0) -> None:
2730
self.controller_ip = controller_ip
2831
self.controller_port = controller_port
2932
self.response_timeout = response_timeout
30-
self._loop = asyncio.get_running_loop()
31-
self._protocol_factory = OmniLogicProtocol
33+
# self._loop = asyncio.get_running_loop()
34+
# self._protocol_factory = OmniLogicProtocol
3235

3336
@overload
3437
async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[True]) -> str: ...
@@ -61,8 +64,13 @@ async def async_send_message(self, message_type: MessageType, message: str | Non
6164

6265
return resp
6366

64-
@to_pydantic(pydantic_type=MSPConfig)
65-
async def async_get_config(self) -> str:
67+
@overload
68+
async def async_get_mspconfig(self, raw: Literal[True]) -> str: ...
69+
@overload
70+
async def async_get_mspconfig(self, raw: Literal[False]) -> MSPConfig: ...
71+
@overload
72+
async def async_get_mspconfig(self) -> MSPConfig: ...
73+
async def async_get_mspconfig(self, raw: bool = False) -> MSPConfig | str:
6674
"""Retrieve the MSPConfig from the Omni, optionally parse it into a pydantic model.
6775
6876
Args:
@@ -78,14 +86,21 @@ async def async_get_config(self) -> str:
7886

7987
req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode")
8088

81-
return await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True)
89+
resp = await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True)
8290

83-
@to_pydantic(pydantic_type=FilterDiagnostics)
84-
async def async_get_filter_diagnostics(
85-
self,
86-
pool_id: int,
87-
equipment_id: int,
88-
) -> str:
91+
if raw:
92+
return resp
93+
return MSPConfig.load_xml(resp)
94+
95+
@overload
96+
async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: Literal[True]) -> str: ...
97+
@overload
98+
async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: Literal[False]) -> FilterDiagnostics: ...
99+
@overload
100+
async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int) -> FilterDiagnostics: ...
101+
@overload
102+
async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: bool) -> FilterDiagnostics | str: ...
103+
async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: bool = False) -> FilterDiagnostics | str:
89104
"""Retrieve filter diagnostics from the Omni, optionally parse it into a pydantic model.
90105
91106
Args:
@@ -108,10 +123,19 @@ async def async_get_filter_diagnostics(
108123

109124
req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode")
110125

111-
return await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True)
126+
resp = await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True)
127+
128+
if raw:
129+
return resp
130+
return FilterDiagnostics.load_xml(resp)
112131

113-
@to_pydantic(pydantic_type=Telemetry)
114-
async def async_get_telemetry(self) -> str:
132+
@overload
133+
async def async_get_telemetry(self, raw: Literal[True]) -> str: ...
134+
@overload
135+
async def async_get_telemetry(self, raw: Literal[False]) -> Telemetry: ...
136+
@overload
137+
async def async_get_telemetry(self) -> Telemetry: ...
138+
async def async_get_telemetry(self, raw: bool = False) -> Telemetry | str:
115139
"""Retrieve the current telemetry data from the Omni, optionally parse it into a pydantic model.
116140
117141
Returns:
@@ -124,7 +148,11 @@ async def async_get_telemetry(self) -> str:
124148

125149
req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode")
126150

127-
return await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True)
151+
resp = await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True)
152+
153+
if raw:
154+
return resp
155+
return Telemetry.load_xml(resp)
128156

129157
async def async_set_heater(
130158
self,
@@ -352,7 +380,7 @@ async def async_set_light_show(
352380
self,
353381
pool_id: int,
354382
equipment_id: int,
355-
show: ColorLogicShow,
383+
show: ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2,
356384
speed: ColorLogicSpeed = ColorLogicSpeed.ONE_TIMES,
357385
brightness: ColorLogicBrightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT,
358386
reserved: int = 0,
Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,3 @@ class OmniLogicException(Exception):
44

55
class OmniTimeoutException(OmniLogicException):
66
pass
7-
8-
9-
class OmniParsingException(OmniLogicException):
10-
pass
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
from typing_extensions import Self
1111

12+
from ..models.leadmessage import LeadMessage
13+
from ..omnitypes import ClientType, MessageType
1214
from .exceptions import OmniTimeoutException
13-
from .models.leadmessage import LeadMessage
14-
from .omnitypes import ClientType, MessageType
1515

1616
_LOGGER = logging.getLogger(__name__)
1717

@@ -186,7 +186,7 @@ async def _wait_for_ack(self, ack_id: int) -> None:
186186
exc = error_task.result()
187187
if isinstance(exc, Exception):
188188
raise exc
189-
_LOGGER.error("Unknown error occurred during communication with Omnilogic: %s", exc)
189+
_LOGGER.error("Unknown error occurred during communication with OmniLogic: %s", exc)
190190
if data_task in done:
191191
message = data_task.result()
192192
if message.id == ack_id:

pyomnilogic_local/backyard.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from pyomnilogic_local.models.mspconfig import MSPBackyard
2+
from pyomnilogic_local.models.telemetry import Telemetry
3+
4+
from ._base import OmniEquipment
5+
from .bow import Bow
6+
from .colorlogiclight import ColorLogicLight
7+
from .relay import Relay
8+
from .sensor import Sensor
9+
10+
11+
class Backyard(OmniEquipment):
12+
"""Represents the backyard equipment in the OmniLogic system."""
13+
14+
bow: list[Bow] = []
15+
lights: list[ColorLogicLight] = []
16+
relays: list[Relay] = []
17+
sensors: list[Sensor] = []
18+
19+
def __init__(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
20+
super().__init__(mspconfig, telemetry)
21+
22+
self._update_bows(mspconfig, telemetry)
23+
self._update_relays(mspconfig, telemetry)
24+
self._update_sensors(mspconfig, telemetry)
25+
26+
def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
27+
"""Update the bows based on the MSP configuration."""
28+
if mspconfig.bow is None:
29+
self.bow = []
30+
return
31+
32+
self.bow = [Bow(bow, telemetry) for bow in mspconfig.bow]
33+
34+
def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
35+
"""Update the relays based on the MSP configuration."""
36+
if mspconfig.relay is None:
37+
self.relays = []
38+
return
39+
40+
self.relays = [Relay(relay, telemetry) for relay in mspconfig.relay]
41+
42+
def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
43+
"""Update the sensors, bows, lights, and relays based on the MSP configuration."""
44+
if mspconfig.sensor is None:
45+
self.sensors = []
46+
return
47+
48+
self.sensors = [Sensor(sensor, telemetry) for sensor in mspconfig.sensor]

pyomnilogic_local/bow.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pyomnilogic_local._base import OmniEquipment
2+
3+
4+
class Bow(OmniEquipment):
5+
"""Represents a bow in the OmniLogic system."""

pyomnilogic_local/cli/debug/commands.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import click
77

8-
from pyomnilogic_local.api import OmniLogicAPI
8+
from pyomnilogic_local.api.api import OmniLogicAPI
99
from pyomnilogic_local.cli import ensure_connection
1010
from pyomnilogic_local.cli.pcap_utils import parse_pcap_file, process_pcap_messages
1111
from pyomnilogic_local.cli.utils import async_get_filter_diagnostics
@@ -39,7 +39,7 @@ def get_mspconfig(ctx: click.Context) -> None:
3939
"""
4040
ensure_connection(ctx)
4141
omni: OmniLogicAPI = ctx.obj["OMNI"]
42-
mspconfig = asyncio.run(omni.async_get_config(raw=ctx.obj["RAW"]))
42+
mspconfig = asyncio.run(omni.async_get_mspconfig(raw=ctx.obj["RAW"]))
4343
click.echo(mspconfig)
4444

4545

@@ -104,7 +104,7 @@ def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) ->
104104
@click.argument("pcap_file", type=click.Path(exists=True, path_type=Path))
105105
@click.pass_context
106106
def parse_pcap(ctx: click.Context, pcap_file: Path) -> None:
107-
"""Parse a PCAP file and reconstruct Omnilogic protocol communication.
107+
"""Parse a PCAP file and reconstruct OmniLogic protocol communication.
108108
109109
Analyzes network packet captures to decode OmniLogic protocol messages.
110110
Automatically reassembles multi-part messages (LeadMessage + BlockMessages)

pyomnilogic_local/cli/get/lights.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators
22
# mypy: disable-error-code="misc"
33

4-
from typing import Any
4+
from typing import Any, cast
55

66
import click
77

@@ -11,12 +11,11 @@
1111
)
1212
from pyomnilogic_local.models.telemetry import (
1313
Telemetry,
14-
TelemetryType,
14+
TelemetryColorLogicLight,
1515
)
1616
from pyomnilogic_local.omnitypes import (
1717
ColorLogicBrightness,
1818
ColorLogicPowerState,
19-
ColorLogicShow,
2019
ColorLogicSpeed,
2120
)
2221

@@ -41,21 +40,21 @@ def lights(ctx: click.Context) -> None:
4140
if mspconfig.backyard.colorlogic_light:
4241
for light in mspconfig.backyard.colorlogic_light:
4342
lights_found = True
44-
_print_light_info(light, telemetry.get_telem_by_systemid(light.system_id))
43+
_print_light_info(light, cast(TelemetryColorLogicLight, telemetry.get_telem_by_systemid(light.system_id)))
4544

4645
# Check for lights in Bodies of Water
4746
if mspconfig.backyard.bow:
4847
for bow in mspconfig.backyard.bow:
4948
if bow.colorlogic_light:
5049
for cl_light in bow.colorlogic_light:
5150
lights_found = True
52-
_print_light_info(cl_light, telemetry.get_telem_by_systemid(cl_light.system_id))
51+
_print_light_info(cl_light, cast(TelemetryColorLogicLight, telemetry.get_telem_by_systemid(cl_light.system_id)))
5352

5453
if not lights_found:
5554
click.echo("No ColorLogic lights found in the system configuration.")
5655

5756

58-
def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None) -> None:
57+
def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryColorLogicLight | None) -> None:
5958
"""Format and print light information in a nice table format.
6059
6160
Args:
@@ -67,14 +66,15 @@ def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None
6766
click.echo("=" * 60)
6867

6968
light_data: dict[Any, Any] = {**dict(light), **dict(telemetry)} if telemetry else dict(light)
69+
7070
for attr_name, value in light_data.items():
7171
if attr_name == "brightness":
7272
value = ColorLogicBrightness(value).pretty()
7373
elif attr_name == "effects" and isinstance(value, list):
7474
show_names = [show.pretty() if hasattr(show, "pretty") else str(show) for show in value]
7575
value = ", ".join(show_names) if show_names else "None"
7676
elif attr_name == "show" and value is not None:
77-
value = ColorLogicShow(value).pretty()
77+
value = telemetry.show_name(light.type, light.v2_active, True) if telemetry else str(value)
7878
elif attr_name == "speed":
7979
value = ColorLogicSpeed(value).pretty()
8080
elif attr_name == "state":

0 commit comments

Comments
 (0)