Tutorials + how-tos: nine new pages populating the empty Diataxis lanes

src/content/docs/tutorials/  (3 pages, learning-oriented)
  decrypt-your-pca.md (~600 w)
    Walks the user through installing the CLI and running decode-pca
    against a real .pca file, ending with --include-pii to confirm the
    decryption landed on real plaintext (their own customer name).
    Cites the file format reference for what's actually happening.

  dev-stack.md (~700 w)
    Boots the docker dev stack, onboards HA in 60 seconds, adds the
    integration with the documented host/port/key, then five concrete
    things to try (toggle a light, arm an area with right and wrong
    code, trigger a button, watch developer states). Includes the
    panel-device screenshot.

  first-script.md (~750 w)
    Twenty-line Python script: connect, get system info, walk zones,
    then evolves through three more steps to add an event-stream
    consumer and a command dispatch. Shows ASCII output so the user
    knows what to expect on success. Cross-links the two protocol
    quirks pages.

src/content/docs/how-to/  (6 pages, task-oriented recipes)
  find-controller-key.md (~400 w)
    Four ways: from .pca file, PC Access UI, panel keypad, generate
    a new one. Plus a smoke-test command to verify the key works.

  automate-on-alarm.md (~600 w)
    HA event automation pattern keyed off the omni_pca event entity's
    event_type / event_data attributes. Includes an alarm-type-specific
    filter table and a fire-alarm worked example.

  bypass-zone.md (~400 w)
    Three flavours: HA service call, HA per-zone switch entity, raw
    Python. Includes verification snippets and caveats around
    installer-disabled bypass and code requirements.

  send-panel-message.md (~350 w)
    show_message / clear_message services with a 'laundry done'
    automation example. Notes that Omni messages are pre-programmed in
    PC Access, not free-form.

  decode-a-packet.md (~750 w)
    Step-by-step: take a hex dump, decode the outer Packet, derive the
    session key from the ack, decrypt with per-block whitening,
    decode the inner Message, dispatch on opcode. Includes tcpdump
    capture commands at the end.

  migrate-from-jomnilinkii.md (~700 w)
    What changes when swapping from jomnilinkII / pyomnilink to
    omni-pca. Method-name translation table, async-vs-sync surface,
    pattern-matching event handler example, what's gained (quirks,
    types, mock) and what's lost (years of production hardening).

Build: 22 pages clean (was 13), sitemap regenerated, Pagefind index
covers everything. Container rebuilt + recreated; verified
/how-to/automate-on-alarm/ returns HTTP 200 with the right title.
Sidebar autogenerates from the directories so all nine pages appear
without further config.
This commit is contained in:
Ryan Malloy 2026-05-10 17:23:02 -06:00
parent 4ec43b269f
commit 4812b56622
11 changed files with 1102 additions and 0 deletions

View File

@ -0,0 +1,117 @@
---
title: Trigger an HA automation on alarm activation
description: Use the omni_pca event entity to fire automations the moment the panel reports an alarm — burglary, fire, water, panic, or any subset.
sidebar:
order: 2
---
The integration's `event` entity (one per panel) emits HA event-bus
events for every typed `SystemEvent` the panel pushes. Filter on the
event type to react to alarms specifically.
## Recipe — notify on any alarm
```yaml title="configuration.yaml or as an automation"
automation:
- alias: "Notify on Omni alarm"
trigger:
- platform: state
entity_id: event.omni_pro_ii_panel_events
attributes:
event_type: alarm_activated
action:
- service: notify.mobile_app_my_phone
data:
title: "ALARM"
message: >
Area {{ trigger.to_state.attributes.event_data.area_index }}
type {{ trigger.to_state.attributes.event_data.alarm_type }}
```
The event entity's `state` is the timestamp of the most recent event;
its `event_type` and `event_data` attributes carry the typed payload.
Trigger on a state change with `attributes.event_type == alarm_activated`.
## React only to specific alarm types
`alarm_type` is an integer matching `omni_pca.events.AlarmKind`:
| `alarm_type` | Meaning |
|--------------|---------|
| 1 | Burglary |
| 2 | Fire |
| 3 | Auxiliary / Police |
| 4 | Duress |
| 5 | Tamper |
| 6 | Trouble |
| 7 | Freeze |
| 8 | Water |
Add a condition to filter:
```yaml
automation:
- alias: "Smoke alarm only"
trigger:
- platform: state
entity_id: event.omni_pro_ii_panel_events
attributes:
event_type: alarm_activated
condition:
- condition: template
value_template: "{{ trigger.to_state.attributes.event_data.alarm_type == 2 }}"
action:
- service: light.turn_on
target:
entity_id: light.all_house_lights
data:
brightness: 255
- service: notify.mobile_app_my_phone
data:
title: "FIRE ALARM"
message: "Smoke detector tripped — area {{ trigger.to_state.attributes.event_data.area_index }}"
```
## All event types you can filter on
Subset most automations want:
| `event_type` | Fired when |
|--------------|------------|
| `alarm_activated` | Any alarm starts |
| `alarm_cleared` | Alarm acknowledged / system disarmed |
| `arming_changed` | Area arm state changes (`event_data.new_mode` is the new SecurityMode) |
| `zone_state_changed` | Zone opens / closes / is bypassed |
| `unit_state_changed` | Light or output toggles |
| `ac_lost` / `ac_restored` | Mains power loss / restore |
| `battery_low` / `battery_restored` | Backup battery thresholds |
| `phone_line_dead` / `phone_line_restored` | DSL/POTS supervision |
The full list (26 typed subclasses + `unknown` catch-all) is in
[the library API reference](/reference/library-api/#omni_pcaevents).
## Test it without an alarm
Use the dev stack — the mock panel will let you push synthetic events
through the same machinery:
```python
# in a Python script against the mock
await panel.execute_security_command(area=1, mode=SecurityMode.AWAY, code=1234)
```
That triggers `arming_changed`. For real alarm activation against the
mock, you'd need to extend `MockPanel` to push an `AlarmActivated` event
on a synthesized zone trip — left as an exercise, but the pattern is
identical to how `ArmingChanged` is pushed in `_handle_execute_security_command`.
## Caveats
- The `event` entity is a **single entity per panel**, not one per
alarm. You filter inside automations, not via separate entities.
- `alarm_cleared` doesn't always fire — older firmware (pre-3.0) only
emits the activation; you'll need to listen for `arming_changed →
disarmed` instead.
- The `event_data` dict is JSON-serialised, which means enum values
appear as their integer values, not names. The `AlarmKind` table above
maps them.

View File

@ -0,0 +1,94 @@
---
title: Bypass a zone
description: Three ways to bypass an Omni zone — HA service call, HA switch entity, or raw Python command.
sidebar:
order: 3
---
A bypassed zone is excluded from arming. You'd bypass a zone when, for
example, you want to arm Away but leave a window open.
## From an HA automation or script
```yaml
service: omni_pca.bypass_zone
data:
config_entry: <your_omni_config_entry_id>
zone_index: 7 # 1-based
user_code: "1234" # required by most panels for bypass
```
Pick the `config_entry` ID from the integration page's URL or via
**Developer Tools → Services** — the dropdown shows your Omni panel by
name. To restore:
```yaml
service: omni_pca.restore_zone
data:
config_entry: <your_omni_config_entry_id>
zone_index: 7
```
## From the HA UI (per-zone switch)
Each binary zone has a corresponding bypass switch with
`entity_category = config`. They're hidden from the default dashboard but
visible on the device page and in entity selectors:
| Entity ID example | Purpose |
|---|---|
| `binary_sensor.omni_pro_ii_front_door` | open/closed state (read) |
| `binary_sensor.omni_pro_ii_front_door_bypassed` | diagnostic — reads bypass state |
| `switch.omni_pro_ii_front_door_bypass` | **toggles bypass on/off** |
Toggle the `switch` entity to bypass; toggle it off to restore.
## From Python directly
```python
from omni_pca import OmniClient
async with OmniClient(host=..., port=4369, controller_key=...) as panel:
await panel.bypass_zone(7, code=1234) # bypass zone 7
# ...
await panel.restore_zone(7) # restore
```
Both are thin wrappers around `execute_command` with
`Command.BYPASS_ZONE` / `RESTORE_ZONE`. If the panel rejects the
command (wrong code, bypass disabled in installer setup), you get
`CommandFailedError` — same error class HA wraps to a `ServiceValidationError`.
## Verify the change
Check the bypass diagnostic binary sensor or query directly:
```yaml
# automation snippet
- service: omni_pca.bypass_zone
data: { config_entry: ..., zone_index: 7, user_code: "1234" }
- delay: { seconds: 1 }
- service: notify.mobile_app
data:
message: >
Front door bypassed:
{{ is_state('binary_sensor.omni_pro_ii_front_door_bypassed', 'on') }}
```
Or in Python:
```python
status = await panel.get_object_status_for("ZONE", 7)
assert status[0].is_bypassed is True
```
## Caveats
- Some installer configurations disable bypass per-zone (fire and panic
zones are always non-bypassable). The panel will return `Nak` and
`CommandFailedError` in that case.
- Bypassed zones return to "armed" automatically when the area
disarms — you don't need to manually restore unless you want the
diagnostic sensor to flip back to off mid-armed-state.
- The `user_code` parameter is the 4-digit PIN, *not* the user index.
The panel looks it up internally.

View File

@ -0,0 +1,152 @@
---
title: Decode a captured Omni-Link packet
description: Take a hex dump of a packet you captured (Wireshark, tcpdump, panel logs) and decode it byte-by-byte using the library primitives.
sidebar:
order: 5
---
You've captured raw bytes between a client and an Omni controller and
you want to know what they say. The library's primitives let you decode
them in a few lines.
## What you have
A hex dump from `tcpdump -X`, Wireshark, or similar — something like:
```text
0000: 00 01 02 00 ....
```
Or a longer one with the encrypted payload:
```text
0000: 00 03 20 00 c4 8a 9f 0e d2 7e b3 51 88 a4 1c 6f
0010: 44 21 9b f8 00 a3 5d 2c
```
## Step 1 — decode the outer packet
```python
from omni_pca.packet import Packet
from omni_pca.opcodes import PacketType
raw = bytes.fromhex("00 03 20 00 c4 8a 9f 0e d2 7e b3 51 88 a4 1c 6f 44 21 9b f8 00 a3 5d 2c")
pkt = Packet.decode(raw)
print(f"seq={pkt.seq} type={PacketType(pkt.type).name} reserved={pkt.reserved}")
print(f"payload: {len(pkt.data)} bytes ({pkt.data.hex(' ')})")
```
Output:
```text
seq=3 type=OmniLink2Message reserved=0
payload: 20 bytes (c4 8a 9f 0e d2 7e b3 51 88 a4 1c 6f 44 21 9b f8 00 a3 5d 2c)
```
The 4-byte header is plaintext: `[seq_hi seq_lo type reserved]`. For
`OmniLink2Message` (type 0x20) and `OmniLinkMessage` (0x10), the
payload is AES-encrypted and zero-padded to a 16-byte boundary; you
need the session key and the sequence number to decrypt it.
## Step 2 — decrypt the payload (if you have the key)
```python
from omni_pca.crypto import decrypt_message_payload
# Session key = derive_session_key(controller_key, session_id) — see below
SESSION_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb0c")
plaintext = decrypt_message_payload(
ciphertext=pkt.data,
seq=pkt.seq,
session_key=SESSION_KEY,
)
print(f"plaintext: {plaintext.hex(' ')}")
```
If the key is right and the [per-block whitening](/explanation/quirks/#quirk-2--per-block-xor-pre-whitening)
unwound correctly, you'll see a valid Omni v2 message starting with
`0x21`. If it comes back looking like noise, you have the wrong key,
the wrong sequence number, or you're missing the whitening step.
## Step 3 — decode the inner message
```python
from omni_pca.message import Message
from omni_pca.opcodes import OmniLink2MessageType
msg = Message.decode(plaintext)
opcode = OmniLink2MessageType(msg.opcode)
print(f"opcode={opcode.name} length={msg.length} crc_valid={msg.crc_is_valid}")
print(f"data: {msg.data.hex(' ')}")
```
This validates the CRC-16/MODBUS, parses the opcode byte, and gives
you the raw payload. From there, dispatch on `opcode` to one of the
typed parsers in [`omni_pca.models`](/reference/library-api/#omni_pcamodels):
```python
from omni_pca.models import SystemInformation
if opcode == OmniLink2MessageType.SystemInformation:
info = SystemInformation.parse(msg.data[1:]) # strip the opcode byte
print(info)
```
## Step 4 — unencrypted handshake packets
The `ControllerAckNewSession` (type 0x02) and `ClientRequestNewSession`
(type 0x01) packets are *not* AES-encrypted — they're plaintext. The
ack carries the 5-byte SessionID directly:
```python
ack_raw = bytes.fromhex("00 01 02 00 00 01 a3 b2 c1 d4 e5")
pkt = Packet.decode(ack_raw)
# pkt.data layout (clsOmniLinkConnection.cs:1416):
# bytes 0..1 = protocol version (0x00 0x01)
# bytes 2..6 = SessionID
proto_version = (pkt.data[0] << 8) | pkt.data[1]
session_id = pkt.data[2:7]
print(f"proto v{proto_version}, session_id={session_id.hex()}")
```
With the SessionID + your ControllerKey you can derive the session AES
key:
```python
from omni_pca.crypto import derive_session_key
CONTROLLER_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09")
session_key = derive_session_key(CONTROLLER_KEY, session_id)
```
Now you can decrypt every subsequent packet using the steps above.
## Capturing packets in the first place
```bash
# Capture all traffic to/from the panel for 5 minutes:
sudo tcpdump -i any -w omni.pcap -s 0 'host 192.168.1.9 and port 4369' &
sleep 300; sudo killall tcpdump
# Then in Wireshark / tshark:
tshark -r omni.pcap -T fields -e tcp.payload | head
```
Or for a quick interactive look:
```bash
sudo tcpdump -i any -X 'host 192.168.1.9 and port 4369'
```
The `-X` shows hex + ASCII inline.
## Where this is most useful
- Confirming the [per-block whitening quirk](/explanation/quirks/) is
applied correctly when porting the protocol to another language.
- Diagnosing why a third-party Omni client (jomnilinkII / pyomnilink /
homebrew implementation) is dropping the secure session — usually
one of the two quirks isn't implemented.
- Capturing real-world unsolicited push events to feed into the mock
panel as fixtures.

View File

@ -0,0 +1,82 @@
---
title: Find your panel's ControllerKey
description: Four ways to obtain the 32-hex-character AES-128 ControllerKey your client needs to open a secure session.
sidebar:
order: 1
---
You need 32 hex characters. There are four places to look.
## From a `.pca` config file (recommended)
If you have any `.pca` file PC Access has saved for this panel, it's in
there:
```bash
uvx omni-pca decode-pca '/path/to/Your House.pca' --field controller_key
```
The CLI tries the two known XOR keys (`KEY_PC01` and `KEY_EXPORT`),
picks the one that produces the expected `PCA03` magic header, and walks
the file's body to the `Connection.ControllerKey` field. See the
[file format reference](/reference/file-format/) for what's in the bytes.
## From PC Access itself
Open the account in PC Access, then:
**Setup → Misc → Network**
The "Encryption Key 1" field is the 32-hex ControllerKey. Note that PC
Access displays it space-separated; strip the spaces.
If you only see "Encryption Key 1" greyed out, the panel's network
module isn't enabled — see the next section.
## From the panel's keypad / OmniTouch
If you don't have PC Access but you do have physical access to the
panel, you can read it directly from a wired console:
1. **Setup → Installer Code** → enter the installer code.
2. **Misc → Network**
3. Select **Encryption Key 1**.
This is the only path that doesn't require PC Access at any point in
the chain. It's also the path you'll use to *enable* the network module
if it's currently off (set the IP, subnet, gateway, then turn the
"Network Enabled" toggle on).
## Generate a new one
If the panel doesn't yet have a key, or you want to rotate it, just
generate one and program it back via PC Access or the keypad:
```bash
python -c "import secrets; print(secrets.token_hex(16))"
```
After programming the new key, restart the panel's network module so it
takes effect, then update any client using the old key.
## Verify it works
Whichever path you used, smoke-test the key:
```bash
uvx omni-pca check-connection \
--host 192.168.1.9 --port 4369 \
--controller-key 6ba7b4e9b4656de3cd7edd4c650cdb09
```
Success looks like `Connected: Omni Pro II firmware 2.12r1`. A wrong key
fails at the secure-session step with `InvalidEncryptionKeyError`.
## Why "ControllerKey", not just "encryption key"?
The 32-hex value isn't actually used as the AES session key directly —
the protocol does a [non-trivial transformation](/explanation/quirks/#quirk-1--session-key-is-not-the-controllerkey)
involving a controller-supplied nonce. We call the persistent value
"ControllerKey" to distinguish it from the per-session derived AES key.
PC Access calls the same field "Encryption Key 1" in its UI, so they're
synonyms in practice.

View File

@ -0,0 +1,162 @@
---
title: Migrate from jomnilinkII or pyomnilink
description: What changes when you swap an existing Omni-Link client for omni-pca — same panel, different client, two protocol quirks no other client implements.
sidebar:
order: 6
---
If you've been using [`jomnilinkII`](https://github.com/digitaldan/jomnilinkII)
(Java) or [`pyomnilink`](https://github.com/excaliburpartners/omnilink2)
(Python) and want to switch to `omni-pca`, here's what changes and why.
## What stays the same
- **Your panel hardware and firmware** — no changes needed.
- **The ControllerKey**`omni-pca` uses the same persistent 32-hex
key your existing client uses. Pull it the same way (PC Access UI,
`.pca` file, keypad).
- **Network details** — same IP, same port (4369 by default).
- **The user codes / area mode semantics / zone numbering** — these
are panel-side, identical across all clients.
## What changes
### The two non-public protocol quirks
This is the headline. Per the [quirks explainer](/explanation/quirks/),
PC Access does two things that *no* other public Omni-Link client we
checked implements:
1. **Session key XOR mix.** The AES-128 key for the session is *not*
the persistent ControllerKey. It's `ControllerKey[0:11] || (ControllerKey[11:16] XOR SessionID[0:5])`.
2. **Per-block XOR pre-whitening.** The first two bytes of every
16-byte AES block are XORed with the packet's sequence number
before encryption.
If your existing client is currently working against your panel, then
one of these is true:
- The panel firmware is configured to allow unencrypted sessions
(`ProtocolVersion=V1` over a serial-or-modem path), and the existing
client is using that path.
- Your panel is older firmware that didn't ship the quirks (we don't
have a confirmed cutoff version, but pre-3.0 likely doesn't have
them).
- The existing client *does* implement the quirks but didn't document
them in source — possible but we haven't found one.
`omni-pca` always uses the quirks, so it works against modern firmware
out of the box. If your panel is *older* and rejects connections, file
an issue — we can add a `protocol_quirks=False` option.
### API surface
The shape is different. `omni-pca` is async; the others are sync (or
event-loop-based with a different idiom).
```python
# pyomnilink
from omnilink2 import OmniLink2
omni = OmniLink2('192.168.1.9', 4369, 'CONTROLLER_KEY_HEX')
omni.connect()
info = omni.system_information()
print(info)
omni.close()
# omni-pca
import asyncio
from omni_pca import OmniClient
async def main():
async with OmniClient(host='192.168.1.9', port=4369,
controller_key=bytes.fromhex('CONTROLLER_KEY_HEX')) as panel:
info = await panel.get_system_information()
print(info)
asyncio.run(main())
```
Method names mostly translate directly:
| `pyomnilink` / `jomnilinkII` | `omni-pca` |
|---|---|
| `system_information()` | `get_system_information()` |
| `system_status()` | `get_system_status()` |
| `object_properties(type, idx)` | `get_object_properties(ObjectType.X, idx)` |
| `extended_status(type, start, end)` | `get_extended_status(ObjectType.X, start, end)` |
| `command(cmd, p1, p2)` | `execute_command(Command.X, p1, p2)` |
| `unit_on(idx)` | `turn_unit_on(idx)` |
| `subscribe_unsolicited(cb)` | `async for ev in panel.events(): ...` |
All `omni-pca` methods are typed and return parsed dataclasses, not raw
byte tuples.
### Event handling
`pyomnilink` and `jomnilinkII` typically use a callback model: register
a function, get raw events. `omni-pca` exposes events as a typed async
iterator with 26 parsed subclasses:
```python
async for event in panel.events():
match event:
case ZoneStateChanged(zone_index=idx, new_state=state):
...
case ArmingChanged(area_index=idx, new_mode=mode):
...
case AlarmActivated(area_index=idx, alarm_type=kind):
...
```
Subclassing pattern matching catches everything and lets the type
checker yell when you handle an event with the wrong field name.
### Home Assistant
If you're using HA, swap the integration:
1. Remove the existing Omni integration from **Settings → Devices &
Services**.
2. Drop in `custom_components/omni_pca/` — see the
[quickstart](/start/quickstart/#3-add-to-home-assistant).
3. Add the new integration; entity IDs will change (the new naming
convention is `<platform>.omni_pro_ii_<object_name>`).
If you have automations referring to the old entity IDs, search-and-
replace; the matching of zones/units/areas to underlying panel objects
is otherwise identical.
## What you lose
- **Battle-tested years of production use**`omni-pca` is fresh code
(released 2026-05-10). The protocol layer has 351 unit + integration
tests against a faithful mock, but the wider OSS world hasn't kicked
the tyres yet.
- **Some opcode coverage**`omni-pca` v1.0 doesn't have a path for
Programs discovery yet. Most other features are covered.
## What you gain
- **Async + typed API** — better Python ergonomics, mypy-friendly.
- **Two protocol quirks correctly implemented** — works against
firmware that rejects naive AES-ECB clients.
- **Faithful mock panel** — develop and test without touching real
hardware.
- **Modern HA integration** — config flow, discovery, push events,
diagnostics, services. No YAML configuration.
- **Full byte-level documentation** — see [protocol reference](/reference/protocol/),
[file format](/reference/file-format/), and the
[JOURNEY](/journey/) for how it was derived.
## Found a bug?
If you've migrated and something works in `jomnilinkII` / `pyomnilink`
that doesn't in `omni-pca`, that's an opcode we haven't reverse-
engineered yet (or implemented in a model). Open an issue with:
- Panel model + firmware version
- The specific `pyomnilink` / `jomnilinkII` call you were making
- Any captured packets if you have them
The protocol decompilation is exhaustive — if PC Access does something,
we can implement it.

View File

@ -0,0 +1,92 @@
---
title: Show a message on the panel display
description: Use the show_message and clear_message services to push a stored message to all OmniTouch consoles.
sidebar:
order: 4
---
The Omni stores up to 128 short text messages. Show one, clear one, log
one — all from HA automations.
## Programming a message
Messages are configured in PC Access (or via the keypad) — the panel
itself stores the text. The HA service refers to the message by its
1-based index, not the text.
In PC Access:
**Setup → Names → Messages** → pick a slot, type up to 30 characters,
save. The slot index is the number you'll pass to HA.
## Show a message
```yaml
service: omni_pca.show_message
data:
config_entry: <your_omni_config_entry_id>
message_index: 7 # 1-based
```
Every wired console and OmniTouch screen displays the message
immediately, with their attention-tone if configured.
## Clear it
The message stays up until the user dismisses it from a console *or* you
clear it remotely:
```yaml
service: omni_pca.clear_message
data:
config_entry: <your_omni_config_entry_id>
message_index: 7
```
`clear_message` removes the message from all consoles; the next
`show_message` displays whatever you ask for.
## Worked example — laundry done
```yaml
automation:
- alias: "Laundry done — show panel message"
trigger:
- platform: state
entity_id: binary_sensor.washer_running
from: "on"
to: "off"
for: "00:01:00"
action:
- service: omni_pca.show_message
data:
config_entry: omni_pro_ii_main
message_index: 12 # PC Access: "LAUNDRY DONE"
- delay: "00:30:00"
- service: omni_pca.clear_message
data:
config_entry: omni_pro_ii_main
message_index: 12
```
## Multiple messages, multiple panels
You can show multiple messages simultaneously — each is independent;
they cycle on the consoles. Different `message_index` values can be
shown / cleared independently.
If you have multiple Omni panels integrated (one HA install, two
config entries), the `config_entry` field picks which one. The same
`message_index` on different panels refers to different stored text.
## Send a one-shot ad-hoc message
The protocol doesn't expose an "ad-hoc message" path — you have to
pre-program slots in PC Access. A common pattern is to reserve a few
slots ("MESSAGE 1", "MESSAGE 2", ...) and rotate through them as
templates that automations fill the *meaning* into via context.
If you need real free-form messages, use HA's `notify.mobile_app`
service to your phone instead and skip the panel display entirely —
the Omni's tiny LCD lines are best used for status the household needs
to see in the kitchen, not arbitrary notifications.

View File

@ -0,0 +1,111 @@
---
title: Decrypt your first .pca file
description: Pull your panel's IP, port, and AES-128 ControllerKey out of a PC Access .pca config export — no hardware needed.
sidebar:
order: 1
---
By the end of this tutorial you'll have decrypted a `.pca` file, extracted
the panel's network address and AES-128 ControllerKey, and confirmed the
key isn't wrong by spot-checking the embedded customer-name field.
You don't need a panel for this. The `.pca` file already contains
everything.
## What you need
- A `.pca` file from PC Access. These come from the **Account → Save As**
menu in PC Access 3, or you'll find one in `Documents/HAI/PC Access/` on
any machine PC Access has been installed on.
- Python 3.14 or newer.
- 5 minutes.
If you don't have a `.pca` file of your own and just want to see the
mechanics, you can synthesize one with the mock panel — but the headline
moment of this tutorial is seeing your actual panel's hostname pop out
of the bytes, so go find a real one if you can.
## Step 1 — install the CLI
```bash
uvx --from omni-pca omni-pca version
```
This pulls the `omni-pca` package into a temporary venv and runs the
`version` subcommand. If that prints `omni-pca 2026.5.10` (or newer),
you're done with install.
If you'd rather install permanently:
```bash
uv tool install omni-pca
omni-pca version
```
## Step 2 — extract the ControllerKey
```bash
uvx --from omni-pca omni-pca decode-pca '/path/to/Your House.pca' \
--field controller_key
```
You should see 32 hex characters and nothing else. Something like:
```text
6ba7b4e9b4656de3cd7edd4c650cdb09
```
That's your panel's AES-128 session-derivation key. Hold onto it — it's
the credential the HA integration (and any other Omni-Link client) needs.
The same command takes other field names if you need them:
```bash
omni-pca decode-pca '/path/to/Your.pca' --field host # 192.168.1.x
omni-pca decode-pca '/path/to/Your.pca' --field port # 4369
```
## Step 3 — confirm the key is right
The CLI defaults to redacting account fields (name, address, phone) so
you don't accidentally leak PII into a log. To sanity-check that the
decryption worked rather than landed on noise, pass `--include-pii`:
```bash
omni-pca decode-pca '/path/to/Your.pca' --include-pii | head -10
```
You should see your real customer-info fields in plaintext. If those
match what you set up in PC Access, the decryption is correct. If they
look like random bytes, the file is corrupted or the wrong key was
selected — file an issue with the byte length and a hexdump of the first
16 bytes.
:::caution[Treat the plaintext like a password file]
The decrypted `.pca` contains everything an attacker needs to talk to
your panel: hostname, port, ControllerKey, and (commonly) factory-default
user codes (`12345678` is alarmingly frequent). Don't commit it to a repo,
don't paste it in a screenshot, don't email it to yourself.
:::
## What just happened
The `.pca` file is *not* AES-encrypted, despite PC Access shipping AES
code for the wire protocol. It's a Borland-Pascal LCG keystream XORed
byte-by-byte. The CLI tried two known cipher keys (`KEY_PC01` and
`KEY_EXPORT`), picked the one whose decrypted first bytes match the
expected `PCA03` magic header, then walked the resulting plaintext to
find the `Connection.NetworkAddress`, `Connection.NetworkPort`, and
`Connection.ControllerKey` fields.
The full byte-level format is on the [file format reference](/reference/file-format/)
page if you want to see what's in there.
## Where next
- [**Tutorial: spin up a mock panel and try it**](/tutorials/dev-stack/) —
no hardware, just docker. Confirms your ControllerKey works end-to-end.
- [**Tutorial: write your first omni_pca script**](/tutorials/first-script/) —
use the key + host you just extracted to actually talk to the panel.
- [**How-to: find your ControllerKey by other means**](/how-to/find-controller-key/) —
if you don't have a `.pca` file handy.

View File

@ -0,0 +1,127 @@
---
title: Spin up the dev stack
description: Run a real Home Assistant + a mock Omni panel locally in Docker. Click around the integration without touching real hardware.
sidebar:
order: 2
---
Two Docker containers, one Makefile target, and you're looking at a real
Home Assistant UI driving a faithful Omni Pro II emulator over TCP. Useful
for clicking through the integration before you point it at your real
panel, for screenshots, and for catching protocol regressions when you
change the library.
## What you need
- Docker + `docker compose` (compose v2).
- ~500 MB of free disk for the HA + builder images.
- Port 8123 free on localhost.
- The `omni-pca` source repo cloned locally
(`git clone https://github.com/rsp2k/omni-pca`).
## Step 1 — boot the stack
```bash
cd omni-pca/dev/
make dev-up
```
Two containers come up:
- `omni-pca-dev-ha` — Home Assistant `2026.5`, mounting
`../custom_components/omni_pca` read-only so HA loads the integration
from your working tree.
- `dev-mock-panel-1` — a long-running [mock panel](/explanation/architecture/#the-mock-panel)
on port 14369 with a populated state: 5 zones, 4 units, 2 areas,
2 thermostats, 3 buttons, two valid user codes (`1234` and `5678`).
```bash
make dev-logs # tail HA + mock logs
make dev-down # stop everything
make dev-reset # wipe HA state and start fresh
```
Wait ~30 seconds for HA to finish booting. You'll see the usual
`http.log running` line in the logs when it's ready.
## Step 2 — onboard HA
Open <http://localhost:8123/> in a browser. HA's first-run wizard takes
~60 seconds:
1. Set up a user account — anything works for local testing.
2. Pick a location (or skip it).
3. The wizard's "We found compatible devices" step will already list
*HAI/Leviton Omni Panel* — that's our `manifest.json` getting picked
up automatically. You can finish the wizard either way; we'll add the
integration manually next.
## Step 3 — add the integration
**Settings → Devices & Services → Add Integration**, search for
*HAI/Leviton Omni Panel*, then fill in:
| Field | Value |
|-------|-------|
| Host | `host.docker.internal` |
| Port | `14369` |
| Controller Key | `000102030405060708090a0b0c0d0e0f` |
Submit. Within 5 seconds you should see one device — *Omni Pro II* — with
~38 entities discovered:
![Omni Pro II device page after the dev-stack mock seeds 5 zones, 4 units, 2 areas, 2 thermostats, and 3 buttons](../../../assets/screenshots/04-panel-device.png)
## Step 4 — exercise it
Try a few things from the HA UI:
- **Toggle a light.** Living Lamp's switch flips the mock's unit-state
byte; the entity updates instantly via the synthesized `UnitStateChanged`
push event.
- **Arm an area.** Click *Main → Arm Away*, enter `1234`. The area
transitions to `armed_away` and the mock pushes `ArmingChanged`.
- **Use the wrong code.** Same flow with `9999` — HA toasts a service
error and the area stays disarmed. The mock validated the code
server-side and responded `Nak`.
- **Trigger a panel button.** Press *Good Morning* — mock acks the
`EXECUTE_BUTTON` command (no state change to observe, but check the
logs).
- **Open Developer Tools → States.** Filter for `omni_pro_ii` and watch
attributes mutate as you interact with entities elsewhere in the UI.
## Step 5 — change something and reload
Edit `custom_components/omni_pca/binary_sensor.py` (or anywhere in the
integration), then:
```bash
docker compose restart homeassistant
```
HA picks up the change in ~10 seconds. The mounted volume is `:ro` so
you can't accidentally write back from the container.
## What just happened
The HA container is the real upstream Home Assistant image. The mock
container is the same `MockPanel` class our [integration tests](/explanation/architecture/#the-ha-test-harness)
use, exposed via a tiny script (`dev/run_mock_panel.py`). HA opens an
encrypted session to the mock over real TCP — full handshake,
[per-block whitening](/explanation/quirks/#quirk-2--per-block-xor-pre-whitening),
real AES, real CRC. If the protocol agrees end-to-end, every entity
materialises; if anything's off (a missing opcode handler, a wrong byte
offset), it shows up as a missing entity or an `unavailable` state.
This is the same loop the project itself uses — every code change runs
against the mock first. Bringing your own real panel online is
[the next tutorial](/tutorials/first-script/).
## Where next
- [**Tutorial: write your first omni_pca script**](/tutorials/first-script/) —
drop into Python and drive the mock from your own code.
- [**How-to: trigger an HA automation on alarm activation**](/how-to/automate-on-alarm/) —
use the `event` entity for real-time reactions.
- [**Reference: HA entities**](/reference/ha-entities/) — what each entity
exposes.

View File

@ -0,0 +1,165 @@
---
title: Write your first omni_pca script
description: Connect to a panel from Python, fetch system info, walk every named zone, and react to push events. Works against the mock or a real panel.
sidebar:
order: 3
---
Twenty lines of Python that connect to an Omni panel, fetch its model and
firmware, walk every named zone, and stream typed events as they arrive.
Run against the mock if you don't have a panel yet, or a real one once you
have its ControllerKey.
## What you need
- Python 3.14+ and `uv` installed.
- Either:
- The [dev stack running](/tutorials/dev-stack/) (mock at
`127.0.0.1:14369` with key `000102030405060708090a0b0c0d0e0f`), or
- A real panel reachable on TCP/4369 with its ControllerKey extracted
via [the .pca decryption tutorial](/tutorials/decrypt-your-pca/).
## Step 1 — bootstrap a script
```bash
mkdir omni-hello && cd omni-hello
uv init --no-readme --python '>=3.14'
uv add omni-pca
```
You'll get a `pyproject.toml` with `omni-pca` listed and a `hello.py`
stub.
## Step 2 — replace `hello.py`
```python title="hello.py"
"""First contact with an Omni-Link II panel."""
from __future__ import annotations
import asyncio
from omni_pca import OmniClient
# Pick one — mock OR real panel.
HOST = "127.0.0.1" # or "192.168.1.9"
PORT = 14369 # or 4369
KEY = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
# KEY = bytes.fromhex("YOUR_REAL_CONTROLLER_KEY_HERE")
async def main() -> None:
async with OmniClient(host=HOST, port=PORT, controller_key=KEY) as panel:
info = await panel.get_system_information()
print(f"Connected: {info.model_name} firmware {info.firmware_version}")
zones = await panel.list_zone_names()
print(f"\n{len(zones)} named zones:")
for index, name in sorted(zones.items()):
print(f" {index:>3}: {name}")
if __name__ == "__main__":
asyncio.run(main())
```
## Step 3 — run it
```bash
uv run python hello.py
```
Against the dev-stack mock you'll see something like:
```text
Connected: Omni Pro II firmware 2.12r1
5 named zones:
1: FRONT_DOOR
2: GARAGE_ENTRY
3: BACK_DOOR
10: LIVING_MOTION
11: HALL_MOTION
```
If you see this, the four-step secure-session handshake completed: the
client opened a TCP connection, sent `ClientRequestNewSession`, parsed
the controller's `ControllerAckNewSession` (5-byte SessionID), derived
the [session key with the XOR mix](/explanation/quirks/#quirk-1--session-key-is-not-the-controllerkey),
sent `ClientRequestSecureSession` AES-encrypted with [per-block
whitening](/explanation/quirks/#quirk-2--per-block-xor-pre-whitening),
got back `ControllerAckSecureSession`, then issued
`RequestSystemInformation` (opcode 22) and walked
`RequestProperties` (opcode 32) for every zone.
## Step 4 — react to push events
Append to `hello.py`:
```python
async def watch_events() -> None:
async with OmniClient(host=HOST, port=PORT, controller_key=KEY) as panel:
print("Listening for unsolicited events. Trigger something on the panel...")
async for event in panel.events():
print(f" {type(event).__name__}: {event}")
if __name__ == "__main__":
asyncio.run(watch_events())
```
Run it again. Now go open the dev-stack HA UI and toggle a light, or
arm an area, or bypass a zone. You'll see typed events stream in as
the mock pushes them:
```text
Listening for unsolicited events. Trigger something on the panel...
UnitStateChanged: UnitStateChanged(unit_index=1, new_state=1, ...)
ArmingChanged: ArmingChanged(area_index=1, new_mode=3, user_index=1, ...)
ZoneStateChanged: ZoneStateChanged(zone_index=1, ...)
```
Each subclass of `SystemEvent` has its own typed fields. The
[library API reference](/reference/library-api/#omni_pcaevents) lists all
26 subclasses.
## Step 5 — issue a command
Replace `watch_events` with:
```python
async def turn_on_living_lamp() -> None:
async with OmniClient(host=HOST, port=PORT, controller_key=KEY) as panel:
await panel.turn_unit_on(1) # mock: index 1 = LIVING_LAMP
await panel.set_unit_level(1, 60) # 60% brightness
statuses = await panel.get_extended_status_for("UNIT", 1)
print(statuses)
```
That's a `Command` packet (opcode 20) with `command_byte=UNIT_LEVEL`,
`parameter1=60`, `parameter2=1` — followed by an extended-status read so
you can confirm the change took. Against the mock the unit's `state`
byte will be `160` (= 100 + percent), and the brightness conversion in
the HA `light` platform turns that back into HA's 0-255 scale.
## What just happened
You exercised three of the library's core surfaces: read (system info,
properties walk), push (typed event stream), and write (command
dispatch). All of them go through the same `OmniClient` async context
manager, which owns the `OmniConnection` underneath; the connection
handles framing, CRC, AES with the per-block whitening, and sequence-
number tracking.
The full surface is on [the library API reference](/reference/library-api/).
What we covered here is maybe 5% of it; setpoints, area arming, button
macros, program execution, audio control, raw command escape hatches all
work the same way.
## Where next
- [**How-to: bypass a zone**](/how-to/bypass-zone/) — a focused recipe.
- [**How-to: send a panel display message**](/how-to/send-panel-message/) —
for "the laundry is done"-style notifications.
- [**Reference: full Omni-Link II protocol spec**](/reference/protocol/) —
if you want to read raw packets, not just call client methods.