diff --git a/src/omni_pca/__init__.py b/src/omni_pca/__init__.py index 3bb4857..8c471a8 100644 --- a/src/omni_pca/__init__.py +++ b/src/omni_pca/__init__.py @@ -3,7 +3,10 @@ from importlib.metadata import PackageNotFoundError, version from .programs import ( + Condition, + ConditionFamily, Days, + MiscConditional, Program, ProgramCond, ProgramType, @@ -18,7 +21,10 @@ except PackageNotFoundError: __version__ = "0.0.0+unknown" __all__ = [ + "Condition", + "ConditionFamily", "Days", + "MiscConditional", "Program", "ProgramCond", "ProgramType", diff --git a/src/omni_pca/programs.py b/src/omni_pca/programs.py index 3fbe100..f76eabf 100644 --- a/src/omni_pca/programs.py +++ b/src/omni_pca/programs.py @@ -168,6 +168,167 @@ _HR_SUNRISE_SENTINEL = 25 _HR_SUNSET_SENTINEL = 26 +class ConditionFamily(IntEnum): + """Top-level discriminator for the 16-bit ``cond`` / ``cond2`` field. + + Found by ``(cond >> 8) & 0xFC`` — i.e. the high byte's bits 2-7 + (clsText.cs:2226). The four explicit families match + :class:`ProgramCond`; ``SEC`` is the catch-all default that + handles security-mode conditions (and anything else that doesn't + match the first four). + """ + + OTHER = 0 + ZONE = 4 + CTRL = 8 + TIME = 12 + SEC = 16 + + +class MiscConditional(IntEnum): + """Misc-conditional enum (``enuMiscConditional``) used by the + :attr:`ConditionFamily.OTHER` family. + + Low 4 bits of ``cond`` index into this table; the high bits are zero. + """ + + NONE = 0 + NEVER = 1 + LIGHT = 2 + DARK = 3 + PHONE_DEAD = 4 + PHONE_RINGING = 5 + PHONE_OFF_HOOK = 6 + PHONE_ON_HOOK = 7 + AC_POWER_OFF = 8 + AC_POWER_ON = 9 + BATTERY_LOW = 10 + BATTERY_OK = 11 + ENERGY_COST_LOW = 12 + ENERGY_COST_MID = 13 + ENERGY_COST_HIGH = 14 + ENERGY_COST_CRITICAL = 15 + + +@dataclass(frozen=True, slots=True) +class Condition: + """One decoded program condition (``cond`` or ``cond2`` field). + + Format per family (clsText.GetConditionalText, clsText.cs:2224-2273 + and frmAutomationEditCondition.cs): + + * ``OTHER`` (``cond < 0x400``): bits 0-3 = :class:`MiscConditional` + value (e.g. ``DARK``, ``AC_POWER_OFF``). + * ``ZONE`` (``cond high-byte``-bits-2-7 ``== 0x04``): bits 0-7 = + zone number; bit 9 = ``0`` for SECURE, ``1`` for NOT_READY. + * ``CTRL`` (``... == 0x08``): bits 0-8 = unit number (9 bits); bit 9 + = ``0`` for OFF/DOWN, ``1`` for ON/UP. + * ``TIME`` (``... == 0x0C``): bits 0-7 = time-clock number; bit 9 = + ``0`` for DISABLED, ``1`` for ENABLED. + * ``SEC`` (any other value, including ``... == 0x10``): bits 8-11 = + area number; bits 12-14 = :class:`SecurityMode` value; bit 15 = + "arming-transition" flag (or "Lumina setting" on Lumina firmware). + + A ``cond`` of ``0`` is the "no condition" sentinel — the program + always fires regardless of state. + """ + + raw: int + family: ConditionFamily + selector: int # zone# / unit# / clock# / area# / misc-id + operand: int # 0/1 for Zone/Ctrl/Time; mode value for Sec + arming_transition: bool # only meaningful for SEC family + + @classmethod + def decode(cls, cond: int) -> Condition: + """Decode a 16-bit ``cond`` value into its semantic parts.""" + if not 0 <= cond <= 0xFFFF: + raise ValueError(f"cond out of u16 range: {cond}") + fam_byte = (cond >> 8) & 0xFC + if fam_byte == ConditionFamily.OTHER: + return cls( + raw=cond, + family=ConditionFamily.OTHER, + selector=cond & 0x0F, + operand=0, + arming_transition=False, + ) + if fam_byte == ConditionFamily.ZONE: + return cls( + raw=cond, + family=ConditionFamily.ZONE, + selector=cond & 0xFF, + operand=(cond >> 9) & 1, + arming_transition=False, + ) + if fam_byte == ConditionFamily.CTRL: + return cls( + raw=cond, + family=ConditionFamily.CTRL, + selector=cond & 0x1FF, + operand=(cond >> 9) & 1, + arming_transition=False, + ) + if fam_byte == ConditionFamily.TIME: + return cls( + raw=cond, + family=ConditionFamily.TIME, + selector=cond & 0xFF, + operand=(cond >> 9) & 1, + arming_transition=False, + ) + # Default: SEC. Bit 15 is "arming" flag iff bits 12-14 (mode) are + # non-zero -- otherwise it's just the mode-Off encoding marker. + mode = (cond >> 12) & 0x7 + bit15 = (cond >> 15) & 1 + return cls( + raw=cond, + family=ConditionFamily.SEC, + selector=(cond >> 8) & 0x0F, + operand=mode, + arming_transition=bool(bit15 and mode != 0), + ) + + def is_empty(self) -> bool: + """``True`` when the condition field is zero — no condition applies.""" + return self.raw == 0 + + def describe(self) -> str: + """Human-readable description without name lookups. + + Renders objects by index (``"Zone 5"``, ``"Unit 12"``) since + this dataclass doesn't carry the panel name tables. For + installation-name resolution use :func:`format_condition` + below with a name dict. + """ + if self.is_empty(): + return "(no condition)" + if self.family is ConditionFamily.OTHER: + try: + return MiscConditional(self.selector).name + except ValueError: + return f"OTHER({self.selector})" + if self.family is ConditionFamily.ZONE: + verb = "NOT_READY" if self.operand else "SECURE" + return f"Zone {self.selector} {verb}" + if self.family is ConditionFamily.CTRL: + verb = "ON" if self.operand else "OFF" + return f"Unit {self.selector} {verb}" + if self.family is ConditionFamily.TIME: + verb = "ENABLED" if self.operand else "DISABLED" + return f"Time clock {self.selector} {verb}" + # SEC + from .models import SecurityMode # local import to keep top circular-free + try: + mode_name = SecurityMode(self.operand).name + except ValueError: + mode_name = f"MODE({self.operand})" + area = f"Area {self.selector}" if self.selector else "(any area)" + if self.arming_transition: + return f"{area} ARMING {mode_name}" + return f"{area} {mode_name}" + + def _classify_time(hour: int, minute: int) -> tuple[TimeKind, int]: """Decode ``(hour, minute)`` bytes into a ``(kind, value)`` pair. @@ -424,6 +585,19 @@ class Program: return f"{abs(value)} min {'after' if value > 0 else 'before'} sunset" return f"{self.hour:02d}:{self.minute:02d}" + def condition(self) -> Condition: + """Decode the primary ``cond`` field into a :class:`Condition`.""" + return Condition.decode(self.cond) + + def condition2(self) -> Condition: + """Decode the secondary ``cond2`` field (DPC programs only). + + Returns a ``Condition`` with ``family == OTHER`` and + ``selector == 0`` (i.e. ``is_empty()``) when ``cond2 == 0``, + which is the common "no second condition" case. + """ + return Condition.decode(self.cond2) + @property def event_id(self) -> int: """The 16-bit event identifier (only meaningful for EVENT type). diff --git a/tests/test_programs.py b/tests/test_programs.py index 79d7c04..eb8ac5f 100644 --- a/tests/test_programs.py +++ b/tests/test_programs.py @@ -297,3 +297,114 @@ def test_time_kind_round_trip_through_wire() -> None: assert p2.time_kind == TimeKind.SUNSET assert p2.time_offset_minutes == -10 assert p2.format_time() == "10 min before sunset" + + +# ---- Condition bit-split decoder ----------------------------------------- + + +from omni_pca.programs import ( # noqa: E402 + Condition, + ConditionFamily, + MiscConditional, +) + + +def test_condition_empty() -> None: + """cond == 0 → no condition applies.""" + c = Condition.decode(0) + assert c.is_empty() + assert c.family is ConditionFamily.OTHER + assert c.describe() == "(no condition)" + + +@pytest.mark.parametrize( + "cond, family, selector, operand, expected_describe", + [ + # OTHER family — bits 0-3 = MiscConditional value + (0x0000, ConditionFamily.OTHER, 0, 0, "(no condition)"), + (0x0002, ConditionFamily.OTHER, 2, 0, "LIGHT"), + (0x0003, ConditionFamily.OTHER, 3, 0, "DARK"), + (0x0008, ConditionFamily.OTHER, 8, 0, "AC_POWER_OFF"), + (0x000B, ConditionFamily.OTHER, 11, 0, "BATTERY_OK"), + (0x010B, ConditionFamily.OTHER, 11, 0, "BATTERY_OK"), # high bits ignored + # ZONE family — bits 0-7 = zone, bit 9 = NOT_READY (1) / SECURE (0) + (0x0405, ConditionFamily.ZONE, 5, 0, "Zone 5 SECURE"), + (0x0605, ConditionFamily.ZONE, 5, 1, "Zone 5 NOT_READY"), + (0x040B, ConditionFamily.ZONE, 11, 0, "Zone 11 SECURE"), + # CTRL family — bits 0-8 = unit, bit 9 = ON (1) / OFF (0) + (0x0801, ConditionFamily.CTRL, 1, 0, "Unit 1 OFF"), + (0x0A01, ConditionFamily.CTRL, 1, 1, "Unit 1 ON"), + (0x09FF, ConditionFamily.CTRL, 0x1FF, 0, "Unit 511 OFF"), # 9-bit unit + # TIME family — bits 0-7 = clock, bit 9 = ENABLED (1) / DISABLED (0) + (0x0C04, ConditionFamily.TIME, 4, 0, "Time clock 4 DISABLED"), + (0x0E03, ConditionFamily.TIME, 3, 1, "Time clock 3 ENABLED"), + # SEC family — bits 8-11 = area, bits 12-14 = mode, bit 15 = arming flag + # mode=Off (0) with bit 15: encode of "area X is in mode Off" + (0x8100, ConditionFamily.SEC, 1, 0, "Area 1 OFF"), + (0x8800, ConditionFamily.SEC, 8, 0, "Area 8 OFF"), + # mode=Day (1), no exit-delay flag → not arming-transition + (0x1100, ConditionFamily.SEC, 1, 1, "Area 1 DAY"), + # mode=Away (3), bit 15 set → ARMING (in transition) + (0xB100, ConditionFamily.SEC, 1, 3, "Area 1 ARMING AWAY"), + # area=0 selector → "(any area)" + (0x9000, ConditionFamily.SEC, 0, 1, "(any area) ARMING DAY"), + ], +) +def test_condition_decode_per_family( + cond, family, selector, operand, expected_describe +) -> None: + c = Condition.decode(cond) + assert c.family == family, ( + f"cond={cond:#06x} family expected {family.name}, got {c.family.name}" + ) + assert c.selector == selector, f"cond={cond:#06x} selector" + assert c.operand == operand, f"cond={cond:#06x} operand" + assert c.describe() == expected_describe + + +def test_condition_arming_transition_flag_only_when_mode_nonzero() -> None: + """Bit 15 + mode=Off is the 'plain off' encoding, NOT an arming transition. + + Per clsText.cs:2263, the arming-transition branch requires + ``(cond & 0xF000) != 0x8000``, which fails when only bit 15 is set + (mode bits 12-14 are zero). + """ + plain_off = Condition.decode(0x8100) + assert plain_off.arming_transition is False + assert plain_off.describe() == "Area 1 OFF" + + arming = Condition.decode(0xB100) # bit 15 + mode=3 (AWAY) + assert arming.arming_transition is True + assert "ARMING" in arming.describe() + + +def test_program_condition_helpers() -> None: + """Program.condition() / condition2() decode the raw u16 fields.""" + p = Program( + prog_type=int(ProgramType.TIMED), + cond=0x0605, # Zone 5 NOT_READY + cond2=0xB100, # Area 1 ARMING AWAY + ) + c1 = p.condition() + c2 = p.condition2() + assert c1.family is ConditionFamily.ZONE + assert c1.selector == 5 + assert c1.describe() == "Zone 5 NOT_READY" + assert c2.family is ConditionFamily.SEC + assert c2.describe() == "Area 1 ARMING AWAY" + + +def test_condition_rejects_out_of_u16_range() -> None: + with pytest.raises(ValueError): + Condition.decode(-1) + with pytest.raises(ValueError): + Condition.decode(0x10000) + + +def test_misc_conditional_enum_matches_csharp() -> None: + """enuMiscConditional values mirrored from clsText.cs.""" + assert MiscConditional.NONE == 0 + assert MiscConditional.DARK == 3 + assert MiscConditional.AC_POWER_OFF == 8 + assert MiscConditional.BATTERY_OK == 11 + assert MiscConditional.ENERGY_COST_CRITICAL == 15