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