diff --git a/src/content/docs/how-to/.gitkeep b/src/content/docs/how-to/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/content/docs/how-to/automate-on-alarm.md b/src/content/docs/how-to/automate-on-alarm.md new file mode 100644 index 0000000..89dcc49 --- /dev/null +++ b/src/content/docs/how-to/automate-on-alarm.md @@ -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. diff --git a/src/content/docs/how-to/bypass-zone.md b/src/content/docs/how-to/bypass-zone.md new file mode 100644 index 0000000..1597fdf --- /dev/null +++ b/src/content/docs/how-to/bypass-zone.md @@ -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: + 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: + 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. diff --git a/src/content/docs/how-to/decode-a-packet.md b/src/content/docs/how-to/decode-a-packet.md new file mode 100644 index 0000000..fb18d9c --- /dev/null +++ b/src/content/docs/how-to/decode-a-packet.md @@ -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. diff --git a/src/content/docs/how-to/find-controller-key.md b/src/content/docs/how-to/find-controller-key.md new file mode 100644 index 0000000..66d96ba --- /dev/null +++ b/src/content/docs/how-to/find-controller-key.md @@ -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. diff --git a/src/content/docs/how-to/migrate-from-jomnilinkii.md b/src/content/docs/how-to/migrate-from-jomnilinkii.md new file mode 100644 index 0000000..53637ba --- /dev/null +++ b/src/content/docs/how-to/migrate-from-jomnilinkii.md @@ -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 `.omni_pro_ii_`). + +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. diff --git a/src/content/docs/how-to/send-panel-message.md b/src/content/docs/how-to/send-panel-message.md new file mode 100644 index 0000000..bc95f15 --- /dev/null +++ b/src/content/docs/how-to/send-panel-message.md @@ -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: + 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: + 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. diff --git a/src/content/docs/tutorials/.gitkeep b/src/content/docs/tutorials/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/content/docs/tutorials/decrypt-your-pca.md b/src/content/docs/tutorials/decrypt-your-pca.md new file mode 100644 index 0000000..439b051 --- /dev/null +++ b/src/content/docs/tutorials/decrypt-your-pca.md @@ -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. diff --git a/src/content/docs/tutorials/dev-stack.md b/src/content/docs/tutorials/dev-stack.md new file mode 100644 index 0000000..ef4a7d5 --- /dev/null +++ b/src/content/docs/tutorials/dev-stack.md @@ -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 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. diff --git a/src/content/docs/tutorials/first-script.md b/src/content/docs/tutorials/first-script.md new file mode 100644 index 0000000..839a72b --- /dev/null +++ b/src/content/docs/tutorials/first-script.md @@ -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.