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:
parent
eb1a632ef2
commit
ef7d53c468
@ -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",
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user