Real-world programs reference object indexes well past the coordinator's
discovery range — typical example from a live OmniPro II: a program
that's "Turn ON Unit 33025" where the unit number is a raw byte value
from undecoded extended-output addressing. The discovered units bucket
only covers slots up to ~511, so 33025 doesn't match any entry.
Before this commit the dropdown silently fell through to the first
known unit (e.g. ROOM ONE), making it look like the user had selected
that unit. The underlying draft.pr2 stayed at 33025, but a user who
glanced at the form and clicked Save would either preserve the original
(if they didn't touch the select) or accidentally clobber it with the
first list item (if they did).
Fix: _bucketWithPreserve prepends a synthesized option
"(undiscovered <kind> <idx> — preserve original)" when the current
value isn't represented. Applies in all four picker sites:
* Action object picker (Unit / Zone / Area / Button for action commands)
* EVENT trigger Button picker
* EVENT trigger Zone picker
* EVENT trigger Unit picker
The synthesized entry sits at the top of the list (visually distinct)
and is the selected default. Picking any other entry from the dropdown
then becomes an explicit choice — no more silent coercion.
Smoke-tested against the real panel: slot #1 (WHEN OPEN BIG GAR →
Turn ON Unit 33025) now shows "#33025 (undiscovered unit 33025 —
preserve original)" as the selected Unit option. Screenshots updated.
Two bugs surfaced when smoke-testing against a real OmniPro II:
1. Empty list after page load. _discoverViaList ran fire-and-forget;
connectedCallback then synchronously checked _entryId (still null
because await hadn't resolved) and skipped _loadList. The panel
rendered "No programs match the current filters" forever — until
the next 5-second poll tick, which never fires because
_startRefreshTimer was also gated on the same null check.
Fix: have _discoverViaList itself trigger _loadList and
_startRefreshTimer after _entryId lands. The connectedCallback /
updated paths can stay gated on _entryId; the discover path now
takes ownership of "do the initial load too."
2. Dev installs with both a working entry and a setup_retry entry
(mock container down, real panel up) had the panel pick the
setup_retry one first and surface "panel not configured" on every
call. Fix: prefer entries with state === "loaded" in the discover
step, falling back to first entry only when none are loaded.
Also: screenshot.py drops the seed-via-WS step (was unsafe — would
write Programs to whatever entry is loaded, including real panels).
Updates the in-page click helpers to walk the shadow DOM recursively
instead of hardcoding HA's host-element path, so detail/editor
screenshots work on the actual depth-8 element location.
Smoke test against real panel: 154 programs render correctly with
structured English, BEDTIME / OPEN BIG GAR / Zone 133 events all
decoded, B. GAR MAN DOOR [SECURE] live-state badge visible.
Detail panel + editor mode both function end-to-end.
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.
After HA finishes its first-run wizard the /api/onboarding endpoint
returns 404 plain-text instead of a JSON step list. The previous
screenshot run blew up trying to json-parse "404: Not Found".
Both call sites (_onboard and _complete_onboarding) now check the
status code first and treat anything non-200 as "already complete --
skip and go to the login path".
Adds the homeassistant service to the external caddy network with
labels for juliet.warehack.ing so caddy-docker-proxy issues a public
cert and proxies traffic to port 8123. Uses the same streaming-
friendly transport tuning the docs-site service uses, because HA's
frontend keeps long-lived WebSockets open for lovelace state pushes
and config flows -- without stream_timeout: 24h etc., caddy closes
the socket every ~15s and the UI churns reconnects.
Keeps the 8123 host-port mapping intact for direct localhost dev
access; public traffic flows over the caddy bridge.
dev/ha-config/configuration.yaml (not tracked here -- root-owned in
the HA container) was updated separately to add:
http:
use_x_forwarded_for: true
trusted_proxies:
- 10.10.16.0/20 # caddy bridge subnet
Without that block HA rejects the OAuth redirect_uri at login because
the auth check sees the internal docker IP instead of the public host.
Replace the brittle bind-mount-over-site-packages trick with a proper
``pip install --no-deps /opt/omni-pca-src`` in the HA container's
entrypoint. This gives HA a real ``omni_pca-2026.5.10.dist-info`` so
the manifest's requirement check passes, plus the v1 subpackage that's
not in the published wheel yet (omni-pca==2026.5.10 isn't on PyPI).
Before: ``--force-recreate`` broke the dev stack because the bind mount
overlaid the package contents but left no dist-info, and HA's uv-based
installer can't fetch omni-pca from PyPI.
After: container recreate just works. ``docker compose restart
homeassistant`` re-installs from the latest local source on every
start, so HA + library are always in sync with the working tree.
Header comments updated to mention the real-panel (UDP/v1) config-flow
fields alongside the existing mock-panel ones.
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.
Some Omni network modules are configured for UDP, in which case PC Access
falls back to the v1 wire protocol (OmniLinkMessage outer = 0x10, inner
StartChar 0x5A, typed Request*Status opcodes) instead of v2's TCP path
(OmniLink2Message + StartChar 0x21 + parameterised RequestProperties).
This adds a parallel implementation rather than overloading the v2 path.
omni_pca/v1/
connection.py UDP-only OmniConnectionV1; reuses crypto + handshake,
routes post-handshake messages through OmniLinkMessage
(0x10) wrapping v1 inner format. Adds iter_streaming
for the lock-step UploadNames/Acknowledge/EOD pattern.
messages.py Block parsers for the typed v1 status replies (zone,
unit, thermostat, aux), v1 SystemStatus, and NameData
(handles both one-byte and two-byte NameNumber forms).
client.py OmniClientV1: read API (get_system_information,
get_*_status), discovery (iter_names + list_*_names),
write API (execute_command, execute_security_command,
turn_unit_*, set_unit_level, bypass/restore_zone,
execute_button, set_thermostat_*). acknowledge_alerts
is a no-op (v1 has no equivalent opcode).
Discovery uses bare UploadNames; panel streams every defined name across
all types in a fixed order with per-record Acknowledge. Verified against
firmware 2.12 — pulled 16 zones, 44 units, 16 buttons, 8 codes,
2 thermostats, 8 messages in one stream.
src/omni_pca/message.py
Fix flipped START_CHAR_V1_* constants. enuOmniLinkMessageFormat says
Addressable=0x41 and NonAddressable=0x5A; our names had them swapped.
Wire bytes were unchanged, so existing tests kept passing — but
encode_v1() with no serial_address now correctly emits 0x5A, which
is what UDP needs.
tests/
test_v1_messages.py 22 cases; payloads are real wire captures
from a firmware-2.12 panel via probe_v1_recon.
test_v1_client_commands.py 20 cases; payload-packing for the Command
and ExecuteSecurityCommand opcodes,
including BE u16 parameter2 and the
digit-by-digit security code form.
dev/
probe_v1.py Phase-1 smoke: handshake + RequestSystemInformation.
probe_v1_recon.py Raw opcode dump for protocol reconnaissance.
probe_v1_stream.py Streaming UploadNames flow exploration.
probe_v1_client.py Full read-path smoke test via OmniClientV1.
probe_v1_write.py Live no-op execute_command round-trip.
.gitignore: ignore dev/.omni_key (probe scripts read controller key from
this file as one fallback option).
Discovery on firmware 2.12: Request*ExtendedStatus opcodes (63/65/69)
NAK on this firmware — only the basic Request*Status opcodes are
implemented, so OmniClientV1 uses those (3 bytes/unit, 7 bytes/tstat,
4 bytes/aux records). HA still gets enough signal for polling; full
properties discovery uses streaming UploadNames instead.
Test totals: 387 passed, 1 skipped (existing fixture skip).
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).
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.