diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py
index 072c17f..d11a428 100644
--- a/pyomnilogic_local/api/api.py
+++ b/pyomnilogic_local/api/api.py
@@ -5,6 +5,7 @@
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Literal, overload
+from pyomnilogic_local.models.chlorinator_diagnostics import ChlorinatorMeasurement, ChlorinatorRelayPolarity
from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics
from pyomnilogic_local.models.mspconfig import MSPConfig
from pyomnilogic_local.models.telemetry import Telemetry
@@ -223,6 +224,68 @@ async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, ra
return resp
return FilterDiagnostics.load_xml(resp)
+ @overload
+ async def async_get_chlorinator_measurement(self, pool_id: int, chlorinator_id: int, raw: Literal[True]) -> str: ...
+ @overload
+ async def async_get_chlorinator_measurement(self, pool_id: int, chlorinator_id: int, raw: Literal[False]) -> ChlorinatorMeasurement: ...
+ @overload
+ async def async_get_chlorinator_measurement(self, pool_id: int, chlorinator_id: int) -> ChlorinatorMeasurement: ...
+ async def async_get_chlorinator_measurement(self, pool_id: int, chlorinator_id: int, raw: bool = False) -> ChlorinatorMeasurement | str:
+ """Retrieve electrical, temperature, and salt measurements from a chlorinator.
+
+ Args:
+ pool_id: Body-of-water system ID.
+ chlorinator_id: Physical chlorinator equipment ID.
+ raw: Return the raw XML response instead of a parsed model.
+
+ Returns:
+ Parsed chlorinator measurements or the raw XML response.
+ """
+ req_body = self._build_chlorinator_diagnostic_request("GetCHLORMeasurement", pool_id, chlorinator_id)
+ resp = await self.async_send_and_receive(MessageType.GET_DIAGNOSTIC, req_body)
+ if raw:
+ return resp
+ return ChlorinatorMeasurement.load_xml(resp)
+
+ @overload
+ async def async_get_chlorinator_relay_polarity(self, pool_id: int, chlorinator_id: int, raw: Literal[True]) -> str: ...
+ @overload
+ async def async_get_chlorinator_relay_polarity(
+ self, pool_id: int, chlorinator_id: int, raw: Literal[False]
+ ) -> ChlorinatorRelayPolarity: ...
+ @overload
+ async def async_get_chlorinator_relay_polarity(self, pool_id: int, chlorinator_id: int) -> ChlorinatorRelayPolarity: ...
+ async def async_get_chlorinator_relay_polarity(
+ self, pool_id: int, chlorinator_id: int, raw: bool = False
+ ) -> ChlorinatorRelayPolarity | str:
+ """Retrieve the relay polarity from physical chlorinator equipment.
+
+ Args:
+ pool_id: Body-of-water system ID.
+ chlorinator_id: Physical chlorinator equipment ID.
+ raw: Return the raw XML response instead of a parsed model.
+
+ Returns:
+ Parsed relay polarity or the raw XML response.
+ """
+ req_body = self._build_chlorinator_diagnostic_request("GetCHLORRelayPolarity", pool_id, chlorinator_id)
+ resp = await self.async_send_and_receive(MessageType.GET_DIAGNOSTIC, req_body)
+ if raw:
+ return resp
+ return ChlorinatorRelayPolarity.load_xml(resp)
+
+ @staticmethod
+ def _build_chlorinator_diagnostic_request(operation: str, pool_id: int, chlorinator_id: int) -> str:
+ body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE})
+ name_element = ET.SubElement(body_element, "Name")
+ name_element.text = operation
+ parameters_element = ET.SubElement(body_element, "Parameters")
+ parameter = ET.SubElement(parameters_element, "Parameter", name="PoolID", dataType="int")
+ parameter.text = str(pool_id)
+ parameter = ET.SubElement(parameters_element, "Parameter", name="ChlorID", dataType="int")
+ parameter.text = str(chlorinator_id)
+ return ET.tostring(body_element, xml_declaration=True, encoding="unicode")
+
@overload
async def async_get_telemetry(self, raw: Literal[True]) -> str: ...
@overload
diff --git a/pyomnilogic_local/models/__init__.py b/pyomnilogic_local/models/__init__.py
index 320c5d4..b10a251 100644
--- a/pyomnilogic_local/models/__init__.py
+++ b/pyomnilogic_local/models/__init__.py
@@ -2,11 +2,14 @@
from __future__ import annotations
+from .chlorinator_diagnostics import ChlorinatorMeasurement, ChlorinatorRelayPolarity
from .filter_diagnostics import FilterDiagnostics
from .mspconfig import MSPConfig, MSPConfigType, MSPEquipmentType
from .telemetry import Telemetry, TelemetryType
__all__ = [
+ "ChlorinatorMeasurement",
+ "ChlorinatorRelayPolarity",
"FilterDiagnostics",
"MSPConfig",
"MSPConfigType",
diff --git a/pyomnilogic_local/models/chlorinator_diagnostics.py b/pyomnilogic_local/models/chlorinator_diagnostics.py
new file mode 100644
index 0000000..fd06122
--- /dev/null
+++ b/pyomnilogic_local/models/chlorinator_diagnostics.py
@@ -0,0 +1,296 @@
+"""Models for OmniLogic chlorinator diagnostic responses."""
+
+from __future__ import annotations
+
+import math
+from typing import ClassVar, Self
+
+from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError
+from xmltodict import parse as xml_parse
+
+from pyomnilogic_local.models.exceptions import OmniParsingError
+
+_INVALID_DIAGNOSTIC_VALUE = 0xFFFF
+_ADC_REFERENCE_VOLTAGE = 5
+_ADC_MAX_VALUE = 255
+_VOLTAGE_SCALE = 8.15
+_CURRENT_SCALE = 0.489
+
+# Lookup table used by OmniLogic to convert the cell and board thermistor ADC
+# readings to tenths of a degree Fahrenheit. Values between entries are
+# interpolated using the low three bits of the raw reading.
+_TEMPERATURE_TENTHS_F = (
+ 8000,
+ 3740,
+ 3136,
+ 2817,
+ 2603,
+ 2444,
+ 2318,
+ 2214,
+ 2125,
+ 2048,
+ 1980,
+ 1919,
+ 1864,
+ 1813,
+ 1767,
+ 1723,
+ 1683,
+ 1645,
+ 1610,
+ 1576,
+ 1544,
+ 1514,
+ 1485,
+ 1458,
+ 1431,
+ 1406,
+ 1381,
+ 1358,
+ 1335,
+ 1313,
+ 1291,
+ 1270,
+ 1250,
+ 1230,
+ 1211,
+ 1193,
+ 1174,
+ 1156,
+ 1139,
+ 1122,
+ 1105,
+ 1088,
+ 1072,
+ 1056,
+ 1041,
+ 1025,
+ 1010,
+ 995,
+ 980,
+ 966,
+ 951,
+ 937,
+ 923,
+ 909,
+ 895,
+ 881,
+ 868,
+ 854,
+ 841,
+ 828,
+ 815,
+ 802,
+ 789,
+ 776,
+ 763,
+ 750,
+ 737,
+ 725,
+ 712,
+ 699,
+ 687,
+ 674,
+ 661,
+ 649,
+ 636,
+ 624,
+ 611,
+ 598,
+ 586,
+ 573,
+ 560,
+ 547,
+ 534,
+ 521,
+ 508,
+ 495,
+ 482,
+ 469,
+ 455,
+ 442,
+ 428,
+ 414,
+ 400,
+ 386,
+ 372,
+ 357,
+ 342,
+ 327,
+ 312,
+ 297,
+ 281,
+ 265,
+ 248,
+ 231,
+ 214,
+ 196,
+ 178,
+ 159,
+ 140,
+ 120,
+ 99,
+ 77,
+ 54,
+ 31,
+ 6,
+)
+
+
+def _combine_bytes(high_byte: int, low_byte: int) -> int:
+ return high_byte << 8 | low_byte
+
+
+def _decode_temperature(raw_value: int) -> float | None:
+ if raw_value == 0:
+ return 0.0
+ table_index, interpolation = divmod(raw_value, 8)
+ if raw_value >= _INVALID_DIAGNOSTIC_VALUE or table_index >= len(_TEMPERATURE_TENTHS_F) - 1:
+ return None
+
+ temperature = _TEMPERATURE_TENTHS_F[table_index]
+ if interpolation:
+ difference = temperature - _TEMPERATURE_TENTHS_F[table_index + 1]
+ temperature -= math.floor(((difference * interpolation * 10 / 8) + 5) / 10)
+ return temperature / 10
+
+
+class ChlorinatorDiagnosticParameter(BaseModel):
+ """A named value from a chlorinator diagnostic response."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ name: str = Field(alias="@name")
+ data_type: str = Field(alias="@dataType")
+ value: int = Field(alias="#text")
+
+
+class ChlorinatorDiagnosticResponse(BaseModel):
+ """Base model for parameter-based chlorinator diagnostic responses."""
+
+ model_config = ConfigDict(from_attributes=True)
+ expected_response_name: ClassVar[str]
+ _raw: str = PrivateAttr(default="")
+
+ name: str = Field(alias="Name")
+ parameters: list[ChlorinatorDiagnosticParameter] = Field(alias="Parameters")
+
+ def get_param_by_name(self, name: str) -> int:
+ """Return a diagnostic parameter by its wire name."""
+ parameter = next((parameter for parameter in self.parameters if parameter.name == name), None)
+ if parameter is None:
+ msg = f"Missing chlorinator diagnostic parameter: {name}"
+ raise OmniParsingError(msg)
+ return parameter.value
+
+ @classmethod
+ def load_xml(cls, xml: str) -> Self:
+ """Parse a chlorinator diagnostic XML response."""
+ data = xml_parse(xml, force_list=("Parameter",))
+ response_name = data.get("Response", {}).get("Name")
+ if response_name != cls.expected_response_name:
+ msg = f"Expected {cls.expected_response_name} response, got {response_name!r}"
+ raise OmniParsingError(msg)
+ data["Response"]["Parameters"] = data["Response"]["Parameters"]["Parameter"]
+ try:
+ instance = cls.model_validate(data["Response"])
+ instance._raw = xml
+ except ValidationError as exc:
+ msg = f"Failed to parse chlorinator diagnostics: {exc}"
+ raise OmniParsingError(msg) from exc
+ return instance
+
+
+class ChlorinatorMeasurement(ChlorinatorDiagnosticResponse):
+ """Electrical, temperature, and salt measurements from a chlorinator cell."""
+
+ expected_response_name = "GetCHLORMeasurementRsp"
+
+ @property
+ def pool_id(self) -> int:
+ """Body-of-water system ID."""
+ return self.get_param_by_name("PoolID")
+
+ @property
+ def chlorinator_id(self) -> int:
+ """Physical chlorinator equipment ID."""
+ return self.get_param_by_name("ChlorID")
+
+ def _combined_parameter(self, prefix: str) -> int:
+ return _combine_bytes(self.get_param_by_name(f"{prefix}HighByte"), self.get_param_by_name(f"{prefix}LowByte"))
+
+ @property
+ def voltage_raw(self) -> int:
+ """Raw cell-voltage ADC reading."""
+ return self._combined_parameter("Voltage")
+
+ @property
+ def current_raw(self) -> int:
+ """Raw cell-current ADC reading."""
+ return self._combined_parameter("Current")
+
+ @property
+ def cell_temperature_raw(self) -> int:
+ """Raw cell-temperature thermistor reading."""
+ return self._combined_parameter("CellTemp")
+
+ @property
+ def board_temperature_raw(self) -> int:
+ """Raw controller-board temperature thermistor reading."""
+ return self._combined_parameter("BoardTemp")
+
+ @property
+ def voltage(self) -> float | None:
+ """Cell voltage in volts, or None when the controller reports an invalid sentinel."""
+ if self.voltage_raw >= _INVALID_DIAGNOSTIC_VALUE:
+ return None
+ return self.voltage_raw * _ADC_REFERENCE_VOLTAGE / _ADC_MAX_VALUE * _VOLTAGE_SCALE
+
+ @property
+ def current(self) -> float | None:
+ """Cell current in amperes, or None when the controller reports an invalid sentinel."""
+ if self.current_raw >= _INVALID_DIAGNOSTIC_VALUE:
+ return None
+ return (self.current_raw * _ADC_REFERENCE_VOLTAGE / _ADC_MAX_VALUE) / _CURRENT_SCALE
+
+ @property
+ def cell_temperature_f(self) -> float | None:
+ """Cell temperature in degrees Fahrenheit, or None when invalid."""
+ return _decode_temperature(self.cell_temperature_raw)
+
+ @property
+ def board_temperature_f(self) -> float | None:
+ """Controller-board temperature in degrees Fahrenheit, or None when invalid."""
+ return _decode_temperature(self.board_temperature_raw)
+
+ @property
+ def instant_salt_level(self) -> int:
+ """Instantaneous salt level in parts per million."""
+ return self._combined_parameter("InstantSaltLevel")
+
+ @property
+ def average_salt_level(self) -> int:
+ """Average salt level in parts per million."""
+ return self._combined_parameter("AverageSaltLevel")
+
+
+class ChlorinatorRelayPolarity(ChlorinatorDiagnosticResponse):
+ """Relay polarity reported by the physical chlorinator equipment."""
+
+ expected_response_name = "GetCHLORRelayPolarityRsp"
+
+ @property
+ def pool_id(self) -> int:
+ """Body-of-water system ID."""
+ return self.get_param_by_name("PoolID")
+
+ @property
+ def chlorinator_id(self) -> int:
+ """Physical chlorinator equipment ID."""
+ return self.get_param_by_name("ChlorID")
+
+ @property
+ def relay_setting(self) -> int:
+ """Raw chlorinator relay polarity setting."""
+ return self.get_param_by_name("RelaySetting")
diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py
index 588cc1c..cc96c68 100644
--- a/pyomnilogic_local/omnitypes.py
+++ b/pyomnilogic_local/omnitypes.py
@@ -31,13 +31,16 @@ class MessageType(PrettyEnum, IntEnum):
RUN_GROUP = 317
RESTORE_IDLE_STATE = 340
GET_FILTER_DIAGNOSTIC = 386
+ GET_DIAGNOSTIC = 386
HANDSHAKE = 1000
MSP_ACK = 1002
MSP_CONFIGURATIONUPDATE = 1003
MSP_TELEMETRY_UPDATE = 1004
MSP_GET_FILTER_SPEED_RESPONSE = 1010
+ MSP_CHLOR_RELAY_POLARITY_RESPONSE = 1174
MSP_ALARM_LIST_RESPONSE = 1304
MSP_FILTER_DIAGNOSTIC_INFO_RESPONSE = 1386
+ MSP_DIAGNOSTIC_INFO_RESPONSE = 1386
MSP_LEADMESSAGE = 1998
MSP_BLOCKMESSAGE = 1999
diff --git a/tests/test_api.py b/tests/test_api.py
index ea94359..0459685 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -243,6 +243,48 @@ async def test_async_get_filter_diagnostics_generates_valid_xml() -> None:
assert _find_param(root, "equipmentId").text == "2"
+@pytest.mark.asyncio
+async def test_async_get_chlorinator_measurement_generates_valid_xml() -> None:
+ """Test chlorinator measurement request generation."""
+ api = OmniLogicAPI("controller.local")
+
+ with patch.object(api, "async_send_and_receive", new_callable=AsyncMock) as mock_send:
+ mock_send.return_value = 'GetCHLORMeasurementRsp'
+
+ await api.async_get_chlorinator_measurement(pool_id=1, chlorinator_id=4, raw=True)
+
+ mock_send.assert_awaited_once()
+ message_type, xml_payload = mock_send.call_args.args
+ root = ET.fromstring(xml_payload)
+
+ assert message_type is MessageType.GET_DIAGNOSTIC
+ assert _get_xml_tag(root) == "Request"
+ assert _find_elem(root, "Name").text == "GetCHLORMeasurement"
+ assert _find_param(root, "PoolID").text == "1"
+ assert _find_param(root, "ChlorID").text == "4"
+
+
+@pytest.mark.asyncio
+async def test_async_get_chlorinator_relay_polarity_generates_valid_xml() -> None:
+ """Test chlorinator relay polarity request generation."""
+ api = OmniLogicAPI("controller.local")
+
+ with patch.object(api, "async_send_and_receive", new_callable=AsyncMock) as mock_send:
+ mock_send.return_value = 'GetCHLORRelayPolarityRsp'
+
+ await api.async_get_chlorinator_relay_polarity(pool_id=1, chlorinator_id=4, raw=True)
+
+ mock_send.assert_awaited_once()
+ message_type, xml_payload = mock_send.call_args.args
+ root = ET.fromstring(xml_payload)
+
+ assert message_type is MessageType.GET_DIAGNOSTIC
+ assert _get_xml_tag(root) == "Request"
+ assert _find_elem(root, "Name").text == "GetCHLORRelayPolarity"
+ assert _find_param(root, "PoolID").text == "1"
+ assert _find_param(root, "ChlorID").text == "4"
+
+
@pytest.mark.asyncio
async def test_async_set_heater_generates_valid_xml() -> None:
"""Test that async_set_heater generates valid XML with correct parameters."""
diff --git a/tests/test_chlorinator_diagnostics.py b/tests/test_chlorinator_diagnostics.py
new file mode 100644
index 0000000..068a44b
--- /dev/null
+++ b/tests/test_chlorinator_diagnostics.py
@@ -0,0 +1,140 @@
+"""Tests for chlorinator diagnostic response parsing."""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+
+from pyomnilogic_local.models.chlorinator_diagnostics import ChlorinatorMeasurement, ChlorinatorRelayPolarity
+from pyomnilogic_local.models.exceptions import OmniParsingError
+
+MEASUREMENT_XML = """\
+
+
+ GetCHLORMeasurementRsp
+
+ 7
+ 8
+ 0
+ 198
+ 0
+ 9
+ 1
+ 200
+ 1
+ 23
+ 11
+ 53
+ 11
+ 120
+
+
+"""
+
+OFF_MEASUREMENT_XML = """\
+
+
+ GetCHLORMeasurementRsp
+
+ 7
+ 8
+ 0
+ 0
+ 0
+ 0
+ 0
+ 0
+ 0
+ 0
+ 0
+ 0
+ 11
+ 84
+
+
+"""
+
+INVALID_TEMPERATURE_XML = MEASUREMENT_XML.replace(
+ '1',
+ '255',
+).replace(
+ '200',
+ '255',
+)
+
+POLARITY_XML = """\
+
+
+ GetCHLORRelayPolarityRsp
+
+ 7
+ 8
+ 1
+
+
+"""
+
+
+def test_chlorinator_measurement_parses_diagnostics() -> None:
+ """Parse raw measurement bytes and expose engineering units."""
+ measurement = ChlorinatorMeasurement.load_xml(MEASUREMENT_XML)
+
+ assert measurement.pool_id == 7
+ assert measurement.chlorinator_id == 8
+ assert measurement.voltage_raw == 198
+ assert measurement.current_raw == 9
+ assert measurement.cell_temperature_raw == 456
+ assert measurement.board_temperature_raw == 279
+ assert measurement.instant_salt_level == 2869
+ assert measurement.average_salt_level == 2936
+ assert math.isclose(measurement.voltage, 31.64, abs_tol=0.01)
+ assert math.isclose(measurement.current, 0.36, abs_tol=0.01)
+ assert math.isclose(measurement.cell_temperature_f, 85.4, abs_tol=0.1)
+ assert math.isclose(measurement.board_temperature_f, 119.5, abs_tol=0.1)
+
+
+def test_chlorinator_measurement_preserves_off_state() -> None:
+ """Represent zero instantaneous values returned while disabled."""
+ measurement = ChlorinatorMeasurement.load_xml(OFF_MEASUREMENT_XML)
+
+ assert measurement.voltage == pytest.approx(0.0)
+ assert measurement.current == pytest.approx(0.0)
+ assert measurement.cell_temperature_f == pytest.approx(0.0)
+ assert measurement.board_temperature_f == pytest.approx(0.0)
+ assert measurement.instant_salt_level == 0
+ assert measurement.average_salt_level == 2900
+
+
+def test_chlorinator_measurement_reports_invalid_temperature() -> None:
+ """Represent the controller's invalid temperature sentinel as None."""
+ measurement = ChlorinatorMeasurement.load_xml(INVALID_TEMPERATURE_XML)
+
+ assert measurement.cell_temperature_raw == 0xFFFF
+ assert measurement.cell_temperature_f is None
+
+
+def test_chlorinator_relay_polarity_parses_setting() -> None:
+ """Parse the chlorinator relay polarity setting."""
+ polarity = ChlorinatorRelayPolarity.load_xml(POLARITY_XML)
+
+ assert polarity.pool_id == 7
+ assert polarity.chlorinator_id == 8
+ assert polarity.relay_setting == 1
+
+
+def test_chlorinator_measurement_reports_missing_parameter() -> None:
+ """Raise a protocol parsing error when a required parameter is absent."""
+ measurement = ChlorinatorMeasurement.load_xml(MEASUREMENT_XML.replace(' name="VoltageLowByte"', ' name="UnexpectedParameter"'))
+
+ with pytest.raises(OmniParsingError, match="Missing chlorinator diagnostic parameter: VoltageLowByte"):
+ _ = measurement.voltage
+
+
+def test_chlorinator_measurement_rejects_unexpected_response() -> None:
+ """Reject a response intended for a different diagnostic operation."""
+ with pytest.raises(
+ OmniParsingError,
+ match="Expected GetCHLORMeasurementRsp response, got 'GetCHLORRelayPolarityRsp'",
+ ):
+ ChlorinatorMeasurement.load_xml(POLARITY_XML)