16 Commits

Author SHA1 Message Date
8250df0206 pca_file: TimeAdj, AlarmResetTime, ArmingConfirmation, TwoWayAudio
Four more scalars sandwiched around the thermostat arrays
(clsHAC.cs:3303-3321):

  3394: TimeAdj             — daily clock-drift adjust minute (1..59, default 30)
  3395: AlarmResetTime      — alarm-clear retry delay (1..30 default 6; 30..60/30 on EURO)
  3396: ArmingConfirmation  — beep on successful arm (bool)
  3461: TwoWayAudio         — central-station 2-way audio on alarm (bool)

3461 sits right after Thermostat.Type[1..64] @ 3397..3460.

Live fixture: time_adj=30 (panel default), alarm_reset_time=4,
arming_confirmation=False, two_way_audio=False — coherent
plain-vanilla home-install values.

Full suite: 499 passed, 1 skipped.
2026-05-14 01:10:33 -06:00
c7eb92122b pca_file: ZoneOptions + thermostat type/areas — per-object props done
Closes out the per-object property triad. These three fields live
deep in the installer section past the zone-area / button-area-group
arrays (clsHAC.cs:3290-3416):

  3330..3393: Thermostats[1..64].Areas  (area-membership bitmask)
  3397..3460: Thermostats[1..64].Type   (raw enuThermostatType)
  3553..3728: Zones[1..176].ZoneOptions (raw options byte)

Offsets derived from the OMNI_PRO_II CAP constants (numConsoles=16,
numTstats=64, numDCMCodes=16, numMessageGroups=16, numSerialPorts=6,
numSCI+numUART=5) plus its feature set — SuperviseBell +
SuperviseExteriorSounder + ZoneResistors + Addressable + UPB all
present, contributing exactly 9 conditional bytes before
ReportBypassRestore. Verified empirically: ZoneOptions is a clean
176-byte run of the default value 4, bounded by CrossZoneTimer=60
as the canary byte just before it.

New PcaAccount fields: zone_options, thermostat_types,
thermostat_areas. MockZoneState gains `options`, MockThermostatState
gains `thermostat_type` + `areas`. The mock's zone and thermostat
Properties replies now serve the real values instead of the
hardcoded 0 / 1 they used before — so HA discovery against
MockState.from_pca gets the complete per-object property set.

Live fixture: all 176 zones at the default options=4, both named
thermostats type 1, thermostat areas 0xFF (all) → normalised to
area 1 in the mock (consistent with the unit-area handling).

With this the OMNI_PRO_II SetupData decode is functionally complete
for every per-object property a consumer would want — zones, units,
areas, thermostats all carry type + area + options sourced from the
file rather than faked.

Full suite: 499 passed, 1 skipped.
2026-05-13 23:33:37 -06:00
e61e37a3fc pca_file: finish SetupData — telephony, misc scalars, DCM block
Final SetupData sweep. Everything still walked-but-discarded is now
captured.

Telephony / dialer (user-section head + installer):
  telephone_access, answer_outside_call, remote_commands_ok,
  rings_before_answer, dial_mode, my_phone_number (PII, repr=False),
  callback_number.

Misc panel scalars:
  high_security, freeze_alarm, flash_light_num (BE u16 — the X10 unit
  flashed on alarm), announce_alarms, house_code, zone_expansions,
  num_exp_enc, num_thermostats, exterior_horn_delay, dialout_delay,
  verify_fire_alarms, enable_console_emg, time_format, date_format,
  ac_power_freq, dead_line_detect, off_hook_detect.

DCM (Digital Communicator Module) — the alarm-dialer block — as a
new DcmConfig dataclass: primary/backup phone numbers (PII,
repr=False) + account IDs, dcm_type, supervisory test schedule +
code, the 176-entry per-zone alarm-code table, and the 8 emergency
event codes (Freeze/Fire/Police/Aux/Duress/BatteryLow/FireZone/Cancel).

The phone-number strings use clsHardwareArray.ReadString — a
fixed-width MaxLength+1 slot whose content runs until the first 0xFF,
with no length prefix (distinct from the String8 used by Names).
New _read_hw_string helper handles that format. "-" is the panel's
blank-number sentinel.

Live fixture decodes coherently: telephone access on, 8 rings before
answer, panel's own number "208-854-7071", HouseCode A, 64
thermostats, fire-alarm verification on. DCM is unconfigured for
central-station monitoring (blank "-" numbers, 0xAAAA default
account IDs) but the per-zone alarm-code table is fully populated.

With this, the OMNI_PRO_II SetupData block is essentially fully
decoded — every field clsHAC._ParseSetupData reads up through the
zone-area / button-area-group arrays is now surfaced on PcaAccount.

Full suite: 499 passed, 1 skipped.
2026-05-13 23:13:27 -06:00
362580bccc pca_file: AccountRemarks_Extended + 9 per-family Description tables
The PCA03 post-Connection extension was previously walked as opaque
bytes — _walk_to_remarks read past AccountRemarks_Extended and the
nine 33-byte-per-slot Description tables only to advance the cursor
to the actual Remarks dict.

This pass keeps the data instead. New PcaAccount fields, all populated
only when FileVersion >= 3:

* account_remarks_extended — free-text installer notes (repr=False)
* zone/unit/button/code/thermostat/area/message/audio_source/audio_zone
  _descriptions — per-family {slot: description} dicts

Per-slot format inside each Description block is the same String8(32)
that names use elsewhere: 1 length byte + 32 padded bytes, decoded
to UTF-8 with NUL-strip. The leading u32 count can exceed the family's
actual object count (real panels write the max-slot count regardless
of how many are populated); we read all of them and filter empties.

Live fixture decodes cleanly: every Description table is empty
(homeowner never filled them in — that's reality, not a parser
fault). The hand-built synthetic test in test_pca_file proves the
decode works when the data is actually present (zones 1+2 with
descriptions "FOYER!" and "GARAGE LT").

_walk_to_remarks now returns a _RemarksWalk dataclass aggregating
all of the post-Connection extraction; existing remarks-related
tests updated to use the new return shape.

Full suite: 499 passed, 1 skipped.
2026-05-13 22:32:20 -06:00
7b789f8cfb pca_file: Latitude / Longitude / TimeZone from SetupData
Three single-byte scalars sandwiched between the TimeClocks block and
AnnounceAlarms (clsHAC.cs:3064-3066). Raw bytes — no N/S/E/W modifier
at this position (those live in the optional WorldWideLatitude feature
block past DST). TimeZone is hours west of UTC on OMNI_PRO_II.

Live fixture decodes as 44°N / 117°W / TZ 7 (Pacific Daylight) —
matches a real northern-US install on the panel time.

PcaAccount gains latitude / longitude / time_zone (int each).
Walker reads them via the existing _read_scalar_byte helper.

Full suite: 499 passed, 1 skipped.
2026-05-13 16:39:30 -06:00
b8745e17de pca_file: HouseCodeFormat, TimeClocks, Installer/PCAccess codes
Three more SetupData fields, plus a refinement that uses one of them:

* HouseCodes.EnableExtCode[1..16] at user-section offset 1917 —
  one enuHouseCodeFormat byte per 16-unit X10 group. Live fixture:
  HouseCode 1 = HLC (5), HouseCodes 2..16 = Extended (1).

* Six 5-byte clsWhen structs at 1863..1892 — TimeClock 1/2/3 On/Off
  schedules. Exposed as a new TimeClock dataclass tuple. Live fixture
  TC1 = On 22:30 → Off 06:00 daily (outdoor-lights pattern).

* InstallerCode (BE u16 @ 2995), EnablePCAccess (bool @ 2997),
  PCAccessCode (BE u16 @ 2998) — both codes are PII so repr=False.

Refinement: unit_types for X10 units now resolves the specific
sub-type via the HouseCodeFormat table instead of the previous
collapsed Standard. Mapping mirrors clsUnit.CalculateUnitType
(clsUnit.cs:928-999) including the (Number-1)%8==0 split for HLC
(HLCRoom vs HLCLoad) and ZWave (ViziaRoomController vs ViziaLoad).

Live fixture now reports the right thing: unit 1 (ROOM ONE) is
HLCRoom (5), units 2-8 (FRONT PORCH, PENDANT LTS, …) are HLCLoad (6),
unit 9 is HLCRoom again, etc. Units 17-256 under HouseCodes 2..16 are
all Extended (2). Total: 2 HLCRoom + 14 HLCLoad + 240 Extended +
136 Output + 119 Flag = 511 unit slots, matches numUnits exactly.

Full suite: 499 passed, 1 skipped.
2026-05-13 09:13:40 -06:00
7683557bbb pca_file: PerimeterChime/AudibleExitDelay, DST, unit type+area, code PINs
Four more SetupData fields landed in one pass. The user-section walk
past the previously-mapped 5 contiguous area flags continues with 70
bytes of intervening config (HighSecurity/FreezeAlarm/FlashLightNum/
HouseCodes flags × 32 / 6 TimeClock When-structs / Latitude/Longitude/
TimeZone/AnnounceAlarms) to reach:

  1897..1904: PerimeterChime[1..8]    (bool[8])
  1905..1912: AudibleExitDelay[1..8]  (bool[8])
  1913..1916: DSTStartMonth/Week, DSTEndMonth/Week (4 scalar bytes)

Live fixture DST decodes as US-standard (March / 2nd Sunday →
November / 1st Sunday). Area-1 PerimeterChime is OFF (homeowner
disabled), the panel default for unused areas 2-8 is ON.

Unit type + area assignment, derived from CAP index ranges and the
AreaGroups bitmap arrays at installer offsets 3035..3105
(clsHAC.cs:3242-3289):

  X10 units    (1..256)   → enuOL2UnitType.Standard (1),
                            16 units per AreaGroups byte
  ExpEnc       (257..384) → Output (13), 4 units per byte
  VoltOut      (385..392) → Output (13), 1 byte per unit
  FlagOut      (393..511) → Flag (12), 8 flags per byte

The X10 sub-types (Standard/Extended/HLC/UPB/ZWave/…) collapse to
Standard since deriving them needs the HouseCodes EnableExtCode table
which we don't decode yet. Live fixture all-511-units classify
correctly: 256 X10 + 128 ExpEnc + 8 VoltOut + 119 FlagOut.

Unit areas are 8-bit membership bitmasks. The live fixture has 0xFF
everywhere ("panel default — all 8 areas"); from_pca normalises that
to 0x01 ("area 1 only") so the mock's Properties reply gives HA a
single sensible area instead of bit-set noise.

Code PINs (offset 383, 99 × 14-byte entries). Per-entry layout:
  bytes 0..1: PIN (BE u16; plain 4-digit 0..9999)
  byte 2:     Authority (enuCodeAuthority: 0=Disabled, 1=User,
              2=Manager, 3=Installer)
  byte 3:     Areas bitmask
  bytes 4..13: WhenOn + WhenOff (2 × clsWhen)

PINs are PII — ``PcaAccount.code_pins`` is marked ``repr=False`` so
a stray ``print(acct)`` can never leak them into logs. They aren't
auto-threaded to MockState.user_codes either; tests set their own
PINs explicitly. Live-fixture decode is sane: COMPUTER=4932/User,
HOMEOWNER=1234/User, Kevin=3411/User, Debra=0000/Manager, etc.

MockAreaState gains perimeter_chime + audible_exit_delay.
MockUnitState gains unit_type + areas (and the Properties reply
serves the configured values now).

Full suite: 499 passed, 1 skipped.
2026-05-13 08:40:27 -06:00
994608a4f6 pca_file + v2 client: area flags + Area-N fallback
SetupData side (clsHAC.cs:3020-3038): five contiguous bool[8] arrays
immediately after ExitDelay carry per-area config flags. Offsets:

  1787..1794: EntryChime
  1795..1802: QuickArm
  1803..1810: AutoBypass
  1811..1818: AllOnForAlarm
  1819..1826: TroubleBeep

Verified against live fixture: area 1 shows real homeowner choices
(QuickArm + AllOnForAlarm enabled, others off), unused areas 2-8 carry
the panel defaults (EntryChime/AutoBypass/TroubleBeep on by default).

PerimeterChime and AudibleExitDelay aren't in this contiguous block —
they live past FlashLightNum, HouseCodes flags, and 6 TimeClock
When-structs. Deferred.

New PcaAccount fields:
  area_entry_chime, area_quick_arm, area_auto_bypass,
  area_all_on_for_alarm, area_trouble_beep — all dict[int, bool].

MockAreaState gains the same five fields. They aren't carried in the
Properties reply on the wire (the OL2 message format doesn't have
them), so they live on MockState for snapshots and any future
SetupData-aware code, but don't surface through HA discovery yet.

v2 client list_area_names fallback: when the Properties walk turns up
no named areas (common — most homes don't name them), synthesize
"Area 1".."Area 8" so HA's _discover_areas has slots to walk.
Mirrors the v1 adapter behaviour exactly.

Knock-on win in the live-fixture HA test: area 1 now reaches
coordinator.data.areas with its configured 60s/90s delays from
SetupData, end-to-end through .pca → MockState → wire Properties →
HA's AreaProperties parser.

Full suite: 499 passed, 1 skipped.
2026-05-13 08:19:38 -06:00
501686795b pca_file: extract entry/exit delays, TempFormat, NumAreasUsed
Three more SetupData fields, varying in difficulty:

* Entry/exit delays per area — in the user section, behind 280 bytes
  of Phone[8] config and 1386 bytes of Codes[99]. Derived offsets by
  counting fixed-width fields out from Seek(1): EntryDelay[1..8] at
  offset 1771, ExitDelay[1..8] at 1779. Verified against live fixture
  (area 1: entry=60s, exit=90s; unused areas: 15s/15s panel defaults).

* TempFormat at installer offset 2993 — single byte, enuTempFormat
  (1=F, 2=C). Live fixture = 1 (US install).

* NumAreasUsed at installer offset 3034 — count of installer-enabled
  security areas. Live fixture = 1 (single-area home).

PcaAccount now carries area_entry_delays, area_exit_delays, temp_format,
num_areas_used. MockAreaState gains entry_delay/exit_delay/enabled
fields; mock _build_area_properties serves the configured values
(was hardcoded 60/30/Enabled).

MockState.from_pca now synthesizes per-area MockAreaState entries
for the union of named areas + (1..num_areas_used), filling in delays
and enabled flag. This means a single-area install with no
user-assigned name still surfaces area 1 with the correct config —
matching what an installer would see in PC Access.

(HA's coordinator only enumerates named areas via list_area_names,
so the area properties don't yet reach the diagnostic surface for
unnamed-but-in-use areas. That's a separate filter to revisit; the
data flow through pca_file → MockState → wire Properties reply is
already correct.)

Full suite: 499 passed, 1 skipped.
2026-05-12 22:35:55 -06:00
8141599b4e pca_file: extract per-zone Area assignment from SetupData
Walks the OMNI_PRO_II installer section past ZoneType, DCM stuff,
thermostat config, and the X10/VoltOut/FlagOut/ExpEnc area-group
arrays to land on the 176-byte Zones[].Area block at offset 3106.

The path from instSetupStart (2560) to zone area:

  ZoneType[176] → DCM phones/accounts/type/test(5-byte clsWhen) →
  DCMAlarmCode[176] → 8 DCM bytes → TempFormat..NumAreasUsed (29 bytes
  of misc config including 25-byte CallBackNumber) → X10 area groups
  (16) → VoltOut (8) → FlagOut (15) → ExpEnc (32) → Zones[].Area (176).

Total preamble within installer section = 546 bytes. Verified against
the live fixture: 176 zones all assigned to area 1 (single-area
install), matches expectation.

PcaAccount.zone_areas now carries {slot: area_number}; MockState.from_pca
threads it through MockZoneState.area; mock _build_zone_properties already
serves it. End-to-end test verifies the area flows through to
coordinator.data.zones[*].area.

This was the largest single-RE jump in SetupData decoding so far — got
us past the variable-length DCM block by counting fixed-width fields
out from the known ZoneType end. The clsWhen=5-byte struct was the
last unknown; derived from clsHardwareArray.ReadWhen (clsHardwareArray
.cs:456-468).

Full suite: 499 passed, 1 skipped.
2026-05-12 22:26:25 -06:00
70bf9caf58 pca_file: extract zone_type from SetupData installer section
SetupData (3840 bytes) holds the panel's per-object property tables.
Layout for OMNI_PRO_II's installer section (Seek to instSetupStart=2560
in clsHAC._ParseSetupData at clsHAC.cs:3156):

  offset 2560: HouseCode (1 byte)
  offsets 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
  offset 2570: ZoneExpansions (1 byte)
  offset 2571: NumExpEnc (1 byte)
  offsets 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType per zone)

Verified against the live fixture: 2 EntryExit + 4 Perimeter + 3 AwayInt
+ 1 Extended_Range_OutdoorTemp + 166 Auxiliary (panel default for
unused slots) — matches the named-zones cross-reference exactly.

PcaAccount gains a zone_types dict (1-based slot → raw byte). The
walker stashes the SetupData blob to a buffer up front and indexes
in by offset rather than chasing the sequential parser through all
of telephony/codes/areas — that's a bigger RE pass for another day.

MockZoneState now carries zone_type and area fields. MockState.from_pca
threads acct.zone_types through, and _build_zone_properties uses the
real value instead of hardcoded 0 (EntryExit). End-to-end against
MockPanel.from_pca: HA's discovery now classifies binary vs. analog
zones correctly straight from the .pca — outdoor temp zone surfaces
as a temperature sensor entity, motion sensors as binary_sensor,
door zones as the right kind of binary_sensor.

Full suite: 499 passed, 1 skipped. RE notes in pca_file.py.
2026-05-12 22:18:32 -06:00
7db9616a34 pca_file: extract Zone/Unit/Button/Code/Tstat/Area/Message names
The Names block (between SetupData and Voices) was previously walked
as opaque bytes. It's actually a sequence of seven object-family
tables, each storing N × String8(L) per the
clsAbstractNamedItem.ReadName / clsPcaCryptFileStream.ReadString8(out S, byte L)
pattern. Per-slot layout is [1 byte actual length][L bytes name],
with length 0 meaning "unused".

New PcaAccount fields:
* zone_names, unit_names, button_names, code_names,
  thermostat_names, area_names, message_names
  — each is {1-based slot: name}, only non-empty slots.

Object *properties* (zone_type, area_membership, etc.) aren't
extracted yet — those live in SetupData, which remains opaque.
Names alone unlock the biggest win: meaningful entity labels in
HA from a .pca snapshot.

MockState.from_pca now seeds zones/units/areas/thermostats/buttons
with MockZoneState/MockUnitState/etc. instances carrying just the
name. Defaults handle everything else. A connected client sees the
real panel's names through normal wire discovery (UploadNames
streams them back, properties synth fills the rest).

New end-to-end test verifies the HA integration discovers all 16
zones, 44 units, 16 buttons, 2 thermostats from the live fixture
when the MockPanel is built via MockState.from_pca — proving the
full file → mock → wire → HA pipeline.

Live fixture: 16 zones, 44 units, 16 buttons, 8 codes, 2 thermostats,
0 areas, 8 messages, 330 programs. (Areas in this v1 install have
no user-assigned names — expected.)

Full suite: 499 passed, 1 skipped (fixture-gated).
2026-05-12 20:34:00 -06:00
00f0028053 pca_file: parse the Remarks table (RemarkID → text resolution)
Decodes the remark-text dict that Remark-typed program records refer to
via their 32-bit BE RemarkID. The table lives after the Connection
block in PCA03 files; getting to it means walking past ModemBaud +
PCModemInit flags + AccountRemarks_Extended + nine 33-byte-per-entry
Description blocks (Zones, Units, Buttons, Codes, Thermostats, Areas,
Messages, AudioSources, AudioZones).

Format reverse-engineered from clsPrograms.ReadRemarks (clsPrograms.cs:
148-168) and the file-body walker in clsHAC.cs:8055-8079. Each entry is
[u32 LE remark_id][u16 LE text_length][N bytes UTF-8], preceded by a
[u32 LE _RemarksNextID][u32 LE count] header.

pca_file changes:
* PcaAccount.remarks: dict[int, str] (default {}).
* New _walk_to_remarks helper called from parse_pca_file when
  file_version >= 3. Best-effort: any read failure leaves remarks={}.
* New _DESCRIPTION_SLOT_BYTES (= 33) constant.

tests/test_pca_file.py (4 new cases):
* Walker on an empty Remarks table (decode count=0 cleanly).
* Walker decodes three hand-built entries, including a UTF-8 string
  with non-ASCII characters.
* Truncated input returns {} rather than raising.
* Live fixture (Our_House.pca.plain): walker consumes the prelude +
  nine description blocks + zero-count remarks block without raising.
  This panel has no Remark-typed programs, so {} is the expected
  result -- and the *coarse* walker validation here is what proves
  the description-block sizes (counts up to 511) are correct.

Full suite: 426 passed, 1 skipped (was 422 / 1).
2026-05-11 21:33:53 -06:00
d4c04b3044 programs: typed decoder/encoder for the 14-byte program record
First reverse-engineering pass on the panel's built-in automation
engine. Adds a typed Python Program dataclass that decodes/encodes the
14-byte program record used both on the wire (clsOLMsgProgramData) and
on disk (the 21,000-byte Programs block in a .pca file).

Coverage:
* enums: ProgramType, ProgramCond, Days bitmask
* Program dataclass with from_wire_bytes / from_file_record /
  encode_wire_bytes / encode_file_record (Mon/Day swap for EVENT-typed
  records applied on the file form only -- mirrors clsProgram.Read at
  clsProgram.cs:471, while clsProgram.ToByteArray omits the swap)
* Remark variant (bytes 1-4 = BE u32 RemarkID instead of cond/cond2)
* unknown ProgType / Cmd bytes pass through as raw ints with a
  once-per-process warning
* decode_program_table for the full 1500-slot .pca block
* pca_file.parse_pca_file populates PcaAccount.programs (backward-
  compatible: defaults to ())
* mock_panel.MockState.programs + _reply_program_data so OmniLink2
  UploadProgram (opcode 9) round-trips through the test fixture

Verification (422 passed, 1 skipped — was 400):
* 15 unit tests in test_programs.py: golden bytes for each ProgramType,
  Mon/Day swap proven distinct between wire and file layouts, Remark
  round-trip, 500 random-input wire+file round-trips, unknown-enum
  tolerance
* 4 fixture-gated live-data tests in test_pca_file.py: all 1500 slots
  decode cleanly, 330 non-empty (matches Phase 1 recon distribution
  209 TIMED / 105 EVENT / 16 YEARLY), 21,000-byte byte-for-byte
  round-trip against the live decrypted fixture, YEARLY month/day in
  valid calendar ranges
* 3 wire-echo tests in test_e2e_program_echo.py: client drives
  UploadProgram (opcode 9) through the mock, server replies with
  ProgramData (opcode 10) wrapping [number_hi, number_lo, body];
  full Program round-trips field-by-field, empty slots return zero
  bodies, EVENT bytes are emitted in wire order (no swap)

What this pass deliberately leaves open (documented in the docs page):
* cond / cond2 internal bit split (selector vs operand)
* multi-record clausal encoding (When/At/Every/And/Or/Then)
* RemarkID -> RemarkText lookup table layout
* DPC capability flag location for non-OPII models
* TIMED time-of-day vs sunrise/sunset-relative offset flag

References:
* clsProgram.cs (entire) — field accessors, Read/Write, Evt u16
* enuProgramType.cs / enuProgramCond.cs / enuDays.cs
* Owner's Manual SETUP chapter — user-facing programming-line model
* Installation Manual SETUP MISC — installer-facing setup screen
2026-05-11 19:48:00 -06:00
24eecceff9 docs: second cross-ref pass (HvacMode/FanMode/HoldMode, pca_file, v1/connection)
Follow-up to 0d6465d, sprinkling pca-re/docs/manuals/ citations into
three more files that map to user-visible or installer-visible panel
behavior:

models.HvacMode / FanMode / HoldMode
  Docstrings now explain which values correspond to keypad menu picks
  vs. programmatic-only states, and point at the Owner's Manual
  *Scene Commands* chapter where each menu is laid out.

pca_file.py
  Module docstring adds Cross-references to the Installation Manual's
  *INSTALLER SETUP* chapter (SETUP CONTROL/ZONES/AREAS/MISC/EXPANSION)
  -- those are the keypad screens that produce the very SetupData /
  Names / Programs blocks the parser walks. Also points at APPENDIX C
  (zone and unit mapping) for where the _CAP_OMNI_PRO_II numbers come
  from on the panel side.

v1/connection.py
  Module docstring adds cross-references to the docs-site pages that
  explain (a) the non-public handshake quirks the v1 connection relies
  on for crypto and (b) why subsequent RequestUnitStatus calls need
  the long-form BE u16 payload (Appendix C zone/unit mapping again).

No code changes, doc-only; 387 tests still pass.
2026-05-11 15:33:14 -06:00
9a024181ae Initial scaffold + protocol primitives
Project scaffold (uv, pyproject.toml CalVer 2026.5.10, ruff, pytest, mypy
strict config, MIT, README, .gitignore protecting any .pca / panel keys).

Library primitives (src/omni_pca/):
- crypto.py     AES-128-ECB + per-block XOR seq pre-whitening, session-key
                derivation (CK[0:11] || (CK[11:16] XOR SessionID))
- opcodes.py    Byte-exact PacketType (12), v1 MessageType (104),
                v2 MessageType (83), ConnectionType, ProtocolVersion
- packet.py     Outer Packet dataclass with encode/decode
- message.py    Inner Message + CRC-16/MODBUS, helpers for v1/v2
- pca_file.py   Borland LCG XOR cipher, PcaReader, .pca + .CFG parsers
                (KEY_PC01 = 0x14326573, KEY_EXPORT = 0x17569237 — fixed
                from initial typo; verified via test_keys_match_decompiled)
- __main__.py   CLI: 'omni-pca decode-pca <file> --field {host,port,...}'
                PII opt-in via --include-pii

49 tests pass, 1 skipped (live fixture). Ruff clean.
2026-05-10 12:46:26 -06:00