17 Commits

Author SHA1 Message Date
81725b4dbf HA config_flow: transport dropdown (TCP/UDP) for the new UDP path
custom_components/omni_pca/const.py:
  + CONF_TRANSPORT, TRANSPORT_TCP, TRANSPORT_UDP, DEFAULT_TRANSPORT='tcp'

custom_components/omni_pca/config_flow.py:
  + 'transport' field in _USER_SCHEMA with vol.In([tcp, udp]),
    default tcp (so existing flows are unchanged)
  + transport stored in entry.data on create
  + reauth carries the existing transport over from entry.data
  + _probe() takes transport=, propagates to OmniClient

custom_components/omni_pca/coordinator.py:
  + transport= constructor arg, defaults to 'tcp'
  + _ensure_connected passes transport= through to OmniClient

custom_components/omni_pca/__init__.py:
  + reads transport from entry.data (default tcp), passes to coordinator

Backward-compat: existing config entries without a transport key fall
through to 'tcp', identical to current behavior. New entries get the
choice at the config-flow form. The reauth step preserves the existing
transport so users don't have to re-pick it.

357 tests pass; ruff clean across src/ tests/ custom_components/.
HA integration tests don't need updating because they don't pass
transport= explicitly (default tcp matches the mock's default).
2026-05-10 21:15:56 -06:00
7f82dbbbfa UDP transport: parallel codepath in OmniConnection + MockPanel
The C# decompile shows enuOmniLinkConnectionType has both Network_TCP=4
and Network_UDP=3 (clsOmniLinkConnection.cs uses udpSend/tcpSend
parallel paths), and clsHAC carries an enuPreferredNetworkProtocol
{TCP, UDP} per-installation byte. User reports their panel is
configured for UDP. The TCP-only assumption was too narrow.

Wire format is identical: same Packet/Message framing, same handshake,
same per-block whitening, same opcodes, same port. Only differences:
* UDP is connectionless; each datagram = one Packet (no stream framing)
* UDP needs explicit retry-on-timeout for reliability

src/omni_pca/connection.py:
- New constructor args: transport: Literal['tcp','udp']='tcp',
  udp_retry_count: int = 3
- connect()/close() branch on transport — TCP keeps the existing
  asyncio.open_connection + StreamReader/Writer + reader_task path;
  UDP uses asyncio.get_running_loop().create_datagram_endpoint with
  remote_addr= so transport.sendto(data) works without per-datagram
  addrs. The reader_task is TCP-only.
- _write_packet branches between writer.write and udp_transport.sendto
- request() loops up to (1 + udp_retry_count) attempts on UDP, retrying
  on RequestTimeoutError; TCP gets a single attempt (existing behavior)
- New _OmniDatagramProtocol that decodes each datagram into a Packet
  and delegates to the shared _dispatch (which already knows how to
  route handshake / solicited / unsolicited)

src/omni_pca/mock_panel.py:
- serve(transport='tcp'|'udp') public arg; defaults preserve existing
  TCP behavior. Internally splits into _serve_tcp / _serve_udp.
- New _MockServerDatagramProtocol that mirrors _handle_client for UDP.
  Tracks one active client by addr (single-session, matches Omni's
  single-client constraint). Reuses the panel's existing _dispatch_v2,
  _reply_*, _build_* helpers — the dispatch logic is unchanged, only
  the transport framing differs.
- New _schedule_udp_push for synthesized SystemEvents (seq=0) push
  to the active client's addr after state mutations.

src/omni_pca/client.py:
- OmniClient gains transport= and udp_retry_count= kwargs that pass
  through to OmniConnection. Default is 'tcp' so existing callers
  are unaffected.

tests/test_e2e_udp.py — 6 e2e tests:
- handshake roundtrip
- get_system_information
- arm area with right code
- arm with wrong code -> CommandFailedError
- turn unit on -> push UnitStateChanged event
- wrong ControllerKey -> HandshakeError

All run under 0.2s. Combined with the existing TCP suite: 357 tests
pass (was 351), ruff clean across src/ tests/.

The HA integration's config_flow still defaults to TCP; users on UDP
panels can manually set transport= via the OmniClient init path. A
follow-up commit will add transport to the HA config flow as a
dropdown option.
2026-05-10 20:42:43 -06:00
5f6404a7e0 README: Gitea install URLs + docs site links + accurate quick starts
- Move install instructions from PyPI-only to Gitea-release-first
  (pip from git+https or direct wheel URL); note PyPI as 'pending'
- Add Project home + Documentation links at top
- Fix quick-start API name (get_system_info -> get_system_information,
  matching the actual library)
- Replace HA quick-start with the manual-clone path that works today
  (HACS support pending PyPI publish)
- Cross-link tutorials (first-script, dev-stack) and how-tos
  (install-in-home-assistant) from hai-omni-pro-ii.warehack.ing
- Add Tests section showing how to run the suite
- Add License + JOURNEY link in acknowledgements
2026-05-10 17:53:56 -06:00
04b6a44403 URLs: github.com/rsp2k/omni-pca -> git.supported.systems/warehack.ing/omni-pca
Project moved to a self-hosted Gitea at git.supported.systems under the
warehack.ing org. Updated:
  pyproject.toml                            project.urls.Repository
  custom_components/omni_pca/manifest.json  documentation, issue_tracker
  custom_components/omni_pca/README.md      every link
  CHANGELOG.md                              release tag URL

Tests still 351 + 1 skip. No code changed.
v2026.5.10
2026-05-10 17:47:04 -06:00
7b4052624c Docs: extend JOURNEY through the HA + harness + demo arc; add CHANGELOG
docs/JOURNEY.md — replaced the placeholder 'What's next' section with
seven new chronological entries covering everything that happened after
the panel-search comedy:

  - HA rebuild Phase A: poll-vs-push decision, pure-function helpers
    extraction, 61 unit tests with no HA imports
  - HA Phase B: the six new entity platforms, the Omni state-byte
    overload, security-mode-to-alarm-state mapping, the scene-platform
    skip decision
  - HA Phase C: services + diagnostics + repairs flow
  - 'wait, did we mock enough?' — catching the missing Thermostat
    (6) and Button (3) RequestProperties handlers BEFORE the HA
    harness ever touched the mock
  - HA test harness rough patches: requires-python conflict, pytest_socket
    fight, the CONF_ENTRY_ID-doesn't-exist-in-HA find, teardown hang
    fixed by converting configured_panel into a generator
  - Docker dev stack: mounting only src/ to dodge the read-only-venv
    problem with uv
  - Automated onboarding + screenshots: the auth_code OAuth dance, the
    template-endpoint device-id trick, playwright auto-injection of
    hassTokens, the discovery-during-onboarding nice surprise

Plus appended five new entries to 'Things worth remembering':
  - Pure functions are the cheapest thing in test suites
  - Mocking the entire protocol counterpart catches whole categories
  - pytest_socket + real network can coexist
  - The 'build without a real device' loop is unreasonably effective
  - (existing entries kept verbatim)

Final length: ~6800 words, 27 dated sections plus the lessons list.

CHANGELOG.md — new file. Single 2026.5.10 entry under Keep-a-Changelog-
ish format, broken into seven sections matching the project layers:
Protocol layer (RE findings), Library, Home Assistant integration,
Tests, Developer tooling, Documentation, Known gaps. Cites the source
line numbers for the two non-public protocol quirks. Lists every
public module + every entity platform. Linked to git tag template at
the bottom (release not pushed yet).

Tests still 351 + 1 skip. No code changed.
2026-05-10 16:29:41 -06:00
f6a09592f1 Live demo: HA in docker discovers + drives mock panel, screenshots captured
dev/screenshot.py — end-to-end automated demo:
  * onboards HA via /api/onboarding (user creation + auth_code flow)
  * subsequent runs log in via /auth/login_flow with saved credentials
  * adds the omni_pca config entry via /api/config/config_entries/flow
  * uses HA's template REST endpoint to discover the panel device_id
  * launches a headless chromium via playwright with prefetched auth tokens
  * captures 6 deep-linked screenshots:
      01-overview.png            — Lovelace
      02-integrations-list.png   — HAI/Leviton sitting next to HA's built-ins
      03-omni-pca-config.png     — '1 device · 38 entities', custom integration
      04-panel-device.png        — Omni Pro II device page with full controls
      05-entities-omni.png       — config_entry filtered entities table
      06-developer-states.png    — alarm_control_panel.omni_pro_ii_main with
                                   raw_mode_name=OFF, code_arm_required=true,
                                   etc. proving real entity state from mock

dev/docker-compose.yml — mock-panel command rewritten:
  * Mounts only src/ and run_mock_panel.py (read-only) instead of the full
    project so uv doesn't try to recreate the host's .venv on a RO mount
  * Installs cryptography via uv pip install --system
  * PYTHONPATH set to /tmp/mock/src so omni_pca imports work without a
    package install

dev/artifacts/screenshots/2026-05-10/ — six PNGs from the run.

.gitignore — adds dist/ for build artifacts.

Confirmed end-to-end: HA discovered the integration via mDNS hint
(showed up in the onboarding wizard's compatible-devices step), the
config flow connected to the mock over host.docker.internal:14369,
materialized 38 entities across 8 platforms (alarm_control_panel,
binary_sensor, button, climate, event, light, sensor, switch), and
displayed everything in the device + entity registries with friendly
names and attributes intact. The integration name hash is 38 entities
because the mock seeds 5 zones (binary + bypass) + 4 units + 2 areas +
2 thermostats + 3 buttons + 3 system-level binary sensors + 2 system
sensors + 6 thermostat sensors + 1 event entity = 38 (matches HA UI).
2026-05-10 16:17:33 -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
93b7e1f604 Mock: add Thermostat + Button RequestProperties handlers
The HA coordinator walks ObjectType.THERMOSTAT (6) and ObjectType.BUTTON
(3) via raw RequestProperties to discover them — the high-level
get_object_properties() path only knows zones/units/areas in v1.0. The
mock was returning Nak for both, which made HA discover zero thermostats
and zero buttons no matter how MockState was seeded.

src/omni_pca/mock_panel.py:
- New MockButtonState dataclass (just a name)
- MockState gains buttons: dict[int, MockButtonState] (with the same
  bare-string -> dataclass __post_init__ promotion as the others)
- _OBJ_BUTTON = 3, _BUTTON_NAME_LEN = 12, _THERMOSTAT_NAME_LEN = 12
  constants
- thermostat_name_bytes() / button_name_bytes() helpers
- _build_thermostat_properties() emits the 23-byte Properties body
  matching ThermostatProperties.parse offsets (object number BE u16,
  communicating flag, current temp, heat/cool setpoints, system/fan/
  hold modes, thermostat type, 12-byte NUL-padded name)
- _build_button_properties() emits the 15-byte body (object number BE
  u16 + 12-byte name)
- _reply_properties / _object_store dispatch both new types

tests/test_e2e_client_mock.py — two new e2e tests drive raw
RequestProperties walks for thermostats and buttons against a seeded
mock and assert ThermostatProperties / ButtonProperties parse cleanly,
mirroring what the HA coordinator's _walk_properties() does.

333 tests pass (was 331); ruff clean. Mock surface now matches every
opcode the HA coordinator and entity platforms actually call.
2026-05-10 15:09:31 -06:00
83d85a9885 HA Phase C: services + diagnostics + README polish
custom_components/omni_pca/services.yaml — declares 7 services with
config_entry selectors so HA's UI gives users a panel picker:
  bypass_zone, restore_zone, execute_program, show_message,
  clear_message, acknowledge_alerts, send_command (raw escape hatch)

custom_components/omni_pca/services.py — async handlers wired via
async_setup_services on entry setup; idempotent across multiple entries.
Each handler validates entry_id, looks up the right coordinator, calls
the matching OmniClient method. CommandFailedError wrapped to
HomeAssistantError; unknown Command codes raise ServiceValidationError.
async_unload_services removes them when the last entry unloads.

custom_components/omni_pca/diagnostics.py — async_get_config_entry_
diagnostics dumps a redacted snapshot for bug reports: panel model +
firmware, discovered/live counts per object type, sha256-hashed zone/
unit/area names (so uniqueness is visible without leaking PII), last
event class, controller key REDACTED via async_redact_data.

custom_components/omni_pca/__init__.py — wires async_setup_services on
entry setup and async_unload_services on the last entry unload.

custom_components/omni_pca/README.md — full entity table, service list,
example automation, troubleshooting section, link to JOURNEY.md.

Top-level README — entity rundown updated to reflect the full v1.0
surface (was: 'binary_sensor for zones').

331 tests still pass; ruff clean across src/ tests/ custom_components/.
hacs.json already in place from initial scaffold.
2026-05-10 15:01:47 -06:00
57b8aa4b04 HA Phase B: alarm + light + switch + climate + sensor + button + event
custom_components/omni_pca/ — six new platform modules wrapping the
v1.0 client surface. Every command method catches CommandFailedError
and re-raises HomeAssistantError so panel rejections (bad code, etc.)
become user-friendly HA errors instead of silent failures.

alarm_control_panel.py — OmniAreaAlarmPanel per discovered area.
  Supports ARM_HOME (Day) / ARM_NIGHT / ARM_AWAY / ARM_VACATION /
  ARM_CUSTOM_BYPASS (Day-Instant). State derives from area_status via
  pure helpers.security_mode_to_alarm_state which handles arming-in-
  progress, entry/exit timers, and active-alarm overrides.

light.py — OmniUnitLight per discovered unit (every unit; non-dimmable
  units silently ignore brightness, no harm done). Brightness conversion
  via helpers.omni_state_to_ha_brightness / ha_brightness_to_omni_percent
  (Omni state byte: 0=off, 1=on, 100..200=brightness percent).

switch.py — OmniZoneBypassSwitch per binary zone. CONFIG entity_category;
  pairs with the existing diagnostic 'zone bypassed' binary_sensor.

climate.py — OmniThermostatClimate per discovered thermostat.
  Supports OFF / HEAT / COOL / HEAT_COOL hvac_modes; auto / on / diffuse
  fan_modes; none / hold / vacation preset_modes. Single-setpoint and
  range setpoint via TARGET_TEMPERATURE_RANGE. Fahrenheit native (Omni
  panels are F-native; HA handles unit conversion downstream).

sensor.py — analog zones (temperature/humidity/power) + per-thermostat
  diagnostic temp/humidity/outdoor sensors + OmniSystemModelSensor
  + OmniLastEventSensor (event_class + parsed event fields as attrs).

button.py — OmniPanelButton per discovered button macro. Programs not
  yet exposed because the library lacks RequestProperties for Programs.

event.py — single OmniPanelEvent per panel relaying typed SystemEvents
  via _trigger_event. event_types: zone_state_changed, unit_state_changed,
  arming_changed, alarm_activated/cleared, ac_lost/restored,
  battery_low/restored, user_macro_button, phone_line_dead/restored.
  Automations key off platform: event + event_type filter.

helpers.py — extended with security_mode_to_alarm_state,
  ARM_SERVICE_TO_SECURITY_MODE, omni_state_to_ha_brightness +
  ha_brightness_to_omni_percent, omni/ha_{hvac,fan,hold} round-trips,
  fahrenheit_to_omni_raw / celsius_to_omni_raw, analog_zone_device_class,
  EVENT_TYPES tuple, event_type_for(class_name).

__init__.py — PLATFORMS extended to all 8 entity types.

scene.py intentionally NOT created — Omni 'scenes' are user-defined
button macros, already covered by the button platform. Documented in
README; revisit if/when the library gains scene-discovery opcodes.

tests/test_ha_helpers.py: +67 unit tests covering every new helper.
331 tests pass (was 264). Ruff clean across src/ tests/ custom_components/.
2026-05-10 14:59:18 -06:00
e8ed7d1b89 HA Phase A: rebuild coordinator + binary_sensor on v1.0 client + JOURNEY.md
custom_components/omni_pca/coordinator.py — full rewrite:
- Long-lived OmniClient for entry lifetime
- One-shot discovery: system info + zone/unit/area/thermostat/button names
  via list_*_names + per-index get_object_properties
- Periodic poll (30s default): get_extended_status for zones/units/thermostats,
  get_object_status for areas, skip empty discoveries
- Background _run_event_listener task consuming client.events(), patches
  state in-place and async_set_updated_data on push:
    ZoneStateChanged    -> patch zone_status raw byte
    UnitStateChanged    -> patch unit_status state, preserve brightness
    ArmingChanged       -> patch area_status mode + last_user
    AlarmActivated/Cleared -> trigger refresh
    AcLost/Restored, BatteryLow/Restored -> recorded for sensors
- InvalidEncryptionKeyError/HandshakeError -> ConfigEntryAuthFailed (HA reauth)
- OmniConnectionError/RequestTimeoutError -> UpdateFailed + drop client
- Event task cancelled in async_shutdown

custom_components/omni_pca/binary_sensor.py — full rewrite:
- OmniZoneBinarySensor per discovered zone (device class from zone type:
  smoke/water/freeze use latched-alarm bit; doors/motion use current condition)
- OmniZoneBypassedBinarySensor per zone (DIAGNOSTIC, PROBLEM)
- OmniSystemAcBinarySensor (POWER, prefers AcLost/AcRestored push)
- OmniSystemBatteryBinarySensor (BATTERY)
- OmniSystemTroubleBinarySensor (PROBLEM)

custom_components/omni_pca/helpers.py — pure functions extracted for testing:
- device_class_for_zone_type, is_binary_zone_type, use_latched_alarm_for_zone,
  prettify_name. 61 unit tests in tests/test_ha_helpers.py.

docs/JOURNEY.md — 4383-word raw chronological retrospective of the whole
arc from binary archive to working library. 18 dated sections including
the 2191-byte magic-number header validation moment, the two non-public
protocol quirks, the offline-panel comedy. Source material for future
writeups (intentionally raw, not polished).

264 tests pass (was 203, +61 helper tests). Ruff clean across all dirs.
2026-05-10 14:48:50 -06:00
c26db62959 Library v1.0 phase C: stateful mock + e2e for the new surface
src/omni_pca/client.py — wire OmniClient.events() that returns an async
iterator over typed SystemEvent objects (built on events.EventStream).

src/omni_pca/mock_panel.py — substantial expansion:
- Per-object state dataclasses (MockUnitState, MockAreaState, MockZoneState,
  MockThermostatState) plus user_codes table for security validation
- Backward-compat: existing callers passing {idx: 'NAME'} strings still work
  via __post_init__ string-promotion to the matching Mock*State instance
- New opcode handlers:
    Command (20)                  -> Ack with state mutation, dispatches
                                     UNIT_ON/OFF/LEVEL, BYPASS/RESTORE_ZONE,
                                     SET_THERMOSTAT_HEAT/COOL/SYS/FAN/HOLD
    ExecuteSecurityCommand (74)   -> Ack on valid code (mode applied);
                                     Nak on invalid code
    RequestStatus (34)            -> Status (35) for Zone/Unit/Area/Thermostat
                                     hard-coded record sizes per
                                     clsOL2MsgStatus.cs:13-27
    RequestExtendedStatus (58)    -> ExtendedStatus (59) with object_length
                                     prefix, richer fields per object type
    AcknowledgeAlerts (60)        -> Ack
- Synthesized SystemEvents (55) push on state change with seq=0; events round-
  trip cleanly through events.parse_events() (validated by tests, not just
  asserted in code)

tests/test_e2e_client_mock.py — +9 e2e tests covering arm/disarm with code
validation, unit on/off/level, zone bypass/restore, thermostat setpoint,
push events for arming and unit changes, acknowledge_alerts.

203 passed (was 194), 2 skipped (HA harness + .pca fixture). Ruff clean.

Library v1.0 surface complete: read-only, command, status, extended status,
events. Next: rebuild the HA custom_component on top of this.
2026-05-10 14:28:35 -06:00
68cf44a585 Library v1.0 phase B: command opcodes + typed system events
src/omni_pca/commands.py — Command IntEnum (64 values, sourced from
enuUnitCommand.cs which is the canonical 'enuCommand' despite the misleading
name) + SecurityCommandResponse + CommandFailedError exception. Notable
discovery: enuUnitCommand.UserSetting (104) is actually EXECUTE_PROGRAM;
renamed for clarity, C# alias documented inline.

src/omni_pca/client.py — 18 new methods on OmniClient:
  Core: execute_command, execute_security_command, acknowledge_alerts,
        get_object_status, get_extended_status
  Wrappers: turn_unit_on/off, set_unit_level, bypass_zone, restore_zone,
        set_thermostat_{system,fan,hold}_mode,
        set_thermostat_{heat,cool}_setpoint_raw,
        execute_button, execute_program, show_message, clear_message
  All command methods raise CommandFailedError on Nak.

src/omni_pca/events.py — typed SystemEvents (opcode 55) decoder.
- EventType IntEnum (28 dispatch tags)
- 26 SystemEvent subclasses + UnknownEvent catch-all
  Includes: ZoneStateChanged, UnitStateChanged, ArmingChanged,
  AlarmActivated/Cleared, AcLost/Restored, BatteryLow/Restored,
  PhoneLine{Off,On,Dead,Restored}, UserMacroButton, ProLinkMessage,
  CentraLiteSwitch, X10CodeReceived, AllOnOff, DcmTrouble/Ok,
  EnergyCostChanged, CameraTrigger, AccessReaderEvent, UpbLinkEvent
- SystemEvents packets carry MULTIPLE events; public API is
  parse_events(message) -> list[SystemEvent], plus SystemEvent.parse()
- EventStream helper that flattens batches across messages
- Wiring of OmniClient.events() left for next pass

55 new tests across both files. 194 pass, 2 pre-existing skips. Ruff clean.
2026-05-10 14:17:12 -06:00
08974e2ec4 Models: 16 status/properties dataclasses + enums + temp converters
src/omni_pca/models.py — adds typed parsers for the full Omni object
surface beyond the initial Zone/Unit/Area properties:
- ZoneStatus, UnitStatus, AreaStatus (live state)
- ThermostatProperties, ThermostatStatus (with omni_temp_to_celsius/_fahrenheit
  via clsText.DecodeTempRaw — the scale is LINEAR, not non-linear as I'd
  hypothesized: C = raw/2 - 40)
- ButtonProperties, ProgramProperties, CodeProperties, MessageProperties
- AuxSensorStatus
- AudioZoneProperties + AudioZoneStatus
- AudioSourceProperties + AudioSourceStatus
- UserSettingProperties + UserSettingStatus

Module helpers:
- IntEnums: ObjectType, SecurityMode, HvacMode, FanMode, HoldMode,
  ThermostatKind, ZoneType, UserSettingKind
- OBJECT_TYPE_TO_PROPERTIES / OBJECT_TYPE_TO_STATUS dispatch tables
- omni_temp_to_celsius/_fahrenheit linear conversion (citation: clsText.cs)

42 new tests in tests/test_models_extended.py; 139 total pass.
2026-05-10 14:02:16 -06:00
2e439364bd HA custom_component scaffold (binary_sensor for zones)
custom_components/omni_pca/ — drop-in HA integration:
- manifest.json (HA 2026.x, iot_class=local_push, requires omni-pca lib)
- config_flow.py — host/port/controller_key with auth + reauth steps,
  parse_controller_key() extracted as pure testable function
- coordinator.py — OmniDataUpdateCoordinator with long-lived OmniClient,
  unsolicited push wiring, ConfigEntryAuthFailed on bad key, reconnect on err
- binary_sensor.py — one entity per named zone, zone_type -> device_class map
  (OPENING/MOTION/SMOKE/etc), is_on derived from ZoneProperties.status
- const.py, strings.json, translations/en.json, README.md
- hacs.json at root for HACS distribution

tests: 97 pass + 2 skip (HA harness not installed; importorskip in
test_ha_imports.py). 12 cases for parse_controller_key validation.
Ruff clean across src/ tests/ custom_components/. Status of HA component
itself NOT validated against a running HA — needs that next.
2026-05-10 13:09:27 -06:00
1901d6ec87 Async client + mock panel + e2e roundtrip
src/omni_pca/connection.py — low-level OmniConnection
- 4-step secure-session handshake (NewSession, SecureSession)
- Per-direction monotonic seq with 0xFFFF -> 1 wraparound (skips 0)
- TCP framing: read first 16-byte block, decrypt, learn length, read rest
- Reader task dispatches solicited replies to Future, unsolicited to queue
- Custom exceptions: HandshakeError, InvalidEncryptionKeyError, ProtocolError,
  RequestTimeoutError

src/omni_pca/models.py — typed response objects
- SystemInformation (with model_name lookup), SystemStatus, ZoneProperties,
  UnitProperties, AreaProperties — all frozen+slots dataclasses with
  .parse(payload) classmethods

src/omni_pca/client.py — high-level OmniClient
- get_system_information / get_system_status / get_object_properties
- list_{zone,unit,area}_names walks via RequestProperties rel=1
- subscribe(callback) for unsolicited messages

src/omni_pca/mock_panel.py — async TCP server emulating an Omni Pro II
- Full handshake (controller side), seedable MockState
- Implements RequestSystemInformation, RequestSystemStatus,
  RequestProperties (Zone/Unit/Area, both absolute and rel=1 iteration
  with EOD termination); Nak for everything else
- 'omni-pca mock-panel' CLI subcommand

tests/ — 85 passed, 1 skip (live fixture)
- 23 unit tests for connection/models/client (canned-server fixtures)
- 7 unit tests for mock panel (raw protocol drive)
- 6 e2e tests: real OmniClient over real TCP to real MockPanel,
  proves handshake + AES + whitening + sequencing all agree
2026-05-10 13:02:49 -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