Initial scaffold

Astro 6 + Starlight 0.39 documentation site for omni-pca, organised
around the Diatáxis framework (Tutorials / How-to / Reference /
Explanation), plus a chronological Journey page and Changelog.

Theme: muted slate-blue with amber accents. astro-icon + lucide
preinstalled. Astro telemetry and Starlight devToolbar both off.

Deployment: multi-stage Dockerfile (node:25-alpine builder ->
caddy:2-alpine runtime), inner Caddy serves static dist on :80,
outer caddy-docker-proxy on the host terminates TLS for
hai-omni-pro-ii.warehack.ing.
This commit is contained in:
Ryan Malloy 2026-05-10 16:42:12 -06:00
commit c5e72c679b
32 changed files with 7523 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules
dist
.astro
.git
.env
.env.local
.env.production
*.tsbuildinfo
.DS_Store
.vscode
README.md

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
# Copy to .env and adjust as needed.
COMPOSE_PROJECT=hai-omni-docs
DOMAIN=hai-omni-pro-ii.warehack.ing

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.local
.env.production
# typescript
*.tsbuildinfo
# macOS-specific files
.DS_Store

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

22
Caddyfile Normal file
View File

@ -0,0 +1,22 @@
# Inner Caddy: just serves the static dist on :80.
# The host's caddy-docker-proxy handles TLS and routing for the public hostname.
:80 {
root * /srv
encode zstd gzip
file_server
# SPA-ish fallback: prefer the directory's index.html, then a 404 page.
try_files {path} {path}/ /404.html
header {
# Light security defaults; the outer Caddy can override.
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "interest-cohort=()"
}
# Long cache for fingerprinted assets emitted by Astro.
@hashed path_regexp \.[A-Za-z0-9_-]{8,}\.(js|css|woff2?|svg|png|jpg|jpeg|webp|avif)$
header @hashed Cache-Control "public, max-age=31536000, immutable"
}

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
# ---- builder ----
FROM node:25-alpine AS builder
ENV ASTRO_TELEMETRY_DISABLED=1 \
NODE_ENV=production \
CI=true
WORKDIR /app
# Install deps in their own layer for better caching.
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
# Copy the rest of the source and build.
COPY . .
RUN npm run build
# ---- runtime ----
FROM caddy:2-alpine AS runtime
# Run as non-root.
RUN addgroup -S docs && adduser -S -G docs docs
WORKDIR /srv
COPY --from=builder /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
# caddy:2-alpine ships with a sane default user model; keep ports unprivileged.
EXPOSE 80
USER docs
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

50
Makefile Normal file
View File

@ -0,0 +1,50 @@
# hai-omni-docs Makefile
#
# Local dev: make dev (Astro dev server, hot reload, no docker)
# Smoke build: make ci (npm run build — verify static output)
# Deploy: make build && make up
#
# Reads .env if present so DOMAIN / COMPOSE_PROJECT propagate to compose.
SHELL := /bin/bash
COMPOSE := docker compose
.PHONY: help dev install ci build up down restart logs ps clean
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
install: ## Install npm dependencies
npm install
dev: ## Start Astro dev server with hot reload (host)
npm run dev
ci: ## Smoke-test the static build locally
npm run build
build: ## Build the docker image
$(COMPOSE) build
up: ## Start the docs container in the background
$(COMPOSE) up -d
@echo
@echo "==> Tailing recent logs ..."
@$(COMPOSE) logs -f --tail=20
down: ## Stop and remove the docs container
$(COMPOSE) down
restart: ## Recreate the docs container
$(COMPOSE) up -d --force-recreate
@$(COMPOSE) logs -f --tail=20
logs: ## Follow logs
$(COMPOSE) logs -f --tail=50
ps: ## Show container status
$(COMPOSE) ps
clean: ## Remove build artifacts
rm -rf dist .astro

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# hai-omni-docs
Documentation site for [`omni-pca`](https://github.com/rsp2k/omni-pca) — a
reverse-engineered Python library and Home Assistant integration for HAI/Leviton
Omni Pro II home automation panels. Built with Astro + Starlight, organised
around the Diátaxis framework.
Live: <https://hai-omni-pro-ii.warehack.ing>
## Local development
```sh
make install # one-time
make dev # http://localhost:4321 with hot reload
```
## Production build (smoke test)
```sh
make ci # runs `npm run build`, output in ./dist
```
## Deploy via Caddy
The container ships its static `dist/` from an inner Caddy on `:80`; the host's
`caddy-docker-proxy` terminates TLS and routes the configured `DOMAIN` to it via
the external `caddy` network.
```sh
cp .env.example .env # adjust COMPOSE_PROJECT / DOMAIN as needed
make build
make up # then tails logs
```
## Layout
```
src/
content/docs/
index.mdx
start/ # Overview + Quick start
tutorials/ # Diátaxis: learning-oriented
how-to/ # Diátaxis: task-oriented
reference/ # Diátaxis: information-oriented (protocol, file format, API)
explanation/ # Diátaxis: understanding-oriented (quirks, architecture, bugs)
journey.md # Chronological retrospective
changelog.md
styles/theme.css # Slate-blue + amber accent
assets/logo.svg
```
## Source project
- Library + integration: <https://github.com/rsp2k/omni-pca>

89
astro.config.mjs Normal file
View File

@ -0,0 +1,89 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import icon from 'astro-icon';
// https://astro.build/config
export default defineConfig({
site: 'https://hai-omni-pro-ii.warehack.ing',
telemetry: false,
devToolbar: { enabled: false },
integrations: [
icon({
include: {
lucide: ['*'],
},
}),
starlight({
title: 'HAI Omni Pro II — omni-pca docs',
description:
'Reverse-engineered Python library and Home Assistant integration for HAI/Leviton Omni Pro II home automation panels.',
logo: {
src: './src/assets/logo.svg',
replacesTitle: false,
},
favicon: '/favicon.svg',
customCss: ['./src/styles/theme.css'],
social: [
{
icon: 'github',
label: 'GitHub',
href: 'https://github.com/rsp2k/omni-pca',
},
],
editLink: {
// Placeholder — update once the docs repo lives somewhere on GitHub.
baseUrl: 'https://github.com/rsp2k/hai-omni-docs/edit/main/',
},
lastUpdated: true,
pagination: true,
sidebar: [
{
label: 'Start here',
collapsed: false,
items: [
{ label: 'Overview', slug: '' },
{ label: 'Quick start', slug: 'start/quickstart' },
],
},
{
label: 'Tutorials',
collapsed: true,
items: [{ autogenerate: { directory: 'tutorials' } }],
},
{
label: 'How-to guides',
collapsed: true,
items: [{ autogenerate: { directory: 'how-to' } }],
},
{
label: 'Reference',
collapsed: false,
items: [
{ label: 'Protocol', slug: 'reference/protocol' },
{ label: 'File format', slug: 'reference/file-format' },
{ label: 'Library API', slug: 'reference/library-api' },
{ label: 'HA entities', slug: 'reference/ha-entities' },
{ label: 'HA services', slug: 'reference/ha-services' },
],
},
{
label: 'Explanation',
collapsed: false,
items: [
{ label: 'Two non-public quirks', slug: 'explanation/quirks' },
{ label: 'Architecture', slug: 'explanation/architecture' },
{ label: 'The PC Access bug', slug: 'explanation/pc-access-bug' },
],
},
{ label: 'Journey', slug: 'journey' },
{ label: 'Changelog', slug: 'changelog' },
],
}),
],
vite: {
server: {
host: '0.0.0.0',
},
},
});

19
docker-compose.yml Normal file
View File

@ -0,0 +1,19 @@
services:
docs:
build:
context: .
dockerfile: Dockerfile
image: ${COMPOSE_PROJECT:-hai-omni-docs}/docs:latest
container_name: ${COMPOSE_PROJECT:-hai-omni-docs}-docs
restart: unless-stopped
networks:
- caddy
expose:
- "80"
labels:
caddy: ${DOMAIN:-hai-omni-pro-ii.warehack.ing}
caddy.reverse_proxy: "{{upstreams 80}}"
networks:
caddy:
external: true

6854
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "hai-omni-docs",
"type": "module",
"version": "0.0.1",
"description": "Documentation site for omni-pca — a Python library and Home Assistant integration for HAI/Leviton Omni Pro II panels.",
"author": "Ryan Malloy <ryan@supported.systems>",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/starlight": "^0.39.2",
"@iconify-json/lucide": "^1.2.106",
"astro": "^6.2.2",
"astro-icon": "^1.1.5",
"sharp": "^0.34.5"
}
}

9
public/favicon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="3" y="3" width="26" height="26" rx="3" fill="#1a1d22" stroke="#4f86c6" stroke-width="1.5"/>
<rect x="3" y="3" width="26" height="6" rx="3" fill="#2c3138"/>
<circle cx="7" cy="6" r="1" fill="#f0a830"/>
<circle cx="10.5" cy="6" r="1" fill="#4f86c6"/>
<rect x="7" y="13" width="18" height="2" rx="1" fill="#4f86c6"/>
<rect x="7" y="17" width="18" height="2" rx="1" fill="#4f86c6" opacity="0.7"/>
<rect x="7" y="21" width="12" height="2" rx="1" fill="#4f86c6" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

9
src/assets/logo.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="6" y="6" width="20" height="20" rx="2"/>
<line x1="6" y1="11" x2="26" y2="11"/>
<circle cx="9.5" cy="8.5" r="0.6" fill="#f0a830" stroke="none"/>
<circle cx="12" cy="8.5" r="0.6" fill="currentColor" stroke="none"/>
<line x1="10" y1="16" x2="22" y2="16"/>
<line x1="10" y1="20" x2="22" y2="20"/>
<line x1="10" y1="23" x2="18" y2="23"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

7
src/content.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@ -0,0 +1,6 @@
---
title: Changelog
description: Notable changes to omni-pca.
---
TODO: filled by parallel agent.

View File

@ -0,0 +1,6 @@
---
title: Architecture
description: How the library, the integration, and the panel fit together.
---
TODO: filled by parallel agent.

View File

@ -0,0 +1,6 @@
---
title: The PC Access bug
description: A latent defect in HAI's official client and why it matters.
---
TODO: filled by parallel agent.

View File

@ -0,0 +1,6 @@
---
title: The two non-public quirks
description: Why public Omni-Link clients silently fail on the first encrypted message.
---
TODO: filled by parallel agent.

View File

View File

@ -0,0 +1,61 @@
---
title: HAI Omni Pro II — omni-pca
description: Reverse-engineered Python library and Home Assistant integration for HAI/Leviton Omni Pro II home automation panels.
template: doc
---
import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
## What it is
`omni-pca` is an async Python library and a matching Home Assistant custom
component for HAI / Leviton **Omni Pro II**, **Omni IIe**, **Omni LTe**, and
**Lumina** panels. It speaks Omni-Link II straight to the controller over TCP,
opens an encrypted session, and surfaces the panel's typed object model — zones,
units, areas, thermostats, buttons, programs, codes, messages — plus the
unsolicited push-event stream the panel emits on state changes.
The protocol layer was built from a clean-room decompilation of HAI's PC Access
3.17. Every opcode, byte layout, and crypto step is cited back to the source
line in the decompiled C# (`clsOmniLinkConnection.cs`, `clsHAC.cs`, etc.).
## Two non-public protocol quirks
The wire protocol — as actually implemented in PC Access 3.17 — has two
non-public quirks that public Omni-Link clients miss. Without them the panel
will accept your TCP connection, complete the unencrypted handshake, and then
silently drop you on the first encrypted message:
1. **Session key XOR mix.** The AES-128 session key is *not* the panel's
`ControllerKey` directly. Bytes `[11..16)` of the ControllerKey are XORed
with a 5-byte `SessionID` nonce that the controller sends in
`ControllerAckNewSession`. Bytes `[0..11)` are the ControllerKey verbatim.
2. **Per-block XOR pre-whitening before AES.** Before each 16-byte block is
AES-encrypted, its first two bytes are XORed with the packet's 16-bit
sequence number (high byte first). The same mask is applied to *every*
block of the packet. Decrypt reverses it.
Both are unambiguous in the decompiled C# (`clsOmniLinkConnection.cs:1886-1892`
and `:396-401`). Neither appears in `jomnilinkII`, `pyomnilink`, or any
third-party Omni-Link writeup we found. See [the quirks
explainer](/explanation/quirks/) for the full story.
## Where to start
<CardGrid>
<LinkCard
title="Decode your .pca file"
href="/start/quickstart/"
description="Pull the panel's IP, port, and AES-128 ControllerKey out of an encrypted .pca config export — no panel hardware required."
/>
<LinkCard
title="Protocol reference"
href="/reference/protocol/"
description="Byte-level Omni-Link II handshake, packet layouts, key derivation, steady-state encryption, sequence numbers, and teardown."
/>
<LinkCard
title="The Journey"
href="/journey/"
description="Chronological retrospective of the reverse-engineering work — pile of binaries to 351 passing tests in a few days."
/>
</CardGrid>

View File

@ -0,0 +1,6 @@
---
title: Journey
description: Chronological retrospective of the omni-pca reverse-engineering work.
---
TODO: filled by parallel agent — chronological retrospective of the reverse-engineering work.

View File

@ -0,0 +1,6 @@
---
title: File format
description: On-disk layout of .pca and PCA01.CFG files.
---
TODO: filled by parallel agent — `.pca` / `PCA01.CFG` layout.

View File

@ -0,0 +1,6 @@
---
title: HA entities
description: Home Assistant entities exposed by the integration.
---
TODO: filled by parallel agent — Home Assistant entity catalog.

View File

@ -0,0 +1,6 @@
---
title: HA services
description: Home Assistant services exposed by the integration.
---
TODO: filled by parallel agent — Home Assistant service catalog.

View File

@ -0,0 +1,6 @@
---
title: Library API
description: Public surface of the omni_pca Python library.
---
TODO: filled by parallel agent — `omni_pca` Python API surface.

View File

@ -0,0 +1,6 @@
---
title: Protocol
description: Omni-Link II handshake, packet/message structure, and opcode reference.
---
TODO: filled by parallel agent — handshake, packet/message structure, opcodes.

View File

@ -0,0 +1,112 @@
---
title: Quick start
description: Three minimal flows — decode your .pca file, drive the panel from Python, and add the integration to Home Assistant.
---
Three flows. Each one assumes you have an Omni Pro II (or compatible) panel
and a `.pca` configuration export from PC Access. The first flow needs nothing
else — no panel reachable on the network, no HA install. The second adds the
panel itself. The third stacks Home Assistant on top.
## 1. Decode your `.pca` file
Pull the panel's connection details (IP, port, AES-128 `ControllerKey`) out of
the encrypted `.pca` blob. No panel hardware required — the file already
contains everything you need.
```bash
pip install omni-pca
# or, no install:
uvx omni-pca decode-pca '/path/to/Your.pca' --field controller_key
```
Other useful fields:
```bash
uvx omni-pca decode-pca '/path/to/Your.pca' --field host
uvx omni-pca decode-pca '/path/to/Your.pca' --field port
uvx omni-pca decode-pca '/path/to/Your.pca' --include-pii # full dump, with account info
```
:::caution[The decrypted `.pca` plaintext contains PII]
Account name, address, phone number, and (commonly) factory-default user
codes all live in the file in plaintext after decryption. The CLI redacts
account fields by default; pass `--include-pii` only when you know what you're
doing, and never commit decrypted plaintext to a public repo.
:::
The cipher is *not* AES — it's a Borland-Pascal LCG keystream XORed byte-by-byte
with the file. The per-installation key lives inside `PCA01.CFG`, encrypted
with a hardcoded key. See the [file format reference](/reference/file-format/)
for the byte-level layout.
## 2. Talk to the panel from Python
With the `ControllerKey` in hand, open an encrypted session and ask the panel
who it is.
```python
import asyncio
from omni_pca import OmniClient
async def main():
async with OmniClient(
host="192.168.1.9",
port=4369,
controller_key=bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09"),
) as panel:
info = await panel.get_system_information()
print(info.model_name, info.firmware_version)
# Walk every named zone.
zones = await panel.list_zone_names()
for index, name in zones.items():
print(f" zone {index:>3}: {name}")
asyncio.run(main())
```
The `OmniClient` async context manager runs the four-step secure-session
handshake, derives the session key (with the [XOR mix](/explanation/quirks/)),
sets up the per-direction sequence counter, and starts the background reader
task. When you `await client.get_system_information()` it sends a
`RequestSystemInformation` (opcode 22), waits for the matching reply,
and parses the payload into a `SystemInformation` dataclass.
The full surface — commands, status queries, typed event stream — is
documented in the [Library API reference](/reference/library-api/).
:::tip[No panel handy?]
The library ships a stateful `MockPanel` that emulates the controller side
of the protocol over TCP. Spin it up in-process with
`from omni_pca.mock_panel import MockPanel` — see the
[architecture overview](/explanation/architecture/) for how the test stack
uses it.
:::
## 3. Add to Home Assistant
```bash
# From the project root, copy the integration into your HA config.
cp -r custom_components/omni_pca/ \
~/.homeassistant/config/custom_components/
# Restart HA.
```
Then in HA: **Settings → Devices & Services → Add Integration**, search for
*HAI/Leviton Omni Panel*, and fill in:
- **Host** — IP or hostname of the panel (e.g., `192.168.1.9`)
- **Port**`4369` (HAI's reserved port; default)
- **Controller Key** — the 32 hex characters from step 1
The integration creates one HA device per panel plus typed entities for every
named object on the controller — alarm panels for areas, lights for units,
binary_sensors for zones, climate for thermostats, and so on. State
propagates over the panel's unsolicited push channel; a 30-second poll
backstops anything that didn't push.
See the [HA entity catalogue](/reference/ha-entities/) for what gets created
and the [HA service reference](/reference/ha-services/) for the seven
services you can call from automations.

View File

60
src/styles/theme.css Normal file
View File

@ -0,0 +1,60 @@
/*
* HAI Omni Pro II docs theme.
*
* Muted slate-blue base with warm amber accents evocative of an
* indicator LED on a beige 1990s alarm panel. No purple anywhere.
*
* Starlight color tokens reference:
* https://starlight.astro.build/guides/css-and-tailwind/#theming
*/
:root {
/* Dark theme (default) — slate-blue with amber accent */
--sl-color-accent-low: #1c2a3a;
--sl-color-accent: #4f86c6;
--sl-color-accent-high: #b9d3ee;
--sl-color-white: #ffffff;
--sl-color-gray-1: #e6e8eb;
--sl-color-gray-2: #b8bcc2;
--sl-color-gray-3: #7e848d;
--sl-color-gray-4: #4a4f57;
--sl-color-gray-5: #2c3138;
--sl-color-gray-6: #1a1d22;
--sl-color-black: #101317;
/* Amber accent for callouts, badges, highlighted prose */
--accent-amber: #f0a830;
--accent-amber-soft: #f5c875;
}
:root[data-theme='light'] {
--sl-color-accent-low: #d8e4f2;
--sl-color-accent: #2d5a8c;
--sl-color-accent-high: #1c3a5e;
--sl-color-white: #101317;
--sl-color-gray-1: #2c3138;
--sl-color-gray-2: #4a4f57;
--sl-color-gray-3: #7e848d;
--sl-color-gray-4: #b8bcc2;
--sl-color-gray-5: #e6e8eb;
--sl-color-gray-6: #f4f5f7;
--sl-color-black: #ffffff;
--accent-amber: #b87010;
--accent-amber-soft: #d68a2a;
}
/* Subtle amber tint on inline code so reverse-engineered opcodes / hex
* stand out without being shouty. */
:not(pre) > code {
color: var(--accent-amber);
background-color: color-mix(in srgb, var(--accent-amber) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-amber) 25%, transparent);
}
/* Slightly tighter heading rhythm — reads better for technical reference. */
.sl-markdown-content h2 {
margin-top: 2.5rem;
}

5
tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strictest",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}