PyAgoraRTC 0.2.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.
- pyagorartc/__init__.py +67 -0
- pyagorartc/ap/__init__.py +17 -0
- pyagorartc/ap/client.py +244 -0
- pyagorartc/ap/password.py +14 -0
- pyagorartc/ap/response.py +329 -0
- pyagorartc/const.py +55 -0
- pyagorartc/exceptions.py +61 -0
- pyagorartc/models.py +223 -0
- pyagorartc/py.typed +0 -0
- pyagorartc/rtm/__init__.py +5 -0
- pyagorartc/rtm/client.py +170 -0
- pyagorartc/sdp/__init__.py +26 -0
- pyagorartc/sdp/answer.py +320 -0
- pyagorartc/sdp/candidates.py +103 -0
- pyagorartc/sdp/offer.py +260 -0
- pyagorartc/session/__init__.py +87 -0
- pyagorartc/session/messages.py +570 -0
- pyagorartc/session/recovery.py +166 -0
- pyagorartc/session/session.py +696 -0
- pyagorartc/session/transport.py +130 -0
- pyagorartc-0.2.0.dist-info/METADATA +151 -0
- pyagorartc-0.2.0.dist-info/RECORD +24 -0
- pyagorartc-0.2.0.dist-info/WHEEL +4 -0
- pyagorartc-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The gateway WebSocket session: frame builders and parsers, timing policies, and the transport seam."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyagorartc.session.messages import (
|
|
6
|
+
BROWSER_USER_AGENT,
|
|
7
|
+
ErrorInfo,
|
|
8
|
+
FrameType,
|
|
9
|
+
GatewayFrame,
|
|
10
|
+
JoinResult,
|
|
11
|
+
Notification,
|
|
12
|
+
P2PLost,
|
|
13
|
+
P2POk,
|
|
14
|
+
RtpCapabilities,
|
|
15
|
+
UserEvent,
|
|
16
|
+
build_join,
|
|
17
|
+
build_leave,
|
|
18
|
+
build_ping,
|
|
19
|
+
build_renew_token,
|
|
20
|
+
build_set_client_role,
|
|
21
|
+
build_subscribe,
|
|
22
|
+
build_unsubscribe,
|
|
23
|
+
describe_frame,
|
|
24
|
+
encode_frame,
|
|
25
|
+
existing_streams_from_join,
|
|
26
|
+
is_quit,
|
|
27
|
+
lacks_payload_type,
|
|
28
|
+
new_process_id,
|
|
29
|
+
new_request_id,
|
|
30
|
+
parse_error,
|
|
31
|
+
parse_frame,
|
|
32
|
+
parse_join_result,
|
|
33
|
+
parse_notification,
|
|
34
|
+
parse_p2p_lost,
|
|
35
|
+
parse_p2p_ok,
|
|
36
|
+
parse_remote_stream,
|
|
37
|
+
parse_rtp_capability_change,
|
|
38
|
+
parse_user_event,
|
|
39
|
+
)
|
|
40
|
+
from pyagorartc.session.recovery import Keepalive, PeerRecovery, RenewDebounce
|
|
41
|
+
from pyagorartc.session.session import AgoraSession, Spawn
|
|
42
|
+
from pyagorartc.session.transport import GatewayConnection, GatewayTransport, WebsocketsConnection, WebsocketsTransport
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"BROWSER_USER_AGENT",
|
|
46
|
+
"AgoraSession",
|
|
47
|
+
"ErrorInfo",
|
|
48
|
+
"FrameType",
|
|
49
|
+
"GatewayConnection",
|
|
50
|
+
"GatewayFrame",
|
|
51
|
+
"GatewayTransport",
|
|
52
|
+
"JoinResult",
|
|
53
|
+
"Keepalive",
|
|
54
|
+
"Notification",
|
|
55
|
+
"P2PLost",
|
|
56
|
+
"P2POk",
|
|
57
|
+
"PeerRecovery",
|
|
58
|
+
"RenewDebounce",
|
|
59
|
+
"RtpCapabilities",
|
|
60
|
+
"Spawn",
|
|
61
|
+
"UserEvent",
|
|
62
|
+
"WebsocketsConnection",
|
|
63
|
+
"WebsocketsTransport",
|
|
64
|
+
"build_join",
|
|
65
|
+
"build_leave",
|
|
66
|
+
"build_ping",
|
|
67
|
+
"build_renew_token",
|
|
68
|
+
"build_set_client_role",
|
|
69
|
+
"build_subscribe",
|
|
70
|
+
"build_unsubscribe",
|
|
71
|
+
"describe_frame",
|
|
72
|
+
"encode_frame",
|
|
73
|
+
"existing_streams_from_join",
|
|
74
|
+
"is_quit",
|
|
75
|
+
"lacks_payload_type",
|
|
76
|
+
"new_process_id",
|
|
77
|
+
"new_request_id",
|
|
78
|
+
"parse_error",
|
|
79
|
+
"parse_frame",
|
|
80
|
+
"parse_join_result",
|
|
81
|
+
"parse_notification",
|
|
82
|
+
"parse_p2p_lost",
|
|
83
|
+
"parse_p2p_ok",
|
|
84
|
+
"parse_remote_stream",
|
|
85
|
+
"parse_rtp_capability_change",
|
|
86
|
+
"parse_user_event",
|
|
87
|
+
]
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Builders and parsers for every gateway WebSocket frame; the only module that spells a gateway wire key.
|
|
2
|
+
|
|
3
|
+
Pure: no I/O and no clock reads. Timestamps and ids are parameters; ``new_request_id`` and
|
|
4
|
+
``new_process_id`` are the one place ids are minted. Shapes and provenance: ``docs/protocol.md`` §2 to §5.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import secrets
|
|
14
|
+
from typing import TYPE_CHECKING, NamedTuple, TypeIs
|
|
15
|
+
|
|
16
|
+
from pyagorartc.const import SDK_VERSION
|
|
17
|
+
from pyagorartc.exceptions import JoinRejectedError
|
|
18
|
+
from pyagorartc.models import RemoteStream, as_int
|
|
19
|
+
from pyagorartc.sdp import offers_rtx
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
from pyagorartc.models import ChannelCredentials, SessionOptions
|
|
25
|
+
|
|
26
|
+
_LOGGER = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
type JsonObject = dict[str, object]
|
|
29
|
+
|
|
30
|
+
# shipped (HA-Luba and PetKit); the SDK sends navigator.userAgent.
|
|
31
|
+
BROWSER_USER_AGENT = (
|
|
32
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
|
|
33
|
+
)
|
|
34
|
+
DEFAULT_P2P_ID = 1
|
|
35
|
+
STREAM_MODE = "live"
|
|
36
|
+
JOIN_ROLE = "host"
|
|
37
|
+
RESULT_SUCCESS = "success"
|
|
38
|
+
RESULT_FAILED = "failed"
|
|
39
|
+
NOTIFICATION_QUIT = "quit"
|
|
40
|
+
NO_PAYLOAD_TYPE = 0
|
|
41
|
+
_REQUEST_ID_BYTES = 3
|
|
42
|
+
|
|
43
|
+
# shipped (HA-Luba's flag set); nested under userAttributes per the SDK (D7). enableInstantVideo is set per session.
|
|
44
|
+
_USER_ATTRIBUTES: Mapping[str, object] = {
|
|
45
|
+
"enableAudioMetadata": False,
|
|
46
|
+
"enableAudioPts": False,
|
|
47
|
+
"enableNetworkQualityProbe": False,
|
|
48
|
+
"enablePublishedUserList": True,
|
|
49
|
+
"enableUserList": False,
|
|
50
|
+
"maxSubscription": 50,
|
|
51
|
+
"enableUserLicenseCheck": True,
|
|
52
|
+
"enableRTX": True,
|
|
53
|
+
"enableInstantVideo": False,
|
|
54
|
+
"enableDataStream2": False,
|
|
55
|
+
"enableAutFeedback": True,
|
|
56
|
+
"enableUserAutoRebalanceCheck": True,
|
|
57
|
+
"enableXR": True,
|
|
58
|
+
"enableLossbasedBwe": True,
|
|
59
|
+
"enableAutCC": True,
|
|
60
|
+
"enablePreallocPC": True,
|
|
61
|
+
"enablePubTWCC": False,
|
|
62
|
+
"enableSubTWCC": True,
|
|
63
|
+
"enablePubRTX": True,
|
|
64
|
+
"enableSubRTX": True,
|
|
65
|
+
"enableVosFallback": False,
|
|
66
|
+
"enableQualityFallback": False,
|
|
67
|
+
"enableDualStreamFlag": False,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# PetKit's markers for "this node in the join payload is a video stream" (the server's key is unverified).
|
|
71
|
+
_VIDEO_CODEC_MARKERS = frozenset({"h264", "h265", "video"})
|
|
72
|
+
# Deeper than any payload the SDK describes; bounds the walk on a hostile or broken frame.
|
|
73
|
+
_MAX_STREAM_SEARCH_DEPTH = 32
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FrameType(StrEnum):
|
|
77
|
+
"""Every gateway frame ``_type`` the library builds or recognises (SDK:28390-28474)."""
|
|
78
|
+
|
|
79
|
+
JOIN_V3 = "join_v3"
|
|
80
|
+
REJOIN_V3 = "rejoin_v3"
|
|
81
|
+
LEAVE = "leave"
|
|
82
|
+
PING = "ping"
|
|
83
|
+
PING_BACK = "ping_back"
|
|
84
|
+
SUBSCRIBE = "subscribe"
|
|
85
|
+
UNSUBSCRIBE = "unsubscribe"
|
|
86
|
+
RENEW_TOKEN = "renew_token" # noqa: S105 - a frame name, not a secret
|
|
87
|
+
SET_CLIENT_ROLE = "set_client_role"
|
|
88
|
+
ANSWER = "answer"
|
|
89
|
+
ERROR = "error"
|
|
90
|
+
ON_USER_ONLINE = "on_user_online"
|
|
91
|
+
ON_USER_OFFLINE = "on_user_offline"
|
|
92
|
+
ON_STREAM_FALLBACK_UPDATE = "on_stream_fallback_update"
|
|
93
|
+
ON_PUBLISH_STREAM = "on_publish_stream"
|
|
94
|
+
ON_UPLINK_STATS = "on_uplink_stats"
|
|
95
|
+
ON_P2P_LOST = "on_p2p_lost"
|
|
96
|
+
ON_P2P_OK = "on_p2p_ok"
|
|
97
|
+
ON_REMOVE_STREAM = "on_remove_stream"
|
|
98
|
+
ON_ADD_AUDIO_STREAM = "on_add_audio_stream"
|
|
99
|
+
ON_ADD_VIDEO_STREAM = "on_add_video_stream"
|
|
100
|
+
ON_TOKEN_PRIVILEGE_WILL_EXPIRE = "on_token_privilege_will_expire" # noqa: S105 - a frame name, not a secret
|
|
101
|
+
ON_TOKEN_PRIVILEGE_DID_EXPIRE = "on_token_privilege_did_expire" # noqa: S105 - a frame name, not a secret
|
|
102
|
+
ON_USER_BANNED = "on_user_banned"
|
|
103
|
+
ON_USER_LICENSE_BANNED = "on_user_license_banned"
|
|
104
|
+
ON_NOTIFICATION = "on_notification"
|
|
105
|
+
ON_CRYPT_ERROR = "on_crypt_error"
|
|
106
|
+
MUTE_AUDIO = "mute_audio"
|
|
107
|
+
MUTE_VIDEO = "mute_video"
|
|
108
|
+
UNMUTE_AUDIO = "unmute_audio"
|
|
109
|
+
UNMUTE_VIDEO = "unmute_video"
|
|
110
|
+
RECEIVE_METADATA = "receive_metadata"
|
|
111
|
+
ON_DATA_STREAM = "on_data_stream"
|
|
112
|
+
ON_RTP_CAPABILITY_CHANGE = "on_rtp_capability_change"
|
|
113
|
+
ON_REMOTE_DATASTREAM_UPDATE = "on_remote_datastream_update"
|
|
114
|
+
ON_REMOTE_FULL_DATASTREAM_INFO = "on_remote_full_datastream_info"
|
|
115
|
+
ENABLE_LOCAL_VIDEO = "enable_local_video"
|
|
116
|
+
DISABLE_LOCAL_VIDEO = "disable_local_video"
|
|
117
|
+
ENABLE_LOCAL_AUDIO = "enable_local_audio"
|
|
118
|
+
DISABLE_LOCAL_AUDIO = "disable_local_audio"
|
|
119
|
+
ON_PUBLISHED_USER_LIST = "on_published_user_list"
|
|
120
|
+
ENABLE_MULTI_STREAM = "enable_multi_stream"
|
|
121
|
+
ON_USER_LIST = "on_user_list"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, repr=False)
|
|
125
|
+
class GatewayFrame:
|
|
126
|
+
"""One decoded gateway frame (protocol.md §2.1).
|
|
127
|
+
|
|
128
|
+
``type`` is the raw ``_type`` string (compare it with ``FrameType``; unknown names are kept),
|
|
129
|
+
``message`` is ``_message`` or ``{}`` when absent or not an object, and ``raw`` is the whole
|
|
130
|
+
decoded object. ``repr`` shows only the envelope, since payloads can carry ICE passwords and
|
|
131
|
+
rejoin tokens.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
id: str | None
|
|
135
|
+
type: str | None
|
|
136
|
+
result: str | None
|
|
137
|
+
message: Mapping[str, object] = field(default_factory=dict)
|
|
138
|
+
raw: Mapping[str, object] = field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def is_response(self) -> bool:
|
|
142
|
+
"""A frame with ``_id`` answers a request, even if it also carries ``_type`` (SDK dispatch)."""
|
|
143
|
+
return self.id is not None
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def is_event(self) -> bool:
|
|
147
|
+
"""A server-initiated event: no ``_id``."""
|
|
148
|
+
return self.id is None
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def ok(self) -> bool:
|
|
152
|
+
"""Whether this is a response whose ``_result`` is ``success``."""
|
|
153
|
+
return self.is_response and self.result == RESULT_SUCCESS
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
return (
|
|
157
|
+
f"GatewayFrame(id={self.id!r}, type={self.type!r}, result={self.result!r}, "
|
|
158
|
+
f"message_keys={sorted(self.message)!r})"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, kw_only=True, repr=False)
|
|
163
|
+
class JoinResult:
|
|
164
|
+
"""What the session reads from a successful ``join_v3`` response (protocol.md §2.5)."""
|
|
165
|
+
|
|
166
|
+
ortc: Mapping[str, object]
|
|
167
|
+
rejoin_token: str | None
|
|
168
|
+
cid: int | None
|
|
169
|
+
uid: int | None
|
|
170
|
+
vid: int | None
|
|
171
|
+
cname: str | None
|
|
172
|
+
existing_streams: list[RemoteStream]
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def offers_rtx(self) -> bool:
|
|
176
|
+
"""Whether the gateway ORTC lists an ``rtx`` video codec; the value for ``subscribe.rtx`` (``sdp.offers_rtx``)."""
|
|
177
|
+
return offers_rtx(self.ortc)
|
|
178
|
+
|
|
179
|
+
def __repr__(self) -> str:
|
|
180
|
+
return (
|
|
181
|
+
f"JoinResult(uid={self.uid!r}, cid={self.cid!r}, vid={self.vid!r}, cname={self.cname!r}, "
|
|
182
|
+
f"rejoin_token={'<present>' if self.rejoin_token else None}, "
|
|
183
|
+
f"existing_streams={self.existing_streams!r})"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class UserEvent(NamedTuple):
|
|
188
|
+
"""``on_user_online`` / ``on_user_offline``; ``reason`` values are unverified."""
|
|
189
|
+
|
|
190
|
+
uid: int
|
|
191
|
+
reason: str | None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Notification(NamedTuple):
|
|
195
|
+
"""``on_notification``: ``action`` is ``quit``, ``recover``, ``retry``, ``warn``…; ``code`` per protocol.md §5."""
|
|
196
|
+
|
|
197
|
+
action: str | None
|
|
198
|
+
code: int | None
|
|
199
|
+
detail: str | None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class P2PLost(NamedTuple):
|
|
203
|
+
"""``on_p2p_lost``."""
|
|
204
|
+
|
|
205
|
+
code: int | None
|
|
206
|
+
error: str | None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class P2POk(NamedTuple):
|
|
210
|
+
"""``on_p2p_ok``; ``uid`` should equal the join uid."""
|
|
211
|
+
|
|
212
|
+
uid: int | None
|
|
213
|
+
proxy: bool
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class RtpCapabilities(NamedTuple):
|
|
217
|
+
"""``on_rtp_capability_change``."""
|
|
218
|
+
|
|
219
|
+
video_codecs: tuple[str, ...]
|
|
220
|
+
extmap_allow_mixed: bool
|
|
221
|
+
web_av1_svc: bool
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ErrorInfo(NamedTuple):
|
|
225
|
+
"""The code and text of an ``error`` event or a ``failed`` response."""
|
|
226
|
+
|
|
227
|
+
code: int | None
|
|
228
|
+
message: str | None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def new_request_id() -> str:
|
|
232
|
+
"""A fresh 6-character ``_id`` (the SDK's ``qO(6,"")``; shipped ``secrets.token_hex(3)``)."""
|
|
233
|
+
return secrets.token_hex(_REQUEST_ID_BYTES)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def new_process_id() -> str:
|
|
237
|
+
"""A fresh ``process_id`` in the SDK's ``process-<8>-<4>-<4>-<4>-<12>`` shape."""
|
|
238
|
+
groups = (secrets.token_hex(n) for n in (4, 2, 2, 2, 6))
|
|
239
|
+
return "process-" + "-".join(groups)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def encode_frame(frame: Mapping[str, object]) -> str:
|
|
243
|
+
"""The text frame to send."""
|
|
244
|
+
return json.dumps(frame)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def build_join( # noqa: PLR0913 - every per-session join input is explicit so the builder stays pure
|
|
248
|
+
creds: ChannelCredentials,
|
|
249
|
+
ortc: Mapping[str, object],
|
|
250
|
+
ap_response: Mapping[str, object],
|
|
251
|
+
*,
|
|
252
|
+
options: SessionOptions,
|
|
253
|
+
session_id: str,
|
|
254
|
+
process_id: str,
|
|
255
|
+
client_ts_ms: int,
|
|
256
|
+
request_id: str,
|
|
257
|
+
p2p_id: int = DEFAULT_P2P_ID,
|
|
258
|
+
) -> JsonObject:
|
|
259
|
+
"""The ``join_v3`` request (D7, D20).
|
|
260
|
+
|
|
261
|
+
Channel encryption is never sent: the SDK RSA-wraps the secret with a key it embeds (D20, Q17), so
|
|
262
|
+
``creds.encryption`` only logs a WARNING.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
creds: The channel to join; ``license`` and ``string_uid`` are sent only when set.
|
|
266
|
+
ortc: The client ORTC from ``sdp.offer_to_ortc`` with candidates already merged (D11).
|
|
267
|
+
ap_response: The flag-4096 AP block in its join form (``ticket`` set, ``server_ts``).
|
|
268
|
+
options: ``client_codec``, ``instant_video`` and ``extra_join_attributes`` (merged into
|
|
269
|
+
``userAttributes`` last, so they override the shipped flags) are read.
|
|
270
|
+
session_id: The viewer's session id.
|
|
271
|
+
process_id: From ``new_process_id``; stable for the session.
|
|
272
|
+
client_ts_ms: Wall-clock milliseconds, sent as ``join_ts``.
|
|
273
|
+
request_id: The frame ``_id``; the join response echoes it.
|
|
274
|
+
p2p_id: The SDK's peer-connection counter; 1 for the first and only connection.
|
|
275
|
+
|
|
276
|
+
"""
|
|
277
|
+
user_attributes = {
|
|
278
|
+
**_USER_ATTRIBUTES,
|
|
279
|
+
"enableInstantVideo": options.instant_video,
|
|
280
|
+
"enablePreallocPC": options.prealloc_pc,
|
|
281
|
+
**options.extra_join_attributes,
|
|
282
|
+
}
|
|
283
|
+
message: JsonObject = {
|
|
284
|
+
"p2p_id": p2p_id,
|
|
285
|
+
"session_id": session_id,
|
|
286
|
+
"app_id": creds.app_id,
|
|
287
|
+
"channel_key": creds.token,
|
|
288
|
+
"channel_name": creds.channel_name,
|
|
289
|
+
"sdk_version": SDK_VERSION,
|
|
290
|
+
"browser": BROWSER_USER_AGENT,
|
|
291
|
+
"process_id": process_id,
|
|
292
|
+
"mode": STREAM_MODE,
|
|
293
|
+
"codec": options.client_codec,
|
|
294
|
+
"role": JOIN_ROLE,
|
|
295
|
+
"has_changed_gateway": False,
|
|
296
|
+
"ap_response": dict(ap_response),
|
|
297
|
+
"extend": "",
|
|
298
|
+
"details": {"6": creds.string_uid} if creds.string_uid else {},
|
|
299
|
+
"features": {"rejoin": True},
|
|
300
|
+
"attributes": {"userAttributes": user_attributes},
|
|
301
|
+
"join_ts": client_ts_ms,
|
|
302
|
+
"ortc": ortc,
|
|
303
|
+
}
|
|
304
|
+
if creds.license:
|
|
305
|
+
message["license"] = creds.license
|
|
306
|
+
if creds.string_uid:
|
|
307
|
+
message["string_uid"] = creds.string_uid
|
|
308
|
+
if creds.encryption is not None:
|
|
309
|
+
_LOGGER.warning(
|
|
310
|
+
"Channel encryption (%s) is not supported; joining without aes_* fields (D20)", creds.encryption.mode
|
|
311
|
+
)
|
|
312
|
+
return _request(FrameType.JOIN_V3, request_id, message)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def build_subscribe(
|
|
316
|
+
stream: RemoteStream,
|
|
317
|
+
*,
|
|
318
|
+
codec: str,
|
|
319
|
+
rtx: bool,
|
|
320
|
+
request_id: str,
|
|
321
|
+
p2p_id: int = DEFAULT_P2P_ID,
|
|
322
|
+
twcc: bool = True,
|
|
323
|
+
) -> JsonObject:
|
|
324
|
+
"""A ``subscribe`` for one remote video stream.
|
|
325
|
+
|
|
326
|
+
``codec`` must be the join's ``client_codec`` (the SDK sends ``spec.codec`` in both); ``rtx`` is
|
|
327
|
+
``JoinResult.offers_rtx``, because the gateway relays RTX only on payload types it offered.
|
|
328
|
+
"""
|
|
329
|
+
return _request(
|
|
330
|
+
FrameType.SUBSCRIBE,
|
|
331
|
+
request_id,
|
|
332
|
+
{
|
|
333
|
+
"stream_id": stream.uid,
|
|
334
|
+
"stream_type": "video",
|
|
335
|
+
"mode": STREAM_MODE,
|
|
336
|
+
"codec": codec,
|
|
337
|
+
"p2p_id": p2p_id,
|
|
338
|
+
"twcc": twcc,
|
|
339
|
+
"rtx": rtx,
|
|
340
|
+
"extend": "",
|
|
341
|
+
"ssrcId": stream.ssrc,
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def build_unsubscribe(stream_id: int, *, request_id: str, p2p_id: int = DEFAULT_P2P_ID) -> JsonObject:
|
|
347
|
+
"""An ``unsubscribe`` for the stream published by ``stream_id`` (the publisher's uid)."""
|
|
348
|
+
return _request(FrameType.UNSUBSCRIBE, request_id, {"p2p_id": p2p_id, "ortc": [], "stream_id": stream_id})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def build_ping(request_id: str) -> JsonObject:
|
|
352
|
+
"""A ``ping``; it carries no ``_message``."""
|
|
353
|
+
return {"_id": request_id, "_type": FrameType.PING.value}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def build_leave(request_id: str) -> JsonObject:
|
|
357
|
+
"""A ``leave``; send only after a successful join, then close the socket."""
|
|
358
|
+
return {"_id": request_id, "_type": FrameType.LEAVE.value}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def build_renew_token(token: str, *, request_id: str) -> JsonObject:
|
|
362
|
+
"""A ``renew_token`` carrying ``token`` (the provider's new token or the join token, D8)."""
|
|
363
|
+
return _request(FrameType.RENEW_TOKEN, request_id, {"token": token})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def build_set_client_role(role: str, level: int, *, client_ts_ms: int, request_id: str) -> JsonObject:
|
|
367
|
+
"""A ``set_client_role``; sent only when ``SessionOptions.send_set_client_role`` is on (D6)."""
|
|
368
|
+
return _request(FrameType.SET_CLIENT_ROLE, request_id, {"role": role, "level": level, "client_ts": client_ts_ms})
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def parse_frame(text: str) -> GatewayFrame | None:
|
|
372
|
+
"""Decode one text frame, or ``None`` when it is not a JSON object.
|
|
373
|
+
|
|
374
|
+
Unknown ``_type`` values and extra keys are kept, never rejected (Constitution §7); the caller
|
|
375
|
+
logs a ``None`` or an unhandled type once at DEBUG and moves on.
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
decoded = json.loads(text)
|
|
379
|
+
except (ValueError, RecursionError):
|
|
380
|
+
return None
|
|
381
|
+
if not isinstance(decoded, dict):
|
|
382
|
+
return None
|
|
383
|
+
return _frame_from(decoded)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _frame_from(decoded: Mapping[str, object]) -> GatewayFrame:
|
|
387
|
+
message = decoded.get("_message")
|
|
388
|
+
return GatewayFrame(
|
|
389
|
+
id=_as_str(decoded.get("_id")),
|
|
390
|
+
type=_as_str(decoded.get("_type")),
|
|
391
|
+
result=_as_str(decoded.get("_result")),
|
|
392
|
+
message=message if isinstance(message, dict) else {},
|
|
393
|
+
raw=decoded,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def parse_join_result(frame: GatewayFrame) -> JoinResult:
|
|
398
|
+
"""Read a ``join_v3`` response.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
JoinRejectedError: The frame is not a success response (``code`` from ``error_code`` or
|
|
402
|
+
``code``, as the SDK reads it), or the success carries no ``ortc`` object.
|
|
403
|
+
|
|
404
|
+
"""
|
|
405
|
+
if not frame.ok:
|
|
406
|
+
if frame.result == RESULT_FAILED:
|
|
407
|
+
error = parse_error(frame)
|
|
408
|
+
raise JoinRejectedError(error.code, error.message or "join failed")
|
|
409
|
+
raise JoinRejectedError(None, f"join response has result {frame.result!r}")
|
|
410
|
+
message = frame.message
|
|
411
|
+
ortc = message.get("ortc")
|
|
412
|
+
if not isinstance(ortc, dict) or not ortc:
|
|
413
|
+
raise JoinRejectedError(None, "join response has no 'ortc'")
|
|
414
|
+
return JoinResult(
|
|
415
|
+
ortc=ortc,
|
|
416
|
+
rejoin_token=_as_str(message.get("rejoin_token")),
|
|
417
|
+
cid=as_int(message.get("cid")),
|
|
418
|
+
uid=as_int(message.get("uid")),
|
|
419
|
+
vid=as_int(message.get("vid")),
|
|
420
|
+
cname=_as_str(message.get("cname")),
|
|
421
|
+
existing_streams=existing_streams_from_join(message),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def describe_frame(frame: GatewayFrame | Mapping[str, object]) -> str:
|
|
426
|
+
"""A log-safe one-line outline of ``frame`` (decoded, or a built frame about to be sent): type, id, keys only."""
|
|
427
|
+
if not isinstance(frame, GatewayFrame):
|
|
428
|
+
frame = _frame_from(frame)
|
|
429
|
+
return f"type={frame.type} id={frame.id} message_keys={sorted(frame.message)}"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def existing_streams_from_join(message: Mapping[str, object]) -> list[RemoteStream]:
|
|
433
|
+
"""Video streams already published when we joined, found anywhere in the join payload.
|
|
434
|
+
|
|
435
|
+
PetKit's walk (``_find_existing_video_streams``): any object with an int ``uid`` and ``ssrcId``
|
|
436
|
+
plus a video marker, at most 32 levels deep. Deduplicated by ``(uid, ssrc)`` in payload order. The
|
|
437
|
+
server's key is unverified.
|
|
438
|
+
"""
|
|
439
|
+
found: dict[tuple[int, int], RemoteStream] = {}
|
|
440
|
+
_collect_video_streams(message, found)
|
|
441
|
+
return list(found.values())
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_remote_stream(message: Mapping[str, object]) -> RemoteStream | None:
|
|
445
|
+
"""An ``on_add_video_stream`` payload, or ``None`` without an int ``uid`` and ``ssrcId``.
|
|
446
|
+
|
|
447
|
+
The event type implies video, so no ``video`` flag is required (HA-Luba). ``codec`` is lower-cased.
|
|
448
|
+
A ``pt`` of 0 is logged at WARNING: the gateway found no offered codec it can relay the stream as.
|
|
449
|
+
"""
|
|
450
|
+
uid = as_int(message.get("uid"))
|
|
451
|
+
ssrc = as_int(message.get("ssrcId"))
|
|
452
|
+
if uid is None or ssrc is None:
|
|
453
|
+
return None
|
|
454
|
+
stream = _stream_from(message, uid, ssrc)
|
|
455
|
+
if lacks_payload_type(stream):
|
|
456
|
+
_LOGGER.warning(
|
|
457
|
+
"Gateway negotiated no video payload type for uid %s (pt=0, codec %s): the offer carried no codec "
|
|
458
|
+
"this stream can be relayed as; an H265 publisher needs a viewer with HEVC decode",
|
|
459
|
+
uid,
|
|
460
|
+
stream.codec,
|
|
461
|
+
)
|
|
462
|
+
return stream
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def lacks_payload_type(stream: RemoteStream) -> bool:
|
|
466
|
+
"""Whether the gateway announced the stream with ``pt`` 0, so it will decode to nothing."""
|
|
467
|
+
return stream.payload_type == NO_PAYLOAD_TYPE
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def parse_user_event(message: Mapping[str, object]) -> UserEvent | None:
|
|
471
|
+
"""An ``on_user_online`` / ``on_user_offline`` payload, or ``None`` without a uid."""
|
|
472
|
+
if (uid := as_int(message.get("uid"))) is None:
|
|
473
|
+
return None
|
|
474
|
+
return UserEvent(uid, _as_str(message.get("reason")))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def parse_notification(message: Mapping[str, object]) -> Notification:
|
|
478
|
+
"""An ``on_notification`` payload."""
|
|
479
|
+
return Notification(_as_str(message.get("action")), as_int(message.get("code")), _as_str(message.get("detail")))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def is_quit(notification: Notification) -> bool:
|
|
483
|
+
"""Whether the gateway has quit this session (``action: quit``, e.g. code 2003 repeat join)."""
|
|
484
|
+
return notification.action == NOTIFICATION_QUIT
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def parse_p2p_lost(frame: GatewayFrame) -> P2PLost:
|
|
488
|
+
"""An ``on_p2p_lost`` event, read from ``_message`` (shipped code read the top level, protocol.md §5).
|
|
489
|
+
|
|
490
|
+
Each field falls back to the top level only when ``_message`` lacks it, until a capture settles it.
|
|
491
|
+
"""
|
|
492
|
+
code = as_int(frame.message.get("error_code"))
|
|
493
|
+
error = _as_str(frame.message.get("error_str"))
|
|
494
|
+
return P2PLost(
|
|
495
|
+
code if code is not None else as_int(frame.raw.get("error_code")),
|
|
496
|
+
error if error is not None else _as_str(frame.raw.get("error_str")),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def parse_p2p_ok(message: Mapping[str, object]) -> P2POk:
|
|
501
|
+
"""An ``on_p2p_ok`` payload."""
|
|
502
|
+
return P2POk(as_int(message.get("uid")), message.get("proxy") is True)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def parse_rtp_capability_change(message: Mapping[str, object]) -> RtpCapabilities:
|
|
506
|
+
"""An ``on_rtp_capability_change`` payload; non-string codec entries are dropped."""
|
|
507
|
+
codecs = message.get("video_codec")
|
|
508
|
+
return RtpCapabilities(
|
|
509
|
+
tuple(c for c in codecs if isinstance(c, str)) if isinstance(codecs, list) else (),
|
|
510
|
+
message.get("extmap_allow_mixed") is True,
|
|
511
|
+
message.get("web_av1_svc") is True,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def parse_error(frame: GatewayFrame) -> ErrorInfo:
|
|
516
|
+
"""The error carried by an ``error`` event (``error``) or a failed response (``error_code || code``, ``error_str``)."""
|
|
517
|
+
message = frame.message
|
|
518
|
+
code = as_int(message.get("error_code")) or as_int(message.get("code"))
|
|
519
|
+
return ErrorInfo(code, _as_str(message.get("error_str")) or _as_str(message.get("error")))
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _request(frame_type: FrameType, request_id: str, message: JsonObject) -> JsonObject:
|
|
523
|
+
return {"_id": request_id, "_type": frame_type.value, "_message": message}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _collect_video_streams(node: object, found: dict[tuple[int, int], RemoteStream], depth: int = 0) -> None:
|
|
527
|
+
if depth > _MAX_STREAM_SEARCH_DEPTH:
|
|
528
|
+
_LOGGER.debug("Join payload nests deeper than %s levels; not searched further", _MAX_STREAM_SEARCH_DEPTH)
|
|
529
|
+
return
|
|
530
|
+
if isinstance(node, dict):
|
|
531
|
+
if stream := _existing_video_stream(node):
|
|
532
|
+
found.setdefault((stream.uid, stream.ssrc), stream)
|
|
533
|
+
for value in node.values():
|
|
534
|
+
_collect_video_streams(value, found, depth + 1)
|
|
535
|
+
elif isinstance(node, list):
|
|
536
|
+
for item in node:
|
|
537
|
+
_collect_video_streams(item, found, depth + 1)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _existing_video_stream(node: Mapping[str, object]) -> RemoteStream | None:
|
|
541
|
+
has_video_marker = (
|
|
542
|
+
node.get("video") is True
|
|
543
|
+
or node.get("stream_type") == "video"
|
|
544
|
+
or node.get("codec") in _VIDEO_CODEC_MARKERS
|
|
545
|
+
or node.get("rtxSsrcId") is not None
|
|
546
|
+
)
|
|
547
|
+
uid, ssrc = node.get("uid"), node.get("ssrcId")
|
|
548
|
+
if not has_video_marker or not _is_int(uid) or not _is_int(ssrc):
|
|
549
|
+
return None
|
|
550
|
+
return _stream_from(node, uid, ssrc)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _stream_from(message: Mapping[str, object], uid: int, ssrc: int) -> RemoteStream:
|
|
554
|
+
codec = _as_str(message.get("codec"))
|
|
555
|
+
return RemoteStream(
|
|
556
|
+
uid=uid,
|
|
557
|
+
ssrc=ssrc,
|
|
558
|
+
rtx_ssrc=as_int(message.get("rtxSsrcId")),
|
|
559
|
+
codec=codec.lower() if codec else None,
|
|
560
|
+
payload_type=as_int(message.get("pt")),
|
|
561
|
+
cname=_as_str(message.get("cname")),
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _is_int(value: object) -> TypeIs[int]:
|
|
566
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _as_str(value: object) -> str | None:
|
|
570
|
+
return value if isinstance(value, str) else None
|