programs: decode cond / cond2 into Condition (family + selector + operand)

The 16-bit cond/cond2 fields of a program record pack a 5-family
discriminator + a per-family selector + a per-family operand. The high
byte's bits 2-7 (i.e. (cond >> 8) & 0xFC) pick the family; the rest is
family-specific:

  OTHER  → bits 0-3   = MiscConditional value (DARK, AC_POWER_OFF, …)
  ZONE   → bits 0-7   = zone #;  bit 9 = NOT_READY (1) / SECURE (0)
  CTRL   → bits 0-8   = unit #;  bit 9 = ON       (1) / OFF    (0)
  TIME   → bits 0-7   = clock#;  bit 9 = ENABLED  (1) / DISABLED(0)
  SEC    → bits 8-11  = area #;  bits 12-14 = SecurityMode;
                                 bit 15 = arming-transition flag
                                 (only when mode != 0)

The Sec family is the catch-all default (per clsText.cs:2226-2273 the
switch falls through to it from anything not Other/Zone/Ctrl/Time).

omni_pca.programs:
* New ConditionFamily IntEnum and MiscConditional IntEnum.
* New Condition frozen dataclass with decode classmethod, is_empty,
  describe (renders with index-based labels for offline use).
* New Program.condition() and Program.condition2() helpers.

omni_pca top-level: re-exports Condition, ConditionFamily, MiscConditional.

Verified against the live fixture (330 defined programs):
  cond family distribution: SEC=156, TIME=8, ZONE=4, CTRL=3, OTHER=3
  cond2 family distribution: SEC=21, TIME=10

tests/test_programs.py (+24 cases):
* Parametrised per-family decode with worked examples from the docs.
* Arming-transition flag asserts (mode=Off + bit 15 is NOT arming).
* Program.condition()/condition2() integration.
* OTHER ignores high bits (PC Access sometimes leaves them set).
* u16 range validation.
* MiscConditional enum values match enuMiscConditional.cs.

Full suite: 463 passed, 1 skipped (was 439 / 1).

Source: clsText.GetConditionalText (clsText.cs:2224-2273) for the
decode logic; frmAutomationEditCondition.cs:615-2550 for the encoder.
This commit is contained in:
Ryan Malloy 2026-05-11 22:34:50 -06:00
parent eb1a632ef2
commit ef7d53c468
3 changed files with 291 additions and 0 deletions

View File

@ -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",

View File

@ -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).

View File

@ -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