First reverse-engineering pass on the panel's built-in automation engine. Adds a typed Python Program dataclass that decodes/encodes the 14-byte program record used both on the wire (clsOLMsgProgramData) and on disk (the 21,000-byte Programs block in a .pca file). Coverage: * enums: ProgramType, ProgramCond, Days bitmask * Program dataclass with from_wire_bytes / from_file_record / encode_wire_bytes / encode_file_record (Mon/Day swap for EVENT-typed records applied on the file form only -- mirrors clsProgram.Read at clsProgram.cs:471, while clsProgram.ToByteArray omits the swap) * Remark variant (bytes 1-4 = BE u32 RemarkID instead of cond/cond2) * unknown ProgType / Cmd bytes pass through as raw ints with a once-per-process warning * decode_program_table for the full 1500-slot .pca block * pca_file.parse_pca_file populates PcaAccount.programs (backward- compatible: defaults to ()) * mock_panel.MockState.programs + _reply_program_data so OmniLink2 UploadProgram (opcode 9) round-trips through the test fixture Verification (422 passed, 1 skipped — was 400): * 15 unit tests in test_programs.py: golden bytes for each ProgramType, Mon/Day swap proven distinct between wire and file layouts, Remark round-trip, 500 random-input wire+file round-trips, unknown-enum tolerance * 4 fixture-gated live-data tests in test_pca_file.py: all 1500 slots decode cleanly, 330 non-empty (matches Phase 1 recon distribution 209 TIMED / 105 EVENT / 16 YEARLY), 21,000-byte byte-for-byte round-trip against the live decrypted fixture, YEARLY month/day in valid calendar ranges * 3 wire-echo tests in test_e2e_program_echo.py: client drives UploadProgram (opcode 9) through the mock, server replies with ProgramData (opcode 10) wrapping [number_hi, number_lo, body]; full Program round-trips field-by-field, empty slots return zero bodies, EVENT bytes are emitted in wire order (no swap) What this pass deliberately leaves open (documented in the docs page): * cond / cond2 internal bit split (selector vs operand) * multi-record clausal encoding (When/At/Every/And/Or/Then) * RemarkID -> RemarkText lookup table layout * DPC capability flag location for non-OPII models * TIMED time-of-day vs sunrise/sunset-relative offset flag References: * clsProgram.cs (entire) — field accessors, Read/Write, Evt u16 * enuProgramType.cs / enuProgramCond.cs / enuDays.cs * Owner's Manual SETUP chapter — user-facing programming-line model * Installation Manual SETUP MISC — installer-facing setup screen
omni-pca
Async Python client for HAI/Leviton Omni-Link II home automation panels — Omni Pro II, Omni IIe, Omni LTe, Lumina.
Includes a Home Assistant custom component (custom_components/omni_pca/).
Project home: https://git.supported.systems/warehack.ing/omni-pca Documentation: https://hai-omni-pro-ii.warehack.ing/
Status
Alpha. Built from a full reverse-engineering of HAI's PC Access 3.17 (the Windows installer/programmer app). The protocol layer captures two non-public quirks that public Omni-Link clients miss:
- Session key is not the ControllerKey. Last 5 bytes are XORed with a controller-supplied SessionID nonce.
- Per-block XOR pre-whitening before AES. First two bytes of every 16-byte block are XORed with the packet's sequence number.
The full byte-level protocol spec lives at https://hai-omni-pro-ii.warehack.ing/reference/protocol/.
Install
The library isn't on PyPI yet (pending), so install directly from the Gitea release:
# Pinned to a specific release (recommended)
pip install "omni-pca @ git+https://git.supported.systems/warehack.ing/omni-pca.git@v2026.5.10"
# Or the wheel from the release page
pip install https://git.supported.systems/warehack.ing/omni-pca/releases/download/v2026.5.10/omni_pca-2026.5.10-py3-none-any.whl
# Or with uv
uv add "omni-pca @ git+https://git.supported.systems/warehack.ing/omni-pca.git@v2026.5.10"
Once published to PyPI, the canonical install will be pip install omni-pca.
Quick start (library)
import asyncio
from omni_pca import OmniClient
async def main():
async with OmniClient(
host="192.168.1.9",
port=4369,
controller_key=bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09"),
) as panel:
info = await panel.get_system_information()
print(info.model_name, info.firmware_version)
asyncio.run(main())
For the panel walkthrough — connect, list zones, react to push events — see the tutorial.
Two wire dialects — TCP/v2 vs UDP/v1
The Omni network module is configurable at the panel keypad to listen on TCP, UDP, or both. Each transport speaks a different wire dialect — OmniClient above handles the TCP path (OmniLink2, the modern wire format used by PC Access ≥ 3); panels configured UDP-only fall back to the legacy v1 protocol with typed RequestZoneStatus / RequestUnitStatus opcodes, no RequestProperties, and streaming name downloads. For those, use OmniClientV1 from the omni_pca.v1 subpackage:
from omni_pca.v1 import OmniClientV1
async with OmniClientV1(
host="192.168.1.9",
controller_key=bytes.fromhex("..."),
) as panel:
info = await panel.get_system_information() # same dataclass as v2
names = await panel.list_all_names() # streaming UploadNames
zones = await panel.get_zone_status(1, 16) # typed status by range
await panel.execute_security_command(area=1, mode=SecurityMode.AWAY, code=1234)
The HA integration picks the right client automatically based on the Transport dropdown in the config flow (TCP vs UDP). See zone & unit numbering for why v1 panels need the long-form RequestUnitStatus for unit indices > 255.
Quick start (Home Assistant)
# Manual install — works on every HA flavour
cd /path/to/your/homeassistant/config/
mkdir -p custom_components
cd custom_components
git clone https://git.supported.systems/warehack.ing/omni-pca tmp-omni
cp -r tmp-omni/custom_components/omni_pca .
rm -rf tmp-omni
Restart HA, then add the integration via Settings → Devices & Services. You'll need:
- Panel IP / hostname
- TCP port (default 4369)
- ControllerKey as 32 hex chars
Get the ControllerKey from your .pca file using the bundled CLI:
omni-pca decode-pca '/path/to/Your.pca' --field controller_key
The integration creates one HA device per panel plus typed entities for every named object on the controller: alarm_control_panel for areas, light for units, binary_sensor + switch for zones (state + bypass), climate for thermostats, sensor for analog zones and panel telemetry, button for panel macros, and event for the typed push-notification stream. See custom_components/omni_pca/README.md for the full entity + service catalog, or the HA install how-to for the step-by-step.
Without a panel — mock controller
The library ships a stateful MockPanel that emulates the controller side of the protocol over real TCP. Useful for offline development, integration tests, and demos:
from omni_pca.mock_panel import MockPanel
async with MockPanel(controller_key=...).serve(port=14369):
# Connect a real OmniClient to localhost:14369 — full handshake + AES
...
The local dev stack (dev/docker-compose.yml) packages a real Home Assistant container and the mock panel side-by-side so you can click through the integration without touching real hardware. See the dev-stack tutorial.
Tests
uv sync --group ha
uv run pytest -q
351 tests across the protocol primitives, the mock panel, the OmniClient ↔ MockPanel end-to-end roundtrip, and an in-process Home Assistant harness driving the integration via the real config flow + service calls.
Versioning
Date-based (CalVer): YYYY.M.D. Bumped on backwards-incompatible changes. See CHANGELOG.md.
License
MIT. See LICENSE.
Acknowledgments
This client is independent and not affiliated with Leviton or HAI. Protocol details derived from clean-room analysis of the publicly-distributed PC Access installer. The reverse-engineering arc is documented at https://hai-omni-pro-ii.warehack.ing/journey/.