pyronix-cloud 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.
- pyronix_cloud/__init__.py +110 -0
- pyronix_cloud/announce.py +107 -0
- pyronix_cloud/area_rules.py +62 -0
- pyronix_cloud/backoff.py +62 -0
- pyronix_cloud/client.py +265 -0
- pyronix_cloud/cloud.py +208 -0
- pyronix_cloud/cloud_stages.py +56 -0
- pyronix_cloud/commands.py +124 -0
- pyronix_cloud/connection.py +223 -0
- pyronix_cloud/crypto.py +273 -0
- pyronix_cloud/documents.py +285 -0
- pyronix_cloud/errors.py +95 -0
- pyronix_cloud/frames.py +195 -0
- pyronix_cloud/link.py +246 -0
- pyronix_cloud/link_codec.py +75 -0
- pyronix_cloud/link_events.py +42 -0
- pyronix_cloud/link_nacks.py +79 -0
- pyronix_cloud/models.py +231 -0
- pyronix_cloud/panel_feed.py +175 -0
- pyronix_cloud/panel_stages.py +95 -0
- pyronix_cloud/panel_state.py +129 -0
- pyronix_cloud/py.typed +0 -0
- pyronix_cloud/session.py +212 -0
- pyronix_cloud/session_events.py +65 -0
- pyronix_cloud/settings.py +40 -0
- pyronix_cloud/testing/__init__.py +8 -0
- pyronix_cloud/testing/__main__.py +72 -0
- pyronix_cloud/testing/cloud_session.py +232 -0
- pyronix_cloud/testing/dump.py +175 -0
- pyronix_cloud/testing/fake_cloud.py +202 -0
- pyronix_cloud/testing/fake_panel.py +267 -0
- pyronix_cloud/testing/memory_socket.py +88 -0
- pyronix_cloud/testing/panel_documents.py +132 -0
- pyronix_cloud/transport.py +166 -0
- pyronix_cloud/websocket.py +124 -0
- pyronix_cloud-0.1.0.dist-info/METADATA +146 -0
- pyronix_cloud-0.1.0.dist-info/RECORD +40 -0
- pyronix_cloud-0.1.0.dist-info/WHEEL +4 -0
- pyronix_cloud-0.1.0.dist-info/licenses/COPYING +674 -0
- pyronix_cloud-0.1.0.dist-info/licenses/LICENSES/LGPL-3.0-or-later.txt +165 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Fryer
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
"""Async client for Pyronix panels via the Pyronix Cloud (HomeControl2) relay.
|
|
4
|
+
|
|
5
|
+
The protocol is specified in PROTOCOL.md at the repository root.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from pyronix_cloud.backoff import Backoff
|
|
11
|
+
from pyronix_cloud.client import PyronixClient
|
|
12
|
+
from pyronix_cloud.cloud import LIVE_URL, TEST_URL
|
|
13
|
+
from pyronix_cloud.errors import (
|
|
14
|
+
AccessDeniedError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
CloudError,
|
|
17
|
+
CommandNotConfirmedError,
|
|
18
|
+
CommunicationError,
|
|
19
|
+
ConnectionClosedError,
|
|
20
|
+
DocumentError,
|
|
21
|
+
FrameError,
|
|
22
|
+
HandshakeError,
|
|
23
|
+
NotConnectedError,
|
|
24
|
+
NotPermittedError,
|
|
25
|
+
PanelBusyError,
|
|
26
|
+
PanelOfflineError,
|
|
27
|
+
ProtocolError,
|
|
28
|
+
PyronixError,
|
|
29
|
+
PyronixTimeoutError,
|
|
30
|
+
SubscriptionExpiredError,
|
|
31
|
+
UnknownCloudReplyError,
|
|
32
|
+
)
|
|
33
|
+
from pyronix_cloud.models import (
|
|
34
|
+
Area,
|
|
35
|
+
AreaDocument,
|
|
36
|
+
AreaEvent,
|
|
37
|
+
AreaStatus,
|
|
38
|
+
FaultDocument,
|
|
39
|
+
Input,
|
|
40
|
+
InputDocument,
|
|
41
|
+
InputState,
|
|
42
|
+
LogDocument,
|
|
43
|
+
LogEntry,
|
|
44
|
+
Output,
|
|
45
|
+
OutputDocument,
|
|
46
|
+
PanelDocument,
|
|
47
|
+
PanelFault,
|
|
48
|
+
PanelInfo,
|
|
49
|
+
SetType,
|
|
50
|
+
UnknownDocument,
|
|
51
|
+
User,
|
|
52
|
+
UserDocument,
|
|
53
|
+
)
|
|
54
|
+
from pyronix_cloud.panel_state import PanelSnapshot
|
|
55
|
+
from pyronix_cloud.settings import Credentials, Timeouts
|
|
56
|
+
from pyronix_cloud.websocket import aiohttp_connector
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
__version__ = version("pyronix-cloud")
|
|
60
|
+
except PackageNotFoundError: # pragma: no cover - only when run from a raw checkout
|
|
61
|
+
__version__ = "0.0.0"
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"LIVE_URL",
|
|
65
|
+
"TEST_URL",
|
|
66
|
+
"AccessDeniedError",
|
|
67
|
+
"Area",
|
|
68
|
+
"AreaDocument",
|
|
69
|
+
"AreaEvent",
|
|
70
|
+
"AreaStatus",
|
|
71
|
+
"AuthenticationError",
|
|
72
|
+
"Backoff",
|
|
73
|
+
"CloudError",
|
|
74
|
+
"CommandNotConfirmedError",
|
|
75
|
+
"CommunicationError",
|
|
76
|
+
"ConnectionClosedError",
|
|
77
|
+
"Credentials",
|
|
78
|
+
"DocumentError",
|
|
79
|
+
"FaultDocument",
|
|
80
|
+
"FrameError",
|
|
81
|
+
"HandshakeError",
|
|
82
|
+
"Input",
|
|
83
|
+
"InputDocument",
|
|
84
|
+
"InputState",
|
|
85
|
+
"LogDocument",
|
|
86
|
+
"LogEntry",
|
|
87
|
+
"NotConnectedError",
|
|
88
|
+
"NotPermittedError",
|
|
89
|
+
"Output",
|
|
90
|
+
"OutputDocument",
|
|
91
|
+
"PanelBusyError",
|
|
92
|
+
"PanelDocument",
|
|
93
|
+
"PanelFault",
|
|
94
|
+
"PanelInfo",
|
|
95
|
+
"PanelOfflineError",
|
|
96
|
+
"PanelSnapshot",
|
|
97
|
+
"ProtocolError",
|
|
98
|
+
"PyronixClient",
|
|
99
|
+
"PyronixError",
|
|
100
|
+
"PyronixTimeoutError",
|
|
101
|
+
"SetType",
|
|
102
|
+
"SubscriptionExpiredError",
|
|
103
|
+
"Timeouts",
|
|
104
|
+
"UnknownCloudReplyError",
|
|
105
|
+
"UnknownDocument",
|
|
106
|
+
"User",
|
|
107
|
+
"UserDocument",
|
|
108
|
+
"__version__",
|
|
109
|
+
"aiohttp_connector",
|
|
110
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Fryer
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
"""Decode the panel's ``a`` announce: System ID, modem type and key extra bytes.
|
|
4
|
+
|
|
5
|
+
PROTOCOL.md §4.4 (step 3), §4.6.
|
|
6
|
+
|
|
7
|
+
The modem byte ``p[1]`` is read unsigned, fixing the app bug that left 0x81-0x86
|
|
8
|
+
as ``UNKNOWN``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
import enum
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
_FLAGS_AT: Final = 1
|
|
18
|
+
_ID_START: Final = 2
|
|
19
|
+
_EXTRA_START: Final = 10
|
|
20
|
+
_EXTRA_MAX: Final = 4
|
|
21
|
+
_EXTRA_BASE_LEN: Final = 42
|
|
22
|
+
_EXTENDED: Final = 0x80
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModemType(enum.Enum):
|
|
26
|
+
"""The panel's uplink. Values are the extended codes of §4.6 (0 = unknown)."""
|
|
27
|
+
|
|
28
|
+
UNKNOWN = 0
|
|
29
|
+
GSM = 0x84
|
|
30
|
+
GPRS = 0x85
|
|
31
|
+
WAN_LAN = 0x83
|
|
32
|
+
USB = 0x81
|
|
33
|
+
PSTN = 0x86
|
|
34
|
+
RS232 = 0x82
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_MODEM_FLAGS: Final = (
|
|
38
|
+
(0x60, ModemType.WAN_LAN),
|
|
39
|
+
(0x02, ModemType.GPRS),
|
|
40
|
+
(0x01, ModemType.GSM),
|
|
41
|
+
(0x04, ModemType.PSTN),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Announced:
|
|
47
|
+
"""What the panel announced about itself, as reported to callers."""
|
|
48
|
+
|
|
49
|
+
modem: ModemType
|
|
50
|
+
"""The uplink the panel reports."""
|
|
51
|
+
|
|
52
|
+
json_version: int
|
|
53
|
+
"""The first key extra byte, or 0 if there is none (``getJsonVersion``)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Announce:
|
|
58
|
+
"""The fields of an ``a`` announce frame."""
|
|
59
|
+
|
|
60
|
+
panel_id: bytes
|
|
61
|
+
"""Payload bytes 2-9, to compare with the configured System ID."""
|
|
62
|
+
|
|
63
|
+
key_extra: bytes
|
|
64
|
+
"""Up to 4 bytes mixed into the session key (``announceExtra``)."""
|
|
65
|
+
|
|
66
|
+
announced: Announced
|
|
67
|
+
"""The part reported to callers."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def modem_type(flags: int) -> ModemType:
|
|
71
|
+
"""Decode the announce modem byte.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
flags: ``p[1]``, read unsigned (0..255).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The extended code if bit 7 is set, else the first matching flag bit.
|
|
78
|
+
"""
|
|
79
|
+
if flags & _EXTENDED:
|
|
80
|
+
return next((m for m in ModemType if m.value == flags), ModemType.UNKNOWN)
|
|
81
|
+
return next((m for bit, m in _MODEM_FLAGS if flags & bit), ModemType.UNKNOWN)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_announce(payload: bytes, frame_len: int) -> Announce:
|
|
85
|
+
"""Decode a verified ``a`` frame's payload.
|
|
86
|
+
|
|
87
|
+
The extra bytes are ``p[10 : 10 + min(frame_len - 42, 4)]``, kept only when
|
|
88
|
+
bit 7 of ``p[1]`` is set, exactly as ``initialKeyMessages`` does.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
payload: The frame payload (after the 5-byte header).
|
|
92
|
+
frame_len: The length of the whole raw frame.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The decoded announce. A payload too short for a field yields a short
|
|
96
|
+
``panel_id`` (which will not match) and flags of 0.
|
|
97
|
+
"""
|
|
98
|
+
flags = payload[_FLAGS_AT] if len(payload) > _FLAGS_AT else 0
|
|
99
|
+
extra_len = min(frame_len - _EXTRA_BASE_LEN, _EXTRA_MAX)
|
|
100
|
+
extra = b""
|
|
101
|
+
if flags & _EXTENDED and extra_len > 0:
|
|
102
|
+
extra = payload[_EXTRA_START : _EXTRA_START + extra_len]
|
|
103
|
+
return Announce(
|
|
104
|
+
panel_id=payload[_ID_START:_EXTRA_START],
|
|
105
|
+
key_extra=extra,
|
|
106
|
+
announced=Announced(modem_type(flags), extra[0] if extra else 0),
|
|
107
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Fryer
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
"""Decide whether a set/unset command is allowed and when the panel has confirmed it.
|
|
4
|
+
|
|
5
|
+
Both checks read only a :class:`~pyronix_cloud.panel_state.PanelSnapshot`, so
|
|
6
|
+
they are pure functions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Final
|
|
12
|
+
|
|
13
|
+
from pyronix_cloud.errors import NotPermittedError
|
|
14
|
+
from pyronix_cloud.models import AreaStatus
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pyronix_cloud.panel_state import PanelSnapshot
|
|
18
|
+
|
|
19
|
+
ARMED: Final = frozenset({AreaStatus.SETTING, AreaStatus.SET, AreaStatus.ALARMED})
|
|
20
|
+
"""Statuses that confirm a set command: the area left unset."""
|
|
21
|
+
|
|
22
|
+
DISARMED: Final = frozenset({AreaStatus.UNSET})
|
|
23
|
+
"""Statuses that confirm an unset command."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def all_in(
|
|
27
|
+
snapshot: PanelSnapshot, areas: tuple[int, ...], statuses: frozenset[AreaStatus]
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Return whether every area is known and has one of ``statuses``.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
snapshot: The panel state.
|
|
33
|
+
areas: Area numbers.
|
|
34
|
+
statuses: The accepted statuses.
|
|
35
|
+
"""
|
|
36
|
+
known = snapshot.areas
|
|
37
|
+
return all(n in known and known[n].status in statuses for n in areas)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_permitted(
|
|
41
|
+
snapshot: PanelSnapshot, areas: tuple[int, ...], *, set_areas: bool
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Raise unless the logged-in user may set (or unset) every area.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
snapshot: The panel state, with the ``user`` document.
|
|
47
|
+
areas: Area numbers.
|
|
48
|
+
set_areas: True for a set command, False for unset.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
NotPermittedError: The user may not act on some of the areas (or no
|
|
52
|
+
user is known yet).
|
|
53
|
+
"""
|
|
54
|
+
user = snapshot.user
|
|
55
|
+
allowed = frozenset[int]()
|
|
56
|
+
if user is not None:
|
|
57
|
+
allowed = user.set_areas if set_areas else user.unset_areas
|
|
58
|
+
denied = sorted(set(areas) - allowed)
|
|
59
|
+
if denied:
|
|
60
|
+
verb = "set" if set_areas else "unset"
|
|
61
|
+
msg = f"the user may not {verb} areas {denied}"
|
|
62
|
+
raise NotPermittedError(msg)
|
pyronix_cloud/backoff.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Fryer
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
"""Exponential reconnect back-off with jitter for persistent sessions.
|
|
4
|
+
|
|
5
|
+
Repeated failed logins may lock the panel or raise tamper alerts (AGENTS.md), so a
|
|
6
|
+
persistent connection must never retry in a tight loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Backoff:
|
|
19
|
+
"""Delays that grow by ``factor`` per failure, capped at ``maximum``."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
initial: float = 5.0,
|
|
25
|
+
maximum: float = 300.0,
|
|
26
|
+
factor: float = 2.0,
|
|
27
|
+
jitter: float = 0.1,
|
|
28
|
+
rng: Callable[[], float] = random.random,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Configure the back-off.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
initial: Delay in seconds after the first failure.
|
|
34
|
+
maximum: Upper bound for any delay, before jitter.
|
|
35
|
+
factor: Growth per consecutive failure.
|
|
36
|
+
jitter: Fraction of the delay added at random (0.1 adds up to 10 %).
|
|
37
|
+
rng: Source of uniform floats in [0, 1); injectable for tests.
|
|
38
|
+
"""
|
|
39
|
+
if initial <= 0 or maximum < initial or factor < 1 or not 0 <= jitter <= 1:
|
|
40
|
+
msg = "invalid back-off parameters"
|
|
41
|
+
raise ValueError(msg)
|
|
42
|
+
self._initial = initial
|
|
43
|
+
self._maximum = maximum
|
|
44
|
+
self._factor = factor
|
|
45
|
+
self._jitter = jitter
|
|
46
|
+
self._rng = rng
|
|
47
|
+
self._failures = 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def failures(self) -> int:
|
|
51
|
+
"""Consecutive failures since the last :meth:`reset`."""
|
|
52
|
+
return self._failures
|
|
53
|
+
|
|
54
|
+
def next_delay(self) -> float:
|
|
55
|
+
"""Record a failure and return the seconds to wait before the next try."""
|
|
56
|
+
base = min(self._maximum, self._initial * self._factor**self._failures)
|
|
57
|
+
self._failures += 1
|
|
58
|
+
return base * (1 + self._jitter * self._rng())
|
|
59
|
+
|
|
60
|
+
def reset(self) -> None:
|
|
61
|
+
"""Forget earlier failures after a successful connection."""
|
|
62
|
+
self._failures = 0
|
pyronix_cloud/client.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Fryer
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
"""Async client for one panel session over the cloud relay with arm and disarm commands.
|
|
4
|
+
|
|
5
|
+
:class:`PyronixClient` opens a WebSocket through an injected
|
|
6
|
+
:data:`~pyronix_cloud.websocket.Connector`, runs the protocol session to the end
|
|
7
|
+
of the login dump (:mod:`pyronix_cloud.connection`), keeps the panel state
|
|
8
|
+
(:mod:`pyronix_cloud.panel_feed`) and sends set/unset commands, waiting for the
|
|
9
|
+
panel to confirm them. It never logs the app password, the user code or the
|
|
10
|
+
login payload.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
import logging
|
|
17
|
+
from typing import TYPE_CHECKING, Self
|
|
18
|
+
|
|
19
|
+
from pyronix_cloud import commands
|
|
20
|
+
from pyronix_cloud.area_rules import ARMED, DISARMED, all_in, check_permitted
|
|
21
|
+
from pyronix_cloud.cloud import LIVE_URL, CloudIdentity
|
|
22
|
+
from pyronix_cloud.connection import Connection, open_socket
|
|
23
|
+
from pyronix_cloud.errors import (
|
|
24
|
+
CommandNotConfirmedError,
|
|
25
|
+
ConnectionClosedError,
|
|
26
|
+
NotConnectedError,
|
|
27
|
+
PyronixError,
|
|
28
|
+
)
|
|
29
|
+
from pyronix_cloud.panel_feed import PanelFeed
|
|
30
|
+
from pyronix_cloud.session import ProtocolSession
|
|
31
|
+
from pyronix_cloud.settings import Credentials, Timeouts
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Callable, Iterable
|
|
35
|
+
from types import TracebackType
|
|
36
|
+
|
|
37
|
+
from pyronix_cloud.models import AreaStatus
|
|
38
|
+
from pyronix_cloud.panel_feed import SnapshotListener
|
|
39
|
+
from pyronix_cloud.panel_state import PanelSnapshot
|
|
40
|
+
from pyronix_cloud.websocket import Connector
|
|
41
|
+
|
|
42
|
+
__all__ = ["Credentials", "PyronixClient", "Timeouts"]
|
|
43
|
+
|
|
44
|
+
_LOGGER = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
type DisconnectListener = Callable[[BaseException | None], None]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PyronixClient:
|
|
50
|
+
"""One panel session over the Pyronix Cloud relay."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
credentials: Credentials,
|
|
55
|
+
connector: Connector,
|
|
56
|
+
*,
|
|
57
|
+
url: str = LIVE_URL,
|
|
58
|
+
timeouts: Timeouts | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Prepare the client; nothing is sent until :meth:`connect`.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
credentials: The panel identity and secrets.
|
|
64
|
+
connector: Opens the WebSocket (see ``aiohttp_connector``).
|
|
65
|
+
url: The cloud relay URL.
|
|
66
|
+
timeouts: Phase timeouts; :class:`Timeouts` defaults if None.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: A credential is malformed (the value is not echoed).
|
|
70
|
+
"""
|
|
71
|
+
self._user_code = credentials.user_code
|
|
72
|
+
self._identity = CloudIdentity(
|
|
73
|
+
app_user=credentials.app_user,
|
|
74
|
+
panel_id=credentials.panel_id,
|
|
75
|
+
app_password=credentials.app_password,
|
|
76
|
+
device_id=credentials.device_id,
|
|
77
|
+
push_id=credentials.push_id,
|
|
78
|
+
)
|
|
79
|
+
commands.login(credentials.user_code) # validate the code now, not later
|
|
80
|
+
self._connector = connector
|
|
81
|
+
self._url = url
|
|
82
|
+
self._timeouts = timeouts or Timeouts()
|
|
83
|
+
self._feed = PanelFeed(self._timeouts)
|
|
84
|
+
self._connection: Connection | None = None
|
|
85
|
+
self._connected = False
|
|
86
|
+
self._disconnect_listeners: list[DisconnectListener] = []
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def snapshot(self) -> PanelSnapshot:
|
|
90
|
+
"""The latest panel state."""
|
|
91
|
+
return self._feed.snapshot
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def connected(self) -> bool:
|
|
95
|
+
"""Whether a session is in data mode with its login dump complete."""
|
|
96
|
+
return self._connected
|
|
97
|
+
|
|
98
|
+
def add_listener(self, listener: SnapshotListener) -> Callable[[], None]:
|
|
99
|
+
"""Call ``listener`` with each changed snapshot; returns a remover."""
|
|
100
|
+
return self._feed.add_listener(listener)
|
|
101
|
+
|
|
102
|
+
def add_disconnect_listener(
|
|
103
|
+
self, listener: DisconnectListener
|
|
104
|
+
) -> Callable[[], None]:
|
|
105
|
+
"""Call ``listener`` when a connected session ends on its own.
|
|
106
|
+
|
|
107
|
+
It gets the error, or None after a clean panel goodbye. It is not
|
|
108
|
+
called after :meth:`close`. Returns a remover.
|
|
109
|
+
"""
|
|
110
|
+
self._disconnect_listeners.append(listener)
|
|
111
|
+
|
|
112
|
+
def remove() -> None:
|
|
113
|
+
with suppress(ValueError):
|
|
114
|
+
self._disconnect_listeners.remove(listener)
|
|
115
|
+
|
|
116
|
+
return remove
|
|
117
|
+
|
|
118
|
+
async def connect(self) -> PanelSnapshot:
|
|
119
|
+
"""Open a session and wait for the panel's login dump.
|
|
120
|
+
|
|
121
|
+
On any failure or cancellation the goodbye is sent and the socket
|
|
122
|
+
closed before the error propagates.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The snapshot once the dump is complete.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
RuntimeError: A session is already open.
|
|
129
|
+
CommunicationError: The cloud relay could not be reached.
|
|
130
|
+
CloudError: The cloud relay refused the session.
|
|
131
|
+
AuthenticationError: The panel rejected the credentials.
|
|
132
|
+
PyronixTimeoutError: A stage or the dump did not finish in time.
|
|
133
|
+
PyronixError: Any other protocol or socket failure.
|
|
134
|
+
"""
|
|
135
|
+
if self._connection is not None:
|
|
136
|
+
msg = "the client already has a session; close it first"
|
|
137
|
+
raise RuntimeError(msg)
|
|
138
|
+
self._feed.restart()
|
|
139
|
+
session = ProtocolSession(self._identity, self._user_code)
|
|
140
|
+
connection = Connection(
|
|
141
|
+
await open_socket(self._connector, self._url, self._timeouts.connect),
|
|
142
|
+
session,
|
|
143
|
+
on_event=self._feed.handle,
|
|
144
|
+
on_end=self._on_end,
|
|
145
|
+
close_timeout=self._timeouts.close,
|
|
146
|
+
)
|
|
147
|
+
self._connection = connection
|
|
148
|
+
try:
|
|
149
|
+
await connection.start()
|
|
150
|
+
await self._feed.wait_for_dump()
|
|
151
|
+
except BaseException:
|
|
152
|
+
self._connection = None
|
|
153
|
+
await connection.close()
|
|
154
|
+
raise
|
|
155
|
+
self._connected = True
|
|
156
|
+
_LOGGER.debug("Connected; login dump complete")
|
|
157
|
+
return self.snapshot
|
|
158
|
+
|
|
159
|
+
async def arm(self, areas: Iterable[int], *, force: bool = True) -> PanelSnapshot:
|
|
160
|
+
"""Set areas and wait until each is setting, set or in alarm.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
areas: Area numbers.
|
|
164
|
+
force: Omit open inputs and set anyway (as the app does).
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The snapshot that confirmed the command.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
NotConnectedError: No session is connected.
|
|
171
|
+
ValueError: The area numbers are invalid.
|
|
172
|
+
NotPermittedError: The user may not set one of the areas.
|
|
173
|
+
CommandNotConfirmedError: The panel did not confirm in time.
|
|
174
|
+
"""
|
|
175
|
+
numbers = tuple(areas)
|
|
176
|
+
connection = self._require_connection()
|
|
177
|
+
payload = commands.set_areas(numbers, force=force)
|
|
178
|
+
check_permitted(self.snapshot, numbers, set_areas=True)
|
|
179
|
+
await connection.send_command(payload)
|
|
180
|
+
return await self._confirm(numbers, ARMED)
|
|
181
|
+
|
|
182
|
+
async def disarm(self, areas: Iterable[int]) -> PanelSnapshot:
|
|
183
|
+
"""Unset areas and wait until each is unset.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
areas: Area numbers.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The snapshot that confirmed the command (at once, without sending,
|
|
190
|
+
if every area is already unset).
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
NotConnectedError: No session is connected.
|
|
194
|
+
ValueError: The area numbers are invalid.
|
|
195
|
+
NotPermittedError: The user may not unset one of the areas.
|
|
196
|
+
CommandNotConfirmedError: The panel did not confirm in time.
|
|
197
|
+
"""
|
|
198
|
+
numbers = tuple(areas)
|
|
199
|
+
connection = self._require_connection()
|
|
200
|
+
payload = commands.unset_areas(numbers)
|
|
201
|
+
check_permitted(self.snapshot, numbers, set_areas=False)
|
|
202
|
+
if all_in(self.snapshot, numbers, DISARMED):
|
|
203
|
+
return self.snapshot
|
|
204
|
+
await connection.send_command(payload)
|
|
205
|
+
return await self._confirm(numbers, DISARMED)
|
|
206
|
+
|
|
207
|
+
async def close(self) -> None:
|
|
208
|
+
"""Say goodbye and close the socket.
|
|
209
|
+
|
|
210
|
+
Idempotent. The goodbye completes even if the caller is cancelled, and
|
|
211
|
+
takes at most ``Timeouts.close``.
|
|
212
|
+
"""
|
|
213
|
+
self._connected = False
|
|
214
|
+
connection, self._connection = self._connection, None
|
|
215
|
+
if connection is not None:
|
|
216
|
+
await connection.close()
|
|
217
|
+
self._feed.end(ConnectionClosedError("the client closed the session"))
|
|
218
|
+
|
|
219
|
+
async def __aenter__(self) -> Self:
|
|
220
|
+
"""Connect and return the client."""
|
|
221
|
+
await self.connect()
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
async def __aexit__(
|
|
225
|
+
self,
|
|
226
|
+
exc_type: type[BaseException] | None,
|
|
227
|
+
exc: BaseException | None,
|
|
228
|
+
tb: TracebackType | None,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Close the session."""
|
|
231
|
+
await self.close()
|
|
232
|
+
|
|
233
|
+
def _require_connection(self) -> Connection:
|
|
234
|
+
"""Return the connection, or raise if no session is connected."""
|
|
235
|
+
if not self._connected or self._connection is None:
|
|
236
|
+
msg = "the panel session is not connected"
|
|
237
|
+
raise NotConnectedError(msg)
|
|
238
|
+
return self._connection
|
|
239
|
+
|
|
240
|
+
async def _confirm(
|
|
241
|
+
self, numbers: tuple[int, ...], statuses: frozenset[AreaStatus]
|
|
242
|
+
) -> PanelSnapshot:
|
|
243
|
+
"""Wait until every area in ``numbers`` reaches one of ``statuses``."""
|
|
244
|
+
return await self._feed.wait_until(
|
|
245
|
+
lambda snapshot: all_in(snapshot, numbers, statuses),
|
|
246
|
+
self._timeouts.command,
|
|
247
|
+
lambda: CommandNotConfirmedError(f"areas {list(numbers)} not confirmed"),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _on_end(self, error: PyronixError | None) -> None:
|
|
251
|
+
"""Record that the session ended and tell the disconnect listeners."""
|
|
252
|
+
self._feed.end(error)
|
|
253
|
+
was_connected, self._connected = self._connected, False
|
|
254
|
+
if not was_connected:
|
|
255
|
+
return
|
|
256
|
+
self._connection = None
|
|
257
|
+
if error is None:
|
|
258
|
+
_LOGGER.debug("The panel ended the session")
|
|
259
|
+
else:
|
|
260
|
+
_LOGGER.warning("The panel session ended: %s", error)
|
|
261
|
+
for listener in list(self._disconnect_listeners):
|
|
262
|
+
try:
|
|
263
|
+
listener(error)
|
|
264
|
+
except Exception:
|
|
265
|
+
_LOGGER.exception("Error in a disconnect listener")
|