aiopyronix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aiopyronix/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Asynchronous PyronixCloud client for Pyronix alarm panels.
2
+
3
+ A session opens only when the caller asks for it. Each arm or disarm request is
4
+ sent once and never repeated. Connection fields and wire payloads are never logged.
5
+ """
6
+
7
+ from .client import (
8
+ OPTIONAL_AUTH,
9
+ REQUIRED_AUTH,
10
+ STATUS,
11
+ AreaState,
12
+ AuthenticationError,
13
+ CommandUnconfirmedError,
14
+ ConnectionState,
15
+ PanelBusyError,
16
+ PanelClient,
17
+ Snapshot,
18
+ empty_snapshot,
19
+ validate_auth,
20
+ )
21
+ from .protocol import ProtocolError
22
+
23
+ __all__ = [
24
+ "OPTIONAL_AUTH",
25
+ "REQUIRED_AUTH",
26
+ "STATUS",
27
+ "AreaState",
28
+ "AuthenticationError",
29
+ "CommandUnconfirmedError",
30
+ "ConnectionState",
31
+ "PanelBusyError",
32
+ "PanelClient",
33
+ "ProtocolError",
34
+ "Snapshot",
35
+ "empty_snapshot",
36
+ "validate_auth",
37
+ ]
aiopyronix/client.py ADDED
@@ -0,0 +1,537 @@
1
+ """Explicit Pyronix sessions. Never log connection fields or wire payloads."""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable, Coroutine, Mapping
5
+ import contextlib
6
+ import copy
7
+ import json
8
+ import time
9
+ from typing import Any, Literal, NotRequired, TypedDict
10
+
11
+ import aiohttp
12
+
13
+ from .protocol import (
14
+ FrameBuffer,
15
+ PanelSession,
16
+ ProtocolError,
17
+ cloud_command,
18
+ cloud_message,
19
+ )
20
+
21
+ ENDPOINT = "wss://app.pyronixcloud.com:443"
22
+ STATUS = {
23
+ 0: "Unset",
24
+ 1: "Set",
25
+ 2: "Alarmed",
26
+ 3: "Setting",
27
+ 4: "Cannot Set",
28
+ 5: "Can Override",
29
+ 6: "Set Cancelled",
30
+ }
31
+ REQUIRED_AUTH = (
32
+ "AppUser",
33
+ "PushId",
34
+ "DeviceId",
35
+ "PanelId",
36
+ "SystemName",
37
+ "PanelPwd",
38
+ "UserCode",
39
+ )
40
+ OPTIONAL_AUTH = (
41
+ "AppVersion",
42
+ "AppType",
43
+ "OSName",
44
+ "OSVersion",
45
+ "NetworkType",
46
+ "AppLang",
47
+ )
48
+
49
+ type ConnectionState = Literal["disconnected", "connecting", "connected", "error"]
50
+
51
+
52
+ class AreaState(TypedDict):
53
+ """The last reported state of one panel area."""
54
+
55
+ name: str
56
+ value: int | None
57
+ status: str
58
+
59
+
60
+ class Snapshot(TypedDict):
61
+ """Area states, permissions and firmware retained from panel records."""
62
+
63
+ areas: dict[int, AreaState]
64
+ can_arm: set[int]
65
+ can_disarm: set[int]
66
+ permissions_received: bool
67
+ observed_at: float | None
68
+ firmware: NotRequired[str]
69
+
70
+
71
+ class AuthenticationError(ProtocolError):
72
+ """A confirmed rejection; caller must not repeatedly retry credentials."""
73
+
74
+
75
+ class PanelBusyError(ProtocolError):
76
+ """A local operation owns the connection; this is not a panel outage."""
77
+
78
+
79
+ class CommandUnconfirmedError(ProtocolError):
80
+ """The command was attempted, but its outcome remains uncertain."""
81
+
82
+ def __init__(self, snapshot: Snapshot | None = None) -> None:
83
+ """Keep the latest state, if any, for the caller to inspect."""
84
+ super().__init__(
85
+ "Command attempted, but the requested state is not confirmed. "
86
+ "Check the panel before trying again; the command was not repeated."
87
+ )
88
+ self.snapshot = snapshot
89
+
90
+
91
+ def empty_snapshot() -> Snapshot:
92
+ """Return a snapshot with no areas and no permissions."""
93
+ return {
94
+ "areas": {},
95
+ "can_arm": set(),
96
+ "can_disarm": set(),
97
+ "permissions_received": False,
98
+ "observed_at": None,
99
+ }
100
+
101
+
102
+ def operation_confirmed(operation: str, value: int | None) -> bool:
103
+ """Setting confirms an arm request, without claiming the area is fully armed."""
104
+ return value in (1, 3) if operation == "arm" else value == 0
105
+
106
+
107
+ def validate_auth(auth: object) -> dict[str, str]:
108
+ """Return only the connection fields the session needs."""
109
+ if not isinstance(auth, Mapping) or not all(
110
+ isinstance(auth.get(k), str) and auth[k] for k in REQUIRED_AUTH
111
+ ):
112
+ raise ProtocolError("Missing connection details")
113
+ if (
114
+ len(auth["PanelId"]) != 8
115
+ or not auth["UserCode"].isascii()
116
+ or not auth["UserCode"].isdigit()
117
+ or not 4 <= len(auth["UserCode"]) <= 8
118
+ ):
119
+ raise ProtocolError("Invalid panel connection details")
120
+ allowed = {*REQUIRED_AUTH, *OPTIONAL_AUTH}
121
+ return {k: str(v) for k, v in auth.items() if k in allowed}
122
+
123
+
124
+ def ingest(snapshot: Snapshot, record: Mapping[str, Any]) -> None:
125
+ """Retain only area state, allowed operations and firmware; drop raw user data."""
126
+ kind = record.get("type")
127
+ if kind == "area":
128
+ for row in record.get("Detail", []):
129
+ index, value = row.get("R"), row.get("V")
130
+ if type(index) is int and 0 <= index <= 255 and type(value) is int:
131
+ snapshot["areas"][index] = {
132
+ "name": str(row.get("N", "Area"))[:80],
133
+ "value": value,
134
+ "status": STATUS.get(value, "Unknown"),
135
+ }
136
+ elif kind == "user":
137
+ snapshot["can_arm"] = _permitted_areas(record, "SetAreas")
138
+ snapshot["can_disarm"] = _permitted_areas(record, "UnsetAreas")
139
+ snapshot["permissions_received"] = True
140
+ elif kind == "panel":
141
+ hi, lo = record.get("VersionHi"), record.get("VersionLo")
142
+ if type(hi) is int and type(lo) is int:
143
+ snapshot["firmware"] = f"{hi}.{lo}"
144
+
145
+
146
+ def _permitted_areas(record: Mapping[str, Any], source: str) -> set[int]:
147
+ values = record.get(source, {}).get("R", [])
148
+ if not isinstance(values, list) or any(
149
+ type(v) is not int or not 0 <= v <= 255 for v in values
150
+ ):
151
+ raise ProtocolError("Invalid panel permissions")
152
+ return set(values)
153
+
154
+
155
+ def complete(snapshot: Snapshot) -> bool:
156
+ """Whether every permitted area has reported its state."""
157
+ permitted = snapshot["can_arm"] | snapshot["can_disarm"]
158
+ return (
159
+ snapshot["permissions_received"]
160
+ and bool(permitted)
161
+ and permitted <= snapshot["areas"].keys()
162
+ )
163
+
164
+
165
+ def validate_control(operation: str, area: int, snapshot: Snapshot) -> None:
166
+ """Exactly one area, no forced omissions or output/installer operations."""
167
+ if (
168
+ operation not in ("arm", "disarm")
169
+ or type(area) is not int
170
+ or not 0 <= area <= 255
171
+ ):
172
+ raise ProtocolError("Unsupported alarm operation")
173
+ allowed = snapshot["can_arm"] if operation == "arm" else snapshot["can_disarm"]
174
+ if not complete(snapshot) or area not in allowed:
175
+ raise ProtocolError("Panel user cannot control this area")
176
+ if operation == "arm" and snapshot["areas"][area]["value"] in (4, 5):
177
+ raise ProtocolError("Panel reports the area cannot be set without intervention")
178
+
179
+
180
+ def control_frame(
181
+ session: PanelSession, operation: str, area: int, snapshot: Snapshot
182
+ ) -> str:
183
+ """Build the single-area arm or disarm frame after validating it."""
184
+ validate_control(operation, area, snapshot)
185
+ if session.phase != "data":
186
+ raise ProtocolError("Panel session is not ready")
187
+ # Installed HomeControl SDK sendArmAreas / sendUnArmAreas: 45 / 47.
188
+ return session.frame("D", bytes([45 if operation == "arm" else 47, 1, area]))
189
+
190
+
191
+ class PanelClient:
192
+ """One explicitly opened session; commands and incoming status share its socket."""
193
+
194
+ CONNECT_TIMEOUT = 50
195
+ COMMAND_TIMEOUT = 25
196
+ HEARTBEAT_INTERVAL = 10
197
+ SILENCE_TIMEOUT = 40
198
+ IDLE_TIMEOUT = 300
199
+
200
+ def __init__(
201
+ self,
202
+ http: aiohttp.ClientSession,
203
+ auth: Mapping[str, Any],
204
+ on_update: Callable[[Snapshot], None] | None = None,
205
+ on_state: Callable[[], None] | None = None,
206
+ create_task: Callable[[Coroutine[Any, Any, None]], asyncio.Task[None]]
207
+ | None = None,
208
+ ) -> None:
209
+ """Validate the connection details without connecting."""
210
+ self.http = http
211
+ self.auth = validate_auth(auth)
212
+ self.on_update = on_update
213
+ self.on_state = on_state
214
+ self.create_task = create_task or asyncio.create_task
215
+ self.state: ConnectionState = "disconnected"
216
+ self.detail: str | None = None
217
+ self.idle_deadline: float | None = None
218
+ self.idle_expires_at: float | None = None
219
+ self.last_received: float | None = None
220
+ self.data = empty_snapshot()
221
+ self._reader: asyncio.Task[None] | None = None
222
+ self._ws: aiohttp.ClientWebSocketResponse | None = None
223
+ self._session: PanelSession | None = None
224
+ self._ready: asyncio.Future[Snapshot] | None = None
225
+ self._pending: tuple[str, int, asyncio.Future[Snapshot]] | None = None
226
+ self._phase = "cloud"
227
+ self._password_requested = self._connect_requested = False
228
+ self._write_lock = asyncio.Lock()
229
+ self._command_lock = asyncio.Lock()
230
+ self._stop_requested = False
231
+
232
+ @property
233
+ def connected(self) -> bool:
234
+ """Whether a live, authenticated session is open."""
235
+ return self.state == "connected" and self._ws is not None
236
+
237
+ def snapshot(self) -> Snapshot:
238
+ """Return an independent copy of the current state."""
239
+ return copy.deepcopy(self.data)
240
+
241
+ def _set_state(self, state: ConnectionState, detail: str | None = None) -> None:
242
+ self.state, self.detail = state, detail
243
+ if self.on_state:
244
+ self.on_state()
245
+
246
+ def _touch(self) -> None:
247
+ self.idle_deadline = time.monotonic() + self.IDLE_TIMEOUT
248
+ self.idle_expires_at = time.time() + self.IDLE_TIMEOUT
249
+ if self.on_state:
250
+ self.on_state()
251
+
252
+ @staticmethod
253
+ def _future() -> asyncio.Future[Snapshot]:
254
+ future: asyncio.Future[Snapshot] = asyncio.get_running_loop().create_future()
255
+ # Disconnect can settle a request whose caller has already been cancelled.
256
+ future.add_done_callback(lambda f: None if f.cancelled() else f.exception())
257
+ return future
258
+
259
+ async def connect(self) -> Snapshot:
260
+ """Open a session, or extend an open one, and return the panel state."""
261
+ if self.connected:
262
+ self._touch()
263
+ return self.snapshot()
264
+ if (
265
+ self._reader is not None
266
+ and not self._reader.done()
267
+ and self.state != "connecting"
268
+ ):
269
+ await self.disconnect()
270
+ if self._reader is None or self._reader.done():
271
+ self.data = empty_snapshot()
272
+ self._stop_requested = False
273
+ self._ready = self._future()
274
+ self._set_state("connecting")
275
+ self._reader = self.create_task(self._run())
276
+ ready = self._ready
277
+ if ready is None:
278
+ raise ProtocolError("Connection was disconnected.")
279
+ try:
280
+ # Unlike wait_for(shield()), this leaves the shared future untouched, so
281
+ # failing it after a timeout or cancellation is not logged as unhandled.
282
+ done, _ = await asyncio.wait({ready}, timeout=self.CONNECT_TIMEOUT)
283
+ except asyncio.CancelledError:
284
+ await self.disconnect()
285
+ raise
286
+ if not done:
287
+ await self.disconnect()
288
+ error = ProtocolError("Connection timed out. Press Connect to try again.")
289
+ self._set_state("error", str(error))
290
+ raise error
291
+ return ready.result()
292
+
293
+ async def disconnect(self) -> None:
294
+ """Close the session. This never sends an alarm command."""
295
+ self._stop_requested = True
296
+ self._set_state("disconnected")
297
+ reader = self._reader
298
+ if reader is not None and not reader.done():
299
+ reader.cancel()
300
+ with contextlib.suppress(asyncio.CancelledError):
301
+ await reader
302
+ self._reader = None
303
+ if self._ready is not None and not self._ready.done():
304
+ self._ready.set_exception(ProtocolError("Connection was disconnected."))
305
+ self.idle_deadline = None
306
+ self.idle_expires_at = None
307
+ self._set_state("disconnected")
308
+
309
+ async def control(self, operation: str, area: int) -> Snapshot:
310
+ """Send one arm or disarm request and wait for the panel to confirm it."""
311
+ if self._command_lock.locked():
312
+ raise PanelBusyError(
313
+ "Another panel command is in progress; wait for its result"
314
+ )
315
+ async with self._command_lock:
316
+ if not self.connected:
317
+ raise ProtocolError(
318
+ "Disconnected. Press Connect before choosing an alarm action."
319
+ )
320
+ self._touch()
321
+ future = None
322
+ try:
323
+ async with asyncio.timeout(self.COMMAND_TIMEOUT):
324
+ async with self._write_lock:
325
+ ws, session = self._ws, self._session
326
+ if not self.connected or ws is None or session is None:
327
+ raise ProtocolError(
328
+ "Disconnected. Press Connect before choosing an alarm action."
329
+ )
330
+ validate_control(operation, area, self.data)
331
+ if operation_confirmed(
332
+ operation, self.data["areas"][area]["value"]
333
+ ):
334
+ return self.snapshot()
335
+ frame = control_frame(session, operation, area, self.data)
336
+ future = self._future()
337
+ self._pending = (operation, area, future)
338
+ # Set pending before writing: a failed write may still reach the panel.
339
+ await ws.send_str(frame)
340
+ return await asyncio.shield(future)
341
+ except TimeoutError:
342
+ raise CommandUnconfirmedError(
343
+ self.snapshot() if self.connected else None
344
+ ) from None
345
+ except aiohttp.ClientError, OSError, ConnectionError:
346
+ await self.disconnect()
347
+ raise CommandUnconfirmedError from None
348
+ finally:
349
+ if future is not None and self._pending and self._pending[2] is future:
350
+ self._pending = None
351
+ if future is not None and not future.done():
352
+ future.cancel()
353
+
354
+ async def _send(self, build_frame: Callable[[], str]) -> None:
355
+ async with self._write_lock:
356
+ if self._ws is None:
357
+ raise ConnectionError("Panel connection closed")
358
+ await self._ws.send_str(build_frame())
359
+
360
+ async def _run(self) -> None:
361
+ error = None
362
+ self._phase = "cloud"
363
+ self._password_requested = self._connect_requested = False
364
+ framing = FrameBuffer()
365
+ try:
366
+ session = self._session = PanelSession(
367
+ self.auth["PanelId"], self.auth["PanelPwd"]
368
+ )
369
+ async with self.http.ws_connect(
370
+ ENDPOINT,
371
+ timeout=aiohttp.ClientWSTimeout(ws_close=3),
372
+ heartbeat=None,
373
+ max_msg_size=1000000,
374
+ ) as ws:
375
+ self._ws = ws
376
+ await self._send(lambda: cloud_message(self.auth, "subscription"))
377
+ try:
378
+ await self._receive(ws, session, framing)
379
+ finally:
380
+ if self._phase in ("handshake", "data"):
381
+ with contextlib.suppress(
382
+ TimeoutError, aiohttp.ClientError, OSError, RuntimeError
383
+ ):
384
+ await asyncio.wait_for(self._send(session.close), 2)
385
+ except asyncio.CancelledError:
386
+ pass
387
+ except ProtocolError as exc:
388
+ error = exc
389
+ except TimeoutError, aiohttp.ClientError, OSError, ValueError, UnicodeError:
390
+ error = ProtocolError(
391
+ "Panel connection failed or closed. Press Connect to try again."
392
+ )
393
+ finally:
394
+ self._ws = None
395
+ self.idle_deadline = None
396
+ self.idle_expires_at = None
397
+ if self._pending and not self._pending[2].done():
398
+ self._pending[2].set_exception(CommandUnconfirmedError())
399
+ if self._ready is not None and not self._ready.done():
400
+ self._ready.set_exception(
401
+ error or ProtocolError("Connection was disconnected.")
402
+ )
403
+ if error and not self._stop_requested:
404
+ self._set_state("error", str(error))
405
+ else:
406
+ self._set_state("disconnected")
407
+
408
+ async def _receive(
409
+ self,
410
+ ws: aiohttp.ClientWebSocketResponse,
411
+ session: PanelSession,
412
+ framing: FrameBuffer,
413
+ ) -> None:
414
+ while True:
415
+ if (
416
+ self.connected
417
+ and self._pending is None
418
+ and self.idle_deadline is not None
419
+ and time.monotonic() >= self.idle_deadline
420
+ ):
421
+ return
422
+ try:
423
+ message = await asyncio.wait_for(
424
+ ws.receive(), self.HEARTBEAT_INTERVAL if self.connected else 20
425
+ )
426
+ except TimeoutError:
427
+ if (
428
+ not self.connected
429
+ or self.last_received is None
430
+ or time.monotonic() - self.last_received >= self.SILENCE_TIMEOUT
431
+ ):
432
+ raise ConnectionError("Panel stopped responding") from None
433
+ await self._send(session.heartbeat)
434
+ continue
435
+ if message.type not in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
436
+ raise ConnectionError("Panel connection closed")
437
+ self.last_received = time.monotonic()
438
+ if not message.data.strip():
439
+ if self._phase == "data":
440
+ await self._send(session.heartbeat)
441
+ continue
442
+ for raw in framing.feed(message.data):
443
+ await self._handle_frame(raw, session)
444
+
445
+ async def _handle_frame(self, raw: str, session: PanelSession) -> None:
446
+ command = cloud_command(raw)
447
+ if command:
448
+ await self._handle_cloud_command(command, session)
449
+ elif self._phase == "handshake":
450
+ reply = session.handshake(raw)
451
+ if reply:
452
+ await self._send(lambda: reply)
453
+ else:
454
+ await self._send(lambda: session.login(self.auth["UserCode"]))
455
+ self._phase = "data"
456
+ elif self._phase == "data":
457
+ await self._handle_panel_data(raw, session)
458
+ else:
459
+ raise ProtocolError("Unexpected panel session state")
460
+
461
+ async def _handle_cloud_command(self, command: str, session: PanelSession) -> None:
462
+ if command in ("w", "m") and not self._password_requested:
463
+ self._password_requested = True
464
+ await self._send(lambda: cloud_message(self.auth, "password"))
465
+ elif command == "j" and not self._connect_requested:
466
+ self._connect_requested = True
467
+ await self._send(lambda: cloud_message(self.auth, "connect"))
468
+ elif command == "u" and self._phase == "cloud":
469
+ await self._send(lambda: cloud_message(self.auth, "connect", encoded=False))
470
+ await self._send(session.announce)
471
+ self._phase = "handshake"
472
+ elif command == "a":
473
+ raise AuthenticationError(
474
+ "Panel access was denied; reconfigure credentials"
475
+ )
476
+ else:
477
+ raise ProtocolError(
478
+ {
479
+ "b": "Panel is busy. Disconnect the other app and press Connect again.",
480
+ "n": "Panel is not polling its cloud service. Press Connect to try again.",
481
+ "i": "Panel subscription has expired",
482
+ }.get(command, "Unexpected cloud response")
483
+ )
484
+
485
+ async def _handle_panel_data(self, raw: str, session: PanelSession) -> None:
486
+ try:
487
+ header, body = session.receive(raw)
488
+ except ProtocolError as exc:
489
+ if not self.data["areas"] and str(exc) == "Panel integrity check failed":
490
+ raise AuthenticationError(
491
+ "Panel login integrity failed; verify credentials"
492
+ ) from None
493
+ raise
494
+ if header in ("e", "f", "c") and not self.data["areas"]:
495
+ raise AuthenticationError("Panel login rejected; reconfigure credentials")
496
+ if header == "g":
497
+ return # Authenticated photo data; this integration only consumes area state.
498
+ if header != "d":
499
+ raise ProtocolError(
500
+ "Panel closed or rejected the session. Press Connect again."
501
+ )
502
+ if not body.strip():
503
+ return
504
+ record = json.loads(body)
505
+ if not isinstance(record, dict):
506
+ raise ProtocolError("Invalid panel status")
507
+ ingest(self.data, record)
508
+ if not complete(self.data):
509
+ return
510
+ if record.get("type") == "area" or self.data["observed_at"] is None:
511
+ self.data["observed_at"] = time.time()
512
+ if not self.connected:
513
+ self._set_state("connected")
514
+ self._touch()
515
+ snapshot = self.snapshot()
516
+ if self.on_update:
517
+ self.on_update(snapshot)
518
+ if self._ready is not None and not self._ready.done():
519
+ self._ready.set_result(snapshot)
520
+ if self._pending and record.get("type") == "area":
521
+ self._settle_pending(record, snapshot)
522
+
523
+ def _settle_pending(self, record: Mapping[str, Any], snapshot: Snapshot) -> None:
524
+ if self._pending is None:
525
+ return
526
+ operation, area, future = self._pending
527
+ if future.done() or not any(
528
+ r.get("R") == area for r in record.get("Detail", [])
529
+ ):
530
+ return
531
+ value = self.data["areas"][area]["value"]
532
+ if operation_confirmed(operation, value):
533
+ future.set_result(snapshot)
534
+ elif value in (4, 5, 6):
535
+ future.set_exception(
536
+ ProtocolError("Panel could not complete the requested operation")
537
+ )
aiopyronix/protocol.py ADDED
@@ -0,0 +1,268 @@
1
+ """Pyronix framing and read-only login, transcribed from the installed vendor SDK.
2
+
3
+ No arm, disarm, omit, output, installer or cloud account mutation command exists
4
+ in this module. Payloads and cryptographic material must never be logged.
5
+ """
6
+
7
+ import base64
8
+ from collections.abc import Mapping
9
+ import hashlib
10
+ import hmac
11
+ import struct
12
+ import xml.etree.ElementTree as ET
13
+
14
+ from cryptography.hazmat.decrepit.ciphers.modes import CFB
15
+ from cryptography.hazmat.primitives import serialization
16
+ from cryptography.hazmat.primitives.asymmetric import ec
17
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
18
+
19
+ BUFFER_LIMIT = 1000000
20
+
21
+
22
+ class ProtocolError(Exception):
23
+ """Fixed messages only; never include untrusted server data."""
24
+
25
+
26
+ class FrameBuffer:
27
+ """A WebSocket message may contain part of, or several, panel packets."""
28
+
29
+ def __init__(self) -> None:
30
+ """Start with an empty receive buffer."""
31
+ self.pending = ""
32
+
33
+ def feed(self, text: str | bytes) -> list[str]:
34
+ """Add received text and return every complete frame."""
35
+ if isinstance(text, bytes):
36
+ text = text.decode("ascii")
37
+ self.pending += text
38
+ if len(self.pending) > BUFFER_LIMIT:
39
+ raise ProtocolError("Panel receive buffer limit exceeded")
40
+ frames = []
41
+ while self.pending.strip():
42
+ self.pending = self.pending.lstrip()
43
+ if self.pending.startswith("("):
44
+ end = self.pending.find(")")
45
+ if end == -1:
46
+ break
47
+ frames.append(self.pending[: end + 1])
48
+ self.pending = self.pending[end + 1 :]
49
+ elif self.pending.startswith("<"):
50
+ end = self.pending.find("</e>")
51
+ if end == -1:
52
+ break
53
+ frames.append(self.pending[: end + 4])
54
+ self.pending = self.pending[end + 4 :]
55
+ else:
56
+ raise ProtocolError("Unexpected panel transport prefix")
57
+ if not self.pending.strip():
58
+ self.pending = ""
59
+ return frames
60
+
61
+
62
+ def wrap(raw: bytes) -> str:
63
+ """Encode a packet in the transport's bracketed base64 form."""
64
+ return "(" + base64.encodebytes(raw).decode("ascii") + ")"
65
+
66
+
67
+ def unwrap(text: str | bytes) -> bytes:
68
+ """Decode one bracketed base64 transport frame."""
69
+ if isinstance(text, bytes):
70
+ text = text.decode("ascii")
71
+ text = text.strip() # The vendor WebSocket callback trims framing whitespace.
72
+ if not (text.startswith("(") and text.endswith(")")):
73
+ raise ProtocolError("Unexpected transport frame")
74
+ try:
75
+ return base64.b64decode("".join(text[1:-1].split()), validate=True)
76
+ except ValueError:
77
+ raise ProtocolError("Invalid transport encoding") from None
78
+
79
+
80
+ def cloud_message(auth: Mapping[str, str], kind: str, encoded: bool = True) -> str:
81
+ """Allow only subscription, saved credential read and panel connection."""
82
+ if kind not in ("subscription", "password", "connect"):
83
+ raise ProtocolError("Unsupported cloud operation")
84
+ values = [("n", auth["AppUser"]), ("u", auth["PushId"])]
85
+ if kind == "connect":
86
+ values += [
87
+ ("a", auth["PanelId"]),
88
+ ("b", auth["SystemName"]),
89
+ ("z", auth["PanelPwd"]),
90
+ ]
91
+ else:
92
+ values += [("w" if kind == "subscription" else "j", auth["PanelId"])]
93
+ values += [
94
+ (key, auth.get(field, ""))
95
+ for key, field in (
96
+ ("i", "DeviceId"),
97
+ ("l", "AppVersion"),
98
+ ("t", "AppType"),
99
+ ("m", "OSName"),
100
+ ("v", "OSVersion"),
101
+ ("c", "NetworkType"),
102
+ ("lang", "AppLang"),
103
+ )
104
+ ]
105
+ root = ET.Element("p")
106
+ for key, value in values:
107
+ ET.SubElement(root, key).text = str(value)
108
+ raw = ET.tostring(root, encoding="utf-8", short_empty_elements=False)
109
+ return wrap(raw) if encoded else "(" + raw.decode() + ")"
110
+
111
+
112
+ def cloud_command(text: str) -> str | None:
113
+ """Return the one-letter cloud reply code, or None for a panel frame."""
114
+ try:
115
+ # Replies are short buffered frames. ElementTree does not resolve external
116
+ # entities and expat limits entity expansion, so defusedxml adds nothing here.
117
+ root = ET.fromstring(text) # noqa: S314
118
+ except ET.ParseError, TypeError:
119
+ return None
120
+ # Do not return the data: some replies include the app password.
121
+ body = "".join(root.itertext())
122
+ return body[:1] if root.tag == "e" and body[:1] in "wjmuabionx" else None
123
+
124
+
125
+ def fixed_key(password: str, panel_id: str) -> bytes:
126
+ """Derive the static key mixed into each session key."""
127
+ if len(password) < 32:
128
+ return hashlib.sha256(password.encode() + b"\0" + panel_id.encode()).digest()[
129
+ :16
130
+ ]
131
+ try:
132
+ key = bytes.fromhex(password[:32])
133
+ except ValueError:
134
+ raise ProtocolError("Invalid protected credential format") from None
135
+ if len(key) != 16:
136
+ raise ProtocolError("Invalid protected credential format")
137
+ return key
138
+
139
+
140
+ def crypt_body(
141
+ key: bytes,
142
+ header: int,
143
+ length: int,
144
+ sequence: int,
145
+ body: bytes,
146
+ decrypt: bool = False,
147
+ ) -> bytes:
148
+ """Encrypt or decrypt a packet body as the vendor SDK does."""
149
+ # SDK: AES-ECB of four LE uint32s seeds the CFB128 feedback register.
150
+ # Its PKCS padding grows unused trailing blocks; only the first matters.
151
+ seed = struct.pack("<IIII", header, length, sequence, 0)
152
+ ecb = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
153
+ iv = ecb.update(seed) + ecb.finalize()
154
+ cipher = Cipher(algorithms.AES(key), CFB(iv))
155
+ worker = cipher.decryptor() if decrypt else cipher.encryptor()
156
+ return worker.update(body) + worker.finalize()
157
+
158
+
159
+ class PanelSession:
160
+ """Packet sequencing, key exchange and integrity for one panel session."""
161
+
162
+ def __init__(self, panel_id: str, password: str) -> None:
163
+ """Prepare a session that has not yet exchanged keys."""
164
+ self.panel_id = panel_id.encode()
165
+ if len(self.panel_id) != 8:
166
+ raise ProtocolError("Unexpected panel identifier format")
167
+ self.fixed = fixed_key(password, panel_id)
168
+ self.tx_sequence = self.rx_sequence = 0
169
+ self.key: bytes | None = None
170
+ self.private: ec.EllipticCurvePrivateKey | None = None
171
+ self.extra = b""
172
+ self.phase = "announce"
173
+
174
+ def frame(self, header: str, body: bytes = b"") -> str:
175
+ """Build the next outgoing frame; data headers are encrypted."""
176
+ self.tx_sequence = (self.tx_sequence + 1) & 0xFFFFFFFF
177
+ head = struct.pack("<BHH", ord(header), self.tx_sequence & 0xFFFF, len(body))
178
+ tail = body + hashlib.sha256(head + body).digest()[:16]
179
+ if header in "DEG":
180
+ if self.key is None:
181
+ raise ProtocolError("Session key is unavailable")
182
+ tail = crypt_body(self.key, ord(header), len(body), self.tx_sequence, tail)
183
+ return wrap(head + tail)
184
+
185
+ def receive(self, text: str) -> tuple[str, bytes]:
186
+ """Verify one incoming frame and return its header and body."""
187
+ raw = unwrap(text)
188
+ if len(raw) < 21:
189
+ raise ProtocolError("Truncated panel frame")
190
+ header, sequence, length = struct.unpack("<BHH", raw[:5])
191
+ if length != len(raw) - 21 or chr(header) not in "abcdefg":
192
+ raise ProtocolError("Invalid panel frame")
193
+ delta = (sequence - (self.rx_sequence & 0xFFFF)) & 0xFFFF
194
+ if not 0 < delta < 128:
195
+ raise ProtocolError("Stale or invalid panel sequence")
196
+ full_sequence = self.rx_sequence + delta
197
+ tail = raw[5:]
198
+ if chr(header) in "deg":
199
+ if self.key is None:
200
+ raise ProtocolError("Encrypted data before session establishment")
201
+ tail = crypt_body(
202
+ self.key, header, length, full_sequence, tail, decrypt=True
203
+ )
204
+ if not hmac.compare_digest(
205
+ tail[-16:], hashlib.sha256(raw[:5] + tail[:-16]).digest()[:16]
206
+ ):
207
+ raise ProtocolError("Panel integrity check failed")
208
+ self.rx_sequence = full_sequence
209
+ return chr(header), tail[:-16]
210
+
211
+ def announce(self) -> str:
212
+ """Open the panel handshake."""
213
+ return self.frame("A")
214
+
215
+ def handshake(self, text: str) -> str | None:
216
+ """Answer a handshake frame; None once the session key is established."""
217
+ header, body = self.receive(text)
218
+ if self.phase == "announce":
219
+ if header != "a" or len(body) < 10 or body[2:10] != self.panel_id:
220
+ raise ProtocolError("Panel announcement mismatch")
221
+ if body[1] & 128:
222
+ self.extra = body[10 : 10 + min(max(len(body) - 21, 0), 4)]
223
+ self.private = ec.generate_private_key(ec.SECP256R1())
224
+ public = self.private.public_key().public_bytes(
225
+ serialization.Encoding.X962,
226
+ serialization.PublicFormat.UncompressedPoint,
227
+ )
228
+ self.phase = "ecdh"
229
+ return self.frame("B", b"\x01" + public)
230
+ if (
231
+ self.phase != "ecdh"
232
+ or self.private is None
233
+ or header != "b"
234
+ or len(body) not in (34, 66)
235
+ ):
236
+ raise ProtocolError("Panel key exchange rejected")
237
+ try:
238
+ public_key = ec.EllipticCurvePublicKey.from_encoded_point(
239
+ ec.SECP256R1(), body[1:]
240
+ )
241
+ shared = self.private.exchange(ec.ECDH(), public_key)
242
+ except ValueError:
243
+ raise ProtocolError("Invalid panel public key") from None
244
+ self.key = hashlib.sha256(self.fixed + shared + self.extra).digest()[:16]
245
+ self.private = None
246
+ self.phase = "data"
247
+ return None
248
+
249
+ def login(self, code: str) -> str:
250
+ """Log in with the panel user code once the session is encrypted."""
251
+ if (
252
+ self.phase != "data"
253
+ or not code.isascii()
254
+ or not code.isdigit()
255
+ or not 4 <= len(code) <= 8
256
+ ):
257
+ raise ProtocolError("Invalid protected login configuration")
258
+ return self.frame("D", b"," + code.encode() + b"\n")
259
+
260
+ def heartbeat(self) -> str:
261
+ """Keep an idle session open."""
262
+ if self.phase != "data":
263
+ raise ProtocolError("Session is not ready")
264
+ return self.frame("D", b" ")
265
+
266
+ def close(self) -> str:
267
+ """Close the session, encrypted once a key exists."""
268
+ return self.frame("E" if self.key else "F")
aiopyronix/py.typed ADDED
File without changes
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.5
2
+ Name: aiopyronix
3
+ Version: 0.1.0
4
+ Summary: Asynchronous PyronixCloud client for Pyronix alarm panels, with explicit sessions
5
+ Project-URL: Source, https://github.com/Herbertmt978/aiopyronix
6
+ Project-URL: Issues, https://github.com/Herbertmt978/aiopyronix/issues
7
+ Project-URL: Changelog, https://github.com/Herbertmt978/aiopyronix/blob/main/CHANGELOG.md
8
+ Author: Herbertmt978
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: alarm,asyncio,home-assistant,homecontrol,pyronix
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Home Automation
20
+ Classifier: Topic :: Security
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.14
23
+ Requires-Dist: aiohttp>=3.13.0
24
+ Requires-Dist: cryptography>=47.0.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # aiopyronix
28
+
29
+ [![PyPI](https://img.shields.io/pypi/v/aiopyronix)](https://pypi.org/project/aiopyronix/)
30
+ [![CI](https://github.com/Herbertmt978/aiopyronix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Herbertmt978/aiopyronix/actions/workflows/ci.yml)
31
+ [![Licence: MIT](https://img.shields.io/badge/Licence-MIT-0F766E.svg)](https://github.com/Herbertmt978/aiopyronix/blob/main/LICENSE)
32
+
33
+ An asynchronous Python client for Pyronix alarm panels reached through
34
+ PyronixCloud, the service used by the HomeControl 2.0 app.
35
+
36
+ It powers the [Pyronix HomeControl integration for Home Assistant](https://github.com/Herbertmt978/HA-Pyronix),
37
+ and it can be used on its own. This is independent community software. It is
38
+ not an official Pyronix, Hikvision or Home Assistant product.
39
+
40
+ ## Design
41
+
42
+ The client controls an intruder alarm, so caution takes priority over convenience:
43
+
44
+ - **Explicit sessions.** Nothing connects when a `PanelClient` is created. A
45
+ session opens only when `connect()` is called. There is no polling or
46
+ automatic reconnection.
47
+ - **Idle sessions close.** A session closes after five minutes without a
48
+ `connect()` or `control()` call. Heartbeats keep the socket alive, but they
49
+ do not extend that limit.
50
+ - **Commands are sent once.** `control()` arms or disarms exactly one area and
51
+ waits for the panel to confirm it. When the outcome cannot be confirmed, it
52
+ raises `CommandUnconfirmedError`. The command is never repeated, even after
53
+ a lost connection.
54
+ - **A narrow command set.** Only single-area arm and disarm are possible. The
55
+ library has no forced omissions, outputs, installer functions or cloud
56
+ account changes.
57
+ - **Nothing sensitive is logged.** The library does not log. Its error
58
+ messages are fixed text and never contain credentials, panel data or wire
59
+ payloads.
60
+
61
+ Status updates and commands share one WebSocket. Each frame is encrypted with
62
+ a session key agreed through ECDH (P-256) and checked for integrity and
63
+ sequence.
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ pip install aiopyronix
69
+ ```
70
+
71
+ Python 3.14 or later is required.
72
+
73
+ ## Connection details
74
+
75
+ `PanelClient` needs the connection details that the HomeControl 2.0 app saves
76
+ when you sign in:
77
+
78
+ | Field | Meaning |
79
+ | --- | --- |
80
+ | `AppUser`, `PushId`, `DeviceId` | The app installation registered with PyronixCloud |
81
+ | `PanelId` | The eight-character panel identifier |
82
+ | `SystemName` | The panel name shown in the app |
83
+ | `PanelPwd` | The app password stored for the panel |
84
+ | `UserCode` | The panel user code, four to eight digits |
85
+
86
+ `AppVersion`, `AppType`, `OSName`, `OSVersion`, `NetworkType` and `AppLang`
87
+ are optional. Other keys are discarded. The Home Assistant integration
88
+ includes a [setup helper](https://github.com/Herbertmt978/HA-Pyronix#connect-your-own-alarm)
89
+ that reads these details from your own signed-in app.
90
+
91
+ ## Usage
92
+
93
+ ```python
94
+ import asyncio
95
+ import json
96
+ from pathlib import Path
97
+
98
+ import aiohttp
99
+
100
+ from aiopyronix import CommandUnconfirmedError, PanelClient
101
+
102
+
103
+ async def main() -> None:
104
+ auth = json.loads(Path("pyronix.json").read_text())
105
+ async with aiohttp.ClientSession() as http:
106
+ client = PanelClient(http, auth)
107
+ try:
108
+ snapshot = await client.connect()
109
+ for index, area in snapshot["areas"].items():
110
+ print(index, area["name"], area["status"])
111
+
112
+ if 0 in snapshot["can_arm"]:
113
+ try:
114
+ await client.control("arm", 0)
115
+ except CommandUnconfirmedError:
116
+ print("Check the panel before trying again")
117
+ finally:
118
+ await client.disconnect()
119
+
120
+
121
+ asyncio.run(main())
122
+ ```
123
+
124
+ `connect()` returns once every area the user may control has reported its
125
+ state. Calling it on an open session returns the current state and restarts
126
+ the idle timer.
127
+
128
+ ### Callbacks
129
+
130
+ - `on_update(snapshot)` runs whenever the panel reports new state.
131
+ - `on_state()` runs when `state`, `detail` or `idle_expires_at` changes.
132
+ - `create_task(coroutine)` starts the session's background reader. It defaults
133
+ to `asyncio.create_task`. Home Assistant passes
134
+ `entry.async_create_background_task`.
135
+
136
+ ### State
137
+
138
+ - `snapshot()` returns an independent copy of the latest `Snapshot`: the
139
+ `areas`, the `can_arm` and `can_disarm` area sets, `permissions_received`,
140
+ `observed_at` and, when reported, `firmware`.
141
+ - `state` is `disconnected`, `connecting`, `connected` or `error`. After an
142
+ error, `detail` holds a fixed explanation.
143
+ - `idle_expires_at` is the wall-clock time at which an idle session will close.
144
+
145
+ Area `value` codes are listed in `STATUS`: `0` Unset, `1` Set, `2` Alarmed,
146
+ `3` Setting, `4` Cannot Set, `5` Can Override and `6` Set Cancelled. An arm
147
+ request counts as confirmed once the area reports Setting. Arming is refused
148
+ while an area reports Cannot Set or Can Override.
149
+
150
+ ### Errors
151
+
152
+ All errors derive from `ProtocolError`:
153
+
154
+ | Exception | Meaning |
155
+ | --- | --- |
156
+ | `AuthenticationError` | The cloud or panel rejected the credentials. Ask for new details; do not retry automatically. |
157
+ | `PanelBusyError` | Another command on this client is still waiting for its result. |
158
+ | `CommandUnconfirmedError` | The command may have reached the panel, but its outcome is unknown. `snapshot` holds the latest state, if the session is still open. |
159
+ | `ProtocolError` | Any other connection, validation or panel failure. |
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ python -m pip install -e . --group dev
165
+ ruff check .
166
+ ruff format --check .
167
+ mypy
168
+ pytest --cov
169
+ ```
170
+
171
+ The tests use a simulated panel and require 100% branch coverage. They never
172
+ contact PyronixCloud.
173
+
174
+ ## Licence
175
+
176
+ [MIT](LICENSE)
@@ -0,0 +1,8 @@
1
+ aiopyronix/__init__.py,sha256=a2fTBPY4K7YenM2jITIRcL1Zz1pE1DC2nCzkvhboc74,812
2
+ aiopyronix/client.py,sha256=hkCAQgKZ3eDIAsdOkp8u_GyKEwE_23nAGwjvxIKo37s,20948
3
+ aiopyronix/protocol.py,sha256=YKtwlS2MWJb-tCAO6arr9JP3QaC3BOdJYTjaFlH8NjU,10535
4
+ aiopyronix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ aiopyronix-0.1.0.dist-info/METADATA,sha256=nDccs7HLBmnp6BR2BQ1kryvjgO6uBCLCzedjOIPh4IE,6651
6
+ aiopyronix-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ aiopyronix-0.1.0.dist-info/licenses/LICENSE,sha256=-32dWCPbhRVK0yNgXBoX6Ky6fwZWtk7rrUyYqqRbuzU,1069
8
+ aiopyronix-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Herbertmt978
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.