docs: reference/program-format — wire layout + Mon/Day quirk + worked example

New reference page documenting the Omni Pro II's 14-byte program record
format. Mirrors file-format.mdx's structure: byte-offset table, enum
tables (ProgramType, ProgramCond, Days), worked example decoding a
real slot from the live fixture, callouts for the EVENT-only Mon/Day
swap on disk, and an explicit "what we don't yet know" section that
lists the gaps a future editor pass would have to close (cond bit
split, multi-record clausal encoding, RemarkID -> RemarkText lookup,
DPC capability flag, sunrise/sunset offset flag).

Sidebar adds the entry between File format and Hardware specs.
This commit is contained in:
Ryan Malloy 2026-05-11 19:48:07 -06:00
parent 814d945f6d
commit f45e9fd014
2 changed files with 254 additions and 0 deletions

View File

@ -62,6 +62,7 @@ export default defineConfig({
items: [ items: [
{ label: 'Protocol', slug: 'reference/protocol' }, { label: 'Protocol', slug: 'reference/protocol' },
{ label: 'File format', slug: 'reference/file-format' }, { label: 'File format', slug: 'reference/file-format' },
{ label: 'Program record format', slug: 'reference/program-format' },
{ label: 'Hardware specs', slug: 'reference/hardware-specs' }, { label: 'Hardware specs', slug: 'reference/hardware-specs' },
{ label: 'Library API', slug: 'reference/library-api' }, { label: 'Library API', slug: 'reference/library-api' },
{ label: 'HA entities', slug: 'reference/ha-entities' }, { label: 'HA entities', slug: 'reference/ha-entities' },

View File

@ -0,0 +1,253 @@
---
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 string lives in a
separate table on disk that we have not yet reverse-engineered.
## 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.
- **RemarkID → RemarkText lookup.** The remark-text table on disk has
not been located; `RemarkID` decoded fine, but we can't resolve it
to a string today.
- **`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.