--- title: Program record format description: Wire layout of the Omni Pro II's 14-byte program record — the panel-side automation engine's storage unit. Includes the Mon/Day file-format swap, the Remark variant, and a worked example. --- A "program" in Omni Pro II vocabulary is one line of the panel's built-in automation engine: roughly "if **X** happens at time **Y**, do **Z**." The panel stores up to **1500 programs** in a fixed-size table — in a `.pca` file they live in a 21,000-byte block (1500 × 14 bytes); on the wire they are exchanged one at a time via `clsOLMsgProgramData` / `clsOL2MsgProgramData`. PC Access exposes a visual editor for them; the HA integration currently surfaces them as raw decoded fields only. The user-facing model (every program line has a **WHEN**, an optional **&IF** condition, and a **THEN**) is documented in the *Owner's Manual* under "Programming" and the *Installation Manual* under "SETUP MISC → Programs". ## The 14-byte record The Omni Pro II has the `DoubleProgramConditional` feature flag set so every record is **14 bytes** with two condition slots. (Models without the flag would use 12 bytes with one cond — we don't yet support those.) | Offset | Field | Type | Notes | |-------:|---|---|---| | 0 | `prog_type` | byte | `ProgramType` enum | | 1-2 | `cond` | BE u16 | Primary condition (opaque this pass) | | 3-4 | `cond2` | BE u16 | Secondary condition (opaque this pass) | | 5 | `cmd` | byte | [`Command`](/reference/library-api/) enum | | 6 | `par` | byte | Command parameter (level, mode, …) | | 7-8 | `pr2` | BE u16 | Secondary parameter (usually object number) | | 9-10 | `month`, `day` | bytes | **Swapped to `day, month` on disk for `EVENT` type** — see below | | 11 | `days` | byte | `Days` bitmask (Mon=`0x02` … Sun=`0x80`) | | 12 | `hour` | byte | 0-23 for absolute time; offset for sunrise/sunset-relative | | 13 | `minute` | byte | 0-59 | ### `Remark` variant When `prog_type == REMARK` (4) the bytes at offsets 1-4 hold a single **32-bit BE RemarkID** instead of cond + cond2: | Offset | Field | Type | |-------:|---|---| | 0 | `prog_type` (= 4) | byte | | 1-4 | `remark_id` | BE u32 | | 5-13 | (unused, typically zero) | bytes | The lookup from `remark_id` to the user-visible remark text lives in a separate **Remarks table** further down the `.pca` body. Layout (`clsPrograms.ReadRemarks`, clsPrograms.cs:148-168): ``` [u32 LE _RemarksNextID] [u32 LE count] [ for each entry: [u32 LE remark_id] [u16 LE text_length][text_length bytes UTF-8] ] ``` To reach the Remarks table from end-of-Connection, the walker skips nine fixed-shape Description blocks (one per object family, each `[u32 count] + count × 33 bytes`); see `clsHAC.cs:8055-8079`. The Python parser puts the resolved dict on `PcaAccount.remarks`: ```python from omni_pca.pca_file import parse_pca_file, KEY_EXPORT from omni_pca.programs import ProgramType, iter_defined acct = parse_pca_file("Account.pca", key=KEY_EXPORT) for p in iter_defined(acct.programs): if p.prog_type == ProgramType.REMARK: text = acct.remarks.get(p.remark_id, "") print(f"slot {p.slot}: remark {p.remark_id} = {text!r}") ``` ## Enums ### `ProgramType` (byte 0) | Value | Name | Meaning | |------:|---|---| | 0 | `FREE` | Unused slot (all 14 bytes zero) | | 1 | `TIMED` | Fires at a specific time of day on selected weekdays | | 2 | `EVENT` | Fires when a panel event occurs (zone open, X-10, …) | | 3 | `YEARLY` | Fires on a specific calendar date each year | | 4 | `REMARK` | Stores a RemarkID + free-text association | | 5 | `WHEN` | Connector — string multiple records into one line *(RE-pending)* | | 6 | `AT` | Connector *(RE-pending)* | | 7 | `EVERY` | Connector *(RE-pending)* | | 8 | `AND` | Connector *(RE-pending)* | | 9 | `OR` | Connector *(RE-pending)* | | 10 | `THEN` | Connector *(RE-pending)* | Source: `enuProgramType.cs`. ### `ProgramCond` (high bits of `cond` / `cond2`) | Value | Name | Family | |------:|---|---| | 0 | `OTHER` | catch-all / miscellaneous | | 4 | `ZONE` | zone-state condition | | 8 | `CTRL` | control-unit condition | | 12 | `TIME` | time-clock condition | | 16 | `SEC` | security-mode condition | Source: `enuProgramCond.cs`. The internal bit-split of `cond` (selector vs operand within the 16-bit field) is **not yet decoded** — see "What we don't yet know" below. ### `Days` (byte 11) Bitmask with Monday as the *low-order valid bit* — not bit 0: | Bit | Name | |-----|---| | `0x02` | Monday | | `0x04` | Tuesday | | `0x08` | Wednesday | | `0x10` | Thursday | | `0x20` | Friday | | `0x40` | Saturday | | `0x80` | Sunday | Source: `enuDays.cs`. `0x00` = no day selected. ## Worked example Take this slot from our live fixture (slot 22): ``` 01 8d 09 9b 09 44 03 01 00 08 0c 3e 07 0f ``` Decoded as a file record: | Offset | Bytes | Field | Value | |-------:|:--|:--|:--| | 0 | `01` | `prog_type` | `TIMED` (1) | | 1-2 | `8d 09` | `cond` | `0x8d09` | | 3-4 | `9b 09` | `cond2` | `0x9b09` | | 5 | `44` | `cmd` | `0x44` (raw — opcode is one of the many `enuUnitCommand` values) | | 6 | `03` | `par` | 3 | | 7-8 | `01 00` | `pr2` | 256 | | 9 | `08` | `month` (TIMED → no swap) | 8 | | 10 | `0c` | `day` | 12 | | 11 | `3e` | `days` | `Mon\|Tue\|Wed\|Thu\|Fri` (weekdays) | | 12 | `07` | `hour` | 07 | | 13 | `0f` | `minute` | 15 | So: "TIMED program firing at 07:15 on weekdays, doing command 0x44 with par=3 and pr2=256, gated by the condition pair 0x8d09 / 0x9b09." ## Quirk: the `EVENT` Mon/Day swap For `EVENT`-typed programs on disk only, bytes 9 and 10 are stored in the order `[day, month]` instead of `[month, day]`. The on-the-wire `clsOLMsgProgramData` reply *does not* apply this swap — only `clsProgram.Read` / `Write` (the file-IO methods) do, at `clsProgram.cs:471-484` and `:506-515`. The Python decoder hides this: ```python from omni_pca.programs import Program # These two byte strings are the same EVENT program — on disk vs on wire. disk_bytes = bytes.fromhex("02 0c04 0000 01 01 0000 05 0c 00 07 0f".replace(" ", "")) wire_bytes = bytes.fromhex("02 0c04 0000 01 01 0000 0c 05 00 07 0f".replace(" ", "")) p_disk = Program.from_file_record(disk_bytes) p_wire = Program.from_wire_bytes(wire_bytes) assert p_disk.month == 12 and p_disk.day == 5 assert p_wire.month == 12 and p_wire.day == 5 assert p_disk == p_wire # same semantic Program ``` The encoder re-applies the swap on the way out, so file/wire round-trip stability is preserved. ## Also for EVENT: bytes 9/10 are an event identifier, not a date `clsProgram.Evt` (line 152) defines a u16 view: `Evt = (Mon << 8) | Day`. For `EVENT`-typed programs the calendar-month/calendar-day interpretation is a misnomer — those two bytes encode a 16-bit *event identifier* (which zone triggered, which X-10 code received, etc.). The dataclass exposes `event_id` as a convenience: ```python p = Program.from_wire_bytes(wire_bytes) if p.prog_type == ProgramType.EVENT: print(f"event_id = 0x{p.event_id:04x}") ``` The selector/operand decoding inside `event_id` is part of the same "what we don't know" set as `cond` semantics. ## Wire vs on-disk parity | | `.pca` file (slot in table) | `clsOLMsgProgramData` (wire) | |---|---|---| | Total bytes | 14 (slot index implicit) | 16 (prefix + body) | | Prefix | — | `[program_number_hi, program_number_lo]` BE u16 | | Body | 14 bytes as documented above | 14 bytes — **never** swaps Mon/Day | | Identified by | Position in 1500-slot block | `clsOLMsgProgramData.ProgramNumber` (Data[1-2]) | The `omni_pca.programs` module distinguishes: ```python Program.from_wire_bytes(body) # for OmniLink message replies Program.from_file_record(body) # for .pca table slots ``` with matching `encode_wire_bytes()` / `encode_file_record()` round-trip methods. ## What we don't yet know This page covers the byte-level mechanics. The semantic decoding of `cond` / `cond2` — what zone number, security mode, or time clock a specific 16-bit value refers to — is a separate reverse-engineering pass. So is the multi-record clausal encoding hinted at by the `WHEN/AT/EVERY/AND/OR/THEN` connector values in `ProgramType`. Concretely: - **`cond` / `cond2` internal bit split.** The high bits encode the `ProgramCond` family (Zone / Ctrl / Time / Sec); we don't yet know where the selector index (zone number, etc.) and the operand ("not ready", "Day mode", …) live in the low bits. None of the 330 defined programs in our fixture is enough to triangulate this — we'd need to author known programs in PC Access and diff the exported bytes. - **Multi-record clausal encoding.** No program in our live fixture uses the `WHEN / AT / EVERY / AND / OR / THEN` ProgType values — so we can't yet say whether they reference adjacent slots, use extra bytes within a single slot, or live in some separate clause table. - **`DoubleProgramConditional` capability flag.** We hardcode 14-byte records for the Omni Pro II. Other models may use the 12-byte form. Locating where the panel advertises the flag (and which non-OPII models clear it) is its own follow-up. - **TIMED time-of-day encoding.** Some TIMED programs in the live fixture have `hour > 23` or `minute > 59`, suggesting bytes 12/13 also encode sunrise/sunset-relative offsets (Owner's Manual: ±0-120 minutes). The flag distinguishing absolute time from relative offset has not been isolated. ## Python usage ```python from omni_pca.pca_file import parse_pca_file, KEY_EXPORT from omni_pca.programs import ProgramType, iter_defined acct = parse_pca_file("/path/to/Account.pca", key=KEY_EXPORT) for p in iter_defined(acct.programs): t = ProgramType(p.prog_type).name print(f"slot {p.slot:4} {t:6} cmd=0x{p.cmd:02x} " f"{p.hour:02d}:{p.minute:02d} days=0x{p.days:02x}") ``` `acct.programs` is a `tuple[Program, ...]` of length 1500; `iter_defined` filters to in-use slots (matches the panel's "non-FREE" definition). References: - `clsProgram.cs` — field accessors, the Read/Write swap for Event, `ToByteArray` / `FromByteArray`, and the `Evt` u16 view. - `enuProgramType.cs`, `enuProgramCond.cs`, `enuDays.cs` — the byte enums mirrored in `omni_pca.programs`. - `clsHAC.cs:180-549` — wire-format `clsOLMsgProgramData` (v1) and `:1180-1330` — `clsOL2MsgProgramData` (v2). Both prepend a 2-byte BE `ProgramNumber` to the 14-byte body. - [`.pca` file format](/reference/file-format/) — where the 21,000-byte Programs block sits in the body walk. - [Library API → `Program`](/reference/library-api/) — public surface for `Program.from_wire_bytes` / `from_file_record` etc.