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,130 @@
|
|
|
1
|
+
"""The ``GatewayTransport`` seam: what the session needs from a WebSocket, and nothing more.
|
|
2
|
+
|
|
3
|
+
The ``websockets`` adapter lives beside it; the unit tier replaces it with a scripted fake.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
import logging
|
|
10
|
+
import ssl
|
|
11
|
+
from typing import TYPE_CHECKING, Protocol
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from websockets.asyncio.client import connect as ws_connect
|
|
15
|
+
from websockets.exceptions import WebSocketException
|
|
16
|
+
from websockets.protocol import State
|
|
17
|
+
|
|
18
|
+
from pyagorartc.exceptions import GatewayConnectError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from websockets.asyncio.client import ClientConnection
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GatewayConnection(Protocol):
|
|
25
|
+
"""One open gateway socket: text frames in and out, and a close."""
|
|
26
|
+
|
|
27
|
+
async def send(self, text: str) -> None:
|
|
28
|
+
"""Send one text frame; raises ``GatewayConnectError`` if the socket is gone."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
async def recv(self) -> str:
|
|
32
|
+
"""Return the next text frame; raises ``GatewayConnectError`` when the socket closes."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
async def close(self) -> None:
|
|
36
|
+
"""Close the socket; idempotent."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_open(self) -> bool:
|
|
41
|
+
"""Whether frames can still be sent."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GatewayTransport(Protocol):
|
|
46
|
+
"""Opens gateway sockets. ``url`` is the full ``wss://`` URL for one edge."""
|
|
47
|
+
|
|
48
|
+
async def connect(self, url: str, *, timeout_s: float, verify_ssl: bool) -> GatewayConnection:
|
|
49
|
+
"""Open one connection.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
GatewayConnectError: The socket could not be opened in time.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ssl_context(*, verify: bool) -> ssl.SSLContext:
|
|
59
|
+
context = ssl.create_default_context()
|
|
60
|
+
if not verify:
|
|
61
|
+
context.check_hostname = False
|
|
62
|
+
context.verify_mode = ssl.CERT_NONE
|
|
63
|
+
return context
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Built at import: loading the CA bundle is blocking I/O that must not run on the event loop.
|
|
67
|
+
_VERIFIED_CONTEXT = _ssl_context(verify=True)
|
|
68
|
+
_UNVERIFIED_CONTEXT = _ssl_context(verify=False)
|
|
69
|
+
_SOCKET_ERRORS = (WebSocketException, OSError)
|
|
70
|
+
# websockets logs every frame at DEBUG, and a renew_token frame is short enough to carry its whole token (§6).
|
|
71
|
+
_WIRE_LOGGER = logging.getLogger("pyagorartc.session.wire")
|
|
72
|
+
_WIRE_LOGGER.setLevel(logging.INFO)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WebsocketsConnection:
|
|
76
|
+
"""``GatewayConnection`` over a ``websockets`` client connection."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, connection: ClientConnection) -> None:
|
|
79
|
+
self._connection = connection
|
|
80
|
+
|
|
81
|
+
async def send(self, text: str) -> None:
|
|
82
|
+
"""Send one text frame; raises ``GatewayConnectError`` if the socket is gone."""
|
|
83
|
+
try:
|
|
84
|
+
await self._connection.send(text)
|
|
85
|
+
except _SOCKET_ERRORS as exc:
|
|
86
|
+
raise GatewayConnectError("gateway socket closed") from exc
|
|
87
|
+
|
|
88
|
+
async def recv(self) -> str:
|
|
89
|
+
"""Return the next frame as text; raises ``GatewayConnectError`` when the socket closes."""
|
|
90
|
+
try:
|
|
91
|
+
data = await self._connection.recv()
|
|
92
|
+
except _SOCKET_ERRORS as exc:
|
|
93
|
+
raise GatewayConnectError("gateway socket closed") from exc
|
|
94
|
+
return data.decode("utf-8", errors="replace") if isinstance(data, bytes) else data
|
|
95
|
+
|
|
96
|
+
async def close(self) -> None:
|
|
97
|
+
"""Close the socket; idempotent."""
|
|
98
|
+
with suppress(*_SOCKET_ERRORS):
|
|
99
|
+
await self._connection.close()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def is_open(self) -> bool:
|
|
103
|
+
"""Whether frames can still be sent."""
|
|
104
|
+
return self._connection.state is State.OPEN
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ssl_for(url: str, *, verify_ssl: bool) -> ssl.SSLContext | None:
|
|
108
|
+
"""The TLS context for ``url``: none for ``ws://`` (the loopback fake gateway), verified unless told not (D10)."""
|
|
109
|
+
if urlsplit(url).scheme == "ws":
|
|
110
|
+
return None
|
|
111
|
+
return _VERIFIED_CONTEXT if verify_ssl else _UNVERIFIED_CONTEXT
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WebsocketsTransport:
|
|
115
|
+
"""``GatewayTransport`` backed by ``websockets``; TLS is verified unless ``verify_ssl`` is False (D10)."""
|
|
116
|
+
|
|
117
|
+
async def connect(self, url: str, *, timeout_s: float, verify_ssl: bool) -> GatewayConnection:
|
|
118
|
+
"""Open one gateway socket.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
GatewayConnectError: The socket could not be opened within ``timeout_s``.
|
|
122
|
+
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
connection = await ws_connect(
|
|
126
|
+
url, ssl=ssl_for(url, verify_ssl=verify_ssl), open_timeout=timeout_s, logger=_WIRE_LOGGER
|
|
127
|
+
)
|
|
128
|
+
except (TimeoutError, *_SOCKET_ERRORS) as exc:
|
|
129
|
+
raise GatewayConnectError(f"could not open the gateway socket at {url}") from exc
|
|
130
|
+
return WebsocketsConnection(connection)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: PyAgoraRTC
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Async Python client for Agora RTC signalling: edge discovery, SDP/ORTC negotiation and the WebSocket gateway session
|
|
5
|
+
Project-URL: Homepage, https://github.com/mikey0000/PyAgoraRTC
|
|
6
|
+
Project-URL: Documentation, https://github.com/mikey0000/PyAgoraRTC/tree/main/docs
|
|
7
|
+
Author: Michael Arthur
|
|
8
|
+
License-Expression: GPL-3.0-or-later
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agora,asyncio,camera,ortc,rtc,sdp,webrtc
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Multimedia :: Video
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Requires-Dist: aiohttp>=3.10
|
|
20
|
+
Requires-Dist: sdp-transform>=1.1.0
|
|
21
|
+
Requires-Dist: websockets>=13.1
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pyagorartc
|
|
25
|
+
|
|
26
|
+
Async Python client for Agora RTC signalling, for WebRTC consumers that are
|
|
27
|
+
not the Agora SDK: a browser behind Home Assistant, go2rtc, pion.
|
|
28
|
+
|
|
29
|
+
A device publishes video to an Agora channel. `pyagorartc` finds the Agora
|
|
30
|
+
edge, turns the consumer's SDP offer into Agora's ORTC, joins the channel
|
|
31
|
+
over the gateway WebSocket, subscribes to the stream, and hands back an
|
|
32
|
+
answer SDP. Media then flows directly between the consumer and the Agora
|
|
33
|
+
edge. It also sends Agora RTM peer messages over REST, which some vendors
|
|
34
|
+
use as a device control channel.
|
|
35
|
+
|
|
36
|
+
It knows channels, tokens, edges, SDP and gateway messages. It does not
|
|
37
|
+
know any vendor. Mammotion mowers and PetKit cameras are the two known
|
|
38
|
+
hosts; vendor behaviour plugs in through callbacks and options.
|
|
39
|
+
|
|
40
|
+
## What it does not do
|
|
41
|
+
|
|
42
|
+
- **No media.** It never touches RTP. The consumer receives the stream.
|
|
43
|
+
- **No decryption.** Agora channel encryption (`aes_*`) is applied inside
|
|
44
|
+
the SDK's media path. The fields are modelled but never sent (the SDK
|
|
45
|
+
RSA-wraps the secret), and a plain WebRTC consumer cannot decode an
|
|
46
|
+
encrypted stream anyway (D20, Q17).
|
|
47
|
+
- **No relay.** It does not run go2rtc, a TURN server, or an HTTP/WHEP
|
|
48
|
+
endpoint. Those are host jobs.
|
|
49
|
+
- **No vendor API.** Credentials arrive already fetched.
|
|
50
|
+
- **No recovery policy.** It times peer recovery; the host decides what
|
|
51
|
+
recovery means.
|
|
52
|
+
|
|
53
|
+
## Status
|
|
54
|
+
|
|
55
|
+
Alpha (`0.x`). The API below is the target surface; `pyagorartc.__all__` is
|
|
56
|
+
the supported part of it (Constitution §10). Wire behaviour comes from
|
|
57
|
+
recordings, the Agora Web SDK 4.24.3 and two shipped integrations; what is
|
|
58
|
+
still a guess is listed in [`docs/open_questions.md`](docs/open_questions.md).
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
pip install pyagorartc
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Python 3.13+. Dependencies: `aiohttp`, `websockets>=13.1`, `sdp-transform`.
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import aiohttp
|
|
72
|
+
from pyagorartc import AgoraAPClient, AgoraSession, ChannelCredentials, CloseReason, PyAgoraRTCError, SessionOptions
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def on_closed(reason: CloseReason) -> None:
|
|
76
|
+
print("session ended:", reason)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def on_peer_left(uid: int) -> None:
|
|
80
|
+
print("publisher", uid, "left; ask the device to publish again")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def stream(viewer, creds: ChannelCredentials) -> None:
|
|
84
|
+
async with aiohttp.ClientSession() as http:
|
|
85
|
+
ap = await AgoraAPClient(http).choose_server(creds)
|
|
86
|
+
viewer.set_ice_servers(ap.get_ice_servers()) # before the viewer makes its offer
|
|
87
|
+
offer_sdp = await viewer.create_offer()
|
|
88
|
+
|
|
89
|
+
session = AgoraSession(creds, ap, options=SessionOptions(target_uid=1),
|
|
90
|
+
on_closed=on_closed, on_peer_left=on_peer_left)
|
|
91
|
+
try:
|
|
92
|
+
answer_sdp = await session.join(offer_sdp, session_id="viewer-1")
|
|
93
|
+
await viewer.set_answer(answer_sdp)
|
|
94
|
+
await viewer.wait_until_done()
|
|
95
|
+
except PyAgoraRTCError as err:
|
|
96
|
+
print("join failed:", err)
|
|
97
|
+
finally:
|
|
98
|
+
await session.close() # cancel and await owned tasks, leave, close
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`viewer` stands for whatever produces the offer. One session serves one
|
|
102
|
+
offer; a new offer is a new `AgoraSession`. Candidates known before
|
|
103
|
+
`join()` go in with `session.add_ice_candidate(...)`.
|
|
104
|
+
|
|
105
|
+
## Vendor policy
|
|
106
|
+
|
|
107
|
+
| Concern | Library provides | Host provides |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| Token refresh | `token_provider`, 30 s debounce (D8) | the vendor call that mints a token |
|
|
110
|
+
| Device keep-alive | `keepalive`, `keepalive_interval_s`, `deadline` (D15) | the call to the device |
|
|
111
|
+
| Stream recovery | peer-recovery timing, `on_peer_left(uid)` (D14) | re-requesting the stream |
|
|
112
|
+
| Which camera | `SessionOptions.target_uid` | slot → uid |
|
|
113
|
+
| Consumer quirks | `declare_remote_video_ssrc`, `disable_audio`, `instant_video`, subscribe retry | choosing them |
|
|
114
|
+
| `set_client_role` after join | `send_set_client_role`, default off (D6) | knowing the device tolerates it |
|
|
115
|
+
| RTM control | `RtmRestClient.send_peer_message` (D18) | the commands and their cadence |
|
|
116
|
+
| Viewer transport | answer SDP, ICE servers, candidate helpers | views, auth, relays |
|
|
117
|
+
|
|
118
|
+
The full table is in [`docs/architecture.md`](docs/architecture.md) §4.
|
|
119
|
+
|
|
120
|
+
## Errors
|
|
121
|
+
|
|
122
|
+
All exceptions derive from `PyAgoraRTCError`. None carries a token, ticket,
|
|
123
|
+
credential or key.
|
|
124
|
+
|
|
125
|
+
| Exception | Meaning |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `APError` | Edge discovery failed on every access-point host. |
|
|
128
|
+
| `APRejectedError` | The access point answered, but rejected every service (`.codes`). |
|
|
129
|
+
| `SdpError` | The offer could not be converted, or no answer could be built. |
|
|
130
|
+
| `GatewayConnectError` | No gateway edge accepted the WebSocket, or it closed (or stalled) before the join result. |
|
|
131
|
+
| `JoinRejectedError` | The gateway refused the join (`.code`). |
|
|
132
|
+
| `JoinTimeoutError` | No join result in time. |
|
|
133
|
+
| `SessionClosedError` | The session has already ended. |
|
|
134
|
+
| `RtmError` | An RTM peer message was not accepted (`.status`, `.code`). |
|
|
135
|
+
|
|
136
|
+
A failed join raises; it never returns a made-up answer (D9). A running
|
|
137
|
+
session reports its end once, through `on_closed(CloseReason)` (D14).
|
|
138
|
+
|
|
139
|
+
## Documentation
|
|
140
|
+
|
|
141
|
+
- [`CONSTITUTION.md`](CONSTITUTION.md): the rules.
|
|
142
|
+
- [`docs/architecture.md`](docs/architecture.md): layers, lifecycle, single homes.
|
|
143
|
+
- [`docs/decisions.md`](docs/decisions.md): why (D1–D27).
|
|
144
|
+
- [`docs/open_questions.md`](docs/open_questions.md): what is not settled (Q1–Q19).
|
|
145
|
+
- [`docs/protocol.md`](docs/protocol.md): what the wire looks like.
|
|
146
|
+
- [`docs/migration.md`](docs/migration.md): moving the Mammotion and PetKit integrations onto this library.
|
|
147
|
+
- [`docs/testing.md`](docs/testing.md): how it is tested.
|
|
148
|
+
|
|
149
|
+
## Licence
|
|
150
|
+
|
|
151
|
+
GPL-3.0-or-later. See [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
pyagorartc/__init__.py,sha256=EnG8LuIf9JI0Ap4wDYLXOC1PAPhIFAh7x2RrXuTf310,1503
|
|
2
|
+
pyagorartc/const.py,sha256=38JtRe3d21pajaI1MLEczhrpIV709xZpRmc7yPBxLws,2261
|
|
3
|
+
pyagorartc/exceptions.py,sha256=Pls-eiFipMrfn-5S6tme2H-DinmTrj-LRFL2yTv_YVY,2026
|
|
4
|
+
pyagorartc/models.py,sha256=mXCfmRi-qYDBAuxYmhPf7Gkp7g3YH9YHJH9KYD6Rj7g,7292
|
|
5
|
+
pyagorartc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pyagorartc/ap/__init__.py,sha256=yhp_jlDe-HAb_PlLP04QGnEKWcvlaxdQkTuEJmPcKnY,481
|
|
7
|
+
pyagorartc/ap/client.py,sha256=i0A5i8khpER0o5uCqd3_cdelgVTYvzcFd5BsQGacMsY,8829
|
|
8
|
+
pyagorartc/ap/password.py,sha256=i1-Cj_UAsDcFpFiqS8MI2qR4SYjDqahDEJVTA432_go,502
|
|
9
|
+
pyagorartc/ap/response.py,sha256=AEvDpMdNJ1EaR1X61p4Couxamwu5fKwpJhm5s3Z29HI,12953
|
|
10
|
+
pyagorartc/rtm/__init__.py,sha256=juxW-MBIOyNs0mVTrOHZdvPtsHHuhwWVjyLWBoxUp-A,169
|
|
11
|
+
pyagorartc/rtm/client.py,sha256=ds0PpSW9IY_wcsnGRdDnB8QW4MKSd1pqIvu1keAbJLc,6958
|
|
12
|
+
pyagorartc/sdp/__init__.py,sha256=eSRns-Rp2d3uUQ3Bw_-ioWTgLPoQdnL1S6yU14rOd3Q,738
|
|
13
|
+
pyagorartc/sdp/answer.py,sha256=Q9xlHQcsMr3LUmEHqohbChobQR2IMGqefPfC21kXZiE,14788
|
|
14
|
+
pyagorartc/sdp/candidates.py,sha256=dHeVT-56IAglTPBPQXExm992n5_YfnXwj77_v-3AY8Y,4188
|
|
15
|
+
pyagorartc/sdp/offer.py,sha256=748xUSkdixoMhPRLZ9Hj_m69a4WOHk3OVDjEg5uWrRI,10521
|
|
16
|
+
pyagorartc/session/__init__.py,sha256=8Ao68NG2OdMFqv__Np_D06i3z0fMZSb6cJcruCdjE_k,2031
|
|
17
|
+
pyagorartc/session/messages.py,sha256=cfmH-CwnN66pclxxA5TVt7Oq2bC5iuoFuWe2r3qLtno,20884
|
|
18
|
+
pyagorartc/session/recovery.py,sha256=EZLLhQZ4X3eteynqYcSOeYohH1MXCijXS2UPAo5_bOM,6667
|
|
19
|
+
pyagorartc/session/session.py,sha256=0Gs7dWfscIcErmcEMy5LaFy1v6yCGfg5VbR9jkIA2Uk,31667
|
|
20
|
+
pyagorartc/session/transport.py,sha256=1thvlEJDJSAOWbNu_sBlPa4pcP96BFHNxzUgrcEF9VM,4568
|
|
21
|
+
pyagorartc-0.2.0.dist-info/METADATA,sha256=cS8VvzcQvvkNgS-tD0UJ0cuDqS8rdOu5Uu1NLQRfLAM,6523
|
|
22
|
+
pyagorartc-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
23
|
+
pyagorartc-0.2.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
24
|
+
pyagorartc-0.2.0.dist-info/RECORD,,
|