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 +37 -0
- aiopyronix/client.py +537 -0
- aiopyronix/protocol.py +268 -0
- aiopyronix/py.typed +0 -0
- aiopyronix-0.1.0.dist-info/METADATA +176 -0
- aiopyronix-0.1.0.dist-info/RECORD +8 -0
- aiopyronix-0.1.0.dist-info/WHEEL +4 -0
- aiopyronix-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://pypi.org/project/aiopyronix/)
|
|
30
|
+
[](https://github.com/Herbertmt978/aiopyronix/actions/workflows/ci.yml)
|
|
31
|
+
[](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,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.
|