omni-pca/src/omni_pca/commands.py
Ryan Malloy 68cf44a585 Library v1.0 phase B: command opcodes + typed system events
src/omni_pca/commands.py — Command IntEnum (64 values, sourced from
enuUnitCommand.cs which is the canonical 'enuCommand' despite the misleading
name) + SecurityCommandResponse + CommandFailedError exception. Notable
discovery: enuUnitCommand.UserSetting (104) is actually EXECUTE_PROGRAM;
renamed for clarity, C# alias documented inline.

src/omni_pca/client.py — 18 new methods on OmniClient:
  Core: execute_command, execute_security_command, acknowledge_alerts,
        get_object_status, get_extended_status
  Wrappers: turn_unit_on/off, set_unit_level, bypass_zone, restore_zone,
        set_thermostat_{system,fan,hold}_mode,
        set_thermostat_{heat,cool}_setpoint_raw,
        execute_button, execute_program, show_message, clear_message
  All command methods raise CommandFailedError on Nak.

src/omni_pca/events.py — typed SystemEvents (opcode 55) decoder.
- EventType IntEnum (28 dispatch tags)
- 26 SystemEvent subclasses + UnknownEvent catch-all
  Includes: ZoneStateChanged, UnitStateChanged, ArmingChanged,
  AlarmActivated/Cleared, AcLost/Restored, BatteryLow/Restored,
  PhoneLine{Off,On,Dead,Restored}, UserMacroButton, ProLinkMessage,
  CentraLiteSwitch, X10CodeReceived, AllOnOff, DcmTrouble/Ok,
  EnergyCostChanged, CameraTrigger, AccessReaderEvent, UpbLinkEvent
- SystemEvents packets carry MULTIPLE events; public API is
  parse_events(message) -> list[SystemEvent], plus SystemEvent.parse()
- EventStream helper that flattens batches across messages
- Wiring of OmniClient.events() left for next pass

55 new tests across both files. 194 pass, 2 pre-existing skips. Ruff clean.
2026-05-10 14:17:12 -06:00

212 lines
10 KiB
Python

"""Command (opcode 20) and ExecuteSecurityCommand (opcode 74) primitives.
This module pins down the exact byte values the panel expects in the
*first byte* of a Command (opcode 20) payload, plus the failure-mode
exception used by the typed methods on :class:`omni_pca.client.OmniClient`.
Naming note: there is no standalone ``enuCommand`` enum in HAI_Shared —
the C# code uses :class:`enuUnitCommand` (file
``decompiled/project/HAI_Shared/enuUnitCommand.cs``) for *every* Command
opcode regardless of object type, even though the name suggests it's
unit-only. We mirror that single enum here under the cleaner name
:class:`Command`. Every member cites the line in ``enuUnitCommand.cs``
where its byte value is defined.
The rich Command (opcode 20) wire format (from
``clsOL2MsgCommand.cs:5-57``) is:
payload[0] = command byte (this enum)
payload[1] = parameter1 (single byte, e.g. brightness, mode value)
payload[2] = parameter2 high byte (BE u16)
payload[3] = parameter2 low byte
Parameter2 is almost always the **object number** (unit#, zone#,
thermostat#, message#, button#, scene#). Parameter1 carries whatever the
specific command needs (level, mode, set-point, etc.) — see the per-
member doc-comments below for the mapping.
The ExecuteSecurityCommand (opcode 74) wire format (from
``clsOL2MsgExecuteSecurityCommand.cs:5-90``) is:
payload[0] = area number (1-based)
payload[1] = security mode byte (enuSecurityMode, raw 0-7)
payload[2] = code digit 1 (thousands place, 0-9)
payload[3] = code digit 2 (hundreds place, 0-9)
payload[4] = code digit 3 (tens place, 0-9)
payload[5] = code digit 4 (ones place, 0-9)
The reply (ExecuteSecurityCommandResponse, opcode 75) carries a single
status byte at ``payload[0]`` whose values are listed in
``enuSecurityCommnadResponse.cs`` — :class:`SecurityCommandResponse`
mirrors that enum.
"""
from __future__ import annotations
from enum import IntEnum
from .connection import ProtocolError
class Command(IntEnum):
"""OMNI command codes used as ``payload[0]`` of a Command (opcode 20).
Every member's value is sourced from
``decompiled/project/HAI_Shared/enuUnitCommand.cs``; the trailing
line-number reference points to the exact definition.
"""
# ---- unit / lighting ------------------------------------------------
UNIT_OFF = 0 # enuUnitCommand.Off, line 5
UNIT_ON = 1 # enuUnitCommand.On, line 6
ALL_OFF = 2 # enuUnitCommand.AllOff, line 7 (alias: All=2 line 8)
ALL_ON = 3 # enuUnitCommand.AllOn, line 9
BYPASS_ZONE = 4 # enuUnitCommand.Bypass, line 10
RESTORE_ZONE = 5 # enuUnitCommand.Restore, line 11
RESTORE_ALL_ZONES = 6 # enuUnitCommand.RestoreAll, line 12
EXECUTE_BUTTON = 7 # enuUnitCommand.Button, line 13
ENERGY = 8 # enuUnitCommand.Energy, line 14
UNIT_LEVEL = 9 # enuUnitCommand.Level, line 15 (param1 = 0..100 %)
UNIT_DECREMENT_COUNTER = 10 # enuUnitCommand.Dec, line 16
UNIT_INCREMENT_COUNTER = 11 # enuUnitCommand.Inc, line 17
UNIT_SET_COUNTER = 12 # enuUnitCommand.Set, line 18
UNIT_RAMP = 13 # enuUnitCommand.Ramp, line 19
COMPOSE_SCENE = 14 # enuUnitCommand.Compose, line 20
UPB_STATUS_REQUEST = 15 # enuUnitCommand.UPBStatus, line 21
DIM_STEP = 16 # enuUnitCommand.Dim, line 22 (param1 = step)
DIM_1 = 17 # enuUnitCommand.Dim1, line 23
DIM_2 = 18 # enuUnitCommand.Dim2, line 24
DIM_3 = 19 # enuUnitCommand.Dim3, line 25
DIM_4 = 20 # enuUnitCommand.Dim4, line 26
DIM_5 = 21 # enuUnitCommand.Dim5, line 27
DIM_6 = 22 # enuUnitCommand.Dim6, line 28
DIM_7 = 23 # enuUnitCommand.Dim7, line 29
DIM_8 = 24 # enuUnitCommand.Dim8, line 30
DIM_9 = 25 # enuUnitCommand.Dim9, line 31
UPB_BLINK = 26 # enuUnitCommand.UPBBlink, line 32
UPB_BLINK_OFF = 27 # enuUnitCommand.UPBBlinkOff, line 33
UPB_LINK_OFF = 28 # enuUnitCommand.UPBLinkOff, line 34
UPB_LINK_ON = 29 # enuUnitCommand.UPBLinkOn, line 35
UPB_LINK_SET = 30 # enuUnitCommand.UPBLinkSet, line 36
UPB_LINK_FADE_STOP = 31 # enuUnitCommand.UPBLinkFadeStop, line 37
BRIGHT_STEP = 32 # enuUnitCommand.Bright, line 38 (param1 = step)
BRIGHT_1 = 33 # enuUnitCommand.Bright1, line 39
BRIGHT_2 = 34 # enuUnitCommand.Bright2, line 40
BRIGHT_3 = 35 # enuUnitCommand.Bright3, line 41
BRIGHT_4 = 36 # enuUnitCommand.Bright4, line 42
BRIGHT_5 = 37 # enuUnitCommand.Bright5, line 43
BRIGHT_6 = 38 # enuUnitCommand.Bright6, line 44
BRIGHT_7 = 39 # enuUnitCommand.Bright7, line 45
BRIGHT_8 = 40 # enuUnitCommand.Bright8, line 46
BRIGHT_9 = 41 # enuUnitCommand.Bright9, line 47
CENTRALITE_SCENE_OFF = 42 # enuUnitCommand.CentraLiteSceneOff, line 48
CENTRALITE_SCENE_ON = 43 # enuUnitCommand.CentraLiteSceneOn, line 49
UPB_LED_OFF = 44 # enuUnitCommand.UPBLEDOff, line 50
UPB_LED_ON = 45 # enuUnitCommand.UPBLEDOn, line 51
RADIO_RA_PHANTOM_OFF = 46 # enuUnitCommand.RadioRAPhantomOff, line 52
RADIO_RA_PHANTOM_ON = 47 # enuUnitCommand.RadioRAPhantomOn, line 53
# ---- security (alternative path; preferred path is opcode 74) ------
# When sent through a Command (opcode 20), parameter1 carries the user
# code index (1-based) and parameter2 carries the area number. The
# panel honours these only if the code is enabled for the area.
SECURITY_OFF = 48 # enuUnitCommand.SecurityOff, line 55 (alias Security=48 line 54)
SECURITY_DAY = 49 # enuUnitCommand.SecurityDay, line 56
SECURITY_NIGHT = 50 # enuUnitCommand.SecurityNight, line 57
SECURITY_AWAY = 51 # enuUnitCommand.SecurityAway, line 58
SECURITY_VACATION = 52 # enuUnitCommand.SecurityVac, line 59
SECURITY_DAY_INSTANT = 53 # enuUnitCommand.SecurityDyi, line 60
SECURITY_NIGHT_DELAYED = 54 # enuUnitCommand.SecurityNtd, line 61
SECURITY_ANY_CHANGE = 55 # enuUnitCommand.SecurityAny, line 62
SECURITY_ARMING_DAY = 57 # enuUnitCommand.SecurityArmingDay, line 63
SECURITY_ARMING_NIGHT = 58 # enuUnitCommand.SecurityArmingNight, line 64
SECURITY_ARMING_AWAY = 59 # enuUnitCommand.SecurityArmingAway, line 65
SECURITY_ARMING_VACATION = 60 # enuUnitCommand.SecurityArmingVacation, line 66
SECURITY_ARMING_DAY_INSTANT = 61 # enuUnitCommand.SecurityArmingDayInst, line 67
SECURITY_ARMING_NIGHT_DELAYED = 62 # enuUnitCommand.SecurityArmingNightDelay, line 68
# ---- energy (HMS) --------------------------------------------------
ENERGY_OFF = 64 # enuUnitCommand.Eof, line 69
ENERGY_ON = 65 # enuUnitCommand.Eon, line 70
# ---- thermostat ---------------------------------------------------
SET_THERMOSTAT_HEAT_SETPOINT = 66 # enuUnitCommand.SetLowSetPt, line 71
SET_THERMOSTAT_COOL_SETPOINT = 67 # enuUnitCommand.SetHighSetPt, line 72
SET_THERMOSTAT_SYSTEM_MODE = 68 # enuUnitCommand.Mode, line 73
SET_THERMOSTAT_FAN_MODE = 69 # enuUnitCommand.Fan, line 74
SET_THERMOSTAT_HOLD_MODE = 70 # enuUnitCommand.Hold, line 75
THERMOSTAT_INC_DEC_LO = 71 # enuUnitCommand.IncDecLo, line 76
THERMOSTAT_INC_DEC_HI = 72 # enuUnitCommand.IncDecHi, line 77
SET_THERMOSTAT_HUMIDIFY_SETPOINT = 73 # enuUnitCommand.SetHumidifySetPt, line 78
SET_THERMOSTAT_DEHUMIDIFY_SETPOINT = 74 # enuUnitCommand.SetDeHumidifySetPt, line 79
# ---- panel display messages ---------------------------------------
SHOW_MESSAGE_WITH_BEEP = 80 # enuUnitCommand.ShowMsgWBeep, line 81 (alias FirstMsgCmd=80 line 80)
LOG_MESSAGE = 81 # enuUnitCommand.LogMsg, line 82
CLEAR_MESSAGE = 82 # enuUnitCommand.ClearMsg, line 83
SAY_MESSAGE = 83 # enuUnitCommand.SayMsg, line 84
PHONE_MESSAGE = 84 # enuUnitCommand.PhoneMsg, line 85
SEND_MESSAGE = 85 # enuUnitCommand.SendMsg, line 86
SHOW_MESSAGE_NO_BEEP = 86 # enuUnitCommand.ShowMsgNoBeep, line 87
EMAIL_MESSAGE = 87 # enuUnitCommand.EMailMsg, line 88 (alias LastMsgCmd=87 line 89)
# ---- scenes / misc -----------------------------------------------
SCENE_OFF = 96 # enuUnitCommand.SceneOff, line 90
SCENE_ON = 97 # enuUnitCommand.SceneOn, line 91
SCENE_SET = 98 # enuUnitCommand.SceneSet, line 92
TOGGLE = 99 # enuUnitCommand.Toggle, line 93
SHOW_VIDEO = 100 # enuUnitCommand.ShowVideo, line 94
TIMED_LEVEL = 101 # enuUnitCommand.TimedLevel, line 95
CONSOLE_BEEP = 102 # enuUnitCommand.ConsoleBeep, line 96
BEEP = 103 # enuUnitCommand.Beep, line 97
EXECUTE_PROGRAM = 104 # enuUnitCommand.UserSetting, line 98
LOCK = 105 # enuUnitCommand.Lock, line 99
UNLOCK = 106 # enuUnitCommand.Unlock, line 100
LUTRON_HOMEWORKS_KEYPAD = 107 # enuUnitCommand.LutronHomeWorksKeypadButtonPress, line 101
CLIPSAL_C_BUS_SCENE = 108 # enuUnitCommand.Clipsal_C_Bus_Scene, line 102
RADIO_RA2_PHANTOM = 109 # enuUnitCommand.RadioRA2Phantom, line 103
STOP = 110 # enuUnitCommand.Stop, line 104
# ---- audio --------------------------------------------------------
AUDIO_ZONE = 112 # enuUnitCommand.AudioZone, line 105
AUDIO_VOLUME = 113 # enuUnitCommand.AudioVolume, line 106
AUDIO_SOURCE = 114 # enuUnitCommand.AudioSource, line 107
AUDIO_KEY_PRESS = 115 # enuUnitCommand.AudioKeyPress, line 108
class SecurityCommandResponse(IntEnum):
"""Status byte returned in an ExecuteSecurityCommandResponse (opcode 75).
Source: ``decompiled/project/HAI_Shared/enuSecurityCommnadResponse.cs``
(typo in the C# enum name preserved here for grep parity).
"""
SUCCESS = 0 # line 5
INVALID_CODE = 1 # line 6
INVALID_SECURITY_MODE = 2 # line 7
INVALID_AREA = 3 # line 8
ZONES_NOT_READY = 4 # line 9
INSTALLER_RESTORE_NEEDED = 5 # line 10
CODE_LOCKED_OUT = 6 # line 11
INVALID = 0xFF # line 12
class CommandFailedError(ProtocolError):
"""A command opcode was Nak'd by the panel, or returned a structured
failure code (e.g. the Security command response carries one of the
:class:`SecurityCommandResponse` values).
The ``failure_code`` attribute is set when the panel returned an
ExecuteSecurityCommandResponse with a non-zero status byte; it's
``None`` for plain Nak replies that carry no further detail.
"""
def __init__(
self,
message: str,
*,
failure_code: int | None = None,
) -> None:
super().__init__(message)
self.failure_code = failure_code