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.
Files changed (40) hide show
  1. pyronix_cloud/__init__.py +110 -0
  2. pyronix_cloud/announce.py +107 -0
  3. pyronix_cloud/area_rules.py +62 -0
  4. pyronix_cloud/backoff.py +62 -0
  5. pyronix_cloud/client.py +265 -0
  6. pyronix_cloud/cloud.py +208 -0
  7. pyronix_cloud/cloud_stages.py +56 -0
  8. pyronix_cloud/commands.py +124 -0
  9. pyronix_cloud/connection.py +223 -0
  10. pyronix_cloud/crypto.py +273 -0
  11. pyronix_cloud/documents.py +285 -0
  12. pyronix_cloud/errors.py +95 -0
  13. pyronix_cloud/frames.py +195 -0
  14. pyronix_cloud/link.py +246 -0
  15. pyronix_cloud/link_codec.py +75 -0
  16. pyronix_cloud/link_events.py +42 -0
  17. pyronix_cloud/link_nacks.py +79 -0
  18. pyronix_cloud/models.py +231 -0
  19. pyronix_cloud/panel_feed.py +175 -0
  20. pyronix_cloud/panel_stages.py +95 -0
  21. pyronix_cloud/panel_state.py +129 -0
  22. pyronix_cloud/py.typed +0 -0
  23. pyronix_cloud/session.py +212 -0
  24. pyronix_cloud/session_events.py +65 -0
  25. pyronix_cloud/settings.py +40 -0
  26. pyronix_cloud/testing/__init__.py +8 -0
  27. pyronix_cloud/testing/__main__.py +72 -0
  28. pyronix_cloud/testing/cloud_session.py +232 -0
  29. pyronix_cloud/testing/dump.py +175 -0
  30. pyronix_cloud/testing/fake_cloud.py +202 -0
  31. pyronix_cloud/testing/fake_panel.py +267 -0
  32. pyronix_cloud/testing/memory_socket.py +88 -0
  33. pyronix_cloud/testing/panel_documents.py +132 -0
  34. pyronix_cloud/transport.py +166 -0
  35. pyronix_cloud/websocket.py +124 -0
  36. pyronix_cloud-0.1.0.dist-info/METADATA +146 -0
  37. pyronix_cloud-0.1.0.dist-info/RECORD +40 -0
  38. pyronix_cloud-0.1.0.dist-info/WHEEL +4 -0
  39. pyronix_cloud-0.1.0.dist-info/licenses/COPYING +674 -0
  40. 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)
@@ -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
@@ -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")