feat: add PRT3 ASCII serial connection type#596
feat: add PRT3 ASCII serial connection type#596NaanyaBiz wants to merge 20 commits intoParadoxAlarmInterface:devfrom
Conversation
Describes why PRT3 is a separate connection type from native serial, where the transport/protocol/adapter layers live, what can be reused from existing PAI, the v1 scope, and documented limitations.
Maps the approved architecture onto the actual PAI codebase: exact insertion points in config.py and paradox.py, full spec for every new file/class, runtime path analysis, test patterns for each layer, phased delivery plan, and risk register.
Add the minimum non-invasive scaffolding for the PRT3 connection type: - paradox/config.py: add "PRT3" to CONNECTION_TYPE allowed list; add PRT3_SERIAL_PORT, PRT3_SERIAL_BAUD, PRT3_MAX_AREAS, PRT3_MAX_ZONES, PRT3_MAX_USERS config keys. - paradox/paradox.py: add PRT3 branch in connection property (instantiates PRT3SerialConnection); add guard in connect() that returns False with an error log — binary panel detection does not apply to PRT3. - paradox/connections/prt3/: PRT3Protocol (ASCII line framer skeleton) and PRT3SerialConnection (only overrides make_protocol()). - paradox/hardware/prt3/: PRT3Panel, PRT3Paradox, parser dataclasses, encoder stubs, PRT3Event, property_map re-export from spectra_magellan. All protocol logic raises NotImplementedError with Phase 2/3 TODO labels. - tests/connection/prt3/, tests/hardware/prt3/: import smoke tests and NotImplementedError assertions; 869 existing tests unaffected. - docs/prt3-architecture.md: branch status note added. No existing Serial or IP150 behaviour is changed.
Implements the complete PRT3 ASCII protocol layer: parser.py: parse_line() converts raw \r-stripped ASCII lines into typed dataclasses (PRT3CommStatus, PRT3BufferFull, PRT3CommandEcho, PRT3AreaStatus, PRT3ZoneStatus, PRT3LabelReply, PRT3SystemEvent, PRT3PgmEvent). Malformed lines log WARNING and return None; no exceptions raised to caller. encoder.py: pure functions for all v1 outbound commands (RA/RZ/AL/ZL/UL status/label requests; AA/AQ/AD arm/quick-arm/disarm; PE/PM/PF panic; UK utility key). Each returns bytes including trailing \r. Input validated; out-of-range args raise ValueError. No undocumented commands. tests/hardware/prt3/fixtures.py: position-verified PRT3 wire-format fixture strings for all message types, plus ALL_FIXTURES collection for replay tests. tests/hardware/prt3/test_encoder.py: 339 tests covering exact output format, boundary values (area 1/8, zone 1/192, user 1/999, key 1/251), all arm modes, code length validation, error cases, echo prefix table, and cross-cutting bytes/ASCII/\r assertions. tests/hardware/prt3/test_parser.py: full fixture-driven parser coverage including all flag combinations, boundary numbers, disambiguation of COMM status vs command echoes, failed info commands, malformed/unknown lines, and all-fixtures smoke test. 1194 tests pass; 0 regressions.
adapter.py: pure normalization boundary — arm_state enum to PAI boolean partition flags, zone open_state to PAI zone flags, label reply to PAI labels dict, build_flat_status for convert_raw_status compatibility. panel.py: Phase 2 — parse_message delegates to parse_line, initialize_ communication awaits COMM&ok, load_labels polls AL/ZL/UL sequentially, request_status polls RA/RZ and returns flat status dict (single virtual address 0 covers all configured areas+zones), control_partitions maps arm/disarm commands to AA/AQ/AD (with PRT3_USER_CODE fallback to quick- arm), send_panic maps to PE/PM/PF, _prt3_send_wait helper for typed request/reply via wait_for_message. event.py: EVENT_MAP for G-groups 0-66 (zone, arm/disarm, bypass, alarm, panic, trouble, power-up, utility key, status broadcasts). PRT3Event. from_prt3 constructs PAI Event without LiveEvent binary assertions. protocol.py: PRT3Protocol framer complete. data_received buffers bytes and emits complete cr-terminated lines. send_message writes raw bytes. Compat fixes: EventMessageHandler/ErrorMessageHandler can_handle guards against non-Container types. HandlerRegistry no-handler log guarded. config.py: PRT3_USER_CODE and PRT3_COMM_TIMEOUT config keys. 1260 tests pass.
- paradox.py: implement PRT3 connect() path — ASCII COMM&ok handshake, synthesised DetectedPanel for stable HA entity anchoring, event handler registration, control_utility_key() dispatcher - paradox.py: guard sync_time() and _clean_session() — PRT3 has no SetTimeDate or CloseConnection frame - hardware/prt3/panel.py: full panel implementation — initialize_communication, load_labels (AL/ZL/UL), request_status (RA/RZ), control_partitions (AA/AQ/AD), send_utility_key (UK), send_panic (PE/PM/PF); retries=1 on utility key (commands are not idempotent) - hardware/prt3/event.py: complete event map for G000-G066; G065 per-N dispatch (N001=exit_delay, N002=entry_delay) so HA shows "arming" state during countdown without flickering; arm/disarm events clear exit_delay - hardware/prt3/parser.py: parser fixes and PGM line support - config.py: add PRT3_UTILITY_KEYS and MQTT_UTILITY_KEY_TOPIC - config/pai.conf.example: PRT3 section with annotated options Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- entities/button.py: new UtilityKeyButton entity — command_topic wired to
paradox/{control}/{utility_key}/{n}, HA discovery config with payload_press
- entities/factory.py: make_utility_key_button() factory method
- homeassistant.py: _publish_utility_key_configs() publishes one button
discovery config per PRT3_UTILITY_KEYS entry on status_update
- basic.py: subscribe paradox/control/utility_key/+ when CONNECTION_TYPE=PRT3;
_mqtt_handle_utility_key() dispatches to control_utility_key()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When an interface registers with MQTTConnection after the broker connection is already established, on_connect was never called, leaving control subscriptions and connected_future in an uninitialised state. Fire on_connect immediately in register() when already connected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- test_command_dispatch.py: arm/disarm/quick-arm/panic/utility-key command encoding and echo matching; failure and timeout paths - test_panel.py: initialize_communication, load_labels, request_status; COMM&ok / COMM&fail paths - test_mqtt_integration.py: panel_detected on connect; UtilityKeyButton serialize and topic construction; factory wiring; _publish_utility_key_configs publishes one config per key; PRT3Event timestamp/subtype/props; G065 per-N exit/entry delay dispatch; arm/disarm clears exit_delay - fixtures.py: add G065 exit/entry delay event fixtures, PGM fixtures - test_parser.py: updated for PGM line and edge cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers hardware wiring, baud configuration, user code setup, utility keys, HA entity types, arming state mapping, and v1 limitations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test expected send_utility_key to retry on timeout and succeed on the second attempt. Retries were removed to prevent gate/latch double-triggering (utility key commands are not idempotent). Updated test asserts False return and exactly one write on timeout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- protocol.py: guard against buffer overflow (>512 bytes) - panel.py: send_panic() accepts list[int] to match EVO/SM signature; add arm_sleep alias (instant arm); warn when zone-poll timeout exceeds half of KEEP_ALIVE_INTERVAL - runtime.py: remove TODO-stub file (logic lives in panel.py / paradox.py) - config.py: lower PRT3_MAX_USERS default to 32; add security note on PRT3_USER_CODE storage - mqtt/core.py: replay correct 5-arg on_connect signature to late registrars - mqtt/abstract_entity.py: fix format() → format_map() with correct dict literal (was a set literal — would have raised TypeError at runtime) - mqtt/button.py: same format_map fix - paradox.py: re-raise CancelledError from control_utility_key instead of swallowing it - tests: update panic tests to pass [int] list; update CancelledError test to assert re-raise Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
prt3-architecture.md: remove stale "not yet functional" status section, add adapter.py to layer layout, remove deleted runtime.py, expand wiring section to reflect actual scope (MQTT utility key integration, full PRT3_* config key list). prt3-implementation-plan.md: removed — planning artifact; the PRT3Paradox subclass design it describes was superseded by the guard approach that was actually built. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new first-class PRT3 ASCII serial backend (Paradox PRT3 printer module) alongside existing Serial/IP paths, including parsing/encoding, panel implementation, event mapping, and MQTT/HA utility-key button support.
Changes:
- Add PRT3 connection/protocol framing and a full
PRT3Panelimplementation (parser, encoder, adapter, event mapping). - Integrate PRT3 into runtime selection (
paradox.py) and add protocol-gap guards plus PRT3 event dispatch. - Extend MQTT + Home Assistant discovery with Utility Key button entities and handling; add comprehensive PRT3-focused tests and docs.
Reviewed changes
Copilot reviewed 31 out of 33 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
paradox/paradox.py |
Adds PRT3 connection selection, connect path, utility-key control API, and PRT3 event dispatch integration. |
paradox/config.py |
Adds PRT3_* config keys and MQTT_UTILITY_KEY_TOPIC. |
paradox/connections/prt3/connection.py |
Introduces PRT3SerialConnection using ASCII protocol framing. |
paradox/connections/prt3/protocol.py |
Implements CR-delimited ASCII line framer for PRT3. |
paradox/connections/prt3/__init__.py |
Declares PRT3 connection package. |
paradox/hardware/prt3/parser.py |
Parses PRT3 ASCII lines into typed dataclasses. |
paradox/hardware/prt3/encoder.py |
Encodes PRT3 ASCII commands (status/labels/arm/disarm/panic/utility key). |
paradox/hardware/prt3/adapter.py |
Normalizes PRT3 status/labels into PAI’s storage/status formats. |
paradox/hardware/prt3/event.py |
Maps PRT3 G-group events into PAI Event objects. |
paradox/hardware/prt3/panel.py |
Implements Panel API over PRT3 polling + control + async events. |
paradox/hardware/prt3/property.py |
Re-exports existing property map for consistency with other panels. |
paradox/hardware/prt3/__init__.py |
Declares PRT3 hardware package. |
paradox/lib/async_message_manager.py |
Guards event/error handlers so non-binary (PRT3) messages don’t assert/crash. |
paradox/lib/handlers.py |
Makes “No handler” logging robust for non-Container message types. |
paradox/interfaces/mqtt/core.py |
Replays on_connect to late-registered interfaces (fix for missed subscriptions). |
paradox/interfaces/mqtt/basic.py |
Subscribes + handles PRT3 utility-key MQTT commands. |
paradox/interfaces/mqtt/homeassistant.py |
Publishes HA discovery configs for PRT3 utility-key buttons. |
paradox/interfaces/mqtt/entities/factory.py |
Adds factory method to build utility-key button entities. |
paradox/interfaces/mqtt/entities/button.py |
Adds UtilityKeyButton HA discovery entity for PRT3 utility keys. |
paradox/interfaces/mqtt/entities/abstract_entity.py |
Fixes entity prefix formatting (format_map) used in HA discovery serialization. |
tests/connection/prt3/test_protocol.py |
Tests PRT3 protocol framing, buffering, and send behavior. |
tests/connection/prt3/__init__.py |
Declares test package for PRT3 connection. |
tests/hardware/prt3/fixtures.py |
Adds verified PRT3 ASCII fixture lines for deterministic tests. |
tests/hardware/prt3/test_parser.py |
Extensive coverage of PRT3 parser behavior and edge cases. |
tests/hardware/prt3/test_encoder.py |
Validates command encoding, echo prefixes, ASCII, and boundaries. |
tests/hardware/prt3/test_adapter.py |
Tests normalization into PAI storage/status formats. |
tests/hardware/prt3/test_panel.py |
Tests request/reply cycles and retry behavior for panel methods. |
tests/hardware/prt3/test_command_dispatch.py |
Tests Paradox control dispatch for partitions and utility keys. |
tests/hardware/prt3/test_mqtt_integration.py |
Tests panel_detected emission, HA discovery JSON, and event passthrough. |
tests/hardware/prt3/__init__.py |
Declares test package for PRT3 hardware. |
docs/prt3-usage.md |
Documents setup, configuration, HA behavior, and limitations. |
docs/prt3-architecture.md |
Documents PRT3 layering, runtime wiring points, and v1 scope. |
config/pai.conf.example |
Adds example PRT3 configuration and MQTT utility-key topic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
event.py: replace dict() constructor calls with {} literals (43 instances)
button.py: same (1 instance)
parser.py: reword PRT3PgmEvent.on inline comment to avoid false
"commented-out code" flag; refactor parse_line to pre-compute
has_digit_index — eliminates repeated and-chains and 3 nesting levels,
reducing cognitive complexity from 23 to 12
panel.py: extract _load_label_range() from load_labels (complexity 27→8),
extract _poll_area_statuses() and _poll_zone_statuses() from
request_status (complexity 18→5), extract _build_partition_cmd() from
control_partitions (complexity 17→9)
paradox.py: extract _prt3_connect() from connect() (complexity 20→13)
test_protocol.py: add explanatory comments to empty interface stubs;
rename unused 'handler' variable to '_'
test_adapter.py: rename unused loop variables to '_'
test_encoder.py: add NOSONAR to intentional wrong-type test argument
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
homeassistant.py: gate utility-key discovery publish on CONNECTION_TYPE == 'PRT3' to prevent non-functional HA buttons on non-PRT3 connections; add 1..251 range check with warning in _publish_utility_key_configs() panel.py: redact user code from retry-timeout log — log only the 5-char command prefix instead of full command bytes config.py: allow PRT3_MAX_ZONES and PRT3_MAX_USERS to be set to 0 to disable zone polling / user label loading respectively paradox.py: fix misleading ConnectionError message on link-unresponsive failure; catch ValueError/TypeError in control_utility_key() for out-of-range key numbers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
panel.py: remove vestigial 'timeout' parameter from _prt3_send_wait — no caller passed an explicit value; cfg.IO_TIMEOUT is used directly parser.py: extract _parse_info_reply() from parse_line, reducing cognitive complexity from 22 to 12 (limit: 15) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Agent-Logs-Url: https://github.com/ParadoxAlarmInterface/pai/sessions/94454ed5-3add-486f-ae3e-ad5f8295702c Co-authored-by: yozik04 <2420038+yozik04@users.noreply.github.com>
Three observed bugs caused HA to show wrong partition state:
1. Stuck "arming" after a quick arm+disarm cycle. G014 (Disarm with
User Code) can fire with area=0 (global) on at least some firmware;
the previous handler called get_container_object("partition", 0),
got None, and silently dropped the change. Partition events with
area in (0, 255) are now broadcast to every known partition so the
disarm clears state on all of them.
2. Brief "armed_away" flash on disarm from a fully-armed state. Per
PRT3 ASCII spec page 18 G013 is "Disarm with Master", but PAI had
it mapped as "auto-armed" (change={"arm": True, ...}). When a
master code disarmed the panel, G013 fired and set arm=True, then
_update_partition_states fell through to the "armed_away" else
branch (since none of arm_stay/arm_away/arm_force were True) and
published armed_away until the next RA poll corrected it. The arm
group is now (G009, G010, G011, G012) and the disarm group is
(G013, G014, G015, G016, G017) per spec. Adds previously-missing
G009 (Arming with Master) and aligns G011/G012/G016/G017 labels to
the spec; functional change dicts for G011/G012/G015/G016 were
already correct.
3. 4-7 s delay before HA shows "disarmed" after a disarm during exit
delay. The PRT3 ASCII protocol does not reliably emit a disarm
G-event for ASCII-initiated disarms during exit delay (only G065
N=000, which per spec is the "Ready" status flag, not an
"exit_delay cleared" signal -- it can fire while exit delay is
active). control_partition now applies the disarmed state
optimistically on the panel's AD&OK echo (which is authoritative)
and arms a 3 s freeze window during which arm-related keys from RA
polls are dropped. The window prevents a stale RA reply (panel
hasn't internally settled yet) from briefly re-asserting arm=True,
and auto-expires so it can't cause a permanently stuck state.
docs/prt3-architecture.md gains an "Arm/disarm state tracking" section
explaining the three-source state model (RA polling, G-events,
optimistic command echo), the Ready-vs-exit_delay distinction, and the
HA-disarm-during-exit-delay sequence.
Also: control_partition log was promoted from DEBUG to INFO so user-
initiated commands appear in default logs, and a one-line "PRT3 system
event: G..N..A.." log was added in handle_prt3_event_message which
proved essential for diagnosing this class of issue.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for the structured review (recorded in #597) — the multi-agent framing surfaced several things worth fixing. Before acting on the findings I traced each one against the code so we'd target the right things. Below is what I plan to act on, where I'd push back, and what I'd defer. Findings I'll act on
Findings whose impact I read differentlyBug #1 — For binary users only The architectural critique is solid though, and I'd happily take it further: register Bug #2 — The synthetic- That said, the suggested fix (skip replay when Bug #3 — severity scoping Real defect, agreed on the fix. The Bug #4 — severity scoping Real defect, agreed on the fix. The "crashing the run loop" framing isn't quite right: the MQTT path is wrapped by Minor #3 — The orchestrator catches it: DeferringArch #1 (LSP) and Arch #2 (string guards) — agreed in principle, both worth doing. The concrete behaviour issues they cite are mitigated by Minor #3 above, so I'd fold the cleanup into a follow-up rather than the v1 merge: extract Heads-upWhile integration-testing on a live EVO192 panel over the last day, three orthogonal arm/disarm state bugs surfaced and are fixed in
Architecture notes on the three-source state model (RA polling, G-events, command echo) added to |
- Bug ParadoxAlarmInterface#2: mqtt/core.py skip on_connect replay when _last_connect_args is None - Bug ParadoxAlarmInterface#3: panel.py _prt3_send_wait fast-fails on PRT3BufferFull instead of full timeout - Bug ParadoxAlarmInterface#4: PRT3_USER_CODE validated at config load; _build_partition_cmd catches ValueError - Minor ParadoxAlarmInterface#1: PRT3_SERIAL_BAUD set validator [9600, 19200]; config validator extended for int lists - Minor ParadoxAlarmInterface#2: G065 per-N overrides in EVENT_MAP number_overrides; from_prt3 uses generic lookup - Minor ParadoxAlarmInterface#4: handle_prt3_event_message catches expected exceptions explicitly - Minor ParadoxAlarmInterface#5: slice comment in retry warning; expanded PRT3_USER_CODE security doc - Minor ParadoxAlarmInterface#6: tests for buffer-full retry, malformed code, PRT3_USER_CODE/baud validation 1369 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- S112: PRT3_USER_CODE validation raises ValueError instead of Exception - S3776: handle_prt3_event_message complexity reduced by extracting _apply_prt3_event_change helper method (was 16, now below 15) - S3776: _on_status_update complexity reduced by extracting _filter_arm_freeze helper method (was 16, now below 15) - S7504: remove unnecessary list() wrapping dict.keys() in event broadcast - S7503: three sync-only test functions had spurious async keyword removed - S1481: four tests had unused mock_panel renamed to _ 1369 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- prt3-usage.md: PRT3_SERIAL_BAUD is now strictly 9600 or 19200 (not a free range); PRT3_USER_CODE documents the 1-6 digit format, startup validation, and the security note about debug log streams - prt3-architecture.md: _prt3_send_wait buffer-full composite predicate and fast-retry behaviour noted; EVENT_MAP number_overrides pattern documented for future maintainers adding per-N group overrides Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|



Summary
This PR adds support for the Paradox PRT3 printer module as a first-class
connection type alongside the existing
SerialandIPbackends. The PRT3exposes a documented ASCII serial protocol over a standard DB9/USB-serial cable,
providing a practical integration path for panels where the native binary protocol
and IP module are no longer accessible.
Motivation
Two trends have progressively closed off the existing integration paths for Paradox
panels:
Native serial encryption — newer EVO/Spectra firmware encrypts the binary
serial link, making reverse-engineered integrations unreliable or broken outright
on updated panels.
IP150 lockdown — recent IP150 firmware versions restrict connections from
third-party software, breaking IP-based integrations on many panels.
The PRT3 module is unaffected by both. Paradox publishes and maintains its ASCII
protocol; it has been stable across firmware generations and works over commodity
USB-to-serial hardware. Making PRT3 a supported PAI backend preserves integration
capability for a meaningful portion of the Paradox install base.
Architecture
PRT3 is implemented as a peer connection type, not a wrapper around the existing
native serial path.
PRT3SerialConnectionextendsSerialCommunication, overriding only the protocolfactory to produce a line-oriented ASCII framer instead of binary.
PRT3Panelextends the samePanelbase class as EVO/Spectra panels, implementingall required methods via ASCII commands (
RA/RZfor status,AL/ZL/ULforlabels,
AA/AQ/ADfor arm/disarm,UKfor utility keys,PE/PM/PFforpanic).
paradox.pyselects PRT3 in the existingif/elif/elseconnection type chain.MemoryStorage→ MQTT pipeline as other backends.The only special-casing outside the
prt3/module is:sync_time,_clean_session,control_utility_keyguard)PRT3_UTILITY_KEYS)Total: 9 conditionals, all justified by genuine protocol differences.
What's included
New files
paradox/connections/prt3/connection.pyparadox/connections/prt3/protocol.pyasyncioprotocol; splits on\rparadox/hardware/prt3/parser.pyparadox/hardware/prt3/encoder.pyparadox/hardware/prt3/panel.pyparadox/hardware/prt3/event.pyparadox/hardware/prt3/adapter.pyparadox/hardware/prt3/property.pyparadox/hardware/prt3/runtime.pyparadox/interfaces/mqtt/entities/button.pyUtilityKeyButtonHA discovery entitydocs/prt3-usage.mdModified files
paradox/paradox.pyparadox/config.pyPRT3_*config keys,MQTT_UTILITY_KEY_TOPICparadox/interfaces/mqtt/entities/factory.pymake_utility_key_button()paradox/interfaces/mqtt/homeassistant.py_publish_utility_key_configs()paradox/interfaces/mqtt/basic.pyparadox/interfaces/mqtt/core.pyon_connectfor late-registering interfacesconfig/pai.conf.exampleTests
tests/hardware/prt3/test_parser.pytests/hardware/prt3/test_encoder.pytests/hardware/prt3/test_adapter.pytests/hardware/prt3/test_command_dispatch.pytests/hardware/prt3/test_panel.pyinitialize_communication,load_labels,request_statustests/hardware/prt3/test_mqtt_integration.pytests/hardware/prt3/fixtures.pytests/connection/prt3/test_protocol.pyConfiguration
Full options documented in
config/pai.conf.exampleanddocs/prt3-usage.md.Known limitations (v1)
SetTimeDatecommand —SYNC_TIMEis a no-opTesting
All tests use synchronous or
asyncio-mocked transports; no hardware required.