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:
parent
4ec43b269f
commit
4812b56622
117
src/content/docs/how-to/automate-on-alarm.md
Normal file
117
src/content/docs/how-to/automate-on-alarm.md
Normal 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.
|
||||||
94
src/content/docs/how-to/bypass-zone.md
Normal file
94
src/content/docs/how-to/bypass-zone.md
Normal 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.
|
||||||
152
src/content/docs/how-to/decode-a-packet.md
Normal file
152
src/content/docs/how-to/decode-a-packet.md
Normal 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.
|
||||||
82
src/content/docs/how-to/find-controller-key.md
Normal file
82
src/content/docs/how-to/find-controller-key.md
Normal 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.
|
||||||
162
src/content/docs/how-to/migrate-from-jomnilinkii.md
Normal file
162
src/content/docs/how-to/migrate-from-jomnilinkii.md
Normal 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.
|
||||||
92
src/content/docs/how-to/send-panel-message.md
Normal file
92
src/content/docs/how-to/send-panel-message.md
Normal 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.
|
||||||
111
src/content/docs/tutorials/decrypt-your-pca.md
Normal file
111
src/content/docs/tutorials/decrypt-your-pca.md
Normal 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.
|
||||||
127
src/content/docs/tutorials/dev-stack.md
Normal file
127
src/content/docs/tutorials/dev-stack.md
Normal 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
165
src/content/docs/tutorials/first-script.md
Normal file
165
src/content/docs/tutorials/first-script.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user