Skip to content

Program record format

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 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.)

OffsetFieldTypeNotes
0prog_typebyteProgramType enum
1-2condLE u16First inline AND-IF condition (zero = no condition)
3-4cond2LE u16Second inline AND-IF condition (zero = no condition)
5cmdbyteCommand enum
6parbyteCommand parameter (level, mode, …)
7-8pr2LE u16Secondary parameter (usually object number)
9-10month, daybytesSwapped to day, month on disk for EVENT type — see below
11daysbyteDays bitmask (Mon=0x02 … Sun=0x80)
12hourbyte0-23 for absolute time; offset for sunrise/sunset-relative
13minutebyte0-59

When prog_type == REMARK (4) the bytes at offsets 1-4 hold a single 32-bit BE RemarkID instead of cond + cond2:

OffsetFieldType
0prog_type (= 4)byte
1-4remark_idBE 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:

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}")

The 11 values split into two encoding families. Which family a block uses depends on the panel firmware version — see Compact vs multi-record form below.

ValueNameFamilyMeaning
0FREEcompactUnused slot (all 14 bytes zero)
1TIMEDcompactTime-of-day trigger with inline action
2EVENTcompactPanel event (zone open, X-10, …) with inline action
3YEARLYcompactYearly calendar trigger with inline action
4REMARKcompactRemarkID + free-text association (see Remark variant)
5WHENmulti-recordEvent-trigger record (firmware ≥3.0.0)
6ATmulti-recordTime-trigger record (firmware ≥3.0.0)
7EVERYmulti-recordRecurring-trigger record (firmware ≥3.0.0)
8ANDmulti-recordAND-condition record (firmware ≥3.0.0)
9ORmulti-recordOR-alternative separator (firmware ≥3.0.0)
10THENmulti-recordAction record (firmware ≥3.0.0)

Source: enuProgramType.cs; the firmware gate is Features.Add(MultiLinePrograms, 196608u) at clsCapOMNI_PRO_II.cs:290.

ConditionFamily (high bits of cond / cond2)

Section titled “ConditionFamily (high bits of cond / cond2)”
ValueNameFamily
0OTHERmisc-conditional (DARK, AC_POWER_OFF, …)
4ZONEzone-state condition
8CTRLcontrol-unit (light/output) condition
12TIMEtime-clock condition
16SECsecurity-mode condition (catch-all)

Family is found by (cond >> 8) & 0xFC — bits 2-7 of the high byte. Source: enuProgramCond.cs. See “cond / cond2 bit split” below for the full per-family decode.

Bitmask with Monday as the low-order valid bit — not bit 0:

BitName
0x02Monday
0x04Tuesday
0x08Wednesday
0x10Thursday
0x20Friday
0x40Saturday
0x80Sunday

Source: enuDays.cs. 0x00 = no day selected.

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:

OffsetBytesFieldValue (LE for u16)
001prog_typeTIMED (1)
1-28d 09cond0x098d → CTRL family, Unit 397, OFF
3-49b 09cond20x099b → CTRL family, Unit 411, OFF
544cmd0x44 (raw — opcode is one of the many enuUnitCommand values)
603par3
7-801 00pr20x0001 = 1 (object #1)
908month (TIMED → no swap)8
100cday12
113edaysMon|Tue|Wed|Thu|Fri (weekdays)
1207hour07
130fminute15

So: “TIMED program firing at 07:15 on weekdays, doing command 0x44 with par=3 against object #1, gated by Unit 397 OFF AND IF Unit 411 OFF.”

Note that the bytes at offsets 7-8 are 01 00 but the value is 1 (not 256): the low byte comes first. Same for cond (8d 090x098d, not 0x8d09) and cond2.

The 16-bit cond (and cond2) field packs family + selector + operand. The high byte’s bits 2-7 (i.e. (cond >> 8) & 0xFC) discriminate the family; the bottom byte and low bits of the high byte carry the rest.

FamilyMatchBit layoutSelectorOperand
OTHER(cond >> 8) & 0xFC == 0x00....‥‥‥‥ ‥‥‥‥mmmmbits 0-3 = MiscConditional(no operand)
ZONE... == 0x04000001oo zzzzzzzzbits 0-7 = zone #bit 9 = 0=SECURE, 1=NOT_READY
CTRL... == 0x08000010oo uuuuuuuu (+ bit 8 = high u)bits 0-8 = unit #bit 9 = 0=OFF, 1=ON
TIME... == 0x0C000011oo ccccccccbits 0-7 = clock #bit 9 = 0=DISABLED, 1=ENABLED
SECanything else (incl. 0x10+)xmmm aaaa ........bits 8-11 = area # (0 = any)bits 12-14 = SecurityMode, bit 15 = arming-transition flag

Worked examples:

cond = 0x0605 → ZONE, zone 5, NOT_READY ("Zone 5 NOT_READY")
cond = 0x0A0F → CTRL, unit 15, ON ("Unit 15 ON")
cond = 0x0E03 → TIME, clock 3, ENABLED ("Time clock 3 ENABLED")
cond = 0x000B → OTHER, BATTERY_OK
cond = 0x8100 → SEC, area 1, mode=OFF ("Area 1 OFF")
cond = 0xB100 → SEC, area 1, ARMING AWAY (bit 15 + mode=3)

Python usage:

from omni_pca.programs import Condition, ConditionFamily, Program, ProgramType
p = Program(
prog_type=int(ProgramType.TIMED),
cond=0x0605, # "Zone 5 NOT_READY"
cond2=0xB100, # "Area 1 ARMING AWAY"
)
c1 = p.condition()
assert c1.family is ConditionFamily.ZONE
assert c1.selector == 5 and c1.operand == 1
assert c1.describe() == "Zone 5 NOT_READY"

Condition.describe() does the rendering with index-based labels ("Zone 5", "Unit 12") — name lookups need the panel name tables and are left to the caller. A future helper that takes a name map would be the obvious next step for a UI editor.

A few details worth knowing:

  • OTHER ignores high bits. cond=0x010B and cond=0x000B both decode to BATTERY_OK — PC Access’s encoder sometimes leaves high bits set on Other-family conditions; the decoder masks them off.
  • SEC with mode=Off + bit 15 is the plain “Area X is OFF” encoding, NOT an arming transition. The arming-transition rendering kicks in only when bit 15 and the mode bits 12-14 are non-zero (per clsText.cs:2263).
  • Area 0 in SEC is the “no specific area” / “any area” form — PC Access renders the area name as blank in this case.

Source: clsText.GetConditionalText at clsText.cs:2224-2273 (decode) and frmAutomationEditCondition.cs:615-2550 (encode/UI). The MiscConditional enum mirrors enuMiscConditional.cs.

TIMED programs: absolute time vs sunrise/sunset offset

Section titled “TIMED programs: absolute time vs sunrise/sunset offset”

The hour byte at offset 12 is overloaded as a one-of-three discriminator:

hour byteMeaning
0 – 23Absolute wall-clock time. minute is unsigned 0-59.
25Sunrise-relative. minute is read as a signed byte.
26Sunset-relative. minute is read as a signed byte.

For the sunrise/sunset forms, the signed minute byte is the offset in minutes: positive = after, negative = before, zero = at.

from omni_pca.programs import Program, ProgramType, TimeKind
p = Program(prog_type=int(ProgramType.TIMED), hour=26, minute=246, days=0x40)
assert p.time_kind == TimeKind.SUNSET
assert p.time_offset_minutes == -10 # 246 as sbyte == -10
assert p.format_time() == "10 min before sunset"

The decoder hides the sbyte trick behind a time_kind property and a time_offset_minutes view; the original hour / minute bytes are still on the dataclass for round-trip fidelity. format_time() renders a friendly label so HA entity callers get "30 min after sunrise" rather than (25, 30).

Source: frmPopUpEditTime.cs:186-217 (decode) and :241-263 (encode) in the decompiled PC Access form. The Owner’s Manual (“±0-120 minutes”) is a UI-level constraint; the wire encoding supports the full sbyte range (-128..127).

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:

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

Section titled “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:

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.

.pca file (slot in table)clsOLMsgProgramData (wire)
Total bytes14 (slot index implicit)16 (prefix + body)
Prefix[program_number_hi, program_number_lo] BE u16
Body14 bytes as documented above14 bytes — never swaps Mon/Day
Identified byPosition in 1500-slot blockclsOLMsgProgramData.ProgramNumber (Data[1-2])

The omni_pca.programs module distinguishes:

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.

The same user-visible “program block” (1 WHEN + 0-N AND-IFs + 1 THEN) has two on-disk encodings. PC Access picks compact whenever the block can fit, and multi-record otherwise.

The block fits in one 14-byte record. prog_type is TIMED, EVENT, or YEARLY depending on the trigger kind:

  • The action goes inline in cmd / par / pr2.
  • Up to two AND-IF conditions go inline in cond and cond2.
  • For EVENT, the event identifier replaces the calendar month/day at bytes 9-10 (see the EVENT Evt u16 section).

This is how 100% of records in any panel running firmware before 3.0 are encoded. It is also how PC Access encodes any block that fits the constraints, even on firmware ≥3.0 — see the simplification rules in frmAutomationEditBlock.cs:589 SimplifyLines.

Multi-record form (firmware ≥3.0.0 only)

Section titled “Multi-record form (firmware ≥3.0.0 only)”

When the block cannot fit in compact form, PC Access emits one 14-byte record per “line”, consuming sequential slots in the program table:

ProgTypeRoleNotable fields
WHEN (5)Event triggerevent-id at bytes 9-10 BE (wire form, no swap); cmd/par/pr2 zero
AT (6)Single-occurrence time triggermonth/day/days/hour/minute at bytes 9-13 (same as compact TIMED)
EVERY (7)Recurring time triggerinterval at bytes 3-4 BE
AND (8)One AND-IF conditionfamily + operand at byte 1; instance at bytes 3-4 BE
OR (9)Separator between AND-groupsno payload — only the prog_type byte is set
THEN (10)Actionsame cmd / par / pr2 (LE) layout as compact form

Per-record byte tables for each of these are in the per-record byte layouts section below.

A 5-record block from our test fixture (one WHEN, three ANDs, one THEN, all in adjacent slots):

slot N+0: 05 00 00 00 00 00 00 00 00 04 01 00 00 00 WHEN <Zone 1 Secure>
slot N+1: 08 00 00 00 01 00 00 00 00 00 00 00 00 00 AND IF NEVER
slot N+2: 08 00 00 00 01 00 00 00 00 00 00 00 00 00 AND IF NEVER
slot N+3: 08 00 00 00 01 00 00 00 00 00 00 00 00 00 AND IF NEVER
slot N+4: 0a 00 00 00 00 01 00 01 00 00 00 00 00 00 THEN UNIT 1 ON

The firmware gate is Features.Add(MultiLinePrograms, 196608u) in clsCapOMNI_PRO_II.cs:290 (the 24-bit value packs as major*65536 + minor*256 + revision → 3.0.0).

When MultiLinePrograms is OFF (firmware before 3.0):

  • PC Access’s Or toolbar button and “Add Comment Block” menu item are disabled.
  • PC Access refuses to save any block with 3+ AND-IFs, an OR clause, or a comment.
  • The panel’s program table can only contain ProgType values 0-4.

The user-facing panel from this repository runs firmware 2.16A, so all programs in its .pca are compact-form by necessity. Our test captures of the multi-record form were produced by using PC Access’s Account → Version Override → “Controller Firmware >= 3.0”, which spoofs the capability for editing only; the resulting .pca won’t load on a real 2.16A panel.

Per-record byte layouts (firmware ≥3.0 multi-record form)

Section titled “Per-record byte layouts (firmware ≥3.0 multi-record form)”
Byte(s)FieldNotes
0prog_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-13zeros(action lives in a separate THEN record)

AT (ProgType=6) — single-occurrence time trigger

Section titled “AT (ProgType=6) — single-occurrence time trigger”
Byte(s)FieldNotes
0prog_type= 6
9monthcalendar month
10daycalendar day
11daysDays bitmask (bit 1=Mon … bit 7=Sun)
12hour0-23, or 25/26 for sunrise/sunset (see TIMED section)
13minute0-59, or signed for sunrise/sunset offset
1-8zeros(action lives in a separate THEN record)

Same time-field layout as compact-form TIMED, just with cmd/par/pr2 zero.

Byte(s)FieldNotes
0prog_type= 7
3-4 (BE u16)intervalrecurrence value (e.g. 5 for “5 SECONDS” UI default)
1-2, 5-13zeros(unit of interval not yet RE’d — seconds/minutes/hours likely fixed by value range)

For the traditional case (no structured comparison operator):

Byte(s)FieldNotes
0prog_type= 8
1family + operandHigh byte of the compact-form cond u16 (see cond bit split). e.g. 0x04 = ZONE+SECURE, 0x0A = CTRL+ON, 0x00 = OTHER family.
3-4 (BE u16)instanceObject number: zone#, unit#, MiscConditional value, etc.
2, 5-13zeros(Bytes 5-13 are populated for structured comparison operators like “TEMP > 70” — not yet fully RE’d.)

OR (ProgType=9) — separator between AND-groups

Section titled “OR (ProgType=9) — separator between AND-groups”
ByteValue
00x09 (OR ProgType)
1-13all zero

Pure discriminator — carries no payload. Marks the boundary between alternative AND-IF groups within a block.

Same cmd / par / pr2 layout as compact-form records (cmd at byte 5, par at byte 6, pr2 at bytes 7-8 LE).

The AND record has two modes distinguished by the OP byte:

  • Traditional (OP == 0): the simple-AND-IF case covered in the table above. Byte 2 holds a ProgramCond family code (ZONE=4, CTRL=8, …) and bytes 3-4 hold instance << 8.
  • Structured (OP > 0): comparison-operator conditions like DATE IS EQUAL TO 12/31, TEMPERATURE > 70, ZONE IS BYPASSED.

Per the clsProgram.cs:326-436 accessors, a structured AND record’s 14 bytes decompose as:

Byte(s) (in-memory Data[])FieldType
1OPbyte (enuCondOP)
2Arg1_ArgTypebyte (enuCondArgType)
3-4Arg1_IXu16
5Arg1_Fieldbyte
6Arg2_ArgTypebyte
7-8Arg2_IXu16
9Arg2_Fieldbyte
10-11CompConstu16

enuCondOP (byte 1):

ValueNameUI label
0Arg1_Traditional(simple condition, see table above)
1Arg1_EQ_Arg2IS EQUAL TO
2Arg1_NE_Arg2IS NOT EQUAL TO
3Arg1_LT_Arg2IS LESS THAN
4Arg1_GT_Arg2IS GREATER THAN
5Arg1_OddIS ODD
6Arg1_EvenIS EVEN
7Arg1_Multiple_Arg2IS A MULTIPLE OF
8Arg1_IN_Arg2IS IN
9Arg1_NOT_IN_Arg2IS NOT IN

enuCondArgType (bytes 2, 6):

ValueName
0Constant
1UserSetting
2Zone
3Unit
4Thermostat
5Auxillary
6Area
7TimeDate
8Audio
9AccessControl
10Message
11System

Worked example: AND IF DATE IS EQUAL TO 12/31

Section titled “Worked example: AND IF DATE IS EQUAL TO 12/31”

Authored as a 5-line block, the AND record landed at:

08 07 01 00 00 01 00 1f 0c 00 00 00 00 00

Tracing the byte flow through Read (LE u16) → setter (swap to BE in Data[]) → accessor (BE read):

Byte positionDiskRead u16 LEAfter setter (Data[])Accessor reads
1-207 01Cond=0x0107Data[1]=01, Data[2]=07OP=1 (Arg1_EQ_Arg2), Arg1_ArgType=7 (TimeDate)
3-400 00Cond2=0x0000Data[3]=00, Data[4]=00Arg1_IX=0 (CURRENT_DATE)
501Data[5]=01Arg1_Field=1
600Data[6]=00Arg2_ArgType=0 (Constant)
7-81f 0cPr2=0x0c1fData[7]=0c, Data[8]=1fArg2_IX=0x0c1f = (month=12, day=31)
900Data[9]=00Arg2_Field=0
10-1100 00Data[10]=00, Data[11]=00CompConst=0

How Traditional and Structured share the same byte slots

Section titled “How Traditional and Structured share the same byte slots”

The C# clsConditionLine.Cond property at clsConditionLine.cs:17-33 is the bridge between the two modes:

public ushort Cond {
get {
if (OP == enuCondOP.Arg1_Traditional)
return ((Arg1.ArgType << 8) | (Arg1.IX >> 8));
return 0;
}
set {
OP = enuCondOP.Arg1_Traditional;
Arg1.ArgType = (enuCondArgType)(value >> 8);
Arg1.IX = (ushort)((value & 0xFF) << 8);
}
}

When you set Cond = 0x0405 for “Zone 5 Secure”:

  • Arg1.ArgType receives 0x04 (the family code value, stored as a raw byte — semantically a ProgramCond.ZONE even though the field type is enuCondArgType)
  • Arg1.IX receives (0x05 << 8) = 0x0500 (the instance number shifted up by 8 bits)

These get serialised through the same clsProgram.Cond / Cond2 setters as compact-form records, ending up on disk as 04 00 00 05 at bytes 1-4. The same disk slots therefore double as:

  • OP (byte 2 LE → Data[1]) and Arg1_ArgType (byte 1 LE → Data[2]) for structured records, OR
  • family << 8 | instance >> 8 packed into the Cond u16 for Traditional records (using the same byte storage but interpreted via the clsConditionLine.Cond synthesis)

The omni_pca.Program class exposes and_op, and_arg1_argtype, and_arg1_ix, and_arg2_ix etc. as direct accessors for the structured case, plus and_family / and_instance as Traditional-friendly aliases (with and_instance doing the right shift for the Traditional case automatically).

  • 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 its own follow-up.
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).

For records with prog_type in the firmware-≥3.0 range (5-10), the Program class exposes type-specific accessor properties:

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 (either Traditional or Structured)
elif p.prog_type == ProgramType.AND:
if p.and_op == 0:
# Traditional: family code at byte 1, instance at byte 4
family = p.and_family # 0x04=ZONE, 0x08=CTRL, 0x0A=CTRL+ON, ...
operand = (family & 0x02) >> 1 # ON / NOT_READY / ENABLED
instance = p.and_instance # zone#, unit#, MiscConditional value
else:
# Structured: e.g. "AND IF DATE IS EQUAL TO 12/31"
op_ = p.and_op # enuCondOP (1=EQ, 2=NE, 3=LT, 4=GT, ...)
arg1_type = p.and_arg1_argtype # enuCondArgType (TimeDate=7, ...)
arg1_ix = p.and_arg1_ix # object index or sub-field code
arg1_fld = p.and_arg1_field
arg2_type = p.and_arg2_argtype # often Constant (=0)
arg2_ix = p.and_arg2_ix # constant value or 2nd object
arg2_fld = p.and_arg2_field
compconst = p.and_compconst
# 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, 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-1330clsOL2MsgProgramData (v2). Both prepend a 2-byte BE ProgramNumber to the 14-byte body.
  • .pca file format — where the 21,000-byte Programs block sits in the body walk.
  • Library API → Program — public surface for Program.from_wire_bytes / from_file_record etc.