SetupData side (clsHAC.cs:3020-3038): five contiguous bool[8] arrays
immediately after ExitDelay carry per-area config flags. Offsets:
1787..1794: EntryChime
1795..1802: QuickArm
1803..1810: AutoBypass
1811..1818: AllOnForAlarm
1819..1826: TroubleBeep
Verified against live fixture: area 1 shows real homeowner choices
(QuickArm + AllOnForAlarm enabled, others off), unused areas 2-8 carry
the panel defaults (EntryChime/AutoBypass/TroubleBeep on by default).
PerimeterChime and AudibleExitDelay aren't in this contiguous block —
they live past FlashLightNum, HouseCodes flags, and 6 TimeClock
When-structs. Deferred.
New PcaAccount fields:
area_entry_chime, area_quick_arm, area_auto_bypass,
area_all_on_for_alarm, area_trouble_beep — all dict[int, bool].
MockAreaState gains the same five fields. They aren't carried in the
Properties reply on the wire (the OL2 message format doesn't have
them), so they live on MockState for snapshots and any future
SetupData-aware code, but don't surface through HA discovery yet.
v2 client list_area_names fallback: when the Properties walk turns up
no named areas (common — most homes don't name them), synthesize
"Area 1".."Area 8" so HA's _discover_areas has slots to walk.
Mirrors the v1 adapter behaviour exactly.
Knock-on win in the live-fixture HA test: area 1 now reaches
coordinator.data.areas with its configured 60s/90s delays from
SetupData, end-to-end through .pca → MockState → wire Properties →
HA's AreaProperties parser.
Full suite: 499 passed, 1 skipped.
v2 path adds an iterator over UploadProgram with request_reason=1
("next defined after slot"), mirroring the C# ReadConfig loop at
clsHAC.cs:4985 (seed call) and 5331 (per-reply re-issue). The mock
panel now honours reason=1: walks state.programs for the next
slot strictly greater than the requested one, returns EOD when none.
v1 path wraps OmniConnectionV1.iter_streaming(UploadPrograms) and
decodes each ProgramData reply into a Program. The panel already
streams in slot-ascending order from the previous commit, so the
client just decodes-and-yields.
Both methods return AsyncIterator[Program] for HA-side consumption.
Tests cover populated and empty states for both dialects, plus the
raw v2 reason=1 semantics on a single request.
The C# decompile shows enuOmniLinkConnectionType has both Network_TCP=4
and Network_UDP=3 (clsOmniLinkConnection.cs uses udpSend/tcpSend
parallel paths), and clsHAC carries an enuPreferredNetworkProtocol
{TCP, UDP} per-installation byte. User reports their panel is
configured for UDP. The TCP-only assumption was too narrow.
Wire format is identical: same Packet/Message framing, same handshake,
same per-block whitening, same opcodes, same port. Only differences:
* UDP is connectionless; each datagram = one Packet (no stream framing)
* UDP needs explicit retry-on-timeout for reliability
src/omni_pca/connection.py:
- New constructor args: transport: Literal['tcp','udp']='tcp',
udp_retry_count: int = 3
- connect()/close() branch on transport — TCP keeps the existing
asyncio.open_connection + StreamReader/Writer + reader_task path;
UDP uses asyncio.get_running_loop().create_datagram_endpoint with
remote_addr= so transport.sendto(data) works without per-datagram
addrs. The reader_task is TCP-only.
- _write_packet branches between writer.write and udp_transport.sendto
- request() loops up to (1 + udp_retry_count) attempts on UDP, retrying
on RequestTimeoutError; TCP gets a single attempt (existing behavior)
- New _OmniDatagramProtocol that decodes each datagram into a Packet
and delegates to the shared _dispatch (which already knows how to
route handshake / solicited / unsolicited)
src/omni_pca/mock_panel.py:
- serve(transport='tcp'|'udp') public arg; defaults preserve existing
TCP behavior. Internally splits into _serve_tcp / _serve_udp.
- New _MockServerDatagramProtocol that mirrors _handle_client for UDP.
Tracks one active client by addr (single-session, matches Omni's
single-client constraint). Reuses the panel's existing _dispatch_v2,
_reply_*, _build_* helpers — the dispatch logic is unchanged, only
the transport framing differs.
- New _schedule_udp_push for synthesized SystemEvents (seq=0) push
to the active client's addr after state mutations.
src/omni_pca/client.py:
- OmniClient gains transport= and udp_retry_count= kwargs that pass
through to OmniConnection. Default is 'tcp' so existing callers
are unaffected.
tests/test_e2e_udp.py — 6 e2e tests:
- handshake roundtrip
- get_system_information
- arm area with right code
- arm with wrong code -> CommandFailedError
- turn unit on -> push UnitStateChanged event
- wrong ControllerKey -> HandshakeError
All run under 0.2s. Combined with the existing TCP suite: 357 tests
pass (was 351), ruff clean across src/ tests/.
The HA integration's config_flow still defaults to TCP; users on UDP
panels can manually set transport= via the OmniClient init path. A
follow-up commit will add transport to the HA config flow as a
dropdown option.
src/omni_pca/client.py — wire OmniClient.events() that returns an async
iterator over typed SystemEvent objects (built on events.EventStream).
src/omni_pca/mock_panel.py — substantial expansion:
- Per-object state dataclasses (MockUnitState, MockAreaState, MockZoneState,
MockThermostatState) plus user_codes table for security validation
- Backward-compat: existing callers passing {idx: 'NAME'} strings still work
via __post_init__ string-promotion to the matching Mock*State instance
- New opcode handlers:
Command (20) -> Ack with state mutation, dispatches
UNIT_ON/OFF/LEVEL, BYPASS/RESTORE_ZONE,
SET_THERMOSTAT_HEAT/COOL/SYS/FAN/HOLD
ExecuteSecurityCommand (74) -> Ack on valid code (mode applied);
Nak on invalid code
RequestStatus (34) -> Status (35) for Zone/Unit/Area/Thermostat
hard-coded record sizes per
clsOL2MsgStatus.cs:13-27
RequestExtendedStatus (58) -> ExtendedStatus (59) with object_length
prefix, richer fields per object type
AcknowledgeAlerts (60) -> Ack
- Synthesized SystemEvents (55) push on state change with seq=0; events round-
trip cleanly through events.parse_events() (validated by tests, not just
asserted in code)
tests/test_e2e_client_mock.py — +9 e2e tests covering arm/disarm with code
validation, unit on/off/level, zone bypass/restore, thermostat setpoint,
push events for arming and unit changes, acknowledge_alerts.
203 passed (was 194), 2 skipped (HA harness + .pca fixture). Ruff clean.
Library v1.0 surface complete: read-only, command, status, extended status,
events. Next: rebuild the HA custom_component on top of this.