From 9b85cfd63d2f69d0885a307898186e6ec1a40f43 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 28 May 2026 01:02:32 +0000 Subject: [PATCH 1/2] feat: bring in a lot more state types and data BREAKING CHANGE: Several states/modes/operations/etc have been renamed to better align with OmniLogic internal values --- pyomnilogic_local/api/api.py | 26 ++--- pyomnilogic_local/api/protocol.py | 4 +- pyomnilogic_local/colorlogiclight.py | 8 +- pyomnilogic_local/filter.py | 6 +- pyomnilogic_local/omnitypes.py | 139 ++++++++++++++++++--------- tests/test_api.py | 4 +- tests/test_filter_pump.py | 2 +- tests/test_protocol.py | 10 +- 8 files changed, 122 insertions(+), 77 deletions(-) diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index 726fff2..072c17f 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -215,7 +215,7 @@ async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, ra _LOGGER.debug("Sending GetUIFilterDiagnosticInfo with body: %s", req_body) - resp = await self.async_send_and_receive(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body) + resp = await self.async_send_and_receive(MessageType.GET_FILTER_DIAGNOSTIC, req_body) _LOGGER.debug("Received response for GetUIFilterDiagnosticInfo: %s", resp) @@ -285,7 +285,7 @@ async def async_set_heater( _LOGGER.debug("Sending SetUIHeaterCmd with body: %s", req_body) - return await self.async_send(MessageType.SET_HEATER_COMMAND, req_body) + return await self.async_send(MessageType.SET_HEATER, req_body) async def async_set_solar_heater( self, @@ -320,7 +320,7 @@ async def async_set_solar_heater( _LOGGER.debug("Sending SetUISolarSetPointCmd with body: %s", req_body) - return await self.async_send(MessageType.SET_SOLAR_SET_POINT_COMMAND, req_body) + return await self.async_send(MessageType.SET_SOLAR_SETPOINT, req_body) async def async_set_heater_mode( self, @@ -355,7 +355,7 @@ async def async_set_heater_mode( _LOGGER.debug("Sending SetUIHeaterModeCmd with body: %s", req_body) - return await self.async_send(MessageType.SET_HEATER_MODE_COMMAND, req_body) + return await self.async_send(MessageType.SET_HEATER_MODE, req_body) async def async_set_heater_enable( self, @@ -390,7 +390,7 @@ async def async_set_heater_enable( _LOGGER.debug("Sending SetHeaterEnable with body: %s", req_body) - return await self.async_send(MessageType.SET_HEATER_ENABLED, req_body) + return await self.async_send(MessageType.SET_HEATER_ENABLE, req_body) async def async_set_equipment( self, @@ -552,7 +552,7 @@ async def async_set_light_show( _LOGGER.debug("Sending SetStandAloneLightShow with body: %s", req_body) - return await self.async_send(MessageType.SET_STANDALONE_LIGHT_SHOW, req_body) + return await self.async_send(MessageType.SET_LIGHT_SHOW, req_body) async def async_set_chlorinator_enable(self, pool_id: int, enabled: int | bool) -> None: body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) @@ -570,7 +570,7 @@ async def async_set_chlorinator_enable(self, pool_id: int, enabled: int | bool) _LOGGER.debug("Sending SetCHLOREnable with body: %s", req_body) - return await self.async_send(MessageType.SET_CHLOR_ENABLED, req_body) + return await self.async_send(MessageType.SET_CHLOR_ENABLE, req_body) # This is used to set the ORP target value on a CSAD async def async_set_csad_orp_target_level( @@ -596,7 +596,7 @@ async def async_set_csad_orp_target_level( _LOGGER.debug("Sending SetUICSADORPTargetLevel with body: %s", req_body) - return await self.async_send(MessageType.SET_CSAD_ORP_TARGET, req_body) + return await self.async_send(MessageType.SET_CSAD_ORP_TARGET_LEVEL, req_body) # This is used to set the pH target value on a CSAD async def async_set_csad_ph_target_value( @@ -665,7 +665,7 @@ async def async_set_chlorinator_params( _LOGGER.debug("Sending SetCHLORParams with body: %s", req_body) - return await self.async_send(MessageType.SET_CHLOR_PARAMS, req_body) + return await self.async_send(MessageType.CHLOR_PARAMS_SET, req_body) async def async_set_chlorinator_superchlorinate( self, @@ -690,7 +690,7 @@ async def async_set_chlorinator_superchlorinate( _LOGGER.debug("Sending SetUISuperCHLORCmd with body: %s", req_body) - return await self.async_send(MessageType.SET_SUPERCHLORINATE, req_body) + return await self.async_send(MessageType.CHLOR_SUPER_CHLOR_SET, req_body) async def async_restore_idle_state(self) -> None: body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) @@ -747,7 +747,7 @@ async def async_set_spillover( _LOGGER.debug("Sending SetUISpilloverCmd with body: %s", req_body) - return await self.async_send(MessageType.SET_SPILLOVER, req_body) + return await self.async_send(MessageType.SET_SPILLOVER_CMD, req_body) async def async_set_group_enable( self, @@ -790,7 +790,7 @@ async def async_set_group_enable( _LOGGER.debug("Sending RunGroupCmd with body: %s", req_body) - return await self.async_send(MessageType.RUN_GROUP_CMD, req_body) + return await self.async_send(MessageType.RUN_GROUP, req_body) async def async_edit_schedule( self, @@ -863,4 +863,4 @@ async def async_edit_schedule( _LOGGER.debug("Sending EditUIScheduleCmd with body: %s", req_body) - return await self.async_send(MessageType.EDIT_SCHEDULE, req_body) + return await self.async_send(MessageType.EDIT_SCHEDULE_CMD, req_body) diff --git a/pyomnilogic_local/api/protocol.py b/pyomnilogic_local/api/protocol.py index c816b85..69e9869 100644 --- a/pyomnilogic_local/api/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -28,7 +28,7 @@ _ACK_PAYLOAD = f'\nAck\n' -_ACK_TYPES = frozenset({MessageType.ACK, MessageType.XML_ACK}) +_ACK_TYPES = frozenset({MessageType.ACK, MessageType.MSP_ACK}) # Type alias for items placed on the receive queue: either a parsed message or a parse error. _QueueItem = OmniLogicMessage | OmniMessageFormatError @@ -110,7 +110,7 @@ def _send_xml_ack(self, msg_id: int) -> None: if self._transport is None: _LOGGER.warning("cannot send ACK for ID %d, transport unavailable", msg_id) return - ack = OmniLogicMessage(msg_id=msg_id, msg_type=MessageType.XML_ACK, payload=_ACK_PAYLOAD) + ack = OmniLogicMessage(msg_id=msg_id, msg_type=MessageType.ACK, payload=_ACK_PAYLOAD) self._transport.sendto(bytes(ack)) _LOGGER.debug("sent XML_ACK for message ID %d", msg_id) diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 34eb975..ca7fd6f 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -222,10 +222,10 @@ def is_ready(self) -> bool: # Then check light-specific readiness return self.state not in [ - ColorLogicPowerState.FIFTEEN_SECONDS_WHITE, - ColorLogicPowerState.CHANGING_SHOW, - ColorLogicPowerState.POWERING_OFF, - ColorLogicPowerState.COOLDOWN, + ColorLogicPowerState.STARTING_APP, + ColorLogicPowerState.SHOW_ADVANCE, + ColorLogicPowerState.WAIT_POWER_DOWN, + ColorLogicPowerState.POWER_DOWN, ] @control_method diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index 0326f89..b64ec3b 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -179,8 +179,8 @@ def is_on(self) -> bool: FilterState.PRIMING, FilterState.HEATER_EXTEND, FilterState.CSAD_EXTEND, - FilterState.FILTER_FORCE_PRIMING, - FilterState.FILTER_SUPERCHLORINATE, + FilterState.FORCE_PRIMING, + FilterState.SUPERCHLORINATE, ) @property @@ -203,7 +203,7 @@ def is_ready(self) -> bool: # We need to consider the filter as ready in this state, otherwise we cannot control the # virtual filter to switch the physical filter back BoW # ref: https://github.com/cryptk/python-omnilogic-local/issues/100 - return self.state in (FilterState.OFF, FilterState.ON, FilterState.SUSPEND) + return self.state in (FilterState.OFF, FilterState.ON, FilterState.SUSPENDED) # Control methods @control_method diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index c91c1c0..aaedf2a 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -7,33 +7,35 @@ # OmniAPI Enums class MessageType(PrettyEnum, IntEnum): - XML_ACK = 0000 + ACK = 0000 REQUEST_CONFIGURATION = 1 SET_FILTER_SPEED = 9 - SET_HEATER_COMMAND = 11 - SET_SUPERCHLORINATE = 15 - SET_SOLAR_SET_POINT_COMMAND = 40 - SET_HEATER_MODE_COMMAND = 42 - SET_CHLOR_ENABLED = 121 - SET_HEATER_ENABLED = 147 - SET_CHLOR_PARAMS = 155 + SET_HEATER = 11 + CHLOR_SUPER_CHLOR_SET = 15 + SET_SOLAR_SETPOINT = 40 + SET_HEATER_MODE = 42 + SET_CHLOR_ENABLE = 121 + SET_HEATER_ENABLE = 147 + CHLOR_PARAMS_SET = 155 SET_EQUIPMENT = 164 - CREATE_SCHEDULE = 230 - DELETE_SCHEDULE = 231 - EDIT_SCHEDULE = 233 + CREATE_SCHEDULE_CMD = 230 + DELETE_SCHEDULE_CMD = 231 + EDIT_SCHEDULE_CMD = 233 SET_CSAD_TARGET_VALUE = 253 - SET_CSAD_ORP_TARGET = 281 + SET_CSAD_ENABLED = 259 + SET_CSAD_ORP_TARGET_LEVEL = 281 GET_TELEMETRY = 300 - GET_ALARM_LIST = 304 - SET_STANDALONE_LIGHT_SHOW = 308 - SET_SPILLOVER = 311 - RUN_GROUP_CMD = 317 + GET_ALLALARMLIST = 304 + SET_LIGHT_SHOW = 308 + SET_SPILLOVER_CMD = 311 + RUN_GROUP = 317 RESTORE_IDLE_STATE = 340 - GET_FILTER_DIAGNOSTIC_INFO = 386 + GET_FILTER_DIAGNOSTIC = 386 HANDSHAKE = 1000 - ACK = 1002 + MSP_ACK = 1002 MSP_CONFIGURATIONUPDATE = 1003 MSP_TELEMETRY_UPDATE = 1004 + MSP_GET_FILTER_SPEED_RESPONSE = 1010 MSP_ALARM_LIST_RESPONSE = 1304 MSP_FILTER_DIAGNOSTIC_INFO_RESPONSE = 1386 MSP_LEADMESSAGE = 1998 @@ -78,16 +80,14 @@ class BackyardState(PrettyEnum, IntEnum): SERVICE_MODE = 2 CONFIG_MODE = 3 TIMED_SERVICE_MODE = 4 - - -class BodyOfWaterState(PrettyEnum, IntEnum): - NO_FLOW = 0 - FLOW = 1 + MAX = 5 class BodyOfWaterType(PrettyEnum, StrEnum): POOL = "BOW_POOL" SPA = "BOW_SPA" + UNCONFIGURED = "BOW_UNCFG" + BACKYARD_DEVICE = "BOW_BYD" # Chlorinators @@ -164,11 +164,20 @@ class ChlorinatorOperatingMode(PrettyEnum, IntEnum): ORP_TIMED_RW = 3 # Chlorinator in ORP mode experienced CSAD condition that prevents ORP operation +class ChlorinatorOperatingState(PrettyEnum, IntEnum): + WAIT_DEV_READY = 0 + PAUSE = 1 + CONTINUE = 2 + HALT = 3 + TEMP_PAUSE = 4 + + class ChlorinatorType(PrettyEnum, StrEnum): MAIN_PANEL = "CHLOR_TYPE_MAIN_PANEL" DISPENSER = "CHLOR_TYPE_DISPENSER" AQUA_RITE = "CHLOR_TYPE_AQUA_RITE" AQUA_RITE_S3 = "CHLOR_TYPE_AQR_S3" + EXPANSION_PANEL = "CHLOR_TYPE_EXPANSION_PANEL" class ChlorinatorDispenserType(PrettyEnum, StrEnum): @@ -212,6 +221,11 @@ class ColorLogicBrightness(PrettyEnum, IntEnum): ONE_HUNDRED_PERCENT = 4 +class ColorLogicSpecialEffect(PrettyEnum, IntEnum): + NO_EFFECT = 0 + FLICKER = 1 + + type LightShows = ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | PentairShow | ZodiacShow @@ -330,12 +344,12 @@ class ZodiacShow(PrettyEnum, IntEnum): class ColorLogicPowerState(PrettyEnum, IntEnum): OFF = 0 - POWERING_OFF = 1 - INITIALIZING = 2 # The app shows this as 15 seconds of white, but this state seems to happen when the Omni first powers up - CHANGING_SHOW = 3 - FIFTEEN_SECONDS_WHITE = 4 - ACTIVE = 6 - COOLDOWN = 7 + WAIT_POWER_DOWN = 1 + RESETTING = 2 + SHOW_ADVANCE = 3 + STARTING_APP = 4 + ON = 6 + POWER_DOWN = 7 class ColorLogicLightType(PrettyEnum, StrEnum): @@ -345,6 +359,7 @@ class ColorLogicLightType(PrettyEnum, StrEnum): SAM = "COLOR_LOGIC_SAM" PENTAIR_COLOR = "CL_P_COLOR" ZODIAC_COLOR = "CL_Z_COLOR" + WATER_BOWL = "CL_WATER_BOWL" def __str__(self) -> str: """Return the string representation of the ColorLogicLightType.""" @@ -369,9 +384,9 @@ class CSADStatus(PrettyEnum, IntEnum): class CSADMode(PrettyEnum, IntEnum): OFF = 0 AUTO = 1 - FORCE_ON = 2 + FORCED_ON = 2 MONITORING = 3 - DISPENSING_OFF = 4 + DISPENSE_OFF = 4 # Filters @@ -379,15 +394,16 @@ class FilterState(PrettyEnum, IntEnum): OFF = 0 ON = 1 PRIMING = 2 - WAITING_TURN_OFF = 3 - WAITING_TURN_OFF_MANUAL = 4 + WAITING_TO_TURN_OFF = 3 + WAITING_TO_TURN_OFF_MANUAL = 4 HEATER_EXTEND = 5 - COOLDOWN = 6 - SUSPEND = 7 + COOL_DOWN_MODE = 6 + SUSPENDED = 7 CSAD_EXTEND = 8 - FILTER_SUPERCHLORINATE = 9 - FILTER_FORCE_PRIMING = 10 - FILTER_WAITING_TURN_OFF = 11 + SUPERCHLORINATE = 9 + FORCE_PRIMING = 10 + WAITING_FOR_PUMP_TO_TURN_OFF = 11 + WAITING_TO_CHANGE_VALVES = 12 class FilterType(PrettyEnum, StrEnum): @@ -447,6 +463,7 @@ class HeaterState(PrettyEnum, IntEnum): OFF = 0 ON = 1 PAUSE = 2 + OFF_COOL_DOWN = 3 class HeaterType(PrettyEnum, StrEnum): @@ -458,22 +475,28 @@ class HeaterType(PrettyEnum, StrEnum): SMART = "HTR_SMART" CHILLER = "HTR_CHILLER" SMART_HEAT_PUMP = "HTR_SMART_HEAT_PUMP" + SMART_VSHP = "HTR_SMART_VSHP" + SMART_GAS = "HTR_SMART_GAS" + TEST_HEATER = "HTR_TEST_HEATER" class HeaterMode(PrettyEnum, IntEnum): - HEAT = 0 - COOL = 1 + HEATING = 0 + COOLING = 1 AUTO = 2 - UNKNOWN_1 = 3 # https://github.com/cryptk/haomnilogic-local/issues/172 + OFF = 3 # https://github.com/cryptk/haomnilogic-local/issues/172 # Pumps class PumpState(PrettyEnum, IntEnum): OFF = 0 ON = 1 - FREEZE_PROTECT = 2 # This is an assumption that 2 means freeze protect, ref: https://github.com/cryptk/haomnilogic-local/issues/147 - UNKNOWN_1 = 3 # We assume this value exists as we have evidence of a state of 4 existing + ON_FREEZE_PROTECT = 2 # This is an assumption that 2 means freeze protect, ref: https://github.com/cryptk/haomnilogic-local/issues/147 + OFF_FOR_VALVES_CHANGING = 3 # We assume this value exists as we have evidence of a state of 4 existing PRIMING = 4 # https://github.com/cryptk/haomnilogic-local/issues/223 + UNUSED = 5 + WAITING_FOR_INTERLOCK = 6 + PAUSED = 7 class PumpType(PrettyEnum, StrEnum): @@ -482,6 +505,20 @@ class PumpType(PrettyEnum, StrEnum): VARIABLE_SPEED = "PMP_VARIABLE_SPEED_PUMP" +class PumpWhyOn(PrettyEnum, IntEnum): + NO_MESSAGE = 0 + FREEZE_PROTECT = 1 + INTERLOCK = 2 + GROUP_ON = 3 + GROUP_OFF = 4 + MANUAL_OFF = 5 + COUNTDOWN_DONE = 6 + END_SCHEDULE = 7 + MANUAL_ON = 8 + COUNTDOWN_TIMER = 9 + SCHEDULE_ON = 10 + + class PumpFunction(PrettyEnum, StrEnum): PUMP = "PMP_PUMP" WATER_FEATURE = "PMP_WATER_FEATURE" @@ -497,6 +534,7 @@ class PumpFunction(PrettyEnum, StrEnum): CLEANER_SUCTION = "PMP_CLEANER_SUCTION" CLEANER_ROBOTIC = "PMP_CLEANER_ROBOTIC" CLEANER_IN_FLOOR = "PMP_CLEANER_IN_FLOOR" + WATER_BOWL = "PMP_WATER_BOWL" class PumpSpeedPresets(PrettyEnum, StrEnum): @@ -524,17 +562,24 @@ class RelayFunction(PrettyEnum, StrEnum): CLEANER_SUCTION = "RLY_CLEANER_SUCTION" CLEANER_ROBOTIC = "RLY_CLEANER_ROBOTIC" CLEANER_IN_FLOOR = "RLY_CLEANER_IN_FLOOR" + WATER_BOWL = "RLY_WATER_BOWL" class RelayState(PrettyEnum, IntEnum): OFF = 0 ON = 1 + ON_FREEZE_PROTECT = 2 + WAITING_FOR_INTERLOCK = 3 + PAUSED = 4 + WAITING_FOR_FILTER = 5 + STATE_MAX_ENTRY = 6 class RelayType(PrettyEnum, StrEnum): VALVE_ACTUATOR = "RLY_VALVE_ACTUATOR" HIGH_VOLTAGE = "RLY_HIGH_VOLTAGE_RELAY" LOW_VOLTAGE = "RLY_LOW_VOLTAGE_RELAY" + SMART_VALVE_ACTUATOR = "RLY_SMART_VALVE_ACTUATOR" class RelayWhyOn(PrettyEnum, IntEnum): @@ -549,15 +594,14 @@ class RelayWhyOn(PrettyEnum, IntEnum): GROUP_ON = 8 FREEZE_PROTECT = 9 INTERLOCK = 10 - MAX_ACTION = 11 # Sensors class SensorType(PrettyEnum, StrEnum): - AIR_TEMP = "SENSOR_AIR_TEMP" - SOLAR_TEMP = "SENSOR_SOLAR_TEMP" WATER_TEMP = "SENSOR_WATER_TEMP" + AIR_TEMP = "SENSOR_AIR_TEMP" FLOW = "SENSOR_FLOW" + SOLAR_TEMP = "SENSOR_SOLAR_TEMP" ORP = "SENSOR_ORP" EXT_INPUT = "SENSOR_EXT_INPUT" @@ -568,8 +612,9 @@ class SensorUnits(PrettyEnum, StrEnum): PPM = "UNITS_PPM" GRAMS_PER_LITER = "UNITS_GRAMS_PER_LITER" MILLIVOLTS = "UNITS_MILLIVOLTS" - NO_UNITS = "UNITS_NO_UNITS" ACTIVE_INACTIVE = "UNITS_ACTIVE_INACTIVE" + NO_UNITS = "UNITS_NO_UNITS" + ADC = "UNITS_ADC" # Valve Actuators diff --git a/tests/test_api.py b/tests/test_api.py index 019b087..ea94359 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -342,7 +342,7 @@ async def test_async_set_heater_mode_generates_valid_xml() -> None: with patch.object(api, "async_send", new_callable=AsyncMock) as mock_send: mock_send.return_value = None - await api.async_set_heater_mode(pool_id=1, equipment_id=2, mode=HeaterMode.HEAT) + await api.async_set_heater_mode(pool_id=1, equipment_id=2, mode=HeaterMode.HEATING) mock_send.assert_called_once() call_args = mock_send.call_args @@ -352,7 +352,7 @@ async def test_async_set_heater_mode_generates_valid_xml() -> None: assert _get_xml_tag(root) == "Request" assert _find_elem(root, "Name").text == "SetUIHeaterModeCmd" - assert _find_param(root, "Mode").text == str(HeaterMode.HEAT.value) + assert _find_param(root, "Mode").text == str(HeaterMode.HEATING.value) @pytest.mark.asyncio diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index 9ebc399..9657de6 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -185,7 +185,7 @@ def test_filter_is_ready_false(self, mock_omni: Mock, sample_filter_config: MSPF assert filter_obj.is_ready is False # WAITING_TURN_OFF state - filter_obj.telemetry.state = FilterState.WAITING_TURN_OFF + filter_obj.telemetry.state = FilterState.WAITING_TO_TURN_OFF assert filter_obj.is_ready is False @pytest.mark.asyncio diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 93bf179..1b97a26 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -35,15 +35,15 @@ def test_parse_basic_ack() -> None: bytes_ack = b"\x99_\xd1l\x00\x00\x00\x00dv\x8f\xc11.20\x00\x00\x03\xea\x03\x00\x00\x00" message = OmniLogicMessage.from_bytes(bytes_ack) assert message.id == 2573193580 - assert message.type is MessageType.ACK + assert message.type is MessageType.MSP_ACK assert message.compressed is False - assert str(message) == "ID: 2573193580, Type: ACK, Compressed: False, Client: OMNI, Body: " + assert str(message) == "ID: 2573193580, Type: MSP_ACK, Compressed: False, Client: OMNI, Body: " def test_create_basic_ack() -> None: """Validate that we can create a valid basic ACK packet.""" bytes_ack = b"\x99_\xd1l\x00\x00\x00\x00dv\x8f\xc11.20\x00\x00\x03\xea\x03\x00\x00\x00" - message = OmniLogicMessage(2573193580, MessageType.ACK, payload=None, version="1.20") + message = OmniLogicMessage(2573193580, MessageType.MSP_ACK, payload=None, version="1.20") message.client_type = ClientType.OMNI message.timestamp = 1685491649 assert bytes(message) == bytes_ack @@ -406,7 +406,7 @@ async def test_ensure_sent_xml_ack_message() -> None: sent_bytes = protocol._transport.sendto.call_args[0][0] # Verify the sent bytes parse back to an XML_ACK message with the correct ID parsed = OmniLogicMessage.from_bytes(sent_bytes) - assert parsed.type == MessageType.XML_ACK + assert parsed.type == MessageType.ACK assert parsed.id == 456 @@ -527,7 +527,7 @@ async def test_send_ack_generates_xml() -> None: sent_bytes = protocol._transport.sendto.call_args[0][0] parsed = OmniLogicMessage.from_bytes(sent_bytes) - assert parsed.type == MessageType.XML_ACK + assert parsed.type == MessageType.ACK assert parsed.id == 12345 # Verify XML structure contains the expected Ack name element From 584e75a07b1736ce9b28fb650c40f77c95734478 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 28 May 2026 19:30:09 +0000 Subject: [PATCH 2/2] feat: implement new omnitype data BREAKING CHANGE: Many items that previously returned a raw int value now parse a proper enum value. Specifically Chlorinator operating_state, ColorLogicLight special_effect, Heater why_on, Pump why_on --- pyomnilogic_local/chlorinator.py | 19 ++++++++++++------- pyomnilogic_local/colorlogiclight.py | 4 ++-- pyomnilogic_local/heater.py | 4 ++-- pyomnilogic_local/models/mspconfig.py | 6 +++--- pyomnilogic_local/models/telemetry.py | 12 ++++++++---- pyomnilogic_local/omnitypes.py | 23 ++++++++++++++++++++++- pyomnilogic_local/pump.py | 4 ++-- pyomnilogic_local/schedule.py | 5 +++-- tests/test_filter_pump.py | 10 ++++++---- 9 files changed, 60 insertions(+), 27 deletions(-) diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index 244563d..773d067 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -8,13 +8,18 @@ from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPChlorinator from pyomnilogic_local.models.telemetry import TelemetryChlorinator -from pyomnilogic_local.omnitypes import ChlorinatorMSPConfigMode, ChlorinatorStatus +from pyomnilogic_local.omnitypes import ChlorinatorMode, ChlorinatorStatus from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic - from pyomnilogic_local.omnitypes import ChlorinatorCellType, ChlorinatorDispenserType, ChlorinatorOperatingMode + from pyomnilogic_local.omnitypes import ( + ChlorinatorCellType, + ChlorinatorDispenserType, + ChlorinatorOperatingMode, + ChlorinatorOperatingState, + ) class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): @@ -94,12 +99,12 @@ def cell_type(self) -> ChlorinatorCellType: # Expose Telemetry attributes @property - def operating_state(self) -> int: + def operating_state(self) -> ChlorinatorOperatingState: """Current operational state of the chlorinator (raw value).""" return self.telemetry.operating_state @property - def mode(self) -> ChlorinatorMSPConfigMode: + def mode(self) -> ChlorinatorMode: """Current operating mode from MSP Config (NOT_CONFIGURED, TIMED, ORP_AUTO). TThis data appears to have some discrepancies with the mode reported in the Telemetry. @@ -421,7 +426,7 @@ async def set_timed_percent(self, percent: int) -> None: ) @control_method - async def set_op_mode(self, op_mode: ChlorinatorMSPConfigMode) -> None: + async def set_op_mode(self, op_mode: ChlorinatorMode) -> None: """Set the operating mode for chlorine generation. Args: @@ -453,9 +458,9 @@ async def set_op_mode(self, op_mode: ChlorinatorMSPConfigMode) -> None: new_op_mode: int match op_mode: - case ChlorinatorMSPConfigMode.TIMED: + case ChlorinatorMode.TIMED: new_op_mode = 1 - case ChlorinatorMSPConfigMode.ORP_AUTO: + case ChlorinatorMode.ORP_AUTO: new_op_mode = 2 case _: msg = f"Unsupported operating mode: {op_mode}" diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index ca7fd6f..6a604cb 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic - from pyomnilogic_local.omnitypes import LightShows + from pyomnilogic_local.omnitypes import ColorLogicSpecialEffect, LightShows _LOGGER = logging.getLogger(__name__) @@ -197,7 +197,7 @@ def brightness(self) -> ColorLogicBrightness: return ColorLogicBrightness.ONE_HUNDRED_PERCENT @property - def special_effect(self) -> int: + def special_effect(self) -> ColorLogicSpecialEffect: """Returns the current special effect.""" return self.telemetry.special_effect diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index f65caba..18efe6f 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic - from pyomnilogic_local.omnitypes import HeaterMode + from pyomnilogic_local.omnitypes import HeaterMode, HeaterWhyOn class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): @@ -173,7 +173,7 @@ def silent_mode(self) -> int: return self.telemetry.silent_mode @property - def why_on(self) -> int: + def why_on(self) -> HeaterWhyOn: """Returns the reason why the heater is on from telemetry. We don't have a good understanding of what these values mean yet diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 42547b9..d8207ce 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -20,7 +20,7 @@ BodyOfWaterType, ChlorinatorCellType, ChlorinatorDispenserType, - ChlorinatorMSPConfigMode, + ChlorinatorMode, ChlorinatorType, ColorLogicLightType, ColorLogicShow25, @@ -218,7 +218,7 @@ class MSPChlorinator(OmniBase): omni_type: OmniType = OmniType.CHLORINATOR enabled: bool = Field(alias="Enabled") - mode: ChlorinatorMSPConfigMode = Field(alias="Mode") + mode: ChlorinatorMode = Field(alias="Mode") timed_percent: int = Field(alias="Timed-Percent") superchlor_timeout: int = Field(alias="SuperChlor-Timeout") orp_timeout: int = Field(alias="ORP-Timeout") @@ -377,7 +377,7 @@ class MSPSchedule(OmniBase): bow_id: int = Field(alias="bow-system-id") # pyright: ignore[reportGeneralTypeIssues] equipment_id: int = Field(alias="equipment-id") system_id: int = Field(alias="schedule-system-id") - event: MessageType = Field(alias="event") + event: MessageType | int = Field(alias="event") data: int = Field(alias="data") enabled: bool = Field() start_minute: int = Field(alias="start-minute") diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index 3c2907d..987f135 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -11,6 +11,7 @@ ChlorinatorAlert, ChlorinatorError, ChlorinatorOperatingMode, + ChlorinatorOperatingState, ChlorinatorStatus, ColorLogicBrightness, ColorLogicLightType, @@ -19,6 +20,7 @@ ColorLogicShow40, ColorLogicShowUCL, ColorLogicShowUCLV2, + ColorLogicSpecialEffect, ColorLogicSpeed, CSADMode, CSADStatus, @@ -28,10 +30,12 @@ GroupState, HeaterMode, HeaterState, + HeaterWhyOn, LightShows, OmniType, PentairShow, PumpState, + PumpWhyOn, RelayState, RelayWhyOn, ValveActuatorState, @@ -127,7 +131,7 @@ class TelemetryChlorinator(BaseModel): chlr_alert_raw: int = Field(alias="@chlrAlert") chlr_error_raw: int = Field(alias="@chlrError") sc_mode: int = Field(alias="@scMode") - operating_state: int = Field(alias="@operatingState") + operating_state: ChlorinatorOperatingState = Field(alias="@operatingState") timed_percent: int | None = Field(alias="@Timed-Percent", default=None) operating_mode: ChlorinatorOperatingMode = Field(alias="@operatingMode") enable: bool = Field(alias="@enable") @@ -263,7 +267,7 @@ class TelemetryColorLogicLight(BaseModel): show: LightShows = Field(alias="@currentShow") speed: ColorLogicSpeed = Field(alias="@speed") brightness: ColorLogicBrightness = Field(alias="@brightness") - special_effect: int = Field(alias="@specialEffect") + special_effect: ColorLogicSpecialEffect = Field(alias="@specialEffect") def show_name( self, model: ColorLogicLightType, v2: bool @@ -381,7 +385,7 @@ class TelemetryPump(BaseModel): state: PumpState = Field(alias="@pumpState") speed: int = Field(alias="@pumpSpeed") last_speed: int = Field(alias="@lastSpeed") - why_on: int = Field(alias="@whyOn") + why_on: PumpWhyOn = Field(alias="@whyOn") class TelemetryRelay(BaseModel): @@ -447,7 +451,7 @@ class TelemetryVirtualHeater(BaseModel): solar_set_point: int = Field(alias="@SolarSetPoint") mode: HeaterMode = Field(alias="@Mode") silent_mode: int = Field(alias="@SilentMode") - why_on: int = Field(alias="@whyHeaterIsOn") + why_on: HeaterWhyOn = Field(alias="@whyHeaterIsOn") type TelemetryType = ( diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index aaedf2a..588cc1c 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -151,12 +151,14 @@ class ChlorinatorError(PrettyEnum, Flag): AQUARITE_PCB_ERROR = 1 << 14 -class ChlorinatorMSPConfigMode(PrettyEnum, StrEnum): +# Chlorinator Mode is used in the MSPConfig to represent the desired mode +class ChlorinatorMode(PrettyEnum, StrEnum): DISABLED = "CHLOR_OP_MODE_NOT_CONFIG_R" TIMED = "CHLOR_OP_MODE_TIMED" ORP_AUTO = "CHLOR_OP_MODE_ORP_AUTO" +# Chlorinator Operating Mode is used in the Telemetry to represent the current mode class ChlorinatorOperatingMode(PrettyEnum, IntEnum): DISABLED = 0 TIMED = 1 @@ -487,6 +489,25 @@ class HeaterMode(PrettyEnum, IntEnum): OFF = 3 # https://github.com/cryptk/haomnilogic-local/issues/172 +class HeaterWhyOn(PrettyEnum, IntEnum): + NO_MESSAGE = 0 + STOP_HEATER = 1 + BOOST = 2 + MANUAL_ON = 3 + ON_EVENT = 4 + COOLING = 5 + SET_CUR_SET_POINT = 6 + PAUSE = 7 + RESUME = 8 + SET_HEATER_SCHEDULE = 9 + RESTORE_HEATER_SETPOINT = 10 + STOP_COOL_DOWN = 11 + SET_SOLAR_SET_POINT = 12 + SET_SOLAR_SCHEDULE = 13 + RESTORE_SOLAR_SETPOINT = 14 + SET_HEATER_MODE = 15 + + # Pumps class PumpState(PrettyEnum, IntEnum): OFF = 0 diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index 2169803..783e732 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -10,7 +10,7 @@ from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: - from pyomnilogic_local.omnitypes import PumpFunction + from pyomnilogic_local.omnitypes import PumpFunction, PumpWhyOn class Pump(OmniEquipment[MSPPump, TelemetryPump]): @@ -151,7 +151,7 @@ def last_speed(self) -> int: return self.telemetry.last_speed @property - def why_on(self) -> int: + def why_on(self) -> PumpWhyOn: """Reason why the pump is on. We don't have a confirmation that these are the same as the FilterWhyOn states yet. diff --git a/pyomnilogic_local/schedule.py b/pyomnilogic_local/schedule.py index f1e42f2..ca9aa9d 100644 --- a/pyomnilogic_local/schedule.py +++ b/pyomnilogic_local/schedule.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import MessageType class Schedule(OmniEquipment[MSPSchedule, None]): @@ -78,9 +79,9 @@ def equipment_id(self) -> int: return self.mspconfig.equipment_id @property - def event(self) -> int: + def event(self) -> MessageType | int: """Returns the event/action ID that will be executed.""" - return self.mspconfig.event.value + return self.mspconfig.event @property def data(self) -> int: diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index 9657de6..86665e6 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -13,9 +13,11 @@ FilterSpeedPresets, FilterState, FilterType, + FilterWhyOn, PumpFunction, PumpState, PumpType, + PumpWhyOn, ) from pyomnilogic_local.pump import Pump @@ -57,7 +59,7 @@ def sample_filter_telemetry() -> TelemetryFilter: "@filterState": FilterState.ON, "@filterSpeed": 60, "@valvePosition": 1, - "@whyFilterIsOn": 14, + "@whyFilterIsOn": FilterWhyOn.TIMED_EVENT, "@reportedFilterSpeed": 60, "@power": 500, "@lastSpeed": 50, @@ -95,7 +97,7 @@ def sample_pump_telemetry() -> TelemetryPump: "@pumpState": PumpState.ON, "@pumpSpeed": 60, "@lastSpeed": 50, - "@whyOn": 11, + "@whyOn": PumpWhyOn.MANUAL_ON, }, ) @@ -142,7 +144,7 @@ def test_filter_properties_telemetry(self, mock_omni: Mock, sample_filter_config assert filter_obj.state == FilterState.ON assert filter_obj.speed == 60 assert filter_obj.valve_position == 1 - assert filter_obj.why_on == 14 + assert filter_obj.why_on == FilterWhyOn.TIMED_EVENT assert filter_obj.reported_speed == 60 assert filter_obj.power == 500 assert filter_obj.last_speed == 50 @@ -291,7 +293,7 @@ def test_pump_properties_telemetry(self, mock_omni: Mock, sample_pump_config: MS assert pump_obj.state == PumpState.ON assert pump_obj.speed == 60 assert pump_obj.last_speed == 50 - assert pump_obj.why_on == 11 + assert pump_obj.why_on == PumpWhyOn.MANUAL_ON def test_pump_is_on_true(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test is_on returns True when pump is on."""