omni-pca-docs/src/content/docs/reference/program-format.mdx
Ryan Malloy e4dbcf0429 docs/program-format: document Remarks-table layout + resolution path
The previous "What we don't yet know" entry for RemarkID -> RemarkText
is now closed. Replaced the placeholder note with the actual format
(clsPrograms.ReadRemarks layout) and a Python usage example showing
how to resolve a Program's remark_id against PcaAccount.remarks.

Removed the "RemarkID -> RemarkText lookup" line from the
"what we don't yet know" list.
2026-05-11 21:34:02 -06:00

278 lines
10 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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, "<unresolved>")
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.