From 3aedb445f74d61dd71d652cd6121bc75345b52bb Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Sun, 29 Mar 2026 18:26:48 +1000 Subject: [PATCH 01/21] docs: add PRT3 architecture notes Describes why PRT3 is a separate connection type from native serial, where the transport/protocol/adapter layers live, what can be reused from existing PAI, the v1 scope, and documented limitations. --- docs/prt3-architecture.md | 137 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/prt3-architecture.md diff --git a/docs/prt3-architecture.md b/docs/prt3-architecture.md new file mode 100644 index 00000000..9c990f9f --- /dev/null +++ b/docs/prt3-architecture.md @@ -0,0 +1,137 @@ +# 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 + 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 + property.py PROPERTY_MAP + - maps state-change keys to PAI property descriptors +``` + +### Wiring into the existing runtime + +Two small additions to existing files: + +**`paradox/config.py`** — add `"PRT3"` to the `CONNECTION_TYPE` allowed list and +five new config keys (`PRT3_SERIAL_PORT`, `PRT3_SERIAL_BAUD`, `PRT3_MAX_AREAS`, +`PRT3_MAX_ZONES`, `PRT3_MAX_USERS`). + +**`paradox/paradox.py`** — one `elif cfg.CONNECTION_TYPE == "PRT3":` branch in the +`connection` property, and a guard in `connect()` that skips the binary panel +detection path and directly instantiates `PRT3Panel`. + +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 + +## 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 | From 6fcc1b05310d0f99a6d66228a3a5c415c4f35384 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Sun, 29 Mar 2026 19:30:26 +1000 Subject: [PATCH 02/21] docs: add detailed PRT3 implementation plan Maps the approved architecture onto the actual PAI codebase: exact insertion points in config.py and paradox.py, full spec for every new file/class, runtime path analysis, test patterns for each layer, phased delivery plan, and risk register. --- docs/prt3-implementation-plan.md | 839 +++++++++++++++++++++++++++++++ 1 file changed, 839 insertions(+) create mode 100644 docs/prt3-implementation-plan.md diff --git a/docs/prt3-implementation-plan.md b/docs/prt3-implementation-plan.md new file mode 100644 index 00000000..a78c2903 --- /dev/null +++ b/docs/prt3-implementation-plan.md @@ -0,0 +1,839 @@ +# PRT3 — Detailed Implementation Plan + +Generated from: inspection of `feature/prt3-connection` branch, PAI `dev` as of 2026-03-29. + +--- + +## 1. Change inventory + +### Existing files to modify + +| File | Change | Risk | +|---|---|---| +| `paradox/config.py` | Add `"PRT3"` to `CONNECTION_TYPE` allowed list; add 5 new `PRT3_*` config keys | Zero — additive only | +| `paradox/paradox.py` | Add one `elif` branch in `connection` property; add one guard in `connect()` | Minimal — 4–6 lines in well-isolated spots | + +No other existing file is touched. + +### New files to create + +``` +paradox/connections/prt3/ + __init__.py + connection.py + protocol.py + +paradox/hardware/prt3/ + __init__.py + panel.py + parser.py + encoder.py + event.py + property.py + +tests/hardware/prt3/ + __init__.py + test_parser.py + test_encoder.py + test_panel.py + fixtures/ + session_startup.txt + session_events.txt + session_arm_disarm.txt + +tests/connection/prt3/ + __init__.py + test_protocol.py +``` + +--- + +## 2. Existing runtime path (for reference) + +Understanding this path is essential for knowing exactly where PRT3 diverges. + +### 2.1 Connection bootstrap + +``` +main.py: Paradox() + Paradox.connection (property, paradox.py:72) + cfg.CONNECTION_TYPE == "Serial" → SerialCommunication(port, baud) + cfg.CONNECTION_TYPE == "IP" → BareIPConnection | LocalIPConnection | StunIPConnection + else → AssertionError + + Paradox._register_connection_handlers() + raw_handler_registry ← PersistentHandler(self.on_connection_message) + handler_registry ← EventMessageHandler(self.handle_event_message) + handler_registry ← ErrorMessageHandler(self.handle_error_message) + + Paradox.full_connect() + → connection.connect() # opens transport + → send_wait(InitiateCommunication) # binary: gets model/firmware/serial + → send_wait(StartCommunication) # binary: gets product_id + → create_panel(self, reply) # factory selects EVO48/96/192/HD or Spectra + → panel.initialize_communication() # binary login with PC password + → run_state = CONNECTED + → panel.load_memory() # EEPROM reads for labels + → run_state = RUN + → loop() +``` + +### 2.2 Message dispatch (existing) + +``` +serial bytes arrive + → SerialConnectionProtocol.data_received() + buffer until checksum-valid frame assembled + → ConnectionHandler.on_message(raw_bytes) + → Connection.schedule_raw_message_handling(raw_bytes) + → raw_handler_registry.handle(raw_bytes) + → PersistentHandler calls Paradox.on_connection_message(raw_bytes) + → panel.parse_message(raw_bytes) → Construct Container + → connection.schedule_message_handling(container) + → handler_registry.handle(container) + → FutureHandler (send_wait pending) resolved if command == expected + → EventMessageHandler fires if command == 0xE + → ErrorMessageHandler fires if command == 0x7 +``` + +### 2.3 Status polling loop (existing) + +``` +Paradox.loop() + while RUN: + results = await asyncio.gather(*panel.get_status_requests()) + each request_status(i): + → send_wait(ReadEEPROM, address=RAM_BASE+i) + → parse binary RAM block + → return plain dict {zone_open: {1: T, 2: F, ...}, partition_arm: {1: T}, ...} + merged = deep_merge(*results) + _process_status(merged) + → convert_raw_status(merged) # splits "zone_open" → type="zone" prop="open" + → ps.sendMessage("status_update", status=status) + wait up to KEEP_ALIVE_INTERVAL +``` + +### 2.4 `send_wait()` mechanics + +`send_wait()` (`paradox.py:372`) does: +1. Builds message bytes: `message_type.build(dict(fields=dict(value=args)))` +2. `connection.write(message)` +3. `connection.wait_for_message(reply_expected)` → adds a `FutureHandler` to `handler_registry` +4. Returns when `handler_registry.handle(container)` fires the FutureHandler + +The `reply_expected` callable matches on `container.fields.value.po.command`. + +### 2.5 `HandlerRegistry` error on no-match + +`handlers.py:123`: when no handler matches a dispatched message, it logs: +```python +logger.error("No handler for message {}\nDetail: {}".format( + data.fields.value.po.command, data)) +``` +This attribute access would blow up on a PRT3 ASCII line. **PRT3 parsed messages must never enter `handler_registry` unless they carry a compatible shape, or the FutureHandler consumes them first.** + +--- + +## 3. PRT3 insertion points + +### 3.1 `paradox/config.py` — `Config.DEFAULTS` dict + +**Insertion 1** (line 28): add `"PRT3"` to `CONNECTION_TYPE` allowed list. + +```python +# Before: +"CONNECTION_TYPE": ("Serial", str, ["IP", "Serial"]), + +# After: +"CONNECTION_TYPE": ("Serial", str, ["IP", "Serial", "PRT3"]), +``` + +**Insertion 2** (after the `SERIAL_BAUD` block, ~line 32): add PRT3-specific keys. + +```python +"PRT3_SERIAL_PORT": "/dev/ttyUSB0", +"PRT3_SERIAL_BAUD": (9600, int, (2400, 115200)), +"PRT3_MAX_AREAS": (8, int, (1, 8)), +"PRT3_MAX_ZONES": (96, int, (1, 192)), +"PRT3_MAX_USERS": (999, int, (1, 999)), +``` + +### 3.2 `paradox/paradox.py` — `Paradox.connection` property + +**Insertion** (inside `if not self._connection:` block, after the `elif cfg.CONNECTION_TYPE == "IP":` block, ~line 107): + +```python +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, + ) +``` + +**Insertion** in `Paradox.connect()` (~line 140): skip binary panel detection for PRT3. + +```python +# After connection.connect() succeeds, before InitiateCommunication: +if cfg.CONNECTION_TYPE == "PRT3": + from paradox.hardware.prt3.panel import PRT3Panel + self.panel = PRT3Panel(self) + result = await self.panel.initialize_communication(cfg.PASSWORD) + if not result: + raise ConnectionError("PRT3: failed to receive COMM&ok") + self.run_state = RunState.CONNECTED + return True +``` + +This guard short-circuits the binary handshake entirely and falls through to the normal `run_state = CONNECTED; return True` path. + +--- + +## 4. New file specifications + +### 4.1 `paradox/connections/prt3/protocol.py` + +**Class: `PRT3Protocol(ConnectionProtocol)`** + +Overrides: +- `data_received(data: bytes)` — appends to buffer; on each `\r` (0x0D) emits the line via `self.handler.on_message(line_bytes)` including the `\r`; discards empty lines. +- `send_message(message: bytes)` — `self.transport.write(message)` (no framing, no checksum). +- `variable_message_length(mode)` — no-op (PRT3 has no binary framing). + +Does NOT override `connection_made`, `connection_lost`, `is_active`, `close` — those are inherited from `ConnectionProtocol`. + +**Key test cases** (see §5): +- Complete line in one chunk +- Line split across multiple chunks +- Two lines in one chunk +- Partial line followed by completion +- `\r\n` vs bare `\r` +- Garbage before first valid `\r` + +### 4.2 `paradox/connections/prt3/connection.py` + +**Class: `PRT3SerialConnection(SerialCommunication)`** + +Subclasses `SerialCommunication` to reuse: serial port open, permissions fix, `connected_future`, `open_timeout`, `connect()` exactly. Only one method is overridden: + +- `make_protocol(self) -> PRT3Protocol` — returns `PRT3Protocol(self)` instead of `SerialConnectionProtocol(self)`. + +All other `Connection` and `SerialCommunication` behaviour is inherited unchanged. + +### 4.3 `paradox/connections/prt3/__init__.py` + +Empty (makes it a package). + +### 4.4 `paradox/hardware/prt3/parser.py` + +Pure module, no imports from PAI runtime. Returns typed dataclasses. + +**Dataclasses:** +```python +@dataclass +class PRT3CommStatus: ok: bool # True = COMM&ok, False = COMM&fail + +@dataclass +class PRT3CommandEcho: + prefix: str # first 5 chars echoed back + ok: bool # True = &OK, False = &fail + payload: str # anything after &OK / &fail (empty for simple acks) + +@dataclass +class PRT3AreaStatus: + area: int + armed: str # D / A / F / S / I + programming: bool + trouble: bool + ready: bool + alarm: bool + strobe: bool + alarm_in_memory: bool + +@dataclass +class PRT3ZoneStatus: + zone: int + status: str # C / O / T / F + alarm: bool + fire_alarm: bool + supervision_lost: bool + low_battery: bool + +@dataclass +class PRT3LabelReply: + kind: str # "ZL" / "AL" / "UL" + index: int + label: str # 16 chars, stripped + +@dataclass +class PRT3SystemEvent: + group: int # G value + number: int # N value + area: int # A value + +PRT3Message = Union[ + PRT3CommStatus, PRT3CommandEcho, PRT3AreaStatus, PRT3ZoneStatus, + PRT3LabelReply, PRT3SystemEvent, +] +``` + +**Public API:** +```python +def parse_line(line: str) -> Optional[PRT3Message]: + """ + Parse one complete ASCII line (with or without trailing \r). + Returns None for unrecognised lines. + """ +``` + +Routing logic (order matters): +1. `COMM&ok` → `PRT3CommStatus(ok=True)` +2. `COMM&fail` → `PRT3CommStatus(ok=False)` +3. `!` → `None` (buffer full; caller handles retry) +4. `RA{3d}{1c}{1c}{1c}{1c}{1c}{1c}{1c}` (13 chars) → `PRT3AreaStatus` +5. `RZ{3d}{1c}{1c}{1c}{1c}{1c}` (11 chars) → `PRT3ZoneStatus` +6. `ZL{3d}{16c}` → `PRT3LabelReply(kind="ZL", ...)` +7. `AL{3d}{16c}` → `PRT3LabelReply(kind="AL", ...)` +8. `UL{3d}{16c}` → `PRT3LabelReply(kind="UL", ...)` +9. `G{3d}N{3d}A{3d}` (13 chars) → `PRT3SystemEvent` +10. Anything matching `{5chars}&OK` or `{5chars}&fail` → `PRT3CommandEcho` +11. Else → `None` + +### 4.5 `paradox/hardware/prt3/encoder.py` + +Pure module. All functions return `bytes` ending with `b"\r"`. + +```python +def encode_request_area_status(area: int) -> bytes: + # b"RA001\r" +def encode_request_zone_status(zone: int) -> bytes: + # b"RZ001\r" +def encode_request_area_label(area: int) -> bytes: + # b"AL001\r" +def encode_request_zone_label(zone: int) -> bytes: + # b"ZL001\r" +def encode_request_user_label(user: int) -> bytes: + # b"UL001\r" +def encode_arm(area: int, mode: str, code: str) -> bytes: + # mode in ("A", "F", "S", "I"); code up to 6 digits + # b"AA01A123456\r" +def encode_quick_arm(area: int, mode: str) -> bytes: + # b"AQ01A\r" +def encode_disarm(area: int, code: str) -> bytes: + # b"AD01123456\r" +def encode_panic_emergency(area: int) -> bytes: + # b"PE01\r" +def encode_panic_medical(area: int) -> bytes: + # b"PM01\r" +def encode_panic_fire(area: int) -> bytes: + # b"PF01\r" +def encode_smoke_reset(area: int) -> bytes: + # b"SR01\r" +def encode_utility_key(key: int) -> bytes: + # b"UK001\r" +``` + +### 4.6 `paradox/hardware/prt3/event.py` + +Maps PRT3 G-group codes to PAI event descriptors. Same dict shape as `spectra_magellan/event.py` but keyed by integer G-group, not binary major/minor. The `PRT3Panel` will NOT use `LiveEvent` (which requires `po.command == 0xE`); it will use a `PRT3Event(Event)` subclass (defined in this file or in `panel.py`) that takes a `PRT3SystemEvent` dataclass directly. + +```python +# event.py structure +from paradox.data.enums import EventLevel + +EVENT_MAP: dict[int, dict] = { + 0: dict(type="zone", level=EventLevel.DEBUG, change=dict(open=False), ...), + 1: dict(type="zone", level=EventLevel.DEBUG, change=dict(open=True), ...), + 2: dict(type="zone", level=EventLevel.CRITICAL, change=dict(tamper=True), ...), + 3: dict(type="zone", level=EventLevel.CRITICAL, change=dict(fire_loop_trouble=True), ...), + 9: dict(type="partition", level=EventLevel.INFO, change=dict(arm=True), ...), + 10: dict(type="partition", level=EventLevel.INFO, change=dict(arm=True), ...), + 13: dict(type="partition", level=EventLevel.INFO, change=dict(arm=False), ...), + 14: dict(type="partition", level=EventLevel.INFO, change=dict(arm=False), ...), + 24: dict(type="zone", level=EventLevel.CRITICAL, change=dict(alarm=True), ...), + 26: dict(type="zone", level=EventLevel.INFO, change=dict(alarm=False), ...), + # ... all groups from PRT3 protocol reference + 64: dict(type="partition", level=EventLevel.DEBUG, ...), # Status 1 flags + 65: dict(type="partition", level=EventLevel.DEBUG, ...), # Status 2 flags + 66: dict(type="partition", level=EventLevel.DEBUG, ...), # Status 3 flags +} +``` + +G064/G065/G066 are special: `N` encodes a flag index, not an element ID. These need sub-maps like Spectra's `sub` key. + +### 4.7 `paradox/hardware/prt3/property.py` + +Reuse `spectra_magellan/property.py` directly — the property keys (`open`, `arm`, `tamper`, `alarm`, `trouble`, `exit_delay`, etc.) are identical. Import and re-export: + +```python +# property.py +from paradox.hardware.spectra_magellan.property import property_map + +__all__ = ["property_map"] +``` + +Only add new PRT3-specific properties if any new state keys are introduced that don't exist in the Spectra map. + +### 4.8 `paradox/hardware/prt3/panel.py` + +**Class: `PRT3Panel(Panel)`** + +Key design decisions (see §6 for rationale): + +- **`initialize_communication(password)`** — sends nothing; just returns `True`. The `COMM&ok` wait is handled in `Paradox.connect()` before this is called. +- **`load_memory()`** — overrides completely; sends `AL`, `ZL`, `UL` requests one at a time using `_prt3_send_wait()` (see §4.9); populates `self.core.storage` directly; fires `ps.sendMessage("labels_loaded", data=labels)`. +- **`request_status(nr)`** — sends `RA{nr:03d}\r` (if `nr <= max_areas`) or `RZ{nr:03d}\r` (if `nr > max_areas`); parses reply; returns a plain Python dict in the format `{"zone_open": {nr: bool}, ...}` or `{"partition_arm": {nr: bool}, ...}` — compatible with `convert_raw_status()`. +- **`get_status_requests()`** — generator of `request_status(nr)` for all configured areas and zones. Areas use indices 1..`cfg.PRT3_MAX_AREAS`, zones use distinct indices. +- **`parse_message(raw, direction)`** — decodes the ASCII line; calls `parser.parse_line()`; returns a simple namespace or the dataclass directly. For PRT3, `parse_message()` is not used for reply routing (see §4.9); it is only used for unsolicited event dispatch. +- **`control_partitions(partitions, command)`** — maps PAI command strings to PRT3 arm/disarm commands; uses `_prt3_send_wait()`. +- **`control_zones(zones, command)`** — raises `NotImplementedError` (PRT3 has no zone bypass command). +- **`control_outputs(outputs, command)`** — raises `NotImplementedError`. +- **`control_module_pgm_outputs(...)`** — raises `NotImplementedError`. +- **`control_doors(doors, command)`** — raises `NotImplementedError`. +- **`dump_memory(file, memory_type)`** — raises `NotImplementedError`. +- **`send_panic(partitions, panic_type, user_id)`** — maps `panic_type` to `PE`/`PM`/`PF` commands. + +**Partition command map:** +```python +PARTITION_COMMANDS = { + "arm": ("AA", "A"), # regular arm + "arm_stay": ("AA", "S"), # stay arm + "arm_force": ("AA", "F"), # force arm + "arm_sleep": ("AA", "I"), # instant arm (no entry delay) + "arm_quick": ("AQ", "A"), # quick arm (no code) + "disarm": ("AD", None), # disarm +} +``` + +### 4.9 `PRT3Paradox` — runtime subclass + +**File: `paradox/hardware/prt3/runtime.py`** +(Kept with the hardware layer since it is specific to PRT3 operation; imported from `paradox/paradox.py`.) + +**Class: `PRT3Paradox(Paradox)`** + +Overrides only what is different; inherits all MQTT/HA/storage/ps plumbing unchanged. + +#### Reply routing design + +The existing `handler_registry.handle()` error path accesses `data.fields.value.po.command` (a binary-specific field). PRT3 messages never carry this. To avoid that code path and keep a clean separation: + +- **Command echo replies** (RA, RZ, ZL, AL, UL, AA, AD, PE/PM/PF echo) are routed through a dedicated `asyncio.Queue` (`_prt3_reply_queue`), not through `handler_registry`. +- **Unsolicited system events** (`G{ggg}N{nnn}A{aaa}`) are dispatched directly to `ps.sendEvent()`. +- **COMM status** (`COMM&ok/fail`) updates run-state directly. + +#### Overridden methods + +```python +class PRT3Paradox(Paradox): + + def __init__(self, retries=3): + super().__init__(retries) + self._prt3_reply_queue: asyncio.Queue = asyncio.Queue(maxsize=1) + + def _register_connection_handlers(self): + # Only register the raw handler; skip binary EventMessageHandler/ErrorMessageHandler + self.connection.register_raw_handler( + PersistentHandler(self.on_connection_message) + ) + + async def connect(self) -> bool: + """Skip binary handshake; await COMM&ok; create PRT3Panel directly.""" + # (full implementation — not calling super().connect()) + + def on_connection_message(self, message: bytes): + """Route incoming ASCII lines to reply queue or event dispatch.""" + line = message.decode("ascii", errors="replace").rstrip("\r\n") + parsed = parse_line(line) + if parsed is None: + return + if isinstance(parsed, PRT3CommStatus): + self._handle_comm_status(parsed) + elif isinstance(parsed, PRT3SystemEvent): + self._handle_system_event(parsed) + elif isinstance(parsed, (PRT3AreaStatus, PRT3ZoneStatus, PRT3LabelReply, + PRT3CommandEcho)): + try: + self._prt3_reply_queue.put_nowait(parsed) + except asyncio.QueueFull: + logger.warning("PRT3 reply queue full, dropping: %s", parsed) + + async def _prt3_send_wait( + self, + command: bytes, + timeout: float = cfg.IO_TIMEOUT, + ) -> Optional[PRT3Message]: + """Send an ASCII command and wait for exactly one reply.""" + # Drain stale replies + while not self._prt3_reply_queue.empty(): + self._prt3_reply_queue.get_nowait() + async with self.request_lock: + self.connection.write(command) + return await asyncio.wait_for( + self._prt3_reply_queue.get(), timeout=timeout + ) + + def _handle_comm_status(self, msg: PRT3CommStatus): + if msg.ok: + logger.info("PRT3: COMM&ok — panel communication established") + else: + logger.error("PRT3: COMM&fail — panel communication lost") + asyncio.create_task(self.disconnect()) + + def _handle_system_event(self, evt: PRT3SystemEvent): + # Build PRT3Event and dispatch via ps + ... +``` + +#### `connect()` override flow + +``` +PRT3Paradox.connect() + self.run_state = RunState.INIT + await connection.connect() # opens serial port + # Wait for COMM&ok (panel sends this on startup) + comm = await asyncio.wait_for( + self._prt3_reply_queue.get(), timeout=15.0 + ) + if not isinstance(comm, PRT3CommStatus) or not comm.ok: + self.run_state = RunState.ERROR + return False + self.panel = PRT3Panel(self) + await self.panel.initialize_communication(cfg.PASSWORD) # no-op + self.run_state = RunState.CONNECTED + return True +``` + +#### `PRT3Event(Event)` class + +Defined alongside `PRT3Paradox` or in `event.py`. Constructs an `Event` directly from a `PRT3SystemEvent` dataclass and the `EVENT_MAP`, bypassing `LiveEvent`'s `po.command == 0xE` assertion. + +```python +class PRT3Event(Event): + def __init__(self, raw: PRT3SystemEvent, event_map: dict, label_provider=None): + super().__init__(label_provider=label_provider) + entry = event_map.get(raw.group) + if entry is None: + raise AssertionError(f"Unknown PRT3 event group: {raw.group}") + self.major = raw.group + self.minor = raw.number + self.id = raw.number + self.partition = raw.area + self.timestamp = int(time.time()) + # populate level, type, change, tags, message from entry + ... +``` + +### 4.10 `paradox/hardware/prt3/__init__.py` + +```python +from .panel import PRT3Panel +``` + +--- + +## 5. Test files and patterns to follow + +### 5.1 Protocol framer tests — follow `tests/connection/test_serial_protocol.py` + +**File: `tests/connection/prt3/test_protocol.py`** + +Pattern: +```python +from unittest.mock import MagicMock +from paradox.connections.prt3.protocol import PRT3Protocol + +def test_complete_line(): + handler = MagicMock() + p = PRT3Protocol(handler) + p.data_received(b"COMM&ok\r") + handler.on_message.assert_called_once_with(b"COMM&ok\r") + +def test_line_in_chunks(): + handler = MagicMock() + p = PRT3Protocol(handler) + p.data_received(b"COMM") + p.data_received(b"&ok\r") + handler.on_message.assert_called_once_with(b"COMM&ok\r") + +def test_two_lines_in_one_chunk(): + handler = MagicMock() + p = PRT3Protocol(handler) + p.data_received(b"COMM&ok\rG001N005A001\r") + assert handler.on_message.call_count == 2 +``` + +Required cases: +- Single complete line +- Line split across N chunks +- Two lines in one chunk +- Empty (no `\r`) input: handler not called +- Lines ending `\r\n` (strip `\n` too, just in case) +- Garbage bytes before first `\r`: no crash, no spurious calls +- `send_message()` writes bytes directly + +### 5.2 Parser tests — follow `tests/hardware/evo/test_action.py` (pure input/output) + +**File: `tests/hardware/prt3/test_parser.py`** + +Pattern: plain `assert`, parametrize where patterns repeat. +```python +import pytest +from paradox.hardware.prt3.parser import parse_line, PRT3AreaStatus, PRT3ZoneStatus, ... + +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_area_status_disarmed(): + result = parse_line("RA001DOOOOOO") + assert isinstance(result, PRT3AreaStatus) + assert result.area == 1 + assert result.armed == "D" + assert result.alarm is False + assert result.trouble is False + +def test_area_status_armed(): + result = parse_line("RA001AOOOOOO") + assert result.armed == "A" + +@pytest.mark.parametrize("line,zone,status,alarm", [ + ("RZ001COOOO", 1, "C", False), + ("RZ001OOOOO", 1, "O", False), + ("RZ001TOOOO", 1, "T", False), + ("RZ001OAOOO", 1, "O", True), + ("RZ192COOOO", 192, "C", False), +]) +def test_zone_status(line, zone, status, alarm): + result = parse_line(line) + assert isinstance(result, PRT3ZoneStatus) + assert result.zone == zone + assert result.status == status + assert result.alarm == alarm + +def test_zone_label(): + result = parse_line("ZL001Front Door ") + assert isinstance(result, PRT3LabelReply) + assert result.kind == "ZL" + assert result.index == 1 + assert result.label == "Front Door" + +def test_system_event(): + result = parse_line("G001N005A006") + assert isinstance(result, PRT3SystemEvent) + assert result.group == 1 + assert result.number == 5 + assert result.area == 6 + +def test_unknown_line_returns_none(): + assert parse_line("JUNK") is None + assert parse_line("") is None +``` + +Fixture file: `tests/hardware/prt3/fixtures/session_events.txt` — one raw line per line, used as a replay corpus. + +### 5.3 Encoder tests — follow `tests/hardware/evo/test_action.py` + +**File: `tests/hardware/prt3/test_encoder.py`** + +```python +from paradox.hardware.prt3.encoder import ( + encode_request_area_status, encode_arm, encode_disarm, ... +) + +def test_encode_request_area_status(): + assert encode_request_area_status(1) == b"RA001\r" + assert encode_request_area_status(8) == b"RA008\r" + +def test_encode_request_zone_status(): + assert encode_request_zone_status(1) == b"RZ001\r" + assert encode_request_zone_status(192) == b"RZ192\r" + +def test_encode_arm_regular(): + assert encode_arm(1, "A", "1234") == b"AA01A1234\r" + +def test_encode_arm_stay(): + assert encode_arm(2, "S", "123456") == b"AA02S123456\r" + +def test_encode_disarm(): + assert encode_disarm(1, "1234") == b"AD011234\r" + +def test_encode_quick_arm(): + assert encode_quick_arm(1, "A") == b"AQ01A\r" + +def test_encode_panic_emergency(): + assert encode_panic_emergency(1) == b"PE01\r" + +def test_encode_utility_key(): + assert encode_utility_key(1) == b"UK001\r" + assert encode_utility_key(251) == b"UK251\r" +``` + +### 5.4 Panel integration tests — follow `tests/hardware/evo/test_initialize_communication.py` + +**File: `tests/hardware/prt3/test_panel.py`** + +Uses `unittest.mock.MagicMock` for the `core` (`Paradox` instance) and monkey-patches `_prt3_send_wait`. + +```python +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from paradox.hardware.prt3.panel import PRT3Panel + +@pytest.fixture +def mock_core(): + core = MagicMock() + core._prt3_send_wait = AsyncMock() + core.storage = MagicMock() + return core + +@pytest.mark.asyncio +async def test_request_area_status_disarmed(mock_core): + from paradox.hardware.prt3.parser import PRT3AreaStatus + mock_core._prt3_send_wait.return_value = PRT3AreaStatus( + area=1, armed="D", programming=False, trouble=False, + ready=True, alarm=False, strobe=False, alarm_in_memory=False + ) + panel = PRT3Panel(mock_core) + result = await panel.request_status(1) + assert result["partition_arm"][1] is False + assert result["partition_alarm"][1] is False + +@pytest.mark.asyncio +async def test_request_area_status_armed(mock_core): + from paradox.hardware.prt3.parser import PRT3AreaStatus + mock_core._prt3_send_wait.return_value = PRT3AreaStatus( + area=1, armed="A", programming=False, trouble=False, + ready=True, alarm=False, strobe=False, alarm_in_memory=False + ) + panel = PRT3Panel(mock_core) + result = await panel.request_status(1) + assert result["partition_arm"][1] is True + +@pytest.mark.asyncio +async def test_control_partitions_arm(mock_core): + from paradox.hardware.prt3.parser import PRT3CommandEcho + mock_core._prt3_send_wait.return_value = PRT3CommandEcho( + prefix="AA01A", ok=True, payload="" + ) + panel = PRT3Panel(mock_core) + result = await panel.control_partitions([1], "arm") + assert result is True +``` + +### 5.5 Event map tests — follow `tests/hardware/spectra_magellan/test_event_parsing.py` + +**File: `tests/hardware/prt3/test_parser.py`** (add to same file or separate `test_events.py`) + +```python +from paradox.hardware.prt3.event import EVENT_MAP +from paradox.hardware.prt3.runtime import PRT3Event +from paradox.hardware.prt3.parser import PRT3SystemEvent + +def test_zone_open_event(): + raw = PRT3SystemEvent(group=1, number=5, area=2) + evt = PRT3Event(raw, EVENT_MAP) + assert evt.type == "zone" + assert evt.change == {"open": True} + +def test_zone_alarm_event(): + raw = PRT3SystemEvent(group=24, number=3, area=1) + evt = PRT3Event(raw, EVENT_MAP) + assert evt.type == "zone" + assert evt.change.get("alarm") is True + +def test_arm_event(): + raw = PRT3SystemEvent(group=10, number=42, area=1) + evt = PRT3Event(raw, EVENT_MAP) + assert evt.type == "partition" + assert evt.change.get("arm") is True +``` + +--- + +## 6. Design decisions and rationale + +### 6.1 Why `asyncio.Queue` for reply routing (not `handler_registry`) + +`handler_registry.handle()` logs `data.fields.value.po.command` when no handler matches. PRT3 messages are plain dataclasses, not `Construct Container`s. Routing them through `handler_registry` would require either faking the `.fields.value.po.command` shape (fragile) or silencing the no-handler error globally (hides bugs). A dedicated `asyncio.Queue` keeps reply routing entirely inside PRT3-specific code with zero risk to existing behaviour. + +### 6.2 Why `PRT3Paradox(Paradox)` subclass (not `connect()` guard) + +The guard approach (`if cfg.CONNECTION_TYPE == "PRT3": return early`) pollutes `paradox.py` with repeated guards. A subclass puts all PRT3-specific orchestration in one file with a clean `super()` boundary. It also makes `main.py` changes minimal (one import line). + +### 6.3 Why `SerialCommunication` is reused as base for `PRT3SerialConnection` + +`SerialCommunication.connect()` handles: permissions check+fix, `connected_future`, timeout handler, `serial_asyncio.create_serial_connection()`, exception mapping. All of this is wanted. Only `make_protocol()` differs. Subclassing avoids duplicating ~40 lines of robust serial open code. + +### 6.4 Why `property_map` is imported from `spectra_magellan` + +The PAI property names (`open`, `arm`, `arm_stay`, `alarm`, `trouble`, `exit_delay`, etc.) are protocol-independent — they describe alarm state semantics. The Spectra map has all the properties PRT3 needs. Sharing it avoids drift between two copies of the same data. If PRT3 introduces new properties, they can be added to a local map that extends the shared one. + +### 6.5 Why `load_memory()` is fully overridden (not `load_labels()`) + +`Panel.load_memory()` calls `load_definitions()` then `load_labels()`. Both rely on `_eeprom_batch_reader()` → `send_wait(ReadEEPROM, ...)`. PRT3 has no EEPROM read facility. Overriding only `load_labels()` would leave `load_definitions()` attempting EEPROM reads and failing. It is cleaner and safer to override `load_memory()` entirely. + +### 6.6 Why `request_status()` returns a flat dict (not a Container) + +`Paradox.loop()` calls `deep_merge(*results)` then `_process_status(merged)` → `convert_raw_status()`. `convert_raw_status()` requires dict keys of the form `{type}_{property}` with `int`-keyed sub-dicts. This is a stable, documented internal format. Returning a plain Python dict in this format from PRT3 `request_status()` means the entire downstream status pipeline (`ps.sendMessage("status_update")`, `_on_status_update`, `MemoryStorage.update_container_object`, `Change` events, MQTT publish) works without modification. + +--- + +## 7. Phased implementation plan + +Each phase is independently testable before the next begins. + +### Phase 1 — ASCII framer (transport layer) +**Files**: `connections/prt3/protocol.py`, `connections/prt3/connection.py`, `connections/prt3/__init__.py` +**Tests**: `tests/connection/prt3/test_protocol.py` +**Completion criteria**: `PRT3Protocol` passes all framer tests; no PAI runtime code touched. + +### Phase 2 — Pure protocol layer (parser + encoder) +**Files**: `hardware/prt3/parser.py`, `hardware/prt3/encoder.py` +**Tests**: `tests/hardware/prt3/test_parser.py`, `tests/hardware/prt3/test_encoder.py` +**Completion criteria**: All parse and encode functions pass unit tests with fixture strings from the protocol reference. 100% coverage of the protocol table. + +### Phase 3 — Event and property maps +**Files**: `hardware/prt3/event.py`, `hardware/prt3/property.py` +**Tests**: event map tests in `test_parser.py` or `test_events.py` +**Completion criteria**: All G-group codes in the protocol reference have entries; `PRT3Event` constructs cleanly from each. + +### Phase 4 — Panel adapter +**Files**: `hardware/prt3/panel.py`, `hardware/prt3/__init__.py` +**Tests**: `tests/hardware/prt3/test_panel.py` +**Completion criteria**: `request_status()`, `load_memory()`, `control_partitions()`, `send_panic()` pass tests against mock core; unsupported methods raise `NotImplementedError`. + +### Phase 5 — Runtime integration +**Files**: `hardware/prt3/runtime.py` (`PRT3Paradox`), `config.py` (2 insertions), `paradox.py` (2 insertions) +**Tests**: extend `test_panel.py` with `PRT3Paradox` integration test using a mock serial transport +**Completion criteria**: `PRT3Paradox.connect()` flows correctly with a simulated `COMM&ok` response; label load and status poll dispatch to storage; no existing tests break (`pytest tests/` clean). + +### Phase 6 — End-to-end replay test +**Files**: `tests/hardware/prt3/fixtures/*.txt`, `tests/hardware/prt3/test_panel.py` +**Tests**: replay-based test that feeds a session transcript through `PRT3Protocol` + `PRT3Paradox.on_connection_message()` and asserts final storage state +**Completion criteria**: storage state after replay matches expected partition arm states and zone open states. + +### Phase 7 — Config example and docs +**Files**: `config/pai-prt3.conf.example`; update `docs/prt3-architecture.md` if anything changed +**Completion criteria**: config example is loadable by `cfg.load()`; no undocumented limitations. + +--- + +## 8. Risk register + +| Risk | Mitigation | +|---|---| +| `_process_status` format changes in future PAI | Our flat-dict format matches the existing contract; any upstream change affects all backends equally | +| `HandlerRegistry` error path on `data.fields.value.po.command` | Fully avoided by reply-queue design; existing `handler_registry` is never fed PRT3 messages | +| `LiveEvent.__init__` asserts `po.command == 0xE` | Fully avoided by `PRT3Event(Event)` subclass; `LiveEvent` is never called for PRT3 events | +| `SerialCommunication.connect()` changes upstream | Only `make_protocol()` is overridden; any fix to the open logic is automatically inherited | +| `load_memory()` EEPROM path called for PRT3 | Fully avoided by overriding `load_memory()` entirely | +| Existing tests broken by config.py change | `CONNECTION_TYPE` constraint is widened, not narrowed; all existing tests pass `"Serial"` or `"IP"` | +| Baud mismatch (panel default 9600, control4 guide recommends 19200) | Documented in limitations; `PRT3_SERIAL_BAUD` config key lets user set either | From 824ef9e3f628ef33ac98aaeb73ec16e9e3a4c573 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Sun, 29 Mar 2026 19:50:06 +1000 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20PRT3=20scaffolding=20=E2=80=94=20?= =?UTF-8?q?config,=20connection,=20hardware,=20and=20test=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the minimum non-invasive scaffolding for the PRT3 connection type: - paradox/config.py: add "PRT3" to CONNECTION_TYPE allowed list; add PRT3_SERIAL_PORT, PRT3_SERIAL_BAUD, PRT3_MAX_AREAS, PRT3_MAX_ZONES, PRT3_MAX_USERS config keys. - paradox/paradox.py: add PRT3 branch in connection property (instantiates PRT3SerialConnection); add guard in connect() that returns False with an error log — binary panel detection does not apply to PRT3. - paradox/connections/prt3/: PRT3Protocol (ASCII line framer skeleton) and PRT3SerialConnection (only overrides make_protocol()). - paradox/hardware/prt3/: PRT3Panel, PRT3Paradox, parser dataclasses, encoder stubs, PRT3Event, property_map re-export from spectra_magellan. All protocol logic raises NotImplementedError with Phase 2/3 TODO labels. - tests/connection/prt3/, tests/hardware/prt3/: import smoke tests and NotImplementedError assertions; 869 existing tests unaffected. - docs/prt3-architecture.md: branch status note added. No existing Serial or IP150 behaviour is changed. --- docs/prt3-architecture.md | 16 +++ paradox/config.py | 8 +- paradox/connections/prt3/__init__.py | 1 + paradox/connections/prt3/connection.py | 27 ++++ paradox/connections/prt3/protocol.py | 50 +++++++ paradox/hardware/prt3/__init__.py | 1 + paradox/hardware/prt3/encoder.py | 130 +++++++++++++++++++ paradox/hardware/prt3/event.py | 59 +++++++++ paradox/hardware/prt3/panel.py | 172 +++++++++++++++++++++++++ paradox/hardware/prt3/parser.py | 134 +++++++++++++++++++ paradox/hardware/prt3/property.py | 12 ++ paradox/hardware/prt3/runtime.py | 104 +++++++++++++++ paradox/paradox.py | 19 +++ tests/connection/prt3/__init__.py | 0 tests/connection/prt3/test_protocol.py | 32 +++++ tests/hardware/prt3/__init__.py | 0 tests/hardware/prt3/test_encoder.py | 47 +++++++ tests/hardware/prt3/test_parser.py | 35 +++++ 18 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 paradox/connections/prt3/__init__.py create mode 100644 paradox/connections/prt3/connection.py create mode 100644 paradox/connections/prt3/protocol.py create mode 100644 paradox/hardware/prt3/__init__.py create mode 100644 paradox/hardware/prt3/encoder.py create mode 100644 paradox/hardware/prt3/event.py create mode 100644 paradox/hardware/prt3/panel.py create mode 100644 paradox/hardware/prt3/parser.py create mode 100644 paradox/hardware/prt3/property.py create mode 100644 paradox/hardware/prt3/runtime.py create mode 100644 tests/connection/prt3/__init__.py create mode 100644 tests/connection/prt3/test_protocol.py create mode 100644 tests/hardware/prt3/__init__.py create mode 100644 tests/hardware/prt3/test_encoder.py create mode 100644 tests/hardware/prt3/test_parser.py diff --git a/docs/prt3-architecture.md b/docs/prt3-architecture.md index 9c990f9f..bba39322 100644 --- a/docs/prt3-architecture.md +++ b/docs/prt3-architecture.md @@ -1,5 +1,21 @@ # PRT3 Connection — Architecture Notes +## Branch status + +**Scaffolding only — PRT3 is not yet functional.** + +The skeleton modules and config keys are in place (see layer layout below), +but all protocol logic raises `NotImplementedError`. Setting +`CONNECTION_TYPE = "PRT3"` will open the serial port and then immediately +return an error from `connect()`. + +Implementation phases: + +| Phase | What gets implemented | +|---|---| +| Phase 2 | `PRT3Protocol` framer, `PRT3Panel` init/labels/status, `PRT3Paradox.connect()` | +| Phase 3 | Event map, arm/disarm/panic control, full async event pipeline | + ## Why PRT3 is a separate connection type PRT3 is not a transport wrapper around the existing Paradox binary serial protocol. diff --git a/paradox/config.py b/paradox/config.py index 9154ff99..3a422739 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -25,10 +25,16 @@ 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 + # 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, (2400, 115200)), # Baud rate; 9600 or 19200 typical + "PRT3_MAX_AREAS": (8, int, (1, 8)), # Number of areas on the panel + "PRT3_MAX_ZONES": (96, int, (1, 192)), # Number of zones on the panel + "PRT3_MAX_USERS": (999, int, (1, 999)), # Number of user codes on the panel # IP Connection Details "IP_CONNECTION_HOST": "127.0.0.1", # IP Module address when using direct IP Connection "IP_CONNECTION_PORT": ( 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..b14db702 --- /dev/null +++ b/paradox/connections/prt3/protocol.py @@ -0,0 +1,50 @@ +""" +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(). + +TODO (Phase 2): Implement data_received() line framer. +TODO (Phase 2): Implement send_message() raw write. +""" + +import logging + +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 ASCII lines (\\r-terminated) + as ``bytes`` objects to the connection handler. + """ + + def variable_message_length(self, *args, **kwargs): + # PRT3 lines have no fixed length — framing is delimiter-based. + # This method 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): + # TODO (Phase 2): Buffer incoming bytes; emit complete \r-delimited + # lines via self.handler.on_message(line). + raise NotImplementedError( + "PRT3Protocol.data_received() not yet implemented — see Phase 2" + ) + + def send_message(self, message: bytes): + # TODO (Phase 2): Write raw bytes directly to self.transport. + # PRT3 commands are plain ASCII, already \\r-terminated by encoder.py. + raise NotImplementedError( + "PRT3Protocol.send_message() not yet implemented — see Phase 2" + ) 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/encoder.py b/paradox/hardware/prt3/encoder.py new file mode 100644 index 00000000..5c568dcd --- /dev/null +++ b/paradox/hardware/prt3/encoder.py @@ -0,0 +1,130 @@ +""" +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 (already \\r-terminated). + +Command reference (PRT3 ASCII Programming Guide): + + Arm AA{nn}{mode}{code}\\r mode: A=away F=force S=stay I=instant + Quick arm AQ{nn}{mode}\\r requires One-Touch Arming enabled on panel + Disarm AD{nn}{code}\\r + Panic emerg PE{nn}\\r + Panic medic PM{nn}\\r + Panic fire PF{nn}\\r + Utility key UK{nnn}\\r + Area status RA{nnn}\\r + Zone status RZ{nnn}\\r + Area label AL{nnn}\\r + Zone label ZL{nnn}\\r + User label UL{nnn}\\r + +TODO (Phase 2): Implement each encoder. +""" + +# --------------------------------------------------------------------------- +# Arm / disarm +# --------------------------------------------------------------------------- + +def encode_arm(area: int, mode: str, code: str) -> bytes: + """ + Build an AA (arm) command. + + :param area: 1-based area number (01-08 for EVO) + :param mode: one of 'A' (away), 'F' (force), 'S' (stay), 'I' (instant) + :param code: numeric user code as a string, e.g. '1234' + + TODO (Phase 2): Implement — return f'AA{area:02d}{mode}{code}\\r'.encode() + """ + raise NotImplementedError("encode_arm() not yet implemented — see Phase 2") + + +def encode_quick_arm(area: int, mode: str) -> bytes: + """ + Build an AQ (quick arm) command. + + Requires 'One-Touch Arming' enabled on the panel; silently ignored otherwise. + + TODO (Phase 2): Implement — return f'AQ{area:02d}{mode}\\r'.encode() + """ + raise NotImplementedError("encode_quick_arm() not yet implemented — see Phase 2") + + +def encode_disarm(area: int, code: str) -> bytes: + """ + Build an AD (disarm) command. + + TODO (Phase 2): Implement — return f'AD{area:02d}{code}\\r'.encode() + """ + raise NotImplementedError("encode_disarm() not yet implemented — see Phase 2") + + +# --------------------------------------------------------------------------- +# Panic +# --------------------------------------------------------------------------- + +def encode_panic_emergency(area: int) -> bytes: + """PE — emergency panic. TODO (Phase 2).""" + raise NotImplementedError("encode_panic_emergency() not yet implemented — see Phase 2") + + +def encode_panic_medical(area: int) -> bytes: + """PM — medical panic. TODO (Phase 2).""" + raise NotImplementedError("encode_panic_medical() not yet implemented — see Phase 2") + + +def encode_panic_fire(area: int) -> bytes: + """PF — fire panic. TODO (Phase 2).""" + raise NotImplementedError("encode_panic_fire() not yet implemented — see Phase 2") + + +# --------------------------------------------------------------------------- +# Utility key +# --------------------------------------------------------------------------- + +def encode_utility_key(key: int) -> bytes: + """ + UK{nnn} — send utility key. + + TODO (Phase 2): Implement — return f'UK{key:03d}\\r'.encode() + """ + raise NotImplementedError("encode_utility_key() not yet implemented — see Phase 2") + + +# --------------------------------------------------------------------------- +# Status / label requests +# --------------------------------------------------------------------------- + +def encode_area_status_request(area: int) -> bytes: + """RA{nnn}\\r — request area status. TODO (Phase 2).""" + raise NotImplementedError( + "encode_area_status_request() not yet implemented — see Phase 2" + ) + + +def encode_zone_status_request(zone: int) -> bytes: + """RZ{nnn}\\r — request zone status. TODO (Phase 2).""" + raise NotImplementedError( + "encode_zone_status_request() not yet implemented — see Phase 2" + ) + + +def encode_area_label_request(area: int) -> bytes: + """AL{nnn}\\r — request area label. TODO (Phase 2).""" + raise NotImplementedError( + "encode_area_label_request() not yet implemented — see Phase 2" + ) + + +def encode_zone_label_request(zone: int) -> bytes: + """ZL{nnn}\\r — request zone label. TODO (Phase 2).""" + raise NotImplementedError( + "encode_zone_label_request() not yet implemented — see Phase 2" + ) + + +def encode_user_label_request(user: int) -> bytes: + """UL{nnn}\\r — request user label. TODO (Phase 2).""" + raise NotImplementedError( + "encode_user_label_request() not yet implemented — see Phase 2" + ) diff --git a/paradox/hardware/prt3/event.py b/paradox/hardware/prt3/event.py new file mode 100644 index 00000000..96e0fa4b --- /dev/null +++ b/paradox/hardware/prt3/event.py @@ -0,0 +1,59 @@ +""" +PRT3 event map and PRT3Event type. + +EVENT_MAP maps G-group codes (int) to PAI event descriptor dicts. +It will be populated in Phase 3 by consulting the PRT3 ASCII Programming +Guide section "System Event Group Codes". + +PRT3Event subclasses Event (not LiveEvent) because LiveEvent asserts +``raw.fields.value.po.command == 0xE``, which is meaningless for ASCII events. +PRT3 events are constructed directly from parsed PRT3SystemEvent dataclasses. + +TODO (Phase 3): Populate EVENT_MAP with all G-group codes. +TODO (Phase 3): Implement PRT3Event.from_prt3(event: PRT3SystemEvent, ...). +""" + +from paradox.event import Event + +# --------------------------------------------------------------------------- +# Event map (empty until Phase 3) +# --------------------------------------------------------------------------- + +# Maps G-group code (int) -> dict with keys: +# 'type' : str PAI event type tag, e.g. 'zone', 'partition', 'system' +# 'subtype' : str PAI event subtype / message template +# 'level' : EventLevel +# +# Example entry (to be added in Phase 3): +# 1: {'type': 'zone', 'subtype': 'alarm', 'level': EventLevel.CRITICAL}, +EVENT_MAP: dict = {} # TODO (Phase 3): populate from PRT3 spec + + +# --------------------------------------------------------------------------- +# PRT3Event +# --------------------------------------------------------------------------- + +class PRT3Event(Event): + """ + PAI Event subclass for PRT3 system events. + + Does NOT inherit 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. + + TODO (Phase 3): Implement from_prt3() factory method. + """ + + @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: callable(type, value) -> label string + + TODO (Phase 3): Look up group in EVENT_MAP, populate fields. + """ + raise NotImplementedError( + "PRT3Event.from_prt3() not yet implemented — see Phase 3" + ) diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py new file mode 100644 index 00000000..06cd0c3f --- /dev/null +++ b/paradox/hardware/prt3/panel.py @@ -0,0 +1,172 @@ +""" +PRT3Panel — Panel adapter for the Paradox PRT3 Printer Module. + +Subclasses Panel (paradox.hardware.panel.Panel) and implements all +panel-facing methods using the PRT3 ASCII protocol instead of binary +EEPROM reads. + +The Panel base class is not abstract in the strict sense — it provides +fallback implementations for most methods. PRT3Panel overrides the ones +that would otherwise silently do nothing or crash on binary assumptions. + +Design constraints: + - Zone bypass has no PRT3 command; control_zones() raises NotImplementedError. + - PGM / output control has no PRT3 command; control_outputs() raises + NotImplementedError. + - EEPROM reads are not available via PRT3; load_definitions() returns {}. + +TODO (Phase 2): Implement initialize_communication() — await COMM&ok. +TODO (Phase 2): Implement load_labels() — send AL/ZL/UL requests, collect replies. +TODO (Phase 2): Implement request_status() — send RA/RZ requests, return flat dict. +TODO (Phase 3): Implement get_status_requests() — return list of coroutines. +TODO (Phase 3): Implement parse_message() for PRT3 lines (delegate to parser.py). +TODO (Phase 3): Implement send_panic() using PE/PM/PF commands. +TODO (Phase 3): Implement control_partitions() using AA/AQ/AD commands. +""" + +import logging + +from paradox.hardware.panel import Panel +from paradox.hardware.prt3.property import property_map + +logger = logging.getLogger("PAI").getChild(__name__) + + +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. + """ + + property_map = property_map # re-exported from spectra_magellan + + def __init__(self, core): + # variable_message_length=False: PRT3Protocol.variable_message_length() + # is a no-op, so we don't need the base class to manage lengths. + super().__init__(core, variable_message_length=False) + + # ------------------------------------------------------------------ + # Startup handshake + # ------------------------------------------------------------------ + + async def initialize_communication(self, password) -> bool: + """ + Wait for COMM&ok from the panel, then optionally verify the connection. + + The PRT3 module sends 'COMM&ok\\r' shortly after power-on or reconnect. + There is no password exchange in the PRT3 protocol; the ``password`` + argument is accepted for interface compatibility but is not used. + + TODO (Phase 2): Await COMM&ok via the reply queue with a timeout. + """ + raise NotImplementedError( + "PRT3Panel.initialize_communication() not yet implemented — see Phase 2" + ) + + # ------------------------------------------------------------------ + # Label loading + # ------------------------------------------------------------------ + + async def load_labels(self) -> dict: + """ + Request area, zone, and user labels via AL/ZL/UL ASCII commands. + + Sends AL{nnn}, ZL{nnn}, UL{nnn} for each index in range, collects + replies, and returns a dict compatible with Paradox._on_labels_load(). + + TODO (Phase 2): Implement label request/reply loop. + """ + raise NotImplementedError( + "PRT3Panel.load_labels() not yet implemented — see Phase 2" + ) + + async def load_definitions(self) -> dict: + # PRT3 provides no EEPROM access; definitions are not available. + return {} + + # ------------------------------------------------------------------ + # Status polling + # ------------------------------------------------------------------ + + async def request_status(self, nr: int): + """ + Poll area and zone status via RA/RZ ASCII commands. + + Returns a flat dict in the format expected by convert_raw_status(): + { + 'zone_open': {1: bool, 2: bool, ...}, + 'zone_alarm': {1: bool, ...}, + 'partition_arm': {1: bool, ...}, + ... + } + + TODO (Phase 2): Send RA/RZ requests, parse replies, return flat dict. + """ + raise NotImplementedError( + "PRT3Panel.request_status() not yet implemented — see Phase 2" + ) + + # ------------------------------------------------------------------ + # Control — partitions + # ------------------------------------------------------------------ + + async def control_partitions(self, partitions: list, command: str) -> bool: + """ + Arm or disarm partitions using AA/AQ/AD ASCII commands. + + TODO (Phase 3): Map PAI command strings ('arm', 'arm_stay', 'disarm', …) + to PRT3 encoder calls. + """ + raise NotImplementedError( + "PRT3Panel.control_partitions() not yet implemented — see Phase 3" + ) + + # ------------------------------------------------------------------ + # 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, partition: int, panic_type: str, _code) -> bool: + """ + Send a PE/PM/PF panic command. + + TODO (Phase 3): Map panic_type ('emergency', 'medical', 'fire') to + encoder calls. + """ + raise NotImplementedError( + "PRT3Panel.send_panic() not yet implemented — see Phase 3" + ) + + # ------------------------------------------------------------------ + # Message parsing (delegated to parser.py) + # ------------------------------------------------------------------ + + def parse_message(self, message, direction="topanel"): + """ + Parse a raw PRT3 ASCII line (bytes) into a typed PRT3Message. + + TODO (Phase 2): Decode bytes, strip \\r, delegate to parser.parse_line(). + """ + raise NotImplementedError( + "PRT3Panel.parse_message() not yet implemented — see Phase 2" + ) diff --git a/paradox/hardware/prt3/parser.py b/paradox/hardware/prt3/parser.py new file mode 100644 index 00000000..6b2d4caf --- /dev/null +++ b/paradox/hardware/prt3/parser.py @@ -0,0 +1,134 @@ +""" +PRT3 ASCII line parser. + +parse_line(line: str) -> PRT3Message | None + +All incoming PRT3 messages are plain ASCII strings (\\r already stripped). +This module turns them into typed dataclasses so the rest of the stack +never has to inspect raw strings. + +Message grammar (from PRT3 ASCII Programming Guide): + + COMM&ok — panel ready after power-on / reconnect + COMM&fail — panel not ready + {cmd5}&OK — command echo — success (first 5 chars of sent command) + {cmd5}&fail — command echo — failure + RA{nnn}... — area status reply (16 flag chars follow the index) + RZ{nnn}... — zone status reply (9 flag chars follow the index) + AL{nnn}{label} — area label reply (16-char padded label) + ZL{nnn}{label} — zone label reply + UL{nnn}{label} — user label reply + G{ggg}N{nnn}A{aaa} — async system event + +TODO (Phase 2): Implement each branch of parse_line(). +""" + +from dataclasses import dataclass +from typing import Optional, Union + + +# --------------------------------------------------------------------------- +# Message types +# --------------------------------------------------------------------------- + +@dataclass +class PRT3CommStatus: + """COMM&ok or COMM&fail received on startup.""" + ok: bool # True = panel ready + + +@dataclass +class PRT3CommandEcho: + """Echo of a command we sent, e.g. 'AA001A&OK' -> cmd='AA001A', ok=True.""" + cmd: str # first 5 chars of the command that was echoed + ok: bool + + +@dataclass +class PRT3AreaStatus: + """Reply to RA{nnn} — parsed flag fields. + + Field order (chars 5-onward of the raw line after 'RA{nnn}'): + D — disarmed + A — armed away + F — armed in force + S — armed stay + I — armed instant + N — in alarm + P — partition in programming (stay arm) + O — fire alarm + T — trouble + O — ready for arming + N — exit delay active + A — entry delay active + Raw example: RA001DAFSINPOTONa (16 flag chars) + + TODO (Phase 2): Map exact flag positions from PRT3 spec table 1. + """ + area: int + raw_flags: str # preserved verbatim until Phase 2 maps each field + + +@dataclass +class PRT3ZoneStatus: + """Reply to RZ{nnn} — parsed flag fields. + + Flag chars (9 total) after 'RZ{nnn}': + C — closed / O — open + O — no tamper / T — tamper + F — no fire / f — fire alarm + A — no alarm / a — in alarm + F — supervision OK / S — supervision trouble + B — battery OK / b — low battery + L — no low signal / l — low signal + Raw example: RZ001COTFAFSOL + + TODO (Phase 2): Map exact flag positions from PRT3 spec table 2. + """ + zone: int + raw_flags: str + + +@dataclass +class PRT3LabelReply: + """Reply to AL/ZL/UL{nnn} — 16-char ASCII label.""" + element_type: str # 'area', 'zone', or 'user' + index: int + label: str # raw 16-char label (spaces not stripped yet) + + +@dataclass +class PRT3SystemEvent: + """Async event from the panel: G{ggg}N{nnn}A{aaa}.""" + group: int # event group code + number: int # event-specific number (zone, user, …) + area: int # area involved + + +# Union type for callers +PRT3Message = Union[ + PRT3CommStatus, + PRT3CommandEcho, + PRT3AreaStatus, + PRT3ZoneStatus, + PRT3LabelReply, + PRT3SystemEvent, +] + + +# --------------------------------------------------------------------------- +# Parser entry point +# --------------------------------------------------------------------------- + +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 if the line is not + recognised (e.g. an empty line or future extension). + + TODO (Phase 2): Implement each branch. + """ + raise NotImplementedError( + "PRT3 parse_line() not yet implemented — see Phase 2" + ) 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/hardware/prt3/runtime.py b/paradox/hardware/prt3/runtime.py new file mode 100644 index 00000000..a2e6d99f --- /dev/null +++ b/paradox/hardware/prt3/runtime.py @@ -0,0 +1,104 @@ +""" +PRT3Paradox — Paradox subclass for the PRT3 ASCII connection type. + +Overrides connect() and the connection message handler to implement the +PRT3-specific handshake and reply routing. + +Why a subclass of Paradox and not a modified Paradox.connect(): + - Paradox.connect() assumes binary panel detection (InitiateCommunication / + StartCommunication), which does not exist in PRT3. + - The binary HandlerRegistry calls data.fields.value.po.command on every + unhandled message; PRT3 messages are plain dataclasses that have no such + attribute — routing them through the registry would crash. + - Keeping the override here avoids adding PRT3-specific branches throughout + the base class. + +Reply routing: + All PRT3 replies (echoes, status, labels) are routed through + _prt3_reply_queue (asyncio.Queue). Callers in panel.py await items from + this queue rather than registering HandlerRegistry entries. + +TODO (Phase 2): Implement connect() — await COMM&ok, instantiate PRT3Panel, + call load_labels() and kick off status polling loop. +TODO (Phase 2): Implement on_connection_message() — parse incoming ASCII line, + put result into _prt3_reply_queue. +TODO (Phase 3): Implement _register_connection_handlers() — register only the + raw handler; skip EventMessageHandler and ErrorMessageHandler (binary only). +""" + +import asyncio +import logging + +from paradox.paradox import Paradox +from paradox.hardware.prt3.panel import PRT3Panel + +logger = logging.getLogger("PAI").getChild(__name__) + + +class PRT3Paradox(Paradox): + """ + Paradox orchestrator subclass for the PRT3 ASCII interface. + + Callers that set CONNECTION_TYPE = 'PRT3' should instantiate this + class rather than the base Paradox class. + + TODO (Phase 2): Replace the NotImplementedError stubs below with + working implementations. + """ + + def __init__(self, retries=3): + super().__init__(retries=retries) + # Queue used to route PRT3 reply lines back to awaiting callers + # in PRT3Panel without going through HandlerRegistry (which would + # crash on the missing .fields.value.po.command attribute). + self._prt3_reply_queue: asyncio.Queue = asyncio.Queue() + + def _register_connection_handlers(self): + """ + Register only the raw message handler for PRT3. + + The base class also registers EventMessageHandler and + ErrorMessageHandler, which access binary Container fields. + PRT3 messages must never reach those handlers. + + TODO (Phase 3): Register PersistentHandler(self.on_connection_message) only. + """ + raise NotImplementedError( + "PRT3Paradox._register_connection_handlers() not yet implemented — see Phase 3" + ) + + async def connect(self) -> bool: + """ + PRT3-specific connection sequence: + + 1. Open the serial port (inherited serial_asyncio logic). + 2. Wait for COMM&ok\\r from the panel (timeout 30 s). + 3. Instantiate PRT3Panel. + 4. Call panel.load_labels() to populate storage. + 5. Set run_state = CONNECTED and return True. + + There is no InitiateCommunication / StartCommunication binary exchange. + + TODO (Phase 2): Implement this method. + """ + raise NotImplementedError( + "PRT3Paradox.connect() not yet implemented — see Phase 2" + ) + + def on_connection_message(self, message: bytes): + """ + Receive a raw \\r-stripped ASCII line from PRT3Protocol. + + Parses the line via parser.parse_line() and routes the result: + - PRT3CommStatus → handle connect/disconnect state + - PRT3CommandEcho → put in _prt3_reply_queue + - PRT3AreaStatus → put in _prt3_reply_queue + - PRT3ZoneStatus → put in _prt3_reply_queue + - PRT3LabelReply → put in _prt3_reply_queue + - PRT3SystemEvent → publish to ps 'events' topic + + TODO (Phase 2): Implement this method. + """ + raise NotImplementedError( + "PRT3Paradox.on_connection_message() not yet implemented — see Phase 2" + ) diff --git a/paradox/paradox.py b/paradox/paradox.py index 58d99d0f..5af82b18 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -104,6 +104,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}") @@ -137,6 +146,16 @@ async def connect(self) -> bool: logger.info("Connecting to Panel") + # PRT3 uses a completely different handshake — binary panel detection is not + # applicable. Full PRT3 connect() is implemented in PRT3Paradox (hardware/prt3/). + if cfg.CONNECTION_TYPE == "PRT3": + logger.error( + "PRT3 runtime connect() not yet implemented; " + "see paradox/hardware/prt3/runtime.py" + ) + self.run_state = RunState.ERROR + return False + if not self.panel: self.panel = create_panel(self) self.connection.variable_message_length(self.panel.variable_message_length) 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..798a7871 --- /dev/null +++ b/tests/connection/prt3/test_protocol.py @@ -0,0 +1,32 @@ +""" +Smoke tests for paradox.connections.prt3. + +These tests verify that the module graph imports cleanly and that the +scaffolded classes are importable and have the expected type hierarchy. +Protocol logic tests will be added in Phase 2. +""" + +from paradox.connections.prt3.protocol import PRT3Protocol +from paradox.connections.prt3.connection import PRT3SerialConnection +from paradox.connections.protocols import ConnectionProtocol +from paradox.connections.serial_connection import SerialCommunication + + +def test_prt3_protocol_is_connection_protocol(): + """PRT3Protocol must inherit from ConnectionProtocol.""" + assert issubclass(PRT3Protocol, ConnectionProtocol) + + +def test_prt3_serial_connection_is_serial_communication(): + """PRT3SerialConnection must inherit from SerialCommunication.""" + assert issubclass(PRT3SerialConnection, SerialCommunication) + + +def test_prt3_serial_connection_make_protocol_returns_prt3_protocol(): + """make_protocol() must return a PRT3Protocol instance.""" + # We cannot open a real serial port in tests; pass a fake port path + # and a dummy handler. make_protocol() is a synchronous factory that + # just calls PRT3Protocol(self), so no I/O occurs. + conn = PRT3SerialConnection.__new__(PRT3SerialConnection) + proto = conn.make_protocol() + assert isinstance(proto, PRT3Protocol) 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/test_encoder.py b/tests/hardware/prt3/test_encoder.py new file mode 100644 index 00000000..d2a0c283 --- /dev/null +++ b/tests/hardware/prt3/test_encoder.py @@ -0,0 +1,47 @@ +""" +Smoke tests for paradox.hardware.prt3.encoder. + +Verifies that the module imports cleanly and that all encoder stubs raise +NotImplementedError as expected. Encoder logic tests will be added in +Phase 2 once the functions are implemented. +""" + +import pytest + +from paradox.hardware.prt3.encoder import ( + encode_arm, + encode_quick_arm, + encode_disarm, + encode_panic_emergency, + encode_panic_medical, + encode_panic_fire, + encode_utility_key, + encode_area_status_request, + encode_zone_status_request, + encode_area_label_request, + encode_zone_label_request, + encode_user_label_request, +) + + +@pytest.mark.parametrize( + "fn,args", + [ + (encode_arm, (1, "A", "1234")), + (encode_quick_arm, (1, "A")), + (encode_disarm, (1, "1234")), + (encode_panic_emergency, (1,)), + (encode_panic_medical, (1,)), + (encode_panic_fire, (1,)), + (encode_utility_key, (1,)), + (encode_area_status_request, (1,)), + (encode_zone_status_request, (1,)), + (encode_area_label_request, (1,)), + (encode_zone_label_request, (1,)), + (encode_user_label_request, (1,)), + ], +) +def test_encoder_raises_not_implemented(fn, args): + """Every encoder stub must raise NotImplementedError until Phase 2.""" + with pytest.raises(NotImplementedError): + fn(*args) diff --git a/tests/hardware/prt3/test_parser.py b/tests/hardware/prt3/test_parser.py new file mode 100644 index 00000000..fb0ba7f7 --- /dev/null +++ b/tests/hardware/prt3/test_parser.py @@ -0,0 +1,35 @@ +""" +Smoke tests for paradox.hardware.prt3.parser. + +Verifies that the module imports cleanly and that all expected dataclasses +and the parse_line() entry point are present. Parser logic tests will be +added in Phase 2 once parse_line() is implemented. +""" + +import pytest + +from paradox.hardware.prt3.parser import ( + PRT3CommStatus, + PRT3CommandEcho, + PRT3AreaStatus, + PRT3ZoneStatus, + PRT3LabelReply, + PRT3SystemEvent, + parse_line, +) + + +def test_dataclasses_importable(): + """All PRT3 message dataclasses must be importable.""" + assert PRT3CommStatus is not None + assert PRT3CommandEcho is not None + assert PRT3AreaStatus is not None + assert PRT3ZoneStatus is not None + assert PRT3LabelReply is not None + assert PRT3SystemEvent is not None + + +def test_parse_line_raises_not_implemented(): + """parse_line() must raise NotImplementedError until Phase 2.""" + with pytest.raises(NotImplementedError): + parse_line("COMM&ok") From a47a2b527a6e596b68123c53818f97233b50e911 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Sun, 29 Mar 2026 20:20:20 +1000 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20PRT3=20protocol=20layer=20?= =?UTF-8?q?=E2=80=94=20parser,=20encoder,=20and=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the complete PRT3 ASCII protocol layer: parser.py: parse_line() converts raw \r-stripped ASCII lines into typed dataclasses (PRT3CommStatus, PRT3BufferFull, PRT3CommandEcho, PRT3AreaStatus, PRT3ZoneStatus, PRT3LabelReply, PRT3SystemEvent, PRT3PgmEvent). Malformed lines log WARNING and return None; no exceptions raised to caller. encoder.py: pure functions for all v1 outbound commands (RA/RZ/AL/ZL/UL status/label requests; AA/AQ/AD arm/quick-arm/disarm; PE/PM/PF panic; UK utility key). Each returns bytes including trailing \r. Input validated; out-of-range args raise ValueError. No undocumented commands. tests/hardware/prt3/fixtures.py: position-verified PRT3 wire-format fixture strings for all message types, plus ALL_FIXTURES collection for replay tests. tests/hardware/prt3/test_encoder.py: 339 tests covering exact output format, boundary values (area 1/8, zone 1/192, user 1/999, key 1/251), all arm modes, code length validation, error cases, echo prefix table, and cross-cutting bytes/ASCII/\r assertions. tests/hardware/prt3/test_parser.py: full fixture-driven parser coverage including all flag combinations, boundary numbers, disambiguation of COMM status vs command echoes, failed info commands, malformed/unknown lines, and all-fixtures smoke test. 1194 tests pass; 0 regressions. --- paradox/hardware/prt3/encoder.py | 301 ++++++++---- paradox/hardware/prt3/parser.py | 368 ++++++++++++--- tests/hardware/prt3/fixtures.py | 198 ++++++++ tests/hardware/prt3/test_encoder.py | 494 ++++++++++++++++++-- tests/hardware/prt3/test_parser.py | 678 +++++++++++++++++++++++++++- 5 files changed, 1835 insertions(+), 204 deletions(-) create mode 100644 tests/hardware/prt3/fixtures.py diff --git a/paradox/hardware/prt3/encoder.py b/paradox/hardware/prt3/encoder.py index 5c568dcd..ef2af459 100644 --- a/paradox/hardware/prt3/encoder.py +++ b/paradox/hardware/prt3/encoder.py @@ -1,130 +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 (already \\r-terminated). - -Command reference (PRT3 ASCII Programming Guide): - - Arm AA{nn}{mode}{code}\\r mode: A=away F=force S=stay I=instant - Quick arm AQ{nn}{mode}\\r requires One-Touch Arming enabled on panel - Disarm AD{nn}{code}\\r - Panic emerg PE{nn}\\r - Panic medic PM{nn}\\r - Panic fire PF{nn}\\r - Utility key UK{nnn}\\r - Area status RA{nnn}\\r - Zone status RZ{nnn}\\r - Area label AL{nnn}\\r - Zone label ZL{nnn}\\r - User label UL{nnn}\\r - -TODO (Phase 2): Implement each 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 / disarm +# Arm-mode constants (pass as the ``mode`` argument to encode_arm / +# encode_quick_arm) # --------------------------------------------------------------------------- -def encode_arm(area: int, mode: str, code: str) -> bytes: - """ - Build an AA (arm) command. +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}" + ) + - :param area: 1-based area number (01-08 for EVO) - :param mode: one of 'A' (away), 'F' (force), 'S' (stay), 'I' (instant) - :param code: numeric user code as a string, e.g. '1234' +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}" + ) - TODO (Phase 2): Implement — return f'AA{area:02d}{mode}{code}\\r'.encode() + +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. """ - raise NotImplementedError("encode_arm() not yet implemented — see Phase 2") + _validate_area(area) + return _cmd(f"RA{area:03d}") -def encode_quick_arm(area: int, mode: str) -> bytes: +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. """ - Build an AQ (quick arm) command. + _validate_zone(zone) + return _cmd(f"RZ{zone:03d}") - Requires 'One-Touch Arming' enabled on the panel; silently ignored otherwise. - TODO (Phase 2): Implement — return f'AQ{area:02d}{mode}\\r'.encode() +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. """ - raise NotImplementedError("encode_quick_arm() not yet implemented — see Phase 2") + _validate_area(area) + return _cmd(f"AL{area:03d}") -def encode_disarm(area: int, code: str) -> bytes: +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. """ - Build an AD (disarm) command. + _validate_zone(zone) + return _cmd(f"ZL{zone:03d}") - TODO (Phase 2): Implement — return f'AD{area:02d}{code}\\r'.encode() + +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. """ - raise NotImplementedError("encode_disarm() not yet implemented — see Phase 2") + _validate_user(user) + return _cmd(f"UL{user:03d}") # --------------------------------------------------------------------------- -# Panic +# Arm / quick arm / disarm # --------------------------------------------------------------------------- -def encode_panic_emergency(area: int) -> bytes: - """PE — emergency panic. TODO (Phase 2).""" - raise NotImplementedError("encode_panic_emergency() not yet implemented — see Phase 2") +def encode_arm(area: int, mode: str, code: str) -> bytes: + """``AA{nnn}{mode}{code}\\r`` — arm an area. -def encode_panic_medical(area: int) -> bytes: - """PM — medical panic. TODO (Phase 2).""" - raise NotImplementedError("encode_panic_medical() not yet implemented — see Phase 2") + :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_panic_fire(area: int) -> bytes: - """PF — fire panic. TODO (Phase 2).""" - raise NotImplementedError("encode_panic_fire() not yet implemented — see Phase 2") +def encode_quick_arm(area: int, mode: str) -> bytes: + """``AQ{nnn}{mode}\\r`` — quick-arm an area (no user code required). -# --------------------------------------------------------------------------- -# Utility key -# --------------------------------------------------------------------------- + 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). -def encode_utility_key(key: int) -> bytes: + :param area: 1-based area number (1-8). + :param mode: one of the ARM_MODE_* constants. + :raises ValueError: if any argument is invalid. """ - UK{nnn} — send utility key. + _validate_area(area) + _validate_arm_mode(mode) + return _cmd(f"AQ{area:03d}{mode}") - TODO (Phase 2): Implement — return f'UK{key:03d}\\r'.encode() + +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. """ - raise NotImplementedError("encode_utility_key() not yet implemented — see Phase 2") + _validate_area(area) + _validate_code(code) + return _cmd(f"AD{area:03d}{code}") # --------------------------------------------------------------------------- -# Status / label requests +# Panic commands # --------------------------------------------------------------------------- -def encode_area_status_request(area: int) -> bytes: - """RA{nnn}\\r — request area status. TODO (Phase 2).""" - raise NotImplementedError( - "encode_area_status_request() not yet implemented — see Phase 2" - ) +def encode_panic_emergency(area: int) -> bytes: + """``PE{nnn}\\r`` — trigger an emergency (police) panic alarm. -def encode_zone_status_request(zone: int) -> bytes: - """RZ{nnn}\\r — request zone status. TODO (Phase 2).""" - raise NotImplementedError( - "encode_zone_status_request() not yet implemented — see Phase 2" - ) + 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_area_label_request(area: int) -> bytes: - """AL{nnn}\\r — request area label. TODO (Phase 2).""" - raise NotImplementedError( - "encode_area_label_request() not yet implemented — see Phase 2" - ) +def encode_panic_medical(area: int) -> bytes: + """``PM{nnn}\\r`` — trigger a medical panic alarm. -def encode_zone_label_request(zone: int) -> bytes: - """ZL{nnn}\\r — request zone label. TODO (Phase 2).""" - raise NotImplementedError( - "encode_zone_label_request() not yet implemented — see Phase 2" - ) + :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_user_label_request(user: int) -> bytes: - """UL{nnn}\\r — request user label. TODO (Phase 2).""" - raise NotImplementedError( - "encode_user_label_request() not yet implemented — see Phase 2" - ) +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/parser.py b/paradox/hardware/prt3/parser.py index 6b2d4caf..2f15e1ee 100644 --- a/paradox/hardware/prt3/parser.py +++ b/paradox/hardware/prt3/parser.py @@ -1,134 +1,354 @@ """ PRT3 ASCII line parser. -parse_line(line: str) -> PRT3Message | None - -All incoming PRT3 messages are plain ASCII strings (\\r already stripped). -This module turns them into typed dataclasses so the rest of the stack -never has to inspect raw strings. - -Message grammar (from PRT3 ASCII Programming Guide): - - COMM&ok — panel ready after power-on / reconnect - COMM&fail — panel not ready - {cmd5}&OK — command echo — success (first 5 chars of sent command) - {cmd5}&fail — command echo — failure - RA{nnn}... — area status reply (16 flag chars follow the index) - RZ{nnn}... — zone status reply (9 flag chars follow the index) - AL{nnn}{label} — area label reply (16-char padded label) - ZL{nnn}{label} — zone label reply - UL{nnn}{label} — user label reply - G{ggg}N{nnn}A{aaa} — async system event - -TODO (Phase 2): Implement each branch of parse_line(). +``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 types +# Message dataclasses # --------------------------------------------------------------------------- + @dataclass class PRT3CommStatus: - """COMM&ok or COMM&fail received on startup.""" + """``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, e.g. 'AA001A&OK' -> cmd='AA001A', ok=True.""" - cmd: str # first 5 chars of the command that was echoed + """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} — parsed flag fields. - - Field order (chars 5-onward of the raw line after 'RA{nnn}'): - D — disarmed - A — armed away - F — armed in force - S — armed stay - I — armed instant - N — in alarm - P — partition in programming (stay arm) - O — fire alarm - T — trouble - O — ready for arming - N — exit delay active - A — entry delay active - Raw example: RA001DAFSINPOTONa (16 flag chars) - - TODO (Phase 2): Map exact flag positions from PRT3 spec table 1. + """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 - raw_flags: str # preserved verbatim until Phase 2 maps each field + 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} — parsed flag fields. - - Flag chars (9 total) after 'RZ{nnn}': - C — closed / O — open - O — no tamper / T — tamper - F — no fire / f — fire alarm - A — no alarm / a — in alarm - F — supervision OK / S — supervision trouble - B — battery OK / b — low battery - L — no low signal / l — low signal - Raw example: RZ001COTFAFSOL - - TODO (Phase 2): Map exact flag positions from PRT3 spec table 2. - """ + """Reply to ``RZ{nnn}`` — zone status flags.""" zone: int - raw_flags: str + 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 AL/ZL/UL{nnn} — 16-char ASCII label.""" - element_type: str # 'area', 'zone', or 'user' + """Reply to ``ZL/AL/UL{nnn}`` — 16-character ASCII label (spaces preserved).""" + element_type: str # "zone", "area", or "user" index: int - label: str # raw 16-char label (spaces not stripped yet) + label: str # exactly 16 chars; trailing spaces not stripped @dataclass class PRT3SystemEvent: - """Async event from the panel: G{ggg}N{nnn}A{aaa}.""" - group: int # event group code - number: int # event-specific number (zone, user, …) - area: int # area involved + """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 = activated (PGMxxON), False = deactivated (PGMxxOFF) -# Union type for callers +# 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"} + # --------------------------------------------------------------------------- -# Parser entry point +# Public API # --------------------------------------------------------------------------- + def parse_line(line: str) -> Optional[PRT3Message]: - """ - Parse a single \\r-stripped ASCII line from the PRT3 module. + """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. - Returns a typed PRT3Message dataclass, or None if the line is not - recognised (e.g. an empty line or future extension). + Malformed lines with a recognised prefix (e.g. ``RA`` with wrong length) + emit a ``WARNING`` log and return ``None`` rather than raising. - TODO (Phase 2): Implement each branch. + 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. """ - raise NotImplementedError( - "PRT3 parse_line() not yet implemented — see Phase 2" + 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. Area status reply: RA{nnn}{7-char flags} = 12 chars + if ( + line.startswith("RA") + and len(line) > 4 + and line[2:5].isdigit() + ): + if len(line) == _AREA_STATUS_LEN: + return _parse_area_status(line) + # Shorter (e.g. RA001&fail) falls through to the echo check below + + # 6. Zone status reply: RZ{nnn}{5-char flags} = 10 chars + if ( + line.startswith("RZ") + and len(line) > 4 + and line[2:5].isdigit() + ): + if len(line) == _ZONE_STATUS_LEN: + return _parse_zone_status(line) + + # 7. Label replies: ZL/AL/UL{nnn}{16-char label} = 21 chars + prefix2 = line[:2] + if ( + prefix2 in _LABEL_PREFIXES + and len(line) > 4 + and line[2:5].isdigit() + ): + if len(line) == _LABEL_LEN: + return _parse_label(line) + + # 8. Command echoes: {5 chars}&OK (8) or {5 chars}&fail (10) + if len(line) == _ECHO_OK_LEN and line.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_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/tests/hardware/prt3/fixtures.py b/tests/hardware/prt3/fixtures.py new file mode 100644 index 00000000..366d8d65 --- /dev/null +++ b/tests/hardware/prt3/fixtures.py @@ -0,0 +1,198 @@ +""" +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" + +# 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_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_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_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_encoder.py b/tests/hardware/prt3/test_encoder.py index d2a0c283..a9a3524e 100644 --- a/tests/hardware/prt3/test_encoder.py +++ b/tests/hardware/prt3/test_encoder.py @@ -1,47 +1,479 @@ """ -Smoke tests for paradox.hardware.prt3.encoder. +Unit tests for PRT3 ASCII command encoder. -Verifies that the module imports cleanly and that all encoder stubs raise -NotImplementedError as expected. Encoder logic tests will be added in -Phase 2 once the functions are implemented. +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_quick_arm, encode_disarm, encode_panic_emergency, - encode_panic_medical, encode_panic_fire, + encode_panic_medical, + encode_quick_arm, encode_utility_key, - encode_area_status_request, - encode_zone_status_request, - encode_area_label_request, - encode_zone_label_request, encode_user_label_request, + encode_zone_label_request, + encode_zone_status_request, ) -@pytest.mark.parametrize( - "fn,args", - [ - (encode_arm, (1, "A", "1234")), - (encode_quick_arm, (1, "A")), - (encode_disarm, (1, "1234")), - (encode_panic_emergency, (1,)), - (encode_panic_medical, (1,)), - (encode_panic_fire, (1,)), - (encode_utility_key, (1,)), - (encode_area_status_request, (1,)), - (encode_zone_status_request, (1,)), - (encode_area_label_request, (1,)), - (encode_zone_label_request, (1,)), - (encode_user_label_request, (1,)), - ], -) -def test_encoder_raises_not_implemented(fn, args): - """Every encoder stub must raise NotImplementedError until Phase 2.""" - with pytest.raises(NotImplementedError): - fn(*args) +# --------------------------------------------------------------------------- +# 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] + + +# --------------------------------------------------------------------------- +# 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_parser.py b/tests/hardware/prt3/test_parser.py index fb0ba7f7..8f54d2c8 100644 --- a/tests/hardware/prt3/test_parser.py +++ b/tests/hardware/prt3/test_parser.py @@ -1,35 +1,675 @@ """ -Smoke tests for paradox.hardware.prt3.parser. +Unit tests for paradox.hardware.prt3.parser. -Verifies that the module imports cleanly and that all expected dataclasses -and the parse_line() entry point are present. Parser logic tests will be -added in Phase 2 once parse_line() is implemented. +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 ( - PRT3CommStatus, - PRT3CommandEcho, + ARM_AWAY, + ARM_DISARMED, + ARM_FORCE, + ARM_INSTANT, + ARM_STAY, PRT3AreaStatus, - PRT3ZoneStatus, + 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 + + +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, +]) +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_dataclasses_importable(): - """All PRT3 message dataclasses must be importable.""" - assert PRT3CommStatus is not None - assert PRT3CommandEcho is not None - assert PRT3AreaStatus is not None - assert PRT3ZoneStatus is not None - assert PRT3LabelReply is not None - assert PRT3SystemEvent is not None +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_parse_line_raises_not_implemented(): - """parse_line() must raise NotImplementedError until Phase 2.""" - with pytest.raises(NotImplementedError): - parse_line("COMM&ok") +def test_zone_open_state_constants_are_distinct(): + states = [ZONE_CLOSED, ZONE_OPEN, ZONE_TAMPERED, ZONE_FIRE_LOOP_TROUBLE] + assert len(set(states)) == 4 From 3ec1cb490438f9126d2cd14cd1747b954822b909 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Sun, 29 Mar 2026 20:51:10 +1000 Subject: [PATCH 05/21] feat: PRT3 adapter and state normalization layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adapter.py: pure normalization boundary — arm_state enum to PAI boolean partition flags, zone open_state to PAI zone flags, label reply to PAI labels dict, build_flat_status for convert_raw_status compatibility. panel.py: Phase 2 — parse_message delegates to parse_line, initialize_ communication awaits COMM&ok, load_labels polls AL/ZL/UL sequentially, request_status polls RA/RZ and returns flat status dict (single virtual address 0 covers all configured areas+zones), control_partitions maps arm/disarm commands to AA/AQ/AD (with PRT3_USER_CODE fallback to quick- arm), send_panic maps to PE/PM/PF, _prt3_send_wait helper for typed request/reply via wait_for_message. event.py: EVENT_MAP for G-groups 0-66 (zone, arm/disarm, bypass, alarm, panic, trouble, power-up, utility key, status broadcasts). PRT3Event. from_prt3 constructs PAI Event without LiveEvent binary assertions. protocol.py: PRT3Protocol framer complete. data_received buffers bytes and emits complete cr-terminated lines. send_message writes raw bytes. Compat fixes: EventMessageHandler/ErrorMessageHandler can_handle guards against non-Container types. HandlerRegistry no-handler log guarded. config.py: PRT3_USER_CODE and PRT3_COMM_TIMEOUT config keys. 1260 tests pass. --- paradox/config.py | 2 + paradox/connections/prt3/protocol.py | 56 ++- paradox/hardware/prt3/adapter.py | 230 ++++++++++++ paradox/hardware/prt3/event.py | 286 +++++++++++++-- paradox/hardware/prt3/panel.py | 423 +++++++++++++++++---- paradox/lib/async_message_manager.py | 12 +- paradox/lib/handlers.py | 10 +- tests/hardware/prt3/test_adapter.py | 526 +++++++++++++++++++++++++++ 8 files changed, 1412 insertions(+), 133 deletions(-) create mode 100644 paradox/hardware/prt3/adapter.py create mode 100644 tests/hardware/prt3/test_adapter.py diff --git a/paradox/config.py b/paradox/config.py index 3a422739..96bcdc5d 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -35,6 +35,8 @@ class Config: "PRT3_MAX_AREAS": (8, int, (1, 8)), # Number of areas on the panel "PRT3_MAX_ZONES": (96, int, (1, 192)), # Number of zones on the panel "PRT3_MAX_USERS": (999, int, (1, 999)), # Number of user codes on the panel + "PRT3_USER_CODE": "", # User code for arm/disarm commands (1-6 digits); empty = quick-arm only + "PRT3_COMM_TIMEOUT": (10, int, (1, 60)), # Seconds to wait for COMM&ok on connect # IP Connection Details "IP_CONNECTION_HOST": "127.0.0.1", # IP Module address when using direct IP Connection "IP_CONNECTION_PORT": ( diff --git a/paradox/connections/prt3/protocol.py b/paradox/connections/prt3/protocol.py index b14db702..a26dd40f 100644 --- a/paradox/connections/prt3/protocol.py +++ b/paradox/connections/prt3/protocol.py @@ -9,13 +9,12 @@ This class buffers incoming bytes and emits complete \\r-delimited lines to the owning connection handler via on_message(). - -TODO (Phase 2): Implement data_received() line framer. -TODO (Phase 2): Implement send_message() raw write. """ +import binascii import logging +from paradox.config import config as cfg from paradox.connections.protocols import ConnectionProtocol logger = logging.getLogger("PAI").getChild(__name__) @@ -25,26 +24,47 @@ class PRT3Protocol(ConnectionProtocol): """ Framing protocol for the PRT3 ASCII serial interface. - Buffers incoming bytes and emits complete ASCII lines (\\r-terminated) - as ``bytes`` objects to the connection handler. + 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 have no fixed length — framing is delimiter-based. - # This method is a deliberate no-op so the Panel base class can call - # it without error; actual line assembly happens in data_received(). + # 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): - # TODO (Phase 2): Buffer incoming bytes; emit complete \r-delimited - # lines via self.handler.on_message(line). - raise NotImplementedError( - "PRT3Protocol.data_received() not yet implemented — see Phase 2" - ) + """ + 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 + + 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): - # TODO (Phase 2): Write raw bytes directly to self.transport. - # PRT3 commands are plain ASCII, already \\r-terminated by encoder.py. - raise NotImplementedError( - "PRT3Protocol.send_message() not yet implemented — see Phase 2" - ) + """ + 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/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/event.py b/paradox/hardware/prt3/event.py index 96e0fa4b..4679e309 100644 --- a/paradox/hardware/prt3/event.py +++ b/paradox/hardware/prt3/event.py @@ -1,32 +1,220 @@ """ -PRT3 event map and PRT3Event type. +PRT3 event map and PRT3Event. EVENT_MAP maps G-group codes (int) to PAI event descriptor dicts. -It will be populated in Phase 3 by consulting the PRT3 ASCII Programming -Guide section "System Event Group Codes". +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. -PRT3Event subclasses Event (not LiveEvent) because LiveEvent asserts -``raw.fields.value.po.command == 0xE``, which is meaningless for ASCII events. -PRT3 events are constructed directly from parsed PRT3SystemEvent dataclasses. +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. -TODO (Phase 3): Populate EVENT_MAP with all G-group codes. -TODO (Phase 3): Implement PRT3Event.from_prt3(event: PRT3SystemEvent, ...). +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). """ -from paradox.event import Event +import logging + +from paradox.event import Event, EventLevel + +logger = logging.getLogger("PAI").getChild(__name__) + # --------------------------------------------------------------------------- -# Event map (empty until Phase 3) +# Event group code table +# PRT3 ASCII Programming Guide §System Event Group Codes # --------------------------------------------------------------------------- -# Maps G-group code (int) -> dict with keys: -# 'type' : str PAI event type tag, e.g. 'zone', 'partition', 'system' -# 'subtype' : str PAI event subtype / message template -# 'level' : EventLevel -# -# Example entry (to be added in Phase 3): -# 1: {'type': 'zone', 'subtype': 'alarm', 'level': EventLevel.CRITICAL}, -EVENT_MAP: dict = {} # TODO (Phase 3): populate from PRT3 spec +EVENT_MAP: dict = { + # Zone status events (number = zone ID, area = affected area) + 0: dict(type="zone", subtype="restored", level=EventLevel.INFO, + change={"alarm": False, "open": False}, + tags=["zone", "restore"], + message="Zone {label} restored"), + 1: dict(type="zone", subtype="open", level=EventLevel.DEBUG, + change={"open": True}, + tags=["zone", "open"], + message="Zone {label} open"), + 2: dict(type="zone", subtype="tampered", level=EventLevel.CRITICAL, + change={"tamper": True, "open": True}, + tags=["zone", "tamper", "trouble"], + message="Zone {label} tampered"), + 3: dict(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 (number = user ID, area = affected partition) + 10: dict(type="partition", subtype="arm", level=EventLevel.INFO, + change={"arm": True}, + tags=["arm", "user"], + message="Partition {label} armed by user"), + 11: dict(type="partition", subtype="arm", level=EventLevel.INFO, + change={"arm": True}, + tags=["arm", "master"], + message="Partition {label} armed by master"), + 12: dict(type="partition", subtype="arm", level=EventLevel.INFO, + change={"arm": True}, + tags=["arm", "keyswitch"], + message="Partition {label} armed via keyswitch"), + 13: dict(type="partition", subtype="arm", level=EventLevel.INFO, + change={"arm": True}, + tags=["arm", "auto"], + message="Partition {label} auto-armed"), + + # Disarm events (number = user ID, area = affected partition) + 14: dict(type="partition", subtype="disarm", level=EventLevel.INFO, + change={"arm": False}, + tags=["disarm", "user"], + message="Partition {label} disarmed by user"), + 15: dict(type="partition", subtype="disarm", level=EventLevel.INFO, + change={"arm": False}, + tags=["disarm", "master"], + message="Partition {label} disarmed by master"), + 16: dict(type="partition", subtype="disarm", level=EventLevel.INFO, + change={"arm": False}, + tags=["disarm", "keyswitch"], + message="Partition {label} disarmed via keyswitch"), + 17: dict(type="partition", subtype="disarm", level=EventLevel.INFO, + change={"arm": False, "audible_alarm": False}, + tags=["disarm", "alarm_cancel"], + message="Partition {label} disarmed after alarm"), + 18: dict(type="partition", subtype="alarm_cancelled", level=EventLevel.INFO, + change={"audible_alarm": False}, + tags=["alarm", "cancel"], + message="Partition {label} alarm cancelled"), + 20: dict(type="partition", subtype="disarm", level=EventLevel.INFO, + change={"arm": False}, + tags=["disarm", "special"], + message="Partition {label} special disarm"), + + # Zone bypass events (number = zone ID) + 21: dict(type="zone", subtype="bypassed", level=EventLevel.INFO, + change={"bypassed": True}, + tags=["zone", "bypass"], + message="Zone {label} bypassed"), + 23: dict(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: dict(type="zone", subtype="alarm", level=EventLevel.CRITICAL, + change={"alarm": True}, + tags=["zone", "alarm"], + message="Zone {label} in alarm"), + 25: dict(type="zone", subtype="fire_alarm", level=EventLevel.CRITICAL, + change={"fire": True}, + tags=["zone", "alarm", "fire"], + message="Zone {label} fire alarm"), + 26: dict(type="zone", subtype="alarm_restored", level=EventLevel.INFO, + change={"alarm": False}, + tags=["zone", "alarm", "restore"], + message="Zone {label} alarm restored"), + 27: dict(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: dict(type="partition", subtype="panic_alarm", level=EventLevel.CRITICAL, + change={"panic_alarm": True}, + tags=["alarm", "panic"], + message="Partition {label} panic alarm"), + 30: dict(type="partition", subtype="alarm", level=EventLevel.CRITICAL, + change={"audible_alarm": True}, + tags=["alarm"], + message="Partition {label} alarm shutdown"), + 31: dict(type="zone", subtype="tamper_alarm", level=EventLevel.CRITICAL, + change={"alarm": True, "tamper": True}, + tags=["zone", "alarm", "tamper"], + message="Zone {label} tamper alarm"), + 32: dict(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: dict(type="system", subtype="trouble", level=EventLevel.CRITICAL, + change={}, + tags=["trouble"], + message="New trouble event"), + 34: dict(type="system", subtype="trouble_restored", level=EventLevel.INFO, + change={}, + tags=["trouble", "restore"], + message="Trouble restored"), + 36: dict(type="system", subtype="ac_failure", level=EventLevel.CRITICAL, + change={"ac_failure_trouble": True}, + tags=["trouble", "power"], + message="AC power failure"), + 38: dict(type="system", subtype="battery_trouble", level=EventLevel.CRITICAL, + change={"battery_failure_trouble": True}, + tags=["trouble", "battery"], + message="Battery trouble"), + + # Power / communication + 45: dict(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: dict(type="system", subtype="utility_key", level=EventLevel.INFO, + change={}, + tags=["system", "utility"], + message="Utility key {number} activated"), + + # Zone lifecycle + 56: dict(type="zone", subtype="bypassed", level=EventLevel.INFO, + change={"bypassed": True}, + tags=["zone", "bypass"], + message="Zone {label} bypassed"), + 59: dict(type="zone", subtype="closed", level=EventLevel.DEBUG, + change={"open": False}, + tags=["zone"], + message="Zone {label} closed"), + 60: dict(type="zone", subtype="low_battery", level=EventLevel.CRITICAL, + change={"low_battery_trouble": True}, + tags=["zone", "trouble", "battery"], + message="Zone {label} low battery"), + 61: dict(type="zone", subtype="supervision_trouble", level=EventLevel.CRITICAL, + change={"supervision_trouble": True}, + tags=["zone", "trouble", "supervision"], + message="Zone {label} supervision failure"), + 62: dict(type="zone", subtype="low_battery_restored", level=EventLevel.INFO, + change={"low_battery_trouble": False}, + tags=["zone", "battery", "restore"], + message="Zone {label} battery restored"), + 63: dict(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 = 0 (not used) + 64: dict(type="partition", subtype="status_armed", level=EventLevel.INFO, + change={"arm": True}, + tags=["arm", "status"], + message="Partition {label} armed (status event)"), + 65: dict(type="partition", subtype="status_armed", level=EventLevel.INFO, + change={"arm": True}, + tags=["arm", "status"], + message="Partition {label} armed steady state"), + 66: dict(type="system", subtype="status_tamper", level=EventLevel.CRITICAL, + change={}, + tags=["trouble", "tamper", "status"], + message="Status: tamper or trouble detected"), +} # --------------------------------------------------------------------------- @@ -37,11 +225,9 @@ class PRT3Event(Event): """ PAI Event subclass for PRT3 system events. - Does NOT inherit 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. - - TODO (Phase 3): Implement from_prt3() factory method. + 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 @@ -49,11 +235,51 @@ 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: callable(type, value) -> label string - - TODO (Phase 3): Look up group in EVENT_MAP, populate fields. + :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. """ - raise NotImplementedError( - "PRT3Event.from_prt3() not yet implemented — see Phase 3" - ) + descriptor = EVENT_MAP.get(prt3_event.group) + 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 = dict( + type="system", + subtype="unknown", + level=EventLevel.DEBUG, + change={}, + tags=["unknown"], + message=f"Unknown event G{prt3_event.group:03d}", + ) + + element_type = descriptor["type"] + element_id = prt3_event.number # zone/user/key ID + area = prt3_event.area # 0=global, 1-8=specific, 255=any + + # 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.level = descriptor["level"] + event.type = element_type + 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 index 06cd0c3f..0f73fffd 100644 --- a/paradox/hardware/prt3/panel.py +++ b/paradox/hardware/prt3/panel.py @@ -1,36 +1,80 @@ """ PRT3Panel — Panel adapter for the Paradox PRT3 Printer Module. -Subclasses Panel (paradox.hardware.panel.Panel) and implements all -panel-facing methods using the PRT3 ASCII protocol instead of binary -EEPROM reads. - -The Panel base class is not abstract in the strict sense — it provides -fallback implementations for most methods. PRT3Panel overrides the ones -that would otherwise silently do nothing or crash on binary assumptions. - -Design constraints: - - Zone bypass has no PRT3 command; control_zones() raises NotImplementedError. - - PGM / output control has no PRT3 command; control_outputs() raises - NotImplementedError. - - EEPROM reads are not available via PRT3; load_definitions() returns {}. - -TODO (Phase 2): Implement initialize_communication() — await COMM&ok. -TODO (Phase 2): Implement load_labels() — send AL/ZL/UL requests, collect replies. -TODO (Phase 2): Implement request_status() — send RA/RZ requests, return flat dict. -TODO (Phase 3): Implement get_status_requests() — return list of coroutines. -TODO (Phase 3): Implement parse_message() for PRT3 lines (delegate to parser.py). -TODO (Phase 3): Implement send_panic() using PE/PM/PF commands. -TODO (Phase 3): Implement control_partitions() using AA/AQ/AD commands. +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, + 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", +} + +# Panic type → encoder function +_PANIC_ENCODERS = { + "emergency": encoder.encode_panic_emergency, + "medical": encoder.encode_panic_medical, + "fire": encoder.encode_panic_fire, +} + class PRT3Panel(Panel): """ @@ -38,33 +82,109 @@ class PRT3Panel(Panel): 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. + from the base Panel class are replaced with ASCII equivalents. """ - property_map = property_map # re-exported from spectra_magellan + 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, so we don't need the base class to manage lengths. + # is a no-op; the Panel base class must not try to manage lengths. super().__init__(core, variable_message_length=False) + # ------------------------------------------------------------------ + # 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, + timeout: Optional[float] = None, + ): + """ + Write a PRT3 command and await a matching reply. + + :param command_bytes: Encoded command bytes (\\r-terminated). + :param predicate: callable(PRT3Message) → bool. The first + message for which this returns True is returned. + :param timeout: Override the default IO_TIMEOUT. + :returns: Matching PRT3Message, or None on timeout. + """ + self.core.connection.write(command_bytes) + try: + return await self.core.connection.wait_for_message( + predicate, + timeout=timeout if timeout is not None else cfg.IO_TIMEOUT, + ) + except asyncio.TimeoutError: + return None + # ------------------------------------------------------------------ # Startup handshake # ------------------------------------------------------------------ async def initialize_communication(self, password) -> bool: """ - Wait for COMM&ok from the panel, then optionally verify the connection. + Wait for COMM&ok from the panel. - The PRT3 module sends 'COMM&ok\\r' shortly after power-on or reconnect. - There is no password exchange in the PRT3 protocol; the ``password`` - argument is accepted for interface compatibility but is not used. + The PRT3 module emits 'COMM&ok\\r' shortly after power-on or + reconnect. There is no password exchange; the ``password`` argument + is accepted for interface compatibility but ignored. - TODO (Phase 2): Await COMM&ok via the reply queue with a timeout. + Returns True if COMM&ok is received before the timeout. + Returns False if COMM&fail arrives first, or on timeout. """ - raise NotImplementedError( - "PRT3Panel.initialize_communication() not yet implemented — see Phase 2" - ) + logger.info("PRT3: awaiting COMM&ok from panel") + try: + msg = await self.core.connection.wait_for_message( + lambda m: isinstance(m, PRT3CommStatus), + timeout=cfg.PRT3_COMM_TIMEOUT, + ) + if msg.ok: + logger.info("PRT3: panel ready (COMM&ok)") + return True + logger.error("PRT3: panel communication failure (COMM&fail)") + return False + except asyncio.TimeoutError: + logger.error( + "PRT3: timeout waiting for COMM&ok (%.0fs)", 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 @@ -74,40 +194,140 @@ async def load_labels(self) -> dict: """ Request area, zone, and user labels via AL/ZL/UL ASCII commands. - Sends AL{nnn}, ZL{nnn}, UL{nnn} for each index in range, collects - replies, and returns a dict compatible with Paradox._on_labels_load(). + 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. - TODO (Phase 2): Implement label request/reply loop. + Returns a labels dict compatible with Paradox._on_labels_load(). """ - raise NotImplementedError( - "PRT3Panel.load_labels() not yet implemented — see Phase 2" + logger.info("PRT3: loading labels") + replies = [] + + # Area labels — AL001..AL{max} + for area in range(1, cfg.PRT3_MAX_AREAS + 1): + cmd = encoder.encode_area_label_request(area) + expected_cmd = f"AL{area:03d}" + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_cmd, a=area: ( + (isinstance(m, PRT3LabelReply) and m.element_type == "area" and m.index == a) + 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: area %d label not found (panel returned &fail)", area) + elif msg is None: + logger.warning("PRT3: timeout loading area %d label", area) + + # Zone labels — ZL001..ZL{max} + for zone in range(1, cfg.PRT3_MAX_ZONES + 1): + cmd = encoder.encode_zone_label_request(zone) + expected_cmd = f"ZL{zone:03d}" + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_cmd, z=zone: ( + (isinstance(m, PRT3LabelReply) and m.element_type == "zone" and m.index == z) + 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: zone %d label not found", zone) + elif msg is None: + logger.warning("PRT3: timeout loading zone %d label", zone) + + # User labels — UL001..UL{max} + for user in range(1, cfg.PRT3_MAX_USERS + 1): + cmd = encoder.encode_user_label_request(user) + expected_cmd = f"UL{user:03d}" + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_cmd, u=user: ( + (isinstance(m, PRT3LabelReply) and m.element_type == "user" and m.index == u) + 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: user %d label not found", user) + elif msg is None: + logger.warning("PRT3: timeout loading user %d label", user) + + 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", {})), ) - - async def load_definitions(self) -> dict: - # PRT3 provides no EEPROM access; definitions are not available. - return {} + return labels # ------------------------------------------------------------------ # Status polling # ------------------------------------------------------------------ - async def request_status(self, nr: int): + async def request_status(self, nr: int) -> dict: """ - Poll area and zone status via RA/RZ ASCII commands. + 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:: - Returns a flat dict in the format expected by convert_raw_status(): { - 'zone_open': {1: bool, 2: bool, ...}, - 'zone_alarm': {1: bool, ...}, - 'partition_arm': {1: bool, ...}, - ... + "partition_arm": {1: True, 2: False}, + "partition_ready_status": {1: True, 2: True}, + ... + "zone_open": {1: False, 2: True}, + ... } - TODO (Phase 2): Send RA/RZ requests, parse replies, return flat dict. + 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. """ - raise NotImplementedError( - "PRT3Panel.request_status() not yet implemented — see Phase 2" - ) + area_msgs = [] + zone_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): + area_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) + + 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): + zone_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 adapter.build_flat_status(area_msgs, zone_msgs) # ------------------------------------------------------------------ # Control — partitions @@ -115,14 +335,57 @@ async def request_status(self, nr: int): async def control_partitions(self, partitions: list, command: str) -> bool: """ - Arm or disarm partitions using AA/AQ/AD ASCII commands. + 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. - TODO (Phase 3): Map PAI command strings ('arm', 'arm_stay', 'disarm', …) - to PRT3 encoder calls. + Disarm: always requires PRT3_USER_CODE; returns False if not set. """ - raise NotImplementedError( - "PRT3Panel.control_partitions() not yet implemented — see Phase 3" - ) + user_code = cfg.PRT3_USER_CODE + accepted = False + + for partition in partitions: + if command == "disarm": + if not user_code: + logger.error( + "PRT3: disarm requires PRT3_USER_CODE to be configured" + ) + continue + cmd = encoder.encode_disarm(partition, user_code) + expected_echo = f"AD{partition:03d}" + + elif command in _QUICK_ARM_MODES: + mode = _QUICK_ARM_MODES[command] + if user_code: + cmd = encoder.encode_arm(partition, mode, user_code) + expected_echo = f"AA{partition:03d}" + else: + cmd = encoder.encode_quick_arm(partition, mode) + expected_echo = f"AQ{partition:03d}" + + else: + logger.error("PRT3: unknown partition command %r", command) + continue + + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_echo: ( + isinstance(m, PRT3CommandEcho) and m.cmd == ec + ), + ) + 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) @@ -150,23 +413,31 @@ async def send_panic(self, partition: int, panic_type: str, _code) -> bool: """ Send a PE/PM/PF panic command. - TODO (Phase 3): Map panic_type ('emergency', 'medical', 'fire') to - encoder calls. + :param partition: 1-based area number (1-8). + :param panic_type: 'emergency', 'medical', or 'fire'. + :param _code: Not used by PRT3 (panic commands carry no code). """ - raise NotImplementedError( - "PRT3Panel.send_panic() not yet implemented — see Phase 3" - ) - - # ------------------------------------------------------------------ - # Message parsing (delegated to parser.py) - # ------------------------------------------------------------------ - - def parse_message(self, message, direction="topanel"): - """ - Parse a raw PRT3 ASCII line (bytes) into a typed PRT3Message. - - TODO (Phase 2): Decode bytes, strip \\r, delegate to parser.parse_line(). - """ - raise NotImplementedError( - "PRT3Panel.parse_message() not yet implemented — see Phase 2" + encode_fn = _PANIC_ENCODERS.get(panic_type) + if encode_fn is None: + logger.error("PRT3: unknown panic type %r", panic_type) + return False + + 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 + ), ) + if msg is None: + logger.warning("PRT3: timeout on %s panic area %d", panic_type, partition) + return False + if not msg.ok: + logger.warning( + "PRT3: %s panic area %d rejected (&fail)", panic_type, partition + ) + return False + logger.info("PRT3: %s panic area %d accepted", panic_type, partition) + return True diff --git a/paradox/lib/async_message_manager.py b/paradox/lib/async_message_manager.py index 8b7c9b30..725fe000 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 (not hasattr(values, "requested_event_nr")) 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/tests/hardware/prt3/test_adapter.py b/tests/hardware/prt3/test_adapter.py new file mode 100644 index 00000000..44a48a00 --- /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, idx, entry = label_entry_from_reply( + _label(element_type="zone", index=1, label="Front Door ") + ) + assert container == "zone" + + def test_area_maps_to_partition(self): + container, idx, entry = label_entry_from_reply( + _label(element_type="area", index=1, label="Home ") + ) + assert container == "partition" + + def test_user_stays_user(self): + container, idx, entry = 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): + container, idx, entry = 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()) From 4d1d8419d365ca63a650b279dcb96ae4907ef85c Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 09:33:30 +1000 Subject: [PATCH 06/21] feat: complete PRT3 runtime integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - paradox.py: implement PRT3 connect() path — ASCII COMM&ok handshake, synthesised DetectedPanel for stable HA entity anchoring, event handler registration, control_utility_key() dispatcher - paradox.py: guard sync_time() and _clean_session() — PRT3 has no SetTimeDate or CloseConnection frame - hardware/prt3/panel.py: full panel implementation — initialize_communication, load_labels (AL/ZL/UL), request_status (RA/RZ), control_partitions (AA/AQ/AD), send_utility_key (UK), send_panic (PE/PM/PF); retries=1 on utility key (commands are not idempotent) - hardware/prt3/event.py: complete event map for G000-G066; G065 per-N dispatch (N001=exit_delay, N002=entry_delay) so HA shows "arming" state during countdown without flickering; arm/disarm events clear exit_delay - hardware/prt3/parser.py: parser fixes and PGM line support - config.py: add PRT3_UTILITY_KEYS and MQTT_UTILITY_KEY_TOPIC - config/pai.conf.example: PRT3 section with annotated options Co-Authored-By: Claude Sonnet 4.6 --- config/pai.conf.example | 20 ++++- paradox/config.py | 2 + paradox/hardware/prt3/event.py | 75 ++++++++++++----- paradox/hardware/prt3/panel.py | 137 ++++++++++++++++++++++++++------ paradox/hardware/prt3/parser.py | 3 +- paradox/paradox.py | 87 ++++++++++++++++++-- 6 files changed, 272 insertions(+), 52 deletions(-) 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/paradox/config.py b/paradox/config.py index 96bcdc5d..6bb09886 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -37,6 +37,7 @@ class Config: "PRT3_MAX_USERS": (999, int, (1, 999)), # Number of user codes on the panel "PRT3_USER_CODE": "", # User code for arm/disarm commands (1-6 digits); empty = quick-arm only "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": ( @@ -149,6 +150,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 diff --git a/paradox/hardware/prt3/event.py b/paradox/hardware/prt3/event.py index 4679e309..048dc7a1 100644 --- a/paradox/hardware/prt3/event.py +++ b/paradox/hardware/prt3/event.py @@ -24,6 +24,7 @@ """ import logging +import time from paradox.event import Event, EventLevel @@ -55,38 +56,40 @@ message="Zone {label} fire loop trouble"), # Arm events (number = user ID, area = affected partition) + # exit_delay cleared because the panel is now fully armed (delay is over) 10: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True}, + change={"arm": True, "exit_delay": False}, tags=["arm", "user"], message="Partition {label} armed by user"), 11: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True}, + change={"arm": True, "exit_delay": False}, tags=["arm", "master"], message="Partition {label} armed by master"), 12: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True}, + change={"arm": True, "exit_delay": False}, tags=["arm", "keyswitch"], message="Partition {label} armed via keyswitch"), 13: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True}, + change={"arm": True, "exit_delay": False}, tags=["arm", "auto"], message="Partition {label} auto-armed"), # Disarm events (number = user ID, area = affected partition) + # exit_delay cleared because arming was cancelled 14: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False}, + change={"arm": False, "exit_delay": False}, tags=["disarm", "user"], message="Partition {label} disarmed by user"), 15: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False}, + change={"arm": False, "exit_delay": False}, tags=["disarm", "master"], message="Partition {label} disarmed by master"), 16: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False}, + change={"arm": False, "exit_delay": False}, tags=["disarm", "keyswitch"], message="Partition {label} disarmed via keyswitch"), 17: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False, "audible_alarm": False}, + change={"arm": False, "exit_delay": False, "audible_alarm": False}, tags=["disarm", "alarm_cancel"], message="Partition {label} disarmed after alarm"), 18: dict(type="partition", subtype="alarm_cancelled", level=EventLevel.INFO, @@ -94,7 +97,7 @@ tags=["alarm", "cancel"], message="Partition {label} alarm cancelled"), 20: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False}, + change={"arm": False, "exit_delay": False}, tags=["disarm", "special"], message="Partition {label} special disarm"), @@ -201,15 +204,18 @@ message="Zone {label} supervision restored"), # Status events — periodic armed/trouble state broadcasts - # area = affected partition; number = 0 (not used) - 64: dict(type="partition", subtype="status_armed", level=EventLevel.INFO, - change={"arm": True}, - tags=["arm", "status"], - message="Partition {label} armed (status event)"), - 65: dict(type="partition", subtype="status_armed", level=EventLevel.INFO, - change={"arm": True}, - tags=["arm", "status"], - message="Partition {label} armed steady state"), + # 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: dict(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 applied in from_prt3() below; this is the fallback. + 65: dict(type="partition", subtype="status_update", level=EventLevel.DEBUG, + change={}, + tags=["status"], + message="Partition {label} Status-2 event (N{number})"), 66: dict(type="system", subtype="status_tamper", level=EventLevel.CRITICAL, change={}, tags=["trouble", "tamper", "status"], @@ -243,6 +249,28 @@ def from_prt3(cls, prt3_event, label_provider=None) -> "PRT3Event": tags, message, and additional_data populated. """ descriptor = EVENT_MAP.get(prt3_event.group) + + # G065 Status-2 per-N overrides + # The base descriptor is a no-op; specific N values carry state changes. + if prt3_event.group == 65: + n = prt3_event.number + if n == 1: # exit delay started → show HA "arming" state + descriptor = dict( + type="partition", subtype="exit_delay", + level=EventLevel.INFO, + change={"exit_delay": True}, + tags=["status", "exit_delay"], + message="Partition {label} exit delay started", + ) + elif n == 2: # entry delay started + descriptor = dict( + type="partition", subtype="entry_delay", + level=EventLevel.INFO, + change={"entry_delay": True}, + tags=["status", "entry_delay"], + message="Partition {label} entry delay started", + ) + if descriptor is None: logger.debug( "PRT3: unknown event group G%03d N%03d A%03d", @@ -258,9 +286,16 @@ def from_prt3(cls, prt3_event, label_provider=None) -> "PRT3Event": ) element_type = descriptor["type"] - element_id = prt3_event.number # zone/user/key ID 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) @@ -268,8 +303,10 @@ def from_prt3(cls, prt3_event, label_provider=None) -> "PRT3Event": 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 diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py index 0f73fffd..6ebf7cfb 100644 --- a/paradox/hardware/prt3/panel.py +++ b/paradox/hardware/prt3/panel.py @@ -127,23 +127,39 @@ async def _prt3_send_wait( command_bytes: bytes, predicate, timeout: Optional[float] = None, + 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 timeout: Override the default IO_TIMEOUT. - :returns: Matching PRT3Message, or None on timeout. + :param retries: Total attempts (1 = no retry, 2 = one retry…). + Retries are only performed on timeout; a received + reply (ok or &fail) is returned immediately. + :returns: Matching PRT3Message, or None if all attempts + timed out. """ - self.core.connection.write(command_bytes) - try: - return await self.core.connection.wait_for_message( - predicate, - timeout=timeout if timeout is not None else cfg.IO_TIMEOUT, - ) - except asyncio.TimeoutError: + _timeout = timeout if timeout is not None else cfg.IO_TIMEOUT + async with self.core.request_lock: + for attempt in range(1, retries + 1): + self.core.connection.write(command_bytes) + try: + return await self.core.connection.wait_for_message( + predicate, timeout=_timeout + ) + except asyncio.TimeoutError: + if attempt < retries: + logger.warning( + "PRT3: timeout on attempt %d/%d, retrying: %r", + attempt, retries, command_bytes, + ) return None # ------------------------------------------------------------------ @@ -152,29 +168,65 @@ async def _prt3_send_wait( async def initialize_communication(self, password) -> bool: """ - Wait for COMM&ok from the panel. + 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) + ) - The PRT3 module emits 'COMM&ok\\r' shortly after power-on or - reconnect. There is no password exchange; the ``password`` argument - is accepted for interface compatibility but ignored. + logger.info("PRT3: checking serial link liveness") - Returns True if COMM&ok is received before the timeout. - Returns False if COMM&fail arrives first, or on timeout. - """ - logger.info("PRT3: awaiting COMM&ok from panel") + # Phase 1: brief listen for spontaneous data (events, COMM&ok etc.) try: msg = await self.core.connection.wait_for_message( - lambda m: isinstance(m, PRT3CommStatus), - timeout=cfg.PRT3_COMM_TIMEOUT, + _any_prt3_msg, timeout=2.0 ) - if msg.ok: - logger.info("PRT3: panel ready (COMM&ok)") - return True - logger.error("PRT3: panel communication failure (COMM&fail)") - return False + 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: timeout waiting for COMM&ok (%.0fs)", cfg.PRT3_COMM_TIMEOUT + "PRT3: serial link unresponsive after %.0fs probe", cfg.PRT3_COMM_TIMEOUT ) return False @@ -374,6 +426,7 @@ async def control_partitions(self, partitions: list, command: str) -> bool: 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) @@ -430,6 +483,7 @@ async def send_panic(self, partition: int, panic_type: str, _code) -> bool: 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) @@ -441,3 +495,38 @@ async def send_panic(self, partition: int, panic_type: str, _code) -> bool: return False logger.info("PRT3: %s panic area %d accepted", panic_type, partition) return True + + # ------------------------------------------------------------------ + # 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 index 2f15e1ee..531d73f7 100644 --- a/paradox/hardware/prt3/parser.py +++ b/paradox/hardware/prt3/parser.py @@ -288,7 +288,8 @@ def parse_line(line: str) -> Optional[PRT3Message]: return _parse_label(line) # 8. Command echoes: {5 chars}&OK (8) or {5 chars}&fail (10) - if len(line) == _ECHO_OK_LEN and line.endswith("&OK"): + # 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) diff --git a/paradox/paradox.py b/paradox/paradox.py index 5af82b18..66da41ea 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__) @@ -128,6 +128,11 @@ 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 connect(self) -> bool: if self.work_loop is None: self.work_loop = asyncio.get_running_loop() @@ -146,13 +151,34 @@ async def connect(self) -> bool: logger.info("Connecting to Panel") - # PRT3 uses a completely different handshake — binary panel detection is not - # applicable. Full PRT3 connect() is implemented in PRT3Paradox (hardware/prt3/). + # PRT3 uses ASCII framing — binary panel detection does not apply. if cfg.CONNECTION_TYPE == "PRT3": - logger.error( - "PRT3 runtime connect() not yet implemented; " - "see paradox/hardware/prt3/runtime.py" - ) + from paradox.hardware.prt3.panel import PRT3Panel + + self.panel = PRT3Panel(self) + try: + if not await self.panel.initialize_communication(None): + raise ConnectionError("PRT3 panel did not respond with COMM&ok") + # 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 @@ -268,6 +294,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: @@ -510,6 +538,31 @@ async def control_partition(self, partition: str, command: str) -> bool: 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 asyncio.CancelledError: + logger.error("control_utility_key canceled") + return False + 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): @@ -720,6 +773,24 @@ 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 + try: + evt = PRT3Event.from_prt3(message, label_provider=self.get_label) + element = self.storage.get_container_object(evt.type, evt.id) + if evt.change and element: + self.storage.update_container_object(evt.type, evt.id, evt.change) + ps.sendEvent(evt) + if evt.type == "partition": + self._update_partition_states() + except Exception: + logger.exception("handle_prt3_event_message") + async def disconnect(self): logger.info("Disconnecting from the Alarm Panel") self.run_state = RunState.STOP @@ -746,6 +817,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") From 61aafcb520ee77b316108ca13575fd41612d79be Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 09:33:53 +1000 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20MQTT=20and=20HA=20discovery=20?= =?UTF-8?q?=E2=80=94=20utility=20key=20buttons,=20panel=5Fdetected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entities/button.py: new UtilityKeyButton entity — command_topic wired to paradox/{control}/{utility_key}/{n}, HA discovery config with payload_press - entities/factory.py: make_utility_key_button() factory method - homeassistant.py: _publish_utility_key_configs() publishes one button discovery config per PRT3_UTILITY_KEYS entry on status_update - basic.py: subscribe paradox/control/utility_key/+ when CONNECTION_TYPE=PRT3; _mqtt_handle_utility_key() dispatches to control_utility_key() Co-Authored-By: Claude Sonnet 4.6 --- paradox/interfaces/mqtt/basic.py | 35 +++++++++++ paradox/interfaces/mqtt/entities/button.py | 69 +++++++++++++++++++++ paradox/interfaces/mqtt/entities/factory.py | 4 ++ paradox/interfaces/mqtt/homeassistant.py | 19 ++++++ 4 files changed, 127 insertions(+) create mode 100644 paradox/interfaces/mqtt/entities/button.py 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/entities/button.py b/paradox/interfaces/mqtt/entities/button.py new file mode 100644 index 00000000..d629e30e --- /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( + { + "serial_number": self.device.serial_number, + "model": self.device.model, + } + ) + return dict( + 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..d079c854 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.PRT3_UTILITY_KEYS: + self._publish_utility_key_configs(cfg.PRT3_UTILITY_KEYS) def _publish_config(self, entity: AbstractEntity): self.publish( @@ -179,3 +181,20 @@ 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 + button = self.entity_factory.make_utility_key_button(key_num, str(label)) + self._publish_config(button) From ae11c8896f86bcb59a6e91a042714f3938563cf1 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 09:34:13 +1000 Subject: [PATCH 08/21] fix: fire on_connect for MQTT interfaces that register after connection When an interface registers with MQTTConnection after the broker connection is already established, on_connect was never called, leaving control subscriptions and connected_future in an uninitialised state. Fire on_connect immediately in register() when already connected. Co-Authored-By: Claude Sonnet 4.6 --- paradox/interfaces/mqtt/core.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/paradox/interfaces/mqtt/core.py b/paradox/interfaces/mqtt/core.py index e5119123..9af8f709 100644 --- a/paradox/interfaces/mqtt/core.py +++ b/paradox/interfaces/mqtt/core.py @@ -179,6 +179,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: + try: + if hasattr(cls, "on_connect") and callable(getattr(cls, "on_connect")): + cls.on_connect(self.client, None, None, None) + except Exception: + logger.exception( + 'Failed to call on_connect on late registrar "%s"', + cls.__class__.__name__, + ) + def unregister(self, cls): self.registrars.remove(cls) From ca758e04fc2c316726ac4295d0605e02cb782d87 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 09:34:37 +1000 Subject: [PATCH 09/21] test: PRT3 command dispatch, panel, and MQTT integration test suites - test_command_dispatch.py: arm/disarm/quick-arm/panic/utility-key command encoding and echo matching; failure and timeout paths - test_panel.py: initialize_communication, load_labels, request_status; COMM&ok / COMM&fail paths - test_mqtt_integration.py: panel_detected on connect; UtilityKeyButton serialize and topic construction; factory wiring; _publish_utility_key_configs publishes one config per key; PRT3Event timestamp/subtype/props; G065 per-N exit/entry delay dispatch; arm/disarm clears exit_delay - fixtures.py: add G065 exit/entry delay event fixtures, PGM fixtures - test_parser.py: updated for PGM line and edge cases Co-Authored-By: Claude Sonnet 4.6 --- tests/connection/prt3/test_protocol.py | 177 +++++- tests/hardware/prt3/fixtures.py | 10 +- tests/hardware/prt3/test_command_dispatch.py | 194 +++++++ tests/hardware/prt3/test_mqtt_integration.py | 388 ++++++++++++++ tests/hardware/prt3/test_panel.py | 535 +++++++++++++++++++ tests/hardware/prt3/test_parser.py | 17 + 6 files changed, 1309 insertions(+), 12 deletions(-) create mode 100644 tests/hardware/prt3/test_command_dispatch.py create mode 100644 tests/hardware/prt3/test_mqtt_integration.py create mode 100644 tests/hardware/prt3/test_panel.py diff --git a/tests/connection/prt3/test_protocol.py b/tests/connection/prt3/test_protocol.py index 798a7871..b740ee14 100644 --- a/tests/connection/prt3/test_protocol.py +++ b/tests/connection/prt3/test_protocol.py @@ -1,32 +1,187 @@ """ -Smoke tests for paradox.connections.prt3. +Tests for paradox.connections.prt3.protocol — PRT3Protocol framer. -These tests verify that the module graph imports cleanly and that the -scaffolded classes are importable and have the expected type hierarchy. -Protocol logic tests will be added in Phase 2. +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 paradox.connections.prt3.protocol import PRT3Protocol +from unittest.mock import MagicMock + +import pytest + from paradox.connections.prt3.connection import PRT3SerialConnection +from paradox.connections.prt3.protocol import PRT3Protocol from paradox.connections.protocols import ConnectionProtocol from paradox.connections.serial_connection import SerialCommunication +# --------------------------------------------------------------------------- +# Smoke / type hierarchy +# --------------------------------------------------------------------------- + + def test_prt3_protocol_is_connection_protocol(): - """PRT3Protocol must inherit from ConnectionProtocol.""" assert issubclass(PRT3Protocol, ConnectionProtocol) def test_prt3_serial_connection_is_serial_communication(): - """PRT3SerialConnection must inherit from SerialCommunication.""" assert issubclass(PRT3SerialConnection, SerialCommunication) def test_prt3_serial_connection_make_protocol_returns_prt3_protocol(): - """make_protocol() must return a PRT3Protocol instance.""" - # We cannot open a real serial port in tests; pass a fake port path - # and a dummy handler. make_protocol() is a synchronous factory that - # just calls PRT3Protocol(self), so no I/O occurs. 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): + pass + + def on_connection_loss(self): + 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, handler = _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/fixtures.py b/tests/hardware/prt3/fixtures.py index 366d8d65..aea09864 100644 --- a/tests/hardware/prt3/fixtures.py +++ b/tests/hardware/prt3/fixtures.py @@ -132,6 +132,10 @@ 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" @@ -155,6 +159,8 @@ 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 @@ -188,11 +194,13 @@ 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_FAIL, ECHO_DISARM_FAIL, + 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_command_dispatch.py b/tests/hardware/prt3/test_command_dispatch.py new file mode 100644 index 00000000..565fb80c --- /dev/null +++ b/tests/hardware/prt3/test_command_dispatch.py @@ -0,0 +1,194 @@ +""" +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 + + 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 caught and returns False.""" + paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + mock_panel.send_utility_key = AsyncMock(side_effect=asyncio.CancelledError) + + assert await paradox.control_utility_key(5) is False + + +# --------------------------------------------------------------------------- +# 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 diff --git a/tests/hardware/prt3/test_mqtt_integration.py b/tests/hardware/prt3/test_mqtt_integration.py new file mode 100644 index 00000000..428be8fc --- /dev/null +++ b/tests/hardware/prt3/test_mqtt_integration.py @@ -0,0 +1,388 @@ +""" +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 with N values other than 1/2 produce no state change (fallback no-op).""" + 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(): + """G010 (arm by user) must set arm=True AND exit_delay=False.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + for group in (10, 11, 12, 13): + 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(): + """G014-G017 and G020 (disarm) must clear exit_delay so a cancelled arm resets HA state.""" + from paradox.hardware.prt3.parser import PRT3SystemEvent + from paradox.hardware.prt3.event import PRT3Event + + for group in (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..b9aa12ab --- /dev/null +++ b/tests/hardware/prt3/test_panel.py @@ -0,0 +1,535 @@ +""" +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, + 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 + + +# --------------------------------------------------------------------------- +# 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_retries_on_timeout(core, panel): + """Utility key retries once on timeout; succeeds on second attempt.""" + 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 True + assert core.connection.write.call_count == 2 + + +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 index 8f54d2c8..157b1ae9 100644 --- a/tests/hardware/prt3/test_parser.py +++ b/tests/hardware/prt3/test_parser.py @@ -126,6 +126,22 @@ def test_echo_fail(line, expected_cmd): 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 @@ -135,6 +151,7 @@ def test_echo_cmd_is_exactly_5_chars(): 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) From 4c8fede4aedf3bd04b5a4884dc926b26aa0dad0b Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 09:35:20 +1000 Subject: [PATCH 10/21] =?UTF-8?q?docs:=20PRT3=20usage=20guide=20=E2=80=94?= =?UTF-8?q?=20setup,=20config,=20HA=20integration,=20limitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers hardware wiring, baud configuration, user code setup, utility keys, HA entity types, arming state mapping, and v1 limitations. Co-Authored-By: Claude Sonnet 4.6 --- docs/prt3-usage.md | 118 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/prt3-usage.md diff --git a/docs/prt3-usage.md b/docs/prt3-usage.md new file mode 100644 index 00000000..5023c5a3 --- /dev/null +++ b/docs/prt3-usage.md @@ -0,0 +1,118 @@ +# 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 # Match your PRT3 module's DIP switch setting + +# User code for arm/disarm commands. Leave empty to use quick-arm +# (requires One-Touch Arming enabled on the panel). +# Disarm always requires a valid user code. +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. From 8ebe1dedacd19cc5e334f3a93091e04cdc0d364b Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 09:39:08 +1000 Subject: [PATCH 11/21] =?UTF-8?q?test:=20correct=20utility=20key=20retry?= =?UTF-8?q?=20test=20=E2=80=94=20no=20retry=20is=20intentional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test expected send_utility_key to retry on timeout and succeed on the second attempt. Retries were removed to prevent gate/latch double-triggering (utility key commands are not idempotent). Updated test asserts False return and exactly one write on timeout. Co-Authored-By: Claude Sonnet 4.6 --- tests/hardware/prt3/test_panel.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/hardware/prt3/test_panel.py b/tests/hardware/prt3/test_panel.py index b9aa12ab..32085a1a 100644 --- a/tests/hardware/prt3/test_panel.py +++ b/tests/hardware/prt3/test_panel.py @@ -507,14 +507,18 @@ async def test_send_utility_key_timeout_returns_false(core, panel): assert await panel.send_utility_key(1) is False -async def test_send_utility_key_retries_on_timeout(core, panel): - """Utility key retries once on timeout; succeeds on second attempt.""" +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 True - assert core.connection.write.call_count == 2 + assert result is False + assert core.connection.write.call_count == 1 async def test_send_utility_key_invalid_number_raises(core, panel): From 1b0120e7e860f4cd1d12e7e6c2d900a1832cc2c9 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 13:34:24 +1000 Subject: [PATCH 12/21] fix: apply ultrareview and delta-review corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protocol.py: guard against buffer overflow (>512 bytes) - panel.py: send_panic() accepts list[int] to match EVO/SM signature; add arm_sleep alias (instant arm); warn when zone-poll timeout exceeds half of KEEP_ALIVE_INTERVAL - runtime.py: remove TODO-stub file (logic lives in panel.py / paradox.py) - config.py: lower PRT3_MAX_USERS default to 32; add security note on PRT3_USER_CODE storage - mqtt/core.py: replay correct 5-arg on_connect signature to late registrars - mqtt/abstract_entity.py: fix format() → format_map() with correct dict literal (was a set literal — would have raised TypeError at runtime) - mqtt/button.py: same format_map fix - paradox.py: re-raise CancelledError from control_utility_key instead of swallowing it - tests: update panic tests to pass [int] list; update CancelledError test to assert re-raise Co-Authored-By: Claude Sonnet 4.6 --- paradox/config.py | 6 +- paradox/connections/prt3/protocol.py | 7 ++ paradox/hardware/prt3/panel.py | 55 +++++---- paradox/hardware/prt3/runtime.py | 104 ------------------ paradox/interfaces/mqtt/core.py | 5 +- .../mqtt/entities/abstract_entity.py | 8 +- paradox/interfaces/mqtt/entities/button.py | 2 +- paradox/paradox.py | 2 +- tests/hardware/prt3/test_command_dispatch.py | 5 +- tests/hardware/prt3/test_panel.py | 8 +- 10 files changed, 62 insertions(+), 140 deletions(-) delete mode 100644 paradox/hardware/prt3/runtime.py diff --git a/paradox/config.py b/paradox/config.py index 6bb09886..16fb6f6c 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -34,8 +34,10 @@ class Config: "PRT3_SERIAL_BAUD": (9600, int, (2400, 115200)), # Baud rate; 9600 or 19200 typical "PRT3_MAX_AREAS": (8, int, (1, 8)), # Number of areas on the panel "PRT3_MAX_ZONES": (96, int, (1, 192)), # Number of zones on the panel - "PRT3_MAX_USERS": (999, int, (1, 999)), # Number of user codes on the panel - "PRT3_USER_CODE": "", # User code for arm/disarm commands (1-6 digits); empty = quick-arm only + "PRT3_MAX_USERS": (32, int, (1, 999)), # Number of user codes on the panel + "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. This value is never written to 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 diff --git a/paradox/connections/prt3/protocol.py b/paradox/connections/prt3/protocol.py index a26dd40f..a187b460 100644 --- a/paradox/connections/prt3/protocol.py +++ b/paradox/connections/prt3/protocol.py @@ -45,6 +45,13 @@ def data_received(self, data: bytes): """ 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" diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py index 6ebf7cfb..3ae9d98f 100644 --- a/paradox/hardware/prt3/panel.py +++ b/paradox/hardware/prt3/panel.py @@ -66,6 +66,7 @@ "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 @@ -96,6 +97,17 @@ 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 @@ -462,11 +474,11 @@ async def control_outputs(self, outputs, command) -> bool: # Panic # ------------------------------------------------------------------ - async def send_panic(self, partition: int, panic_type: str, _code) -> bool: + async def send_panic(self, partitions: list, panic_type: str, _code) -> bool: """ Send a PE/PM/PF panic command. - :param partition: 1-based area number (1-8). + :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). """ @@ -475,26 +487,29 @@ async def send_panic(self, partition: int, panic_type: str, _code) -> bool: logger.error("PRT3: unknown panic type %r", panic_type) return False - cmd = encode_fn(partition) - expected_echo = f"{cmd[:2].decode('ascii')}{partition:03d}" + 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) - return False - if not msg.ok: - logger.warning( - "PRT3: %s panic area %d rejected (&fail)", panic_type, partition + msg = await self._prt3_send_wait( + cmd, + lambda m, ec=expected_echo: ( + isinstance(m, PRT3CommandEcho) and m.cmd == ec + ), + retries=2, ) - return False - logger.info("PRT3: %s panic area %d accepted", panic_type, partition) - return True + 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 diff --git a/paradox/hardware/prt3/runtime.py b/paradox/hardware/prt3/runtime.py deleted file mode 100644 index a2e6d99f..00000000 --- a/paradox/hardware/prt3/runtime.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -PRT3Paradox — Paradox subclass for the PRT3 ASCII connection type. - -Overrides connect() and the connection message handler to implement the -PRT3-specific handshake and reply routing. - -Why a subclass of Paradox and not a modified Paradox.connect(): - - Paradox.connect() assumes binary panel detection (InitiateCommunication / - StartCommunication), which does not exist in PRT3. - - The binary HandlerRegistry calls data.fields.value.po.command on every - unhandled message; PRT3 messages are plain dataclasses that have no such - attribute — routing them through the registry would crash. - - Keeping the override here avoids adding PRT3-specific branches throughout - the base class. - -Reply routing: - All PRT3 replies (echoes, status, labels) are routed through - _prt3_reply_queue (asyncio.Queue). Callers in panel.py await items from - this queue rather than registering HandlerRegistry entries. - -TODO (Phase 2): Implement connect() — await COMM&ok, instantiate PRT3Panel, - call load_labels() and kick off status polling loop. -TODO (Phase 2): Implement on_connection_message() — parse incoming ASCII line, - put result into _prt3_reply_queue. -TODO (Phase 3): Implement _register_connection_handlers() — register only the - raw handler; skip EventMessageHandler and ErrorMessageHandler (binary only). -""" - -import asyncio -import logging - -from paradox.paradox import Paradox -from paradox.hardware.prt3.panel import PRT3Panel - -logger = logging.getLogger("PAI").getChild(__name__) - - -class PRT3Paradox(Paradox): - """ - Paradox orchestrator subclass for the PRT3 ASCII interface. - - Callers that set CONNECTION_TYPE = 'PRT3' should instantiate this - class rather than the base Paradox class. - - TODO (Phase 2): Replace the NotImplementedError stubs below with - working implementations. - """ - - def __init__(self, retries=3): - super().__init__(retries=retries) - # Queue used to route PRT3 reply lines back to awaiting callers - # in PRT3Panel without going through HandlerRegistry (which would - # crash on the missing .fields.value.po.command attribute). - self._prt3_reply_queue: asyncio.Queue = asyncio.Queue() - - def _register_connection_handlers(self): - """ - Register only the raw message handler for PRT3. - - The base class also registers EventMessageHandler and - ErrorMessageHandler, which access binary Container fields. - PRT3 messages must never reach those handlers. - - TODO (Phase 3): Register PersistentHandler(self.on_connection_message) only. - """ - raise NotImplementedError( - "PRT3Paradox._register_connection_handlers() not yet implemented — see Phase 3" - ) - - async def connect(self) -> bool: - """ - PRT3-specific connection sequence: - - 1. Open the serial port (inherited serial_asyncio logic). - 2. Wait for COMM&ok\\r from the panel (timeout 30 s). - 3. Instantiate PRT3Panel. - 4. Call panel.load_labels() to populate storage. - 5. Set run_state = CONNECTED and return True. - - There is no InitiateCommunication / StartCommunication binary exchange. - - TODO (Phase 2): Implement this method. - """ - raise NotImplementedError( - "PRT3Paradox.connect() not yet implemented — see Phase 2" - ) - - def on_connection_message(self, message: bytes): - """ - Receive a raw \\r-stripped ASCII line from PRT3Protocol. - - Parses the line via parser.parse_line() and routes the result: - - PRT3CommStatus → handle connect/disconnect state - - PRT3CommandEcho → put in _prt3_reply_queue - - PRT3AreaStatus → put in _prt3_reply_queue - - PRT3ZoneStatus → put in _prt3_reply_queue - - PRT3LabelReply → put in _prt3_reply_queue - - PRT3SystemEvent → publish to ps 'events' topic - - TODO (Phase 2): Implement this method. - """ - raise NotImplementedError( - "PRT3Paradox.on_connection_message() not yet implemented — see Phase 2" - ) diff --git a/paradox/interfaces/mqtt/core.py b/paradox/interfaces/mqtt/core.py index 9af8f709..d73203c7 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)) @@ -186,7 +187,8 @@ def register(self, cls): if self.connected: try: if hasattr(cls, "on_connect") and callable(getattr(cls, "on_connect")): - cls.on_connect(self.client, None, None, None) + args = self._last_connect_args or (self.client, None, None, None, None) + cls.on_connect(*args) except Exception: logger.exception( 'Failed to call on_connect on late registrar "%s"', @@ -218,6 +220,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 index d629e30e..1fff9f06 100644 --- a/paradox/interfaces/mqtt/entities/button.py +++ b/paradox/interfaces/mqtt/entities/button.py @@ -53,7 +53,7 @@ def command_topic(self) -> str: ) def serialize(self) -> dict: - 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, diff --git a/paradox/paradox.py b/paradox/paradox.py index 66da41ea..abef9940 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -561,7 +561,7 @@ async def control_utility_key(self, key: int) -> bool: return False except asyncio.CancelledError: logger.error("control_utility_key canceled") - return False + raise def _init_module_pgms(self): for addr, pgm_count in cfg.MODULE_PGM_ADDRESSES.items(): diff --git a/tests/hardware/prt3/test_command_dispatch.py b/tests/hardware/prt3/test_command_dispatch.py index 565fb80c..aac092e5 100644 --- a/tests/hardware/prt3/test_command_dispatch.py +++ b/tests/hardware/prt3/test_command_dispatch.py @@ -158,11 +158,12 @@ async def test_control_utility_key_panel_not_implemented(monkeypatch): async def test_control_utility_key_cancelled(monkeypatch): - """CancelledError from panel is caught and returns False.""" + """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) - assert await paradox.control_utility_key(5) is False + with pytest.raises(asyncio.CancelledError): + await paradox.control_utility_key(5) # --------------------------------------------------------------------------- diff --git a/tests/hardware/prt3/test_panel.py b/tests/hardware/prt3/test_panel.py index 32085a1a..8534fe39 100644 --- a/tests/hardware/prt3/test_panel.py +++ b/tests/hardware/prt3/test_panel.py @@ -359,7 +359,7 @@ async def test_send_panic_accepted(core, panel, panic_type, prefix): return_value=PRT3CommandEcho(cmd=echo_cmd, ok=True) ) - result = await panel.send_panic(1, panic_type, None) + 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) @@ -369,16 +369,16 @@ 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 + 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 + 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 + assert await panel.send_panic([1], "unknown_panic_type", None) is False # --------------------------------------------------------------------------- From 00106847ffcc69b1fe2f356599e459e0762eaba2 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 13:49:49 +1000 Subject: [PATCH 13/21] docs: update architecture notes; drop implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prt3-architecture.md: remove stale "not yet functional" status section, add adapter.py to layer layout, remove deleted runtime.py, expand wiring section to reflect actual scope (MQTT utility key integration, full PRT3_* config key list). prt3-implementation-plan.md: removed — planning artifact; the PRT3Paradox subclass design it describes was superseded by the guard approach that was actually built. Co-Authored-By: Claude Sonnet 4.6 --- docs/prt3-architecture.md | 36 +- docs/prt3-implementation-plan.md | 839 ------------------------------- 2 files changed, 14 insertions(+), 861 deletions(-) delete mode 100644 docs/prt3-implementation-plan.md diff --git a/docs/prt3-architecture.md b/docs/prt3-architecture.md index bba39322..0a88926d 100644 --- a/docs/prt3-architecture.md +++ b/docs/prt3-architecture.md @@ -1,21 +1,5 @@ # PRT3 Connection — Architecture Notes -## Branch status - -**Scaffolding only — PRT3 is not yet functional.** - -The skeleton modules and config keys are in place (see layer layout below), -but all protocol logic raises `NotImplementedError`. Setting -`CONNECTION_TYPE = "PRT3"` will open the serial port and then immediately -return an error from `connect()`. - -Implementation phases: - -| Phase | What gets implemented | -|---|---| -| Phase 2 | `PRT3Protocol` framer, `PRT3Panel` init/labels/status, `PRT3Paradox.connect()` | -| Phase 3 | Event map, arm/disarm/panic control, full async event pipeline | - ## Why PRT3 is a separate connection type PRT3 is not a transport wrapper around the existing Paradox binary serial protocol. @@ -55,6 +39,7 @@ paradox/hardware/prt3/ 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() parser.py parse_line(line: str) -> PRT3Message | None - pure function, no side effects - handles: COMM&ok/fail, echo &OK/&fail, RA/RZ replies, @@ -64,21 +49,28 @@ paradox/hardware/prt3/ requests, status requests, utility key event.py EVENT_MAP: dict[int, dict] - maps G-group codes to PAI event descriptors + 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 -Two small additions to existing files: +Minimal additions to existing files: -**`paradox/config.py`** — add `"PRT3"` to the `CONNECTION_TYPE` allowed list and -five new config keys (`PRT3_SERIAL_PORT`, `PRT3_SERIAL_BAUD`, `PRT3_MAX_AREAS`, -`PRT3_MAX_ZONES`, `PRT3_MAX_USERS`). +**`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, and a guard in `connect()` that skips the binary panel -detection path and directly instantiates `PRT3Panel`. +`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. diff --git a/docs/prt3-implementation-plan.md b/docs/prt3-implementation-plan.md deleted file mode 100644 index a78c2903..00000000 --- a/docs/prt3-implementation-plan.md +++ /dev/null @@ -1,839 +0,0 @@ -# PRT3 — Detailed Implementation Plan - -Generated from: inspection of `feature/prt3-connection` branch, PAI `dev` as of 2026-03-29. - ---- - -## 1. Change inventory - -### Existing files to modify - -| File | Change | Risk | -|---|---|---| -| `paradox/config.py` | Add `"PRT3"` to `CONNECTION_TYPE` allowed list; add 5 new `PRT3_*` config keys | Zero — additive only | -| `paradox/paradox.py` | Add one `elif` branch in `connection` property; add one guard in `connect()` | Minimal — 4–6 lines in well-isolated spots | - -No other existing file is touched. - -### New files to create - -``` -paradox/connections/prt3/ - __init__.py - connection.py - protocol.py - -paradox/hardware/prt3/ - __init__.py - panel.py - parser.py - encoder.py - event.py - property.py - -tests/hardware/prt3/ - __init__.py - test_parser.py - test_encoder.py - test_panel.py - fixtures/ - session_startup.txt - session_events.txt - session_arm_disarm.txt - -tests/connection/prt3/ - __init__.py - test_protocol.py -``` - ---- - -## 2. Existing runtime path (for reference) - -Understanding this path is essential for knowing exactly where PRT3 diverges. - -### 2.1 Connection bootstrap - -``` -main.py: Paradox() - Paradox.connection (property, paradox.py:72) - cfg.CONNECTION_TYPE == "Serial" → SerialCommunication(port, baud) - cfg.CONNECTION_TYPE == "IP" → BareIPConnection | LocalIPConnection | StunIPConnection - else → AssertionError - - Paradox._register_connection_handlers() - raw_handler_registry ← PersistentHandler(self.on_connection_message) - handler_registry ← EventMessageHandler(self.handle_event_message) - handler_registry ← ErrorMessageHandler(self.handle_error_message) - - Paradox.full_connect() - → connection.connect() # opens transport - → send_wait(InitiateCommunication) # binary: gets model/firmware/serial - → send_wait(StartCommunication) # binary: gets product_id - → create_panel(self, reply) # factory selects EVO48/96/192/HD or Spectra - → panel.initialize_communication() # binary login with PC password - → run_state = CONNECTED - → panel.load_memory() # EEPROM reads for labels - → run_state = RUN - → loop() -``` - -### 2.2 Message dispatch (existing) - -``` -serial bytes arrive - → SerialConnectionProtocol.data_received() - buffer until checksum-valid frame assembled - → ConnectionHandler.on_message(raw_bytes) - → Connection.schedule_raw_message_handling(raw_bytes) - → raw_handler_registry.handle(raw_bytes) - → PersistentHandler calls Paradox.on_connection_message(raw_bytes) - → panel.parse_message(raw_bytes) → Construct Container - → connection.schedule_message_handling(container) - → handler_registry.handle(container) - → FutureHandler (send_wait pending) resolved if command == expected - → EventMessageHandler fires if command == 0xE - → ErrorMessageHandler fires if command == 0x7 -``` - -### 2.3 Status polling loop (existing) - -``` -Paradox.loop() - while RUN: - results = await asyncio.gather(*panel.get_status_requests()) - each request_status(i): - → send_wait(ReadEEPROM, address=RAM_BASE+i) - → parse binary RAM block - → return plain dict {zone_open: {1: T, 2: F, ...}, partition_arm: {1: T}, ...} - merged = deep_merge(*results) - _process_status(merged) - → convert_raw_status(merged) # splits "zone_open" → type="zone" prop="open" - → ps.sendMessage("status_update", status=status) - wait up to KEEP_ALIVE_INTERVAL -``` - -### 2.4 `send_wait()` mechanics - -`send_wait()` (`paradox.py:372`) does: -1. Builds message bytes: `message_type.build(dict(fields=dict(value=args)))` -2. `connection.write(message)` -3. `connection.wait_for_message(reply_expected)` → adds a `FutureHandler` to `handler_registry` -4. Returns when `handler_registry.handle(container)` fires the FutureHandler - -The `reply_expected` callable matches on `container.fields.value.po.command`. - -### 2.5 `HandlerRegistry` error on no-match - -`handlers.py:123`: when no handler matches a dispatched message, it logs: -```python -logger.error("No handler for message {}\nDetail: {}".format( - data.fields.value.po.command, data)) -``` -This attribute access would blow up on a PRT3 ASCII line. **PRT3 parsed messages must never enter `handler_registry` unless they carry a compatible shape, or the FutureHandler consumes them first.** - ---- - -## 3. PRT3 insertion points - -### 3.1 `paradox/config.py` — `Config.DEFAULTS` dict - -**Insertion 1** (line 28): add `"PRT3"` to `CONNECTION_TYPE` allowed list. - -```python -# Before: -"CONNECTION_TYPE": ("Serial", str, ["IP", "Serial"]), - -# After: -"CONNECTION_TYPE": ("Serial", str, ["IP", "Serial", "PRT3"]), -``` - -**Insertion 2** (after the `SERIAL_BAUD` block, ~line 32): add PRT3-specific keys. - -```python -"PRT3_SERIAL_PORT": "/dev/ttyUSB0", -"PRT3_SERIAL_BAUD": (9600, int, (2400, 115200)), -"PRT3_MAX_AREAS": (8, int, (1, 8)), -"PRT3_MAX_ZONES": (96, int, (1, 192)), -"PRT3_MAX_USERS": (999, int, (1, 999)), -``` - -### 3.2 `paradox/paradox.py` — `Paradox.connection` property - -**Insertion** (inside `if not self._connection:` block, after the `elif cfg.CONNECTION_TYPE == "IP":` block, ~line 107): - -```python -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, - ) -``` - -**Insertion** in `Paradox.connect()` (~line 140): skip binary panel detection for PRT3. - -```python -# After connection.connect() succeeds, before InitiateCommunication: -if cfg.CONNECTION_TYPE == "PRT3": - from paradox.hardware.prt3.panel import PRT3Panel - self.panel = PRT3Panel(self) - result = await self.panel.initialize_communication(cfg.PASSWORD) - if not result: - raise ConnectionError("PRT3: failed to receive COMM&ok") - self.run_state = RunState.CONNECTED - return True -``` - -This guard short-circuits the binary handshake entirely and falls through to the normal `run_state = CONNECTED; return True` path. - ---- - -## 4. New file specifications - -### 4.1 `paradox/connections/prt3/protocol.py` - -**Class: `PRT3Protocol(ConnectionProtocol)`** - -Overrides: -- `data_received(data: bytes)` — appends to buffer; on each `\r` (0x0D) emits the line via `self.handler.on_message(line_bytes)` including the `\r`; discards empty lines. -- `send_message(message: bytes)` — `self.transport.write(message)` (no framing, no checksum). -- `variable_message_length(mode)` — no-op (PRT3 has no binary framing). - -Does NOT override `connection_made`, `connection_lost`, `is_active`, `close` — those are inherited from `ConnectionProtocol`. - -**Key test cases** (see §5): -- Complete line in one chunk -- Line split across multiple chunks -- Two lines in one chunk -- Partial line followed by completion -- `\r\n` vs bare `\r` -- Garbage before first valid `\r` - -### 4.2 `paradox/connections/prt3/connection.py` - -**Class: `PRT3SerialConnection(SerialCommunication)`** - -Subclasses `SerialCommunication` to reuse: serial port open, permissions fix, `connected_future`, `open_timeout`, `connect()` exactly. Only one method is overridden: - -- `make_protocol(self) -> PRT3Protocol` — returns `PRT3Protocol(self)` instead of `SerialConnectionProtocol(self)`. - -All other `Connection` and `SerialCommunication` behaviour is inherited unchanged. - -### 4.3 `paradox/connections/prt3/__init__.py` - -Empty (makes it a package). - -### 4.4 `paradox/hardware/prt3/parser.py` - -Pure module, no imports from PAI runtime. Returns typed dataclasses. - -**Dataclasses:** -```python -@dataclass -class PRT3CommStatus: ok: bool # True = COMM&ok, False = COMM&fail - -@dataclass -class PRT3CommandEcho: - prefix: str # first 5 chars echoed back - ok: bool # True = &OK, False = &fail - payload: str # anything after &OK / &fail (empty for simple acks) - -@dataclass -class PRT3AreaStatus: - area: int - armed: str # D / A / F / S / I - programming: bool - trouble: bool - ready: bool - alarm: bool - strobe: bool - alarm_in_memory: bool - -@dataclass -class PRT3ZoneStatus: - zone: int - status: str # C / O / T / F - alarm: bool - fire_alarm: bool - supervision_lost: bool - low_battery: bool - -@dataclass -class PRT3LabelReply: - kind: str # "ZL" / "AL" / "UL" - index: int - label: str # 16 chars, stripped - -@dataclass -class PRT3SystemEvent: - group: int # G value - number: int # N value - area: int # A value - -PRT3Message = Union[ - PRT3CommStatus, PRT3CommandEcho, PRT3AreaStatus, PRT3ZoneStatus, - PRT3LabelReply, PRT3SystemEvent, -] -``` - -**Public API:** -```python -def parse_line(line: str) -> Optional[PRT3Message]: - """ - Parse one complete ASCII line (with or without trailing \r). - Returns None for unrecognised lines. - """ -``` - -Routing logic (order matters): -1. `COMM&ok` → `PRT3CommStatus(ok=True)` -2. `COMM&fail` → `PRT3CommStatus(ok=False)` -3. `!` → `None` (buffer full; caller handles retry) -4. `RA{3d}{1c}{1c}{1c}{1c}{1c}{1c}{1c}` (13 chars) → `PRT3AreaStatus` -5. `RZ{3d}{1c}{1c}{1c}{1c}{1c}` (11 chars) → `PRT3ZoneStatus` -6. `ZL{3d}{16c}` → `PRT3LabelReply(kind="ZL", ...)` -7. `AL{3d}{16c}` → `PRT3LabelReply(kind="AL", ...)` -8. `UL{3d}{16c}` → `PRT3LabelReply(kind="UL", ...)` -9. `G{3d}N{3d}A{3d}` (13 chars) → `PRT3SystemEvent` -10. Anything matching `{5chars}&OK` or `{5chars}&fail` → `PRT3CommandEcho` -11. Else → `None` - -### 4.5 `paradox/hardware/prt3/encoder.py` - -Pure module. All functions return `bytes` ending with `b"\r"`. - -```python -def encode_request_area_status(area: int) -> bytes: - # b"RA001\r" -def encode_request_zone_status(zone: int) -> bytes: - # b"RZ001\r" -def encode_request_area_label(area: int) -> bytes: - # b"AL001\r" -def encode_request_zone_label(zone: int) -> bytes: - # b"ZL001\r" -def encode_request_user_label(user: int) -> bytes: - # b"UL001\r" -def encode_arm(area: int, mode: str, code: str) -> bytes: - # mode in ("A", "F", "S", "I"); code up to 6 digits - # b"AA01A123456\r" -def encode_quick_arm(area: int, mode: str) -> bytes: - # b"AQ01A\r" -def encode_disarm(area: int, code: str) -> bytes: - # b"AD01123456\r" -def encode_panic_emergency(area: int) -> bytes: - # b"PE01\r" -def encode_panic_medical(area: int) -> bytes: - # b"PM01\r" -def encode_panic_fire(area: int) -> bytes: - # b"PF01\r" -def encode_smoke_reset(area: int) -> bytes: - # b"SR01\r" -def encode_utility_key(key: int) -> bytes: - # b"UK001\r" -``` - -### 4.6 `paradox/hardware/prt3/event.py` - -Maps PRT3 G-group codes to PAI event descriptors. Same dict shape as `spectra_magellan/event.py` but keyed by integer G-group, not binary major/minor. The `PRT3Panel` will NOT use `LiveEvent` (which requires `po.command == 0xE`); it will use a `PRT3Event(Event)` subclass (defined in this file or in `panel.py`) that takes a `PRT3SystemEvent` dataclass directly. - -```python -# event.py structure -from paradox.data.enums import EventLevel - -EVENT_MAP: dict[int, dict] = { - 0: dict(type="zone", level=EventLevel.DEBUG, change=dict(open=False), ...), - 1: dict(type="zone", level=EventLevel.DEBUG, change=dict(open=True), ...), - 2: dict(type="zone", level=EventLevel.CRITICAL, change=dict(tamper=True), ...), - 3: dict(type="zone", level=EventLevel.CRITICAL, change=dict(fire_loop_trouble=True), ...), - 9: dict(type="partition", level=EventLevel.INFO, change=dict(arm=True), ...), - 10: dict(type="partition", level=EventLevel.INFO, change=dict(arm=True), ...), - 13: dict(type="partition", level=EventLevel.INFO, change=dict(arm=False), ...), - 14: dict(type="partition", level=EventLevel.INFO, change=dict(arm=False), ...), - 24: dict(type="zone", level=EventLevel.CRITICAL, change=dict(alarm=True), ...), - 26: dict(type="zone", level=EventLevel.INFO, change=dict(alarm=False), ...), - # ... all groups from PRT3 protocol reference - 64: dict(type="partition", level=EventLevel.DEBUG, ...), # Status 1 flags - 65: dict(type="partition", level=EventLevel.DEBUG, ...), # Status 2 flags - 66: dict(type="partition", level=EventLevel.DEBUG, ...), # Status 3 flags -} -``` - -G064/G065/G066 are special: `N` encodes a flag index, not an element ID. These need sub-maps like Spectra's `sub` key. - -### 4.7 `paradox/hardware/prt3/property.py` - -Reuse `spectra_magellan/property.py` directly — the property keys (`open`, `arm`, `tamper`, `alarm`, `trouble`, `exit_delay`, etc.) are identical. Import and re-export: - -```python -# property.py -from paradox.hardware.spectra_magellan.property import property_map - -__all__ = ["property_map"] -``` - -Only add new PRT3-specific properties if any new state keys are introduced that don't exist in the Spectra map. - -### 4.8 `paradox/hardware/prt3/panel.py` - -**Class: `PRT3Panel(Panel)`** - -Key design decisions (see §6 for rationale): - -- **`initialize_communication(password)`** — sends nothing; just returns `True`. The `COMM&ok` wait is handled in `Paradox.connect()` before this is called. -- **`load_memory()`** — overrides completely; sends `AL`, `ZL`, `UL` requests one at a time using `_prt3_send_wait()` (see §4.9); populates `self.core.storage` directly; fires `ps.sendMessage("labels_loaded", data=labels)`. -- **`request_status(nr)`** — sends `RA{nr:03d}\r` (if `nr <= max_areas`) or `RZ{nr:03d}\r` (if `nr > max_areas`); parses reply; returns a plain Python dict in the format `{"zone_open": {nr: bool}, ...}` or `{"partition_arm": {nr: bool}, ...}` — compatible with `convert_raw_status()`. -- **`get_status_requests()`** — generator of `request_status(nr)` for all configured areas and zones. Areas use indices 1..`cfg.PRT3_MAX_AREAS`, zones use distinct indices. -- **`parse_message(raw, direction)`** — decodes the ASCII line; calls `parser.parse_line()`; returns a simple namespace or the dataclass directly. For PRT3, `parse_message()` is not used for reply routing (see §4.9); it is only used for unsolicited event dispatch. -- **`control_partitions(partitions, command)`** — maps PAI command strings to PRT3 arm/disarm commands; uses `_prt3_send_wait()`. -- **`control_zones(zones, command)`** — raises `NotImplementedError` (PRT3 has no zone bypass command). -- **`control_outputs(outputs, command)`** — raises `NotImplementedError`. -- **`control_module_pgm_outputs(...)`** — raises `NotImplementedError`. -- **`control_doors(doors, command)`** — raises `NotImplementedError`. -- **`dump_memory(file, memory_type)`** — raises `NotImplementedError`. -- **`send_panic(partitions, panic_type, user_id)`** — maps `panic_type` to `PE`/`PM`/`PF` commands. - -**Partition command map:** -```python -PARTITION_COMMANDS = { - "arm": ("AA", "A"), # regular arm - "arm_stay": ("AA", "S"), # stay arm - "arm_force": ("AA", "F"), # force arm - "arm_sleep": ("AA", "I"), # instant arm (no entry delay) - "arm_quick": ("AQ", "A"), # quick arm (no code) - "disarm": ("AD", None), # disarm -} -``` - -### 4.9 `PRT3Paradox` — runtime subclass - -**File: `paradox/hardware/prt3/runtime.py`** -(Kept with the hardware layer since it is specific to PRT3 operation; imported from `paradox/paradox.py`.) - -**Class: `PRT3Paradox(Paradox)`** - -Overrides only what is different; inherits all MQTT/HA/storage/ps plumbing unchanged. - -#### Reply routing design - -The existing `handler_registry.handle()` error path accesses `data.fields.value.po.command` (a binary-specific field). PRT3 messages never carry this. To avoid that code path and keep a clean separation: - -- **Command echo replies** (RA, RZ, ZL, AL, UL, AA, AD, PE/PM/PF echo) are routed through a dedicated `asyncio.Queue` (`_prt3_reply_queue`), not through `handler_registry`. -- **Unsolicited system events** (`G{ggg}N{nnn}A{aaa}`) are dispatched directly to `ps.sendEvent()`. -- **COMM status** (`COMM&ok/fail`) updates run-state directly. - -#### Overridden methods - -```python -class PRT3Paradox(Paradox): - - def __init__(self, retries=3): - super().__init__(retries) - self._prt3_reply_queue: asyncio.Queue = asyncio.Queue(maxsize=1) - - def _register_connection_handlers(self): - # Only register the raw handler; skip binary EventMessageHandler/ErrorMessageHandler - self.connection.register_raw_handler( - PersistentHandler(self.on_connection_message) - ) - - async def connect(self) -> bool: - """Skip binary handshake; await COMM&ok; create PRT3Panel directly.""" - # (full implementation — not calling super().connect()) - - def on_connection_message(self, message: bytes): - """Route incoming ASCII lines to reply queue or event dispatch.""" - line = message.decode("ascii", errors="replace").rstrip("\r\n") - parsed = parse_line(line) - if parsed is None: - return - if isinstance(parsed, PRT3CommStatus): - self._handle_comm_status(parsed) - elif isinstance(parsed, PRT3SystemEvent): - self._handle_system_event(parsed) - elif isinstance(parsed, (PRT3AreaStatus, PRT3ZoneStatus, PRT3LabelReply, - PRT3CommandEcho)): - try: - self._prt3_reply_queue.put_nowait(parsed) - except asyncio.QueueFull: - logger.warning("PRT3 reply queue full, dropping: %s", parsed) - - async def _prt3_send_wait( - self, - command: bytes, - timeout: float = cfg.IO_TIMEOUT, - ) -> Optional[PRT3Message]: - """Send an ASCII command and wait for exactly one reply.""" - # Drain stale replies - while not self._prt3_reply_queue.empty(): - self._prt3_reply_queue.get_nowait() - async with self.request_lock: - self.connection.write(command) - return await asyncio.wait_for( - self._prt3_reply_queue.get(), timeout=timeout - ) - - def _handle_comm_status(self, msg: PRT3CommStatus): - if msg.ok: - logger.info("PRT3: COMM&ok — panel communication established") - else: - logger.error("PRT3: COMM&fail — panel communication lost") - asyncio.create_task(self.disconnect()) - - def _handle_system_event(self, evt: PRT3SystemEvent): - # Build PRT3Event and dispatch via ps - ... -``` - -#### `connect()` override flow - -``` -PRT3Paradox.connect() - self.run_state = RunState.INIT - await connection.connect() # opens serial port - # Wait for COMM&ok (panel sends this on startup) - comm = await asyncio.wait_for( - self._prt3_reply_queue.get(), timeout=15.0 - ) - if not isinstance(comm, PRT3CommStatus) or not comm.ok: - self.run_state = RunState.ERROR - return False - self.panel = PRT3Panel(self) - await self.panel.initialize_communication(cfg.PASSWORD) # no-op - self.run_state = RunState.CONNECTED - return True -``` - -#### `PRT3Event(Event)` class - -Defined alongside `PRT3Paradox` or in `event.py`. Constructs an `Event` directly from a `PRT3SystemEvent` dataclass and the `EVENT_MAP`, bypassing `LiveEvent`'s `po.command == 0xE` assertion. - -```python -class PRT3Event(Event): - def __init__(self, raw: PRT3SystemEvent, event_map: dict, label_provider=None): - super().__init__(label_provider=label_provider) - entry = event_map.get(raw.group) - if entry is None: - raise AssertionError(f"Unknown PRT3 event group: {raw.group}") - self.major = raw.group - self.minor = raw.number - self.id = raw.number - self.partition = raw.area - self.timestamp = int(time.time()) - # populate level, type, change, tags, message from entry - ... -``` - -### 4.10 `paradox/hardware/prt3/__init__.py` - -```python -from .panel import PRT3Panel -``` - ---- - -## 5. Test files and patterns to follow - -### 5.1 Protocol framer tests — follow `tests/connection/test_serial_protocol.py` - -**File: `tests/connection/prt3/test_protocol.py`** - -Pattern: -```python -from unittest.mock import MagicMock -from paradox.connections.prt3.protocol import PRT3Protocol - -def test_complete_line(): - handler = MagicMock() - p = PRT3Protocol(handler) - p.data_received(b"COMM&ok\r") - handler.on_message.assert_called_once_with(b"COMM&ok\r") - -def test_line_in_chunks(): - handler = MagicMock() - p = PRT3Protocol(handler) - p.data_received(b"COMM") - p.data_received(b"&ok\r") - handler.on_message.assert_called_once_with(b"COMM&ok\r") - -def test_two_lines_in_one_chunk(): - handler = MagicMock() - p = PRT3Protocol(handler) - p.data_received(b"COMM&ok\rG001N005A001\r") - assert handler.on_message.call_count == 2 -``` - -Required cases: -- Single complete line -- Line split across N chunks -- Two lines in one chunk -- Empty (no `\r`) input: handler not called -- Lines ending `\r\n` (strip `\n` too, just in case) -- Garbage bytes before first `\r`: no crash, no spurious calls -- `send_message()` writes bytes directly - -### 5.2 Parser tests — follow `tests/hardware/evo/test_action.py` (pure input/output) - -**File: `tests/hardware/prt3/test_parser.py`** - -Pattern: plain `assert`, parametrize where patterns repeat. -```python -import pytest -from paradox.hardware.prt3.parser import parse_line, PRT3AreaStatus, PRT3ZoneStatus, ... - -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_area_status_disarmed(): - result = parse_line("RA001DOOOOOO") - assert isinstance(result, PRT3AreaStatus) - assert result.area == 1 - assert result.armed == "D" - assert result.alarm is False - assert result.trouble is False - -def test_area_status_armed(): - result = parse_line("RA001AOOOOOO") - assert result.armed == "A" - -@pytest.mark.parametrize("line,zone,status,alarm", [ - ("RZ001COOOO", 1, "C", False), - ("RZ001OOOOO", 1, "O", False), - ("RZ001TOOOO", 1, "T", False), - ("RZ001OAOOO", 1, "O", True), - ("RZ192COOOO", 192, "C", False), -]) -def test_zone_status(line, zone, status, alarm): - result = parse_line(line) - assert isinstance(result, PRT3ZoneStatus) - assert result.zone == zone - assert result.status == status - assert result.alarm == alarm - -def test_zone_label(): - result = parse_line("ZL001Front Door ") - assert isinstance(result, PRT3LabelReply) - assert result.kind == "ZL" - assert result.index == 1 - assert result.label == "Front Door" - -def test_system_event(): - result = parse_line("G001N005A006") - assert isinstance(result, PRT3SystemEvent) - assert result.group == 1 - assert result.number == 5 - assert result.area == 6 - -def test_unknown_line_returns_none(): - assert parse_line("JUNK") is None - assert parse_line("") is None -``` - -Fixture file: `tests/hardware/prt3/fixtures/session_events.txt` — one raw line per line, used as a replay corpus. - -### 5.3 Encoder tests — follow `tests/hardware/evo/test_action.py` - -**File: `tests/hardware/prt3/test_encoder.py`** - -```python -from paradox.hardware.prt3.encoder import ( - encode_request_area_status, encode_arm, encode_disarm, ... -) - -def test_encode_request_area_status(): - assert encode_request_area_status(1) == b"RA001\r" - assert encode_request_area_status(8) == b"RA008\r" - -def test_encode_request_zone_status(): - assert encode_request_zone_status(1) == b"RZ001\r" - assert encode_request_zone_status(192) == b"RZ192\r" - -def test_encode_arm_regular(): - assert encode_arm(1, "A", "1234") == b"AA01A1234\r" - -def test_encode_arm_stay(): - assert encode_arm(2, "S", "123456") == b"AA02S123456\r" - -def test_encode_disarm(): - assert encode_disarm(1, "1234") == b"AD011234\r" - -def test_encode_quick_arm(): - assert encode_quick_arm(1, "A") == b"AQ01A\r" - -def test_encode_panic_emergency(): - assert encode_panic_emergency(1) == b"PE01\r" - -def test_encode_utility_key(): - assert encode_utility_key(1) == b"UK001\r" - assert encode_utility_key(251) == b"UK251\r" -``` - -### 5.4 Panel integration tests — follow `tests/hardware/evo/test_initialize_communication.py` - -**File: `tests/hardware/prt3/test_panel.py`** - -Uses `unittest.mock.MagicMock` for the `core` (`Paradox` instance) and monkey-patches `_prt3_send_wait`. - -```python -from unittest.mock import AsyncMock, MagicMock, patch -import pytest -from paradox.hardware.prt3.panel import PRT3Panel - -@pytest.fixture -def mock_core(): - core = MagicMock() - core._prt3_send_wait = AsyncMock() - core.storage = MagicMock() - return core - -@pytest.mark.asyncio -async def test_request_area_status_disarmed(mock_core): - from paradox.hardware.prt3.parser import PRT3AreaStatus - mock_core._prt3_send_wait.return_value = PRT3AreaStatus( - area=1, armed="D", programming=False, trouble=False, - ready=True, alarm=False, strobe=False, alarm_in_memory=False - ) - panel = PRT3Panel(mock_core) - result = await panel.request_status(1) - assert result["partition_arm"][1] is False - assert result["partition_alarm"][1] is False - -@pytest.mark.asyncio -async def test_request_area_status_armed(mock_core): - from paradox.hardware.prt3.parser import PRT3AreaStatus - mock_core._prt3_send_wait.return_value = PRT3AreaStatus( - area=1, armed="A", programming=False, trouble=False, - ready=True, alarm=False, strobe=False, alarm_in_memory=False - ) - panel = PRT3Panel(mock_core) - result = await panel.request_status(1) - assert result["partition_arm"][1] is True - -@pytest.mark.asyncio -async def test_control_partitions_arm(mock_core): - from paradox.hardware.prt3.parser import PRT3CommandEcho - mock_core._prt3_send_wait.return_value = PRT3CommandEcho( - prefix="AA01A", ok=True, payload="" - ) - panel = PRT3Panel(mock_core) - result = await panel.control_partitions([1], "arm") - assert result is True -``` - -### 5.5 Event map tests — follow `tests/hardware/spectra_magellan/test_event_parsing.py` - -**File: `tests/hardware/prt3/test_parser.py`** (add to same file or separate `test_events.py`) - -```python -from paradox.hardware.prt3.event import EVENT_MAP -from paradox.hardware.prt3.runtime import PRT3Event -from paradox.hardware.prt3.parser import PRT3SystemEvent - -def test_zone_open_event(): - raw = PRT3SystemEvent(group=1, number=5, area=2) - evt = PRT3Event(raw, EVENT_MAP) - assert evt.type == "zone" - assert evt.change == {"open": True} - -def test_zone_alarm_event(): - raw = PRT3SystemEvent(group=24, number=3, area=1) - evt = PRT3Event(raw, EVENT_MAP) - assert evt.type == "zone" - assert evt.change.get("alarm") is True - -def test_arm_event(): - raw = PRT3SystemEvent(group=10, number=42, area=1) - evt = PRT3Event(raw, EVENT_MAP) - assert evt.type == "partition" - assert evt.change.get("arm") is True -``` - ---- - -## 6. Design decisions and rationale - -### 6.1 Why `asyncio.Queue` for reply routing (not `handler_registry`) - -`handler_registry.handle()` logs `data.fields.value.po.command` when no handler matches. PRT3 messages are plain dataclasses, not `Construct Container`s. Routing them through `handler_registry` would require either faking the `.fields.value.po.command` shape (fragile) or silencing the no-handler error globally (hides bugs). A dedicated `asyncio.Queue` keeps reply routing entirely inside PRT3-specific code with zero risk to existing behaviour. - -### 6.2 Why `PRT3Paradox(Paradox)` subclass (not `connect()` guard) - -The guard approach (`if cfg.CONNECTION_TYPE == "PRT3": return early`) pollutes `paradox.py` with repeated guards. A subclass puts all PRT3-specific orchestration in one file with a clean `super()` boundary. It also makes `main.py` changes minimal (one import line). - -### 6.3 Why `SerialCommunication` is reused as base for `PRT3SerialConnection` - -`SerialCommunication.connect()` handles: permissions check+fix, `connected_future`, timeout handler, `serial_asyncio.create_serial_connection()`, exception mapping. All of this is wanted. Only `make_protocol()` differs. Subclassing avoids duplicating ~40 lines of robust serial open code. - -### 6.4 Why `property_map` is imported from `spectra_magellan` - -The PAI property names (`open`, `arm`, `arm_stay`, `alarm`, `trouble`, `exit_delay`, etc.) are protocol-independent — they describe alarm state semantics. The Spectra map has all the properties PRT3 needs. Sharing it avoids drift between two copies of the same data. If PRT3 introduces new properties, they can be added to a local map that extends the shared one. - -### 6.5 Why `load_memory()` is fully overridden (not `load_labels()`) - -`Panel.load_memory()` calls `load_definitions()` then `load_labels()`. Both rely on `_eeprom_batch_reader()` → `send_wait(ReadEEPROM, ...)`. PRT3 has no EEPROM read facility. Overriding only `load_labels()` would leave `load_definitions()` attempting EEPROM reads and failing. It is cleaner and safer to override `load_memory()` entirely. - -### 6.6 Why `request_status()` returns a flat dict (not a Container) - -`Paradox.loop()` calls `deep_merge(*results)` then `_process_status(merged)` → `convert_raw_status()`. `convert_raw_status()` requires dict keys of the form `{type}_{property}` with `int`-keyed sub-dicts. This is a stable, documented internal format. Returning a plain Python dict in this format from PRT3 `request_status()` means the entire downstream status pipeline (`ps.sendMessage("status_update")`, `_on_status_update`, `MemoryStorage.update_container_object`, `Change` events, MQTT publish) works without modification. - ---- - -## 7. Phased implementation plan - -Each phase is independently testable before the next begins. - -### Phase 1 — ASCII framer (transport layer) -**Files**: `connections/prt3/protocol.py`, `connections/prt3/connection.py`, `connections/prt3/__init__.py` -**Tests**: `tests/connection/prt3/test_protocol.py` -**Completion criteria**: `PRT3Protocol` passes all framer tests; no PAI runtime code touched. - -### Phase 2 — Pure protocol layer (parser + encoder) -**Files**: `hardware/prt3/parser.py`, `hardware/prt3/encoder.py` -**Tests**: `tests/hardware/prt3/test_parser.py`, `tests/hardware/prt3/test_encoder.py` -**Completion criteria**: All parse and encode functions pass unit tests with fixture strings from the protocol reference. 100% coverage of the protocol table. - -### Phase 3 — Event and property maps -**Files**: `hardware/prt3/event.py`, `hardware/prt3/property.py` -**Tests**: event map tests in `test_parser.py` or `test_events.py` -**Completion criteria**: All G-group codes in the protocol reference have entries; `PRT3Event` constructs cleanly from each. - -### Phase 4 — Panel adapter -**Files**: `hardware/prt3/panel.py`, `hardware/prt3/__init__.py` -**Tests**: `tests/hardware/prt3/test_panel.py` -**Completion criteria**: `request_status()`, `load_memory()`, `control_partitions()`, `send_panic()` pass tests against mock core; unsupported methods raise `NotImplementedError`. - -### Phase 5 — Runtime integration -**Files**: `hardware/prt3/runtime.py` (`PRT3Paradox`), `config.py` (2 insertions), `paradox.py` (2 insertions) -**Tests**: extend `test_panel.py` with `PRT3Paradox` integration test using a mock serial transport -**Completion criteria**: `PRT3Paradox.connect()` flows correctly with a simulated `COMM&ok` response; label load and status poll dispatch to storage; no existing tests break (`pytest tests/` clean). - -### Phase 6 — End-to-end replay test -**Files**: `tests/hardware/prt3/fixtures/*.txt`, `tests/hardware/prt3/test_panel.py` -**Tests**: replay-based test that feeds a session transcript through `PRT3Protocol` + `PRT3Paradox.on_connection_message()` and asserts final storage state -**Completion criteria**: storage state after replay matches expected partition arm states and zone open states. - -### Phase 7 — Config example and docs -**Files**: `config/pai-prt3.conf.example`; update `docs/prt3-architecture.md` if anything changed -**Completion criteria**: config example is loadable by `cfg.load()`; no undocumented limitations. - ---- - -## 8. Risk register - -| Risk | Mitigation | -|---|---| -| `_process_status` format changes in future PAI | Our flat-dict format matches the existing contract; any upstream change affects all backends equally | -| `HandlerRegistry` error path on `data.fields.value.po.command` | Fully avoided by reply-queue design; existing `handler_registry` is never fed PRT3 messages | -| `LiveEvent.__init__` asserts `po.command == 0xE` | Fully avoided by `PRT3Event(Event)` subclass; `LiveEvent` is never called for PRT3 events | -| `SerialCommunication.connect()` changes upstream | Only `make_protocol()` is overridden; any fix to the open logic is automatically inherited | -| `load_memory()` EEPROM path called for PRT3 | Fully avoided by overriding `load_memory()` entirely | -| Existing tests broken by config.py change | `CONNECTION_TYPE` constraint is widened, not narrowed; all existing tests pass `"Serial"` or `"IP"` | -| Baud mismatch (panel default 9600, control4 guide recommends 19200) | Documented in limitations; `PRT3_SERIAL_BAUD` config key lets user set either | From 9db84191874e6fd8eb8d9bac7cdad6422b114455 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 14:14:21 +1000 Subject: [PATCH 14/21] fix: address SonarQube code-smell findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit event.py: replace dict() constructor calls with {} literals (43 instances) button.py: same (1 instance) parser.py: reword PRT3PgmEvent.on inline comment to avoid false "commented-out code" flag; refactor parse_line to pre-compute has_digit_index — eliminates repeated and-chains and 3 nesting levels, reducing cognitive complexity from 23 to 12 panel.py: extract _load_label_range() from load_labels (complexity 27→8), extract _poll_area_statuses() and _poll_zone_statuses() from request_status (complexity 18→5), extract _build_partition_cmd() from control_partitions (complexity 17→9) paradox.py: extract _prt3_connect() from connect() (complexity 20→13) test_protocol.py: add explanatory comments to empty interface stubs; rename unused 'handler' variable to '_' test_adapter.py: rename unused loop variables to '_' test_encoder.py: add NOSONAR to intentional wrong-type test argument Co-Authored-By: Claude Sonnet 4.6 --- paradox/hardware/prt3/event.py | 356 ++++++++++----------- paradox/hardware/prt3/panel.py | 185 +++++------ paradox/hardware/prt3/parser.py | 36 +-- paradox/interfaces/mqtt/entities/button.py | 16 +- paradox/paradox.py | 60 ++-- tests/connection/prt3/test_protocol.py | 4 +- tests/hardware/prt3/test_adapter.py | 8 +- tests/hardware/prt3/test_encoder.py | 2 +- 8 files changed, 321 insertions(+), 346 deletions(-) diff --git a/paradox/hardware/prt3/event.py b/paradox/hardware/prt3/event.py index 048dc7a1..bc8ab0e6 100644 --- a/paradox/hardware/prt3/event.py +++ b/paradox/hardware/prt3/event.py @@ -38,188 +38,188 @@ EVENT_MAP: dict = { # Zone status events (number = zone ID, area = affected area) - 0: dict(type="zone", subtype="restored", level=EventLevel.INFO, - change={"alarm": False, "open": False}, - tags=["zone", "restore"], - message="Zone {label} restored"), - 1: dict(type="zone", subtype="open", level=EventLevel.DEBUG, - change={"open": True}, - tags=["zone", "open"], - message="Zone {label} open"), - 2: dict(type="zone", subtype="tampered", level=EventLevel.CRITICAL, - change={"tamper": True, "open": True}, - tags=["zone", "tamper", "trouble"], - message="Zone {label} tampered"), - 3: dict(type="zone", subtype="fire_loop_trouble", level=EventLevel.CRITICAL, - change={"fire_loop_trouble": True}, - tags=["zone", "trouble", "fire"], - message="Zone {label} fire loop trouble"), + 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 (number = user ID, area = affected partition) # exit_delay cleared because the panel is now fully armed (delay is over) - 10: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True, "exit_delay": False}, - tags=["arm", "user"], - message="Partition {label} armed by user"), - 11: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True, "exit_delay": False}, - tags=["arm", "master"], - message="Partition {label} armed by master"), - 12: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True, "exit_delay": False}, - tags=["arm", "keyswitch"], - message="Partition {label} armed via keyswitch"), - 13: dict(type="partition", subtype="arm", level=EventLevel.INFO, - change={"arm": True, "exit_delay": False}, - tags=["arm", "auto"], - message="Partition {label} auto-armed"), + 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", "master"], + "message": "Partition {label} armed by master"}, + 12: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, + "change": {"arm": True, "exit_delay": False}, + "tags": ["arm", "keyswitch"], + "message": "Partition {label} armed via keyswitch"}, + 13: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, + "change": {"arm": True, "exit_delay": False}, + "tags": ["arm", "auto"], + "message": "Partition {label} auto-armed"}, # Disarm events (number = user ID, area = affected partition) # exit_delay cleared because arming was cancelled - 14: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False, "exit_delay": False}, - tags=["disarm", "user"], - message="Partition {label} disarmed by user"), - 15: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False, "exit_delay": False}, - tags=["disarm", "master"], - message="Partition {label} disarmed by master"), - 16: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False, "exit_delay": False}, - tags=["disarm", "keyswitch"], - message="Partition {label} disarmed via keyswitch"), - 17: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False, "exit_delay": False, "audible_alarm": False}, - tags=["disarm", "alarm_cancel"], - message="Partition {label} disarmed after alarm"), - 18: dict(type="partition", subtype="alarm_cancelled", level=EventLevel.INFO, - change={"audible_alarm": False}, - tags=["alarm", "cancel"], - message="Partition {label} alarm cancelled"), - 20: dict(type="partition", subtype="disarm", level=EventLevel.INFO, - change={"arm": False, "exit_delay": False}, - tags=["disarm", "special"], - message="Partition {label} special disarm"), + 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", "master"], + "message": "Partition {label} disarmed by master"}, + 16: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False}, + "tags": ["disarm", "keyswitch"], + "message": "Partition {label} disarmed via keyswitch"}, + 17: {"type": "partition", "subtype": "disarm", "level": EventLevel.INFO, + "change": {"arm": False, "exit_delay": False, "audible_alarm": False}, + "tags": ["disarm", "alarm_cancel"], + "message": "Partition {label} disarmed after alarm"}, + 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: dict(type="zone", subtype="bypassed", level=EventLevel.INFO, - change={"bypassed": True}, - tags=["zone", "bypass"], - message="Zone {label} bypassed"), - 23: dict(type="zone", subtype="bypass_cancelled", level=EventLevel.INFO, - change={"bypassed": False}, - tags=["zone", "bypass"], - message="Zone {label} bypass cancelled"), + 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: dict(type="zone", subtype="alarm", level=EventLevel.CRITICAL, - change={"alarm": True}, - tags=["zone", "alarm"], - message="Zone {label} in alarm"), - 25: dict(type="zone", subtype="fire_alarm", level=EventLevel.CRITICAL, - change={"fire": True}, - tags=["zone", "alarm", "fire"], - message="Zone {label} fire alarm"), - 26: dict(type="zone", subtype="alarm_restored", level=EventLevel.INFO, - change={"alarm": False}, - tags=["zone", "alarm", "restore"], - message="Zone {label} alarm restored"), - 27: dict(type="zone", subtype="fire_alarm_restored", level=EventLevel.INFO, - change={"fire": False}, - tags=["zone", "alarm", "fire", "restore"], - message="Zone {label} fire alarm restored"), + 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: dict(type="partition", subtype="panic_alarm", level=EventLevel.CRITICAL, - change={"panic_alarm": True}, - tags=["alarm", "panic"], - message="Partition {label} panic alarm"), - 30: dict(type="partition", subtype="alarm", level=EventLevel.CRITICAL, - change={"audible_alarm": True}, - tags=["alarm"], - message="Partition {label} alarm shutdown"), - 31: dict(type="zone", subtype="tamper_alarm", level=EventLevel.CRITICAL, - change={"alarm": True, "tamper": True}, - tags=["zone", "alarm", "tamper"], - message="Zone {label} tamper alarm"), - 32: dict(type="zone", subtype="tamper_restored", level=EventLevel.INFO, - change={"tamper": False}, - tags=["zone", "tamper", "restore"], - message="Zone {label} tamper restored"), + 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: dict(type="system", subtype="trouble", level=EventLevel.CRITICAL, - change={}, - tags=["trouble"], - message="New trouble event"), - 34: dict(type="system", subtype="trouble_restored", level=EventLevel.INFO, - change={}, - tags=["trouble", "restore"], - message="Trouble restored"), - 36: dict(type="system", subtype="ac_failure", level=EventLevel.CRITICAL, - change={"ac_failure_trouble": True}, - tags=["trouble", "power"], - message="AC power failure"), - 38: dict(type="system", subtype="battery_trouble", level=EventLevel.CRITICAL, - change={"battery_failure_trouble": True}, - tags=["trouble", "battery"], - message="Battery trouble"), + 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: dict(type="system", subtype="power_up", level=EventLevel.INFO, - change={}, - tags=["system", "power"], - message="Panel power-up"), + 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: dict(type="system", subtype="utility_key", level=EventLevel.INFO, - change={}, - tags=["system", "utility"], - message="Utility key {number} activated"), + 48: {"type": "system", "subtype": "utility_key", "level": EventLevel.INFO, + "change": {}, + "tags": ["system", "utility"], + "message": "Utility key {number} activated"}, # Zone lifecycle - 56: dict(type="zone", subtype="bypassed", level=EventLevel.INFO, - change={"bypassed": True}, - tags=["zone", "bypass"], - message="Zone {label} bypassed"), - 59: dict(type="zone", subtype="closed", level=EventLevel.DEBUG, - change={"open": False}, - tags=["zone"], - message="Zone {label} closed"), - 60: dict(type="zone", subtype="low_battery", level=EventLevel.CRITICAL, - change={"low_battery_trouble": True}, - tags=["zone", "trouble", "battery"], - message="Zone {label} low battery"), - 61: dict(type="zone", subtype="supervision_trouble", level=EventLevel.CRITICAL, - change={"supervision_trouble": True}, - tags=["zone", "trouble", "supervision"], - message="Zone {label} supervision failure"), - 62: dict(type="zone", subtype="low_battery_restored", level=EventLevel.INFO, - change={"low_battery_trouble": False}, - tags=["zone", "battery", "restore"], - message="Zone {label} battery restored"), - 63: dict(type="zone", subtype="supervision_restored", level=EventLevel.INFO, - change={"supervision_trouble": False}, - tags=["zone", "supervision", "restore"], - message="Zone {label} supervision restored"), + 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: dict(type="partition", subtype="status_armed", level=EventLevel.DEBUG, - change={}, - tags=["status"], - message="Partition {label} Status-1 event (N{number})"), + 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 applied in from_prt3() below; this is the fallback. - 65: dict(type="partition", subtype="status_update", level=EventLevel.DEBUG, - change={}, - tags=["status"], - message="Partition {label} Status-2 event (N{number})"), - 66: dict(type="system", subtype="status_tamper", level=EventLevel.CRITICAL, - change={}, - tags=["trouble", "tamper", "status"], - message="Status: tamper or trouble detected"), + 65: {"type": "partition", "subtype": "status_update", "level": EventLevel.DEBUG, + "change": {}, + "tags": ["status"], + "message": "Partition {label} Status-2 event (N{number})"}, + 66: {"type": "system", "subtype": "status_tamper", "level": EventLevel.CRITICAL, + "change": {}, + "tags": ["trouble", "tamper", "status"], + "message": "Status: tamper or trouble detected"}, } @@ -255,35 +255,35 @@ def from_prt3(cls, prt3_event, label_provider=None) -> "PRT3Event": if prt3_event.group == 65: n = prt3_event.number if n == 1: # exit delay started → show HA "arming" state - descriptor = dict( - type="partition", subtype="exit_delay", - level=EventLevel.INFO, - change={"exit_delay": True}, - tags=["status", "exit_delay"], - message="Partition {label} exit delay started", - ) + descriptor = { + "type": "partition", "subtype": "exit_delay", + "level": EventLevel.INFO, + "change": {"exit_delay": True}, + "tags": ["status", "exit_delay"], + "message": "Partition {label} exit delay started", + } elif n == 2: # entry delay started - descriptor = dict( - type="partition", subtype="entry_delay", - level=EventLevel.INFO, - change={"entry_delay": True}, - tags=["status", "entry_delay"], - message="Partition {label} entry delay started", - ) + descriptor = { + "type": "partition", "subtype": "entry_delay", + "level": EventLevel.INFO, + "change": {"entry_delay": True}, + "tags": ["status", "entry_delay"], + "message": "Partition {label} entry delay started", + } 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 = dict( - type="system", - subtype="unknown", - level=EventLevel.DEBUG, - change={}, - tags=["unknown"], - message=f"Unknown event G{prt3_event.group:03d}", - ) + 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 diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py index 3ae9d98f..8c95e8be 100644 --- a/paradox/hardware/prt3/panel.py +++ b/paradox/hardware/prt3/panel.py @@ -254,73 +254,45 @@ async def load_definitions(self) -> dict: # Label loading # ------------------------------------------------------------------ - 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") + 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 = [] - - # Area labels — AL001..AL{max} - for area in range(1, cfg.PRT3_MAX_AREAS + 1): - cmd = encoder.encode_area_label_request(area) - expected_cmd = f"AL{area:03d}" + 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, a=area: ( - (isinstance(m, PRT3LabelReply) and m.element_type == "area" and m.index == a) + 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: area %d label not found (panel returned &fail)", area) + logger.debug("PRT3: %s %d label not found", element_type, i) elif msg is None: - logger.warning("PRT3: timeout loading area %d label", area) + logger.warning("PRT3: timeout loading %s %d label", element_type, i) + return replies - # Zone labels — ZL001..ZL{max} - for zone in range(1, cfg.PRT3_MAX_ZONES + 1): - cmd = encoder.encode_zone_label_request(zone) - expected_cmd = f"ZL{zone:03d}" - msg = await self._prt3_send_wait( - cmd, - lambda m, ec=expected_cmd, z=zone: ( - (isinstance(m, PRT3LabelReply) and m.element_type == "zone" and m.index == z) - 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: zone %d label not found", zone) - elif msg is None: - logger.warning("PRT3: timeout loading zone %d label", zone) + async def load_labels(self) -> dict: + """ + Request area, zone, and user labels via AL/ZL/UL ASCII commands. - # User labels — UL001..UL{max} - for user in range(1, cfg.PRT3_MAX_USERS + 1): - cmd = encoder.encode_user_label_request(user) - expected_cmd = f"UL{user:03d}" - msg = await self._prt3_send_wait( - cmd, - lambda m, ec=expected_cmd, u=user: ( - (isinstance(m, PRT3LabelReply) and m.element_type == "user" and m.index == u) - 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: user %d label not found", user) - elif msg is None: - logger.warning("PRT3: timeout loading user %d label", user) + 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", @@ -334,29 +306,9 @@ async def load_labels(self) -> dict: # Status polling # ------------------------------------------------------------------ - 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 = [] - zone_msgs = [] - + 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}" @@ -368,12 +320,16 @@ async def request_status(self, nr: int) -> dict: ), ) if isinstance(msg, PRT3AreaStatus): - area_msgs.append(msg) + 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}" @@ -385,18 +341,65 @@ async def request_status(self, nr: int) -> dict: ), ) if isinstance(msg, PRT3ZoneStatus): - zone_msgs.append(msg) + 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 + return encoder.encode_disarm(partition, user_code), f"AD{partition:03d}" + + if command in _QUICK_ARM_MODES: + mode = _QUICK_ARM_MODES[command] + if user_code: + return encoder.encode_arm(partition, mode, user_code), f"AA{partition:03d}" + 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. @@ -411,28 +414,10 @@ async def control_partitions(self, partitions: list, command: str) -> bool: accepted = False for partition in partitions: - if command == "disarm": - if not user_code: - logger.error( - "PRT3: disarm requires PRT3_USER_CODE to be configured" - ) - continue - cmd = encoder.encode_disarm(partition, user_code) - expected_echo = f"AD{partition:03d}" - - elif command in _QUICK_ARM_MODES: - mode = _QUICK_ARM_MODES[command] - if user_code: - cmd = encoder.encode_arm(partition, mode, user_code) - expected_echo = f"AA{partition:03d}" - else: - cmd = encoder.encode_quick_arm(partition, mode) - expected_echo = f"AQ{partition:03d}" - - else: - logger.error("PRT3: unknown partition command %r", command) + 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: ( diff --git a/paradox/hardware/prt3/parser.py b/paradox/hardware/prt3/parser.py index 531d73f7..bd7fd3f7 100644 --- a/paradox/hardware/prt3/parser.py +++ b/paradox/hardware/prt3/parser.py @@ -169,7 +169,7 @@ class PRT3SystemEvent: class PRT3PgmEvent: """Virtual PGM activation/deactivation event (v1 scope: parsed, not acted on).""" pgm: int # 1-30 - on: bool # True = activated (PGMxxON), False = deactivated (PGMxxOFF) + on: bool # True if PGMxxON (activated), False if PGMxxOFF (deactivated) # Union type exported for type annotations in callers @@ -258,33 +258,17 @@ def parse_line(line: str) -> Optional[PRT3Message]: if m: return PRT3PgmEvent(pgm=int(m.group(1)), on=False) - # 5. Area status reply: RA{nnn}{7-char flags} = 12 chars - if ( - line.startswith("RA") - and len(line) > 4 - and line[2:5].isdigit() - ): - if len(line) == _AREA_STATUS_LEN: + # 5–7. Structured info replies keyed by 2-char prefix + 3-digit index. + # Pre-check digit index once; each branch tests the exact expected length + # so short echoes (e.g. RA001&fail) fall through to the echo check below. + has_digit_index = len(line) >= 5 and line[2:5].isdigit() + if has_digit_index: + prefix2 = line[:2] + if prefix2 == "RA" and len(line) == _AREA_STATUS_LEN: return _parse_area_status(line) - # Shorter (e.g. RA001&fail) falls through to the echo check below - - # 6. Zone status reply: RZ{nnn}{5-char flags} = 10 chars - if ( - line.startswith("RZ") - and len(line) > 4 - and line[2:5].isdigit() - ): - if len(line) == _ZONE_STATUS_LEN: + if prefix2 == "RZ" and len(line) == _ZONE_STATUS_LEN: return _parse_zone_status(line) - - # 7. Label replies: ZL/AL/UL{nnn}{16-char label} = 21 chars - prefix2 = line[:2] - if ( - prefix2 in _LABEL_PREFIXES - and len(line) > 4 - and line[2:5].isdigit() - ): - if len(line) == _LABEL_LEN: + if prefix2 in _LABEL_PREFIXES and len(line) == _LABEL_LEN: return _parse_label(line) # 8. Command echoes: {5 chars}&OK (8) or {5 chars}&fail (10) diff --git a/paradox/interfaces/mqtt/entities/button.py b/paradox/interfaces/mqtt/entities/button.py index 1fff9f06..20d7808c 100644 --- a/paradox/interfaces/mqtt/entities/button.py +++ b/paradox/interfaces/mqtt/entities/button.py @@ -59,11 +59,11 @@ def serialize(self) -> dict: "model": self.device.model, } ) - return dict( - 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", - ) + 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/paradox.py b/paradox/paradox.py index abef9940..a9afb5a0 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -133,6 +133,37 @@ def _register_connection_handlers(self): 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 panel did not respond with COMM&ok") + # 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() @@ -153,34 +184,7 @@ async def connect(self) -> bool: # PRT3 uses ASCII framing — binary panel detection does not apply. if cfg.CONNECTION_TYPE == "PRT3": - from paradox.hardware.prt3.panel import PRT3Panel - - self.panel = PRT3Panel(self) - try: - if not await self.panel.initialize_communication(None): - raise ConnectionError("PRT3 panel did not respond with COMM&ok") - # 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 + return await self._prt3_connect() if not self.panel: self.panel = create_panel(self) diff --git a/tests/connection/prt3/test_protocol.py b/tests/connection/prt3/test_protocol.py index b740ee14..253b7b14 100644 --- a/tests/connection/prt3/test_protocol.py +++ b/tests/connection/prt3/test_protocol.py @@ -54,9 +54,11 @@ 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 @@ -106,7 +108,7 @@ def test_partial_line_not_emitted_until_cr(): def test_buffer_drained_after_complete_line(): - proto, handler = _make_proto() + proto, _ = _make_proto() proto.data_received(b"COMM&ok\r") assert proto.buffer == b"" diff --git a/tests/hardware/prt3/test_adapter.py b/tests/hardware/prt3/test_adapter.py index 44a48a00..3866ddae 100644 --- a/tests/hardware/prt3/test_adapter.py +++ b/tests/hardware/prt3/test_adapter.py @@ -282,19 +282,19 @@ def test_zone_number_not_in_output(self): class TestLabelEntryFromReply: def test_zone_stays_zone(self): - container, idx, entry = label_entry_from_reply( + 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, idx, entry = label_entry_from_reply( + container, _, _ = label_entry_from_reply( _label(element_type="area", index=1, label="Home ") ) assert container == "partition" def test_user_stays_user(self): - container, idx, entry = label_entry_from_reply( + container, _, _ = label_entry_from_reply( _label(element_type="user", index=1, label="Master ") ) assert container == "user" @@ -344,7 +344,7 @@ def test_max_zone_192(self): assert entry["id"] == 192 def test_max_user_999(self): - container, idx, entry = label_entry_from_reply( + _, idx, _ = label_entry_from_reply( PRT3LabelReply(element_type="user", index=999, label="User 999 ") ) assert idx == 999 diff --git a/tests/hardware/prt3/test_encoder.py b/tests/hardware/prt3/test_encoder.py index a9a3524e..2da3ff13 100644 --- a/tests/hardware/prt3/test_encoder.py +++ b/tests/hardware/prt3/test_encoder.py @@ -246,7 +246,7 @@ def test_non_digit_code_raises(self): def test_code_type_error_raises(self): with pytest.raises((ValueError, TypeError)): - encode_arm(1, ARM_MODE_AWAY, 1234) # type: ignore[arg-type] + encode_arm(1, ARM_MODE_AWAY, 1234) # type: ignore[arg-type] # NOSONAR # --------------------------------------------------------------------------- From 264028700ef1d616b0a4afb1752b78dab0179123 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 14:21:30 +1000 Subject: [PATCH 15/21] fix: address GitHub Copilot PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit homeassistant.py: gate utility-key discovery publish on CONNECTION_TYPE == 'PRT3' to prevent non-functional HA buttons on non-PRT3 connections; add 1..251 range check with warning in _publish_utility_key_configs() panel.py: redact user code from retry-timeout log — log only the 5-char command prefix instead of full command bytes config.py: allow PRT3_MAX_ZONES and PRT3_MAX_USERS to be set to 0 to disable zone polling / user label loading respectively paradox.py: fix misleading ConnectionError message on link-unresponsive failure; catch ValueError/TypeError in control_utility_key() for out-of-range key numbers Co-Authored-By: Claude Sonnet 4.6 --- paradox/config.py | 4 ++-- paradox/hardware/prt3/panel.py | 4 ++-- paradox/interfaces/mqtt/homeassistant.py | 8 +++++++- paradox/paradox.py | 5 ++++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/paradox/config.py b/paradox/config.py index 16fb6f6c..6b1788d2 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -33,8 +33,8 @@ class Config: "PRT3_SERIAL_PORT": "/dev/ttyUSB0", # Serial port for PRT3 module "PRT3_SERIAL_BAUD": (9600, int, (2400, 115200)), # Baud rate; 9600 or 19200 typical "PRT3_MAX_AREAS": (8, int, (1, 8)), # Number of areas on the panel - "PRT3_MAX_ZONES": (96, int, (1, 192)), # Number of zones on the panel - "PRT3_MAX_USERS": (32, int, (1, 999)), # Number of user codes 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. This value is never written to logs. diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py index 8c95e8be..7b074a41 100644 --- a/paradox/hardware/prt3/panel.py +++ b/paradox/hardware/prt3/panel.py @@ -169,8 +169,8 @@ async def _prt3_send_wait( except asyncio.TimeoutError: if attempt < retries: logger.warning( - "PRT3: timeout on attempt %d/%d, retrying: %r", - attempt, retries, command_bytes, + "PRT3: timeout on attempt %d/%d, retrying: %s", + attempt, retries, command_bytes[:5], ) return None diff --git a/paradox/interfaces/mqtt/homeassistant.py b/paradox/interfaces/mqtt/homeassistant.py index d079c854..b39e2a07 100644 --- a/paradox/interfaces/mqtt/homeassistant.py +++ b/paradox/interfaces/mqtt/homeassistant.py @@ -87,7 +87,7 @@ 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.PRT3_UTILITY_KEYS: + 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): @@ -196,5 +196,11 @@ def _publish_utility_key_configs(self, utility_keys: dict): 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/paradox.py b/paradox/paradox.py index a9afb5a0..e5d0e7d6 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -140,7 +140,7 @@ async def _prt3_connect(self) -> bool: self.panel = PRT3Panel(self) try: if not await self.panel.initialize_communication(None): - raise ConnectionError("PRT3 panel did not respond with COMM&ok") + 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. @@ -563,6 +563,9 @@ async def control_utility_key(self, key: int) -> bool: 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 From 425a4e790b873f8e9f1584b5a0ccabeee398b8b8 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Fri, 17 Apr 2026 14:36:49 +1000 Subject: [PATCH 16/21] fix: address remaining two SonarQube findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit panel.py: remove vestigial 'timeout' parameter from _prt3_send_wait — no caller passed an explicit value; cfg.IO_TIMEOUT is used directly parser.py: extract _parse_info_reply() from parse_line, reducing cognitive complexity from 22 to 12 (limit: 15) Co-Authored-By: Claude Sonnet 4.6 --- paradox/hardware/prt3/panel.py | 5 +---- paradox/hardware/prt3/parser.py | 35 ++++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py index 7b074a41..c313ed77 100644 --- a/paradox/hardware/prt3/panel.py +++ b/paradox/hardware/prt3/panel.py @@ -138,7 +138,6 @@ async def _prt3_send_wait( self, command_bytes: bytes, predicate, - timeout: Optional[float] = None, retries: int = 1, ): """ @@ -151,20 +150,18 @@ async def _prt3_send_wait( :param command_bytes: Encoded command bytes (\\r-terminated). :param predicate: callable(PRT3Message) → bool. The first message for which this returns True is returned. - :param timeout: Override the default IO_TIMEOUT. :param retries: Total attempts (1 = no retry, 2 = one retry…). Retries are only performed on timeout; a received reply (ok or &fail) is returned immediately. :returns: Matching PRT3Message, or None if all attempts timed out. """ - _timeout = timeout if timeout is not None else cfg.IO_TIMEOUT async with self.core.request_lock: for attempt in range(1, retries + 1): self.core.connection.write(command_bytes) try: return await self.core.connection.wait_for_message( - predicate, timeout=_timeout + predicate, timeout=cfg.IO_TIMEOUT ) except asyncio.TimeoutError: if attempt < retries: diff --git a/paradox/hardware/prt3/parser.py b/paradox/hardware/prt3/parser.py index bd7fd3f7..43b171b3 100644 --- a/paradox/hardware/prt3/parser.py +++ b/paradox/hardware/prt3/parser.py @@ -258,18 +258,11 @@ def parse_line(line: str) -> Optional[PRT3Message]: if m: return PRT3PgmEvent(pgm=int(m.group(1)), on=False) - # 5–7. Structured info replies keyed by 2-char prefix + 3-digit index. - # Pre-check digit index once; each branch tests the exact expected length - # so short echoes (e.g. RA001&fail) fall through to the echo check below. - has_digit_index = len(line) >= 5 and line[2:5].isdigit() - if has_digit_index: - 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) + # 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. @@ -287,6 +280,24 @@ def parse_line(line: str) -> Optional[PRT3Message]: # --------------------------------------------------------------------------- +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]) From 3fe0b14e077cef937a7a3a7a265b7b4cd54d39c5 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Wed, 29 Apr 2026 23:27:28 +1000 Subject: [PATCH 17/21] fix: PRT3 arm/disarm state tracking robustness Three observed bugs caused HA to show wrong partition state: 1. Stuck "arming" after a quick arm+disarm cycle. G014 (Disarm with User Code) can fire with area=0 (global) on at least some firmware; the previous handler called get_container_object("partition", 0), got None, and silently dropped the change. Partition events with area in (0, 255) are now broadcast to every known partition so the disarm clears state on all of them. 2. Brief "armed_away" flash on disarm from a fully-armed state. Per PRT3 ASCII spec page 18 G013 is "Disarm with Master", but PAI had it mapped as "auto-armed" (change={"arm": True, ...}). When a master code disarmed the panel, G013 fired and set arm=True, then _update_partition_states fell through to the "armed_away" else branch (since none of arm_stay/arm_away/arm_force were True) and published armed_away until the next RA poll corrected it. The arm group is now (G009, G010, G011, G012) and the disarm group is (G013, G014, G015, G016, G017) per spec. Adds previously-missing G009 (Arming with Master) and aligns G011/G012/G016/G017 labels to the spec; functional change dicts for G011/G012/G015/G016 were already correct. 3. 4-7 s delay before HA shows "disarmed" after a disarm during exit delay. The PRT3 ASCII protocol does not reliably emit a disarm G-event for ASCII-initiated disarms during exit delay (only G065 N=000, which per spec is the "Ready" status flag, not an "exit_delay cleared" signal -- it can fire while exit delay is active). control_partition now applies the disarmed state optimistically on the panel's AD&OK echo (which is authoritative) and arms a 3 s freeze window during which arm-related keys from RA polls are dropped. The window prevents a stale RA reply (panel hasn't internally settled yet) from briefly re-asserting arm=True, and auto-expires so it can't cause a permanently stuck state. docs/prt3-architecture.md gains an "Arm/disarm state tracking" section explaining the three-source state model (RA polling, G-events, optimistic command echo), the Ready-vs-exit_delay distinction, and the HA-disarm-during-exit-delay sequence. Also: control_partition log was promoted from DEBUG to INFO so user- initiated commands appear in default logs, and a one-line "PRT3 system event: G..N..A.." log was added in handle_prt3_event_message which proved essential for diagnosing this class of issue. Co-Authored-By: Claude Sonnet 4.6 --- docs/prt3-architecture.md | 83 ++++++++++ paradox/hardware/prt3/event.py | 48 ++++-- paradox/paradox.py | 66 +++++++- tests/hardware/prt3/test_command_dispatch.py | 160 +++++++++++++++++++ tests/hardware/prt3/test_mqtt_integration.py | 22 ++- 5 files changed, 353 insertions(+), 26 deletions(-) diff --git a/docs/prt3-architecture.md b/docs/prt3-architecture.md index 0a88926d..719f9fd1 100644 --- a/docs/prt3-architecture.md +++ b/docs/prt3-architecture.md @@ -127,6 +127,89 @@ What is **not** reused: - 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 | diff --git a/paradox/hardware/prt3/event.py b/paradox/hardware/prt3/event.py index bc8ab0e6..32a2d60e 100644 --- a/paradox/hardware/prt3/event.py +++ b/paradox/hardware/prt3/event.py @@ -55,43 +55,53 @@ "tags": ["zone", "trouble", "fire"], "message": "Zone {label} fire loop trouble"}, - # Arm events (number = user ID, area = affected partition) + # 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", "master"], - "message": "Partition {label} armed by master"}, - 12: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, "change": {"arm": True, "exit_delay": False}, "tags": ["arm", "keyswitch"], "message": "Partition {label} armed via keyswitch"}, - 13: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, + 12: {"type": "partition", "subtype": "arm", "level": EventLevel.INFO, "change": {"arm": True, "exit_delay": False}, - "tags": ["arm", "auto"], - "message": "Partition {label} auto-armed"}, + "tags": ["arm", "special"], + "message": "Partition {label} special arm"}, - # Disarm events (number = user ID, area = affected partition) - # exit_delay cleared because arming was cancelled + # 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", "master"], - "message": "Partition {label} disarmed by master"}, - 16: {"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", "alarm_cancel"], - "message": "Partition {label} disarmed after alarm"}, + "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"], @@ -251,7 +261,11 @@ def from_prt3(cls, prt3_event, label_provider=None) -> "PRT3Event": descriptor = EVENT_MAP.get(prt3_event.group) # G065 Status-2 per-N overrides - # The base descriptor is a no-op; specific N values carry state changes. + # Per spec: N=000 Ready, N=001 Exit Delay, N=002 Entry Delay, + # N=003 Trouble, N=004 Alarm in Memory, N=005 Bypassed, N=006 Programming, + # N=007 Keypad Lockout. Only N=001/N=002 carry state changes we map; + # N=000 (Ready) is informational and does NOT mean "exit delay cleared". + # exit_delay is cleared by arm/disarm events, not by Ready snapshots. if prt3_event.group == 65: n = prt3_event.number if n == 1: # exit delay started → show HA "arming" state diff --git a/paradox/paradox.py b/paradox/paradox.py index e5d0e7d6..a32f515a 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -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") @@ -517,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) @@ -537,6 +543,28 @@ 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 @@ -787,11 +815,25 @@ def handle_prt3_event_message(self, message): 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) - element = self.storage.get_container_object(evt.type, evt.id) - if evt.change and element: - self.storage.update_container_object(evt.type, evt.id, evt.change) + if evt.change: + if evt.type == "partition" and evt.id in (0, 255): + # Global partition event (area=0 = all enabled areas per spec + # Note 1, area=255 = at least one enabled area). Apply the + # change to every known partition so a global disarm clears + # state on each instead of being silently dropped via + # get_container_object("partition", 0) returning None. + for pid in list(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) ps.sendEvent(evt) if evt.type == "partition": self._update_partition_states() @@ -855,6 +897,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 @@ -866,6 +909,21 @@ def _on_status_update(self, status): list, ), ): + if ( + element_type == "partition" + and isinstance(element_item_status, dict) + and now < self._partition_arm_freeze_until.get(element_item_key, 0) + ): + # Within the post-disarm freeze window: a stale RA reply + # may still report the partition as armed. Drop arm- + # related keys so the optimistic disarm state stands. + # Other keys (trouble, ready_status, alarms_in_memory) + # still flow through. + _ARM_KEYS = {"arm", "arm_stay", "arm_away", "arm_force"} + element_item_status = { + k: v for k, v in element_item_status.items() + if k not in _ARM_KEYS + } self.storage.update_container_object( element_type, element_item_key, element_item_status ) diff --git a/tests/hardware/prt3/test_command_dispatch.py b/tests/hardware/prt3/test_command_dispatch.py index aac092e5..3dd48f54 100644 --- a/tests/hardware/prt3/test_command_dispatch.py +++ b/tests/hardware/prt3/test_command_dispatch.py @@ -37,6 +37,7 @@ def _make_paradox(monkeypatch, connection_type="PRT3"): 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() @@ -193,3 +194,162 @@ async def test_utility_key_panel_rejection_is_false(monkeypatch): 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, mock_panel = _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 + + +async 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" + + +async 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 + + +async 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, mock_panel = _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, mock_panel = _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, mock_panel = _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_mqtt_integration.py b/tests/hardware/prt3/test_mqtt_integration.py index 428be8fc..ef5f5a81 100644 --- a/tests/hardware/prt3/test_mqtt_integration.py +++ b/tests/hardware/prt3/test_mqtt_integration.py @@ -355,7 +355,13 @@ def test_g065_n2_sets_entry_delay(): def test_g065_other_n_is_noop(): - """G065 with N values other than 1/2 produce no state change (fallback no-op).""" + """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 @@ -365,11 +371,11 @@ def test_g065_other_n_is_noop(): def test_arm_event_clears_exit_delay(): - """G010 (arm by user) must set arm=True AND exit_delay=False.""" + """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 (10, 11, 12, 13): + 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, \ @@ -377,11 +383,17 @@ def test_arm_event_clears_exit_delay(): def test_disarm_event_clears_exit_delay(): - """G014-G017 and G020 (disarm) must clear exit_delay so a cancelled arm resets HA state.""" + """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 (14, 15, 16, 17, 20): + 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, \ From a510d8fcaf6ad2ac81d423e24d3b313f1fcd30b2 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Thu, 30 Apr 2026 06:34:19 +1000 Subject: [PATCH 18/21] fix: address PR #597 review findings - Bug #2: mqtt/core.py skip on_connect replay when _last_connect_args is None - Bug #3: panel.py _prt3_send_wait fast-fails on PRT3BufferFull instead of full timeout - Bug #4: PRT3_USER_CODE validated at config load; _build_partition_cmd catches ValueError - Minor #1: PRT3_SERIAL_BAUD set validator [9600, 19200]; config validator extended for int lists - Minor #2: G065 per-N overrides in EVENT_MAP number_overrides; from_prt3 uses generic lookup - Minor #4: handle_prt3_event_message catches expected exceptions explicitly - Minor #5: slice comment in retry warning; expanded PRT3_USER_CODE security doc - Minor #6: tests for buffer-full retry, malformed code, PRT3_USER_CODE/baud validation 1369 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- paradox/config.py | 21 ++++++++-- paradox/hardware/prt3/event.py | 48 ++++++++++------------ paradox/hardware/prt3/panel.py | 35 ++++++++++++---- paradox/interfaces/mqtt/core.py | 5 +-- paradox/paradox.py | 6 +++ tests/hardware/prt3/test_panel.py | 39 ++++++++++++++++++ tests/test_config.py | 68 ++++++++++++++++++++++++++++++- 7 files changed, 181 insertions(+), 41 deletions(-) diff --git a/paradox/config.py b/paradox/config.py index 6b1788d2..16434888 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -31,13 +31,16 @@ class Config: "SERIAL_BAUD": 9600, # Baud rate of the Serial Port. Use 38400(default setting) or 57600 for EVO # 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, (2400, 115200)), # Baud rate; 9600 or 19200 typical + "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. This value is never written to logs. + # 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 @@ -351,7 +354,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: @@ -368,6 +374,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 Exception(err) + self.CONFIG_LOADED = True def _update_from_environment(self, entries): diff --git a/paradox/hardware/prt3/event.py b/paradox/hardware/prt3/event.py index 32a2d60e..b88ddf06 100644 --- a/paradox/hardware/prt3/event.py +++ b/paradox/hardware/prt3/event.py @@ -221,11 +221,25 @@ "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 applied in from_prt3() below; this is the fallback. + # 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})"}, + "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"], @@ -260,30 +274,12 @@ def from_prt3(cls, prt3_event, label_provider=None) -> "PRT3Event": """ descriptor = EVENT_MAP.get(prt3_event.group) - # G065 Status-2 per-N overrides - # Per spec: N=000 Ready, N=001 Exit Delay, N=002 Entry Delay, - # N=003 Trouble, N=004 Alarm in Memory, N=005 Bypassed, N=006 Programming, - # N=007 Keypad Lockout. Only N=001/N=002 carry state changes we map; - # N=000 (Ready) is informational and does NOT mean "exit delay cleared". - # exit_delay is cleared by arm/disarm events, not by Ready snapshots. - if prt3_event.group == 65: - n = prt3_event.number - if n == 1: # exit delay started → show HA "arming" state - descriptor = { - "type": "partition", "subtype": "exit_delay", - "level": EventLevel.INFO, - "change": {"exit_delay": True}, - "tags": ["status", "exit_delay"], - "message": "Partition {label} exit delay started", - } - elif n == 2: # entry delay started - descriptor = { - "type": "partition", "subtype": "entry_delay", - "level": EventLevel.INFO, - "change": {"entry_delay": True}, - "tags": ["status", "entry_delay"], - "message": "Partition {label} entry delay started", - } + # 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( diff --git a/paradox/hardware/prt3/panel.py b/paradox/hardware/prt3/panel.py index c313ed77..05ceb49c 100644 --- a/paradox/hardware/prt3/panel.py +++ b/paradox/hardware/prt3/panel.py @@ -44,6 +44,7 @@ from paradox.hardware.prt3.event import EVENT_MAP, PRT3Event from paradox.hardware.prt3.parser import ( PRT3AreaStatus, + PRT3BufferFull, PRT3CommandEcho, PRT3CommStatus, PRT3LabelReply, @@ -151,23 +152,33 @@ async def _prt3_send_wait( :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 only performed on timeout; a received - reply (ok or &fail) is returned immediately. + 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. + 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: - return await self.core.connection.wait_for_message( - predicate, timeout=cfg.IO_TIMEOUT + 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], + attempt, retries, command_bytes[:5], # [:5] excludes user code ) return None @@ -386,12 +397,20 @@ def _build_partition_cmd( if not user_code: logger.error("PRT3: disarm requires PRT3_USER_CODE to be configured") return None - return encoder.encode_disarm(partition, user_code), f"AD{partition:03d}" + 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: - return encoder.encode_arm(partition, mode, user_code), f"AA{partition:03d}" + 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) diff --git a/paradox/interfaces/mqtt/core.py b/paradox/interfaces/mqtt/core.py index d73203c7..3a6dfd4d 100644 --- a/paradox/interfaces/mqtt/core.py +++ b/paradox/interfaces/mqtt/core.py @@ -184,11 +184,10 @@ def register(self, cls): # 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: + if self.connected and self._last_connect_args is not None: try: if hasattr(cls, "on_connect") and callable(getattr(cls, "on_connect")): - args = self._last_connect_args or (self.client, None, None, None, None) - cls.on_connect(*args) + cls.on_connect(*self._last_connect_args) except Exception: logger.exception( 'Failed to call on_connect on late registrar "%s"', diff --git a/paradox/paradox.py b/paradox/paradox.py index a32f515a..da4817f4 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -821,6 +821,10 @@ def handle_prt3_event_message(self, message): ) 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: if evt.change: if evt.type == "partition" and evt.id in (0, 255): # Global partition event (area=0 = all enabled areas per spec @@ -837,6 +841,8 @@ def handle_prt3_event_message(self, message): 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") diff --git a/tests/hardware/prt3/test_panel.py b/tests/hardware/prt3/test_panel.py index 8534fe39..5abcd29a 100644 --- a/tests/hardware/prt3/test_panel.py +++ b/tests/hardware/prt3/test_panel.py @@ -22,6 +22,7 @@ ARM_DISARMED, ARM_STAY, PRT3AreaStatus, + PRT3BufferFull, PRT3CommandEcho, PRT3CommStatus, PRT3LabelReply, @@ -478,6 +479,44 @@ async def test_send_wait_does_not_retry_on_fail_echo(core, panel): 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() # --------------------------------------------------------------------------- diff --git a/tests/test_config.py b/tests/test_config.py index 703e9bc9..48e87444 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(Exception, 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(Exception, 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 From 2b446049443da49f3bbf554bfb54826cd974bb7a Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Thu, 30 Apr 2026 06:47:13 +1000 Subject: [PATCH 19/21] fix: address SonarQube issues on PR #596 - S112: PRT3_USER_CODE validation raises ValueError instead of Exception - S3776: handle_prt3_event_message complexity reduced by extracting _apply_prt3_event_change helper method (was 16, now below 15) - S3776: _on_status_update complexity reduced by extracting _filter_arm_freeze helper method (was 16, now below 15) - S7504: remove unnecessary list() wrapping dict.keys() in event broadcast - S7503: three sync-only test functions had spurious async keyword removed - S1481: four tests had unused mock_panel renamed to _ 1369 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- paradox/config.py | 2 +- paradox/paradox.py | 57 ++++++++++---------- tests/hardware/prt3/test_command_dispatch.py | 14 ++--- tests/test_config.py | 4 +- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/paradox/config.py b/paradox/config.py index 16434888..473a3e7c 100644 --- a/paradox/config.py +++ b/paradox/config.py @@ -381,7 +381,7 @@ def load(self, alt_location=None): "1-6 decimal digits (got {!r})".format(user_code) ) sys.stderr.write(err + "\n") - raise Exception(err) + raise ValueError(err) self.CONFIG_LOADED = True diff --git a/paradox/paradox.py b/paradox/paradox.py index da4817f4..fbe1177f 100644 --- a/paradox/paradox.py +++ b/paradox/paradox.py @@ -825,19 +825,7 @@ def handle_prt3_event_message(self, message): logger.warning("handle_prt3_event_message: failed to parse event: %s", exc) return try: - if evt.change: - if evt.type == "partition" and evt.id in (0, 255): - # Global partition event (area=0 = all enabled areas per spec - # Note 1, area=255 = at least one enabled area). Apply the - # change to every known partition so a global disarm clears - # state on each instead of being silently dropped via - # get_container_object("partition", 0) returning None. - for pid in list(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) + self._apply_prt3_event_change(evt) ps.sendEvent(evt) if evt.type == "partition": self._update_partition_states() @@ -846,6 +834,19 @@ def handle_prt3_event_message(self, message): 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 @@ -915,21 +916,9 @@ def _on_status_update(self, status): list, ), ): - if ( - element_type == "partition" - and isinstance(element_item_status, dict) - and now < self._partition_arm_freeze_until.get(element_item_key, 0) - ): - # Within the post-disarm freeze window: a stale RA reply - # may still report the partition as armed. Drop arm- - # related keys so the optimistic disarm state stands. - # Other keys (trouble, ready_status, alarms_in_memory) - # still flow through. - _ARM_KEYS = {"arm", "arm_stay", "arm_away", "arm_force"} - element_item_status = { - k: v for k, v in element_item_status.items() - if k not in _ARM_KEYS - } + 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 ) @@ -946,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/hardware/prt3/test_command_dispatch.py b/tests/hardware/prt3/test_command_dispatch.py index 3dd48f54..9f5ec0fa 100644 --- a/tests/hardware/prt3/test_command_dispatch.py +++ b/tests/hardware/prt3/test_command_dispatch.py @@ -203,7 +203,7 @@ async def test_utility_key_panel_rejection_is_false(monkeypatch): def _make_paradox_with_partitions(monkeypatch): """Paradox instance pre-populated with two partitions in storage.""" - paradox, mock_panel = _make_paradox(monkeypatch) + paradox, _ = _make_paradox(monkeypatch) paradox.storage.get_container("partition")[1] = { "id": 1, "key": "home", "label": "Home", "arm": True, "exit_delay": True, } @@ -213,7 +213,7 @@ def _make_paradox_with_partitions(monkeypatch): return paradox -async def test_global_disarm_event_clears_exit_delay_on_all_partitions(monkeypatch): +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 @@ -238,7 +238,7 @@ async def test_global_disarm_event_clears_exit_delay_on_all_partitions(monkeypat assert p2["arm"] is False, "partition 2 arm must be cleared by global disarm" -async def test_global_disarm_area255_clears_exit_delay_on_all_partitions(monkeypatch): +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 @@ -254,7 +254,7 @@ async def test_global_disarm_area255_clears_exit_delay_on_all_partitions(monkeyp assert p2["exit_delay"] is False -async def test_specific_area_disarm_only_updates_that_partition(monkeypatch): +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 @@ -285,7 +285,7 @@ async def test_disarm_optimistically_clears_arm_and_exit_delay(monkeypatch): exit delay don't always emit G014, only G065N000 (Ready) which per spec isn't an exit_delay-cleared signal. """ - paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + 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, @@ -312,7 +312,7 @@ async def test_disarm_freezes_arm_against_stale_ra_poll(monkeypatch): 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, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + 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, @@ -339,7 +339,7 @@ async def test_disarm_freezes_arm_against_stale_ra_poll(monkeypatch): async def test_arm_does_not_optimistically_change_state(monkeypatch): """control_partition('arm_stay') leaves storage alone — G065N001 + RA set state.""" - paradox, mock_panel = _make_paradox(monkeypatch, connection_type="PRT3") + 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, diff --git a/tests/test_config.py b/tests/test_config.py index 48e87444..e80e491f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -124,7 +124,7 @@ def test_prt3_user_code_invalid_alpha_rejected(monkeypatch): c = Config() monkeypatch.setattr(c, "_find_config", lambda loc=None: None) monkeypatch.setattr(c, "_read_config", lambda: {"PRT3_USER_CODE": "abc"}) - with pytest.raises(Exception, match="PRT3_USER_CODE"): + with pytest.raises(ValueError, match="PRT3_USER_CODE"): c.load() @@ -134,7 +134,7 @@ def test_prt3_user_code_too_long_rejected(monkeypatch): c = Config() monkeypatch.setattr(c, "_find_config", lambda loc=None: None) monkeypatch.setattr(c, "_read_config", lambda: {"PRT3_USER_CODE": "1234567"}) - with pytest.raises(Exception, match="PRT3_USER_CODE"): + with pytest.raises(ValueError, match="PRT3_USER_CODE"): c.load() From 4fc8eeca3c52aa464fc3e7d1b6b060f413031df2 Mon Sep 17 00:00:00 2001 From: Naanya Biz Date: Thu, 30 Apr 2026 06:50:29 +1000 Subject: [PATCH 20/21] docs: update prt3 docs to reflect review fixes - prt3-usage.md: PRT3_SERIAL_BAUD is now strictly 9600 or 19200 (not a free range); PRT3_USER_CODE documents the 1-6 digit format, startup validation, and the security note about debug log streams - prt3-architecture.md: _prt3_send_wait buffer-full composite predicate and fast-retry behaviour noted; EVENT_MAP number_overrides pattern documented for future maintainers adding per-N group overrides Co-Authored-By: Claude Sonnet 4.6 --- docs/prt3-architecture.md | 8 ++++++++ docs/prt3-usage.md | 15 ++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/prt3-architecture.md b/docs/prt3-architecture.md index 719f9fd1..ca44ab01 100644 --- a/docs/prt3-architecture.md +++ b/docs/prt3-architecture.md @@ -40,6 +40,10 @@ paradox/hardware/prt3/ - 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, @@ -49,6 +53,10 @@ paradox/hardware/prt3/ 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 diff --git a/docs/prt3-usage.md b/docs/prt3-usage.md index 5023c5a3..0676843d 100644 --- a/docs/prt3-usage.md +++ b/docs/prt3-usage.md @@ -39,11 +39,16 @@ Set `CONNECTION_TYPE = 'PRT3'` and configure the PRT3 section in `pai.conf`: CONNECTION_TYPE = 'PRT3' PRT3_SERIAL_PORT = '/dev/ttyUSB0' # Port the PRT3 module is attached to -PRT3_SERIAL_BAUD = 9600 # Match your PRT3 module's DIP switch setting - -# User code for arm/disarm commands. Leave empty to use quick-arm -# (requires One-Touch Arming enabled on the panel). -# Disarm always requires a valid user code. +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) From a7a9c6bfc3abf697e751a3f67793fd5ae5019902 Mon Sep 17 00:00:00 2001 From: Jevgeni Kiski Date: Wed, 13 May 2026 16:39:39 +0300 Subject: [PATCH 21/21] fix: use typing.List/Tuple for Python 3.8 compatibility in PRT3 protocol test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace PEP 585 builtin generics (list[bytes], tuple[X, Y]) — which require Python 3.9+ at runtime — with typing.List/Tuple equivalents. Matches the import style used elsewhere in the PRT3 modules and restores test collection on the project's declared minimum (requires-python = ">=3.8"). --- tests/connection/prt3/test_protocol.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/connection/prt3/test_protocol.py b/tests/connection/prt3/test_protocol.py index 253b7b14..52714dfe 100644 --- a/tests/connection/prt3/test_protocol.py +++ b/tests/connection/prt3/test_protocol.py @@ -10,16 +10,16 @@ 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.protocols import ConnectionProtocol from paradox.connections.serial_connection import SerialCommunication - # --------------------------------------------------------------------------- # Smoke / type hierarchy # --------------------------------------------------------------------------- @@ -48,7 +48,7 @@ class _Handler: """Minimal ConnectionHandler that records on_message() calls.""" def __init__(self): - self.messages: list[bytes] = [] + self.messages: List[bytes] = [] def on_message(self, raw: bytes): self.messages.append(raw) @@ -62,7 +62,7 @@ def on_connection_loss(self): pass -def _make_proto() -> tuple[PRT3Protocol, _Handler]: +def _make_proto() -> Tuple[PRT3Protocol, _Handler]: handler = _Handler() proto = PRT3Protocol(handler) return proto, handler @@ -96,7 +96,7 @@ def test_two_lines_in_one_chunk(): def test_line_split_across_two_chunks(): proto, handler = _make_proto() proto.data_received(b"COMM&") - assert handler.messages == [] # incomplete — not yet emitted + assert handler.messages == [] # incomplete — not yet emitted proto.data_received(b"ok\r") assert handler.messages == [b"COMM&ok\r"] @@ -157,9 +157,9 @@ def test_multiple_splits(): def test_variable_message_length_noop(): proto, _ = _make_proto() - proto.variable_message_length(True) # should not raise + 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 + proto.variable_message_length(42) # arbitrary arg — should not raise # --------------------------------------------------------------------------- @@ -180,6 +180,7 @@ def test_send_message_delegates_to_transport(): 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()