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
pyronix_cloud/cloud.py ADDED
@@ -0,0 +1,208 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Fryer
2
+ # SPDX-License-Identifier: LGPL-3.0-or-later
3
+ """Cloud relay control language: build `<p>` requests and parse `<e>` replies.
4
+
5
+ PROTOCOL.md §3.3-3.4.
6
+
7
+ Requests are ported from ``HomeControl.makeCloudMessage`` (field order exact, no
8
+ XML escaping, ``SaveAppPwd = 0`` so the app password travels in plain text inside
9
+ ``<z>``). Replies are parsed from the ``<e>`` position and tolerate the short
10
+ forms seen live (``<e>u</e>``, ``<e>j </e>``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field, fields
16
+ import enum
17
+ import re
18
+ from typing import Final
19
+
20
+ from pyronix_cloud.errors import (
21
+ AccessDeniedError,
22
+ CloudError,
23
+ PanelBusyError,
24
+ PanelOfflineError,
25
+ SubscriptionExpiredError,
26
+ UnknownCloudReplyError,
27
+ )
28
+ from pyronix_cloud.transport import wrap
29
+
30
+ LIVE_URL: Final = "wss://app.pyronixcloud.com:443"
31
+ TEST_URL: Final = "wss://apptestlb.pyronixcloud.com:443"
32
+ APP_VERSION: Final = "6.03.001"
33
+ APP_TYPE: Final = 16
34
+ OS_NAME: Final = "Android"
35
+ OS_VERSION: Final = "14"
36
+ NETWORK_TYPE: Final = "Wifi"
37
+ APP_LANG: Final = "uk"
38
+ DEFAULT_PUSH_ID: Final = "ha-pyronix"
39
+
40
+ _REQUIRED_FIELDS: Final = ("app_user", "panel_id", "device_id", "push_id")
41
+ _FORBIDDEN_CHARS: Final = frozenset("<>&")
42
+ _REPLY_START: Final = "<e>"
43
+ _REPLY_END: Final = "</e>"
44
+ _SECRET_TAG: Final = re.compile(r"<z>.*?(?:</z>|\Z)", re.DOTALL)
45
+ _REDACTED: Final = "<z>[REDACTED]</z>"
46
+
47
+ _ERROR_LETTERS: Final[dict[str, type[CloudError]]] = {
48
+ error.letter: error
49
+ for error in (
50
+ AccessDeniedError,
51
+ PanelBusyError,
52
+ SubscriptionExpiredError,
53
+ PanelOfflineError,
54
+ )
55
+ }
56
+ _OK_LETTERS: Final = frozenset("wmjuox")
57
+
58
+
59
+ class CloudRequest(enum.Enum):
60
+ """The cloud control messages this library sends (``CloudMsgType``)."""
61
+
62
+ SUBSCRIPTION = enum.auto()
63
+ REQ_PWD = enum.auto()
64
+ CONN_TO_CLOUD = enum.auto()
65
+ CONN_TO_PANEL = enum.auto()
66
+
67
+
68
+ @dataclass(frozen=True, slots=True, kw_only=True)
69
+ class CloudIdentity:
70
+ """The values that identify the app and the panel to the cloud relay.
71
+
72
+ Raises:
73
+ ValueError: A required field is empty, or a field contains ``<``, ``>``
74
+ or ``&`` (the protocol does no XML escaping). The message names the
75
+ field, never its value.
76
+ """
77
+
78
+ app_user: str
79
+ panel_id: str
80
+ app_password: str = field(repr=False)
81
+ device_id: str
82
+ push_id: str = DEFAULT_PUSH_ID
83
+ system_name: str = "Home"
84
+
85
+ def __post_init__(self) -> None:
86
+ """Validate every field."""
87
+ for name in _REQUIRED_FIELDS:
88
+ if not getattr(self, name):
89
+ msg = f"{name} must not be empty"
90
+ raise ValueError(msg)
91
+ for item in fields(self):
92
+ if _FORBIDDEN_CHARS.intersection(getattr(self, item.name)):
93
+ msg = f"{item.name} must not contain '<', '>' or '&'"
94
+ raise ValueError(msg)
95
+
96
+
97
+ def _type_tags(identity: CloudIdentity, kind: CloudRequest) -> str:
98
+ """Return the type-specific tags of a request (PROTOCOL.md §3.3)."""
99
+ if kind is CloudRequest.SUBSCRIPTION:
100
+ return f"<w>{identity.panel_id}</w>"
101
+ if kind is CloudRequest.REQ_PWD:
102
+ return f"<j>{identity.panel_id}</j>"
103
+ return (
104
+ f"<a>{identity.panel_id}</a><b>{identity.system_name}</b>"
105
+ f"<z>{identity.app_password}</z>"
106
+ )
107
+
108
+
109
+ def build_request(identity: CloudIdentity, kind: CloudRequest) -> str:
110
+ """Build the WebSocket text of a cloud control request.
111
+
112
+ The result contains the app password for the two connect requests; log it
113
+ only through :func:`redact`, and never log the base64 form.
114
+
115
+ Args:
116
+ identity: The app and panel identity.
117
+ kind: Which request to build.
118
+
119
+ Returns:
120
+ ``"(" + base64 + ")"`` for the first three kinds, ``"(" + XML + ")"``
121
+ for :attr:`CloudRequest.CONN_TO_PANEL`.
122
+ """
123
+ xml = (
124
+ f"<p><n>{identity.app_user}</n><u>{identity.push_id}</u>"
125
+ f"{_type_tags(identity, kind)}"
126
+ f"<i>{identity.device_id}</i><l>{APP_VERSION}</l><t>{APP_TYPE}</t>"
127
+ f"<m>{OS_NAME}</m><v>{OS_VERSION}</v><c>{NETWORK_TYPE}</c>"
128
+ f"<lang>{APP_LANG}</lang></p>"
129
+ )
130
+ if kind is CloudRequest.CONN_TO_PANEL:
131
+ return f"({xml})"
132
+ return wrap(xml.encode("utf-8"))
133
+
134
+
135
+ def redact(text: str) -> str:
136
+ """Replace the content of every ``<z>…</z>`` tag with ``[REDACTED]``.
137
+
138
+ An unterminated ``<z>`` is redacted to the end of the text.
139
+
140
+ Args:
141
+ text: Plain (not base64) request text.
142
+
143
+ Returns:
144
+ The text, safe to log.
145
+ """
146
+ return _SECRET_TAG.sub(_REDACTED, text)
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class CloudReply:
151
+ """A parsed ``<e>`` reply from the cloud relay."""
152
+
153
+ letter: str
154
+ data: str = field(repr=False)
155
+
156
+
157
+ def is_heartbeat(message: str) -> bool:
158
+ """Tell whether a text message is the cloud heartbeat.
159
+
160
+ Args:
161
+ message: One inbound WebSocket text message.
162
+
163
+ Returns:
164
+ True only for a single whitespace character. Any other one-character
165
+ message (such as a split-off ``)``) is frame data.
166
+ """
167
+ return len(message) == 1 and message.isspace()
168
+
169
+
170
+ def parse_reply(message: str) -> CloudReply | None:
171
+ """Parse a cloud ``<e>`` reply.
172
+
173
+ Parsing starts at ``<e>``, wherever it is. The letter is the next character;
174
+ the data is what follows the letter and one separator character, up to
175
+ ``</e>`` (or the end of the text if ``</e>`` is missing).
176
+
177
+ Args:
178
+ message: One inbound WebSocket text message.
179
+
180
+ Returns:
181
+ The reply (with an empty letter if nothing follows ``<e>``), or None if
182
+ the message holds no ``<e>``.
183
+ """
184
+ start = message.find(_REPLY_START)
185
+ if start < 0:
186
+ return None
187
+ start += len(_REPLY_START)
188
+ end = message.find(_REPLY_END, start)
189
+ body = message[start:] if end < 0 else message[start:end]
190
+ return CloudReply(letter=body[:1], data=body[2:])
191
+
192
+
193
+ def check_reply(reply: CloudReply) -> None:
194
+ """Raise the error that a cloud reply letter stands for.
195
+
196
+ Args:
197
+ reply: A parsed reply.
198
+
199
+ Raises:
200
+ CloudError: The matching subclass for ``a``, ``b``, ``i`` and ``n``.
201
+ UnknownCloudReplyError: The letter is not one of ``a b i n w m j u o x``.
202
+ """
203
+ error = _ERROR_LETTERS.get(reply.letter)
204
+ if error is not None:
205
+ raise error(error.__doc__)
206
+ if reply.letter not in _OK_LETTERS:
207
+ msg = f"unknown cloud reply letter {reply.letter!r}"
208
+ raise UnknownCloudReplyError(msg)
@@ -0,0 +1,56 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Fryer
2
+ # SPDX-License-Identifier: LGPL-3.0-or-later
3
+ """Advance the cloud relay stages from ``<e>`` replies to the panel relay.
4
+
5
+ PROTOCOL.md §3.4. Sans-IO: each function maps a parsed reply to what the session
6
+ does next, and raises the cloud error for an error letter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from typing import Final
13
+
14
+ from pyronix_cloud import cloud, errors
15
+ from pyronix_cloud.cloud import CloudReply, CloudRequest
16
+ from pyronix_cloud.session_events import Stage
17
+
18
+ _NEXT: Final[dict[str, tuple[Stage, CloudRequest]]] = {
19
+ "w": (Stage.REQUESTING_PASSWORD, CloudRequest.REQ_PWD),
20
+ "m": (Stage.REQUESTING_PASSWORD, CloudRequest.REQ_PWD),
21
+ "j": (Stage.CONNECTING_PANEL, CloudRequest.CONN_TO_CLOUD),
22
+ "u": (Stage.HANDSHAKE, CloudRequest.CONN_TO_PANEL),
23
+ }
24
+
25
+
26
+ def next_step(reply: CloudReply) -> tuple[Stage, CloudRequest] | None:
27
+ """Return the stage and request a reply leads to in the cloud stages.
28
+
29
+ Args:
30
+ reply: A parsed ``<e>`` reply received before the panel relay.
31
+
32
+ Returns:
33
+ The next stage and the request to send, or None for a reply that only
34
+ acknowledges (``o``, ``x``).
35
+
36
+ Raises:
37
+ CloudError: The reply is an error letter or an unknown one.
38
+ """
39
+ cloud.check_reply(reply)
40
+ return _NEXT.get(reply.letter)
41
+
42
+
43
+ def check_relay_reply(reply: CloudReply) -> None:
44
+ """Check a reply that arrives once the panel relay is up.
45
+
46
+ Only error letters matter here; unknown letters and stage letters are
47
+ ignored, since the session has moved past them.
48
+
49
+ Args:
50
+ reply: A parsed ``<e>`` reply received in a panel stage.
51
+
52
+ Raises:
53
+ CloudError: The reply is a known error letter.
54
+ """
55
+ with contextlib.suppress(errors.UnknownCloudReplyError):
56
+ cloud.check_reply(reply)
@@ -0,0 +1,124 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Fryer
2
+ # SPDX-License-Identifier: LGPL-3.0-or-later
3
+ """Build application-layer command payloads carried in `D` frames (PROTOCOL.md §5).
4
+
5
+ These are plaintext payloads; :mod:`pyronix_cloud.frames` encrypts and frames
6
+ them. Only the commands this library uses are defined here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Final
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterable
15
+
16
+ HEARTBEAT: Final = 0x20
17
+ SET_AREAS: Final = 0x2D
18
+ UNSET_AREAS: Final = 0x2F
19
+ REQUEST_LOG: Final = 0x35
20
+
21
+ _MAX_CODE_LEN: Final = 8
22
+ _MAX_AREAS: Final = 127
23
+ _MAX_BYTE: Final = 0xFF
24
+ _FORCE_OMITS: Final = 0x80
25
+
26
+
27
+ def login(user_code: str) -> bytes:
28
+ r"""Build the login payload ``"," + code + "\n"``.
29
+
30
+ Args:
31
+ user_code: The keypad user code, 1 to 8 ASCII digits.
32
+
33
+ Returns:
34
+ The payload. It contains the user code; never log it.
35
+
36
+ Raises:
37
+ ValueError: The code is not 1 to 8 ASCII digits. The message does not
38
+ contain the code.
39
+ """
40
+ if not (
41
+ 0 < len(user_code) <= _MAX_CODE_LEN
42
+ and user_code.isascii()
43
+ and user_code.isdigit()
44
+ ):
45
+ msg = f"user code must be 1 to {_MAX_CODE_LEN} ASCII digits"
46
+ raise ValueError(msg)
47
+ return b"," + user_code.encode("ascii") + b"\n"
48
+
49
+
50
+ def heartbeat() -> bytes:
51
+ """Build the panel heartbeat payload, sent in reply to a cloud heartbeat.
52
+
53
+ Returns:
54
+ ``[0x20]``.
55
+ """
56
+ return bytes([HEARTBEAT])
57
+
58
+
59
+ def _area_bytes(areas: Iterable[int]) -> bytes:
60
+ """Validate area numbers and return them sorted, one byte each."""
61
+ values = list(areas)
62
+ unique = sorted(set(values))
63
+ if not 0 < len(values) <= _MAX_AREAS:
64
+ msg = f"between 1 and {_MAX_AREAS} areas are required, got {len(values)}"
65
+ raise ValueError(msg)
66
+ if len(unique) != len(values):
67
+ msg = "area numbers must be distinct"
68
+ raise ValueError(msg)
69
+ if not all(0 <= area <= _MAX_BYTE for area in unique):
70
+ msg = f"area numbers must be between 0 and {_MAX_BYTE}"
71
+ raise ValueError(msg)
72
+ return bytes(unique)
73
+
74
+
75
+ def set_areas(areas: Iterable[int], *, force: bool = True) -> bytes:
76
+ """Build the set (arm) payload ``[0x2D, n | 0x80 if force, areas…]``.
77
+
78
+ Args:
79
+ areas: 1 to 127 distinct area numbers, each 0 to 255.
80
+ force: Set even if inputs are open, omitting them (the app always does).
81
+
82
+ Returns:
83
+ The payload, with the areas sorted.
84
+
85
+ Raises:
86
+ ValueError: The areas are invalid.
87
+ """
88
+ numbers = _area_bytes(areas)
89
+ flags = _FORCE_OMITS if force else 0
90
+ return bytes([SET_AREAS, len(numbers) | flags]) + numbers
91
+
92
+
93
+ def unset_areas(areas: Iterable[int]) -> bytes:
94
+ """Build the unset (disarm) payload ``[0x2F, n, areas…]``.
95
+
96
+ Args:
97
+ areas: 1 to 127 distinct area numbers, each 0 to 255.
98
+
99
+ Returns:
100
+ The payload, with the areas sorted.
101
+
102
+ Raises:
103
+ ValueError: The areas are invalid.
104
+ """
105
+ numbers = _area_bytes(areas)
106
+ return bytes([UNSET_AREAS, len(numbers)]) + numbers
107
+
108
+
109
+ def request_log(count: int) -> bytes:
110
+ """Build the event-log request payload ``[0x35, count]``.
111
+
112
+ Args:
113
+ count: How many events to request, 1 to 255.
114
+
115
+ Returns:
116
+ The payload.
117
+
118
+ Raises:
119
+ ValueError: The count is out of range.
120
+ """
121
+ if not 0 < count <= _MAX_BYTE:
122
+ msg = f"log count must be between 1 and {_MAX_BYTE}"
123
+ raise ValueError(msg)
124
+ return bytes([REQUEST_LOG, count])
@@ -0,0 +1,223 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Fryer
2
+ # SPDX-License-Identifier: LGPL-3.0-or-later
3
+ """Drive a ProtocolSession over a WebSocket: reader task, sends and goodbye.
4
+
5
+ A :class:`Connection` owns one open socket and one
6
+ :class:`~pyronix_cloud.session.ProtocolSession`. A reader task feeds every
7
+ inbound message into the session, sends what the session asks for and hands
8
+ the events to a callback. Every way out, whether a reader error, a panel
9
+ goodbye, :meth:`Connection.close` or cancellation, sends the session's goodbye
10
+ (best effort) and closes the socket (PROTOCOL.md §4.8).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from functools import partial
17
+ import logging
18
+ from typing import TYPE_CHECKING, Final
19
+
20
+ import aiohttp
21
+
22
+ from pyronix_cloud.errors import (
23
+ ConnectionClosedError,
24
+ NotConnectedError,
25
+ ProtocolError,
26
+ PyronixError,
27
+ PyronixTimeoutError,
28
+ )
29
+ from pyronix_cloud.session import (
30
+ DocumentReceived,
31
+ PanelHeartbeat,
32
+ SessionEnded,
33
+ StageChanged,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable
38
+
39
+ from pyronix_cloud.session import ProtocolSession, SessionOutput
40
+ from pyronix_cloud.websocket import Connector, WebSocket
41
+
42
+ _LOGGER = logging.getLogger(__name__)
43
+
44
+ _SOCKET_ERRORS: Final = (OSError, aiohttp.ClientError)
45
+
46
+ type SessionEvent = StageChanged | DocumentReceived | PanelHeartbeat | SessionEnded
47
+ type EventHandler = Callable[[SessionEvent], None]
48
+ type EndHandler = Callable[[PyronixError | None], None]
49
+
50
+
51
+ async def open_socket(connector: Connector, url: str, within: float) -> WebSocket:
52
+ """Open the WebSocket through ``connector`` within ``within`` seconds.
53
+
54
+ Raises:
55
+ PyronixTimeoutError: The socket did not open in time.
56
+ CommunicationError: The connector could not reach the relay.
57
+ """
58
+ try:
59
+ async with asyncio.timeout(within):
60
+ return await connector(url)
61
+ except TimeoutError as err:
62
+ msg = "timed out opening the cloud WebSocket"
63
+ raise PyronixTimeoutError(msg) from err
64
+
65
+
66
+ class Connection:
67
+ """One protocol session running over one open WebSocket."""
68
+
69
+ def __init__(
70
+ self,
71
+ socket: WebSocket,
72
+ session: ProtocolSession,
73
+ *,
74
+ on_event: EventHandler,
75
+ on_end: EndHandler,
76
+ close_timeout: float,
77
+ ) -> None:
78
+ """Attach to an open socket; nothing is sent until :meth:`start`.
79
+
80
+ Args:
81
+ socket: The open WebSocket to the cloud relay.
82
+ session: A session that has not been started.
83
+ on_event: Called with each session event, after the messages the
84
+ session produced for it have been sent.
85
+ on_end: Called once if the session ends on its own: with the error,
86
+ or None after a clean panel goodbye. Not called after
87
+ :meth:`close`.
88
+ close_timeout: Seconds allowed for the goodbye and socket close.
89
+ """
90
+ self._socket = socket
91
+ self._session = session
92
+ self._on_event = on_event
93
+ self._on_end = on_end
94
+ self._close_timeout = close_timeout
95
+ self._lock = asyncio.Lock()
96
+ self._reader: asyncio.Task[None] | None = None
97
+ self._closer: asyncio.Task[None] | None = None
98
+ self._ending = False
99
+
100
+ async def start(self) -> None:
101
+ """Send the session's first request and start the reader task.
102
+
103
+ Raises:
104
+ ConnectionClosedError: The socket failed while sending.
105
+ """
106
+ await self._exchange(self._session.start)
107
+ self._reader = asyncio.get_running_loop().create_task(
108
+ self._read(), name="pyronix_cloud reader"
109
+ )
110
+
111
+ async def send_command(self, payload: bytes) -> None:
112
+ """Send one application command to the panel.
113
+
114
+ Args:
115
+ payload: A payload from :mod:`pyronix_cloud.commands`.
116
+
117
+ Raises:
118
+ NotConnectedError: The connection is closing or not in data mode.
119
+ ConnectionClosedError: The socket failed while sending.
120
+ """
121
+ if self._closer is not None:
122
+ msg = "the panel session is closing"
123
+ raise NotConnectedError(msg)
124
+ await self._exchange(lambda: self._session.send_command(payload))
125
+
126
+ async def close(self) -> None:
127
+ """Say goodbye, close the socket and stop the reader. Idempotent.
128
+
129
+ The work runs in its own task, so cancelling the caller does not stop
130
+ the goodbye; it is bounded by ``close_timeout``.
131
+ """
132
+ if self._closer is None:
133
+ self._closer = asyncio.get_running_loop().create_task(
134
+ self._close(), name="pyronix_cloud close"
135
+ )
136
+ await asyncio.shield(self._closer)
137
+ reader = self._reader
138
+ if reader is not None and reader is not asyncio.current_task():
139
+ await asyncio.wait([reader])
140
+
141
+ async def _exchange(self, step: Callable[[], SessionOutput]) -> bool:
142
+ """Run one session step, send its output, then dispatch its events.
143
+
144
+ The lock keeps frames in the order the session numbered them.
145
+
146
+ Returns:
147
+ True if the panel ended the session.
148
+ """
149
+ async with self._lock:
150
+ output = step()
151
+ for message in output.outbound:
152
+ await self._send(message)
153
+ for event in output.events:
154
+ self._on_event(event)
155
+ return any(isinstance(event, SessionEnded) for event in output.events)
156
+
157
+ async def _read(self) -> None:
158
+ """Feed inbound messages to the session until it ends or fails."""
159
+ error: PyronixError | None = None
160
+ try:
161
+ while True:
162
+ message = await self._receive()
163
+ if await self._exchange(partial(self._session.receive, message)):
164
+ break
165
+ except PyronixError as err:
166
+ error = err
167
+ except Exception as err:
168
+ _LOGGER.exception("Unexpected error in the panel session reader")
169
+ error = ProtocolError(f"unexpected {type(err).__name__} in the reader")
170
+ error.__cause__ = err
171
+ self._ending = True
172
+ _LOGGER.debug("Panel session ended: %s", error or "panel goodbye")
173
+ await self.close()
174
+ self._on_end(error)
175
+
176
+ async def _send(self, message: str) -> None:
177
+ """Send one text message, mapping socket failures to library errors."""
178
+ try:
179
+ await self._socket.send_text(message)
180
+ except _SOCKET_ERRORS as err:
181
+ msg = f"sending failed: {type(err).__name__}"
182
+ raise ConnectionClosedError(msg) from err
183
+
184
+ async def _receive(self) -> str | bytes:
185
+ """Return the next message, or raise once the socket has closed."""
186
+ try:
187
+ message = await self._socket.receive()
188
+ except _SOCKET_ERRORS as err:
189
+ msg = f"receiving failed: {type(err).__name__}"
190
+ raise ConnectionClosedError(msg) from err
191
+ if message is None:
192
+ msg = "the cloud relay closed the WebSocket"
193
+ raise ConnectionClosedError(msg)
194
+ return message
195
+
196
+ async def _close(self) -> None:
197
+ """Send the goodbye and close the socket within the close timeout."""
198
+ try:
199
+ async with asyncio.timeout(self._close_timeout):
200
+ await self._goodbye()
201
+ except TimeoutError:
202
+ _LOGGER.debug("Goodbye not finished within %.1f s", self._close_timeout)
203
+ self._stop_reader()
204
+
205
+ async def _goodbye(self) -> None:
206
+ """Stop the reader, send the session's goodbye, then close the socket."""
207
+ async with self._lock:
208
+ self._stop_reader()
209
+ for message in self._session.close().outbound:
210
+ try:
211
+ await self._send(message)
212
+ except PyronixError as err:
213
+ _LOGGER.debug("Could not send the goodbye: %s", err)
214
+ break
215
+ try:
216
+ await self._socket.close()
217
+ except _SOCKET_ERRORS as err:
218
+ _LOGGER.debug("Closing the WebSocket failed: %s", type(err).__name__)
219
+
220
+ def _stop_reader(self) -> None:
221
+ """Cancel the reader unless it is the task ending the session."""
222
+ if self._reader is not None and not self._ending:
223
+ self._reader.cancel()