pymammotion 0.5.69__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.
Files changed (154) hide show
  1. pymammotion/__init__.py +53 -0
  2. pymammotion/agora/__init__.py +0 -0
  3. pymammotion/agora/agora_api.py +755 -0
  4. pymammotion/agora/agora_rtc_capabilities.py +748 -0
  5. pymammotion/agora/agora_websockets.py +1175 -0
  6. pymammotion/aliyun/__init__.py +1 -0
  7. pymammotion/aliyun/client.py +235 -0
  8. pymammotion/aliyun/cloud_gateway.py +982 -0
  9. pymammotion/aliyun/model/aep_response.py +21 -0
  10. pymammotion/aliyun/model/connect_response.py +51 -0
  11. pymammotion/aliyun/model/dev_by_account_response.py +195 -0
  12. pymammotion/aliyun/model/login_by_oauth_response.py +64 -0
  13. pymammotion/aliyun/model/regions_response.py +29 -0
  14. pymammotion/aliyun/model/session_by_authcode_response.py +19 -0
  15. pymammotion/aliyun/model/thing_response.py +12 -0
  16. pymammotion/aliyun/regions.py +62 -0
  17. pymammotion/aliyun/tea/core.py +297 -0
  18. pymammotion/aliyun/tmp_constant.py +171 -0
  19. pymammotion/bluetooth/__init__.py +1 -0
  20. pymammotion/bluetooth/ble.py +62 -0
  21. pymammotion/bluetooth/ble_message.py +676 -0
  22. pymammotion/bluetooth/const.py +27 -0
  23. pymammotion/bluetooth/data/__init__.py +0 -0
  24. pymammotion/bluetooth/data/convert.py +25 -0
  25. pymammotion/bluetooth/data/framectrldata.py +40 -0
  26. pymammotion/bluetooth/data/notifydata.py +62 -0
  27. pymammotion/bluetooth/model/__init__.py +0 -0
  28. pymammotion/bluetooth/model/atomic_integer.py +54 -0
  29. pymammotion/const.py +13 -0
  30. pymammotion/data/__init__.py +0 -0
  31. pymammotion/data/model/__init__.py +8 -0
  32. pymammotion/data/model/account.py +8 -0
  33. pymammotion/data/model/device.py +192 -0
  34. pymammotion/data/model/device_config.py +72 -0
  35. pymammotion/data/model/device_info.py +60 -0
  36. pymammotion/data/model/device_limits.py +49 -0
  37. pymammotion/data/model/enums.py +77 -0
  38. pymammotion/data/model/errors.py +12 -0
  39. pymammotion/data/model/events.py +14 -0
  40. pymammotion/data/model/generate_geojson.py +565 -0
  41. pymammotion/data/model/generate_route_information.py +26 -0
  42. pymammotion/data/model/hash_list.py +475 -0
  43. pymammotion/data/model/location.py +36 -0
  44. pymammotion/data/model/mowing_modes.py +77 -0
  45. pymammotion/data/model/rapid_state.py +45 -0
  46. pymammotion/data/model/raw_data.py +215 -0
  47. pymammotion/data/model/region_data.py +102 -0
  48. pymammotion/data/model/report_info.py +182 -0
  49. pymammotion/data/model/work.py +27 -0
  50. pymammotion/data/mower_state_manager.py +369 -0
  51. pymammotion/data/mqtt/__init__.py +1 -0
  52. pymammotion/data/mqtt/event.py +227 -0
  53. pymammotion/data/mqtt/mammotion_properties.py +276 -0
  54. pymammotion/data/mqtt/properties.py +203 -0
  55. pymammotion/data/mqtt/status.py +57 -0
  56. pymammotion/event/__init__.py +6 -0
  57. pymammotion/event/event.py +96 -0
  58. pymammotion/homeassistant/__init__.py +3 -0
  59. pymammotion/homeassistant/mower_api.py +514 -0
  60. pymammotion/homeassistant/rtk_api.py +54 -0
  61. pymammotion/http/__init__.py +0 -0
  62. pymammotion/http/encryption.py +220 -0
  63. pymammotion/http/http.py +673 -0
  64. pymammotion/http/model/__init__.py +0 -0
  65. pymammotion/http/model/camera_stream.py +31 -0
  66. pymammotion/http/model/http.py +249 -0
  67. pymammotion/http/model/response_factory.py +61 -0
  68. pymammotion/http/model/rtk.py +16 -0
  69. pymammotion/mammotion/__init__.py +0 -0
  70. pymammotion/mammotion/commands/__init__.py +0 -0
  71. pymammotion/mammotion/commands/abstract_message.py +24 -0
  72. pymammotion/mammotion/commands/mammotion_command.py +81 -0
  73. pymammotion/mammotion/commands/messages/__init__.py +0 -0
  74. pymammotion/mammotion/commands/messages/basestation.py +43 -0
  75. pymammotion/mammotion/commands/messages/driver.py +122 -0
  76. pymammotion/mammotion/commands/messages/media.py +87 -0
  77. pymammotion/mammotion/commands/messages/navigation.py +564 -0
  78. pymammotion/mammotion/commands/messages/network.py +205 -0
  79. pymammotion/mammotion/commands/messages/ota.py +38 -0
  80. pymammotion/mammotion/commands/messages/system.py +330 -0
  81. pymammotion/mammotion/commands/messages/video.py +33 -0
  82. pymammotion/mammotion/control/__init__.py +0 -0
  83. pymammotion/mammotion/control/joystick.py +145 -0
  84. pymammotion/mammotion/devices/__init__.py +29 -0
  85. pymammotion/mammotion/devices/base.py +163 -0
  86. pymammotion/mammotion/devices/mammotion.py +571 -0
  87. pymammotion/mammotion/devices/mammotion_bluetooth.py +496 -0
  88. pymammotion/mammotion/devices/mammotion_cloud.py +355 -0
  89. pymammotion/mammotion/devices/mammotion_mower_ble.py +48 -0
  90. pymammotion/mammotion/devices/mammotion_mower_cloud.py +39 -0
  91. pymammotion/mammotion/devices/managers/managers.py +81 -0
  92. pymammotion/mammotion/devices/mower_device.py +120 -0
  93. pymammotion/mammotion/devices/mower_manager.py +107 -0
  94. pymammotion/mammotion/devices/rtk_ble.py +89 -0
  95. pymammotion/mammotion/devices/rtk_cloud.py +115 -0
  96. pymammotion/mammotion/devices/rtk_device.py +50 -0
  97. pymammotion/mammotion/devices/rtk_manager.py +125 -0
  98. pymammotion/mqtt/__init__.py +6 -0
  99. pymammotion/mqtt/aliyun_mqtt.py +237 -0
  100. pymammotion/mqtt/linkkit/__init__.py +5 -0
  101. pymammotion/mqtt/linkkit/h2client.py +585 -0
  102. pymammotion/mqtt/linkkit/linkkit.py +3025 -0
  103. pymammotion/mqtt/mammotion_future.py +26 -0
  104. pymammotion/mqtt/mammotion_mqtt.py +214 -0
  105. pymammotion/mqtt/mqtt_models.py +66 -0
  106. pymammotion/proto/__init__.py +4841 -0
  107. pymammotion/proto/basestation.proto +51 -0
  108. pymammotion/proto/basestation_pb2.py +35 -0
  109. pymammotion/proto/basestation_pb2.pyi +89 -0
  110. pymammotion/proto/common.proto +7 -0
  111. pymammotion/proto/common_pb2.py +25 -0
  112. pymammotion/proto/common_pb2.pyi +13 -0
  113. pymammotion/proto/dev_net.proto +321 -0
  114. pymammotion/proto/dev_net_pb2.py +111 -0
  115. pymammotion/proto/dev_net_pb2.pyi +515 -0
  116. pymammotion/proto/luba_msg.proto +76 -0
  117. pymammotion/proto/luba_msg_pb2.py +41 -0
  118. pymammotion/proto/luba_msg_pb2.pyi +97 -0
  119. pymammotion/proto/luba_mul.proto +129 -0
  120. pymammotion/proto/luba_mul_pb2.py +61 -0
  121. pymammotion/proto/luba_mul_pb2.pyi +178 -0
  122. pymammotion/proto/mctrl_driver.proto +107 -0
  123. pymammotion/proto/mctrl_driver_pb2.py +57 -0
  124. pymammotion/proto/mctrl_driver_pb2.pyi +167 -0
  125. pymammotion/proto/mctrl_nav.proto +591 -0
  126. pymammotion/proto/mctrl_nav_pb2.py +136 -0
  127. pymammotion/proto/mctrl_nav_pb2.pyi +1067 -0
  128. pymammotion/proto/mctrl_ota.proto +80 -0
  129. pymammotion/proto/mctrl_ota_pb2.py +45 -0
  130. pymammotion/proto/mctrl_ota_pb2.pyi +128 -0
  131. pymammotion/proto/mctrl_pept.proto +34 -0
  132. pymammotion/proto/mctrl_pept_pb2.py +33 -0
  133. pymammotion/proto/mctrl_pept_pb2.pyi +58 -0
  134. pymammotion/proto/mctrl_sys.proto +741 -0
  135. pymammotion/proto/mctrl_sys_pb2.py +206 -0
  136. pymammotion/proto/mctrl_sys_pb2.pyi +1213 -0
  137. pymammotion/proto/message_pool.py +3 -0
  138. pymammotion/proto/py.typed +0 -0
  139. pymammotion/py.typed +0 -0
  140. pymammotion/utility/constant/__init__.py +3 -0
  141. pymammotion/utility/constant/device_constant.py +315 -0
  142. pymammotion/utility/conversions.py +5 -0
  143. pymammotion/utility/datatype_converter.py +124 -0
  144. pymammotion/utility/device_config.py +755 -0
  145. pymammotion/utility/device_type.py +489 -0
  146. pymammotion/utility/map.py +259 -0
  147. pymammotion/utility/movement.py +18 -0
  148. pymammotion/utility/mur_mur_hash.py +159 -0
  149. pymammotion/utility/periodic.py +106 -0
  150. pymammotion/utility/rocker_util.py +194 -0
  151. pymammotion-0.5.69.dist-info/METADATA +93 -0
  152. pymammotion-0.5.69.dist-info/RECORD +154 -0
  153. pymammotion-0.5.69.dist-info/WHEEL +4 -0
  154. pymammotion-0.5.69.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,1175 @@
1
+ """Agora WebSocket handler for Mammotion WebRTC streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ import json
9
+ import logging
10
+ import secrets
11
+ import ssl
12
+ import time
13
+ from typing import Any
14
+
15
+ import aiohttp
16
+ from homeassistant.core import HomeAssistant
17
+ from sdp_transform import parse as sdp_parse
18
+ from webrtc_models import RTCIceCandidateInit
19
+ from websockets.asyncio.client import ClientConnection, connect
20
+ from websockets.exceptions import WebSocketException
21
+
22
+ from .agora_api import AgoraResponse
23
+ from .agora_rtc_capabilities import (
24
+ get_audio_codecs,
25
+ get_audio_extensions,
26
+ get_video_codecs_recv,
27
+ get_video_codecs_sendrecv,
28
+ get_video_extensions,
29
+ )
30
+ from .coordinator import StreamSubscriptionResponse
31
+
32
+ _LOGGER = logging.getLogger(__name__)
33
+
34
+
35
+ def _create_ws_ssl_context() -> ssl.SSLContext:
36
+ """Create SSL context for WebSocket connections."""
37
+ ssl_context = ssl.create_default_context()
38
+ ssl_context.check_hostname = False
39
+ ssl_context.verify_mode = ssl.CERT_NONE
40
+ return ssl_context
41
+
42
+
43
+ _SSL_CONTEXT = _create_ws_ssl_context()
44
+
45
+
46
+ @dataclass
47
+ class AddressEntry:
48
+ """Agora edge server address entry."""
49
+
50
+ ip: str
51
+ port: int
52
+ ticket: str
53
+
54
+
55
+ @dataclass
56
+ class ResponseInfo:
57
+ """Agora API response information."""
58
+
59
+ code: int
60
+ addresses: list[AddressEntry]
61
+ server_ts: int
62
+ uid: int
63
+ cid: int
64
+ cname: str
65
+ detail: dict[str, str]
66
+ flag: int
67
+ opid: int
68
+ cert: str
69
+
70
+
71
+ @dataclass
72
+ class SdpInfo:
73
+ """SDP parsing information."""
74
+
75
+ parsed_sdp: dict
76
+ fingerprint: str
77
+ ice_ufrag: str
78
+ ice_pwd: str
79
+ audio_codecs: list[dict[str, Any]]
80
+ video_codecs: list[dict[str, Any]]
81
+ audio_extensions: list[dict[str, Any]]
82
+ video_extensions: list[dict[str, Any]]
83
+ audio_direction: str
84
+ video_direction: str
85
+ ice_candidates: list[dict[str, Any]]
86
+
87
+
88
+ class AgoraWebSocketHandler:
89
+ """Handle Agora WebSocket communications for WebRTC streaming."""
90
+
91
+ def __init__(self, hass: HomeAssistant) -> None:
92
+ """Initialize the Agora WebSocket handler."""
93
+ self.hass = hass
94
+ self._websocket: ClientConnection | None = None
95
+ self._connection_state = "DISCONNECTED"
96
+ self._message_handlers: dict[str, Callable] = {}
97
+ self._response_handlers: dict[str, asyncio.Future] = {}
98
+ self.candidates: list[RTCIceCandidateInit] = []
99
+ self._online_users: set[int] = set()
100
+ self._video_streams: dict[int, dict[str, Any]] = {}
101
+ self._answer_sdp: str | None = None
102
+ self._setup_message_handlers()
103
+
104
+ def _setup_message_handlers(self) -> None:
105
+ """Set up message handlers for different WebSocket message types."""
106
+ self._message_handlers = {
107
+ "answer": self._handle_answer,
108
+ "on_p2p_lost": self._handle_p2p_lost,
109
+ "error": self._handle_error,
110
+ "on_rtp_capability_change": self._handle_rtp_capability_change,
111
+ "on_user_online": self._handle_user_online,
112
+ "on_add_video_stream": self._handle_add_video_stream,
113
+ }
114
+
115
+ async def connect_and_join(
116
+ self,
117
+ agora_data: StreamSubscriptionResponse,
118
+ offer_sdp: str,
119
+ session_id: str,
120
+ agora_response: AgoraResponse,
121
+ ) -> str | None:
122
+ """Connect to Agora WebSocket and perform join negotiation.
123
+
124
+ Note: ICE candidates should be set in self.candidates before calling this method.
125
+ These candidates will be incorporated into the offer SDP before sending to Agora.
126
+
127
+ """
128
+ _LOGGER.debug("Starting Agora WebSocket connection for session %s", session_id)
129
+ _LOGGER.info("Agora data: %s", agora_data)
130
+ # Get edge server information
131
+ edge_info = await self._get_agora_edge_services(agora_data)
132
+ if not edge_info:
133
+ _LOGGER.error("Failed to get Agora edge services")
134
+ return None
135
+
136
+ _LOGGER.info("Edge: %s", edge_info)
137
+
138
+ # Parse offer SDP for capabilities
139
+ sdp_info = self._parse_offer_sdp(offer_sdp)
140
+ if not sdp_info:
141
+ _LOGGER.error("Failed to parse offer SDP")
142
+ return None
143
+
144
+ _LOGGER.info("Parsed offer SDP: %s", sdp_info)
145
+
146
+ # Incorporate runtime ICE candidates into offer SDP
147
+ if self.candidates:
148
+ offer_sdp = self._add_candidates_to_sdp(offer_sdp, self.candidates)
149
+ _LOGGER.info("Added %d candidates to offer SDP", len(self.candidates))
150
+ _LOGGER.info("Offer SDP with candidates: %s", offer_sdp)
151
+ # Try each edge address with timeout
152
+ for edge_address in edge_info.addresses:
153
+ edge_ip_dashed = edge_address.ip.replace(".", "-")
154
+ ws_url = f"wss://{edge_ip_dashed}.edge.agora.io:{edge_address.port}"
155
+
156
+ try:
157
+ async with asyncio.timeout(10): # 10 second timeout
158
+ async with connect(ws_url, ssl=_SSL_CONTEXT, ping_timeout=30, close_timeout=30) as websocket:
159
+ self._websocket = websocket
160
+ self._connection_state = "CONNECTED"
161
+ _LOGGER.info("Connected to Agora WebSocket: %s", ws_url)
162
+
163
+ # Send join message
164
+ join_message = self._create_join_message(
165
+ agora_data, offer_sdp, edge_info, sdp_info, agora_response
166
+ )
167
+ await websocket.send(json.dumps(join_message))
168
+ _LOGGER.info("Sent join message to Agora %s", join_message)
169
+
170
+ # Handle responses
171
+ return await self._handle_websocket_messages(websocket, session_id, sdp_info)
172
+
173
+ except TimeoutError:
174
+ _LOGGER.warning("Connection timeout for edge address %s, trying next", ws_url)
175
+ continue
176
+ except (WebSocketException, json.JSONDecodeError) as ex:
177
+ _LOGGER.warning("WebSocket connection failed for %s: %s", ws_url, ex)
178
+ continue
179
+
180
+ # If we get here, all connection attempts failed
181
+ _LOGGER.error("Failed to connect to any Agora edge servers")
182
+ self._connection_state = "DISCONNECTED"
183
+ return None
184
+
185
+ async def _handle_websocket_messages(
186
+ self, websocket: ClientConnection, session_id: str, sdp_info: SdpInfo
187
+ ) -> str | None:
188
+ """Handle incoming WebSocket messages."""
189
+ try:
190
+ async for message in websocket:
191
+ try:
192
+ response = json.loads(message)
193
+ _LOGGER.info("Received Agora message: %s", response)
194
+
195
+ message_type = response.get("_type")
196
+ message_id = response.get("_id")
197
+
198
+ # Handle responses to requests
199
+ if message_id and message_id in self._response_handlers:
200
+ future = self._response_handlers.pop(message_id)
201
+ if not future.done():
202
+ future.set_result(response)
203
+ continue
204
+
205
+ # Handle different message types
206
+ if message_type in self._message_handlers:
207
+ result = await self._message_handlers[message_type](response)
208
+ if result:
209
+ return result
210
+
211
+ # Check for successful join response
212
+ if response.get("_result") == "success":
213
+ return await self._handle_join_success(response, sdp_info)
214
+
215
+ except json.JSONDecodeError as ex:
216
+ _LOGGER.error("Failed to parse Agora message: %s", ex)
217
+
218
+ except WebSocketException as ex:
219
+ _LOGGER.error("WebSocket communication error: %s", ex)
220
+ self._connection_state = "DISCONNECTED"
221
+
222
+ # Fallback: generate basic SDP if no proper response was received
223
+ _LOGGER.warning("No proper WebSocket response received, generating fallback SDP")
224
+ return self._generate_fallback_sdp()
225
+
226
+ async def _handle_join_success(self, response: dict[str, Any], sdp_info: SdpInfo) -> str | None:
227
+ """Handle successful join response and generate answer SDP."""
228
+ message = response.get("_message", {})
229
+ ortc = message.get("ortc", {})
230
+
231
+ if not ortc:
232
+ _LOGGER.error("No ORTC parameters in join success response")
233
+ _LOGGER.info("Full response message: %s", message)
234
+ return None
235
+
236
+ _LOGGER.info("ORTC parameters: %s", ortc)
237
+
238
+ # Generate answer SDP from ORTC parameters
239
+ answer_sdp = self._generate_answer_sdp(ortc, sdp_info)
240
+ if answer_sdp:
241
+ _LOGGER.info("Generated answer SDP from Agora ORTC parameters")
242
+ _LOGGER.info("Generated SDP: %s", answer_sdp)
243
+
244
+ # Store answer SDP for later retrieval
245
+ self._answer_sdp = answer_sdp
246
+
247
+ # Send set_client_role after successful join
248
+ await self._send_set_client_role(role="audience", level=1)
249
+
250
+ return answer_sdp
251
+
252
+ _LOGGER.error("Failed to generate answer SDP")
253
+ return None
254
+
255
+ async def _handle_answer(self, response: dict[str, Any]) -> str | None:
256
+ """Handle answer message."""
257
+ message = response.get("_message", {})
258
+ sdp = message.get("sdp")
259
+ if sdp:
260
+ _LOGGER.info("Received direct answer SDP from Agora")
261
+ return sdp
262
+ return None
263
+
264
+ async def _handle_p2p_lost(self, response: dict[str, Any]) -> None:
265
+ """Handle P2P connection lost message."""
266
+ error_code = response.get("error_code")
267
+ error_str = response.get("error_str", "Unknown error")
268
+ _LOGGER.warning("P2P connection lost: %s (code: %s)", error_str, error_code)
269
+
270
+ # Handle specific error codes
271
+ if error_code == 1 and "stun timeout" in error_str.lower():
272
+ _LOGGER.info("STUN timeout detected, connection may need refreshing")
273
+ # This could trigger a reconnection attempt
274
+
275
+ self._connection_state = "DISCONNECTED"
276
+
277
+ async def _handle_error(self, response: dict[str, Any]) -> None:
278
+ """Handle error message."""
279
+ message = response.get("_message", {})
280
+ error = message.get("error", "Unknown error")
281
+ _LOGGER.error("Agora WebSocket error: %s", error)
282
+
283
+ async def _handle_rtp_capability_change(self, response: dict[str, Any]) -> None:
284
+ """Handle RTP capability change notification."""
285
+ message = response.get("_message", {})
286
+ _LOGGER.info("RTP capabilities changed: %s", message)
287
+ # Store capabilities if needed
288
+ video_codecs = message.get("video_codec", [])
289
+ extmap_allow_mixed = message.get("extmap_allow_mixed", False)
290
+ web_av1_svc = message.get("web_av1_svc", False)
291
+ _LOGGER.info(
292
+ "Video codecs: %s, extmap_allow_mixed: %s, web_av1_svc: %s",
293
+ video_codecs,
294
+ extmap_allow_mixed,
295
+ web_av1_svc,
296
+ )
297
+
298
+ async def _handle_user_online(self, response: dict[str, Any]) -> None:
299
+ """Handle user online notification."""
300
+ message = response.get("_message", {})
301
+ uid = message.get("uid")
302
+ if uid:
303
+ self._online_users.add(uid)
304
+ _LOGGER.info("User %s came online", uid)
305
+
306
+ async def _handle_add_video_stream(self, response: dict[str, Any]) -> None:
307
+ """Handle add video stream notification and auto-subscribe."""
308
+ message = response.get("_message", {})
309
+ uid = message.get("uid")
310
+ ssrc_id = message.get("ssrcId")
311
+ rtx_ssrc_id = message.get("rtxSsrcId")
312
+ cname = message.get("cname")
313
+ is_video = message.get("video", False)
314
+
315
+ if uid and is_video:
316
+ _LOGGER.info(
317
+ "Video stream added - uid: %s, ssrcId: %s, rtxSsrcId: %s, cname: %s",
318
+ uid,
319
+ ssrc_id,
320
+ rtx_ssrc_id,
321
+ cname,
322
+ )
323
+
324
+ # Store stream info
325
+ self._video_streams[uid] = {
326
+ "ssrcId": ssrc_id,
327
+ "rtxSsrcId": rtx_ssrc_id,
328
+ "cname": cname,
329
+ }
330
+
331
+ # Auto-subscribe to the video stream
332
+ if self._websocket:
333
+ await self._send_subscribe(stream_id=uid, ssrc_id=ssrc_id, codec="vp8")
334
+
335
+ def _create_join_message(
336
+ self,
337
+ agora_data: StreamSubscriptionResponse,
338
+ offer_sdp: str,
339
+ edge_info: ResponseInfo,
340
+ sdp_info: SdpInfo,
341
+ agora_response: AgoraResponse,
342
+ ) -> dict[str, Any]:
343
+ """Create join_v3 message for Agora WebSocket."""
344
+ message_id = secrets.token_hex(3) # 6 characters
345
+ process_id = f"process-{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
346
+
347
+ return {
348
+ "_id": message_id,
349
+ "_type": "join_v3",
350
+ "_message": {
351
+ "p2p_id": 3,
352
+ "session_id": secrets.token_hex(16).upper(),
353
+ "app_id": agora_data.appid,
354
+ "channel_key": agora_data.token,
355
+ "channel_name": agora_data.channelName,
356
+ "sdk_version": "4.24.0",
357
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
358
+ "process_id": process_id,
359
+ "mode": "live",
360
+ "codec": "vp8",
361
+ "role": "audience",
362
+ "has_changed_gateway": False,
363
+ "ap_response": agora_response.to_ap_response(),
364
+ "extend": "",
365
+ "details": {},
366
+ "features": {"rejoin": True},
367
+ "attributes": {
368
+ "userAttributes": {
369
+ "enableAudioMetadata": False,
370
+ "enableAudioPts": False,
371
+ "enablePublishedUserList": True,
372
+ "maxSubscription": 50,
373
+ "enableUserLicenseCheck": True,
374
+ "enableRTX": True,
375
+ "enableInstantVideo": False,
376
+ "enableDataStream2": False,
377
+ "enableAutFeedback": True,
378
+ "enableUserAutoRebalanceCheck": True,
379
+ "enableXR": True,
380
+ "enableLossbasedBwe": True,
381
+ "enableAutCC": True,
382
+ "enablePreallocPC": False,
383
+ "enablePubTWCC": False,
384
+ "enableSubTWCC": True,
385
+ "enablePubRTX": True,
386
+ "enableSubRTX": True,
387
+ }
388
+ },
389
+ "join_ts": int(time.time() * 1000),
390
+ "ortc": {
391
+ "iceParameters": {
392
+ "iceUfrag": sdp_info.ice_ufrag,
393
+ "icePwd": sdp_info.ice_pwd,
394
+ },
395
+ "dtlsParameters": {
396
+ "fingerprints": [
397
+ {
398
+ "hashFunction": "sha-256",
399
+ "fingerprint": sdp_info.fingerprint,
400
+ }
401
+ ]
402
+ },
403
+ "rtpCapabilities": {
404
+ "send": {
405
+ "audioCodecs": [],
406
+ "audioExtensions": [],
407
+ "videoCodecs": [],
408
+ "videoExtensions": [],
409
+ },
410
+ "recv": {
411
+ "audioCodecs": [],
412
+ "audioExtensions": [],
413
+ "videoCodecs": get_video_codecs_recv(),
414
+ "videoExtensions": [],
415
+ },
416
+ "sendrecv": {
417
+ "audioCodecs": get_audio_codecs(),
418
+ "audioExtensions": get_audio_extensions(),
419
+ "videoCodecs": get_video_codecs_sendrecv(),
420
+ "videoExtensions": get_video_extensions(),
421
+ },
422
+ },
423
+ "version": "2",
424
+ },
425
+ },
426
+ }
427
+
428
+ async def _send_set_client_role(self, role: str = "audience", level: int = 1) -> None:
429
+ """Send set_client_role message to Agora.
430
+
431
+ Args:
432
+ role: Client role - "audience" or "host"
433
+ level: Role level (default: 1)
434
+
435
+ """
436
+ if not self._websocket:
437
+ _LOGGER.error("Cannot send set_client_role: websocket not connected")
438
+ return
439
+
440
+ message_id = secrets.token_hex(3)
441
+ message = {
442
+ "_id": message_id,
443
+ "_type": "set_client_role",
444
+ "_message": {
445
+ "role": role,
446
+ "level": level,
447
+ "client_ts": int(time.time() * 1000),
448
+ },
449
+ }
450
+
451
+ _LOGGER.info("Sending set_client_role message: %s", message)
452
+ await self._websocket.send(json.dumps(message))
453
+
454
+ async def _send_subscribe(
455
+ self,
456
+ stream_id: int,
457
+ ssrc_id: int,
458
+ codec: str = "vp8",
459
+ stream_type: str = "video",
460
+ mode: str = "live",
461
+ p2p_id: int = 4,
462
+ twcc: bool = True,
463
+ rtx: bool = True,
464
+ extend: str = "",
465
+ ) -> None:
466
+ """Send subscribe message to Agora.
467
+
468
+ Args:
469
+ stream_id: Stream ID (usually the uid)
470
+ ssrc_id: SSRC ID from on_add_video_stream
471
+ codec: Video codec (default: "vp8")
472
+ stream_type: Stream type (default: "video")
473
+ mode: Mode (default: "live")
474
+ p2p_id: P2P ID (default: 4)
475
+ twcc: Enable transport-wide congestion control
476
+ rtx: Enable retransmission
477
+ extend: Extended info
478
+
479
+ """
480
+ if not self._websocket:
481
+ _LOGGER.error("Cannot send subscribe: websocket not connected")
482
+ return
483
+
484
+ message_id = secrets.token_hex(3)
485
+ message = {
486
+ "_id": message_id,
487
+ "_type": "subscribe",
488
+ "_message": {
489
+ "stream_id": stream_id,
490
+ "stream_type": stream_type,
491
+ "mode": mode,
492
+ "codec": codec,
493
+ "p2p_id": p2p_id,
494
+ "twcc": twcc,
495
+ "rtx": rtx,
496
+ "extend": extend,
497
+ "ssrcId": ssrc_id,
498
+ },
499
+ }
500
+
501
+ _LOGGER.info("Sending subscribe message: %s", message)
502
+ await self._websocket.send(json.dumps(message))
503
+
504
+ @staticmethod
505
+ def _add_candidates_to_sdp(sdp: str, candidates: list[RTCIceCandidateInit]) -> str:
506
+ """Add ICE candidates to SDP.
507
+
508
+ Args:
509
+ sdp: Original SDP string
510
+ candidates: List of ICE candidates (dict or string format)
511
+
512
+ Returns:
513
+ Modified SDP with candidates added
514
+
515
+ """
516
+ sdp_lines = sdp.split("\n")
517
+ result_lines = []
518
+ in_media_section = False
519
+
520
+ for i, line in enumerate(sdp_lines):
521
+ # Check if we're entering a media section
522
+ if line.startswith("m="):
523
+ in_media_section = True
524
+
525
+ # Check if we're at the end of a media section (next m= line or end of SDP)
526
+ next_is_media = i + 1 < len(sdp_lines) and sdp_lines[i + 1].startswith("m=")
527
+ is_last_line = i == len(sdp_lines) - 1
528
+
529
+ result_lines.append(line)
530
+
531
+ # Add candidates at the end of each media section
532
+ if in_media_section and (next_is_media or is_last_line):
533
+ for cand in candidates:
534
+ cand_str = cand.candidate
535
+
536
+ if not cand_str:
537
+ continue
538
+
539
+ # Normalize to proper format (ensure it starts with "a=candidate:")
540
+ if not cand_str.startswith("a="):
541
+ if cand_str.startswith("candidate:"):
542
+ cand_str = "a=" + cand_str
543
+ else:
544
+ cand_str = "a=candidate:" + cand_str
545
+
546
+ result_lines.append(cand_str)
547
+
548
+ return "\n".join(result_lines)
549
+
550
+ @staticmethod
551
+ def _parse_offer_sdp(offer_sdp: str) -> SdpInfo | None:
552
+ """Parse offer SDP to extract capabilities and parameters using sdp_transform."""
553
+ try:
554
+ # Parse SDP using sdp_transform
555
+ parsed_sdp = sdp_parse(offer_sdp)
556
+ _LOGGER.info("Parsed SDP structure: %s", parsed_sdp)
557
+
558
+ # Extract fingerprint
559
+ fingerprint = ""
560
+ if "fingerprint" in parsed_sdp:
561
+ fingerprint = parsed_sdp["fingerprint"]["hash"]
562
+ else:
563
+ # Check in media sections
564
+ for media in parsed_sdp.get("media", []):
565
+ if "fingerprint" in media:
566
+ fingerprint = media["fingerprint"]["hash"]
567
+ break
568
+
569
+ # Extract ICE parameters
570
+ ice_ufrag = parsed_sdp.get("iceUfrag", "")
571
+ ice_pwd = parsed_sdp.get("icePwd", "")
572
+
573
+ # Check in media sections if not found at top level
574
+ if not ice_ufrag or not ice_pwd:
575
+ for media in parsed_sdp.get("media", []):
576
+ if not ice_ufrag and "iceUfrag" in media:
577
+ ice_ufrag = media["iceUfrag"]
578
+ if not ice_pwd and "icePwd" in media:
579
+ ice_pwd = media["icePwd"]
580
+ if ice_ufrag and ice_pwd:
581
+ break
582
+
583
+ audio_codecs = []
584
+ video_codecs = []
585
+ audio_extensions = []
586
+ video_extensions = []
587
+
588
+ audio_direction = "sendrecv"
589
+ video_direction = "sendrecv"
590
+
591
+ # Process media sections
592
+ for media in parsed_sdp.get("media", []):
593
+ media_type = media.get("type")
594
+
595
+ # capture direction per media so answer generator can choose complementary dir
596
+ dir_val = media.get("direction", "sendrecv")
597
+
598
+ if media_type == "audio":
599
+ audio_direction = dir_val
600
+ elif media_type == "video":
601
+ video_direction = dir_val
602
+
603
+ # Process RTP codecs
604
+ for rtp in media.get("rtp", []):
605
+ codec_entry = {
606
+ "payloadType": rtp["payload"],
607
+ "rtpMap": {
608
+ "encodingName": rtp["codec"],
609
+ "clockRate": rtp["rate"],
610
+ },
611
+ "rtcpFeedbacks": [],
612
+ }
613
+
614
+ # Add encoding parameters if present
615
+ if "encoding" in rtp:
616
+ codec_entry["rtpMap"]["encodingParameters"] = rtp["encoding"]
617
+
618
+ # Find fmtp parameters for this payload type
619
+ fmtp_params = {}
620
+ for fmtp in media.get("fmtp", []):
621
+ if fmtp["payload"] == rtp["payload"]:
622
+ # Parse config string into parameters
623
+ config = fmtp.get("config", "")
624
+ if config:
625
+ params = {}
626
+ for param_pair in config.split(";"):
627
+ if "=" in param_pair:
628
+ key, value = param_pair.split("=", 1)
629
+ params[key.strip()] = value.strip()
630
+ else:
631
+ # Handle cases like "111/111" for RED codec
632
+ params[param_pair.strip()] = None
633
+ if params:
634
+ fmtp_params = params
635
+ break
636
+
637
+ # Add fmtp if found
638
+ if fmtp_params:
639
+ codec_entry["fmtp"] = {"parameters": fmtp_params}
640
+
641
+ # Process RTCP feedback from SDP
642
+ rtcp_feedbacks = []
643
+ for rtcp_fb in media.get("rtcpFb", []):
644
+ if rtcp_fb.get("payload") == rtp["payload"]:
645
+ feedback = {"type": rtcp_fb["type"]}
646
+ if "subtype" in rtcp_fb:
647
+ feedback["parameter"] = rtcp_fb["subtype"]
648
+ rtcp_feedbacks.append(feedback)
649
+
650
+ # Add default RTCP feedback based on media type and codec if none found
651
+ if not rtcp_feedbacks:
652
+ codec_name = rtp["codec"].upper()
653
+ if media_type == "video":
654
+ if codec_name in ["VP8", "VP9", "H264", "AV1"]:
655
+ rtcp_feedbacks = [
656
+ {"type": "goog-remb"},
657
+ {"type": "transport-cc"},
658
+ {"type": "ccm", "parameter": "fir"},
659
+ {"type": "nack"},
660
+ {"type": "nack", "parameter": "pli"},
661
+ {"type": "rrtr"},
662
+ ]
663
+ elif codec_name == "RTX":
664
+ rtcp_feedbacks = [{"type": "rrtr"}]
665
+ else:
666
+ rtcp_feedbacks = [{"type": "rrtr"}]
667
+ elif media_type == "audio":
668
+ rtcp_feedbacks = [{"type": "rrtr"}]
669
+ if codec_name == "OPUS":
670
+ rtcp_feedbacks.append({"type": "transport-cc"})
671
+
672
+ codec_entry["rtcpFeedbacks"] = rtcp_feedbacks
673
+
674
+ if media_type == "video":
675
+ video_codecs.append(codec_entry)
676
+ elif media_type == "audio":
677
+ audio_codecs.append(codec_entry)
678
+
679
+ # Process extensions
680
+ for ext in media.get("ext", []):
681
+ ext_entry = {
682
+ "entry": ext["value"],
683
+ "extensionName": ext["uri"],
684
+ }
685
+
686
+ # Map common extension names to match Agora's format
687
+ uri_mappings = {
688
+ "urn:ietf:params:rtp-hdrext:ssrc-audio-level": "urn:ietf:params:rtp-hdrext:ssrc-audio-level",
689
+ "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time": "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time",
690
+ "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01": "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01",
691
+ "urn:ietf:params:rtp-hdrext:sdes:mid": "urn:ietf:params:rtp-hdrext:sdes:mid",
692
+ "urn:ietf:params:rtp-hdrext:toffset": "urn:ietf:params:rtp-hdrext:toffset",
693
+ "urn:3gpp:video-orientation": "urn:3gpp:video-orientation",
694
+ "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay": "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay",
695
+ "http://www.webrtc.org/experiments/rtp-hdrext/video-content-type": "http://www.webrtc.org/experiments/rtp-hdrext/video-content-type",
696
+ "http://www.webrtc.org/experiments/rtp-hdrext/video-timing": "http://www.webrtc.org/experiments/rtp-hdrext/video-timing",
697
+ "http://www.webrtc.org/experiments/rtp-hdrext/color-space": "http://www.webrtc.org/experiments/rtp-hdrext/color-space",
698
+ "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id": "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id",
699
+ "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id": "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id",
700
+ }
701
+
702
+ # Use mapped URI if available
703
+ ext_entry["extensionName"] = uri_mappings.get(ext["uri"], ext["uri"])
704
+
705
+ if media_type == "audio":
706
+ audio_extensions.append(ext_entry)
707
+ elif media_type == "video":
708
+ video_extensions.append(ext_entry)
709
+
710
+ # Extract ICE candidates from SDP
711
+ ice_candidates = []
712
+ for media in parsed_sdp.get("media", []):
713
+ for candidate in media.get("candidates", []):
714
+ # Convert sdp_transform candidate format to ORTC format
715
+ ice_candidate = {
716
+ "foundation": candidate.get("foundation", ""),
717
+ "protocol": candidate.get("transport", "udp"),
718
+ "priority": candidate.get("priority", 0),
719
+ "ip": candidate.get("ip", ""),
720
+ "port": candidate.get("port", 0),
721
+ "type": candidate.get("type", "host"),
722
+ }
723
+ # Add optional fields if present
724
+ if "generation" in candidate:
725
+ ice_candidate["generation"] = candidate["generation"]
726
+ if "raddr" in candidate:
727
+ ice_candidate["relatedAddress"] = candidate["raddr"]
728
+ if "rport" in candidate:
729
+ ice_candidate["relatedPort"] = candidate["rport"]
730
+ if "tcptype" in candidate:
731
+ ice_candidate["tcpType"] = candidate["tcptype"]
732
+
733
+ ice_candidates.append(ice_candidate)
734
+
735
+ return SdpInfo(
736
+ parsed_sdp,
737
+ fingerprint=fingerprint,
738
+ ice_ufrag=ice_ufrag,
739
+ ice_pwd=ice_pwd,
740
+ audio_codecs=audio_codecs,
741
+ video_codecs=video_codecs,
742
+ audio_extensions=audio_extensions,
743
+ video_extensions=video_extensions,
744
+ audio_direction=audio_direction,
745
+ video_direction=video_direction,
746
+ ice_candidates=ice_candidates,
747
+ )
748
+
749
+ except (ValueError, IndexError, KeyError) as ex:
750
+ _LOGGER.error("Failed to parse offer SDP with sdp_transform: %s", ex)
751
+ return None
752
+
753
+ def _generate_answer_sdp(self, ortc: dict[str, Any], sdp_info: SdpInfo) -> str | None:
754
+ """Generate SDP answer from ORTC parameters."""
755
+ try:
756
+ from collections import defaultdict
757
+ import random
758
+ import secrets
759
+
760
+ ice_params = ortc.get("iceParameters", {})
761
+ dtls_params = ortc.get("dtlsParameters", {})
762
+ rtp_caps = ortc.get("rtpCapabilities", {}).get("sendrecv", {})
763
+
764
+ _LOGGER.debug("ICE params: %s", ice_params)
765
+ _LOGGER.debug("DTLS params: %s", dtls_params)
766
+ _LOGGER.debug("RTP caps: %s", rtp_caps)
767
+
768
+ # Extract ICE candidates and credentials from ORTC
769
+ ortc_candidates = ice_params.get("candidates", []) or []
770
+ ice_ufrag = ice_params.get("iceUfrag", "") or ""
771
+ ice_pwd = ice_params.get("icePwd", "") or ""
772
+
773
+ # fallback credentials
774
+ if not ice_ufrag:
775
+ ice_ufrag = secrets.token_hex(4)
776
+ _LOGGER.warning("Using fallback ICE ufrag: %s", ice_ufrag)
777
+ if not ice_pwd:
778
+ ice_pwd = secrets.token_hex(16)
779
+ _LOGGER.warning("Using fallback ICE pwd")
780
+
781
+ # Extract DTLS fingerprint
782
+ fingerprints = dtls_params.get("fingerprints", []) or []
783
+ fingerprint = ""
784
+ if fingerprints:
785
+ fp = fingerprints[0]
786
+ fingerprint = f"{fp.get('algorithm', 'sha-256')} {fp.get('fingerprint', '')}"
787
+ if not fingerprint:
788
+ fallback_fingerprint = ":".join([secrets.token_hex(1).upper() for _ in range(32)])
789
+ fingerprint = f"sha-256 {fallback_fingerprint}"
790
+ _LOGGER.warning("Using fallback fingerprint")
791
+
792
+ # Build candidate lines grouped by mid. '*' = generic (no mid)
793
+ candidates_by_mid = defaultdict(list)
794
+
795
+ # Add ORTC-provided candidates from server response
796
+ for i, c in enumerate(ortc_candidates):
797
+ foundation = c.get("foundation", f"candidate{i}")
798
+ protocol = c.get("protocol", "udp")
799
+ priority = c.get("priority", 2103266323)
800
+ ip = c.get("ip", "")
801
+ port = c.get("port", 0)
802
+ ctype = c.get("type", "host")
803
+ cand_line = f"a=candidate:{foundation} 1 {protocol} {priority} {ip} {port} typ {ctype}"
804
+ if c.get("generation") is not None:
805
+ cand_line += f" generation {c.get('generation')}"
806
+ candidates_by_mid["*"].append(cand_line)
807
+
808
+ # Build codec lists (minimal rtpmap entries as before)
809
+ video_codecs = rtp_caps.get("videoCodecs", []) or []
810
+ audio_codecs = rtp_caps.get("audioCodecs", []) or []
811
+
812
+ def answer_direction_for_offer(offer_dir: str) -> str:
813
+ if offer_dir == "sendonly":
814
+ return "recvonly"
815
+ if offer_dir == "recvonly":
816
+ return "sendonly"
817
+ if offer_dir == "sendrecv":
818
+ return "sendrecv"
819
+ return "inactive"
820
+
821
+ # Find payloads (keep first matching as original)
822
+ vp8_payload = None
823
+ for codec in video_codecs:
824
+ if codec.get("rtpMap", {}).get("encodingName", "").upper() == "VP8":
825
+ vp8_payload = codec.get("payloadType")
826
+ break
827
+
828
+ opus_payload = None
829
+ for codec in audio_codecs:
830
+ if codec.get("rtpMap", {}).get("encodingName", "").upper() == "OPUS":
831
+ opus_payload = codec.get("payloadType")
832
+ break
833
+
834
+ if opus_payload is None:
835
+ opus_payload = 111
836
+ _LOGGER.warning("Opus codec not found, using default %s", opus_payload)
837
+ if vp8_payload is None:
838
+ vp8_payload = 96
839
+ _LOGGER.warning("VP8 codec not found, using default %s", vp8_payload)
840
+
841
+ # Determine media order and directions from incoming offer
842
+ media = sdp_info.parsed_sdp.get("media", []) or []
843
+ # build base sdp header
844
+ sdp_lines = [
845
+ "v=0",
846
+ f"o=- {sdp_info.parsed_sdp['origin']['sessionId']} {sdp_info.parsed_sdp['origin']['sessionVersion']} IN IP4 127.0.0.1",
847
+ "s=-",
848
+ "t=0 0",
849
+ "a=group:BUNDLE 0 1",
850
+ "a=msid-semantic: WMS",
851
+ ]
852
+
853
+ # iterate media sections in offer order and emit corresponding answer media sections
854
+ for idx, m in enumerate(media):
855
+ mtype = m.get("type", "audio")
856
+ offer_dir = m.get("direction", "sendonly")
857
+ answer_dir = answer_direction_for_offer(offer_dir)
858
+ mid = str(m.get("mid", str(idx)))
859
+
860
+ # select payload for this media
861
+ if mtype == "audio":
862
+ payloads_str = str(opus_payload)
863
+ else:
864
+ payloads_str = str(vp8_payload)
865
+
866
+ # media header
867
+ sdp_lines.append(f"m={mtype} 9 UDP/TLS/RTP/SAVPF {payloads_str}")
868
+ sdp_lines.append("c=IN IP4 0.0.0.0")
869
+ sdp_lines.append("a=rtcp:9 IN IP4 0.0.0.0")
870
+ sdp_lines.append(f"a=ice-ufrag:{ice_ufrag}")
871
+ sdp_lines.append(f"a=ice-pwd:{ice_pwd}")
872
+ sdp_lines.append("a=ice-options:trickle")
873
+ sdp_lines.append(f"a=fingerprint:{fingerprint}")
874
+ sdp_lines.append("a=setup:active")
875
+ sdp_lines.append(f"a=mid:{mid}")
876
+ sdp_lines.append(f"a={answer_dir}")
877
+ sdp_lines.append("a=rtcp-mux")
878
+
879
+ # minimal rtpmap lines
880
+ if mtype == "audio":
881
+ sdp_lines.append(f"a=rtpmap:{opus_payload} opus/48000/2")
882
+ else:
883
+ sdp_lines.append(f"a=rtpmap:{vp8_payload} VP8/90000")
884
+
885
+ # send SSRC if answer sends
886
+ if "send" in answer_dir:
887
+ local_ssrc = random.randint(1, 2**32 - 1)
888
+ local_cname = ortc.get("cname") or f"pc-{secrets.token_hex(6)}"
889
+ sdp_lines.append(f"a=ssrc:{local_ssrc} cname:{local_cname}")
890
+
891
+ # append candidates for this media: specific mid then generic ones
892
+ # try exact mid key, also accept numeric mline index as key
893
+ specific = candidates_by_mid.get(mid, []) + candidates_by_mid.get(str(idx), [])
894
+ for cl in specific:
895
+ sdp_lines.append(cl)
896
+ for cl in candidates_by_mid.get("*", []):
897
+ sdp_lines.append(cl)
898
+
899
+ generated_sdp = "\r\n".join(sdp_lines) + "\r\n"
900
+ _LOGGER.info("Generated SDP lines count: %s", len(sdp_lines))
901
+ _LOGGER.debug("Generated SDP content: %s", generated_sdp)
902
+
903
+ if self._validate_sdp(generated_sdp):
904
+ return generated_sdp
905
+ _LOGGER.error("Generated SDP failed validation")
906
+ return None
907
+
908
+ except (KeyError, ValueError, AttributeError) as ex:
909
+ _LOGGER.error("Failed to generate answer SDP: %s", ex)
910
+ return None
911
+
912
+ def _validate_sdp(self, sdp: str) -> bool:
913
+ """Validate SDP format to ensure it's parseable by WebRTC."""
914
+ if not sdp or len(sdp.strip()) == 0:
915
+ _LOGGER.error("SDP is empty")
916
+ return False
917
+
918
+ lines = sdp.split("\r\n")
919
+ has_version = False
920
+ has_origin = False
921
+ has_session_name = False
922
+ has_timing = False
923
+ m_line_count = 0
924
+
925
+ for line in lines:
926
+ line = line.strip()
927
+ if not line:
928
+ continue
929
+
930
+ if line.startswith("v="):
931
+ has_version = True
932
+ elif line.startswith("o="):
933
+ has_origin = True
934
+ elif line.startswith("s="):
935
+ has_session_name = True
936
+ elif line.startswith("t="):
937
+ has_timing = True
938
+ elif line.startswith("m="):
939
+ m_line_count += 1
940
+
941
+ if not has_version:
942
+ _LOGGER.error("SDP missing version line (v=)")
943
+ return False
944
+ if not has_origin:
945
+ _LOGGER.error("SDP missing origin line (o=)")
946
+ return False
947
+ if not has_session_name:
948
+ _LOGGER.error("SDP missing session name line (s=)")
949
+ return False
950
+ if not has_timing:
951
+ _LOGGER.error("SDP missing timing line (t=)")
952
+ return False
953
+ if m_line_count < 2:
954
+ _LOGGER.error("SDP has %s m-lines, expected 2 (audio + video)", m_line_count)
955
+ return False
956
+
957
+ _LOGGER.debug("SDP validation passed: %s m-lines found", m_line_count)
958
+ return True
959
+
960
+ def _generate_fallback_sdp(self) -> str:
961
+ """Generate a basic fallback SDP answer."""
962
+ _LOGGER.info("Generating fallback SDP with default parameters")
963
+
964
+ # Generate basic parameters
965
+ ice_ufrag = secrets.token_hex(4)
966
+ ice_pwd = secrets.token_hex(16)
967
+ fallback_fingerprint = ":".join([secrets.token_hex(1).upper() for _ in range(32)])
968
+ fingerprint = f"sha-256 {fallback_fingerprint}"
969
+
970
+ # Default codec payload types
971
+ opus_payload = 109
972
+ vp8_payload = 120
973
+
974
+ # Build fallback SDP answer
975
+ sdp_lines = [
976
+ "v=0",
977
+ f"o=- {secrets.randbelow(2**63)} 2 IN IP4 127.0.0.1",
978
+ "s=-",
979
+ "t=0 0",
980
+ "a=group:BUNDLE 0 1",
981
+ "a=msid-semantic: WMS",
982
+ "",
983
+ # Audio m-line
984
+ f"m=audio 9 UDP/TLS/RTP/SAVPF {opus_payload}",
985
+ "c=IN IP4 0.0.0.0",
986
+ "a=rtcp:9 IN IP4 0.0.0.0",
987
+ f"a=ice-ufrag:{ice_ufrag}",
988
+ f"a=ice-pwd:{ice_pwd}",
989
+ "a=ice-options:trickle",
990
+ f"a=fingerprint:{fingerprint}",
991
+ "a=setup:active",
992
+ "a=mid:0",
993
+ "a=sendrecv",
994
+ "a=rtcp-mux",
995
+ f"a=rtpmap:{opus_payload} opus/48000/2",
996
+ "",
997
+ # Video m-line
998
+ f"m=video 9 UDP/TLS/RTP/SAVPF {vp8_payload}",
999
+ "c=IN IP4 0.0.0.0",
1000
+ "a=rtcp:9 IN IP4 0.0.0.0",
1001
+ f"a=ice-ufrag:{ice_ufrag}",
1002
+ f"a=ice-pwd:{ice_pwd}",
1003
+ "a=ice-options:trickle",
1004
+ f"a=fingerprint:{fingerprint}",
1005
+ "a=setup:active",
1006
+ "a=mid:1",
1007
+ "a=sendrecv",
1008
+ "a=rtcp-mux",
1009
+ f"a=rtpmap:{vp8_payload} VP8/90000",
1010
+ ]
1011
+
1012
+ generated_sdp = "\r\n".join(sdp_lines) + "\r\n"
1013
+ _LOGGER.debug("Generated fallback SDP: %s", generated_sdp)
1014
+
1015
+ # Validate fallback SDP
1016
+ if self._validate_sdp(generated_sdp):
1017
+ return generated_sdp
1018
+ _LOGGER.error("Fallback SDP failed validation")
1019
+ # Return a minimal valid SDP as last resort
1020
+ return self._generate_minimal_sdp()
1021
+
1022
+ def _generate_minimal_sdp(self) -> str:
1023
+ """Generate minimal valid SDP as last resort."""
1024
+ _LOGGER.warning("Generating minimal SDP as last resort")
1025
+
1026
+ ice_ufrag = secrets.token_hex(4)
1027
+ ice_pwd = secrets.token_hex(16)
1028
+
1029
+ minimal_sdp = (
1030
+ "v=0\r\n"
1031
+ f"o=- {secrets.randbelow(2**63)} 2 IN IP4 127.0.0.1\r\n"
1032
+ "s=-\r\n"
1033
+ "t=0 0\r\n"
1034
+ "a=group:BUNDLE 0 1\r\n"
1035
+ "a=msid-semantic: WMS\r\n"
1036
+ "m=audio 9 UDP/TLS/RTP/SAVPF 109\r\n"
1037
+ "c=IN IP4 0.0.0.0\r\n"
1038
+ f"a=ice-ufrag:{ice_ufrag}\r\n"
1039
+ f"a=ice-pwd:{ice_pwd}\r\n"
1040
+ "a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n"
1041
+ "a=setup:active\r\n"
1042
+ "a=mid:0\r\n"
1043
+ "a=sendrecv\r\n"
1044
+ "a=rtcp-mux\r\n"
1045
+ "a=rtpmap:109 opus/48000/2\r\n"
1046
+ "m=video 9 UDP/TLS/RTP/SAVPF 120\r\n"
1047
+ "c=IN IP4 0.0.0.0\r\n"
1048
+ f"a=ice-ufrag:{ice_ufrag}\r\n"
1049
+ f"a=ice-pwd:{ice_pwd}\r\n"
1050
+ "a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n"
1051
+ "a=setup:active\r\n"
1052
+ "a=mid:1\r\n"
1053
+ "a=sendrecv\r\n"
1054
+ "a=rtcp-mux\r\n"
1055
+ "a=rtpmap:120 VP8/90000\r\n"
1056
+ )
1057
+
1058
+ return minimal_sdp
1059
+
1060
+ async def _get_agora_edge_services(self, agora_data: StreamSubscriptionResponse) -> ResponseInfo | None:
1061
+ """Get Agora edge services information."""
1062
+ app_id = agora_data.appid
1063
+ channel_name = agora_data.channelName
1064
+ token = agora_data.token
1065
+ uid = int(agora_data.uid)
1066
+
1067
+ # Generate required IDs for the API call
1068
+ client_ts = int(time.time() * 1000)
1069
+ opid = secrets.randbelow(2**31)
1070
+ sid = secrets.token_hex(16).upper()
1071
+
1072
+ # Create the request payload
1073
+ request_payload = {
1074
+ "appid": app_id,
1075
+ "client_ts": client_ts,
1076
+ "opid": opid,
1077
+ "sid": sid,
1078
+ "request_bodies": [
1079
+ {
1080
+ "uri": 22,
1081
+ "buffer": {
1082
+ "cname": channel_name,
1083
+ "detail": {"11": "CN,GLOBAL", "17": "1", "22": "CN,GLOBAL"},
1084
+ "key": token,
1085
+ "service_ids": [11, 26],
1086
+ "uid": uid,
1087
+ },
1088
+ }
1089
+ ],
1090
+ }
1091
+
1092
+ # Create multipart form data using aiohttp.MultipartWriter
1093
+ writer = aiohttp.MultipartWriter("form-data")
1094
+ part = writer.append(json.dumps(request_payload))
1095
+ part.set_content_disposition("form-data", name="request")
1096
+
1097
+ headers = {
1098
+ "User-Agent": "Home Assistant WebRTC",
1099
+ }
1100
+
1101
+ api_url = "https://webrtc2-ap-web-1.agora.io/api/v2/transpond/webrtc?v=2"
1102
+
1103
+ try:
1104
+ async with (
1105
+ aiohttp.ClientSession() as session,
1106
+ session.post(
1107
+ api_url,
1108
+ data=writer,
1109
+ headers=headers,
1110
+ timeout=aiohttp.ClientTimeout(total=10),
1111
+ ) as response,
1112
+ ):
1113
+ if response.status != 200:
1114
+ _LOGGER.error("Agora API returned status %s", response.status)
1115
+ raise aiohttp.ClientError(f"API returned status {response.status}")
1116
+
1117
+ # Read response as JSON
1118
+ response_text = await response.text()
1119
+ _LOGGER.debug("Agora API raw response: %s", response_text)
1120
+
1121
+ response_data = json.loads(response_text)
1122
+ _LOGGER.debug("Agora API parsed response: %s", response_data)
1123
+
1124
+ # Extract edge services from response
1125
+ response_bodies = response_data.get("response_body", [])
1126
+ for body in reversed(response_bodies):
1127
+ buffer = body.get("buffer", {})
1128
+ if buffer and buffer.get("flag") == 4096:
1129
+ edges_services = buffer.get("edges_services", [])
1130
+ if edges_services:
1131
+ return ResponseInfo(
1132
+ code=buffer["code"],
1133
+ addresses=[
1134
+ AddressEntry(
1135
+ ip=es["ip"],
1136
+ port=es["port"],
1137
+ ticket=buffer["cert"],
1138
+ )
1139
+ for es in edges_services
1140
+ ],
1141
+ server_ts=response_data["enter_ts"],
1142
+ uid=buffer["uid"],
1143
+ cid=buffer["cid"],
1144
+ cname=buffer["cname"],
1145
+ detail={
1146
+ **buffer.get("detail", {}),
1147
+ **response_data.get("detail", {}),
1148
+ },
1149
+ flag=buffer["flag"],
1150
+ opid=response_data["opid"],
1151
+ cert=buffer["cert"],
1152
+ )
1153
+
1154
+ # Fallback if no edge services found
1155
+ _LOGGER.warning("No edge services found in Agora API response, using fallback")
1156
+ raise aiohttp.ClientError("No edge services available")
1157
+
1158
+ except (aiohttp.ClientError, json.JSONDecodeError) as ex:
1159
+ _LOGGER.error("Failed to get Agora edge services: %s", ex)
1160
+ return None
1161
+
1162
+ @property
1163
+ def is_connected(self) -> bool:
1164
+ """Return whether WebSocket is connected."""
1165
+ return self._connection_state == "CONNECTED"
1166
+
1167
+ async def disconnect(self) -> None:
1168
+ """Disconnect from WebSocket."""
1169
+ if self._websocket:
1170
+ await self._websocket.close()
1171
+ self._websocket = None
1172
+ self._connection_state = "DISCONNECTED"
1173
+
1174
+ def add_ice_candidate(self, candidate: RTCIceCandidateInit) -> None:
1175
+ self.candidates.append(candidate)