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.
278 lines
10 KiB
Plaintext
278 lines
10 KiB
Plaintext
---
|
||
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.
|