program-format: correct byte order (LE, not BE) + document multi-record form

The cond / cond2 / pr2 fields are little-endian u16s, not big-endian
as the original page documented. Caught by authoring controlled
programs in PC Access (running in a Win XP VM) and byte-diffing the
resulting .pca. The worked example's expected values are corrected:

- pr2 bytes [01, 00] read as 1, not 256
- cond bytes [8d, 09] read as 0x098d (Unit 397 OFF), not 0x8d09
- cond2 bytes [9b, 09] read as 0x099b (Unit 411 OFF), not 0x9b09

Also promote the multi-record ProgType values (WHEN/AT/EVERY/AND/OR/THEN)
from "RE-pending" to a documented "Compact vs multi-record form" section.
Multi-record form requires firmware >=3.0.0 per clsCapOMNI_PRO_II.cs:290
(Features.Add(MultiLinePrograms, 196608u)); the user's 2.16A panel
cannot produce these records, so all entries in its .pca are compact form.

A 5-record example block (WHEN + 3 ANDs + THEN), captured by using PC
Access's Account Info -> Version Override to spoof firmware 3.0, shows
the per-record layout at the discriminator level. The AND record's
structured-condition fields (OP / Arg1_* / Arg2_* / CompConst) remain
on the RE backlog.
This commit is contained in:
Ryan Malloy 2026-05-12 03:02:09 -06:00
parent db3832c68c
commit 2300be0f6c

View File

@ -1,6 +1,6 @@
---
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.
description: Wire layout of the Omni Pro II's 14-byte program record — the panel-side automation engine's storage unit. Covers the compact single-record form, the firmware-≥3.0 multi-record form, the EVENT Mon/Day file swap, the Remark variant, and a worked example.
---
A "program" in Omni Pro II vocabulary is one line of the panel's built-in
@ -24,16 +24,27 @@ 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) |
| 1-2 | `cond` | **LE u16** | First inline AND-IF condition (zero = no condition) |
| 3-4 | `cond2` | **LE u16** | Second inline AND-IF condition (zero = no condition) |
| 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) |
| 7-8 | `pr2` | **LE 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 |
:::caution[Byte order]
The three 16-bit fields (`cond`, `cond2`, `pr2`) are **little-endian**:
byte N is the low byte and byte N+1 is the high byte. The 32-bit
`remark_id` in the REMARK variant is the one big-endian field
(see below). Earlier revisions of this page documented all of them
as big-endian — that was wrong. The mistake was caught by authoring
known programs in PC Access and byte-diffing the resulting `.pca`;
see the [clausal-RE findings notes](https://github.com/warehack-ing/omni-pca/blob/main/pca-re/clausal-re/FINDINGS.md)
for the empirical work.
:::
### `Remark` variant
When `prog_type == REMARK` (4) the bytes at offsets 1-4 hold a single
@ -79,21 +90,26 @@ for p in iter_defined(acct.programs):
### `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)* |
The 11 values split into two **encoding families**. Which family a
block uses depends on the panel firmware version — see [Compact vs
multi-record form](#compact-vs-multi-record-form) below.
Source: `enuProgramType.cs`.
| Value | Name | Family | Meaning |
|------:|---|---|---|
| 0 | `FREE` | compact | Unused slot (all 14 bytes zero) |
| 1 | `TIMED` | compact | Time-of-day trigger with inline action |
| 2 | `EVENT` | compact | Panel event (zone open, X-10, …) with inline action |
| 3 | `YEARLY` | compact | Yearly calendar trigger with inline action |
| 4 | `REMARK` | compact | RemarkID + free-text association (see [Remark variant](#remark-variant)) |
| 5 | `WHEN` | multi-record | Event-trigger record *(firmware ≥3.0.0)* |
| 6 | `AT` | multi-record | Time-trigger record *(firmware ≥3.0.0)* |
| 7 | `EVERY` | multi-record | Recurring-trigger record *(firmware ≥3.0.0)* |
| 8 | `AND` | multi-record | AND-condition record *(firmware ≥3.0.0)* |
| 9 | `OR` | multi-record | OR-alternative separator *(firmware ≥3.0.0)* |
| 10 | `THEN` | multi-record | Action record *(firmware ≥3.0.0)* |
Source: `enuProgramType.cs`; the firmware gate is
`Features.Add(MultiLinePrograms, 196608u)` at `clsCapOMNI_PRO_II.cs:290`.
### `ConditionFamily` (high bits of `cond` / `cond2`)
@ -135,22 +151,26 @@ Take this slot from our live fixture (slot 22):
Decoded as a file record:
| Offset | Bytes | Field | Value |
| Offset | Bytes | Field | Value (LE for u16) |
|-------:|:--|:--|:--|
| 0 | `01` | `prog_type` | `TIMED` (1) |
| 1-2 | `8d 09` | `cond` | `0x8d09` |
| 3-4 | `9b 09` | `cond2` | `0x9b09` |
| 1-2 | `8d 09` | `cond` | `0x098d` → CTRL family, Unit 397, OFF |
| 3-4 | `9b 09` | `cond2` | `0x099b` → CTRL family, Unit 411, OFF |
| 5 | `44` | `cmd` | `0x44` (raw — opcode is one of the many `enuUnitCommand` values) |
| 6 | `03` | `par` | 3 |
| 7-8 | `01 00` | `pr2` | 256 |
| 7-8 | `01 00` | `pr2` | `0x0001` = 1 (object #1) |
| 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."
So: "TIMED program firing at 07:15 on weekdays, doing command `0x44`
with `par=3` against object #1, gated by `Unit 397 OFF AND IF Unit 411 OFF`."
Note that the **bytes** at offsets 7-8 are `01 00` but the **value** is 1
(not 256): the low byte comes first. Same for `cond` (`8d 09` →
`0x098d`, not `0x8d09`) and `cond2`.
## `cond`/`cond2` bit split
@ -311,22 +331,88 @@ 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
## Compact vs multi-record form
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:
The same user-visible "program block" (1 WHEN + 0-N AND-IFs + 1 THEN)
has two on-disk encodings. PC Access picks **compact** whenever the
block can fit, and **multi-record** otherwise.
- **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.
### Compact form (always available)
The block fits in **one** 14-byte record. `prog_type` is `TIMED`,
`EVENT`, or `YEARLY` depending on the trigger kind:
- The action goes inline in `cmd` / `par` / `pr2`.
- Up to **two** AND-IF conditions go inline in `cond` and `cond2`.
- For `EVENT`, the event identifier replaces the calendar
month/day at bytes 9-10 (see [the EVENT `Evt` u16 section](#also-for-event-bytes-910-are-an-event-identifier-not-a-date)).
This is how 100% of records in any panel running firmware <3.0 are
encoded. It is also how PC Access encodes any block that fits the
constraints, even on firmware ≥3.0 — see the simplification rules
in `frmAutomationEditBlock.cs:589 SimplifyLines`.
### Multi-record form (firmware ≥3.0.0 only)
When the block cannot fit in compact form, PC Access emits one
14-byte record per "line", consuming sequential slots in the program
table:
| ProgType | Role | Notable fields |
|---|---|---|
| `WHEN` (5) | Event trigger | event-id at bytes 9-10 (no swap); cmd/par/pr2 zero |
| `AT` (6) | Single-occurrence time trigger | time fields (layout TBD) |
| `EVERY` (7) | Recurring time trigger | interval (layout TBD) |
| `AND` (8) | One AND-IF condition | structured: `OP` / `Arg1_ArgType` / `Arg1_IX` / `Arg1_Field` / `Arg2_*` / `CompConst` (layout TBD) |
| `OR` (9) | Separator between AND-groups | **no payload** — only the `prog_type` byte is set |
| `THEN` (10) | Action | same `cmd` / `par` / `pr2` layout as compact form |
A 5-record block from our test fixture (one WHEN, three ANDs, one
THEN, all in adjacent slots):
```
slot N+0: 05 00 00 00 00 00 00 00 00 04 01 00 00 00 WHEN <Zone 1 Secure>
slot N+1: 08 00 00 00 01 00 00 00 00 00 00 00 00 00 AND IF NEVER
slot N+2: 08 00 00 00 01 00 00 00 00 00 00 00 00 00 AND IF NEVER
slot N+3: 08 00 00 00 01 00 00 00 00 00 00 00 00 00 AND IF NEVER
slot N+4: 0a 00 00 00 00 01 00 01 00 00 00 00 00 00 THEN UNIT 1 ON
```
The firmware gate is `Features.Add(MultiLinePrograms, 196608u)` in
`clsCapOMNI_PRO_II.cs:290` (the 24-bit value packs as
`major*65536 + minor*256 + revision` → 3.0.0).
When MultiLinePrograms is OFF (firmware <3.0):
- PC Access's `Or` toolbar button and "Add Comment Block" menu item
are disabled.
- PC Access refuses to save any block with 3+ AND-IFs, an OR clause,
or a comment.
- The panel's program table can only contain ProgType values 0-4.
The user-facing panel from this repository runs firmware 2.16A, so
all programs in its `.pca` are compact-form by necessity. Our test
captures of the multi-record form were produced by using PC Access's
**Account → Version Override → "Controller Firmware >= 3.0"**, which
spoofs the capability for editing only; the resulting `.pca` won't
load on a real 2.16A panel.
## Still on the RE backlog
This page covers the byte-level mechanics for the **compact form**
fully and the **multi-record form** at the record-discriminator
level. Concrete follow-up work:
- **AND-record's structured-condition fields** (`OP` / `Arg1_*` /
`Arg2_*` / `CompConst` per `frmAutomationEditBlock.cs:988-1000`).
We have one observation (`AND IF NEVER` → byte 4 = `0x01`); a
controlled-input sweep is needed to map the rest.
- **AT** and **EVERY** trigger records — no captured examples.
- **Comment blocks** — multi-record chained `REMARK` records.
- **`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.
records for the Omni Pro II. Other models clear the flag and use
12-byte records with only `cond` (no `cond2`). Adding support is
its own follow-up.
## Python usage