diff --git a/config/pai.conf.example b/config/pai.conf.example index 2c09493b..08f2e35d 100644 --- a/config/pai.conf.example +++ b/config/pai.conf.example @@ -22,12 +22,29 @@ import logging # DEVELOPMENT_DUMP_MEMORY = False # ### Connection Type -# CONNECTION_TYPE = 'Serial' # Serial or IP +# CONNECTION_TYPE = 'Serial' # Serial or IP or PRT3 # ### Serial Connection Details # SERIAL_PORT = '/dev/ttyS1' # Pathname of the Serial Port # SERIAL_BAUD = 9600 # 9600 for SP/MG. For EVO: Use 38400(default setting) or 57600. 115200 for + versions # +### PRT3 ASCII Printer Module Connection +# CONNECTION_TYPE = 'PRT3' +# PRT3_SERIAL_PORT = '/dev/ttyUSB0' # Serial port the PRT3 module is attached to +# PRT3_SERIAL_BAUD = 9600 # PRT3 fixed baud rate (9600) +# PRT3_USER_CODE = '' # User code for arm/disarm (e.g. '1234'). +# # Leave empty to use quick-arm (requires +# # One-Touch Arming enabled on the panel). +# # Disarm always requires a user code. +# PRT3_MAX_AREAS = 2 # Number of areas (partitions) to poll (1-8) +# PRT3_MAX_ZONES = 96 # Number of zones to poll (1-96) +# PRT3_MAX_USERS = 999 # Number of users to load labels for +# PRT3_COMM_TIMEOUT = 10 # Seconds to wait for COMM&ok on connect +# PRT3_UTILITY_KEYS = { # Keys to expose as HA button entities +# 1: 'Lock Front Door', # key number → display label +# 5: 'Activate Garden Lights', # publish {} or omit to disable +# } +# ### IP Connection Details # IP_CONNECTION_HOST = '127.0.0.1' # IP Module address when using direct IP Connection # IP_CONNECTION_PORT = 10000 # IP Module port when using direct IP Connection @@ -105,6 +122,7 @@ import logging # # MQTT_NOTIFICATIONS_TOPIC = 'notifications' # MQTT_SEND_PANIC_TOPIC = 'panic' +# MQTT_UTILITY_KEY_TOPIC = 'utility_key' # PRT3 only: {base}/control/utility_key/{nnn} # MQTT_PUBLISH_RAW_EVENTS = True # MQTT_INTERFACE_TOPIC = 'interface' # MQTT_TOGGLE_CODES = {} diff --git a/docs/prt3-architecture.md b/docs/prt3-architecture.md new file mode 100644 index 00000000..ca44ab01 --- /dev/null +++ b/docs/prt3-architecture.md @@ -0,0 +1,236 @@ +# PRT3 Connection — Architecture Notes + +## Why PRT3 is a separate connection type + +PRT3 is not a transport wrapper around the existing Paradox binary serial protocol. +It is a distinct ASCII protocol spoken by the PRT3 Printer Module, which acts as a +gateway between a home automation host and the Digiplex EVO control panel's combus. + +| Property | Native serial / IP150 | PRT3 | +|---|---|---| +| Framing | Binary, variable-length, nibble-pattern + checksum | `\r`-delimited ASCII lines | +| Handshake | `InitiateCommunication` / `StartCommunication` binary exchange | Await `COMM&ok\r` on startup | +| Panel detection | `product_id` from `StartCommunicationResponse` | Known at config time | +| Labels | EEPROM reads at arbitrary addresses | `ZL`, `AL`, `UL` ASCII commands | +| Status | Binary RAM block reads at mapped addresses | `RA`, `RZ` ASCII poll commands | +| Events | Binary event packets (`0xE` command byte) | `G{ggg}N{nnn}A{aaa}\r` ASCII events | +| Auth | PC password embedded in binary frame | User code embedded in arm/disarm commands | + +Treating PRT3 as native serial would require faking binary frames that the EVO panel +never generates — this would be fragile, undocumented, and unmaintainable. +PRT3 must be a first-class connection type with its own protocol adapter. + +## Layer layout + +``` +paradox/connections/prt3/ + __init__.py + connection.py PRT3SerialConnection(Connection) + - opens serial port via serial_asyncio + - makes_protocol() returns PRT3Protocol + protocol.py PRT3Protocol(ConnectionProtocol) + - buffers incoming bytes until \r + - emits complete ASCII lines via on_message() + - send_message() writes bytes directly (no framing) + - variable_message_length() is a no-op + +paradox/hardware/prt3/ + __init__.py + panel.py PRT3Panel(Panel) + - implements all Panel abstract methods + - routes parsed lines to state updates or events + - reply routing via _prt3_send_wait() / wait_for_message() + - _prt3_send_wait uses a composite predicate matching both + the caller's expected reply AND PRT3BufferFull ('!'); + a buffer-full response triggers a fast retry rather than + waiting the full IO_TIMEOUT + parser.py parse_line(line: str) -> PRT3Message | None + - pure function, no side effects + - handles: COMM&ok/fail, echo &OK/&fail, RA/RZ replies, + ZL/AL/UL replies, G/N/A events, PGM ON/OFF + encoder.py encode_*(...) -> bytes + - pure command builders: arm, disarm, panic, label + requests, status requests, utility key + event.py EVENT_MAP: dict[int, dict] + - maps G-group codes to PAI event descriptors + - entries may include a "number_overrides" sub-dict keyed + by N value for groups where the N field changes the event + meaning (e.g. G065 N=001 exit_delay, N=002 entry_delay); + from_prt3() applies overrides generically + adapter.py normalise_area_status() / normalise_zone_status() + - converts PRT3 status dataclasses into PAI storage dicts + property.py PROPERTY_MAP + - maps state-change keys to PAI property descriptors +``` + +### Wiring into the existing runtime + +Minimal additions to existing files: + +**`paradox/config.py`** — adds `"PRT3"` to the `CONNECTION_TYPE` allowed list and +new `PRT3_*` config keys (`PRT3_SERIAL_PORT`, `PRT3_SERIAL_BAUD`, `PRT3_MAX_AREAS`, +`PRT3_MAX_ZONES`, `PRT3_MAX_USERS`, `PRT3_USER_CODE`, `PRT3_COMM_TIMEOUT`, +`PRT3_UTILITY_KEYS`). + +**`paradox/paradox.py`** — one `elif cfg.CONNECTION_TYPE == "PRT3":` branch in the +`connection` property; a guard in `connect()` that skips the binary panel detection +path and directly instantiates `PRT3Panel`; protocol-gap guards for `sync_time`, +`_clean_session`, and `control_utility_key`. + +**`paradox/interfaces/mqtt/`** — utility key button discovery (`UtilityKeyButton` +entity, HA discovery publish, MQTT subscription and command handler). + +Everything else is either inherited unchanged or lives in the new modules above. + +## What can be reused from existing PAI + +| Component | Reused as-is | +|---|---| +| `Connection` base class | Yes — `PRT3SerialConnection` subclasses it | +| `serial_asyncio` transport | Yes — same call, different `make_protocol()` | +| `AsyncMessageManager` + `HandlerRegistry` | Yes — raw and parsed handler dispatch | +| `MemoryStorage` | Yes — zone/partition/user containers | +| `ps` pub/sub bus | Yes — `labels_loaded`, `status_update`, `events`, `changes` | +| `Paradox._on_labels_load` | Yes | +| `Paradox._on_status_update` | Yes | +| `Paradox._on_event` | Yes | +| `Paradox._update_partition_states` | Yes | +| MQTT interface | Yes — unchanged | +| Home Assistant discovery | Yes — unchanged | +| All text / GSM / push interfaces | Yes — unchanged | +| `InterfaceManager` | Yes — unchanged | +| Config loading / env override | Yes — unchanged | +| `event.py` `Change` / `Event` / `LiveEvent` types | Yes | + +What is **not** reused: + +- `SerialConnectionProtocol` — binary framer, replaced by `PRT3Protocol` +- `Panel.load_labels()` / `_eeprom_batch_reader()` — EEPROM-based, replaced by ASCII label requests +- `Panel.handle_status()` / `parsers/status.py` — binary status blocks, replaced by ASCII status parsing +- `create_panel()` factory — PRT3 panel is instantiated directly +- `parsers/` (EVO/Spectra `Construct` parsers) — not applicable + +## v1 scope + +**In scope:** + +- Serial transport only (direct USB or BUS2SER) +- `COMM&ok` / `COMM&fail` status handling +- Area label reads (`AL`) +- Zone label reads (`ZL`) +- User label reads (`UL`) +- Area status polling (`RA`) — arm state, trouble, alarm, ready flags +- Zone status polling (`RZ`) — open/closed/tamper/alarm/fire/supervision/battery +- Async system event parsing (`G{ggg}N{nnn}A{aaa}`) +- Arm / quick arm / disarm (`AA`, `AQ`, `AD`) +- Emergency / medical / fire panic (`PE`, `PM`, `PF`) +- Utility key commands (`UK`) +- MQTT publishing via existing PAI MQTT interface +- Home Assistant discovery via existing PAI HA interface + +**Explicitly excluded from v1:** + +- Virtual inputs (`VO` / `VC`) — not needed for alarm state integration +- Virtual PGMs (`PGM{nn}ON/OFF`) — complex to map, deferred +- IP transport — PRT3 is a serial module; TCP tunnelling is out of scope +- Multi-panel sites + +## Arm/disarm state tracking + +The PRT3 ASCII protocol does **not** give a single authoritative signal for "the +partition is now armed" or "the partition is now disarmed". PAI synthesises that +state from three sources, each of which is necessary to handle real-world panel +behaviour without HA showing the wrong state: + +### 1. RA polling (`paradox/hardware/prt3/adapter.py:partition_status_from_area`) + +Every poll cycle the panel reports an arm-state character (D/A/F/S/I) for each +configured area. This drives `arm`, `arm_stay`, `arm_away`, `arm_force` in +storage. Polling lags by 4–7 s on a panel with many zones (`KEEP_ALIVE_INTERVAL` += 10 s, plus per-element 0.8 s timeouts), and during the brief window between +issuing a disarm command and the next RA cycle, the panel can still report +`arm_state='armed_stay'` — the panel hasn't internally settled yet. + +### 2. G-events (`paradox/hardware/prt3/event.py:EVENT_MAP`) + +Async system events fire on state transitions. The mapping follows the PRT3 +ASCII Programming Guide §"System Event Group Codes" (page 18): + +| Group | Spec description | Effect on storage | +|---|---|---| +| 009 | Arming with Master | `arm=True, exit_delay=False` | +| 010 | Arming with User Code | `arm=True, exit_delay=False` | +| 011 | Arming with Keyswitch | `arm=True, exit_delay=False` | +| 012 | Special Arming (auto, one-touch, …) | `arm=True, exit_delay=False` | +| 013 | Disarm with Master | `arm=False, exit_delay=False` | +| 014 | Disarm with User Code | `arm=False, exit_delay=False` | +| 015 | Disarm with Keyswitch | `arm=False, exit_delay=False` | +| 016 | Disarm after alarm with Master | `arm=False, exit_delay=False` | +| 017 | Disarm after alarm with User Code | `arm=False, exit_delay=False, audible_alarm=False` | +| 064 | Status 1 (Armed/Stay/Force/Instant/etc.) | informational | +| 065 N=001 | Exit Delay flag active | `exit_delay=True` | +| 065 N=002 | Entry Delay flag active | `entry_delay=True` | +| 065 N=000 | Ready (no zones open) — informational | no change | + +**Important:** G065 N=000 is the *Ready* status flag, not a "delay cleared" +signal. It can fire while exit delay is still active (an area can be Ready +*and* in Exit Delay simultaneously — Ready means no zones are open). +`exit_delay` is cleared by arm/disarm events, not by Ready snapshots. + +**Area field for G014:** the spec table shows `001-008` (specific area), but +on at least some firmware revisions a global keypad disarm fires `G014…A000`. +Partition events with `area ∈ {0, 255}` are broadcast to every known partition +in `Paradox.handle_prt3_event_message` so the change isn't silently dropped. + +### 3. Optimistic update on command echo (`paradox/paradox.py:control_partition`) + +When PAI sends `AD{aaa}{code}` and the panel echoes `AD{aaa}&OK`, the panel +*has* disarmed — that echo is authoritative. G-events for ASCII-initiated +disarms during exit delay are not emitted reliably (the user's EVO192 only +sends `G065N000A015` which carries no disarm semantics), so PAI applies the +disarmed state directly on echo: + +``` +arm=False, arm_stay=False, arm_away=False, arm_force=False, +exit_delay=False, entry_delay=False +``` + +To stop a stale RA poll (issued microseconds after the echo, when the panel's +internal arm flags haven't updated yet) from briefly re-asserting `arm=True`, +arm-related properties from RA updates are dropped for **3 seconds** after a +disarm command is accepted (`Paradox._partition_arm_freeze_until`). The window +auto-expires, so even pathological panel behaviour can't cause a permanently +stuck state. + +### Sequence: HA disarm during exit delay + +``` +t+0 MQTT paradox/control/partitions/Downstairs ← "disarm" +t+0 PAI → AD002{code} +t+700ms Panel echoes AD002&OK + → optimistic update: arm=False, exit_delay=False, … + → freeze window armed (3 s) + → _update_partition_states publishes current_state="disarmed" + → HA UI updates immediately +t+800ms RA poll for area 2 returns arm_state='armed_stay' (panel still settling) + → arm-keys filtered by freeze; non-arm fields (trouble, ready_status) flow through +t+3.7s RA poll returns arm_state='disarmed' + → freeze expired, arm fields applied normally; state already correct +``` + +## Limitations to document + +| Limitation | Detail | +|---|---| +| Zone bypass | PRT3 has no zone bypass command; `control_zones()` raises `NotImplementedError` | +| PGM / output control | No direct PGM command in PRT3 spec; `control_outputs()` raises `NotImplementedError` | +| Door / access control | No door commands in PRT3 spec | +| Module PGM outputs | No module bus access via PRT3 | +| EEPROM / memory dump | PRT3 provides no EEPROM read facility | +| Time synchronisation | PRT3 has no set-time command | +| Panel count | Only one area/zone count configuration; must be set to match the physical panel | +| EVO48 / EVO96 / EVO192 / DGP-848 differences | Max zones and areas differ; controlled by config, not auto-detected | +| Quick Arm | Requires "One-Touch Arming" enabled on the panel; otherwise the command is silently ignored | +| User codes | Codes must be provided at command time; PAI does not store or manage user codes | +| Event history | PRT3 does not replay buffered events on connect; only live events are received | +| Reconnection | On serial disconnect, all in-flight label/status requests are lost; full re-init on reconnect | diff --git a/docs/prt3-usage.md b/docs/prt3-usage.md new file mode 100644 index 00000000..0676843d --- /dev/null +++ b/docs/prt3-usage.md @@ -0,0 +1,123 @@ +# PRT3 Connection — Usage Guide + +## What is PRT3? + +The PRT3 is a Paradox printer module that exposes an ASCII serial interface over +a DB9 cable. Unlike the native EVO/Spectra binary serial protocol (which requires +knowledge of internal EEPROM addresses and is encrypted on newer firmware), the +PRT3 protocol is documented by Paradox and uses human-readable ASCII commands. + +### Why PRT3 matters + +- **Native serial encryption**: Newer Paradox firmware encrypts the native binary + serial link, making reverse-engineered integrations unreliable or impossible. +- **IP150 lockdown**: Recent IP150 firmware versions restrict third-party connections, + breaking IP-based integrations on many panels. +- **PRT3 is stable**: Paradox publishes and maintains the PRT3 ASCII protocol. It + works over a simple USB-to-serial adapter and is unaffected by firmware encryption + changes to the native protocol. + +PRT3 is therefore a practical, long-term path for integrating Paradox panels that +are otherwise inaccessible. + +--- + +## Hardware setup + +1. Connect the Paradox PRT3 module to the panel's combus. +2. Wire the PRT3's DB9 serial port to a USB-to-serial adapter on your host. +3. Confirm the PRT3 baud rate matches your config (factory default: 9600 baud, + though some panels ship set to 19200 — check your PRT3 module's DIP switches). + +--- + +## Configuration + +Set `CONNECTION_TYPE = 'PRT3'` and configure the PRT3 section in `pai.conf`: + +```python +CONNECTION_TYPE = 'PRT3' + +PRT3_SERIAL_PORT = '/dev/ttyUSB0' # Port the PRT3 module is attached to +PRT3_SERIAL_BAUD = 9600 # Must be 9600 or 19200 — matches PRT3 DIP switch setting + +# User code for arm/disarm commands. Must be 1–6 digits, or leave empty +# to use quick-arm (requires One-Touch Arming enabled on the panel). +# Disarm always requires a valid user code. An invalid format is rejected +# at startup so a misconfigured code fails fast rather than silently at +# first disarm attempt. +# SECURITY: store pai.conf chmod 600 root-owned. The code does not appear +# in PAI logs at default log levels; disable LOGGING_DUMP_MESSAGES and any +# byte-level serial tracing before sharing debug logs. +PRT3_USER_CODE = '1234' + +PRT3_MAX_AREAS = 2 # Number of areas (partitions) to poll (1–8) +PRT3_MAX_ZONES = 32 # Number of zones to poll (1–96 for EVO48; up to 192 for EVO192) +PRT3_MAX_USERS = 32 # Number of users to load labels for + +PRT3_COMM_TIMEOUT = 10 # Seconds to wait for COMM&ok on connect + +# Utility keys: expose panel-programmed outputs as HA button entities. +# Map key numbers (1–251) to display labels. +PRT3_UTILITY_KEYS = { + 1: 'Lock Front Gate', + 2: 'Activate Garden Lights', +} +``` + +### Utility key MQTT topic + +Utility key press commands arrive on: + +``` +{MQTT_BASE_TOPIC}/{MQTT_CONTROL_TOPIC}/{MQTT_UTILITY_KEY_TOPIC}/{key_number} +``` + +Default: `paradox/control/utility_key/1` + +The payload is ignored — any publish to the topic triggers the key. + +--- + +## Home Assistant integration + +With `MQTT_HOMEASSISTANT_AUTODISCOVERY_ENABLE = True` (default), PAI publishes +discovery configs for: + +- **alarm_control_panel** entities — one per area +- **binary_sensor** entities — one per zone +- **button** entities — one per entry in `PRT3_UTILITY_KEYS` + +The alarm panel entity supports: `disarm`, `arm_away`, `arm_home`, `arm_night`. + +### Arming states + +| HA state | Meaning | +|----------|---------| +| `disarmed` | Area is disarmed | +| `arming` | Exit delay in progress | +| `armed_away` | Armed away | +| `armed_home` | Armed stay/instant | +| `pending` | Entry delay in progress | +| `triggered` | Alarm active | + +--- + +## Limitations (v1) + +- **Read-only zone control**: zones cannot be bypassed or forced via PRT3. +- **No output (PGM) control**: virtual PGM outputs are parsed but not acted on. +- **No EEPROM/definition reads**: zone/area/user labels are read via ASCII label + commands; internal panel definitions are not available. +- **No time sync**: PRT3 has no `SetTimeDate` command; `SYNC_TIME` is a no-op. +- **Poll-based status**: area and zone states are established by polling on + connect; ongoing state is maintained via async system events. A brief gap + on reconnect is possible. +- **Area count is fixed by config**: unlike EVO panels, the PRT3 interface does + not report the number of enrolled areas. Set `PRT3_MAX_AREAS` to match your + panel programming. +- **Utility keys are not idempotent**: each command triggers the programmed + action once (e.g. a gate toggle). PAI sends no retries for utility key + commands to avoid double-triggering. +- **User code required for disarm**: quick-arm (One-Touch) is supported for + arming, but disarm always requires `PRT3_USER_CODE` to be set. diff --git a/paradox/config.py b/paradox/config.py index 8388494a..7287f70f 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -25,11 +25,45 @@ class Config: # Development "DEVELOPMENT_DUMP_MEMORY": False, # Connection Type - "CONNECTION_TYPE": ("Serial", str, ["IP", "Serial"]), # Serial or IP + "CONNECTION_TYPE": ( + "Serial", + str, + ["IP", "Serial", "PRT3"], + ), # Serial, IP, or PRT3 # Serial Connection Details "SERIAL_PORT": "/dev/ttyS1", # Pathname of the Serial Port "SERIAL_BAUD": 9600, # Baud rate of the Serial Port. Use 38400(default setting) or 57600 for EVO "SERIAL_ENCRYPTED": False, # Set True for EVO panels with full serial encryption (firmware >= 7.50) + # PRT3 Connection Details (Paradox PRT3 Printer Module — ASCII serial protocol) + "PRT3_SERIAL_PORT": "/dev/ttyUSB0", # Serial port for PRT3 module + "PRT3_SERIAL_BAUD": ( + 9600, + int, + [9600, 19200], + ), # Hardware DIP-switch baud: 9600 or 19200 + "PRT3_MAX_AREAS": (8, int, (1, 8)), # Number of areas on the panel + "PRT3_MAX_ZONES": ( + 96, + int, + (0, 192), + ), # Number of zones; 0 disables zone polling + "PRT3_MAX_USERS": ( + 32, + int, + (0, 999), + ), # Number of users; 0 disables user label polling + "PRT3_USER_CODE": "", # User code for arm/disarm (1-6 digits); empty = quick-arm only. + # SECURITY: this is a live disarm code. Ensure pai.conf is chmod 600 + # and root-owned. PAI never writes user codes to logs at default log + # levels. If LOGGING_DUMP_MESSAGES or byte-level serial tracing is + # enabled in development, raw arm/disarm command bytes that include the + # code may appear in those debug streams; disable both before sharing logs. + "PRT3_COMM_TIMEOUT": ( + 10, + int, + (1, 60), + ), # Seconds to wait for COMM&ok on connect + "PRT3_UTILITY_KEYS": {}, # Keys to expose as HA buttons: {key_num: "Label", …} # IP Connection Details "IP_CONNECTION_HOST": "127.0.0.1", # IP Module address when using direct IP Connection "IP_CONNECTION_PORT": ( @@ -146,6 +180,7 @@ class Config: "MQTT_RAW_TOPIC": "raw", "MQTT_NOTIFICATIONS_TOPIC": "notifications", "MQTT_SEND_PANIC_TOPIC": "panic", + "MQTT_UTILITY_KEY_TOPIC": "utility_key", # PRT3 only: topic for UK commands "MQTT_PUBLISH_RAW_EVENTS": True, "MQTT_PUBLISH_DEFINITIONS": False, # Publish definitions of partitions/zones/users to mqtt. "MQTT_PREFIX_DEVICE_NAME": False, # Add device ID as prefix to entity names: Paradox 12345678 @@ -344,7 +379,10 @@ def load(self, alt_location=None): valid = False if isinstance(v, int): - if expected_value[0] <= v <= expected_value[1]: + if isinstance(expected_value, list): + if v in expected_value: + valid = True + elif expected_value[0] <= v <= expected_value[1]: valid = True elif isinstance(v, str): if v in expected_value: @@ -361,6 +399,15 @@ def load(self, alt_location=None): sys.stderr.write(err + "\n") raise (Exception(err)) + user_code = getattr(self, "PRT3_USER_CODE", "") + if user_code and not re.fullmatch(r"\d{1,6}", user_code): + err = ( + "Error parsing configuration: PRT3_USER_CODE must be empty or " + "1-6 decimal digits (got {!r})".format(user_code) + ) + sys.stderr.write(err + "\n") + raise ValueError(err) + self.CONFIG_LOADED = True def _update_from_environment(self, entries): diff --git a/paradox/connections/prt3/__init__.py b/paradox/connections/prt3/__init__.py new file mode 100644 index 00000000..5e533714 --- /dev/null +++ b/paradox/connections/prt3/__init__.py @@ -0,0 +1 @@ +# PRT3 connection package — ASCII serial protocol for the Paradox PRT3 Printer Module. diff --git a/paradox/connections/prt3/connection.py b/paradox/connections/prt3/connection.py new file mode 100644 index 00000000..9928ba53 --- /dev/null +++ b/paradox/connections/prt3/connection.py @@ -0,0 +1,27 @@ +""" +PRT3SerialConnection — serial transport for the Paradox PRT3 Printer Module. + +Inherits all of SerialCommunication's robust serial-open logic +(permission fix, 5-second timeout, connected_future pattern) and only +overrides make_protocol() to return PRT3Protocol instead of the binary +SerialConnectionProtocol. +""" + +import logging + +from paradox.connections.serial_connection import SerialCommunication +from paradox.connections.prt3.protocol import PRT3Protocol + +logger = logging.getLogger("PAI").getChild(__name__) + + +class PRT3SerialConnection(SerialCommunication): + """ + Serial transport for the PRT3 ASCII interface. + + Identical to SerialCommunication except it uses PRT3Protocol for + line-delimited ASCII framing instead of the binary nibble-pattern framer. + """ + + def make_protocol(self) -> PRT3Protocol: + return PRT3Protocol(self) diff --git a/paradox/connections/prt3/protocol.py b/paradox/connections/prt3/protocol.py new file mode 100644 index 00000000..a187b460 --- /dev/null +++ b/paradox/connections/prt3/protocol.py @@ -0,0 +1,77 @@ +""" +PRT3Protocol — asyncio.Protocol for the Paradox PRT3 Printer Module. + +The PRT3 module speaks a plain ASCII protocol over serial: + - Every message from the panel is terminated with \\r (0x0D). + - Commands sent to the panel are raw ASCII bytes, also \\r-terminated. + - There is no binary framing, no checksum byte, and no variable-length + nibble-pattern header — none of SerialConnectionProtocol applies here. + +This class buffers incoming bytes and emits complete \\r-delimited lines +to the owning connection handler via on_message(). +""" + +import binascii +import logging + +from paradox.config import config as cfg +from paradox.connections.protocols import ConnectionProtocol + +logger = logging.getLogger("PAI").getChild(__name__) + + +class PRT3Protocol(ConnectionProtocol): + """ + Framing protocol for the PRT3 ASCII serial interface. + + Buffers incoming bytes and emits complete \\r-terminated lines as + ``bytes`` objects to the connection handler. The \\r byte is included + in the emitted bytes so the handler can verify framing; PRT3Panel's + parse_message() strips it before calling parse_line(). + """ + + def variable_message_length(self, *args, **kwargs): + # PRT3 lines are delimiter-framed, not length-prefixed. + # This is a deliberate no-op so the Panel base class can call it + # without error; actual line assembly happens in data_received(). + pass + + def data_received(self, data: bytes): + """ + Buffer incoming bytes and emit each complete \\r-terminated line. + + Lines that contain no printable content after stripping whitespace + are silently discarded (e.g. a bare \\r with no preceding payload). + """ + self.buffer += data + + if len(self.buffer) > 512: # PRT3 max line is ~21 bytes; 512 is generous + logger.warning( + "PRT3: buffer overflow (%d bytes), discarding", len(self.buffer) + ) + self.buffer = b"" + return + + while b"\r" in self.buffer: + line, self.buffer = self.buffer.split(b"\r", 1) + line_with_cr = line + b"\r" + + if cfg.LOGGING_DUMP_PACKETS: + logger.debug("PRT3 <- %s", binascii.hexlify(line_with_cr)) + + if line.strip(): # skip empty / whitespace-only lines + self.handler.on_message(line_with_cr) + + def send_message(self, message: bytes): + """ + Write raw ASCII bytes directly to the transport. + + PRT3 commands are already \\r-terminated by encoder.py; no additional + framing is added here. + """ + self.check_active() + + if cfg.LOGGING_DUMP_PACKETS: + logger.debug("PRT3 -> %s", binascii.hexlify(message)) + + self.transport.write(message) diff --git a/paradox/hardware/prt3/__init__.py b/paradox/hardware/prt3/__init__.py new file mode 100644 index 00000000..503f0a58 --- /dev/null +++ b/paradox/hardware/prt3/__init__.py @@ -0,0 +1 @@ +# PRT3 hardware package — panel adapter for the Paradox PRT3 Printer Module. diff --git a/paradox/hardware/prt3/adapter.py b/paradox/hardware/prt3/adapter.py new file mode 100644 index 00000000..e94d77c2 --- /dev/null +++ b/paradox/hardware/prt3/adapter.py @@ -0,0 +1,230 @@ +""" +PRT3 state adapter — pure normalization layer. + +Converts PRT3 protocol message types into the nested dict structures that +PAI's MemoryStorage and status_update pipeline expect. No I/O, no asyncio, +no PAI runtime imports beyond the parser dataclasses and sanitize_key. + +This module is the clean boundary between the PRT3 wire protocol and PAI's +internal state model. All mapping decisions live here; panel.py calls +these functions and forwards results to the runtime. + +Mapping decisions +----------------- + +arm_state → PAI partition flags + disarmed → arm=False arm_stay=False arm_away=False arm_force=False + armed_away → arm=True arm_stay=False arm_away=True arm_force=False + armed_force → arm=True arm_stay=False arm_away=True arm_force=True + (forced arm is always an away-mode arm) + armed_stay → arm=True arm_stay=True arm_away=False arm_force=False + armed_instant → arm=True arm_stay=True arm_away=False arm_force=False + (instant arm = stay without entry delay; PAI has no separate + 'instant' flag — it is represented as stay) + +not_ready → ready_status (polarity inversion) + PRT3 not_ready=True → PAI ready_status=False + +alarm → audible_alarm + PRT3's area-level 'alarm' flag signals that the siren is active. The + nearest PAI equivalent is 'audible_alarm'; it is consumed by + _update_partition_states() which derives current_state='triggered'. + +zone open_state → open, tamper, fire_loop_trouble + closed → open=False tamper=False fire_loop_trouble=False + open → open=True tamper=False fire_loop_trouble=False + tampered → open=True tamper=True fire_loop_trouble=False + (tampered zone is also flagged open; PAI convention) + fire_loop_trouble → open=False tamper=False fire_loop_trouble=True + +label element_type → PAI container name + "zone" → "zone" + "area" → "partition" (PRT3 calls them 'areas'; PAI calls them 'partitions') + "user" → "user" +""" + +from collections import defaultdict +from typing import Dict, Iterable, List, Tuple + +from paradox.hardware.prt3.parser import ( + ARM_AWAY, + ARM_DISARMED, + ARM_FORCE, + ARM_INSTANT, + ARM_STAY, + ZONE_CLOSED, + ZONE_FIRE_LOOP_TROUBLE, + ZONE_OPEN, + ZONE_TAMPERED, + PRT3AreaStatus, + PRT3LabelReply, + PRT3ZoneStatus, +) +from paradox.lib.utils import sanitize_key + +# --------------------------------------------------------------------------- +# Area (partition) status normalization +# --------------------------------------------------------------------------- + +# arm_state → (arm, arm_stay, arm_away, arm_force) +_ARM_STATE_FLAGS: Dict[str, Tuple[bool, bool, bool, bool]] = { + ARM_DISARMED: (False, False, False, False), + ARM_AWAY: (True, False, True, False), + ARM_FORCE: (True, False, True, True), # forced arm is an away arm + ARM_STAY: (True, True, False, False), + ARM_INSTANT: (True, True, False, False), # no PAI 'instant' flag; maps to stay +} + + +def partition_status_from_area(msg: PRT3AreaStatus) -> dict: + """ + Map a PRT3AreaStatus to a PAI partition property dict. + + All keys match PAI's property_map and the format expected by + MemoryStorage.update_container_object(). + """ + arm, arm_stay, arm_away, arm_force = _ARM_STATE_FLAGS[msg.arm_state] + return { + "arm": arm, + "arm_stay": arm_stay, + "arm_away": arm_away, + "arm_force": arm_force, + "trouble": msg.trouble, + "ready_status": not msg.not_ready, # PRT3 says 'not_ready'; PAI says 'ready' + "audible_alarm": msg.alarm, # area alarm flag → siren active + "strobe_alarm": msg.strobe, + "alarms_in_memory": msg.zone_in_memory, + } + + +# --------------------------------------------------------------------------- +# Zone status normalization +# --------------------------------------------------------------------------- + +def zone_status_from_zone(msg: PRT3ZoneStatus) -> dict: + """ + Map a PRT3ZoneStatus to a PAI zone property dict. + + All keys match PAI's property_map and the format expected by + MemoryStorage.update_container_object(). + """ + open_state = msg.open_state + return { + "open": open_state in (ZONE_OPEN, ZONE_TAMPERED), + "tamper": open_state == ZONE_TAMPERED, + "fire_loop_trouble": open_state == ZONE_FIRE_LOOP_TROUBLE, + "alarm": msg.alarm, + "fire": msg.fire_alarm, + "supervision_trouble": msg.supervision_trouble, + "low_battery_trouble": msg.low_battery, + } + + +# --------------------------------------------------------------------------- +# Label normalization +# --------------------------------------------------------------------------- + +# PRT3 "area" → PAI container "partition" +_ELEMENT_TYPE_TO_CONTAINER: Dict[str, str] = { + "zone": "zone", + "area": "partition", + "user": "user", +} + + +def label_entry_from_reply(msg: PRT3LabelReply) -> Tuple[str, int, dict]: + """ + Convert a PRT3LabelReply to a (container_name, index, entry_dict) tuple. + + The entry_dict is compatible with MemoryStorage.deep_merge(): + {"id": n, "key": sanitized_label, "label": raw_label_stripped} + + Trailing spaces are stripped from the label (PRT3 pads to 16 chars with + spaces; PAI displays labels without trailing whitespace). + + If the label is blank (all spaces), the key falls back to + ``{container}_{index:03d}`` so the element still gets a usable key. + """ + container = _ELEMENT_TYPE_TO_CONTAINER[msg.element_type] + label = msg.label.rstrip() + key = sanitize_key(label) if label else f"{container}_{msg.index:03d}" + return container, msg.index, {"id": msg.index, "key": key, "label": label} + + +def labels_dict_from_replies(replies: Iterable[PRT3LabelReply]) -> dict: + """ + Aggregate PRT3LabelReply messages into the format Paradox._on_labels_load() + expects:: + + { + "zone": {1: {"id": 1, "key": "front_door", "label": "Front Door"}, ...}, + "partition": {1: {"id": 1, "key": "home", "label": "Home"}, ...}, + "user": {1: {"id": 1, "key": "master", "label": "Master"}, ...}, + } + + Duplicate indices for the same container are overwritten by the last + reply seen; the panel should not send duplicates, but be defensive. + """ + data: Dict[str, Dict[int, dict]] = defaultdict(dict) + for msg in replies: + container, idx, entry = label_entry_from_reply(msg) + data[container][idx] = entry + return dict(data) + + +# --------------------------------------------------------------------------- +# Flat status dict (convert_raw_status() compatible format) +# --------------------------------------------------------------------------- + +def build_flat_status( + area_msgs: Iterable[PRT3AreaStatus], + zone_msgs: Iterable[PRT3ZoneStatus], +) -> dict: + """ + Build the flat status dict compatible with convert_raw_status(). + + Accumulates multiple area and zone status messages into:: + + { + "partition_arm": {1: True, 2: False}, + "partition_arm_stay": {1: False, 2: False}, + "partition_arm_away": {1: True, 2: False}, + "partition_arm_force": {1: False, 2: False}, + "partition_trouble": {1: False, 2: False}, + "partition_ready_status": {1: True, 2: True}, + "partition_audible_alarm": {1: False, 2: False}, + "partition_strobe_alarm": {1: False, 2: False}, + "partition_alarms_in_memory": {1: False, 2: False}, + "zone_open": {1: False, 2: True}, + "zone_tamper": {1: False, 2: False}, + "zone_fire_loop_trouble": {1: False, 2: False}, + "zone_alarm": {1: False, 2: False}, + "zone_fire": {1: False, 2: False}, + "zone_supervision_trouble": {1: False, 2: False}, + "zone_low_battery_trouble": {1: False, 2: False}, + } + + convert_raw_status() splits "partition_arm" on the first underscore to + produce container="partition", prop="arm". Multi-word properties like + "arm_stay" or "low_battery_trouble" are split once to keep the container + type and the rest becomes the property name. + """ + p_by_prop: Dict[str, Dict[int, bool]] = {} + z_by_prop: Dict[str, Dict[int, bool]] = {} + + for msg in area_msgs: + props = partition_status_from_area(msg) + for prop, val in props.items(): + p_by_prop.setdefault(prop, {})[msg.area] = val + + for msg in zone_msgs: + props = zone_status_from_zone(msg) + for prop, val in props.items(): + z_by_prop.setdefault(prop, {})[msg.zone] = val + + flat: dict = {} + for prop, by_id in p_by_prop.items(): + flat[f"partition_{prop}"] = by_id + for prop, by_id in z_by_prop.items(): + flat[f"zone_{prop}"] = by_id + return flat diff --git a/paradox/hardware/prt3/encoder.py b/paradox/hardware/prt3/encoder.py new file mode 100644 index 00000000..ef2af459 --- /dev/null +++ b/paradox/hardware/prt3/encoder.py @@ -0,0 +1,271 @@ +""" +PRT3 ASCII command encoder. + +Pure functions — no side effects, no I/O. Each function returns a +``bytes`` object ready to write to the serial port, including the trailing +``\\r`` (ASCII 0x0D). + +Command reference (PRT3 ASCII Programming Guide, v1 scope only): + + RA{nnn}\\r Request area status (nnn = 001-008) + RZ{nnn}\\r Request zone status (nnn = 001-192) + AL{nnn}\\r Request area label (nnn = 001-008) + ZL{nnn}\\r Request zone label (nnn = 001-192) + UL{nnn}\\r Request user label (nnn = 001-999) + AA{nnn}{mode}{code}\\r Arm area (mode: A/F/S/I, code: 1-6 digits) + AQ{nnn}{mode}\\r Quick-arm area (requires One-Touch Arming on panel) + AD{nnn}{code}\\r Disarm area (code: 1-6 digits) + PE{nnn}\\r Emergency panic + PM{nnn}\\r Medical panic + PF{nnn}\\r Fire panic + UK{nnn}\\r Utility key (nnn = 001-251) + +All area numbers are 3-digit zero-padded (001-008) even though only 8 areas +exist. Zone, user and key numbers are also 3-digit zero-padded. This +matches the spec byte-table exactly. + +Out-of-scope for v1 (documented but deliberately not implemented here): + VO{nnn}\\r / VC{nnn}\\r Virtual input open / closed + SR{nnn}\\r Smoke reset +""" + +# --------------------------------------------------------------------------- +# Arm-mode constants (pass as the ``mode`` argument to encode_arm / +# encode_quick_arm) +# --------------------------------------------------------------------------- + +ARM_MODE_AWAY = "A" # Regular arm (away) +ARM_MODE_FORCE = "F" # Force arm +ARM_MODE_STAY = "S" # Stay arm +ARM_MODE_INSTANT = "I" # Instant arm + +_VALID_ARM_MODES = frozenset({ARM_MODE_AWAY, ARM_MODE_FORCE, ARM_MODE_STAY, ARM_MODE_INSTANT}) + +# --------------------------------------------------------------------------- +# Limits directly from PRT3 ASCII Programming Guide / Panel Specifications +# --------------------------------------------------------------------------- + +_AREA_MIN, _AREA_MAX = 1, 8 +_ZONE_MIN, _ZONE_MAX = 1, 192 +_USER_MIN, _USER_MAX = 1, 999 +_KEY_MIN, _KEY_MAX = 1, 251 +_CODE_MIN_LEN = 1 # spec: "enter only the appropriate amount of digits" +_CODE_MAX_LEN = 6 # spec: "up to 6 digits" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _validate_area(area: int) -> None: + if not (_AREA_MIN <= area <= _AREA_MAX): + raise ValueError( + f"area must be {_AREA_MIN}-{_AREA_MAX}, got {area!r}" + ) + + +def _validate_zone(zone: int) -> None: + if not (_ZONE_MIN <= zone <= _ZONE_MAX): + raise ValueError( + f"zone must be {_ZONE_MIN}-{_ZONE_MAX}, got {zone!r}" + ) + + +def _validate_user(user: int) -> None: + if not (_USER_MIN <= user <= _USER_MAX): + raise ValueError( + f"user must be {_USER_MIN}-{_USER_MAX}, got {user!r}" + ) + + +def _validate_key(key: int) -> None: + if not (_KEY_MIN <= key <= _KEY_MAX): + raise ValueError( + f"utility key must be {_KEY_MIN}-{_KEY_MAX}, got {key!r}" + ) + + +def _validate_arm_mode(mode: str) -> None: + if mode not in _VALID_ARM_MODES: + raise ValueError( + f"arm mode must be one of {sorted(_VALID_ARM_MODES)}, got {mode!r}" + ) + + +def _validate_code(code: str) -> None: + if not isinstance(code, str): + raise TypeError(f"code must be a str, got {type(code).__name__!r}") + if not code: + raise ValueError("code must not be empty") + if len(code) > _CODE_MAX_LEN: + raise ValueError( + f"code must be at most {_CODE_MAX_LEN} digits, got {len(code)!r}" + ) + if not code.isdigit(): + raise ValueError(f"code must contain only digits, got {code!r}") + + +def _cmd(s: str) -> bytes: + """Append ``\\r`` and encode as ASCII bytes.""" + return (s + "\r").encode("ascii") + + +# --------------------------------------------------------------------------- +# Status and label requests +# --------------------------------------------------------------------------- + + +def encode_area_status_request(area: int) -> bytes: + """``RA{nnn}\\r`` — request area status. + + :param area: 1-based area number (1-8). + :returns: ASCII bytes ready to write to the serial port. + :raises ValueError: if *area* is out of range. + """ + _validate_area(area) + return _cmd(f"RA{area:03d}") + + +def encode_zone_status_request(zone: int) -> bytes: + """``RZ{nnn}\\r`` — request zone status. + + :param zone: 1-based zone number (1-192). + :raises ValueError: if *zone* is out of range. + """ + _validate_zone(zone) + return _cmd(f"RZ{zone:03d}") + + +def encode_area_label_request(area: int) -> bytes: + """``AL{nnn}\\r`` — request area label. + + :param area: 1-based area number (1-8). + :raises ValueError: if *area* is out of range. + """ + _validate_area(area) + return _cmd(f"AL{area:03d}") + + +def encode_zone_label_request(zone: int) -> bytes: + """``ZL{nnn}\\r`` — request zone label. + + :param zone: 1-based zone number (1-192). + :raises ValueError: if *zone* is out of range. + """ + _validate_zone(zone) + return _cmd(f"ZL{zone:03d}") + + +def encode_user_label_request(user: int) -> bytes: + """``UL{nnn}\\r`` — request user label. + + :param user: 1-based user number (1-999). + :raises ValueError: if *user* is out of range. + """ + _validate_user(user) + return _cmd(f"UL{user:03d}") + + +# --------------------------------------------------------------------------- +# Arm / quick arm / disarm +# --------------------------------------------------------------------------- + + +def encode_arm(area: int, mode: str, code: str) -> bytes: + """``AA{nnn}{mode}{code}\\r`` — arm an area. + + :param area: 1-based area number (1-8). + :param mode: one of ``ARM_MODE_AWAY`` ('A'), ``ARM_MODE_FORCE`` ('F'), + ``ARM_MODE_STAY`` ('S'), ``ARM_MODE_INSTANT`` ('I'). + :param code: user code string, 1-6 decimal digits. Variable length — + do **not** pad to 6; the panel accepts the exact digits sent. + :raises ValueError: if any argument is invalid. + + Note: the echo prefix is always the first 5 chars of the command + (``AA{nnn}``), regardless of code length. + """ + _validate_area(area) + _validate_arm_mode(mode) + _validate_code(code) + return _cmd(f"AA{area:03d}{mode}{code}") + + +def encode_quick_arm(area: int, mode: str) -> bytes: + """``AQ{nnn}{mode}\\r`` — quick-arm an area (no user code required). + + Requires **One-Touch Arming** to be enabled in the Digiplex panel + programming. If the feature is disabled the panel silently ignores the + command (no &fail echo is returned). + + :param area: 1-based area number (1-8). + :param mode: one of the ARM_MODE_* constants. + :raises ValueError: if any argument is invalid. + """ + _validate_area(area) + _validate_arm_mode(mode) + return _cmd(f"AQ{area:03d}{mode}") + + +def encode_disarm(area: int, code: str) -> bytes: + """``AD{nnn}{code}\\r`` — disarm an area. + + :param area: 1-based area number (1-8). + :param code: user code string, 1-6 decimal digits. + :raises ValueError: if any argument is invalid. + """ + _validate_area(area) + _validate_code(code) + return _cmd(f"AD{area:03d}{code}") + + +# --------------------------------------------------------------------------- +# Panic commands +# --------------------------------------------------------------------------- + + +def encode_panic_emergency(area: int) -> bytes: + """``PE{nnn}\\r`` — trigger an emergency (police) panic alarm. + + Panic alarms must be individually enabled in the panel programming. + + :param area: 1-based area number (1-8). + :raises ValueError: if *area* is out of range. + """ + _validate_area(area) + return _cmd(f"PE{area:03d}") + + +def encode_panic_medical(area: int) -> bytes: + """``PM{nnn}\\r`` — trigger a medical panic alarm. + + :param area: 1-based area number (1-8). + :raises ValueError: if *area* is out of range. + """ + _validate_area(area) + return _cmd(f"PM{area:03d}") + + +def encode_panic_fire(area: int) -> bytes: + """``PF{nnn}\\r`` — trigger a fire panic alarm. + + :param area: 1-based area number (1-8). + :raises ValueError: if *area* is out of range. + """ + _validate_area(area) + return _cmd(f"PF{area:03d}") + + +# --------------------------------------------------------------------------- +# Utility key +# --------------------------------------------------------------------------- + + +def encode_utility_key(key: int) -> bytes: + """``UK{nnn}\\r`` — send a utility key event. + + :param key: utility key number (1-251). + :raises ValueError: if *key* is out of range. + """ + _validate_key(key) + return _cmd(f"UK{key:03d}") diff --git a/paradox/hardware/prt3/event.py b/paradox/hardware/prt3/event.py new file mode 100644 index 00000000..b88ddf06 --- /dev/null +++ b/paradox/hardware/prt3/event.py @@ -0,0 +1,332 @@ +""" +PRT3 event map and PRT3Event. + +EVENT_MAP maps G-group codes (int) to PAI event descriptor dicts. +PRT3Event subclasses Event (not LiveEvent) because LiveEvent.__init__ +asserts ``raw.fields.value.po.command == 0xE``, which is a binary-protocol +concept that does not exist in PRT3 ASCII messages. + +Event group source +------------------ +G-group codes are taken from the PRT3 ASCII Programming Guide, table +"System Event Group Codes". Only groups observable from v1 scope are +mapped here; unknown groups fall through to a generic "system" entry. + +Event type / subtype conventions +--------------------------------- +``type`` — the PAI element type: "zone", "partition", "user", or "system". +``subtype`` — a short lower-case descriptor matching property_map keys where + possible (e.g. "alarm", "open", "arm"). +``level`` — EventLevel severity. +``change`` — dict of property updates to apply to the element (may be empty). +``tags`` — list of string tags (forwarded to pub/sub consumers). +``message`` — human-readable template string (use {label} for element label). +""" + +import logging +import time + +from paradox.event import Event, EventLevel + +logger = logging.getLogger("PAI").getChild(__name__) + + +# --------------------------------------------------------------------------- +# Event group code table +# PRT3 ASCII Programming Guide §System Event Group Codes +# --------------------------------------------------------------------------- + +EVENT_MAP: dict = { + # Zone status events (number = zone ID, area = affected area) + 0: {"type": "zone", "subtype": "restored", "level": EventLevel.INFO, + "change": {"alarm": False, "open": False}, + "tags": ["zone", "restore"], + "message": "Zone {label} restored"}, + 1: {"type": "zone", "subtype": "open", "level": EventLevel.DEBUG, + "change": {"open": True}, + "tags": ["zone", "open"], + "message": "Zone {label} open"}, + 2: {"type": "zone", "subtype": "tampered", "level": EventLevel.CRITICAL, + "change": {"tamper": True, "open": True}, + "tags": ["zone", "tamper", "trouble"], + "message": "Zone {label} tampered"}, + 3: {"type": "zone", "subtype": "fire_loop_trouble", "level": EventLevel.CRITICAL, + "change": {"fire_loop_trouble": True}, + "tags": ["zone", "trouble", "fire"], + "message": "Zone {label} fire loop trouble"}, + + # Arm events (PRT3 spec page 18: G009=Master, G010=User, G011=Keyswitch, G012=Special) + # number = user/keyswitch ID, area = affected partition. + # exit_delay cleared because the panel is now fully armed (delay is over) + 9: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, + "change": {"arm": True, "exit_delay": False}, + "tags": ["arm", "master"], + "message": "Partition {label} armed by master"}, + 10: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, + "change": {"arm": True, "exit_delay": False}, + "tags": ["arm", "user"], + "message": "Partition {label} armed by user"}, + 11: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, + "change": {"arm": True, "exit_delay": False}, + "tags": ["arm", "keyswitch"], + "message": "Partition {label} armed via keyswitch"}, + 12: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, + "change": {"arm": True, "exit_delay": False}, + "tags": ["arm", "special"], + "message": "Partition {label} special arm"}, + + # Disarm events (PRT3 spec page 18: G013=Master, G014=User, G015=Keyswitch). + # number = user/keyswitch ID, area = affected partition. + # exit_delay cleared because arming was cancelled (or disarm after full arm). + 13: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False}, + "tags": ["disarm", "master"], + "message": "Partition {label} disarmed by master"}, + 14: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False}, + "tags": ["disarm", "user"], + "message": "Partition {label} disarmed by user"}, + 15: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False}, + "tags": ["disarm", "keyswitch"], + "message": "Partition {label} disarmed via keyswitch"}, + # G016/G017 = Disarm after alarm (per spec G016=Master, G017=User, G018=Keyswitch). + # G017 also clears audible_alarm; G016/G018 mappings are kept change-equivalent + # to PAI's pre-spec-realignment behaviour to avoid behaviour drift for events + # we haven't observed from the user's panel — G013 was the critical fix. + 16: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False}, + "tags": ["disarm", "master", "alarm_cancel"], + "message": "Partition {label} disarmed after alarm by master"}, + 17: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False, "audible_alarm": False}, + "tags": ["disarm", "user", "alarm_cancel"], + "message": "Partition {label} disarmed after alarm by user"}, + 18: {"type": "partition", "subtype": "alarm_cancelled", "level": EventLevel.INFO, + "change": {"audible_alarm": False}, + "tags": ["alarm", "cancel"], + "message": "Partition {label} alarm cancelled"}, + 20: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False}, + "tags": ["disarm", "special"], + "message": "Partition {label} special disarm"}, + + # Zone bypass events (number = zone ID) + 21: {"type": "zone", "subtype": "bypassed", "level": EventLevel.INFO, + "change": {"bypassed": True}, + "tags": ["zone", "bypass"], + "message": "Zone {label} bypassed"}, + 23: {"type": "zone", "subtype": "bypass_cancelled", "level": EventLevel.INFO, + "change": {"bypassed": False}, + "tags": ["zone", "bypass"], + "message": "Zone {label} bypass cancelled"}, + + # Alarm events (number = zone ID, area = affected partition) + 24: {"type": "zone", "subtype": "alarm", "level": EventLevel.CRITICAL, + "change": {"alarm": True}, + "tags": ["zone", "alarm"], + "message": "Zone {label} in alarm"}, + 25: {"type": "zone", "subtype": "fire_alarm", "level": EventLevel.CRITICAL, + "change": {"fire": True}, + "tags": ["zone", "alarm", "fire"], + "message": "Zone {label} fire alarm"}, + 26: {"type": "zone", "subtype": "alarm_restored", "level": EventLevel.INFO, + "change": {"alarm": False}, + "tags": ["zone", "alarm", "restore"], + "message": "Zone {label} alarm restored"}, + 27: {"type": "zone", "subtype": "fire_alarm_restored", "level": EventLevel.INFO, + "change": {"fire": False}, + "tags": ["zone", "alarm", "fire", "restore"], + "message": "Zone {label} fire alarm restored"}, + + # Panic alarms (number = user/zone, area = partition) + 29: {"type": "partition", "subtype": "panic_alarm", "level": EventLevel.CRITICAL, + "change": {"panic_alarm": True}, + "tags": ["alarm", "panic"], + "message": "Partition {label} panic alarm"}, + 30: {"type": "partition", "subtype": "alarm", "level": EventLevel.CRITICAL, + "change": {"audible_alarm": True}, + "tags": ["alarm"], + "message": "Partition {label} alarm shutdown"}, + 31: {"type": "zone", "subtype": "tamper_alarm", "level": EventLevel.CRITICAL, + "change": {"alarm": True, "tamper": True}, + "tags": ["zone", "alarm", "tamper"], + "message": "Zone {label} tamper alarm"}, + 32: {"type": "zone", "subtype": "tamper_restored", "level": EventLevel.INFO, + "change": {"tamper": False}, + "tags": ["zone", "tamper", "restore"], + "message": "Zone {label} tamper restored"}, + + # Trouble events (number = zone/module/user, area = affected) + 33: {"type": "system", "subtype": "trouble", "level": EventLevel.CRITICAL, + "change": {}, + "tags": ["trouble"], + "message": "New trouble event"}, + 34: {"type": "system", "subtype": "trouble_restored", "level": EventLevel.INFO, + "change": {}, + "tags": ["trouble", "restore"], + "message": "Trouble restored"}, + 36: {"type": "system", "subtype": "ac_failure", "level": EventLevel.CRITICAL, + "change": {"ac_failure_trouble": True}, + "tags": ["trouble", "power"], + "message": "AC power failure"}, + 38: {"type": "system", "subtype": "battery_trouble", "level": EventLevel.CRITICAL, + "change": {"battery_failure_trouble": True}, + "tags": ["trouble", "battery"], + "message": "Battery trouble"}, + + # Power / communication + 45: {"type": "system", "subtype": "power_up", "level": EventLevel.INFO, + "change": {}, + "tags": ["system", "power"], + "message": "Panel power-up"}, + + # Utility key (number = key number, area = 0 / global) + 48: {"type": "system", "subtype": "utility_key", "level": EventLevel.INFO, + "change": {}, + "tags": ["system", "utility"], + "message": "Utility key {number} activated"}, + + # Zone lifecycle + 56: {"type": "zone", "subtype": "bypassed", "level": EventLevel.INFO, + "change": {"bypassed": True}, + "tags": ["zone", "bypass"], + "message": "Zone {label} bypassed"}, + 59: {"type": "zone", "subtype": "closed", "level": EventLevel.DEBUG, + "change": {"open": False}, + "tags": ["zone"], + "message": "Zone {label} closed"}, + 60: {"type": "zone", "subtype": "low_battery", "level": EventLevel.CRITICAL, + "change": {"low_battery_trouble": True}, + "tags": ["zone", "trouble", "battery"], + "message": "Zone {label} low battery"}, + 61: {"type": "zone", "subtype": "supervision_trouble", "level": EventLevel.CRITICAL, + "change": {"supervision_trouble": True}, + "tags": ["zone", "trouble", "supervision"], + "message": "Zone {label} supervision failure"}, + 62: {"type": "zone", "subtype": "low_battery_restored", "level": EventLevel.INFO, + "change": {"low_battery_trouble": False}, + "tags": ["zone", "battery", "restore"], + "message": "Zone {label} battery restored"}, + 63: {"type": "zone", "subtype": "supervision_restored", "level": EventLevel.INFO, + "change": {"supervision_trouble": False}, + "tags": ["zone", "supervision", "restore"], + "message": "Zone {label} supervision restored"}, + + # Status events — periodic armed/trouble state broadcasts + # area = affected partition; number = bit index within the status word + # G064: Status 1 — N000=armed, N001=arm_stay, N002=arm_force, N003=arm_instant + 64: {"type": "partition", "subtype": "status_armed", "level": EventLevel.DEBUG, + "change": {}, + "tags": ["status"], + "message": "Partition {label} Status-1 event (N{number})"}, + # G065: Status 2 — N001=exit_delay, N002=entry_delay, N003=trouble, N004=alarm_in_memory + # Per-N overrides are in "number_overrides"; this top-level entry is the fallback. + 65: {"type": "partition", "subtype": "status_update", "level": EventLevel.DEBUG, + "change": {}, + "tags": ["status"], + "message": "Partition {label} Status-2 event (N{number})", + "number_overrides": { + # N=000 = Ready (no zones open) — informational; does NOT mean "exit delay cleared". + # exit_delay is cleared by arm/disarm events (G009-G020), not by Ready snapshots. + # N=001: exit delay started → show HA "arming" state + 1: {"subtype": "exit_delay", "level": EventLevel.INFO, + "change": {"exit_delay": True}, + "tags": ["status", "exit_delay"], + "message": "Partition {label} exit delay started"}, + # N=002: entry delay started + 2: {"subtype": "entry_delay", "level": EventLevel.INFO, + "change": {"entry_delay": True}, + "tags": ["status", "entry_delay"], + "message": "Partition {label} entry delay started"}, + }}, + 66: {"type": "system", "subtype": "status_tamper", "level": EventLevel.CRITICAL, + "change": {}, + "tags": ["trouble", "tamper", "status"], + "message": "Status: tamper or trouble detected"}, +} + + +# --------------------------------------------------------------------------- +# PRT3Event +# --------------------------------------------------------------------------- + +class PRT3Event(Event): + """ + PAI Event subclass for PRT3 system events. + + Constructed from a PRT3SystemEvent dataclass via from_prt3(). Does NOT + inherit LiveEvent because LiveEvent.__init__ asserts a binary command + code that does not exist in PRT3 messages. + """ + + @classmethod + def from_prt3(cls, prt3_event, label_provider=None) -> "PRT3Event": + """ + Construct a PRT3Event from a parsed PRT3SystemEvent dataclass. + + :param prt3_event: PRT3SystemEvent(group, number, area) + :param label_provider: Optional callable(element_type, index) → str + for resolving element labels. If None, a + numeric label is used. + :returns: PRT3Event with level, type, subtype, change, + tags, message, and additional_data populated. + """ + descriptor = EVENT_MAP.get(prt3_event.group) + + # Apply per-N overrides declared in EVENT_MAP[group]["number_overrides"]. + # This keeps EVENT_MAP as the single source of truth for all per-N rules. + if descriptor is not None: + overrides = descriptor.get("number_overrides") + if overrides and prt3_event.number in overrides: + descriptor = {**descriptor, **overrides[prt3_event.number]} + + if descriptor is None: + logger.debug( + "PRT3: unknown event group G%03d N%03d A%03d", + prt3_event.group, prt3_event.number, prt3_event.area, + ) + descriptor = { + "type": "system", + "subtype": "unknown", + "level": EventLevel.DEBUG, + "change": {}, + "tags": ["unknown"], + "message": f"Unknown event G{prt3_event.group:03d}", + } + + element_type = descriptor["type"] + area = prt3_event.area # 0=global, 1-8=specific, 255=any + + # For partition events the element being updated IS the area/partition; + # prt3_event.number carries the user/key/flag index, not the partition ID. + # For zone/user/system events, number is the element ID. + if element_type == "partition": + element_id = area + else: + element_id = prt3_event.number + + # Resolve label + if label_provider is not None: + label = label_provider(element_type, element_id) or str(element_id) + else: + label = str(element_id) + + event = cls() + event.timestamp = int(time.time()) + event.level = descriptor["level"] + event.type = element_type + event.subtype = descriptor.get("subtype", "unknown") + event.id = element_id + event.partition = area + event.label = label + event._message_tpl = descriptor["message"] + event.change = dict(descriptor["change"]) # copy to avoid mutation + event.tags = list(descriptor.get("tags", [])) + event.additional_data = { + "group": prt3_event.group, + "number": prt3_event.number, + "area": prt3_event.area, + } + + return event diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py new file mode 100644 index 00000000..05ceb49c --- /dev/null +++ b/paradox/hardware/prt3/panel.py @@ -0,0 +1,548 @@ +""" +PRT3Panel — Panel adapter for the Paradox PRT3 Printer Module. + +Implements all Panel abstract methods using the PRT3 ASCII protocol instead +of binary EEPROM reads. Delegates wire-format parsing to parser.py and +state normalization to adapter.py. + +Protocol overview +----------------- +- Startup: await ``COMM&ok\\r`` from the serial port (no password exchange). +- Labels: poll AL/ZL/UL one-by-one; collect PRT3LabelReply messages. +- Status: poll RA/RZ one-by-one; collect PRT3AreaStatus / PRT3ZoneStatus. +- Control: send AA/AQ/AD for arm/disarm; PE/PM/PF for panic. +- Events: async PRT3SystemEvent messages arrive unsolicited; a persistent + handler in the runtime (follow-up work) dispatches them. + +Design constraints +------------------ +- Zone bypass has no PRT3 command; control_zones() raises NotImplementedError. +- PGM / output control has no PRT3 command; control_outputs() raises. +- EEPROM reads are not available; load_definitions() returns {}. +- Arm with user code requires PRT3_USER_CODE in config; if absent, quick-arm + is used (requires One-Touch Arming enabled on the panel). +- Disarm always requires a user code; if PRT3_USER_CODE is not configured, + disarm commands are rejected with a logged error. + +Handler compatibility note +-------------------------- +The PAI runtime registers EventMessageHandler and ErrorMessageHandler on the +connection's handler_registry. Both assert isinstance(data, Container) which +would raise AssertionError when PRT3Message dataclasses are dispatched. +This is fixed by the guarded versions in async_message_manager.py. +Async system events (PRT3SystemEvent) additionally require a persistent +PRT3EventHandler to be registered — this is Phase 3 / runtime wiring work. +""" + +import asyncio +import logging +from typing import Optional + +from paradox.config import config as cfg +from paradox.hardware.panel import Panel +from paradox.hardware.prt3 import adapter, encoder +from paradox.hardware.prt3.event import EVENT_MAP, PRT3Event +from paradox.hardware.prt3.parser import ( + PRT3AreaStatus, + PRT3BufferFull, + PRT3CommandEcho, + PRT3CommStatus, + PRT3LabelReply, + PRT3ZoneStatus, + parse_line, +) +from paradox.hardware.prt3.property import property_map + +logger = logging.getLogger("PAI").getChild(__name__) + +# --------------------------------------------------------------------------- +# Partition command → arm encoder mapping +# --------------------------------------------------------------------------- + +# Maps PAI command string → (encoder_fn, arm_mode_char) +# All arm variants that don't require a user code use quick-arm. +_QUICK_ARM_MODES = { + "arm": "A", # default arm → away + "arm_away": "A", + "arm_stay": "S", + "arm_force": "F", + "arm_instant": "I", + "arm_sleep": "I", # instant arm (no entry delay) — PRT3 closest to HA arm_night +} + +# Panic type → encoder function +_PANIC_ENCODERS = { + "emergency": encoder.encode_panic_emergency, + "medical": encoder.encode_panic_medical, + "fire": encoder.encode_panic_fire, +} + + +class PRT3Panel(Panel): + """ + Panel implementation for the PRT3 ASCII serial interface. + + Uses ASCII RA/RZ/AL/ZL/UL commands for status and labels, and + AA/AQ/AD/PE/PM/PF commands for control. All EEPROM-based operations + from the base Panel class are replaced with ASCII equivalents. + """ + + property_map = property_map + + # Single virtual status address: PRT3 has no multi-block EEPROM, so + # the poll loop calls request_status(0) once per cycle and it polls + # all configured areas and zones internally. + status_request_addresses = [0] + + def __init__(self, core): + # variable_message_length=False: PRT3Protocol.variable_message_length() + # is a no-op; the Panel base class must not try to manage lengths. + super().__init__(core, variable_message_length=False) + # Warn if the configured zone count means each poll cycle could exceed + # half of KEEP_ALIVE_INTERVAL (in the worst-case all-timeout scenario). + if cfg.PRT3_MAX_ZONES * cfg.IO_TIMEOUT > cfg.KEEP_ALIVE_INTERVAL / 2: + logger.warning( + "PRT3: polling %d zones at %.1f s timeout may take up to %.0f s per " + "cycle (KEEP_ALIVE_INTERVAL=%d s) — consider reducing PRT3_MAX_ZONES", + cfg.PRT3_MAX_ZONES, + cfg.IO_TIMEOUT, + cfg.PRT3_MAX_ZONES * cfg.IO_TIMEOUT, + cfg.KEEP_ALIVE_INTERVAL, + ) + + # ------------------------------------------------------------------ + # Message parsing + # ------------------------------------------------------------------ + + def parse_message(self, message: bytes, direction="topanel"): + """ + Decode a raw PRT3 ASCII line (bytes, \\r-included) into a typed + PRT3Message dataclass. + + Returns None for empty or unparseable input. parse_line() logs a + WARNING for unrecognised lines; this method does not raise. + """ + if not message: + return None + try: + line = message.decode("ascii").rstrip("\r") + except (UnicodeDecodeError, AttributeError): + logger.warning("PRT3: non-ASCII message: %r", message) + return None + return parse_line(line) + + # ------------------------------------------------------------------ + # Internal request/reply helper + # ------------------------------------------------------------------ + + async def _prt3_send_wait( + self, + command_bytes: bytes, + predicate, + retries: int = 1, + ): + """ + Write a PRT3 command and await a matching reply. + + Serialization: the entire retry loop runs under ``core.request_lock`` + so no other command can interleave between a send and its expected + reply, and no other command can interleave between retry attempts. + + :param command_bytes: Encoded command bytes (\\r-terminated). + :param predicate: callable(PRT3Message) → bool. The first + message for which this returns True is returned. + :param retries: Total attempts (1 = no retry, 2 = one retry…). + Retries are performed on timeout or on + PRT3BufferFull (``!``) — the panel dropped the + command; a definitive reply (&ok or &fail) is + returned immediately without retrying. + :returns: Matching PRT3Message, or None if all attempts + timed out or hit buffer-full. + """ + async with self.core.request_lock: + buffer_full_or_match = lambda m: predicate(m) or isinstance(m, PRT3BufferFull) + for attempt in range(1, retries + 1): + self.core.connection.write(command_bytes) + try: + result = await self.core.connection.wait_for_message( + buffer_full_or_match, timeout=cfg.IO_TIMEOUT + ) + if isinstance(result, PRT3BufferFull): + logger.warning( + "PRT3: buffer full on attempt %d/%d, retrying: %s", + attempt, retries, command_bytes[:5], # [:5] excludes user code + ) + continue + return result + except asyncio.TimeoutError: + if attempt < retries: + logger.warning( + "PRT3: timeout on attempt %d/%d, retrying: %s", + attempt, retries, command_bytes[:5], # [:5] excludes user code + ) + return None + + # ------------------------------------------------------------------ + # Startup handshake + # ------------------------------------------------------------------ + + async def initialize_communication(self, password) -> bool: + """ + Verify the PRT3 serial link is live. + + The PRT3 module emits ``COMM&ok\\r`` only on its own power-up or when + the EVO panel reconnects to the module's combus — NOT when the host + opens the serial port. If the module was already running before PAI + started, ``COMM&ok`` was sent before we opened the port and will never + be re-sent. + + Strategy: + 1. Listen briefly (2 s) for spontaneous data (events or COMM&ok). + If the module is live, something usually arrives quickly. + 2. If nothing spontaneous, send RA001 (area 1 status probe) and wait + for any response (ok, &fail, or CommStatus). + 3. Accept any PRT3 message as proof that the link is live. + 4. Return False only on hard failure: COMM&fail (panel not talking to + PRT3 module) or complete silence after PRT3_COMM_TIMEOUT. + + The ``password`` argument is accepted for interface compatibility but + ignored — PRT3 has no password exchange. + """ + from paradox.hardware.prt3.parser import PRT3SystemEvent + + _any_prt3_msg = lambda m: isinstance( + m, (PRT3CommStatus, PRT3AreaStatus, PRT3ZoneStatus, PRT3LabelReply, + PRT3CommandEcho, PRT3SystemEvent) + ) + + logger.info("PRT3: checking serial link liveness") + + # Phase 1: brief listen for spontaneous data (events, COMM&ok etc.) + try: + msg = await self.core.connection.wait_for_message( + _any_prt3_msg, timeout=2.0 + ) + if isinstance(msg, PRT3CommStatus) and not msg.ok: + logger.error("PRT3: panel communication failure (COMM&fail)") + return False + logger.info("PRT3: serial link live (spontaneous: %s)", type(msg).__name__) + return True + except asyncio.TimeoutError: + pass # nothing spontaneous — fall through to probe + + # Phase 2: probe with RA001 and wait for any response + logger.info("PRT3: no spontaneous data; sending RA001 probe") + probe = encoder.encode_area_status_request(1) + self.core.connection.write(probe) + try: + msg = await self.core.connection.wait_for_message( + _any_prt3_msg, + timeout=max(cfg.PRT3_COMM_TIMEOUT - 2.0, 3.0), + ) + if isinstance(msg, PRT3CommStatus) and not msg.ok: + logger.error("PRT3: panel communication failure (COMM&fail)") + return False + logger.info("PRT3: serial link live (probe response: %s)", type(msg).__name__) + return True + except asyncio.TimeoutError: + logger.error( + "PRT3: serial link unresponsive after %.0fs probe", cfg.PRT3_COMM_TIMEOUT + ) + return False + + # ------------------------------------------------------------------ + # Definitions (EEPROM not available via PRT3) + # ------------------------------------------------------------------ + + async def load_definitions(self) -> dict: + """PRT3 provides no EEPROM access; return empty definitions.""" + return {} + + # ------------------------------------------------------------------ + # Label loading + # ------------------------------------------------------------------ + + async def _load_label_range( + self, element_type: str, max_count: int, cmd_fn, prefix: str + ) -> list: + """Fetch labels for one element type (area/zone/user) via ASCII commands.""" + replies = [] + for i in range(1, max_count + 1): + cmd = cmd_fn(i) + expected_cmd = f"{prefix}{i:03d}" + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_cmd, et=element_type, idx=i: ( + (isinstance(m, PRT3LabelReply) and m.element_type == et and m.index == idx) + or (isinstance(m, PRT3CommandEcho) and m.cmd == ec) + ), + ) + if isinstance(msg, PRT3LabelReply): + replies.append(msg) + elif isinstance(msg, PRT3CommandEcho) and not msg.ok: + logger.debug("PRT3: %s %d label not found", element_type, i) + elif msg is None: + logger.warning("PRT3: timeout loading %s %d label", element_type, i) + return replies + + async def load_labels(self) -> dict: + """ + Request area, zone, and user labels via AL/ZL/UL ASCII commands. + + Sends commands one-by-one and collects PRT3LabelReply messages. + A command that returns PRT3CommandEcho(&fail) means the element + doesn't exist and is silently skipped. + + Returns a labels dict compatible with Paradox._on_labels_load(). + """ + logger.info("PRT3: loading labels") + replies = ( + await self._load_label_range("area", cfg.PRT3_MAX_AREAS, encoder.encode_area_label_request, "AL") + + await self._load_label_range("zone", cfg.PRT3_MAX_ZONES, encoder.encode_zone_label_request, "ZL") + + await self._load_label_range("user", cfg.PRT3_MAX_USERS, encoder.encode_user_label_request, "UL") + ) + labels = adapter.labels_dict_from_replies(replies) + logger.info( + "PRT3: labels loaded — %d zones, %d partitions, %d users", + len(labels.get("zone", {})), + len(labels.get("partition", {})), + len(labels.get("user", {})), + ) + return labels + + # ------------------------------------------------------------------ + # Status polling + # ------------------------------------------------------------------ + + async def _poll_area_statuses(self) -> list: + """Poll RA{nnn} for all configured areas; return PRT3AreaStatus list.""" + msgs = [] + for area in range(1, cfg.PRT3_MAX_AREAS + 1): + cmd = encoder.encode_area_status_request(area) + expected_cmd = f"RA{area:03d}" + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_cmd, a=area: ( + (isinstance(m, PRT3AreaStatus) and m.area == a) + or (isinstance(m, PRT3CommandEcho) and m.cmd == ec) + ), + ) + if isinstance(msg, PRT3AreaStatus): + msgs.append(msg) + elif isinstance(msg, PRT3CommandEcho) and not msg.ok: + logger.debug("PRT3: area %d status not found", area) + elif msg is None: + logger.warning("PRT3: timeout polling area %d status", area) + return msgs + + async def _poll_zone_statuses(self) -> list: + """Poll RZ{nnn} for all configured zones; return PRT3ZoneStatus list.""" + msgs = [] + for zone in range(1, cfg.PRT3_MAX_ZONES + 1): + cmd = encoder.encode_zone_status_request(zone) + expected_cmd = f"RZ{zone:03d}" + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_cmd, z=zone: ( + (isinstance(m, PRT3ZoneStatus) and m.zone == z) + or (isinstance(m, PRT3CommandEcho) and m.cmd == ec) + ), + ) + if isinstance(msg, PRT3ZoneStatus): + msgs.append(msg) + elif isinstance(msg, PRT3CommandEcho) and not msg.ok: + logger.debug("PRT3: zone %d status not found", zone) + elif msg is None: + logger.warning("PRT3: timeout polling zone %d status", zone) + return msgs + + async def request_status(self, nr: int) -> dict: + """ + Poll all configured areas and zones. The ``nr`` argument is ignored + (PRT3 has no EEPROM address blocks); it is always called with 0 + from the poll loop via status_request_addresses = [0]. + + Returns the flat status dict that convert_raw_status() accepts:: + + { + "partition_arm": {1: True, 2: False}, + "partition_ready_status": {1: True, 2: True}, + ... + "zone_open": {1: False, 2: True}, + ... + } + + Areas or zones that return &fail (don't exist) are silently excluded. + Timeouts per-element are logged as warnings; the poll loop tolerates + missing replies via the deep_merge / StatusRequestException path. + """ + area_msgs = await self._poll_area_statuses() + zone_msgs = await self._poll_zone_statuses() + return adapter.build_flat_status(area_msgs, zone_msgs) + + # ------------------------------------------------------------------ + # Control — partitions + # ------------------------------------------------------------------ + + def _build_partition_cmd( + self, partition: int, command: str, user_code: str + ) -> Optional[tuple]: + """ + Build (cmd_bytes, expected_echo) for one partition command. + + Returns None and logs an error when the command cannot be sent + (unknown command, or disarm without a user code). + """ + if command == "disarm": + if not user_code: + logger.error("PRT3: disarm requires PRT3_USER_CODE to be configured") + return None + try: + return encoder.encode_disarm(partition, user_code), f"AD{partition:03d}" + except ValueError as exc: + logger.error("PRT3: invalid PRT3_USER_CODE for disarm: %s", exc) + return None + + if command in _QUICK_ARM_MODES: + mode = _QUICK_ARM_MODES[command] + if user_code: + try: + return encoder.encode_arm(partition, mode, user_code), f"AA{partition:03d}" + except ValueError as exc: + logger.error("PRT3: invalid PRT3_USER_CODE for arm: %s", exc) + return None + return encoder.encode_quick_arm(partition, mode), f"AQ{partition:03d}" + + logger.error("PRT3: unknown partition command %r", command) + return None + + async def control_partitions(self, partitions: list, command: str) -> bool: + """ + Arm or disarm partitions using AA/AQ/AD commands. + + Arm commands: uses quick-arm (AQ) if PRT3_USER_CODE is not set, or + arm with code (AA) if PRT3_USER_CODE is configured. Quick-arm + requires One-Touch Arming to be enabled in panel programming. + + Disarm: always requires PRT3_USER_CODE; returns False if not set. + """ + user_code = cfg.PRT3_USER_CODE + accepted = False + + for partition in partitions: + built = self._build_partition_cmd(partition, command, user_code) + if built is None: + continue + cmd, expected_echo = built + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_echo: ( + isinstance(m, PRT3CommandEcho) and m.cmd == ec + ), + retries=2, + ) + if msg is None: + logger.warning("PRT3: timeout on %s partition %d", command, partition) + elif msg.ok: + logger.info("PRT3: %s partition %d accepted", command, partition) + accepted = True + else: + logger.warning( + "PRT3: %s partition %d rejected by panel (&fail)", command, partition + ) + + return accepted + + # ------------------------------------------------------------------ + # Control — zones (not supported by PRT3) + # ------------------------------------------------------------------ + + async def control_zones(self, zones: list, command: str) -> bool: + raise NotImplementedError( + "PRT3 has no zone bypass command — control_zones() is not supported" + ) + + # ------------------------------------------------------------------ + # Control — outputs (not supported by PRT3) + # ------------------------------------------------------------------ + + async def control_outputs(self, outputs, command) -> bool: + raise NotImplementedError( + "PRT3 has no PGM output command — control_outputs() is not supported" + ) + + # ------------------------------------------------------------------ + # Panic + # ------------------------------------------------------------------ + + async def send_panic(self, partitions: list, panic_type: str, _code) -> bool: + """ + Send a PE/PM/PF panic command. + + :param partitions: list of 1-based area numbers (1-8). + :param panic_type: 'emergency', 'medical', or 'fire'. + :param _code: Not used by PRT3 (panic commands carry no code). + """ + encode_fn = _PANIC_ENCODERS.get(panic_type) + if encode_fn is None: + logger.error("PRT3: unknown panic type %r", panic_type) + return False + + accepted = False + for partition in partitions: + cmd = encode_fn(partition) + expected_echo = f"{cmd[:2].decode('ascii')}{partition:03d}" + + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_echo: ( + isinstance(m, PRT3CommandEcho) and m.cmd == ec + ), + retries=2, + ) + if msg is None: + logger.warning("PRT3: timeout on %s panic area %d", panic_type, partition) + continue + if not msg.ok: + logger.warning( + "PRT3: %s panic area %d rejected (&fail)", panic_type, partition + ) + continue + logger.info("PRT3: %s panic area %d accepted", panic_type, partition) + accepted = True + return accepted + + # ------------------------------------------------------------------ + # Utility key + # ------------------------------------------------------------------ + + async def send_utility_key(self, key: int) -> bool: + """ + Send a ``UK{nnn}\\r`` utility key command. + + Utility keys (1-251) trigger actions programmed into the panel + (scene activations, output toggles, etc.). The panel echoes + ``UK{nnn}&OK`` on success or ``UK{nnn}&fail`` if the key is not + programmed. + + :param key: Utility key number, 1-251. + :returns: True if the panel accepted the command, False otherwise. + :raises ValueError: if *key* is out of range (from encoder). + """ + cmd = encoder.encode_utility_key(key) + expected_echo = f"UK{key:03d}" + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_echo: ( + isinstance(m, PRT3CommandEcho) and m.cmd == ec + ), + retries=1, # no retry — utility keys are not idempotent (gate toggles) + ) + if msg is None: + logger.warning("PRT3: timeout on utility key %d", key) + return False + if not msg.ok: + logger.warning("PRT3: utility key %d rejected by panel (&fail)", key) + return False + logger.info("PRT3: utility key %d accepted", key) + return True diff --git a/paradox/hardware/prt3/parser.py b/paradox/hardware/prt3/parser.py new file mode 100644 index 00000000..43b171b3 --- /dev/null +++ b/paradox/hardware/prt3/parser.py @@ -0,0 +1,350 @@ +""" +PRT3 ASCII line parser. + +``parse_line(line: str) -> PRT3Message | None`` + +All incoming PRT3 messages are plain ASCII strings with the trailing ``\\r`` +already stripped by ``PRT3Protocol.data_received()``. This module converts +them into typed dataclasses; the rest of the stack never has to inspect raw +strings. + +Message grammar (PRT3 ASCII Programming Guide, rev. 1.0): + + COMM&ok panel ready (startup or combus restore) + COMM&fail panel / combus communication failure + ! reception buffer full — last command was dropped + {5chars}&OK action-command echo — success + {5chars}&fail action-command echo — failure; also: info-cmd not found + RA{nnn}{7 flags} area status reply (info command) + RZ{nnn}{5 flags} zone status reply (info command) + ZL{nnn}{16 chars} zone label reply (info command) + AL{nnn}{16 chars} area label reply (info command) + UL{nnn}{16 chars} user label reply (info command) + G{ggg}N{nnn}A{aaa} asynchronous system event + PGM{nn}ON virtual PGM activated (v1: parsed but not acted on) + PGM{nn}OFF virtual PGM deactivated (v1: parsed but not acted on) + +Echo rules: + - Action commands (AA/AQ/AD/PE/PM/PF/UK/SR/VO/VC): reply is first-5 + &OK/&fail + - Info commands (RA/RZ/ZL/AL/UL): reply is first-5 + data (no separate &OK) + - A failed info command still produces first-5 + &fail + +Flag layout for RA{nnn}XPTNASMM (positions 5-11, zero-indexed from start): + [5] arm state: D=disarmed A=armed_away F=armed_force S=armed_stay I=armed_instant + [6] P=in_programming / O=no + [7] T=trouble / O=no + [8] N=not_ready / O=ready + [9] A=alarm / O=no + [10] S=strobe / O=no + [11] M=zone_in_memory / O=no + +Flag layout for RZ{nnn}XAFSL (positions 5-9): + [5] open state: C=closed O=open T=tampered F=fire_loop_trouble + [6] A=alarm / O=no + [7] F=fire_alarm / O=no + [8] S=supervision_fault / O=no + [9] L=low_battery / O=no +""" + +import logging +import re +from dataclasses import dataclass +from typing import Optional, Union + +logger = logging.getLogger("PAI").getChild(__name__) + + +# --------------------------------------------------------------------------- +# Arm-state constants (value of PRT3AreaStatus.arm_state) +# --------------------------------------------------------------------------- + +ARM_DISARMED = "disarmed" +ARM_AWAY = "armed_away" +ARM_FORCE = "armed_force" +ARM_STAY = "armed_stay" +ARM_INSTANT = "armed_instant" + +_ARM_STATE_MAP: dict = { + "D": ARM_DISARMED, + "A": ARM_AWAY, + "F": ARM_FORCE, + "S": ARM_STAY, + "I": ARM_INSTANT, +} + +# --------------------------------------------------------------------------- +# Zone open-state constants (value of PRT3ZoneStatus.open_state) +# --------------------------------------------------------------------------- + +ZONE_CLOSED = "closed" +ZONE_OPEN = "open" +ZONE_TAMPERED = "tampered" +ZONE_FIRE_LOOP_TROUBLE = "fire_loop_trouble" + +_ZONE_OPEN_MAP: dict = { + "C": ZONE_CLOSED, + "O": ZONE_OPEN, + "T": ZONE_TAMPERED, + "F": ZONE_FIRE_LOOP_TROUBLE, +} + +# --------------------------------------------------------------------------- +# Message dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class PRT3CommStatus: + """``COMM&ok`` or ``COMM&fail`` — combus/module communication status.""" + ok: bool # True = panel ready + + +@dataclass +class PRT3BufferFull: + """``!`` — module reception buffer full; the preceding command was dropped.""" + + +@dataclass +class PRT3CommandEcho: + """Echo of a command we sent: first 5 chars of the command + &OK / &fail. + + For action commands (arm, disarm, panic, utility key) this is the only + reply. For info commands that fail (e.g. unknown area), this is also + returned. + """ + cmd: str # exactly the first 5 ASCII chars of the command that was sent + ok: bool + + +@dataclass +class PRT3AreaStatus: + """Reply to ``RA{nnn}`` — area status flags. + + ``not_ready`` mirrors the wire protocol: ``True`` means the area is NOT + ready to arm (open zone, active trouble, etc.). The adapter layer should + negate this to produce a ``ready`` property. + """ + area: int + arm_state: str # one of the ARM_* constants above + in_programming: bool + trouble: bool + not_ready: bool # True → area is NOT ready + alarm: bool + strobe: bool + zone_in_memory: bool + + +@dataclass +class PRT3ZoneStatus: + """Reply to ``RZ{nnn}`` — zone status flags.""" + zone: int + open_state: str # one of the ZONE_* constants above + alarm: bool + fire_alarm: bool + supervision_trouble: bool + low_battery: bool + + +@dataclass +class PRT3LabelReply: + """Reply to ``ZL/AL/UL{nnn}`` — 16-character ASCII label (spaces preserved).""" + element_type: str # "zone", "area", or "user" + index: int + label: str # exactly 16 chars; trailing spaces not stripped + + +@dataclass +class PRT3SystemEvent: + """Async system event from the panel: ``G{ggg}N{nnn}A{aaa}``. + + ``area == 0`` means the event occurred in all enabled areas (global). + ``area == 255`` means at least one enabled area (per spec Note 1). + """ + group: int # 3-digit event-group code (000-066) + number: int # event-specific identifier: zone, user, door, key, … + area: int # 0 = global / all, 1-8 = specific area, 255 = any + + +@dataclass +class PRT3PgmEvent: + """Virtual PGM activation/deactivation event (v1 scope: parsed, not acted on).""" + pgm: int # 1-30 + on: bool # True if PGMxxON (activated), False if PGMxxOFF (deactivated) + + +# Union type exported for type annotations in callers +PRT3Message = Union[ + PRT3CommStatus, + PRT3BufferFull, + PRT3CommandEcho, + PRT3AreaStatus, + PRT3ZoneStatus, + PRT3LabelReply, + PRT3SystemEvent, + PRT3PgmEvent, +] + +# --------------------------------------------------------------------------- +# Compiled patterns +# --------------------------------------------------------------------------- + +_RE_SYSTEM_EVENT = re.compile(r"^G(\d{3})N(\d{3})A(\d{3})$") +_RE_PGM_ON = re.compile(r"^PGM(\d{2})ON$") +_RE_PGM_OFF = re.compile(r"^PGM(\d{2})OFF$") + +# Lengths of fully-formed info replies (after \r stripped) +_AREA_STATUS_LEN = 12 # RA + 3-digit area + 7 flags +_ZONE_STATUS_LEN = 10 # RZ + 3-digit zone + 5 flags +_LABEL_LEN = 21 # 2-char type + 3-digit index + 16-char label + +# Lengths of command echoes (after \r stripped) +_ECHO_OK_LEN = 8 # 5-char prefix + "&OK" +_ECHO_FAIL_LEN = 10 # 5-char prefix + "&fail" + +_LABEL_PREFIXES = {"ZL": "zone", "AL": "area", "UL": "user"} + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def parse_line(line: str) -> Optional[PRT3Message]: + """Parse a single ``\\r``-stripped ASCII line from the PRT3 module. + + Returns a typed ``PRT3Message`` dataclass, or ``None`` when the line is + empty, unrecognised, or malformed. + + Malformed lines with a recognised prefix (e.g. ``RA`` with wrong length) + emit a ``WARNING`` log and return ``None`` rather than raising. + + Ordering rationale: + 1. Empties and single-char sentinels first (cheap, unambiguous). + 2. COMM status before generic ``&fail`` / ``&ok`` because ``COMM&`` + is five chars and lowercase ``ok`` would collide with the echo + pattern if we checked echoes first. + 3. Structured info replies before echo check: the echo check catches + failed info commands (e.g. ``RA001&fail``) that fall through. + 4. Unknown lines → WARNING + None. + """ + if not line: + return None + + # 1. Buffer-full sentinel + if line == "!": + return PRT3BufferFull() + + # 2. COMM status (distinct from echo: COMM&ok uses lowercase, COMM&fail + # is 9 chars — neither matches the 8-/10-char echo patterns) + if line == "COMM&ok": + return PRT3CommStatus(ok=True) + if line == "COMM&fail": + return PRT3CommStatus(ok=False) + + # 3. System events: G001N005A006 + m = _RE_SYSTEM_EVENT.match(line) + if m: + return PRT3SystemEvent( + group=int(m.group(1)), + number=int(m.group(2)), + area=int(m.group(3)), + ) + + # 4. Virtual PGM events (v1: parse but do not act on) + m = _RE_PGM_ON.match(line) + if m: + return PRT3PgmEvent(pgm=int(m.group(1)), on=True) + m = _RE_PGM_OFF.match(line) + if m: + return PRT3PgmEvent(pgm=int(m.group(1)), on=False) + + # 5–7. Structured info replies (RA/RZ/ZL/AL/UL). + # Short echoes like RA001&fail fall through when _parse_info_reply returns None. + msg = _parse_info_reply(line) + if msg is not None: + return msg + + # 8. Command echoes: {5 chars}&OK (8) or {5 chars}&fail (10) + # Note: some panel firmware sends lowercase "&ok"; accept both. + if len(line) == _ECHO_OK_LEN and line.upper().endswith("&OK"): + return PRT3CommandEcho(cmd=line[:5], ok=True) + if len(line) == _ECHO_FAIL_LEN and line.endswith("&fail"): + return PRT3CommandEcho(cmd=line[:5], ok=False) + + logger.warning("PRT3 parser: unrecognised line %r", line) + return None + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _parse_info_reply(line: str) -> Optional[PRT3Message]: + """Dispatch structured info replies: RA/RZ/ZL/AL/UL{nnn}{data}. + + Returns None for lines that don't match (e.g. short &fail echoes), + allowing parse_line to fall through to the echo check. + """ + if not (len(line) >= 5 and line[2:5].isdigit()): + return None + prefix2 = line[:2] + if prefix2 == "RA" and len(line) == _AREA_STATUS_LEN: + return _parse_area_status(line) + if prefix2 == "RZ" and len(line) == _ZONE_STATUS_LEN: + return _parse_zone_status(line) + if prefix2 in _LABEL_PREFIXES and len(line) == _LABEL_LEN: + return _parse_label(line) + return None + + +def _parse_area_status(line: str) -> Optional[PRT3AreaStatus]: + """Parse ``RA{nnn}{7 flags}`` → ``PRT3AreaStatus`` or ``None`` on bad flags.""" + area = int(line[2:5]) + arm_char = line[5] + arm_state = _ARM_STATE_MAP.get(arm_char) + if arm_state is None: + logger.warning( + "PRT3 parser: unknown arm-state char %r in area-status line %r", + arm_char, line, + ) + return None + return PRT3AreaStatus( + area=area, + arm_state=arm_state, + in_programming=(line[6] == "P"), + trouble=(line[7] == "T"), + not_ready=(line[8] == "N"), + alarm=(line[9] == "A"), + strobe=(line[10] == "S"), + zone_in_memory=(line[11] == "M"), + ) + + +def _parse_zone_status(line: str) -> Optional[PRT3ZoneStatus]: + """Parse ``RZ{nnn}{5 flags}`` → ``PRT3ZoneStatus`` or ``None`` on bad flags.""" + zone = int(line[2:5]) + open_char = line[5] + open_state = _ZONE_OPEN_MAP.get(open_char) + if open_state is None: + logger.warning( + "PRT3 parser: unknown zone-open char %r in zone-status line %r", + open_char, line, + ) + return None + return PRT3ZoneStatus( + zone=zone, + open_state=open_state, + alarm=(line[6] == "A"), + fire_alarm=(line[7] == "F"), + supervision_trouble=(line[8] == "S"), + low_battery=(line[9] == "L"), + ) + + +def _parse_label(line: str) -> PRT3LabelReply: + """Parse ``ZL/AL/UL{nnn}{16-char label}`` → ``PRT3LabelReply``.""" + element_type = _LABEL_PREFIXES[line[:2]] + index = int(line[2:5]) + label = line[5:] # always 16 chars (spec-mandated, spaces padded) + return PRT3LabelReply(element_type=element_type, index=index, label=label) diff --git a/paradox/hardware/prt3/property.py b/paradox/hardware/prt3/property.py new file mode 100644 index 00000000..f8a2dcd6 --- /dev/null +++ b/paradox/hardware/prt3/property.py @@ -0,0 +1,12 @@ +""" +PRT3 property map. + +Re-exports property_map from spectra_magellan unchanged. + +The property names used by PRT3 status replies (open, alarm, trouble, +arm, arm_stay, arm_force, exit_delay, entry_delay, …) are identical to +those used by the Spectra/Magellan binary panels. There is no need for +a separate map — re-exporting keeps the two in sync automatically. +""" + +from paradox.hardware.spectra_magellan.property import property_map # noqa: F401 diff --git a/paradox/interfaces/mqtt/basic.py b/paradox/interfaces/mqtt/basic.py index a976fb84..e049c3fb 100644 --- a/paradox/interfaces/mqtt/basic.py +++ b/paradox/interfaces/mqtt/basic.py @@ -145,6 +145,16 @@ def on_connect(self, mqttc, userdata, connect_flags, reason_code, properties=Non self._mqtt_handle_send_panic, ) + if cfg.CONNECTION_TYPE == "PRT3": + self.subscribe_callback( + "{}/{}/{}/+".format( + cfg.MQTT_BASE_TOPIC, + cfg.MQTT_CONTROL_TOPIC, + cfg.MQTT_UTILITY_KEY_TOPIC, + ), + self._mqtt_handle_utility_key, + ) + if not self.connected_future.done(): self.connected_future.set_result(True) @@ -362,6 +372,31 @@ async def _mqtt_handle_door_control(self, prep: ParsedMessage): self._publish_command_status(message) + @mqtt_handle_decorator + async def _mqtt_handle_utility_key(self, prep: ParsedMessage): + """PRT3-only: trigger a utility key (UK{nnn}). Payload is ignored.""" + topics = prep.topics + if len(topics) < 4: + logger.error("PRT3 utility key: malformed topic %r", topics) + return + try: + key = int(topics[3]) + except (ValueError, TypeError): + logger.error("PRT3 utility key: invalid key number %r", topics[3]) + return + + message = f"Utility key command: key={key}" + logger.info(message) + self._publish_command_status(message) + + if not await self.alarm.control_utility_key(key): + message = f"Utility key command refused: key={key}" + logger.warning(message) + else: + message = f"Utility key command accepted: key={key}" + + self._publish_command_status(message) + def _handle_panel_event(self, event: Event): """ Handle Live Event diff --git a/paradox/interfaces/mqtt/core.py b/paradox/interfaces/mqtt/core.py index e5119123..3a6dfd4d 100644 --- a/paradox/interfaces/mqtt/core.py +++ b/paradox/interfaces/mqtt/core.py @@ -74,6 +74,7 @@ def __init__(self): self.client.on_connect = self._on_connect_cb self.client.on_disconnect = self._on_disconnect_cb self.state = ConnectionState.NEW + self._last_connect_args = None # replayed to late registrars # self.client.enable_logger(logger) # self.client.on_subscribe = lambda client, userdata, mid, granted_qos: logger.debug("Subscribed: %s" %(mid)) @@ -179,6 +180,20 @@ def register(self, cls): self.start() + # If MQTT was already connected before this registrar started (common + # when interfaces register slightly after the MQTT loop connects), + # fire on_connect immediately so control subscriptions and futures are + # set up correctly. + if self.connected and self._last_connect_args is not None: + try: + if hasattr(cls, "on_connect") and callable(getattr(cls, "on_connect")): + cls.on_connect(*self._last_connect_args) + except Exception: + logger.exception( + 'Failed to call on_connect on late registrar "%s"', + cls.__class__.__name__, + ) + def unregister(self, cls): self.registrars.remove(cls) @@ -204,6 +219,7 @@ def _on_connect_cb(self, client, userdata, connect_flags, reason_code, propertie if not reason_code.is_failure: logger.info("MQTT Broker Connected") self.state = ConnectionState.CONNECTED + self._last_connect_args = (client, userdata, connect_flags, reason_code, properties) self._report_pai_status(self._last_pai_status) self._call_registars("on_connect", client, userdata, connect_flags, reason_code, properties) else: diff --git a/paradox/interfaces/mqtt/entities/abstract_entity.py b/paradox/interfaces/mqtt/entities/abstract_entity.py index 37b90b34..382a560f 100644 --- a/paradox/interfaces/mqtt/entities/abstract_entity.py +++ b/paradox/interfaces/mqtt/entities/abstract_entity.py @@ -45,12 +45,10 @@ def configuration_topic(self): ) def serialize(self): - prefix = cfg.MQTT_HOMEASSISTANT_ENTITY_PREFIX.format( + prefix = cfg.MQTT_HOMEASSISTANT_ENTITY_PREFIX.format_map( { - "serial_number", - self.device.serial_number, - "model", - self.device.model, + "serial_number": self.device.serial_number, + "model": self.device.model, } ) return dict( diff --git a/paradox/interfaces/mqtt/entities/button.py b/paradox/interfaces/mqtt/entities/button.py new file mode 100644 index 00000000..20d7808c --- /dev/null +++ b/paradox/interfaces/mqtt/entities/button.py @@ -0,0 +1,69 @@ +from paradox.config import config as cfg +from paradox.interfaces.mqtt.entities.device import Device + + +class UtilityKeyButton: + """HA MQTT button entity for a PRT3 utility key. + + Buttons are stateless — there is no state_topic. Pressing the button in + HA publishes ``payload_press`` to the command_topic, which the + ``_mqtt_handle_utility_key`` subscriber picks up and forwards to the panel. + + PRT3 limitation: utility keys have no persistent state and no feedback + channel (the panel echoes &OK/&fail but that is not surfaced as HA state). + """ + + hass_entity_type = "button" + + def __init__( + self, + key_num: int, + label: str, + device: Device, + availability_topic: str, + ): + self.device = device + self.availability_topic = availability_topic + self.key_num = key_num + self.label = label or f"Utility Key {key_num}" + + @property + def entity_id(self) -> str: + return f"utility_key_{self.key_num}" + + @property + def configuration_topic(self) -> str: + return "/".join( + [ + cfg.MQTT_HOMEASSISTANT_DISCOVERY_PREFIX, + self.hass_entity_type, + self.device.serial_number, + self.entity_id, + "config", + ] + ) + + @property + def command_topic(self) -> str: + return "{}/{}/{}/{}".format( + cfg.MQTT_BASE_TOPIC, + cfg.MQTT_CONTROL_TOPIC, + cfg.MQTT_UTILITY_KEY_TOPIC, + self.key_num, + ) + + def serialize(self) -> dict: + prefix = cfg.MQTT_HOMEASSISTANT_ENTITY_PREFIX.format_map( + { + "serial_number": self.device.serial_number, + "model": self.device.model, + } + ) + return { + "availability_topic": self.availability_topic, + "device": self.device, + "name": prefix + self.label, + "unique_id": f"paradox_{self.device.serial_number}_{self.entity_id}", + "command_topic": self.command_topic, + "payload_press": "trigger", + } diff --git a/paradox/interfaces/mqtt/entities/factory.py b/paradox/interfaces/mqtt/entities/factory.py index f9296db0..6ad97b32 100644 --- a/paradox/interfaces/mqtt/entities/factory.py +++ b/paradox/interfaces/mqtt/entities/factory.py @@ -1,4 +1,5 @@ from paradox.interfaces.mqtt.entities.alarm_control_panel import AlarmControlPanel +from paradox.interfaces.mqtt.entities.button import UtilityKeyButton from paradox.interfaces.mqtt.entities.binary_sensors import ZoneStatusBinarySensor, \ SystemBinarySensor, PartitionBinarySensor from paradox.interfaces.mqtt.entities.sensor import PAIStatusSensor, SystemStatusSensor, ZoneNumericSensor @@ -37,6 +38,9 @@ def make_pgm_switch(self, pgm): def make_module_pgm_switch(self, module_pgm): return ModulePGMSwitch(module_pgm, self.device, self.availability_topic) + def make_utility_key_button(self, key_num: int, label: str): + return UtilityKeyButton(key_num, label, self.device, self.availability_topic) + def make_system_status(self, system_key, status): if system_key == 'troubles': return SystemBinarySensor(system_key, status, self.device, self.availability_topic) diff --git a/paradox/interfaces/mqtt/homeassistant.py b/paradox/interfaces/mqtt/homeassistant.py index e581e3fc..b39e2a07 100644 --- a/paradox/interfaces/mqtt/homeassistant.py +++ b/paradox/interfaces/mqtt/homeassistant.py @@ -87,6 +87,8 @@ def _publish_when_ready(self, panel: DetectedPanel, status): self._publish_module_pgm_configs(module_pgms) if "system" in status: self._publish_system_property_configs(status["system"]) + if cfg.CONNECTION_TYPE == "PRT3" and cfg.PRT3_UTILITY_KEYS: + self._publish_utility_key_configs(cfg.PRT3_UTILITY_KEYS) def _publish_config(self, entity: AbstractEntity): self.publish( @@ -179,3 +181,26 @@ def _publish_system_property_configs(self, system_statuses): system_key, property_name ) self._publish_config(system_property_config) + + def _publish_utility_key_configs(self, utility_keys: dict): + """Publish HA button discovery configs for PRT3 utility keys. + + ``utility_keys`` is ``{key_num: label}`` from ``PRT3_UTILITY_KEYS``. + Only keys explicitly listed in config are published; the full 1-251 + range is not auto-discovered because utility key actions are + panel-programmed and have no introspectable meaning. + """ + for key_num, label in utility_keys.items(): + try: + key_num = int(key_num) + except (ValueError, TypeError): + logger.warning("PRT3_UTILITY_KEYS: invalid key number %r, skipping", key_num) + continue + if not 1 <= key_num <= 251: + logger.warning( + "PRT3_UTILITY_KEYS: key number %d out of range (expected 1..251), skipping", + key_num, + ) + continue + button = self.entity_factory.make_utility_key_button(key_num, str(label)) + self._publish_config(button) diff --git a/paradox/lib/async_message_manager.py b/paradox/lib/async_message_manager.py index 7e13ac3f..73f8ba6d 100644 --- a/paradox/lib/async_message_manager.py +++ b/paradox/lib/async_message_manager.py @@ -11,15 +11,19 @@ class EventMessageHandler(PersistentHandler): - def can_handle(self, data: Container) -> bool: - assert isinstance(data, Container) + def can_handle(self, data) -> bool: + # Guard: PRT3 messages are dataclasses, not binary Containers. + if not isinstance(data, Container): + return False values = data.fields.value return values.po.command == 0xE and hasattr(values, "event") class ErrorMessageHandler(PersistentHandler): - def can_handle(self, data: Container) -> bool: - assert isinstance(data, Container) + def can_handle(self, data) -> bool: + # Guard: PRT3 messages are dataclasses, not binary Containers. + if not isinstance(data, Container): + return False return data.fields.value.po.command == 0x7 and hasattr( data.fields.value, "message" ) diff --git a/paradox/lib/handlers.py b/paradox/lib/handlers.py index 22e8112b..817ff260 100644 --- a/paradox/lib/handlers.py +++ b/paradox/lib/handlers.py @@ -121,8 +121,8 @@ async def handle(self, data) -> None: raise if not handled and not self._should_ignore_no_handlers: - logger.error( - "No handler for message {}\nDetail: {}".format( - data.fields.value.po.command, data - ) - ) + try: + cmd = data.fields.value.po.command + except AttributeError: + cmd = repr(data) + logger.error("No handler for message %s\nDetail: %s", cmd, data) diff --git a/paradox/paradox.py b/paradox/paradox.py index 58d99d0f..fbe1177f 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -26,7 +26,7 @@ from paradox.lib import ps from paradox.lib.async_message_manager import ErrorMessageHandler, EventMessageHandler from paradox.lib.handlers import PersistentHandler -from paradox.lib.utils import deep_merge +from paradox.lib.utils import deep_merge, sanitize_key from paradox.parsers.status import convert_raw_status logger = logging.getLogger("PAI").getChild(__name__) @@ -50,6 +50,12 @@ def __init__(self, retries=3): self.busy = asyncio.Lock() self.loop_wait_event = asyncio.Event() + # partition_id -> monotonic deadline; while time.monotonic() < deadline, + # arm-related properties from RA polling are ignored for that partition. + # Set after a disarm command is accepted so a stale RA reply doesn't + # briefly re-assert arm=True before the panel internally settles. + self._partition_arm_freeze_until: dict = {} + ps.subscribe(self._on_labels_load, "labels_loaded") ps.subscribe(self._on_definitions_load, "definitons_loaded") ps.subscribe(self._on_status_update, "status_update") @@ -104,6 +110,15 @@ def connection(self): port=cfg.IP_CONNECTION_PORT, password=cfg.IP_CONNECTION_PASSWORD, ) + elif cfg.CONNECTION_TYPE == "PRT3": + logger.info("Using PRT3 Serial Connection") + + from paradox.connections.prt3.connection import PRT3SerialConnection + + self._connection = PRT3SerialConnection( + port=cfg.PRT3_SERIAL_PORT, + baud=cfg.PRT3_SERIAL_BAUD, + ) else: raise AssertionError(f"Invalid connection type: {cfg.CONNECTION_TYPE}") @@ -119,6 +134,42 @@ def _register_connection_handlers(self): self.connection.register_handler(EventMessageHandler(self.handle_event_message)) self.connection.register_handler(ErrorMessageHandler(self.handle_error_message)) + if cfg.CONNECTION_TYPE == "PRT3": + self.connection.register_handler( + PersistentHandler(self.handle_prt3_event_message) + ) + + async def _prt3_connect(self) -> bool: + """PRT3-specific panel connection sequence (called from connect()).""" + from paradox.hardware.prt3.panel import PRT3Panel + + self.panel = PRT3Panel(self) + try: + if not await self.panel.initialize_communication(None): + raise ConnectionError("PRT3 serial link unresponsive — no messages received") + # PRT3 has no binary identification exchange; synthesise a + # DetectedPanel from the configured port so HA discovery has a + # stable device identity to anchor entity unique_ids to. + port_id = sanitize_key(cfg.PRT3_SERIAL_PORT) or "prt3" + ps.sendMessage( + "panel_detected", + panel=DetectedPanel( + product_id=None, + model="PRT3", + firmware_version="N/A", + serial_number=f"prt3_{port_id}", + ), + ) + self.run_state = RunState.CONNECTED + logger.info("PRT3 connection OK") + return True + except asyncio.TimeoutError: + logger.error("Timeout waiting for PRT3 COMM&ok") + except ConnectionError as e: + logger.error("PRT3 connect failed: %s", e) + self.run_state = RunState.ERROR + return False + async def connect(self) -> bool: if self.work_loop is None: self.work_loop = asyncio.get_running_loop() @@ -137,6 +188,10 @@ async def connect(self) -> bool: logger.info("Connecting to Panel") + # PRT3 uses ASCII framing — binary panel detection does not apply. + if cfg.CONNECTION_TYPE == "PRT3": + return await self._prt3_connect() + if not self.panel: self.panel = create_panel(self) self.connection.variable_message_length(self.panel.variable_message_length) @@ -249,6 +304,8 @@ async def dump_memory(self, file, memory_type): ) async def sync_time(self): + if cfg.CONNECTION_TYPE == "PRT3": + return # PRT3 has no SetTimeDate command now = datetime.now().astimezone() if cfg.SYNC_TIME_TIMEZONE: try: @@ -466,7 +523,7 @@ async def control_zone(self, zone: str, command: str) -> bool: async def control_partition(self, partition: str, command: str) -> bool: command = command.lower() - logger.debug(f"Control Partition: {partition} - {command}") + logger.info("Control Partition: %s - %s", partition, command) partitions_selected = self.storage.get_container("partition").select(partition) @@ -486,11 +543,61 @@ async def control_partition(self, partition: str, command: str) -> bool: except asyncio.TimeoutError: logger.error("control_partition timeout") + # Reflect command outcome immediately so HA state matches reality + # without waiting for RA polling (which lags 4–7 s on a busy panel) + # or for G-events that the panel may not emit for ASCII-initiated + # disarms during exit delay. The panel's &OK echo is authoritative. + # The 3 s freeze prevents a stale RA reply (panel hasn't internally + # settled yet) from briefly re-asserting arm=True after the disarm. + if accepted and cfg.CONNECTION_TYPE == "PRT3" and command == "disarm": + change = { + "arm": False, + "arm_stay": False, + "arm_away": False, + "arm_force": False, + "exit_delay": False, + "entry_delay": False, + } + freeze_deadline = time.monotonic() + 3.0 + for pid in partitions_selected: + if self.storage.get_container_object("partition", pid): + self.storage.update_container_object("partition", pid, change) + self._partition_arm_freeze_until[pid] = freeze_deadline + self._update_partition_states() + # Refresh status self.request_status_refresh() # Trigger status update return accepted + async def control_utility_key(self, key: int) -> bool: + """ + Send a PRT3 utility key command (UK{nnn}). + + Only supported when CONNECTION_TYPE = 'PRT3'. Utility keys trigger + actions programmed in the panel (1-251); consult panel programming for + the mapping. + + :param key: Utility key number, 1-251. + :returns: True if the panel accepted the command, False otherwise. + """ + if cfg.CONNECTION_TYPE != "PRT3": + logger.error( + "control_utility_key is only supported with CONNECTION_TYPE = 'PRT3'" + ) + return False + try: + return await self.panel.send_utility_key(key) + except NotImplementedError: + logger.error("send_utility_key not implemented for this panel type") + return False + except (ValueError, TypeError) as e: + logger.error("control_utility_key: invalid key %r — %s", key, e) + return False + except asyncio.CancelledError: + logger.error("control_utility_key canceled") + raise + def _init_module_pgms(self): for addr, pgm_count in cfg.MODULE_PGM_ADDRESSES.items(): if not isinstance(addr, int) or not (1 <= addr <= 254): @@ -701,6 +808,45 @@ def handle_error_message(self, message): elif "code lockout" in message: raise CodeLockout() + def handle_prt3_event_message(self, message): + """Dispatch an async PRT3 system event into PAI's event pipeline.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + if not isinstance(message, PRT3SystemEvent): + return + logger.info( + "PRT3 system event: G%03dN%03dA%03d", + message.group, message.number, message.area, + ) + try: + evt = PRT3Event.from_prt3(message, label_provider=self.get_label) + except (KeyError, AttributeError, ValueError) as exc: + logger.warning("handle_prt3_event_message: failed to parse event: %s", exc) + return + try: + self._apply_prt3_event_change(evt) + ps.sendEvent(evt) + if evt.type == "partition": + self._update_partition_states() + except (KeyError, AttributeError) as exc: + logger.warning("handle_prt3_event_message: storage dispatch error: %s", exc) + except Exception: + logger.exception("handle_prt3_event_message") + + def _apply_prt3_event_change(self, evt): + """Apply a PRT3Event's change dict to the relevant storage object(s).""" + if not evt.change: + return + if evt.type == "partition" and evt.id in (0, 255): + # Global event: broadcast to every known partition (area=0/255 per spec). + for pid in self.storage.get_container("partition").keys(): + self.storage.update_container_object("partition", pid, evt.change) + else: + element = self.storage.get_container_object(evt.type, evt.id) + if element: + self.storage.update_container_object(evt.type, evt.id, evt.change) + async def disconnect(self): logger.info("Disconnecting from the Alarm Panel") self.run_state = RunState.STOP @@ -727,6 +873,8 @@ async def resume(self): def _clean_session(self): logger.info("Clean Session") + if cfg.CONNECTION_TYPE == "PRT3": + return # PRT3 has no binary CloseConnection frame if self.connection.connected: if not self.panel: logger.info("No panel, creating generic one") @@ -756,6 +904,7 @@ def _on_status_update(self, status): if "troubles" in status: self._process_trouble_statuses(status["troubles"]) + now = time.monotonic() for element_type, element_items in status.items(): if element_type in ["troubles"]: # troubles was already parsed continue @@ -767,6 +916,9 @@ def _on_status_update(self, status): list, ), ): + element_item_status = self._filter_arm_freeze( + element_type, element_item_key, element_item_status, now + ) self.storage.update_container_object( element_type, element_item_key, element_item_status ) @@ -783,6 +935,18 @@ def _on_status_update(self, status): if cfg.SYNC_TIME: asyncio.create_task(self.sync_time()) + _ARM_FREEZE_KEYS = frozenset({"arm", "arm_stay", "arm_away", "arm_force"}) + + def _filter_arm_freeze(self, element_type, element_key, status, now): + """Drop arm-related keys from a partition status dict within the post-disarm freeze window.""" + if ( + element_type == "partition" + and isinstance(status, dict) + and now < self._partition_arm_freeze_until.get(element_key, 0) + ): + return {k: v for k, v in status.items() if k not in self._ARM_FREEZE_KEYS} + return status + def _process_trouble_statuses(self, trouble_statuses): global_trouble = False for t_key, t_status in trouble_statuses.items(): diff --git a/tests/connection/prt3/__init__.py b/tests/connection/prt3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/connection/prt3/test_protocol.py b/tests/connection/prt3/test_protocol.py new file mode 100644 index 00000000..52714dfe --- /dev/null +++ b/tests/connection/prt3/test_protocol.py @@ -0,0 +1,190 @@ +""" +Tests for paradox.connections.prt3.protocol — PRT3Protocol framer. + +Coverage: + - Module imports and type hierarchy (smoke) + - data_received(): complete line, two-in-one-chunk, split-across-chunks, + partial (no \\r yet), empty / whitespace-only lines are discarded + - variable_message_length(): no-op (does not raise) + - send_message(): delegates to transport.write(); raises ConnectionError when + no active transport +""" + +from typing import List, Tuple +from unittest.mock import MagicMock + +import pytest + +from paradox.connections.protocols import ConnectionProtocol +from paradox.connections.prt3.connection import PRT3SerialConnection +from paradox.connections.prt3.protocol import PRT3Protocol +from paradox.connections.serial_connection import SerialCommunication + +# --------------------------------------------------------------------------- +# Smoke / type hierarchy +# --------------------------------------------------------------------------- + + +def test_prt3_protocol_is_connection_protocol(): + assert issubclass(PRT3Protocol, ConnectionProtocol) + + +def test_prt3_serial_connection_is_serial_communication(): + assert issubclass(PRT3SerialConnection, SerialCommunication) + + +def test_prt3_serial_connection_make_protocol_returns_prt3_protocol(): + conn = PRT3SerialConnection.__new__(PRT3SerialConnection) + proto = conn.make_protocol() + assert isinstance(proto, PRT3Protocol) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _Handler: + """Minimal ConnectionHandler that records on_message() calls.""" + + def __init__(self): + self.messages: List[bytes] = [] + + def on_message(self, raw: bytes): + self.messages.append(raw) + + def on_connection(self): + # Required by ConnectionHandler interface; no-op in test stub. + pass + + def on_connection_loss(self): + # Required by ConnectionHandler interface; no-op in test stub. + pass + + +def _make_proto() -> Tuple[PRT3Protocol, _Handler]: + handler = _Handler() + proto = PRT3Protocol(handler) + return proto, handler + + +# --------------------------------------------------------------------------- +# Framing: data_received() +# --------------------------------------------------------------------------- + + +def test_complete_line_emitted(): + proto, handler = _make_proto() + proto.data_received(b"COMM&ok\r") + assert handler.messages == [b"COMM&ok\r"] + + +def test_cr_included_in_emitted_bytes(): + proto, handler = _make_proto() + proto.data_received(b"G001N002A003\r") + assert handler.messages[0].endswith(b"\r") + + +def test_two_lines_in_one_chunk(): + proto, handler = _make_proto() + proto.data_received(b"COMM&ok\rG001N002A003\r") + assert len(handler.messages) == 2 + assert handler.messages[0] == b"COMM&ok\r" + assert handler.messages[1] == b"G001N002A003\r" + + +def test_line_split_across_two_chunks(): + proto, handler = _make_proto() + proto.data_received(b"COMM&") + assert handler.messages == [] # incomplete — not yet emitted + proto.data_received(b"ok\r") + assert handler.messages == [b"COMM&ok\r"] + + +def test_partial_line_not_emitted_until_cr(): + proto, handler = _make_proto() + proto.data_received(b"COMM&ok") + assert handler.messages == [] + + +def test_buffer_drained_after_complete_line(): + proto, _ = _make_proto() + proto.data_received(b"COMM&ok\r") + assert proto.buffer == b"" + + +def test_partial_remainder_kept_in_buffer(): + proto, handler = _make_proto() + proto.data_received(b"COMM&ok\rG001N002") + assert len(handler.messages) == 1 + assert proto.buffer == b"G001N002" + + +def test_empty_line_discarded(): + """A bare \\r with no payload must not call on_message().""" + proto, handler = _make_proto() + proto.data_received(b"\r") + assert handler.messages == [] + + +def test_whitespace_only_line_discarded(): + proto, handler = _make_proto() + proto.data_received(b" \r") + assert handler.messages == [] + + +def test_empty_line_between_real_lines(): + proto, handler = _make_proto() + proto.data_received(b"COMM&ok\r\rG001N002A003\r") + assert len(handler.messages) == 2 + assert handler.messages[0] == b"COMM&ok\r" + assert handler.messages[1] == b"G001N002A003\r" + + +def test_multiple_splits(): + """Three lines arriving byte-by-byte.""" + proto, handler = _make_proto() + payload = b"A\rB\rC\r" + for byte in payload: + proto.data_received(bytes([byte])) + assert handler.messages == [b"A\r", b"B\r", b"C\r"] + + +# --------------------------------------------------------------------------- +# variable_message_length() is a no-op +# --------------------------------------------------------------------------- + + +def test_variable_message_length_noop(): + proto, _ = _make_proto() + proto.variable_message_length(True) # should not raise + proto.variable_message_length(False) # should not raise + proto.variable_message_length(42) # arbitrary arg — should not raise + + +# --------------------------------------------------------------------------- +# send_message() raises when transport is not active +# --------------------------------------------------------------------------- + + +def test_send_message_raises_when_not_active(): + proto, _ = _make_proto() + # transport is None (not connected) → ConnectionError + with pytest.raises(ConnectionError): + proto.send_message(b"AQ001A\r") + + +def test_send_message_delegates_to_transport(): + proto, _ = _make_proto() + transport = MagicMock() + proto.transport = transport + # Provide a mock _closed future so is_active() returns True + import asyncio + + loop = asyncio.new_event_loop() + try: + proto._closed = loop.create_future() + proto.send_message(b"AQ001A\r") + transport.write.assert_called_once_with(b"AQ001A\r") + finally: + loop.close() diff --git a/tests/hardware/prt3/__init__.py b/tests/hardware/prt3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hardware/prt3/fixtures.py b/tests/hardware/prt3/fixtures.py new file mode 100644 index 00000000..aea09864 --- /dev/null +++ b/tests/hardware/prt3/fixtures.py @@ -0,0 +1,206 @@ +""" +PRT3 protocol fixture lines — verified against PRT3 ASCII Programming Guide. + +These are representative raw ASCII lines as they arrive from the PRT3 module +with the trailing ``\\r`` already stripped. They serve as documentation of +the wire format and as deterministic inputs for parametrized tests. + +Layout reminder +--------------- +Area status: ``RA{nnn}`` + 7 flag chars (total 12 chars) + [5] D/A/F/S/I arm state + [6] P/O in_programming + [7] T/O trouble + [8] N/O not_ready (N = area is NOT ready) + [9] A/O alarm + [10] S/O strobe + [11] M/O zone_in_memory + +Zone status: ``RZ{nnn}`` + 5 flag chars (total 10 chars) + [5] C/O/T/F open_state (C closed, O open, T tampered, F fire-loop trouble) + [6] A/O alarm + [7] F/O fire_alarm + [8] S/O supervision_trouble + [9] L/O low_battery + +Label: ``ZL|AL|UL{nnn}`` + 16-char label (total 21 chars, space-padded) + +Command echo: ``{5chars}&OK`` (8 chars) or ``{5chars}&fail`` (10 chars) + +System event: ``G{ggg}N{nnn}A{aaa}`` (12 chars) +""" + +# --------------------------------------------------------------------------- +# COMM status +# --------------------------------------------------------------------------- + +COMM_OK = "COMM&ok" # panel ready (startup or combus restore) +COMM_FAIL = "COMM&fail" # combus / panel communication failure + +# --------------------------------------------------------------------------- +# Buffer full +# --------------------------------------------------------------------------- + +BUFFER_FULL = "!" + +# --------------------------------------------------------------------------- +# Area status replies — RA{nnn}{7 flags} +# --------------------------------------------------------------------------- + +# Disarmed, all flags clear (ready to arm) +AREA_DISARMED = "RA001DOOOOOO" # D=disarmed, 6×O + +# Armed away, all flags clear +AREA_ARMED_AWAY = "RA001AOOOOOO" + +# Armed force, all flags clear +AREA_ARMED_FORCE = "RA002FOOOOOO" + +# Armed stay, all flags clear +AREA_ARMED_STAY = "RA003SOOOOOO" + +# Armed instant, all flags clear +AREA_ARMED_INSTANT = "RA004IOOOOOO" + +# Armed instant, zone in memory only +AREA_ARMED_MEMORY = "RA008IOOOOOM" + +# Armed away — alarm active, strobe active +AREA_ARMED_ALARM_STROBE = "RA001AOOOASO" + +# Armed away — alarm active, no strobe +AREA_ARMED_ALARM = "RA001AOOOAOO" + +# Stay armed — trouble active, area not ready +AREA_STAY_TROUBLE = "RA003SOTNOOO" + +# Disarmed — in programming +AREA_IN_PROGRAMMING = "RA001DPOOOOO" + +# All flags active (armed away, prog, trouble, not-ready, alarm, strobe, memory) +AREA_ALL_FLAGS = "RA001APTNASM" + +# Max area number (8), disarmed, all clear +AREA_MAX_NUMBER = "RA008DOOOOOO" + +# --------------------------------------------------------------------------- +# Zone status replies — RZ{nnn}{5 flags} +# --------------------------------------------------------------------------- + +ZONE_CLOSED_OK = "RZ001COOOO" # closed, all clear +ZONE_OPEN_OK = "RZ002OOOOO" # open, no alarms +ZONE_TAMPERED = "RZ003TOOOO" # tampered, no alarms +ZONE_FIRE_LOOP = "RZ004FOOOO" # fire-loop trouble +ZONE_ALARM = "RZ005OAOOO" # open, in alarm +ZONE_FIRE_ALARM = "RZ006OOFOO" # fire alarm (separate from fire-loop) +ZONE_SUPERVISION = "RZ007OOOSO" # supervision trouble +ZONE_LOW_BATTERY = "RZ008OOOOL" # low battery +ZONE_ALL_FLAGS = "RZ009OAFSL" # all flags active (open, alarm, fire, super, battery) +ZONE_MAX_NUMBER = "RZ192COOOO" # max zone (EVO192), closed, all clear + +# --------------------------------------------------------------------------- +# Label replies — ZL/AL/UL{nnn}{16-char label} (always exactly 21 chars) +# --------------------------------------------------------------------------- +# Labels are 16 chars, space-padded on the right. + +ZONE_LABEL_FRONT_DOOR = "ZL001Front Door " # "Front Door" + 6 sp +ZONE_LABEL_BACK_DOOR = "ZL002Back Door " # "Back Door" + 7 sp +ZONE_LABEL_MAX_ZONE = "ZL192Zone 192 " # "Zone 192" + 8 sp +AREA_LABEL_HOME = "AL001Home " # "Home" + 12 sp +AREA_LABEL_MAX_AREA = "AL008Area 8 " # "Area 8" + 10 sp +USER_LABEL_MASTER = "UL001Master " # "Master" + 10 sp +USER_LABEL_MAX_USER = "UL999User 999 " # "User 999" + 8 sp + +# Guard: every label line must be exactly 21 chars +_labels = [ + ZONE_LABEL_FRONT_DOOR, ZONE_LABEL_BACK_DOOR, ZONE_LABEL_MAX_ZONE, + AREA_LABEL_HOME, AREA_LABEL_MAX_AREA, USER_LABEL_MASTER, USER_LABEL_MAX_USER, +] +assert all(len(lbl) == 21 for lbl in _labels), \ + "All label fixtures must be exactly 21 chars (2-char prefix + 3-digit index + 16-char label)" + +# --------------------------------------------------------------------------- +# Command echo replies +# --------------------------------------------------------------------------- + +# Action command success +ECHO_ARM_OK = "AA001&OK" # arm area 1 succeeded +ECHO_QUICK_ARM_OK = "AQ001&OK" +ECHO_DISARM_OK = "AD001&OK" +ECHO_PANIC_EMERG_OK = "PE001&OK" +ECHO_PANIC_MED_OK = "PM001&OK" +ECHO_PANIC_FIRE_OK = "PF001&OK" +ECHO_UTILITY_KEY_OK = "UK001&OK" + +# Lowercase echo variants — some panel firmware sends "&ok" rather than "&OK" +ECHO_ARM_OK_LOWER = "AA001&ok" +ECHO_UTILITY_KEY_OK_LOWER = "UK001&ok" + +# Action command failure (invalid code, wrong state, etc.) +ECHO_ARM_FAIL = "AA001&fail" +ECHO_DISARM_FAIL = "AD001&fail" + +# Info command failure (area/zone/user not found, or out of range) +ECHO_STATUS_FAIL = "RA001&fail" # area status request failed +ECHO_LABEL_FAIL = "ZL001&fail" # zone label request failed + +# --------------------------------------------------------------------------- +# Async system events — G{ggg}N{nnn}A{aaa} +# --------------------------------------------------------------------------- + +EVENT_ZONE_OK = "G000N005A006" # G000=Zone OK, zone 5, area 6 +EVENT_ZONE_OPEN = "G001N005A006" # G001=Zone Open, zone 5, area 6 +EVENT_ZONE_TAMPER = "G002N012A002" # G002=Zone Tampered, zone 12, area 2 +EVENT_ARM_USER = "G010N001A001" # G010=Arm w/user code, user 1, area 1 +EVENT_DISARM_USER = "G014N002A002" # G014=Disarm w/user code, user 2, area 2 +EVENT_ZONE_ALARM = "G024N003A001" # G024=Zone Alarm, zone 3, area 1 +EVENT_FIRE_ALARM = "G025N007A001" # G025=Fire Alarm, zone 7, area 1 +EVENT_TROUBLE_AC = "G036N001A000" # G036=Trouble, AC failure, global +EVENT_POWER_UP = "G045N000A000" # G045=Power-up (all areas), global +EVENT_UTILITY_KEY = "G048N001A000" # G048=Utility Key 1, global +EVENT_STATUS1_ARMED = "G064N000A001" # G064=Status 1: Armed, area 1 +EVENT_STATUS2_EXIT_DLY = "G065N001A001" # G065=Status 2: Exit delay, area 1 +EVENT_STATUS2_ENTRY_DLY = "G065N002A001" # G065=Status 2: Entry delay, area 1 +EVENT_STATUS3_TAMPER = "G066N004A255" # G066=Status 3: Tamper, any area + +# Edge cases +EVENT_ALL_ZERO = "G000N000A000" # minimum — zone 0 / all areas +EVENT_MAX_VALUES = "G066N999A255" # max group, max number, "any area" + +# --------------------------------------------------------------------------- +# Virtual PGM events (v1 scope: parsed but not acted on) +# --------------------------------------------------------------------------- + +PGM_01_ON = "PGM01ON" +PGM_30_ON = "PGM30ON" +PGM_01_OFF = "PGM01OFF" +PGM_30_OFF = "PGM30OFF" + +# --------------------------------------------------------------------------- +# Convenience collections for replay / smoke tests +# --------------------------------------------------------------------------- + +ALL_FIXTURES = [ + COMM_OK, COMM_FAIL, + BUFFER_FULL, + AREA_DISARMED, AREA_ARMED_AWAY, AREA_ARMED_FORCE, AREA_ARMED_STAY, + AREA_ARMED_INSTANT, AREA_ARMED_MEMORY, AREA_ARMED_ALARM_STROBE, + AREA_ARMED_ALARM, AREA_STAY_TROUBLE, AREA_IN_PROGRAMMING, + AREA_ALL_FLAGS, AREA_MAX_NUMBER, + ZONE_CLOSED_OK, ZONE_OPEN_OK, ZONE_TAMPERED, ZONE_FIRE_LOOP, + ZONE_ALARM, ZONE_FIRE_ALARM, ZONE_SUPERVISION, ZONE_LOW_BATTERY, + ZONE_ALL_FLAGS, ZONE_MAX_NUMBER, + ZONE_LABEL_FRONT_DOOR, ZONE_LABEL_BACK_DOOR, ZONE_LABEL_MAX_ZONE, + AREA_LABEL_HOME, AREA_LABEL_MAX_AREA, USER_LABEL_MASTER, USER_LABEL_MAX_USER, + ECHO_ARM_OK, ECHO_QUICK_ARM_OK, ECHO_DISARM_OK, + ECHO_PANIC_EMERG_OK, ECHO_PANIC_MED_OK, ECHO_PANIC_FIRE_OK, + ECHO_UTILITY_KEY_OK, ECHO_ARM_OK_LOWER, ECHO_UTILITY_KEY_OK_LOWER, + ECHO_ARM_FAIL, ECHO_DISARM_FAIL, + ECHO_STATUS_FAIL, ECHO_LABEL_FAIL, + EVENT_ZONE_OK, EVENT_ZONE_OPEN, EVENT_ZONE_TAMPER, EVENT_ARM_USER, + EVENT_DISARM_USER, EVENT_ZONE_ALARM, EVENT_FIRE_ALARM, EVENT_TROUBLE_AC, + EVENT_POWER_UP, EVENT_UTILITY_KEY, EVENT_STATUS1_ARMED, + EVENT_STATUS2_EXIT_DLY, EVENT_STATUS2_ENTRY_DLY, + EVENT_STATUS3_TAMPER, EVENT_ALL_ZERO, EVENT_MAX_VALUES, + PGM_01_ON, PGM_30_ON, PGM_01_OFF, PGM_30_OFF, +] diff --git a/tests/hardware/prt3/test_adapter.py b/tests/hardware/prt3/test_adapter.py new file mode 100644 index 00000000..3866ddae --- /dev/null +++ b/tests/hardware/prt3/test_adapter.py @@ -0,0 +1,526 @@ +""" +Unit tests for the PRT3 state adapter (paradox.hardware.prt3.adapter). + +Tests cover: + - partition_status_from_area() — all arm states, all flag combinations + - zone_status_from_zone() — all open_state values, all flag combinations + - label_entry_from_reply() — area→partition mapping, key sanitization, + trailing space stripping, blank label fallback + - labels_dict_from_replies() — aggregation of mixed type replies + - build_flat_status() — flat key format, multi-area/zone accumulation, + empty inputs +""" + +import pytest + +from paradox.hardware.prt3.adapter import ( + build_flat_status, + label_entry_from_reply, + labels_dict_from_replies, + partition_status_from_area, + zone_status_from_zone, +) +from paradox.hardware.prt3.parser import ( + ARM_AWAY, + ARM_DISARMED, + ARM_FORCE, + ARM_INSTANT, + ARM_STAY, + ZONE_CLOSED, + ZONE_FIRE_LOOP_TROUBLE, + ZONE_OPEN, + ZONE_TAMPERED, + PRT3AreaStatus, + PRT3LabelReply, + PRT3ZoneStatus, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _area(area=1, arm_state=ARM_DISARMED, in_programming=False, trouble=False, + not_ready=False, alarm=False, strobe=False, zone_in_memory=False): + return PRT3AreaStatus( + area=area, + arm_state=arm_state, + in_programming=in_programming, + trouble=trouble, + not_ready=not_ready, + alarm=alarm, + strobe=strobe, + zone_in_memory=zone_in_memory, + ) + + +def _zone(zone=1, open_state=ZONE_CLOSED, alarm=False, fire_alarm=False, + supervision_trouble=False, low_battery=False): + return PRT3ZoneStatus( + zone=zone, + open_state=open_state, + alarm=alarm, + fire_alarm=fire_alarm, + supervision_trouble=supervision_trouble, + low_battery=low_battery, + ) + + +def _label(element_type="zone", index=1, label="Front Door "): + return PRT3LabelReply(element_type=element_type, index=index, label=label) + + +# --------------------------------------------------------------------------- +# partition_status_from_area +# --------------------------------------------------------------------------- + + +class TestPartitionStatusFromArea: + + # --- arm_state → boolean flag decomposition --- + + def test_disarmed(self): + r = partition_status_from_area(_area(arm_state=ARM_DISARMED)) + assert r["arm"] is False + assert r["arm_stay"] is False + assert r["arm_away"] is False + assert r["arm_force"] is False + + def test_armed_away(self): + r = partition_status_from_area(_area(arm_state=ARM_AWAY)) + assert r["arm"] is True + assert r["arm_stay"] is False + assert r["arm_away"] is True + assert r["arm_force"] is False + + def test_armed_force(self): + r = partition_status_from_area(_area(arm_state=ARM_FORCE)) + assert r["arm"] is True + assert r["arm_stay"] is False + assert r["arm_away"] is True + assert r["arm_force"] is True + + def test_armed_stay(self): + r = partition_status_from_area(_area(arm_state=ARM_STAY)) + assert r["arm"] is True + assert r["arm_stay"] is True + assert r["arm_away"] is False + assert r["arm_force"] is False + + def test_armed_instant_maps_to_stay(self): + # PRT3 'instant' has no PAI equivalent; mapped to stay + r = partition_status_from_area(_area(arm_state=ARM_INSTANT)) + assert r["arm"] is True + assert r["arm_stay"] is True + assert r["arm_away"] is False + assert r["arm_force"] is False + + # --- individual flag mappings --- + + def test_trouble_true(self): + r = partition_status_from_area(_area(trouble=True)) + assert r["trouble"] is True + + def test_trouble_false(self): + r = partition_status_from_area(_area(trouble=False)) + assert r["trouble"] is False + + def test_not_ready_inverted_to_ready_status_false(self): + r = partition_status_from_area(_area(not_ready=True)) + assert r["ready_status"] is False + + def test_ready_when_not_not_ready(self): + r = partition_status_from_area(_area(not_ready=False)) + assert r["ready_status"] is True + + def test_alarm_maps_to_audible_alarm(self): + r = partition_status_from_area(_area(alarm=True)) + assert r["audible_alarm"] is True + + def test_no_alarm(self): + r = partition_status_from_area(_area(alarm=False)) + assert r["audible_alarm"] is False + + def test_strobe_maps_to_strobe_alarm(self): + r = partition_status_from_area(_area(strobe=True)) + assert r["strobe_alarm"] is True + + def test_zone_in_memory_maps_to_alarms_in_memory(self): + r = partition_status_from_area(_area(zone_in_memory=True)) + assert r["alarms_in_memory"] is True + + def test_all_flags_clear(self): + r = partition_status_from_area(_area()) + assert r == { + "arm": False, + "arm_stay": False, + "arm_away": False, + "arm_force": False, + "trouble": False, + "ready_status": True, + "audible_alarm": False, + "strobe_alarm": False, + "alarms_in_memory": False, + } + + def test_all_flags_set(self): + r = partition_status_from_area(_area( + arm_state=ARM_AWAY, + trouble=True, + not_ready=True, + alarm=True, + strobe=True, + zone_in_memory=True, + )) + assert r["arm"] is True + assert r["arm_away"] is True + assert r["trouble"] is True + assert r["ready_status"] is False + assert r["audible_alarm"] is True + assert r["strobe_alarm"] is True + assert r["alarms_in_memory"] is True + + def test_area_number_not_in_output(self): + # area number is the container key, not a property in the dict + r = partition_status_from_area(_area(area=5)) + assert "area" not in r + + def test_returns_nine_keys(self): + r = partition_status_from_area(_area()) + assert len(r) == 9 + + +# --------------------------------------------------------------------------- +# zone_status_from_zone +# --------------------------------------------------------------------------- + + +class TestZoneStatusFromZone: + + # --- open_state decomposition --- + + def test_closed_all_false(self): + r = zone_status_from_zone(_zone(open_state=ZONE_CLOSED)) + assert r["open"] is False + assert r["tamper"] is False + assert r["fire_loop_trouble"] is False + + def test_open_sets_open_only(self): + r = zone_status_from_zone(_zone(open_state=ZONE_OPEN)) + assert r["open"] is True + assert r["tamper"] is False + assert r["fire_loop_trouble"] is False + + def test_tampered_sets_open_and_tamper(self): + r = zone_status_from_zone(_zone(open_state=ZONE_TAMPERED)) + assert r["open"] is True + assert r["tamper"] is True + assert r["fire_loop_trouble"] is False + + def test_fire_loop_trouble_sets_fire_loop_only(self): + r = zone_status_from_zone(_zone(open_state=ZONE_FIRE_LOOP_TROUBLE)) + assert r["open"] is False + assert r["tamper"] is False + assert r["fire_loop_trouble"] is True + + # --- individual zone flag mappings --- + + def test_alarm_flag(self): + r = zone_status_from_zone(_zone(alarm=True)) + assert r["alarm"] is True + + def test_fire_alarm_maps_to_fire(self): + r = zone_status_from_zone(_zone(fire_alarm=True)) + assert r["fire"] is True + + def test_supervision_trouble(self): + r = zone_status_from_zone(_zone(supervision_trouble=True)) + assert r["supervision_trouble"] is True + + def test_low_battery_maps_to_low_battery_trouble(self): + r = zone_status_from_zone(_zone(low_battery=True)) + assert r["low_battery_trouble"] is True + + def test_all_flags_clear(self): + r = zone_status_from_zone(_zone()) + assert r == { + "open": False, + "tamper": False, + "fire_loop_trouble": False, + "alarm": False, + "fire": False, + "supervision_trouble": False, + "low_battery_trouble": False, + } + + def test_all_flags_set(self): + r = zone_status_from_zone(_zone( + open_state=ZONE_TAMPERED, + alarm=True, fire_alarm=True, + supervision_trouble=True, low_battery=True, + )) + assert r["open"] is True + assert r["tamper"] is True + assert r["alarm"] is True + assert r["fire"] is True + assert r["supervision_trouble"] is True + assert r["low_battery_trouble"] is True + + def test_returns_seven_keys(self): + assert len(zone_status_from_zone(_zone())) == 7 + + def test_zone_number_not_in_output(self): + r = zone_status_from_zone(_zone(zone=7)) + assert "zone" not in r + + +# --------------------------------------------------------------------------- +# label_entry_from_reply +# --------------------------------------------------------------------------- + + +class TestLabelEntryFromReply: + + def test_zone_stays_zone(self): + container, _, _ = label_entry_from_reply( + _label(element_type="zone", index=1, label="Front Door ") + ) + assert container == "zone" + + def test_area_maps_to_partition(self): + container, _, _ = label_entry_from_reply( + _label(element_type="area", index=1, label="Home ") + ) + assert container == "partition" + + def test_user_stays_user(self): + container, _, _ = label_entry_from_reply( + _label(element_type="user", index=1, label="Master ") + ) + assert container == "user" + + def test_index_preserved(self): + _, idx, _ = label_entry_from_reply(_label(index=42)) + assert idx == 42 + + def test_entry_has_id_key_label(self): + _, _, entry = label_entry_from_reply(_label(index=3, label="Back Door ")) + assert entry["id"] == 3 + assert "key" in entry + assert "label" in entry + + def test_trailing_spaces_stripped_from_label(self): + _, _, entry = label_entry_from_reply(_label(label="Front Door ")) + assert entry["label"] == "Front Door" + + def test_key_is_sanitized(self): + _, _, entry = label_entry_from_reply(_label(label="Front Door ")) + # sanitize_key converts spaces/special chars to underscores + assert " " not in entry["key"] + + def test_key_matches_label_content(self): + _, _, entry = label_entry_from_reply(_label(label="Front Door ")) + assert "Front" in entry["key"] or "front" in entry["key"].lower() + + def test_blank_label_falls_back_to_numeric_key(self): + _, _, entry = label_entry_from_reply( + PRT3LabelReply(element_type="zone", index=7, label=" ") + ) + assert entry["label"] == "" + assert entry["key"] == "zone_007" + + def test_blank_area_label_uses_partition_prefix(self): + _, _, entry = label_entry_from_reply( + PRT3LabelReply(element_type="area", index=3, label=" ") + ) + assert entry["key"] == "partition_003" + + def test_max_zone_192(self): + container, idx, entry = label_entry_from_reply( + PRT3LabelReply(element_type="zone", index=192, label="Zone 192 ") + ) + assert container == "zone" + assert idx == 192 + assert entry["id"] == 192 + + def test_max_user_999(self): + _, idx, _ = label_entry_from_reply( + PRT3LabelReply(element_type="user", index=999, label="User 999 ") + ) + assert idx == 999 + + +# --------------------------------------------------------------------------- +# labels_dict_from_replies +# --------------------------------------------------------------------------- + + +class TestLabelsDictFromReplies: + + def test_empty_input(self): + result = labels_dict_from_replies([]) + assert result == {} + + def test_single_zone_label(self): + result = labels_dict_from_replies([ + PRT3LabelReply(element_type="zone", index=1, label="Front Door ") + ]) + assert "zone" in result + assert 1 in result["zone"] + assert result["zone"][1]["label"] == "Front Door" + + def test_area_label_under_partition_key(self): + result = labels_dict_from_replies([ + PRT3LabelReply(element_type="area", index=1, label="Home ") + ]) + assert "partition" in result + assert 1 in result["partition"] + + def test_mixed_types_each_in_own_container(self): + result = labels_dict_from_replies([ + PRT3LabelReply(element_type="zone", index=1, label="Front Door "), + PRT3LabelReply(element_type="area", index=1, label="Home "), + PRT3LabelReply(element_type="user", index=1, label="Master "), + ]) + assert "zone" in result + assert "partition" in result + assert "user" in result + + def test_multiple_zones(self): + result = labels_dict_from_replies([ + PRT3LabelReply(element_type="zone", index=1, label="Front Door "), + PRT3LabelReply(element_type="zone", index=2, label="Back Door "), + ]) + assert len(result["zone"]) == 2 + assert result["zone"][2]["label"] == "Back Door" + + def test_duplicate_index_overwritten_by_last(self): + result = labels_dict_from_replies([ + PRT3LabelReply(element_type="zone", index=1, label="First Label "), + PRT3LabelReply(element_type="zone", index=1, label="Second Label "), + ]) + assert result["zone"][1]["label"] == "Second Label" + + def test_entry_format_correct(self): + result = labels_dict_from_replies([ + PRT3LabelReply(element_type="zone", index=5, label="Garage Door ") + ]) + entry = result["zone"][5] + assert entry["id"] == 5 + assert entry["key"] != "" + assert entry["label"] == "Garage Door" + + +# --------------------------------------------------------------------------- +# build_flat_status +# --------------------------------------------------------------------------- + + +class TestBuildFlatStatus: + + def test_empty_inputs(self): + result = build_flat_status([], []) + assert result == {} + + def test_single_disarmed_area(self): + result = build_flat_status([_area(area=1, arm_state=ARM_DISARMED)], []) + assert result["partition_arm"] == {1: False} + assert result["partition_arm_stay"] == {1: False} + assert result["partition_arm_away"] == {1: False} + assert result["partition_ready_status"] == {1: True} + + def test_single_armed_away_area(self): + result = build_flat_status([_area(area=1, arm_state=ARM_AWAY)], []) + assert result["partition_arm"] == {1: True} + assert result["partition_arm_away"] == {1: True} + assert result["partition_arm_stay"] == {1: False} + + def test_multiple_areas_keyed_by_area_id(self): + result = build_flat_status([ + _area(area=1, arm_state=ARM_AWAY), + _area(area=2, arm_state=ARM_DISARMED), + ], []) + assert result["partition_arm"] == {1: True, 2: False} + assert result["partition_arm_away"] == {1: True, 2: False} + + def test_single_closed_zone(self): + result = build_flat_status([], [_zone(zone=1, open_state=ZONE_CLOSED)]) + assert result["zone_open"] == {1: False} + assert result["zone_tamper"] == {1: False} + assert result["zone_alarm"] == {1: False} + + def test_single_open_zone(self): + result = build_flat_status([], [_zone(zone=2, open_state=ZONE_OPEN)]) + assert result["zone_open"] == {2: True} + assert result["zone_tamper"] == {2: False} + + def test_tampered_zone_sets_open_and_tamper(self): + result = build_flat_status([], [_zone(zone=3, open_state=ZONE_TAMPERED)]) + assert result["zone_open"] == {3: True} + assert result["zone_tamper"] == {3: True} + + def test_multiple_zones_keyed_by_zone_id(self): + result = build_flat_status([], [ + _zone(zone=1, open_state=ZONE_CLOSED), + _zone(zone=5, open_state=ZONE_OPEN), + ]) + assert result["zone_open"] == {1: False, 5: True} + + def test_partition_keys_prefixed_partition(self): + result = build_flat_status([_area(area=1)], []) + for key in result: + assert key.startswith("partition_"), f"unexpected key: {key!r}" + + def test_zone_keys_prefixed_zone(self): + result = build_flat_status([], [_zone(zone=1)]) + for key in result: + assert key.startswith("zone_"), f"unexpected key: {key!r}" + + def test_mixed_areas_and_zones(self): + result = build_flat_status( + [_area(area=1, arm_state=ARM_STAY)], + [_zone(zone=1, open_state=ZONE_OPEN, alarm=True)], + ) + assert result["partition_arm_stay"] == {1: True} + assert result["zone_open"] == {1: True} + assert result["zone_alarm"] == {1: True} + + def test_ready_status_inversion_in_flat_dict(self): + # not_ready=True on wire → ready_status=False in flat dict + result = build_flat_status([_area(area=1, not_ready=True)], []) + assert result["partition_ready_status"] == {1: False} + + def test_fire_alarm_zone_maps_to_zone_fire(self): + result = build_flat_status([], [_zone(zone=1, fire_alarm=True)]) + assert result["zone_fire"] == {1: True} + + def test_alarm_area_maps_to_partition_audible_alarm(self): + result = build_flat_status([_area(area=1, alarm=True)], []) + assert result["partition_audible_alarm"] == {1: True} + + def test_low_battery_zone_maps_to_low_battery_trouble(self): + result = build_flat_status([], [_zone(zone=1, low_battery=True)]) + assert result["zone_low_battery_trouble"] == {1: True} + + def test_supervision_trouble_zone(self): + result = build_flat_status([], [_zone(zone=1, supervision_trouble=True)]) + assert result["zone_supervision_trouble"] == {1: True} + + def test_all_9_partition_props_present(self): + result = build_flat_status([_area(area=1)], []) + expected = { + "partition_arm", "partition_arm_stay", "partition_arm_away", + "partition_arm_force", "partition_trouble", "partition_ready_status", + "partition_audible_alarm", "partition_strobe_alarm", + "partition_alarms_in_memory", + } + assert expected.issubset(result.keys()) + + def test_all_7_zone_props_present(self): + result = build_flat_status([], [_zone(zone=1)]) + expected = { + "zone_open", "zone_tamper", "zone_fire_loop_trouble", + "zone_alarm", "zone_fire", "zone_supervision_trouble", + "zone_low_battery_trouble", + } + assert expected.issubset(result.keys()) diff --git a/tests/hardware/prt3/test_command_dispatch.py b/tests/hardware/prt3/test_command_dispatch.py new file mode 100644 index 00000000..9f5ec0fa --- /dev/null +++ b/tests/hardware/prt3/test_command_dispatch.py @@ -0,0 +1,355 @@ +""" +Command dispatch tests — Paradox.control_partition and control_utility_key. + +Tests cover: + - control_partition: resolves partition from storage, calls panel method, + triggers status refresh. + - control_utility_key: PRT3 guard, delegates to panel, error paths. + - Logical success vs transport failure distinctions. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from paradox.data.enums import RunState + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_paradox(monkeypatch, connection_type="PRT3"): + """ + Build a Paradox instance with a mocked connection and panel. + + Returns (paradox, mock_panel). + """ + from paradox.paradox import Paradox + + monkeypatch.setattr("paradox.paradox.cfg.CONNECTION_TYPE", connection_type) + + paradox = Paradox.__new__(Paradox) + paradox.request_lock = asyncio.Lock() + paradox.busy = asyncio.Lock() + paradox.loop_wait_event = asyncio.Event() + paradox._run_state = RunState.CONNECTED + paradox.work_loop = None + paradox._partition_arm_freeze_until = {} + + from paradox.data.memory_storage import MemoryStorage + paradox.storage = MemoryStorage() + + mock_panel = MagicMock() + mock_panel.control_partitions = AsyncMock(return_value=True) + mock_panel.send_utility_key = AsyncMock(return_value=True) + paradox.panel = mock_panel + + paradox._connection = MagicMock() + paradox.request_status_refresh = MagicMock() + + return paradox, mock_panel + + +# --------------------------------------------------------------------------- +# control_partition — delegates to panel.control_partitions +# --------------------------------------------------------------------------- + + +async def test_control_partition_calls_panel(monkeypatch): + """control_partition resolves partition from storage and calls panel.""" + from paradox.paradox import Paradox + + paradox, mock_panel = _make_paradox(monkeypatch) + + # Populate storage with a named partition + paradox.storage.get_container("partition")[1] = { + "id": 1, "key": 1, "label": "Home" + } + + result = await paradox.control_partition("1", "arm") + + assert result is True + mock_panel.control_partitions.assert_awaited_once() + call_args = mock_panel.control_partitions.call_args + assert call_args[0][1] == "arm" # command arg + paradox.request_status_refresh.assert_called_once() + + +async def test_control_partition_returns_false_if_not_found(monkeypatch): + """control_partition returns False when no partition matches the selector.""" + paradox, mock_panel = _make_paradox(monkeypatch) + # storage is empty + + result = await paradox.control_partition("99", "arm") + + assert result is False + mock_panel.control_partitions.assert_not_awaited() + + +async def test_control_partition_panel_refuses(monkeypatch): + """Panel returning False propagates as False to caller.""" + paradox, mock_panel = _make_paradox(monkeypatch) + mock_panel.control_partitions = AsyncMock(return_value=False) + + paradox.storage.get_container("partition")[1] = { + "id": 1, "key": 1, "label": "Home" + } + + result = await paradox.control_partition("1", "disarm") + + assert result is False + paradox.request_status_refresh.assert_called_once() + + +async def test_control_partition_not_implemented(monkeypatch): + """NotImplementedError from panel is caught and returns False.""" + paradox, mock_panel = _make_paradox(monkeypatch) + mock_panel.control_partitions = AsyncMock(side_effect=NotImplementedError) + + paradox.storage.get_container("partition")[1] = { + "id": 1, "key": 1, "label": "Home" + } + + result = await paradox.control_partition("1", "arm") + assert result is False + + +# --------------------------------------------------------------------------- +# control_utility_key — PRT3 guard and delegation +# --------------------------------------------------------------------------- + + +async def test_control_utility_key_prt3_accepted(monkeypatch): + """PRT3 path: delegates to panel.send_utility_key and returns True.""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + + result = await paradox.control_utility_key(5) + + assert result is True + mock_panel.send_utility_key.assert_awaited_once_with(5) + + +async def test_control_utility_key_non_prt3_returns_false(monkeypatch): + """Non-PRT3 connection type: returns False without calling panel.""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="Serial") + + result = await paradox.control_utility_key(5) + + assert result is False + mock_panel.send_utility_key.assert_not_awaited() + + +async def test_control_utility_key_panel_rejects(monkeypatch): + """Panel returning False propagates as False.""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + mock_panel.send_utility_key = AsyncMock(return_value=False) + + assert await paradox.control_utility_key(10) is False + + +async def test_control_utility_key_panel_not_implemented(monkeypatch): + """NotImplementedError from panel is caught and returns False.""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + mock_panel.send_utility_key = AsyncMock(side_effect=NotImplementedError) + + assert await paradox.control_utility_key(5) is False + + +async def test_control_utility_key_cancelled(monkeypatch): + """CancelledError from panel is re-raised so the task can be cancelled cleanly.""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + mock_panel.send_utility_key = AsyncMock(side_effect=asyncio.CancelledError) + + with pytest.raises(asyncio.CancelledError): + await paradox.control_utility_key(5) + + +# --------------------------------------------------------------------------- +# Transport vs logical failure distinction +# --------------------------------------------------------------------------- + + +async def test_utility_key_transport_success_is_true(monkeypatch): + """Panel &ok echo → True (transport and logical success).""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + mock_panel.send_utility_key = AsyncMock(return_value=True) + + assert await paradox.control_utility_key(1) is True + + +async def test_utility_key_transport_timeout_is_false(monkeypatch): + """Timeout (panel never echoed) → False (transport failure).""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + mock_panel.send_utility_key = AsyncMock(return_value=False) # panel returns False on timeout + + assert await paradox.control_utility_key(1) is False + + +async def test_utility_key_panel_rejection_is_false(monkeypatch): + """Panel &fail echo → False (logical failure, transport succeeded).""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + mock_panel.send_utility_key = AsyncMock(return_value=False) + + assert await paradox.control_utility_key(2) is False + + +# --------------------------------------------------------------------------- +# handle_prt3_event_message — global partition event broadcast +# --------------------------------------------------------------------------- + + +def _make_paradox_with_partitions(monkeypatch): + """Paradox instance pre-populated with two partitions in storage.""" + paradox, _ = _make_paradox(monkeypatch) + paradox.storage.get_container("partition")[1] = { + "id": 1, "key": "home", "label": "Home", "arm": True, "exit_delay": True, + } + paradox.storage.get_container("partition")[2] = { + "id": 2, "key": "downstairs", "label": "Downstairs", "arm": True, "exit_delay": True, + } + return paradox + + +def test_global_disarm_event_clears_exit_delay_on_all_partitions(monkeypatch): + """G014 with area=0 (global disarm) must clear exit_delay on every partition. + + Regression test for: disarming during exit delay from the panel keypad sends + a global disarm event (area=0). The previous code called + get_container_object("partition", 0) which returned None and silently dropped + the change, leaving exit_delay=True and HA stuck in 'arming' state. + """ + from paradox.hardware.prt3.parser import PRT3SystemEvent + + with patch("paradox.paradox.ps.sendChange"), patch("paradox.paradox.ps.sendEvent"): + paradox = _make_paradox_with_partitions(monkeypatch) + + # Simulate a global disarm event: area=0 means "all areas" + msg = PRT3SystemEvent(group=14, number=1, area=0) + paradox.handle_prt3_event_message(msg) + + p1 = paradox.storage.get_container_object("partition", 1) + p2 = paradox.storage.get_container_object("partition", 2) + assert p1["exit_delay"] is False, "partition 1 exit_delay must be cleared by global disarm" + assert p2["exit_delay"] is False, "partition 2 exit_delay must be cleared by global disarm" + assert p1["arm"] is False, "partition 1 arm must be cleared by global disarm" + assert p2["arm"] is False, "partition 2 arm must be cleared by global disarm" + + +def test_global_disarm_area255_clears_exit_delay_on_all_partitions(monkeypatch): + """G014 with area=255 (any area) is also treated as global and clears all partitions.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + + with patch("paradox.paradox.ps.sendChange"), patch("paradox.paradox.ps.sendEvent"): + paradox = _make_paradox_with_partitions(monkeypatch) + + msg = PRT3SystemEvent(group=14, number=1, area=255) + paradox.handle_prt3_event_message(msg) + + p1 = paradox.storage.get_container_object("partition", 1) + p2 = paradox.storage.get_container_object("partition", 2) + assert p1["exit_delay"] is False + assert p2["exit_delay"] is False + + +def test_specific_area_disarm_only_updates_that_partition(monkeypatch): + """G014 with a specific area (e.g. area=2) only updates partition 2, not partition 1.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + + with patch("paradox.paradox.ps.sendChange"), patch("paradox.paradox.ps.sendEvent"): + paradox = _make_paradox_with_partitions(monkeypatch) + + msg = PRT3SystemEvent(group=14, number=1, area=2) + paradox.handle_prt3_event_message(msg) + + p1 = paradox.storage.get_container_object("partition", 1) + p2 = paradox.storage.get_container_object("partition", 2) + assert p1["exit_delay"] is True, "partition 1 must NOT be touched by area=2 event" + assert p2["exit_delay"] is False, "partition 2 exit_delay must be cleared by area=2 disarm" + + +# --------------------------------------------------------------------------- +# Optimistic disarm — control_partition reflects panel &OK echo immediately +# --------------------------------------------------------------------------- + + +async def test_disarm_optimistically_clears_arm_and_exit_delay(monkeypatch): + """control_partition('disarm') applies disarmed state on panel &OK echo. + + Without this the next RA poll lags 4–7 s on a busy panel and HA shows a + transient 'armed' state because RA reports arm_state='armed_stay' for a + moment after the disarm command (panel hasn't fully cleared its arm flags + yet). G-events alone aren't reliable: PRT3 ASCII-initiated disarms during + exit delay don't always emit G014, only G065N000 (Ready) which per spec + isn't an exit_delay-cleared signal. + """ + paradox, _ = _make_paradox(monkeypatch, connection_type="PRT3") + paradox.storage.get_container("partition")[2] = { + "id": 2, "key": "downstairs", "label": "Downstairs", + "arm": True, "arm_stay": True, "arm_away": False, "arm_force": False, + "exit_delay": True, "entry_delay": False, + } + + with patch("paradox.paradox.ps.sendChange"), patch("paradox.paradox.ps.sendEvent"), \ + patch("paradox.paradox.ps.sendMessage"), patch("paradox.paradox.ps.sendNotification"): + result = await paradox.control_partition("2", "disarm") + + assert result is True + p2 = paradox.storage.get_container_object("partition", 2) + assert p2["arm"] is False + assert p2["arm_stay"] is False + assert p2["exit_delay"] is False + assert p2["entry_delay"] is False + + +async def test_disarm_freezes_arm_against_stale_ra_poll(monkeypatch): + """After a disarm, a stale RA poll showing armed_stay must NOT undo the optimistic disarm. + + The panel's internal state lags ~hundreds of ms after &OK; if RA polls run + in that gap and report arm_state='armed_stay', they'd briefly re-assert + arm=True and HA flashes 'armed_home' before the next poll corrects it. + The freeze window drops arm-related keys from RA updates for a few seconds. + """ + paradox, _ = _make_paradox(monkeypatch, connection_type="PRT3") + paradox.storage.get_container("partition")[2] = { + "id": 2, "key": "downstairs", "label": "Downstairs", + "arm": True, "arm_stay": True, "exit_delay": True, + } + + with patch("paradox.paradox.ps.sendChange"), patch("paradox.paradox.ps.sendEvent"), \ + patch("paradox.paradox.ps.sendMessage"), patch("paradox.paradox.ps.sendNotification"): + await paradox.control_partition("2", "disarm") + + # Simulate an RA poll arriving during the freeze window — should be a no-op + # for arm-related fields. Other fields (trouble) flow through normally. + stale_ra = { + "partition": { + 2: {"arm": True, "arm_stay": True, "trouble": True}, + } + } + paradox._on_status_update(stale_ra) + + p2 = paradox.storage.get_container_object("partition", 2) + assert p2["arm"] is False, "freeze must drop stale RA arm=True" + assert p2["arm_stay"] is False, "freeze must drop stale RA arm_stay=True" + assert p2["trouble"] is True, "non-arm RA fields must still flow through" + + +async def test_arm_does_not_optimistically_change_state(monkeypatch): + """control_partition('arm_stay') leaves storage alone — G065N001 + RA set state.""" + paradox, _ = _make_paradox(monkeypatch, connection_type="PRT3") + paradox.storage.get_container("partition")[2] = { + "id": 2, "key": "downstairs", "label": "Downstairs", + "arm": False, "arm_stay": False, "exit_delay": False, + } + + with patch("paradox.paradox.ps.sendChange"), patch("paradox.paradox.ps.sendEvent"), \ + patch("paradox.paradox.ps.sendMessage"), patch("paradox.paradox.ps.sendNotification"): + result = await paradox.control_partition("2", "arm_stay") + + assert result is True + p2 = paradox.storage.get_container_object("partition", 2) + assert p2["arm"] is False, "arm should be set by G065N001/RA, not optimistically" + assert p2["exit_delay"] is False, "exit_delay should be set by G065N001, not optimistically" diff --git a/tests/hardware/prt3/test_encoder.py b/tests/hardware/prt3/test_encoder.py new file mode 100644 index 00000000..2da3ff13 --- /dev/null +++ b/tests/hardware/prt3/test_encoder.py @@ -0,0 +1,479 @@ +""" +Unit tests for PRT3 ASCII command encoder. + +Each test verifies: + - exact output bytes (including trailing \\r) + - output is valid ASCII + - echo prefix (first 5 chars of decoded output, excl. \\r) matches what the panel + would echo back to identify the reply +""" + +import pytest + +from paradox.hardware.prt3.encoder import ( + ARM_MODE_AWAY, + ARM_MODE_FORCE, + ARM_MODE_INSTANT, + ARM_MODE_STAY, + encode_area_label_request, + encode_area_status_request, + encode_arm, + encode_disarm, + encode_panic_emergency, + encode_panic_fire, + encode_panic_medical, + encode_quick_arm, + encode_utility_key, + encode_user_label_request, + encode_zone_label_request, + encode_zone_status_request, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _decode(b: bytes) -> str: + """Assert b is bytes, ASCII-decodable, ends with \\r; return stripped text.""" + assert isinstance(b, bytes), f"expected bytes, got {type(b).__name__}" + text = b.decode("ascii") # raises if not valid ASCII + assert text.endswith("\r"), f"command must end with \\r: {b!r}" + return text[:-1] # strip trailing \r for inspection + + +def _echo_prefix(b: bytes) -> str: + """Return the first 5 chars of the command (what the panel echoes).""" + return _decode(b)[:5] + + +# --------------------------------------------------------------------------- +# encode_area_status_request RA{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodeAreaStatusRequest: + def test_area_1(self): + assert encode_area_status_request(1) == b"RA001\r" + + def test_area_8(self): + assert encode_area_status_request(8) == b"RA008\r" + + def test_area_4(self): + assert encode_area_status_request(4) == b"RA004\r" + + def test_output_is_bytes_ending_cr(self): + b = encode_area_status_request(1) + _decode(b) # asserts bytes + \r + ascii + + def test_echo_prefix(self): + assert _echo_prefix(encode_area_status_request(3)) == "RA003" + + @pytest.mark.parametrize("bad", [0, 9, -1, 100]) + def test_out_of_range_raises(self, bad): + with pytest.raises(ValueError): + encode_area_status_request(bad) + + +# --------------------------------------------------------------------------- +# encode_zone_status_request RZ{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodeZoneStatusRequest: + def test_zone_1(self): + assert encode_zone_status_request(1) == b"RZ001\r" + + def test_zone_192(self): + assert encode_zone_status_request(192) == b"RZ192\r" + + def test_zone_96(self): + assert encode_zone_status_request(96) == b"RZ096\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_zone_status_request(5)) == "RZ005" + + @pytest.mark.parametrize("bad", [0, 193, -1, 1000]) + def test_out_of_range_raises(self, bad): + with pytest.raises(ValueError): + encode_zone_status_request(bad) + + +# --------------------------------------------------------------------------- +# encode_area_label_request AL{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodeAreaLabelRequest: + def test_area_1(self): + assert encode_area_label_request(1) == b"AL001\r" + + def test_area_8(self): + assert encode_area_label_request(8) == b"AL008\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_area_label_request(2)) == "AL002" + + @pytest.mark.parametrize("bad", [0, 9, -1]) + def test_out_of_range_raises(self, bad): + with pytest.raises(ValueError): + encode_area_label_request(bad) + + +# --------------------------------------------------------------------------- +# encode_zone_label_request ZL{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodeZoneLabelRequest: + def test_zone_1(self): + assert encode_zone_label_request(1) == b"ZL001\r" + + def test_zone_192(self): + assert encode_zone_label_request(192) == b"ZL192\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_zone_label_request(10)) == "ZL010" + + @pytest.mark.parametrize("bad", [0, 193]) + def test_out_of_range_raises(self, bad): + with pytest.raises(ValueError): + encode_zone_label_request(bad) + + +# --------------------------------------------------------------------------- +# encode_user_label_request UL{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodeUserLabelRequest: + def test_user_1(self): + assert encode_user_label_request(1) == b"UL001\r" + + def test_user_999(self): + assert encode_user_label_request(999) == b"UL999\r" + + def test_user_100(self): + assert encode_user_label_request(100) == b"UL100\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_user_label_request(42)) == "UL042" + + @pytest.mark.parametrize("bad", [0, 1000, -1]) + def test_out_of_range_raises(self, bad): + with pytest.raises(ValueError): + encode_user_label_request(bad) + + +# --------------------------------------------------------------------------- +# encode_arm AA{nnn}{mode}{code}\r +# --------------------------------------------------------------------------- + + +class TestEncodeArm: + # Exact output format + def test_arm_away_code_1234(self): + assert encode_arm(1, ARM_MODE_AWAY, "1234") == b"AA001A1234\r" + + def test_arm_force(self): + assert encode_arm(2, ARM_MODE_FORCE, "1") == b"AA002F1\r" + + def test_arm_stay(self): + assert encode_arm(3, ARM_MODE_STAY, "123456") == b"AA003S123456\r" + + def test_arm_instant(self): + assert encode_arm(4, ARM_MODE_INSTANT, "9999") == b"AA004I9999\r" + + # All 4 arm modes produce correct mode char at position 4 (0-indexed) + @pytest.mark.parametrize("mode,char", [ + (ARM_MODE_AWAY, "A"), + (ARM_MODE_FORCE, "F"), + (ARM_MODE_STAY, "S"), + (ARM_MODE_INSTANT, "I"), + ]) + def test_all_arm_modes(self, mode, char): + result = _decode(encode_arm(1, mode, "1234")) + assert result[5] == char, f"mode char at index 5 should be {char!r}" + + # Echo prefix is always first 5 chars (AA + 3-digit area) + def test_echo_prefix_area_1(self): + assert _echo_prefix(encode_arm(1, ARM_MODE_AWAY, "1234")) == "AA001" + + def test_echo_prefix_area_8(self): + assert _echo_prefix(encode_arm(8, ARM_MODE_INSTANT, "999999")) == "AA008" + + # Code length boundaries: 1-6 digits accepted + def test_code_length_1(self): + assert encode_arm(1, ARM_MODE_AWAY, "5") == b"AA001A5\r" + + def test_code_length_6(self): + assert encode_arm(1, ARM_MODE_AWAY, "123456") == b"AA001A123456\r" + + # Code must be variable-length (not zero-padded to 6) + def test_code_not_padded(self): + text = _decode(encode_arm(1, ARM_MODE_AWAY, "5")) + assert text == "AA001A5" + + # Boundary area numbers + def test_area_min(self): + assert encode_arm(1, ARM_MODE_AWAY, "1") == b"AA001A1\r" + + def test_area_max(self): + assert encode_arm(8, ARM_MODE_AWAY, "1") == b"AA008A1\r" + + # ValueError cases + @pytest.mark.parametrize("bad_area", [0, 9, -1]) + def test_bad_area_raises(self, bad_area): + with pytest.raises(ValueError): + encode_arm(bad_area, ARM_MODE_AWAY, "1234") + + def test_bad_mode_raises(self): + with pytest.raises(ValueError): + encode_arm(1, "X", "1234") + + def test_empty_code_raises(self): + with pytest.raises(ValueError): + encode_arm(1, ARM_MODE_AWAY, "") + + def test_code_too_long_raises(self): + with pytest.raises(ValueError): + encode_arm(1, ARM_MODE_AWAY, "1234567") # 7 digits + + def test_non_digit_code_raises(self): + with pytest.raises(ValueError): + encode_arm(1, ARM_MODE_AWAY, "12ab") + + def test_code_type_error_raises(self): + with pytest.raises((ValueError, TypeError)): + encode_arm(1, ARM_MODE_AWAY, 1234) # type: ignore[arg-type] # NOSONAR + + +# --------------------------------------------------------------------------- +# encode_quick_arm AQ{nnn}{mode}\r +# --------------------------------------------------------------------------- + + +class TestEncodeQuickArm: + def test_quick_arm_away(self): + assert encode_quick_arm(1, ARM_MODE_AWAY) == b"AQ001A\r" + + def test_quick_arm_force(self): + assert encode_quick_arm(2, ARM_MODE_FORCE) == b"AQ002F\r" + + def test_quick_arm_stay(self): + assert encode_quick_arm(3, ARM_MODE_STAY) == b"AQ003S\r" + + def test_quick_arm_instant(self): + assert encode_quick_arm(4, ARM_MODE_INSTANT) == b"AQ004I\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_quick_arm(1, ARM_MODE_AWAY)) == "AQ001" + + def test_area_max(self): + assert encode_quick_arm(8, ARM_MODE_INSTANT) == b"AQ008I\r" + + @pytest.mark.parametrize("bad_area", [0, 9, -1]) + def test_bad_area_raises(self, bad_area): + with pytest.raises(ValueError): + encode_quick_arm(bad_area, ARM_MODE_AWAY) + + def test_bad_mode_raises(self): + with pytest.raises(ValueError): + encode_quick_arm(1, "Z") + + # Quick arm has no code — verify no extra chars beyond AQ{nnn}{mode} + def test_no_extra_chars(self): + text = _decode(encode_quick_arm(1, ARM_MODE_AWAY)) + assert text == "AQ001A" + + +# --------------------------------------------------------------------------- +# encode_disarm AD{nnn}{code}\r +# --------------------------------------------------------------------------- + + +class TestEncodeDisarm: + def test_area_1_code_1234(self): + assert encode_disarm(1, "1234") == b"AD0011234\r" + + def test_area_8_code_999999(self): + assert encode_disarm(8, "999999") == b"AD008999999\r" + + def test_code_length_1(self): + assert encode_disarm(1, "5") == b"AD0015\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_disarm(3, "1111")) == "AD003" + + @pytest.mark.parametrize("bad_area", [0, 9]) + def test_bad_area_raises(self, bad_area): + with pytest.raises(ValueError): + encode_disarm(bad_area, "1234") + + def test_empty_code_raises(self): + with pytest.raises(ValueError): + encode_disarm(1, "") + + def test_code_too_long_raises(self): + with pytest.raises(ValueError): + encode_disarm(1, "1234567") + + def test_non_digit_code_raises(self): + with pytest.raises(ValueError): + encode_disarm(1, "pass") + + +# --------------------------------------------------------------------------- +# encode_panic_emergency PE{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodePanicEmergency: + def test_area_1(self): + assert encode_panic_emergency(1) == b"PE001\r" + + def test_area_8(self): + assert encode_panic_emergency(8) == b"PE008\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_panic_emergency(1)) == "PE001" + + @pytest.mark.parametrize("bad", [0, 9]) + def test_bad_area_raises(self, bad): + with pytest.raises(ValueError): + encode_panic_emergency(bad) + + +# --------------------------------------------------------------------------- +# encode_panic_medical PM{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodePanicMedical: + def test_area_1(self): + assert encode_panic_medical(1) == b"PM001\r" + + def test_area_8(self): + assert encode_panic_medical(8) == b"PM008\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_panic_medical(2)) == "PM002" + + @pytest.mark.parametrize("bad", [0, 9]) + def test_bad_area_raises(self, bad): + with pytest.raises(ValueError): + encode_panic_medical(bad) + + +# --------------------------------------------------------------------------- +# encode_panic_fire PF{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodePanicFire: + def test_area_1(self): + assert encode_panic_fire(1) == b"PF001\r" + + def test_area_8(self): + assert encode_panic_fire(8) == b"PF008\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_panic_fire(5)) == "PF005" + + @pytest.mark.parametrize("bad", [0, 9]) + def test_bad_area_raises(self, bad): + with pytest.raises(ValueError): + encode_panic_fire(bad) + + +# --------------------------------------------------------------------------- +# encode_utility_key UK{nnn}\r +# --------------------------------------------------------------------------- + + +class TestEncodeUtilityKey: + def test_key_1(self): + assert encode_utility_key(1) == b"UK001\r" + + def test_key_251(self): + assert encode_utility_key(251) == b"UK251\r" + + def test_key_100(self): + assert encode_utility_key(100) == b"UK100\r" + + def test_echo_prefix(self): + assert _echo_prefix(encode_utility_key(1)) == "UK001" + + @pytest.mark.parametrize("bad", [0, 252, -1, 1000]) + def test_out_of_range_raises(self, bad): + with pytest.raises(ValueError): + encode_utility_key(bad) + + +# --------------------------------------------------------------------------- +# Cross-cutting: all outputs are bytes, valid ASCII, end with \r +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cmd_bytes", [ + encode_area_status_request(1), + encode_area_status_request(8), + encode_zone_status_request(1), + encode_zone_status_request(192), + encode_area_label_request(1), + encode_area_label_request(8), + encode_zone_label_request(1), + encode_zone_label_request(192), + encode_user_label_request(1), + encode_user_label_request(999), + encode_arm(1, ARM_MODE_AWAY, "1234"), + encode_arm(8, ARM_MODE_FORCE, "9"), + encode_arm(1, ARM_MODE_STAY, "123456"), + encode_arm(4, ARM_MODE_INSTANT, "0000"), + encode_quick_arm(1, ARM_MODE_AWAY), + encode_quick_arm(8, ARM_MODE_INSTANT), + encode_disarm(1, "1234"), + encode_disarm(8, "999999"), + encode_panic_emergency(1), + encode_panic_medical(1), + encode_panic_fire(1), + encode_utility_key(1), + encode_utility_key(251), +]) +def test_all_commands_are_bytes_ascii_cr_terminated(cmd_bytes): + assert isinstance(cmd_bytes, bytes) + text = cmd_bytes.decode("ascii") # raises if not valid ASCII + assert text.endswith("\r") + + +# --------------------------------------------------------------------------- +# Echo prefix table: first 5 chars == 2-char verb + 3-digit zero-padded number +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cmd_bytes,expected_prefix", [ + (encode_area_status_request(1), "RA001"), + (encode_area_status_request(8), "RA008"), + (encode_zone_status_request(1), "RZ001"), + (encode_zone_status_request(192), "RZ192"), + (encode_area_label_request(1), "AL001"), + (encode_area_label_request(8), "AL008"), + (encode_zone_label_request(1), "ZL001"), + (encode_zone_label_request(192), "ZL192"), + (encode_user_label_request(1), "UL001"), + (encode_user_label_request(999), "UL999"), + (encode_arm(1, ARM_MODE_AWAY, "1234"), "AA001"), + (encode_arm(8, ARM_MODE_AWAY, "1234"), "AA008"), + (encode_quick_arm(1, ARM_MODE_AWAY), "AQ001"), + (encode_quick_arm(8, ARM_MODE_STAY), "AQ008"), + (encode_disarm(1, "1234"), "AD001"), + (encode_disarm(8, "1234"), "AD008"), + (encode_panic_emergency(1), "PE001"), + (encode_panic_medical(1), "PM001"), + (encode_panic_fire(1), "PF001"), + (encode_utility_key(1), "UK001"), + (encode_utility_key(251), "UK251"), +]) +def test_echo_prefixes(cmd_bytes, expected_prefix): + assert _echo_prefix(cmd_bytes) == expected_prefix diff --git a/tests/hardware/prt3/test_mqtt_integration.py b/tests/hardware/prt3/test_mqtt_integration.py new file mode 100644 index 00000000..ef5f5a81 --- /dev/null +++ b/tests/hardware/prt3/test_mqtt_integration.py @@ -0,0 +1,400 @@ +""" +MQTT / HA-discovery integration tests for PRT3. + +Coverage: + - panel_detected emitted from connect() with synthetic DetectedPanel + - UtilityKeyButton.serialize() produces correct HA discovery JSON + - MQTTAutodiscoveryEntityFactory.make_utility_key_button wires correctly + - HomeAssistantMQTTInterface._publish_utility_key_configs publishes one + config per key in PRT3_UTILITY_KEYS + - PRT3Event.from_prt3 sets timestamp and subtype for event passthrough +""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch, call + +import pytest + +from paradox.data.model import DetectedPanel +from paradox.interfaces.mqtt.entities.button import UtilityKeyButton +from paradox.interfaces.mqtt.entities.device import Device +from paradox.interfaces.mqtt.entities.factory import MQTTAutodiscoveryEntityFactory + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_device(serial="prt3_ttyusb0", model="PRT3"): + panel = DetectedPanel( + product_id=None, + model=model, + firmware_version="N/A", + serial_number=serial, + ) + return Device(panel) + + +# --------------------------------------------------------------------------- +# panel_detected emission on PRT3 connect() +# --------------------------------------------------------------------------- + + +async def test_prt3_connect_emits_panel_detected(monkeypatch): + """After COMM&ok, connect() must publish panel_detected with model='PRT3'.""" + import paradox.paradox as paradox_module + from paradox.paradox import Paradox + from paradox.data.enums import RunState + + monkeypatch.setattr(paradox_module.cfg, "CONNECTION_TYPE", "PRT3") + monkeypatch.setattr(paradox_module.cfg, "PRT3_SERIAL_PORT", "/dev/ttyUSB0") + + paradox = Paradox.__new__(Paradox) + paradox.request_lock = asyncio.Lock() + paradox.busy = asyncio.Lock() + paradox.loop_wait_event = asyncio.Event() + paradox._run_state = RunState.STOP + paradox.work_loop = None + paradox.panel = None + paradox._connection = None + + from paradox.data.memory_storage import MemoryStorage + paradox.storage = MemoryStorage() + + # Mock connection.connect() → True + mock_conn = AsyncMock() + mock_conn.connect = AsyncMock(return_value=True) + mock_conn.connected = True + paradox._connection = mock_conn + + # Capture ps.sendMessage calls + detected_panels = [] + + original_send = paradox_module.ps.sendMessage + + def capture_send(topic, **kwargs): + if topic == "panel_detected": + detected_panels.append(kwargs.get("panel")) + return original_send(topic, **kwargs) + + monkeypatch.setattr(paradox_module.ps, "sendMessage", capture_send) + + # Mock PRT3Panel.initialize_communication → True + with patch("paradox.hardware.prt3.panel.PRT3Panel.initialize_communication", + new=AsyncMock(return_value=True)): + result = await paradox.connect() + + assert result is True + assert len(detected_panels) == 1 + panel = detected_panels[0] + assert isinstance(panel, DetectedPanel) + assert panel.model == "PRT3" + assert panel.serial_number.startswith("prt3_") + # serial_number must be MQTT-topic-safe (no slashes) + assert "/" not in panel.serial_number + + +async def test_prt3_connect_no_panel_detected_on_comm_fail(monkeypatch): + """If COMM&fail is received, panel_detected must NOT be sent.""" + import paradox.paradox as paradox_module + from paradox.paradox import Paradox + from paradox.data.enums import RunState + + monkeypatch.setattr(paradox_module.cfg, "CONNECTION_TYPE", "PRT3") + monkeypatch.setattr(paradox_module.cfg, "PRT3_SERIAL_PORT", "/dev/ttyUSB0") + + paradox = Paradox.__new__(Paradox) + paradox.request_lock = asyncio.Lock() + paradox.busy = asyncio.Lock() + paradox.loop_wait_event = asyncio.Event() + paradox._run_state = RunState.STOP + paradox.work_loop = None + paradox.panel = None + paradox._connection = None + + from paradox.data.memory_storage import MemoryStorage + paradox.storage = MemoryStorage() + + mock_conn = AsyncMock() + mock_conn.connect = AsyncMock(return_value=True) + paradox._connection = mock_conn + + detected_panels = [] + original_send = paradox_module.ps.sendMessage + + def capture_send(topic, **kwargs): + if topic == "panel_detected": + detected_panels.append(kwargs.get("panel")) + return original_send(topic, **kwargs) + + monkeypatch.setattr(paradox_module.ps, "sendMessage", capture_send) + + with patch("paradox.hardware.prt3.panel.PRT3Panel.initialize_communication", + new=AsyncMock(return_value=False)): + result = await paradox.connect() + + assert result is False + assert detected_panels == [] + + +# --------------------------------------------------------------------------- +# UtilityKeyButton serialisation +# --------------------------------------------------------------------------- + + +def test_utility_key_button_command_topic(monkeypatch): + """command_topic must use MQTT_BASE_TOPIC / MQTT_CONTROL_TOPIC / MQTT_UTILITY_KEY_TOPIC.""" + import paradox.interfaces.mqtt.entities.button as btn_module + + monkeypatch.setattr(btn_module.cfg, "MQTT_BASE_TOPIC", "paradox") + monkeypatch.setattr(btn_module.cfg, "MQTT_CONTROL_TOPIC", "control") + monkeypatch.setattr(btn_module.cfg, "MQTT_UTILITY_KEY_TOPIC", "utility_key") + + device = _make_device() + button = UtilityKeyButton(5, "Lock Door", device, "paradox/status") + + assert button.command_topic == "paradox/control/utility_key/5" + + +def test_utility_key_button_configuration_topic(monkeypatch): + """configuration_topic must use HA discovery prefix / 'button' / serial / entity_id / 'config'.""" + import paradox.interfaces.mqtt.entities.button as btn_module + + monkeypatch.setattr(btn_module.cfg, "MQTT_HOMEASSISTANT_DISCOVERY_PREFIX", "homeassistant") + + device = _make_device(serial="prt3_ttyusb0") + button = UtilityKeyButton(5, "Lock Door", device, "paradox/status") + + assert button.configuration_topic == "homeassistant/button/prt3_ttyusb0/utility_key_5/config" + + +def test_utility_key_button_serialize_keys(monkeypatch): + """serialize() must include payload_press and command_topic; must not include state_topic.""" + import paradox.interfaces.mqtt.entities.button as btn_module + + monkeypatch.setattr(btn_module.cfg, "MQTT_BASE_TOPIC", "paradox") + monkeypatch.setattr(btn_module.cfg, "MQTT_CONTROL_TOPIC", "control") + monkeypatch.setattr(btn_module.cfg, "MQTT_UTILITY_KEY_TOPIC", "utility_key") + monkeypatch.setattr(btn_module.cfg, "MQTT_HOMEASSISTANT_ENTITY_PREFIX", "") + + device = _make_device() + button = UtilityKeyButton(5, "Lock Door", device, "paradox/status") + config = button.serialize() + + assert config["payload_press"] == "trigger" + assert "command_topic" in config + assert "state_topic" not in config + assert config["name"] == "Lock Door" + assert "prt3_ttyusb0" in config["unique_id"] + + +def test_utility_key_button_label_fallback(monkeypatch): + """Empty label falls back to 'Utility Key {n}'.""" + import paradox.interfaces.mqtt.entities.button as btn_module + + monkeypatch.setattr(btn_module.cfg, "MQTT_HOMEASSISTANT_ENTITY_PREFIX", "") + + device = _make_device() + button = UtilityKeyButton(7, "", device, "paradox/status") + config = button.serialize() + + assert config["name"] == "Utility Key 7" + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def test_factory_make_utility_key_button(monkeypatch): + import paradox.interfaces.mqtt.entities.button as btn_module + + monkeypatch.setattr(btn_module.cfg, "MQTT_BASE_TOPIC", "paradox") + monkeypatch.setattr(btn_module.cfg, "MQTT_CONTROL_TOPIC", "control") + monkeypatch.setattr(btn_module.cfg, "MQTT_UTILITY_KEY_TOPIC", "utility_key") + monkeypatch.setattr(btn_module.cfg, "MQTT_HOMEASSISTANT_ENTITY_PREFIX", "") + + device = _make_device() + factory = MQTTAutodiscoveryEntityFactory("paradox/status", device) + + button = factory.make_utility_key_button(3, "Garden Lights") + + assert isinstance(button, UtilityKeyButton) + assert button.key_num == 3 + assert button.label == "Garden Lights" + assert button.command_topic == "paradox/control/utility_key/3" + + +# --------------------------------------------------------------------------- +# HomeAssistantMQTTInterface._publish_utility_key_configs +# --------------------------------------------------------------------------- + + +def test_publish_utility_key_configs_publishes_one_per_key(monkeypatch): + """_publish_utility_key_configs must call _publish_config once per valid key.""" + import paradox.interfaces.mqtt.homeassistant as ha_module + + monkeypatch.setattr(ha_module.cfg, "MQTT_BASE_TOPIC", "paradox") + monkeypatch.setattr(ha_module.cfg, "MQTT_CONTROL_TOPIC", "control") + monkeypatch.setattr(ha_module.cfg, "MQTT_UTILITY_KEY_TOPIC", "utility_key") + monkeypatch.setattr(ha_module.cfg, "MQTT_HOMEASSISTANT_DISCOVERY_PREFIX", "homeassistant") + monkeypatch.setattr(ha_module.cfg, "MQTT_HOMEASSISTANT_ENTITY_PREFIX", "") + + from paradox.interfaces.mqtt.homeassistant import HomeAssistantMQTTInterface + + iface = HomeAssistantMQTTInterface.__new__(HomeAssistantMQTTInterface) + device = _make_device() + iface.entity_factory = MQTTAutodiscoveryEntityFactory("paradox/status", device) + iface._publish_config = MagicMock() + + iface._publish_utility_key_configs({1: "Lock Front", 5: "Garden Lights"}) + + assert iface._publish_config.call_count == 2 + published = [c.args[0] for c in iface._publish_config.call_args_list] + topics = {b.configuration_topic for b in published} + assert "homeassistant/button/prt3_ttyusb0/utility_key_1/config" in topics + assert "homeassistant/button/prt3_ttyusb0/utility_key_5/config" in topics + + +def test_publish_utility_key_configs_skips_invalid_keys(monkeypatch): + """Non-integer keys in PRT3_UTILITY_KEYS are skipped with a warning.""" + import paradox.interfaces.mqtt.homeassistant as ha_module + + monkeypatch.setattr(ha_module.cfg, "MQTT_BASE_TOPIC", "paradox") + monkeypatch.setattr(ha_module.cfg, "MQTT_CONTROL_TOPIC", "control") + monkeypatch.setattr(ha_module.cfg, "MQTT_UTILITY_KEY_TOPIC", "utility_key") + monkeypatch.setattr(ha_module.cfg, "MQTT_HOMEASSISTANT_ENTITY_PREFIX", "") + + from paradox.interfaces.mqtt.homeassistant import HomeAssistantMQTTInterface + + iface = HomeAssistantMQTTInterface.__new__(HomeAssistantMQTTInterface) + device = _make_device() + iface.entity_factory = MQTTAutodiscoveryEntityFactory("paradox/status", device) + iface._publish_config = MagicMock() + + iface._publish_utility_key_configs({"bad": "Label", 2: "Valid"}) + + assert iface._publish_config.call_count == 1 + + +# --------------------------------------------------------------------------- +# PRT3Event timestamp and subtype for event passthrough +# --------------------------------------------------------------------------- + + +def test_prt3_event_has_current_timestamp(): + import time + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + before = int(time.time()) + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=10, number=1, area=1)) + after = int(time.time()) + + assert before <= evt.timestamp <= after + + +def test_prt3_event_has_subtype(): + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=10, number=1, area=1)) + assert evt.subtype == "arm" + + +def test_prt3_event_unknown_group_has_subtype_unknown(): + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=999, number=0, area=0)) + assert evt.subtype == "unknown" + + +def test_prt3_event_props_includes_subtype_and_timestamp(): + """event.props must surface subtype and a non-zero timestamp for MQTT passthrough.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=1, number=3, area=1)) + props = evt.props + + assert "subtype" in props + assert props["subtype"] == "open" + assert "timestamp" in props + assert props["timestamp"] > 0 + + +# --------------------------------------------------------------------------- +# Exit-delay / arming-state events (G065 per-N dispatch) +# --------------------------------------------------------------------------- + + +def test_g065_n1_sets_exit_delay(): + """G065N001 must set exit_delay=True so paradox.py maps current_state='arming'.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=65, number=1, area=2)) + + assert evt.change.get("exit_delay") is True + assert "arm" not in evt.change, "G065N001 must not set arm=True (causes flicker)" + assert evt.subtype == "exit_delay" + assert evt.id == 2 # partition element_id == area + + +def test_g065_n2_sets_entry_delay(): + """G065N002 must set entry_delay=True.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=65, number=2, area=1)) + + assert evt.change.get("entry_delay") is True + assert evt.subtype == "entry_delay" + + +def test_g065_other_n_is_noop(): + """G065 N=000 (Ready) and N>=3 produce no state change (informational only). + + Per spec, G065 N=000 is the 'Ready' status flag (no zones open), NOT an + 'exit-delay cleared' signal — the panel can fire it while exit delay is + active. exit_delay is cleared by arm/disarm events or by the optimistic + update on disarm command acceptance, not by Ready snapshots. + """ + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + for n in (0, 3, 4, 5): + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=65, number=n, area=1)) + assert evt.change == {}, f"G065N{n:03d} should be a no-op, got {evt.change}" + + +def test_arm_event_clears_exit_delay(): + """Per PRT3 spec page 18: G009=Master, G010=User, G011=Keyswitch, G012=Special arm.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + for group in (9, 10, 11, 12): + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=group, number=1, area=1)) + assert evt.change.get("arm") is True, f"G{group:03d} must set arm=True" + assert evt.change.get("exit_delay") is False, \ + f"G{group:03d} must clear exit_delay to avoid stale arming state" + + +def test_disarm_event_clears_exit_delay(): + """Per PRT3 spec page 18: G013=Master, G014=User, G015=Keyswitch, G016/17 after alarm. + + G013 is the critical regression: previously mismapped as 'auto-armed' which + caused arm=True to be set when the panel reported a master-code disarm, + producing a transient 'armed_away' flash in HA before the next RA poll + corrected the state. + """ + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + for group in (13, 14, 15, 16, 17, 20): + evt = PRT3Event.from_prt3(PRT3SystemEvent(group=group, number=1, area=1)) + assert evt.change.get("arm") is False, f"G{group:03d} must set arm=False" + assert evt.change.get("exit_delay") is False, \ + f"G{group:03d} must clear exit_delay (cancels in-progress arming)" diff --git a/tests/hardware/prt3/test_panel.py b/tests/hardware/prt3/test_panel.py new file mode 100644 index 00000000..5abcd29a --- /dev/null +++ b/tests/hardware/prt3/test_panel.py @@ -0,0 +1,578 @@ +""" +Runtime unit tests for paradox.hardware.prt3.panel.PRT3Panel. + +Tests cover the request/reply cycle for each public method using a mocked +core+connection pair. No real serial I/O occurs. + +Mock wiring +----------- +``core.request_lock`` — real asyncio.Lock() (PRT3Panel acquires it in _prt3_send_wait) +``core.connection.write`` — MagicMock (records command bytes) +``core.connection.wait_for_message`` — AsyncMock (returns preconfigured messages) +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from paradox.hardware.prt3.panel import PRT3Panel +from paradox.hardware.prt3.parser import ( + ARM_AWAY, + ARM_DISARMED, + ARM_STAY, + PRT3AreaStatus, + PRT3BufferFull, + PRT3CommandEcho, + PRT3CommStatus, + PRT3LabelReply, + PRT3ZoneStatus, + ZONE_CLOSED, + ZONE_OPEN, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def core(): + c = MagicMock() + c.request_lock = asyncio.Lock() + c.connection.write = MagicMock() + c.connection.wait_for_message = AsyncMock(return_value=None) # timeout by default + return c + + +@pytest.fixture +def panel(core): + return PRT3Panel(core) + + +# --------------------------------------------------------------------------- +# parse_message() +# --------------------------------------------------------------------------- + + +def test_parse_message_comm_ok(panel): + result = panel.parse_message(b"COMM&ok\r") + assert isinstance(result, PRT3CommStatus) + assert result.ok is True + + +def test_parse_message_comm_fail(panel): + result = panel.parse_message(b"COMM&fail\r") + assert isinstance(result, PRT3CommStatus) + assert result.ok is False + + +def test_parse_message_empty_returns_none(panel): + assert panel.parse_message(b"") is None + + +def test_parse_message_non_ascii_returns_none(panel): + assert panel.parse_message(b"\xff\xfe\r") is None + + +def test_parse_message_strips_cr(panel): + """parse_message strips the trailing \\r before calling parse_line.""" + result = panel.parse_message(b"COMM&ok\r") + assert isinstance(result, PRT3CommStatus) + + +# --------------------------------------------------------------------------- +# initialize_communication() +# --------------------------------------------------------------------------- + + +async def test_initialize_communication_ok(core, panel): + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommStatus(ok=True) + ) + assert await panel.initialize_communication(None) is True + + +async def test_initialize_communication_fail(core, panel): + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommStatus(ok=False) + ) + assert await panel.initialize_communication(None) is False + + +async def test_initialize_communication_timeout(core, panel): + core.connection.wait_for_message = AsyncMock( + side_effect=asyncio.TimeoutError + ) + assert await panel.initialize_communication(None) is False + + +async def test_initialize_communication_ignores_password_arg(core, panel): + """PRT3 has no password; argument must be accepted but ignored.""" + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommStatus(ok=True) + ) + assert await panel.initialize_communication("secret") is True + + +# --------------------------------------------------------------------------- +# load_definitions() — always returns {} +# --------------------------------------------------------------------------- + + +async def test_load_definitions_returns_empty(panel): + result = await panel.load_definitions() + assert result == {} + + +# --------------------------------------------------------------------------- +# request_status() +# --------------------------------------------------------------------------- + + +def _area_status(area: int, arm_state=ARM_DISARMED) -> PRT3AreaStatus: + return PRT3AreaStatus( + area=area, + arm_state=arm_state, + in_programming=False, + trouble=False, + not_ready=False, + alarm=False, + strobe=False, + zone_in_memory=False, + ) + + +def _zone_status(zone: int, open_state=ZONE_CLOSED) -> PRT3ZoneStatus: + return PRT3ZoneStatus( + zone=zone, + open_state=open_state, + alarm=False, + fire_alarm=False, + supervision_trouble=False, + low_battery=False, + ) + + +def _fail_echo(cmd: str) -> PRT3CommandEcho: + return PRT3CommandEcho(cmd=cmd, ok=False) + + +async def test_request_status_returns_flat_dict(core, panel, monkeypatch): + """One area + one zone returns expected flat status keys.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_MAX_AREAS", 1) + monkeypatch.setattr(cfg, "PRT3_MAX_ZONES", 1) + + area1 = _area_status(1, arm_state=ARM_AWAY) + zone1 = _zone_status(1, open_state=ZONE_OPEN) + + core.connection.wait_for_message = AsyncMock(side_effect=[area1, zone1]) + + result = await panel.request_status(0) + + assert "partition_arm" in result + assert result["partition_arm"][1] is True + assert "zone_open" in result + assert result["zone_open"][1] is True + + +async def test_request_status_skips_fail_areas(core, panel, monkeypatch): + """Areas returning &fail are silently excluded from the result.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_MAX_AREAS", 2) + monkeypatch.setattr(cfg, "PRT3_MAX_ZONES", 0) + + area1 = _area_status(1) + fail2 = _fail_echo("RA002") + + core.connection.wait_for_message = AsyncMock(side_effect=[area1, fail2]) + + result = await panel.request_status(0) + # Area 1 present, area 2 absent + assert 1 in result.get("partition_arm", {}) + assert 2 not in result.get("partition_arm", {}) + + +async def test_request_status_timeout_gives_no_entry(core, panel, monkeypatch): + """Timeouts (wait_for_message returns None) are logged but don't crash.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_MAX_AREAS", 1) + monkeypatch.setattr(cfg, "PRT3_MAX_ZONES", 0) + + core.connection.wait_for_message = AsyncMock(return_value=None) + + result = await panel.request_status(0) + # No exception; result is empty or missing the timed-out area + assert isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# control_partitions() — arm / quick-arm / disarm +# --------------------------------------------------------------------------- + + +async def test_control_partitions_quick_arm(core, panel, monkeypatch): + """Quick-arm (no user code) sends AQ command and returns True on &ok.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "") + + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="AQ001", ok=True) + ) + + result = await panel.control_partitions([1], "arm") + assert result is True + call_args = core.connection.write.call_args[0][0] + assert call_args.startswith(b"AQ001") + + +async def test_control_partitions_arm_with_code(core, panel, monkeypatch): + """Arm with user code sends AA command.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "1234") + + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="AA001", ok=True) + ) + + result = await panel.control_partitions([1], "arm") + assert result is True + call_args = core.connection.write.call_args[0][0] + assert call_args.startswith(b"AA001") + + +async def test_control_partitions_arm_stay(core, panel, monkeypatch): + """arm_stay sends AQ with mode 'S'.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "") + + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="AQ001", ok=True) + ) + + result = await panel.control_partitions([1], "arm_stay") + assert result is True + cmd = core.connection.write.call_args[0][0] + # Mode character 'S' at position 5 (AQ001S) + assert cmd[5:6] == b"S" + + +async def test_control_partitions_disarm_no_code_returns_false(core, panel, monkeypatch): + """Disarm without PRT3_USER_CODE configured must return False.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "") + + result = await panel.control_partitions([1], "disarm") + assert result is False + core.connection.write.assert_not_called() + + +async def test_control_partitions_disarm_with_code(core, panel, monkeypatch): + """Disarm with user code sends AD command and returns True on &ok.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "1234") + + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="AD001", ok=True) + ) + + result = await panel.control_partitions([1], "disarm") + assert result is True + cmd = core.connection.write.call_args[0][0] + assert cmd.startswith(b"AD001") + + +async def test_control_partitions_panel_rejects_command(core, panel, monkeypatch): + """Panel returning &fail means the command was rejected → False.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "") + + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="AQ001", ok=False) + ) + + result = await panel.control_partitions([1], "arm") + assert result is False + + +async def test_control_partitions_unknown_command(core, panel): + result = await panel.control_partitions([1], "dance") + assert result is False + core.connection.write.assert_not_called() + + +async def test_control_partitions_multiple_accepted_if_any_ok(core, panel, monkeypatch): + """Arming two partitions — first accepted, second rejected → True.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "") + + core.connection.wait_for_message = AsyncMock( + side_effect=[ + PRT3CommandEcho(cmd="AQ001", ok=True), + PRT3CommandEcho(cmd="AQ002", ok=False), + ] + ) + + result = await panel.control_partitions([1, 2], "arm") + assert result is True + + +# --------------------------------------------------------------------------- +# control_zones() / control_outputs() — not supported +# --------------------------------------------------------------------------- + + +async def test_control_zones_raises_not_implemented(panel): + with pytest.raises(NotImplementedError): + await panel.control_zones([1], "bypass") + + +async def test_control_outputs_raises_not_implemented(panel): + with pytest.raises(NotImplementedError): + await panel.control_outputs([1], "on") + + +# --------------------------------------------------------------------------- +# send_panic() +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("panic_type,prefix", [ + ("emergency", b"PE"), + ("medical", b"PM"), + ("fire", b"PF"), +]) +async def test_send_panic_accepted(core, panel, panic_type, prefix): + echo_cmd = f"{prefix.decode('ascii')}001" + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd=echo_cmd, ok=True) + ) + + result = await panel.send_panic([1], panic_type, None) + assert result is True + cmd = core.connection.write.call_args[0][0] + assert cmd.startswith(prefix) + + +async def test_send_panic_rejected_by_panel(core, panel): + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="PE001", ok=False) + ) + assert await panel.send_panic([1], "emergency", None) is False + + +async def test_send_panic_timeout(core, panel): + core.connection.wait_for_message = AsyncMock(return_value=None) + assert await panel.send_panic([1], "fire", None) is False + + +async def test_send_panic_unknown_type(panel): + assert await panel.send_panic([1], "unknown_panic_type", None) is False + + +# --------------------------------------------------------------------------- +# load_labels() +# --------------------------------------------------------------------------- + + +async def test_load_labels_returns_labels_dict(core, panel, monkeypatch): + """Labels for area/zone/user are assembled into the expected nested dict.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_MAX_AREAS", 1) + monkeypatch.setattr(cfg, "PRT3_MAX_ZONES", 1) + monkeypatch.setattr(cfg, "PRT3_MAX_USERS", 1) + + core.connection.wait_for_message = AsyncMock( + side_effect=[ + PRT3LabelReply(element_type="area", index=1, label="Home "), + PRT3LabelReply(element_type="zone", index=1, label="Front Door"), + PRT3LabelReply(element_type="user", index=1, label="Admin "), + ] + ) + + labels = await panel.load_labels() + + assert "partition" in labels + assert labels["partition"][1]["label"] == "Home" + assert "zone" in labels + assert labels["zone"][1]["label"] == "Front Door" + assert "user" in labels + assert labels["user"][1]["label"] == "Admin" + + +async def test_load_labels_skips_fail_echo(core, panel, monkeypatch): + """Areas/zones returning &fail are silently excluded.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_MAX_AREAS", 1) + monkeypatch.setattr(cfg, "PRT3_MAX_ZONES", 0) + monkeypatch.setattr(cfg, "PRT3_MAX_USERS", 0) + + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="AL001", ok=False) + ) + + labels = await panel.load_labels() + assert labels.get("partition", {}) == {} + + +# --------------------------------------------------------------------------- +# _prt3_send_wait() — retry behaviour +# --------------------------------------------------------------------------- + + +async def test_send_wait_succeeds_on_first_attempt(core, panel): + """No retry needed when the first attempt returns a reply.""" + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommStatus(ok=True) + ) + result = await panel._prt3_send_wait(b"COMM\r", lambda m: True, retries=2) + assert result is not None + assert core.connection.write.call_count == 1 + + +async def test_send_wait_retries_on_timeout(core, panel): + """A single timeout is retried; second attempt succeeds.""" + core.connection.wait_for_message = AsyncMock( + side_effect=[asyncio.TimeoutError, PRT3CommStatus(ok=True)] + ) + result = await panel._prt3_send_wait(b"COMM\r", lambda m: True, retries=2) + assert result is not None + assert core.connection.write.call_count == 2 + + +async def test_send_wait_returns_none_when_all_retries_exhausted(core, panel): + """Returns None only when every attempt times out.""" + core.connection.wait_for_message = AsyncMock( + side_effect=asyncio.TimeoutError + ) + result = await panel._prt3_send_wait(b"COMM\r", lambda m: True, retries=3) + assert result is None + assert core.connection.write.call_count == 3 + + +async def test_send_wait_does_not_retry_on_fail_echo(core, panel): + """A &fail echo is a definitive answer — no retry should occur.""" + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="AQ001", ok=False) + ) + result = await panel._prt3_send_wait( + b"AQ001A\r", + lambda m: isinstance(m, PRT3CommandEcho), + retries=2, + ) + assert isinstance(result, PRT3CommandEcho) + assert result.ok is False + assert core.connection.write.call_count == 1 # no second attempt + + +async def test_send_wait_buffer_full_returns_none(core, panel): + """PRT3BufferFull on every attempt returns None without waiting full timeout.""" + core.connection.wait_for_message = AsyncMock(return_value=PRT3BufferFull()) + result = await panel._prt3_send_wait( + b"AQ001A\r", + lambda m: isinstance(m, PRT3CommandEcho), + retries=1, + ) + assert result is None + assert core.connection.write.call_count == 1 + + +async def test_send_wait_buffer_full_retries_then_succeeds(core, panel): + """PRT3BufferFull on first attempt triggers a retry; second attempt succeeds.""" + ok_echo = PRT3CommandEcho(cmd="AQ001", ok=True) + core.connection.wait_for_message = AsyncMock( + side_effect=[PRT3BufferFull(), ok_echo] + ) + result = await panel._prt3_send_wait( + b"AQ001A\r", + lambda m: isinstance(m, PRT3CommandEcho), + retries=2, + ) + assert result is ok_echo + assert core.connection.write.call_count == 2 + + +async def test_control_partitions_malformed_code_returns_false(core, panel, monkeypatch): + """Malformed PRT3_USER_CODE returns False without propagating ValueError.""" + from paradox.config import config as cfg + + monkeypatch.setattr(cfg, "PRT3_USER_CODE", "abc") + + result = await panel.control_partitions([1], "disarm") + assert result is False + core.connection.write.assert_not_called() + + +# --------------------------------------------------------------------------- +# send_utility_key() +# --------------------------------------------------------------------------- + + +async def test_send_utility_key_accepted(core, panel): + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="UK005", ok=True) + ) + result = await panel.send_utility_key(5) + assert result is True + cmd = core.connection.write.call_args[0][0] + assert cmd == b"UK005\r" + + +async def test_send_utility_key_rejected_by_panel(core, panel): + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="UK010", ok=False) + ) + assert await panel.send_utility_key(10) is False + + +async def test_send_utility_key_timeout_returns_false(core, panel): + core.connection.wait_for_message = AsyncMock( + side_effect=asyncio.TimeoutError + ) + assert await panel.send_utility_key(1) is False + + +async def test_send_utility_key_no_retry_on_timeout(core, panel): + """Utility key must NOT retry on timeout — commands are not idempotent. + + A gate or latch toggles on each pulse; a retry would cause a double-trigger. + On timeout the command returns False immediately without a second write. + """ + core.connection.wait_for_message = AsyncMock( + side_effect=[asyncio.TimeoutError, PRT3CommandEcho(cmd="UK001", ok=True)] + ) + result = await panel.send_utility_key(1) + assert result is False + assert core.connection.write.call_count == 1 + + +async def test_send_utility_key_invalid_number_raises(core, panel): + """Out-of-range key raises ValueError from the encoder.""" + with pytest.raises(ValueError): + await panel.send_utility_key(0) + with pytest.raises(ValueError): + await panel.send_utility_key(252) + + +async def test_send_utility_key_max_valid(core, panel): + """Key 251 is the maximum valid value.""" + core.connection.wait_for_message = AsyncMock( + return_value=PRT3CommandEcho(cmd="UK251", ok=True) + ) + assert await panel.send_utility_key(251) is True + cmd = core.connection.write.call_args[0][0] + assert cmd == b"UK251\r" diff --git a/tests/hardware/prt3/test_parser.py b/tests/hardware/prt3/test_parser.py new file mode 100644 index 00000000..157b1ae9 --- /dev/null +++ b/tests/hardware/prt3/test_parser.py @@ -0,0 +1,692 @@ +""" +Unit tests for paradox.hardware.prt3.parser. + +Coverage: + - COMM status (ok / fail) + - Buffer-full sentinel + - Command echoes (&OK / &fail) — including failed info requests + - Area status replies — every flag field, every arm state, boundary indices + - Zone status replies — every flag field, every open state, boundary indices + - Label replies — zone/area/user, space preservation, boundary indices + - System events — field extraction, group 0, area 0 (global), area 255 + - Virtual PGM events — on/off, boundary pgm numbers + - Malformed / unknown / truncated lines → None (never raised) + - Replay of every fixture line (smoke test) +""" + +import pytest + +from paradox.hardware.prt3.parser import ( + ARM_AWAY, + ARM_DISARMED, + ARM_FORCE, + ARM_INSTANT, + ARM_STAY, + PRT3AreaStatus, + PRT3BufferFull, + PRT3CommandEcho, + PRT3CommStatus, + PRT3LabelReply, + PRT3PgmEvent, + PRT3SystemEvent, + PRT3ZoneStatus, + ZONE_CLOSED, + ZONE_FIRE_LOOP_TROUBLE, + ZONE_OPEN, + ZONE_TAMPERED, + parse_line, +) +from tests.hardware.prt3 import fixtures as fx + + +# =========================================================================== +# COMM status +# =========================================================================== + + +def test_comm_ok(): + result = parse_line("COMM&ok") + assert isinstance(result, PRT3CommStatus) + assert result.ok is True + + +def test_comm_fail(): + result = parse_line("COMM&fail") + assert isinstance(result, PRT3CommStatus) + assert result.ok is False + + +def test_comm_ok_from_fixture(): + result = parse_line(fx.COMM_OK) + assert isinstance(result, PRT3CommStatus) + assert result.ok is True + + +def test_comm_fail_from_fixture(): + result = parse_line(fx.COMM_FAIL) + assert isinstance(result, PRT3CommStatus) + assert result.ok is False + + +# =========================================================================== +# Buffer full +# =========================================================================== + + +def test_buffer_full(): + result = parse_line("!") + assert isinstance(result, PRT3BufferFull) + + +def test_buffer_full_from_fixture(): + assert isinstance(parse_line(fx.BUFFER_FULL), PRT3BufferFull) + + +# =========================================================================== +# Command echoes +# =========================================================================== + + +@pytest.mark.parametrize( + "line, expected_cmd", + [ + ("AA001&OK", "AA001"), # arm + ("AQ001&OK", "AQ001"), # quick arm + ("AD001&OK", "AD001"), # disarm + ("PE001&OK", "PE001"), # emergency panic + ("PM001&OK", "PM001"), # medical panic + ("PF001&OK", "PF001"), # fire panic + ("UK001&OK", "UK001"), # utility key 1 + ("UK251&OK", "UK251"), # utility key max + ("AA008&OK", "AA008"), # arm area 8 + ], +) +def test_echo_ok(line, expected_cmd): + result = parse_line(line) + assert isinstance(result, PRT3CommandEcho) + assert result.ok is True + assert result.cmd == expected_cmd + + +@pytest.mark.parametrize( + "line, expected_cmd", + [ + ("AA001&fail", "AA001"), # arm failed (invalid code) + ("AD001&fail", "AD001"), # disarm failed + ("RA001&fail", "RA001"), # area status request failed + ("ZL001&fail", "ZL001"), # zone label request failed + ("AL008&fail", "AL008"), # area label request failed + ("UL999&fail", "UL999"), # user label request failed + ], +) +def test_echo_fail(line, expected_cmd): + result = parse_line(line) + assert isinstance(result, PRT3CommandEcho) + assert result.ok is False + assert result.cmd == expected_cmd + + +@pytest.mark.parametrize( + "line, expected_cmd", + [ + ("AA001&ok", "AA001"), # arm — lowercase firmware variant + ("UK001&ok", "UK001"), # utility key — observed on live Paradox panel + ("AD001&ok", "AD001"), # disarm + ], +) +def test_echo_ok_lowercase(line, expected_cmd): + """Some panel firmware sends lowercase '&ok'; parser must accept both cases.""" + result = parse_line(line) + assert isinstance(result, PRT3CommandEcho) + assert result.ok is True + assert result.cmd == expected_cmd + + +def test_echo_cmd_is_exactly_5_chars(): + result = parse_line("AA001&OK") + assert len(result.cmd) == 5 + + +@pytest.mark.parametrize("line", [ + fx.ECHO_ARM_OK, fx.ECHO_QUICK_ARM_OK, fx.ECHO_DISARM_OK, + fx.ECHO_PANIC_EMERG_OK, fx.ECHO_PANIC_MED_OK, fx.ECHO_PANIC_FIRE_OK, + fx.ECHO_UTILITY_KEY_OK, + fx.ECHO_ARM_OK_LOWER, fx.ECHO_UTILITY_KEY_OK_LOWER, +]) +def test_echo_ok_from_fixtures(line): + result = parse_line(line) + assert isinstance(result, PRT3CommandEcho) + assert result.ok is True + + +@pytest.mark.parametrize("line", [ + fx.ECHO_ARM_FAIL, fx.ECHO_DISARM_FAIL, fx.ECHO_STATUS_FAIL, fx.ECHO_LABEL_FAIL, +]) +def test_echo_fail_from_fixtures(line): + result = parse_line(line) + assert isinstance(result, PRT3CommandEcho) + assert result.ok is False + + +# =========================================================================== +# Area status +# =========================================================================== + + +class TestAreaStatus: + """RA{nnn}{7 flags} replies — 12 chars total.""" + + def test_disarmed_all_clear(self): + # "RA001DOOOOOO": D=disarmed, all secondary flags = O (inactive) + r = parse_line(fx.AREA_DISARMED) + assert isinstance(r, PRT3AreaStatus) + assert r.area == 1 + assert r.arm_state == ARM_DISARMED + assert r.in_programming is False + assert r.trouble is False + assert r.not_ready is False + assert r.alarm is False + assert r.strobe is False + assert r.zone_in_memory is False + + @pytest.mark.parametrize( + "line, expected_arm_state", + [ + ("RA001DOOOOOO", ARM_DISARMED), + ("RA001AOOOOOO", ARM_AWAY), + ("RA001FOOOOOO", ARM_FORCE), + ("RA001SOOOOOO", ARM_STAY), + ("RA001IOOOOOO", ARM_INSTANT), + ], + ) + def test_arm_states(self, line, expected_arm_state): + r = parse_line(line) + assert isinstance(r, PRT3AreaStatus) + assert r.arm_state == expected_arm_state + + def test_in_programming_flag_true(self): + # "RA001DPOOOOO" — P at position 6 + r = parse_line("RA001DPOOOOO") + assert isinstance(r, PRT3AreaStatus) + assert r.in_programming is True + assert r.arm_state == ARM_DISARMED + + def test_in_programming_flag_false(self): + r = parse_line("RA001DOOOOOO") + assert r.in_programming is False + + def test_trouble_flag_true(self): + # "RA001DOTOOOO" — T at position 7 + r = parse_line("RA001DOTOOOO") + assert isinstance(r, PRT3AreaStatus) + assert r.trouble is True + + def test_trouble_flag_false(self): + r = parse_line("RA001DOOOOOO") + assert r.trouble is False + + def test_not_ready_flag_true(self): + # "RA001DOONOOO" — N at position 8 + r = parse_line("RA001DOONOOO") + assert isinstance(r, PRT3AreaStatus) + assert r.not_ready is True + + def test_not_ready_flag_false(self): + # O at position 8 means "area IS ready" + r = parse_line("RA001DOOOOOO") + assert r.not_ready is False + + def test_alarm_flag_true(self): + # "RA001AOOOAOO" — A at position 9 + r = parse_line("RA001AOOOAOO") + assert isinstance(r, PRT3AreaStatus) + assert r.alarm is True + assert r.strobe is False + + def test_alarm_flag_false(self): + r = parse_line("RA001DOOOOOO") + assert r.alarm is False + + def test_strobe_flag_true(self): + # "RA001AOOOASO" — A(alarm) at 9, S(strobe) at 10 + r = parse_line("RA001AOOOASO") + assert isinstance(r, PRT3AreaStatus) + assert r.alarm is True + assert r.strobe is True + + def test_zone_in_memory_flag_true(self): + # "RA001IOOOOOM" — M at position 11 + r = parse_line("RA001IOOOOOM") + assert isinstance(r, PRT3AreaStatus) + assert r.arm_state == ARM_INSTANT + assert r.zone_in_memory is True + + def test_zone_in_memory_flag_false(self): + r = parse_line("RA001DOOOOOO") + assert r.zone_in_memory is False + + def test_all_flags_active(self): + # "RA001APTNASM": away, prog, trouble, not-ready, alarm, strobe, memory + r = parse_line(fx.AREA_ALL_FLAGS) + assert isinstance(r, PRT3AreaStatus) + assert r.area == 1 + assert r.arm_state == ARM_AWAY + assert r.in_programming is True + assert r.trouble is True + assert r.not_ready is True + assert r.alarm is True + assert r.strobe is True + assert r.zone_in_memory is True + + def test_stay_trouble_not_ready(self): + # "RA003SOTNOOO": stay, no prog, trouble, not-ready + r = parse_line(fx.AREA_STAY_TROUBLE) + assert r.area == 3 + assert r.arm_state == ARM_STAY + assert r.trouble is True + assert r.not_ready is True + assert r.alarm is False + + def test_alarm_and_strobe_from_fixture(self): + # "RA001AOOOASO": away, alarm, strobe + r = parse_line(fx.AREA_ARMED_ALARM_STROBE) + assert r.arm_state == ARM_AWAY + assert r.alarm is True + assert r.strobe is True + + def test_zone_in_memory_from_fixture(self): + # "RA008IOOOOOM": instant, zone-in-memory + r = parse_line(fx.AREA_ARMED_MEMORY) + assert r.arm_state == ARM_INSTANT + assert r.zone_in_memory is True + assert r.area == 8 + + def test_area_number_max(self): + r = parse_line(fx.AREA_MAX_NUMBER) # "RA008DOOOOOO" + assert r.area == 8 + + @pytest.mark.parametrize("area_num", [1, 4, 8]) + def test_area_number_parsing(self, area_num): + line = f"RA{area_num:03d}DOOOOOO" + r = parse_line(line) + assert isinstance(r, PRT3AreaStatus) + assert r.area == area_num + + def test_unknown_arm_char_returns_none(self): + # 'X' is not D/A/F/S/I + assert parse_line("RA001XOOOOOO") is None + + def test_wrong_length_short_returns_none(self): + assert parse_line("RA001DOOOOO") is None # 11 chars + + def test_wrong_length_long_returns_none(self): + assert parse_line("RA001DOOOOOOO") is None # 13 chars + + +# =========================================================================== +# Zone status +# =========================================================================== + + +class TestZoneStatus: + """RZ{nnn}{5 flags} replies — 10 chars total.""" + + def test_closed_all_clear(self): + r = parse_line(fx.ZONE_CLOSED_OK) # "RZ001COOOO" + assert isinstance(r, PRT3ZoneStatus) + assert r.zone == 1 + assert r.open_state == ZONE_CLOSED + assert r.alarm is False + assert r.fire_alarm is False + assert r.supervision_trouble is False + assert r.low_battery is False + + @pytest.mark.parametrize( + "line, expected_state", + [ + ("RZ001COOOO", ZONE_CLOSED), + ("RZ001OOOOO", ZONE_OPEN), + ("RZ001TOOOO", ZONE_TAMPERED), + ("RZ001FOOOO", ZONE_FIRE_LOOP_TROUBLE), + ], + ) + def test_open_states(self, line, expected_state): + r = parse_line(line) + assert isinstance(r, PRT3ZoneStatus) + assert r.open_state == expected_state + + def test_alarm_flag(self): + r = parse_line(fx.ZONE_ALARM) # "RZ005OAOOO" + assert r.alarm is True + assert r.fire_alarm is False + + def test_fire_alarm_flag(self): + r = parse_line(fx.ZONE_FIRE_ALARM) # "RZ006OOFOO" + assert r.alarm is False + assert r.fire_alarm is True + + def test_supervision_flag(self): + r = parse_line(fx.ZONE_SUPERVISION) # "RZ007OOOSO" + assert r.supervision_trouble is True + assert r.low_battery is False + + def test_low_battery_flag(self): + r = parse_line(fx.ZONE_LOW_BATTERY) # "RZ008OOOOL" + assert r.low_battery is True + assert r.supervision_trouble is False + + def test_all_flags_active(self): + r = parse_line(fx.ZONE_ALL_FLAGS) # "RZ009OAFSL" + assert r.zone == 9 + assert r.open_state == ZONE_OPEN + assert r.alarm is True + assert r.fire_alarm is True + assert r.supervision_trouble is True + assert r.low_battery is True + + def test_zone_number_max(self): + r = parse_line(fx.ZONE_MAX_NUMBER) # "RZ192COOOO" + assert r.zone == 192 + + @pytest.mark.parametrize("zone_num", [1, 96, 192]) + def test_zone_number_parsing(self, zone_num): + line = f"RZ{zone_num:03d}COOOO" + r = parse_line(line) + assert isinstance(r, PRT3ZoneStatus) + assert r.zone == zone_num + + def test_unknown_open_state_returns_none(self): + assert parse_line("RZ001XOOOO") is None + + def test_wrong_length_short_returns_none(self): + assert parse_line("RZ001COOO") is None # 9 chars + + def test_wrong_length_long_returns_none(self): + assert parse_line("RZ001COOOOO") is None # 11 chars + + +# =========================================================================== +# Label replies +# =========================================================================== + + +class TestLabelReply: + """ZL/AL/UL{nnn}{16-char label} replies — 21 chars total.""" + + def test_zone_label(self): + r = parse_line(fx.ZONE_LABEL_FRONT_DOOR) + assert isinstance(r, PRT3LabelReply) + assert r.element_type == "zone" + assert r.index == 1 + assert r.label == "Front Door " + assert len(r.label) == 16 + + def test_area_label(self): + r = parse_line(fx.AREA_LABEL_HOME) + assert isinstance(r, PRT3LabelReply) + assert r.element_type == "area" + assert r.index == 1 + assert r.label == "Home " + + def test_user_label(self): + r = parse_line(fx.USER_LABEL_MASTER) + assert isinstance(r, PRT3LabelReply) + assert r.element_type == "user" + assert r.index == 1 + assert r.label == "Master " + + def test_label_trailing_spaces_preserved(self): + # The full 16-char label including trailing spaces must not be stripped + r = parse_line(fx.AREA_LABEL_HOME) + assert r.label == "Home " # 4 chars + 12 spaces + + def test_zone_label_max_index(self): + r = parse_line(fx.ZONE_LABEL_MAX_ZONE) + assert r.index == 192 + + def test_area_label_max_index(self): + r = parse_line(fx.AREA_LABEL_MAX_AREA) + assert r.index == 8 + + def test_user_label_max_index(self): + r = parse_line(fx.USER_LABEL_MAX_USER) + assert r.index == 999 + + def test_label_is_always_16_chars(self): + for line in [ + fx.ZONE_LABEL_FRONT_DOOR, fx.ZONE_LABEL_BACK_DOOR, + fx.AREA_LABEL_HOME, fx.USER_LABEL_MASTER, + ]: + r = parse_line(line) + assert len(r.label) == 16, f"label length for {line!r}: {len(r.label)}" + + def test_wrong_length_short_returns_none(self): + # 20 chars (one too short) + assert parse_line("ZL001Front Door ") is None + + def test_wrong_length_long_returns_none(self): + # 22 chars (one too long) + assert parse_line("ZL001Front Door ") is None + + +# =========================================================================== +# System events +# =========================================================================== + + +class TestSystemEvent: + """G{ggg}N{nnn}A{aaa} events — 12 chars.""" + + def test_zone_open_event(self): + r = parse_line(fx.EVENT_ZONE_OPEN) # G001N005A006 + assert isinstance(r, PRT3SystemEvent) + assert r.group == 1 + assert r.number == 5 + assert r.area == 6 + + def test_zone_ok_event(self): + r = parse_line(fx.EVENT_ZONE_OK) # G000N005A006 + assert r.group == 0 + assert r.number == 5 + assert r.area == 6 + + def test_global_area_zero(self): + # area == 0 means all enabled areas (global event) + r = parse_line(fx.EVENT_TROUBLE_AC) # G036N001A000 + assert r.area == 0 + + def test_area_255_any_area(self): + # area == 255 means "at least one enabled area" per spec Note 1 + r = parse_line(fx.EVENT_STATUS3_TAMPER) # G066N004A255 + assert r.area == 255 + + def test_all_zero_event(self): + r = parse_line(fx.EVENT_ALL_ZERO) # G000N000A000 + assert isinstance(r, PRT3SystemEvent) + assert r.group == 0 + assert r.number == 0 + assert r.area == 0 + + def test_max_values(self): + r = parse_line(fx.EVENT_MAX_VALUES) # G066N999A255 + assert r.group == 66 + assert r.number == 999 + assert r.area == 255 + + @pytest.mark.parametrize("line, group, number, area", [ + ("G001N005A006", 1, 5, 6), + ("G010N001A001", 10, 1, 1), + ("G014N002A002", 14, 2, 2), + ("G024N003A001", 24, 3, 1), + ("G048N001A000", 48, 1, 0), + ("G064N000A001", 64, 0, 1), + ("G002N012A002", 2, 12, 2), + ("G025N007A001", 25, 7, 1), + ]) + def test_event_fields_parametrized(self, line, group, number, area): + r = parse_line(line) + assert isinstance(r, PRT3SystemEvent) + assert r.group == group + assert r.number == number + assert r.area == area + + def test_wrong_format_too_short_group(self): + assert parse_line("G01N005A006") is None # group only 2 digits + + def test_wrong_format_too_short_number(self): + assert parse_line("G001N5A006") is None # number only 1 digit + + def test_wrong_format_too_short_area(self): + assert parse_line("G001N005A06") is None # area only 2 digits + + def test_wrong_format_too_long_area(self): + assert parse_line("G001N005A0060") is None # area 4 digits + + +# =========================================================================== +# Virtual PGM events +# =========================================================================== + + +class TestPgmEvent: + """PGM{nn}ON/OFF events — v1 scope: parsed but not acted on.""" + + def test_pgm_on(self): + r = parse_line(fx.PGM_01_ON) # "PGM01ON" + assert isinstance(r, PRT3PgmEvent) + assert r.pgm == 1 + assert r.on is True + + def test_pgm_off(self): + r = parse_line(fx.PGM_01_OFF) # "PGM01OFF" + assert isinstance(r, PRT3PgmEvent) + assert r.pgm == 1 + assert r.on is False + + def test_pgm_max_on(self): + r = parse_line(fx.PGM_30_ON) # "PGM30ON" + assert r.pgm == 30 + assert r.on is True + + def test_pgm_max_off(self): + r = parse_line(fx.PGM_30_OFF) # "PGM30OFF" + assert r.pgm == 30 + assert r.on is False + + @pytest.mark.parametrize("pgm_num", [1, 15, 30]) + def test_pgm_number_range(self, pgm_num): + on_line = f"PGM{pgm_num:02d}ON" + off_line = f"PGM{pgm_num:02d}OFF" + r_on = parse_line(on_line) + r_off = parse_line(off_line) + assert isinstance(r_on, PRT3PgmEvent) and r_on.pgm == pgm_num + assert isinstance(r_off, PRT3PgmEvent) and r_off.pgm == pgm_num + + def test_pgm_single_digit_returns_none(self): + # Spec shows 2-digit PGM numbers (01-30); single digit is non-standard + assert parse_line("PGM1ON") is None + + def test_pgm_three_digit_returns_none(self): + assert parse_line("PGM001ON") is None + + +# =========================================================================== +# Malformed / unknown inputs → None (not raised) +# =========================================================================== + + +class TestMalformedInputs: + + def test_empty_string(self): + assert parse_line("") is None + + def test_completely_unknown(self): + assert parse_line("GARBAGE") is None + assert parse_line("XYZ123") is None + + def test_comm_uppercase_ok_is_unknown(self): + # 'COMM&OK' (uppercase OK) is not the COMM status message — + # COMM status uses lowercase 'ok'. The string is 7 chars and + # does not match the 8-char echo pattern either. + assert parse_line("COMM&OK") is None + + def test_area_status_too_short(self): + assert parse_line("RA001DOOOOO") is None # 11 chars + + def test_area_status_too_long(self): + assert parse_line("RA001DOOOOOOO") is None # 13 chars + + def test_zone_status_too_short(self): + assert parse_line("RZ001COOO") is None # 9 chars + + def test_zone_status_too_long(self): + assert parse_line("RZ001COOOOO") is None # 11 chars + + def test_label_too_short(self): + # 20 chars — one below the required 21 + assert parse_line("ZL001Front Door ") is None + + def test_label_too_long(self): + # 22 chars — one above 21 + assert parse_line("ZL001Front Door ") is None + + def test_area_unknown_arm_char(self): + # 'Z' is not a valid arm-state character + assert parse_line("RA001ZOOOOOO") is None + + def test_zone_unknown_open_char(self): + # 'X' is not C/O/T/F + assert parse_line("RZ001XOOOO") is None + + def test_echo_ok_wrong_length_short(self): + assert parse_line("AA01&OK") is None # 7 chars (4-char prefix) + + def test_echo_ok_wrong_length_long(self): + assert parse_line("AA001 &OK") is None # 9 chars (extra space) + + def test_echo_fail_truncated(self): + assert parse_line("AA001&fai") is None # "&fai" not "&fail" + + def test_echo_fail_extended(self): + assert parse_line("AA001&fails") is None # 11 chars + + +# =========================================================================== +# Fixture replay — every fixture line must parse without exception +# =========================================================================== + + +@pytest.mark.parametrize("line", fx.ALL_FIXTURES, ids=fx.ALL_FIXTURES) +def test_all_fixtures_parse_without_exception(line): + """Each fixture line must parse without raising; none should return None.""" + result = parse_line(line) + assert result is not None, f"parse_line({line!r}) unexpectedly returned None" + + +# =========================================================================== +# Public API / type hierarchy +# =========================================================================== + + +def test_all_message_types_importable(): + from paradox.hardware.prt3.parser import ( # noqa: F401 + PRT3AreaStatus, + PRT3BufferFull, + PRT3CommandEcho, + PRT3CommStatus, + PRT3LabelReply, + PRT3Message, + PRT3PgmEvent, + PRT3SystemEvent, + PRT3ZoneStatus, + ) + + +def test_arm_state_constants_are_distinct(): + states = [ARM_DISARMED, ARM_AWAY, ARM_FORCE, ARM_STAY, ARM_INSTANT] + assert len(set(states)) == 5 + + +def test_zone_open_state_constants_are_distinct(): + states = [ZONE_CLOSED, ZONE_OPEN, ZONE_TAMPERED, ZONE_FIRE_LOOP_TROUBLE] + assert len(set(states)) == 4 diff --git a/tests/test_config.py b/tests/test_config.py index 703e9bc9..e80e491f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,6 @@ +import pytest +from unittest.mock import patch, mock_open + from paradox.config import config as cfg, get_limits_for_type def test_get_limits_for_type_for_auto(mocker): @@ -86,4 +89,67 @@ def test_get_limits_for_type_for_specified_empty_string(mocker): assert get_limits_for_type('partition') == [] - assert get_limits_for_type('partition', [1,2]) == [] \ No newline at end of file + assert get_limits_for_type('partition', [1,2]) == [] + + +# --------------------------------------------------------------------------- +# PRT3 config validation +# --------------------------------------------------------------------------- + +def _make_config(monkeypatch, entries: dict): + """Return a fresh Config instance loaded with the given entries dict.""" + from paradox.config import Config + c = Config() + monkeypatch.setattr(c, "_find_config", lambda loc=None: None) + monkeypatch.setattr(c, "_read_config", lambda: entries) + c.load() + return c + + +def test_prt3_user_code_empty_accepted(monkeypatch): + """Empty PRT3_USER_CODE is valid (quick-arm mode).""" + c = _make_config(monkeypatch, {"PRT3_USER_CODE": ""}) + assert c.PRT3_USER_CODE == "" + + +def test_prt3_user_code_digits_accepted(monkeypatch): + """A 1–6 digit code is valid.""" + c = _make_config(monkeypatch, {"PRT3_USER_CODE": "123456"}) + assert c.PRT3_USER_CODE == "123456" + + +def test_prt3_user_code_invalid_alpha_rejected(monkeypatch): + """Alphabetic PRT3_USER_CODE raises at load time.""" + from paradox.config import Config + c = Config() + monkeypatch.setattr(c, "_find_config", lambda loc=None: None) + monkeypatch.setattr(c, "_read_config", lambda: {"PRT3_USER_CODE": "abc"}) + with pytest.raises(ValueError, match="PRT3_USER_CODE"): + c.load() + + +def test_prt3_user_code_too_long_rejected(monkeypatch): + """A 7-digit code raises at load time.""" + from paradox.config import Config + c = Config() + monkeypatch.setattr(c, "_find_config", lambda loc=None: None) + monkeypatch.setattr(c, "_read_config", lambda: {"PRT3_USER_CODE": "1234567"}) + with pytest.raises(ValueError, match="PRT3_USER_CODE"): + c.load() + + +def test_prt3_serial_baud_valid_accepted(monkeypatch): + """9600 and 19200 are the only valid PRT3 baud rates.""" + for baud in (9600, 19200): + c = _make_config(monkeypatch, {"PRT3_SERIAL_BAUD": baud}) + assert c.PRT3_SERIAL_BAUD == baud + + +def test_prt3_serial_baud_38400_rejected(monkeypatch): + """38400 is not a valid PRT3 baud rate and must raise at load time.""" + from paradox.config import Config + c = Config() + monkeypatch.setattr(c, "_find_config", lambda loc=None: None) + monkeypatch.setattr(c, "_read_config", lambda: {"PRT3_SERIAL_BAUD": 38400}) + with pytest.raises(Exception, match="PRT3_SERIAL_BAUD"): + c.load() \ No newline at end of file