diff --git a/astro.config.mjs b/astro.config.mjs index 37bf717..fbdf243 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -62,6 +62,7 @@ export default defineConfig({ items: [ { label: 'Protocol', slug: 'reference/protocol' }, { label: 'File format', slug: 'reference/file-format' }, + { label: 'Program record format', slug: 'reference/program-format' }, { label: 'Hardware specs', slug: 'reference/hardware-specs' }, { label: 'Library API', slug: 'reference/library-api' }, { label: 'HA entities', slug: 'reference/ha-entities' }, diff --git a/src/content/docs/reference/program-format.mdx b/src/content/docs/reference/program-format.mdx new file mode 100644 index 0000000..e8e189e --- /dev/null +++ b/src/content/docs/reference/program-format.mdx @@ -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.