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
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()
|