6 Commits

Author SHA1 Message Date
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
e57fbc41e3 HA: optional .pca file as alternate source for panel programs
Adds CONF_PCA_PATH + CONF_PCA_KEY config-flow fields. When set, the
coordinator parses programs from the .pca file at that path instead
of streaming them over the wire on every entry refresh. Useful for:

* deployments where wire enumeration is slow (1500-slot iteration)
* offline snapshots when the panel is unreachable
* deterministic test setups against a known fixture

The config-flow validates the file is readable and decrypts cleanly,
surfacing pca_not_found / pca_decode_failed errors via the strings/
en.json translations.

The .pca path is checked first in _discover_programs; if absent the
wire path runs as before. So existing deployments are unaffected.

Tests cover the success path (live fixture, 330 programs) and the
two validation failures (missing file, garbage bytes).
2026-05-12 19:15:32 -06:00
b412dc0f37 HA: discover programs over the wire + diagnostic sensor
Coordinator's _discover_programs is no longer a placeholder. It now
drives client.iter_programs() (v2 path) or the v1 adapter's forward
to OmniClientV1.iter_programs (v1 path), populating OmniData.programs
with decoded Program records keyed by slot. Errors are logged and
swallowed so partial enumeration doesn't break entry setup —
programs are non-critical telemetry.

OmniData.programs is now dict[int, Program] rather than the
ProgramProperties dict that was an empty placeholder. The
ProgramProperties dataclass remains in models.py for the Properties
opcode reply path; only the coordinator's value type changed.

New OmniProgramsSensor on the sensor platform: a single diagnostic
entity per panel whose state is the count of defined programs and
whose 'programs' attribute lists each program's slot, type name,
and schedule fields. Easy to consume from automations and the
developer-states UI.

Mock fixture seeds three programs (TIMED+TIMED+EVENT at slots 12 /
42 / 99). New integration test verifies the sensor enumerates them
in slot-ascending order with the expected per-record fields.

Full suite: 494 passed, 1 skipped (fixture-gated).
2026-05-12 19:10:32 -06:00
df8b6128ea HA test harness + docker dev stack — both proven green
Pytest harness (in-process HA + MockPanel)
==========================================
pyproject.toml — bumps requires-python to 3.14.2 to align with HA 2026.5.x
which is what pytest-homeassistant-custom-component pins. Dev group 'ha'
pulls the harness; .python-version updated to 3.14.

src/omni_pca/mock_panel.py — Thermostat (6) and Button (3) RequestProperties
handlers added (previous commit). Without these the HA coordinator's
discovery walk produced empty thermostat/button dicts.

custom_components/omni_pca/services.py — fix CONF_ENTRY_ID import: HA
exports it as ATTR_CONFIG_ENTRY_ID, not CONF_ENTRY_ID. Aliased on import.

tests/conftest.py — re-enables sockets globally (the HA harness installs
pytest_socket which otherwise blocks our network e2e tests).

tests/ha_integration/ — new directory with full HA boot harness:
  conftest.py:
    - autouse enable_custom_integrations so HA loads our component
    - autouse expected_lingering_tasks=True (background event listener)
    - autouse _short_scan_interval (1s instead of 30s for fast tests)
    - panel fixture: MockPanel on a random localhost port for each test
    - configured_panel fixture: builds a MockConfigEntry, runs setup,
      yields, then unloads on teardown so the coordinator's reader task
      and OmniClient socket close cleanly (otherwise verify_cleanup hangs)
  test_setup.py — 12 tests:
    - integration loads + system_info populated
    - alarm_control_panel/light/switch/climate/button/event/binary_sensor
      entities materialise per platform
    - unload_entry tears down cleanly
    - turning a light on via HA service updates the mock state
    - arming via HA service with the right code transitions the area
    - arming with wrong code keeps the area disarmed and surfaces error

Total: 351 passed, 1 skipped (PCA fixture). Ruff clean across src/ tests/
custom_components/. The 12 HA integration tests run in <1s end-to-end —
they boot HA in-process, drive the config flow, exercise services, and
verify state mutations on the mock side.

Docker dev stack (manual smoke / screenshots)
=============================================
dev/docker-compose.yml — HA 2026.5 container + MockPanel sidecar.
dev/run_mock_panel.py — long-running mock with a populated state
  (5 zones, 4 units, 2 areas, 2 thermostats, 3 buttons, codes 1234/5678).
dev/Makefile — make dev-up / dev-logs / dev-down / dev-mock / dev-reset.
dev/README.md — onboarding walkthrough (host=host.docker.internal,
  port=14369, controller_key=000102030405060708090a0b0c0d0e0f).

.gitignore — adds ha-config/ so the persisted HA state from the dev
stack doesn't get committed.
2026-05-10 15:37:48 -06:00