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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any