21 Commits

Author SHA1 Message Date
14d16a5a4c program editor: real edit-existing seed + EVENT/YEARLY editors
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Three pieces close out the editor's main gaps:

F1 — backend includes raw fields in programs/get response:

  _program_to_fields() serialises a Program record into the same
  field dict the editor form consumes. Round-trips through
  programs/write are now lossless (fetch → edit → write produces
  byte-identical wire output if no fields changed). The old TODO
  in _fetchProgramFields was about exactly this — the frontend
  was seeding from sensible defaults rather than real values
  because the wire didn't carry raw fields. Now it does.

  Verified by a new round-trip test: read slot 42, write the same
  fields back, assert the encoded wire bytes are identical.

F2 — EVENT program editor:

  EVENT records pack a 16-bit event_id into (month<<8 | day).
  Editing requires decoding that ID into one of four categories:

    * "button"  — USER_MACRO_BUTTON, low byte = button index
    * "zone"    — ZONE_STATE_CHANGE, packed zone + state-change kind
    * "unit"    — UNIT_STATE_CHANGE, packed unit + on/off
    * "fixed"   — hand-rolled IDs (phone events, AC power) from
                  EVENT_AC_POWER_OFF / EVENT_PHONE_RINGING / etc.

  TS helpers decodeEventId / encodeEventId / packEventIdIntoFields
  mirror the Python helpers in program_engine.py.

  UI: category dropdown switches the sub-fields (button picker,
  zone+state pair, unit+on/off, fixed-event picker). Each change
  re-encodes back to month/day. Existing programs with unrecognised
  IDs fall into a "raw" category that shows the literal hex —
  user can switch category to redefine.

F3 — YEARLY program editor:

  YEARLY records have month + day + hour + minute, no days-bitmask.
  The editor now switches on prog_type to pick the right trigger
  section: month dropdown (named months), day number input,
  hour/minute number inputs.

Editor render path refactored: _renderTriggerSection(draft)
dispatches to _renderTimedTrigger / _renderEventTrigger /
_renderYearlyTrigger by prog_type. _renderActionSection is
shared across all three (command picker + object picker + level%).
Action editing works identically regardless of trigger.

Edit button visibility extended from "TIMED only" to any
program_type in EDITABLE_PROG_TYPES (TIMED / EVENT / YEARLY).
REMARK and clausal chains remain read-only.

Full suite: 648 passed, 1 skipped (up from 647, F1 round-trip test).
Frontend bundle: 56 KB minified (up from 47 KB with EVENT + YEARLY
forms and event-id helpers).
2026-05-16 12:20:21 -06:00
e6308c5624 program editor — Cut 2: TIMED program edit UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Three new pieces compose into an inline edit mode for the side panel:

E1 — omni_pca/programs/write websocket command:
  Accepts a Program dict (mirrors the dataclass field by field) plus
  a slot. Validates with a voluptuous schema (range checks on each
  byte field, prog_type 0..10), constructs the typed Program, calls
  client.download_program over the wire. Updates coordinator.data
  .programs on success so the next list call reflects the edit
  before the next poll catches up. Returns {slot, written: true} on
  success; structured errors on validation / not_supported / write_failed.

E2 — omni_pca/objects/list:
  Returns sorted {index, name} entries for zones / units / areas /
  thermostats / buttons sourced from the coordinator's discovered
  topology. Frontend caches the response client-side; the topology
  doesn't change unless the user reloads the integration.

E3 — Frontend TIMED editor:
  Detail panel grows an "Edit" button for TIMED+compact programs
  (other types stay read-only with no button). Click reveals an
  inline form with:
    * Time row — hour / minute number inputs
    * Days row — 7 toggle buttons (Mon..Sun) matching the bitmask
    * Action row — Command dropdown (friendly verbs from the
      COMMAND_OPTIONS table), object picker that auto-filters to
      the right kind for the selected command (zone / unit / area /
      button / none), and a Level % input for UNIT_LEVEL specifically
    * Read-only inline-conditions notice for programs that carry
      cond / cond2 (editing condition fields is a future cut)
  Save sends the draft via programs/write; Cancel discards.
  The poll timer pauses while editing so the form values don't
  flicker mid-edit.

Scope honesty: this pass edits TIMED programs only. Other types
(EVENT / YEARLY / WHEN / AT / EVERY / REMARK) remain read-only
with Fire / Clone / Clear available. Inline AND-IF condition editing
is deferred — the conditions render as a banner. Creating new programs
uses Clone (already shipped) → edit the clone.

The _fetchProgramFields function currently seeds from defaults (6:00
weekdays, UNIT_ON to first unit) rather than pulling raw fields from
the panel because the get-detail websocket response carries rendered
tokens but not raw bytes. That's a TODO marked inline; for the
clone-then-edit workflow the defaults are fine, but editing existing
programs in place will need a tiny backend addition.

4 new HA-integration tests covering write happy path, overwrite,
invalid payload validation, and objects/list returns named buckets.

Full suite: 647 passed, 1 skipped (up from 643, 4 new tests).
Frontend bundle: 47 KB minified (up from 38 KB with editor + form code).
2026-05-16 01:33:55 -06:00
73f05188dd release: 2026.5.16
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Bundles the program viewer side panel (Lit/TS), program writeback API
(DownloadProgram + Clear/Clone), and the manifest.json documentation
URL fix (now points at hai-omni-pro-ii.warehack.ing instead of the
repo, matching pyproject.toml).
2026-05-16 01:29:25 -06:00
9cdb312baf program writeback: DownloadProgram wire + HA write API + Clear/Clone UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
The program viewer goes from read-only to write-capable. Three layers
land together because a partial implementation isn't actionable.

D1 — wire path:

* OmniClient.download_program(slot, program) — sends opcode 8
  (clsOLMsg2DownloadProgram, clsHAC.cs:1133-1140) with the 2-byte BE
  slot + Program.encode_wire_bytes(). Validates slot range 1..1500
  client-side. Maps Ack → success, Nak → CommandFailedError, any
  other opcode → OmniConnectionError.
* OmniClient.clear_program(slot) — convenience that writes an all-zero
  body. Mock treats this as deletion (removes the slot from
  state.programs) so subsequent reads see it as undefined.
* MockPanel handles DownloadProgram on the v2 dispatch path —
  receive 2-byte slot + 14-byte body, store in state.programs, ack.
* OmniClientV1.download_program raises NotImplementedError. v1 only
  has the bulk DownloadPrograms flow which clears everything before
  rewriting — destructive for HA's edit-one-program use case.
  Documented in the docstring so callers know to route v1 users to
  a v2 connection.

Tests cover: write-then-read round-trip, overwrite of existing slot,
clear deletes the slot, range validation, v1 not-implemented.

D2 — HA websocket commands:

* omni_pca/programs/clear — writes zero body, updates coordinator.
  data.programs immediately so the next list call shows the deletion.
  Returns ``{slot, cleared: true}``. Maps NotImplementedError on v1
  panels to the ``not_supported`` error code.
* omni_pca/programs/clone — copies source_slot → target_slot, with
  the slot field re-stamped. Refuses identical source/target,
  refuses missing source. Same coordinator update pattern.

5 new HA-integration tests covering clear, clone happy path, clone
to same slot, clone from missing source.

D3 — Clear/Clone UI in the side panel:

* "Clone…" button reveals an inline target-slot input (number,
  1..1500). Enter or "Clone" button calls the WS command, then
  navigates the detail panel to the new clone so the user sees the
  result.
* "Clear" button shows an inline confirmation row ("Clear slot N?
  This deletes the program from the panel.") with Yes/Cancel. Yes
  closes the detail panel and refreshes the list — the slot is gone.
* Both surface feedback via the same _writeFeedback state used by
  Fire now (auto-clears after 4 seconds).
* Three new button styles (.primary, .secondary, .danger) and the
  .action-row composite used for both inline prompts.

What's NOT shipped here: a real visual editor for trigger/condition/
action fields. That's a follow-up (~600 lines of new TS + careful
validation work). The current "Cut 1" UX is enough for the common
"I accidentally created a program, clear it" and "I want a variant
of this program, give me a copy in an empty slot" workflows.

Full suite: 643 passed, 1 skipped (up from 634).
Frontend bundle: 38 KB minified (up from 34 KB with the write UI).
2026-05-16 01:14:54 -06:00
f38777e219 HA: side panel frontend (Lit/TypeScript) for the program viewer
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Phase C of the program viewer. Replaces the "panel coming soon" stub
with a real Lit-based side panel that consumes the Phase-B websocket
commands.

Layout (top to bottom):

* Header — title + total/filtered count
* Filter bar — search box (substring match), trigger-type chips
  (TIMED / EVENT / YEARLY / WHEN / AT / EVERY / REMARK), a clearable
  "filtering on <ref>" pill when an entity filter is active
* Two-column body: program list on the left, slide-in detail panel
  on the right. Collapses to single-column on `narrow` view.

The program list renders one row per program (or per chain head, for
clausal multi-record programs). Each row carries the slot number,
the rendered one-line summary token stream, and meta pills for
trigger type / condition count / multi-action count.

The detail panel renders the full structured-English token stream
inside a styled <pre>. A "Fire now" button calls
``omni_pca/programs/fire`` over the wire — the panel actually
runs the program. For chain detail the spanned slot range is shown
underneath.

REF tokens are rendered as `<button>` elements that click to filter
the list to "programs that mention this entity" — the most useful
navigational affordance for the "why is this happening?" use case.

Live-state badges (SECURE / NOT READY / ON 60% / Away / 72°F / …) are
appended to REF tokens via the Phase-B coordinator-backed
StateResolver. The panel polls ``programs/list`` every 5 seconds to
refresh badges; switching to push-event subscriptions is a follow-up
when polling overhead becomes visible.

Theming uses HA's standard CSS variables (--primary-color,
--card-background-color, --divider-color, etc.) so the panel inherits
the user's HA theme automatically.

Build pipeline:

* TypeScript source under ``custom_components/omni_pca/frontend/src/``
* esbuild bundles entry → ESM in one self-contained file
* Output at ``custom_components/omni_pca/www/panel.js`` (~34 KB
  minified) is committed so end-users don't need Node installed
* ``npm run watch`` for HA-dev-time iteration
* tsconfig has strict mode + noUnusedLocals; bundle currently
  type-checks clean

Manifest declares deps on ``http`` and ``websocket_api``; ``frontend``
and ``panel_custom`` are loaded opportunistically (they require
``hass_frontend`` which the test harness doesn't ship — keeping them
out of the manifest deps keeps tests green).

Full suite: 634 passed, 1 skipped (no test changes; the integration
side hasn't moved since Phase B).
2026-05-15 21:21:41 -06:00
821a402d32 release: 2026.5.14
Some checks failed
Validate / HACS validation (push) Has been cancelled
Validate / Hassfest (push) Has been cancelled
Re-tags from a tree that includes the brand assets — v2026.5.11 was
tagged before brand/icon.png landed, so the HACS submission-side
validator saw the tag as brand-less.

Library + integration version bumped to 2026.5.14; manifest requirement
pinned to the matching PyPI build. CHANGELOG entry covers everything
since 2026.5.10: SetupData decoding sweep, AND/OR evaluator, EVENT
taxonomy, WHEN/AT/EVERY clausal chains, brand inline, HACS+hassfest
workflow, GitHub URL switch, websocket side-panel, program_renderer.
2026-05-14 03:12:06 -06:00
ce87ebcb13 HA: websocket commands + side-panel registration
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Phase B of the program viewer. Three websocket commands and a stub
side-panel registration wire the HA integration to consume the
program_renderer library.

Websocket commands (all namespaced ``omni_pca/programs/``):

* ``list``  — paginated, filterable summaries. Filters: trigger_types
  (TIMED / EVENT / YEARLY / WHEN / AT / EVERY), references_entity
  (e.g. ``"unit:7"``), case-insensitive substring search. Each row
  carries summary tokens + a flat ``references`` list for filter UI.
* ``get``   — full structured-English detail for a slot. Clausal
  chains return as one logical unit even when the user clicked an
  interior slot.
* ``fire``  — sends ``Command.EXECUTE_PROGRAM`` over the wire so the
  panel runs the program now. Returns ``{slot, fired: true}`` on
  success or a structured error.

Token serialisation uses short keys (k/t/ek/ei/s) for compact wire
format — the panel's 1500-slot table on a busy install fits in a few
hundred KB of JSON.

Coordinator-backed resolvers:

* ``_CoordinatorNameResolver`` — pulls names from data.zones / units /
  areas / thermostats / buttons (HA-side ZoneProperties etc.)
* ``_CoordinatorStateResolver`` — pulls live state from *_status maps
  so every websocket call sees the freshest available overlay without
  round-tripping the panel. SECURE / NOT READY / BYPASSED for zones,
  OFF / ON / ON 60% for units, Day / Night / Away for areas,
  °F for thermostats.

Side-panel registration: ``async_register_side_panel`` registers a
custom panel under ``Omni Programs`` in HA's sidebar with a
``mdi:script-text-outline`` icon. Bundle is served at
``/api/omni_pca/panel.js`` via a static-path registration. A
working stub panel.js ships now so the wiring is exercisable;
Phase C will drop the real Lit/TS bundle into the same path.

Panel registration is wrapped in a try/except + a once-per-HA-boot
guard so test environments without ``hass_frontend`` installed don't
break the rest of the integration. The manifest only lists ``http``
and ``websocket_api`` as hard dependencies for the same reason —
panel_custom is opportunistic.

10 new HA-integration tests cover list/get/fire end-to-end plus
filters, pagination, search, live-state overlay, and structured-error
returns for bad entry_id / missing slot.

Full suite: 634 passed, 1 skipped (up from 624).
2026-05-14 03:07:00 -06:00
172aa2974a brand: ship icon.png + icon@2x.png inline (HA 2026.3 brands-proxy API)
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Custom components since HA 2026.3.0 can serve brand assets directly from
custom_components/<domain>/brand/, bypassing the home-assistant/brands
repository entirely. HACS validator reads this path before falling back
to the brands repo.

Icons rendered from hai-omni-docs's favicon.svg — abstract control panel
with LED indicators and screen lines. No HAI/Leviton trademark artwork
since this is an unofficial integration.

dev/brand/ keeps the source PNGs (non-interlaced) for re-rendering.
2026-05-14 02:54:38 -06:00
56d288db37 hassfest: fix manifest key order, drop markdown from i18n, add CONFIG_SCHEMA
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
- manifest.json: keys reordered to domain, name, then alphabetical
- strings.json + translations/en.json: rephrase user-step description
  without backticks/angle-brackets (hassfest rejects HTML in i18n strings)
- __init__.py: add CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
  since async_setup exists but the integration is config-entry-only
2026-05-14 02:49:49 -06:00
16655da34c hacs: switch canonical URLs to github.com/rsp2k/omni-pca + add validation CI
- manifest.json documentation/issue_tracker → GitHub (where HACS users land)
- README install instructions → `pip install omni-pca` (now on PyPI)
- pyproject.toml URLs → Repository / Issues / Changelog / Documentation
- custom_components README → HACS default-catalog install flow
- .github/workflows/validate.yml: hacs/action + hassfest on push/PR/weekly

Library remains importable from PyPI; integration tracks the same release tag.
2026-05-14 02:35:21 -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
259c46e558 Release 2026.5.11: v1-over-UDP + HA integration
First PyPI release of the v1 wire path. Wheel published from local
source 2026-05-11 with omni_pca/v1/ subpackage included.

What's in 2026.5.11 vs 2026.5.10 (already on PyPI):
* New omni_pca.v1 subpackage -- OmniConnectionV1, OmniClientV1,
  OmniClientV1Adapter -- for panels that listen on UDP only and
  speak the legacy OmniLink (not OmniLink2) wire dialect.
* HA integration wires the adapter into the coordinator when
  Transport=UDP is selected at config-flow time; v2/TCP path is
  unchanged.
* Streaming UploadNames discovery (bare opcode + lock-step
  Acknowledge until EOD/NAK).
* Long-form RequestUnitStatus for unit indices > 255 (sprinklers,
  named flags, expansion-enclosure outputs).
* Chunked status polls -- firmware 2.12 NAKs at ~63 records per
  request, so we batch in groups of 40.
* OmniConnection.close() now sends ClientSessionTerminated so the
  panel frees our session slot immediately on disconnect.

Verified end-to-end against a firmware 2.12 OmniPro II panel at
192.168.1.9: discovery (16 zones, 44 units, 16 buttons, 8 codes,
2 thermostats, 8 messages) + status polling + execute_command
round-trip all working under HA, side-by-side with the existing
TCP mock-panel path in the dev stack.

README: new "Two wire dialects" section explaining when to pick
TCP/OmniClient vs UDP/OmniClientV1.
manifest.json: requirements bump to omni-pca==2026.5.11.
2026-05-11 13:40:34 -06:00
30b482a8cb HA integration: wire v1+UDP into the coordinator + config flow
OmniClientV1Adapter (src/omni_pca/v1/adapter.py)
  V2-shape facade over OmniClientV1. Exposes the OmniClient surface the
  HA coordinator was written against — get_system_information,
  list_*_names, get_object_properties (synthesized from streamed names),
  get_extended_status (chunked, routed to v1 typed status opcodes),
  get_object_status(AREA, ...) (derived from SystemStatus.area_alarms),
  events() (EventStream on v1 SystemEvents opcode 35), plus all the
  write-method shims.

  Chunks unit/zone/thermostat/aux polls per-type because firmware 2.12
  NAKs Request*Status with >~62 records in one shot (verified live).
  Falls back to "Area 1".."Area 8" when the UploadNames stream returns
  zero areas — common on panels where the installer didn't name them.

custom_components/omni_pca/coordinator.py
  _ensure_connected picks OmniClientV1Adapter for transport=udp. New
  _walk_properties_v1 replaces the v2 RequestProperties walk with a
  name-stream + synthesized-Properties pass.

custom_components/omni_pca/config_flow.py
  _probe routes to OmniClientV1Adapter for transport=udp instead of
  trying to drive v2 OmniClient over UDP (which silently dropped after
  handshake, per the earlier diagnosis).

src/omni_pca/events.py
  parse_events / _ensure_system_events / EventStream now take an
  expected_opcode arg (default v2 SystemEvents=55, v1 callers pass 35).
  Word format is byte-identical between v1 and v2, so the typed-event
  decoder is unchanged.

src/omni_pca/v1/client.py
  _range_status supports the long-form RequestUnitStatus (BE u16
  start/end) so panels with unit indices > 255 (sprinklers, flags) work.

Verified end-to-end against firmware 2.12 panel at 192.168.1.9:
  config entries:
    state=loaded  Omni Pro II (host.docker.internal)  (mock)
    state=loaded  Omni Pro II (192.168.1.9)           (real, v1+UDP)
  real-panel entities created in HA: 96 (30 binary_sensor, 26 light,
  15 switch, 13 button, 9 sensor, 3 climate)
  cross-check: light.omni_pro_ii_front_porch_2 = on  (matches live
  probe: unit #2 'FRONT PORCH' state=0x01 brightness=100)

dev/probe_v1_coordinator.py
  Coordinator-shaped end-to-end smoke test against the real panel
  without HA — drives the full discovery + poll cycle through the
  adapter. Useful for regression-checking the v1 wire path.

dev/add_real_panel.py
  Programmatically adds the real-panel config entry to the dev HA
  stack via the REST config-flow endpoints. Idempotent.
2026-05-11 01:30:49 -06:00
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
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.
2026-05-10 17:47:04 -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
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
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