diff --git a/src/content/docs/reference/program-format.mdx b/src/content/docs/reference/program-format.mdx index 6b5c257..d73ab52 100644 --- a/src/content/docs/reference/program-format.mdx +++ b/src/content/docs/reference/program-format.mdx @@ -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,