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,320 @@
1
+ """Build the answer SDP a viewer applies from the gateway's ``join_v3`` ORTC (``docs/protocol.md`` §6.2).
2
+
3
+ Lifted from HA-Luba's shipped answer builder, with PetKit's offer-payload fallback, audio-disable and declared
4
+ remote SSRC. ``tests/fixtures/sdp/chrome_answer_shipped.sdp`` pins parity with the shipped output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any, cast
12
+
13
+ from pyagorartc.exceptions import SdpError
14
+ from pyagorartc.models import as_int
15
+ from pyagorartc.sdp.offer import DEFAULT_CLOCK_RATE, KINDS, as_mapping, negotiated_caps, parse_offer
16
+
17
+ if TYPE_CHECKING:
18
+ from pyagorartc.models import RemoteStream, SessionOptions
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+ MID_EXTENSION_URI = "urn:ietf:params:rtp-hdrext:sdes:mid"
23
+ #: D16: the edge numbers video mid 2 internally, so a negotiated MID makes Chrome drop every video packet.
24
+ STRIPPED_EXTENSIONS: frozenset[str] = frozenset({MID_EXTENSION_URI})
25
+
26
+ # D5; RFC 5763 §5 forbids actpass in an answer, so a gateway that will take either role gets active.
27
+ _SETUP_FOR_ROLE = {"server": "passive", "client": "active", "auto": "active"}
28
+ # The live gateway reports "client"; an absent role is read the same way (the shipped default).
29
+ _DEFAULT_GATEWAY_ROLE = "client"
30
+ _ANSWER_DIRECTION = {"recvonly": "sendonly", "sendonly": "recvonly", "sendrecv": "sendrecv", "inactive": "inactive"}
31
+ _DEFAULT_OFFER_DIRECTION = "sendrecv"
32
+ # RFC 3551 §6: 0-34 are statically assigned; anything above means nothing without an a=rtpmap (D25).
33
+ _FIRST_UNASSIGNED_PAYLOAD_TYPE = 35
34
+ _DEFAULT_PRIORITY = 2103266323
35
+ _STREAM_ID = "agora"
36
+ _TRACK_ID = "agora-video"
37
+ _SESSION_HEADER = ("v=0", "o=- 0 0 IN IP4 127.0.0.1", "s=AgoraGateway", "t=0 0")
38
+ _REQUIRED_SESSION_LINES = ("v=", "o=", "s=", "t=")
39
+ _MIN_MEDIA_FIELDS = 4
40
+
41
+
42
+ def setup_for_role(role: str | None) -> str:
43
+ """The answer's ``a=setup`` for the gateway's DTLS role (D5); ``None`` reads as ``client``, ``auto`` as ``active``.
44
+
45
+ Raises:
46
+ SdpError: The role is not ``server``, ``client`` or ``auto``.
47
+ """
48
+ if (setup := _SETUP_FOR_ROLE.get(role or _DEFAULT_GATEWAY_ROLE)) is None:
49
+ raise SdpError(f"gateway sent an unknown DTLS role {role!r}")
50
+ return setup
51
+
52
+
53
+ def offers_rtx(gateway_ortc: Mapping[str, object]) -> bool:
54
+ """Whether the gateway lists an ``rtx`` video codec; the live gateway lists none (``docs/protocol.md`` §2.5)."""
55
+ codecs = negotiated_caps(gateway_ortc).get("videoCodecs")
56
+ return any(
57
+ str(as_mapping(as_mapping(codec).get("rtpMap")).get("encodingName") or "").lower() == "rtx"
58
+ for codec in (codecs if isinstance(codecs, list) else [])
59
+ )
60
+
61
+
62
+ def answer_from_ortc(
63
+ gateway_ortc: Mapping[str, object],
64
+ offer_sdp: str,
65
+ *,
66
+ options: SessionOptions,
67
+ remote_video: RemoteStream | None = None,
68
+ ) -> str:
69
+ """The answer SDP for ``offer_sdp``: one section per offer m-line, in offer order, from the gateway ORTC.
70
+
71
+ The answer is ``a=ice-lite``, carries the gateway's ICE credentials, first fingerprint and every candidate in
72
+ each section, and copies the gateway's codecs unchanged. When the gateway lists no codec for a section the
73
+ offer's payload types that carry an ``a=rtpmap`` (or are statically assigned) are answered instead, and a
74
+ section left with none is rejected. Extensions are those both sides list, under the offer's ids, minus
75
+ ``STRIPPED_EXTENSIONS`` when ``options.strip_mid_extension``. ``remote_video`` declares that stream's SSRCs
76
+ and ``a=msid`` in the first video section (D17); its RTX group only when the gateway offers RTX. An offer
77
+ m-line without ``a=mid`` gets none in the answer and is left out of BUNDLE.
78
+
79
+ Raises:
80
+ SdpError: The offer cannot be parsed, or the gateway ORTC lacks ICE credentials, a fingerprint, a known
81
+ DTLS role or a codec's ``payloadType``/``encodingName``, or a required node is not an object
82
+ (D9: nothing is made up).
83
+ """
84
+ offer = cast("Mapping[str, Any]", parse_offer(offer_sdp))
85
+ ice = as_mapping(gateway_ortc.get("iceParameters"))
86
+ dtls = as_mapping(gateway_ortc.get("dtlsParameters"))
87
+ role = dtls.get("role")
88
+ transport = [
89
+ f"a=ice-ufrag:{_required(ice, 'iceUfrag')}",
90
+ f"a=ice-pwd:{_required(ice, 'icePwd')}",
91
+ "a=ice-options:trickle",
92
+ f"a=fingerprint:{_fingerprint(dtls)}",
93
+ f"a=setup:{setup_for_role(role if isinstance(role, str) else None)}",
94
+ ]
95
+ candidates = _candidate_lines(ice.get("candidates"))
96
+ caps = negotiated_caps(gateway_ortc)
97
+ stripped = STRIPPED_EXTENSIONS if options.strip_mid_extension else frozenset()
98
+ ssrc_lines = _ssrc_lines(remote_video, gateway_ortc) if remote_video is not None else []
99
+
100
+ sections: list[list[str]] = []
101
+ rejected: set[str] = set()
102
+ for media in offer["media"]:
103
+ kind = str(media.get("type", ""))
104
+ mid = str(mid_value) if (mid_value := media.get("mid")) is not None else None
105
+ mid_lines = [f"a=mid:{mid}"] if mid is not None else []
106
+ if kind not in KINDS:
107
+ sections.append(_rejected_section(media, mid, rejected))
108
+ continue
109
+ codecs_key, extensions_key = KINDS[kind]
110
+ # sdp_transform only yields the four RFC 3264 directions.
111
+ direction = _ANSWER_DIRECTION[media.get("direction", _DEFAULT_OFFER_DIRECTION)]
112
+ if kind == "audio" and options.disable_audio:
113
+ direction = "inactive"
114
+ if codecs := _codec_list(caps, codecs_key):
115
+ payload_types, codec_lines = _gateway_codecs(codecs, codecs_key)
116
+ else:
117
+ payload_types, codec_lines = _offer_codecs(media)
118
+ if not payload_types:
119
+ _LOGGER.debug("Rejecting offer %s section %s: no payload type it can answer", kind, mid)
120
+ sections.append(_rejected_section(media, mid, rejected))
121
+ continue
122
+ lines = [
123
+ f"m={kind} 9 UDP/TLS/RTP/SAVPF {payload_types}",
124
+ "c=IN IP4 127.0.0.1",
125
+ "a=rtcp:9 IN IP4 0.0.0.0",
126
+ *transport,
127
+ *mid_lines,
128
+ *candidates,
129
+ *_extension_lines(media, caps.get(extensions_key), stripped),
130
+ f"a={direction}",
131
+ "a=rtcp-mux",
132
+ "a=rtcp-rsize",
133
+ *codec_lines,
134
+ ]
135
+ if kind == "video":
136
+ lines.extend(ssrc_lines)
137
+ ssrc_lines = []
138
+ sections.append(lines)
139
+
140
+ header = list(_SESSION_HEADER)
141
+ if bundle := _bundle_mids(offer, rejected):
142
+ header.append(f"a=group:BUNDLE {bundle}")
143
+ header.append("a=ice-lite")
144
+ if "extmapAllowMixed" in offer:
145
+ header.append("a=extmap-allow-mixed")
146
+ header.append("a=msid-semantic: WMS")
147
+ answer = "".join(f"{line}\r\n" for line in [*header, *(line for section in sections for line in section)])
148
+ validate_answer(answer)
149
+ return answer
150
+
151
+
152
+ def validate_answer(sdp: str, *, min_media_sections: int = 1) -> None:
153
+ """Check ``sdp`` has the session lines, at least ``min_media_sections`` m-lines, and no m-line without formats.
154
+
155
+ PetKit's audio-disabled answers are why the floor defaults to one section, not HA-Luba's two.
156
+
157
+ Raises:
158
+ SdpError: Naming the first check that failed.
159
+ """
160
+ lines = [line.strip() for line in sdp.splitlines() if line.strip()]
161
+ if not lines:
162
+ raise SdpError("answer SDP is empty")
163
+ for prefix in _REQUIRED_SESSION_LINES:
164
+ if not any(line.startswith(prefix) for line in lines):
165
+ raise SdpError(f"answer SDP has no {prefix} line")
166
+ media_lines = [line for line in lines if line.startswith("m=")]
167
+ if len(media_lines) < min_media_sections:
168
+ raise SdpError(f"answer SDP has {len(media_lines)} media sections, expected at least {min_media_sections}")
169
+ for line in media_lines:
170
+ if len(line.split()) < _MIN_MEDIA_FIELDS:
171
+ raise SdpError(f"answer SDP media line has no payload types: {line!r}")
172
+
173
+
174
+ def _required(ice: Mapping[str, object], key: str) -> str:
175
+ if not (value := ice.get(key)) or isinstance(value, Mapping | list):
176
+ raise SdpError(f"gateway ORTC has no iceParameters.{key}")
177
+ return str(value)
178
+
179
+
180
+ def _fingerprint(dtls: Mapping[str, object]) -> str:
181
+ fingerprints = dtls.get("fingerprints")
182
+ for entry in fingerprints if isinstance(fingerprints, list) else []:
183
+ if not isinstance(entry, Mapping):
184
+ _LOGGER.debug("Skipping a gateway DTLS fingerprint that is not an object")
185
+ continue
186
+ if value := entry.get("fingerprint"):
187
+ # The gateway writes "algorithm"; fingerprints merged in from the AP response carry "hashFunction".
188
+ return f"{entry.get('hashFunction') or entry.get('algorithm') or 'sha-256'} {value}"
189
+ break
190
+ raise SdpError("gateway ORTC has no DTLS fingerprint")
191
+
192
+
193
+ def _candidate_lines(candidates: object) -> list[str]:
194
+ lines = []
195
+ for candidate in candidates if isinstance(candidates, list) else []:
196
+ if not isinstance(candidate, Mapping):
197
+ _LOGGER.debug("Skipping a gateway candidate that is not an object")
198
+ continue
199
+ if candidate.get("ip") is None or candidate.get("port") is None:
200
+ _LOGGER.debug("Skipping gateway candidate without an address: %s", sorted(candidate))
201
+ continue
202
+ line = (
203
+ f"a=candidate:{candidate.get('foundation', 'udpcandidate')} 1 {candidate.get('protocol', 'udp')} "
204
+ f"{candidate.get('priority', _DEFAULT_PRIORITY)} {candidate['ip']} {candidate['port']} "
205
+ f"typ {candidate.get('type', 'host')}"
206
+ )
207
+ if (generation := candidate.get("generation")) is not None:
208
+ line += f" generation {generation}"
209
+ lines.append(line)
210
+ return lines
211
+
212
+
213
+ def _codec_list(caps: Mapping[str, object], key: str) -> list[object]:
214
+ if (codecs := caps.get(key)) is None:
215
+ return []
216
+ if not isinstance(codecs, list):
217
+ raise SdpError(f"gateway ORTC rtpCapabilities.{key} is not a list")
218
+ return codecs
219
+
220
+
221
+ def _gateway_codecs(codecs: list[object], key: str) -> tuple[str, list[str]]:
222
+ payload_types = []
223
+ lines = []
224
+ for codec in codecs:
225
+ if not isinstance(codec, Mapping):
226
+ raise SdpError(f"gateway ORTC {key} entry is not an object")
227
+ if (payload_type := codec.get("payloadType")) is None:
228
+ raise SdpError("gateway ORTC codec has no payloadType")
229
+ rtp_map = as_mapping(codec.get("rtpMap"))
230
+ if not (name := rtp_map.get("encodingName")):
231
+ raise SdpError(f"gateway ORTC codec {payload_type} has no rtpMap.encodingName")
232
+ rtpmap = f"a=rtpmap:{payload_type} {name}/{rtp_map.get('clockRate', DEFAULT_CLOCK_RATE)}"
233
+ if encoding := rtp_map.get("encodingParameters"):
234
+ rtpmap += f"/{encoding}"
235
+ payload_types.append(str(payload_type))
236
+ lines.append(rtpmap)
237
+ lines.extend(_feedback_lines(payload_type, codec.get("rtcpFeedbacks")))
238
+ if parameters := as_mapping(as_mapping(codec.get("fmtp")).get("parameters")):
239
+ config = ";".join(key if value is None else f"{key}={value}" for key, value in parameters.items())
240
+ lines.append(f"a=fmtp:{payload_type} {config}")
241
+ return " ".join(payload_types), lines
242
+
243
+
244
+ def _feedback_lines(payload_type: object, feedbacks: object) -> list[str]:
245
+ lines = []
246
+ for feedback in feedbacks if isinstance(feedbacks, list) else []:
247
+ if not isinstance(feedback, Mapping) or not (kind := feedback.get("type")):
248
+ _LOGGER.debug("Skipping a gateway rtcpFeedback without a type for codec %s", payload_type)
249
+ continue
250
+ parameter = feedback.get("parameter")
251
+ lines.append(f"a=rtcp-fb:{payload_type} {kind}" + (f" {parameter}" if parameter else ""))
252
+ return lines
253
+
254
+
255
+ def _offer_codecs(media: Mapping[str, Any]) -> tuple[str, list[str]]:
256
+ """The offer's formats that mean something on their own: an ``a=rtpmap`` or a static assignment (D25)."""
257
+ rtpmaps = {str(rtp["payload"]): rtp for rtp in media.get("rtp", [])}
258
+ kept = [
259
+ payload
260
+ for payload in str(media["payloads"]).split()
261
+ if payload in rtpmaps
262
+ or ((number := as_int(payload)) is not None and 0 <= number < _FIRST_UNASSIGNED_PAYLOAD_TYPE)
263
+ ]
264
+ lines = []
265
+ for payload in kept:
266
+ if (rtp := rtpmaps.get(payload)) is None:
267
+ continue
268
+ rtpmap = f"a=rtpmap:{payload} {rtp['codec']}/{rtp.get('rate') or DEFAULT_CLOCK_RATE}"
269
+ if (encoding := rtp.get("encoding")) is not None:
270
+ rtpmap += f"/{encoding}"
271
+ lines.append(rtpmap)
272
+ lines.extend(
273
+ f"a=fmtp:{fmtp['payload']} {fmtp['config']}" for fmtp in media.get("fmtp", []) if str(fmtp["payload"]) in kept
274
+ )
275
+ return " ".join(kept), lines
276
+
277
+
278
+ def _extension_lines(media: Mapping[str, Any], gateway_extensions: object, stripped: frozenset[str]) -> list[str]:
279
+ offered = {str(ext["uri"]): ext["value"] for ext in media.get("ext", [])}
280
+ lines = []
281
+ for ext in gateway_extensions if isinstance(gateway_extensions, list) else []:
282
+ if not isinstance(ext, Mapping):
283
+ _LOGGER.debug("Skipping a gateway header extension that is not an object")
284
+ continue
285
+ if (uri := str(ext.get("extensionName"))) in offered and uri not in stripped:
286
+ lines.append(f"a=extmap:{offered[uri]} {uri}")
287
+ return lines
288
+
289
+
290
+ def _ssrc_lines(stream: RemoteStream, gateway_ortc: Mapping[str, object]) -> list[str]:
291
+ gateway_cname = gateway_ortc.get("cname")
292
+ cname = stream.cname or (gateway_cname if isinstance(gateway_cname, str) and gateway_cname else _STREAM_ID)
293
+ msid = f"{_STREAM_ID} {_TRACK_ID}"
294
+ lines = [
295
+ f"a=msid:{msid}",
296
+ f"a=ssrc:{stream.ssrc} cname:{cname}",
297
+ f"a=ssrc:{stream.ssrc} msid:{msid}",
298
+ f"a=ssrc:{stream.ssrc} mslabel:{_STREAM_ID}",
299
+ f"a=ssrc:{stream.ssrc} label:{_TRACK_ID}",
300
+ ]
301
+ if stream.rtx_ssrc is not None and offers_rtx(gateway_ortc):
302
+ lines += [f"a=ssrc-group:FID {stream.ssrc} {stream.rtx_ssrc}", f"a=ssrc:{stream.rtx_ssrc} cname:{cname}"]
303
+ return lines
304
+
305
+
306
+ def _rejected_section(media: Mapping[str, Any], mid: str | None, rejected: set[str]) -> list[str]:
307
+ lines = [f"m={media['type']} 0 {media['protocol']} {media['payloads']}", "c=IN IP4 0.0.0.0"]
308
+ if mid is not None:
309
+ rejected.add(mid)
310
+ lines.append(f"a=mid:{mid}")
311
+ return lines
312
+
313
+
314
+ def _bundle_mids(offer: Mapping[str, Any], rejected: set[str]) -> str:
315
+ bundle = next((group for group in offer.get("groups", []) if group["type"] == "BUNDLE"), None)
316
+ if bundle is not None:
317
+ mids = bundle["mids"].split()
318
+ else:
319
+ mids = [str(media["mid"]) for media in offer["media"] if media.get("mid") is not None]
320
+ return " ".join(mid for mid in mids if mid not in rejected)
@@ -0,0 +1,103 @@
1
+ """ICE candidate helpers: encode for the join ORTC (D11), read inline and trickled candidates, filter for TURN.
2
+
3
+ Candidates travel as the viewer's verbatim ``candidate:`` strings (``IceCandidate``), so they are read line by
4
+ line rather than through ``sdp_transform``, which rewrites numeric foundations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pyagorartc.models import IceCandidate
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Collection, Iterable
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+ _CANDIDATE_LINE = "a=candidate:"
20
+ _CANDIDATE_PREFIX = "candidate:"
21
+ # RFC 8445 §5.1: foundation component transport priority address port "typ" type [extensions]
22
+ _MIN_FIELDS = 8
23
+ _REFLEXIVE_TYPES = frozenset({"srflx", "prflx"})
24
+ _RELAY_TYPE = "relay"
25
+
26
+
27
+ def candidates_to_ortc(candidates: Iterable[IceCandidate | str]) -> list[dict[str, str | int]]:
28
+ """The candidates in the ``iceParameters.candidates`` shape ``join_v3`` carries.
29
+
30
+ Each entry has ``foundation``, ``ip``, ``port``, ``priority``, ``protocol`` and ``type``, the fields both
31
+ shipped copies sent. A candidate that does not parse is skipped with a DEBUG line.
32
+ """
33
+ encoded: list[dict[str, str | int]] = []
34
+ for candidate in candidates:
35
+ text = candidate.candidate if isinstance(candidate, IceCandidate) else candidate
36
+ if (fields := _fields(text)) is None:
37
+ _LOGGER.debug("Skipping unreadable ICE candidate %r", text)
38
+ continue
39
+ foundation, _component, protocol, priority, ip, port, _typ, kind = fields[:_MIN_FIELDS]
40
+ encoded.append(
41
+ {
42
+ "foundation": foundation,
43
+ "ip": ip,
44
+ "port": int(port),
45
+ "priority": int(priority),
46
+ "protocol": protocol,
47
+ "type": kind,
48
+ }
49
+ )
50
+ return encoded
51
+
52
+
53
+ def parse_trickle_fragment(fragment: str) -> list[IceCandidate]:
54
+ """The candidates in a WHEP ``PATCH`` body (RFC 8840 ``trickle-ice-sdpfrag``), each tagged with its ``a=mid``.
55
+
56
+ ``sdp_mline_index`` is left unset: a fragment names sections by mid and need not list every offer m-line.
57
+ """
58
+ return _read_candidates(fragment, with_index=False)
59
+
60
+
61
+ def extract_inline_candidates(offer_sdp: str) -> list[IceCandidate]:
62
+ """The candidates written into an offer (a non-trickle offer, or ones gathered before it), with mid and index."""
63
+ return _read_candidates(offer_sdp, with_index=True)
64
+
65
+
66
+ def filter_candidates(candidates: Iterable[IceCandidate], turn_ips: Collection[str]) -> list[IceCandidate]:
67
+ """Drop host candidates: keep srflx/prflx, and relays whose address is one of ``turn_ips`` (any relay if empty).
68
+
69
+ Returns the input unchanged when nothing would survive, so a viewer is never left without a candidate.
70
+ """
71
+ candidates = list(candidates)
72
+ kept = []
73
+ for candidate in candidates:
74
+ if (fields := _fields(candidate.candidate)) is None:
75
+ continue
76
+ address, kind = fields[4], fields[7]
77
+ if kind in _REFLEXIVE_TYPES or (kind == _RELAY_TYPE and (not turn_ips or address in turn_ips)):
78
+ kept.append(candidate)
79
+ return kept or candidates
80
+
81
+
82
+ def _fields(text: str) -> list[str] | None:
83
+ fields = text.strip().removeprefix("a=").removeprefix(_CANDIDATE_PREFIX).split()
84
+ if len(fields) < _MIN_FIELDS or fields[6] != "typ" or not (fields[3].isdigit() and fields[5].isdigit()):
85
+ return None
86
+ return fields
87
+
88
+
89
+ def _read_candidates(sdp: str, *, with_index: bool) -> list[IceCandidate]:
90
+ sections: list[tuple[str | None, list[str]]] = [(None, [])]
91
+ for raw in sdp.splitlines():
92
+ line = raw.strip()
93
+ if line.startswith("m="):
94
+ sections.append((None, []))
95
+ elif line.startswith("a=mid:"):
96
+ sections[-1] = (line.removeprefix("a=mid:").strip(), sections[-1][1])
97
+ elif line.startswith(_CANDIDATE_LINE):
98
+ sections[-1][1].append(line.removeprefix("a="))
99
+ return [
100
+ IceCandidate(text, sdp_mid=mid, sdp_mline_index=index - 1 if with_index and index else None)
101
+ for index, (mid, lines) in enumerate(sections)
102
+ for text in lines
103
+ ]
@@ -0,0 +1,260 @@
1
+ """Turn a viewer's SDP offer into the ORTC capabilities ``join_v3`` carries (D3).
2
+
3
+ ``sdp_transform`` is the one SDP parser. The shape and the codec rules follow the Agora Web SDK's
4
+ ``getOrtc`` as HA-Luba shipped it (``docs/protocol.md`` §2.4); ``tests/fixtures/sdp/chrome_offer_ortc_shipped.json``
5
+ pins parity with that code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ import logging
12
+ import re
13
+ from typing import Any, NotRequired, TypedDict
14
+
15
+ from sdp_transform import parse as sdp_parse
16
+
17
+ from pyagorartc.const import DEFAULT_ORTC_DTLS_ROLE
18
+ from pyagorartc.exceptions import SdpError
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ class OrtcIceParameters(TypedDict):
24
+ """``iceParameters`` of the client ORTC; ``candidates`` is merged in by the session (D11)."""
25
+
26
+ iceUfrag: str
27
+ icePwd: str
28
+ candidates: NotRequired[list[dict[str, str | int]]]
29
+
30
+
31
+ class ClientOrtc(TypedDict):
32
+ """The client ORTC ``offer_to_ortc`` builds, keyed by its wire names (protocol.md §2.4)."""
33
+
34
+ iceParameters: OrtcIceParameters
35
+ dtlsParameters: dict[str, object]
36
+ rtpCapabilities: dict[str, dict[str, list[dict[str, object]]]]
37
+ version: str
38
+ cname: NotRequired[str]
39
+
40
+
41
+ #: Media kind -> the ORTC codec and extension keys for it; shared with the answer builder.
42
+ KINDS: Mapping[str, tuple[str, str]] = {
43
+ "audio": ("audioCodecs", "audioExtensions"),
44
+ "video": ("videoCodecs", "videoExtensions"),
45
+ }
46
+ DEFAULT_CLOCK_RATE = 90000
47
+ _BUCKETS = ("send", "recv", "sendrecv")
48
+ # First non-empty bucket wins (HA-Luba ``_negotiated_caps``; protocol.md §2.5).
49
+ _CAPABILITY_ORDER = ("sendrecv", "recv", "send")
50
+ _WILDCARD_PAYLOAD = "*"
51
+ # sdp_transform coerces numeric-looking values to int/float ("0012" -> 12, "nan" -> nan), corrupting these strings.
52
+ _VERBATIM_ATTRIBUTES = {"a=mid:": "mid", "a=ice-ufrag:": "iceUfrag", "a=ice-pwd:": "icePwd"}
53
+ # The same grammar sdp_transform applies (grammar.py), so restored values line up entry for entry.
54
+ _FMTP_LINE = re.compile(r"^a=fmtp:(\d*) ([\S| ]*)")
55
+ _SSRC_LINE = re.compile(r"^a=ssrc:(\d*) ([\w_-]*)(?::(.*))?")
56
+
57
+
58
+ def parse_offer(offer_sdp: str) -> dict[str, object]:
59
+ """Parse an SDP offer with ``sdp_transform``, keeping string attributes it would coerce to numbers (D24).
60
+
61
+ ``mid``, ``iceUfrag``, ``icePwd``, ``payloads``, every ``groups[].mids``, every ``fmtp[].config`` and every
62
+ ``ssrcs[].value`` are the offer's text verbatim, so ``0012``, ``1e10``, ``nan`` and ``inf`` survive.
63
+
64
+ Raises:
65
+ SdpError: The offer has no media sections.
66
+ """
67
+ return _parse(offer_sdp)
68
+
69
+
70
+ def _parse(offer_sdp: str) -> dict[str, Any]:
71
+ parsed: dict[str, Any] = sdp_parse(offer_sdp)
72
+ if not parsed["media"]:
73
+ raise SdpError("offer has no media sections")
74
+ sections: list[dict[str, Any]] = [parsed, *parsed["media"]]
75
+ fmtp_configs: list[list[str]] = [[] for _ in sections]
76
+ ssrc_values: list[list[str | None]] = [[] for _ in sections]
77
+ groups: list[dict[str, str]] = []
78
+ index = 0
79
+ # Walk the lines exactly as sdp_transform does (unstripped, splitlines) so section indices agree.
80
+ for raw in offer_sdp.splitlines():
81
+ if raw.startswith("m="):
82
+ index += 1
83
+ sections[index]["payloads"] = " ".join(raw.split()[3:])
84
+ elif raw.startswith("a=group:") and index == 0:
85
+ kind, _, mids = raw.rstrip().removeprefix("a=group:").partition(" ")
86
+ groups.append({"type": kind, "mids": mids.strip()})
87
+ elif match := _FMTP_LINE.match(raw):
88
+ fmtp_configs[index].append(match[2])
89
+ elif match := _SSRC_LINE.match(raw):
90
+ ssrc_values[index].append(match[3])
91
+ else:
92
+ _restore_attribute(sections[index], raw.rstrip())
93
+ for section, configs, values in zip(sections, fmtp_configs, ssrc_values, strict=True):
94
+ _restore(section.get("fmtp") or [], "config", configs)
95
+ _restore(section.get("ssrcs") or [], "value", values)
96
+ if groups:
97
+ parsed["groups"] = groups
98
+ return parsed
99
+
100
+
101
+ def _restore_attribute(section: dict[str, Any], line: str) -> None:
102
+ for prefix, key in _VERBATIM_ATTRIBUTES.items():
103
+ if line.startswith(prefix):
104
+ section[key] = line.removeprefix(prefix).strip()
105
+
106
+
107
+ def _restore(entries: list[dict[str, Any]], key: str, raw_values: list[str] | list[str | None]) -> None:
108
+ if len(entries) != len(raw_values):
109
+ _LOGGER.debug("Offer %s lines did not line up with the parser's; left as parsed", key)
110
+ return
111
+ for entry, value in zip(entries, raw_values, strict=True):
112
+ if value is not None:
113
+ entry[key] = value
114
+
115
+
116
+ def can_send(codec: Mapping[str, object]) -> bool:
117
+ """Whether the browser may send ``codec`` (an ORTC codec dict), per the SDK's rule.
118
+
119
+ H265 is never sendable; VP9 profiles 1 and 3 and AV1 profile 1 are receive-only.
120
+ """
121
+ name = str(as_mapping(codec.get("rtpMap")).get("encodingName") or "").upper()
122
+ parameters = as_mapping(as_mapping(codec.get("fmtp")).get("parameters"))
123
+ match name:
124
+ case "H265":
125
+ return False
126
+ case "VP9":
127
+ return parameters.get("profile-id") not in {"1", "3"}
128
+ case "AV1":
129
+ return parameters.get("profile") != "1"
130
+ case _:
131
+ return True
132
+
133
+
134
+ def offer_to_ortc(offer_sdp: str, *, dtls_role: str | None = DEFAULT_ORTC_DTLS_ROLE) -> ClientOrtc:
135
+ """The client ORTC for ``join_v3``: ICE and DTLS parameters plus send/recv/sendrecv capability buckets.
136
+
137
+ Sendable codecs go to ``sendrecv`` and the rest to ``recv`` whatever the m-line direction; ``send`` stays
138
+ empty and every extension goes to ``sendrecv`` (the shipped layout). Every codec carries an ``rrtr``
139
+ feedback. ``cname`` is added when an offered ``a=ssrc`` declares one.
140
+
141
+ Args:
142
+ offer_sdp: The viewer's offer.
143
+ dtls_role: ``dtlsParameters.role`` to declare (D4); ``None`` sends none, as the SDK does (Q2).
144
+
145
+ Raises:
146
+ SdpError: The offer has no media sections, no ICE credentials or no DTLS fingerprint.
147
+ """
148
+ parsed = _parse(offer_sdp)
149
+ dtls: dict[str, object] = {"fingerprints": _fingerprints(parsed)}
150
+ if dtls_role is not None:
151
+ dtls["role"] = dtls_role
152
+ caps: dict[str, dict[str, list[dict[str, object]]]] = {
153
+ bucket: {"audioCodecs": [], "audioExtensions": [], "videoCodecs": [], "videoExtensions": []}
154
+ for bucket in _BUCKETS
155
+ }
156
+ for media in parsed["media"]:
157
+ if (kinds := KINDS.get(media.get("type", ""))) is None:
158
+ continue
159
+ codecs_key, extensions_key = kinds
160
+ for codec in _codecs(media):
161
+ caps["sendrecv" if can_send(codec) else "recv"][codecs_key].append(codec)
162
+ caps["sendrecv"][extensions_key].extend(
163
+ {"entry": ext["value"], "extensionName": ext["uri"]} for ext in media.get("ext", [])
164
+ )
165
+ ortc: ClientOrtc = {
166
+ "iceParameters": _ice_parameters(parsed),
167
+ "dtlsParameters": dtls,
168
+ "rtpCapabilities": caps,
169
+ "version": "2",
170
+ }
171
+ if (cname := _cname(parsed)) is not None:
172
+ ortc["cname"] = cname
173
+ return ortc
174
+
175
+
176
+ def negotiated_caps(gateway_ortc: Mapping[str, object]) -> dict[str, object]:
177
+ """The gateway's RTP capabilities: the first non-empty object of ``sendrecv``, ``recv``, ``send``, else the flat block.
178
+
179
+ The one home of the bucket rule; anything that is not an object reads as absent.
180
+ """
181
+ capabilities = as_mapping(gateway_ortc.get("rtpCapabilities"))
182
+ for name in _CAPABILITY_ORDER:
183
+ if bucket := as_mapping(capabilities.get(name)):
184
+ return dict(bucket)
185
+ return dict(capabilities)
186
+
187
+
188
+ def as_mapping(value: object) -> Mapping[str, object]:
189
+ """``value`` when it is a JSON object, else an empty one; how the ORTC readers tolerate a malformed node."""
190
+ return value if isinstance(value, Mapping) else {}
191
+
192
+
193
+ def _ice_parameters(parsed: Mapping[str, Any]) -> OrtcIceParameters:
194
+ for section in (parsed, *parsed["media"]):
195
+ if "iceUfrag" in section:
196
+ if "icePwd" not in section:
197
+ raise SdpError("offer has an ice-ufrag without an ice-pwd")
198
+ return {"iceUfrag": section["iceUfrag"], "icePwd": section["icePwd"]}
199
+ raise SdpError("offer has no ice-ufrag/ice-pwd")
200
+
201
+
202
+ def _fingerprints(parsed: Mapping[str, Any]) -> list[dict[str, str]]:
203
+ for section in (parsed, *parsed["media"]):
204
+ if fingerprint := section.get("fingerprint"):
205
+ return [{"hashFunction": str(fingerprint["type"]), "fingerprint": str(fingerprint["hash"])}]
206
+ raise SdpError("offer has no DTLS fingerprint")
207
+
208
+
209
+ def _codecs(media: Mapping[str, Any]) -> list[dict[str, object]]:
210
+ codecs: list[dict[str, object]] = []
211
+ for rtp in media.get("rtp", []):
212
+ payload_type = rtp["payload"]
213
+ rtp_map: dict[str, object] = {
214
+ "encodingName": str(rtp["codec"]),
215
+ "clockRate": rtp.get("rate") or DEFAULT_CLOCK_RATE,
216
+ }
217
+ if isinstance(encoding := rtp.get("encoding"), int):
218
+ rtp_map["encodingParameters"] = encoding
219
+ feedbacks = [
220
+ {"type": fb["type"], "parameter": str(fb["subtype"])} if "subtype" in fb else {"type": fb["type"]}
221
+ for fb in media.get("rtcpFb", [])
222
+ if fb["payload"] in {payload_type, _WILDCARD_PAYLOAD}
223
+ ]
224
+ if {"type": "rrtr"} not in feedbacks:
225
+ feedbacks.append({"type": "rrtr"})
226
+ parameters: dict[str, str | None] = {}
227
+ for fmtp in media.get("fmtp", []):
228
+ if fmtp["payload"] == payload_type:
229
+ parameters.update(_fmtp_parameters(str(fmtp["config"])))
230
+ codecs.append(
231
+ {
232
+ "payloadType": payload_type,
233
+ "rtpMap": rtp_map,
234
+ "rtcpFeedbacks": feedbacks,
235
+ "fmtp": {"parameters": parameters},
236
+ }
237
+ )
238
+ return codecs
239
+
240
+
241
+ def _fmtp_parameters(config: str) -> dict[str, str | None]:
242
+ """``a=fmtp`` parameters as a dict; a key without ``=`` maps to ``None``, as the SDK does."""
243
+ parameters: dict[str, str | None] = {}
244
+ for part in config.split(";"):
245
+ key, has_value, value = part.partition("=")
246
+ if key := key.strip():
247
+ parameters[key] = value.strip() if has_value else None
248
+ return parameters
249
+
250
+
251
+ def _cname(parsed: Mapping[str, Any]) -> str | None:
252
+ return next(
253
+ (
254
+ str(ssrc["value"])
255
+ for media in parsed["media"]
256
+ for ssrc in media.get("ssrcs", [])
257
+ if ssrc.get("attribute") == "cname"
258
+ ),
259
+ None,
260
+ )