Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 86 additions & 6 deletions pyomnilogic_local/models/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
from ..exceptions import OmniParsingException
from ..omnitypes import (
BackyardState,
ChlorinatorAlert,
ChlorinatorError,
ChlorinatorOperatingMode,
ChlorinatorStatus,
ColorLogicBrightness,
ColorLogicPowerState,
ColorLogicShow,
Expand Down Expand Up @@ -73,18 +76,95 @@ class TelemetryChlorinator(BaseModel):
status_raw: int = Field(alias="@status")
instant_salt_level: int = Field(alias="@instantSaltLevel")
avg_salt_level: int = Field(alias="@avgSaltLevel")
chlr_alert: int = Field(alias="@chlrAlert")
chlr_error: int = Field(alias="@chlrError")
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")
timed_percent: int | None = Field(alias="@Timed-Percent", default=None)
operating_mode: ChlorinatorOperatingMode | int = Field(alias="@operatingMode")
enable: bool = Field(alias="@enable")

# Still need to do a bit more work to determine if a chlorinator is actively chlorinating
# @property
# def active(self) -> bool:
# return self.status_raw & 4 == 4 # Check if bit 4 is set, which means the chlorinator is currently chlorinating
@property
def status(self) -> list[str]:
"""Decode status bitmask into a list of active status flag names.

Returns:
List of active ChlorinatorStatus flag names as strings

Example:
>>> chlorinator.status
['ALERT_PRESENT', 'GENERATING', 'K1_ACTIVE']
"""
return [flag.name for flag in ChlorinatorStatus if self.status_raw & flag.value and flag.name is not None]

@property
def alerts(self) -> list[str]:
"""Decode chlrAlert bitmask into a list of active alert flag names.

Returns:
List of active ChlorinatorAlert flag names as strings

Note:
When both CELL_TEMP_LOW and CELL_TEMP_SCALEBACK are set (bits 5:4 = 11),
they are replaced with "CELL_TEMP_HIGH" for semantic correctness.

Example:
>>> chlorinator.alerts
['SALT_LOW', 'HIGH_CURRENT']
"""

flags = ChlorinatorAlert(self.chlr_alert_raw)
high_temp_bits = ChlorinatorAlert.CELL_TEMP_LOW | ChlorinatorAlert.CELL_TEMP_SCALEBACK
cell_temp_high = False

if flags & high_temp_bits == high_temp_bits:
cell_temp_high = True
flags = flags & ~high_temp_bits

final_flags = [flag.name for flag in ChlorinatorAlert if flags & flag and flag.name is not None]
if cell_temp_high:
final_flags.append("CELL_TEMP_HIGH")

return final_flags

@property
def errors(self) -> list[str]:
"""Decode chlrError bitmask into a list of active error flag names.

Returns:
List of active ChlorinatorError flag names as strings

Note:
When both CELL_ERROR_TYPE and CELL_ERROR_AUTH are set (bits 13:12 = 11),
they are replaced with "CELL_COMM_LOSS" for semantic correctness.

Example:
>>> chlorinator.errors
['CURRENT_SENSOR_SHORT', 'VOLTAGE_SENSOR_OPEN']
"""

flags = ChlorinatorError(self.chlr_error_raw)
cell_comm_loss_bits = ChlorinatorError.CELL_ERROR_TYPE | ChlorinatorError.CELL_ERROR_AUTH
cell_comm_loss = False

if flags & cell_comm_loss_bits == cell_comm_loss_bits:
cell_comm_loss = True
flags = flags & ~cell_comm_loss_bits

final_flags = [flag.name for flag in ChlorinatorError if flags & flag and flag.name is not None]
if cell_comm_loss:
final_flags.append("CELL_COMM_LOSS")

return final_flags

@property
def active(self) -> bool:
"""Check if the chlorinator is actively generating chlorine.

Returns:
True if the GENERATING status flag is set, False otherwise
"""
return ChlorinatorStatus.GENERATING.value & self.status_raw == ChlorinatorStatus.GENERATING.value


class TelemetryCSAD(BaseModel):
Expand Down
64 changes: 59 additions & 5 deletions pyomnilogic_local/omnitypes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from enum import Enum, IntEnum
from enum import Enum, Flag, IntEnum

from .util import PrettyEnum

Expand Down Expand Up @@ -85,12 +85,66 @@ class BodyOfWaterType(str, PrettyEnum):


# Chlorinators
# Chlorinator status is a bitmask that we still need to figure out
# class ChlorinatorStatus(str,Enum):
# pass
class ChlorinatorStatus(Flag):
"""Chlorinator status flags.

These flags represent the current operational state of the chlorinator
and can be combined (multiple flags can be active simultaneously).
"""

ERROR_PRESENT = 1 << 0 # Error present, check chlrError value
ALERT_PRESENT = 1 << 1 # Alert present, check chlrAlert value
GENERATING = 1 << 2 # Power is applied to T-Cell (actively chlorinating)
SYSTEM_PAUSED = 1 << 3 # System processor is pausing chlorination
LOCAL_PAUSED = 1 << 4 # Local processor is pausing chlorination
AUTHENTICATED = 1 << 5 # T-Cell is authenticated
K1_ACTIVE = 1 << 6 # K1 relay is active
K2_ACTIVE = 1 << 7 # K2 relay is active


class ChlorinatorAlert(Flag):
"""Chlorinator alert flags.

Multi-bit fields are represented by their individual values.
Use the helper properties on TelemetryChlorinator for semantic interpretation.
"""

SALT_LOW = 1 << 0 # Salt level is low
SALT_TOO_LOW = 1 << 1 # Salt level is too low
HIGH_CURRENT = 1 << 2 # High current alert
LOW_VOLTAGE = 1 << 3 # Low voltage alert
CELL_TEMP_LOW = 1 << 4 # Cell water temperature low
CELL_TEMP_SCALEBACK = 1 << 5 # Cell water temperature scaleback
# CELL_TEMP_LOW and CELL_TEMP_SCALEBACK = CELL_TEMP_HIGH
BOARD_TEMP_HIGH = 1 << 6 # Board temperature high
BOARD_TEMP_CLEARING = 1 << 7 # Board temperature clearing
CELL_CLEAN = 1 << 11 # Cell cleaning runtime alert


class ChlorinatorError(Flag):
"""Chlorinator error flags.

Multi-bit fields are represented by their individual values.
Use the helper properties on TelemetryChlorinator for semantic interpretation.
"""

CURRENT_SENSOR_SHORT = 1 << 0
CURRENT_SENSOR_OPEN = 1 << 1
VOLTAGE_SENSOR_SHORT = 1 << 2
VOLTAGE_SENSOR_OPEN = 1 << 3
CELL_TEMP_SENSOR_SHORT = 1 << 4
CELL_TEMP_SENSOR_OPEN = 1 << 5
BOARD_TEMP_SENSOR_SHORT = 1 << 6
BOARD_TEMP_SENSOR_OPEN = 1 << 7
K1_RELAY_SHORT = 1 << 8
K1_RELAY_OPEN = 1 << 9
K2_RELAY_SHORT = 1 << 10
K2_RELAY_OPEN = 1 << 11
CELL_ERROR_TYPE = 1 << 12
CELL_ERROR_AUTH = 1 << 13
AQUARITE_PCB_ERROR = 1 << 14


# I have seen one pool that had an operatingMode of 3, I am not sure what that means, perhaps that is an OFF mode
class ChlorinatorOperatingMode(IntEnum):
DISABLED = 0
TIMED = 1
Expand Down
182 changes: 182 additions & 0 deletions tests/test_chlorinator_bitmask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Tests for chlorinator bitmask decoding."""

from pyomnilogic_local.models.telemetry import TelemetryChlorinator


def test_chlorinator_status_decoding() -> None:
"""Test decoding of chlorinator status bitmask."""
# Create a chlorinator with status = 134 (0b10000110)
# Bit 1: ALERT_PRESENT (2)
# Bit 2: GENERATING (4)
# Bit 7: K2_ACTIVE (128)
# Total: 2 + 4 + 128 = 134
data = {
"@systemId": 5,
"@status": 134,
"@instantSaltLevel": 4082,
"@avgSaltLevel": 4042,
"@chlrAlert": 0,
"@chlrError": 0,
"@scMode": 0,
"@operatingState": 1,
"@Timed-Percent": 70,
"@operatingMode": 1,
"@enable": True,
}

chlorinator = TelemetryChlorinator.model_validate(data)

# Check raw value
assert chlorinator.status_raw == 134

# Check decoded status (returns list of string names)
status_flags = chlorinator.status
assert "ALERT_PRESENT" in status_flags
assert "GENERATING" in status_flags
assert "K2_ACTIVE" in status_flags
assert len(status_flags) == 3

# Verify active property
assert chlorinator.active is True


def test_chlorinator_alert_decoding() -> None:
"""Test decoding of chlorinator alert bitmask."""
# Create a chlorinator with chlrAlert = 32 (0b00100000)
# Bit 5: CELL_TEMP_SCALEBACK (32)
data = {
"@systemId": 5,
"@status": 2, # ALERT_PRESENT
"@instantSaltLevel": 4082,
"@avgSaltLevel": 4042,
"@chlrAlert": 32,
"@chlrError": 0,
"@scMode": 0,
"@operatingState": 1,
"@operatingMode": 1,
"@enable": True,
}

chlorinator = TelemetryChlorinator.model_validate(data)

# Check raw value
assert chlorinator.chlr_alert_raw == 32

# Check decoded alerts (returns list of string names)
alert_flags = chlorinator.alerts
assert "CELL_TEMP_SCALEBACK" in alert_flags
assert len(alert_flags) == 1


def test_chlorinator_error_decoding() -> None:
"""Test decoding of chlorinator error bitmask."""
# Create a chlorinator with chlrError = 257 (0b100000001)
# Bit 0: CURRENT_SENSOR_SHORT (1)
# Bit 8: K1_RELAY_SHORT (256)
# Total: 1 + 256 = 257
data = {
"@systemId": 5,
"@status": 1, # ERROR_PRESENT
"@instantSaltLevel": 4082,
"@avgSaltLevel": 4042,
"@chlrAlert": 0,
"@chlrError": 257,
"@scMode": 0,
"@operatingState": 1,
"@operatingMode": 1,
"@enable": True,
}

chlorinator = TelemetryChlorinator.model_validate(data)

# Check raw value
assert chlorinator.chlr_error_raw == 257

# Check decoded errors (returns list of string names)
error_flags = chlorinator.errors
assert "CURRENT_SENSOR_SHORT" in error_flags
assert "K1_RELAY_SHORT" in error_flags
assert len(error_flags) == 2


def test_chlorinator_no_flags() -> None:
"""Test chlorinator with no status/alert/error flags set."""
data = {
"@systemId": 5,
"@status": 0,
"@instantSaltLevel": 4082,
"@avgSaltLevel": 4042,
"@chlrAlert": 0,
"@chlrError": 0,
"@scMode": 0,
"@operatingState": 1,
"@operatingMode": 1,
"@enable": True,
}

chlorinator = TelemetryChlorinator.model_validate(data)

# All should be empty
assert chlorinator.status == []
assert chlorinator.alerts == []
assert chlorinator.errors == []
assert chlorinator.active is False


def test_chlorinator_complex_alerts() -> None:
"""Test complex multi-bit alert combinations."""
# chlrAlert = 67 (0b01000011)
# Bit 0: SALT_LOW (1)
# Bit 1: SALT_VERY_LOW (2)
# Bit 6: BOARD_TEMP_HIGH (64)
# Total: 1 + 2 + 64 = 67
data = {
"@systemId": 5,
"@status": 2,
"@instantSaltLevel": 4082,
"@avgSaltLevel": 4042,
"@chlrAlert": 67,
"@chlrError": 0,
"@scMode": 0,
"@operatingState": 1,
"@operatingMode": 1,
"@enable": True,
}

chlorinator = TelemetryChlorinator.model_validate(data)

alert_flags = chlorinator.alerts
assert "SALT_LOW" in alert_flags
assert "SALT_TOO_LOW" in alert_flags
assert "BOARD_TEMP_HIGH" in alert_flags
assert len(alert_flags) == 3


def test_chlorinator_all_status_flags() -> None:
"""Test chlorinator with all status flags set."""
# status = 255 (0b11111111) - all 8 bits set
data = {
"@systemId": 5,
"@status": 255,
"@instantSaltLevel": 4082,
"@avgSaltLevel": 4042,
"@chlrAlert": 0,
"@chlrError": 0,
"@scMode": 0,
"@operatingState": 1,
"@operatingMode": 1,
"@enable": True,
}

chlorinator = TelemetryChlorinator.model_validate(data)

status_flags = chlorinator.status
assert "ERROR_PRESENT" in status_flags
assert "ALERT_PRESENT" in status_flags
assert "GENERATING" in status_flags
assert "SYSTEM_PAUSED" in status_flags
assert "LOCAL_PAUSED" in status_flags
assert "AUTHENTICATED" in status_flags
assert "K1_ACTIVE" in status_flags
assert "K2_ACTIVE" in status_flags
assert len(status_flags) == 8
Loading