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.
- pymammotion/__init__.py +53 -0
- pymammotion/agora/__init__.py +0 -0
- pymammotion/agora/agora_api.py +755 -0
- pymammotion/agora/agora_rtc_capabilities.py +748 -0
- pymammotion/agora/agora_websockets.py +1175 -0
- pymammotion/aliyun/__init__.py +1 -0
- pymammotion/aliyun/client.py +235 -0
- pymammotion/aliyun/cloud_gateway.py +982 -0
- pymammotion/aliyun/model/aep_response.py +21 -0
- pymammotion/aliyun/model/connect_response.py +51 -0
- pymammotion/aliyun/model/dev_by_account_response.py +195 -0
- pymammotion/aliyun/model/login_by_oauth_response.py +64 -0
- pymammotion/aliyun/model/regions_response.py +29 -0
- pymammotion/aliyun/model/session_by_authcode_response.py +19 -0
- pymammotion/aliyun/model/thing_response.py +12 -0
- pymammotion/aliyun/regions.py +62 -0
- pymammotion/aliyun/tea/core.py +297 -0
- pymammotion/aliyun/tmp_constant.py +171 -0
- pymammotion/bluetooth/__init__.py +1 -0
- pymammotion/bluetooth/ble.py +62 -0
- pymammotion/bluetooth/ble_message.py +676 -0
- pymammotion/bluetooth/const.py +27 -0
- pymammotion/bluetooth/data/__init__.py +0 -0
- pymammotion/bluetooth/data/convert.py +25 -0
- pymammotion/bluetooth/data/framectrldata.py +40 -0
- pymammotion/bluetooth/data/notifydata.py +62 -0
- pymammotion/bluetooth/model/__init__.py +0 -0
- pymammotion/bluetooth/model/atomic_integer.py +54 -0
- pymammotion/const.py +13 -0
- pymammotion/data/__init__.py +0 -0
- pymammotion/data/model/__init__.py +8 -0
- pymammotion/data/model/account.py +8 -0
- pymammotion/data/model/device.py +192 -0
- pymammotion/data/model/device_config.py +72 -0
- pymammotion/data/model/device_info.py +60 -0
- pymammotion/data/model/device_limits.py +49 -0
- pymammotion/data/model/enums.py +77 -0
- pymammotion/data/model/errors.py +12 -0
- pymammotion/data/model/events.py +14 -0
- pymammotion/data/model/generate_geojson.py +565 -0
- pymammotion/data/model/generate_route_information.py +26 -0
- pymammotion/data/model/hash_list.py +475 -0
- pymammotion/data/model/location.py +36 -0
- pymammotion/data/model/mowing_modes.py +77 -0
- pymammotion/data/model/rapid_state.py +45 -0
- pymammotion/data/model/raw_data.py +215 -0
- pymammotion/data/model/region_data.py +102 -0
- pymammotion/data/model/report_info.py +182 -0
- pymammotion/data/model/work.py +27 -0
- pymammotion/data/mower_state_manager.py +369 -0
- pymammotion/data/mqtt/__init__.py +1 -0
- pymammotion/data/mqtt/event.py +227 -0
- pymammotion/data/mqtt/mammotion_properties.py +276 -0
- pymammotion/data/mqtt/properties.py +203 -0
- pymammotion/data/mqtt/status.py +57 -0
- pymammotion/event/__init__.py +6 -0
- pymammotion/event/event.py +96 -0
- pymammotion/homeassistant/__init__.py +3 -0
- pymammotion/homeassistant/mower_api.py +514 -0
- pymammotion/homeassistant/rtk_api.py +54 -0
- pymammotion/http/__init__.py +0 -0
- pymammotion/http/encryption.py +220 -0
- pymammotion/http/http.py +673 -0
- pymammotion/http/model/__init__.py +0 -0
- pymammotion/http/model/camera_stream.py +31 -0
- pymammotion/http/model/http.py +249 -0
- pymammotion/http/model/response_factory.py +61 -0
- pymammotion/http/model/rtk.py +16 -0
- pymammotion/mammotion/__init__.py +0 -0
- pymammotion/mammotion/commands/__init__.py +0 -0
- pymammotion/mammotion/commands/abstract_message.py +24 -0
- pymammotion/mammotion/commands/mammotion_command.py +81 -0
- pymammotion/mammotion/commands/messages/__init__.py +0 -0
- pymammotion/mammotion/commands/messages/basestation.py +43 -0
- pymammotion/mammotion/commands/messages/driver.py +122 -0
- pymammotion/mammotion/commands/messages/media.py +87 -0
- pymammotion/mammotion/commands/messages/navigation.py +564 -0
- pymammotion/mammotion/commands/messages/network.py +205 -0
- pymammotion/mammotion/commands/messages/ota.py +38 -0
- pymammotion/mammotion/commands/messages/system.py +330 -0
- pymammotion/mammotion/commands/messages/video.py +33 -0
- pymammotion/mammotion/control/__init__.py +0 -0
- pymammotion/mammotion/control/joystick.py +145 -0
- pymammotion/mammotion/devices/__init__.py +29 -0
- pymammotion/mammotion/devices/base.py +163 -0
- pymammotion/mammotion/devices/mammotion.py +571 -0
- pymammotion/mammotion/devices/mammotion_bluetooth.py +496 -0
- pymammotion/mammotion/devices/mammotion_cloud.py +355 -0
- pymammotion/mammotion/devices/mammotion_mower_ble.py +48 -0
- pymammotion/mammotion/devices/mammotion_mower_cloud.py +39 -0
- pymammotion/mammotion/devices/managers/managers.py +81 -0
- pymammotion/mammotion/devices/mower_device.py +120 -0
- pymammotion/mammotion/devices/mower_manager.py +107 -0
- pymammotion/mammotion/devices/rtk_ble.py +89 -0
- pymammotion/mammotion/devices/rtk_cloud.py +115 -0
- pymammotion/mammotion/devices/rtk_device.py +50 -0
- pymammotion/mammotion/devices/rtk_manager.py +125 -0
- pymammotion/mqtt/__init__.py +6 -0
- pymammotion/mqtt/aliyun_mqtt.py +237 -0
- pymammotion/mqtt/linkkit/__init__.py +5 -0
- pymammotion/mqtt/linkkit/h2client.py +585 -0
- pymammotion/mqtt/linkkit/linkkit.py +3025 -0
- pymammotion/mqtt/mammotion_future.py +26 -0
- pymammotion/mqtt/mammotion_mqtt.py +214 -0
- pymammotion/mqtt/mqtt_models.py +66 -0
- pymammotion/proto/__init__.py +4841 -0
- pymammotion/proto/basestation.proto +51 -0
- pymammotion/proto/basestation_pb2.py +35 -0
- pymammotion/proto/basestation_pb2.pyi +89 -0
- pymammotion/proto/common.proto +7 -0
- pymammotion/proto/common_pb2.py +25 -0
- pymammotion/proto/common_pb2.pyi +13 -0
- pymammotion/proto/dev_net.proto +321 -0
- pymammotion/proto/dev_net_pb2.py +111 -0
- pymammotion/proto/dev_net_pb2.pyi +515 -0
- pymammotion/proto/luba_msg.proto +76 -0
- pymammotion/proto/luba_msg_pb2.py +41 -0
- pymammotion/proto/luba_msg_pb2.pyi +97 -0
- pymammotion/proto/luba_mul.proto +129 -0
- pymammotion/proto/luba_mul_pb2.py +61 -0
- pymammotion/proto/luba_mul_pb2.pyi +178 -0
- pymammotion/proto/mctrl_driver.proto +107 -0
- pymammotion/proto/mctrl_driver_pb2.py +57 -0
- pymammotion/proto/mctrl_driver_pb2.pyi +167 -0
- pymammotion/proto/mctrl_nav.proto +591 -0
- pymammotion/proto/mctrl_nav_pb2.py +136 -0
- pymammotion/proto/mctrl_nav_pb2.pyi +1067 -0
- pymammotion/proto/mctrl_ota.proto +80 -0
- pymammotion/proto/mctrl_ota_pb2.py +45 -0
- pymammotion/proto/mctrl_ota_pb2.pyi +128 -0
- pymammotion/proto/mctrl_pept.proto +34 -0
- pymammotion/proto/mctrl_pept_pb2.py +33 -0
- pymammotion/proto/mctrl_pept_pb2.pyi +58 -0
- pymammotion/proto/mctrl_sys.proto +741 -0
- pymammotion/proto/mctrl_sys_pb2.py +206 -0
- pymammotion/proto/mctrl_sys_pb2.pyi +1213 -0
- pymammotion/proto/message_pool.py +3 -0
- pymammotion/proto/py.typed +0 -0
- pymammotion/py.typed +0 -0
- pymammotion/utility/constant/__init__.py +3 -0
- pymammotion/utility/constant/device_constant.py +315 -0
- pymammotion/utility/conversions.py +5 -0
- pymammotion/utility/datatype_converter.py +124 -0
- pymammotion/utility/device_config.py +755 -0
- pymammotion/utility/device_type.py +489 -0
- pymammotion/utility/map.py +259 -0
- pymammotion/utility/movement.py +18 -0
- pymammotion/utility/mur_mur_hash.py +159 -0
- pymammotion/utility/periodic.py +106 -0
- pymammotion/utility/rocker_util.py +194 -0
- pymammotion-0.5.69.dist-info/METADATA +93 -0
- pymammotion-0.5.69.dist-info/RECORD +154 -0
- pymammotion-0.5.69.dist-info/WHEEL +4 -0
- 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)
|