program-format: per-record byte tables for all multi-record types

All four originally-open RE questions are now resolved (findings #15-18
in pca-re/clausal-re/FINDINGS.md). Update the docs to reflect:

- Each of the 6 multi-record ProgTypes (WHEN/AT/EVERY/AND/OR/THEN)
  now has its own per-byte table in a dedicated subsection.
- Removed the old stale "AND-record layout" section that conflated the
  Traditional case with structured-OP layouts.
- Moved the structured-OP enum tables (enuCondOP, enuCondArgType) into
  a "Structured-OP AND records (future RE)" section as reference for
  the next pass.
- Added a Python "Decoding multi-record programs" subsection showing
  the new typed accessor properties on the Program class:
    p.is_multi_record(), p.event_id, p.every_interval,
    p.and_family, p.and_instance
- Summary table at the top of "Multi-record form" now states the
  actual layouts instead of "layout TBD".

Removed:
- "Open: byte 1's semantic role" caution (resolved by Unit 1 ON capture).
- Now-redundant AND-record table that didn't match empirical findings.

Kept:
- "Byte order: BE for AND/EVERY records" note (still useful context).
- The 5-record example block (good illustration).
- enuCondOP / enuCondArgType enum tables (still useful reference for
  the structured-OP case).
This commit is contained in:
Ryan Malloy 2026-05-12 05:17:11 -06:00
parent 7d53992841
commit 8ece4fa9ed

View File

@ -360,103 +360,27 @@ 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` / `Arg2` / `CompConst` — see [AND-record layout](#and-record-layout-firmware-30) |
| `WHEN` (5) | Event trigger | event-id at bytes 9-10 BE (wire form, no swap); cmd/par/pr2 zero |
| `AT` (6) | Single-occurrence time trigger | month/day/days/hour/minute at bytes 9-13 (same as compact `TIMED`) |
| `EVERY` (7) | Recurring time trigger | interval at bytes 3-4 BE |
| `AND` (8) | One AND-IF condition | family + operand at byte 1; instance at bytes 3-4 BE |
| `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 |
| `THEN` (10) | Action | same `cmd` / `par` / `pr2` (LE) layout as compact form |
#### `AND`-record layout (firmware ≥3.0)
Per-record byte tables for each of these are in the
[per-record byte layouts](#per-record-byte-layouts-firmware-30-multi-record-form)
section below.
Per the `clsProgram.cs:326-436` accessors, an `AND` record's 14 bytes
decompose as:
| Byte(s) | Field | Type | Notes |
|--------:|---|---|---|
| 0 | `prog_type` | byte | `= 8` (AND) |
| 1 | `OP` | byte | `enuCondOP` — operator (see below) |
| 2 | `Arg1_ArgType` | byte | `enuCondArgType` — what Arg1 is (Zone, Unit, …) |
| 3-4 | `Arg1_IX` | u16 | Index/number of the Arg1 object |
| 5 | `Arg1_Field` | byte | Which sub-field of Arg1 (per-type enum) |
| 6 | `Arg2_ArgType` | byte | |
| 7-8 | `Arg2_IX` | u16 | |
| 9 | `Arg2_Field` | byte | |
| 10-11 | `CompConst` | u16 | Constant operand for comparison ops |
| 12-13 | (unused, zero) | | |
`enuCondOP` (byte 1):
| Value | Name |
|------:|---|
| 0 | `Arg1_Traditional` |
| 1 | `Arg1_EQ_Arg2` |
| 2 | `Arg1_NE_Arg2` |
| 3 | `Arg1_LT_Arg2` |
| 4 | `Arg1_GT_Arg2` |
| 5 | `Arg1_Odd` |
| 6 | `Arg1_Even` |
| 7 | `Arg1_Multiple_Arg2` |
| 8 | `Arg1_IN_Arg2` |
| 9 | `Arg1_NOT_IN_Arg2` |
`enuCondArgType` (bytes 2, 6):
| Value | Name |
|------:|---|
| 0 | `Constant` |
| 1 | `UserSetting` |
| 2 | `Zone` |
| 3 | `Unit` |
| 4 | `Thermostat` |
| 5 | `Auxillary` |
| 6 | `Area` |
| 7 | `TimeDate` |
| 8 | `Audio` |
| 9 | `AccessControl` |
| 10 | `Message` |
| 11 | `System` |
**Important special case:** when `OP == Arg1_Traditional` (= 0), the
AND record's condition is rendered from the **`Cond` u16 at bytes
1-2** using the same per-family scheme as compact-form `cond` (see
[cond/cond2 bit split](#condcond2-bit-split) above) — see
`clsText.cs:2281-2284 GetComplexConditionText`. The richer
`Arg1_*` / `Arg2_*` / `CompConst` fields above are only used when
`OP > 0`. So the same byte positions serve double duty: as `Cond`
(traditional) or as `OP` + `Arg1_ArgType` (structured).
:::note[Disk byte order: BE for AND records (verified)]
Authored an `AND IF ZONE 5 SECURE` capture and diffed it against an
`AND IF NEVER`:
```
NEVER: 08 00 00 00 01 ...
ZONE 5 SECURE: 08 04 00 00 05 ...
↑ ↑
byte 1 byte 4
```
The zone number `5` lands in **byte 4** (high-offset byte of the
positions 3-4 u16). If disk were LE the `5` would be in byte 3. So
**`Arg1_IX` / `Arg2_IX` / `CompConst` are big-endian on disk for
AND records** — opposite of compact-form `cond` / `cond2` / `pr2`
which are LE. The two record families use different byte orders;
the C# encoder writes AND records' u16s directly in BE matching
the in-memory `Data[]` layout. Compact-form fields go through
`Cond`/`Cond2`/`Pr2` setters that swap to LE on the wire.
:::
:::caution[Still open: byte 1's semantic role]
For `AND IF ZONE 5 SECURE`, byte 1 = `0x04`. The C# accessor at
`clsProgram.cs:326` says `OP = Data[1]` and `0x04 = Arg1_GT_Arg2` —
which makes no sense for a zone-state check. But `0x04` *also* matches
the compact-form `ProgramCond.ZONE` family code. So byte 1 is
probably the **condition family code** (re-using compact-form
semantics) when `OP == Arg1_Traditional` (the implicit case), and
only acts as the structured `OP` when the user has actually picked
a comparison operator. Resolving this needs a capture of an
explicitly-structured AND like `AND IF TEMPERATURE > 70`.
:::note[Byte order: u16 fields use BE on disk for AND/EVERY records]
The compact-form `cond` / `cond2` / `pr2` are stored **little-endian**
on disk. The multi-record AND/EVERY records' u16 fields (instance,
interval) are stored **big-endian** — opposite byte order. The two
record families came from different code paths in the C# encoder:
compact-form fields go through the `Cond`/`Cond2`/`Pr2` setters that
swap to LE; AND/EVERY fields are written directly in BE matching the
in-memory `Data[]` layout. Empirically confirmed by authoring
`AND IF ZONE 5 SECURE` and observing the zone number `5` at **byte 4**
(the high-offset byte of the bytes-3-4 u16).
:::
A 5-record block from our test fixture (one WHEN, three ANDs, one
@ -489,18 +413,125 @@ captures of the multi-record form were produced by using PC Access's
spoofs the capability for editing only; the resulting `.pca` won't
load on a real 2.16A panel.
## Still on the RE backlog
### Per-record byte layouts (firmware ≥3.0 multi-record form)
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:
#### `WHEN` (ProgType=5) — event trigger
| Byte(s) | Field | Notes |
| --- | --- | --- |
| 0 | `prog_type` | = 5 |
| 9-10 (BE u16) | event-id | `(family << 8) | instance` — same encoding as compact-form `EVENT`'s bytes 9-10 in wire form. **No** Mon/Day file-form swap. |
| 1-8, 11-13 | zeros | (action lives in a separate `THEN` record) |
#### `AT` (ProgType=6) — single-occurrence time trigger
| Byte(s) | Field | Notes |
| --- | --- | --- |
| 0 | `prog_type` | = 6 |
| 9 | `month` | calendar month |
| 10 | `day` | calendar day |
| 11 | `days` | [`Days`](#days-byte-11) bitmask (bit 1=Mon … bit 7=Sun) |
| 12 | `hour` | 0-23, or 25/26 for sunrise/sunset (see [TIMED section](#timed-programs-absolute-time-vs-sunrisesunset-offset)) |
| 13 | `minute` | 0-59, or signed for sunrise/sunset offset |
| 1-8 | zeros | (action lives in a separate `THEN` record) |
Same time-field layout as compact-form `TIMED`, just with `cmd`/`par`/`pr2` zero.
#### `EVERY` (ProgType=7) — recurring trigger
| Byte(s) | Field | Notes |
| --- | --- | --- |
| 0 | `prog_type` | = 7 |
| 3-4 (BE u16) | `interval` | recurrence value (e.g. 5 for "5 SECONDS" UI default) |
| 1-2, 5-13 | zeros | (unit of interval not yet RE'd — seconds/minutes/hours likely fixed by value range) |
#### `AND` (ProgType=8) — one AND-IF condition
For the **traditional** case (no structured comparison operator):
| Byte(s) | Field | Notes |
| --- | --- | --- |
| 0 | `prog_type` | = 8 |
| 1 | family + operand | High byte of the compact-form `cond` u16 (see [cond bit split](#condcond2-bit-split)). e.g. `0x04` = ZONE+SECURE, `0x0A` = CTRL+ON, `0x00` = OTHER family. |
| 3-4 (BE u16) | instance | Object number: zone#, unit#, `MiscConditional` value, etc. |
| 2, 5-13 | zeros | (Bytes 5-13 are populated for **structured** comparison operators like "TEMP > 70" — not yet fully RE'd.) |
#### `OR` (ProgType=9) — separator between AND-groups
| Byte | Value |
| --- | --- |
| 0 | `0x09` (OR ProgType) |
| 1-13 | all zero |
Pure discriminator — carries no payload. Marks the boundary between alternative AND-IF groups within a block.
#### `THEN` (ProgType=10) — action
Same `cmd` / `par` / `pr2` layout as compact-form records (`cmd` at byte 5, `par` at byte 6, `pr2` at bytes 7-8 LE).
### Structured-OP AND records (future RE)
For the **traditional** AND case (`OP == 0`, the common case), the
AND record's byte layout is the single-AND-IF table in the [per-record
byte layouts](#per-record-byte-layouts-firmware-30-multi-record-form)
section above: byte 1 = family + operand bits, bytes 3-4 BE = instance.
For **structured-OP** cases (e.g. `AND IF TEMPERATURE > 70`), the C#
encoder at `frmAutomationEditBlock.cs:988-1000` populates additional
fields per the `clsProgram.cs:326-436` accessor view:
| Byte(s) (in-memory `Data[]`) | Field | Type |
| --- | --- | --- |
| 1 | `OP` | byte (`enuCondOP`) |
| 2 | `Arg1_ArgType` | byte (`enuCondArgType`) |
| 3-4 | `Arg1_IX` | u16 |
| 5 | `Arg1_Field` | byte |
| 6 | `Arg2_ArgType` | byte |
| 7-8 | `Arg2_IX` | u16 |
| 9 | `Arg2_Field` | byte |
| 10-11 | `CompConst` | u16 |
`enuCondOP` (byte 1 when `OP > 0`):
| Value | Name |
| ---: | --- |
| 0 | `Arg1_Traditional` (= the common case, byte 1 = family + operand, see table above) |
| 1 | `Arg1_EQ_Arg2` |
| 2 | `Arg1_NE_Arg2` |
| 3 | `Arg1_LT_Arg2` |
| 4 | `Arg1_GT_Arg2` |
| 5 | `Arg1_Odd` |
| 6 | `Arg1_Even` |
| 7 | `Arg1_Multiple_Arg2` |
| 8 | `Arg1_IN_Arg2` |
| 9 | `Arg1_NOT_IN_Arg2` |
`enuCondArgType` (bytes 2, 6):
| Value | Name |
| ---: | --- |
| 0 | `Constant` |
| 1 | `UserSetting` |
| 2 | `Zone` |
| 3 | `Unit` |
| 4 | `Thermostat` |
| 5 | `Auxillary` |
| 6 | `Area` |
| 7 | `TimeDate` |
| 8 | `Audio` |
| 9 | `AccessControl` |
| 10 | `Message` |
| 11 | `System` |
The disk-byte-order question for the structured-OP case (whether
bytes 1-2 map to `OP` + `Arg1_ArgType` after the LE Read-time swap,
or whether the encoder uses a different path) is unresolved — needs a
controlled `AND IF TEMP > 70` capture to disambiguate. The `omni_pca`
decoder exposes `CondOP` and `CondArgType` enums for callers who want
to inspect raw bytes once captures are available.
### Other open items
- **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 clear the flag and use
12-byte records with only `cond` (no `cond2`). Adding support is
@ -523,6 +554,55 @@ for p in iter_defined(acct.programs):
`acct.programs` is a `tuple[Program, ...]` of length 1500; `iter_defined`
filters to in-use slots (matches the panel's "non-FREE" definition).
### Decoding multi-record programs
For records with `prog_type` in the firmware-≥3.0 range (5-10), the
`Program` class exposes type-specific accessor properties:
```python
from omni_pca.programs import Program, ProgramType, ProgramCond
p = Program.from_file_record(record_bytes)
if p.is_multi_record():
# WHEN (5) — event trigger
if p.prog_type == ProgramType.WHEN:
event_id = p.event_id # 0x0405 for Zone 5 Secure
family = (event_id >> 8) & 0xFC # ProgramCond.ZONE = 0x04
instance = event_id & 0xFF # zone # 5
# AT (6) — single-occurrence time trigger
elif p.prog_type == ProgramType.AT:
# Same time fields as compact TIMED
when = (p.hour, p.minute)
days = p.days # Days bitmask
# EVERY (7) — recurring trigger
elif p.prog_type == ProgramType.EVERY:
interval = p.every_interval # e.g. 5 for "5 SECONDS"
# AND (8) — one AND-IF condition
elif p.prog_type == ProgramType.AND:
family = p.and_family # high byte: 0x04=ZONE, 0x08=CTRL, 0x0A=CTRL+ON, ...
operand = (family & 0x02) >> 1 # ON / NOT_READY / ENABLED
instance = p.and_instance # zone#, unit#, MiscConditional value
# OR (9) — pure separator, no fields
elif p.prog_type == ProgramType.OR:
pass # only the prog_type byte matters
# THEN (10) — action
elif p.prog_type == ProgramType.THEN:
cmd = p.cmd # enuUnitCommand
par = p.par
pr2 = p.pr2 # object number (LE u16, same as compact action)
```
A complete multi-record block is a *contiguous run* of these records
in the program table — one record per visual line of the PC Access
program-block editor. Use `is_multi_record()` to detect the range
boundaries when iterating.
References:
- `clsProgram.cs` — field accessors, the Read/Write swap for Event,