Some Omni network modules are configured for UDP, in which case PC Access
falls back to the v1 wire protocol (OmniLinkMessage outer = 0x10, inner
StartChar 0x5A, typed Request*Status opcodes) instead of v2's TCP path
(OmniLink2Message + StartChar 0x21 + parameterised RequestProperties).
This adds a parallel implementation rather than overloading the v2 path.
omni_pca/v1/
connection.py UDP-only OmniConnectionV1; reuses crypto + handshake,
routes post-handshake messages through OmniLinkMessage
(0x10) wrapping v1 inner format. Adds iter_streaming
for the lock-step UploadNames/Acknowledge/EOD pattern.
messages.py Block parsers for the typed v1 status replies (zone,
unit, thermostat, aux), v1 SystemStatus, and NameData
(handles both one-byte and two-byte NameNumber forms).
client.py OmniClientV1: read API (get_system_information,
get_*_status), discovery (iter_names + list_*_names),
write API (execute_command, execute_security_command,
turn_unit_*, set_unit_level, bypass/restore_zone,
execute_button, set_thermostat_*). acknowledge_alerts
is a no-op (v1 has no equivalent opcode).
Discovery uses bare UploadNames; panel streams every defined name across
all types in a fixed order with per-record Acknowledge. Verified against
firmware 2.12 — pulled 16 zones, 44 units, 16 buttons, 8 codes,
2 thermostats, 8 messages in one stream.
src/omni_pca/message.py
Fix flipped START_CHAR_V1_* constants. enuOmniLinkMessageFormat says
Addressable=0x41 and NonAddressable=0x5A; our names had them swapped.
Wire bytes were unchanged, so existing tests kept passing — but
encode_v1() with no serial_address now correctly emits 0x5A, which
is what UDP needs.
tests/
test_v1_messages.py 22 cases; payloads are real wire captures
from a firmware-2.12 panel via probe_v1_recon.
test_v1_client_commands.py 20 cases; payload-packing for the Command
and ExecuteSecurityCommand opcodes,
including BE u16 parameter2 and the
digit-by-digit security code form.
dev/
probe_v1.py Phase-1 smoke: handshake + RequestSystemInformation.
probe_v1_recon.py Raw opcode dump for protocol reconnaissance.
probe_v1_stream.py Streaming UploadNames flow exploration.
probe_v1_client.py Full read-path smoke test via OmniClientV1.
probe_v1_write.py Live no-op execute_command round-trip.
.gitignore: ignore dev/.omni_key (probe scripts read controller key from
this file as one fallback option).
Discovery on firmware 2.12: Request*ExtendedStatus opcodes (63/65/69)
NAK on this firmware — only the basic Request*Status opcodes are
implemented, so OmniClientV1 uses those (3 bytes/unit, 7 bytes/tstat,
4 bytes/aux records). HA still gets enough signal for polling; full
properties discovery uses streaming UploadNames instead.
Test totals: 387 passed, 1 skipped (existing fixture skip).
119 lines
4.5 KiB
Python
119 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Probe the v1 UploadNames streaming flow.
|
|
|
|
Sends UploadNames (no payload), then a series of Acknowledge messages,
|
|
dumping each reply until we get an EOD or 30 records (whichever comes
|
|
first). Confirms the lock-step pattern PC Access uses for bulk reads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from probe_v1 import _load_key # type: ignore # noqa: E402
|
|
|
|
from omni_pca.opcodes import OmniLinkMessageType
|
|
from omni_pca.v1 import OmniConnectionV1
|
|
|
|
|
|
_NAME_TYPE_LABELS = {
|
|
1: "Zone", 2: "Unit", 3: "Button", 4: "Code",
|
|
5: "Thermostat", 6: "Area", 7: "Message",
|
|
}
|
|
|
|
|
|
def _decode_namedata(payload: bytes) -> str:
|
|
"""Best-effort decode of a NameData payload for display."""
|
|
if len(payload) < 3:
|
|
return f"<short payload: {payload.hex()}>"
|
|
name_type = payload[0]
|
|
# Heuristic: zones/messages are 15-char names, others 12. With one-byte
|
|
# NameNumber, payload length = 1 (type) + 1 (num) + L (name) + 1 (term).
|
|
# With two-byte NameNumber: 1 + 2 + L + 1.
|
|
L_15 = 15 + 3 # one-byte form, 15-char name
|
|
L_12 = 12 + 3 # one-byte form, 12-char name
|
|
if len(payload) == L_15 or len(payload) == L_15 + 1:
|
|
# 15-char name (Zone or Message), one-byte num.
|
|
num = payload[1]
|
|
name = payload[2:2 + 15].rstrip(b"\x00").decode("utf-8", errors="replace")
|
|
elif len(payload) == L_12 or len(payload) == L_12 + 1:
|
|
# 12-char name, one-byte num.
|
|
num = payload[1]
|
|
name = payload[2:2 + 12].rstrip(b"\x00").decode("utf-8", errors="replace")
|
|
else:
|
|
# Two-byte num form (NameNumber > 255): payload[1..2] = BE u16, then name.
|
|
num = (payload[1] << 8) | payload[2]
|
|
name = payload[3:].rstrip(b"\x00").decode("utf-8", errors="replace")
|
|
|
|
label = _NAME_TYPE_LABELS.get(name_type, f"type{name_type}")
|
|
return f"{label} #{num}: {name!r}"
|
|
|
|
|
|
async def amain(args: argparse.Namespace) -> int:
|
|
key = _load_key(args.key)
|
|
print(f"[stream probe] target {args.host}:{args.port}\n")
|
|
|
|
async with OmniConnectionV1(
|
|
host=args.host, port=args.port, controller_key=key, timeout=4.0
|
|
) as conn:
|
|
from omni_pca.message import Message, START_CHAR_V1_UNADDRESSED
|
|
|
|
# Step 1: bare UploadNames.
|
|
upload = Message(
|
|
start_char=START_CHAR_V1_UNADDRESSED,
|
|
data=bytes([int(OmniLinkMessageType.UploadNames)]),
|
|
)
|
|
seq, fut = conn._send_encrypted(upload)
|
|
reply = conn._decode_inner(await fut)
|
|
print(f"reply 1 (seq={seq}): opcode={reply.opcode} {_decode_namedata(reply.payload) if reply.opcode == int(OmniLinkMessageType.NameData) else f'(payload={reply.payload.hex()!r})'}")
|
|
|
|
if reply.opcode != int(OmniLinkMessageType.NameData):
|
|
print("panel didn't reply with NameData — streaming flow may not apply here")
|
|
return 0
|
|
|
|
# Step 2..N: Acknowledge → next NameData (or EOD).
|
|
for i in range(2, args.max + 1):
|
|
ack = Message(
|
|
start_char=START_CHAR_V1_UNADDRESSED,
|
|
data=bytes([int(OmniLinkMessageType.Ack)]),
|
|
)
|
|
seq, fut = conn._send_encrypted(ack)
|
|
reply = conn._decode_inner(await fut)
|
|
|
|
if reply.opcode == int(OmniLinkMessageType.EOD):
|
|
print(f"reply {i} (seq={seq}): EOD — end of stream after {i-1} records")
|
|
return 0
|
|
if reply.opcode == int(OmniLinkMessageType.NameData):
|
|
print(f"reply {i} (seq={seq}): {_decode_namedata(reply.payload)}")
|
|
else:
|
|
print(f"reply {i} (seq={seq}): unexpected opcode {reply.opcode}, "
|
|
f"payload={reply.payload.hex()}")
|
|
return 1
|
|
|
|
print(f"\nstopped after {args.max} replies (no EOD seen)")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--host", default="192.168.1.9")
|
|
parser.add_argument("--port", type=int, default=4369)
|
|
parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key")
|
|
parser.add_argument("--max", type=int, default=20, help="stop after N replies")
|
|
parser.add_argument("--debug", action="store_true")
|
|
args = parser.parse_args()
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.debug else logging.WARNING,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
return asyncio.run(amain(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|