Skip to content

Commit 69b9e51

Browse files
committed
feat: add @auto_refresh decorator with smart timestamp-based refresh system
- Implement @auto_refresh decorator that automatically refreshes equipment state after control methods - Add update_if_older_than() with asyncio.Lock to prevent redundant API calls - Refactor equipment classes to pass OmniLogic instance instead of OmniLogicAPI for explicit dependency injection
1 parent bb37125 commit 69b9e51

File tree

6 files changed

+203
-46
lines changed

6 files changed

+203
-46
lines changed

pyomnilogic_local/_base.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import logging
2-
from typing import Generic, TypeVar, cast
2+
from typing import TYPE_CHECKING, Generic, TypeVar, cast
33

44
from pyomnilogic_local.api.api import OmniLogicAPI
55
from pyomnilogic_local.models import MSPEquipmentType, Telemetry
66
from pyomnilogic_local.models.telemetry import TelemetryType
77

8+
if TYPE_CHECKING:
9+
from pyomnilogic_local.omnilogic import OmniLogic
10+
811
# Define type variables for generic equipment types
912
MSPConfigT = TypeVar("MSPConfigT", bound=MSPEquipmentType)
1013
TelemetryT = TypeVar("TelemetryT", bound=TelemetryType | None)
@@ -27,18 +30,23 @@ class OmniEquipment(Generic[MSPConfigT, TelemetryT]):
2730
# Use a forward reference for the type hint to avoid issues with self-referential generics
2831
child_equipment: dict[int, "OmniEquipment[MSPConfigT, TelemetryT]"]
2932

30-
def __init__(self, _api: OmniLogicAPI, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None:
33+
def __init__(self, omni: "OmniLogic", mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None:
3134
"""Initialize the equipment with configuration and telemetry data.
3235
3336
Args:
34-
_api: The OmniLogic API instance
37+
omni: The OmniLogic instance (parent controller)
3538
mspconfig: The MSP configuration for this specific equipment
3639
telemetry: The full Telemetry object containing all equipment telemetry
3740
"""
38-
self._api = _api
41+
self._omni = omni
3942

4043
self.update(mspconfig, telemetry)
4144

45+
@property
46+
def _api(self) -> "OmniLogicAPI":
47+
"""Access the OmniLogic API through the parent controller."""
48+
return self._omni._api # pylint: disable=protected-access
49+
4250
@property
4351
def bow_id(self) -> int | None:
4452
"""The bow ID of the equipment."""
@@ -85,7 +93,7 @@ def update_telemetry(self, telemetry: Telemetry) -> None:
8593
# if hasattr(self, "telemetry"):
8694
# Extract the specific telemetry for this equipment from the full telemetry object
8795
# Note: Some equipment (like sensors) don't have their own telemetry, so this may be None
88-
if specific_telemetry := telemetry.get_telem_by_systemid(self.mspconfig.system_id) is not None:
96+
if (specific_telemetry := telemetry.get_telem_by_systemid(self.mspconfig.system_id)) is not None:
8997
self.telemetry = cast(TelemetryT, specific_telemetry)
9098
else:
9199
self.telemetry = cast(TelemetryT, None)

pyomnilogic_local/backyard.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
2+
from typing import TYPE_CHECKING
23

3-
from pyomnilogic_local.api.api import OmniLogicAPI
44
from pyomnilogic_local.collections import EquipmentDict
55
from pyomnilogic_local.models.mspconfig import MSPBackyard
66
from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBackyard
@@ -11,6 +11,9 @@
1111
from .relay import Relay
1212
from .sensor import Sensor
1313

14+
if TYPE_CHECKING:
15+
from pyomnilogic_local.omnilogic import OmniLogic
16+
1417
_LOGGER = logging.getLogger(__name__)
1518

1619

@@ -22,8 +25,8 @@ class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]):
2225
relays: EquipmentDict[Relay] = EquipmentDict()
2326
sensors: EquipmentDict[Sensor] = EquipmentDict()
2427

25-
def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
26-
super().__init__(_api, mspconfig, telemetry)
28+
def __init__(self, omni: "OmniLogic", mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
29+
super().__init__(omni, mspconfig, telemetry)
2730

2831
def _update_equipment(self, mspconfig: MSPBackyard, telemetry: Telemetry | None) -> None:
2932
"""Update both the configuration and telemetry data for the equipment."""
@@ -41,28 +44,28 @@ def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
4144
self.bow = EquipmentDict()
4245
return
4346

44-
self.bow = EquipmentDict([Bow(self._api, bow, telemetry) for bow in mspconfig.bow])
47+
self.bow = EquipmentDict([Bow(self._omni, bow, telemetry) for bow in mspconfig.bow])
4548

4649
def _update_lights(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
4750
"""Update the lights based on the MSP configuration."""
4851
if mspconfig.colorlogic_light is None:
4952
self.lights = EquipmentDict()
5053
return
5154

52-
self.lights = EquipmentDict([ColorLogicLight(self._api, light, telemetry) for light in mspconfig.colorlogic_light])
55+
self.lights = EquipmentDict([ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light])
5356

5457
def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
5558
"""Update the relays based on the MSP configuration."""
5659
if mspconfig.relay is None:
5760
self.relays = EquipmentDict()
5861
return
5962

60-
self.relays = EquipmentDict([Relay(self._api, relay, telemetry) for relay in mspconfig.relay])
63+
self.relays = EquipmentDict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay])
6164

6265
def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None:
6366
"""Update the sensors based on the MSP configuration."""
6467
if mspconfig.sensor is None:
6568
self.sensors = EquipmentDict()
6669
return
6770

68-
self.sensors = EquipmentDict([Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor])
71+
self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor])

pyomnilogic_local/bow.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
from typing import TYPE_CHECKING
2+
13
from pyomnilogic_local._base import OmniEquipment
2-
from pyomnilogic_local.api.api import OmniLogicAPI
34
from pyomnilogic_local.chlorinator import Chlorinator
45
from pyomnilogic_local.collections import EquipmentDict
56
from pyomnilogic_local.colorlogiclight import _LOGGER, ColorLogicLight
@@ -12,6 +13,9 @@
1213
from pyomnilogic_local.relay import Relay
1314
from pyomnilogic_local.sensor import Sensor
1415

16+
if TYPE_CHECKING:
17+
from pyomnilogic_local.omnilogic import OmniLogic
18+
1519

1620
class Bow(OmniEquipment[MSPBoW, TelemetryBoW]):
1721
"""Represents a bow in the OmniLogic system."""
@@ -25,8 +29,8 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]):
2529
chlorinator: Chlorinator | None = None
2630
csads: EquipmentDict[CSAD] = EquipmentDict()
2731

28-
def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
29-
super().__init__(_api, mspconfig, telemetry)
32+
def __init__(self, omni: "OmniLogic", mspconfig: MSPBoW, telemetry: Telemetry) -> None:
33+
super().__init__(omni, mspconfig, telemetry)
3034

3135
@property
3236
def equip_type(self) -> str:
@@ -52,60 +56,60 @@ def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
5256
self.filters = EquipmentDict()
5357
return
5458

55-
self.filters = EquipmentDict([Filter(self._api, filter_, telemetry) for filter_ in mspconfig.filter])
59+
self.filters = EquipmentDict([Filter(self._omni, filter_, telemetry) for filter_ in mspconfig.filter])
5660

5761
def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
5862
"""Update the heater based on the MSP configuration."""
5963
if mspconfig.heater is None:
6064
self.heater = None
6165
return
6266

63-
self.heater = Heater(self._api, mspconfig.heater, telemetry)
67+
self.heater = Heater(self._omni, mspconfig.heater, telemetry)
6468

6569
def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
6670
"""Update the relays based on the MSP configuration."""
6771
if mspconfig.relay is None:
6872
self.relays = EquipmentDict()
6973
return
7074

71-
self.relays = EquipmentDict([Relay(self._api, relay, telemetry) for relay in mspconfig.relay])
75+
self.relays = EquipmentDict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay])
7276

7377
def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
7478
"""Update the sensors based on the MSP configuration."""
7579
if mspconfig.sensor is None:
7680
self.sensors = EquipmentDict()
7781
return
7882

79-
self.sensors = EquipmentDict([Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor])
83+
self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor])
8084

8185
def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
8286
"""Update the lights based on the MSP configuration."""
8387
if mspconfig.colorlogic_light is None:
8488
self.lights = EquipmentDict()
8589
return
8690

87-
self.lights = EquipmentDict([ColorLogicLight(self._api, light, telemetry) for light in mspconfig.colorlogic_light])
91+
self.lights = EquipmentDict([ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light])
8892

8993
def _update_pumps(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
9094
"""Update the pumps based on the MSP configuration."""
9195
if mspconfig.pump is None:
9296
self.pumps = EquipmentDict()
9397
return
9498

95-
self.pumps = EquipmentDict([Pump(self._api, pump, telemetry) for pump in mspconfig.pump])
99+
self.pumps = EquipmentDict([Pump(self._omni, pump, telemetry) for pump in mspconfig.pump])
96100

97101
def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
98102
"""Update the chlorinators based on the MSP configuration."""
99103
if mspconfig.chlorinator is None:
100104
self.chlorinator = None
101105
return
102106

103-
self.chlorinator = Chlorinator(self._api, mspconfig.chlorinator, telemetry)
107+
self.chlorinator = Chlorinator(self._omni, mspconfig.chlorinator, telemetry)
104108

105109
def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None:
106110
"""Update the CSADs based on the MSP configuration."""
107111
if mspconfig.csad is None:
108112
self.csads = EquipmentDict()
109113
return
110114

111-
self.csads = EquipmentDict([CSAD(self._api, csad, telemetry) for csad in mspconfig.csad])
115+
self.csads = EquipmentDict([CSAD(self._omni, csad, telemetry) for csad in mspconfig.csad])

pyomnilogic_local/colorlogiclight.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import logging
2+
from typing import TYPE_CHECKING
23

34
from pyomnilogic_local._base import OmniEquipment
4-
from pyomnilogic_local.api.api import OmniLogicAPI
5+
from pyomnilogic_local.decorators import auto_refresh
56
from pyomnilogic_local.models.mspconfig import MSPColorLogicLight
67
from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight
78
from pyomnilogic_local.omnitypes import (
@@ -13,14 +14,17 @@
1314
)
1415
from pyomnilogic_local.util import OmniEquipmentNotInitializedError
1516

17+
if TYPE_CHECKING:
18+
from pyomnilogic_local.omnilogic import OmniLogic
19+
1620
_LOGGER = logging.getLogger(__name__)
1721

1822

1923
class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight]):
2024
"""Represents a color logic light."""
2125

22-
def __init__(self, _api: OmniLogicAPI, mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None:
23-
super().__init__(_api, mspconfig, telemetry)
26+
def __init__(self, omni: "OmniLogic", mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None:
27+
super().__init__(omni, mspconfig, telemetry)
2428

2529
@property
2630
def model(self) -> ColorLogicLightType:
@@ -68,23 +72,23 @@ def special_effect(self) -> int:
6872
"""Returns the current special effect."""
6973
return self.telemetry.special_effect
7074

75+
@auto_refresh()
7176
async def turn_on(self) -> None:
7277
"""Turns the light on."""
7378
if self.bow_id is None or self.system_id is None:
7479
raise OmniEquipmentNotInitializedError("Cannot turn on light: bow_id or system_id is None")
7580
await self._api.async_set_equipment(self.bow_id, self.system_id, True)
7681

82+
@auto_refresh()
7783
async def turn_off(self) -> None:
7884
"""Turns the light off."""
7985
if self.bow_id is None or self.system_id is None:
8086
raise OmniEquipmentNotInitializedError("Cannot turn off light: bow_id or system_id is None")
8187
await self._api.async_set_equipment(self.bow_id, self.system_id, False)
8288

89+
@auto_refresh()
8390
async def set_show(
84-
self,
85-
show: LightShows | None = None,
86-
speed: ColorLogicSpeed | None = None,
87-
brightness: ColorLogicBrightness | None = None,
91+
self, show: LightShows | None = None, speed: ColorLogicSpeed | None = None, brightness: ColorLogicBrightness | None = None
8892
) -> None:
8993
"""Sets the light show, speed, and brightness."""
9094

pyomnilogic_local/decorators.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Decorators for automatic state management in pyomnilogic_local."""
2+
3+
import asyncio
4+
import functools
5+
import logging
6+
import time
7+
from collections.abc import Callable
8+
from typing import Any, TypeVar, cast
9+
10+
_LOGGER = logging.getLogger(__name__)
11+
12+
F = TypeVar("F", bound=Callable[..., Any])
13+
14+
15+
def auto_refresh(
16+
update_mspconfig: bool = False,
17+
update_telemetry: bool = True,
18+
delay: float = 1.25,
19+
) -> Callable[[F], F]:
20+
"""Decorator to automatically refresh OmniLogic state after method execution.
21+
22+
This decorator will:
23+
1. Execute the decorated method
24+
2. Wait for the specified delay (to allow controller to update)
25+
3. Refresh telemetry/mspconfig if they're older than the post-delay time
26+
27+
The decorator is lock-safe: if multiple decorated methods are called concurrently,
28+
only one refresh will occur thanks to the update_if_older_than mechanism.
29+
30+
Args:
31+
update_mspconfig: Whether to refresh MSPConfig after method execution
32+
update_telemetry: Whether to refresh Telemetry after method execution
33+
delay: Time in seconds to wait after method completes before refreshing
34+
35+
Usage:
36+
@auto_refresh() # Default: telemetry only, 0.25s delay
37+
async def turn_on(self, auto_refresh: bool | None = None):
38+
...
39+
40+
@auto_refresh(update_mspconfig=True, delay=0.5)
41+
async def configure(self, auto_refresh: bool | None = None):
42+
...
43+
44+
The decorated method can accept an optional `auto_refresh` parameter:
45+
- If None (default): Uses the OmniLogic instance's auto_refresh_enabled setting
46+
- If True: Forces auto-refresh regardless of instance setting
47+
- If False: Disables auto-refresh for this call
48+
"""
49+
50+
def decorator(func: F) -> F:
51+
@functools.wraps(func)
52+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
53+
# Extract the 'auto_refresh' parameter if provided
54+
auto_refresh_param = kwargs.pop("auto_refresh", None)
55+
56+
# First arg should be 'self' (equipment instance)
57+
if not args:
58+
raise RuntimeError("@auto_refresh decorator requires a method with 'self' parameter")
59+
60+
self_obj = args[0]
61+
62+
# Get the OmniLogic instance
63+
# Equipment classes should have _omni attribute pointing to parent OmniLogic
64+
if hasattr(self_obj, "_omni") and self_obj._omni is not None: # pylint: disable=protected-access
65+
omni = self_obj._omni # pylint: disable=protected-access
66+
elif hasattr(self_obj, "auto_refresh_enabled"):
67+
# This IS the OmniLogic instance
68+
omni = self_obj
69+
else:
70+
raise RuntimeError("@auto_refresh decorator requires equipment to have '_omni' attribute or be used on OmniLogic methods")
71+
72+
# Determine if we should auto-refresh
73+
should_refresh = auto_refresh_param if auto_refresh_param is not None else omni.auto_refresh_enabled
74+
75+
# Execute the original method
76+
result = await func(*args, **kwargs)
77+
78+
# Perform auto-refresh if enabled
79+
if should_refresh:
80+
# Wait for the controller to process the change
81+
await asyncio.sleep(delay)
82+
83+
# Calculate the target time (after delay)
84+
target_time = time.time()
85+
86+
# Update only if data is older than target time
87+
await omni.update_if_older_than(
88+
telemetry_min_time=target_time if update_telemetry else None,
89+
mspconfig_min_time=target_time if update_mspconfig else None,
90+
)
91+
92+
return result
93+
94+
return cast(F, wrapper)
95+
96+
return decorator

0 commit comments

Comments
 (0)