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,755 @@
|
|
|
1
|
+
"""Agora WebRTC API client for server-to-client communication.
|
|
2
|
+
|
|
3
|
+
This module provides a client for interacting with the Agora WebRTC API endpoint
|
|
4
|
+
at `/api/v2/transpond/webrtc?v=2`. It handles request construction, API calls,
|
|
5
|
+
and response parsing to get edge server addresses and tickets for WebRTC connections.
|
|
6
|
+
|
|
7
|
+
The implementation is based on analysis of the Agora JavaScript SDK (agoraRTC_N.js)
|
|
8
|
+
to ensure compatibility and parity with client-side behavior.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from random import randint
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import aiohttp
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Optional, Type
|
|
21
|
+
|
|
22
|
+
# Service flags (from Agora SDK)
|
|
23
|
+
SERVICE_FLAGS = {
|
|
24
|
+
"CHOOSE_SERVER": 11,
|
|
25
|
+
"CLOUD_PROXY": 18,
|
|
26
|
+
"CLOUD_PROXY_5": 20,
|
|
27
|
+
"CLOUD_PROXY_FALLBACK": 26,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def derive_password(uid: int | str) -> str:
|
|
32
|
+
"""Derive TURN/STUN password using SHA-256 hash.
|
|
33
|
+
|
|
34
|
+
Python equivalent of the JavaScript Ww function from agoraRTC_N.js:
|
|
35
|
+
const Ww = async e => digest("SHA-256", jw(e)).hex()
|
|
36
|
+
|
|
37
|
+
This is used when ENCRYPT_PROXY_USERNAME_AND_PSW feature flag is enabled.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
uid: User ID (numeric or string)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Hexadecimal string representation of SHA-256 hash
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
uid_str = str(uid)
|
|
47
|
+
return hashlib.sha256(uid_str.encode("utf-8")).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class EdgeAddress:
|
|
52
|
+
"""Represents an edge server address."""
|
|
53
|
+
|
|
54
|
+
ip: str
|
|
55
|
+
port: int
|
|
56
|
+
username: str | None = None
|
|
57
|
+
credentials: str | None = None
|
|
58
|
+
ticket: str | None = None
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
"""Convert to dictionary."""
|
|
62
|
+
result = {"ip": self.ip, "port": self.port}
|
|
63
|
+
if self.ticket:
|
|
64
|
+
result["ticket"] = self.ticket
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ICEServer:
|
|
70
|
+
"""Represents an RTCIceServer configuration."""
|
|
71
|
+
|
|
72
|
+
urls: str
|
|
73
|
+
username: str | None = None
|
|
74
|
+
credential: str | None = None
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict:
|
|
77
|
+
"""Convert to dictionary for RTCPeerConnection."""
|
|
78
|
+
result = {"urls": self.urls}
|
|
79
|
+
if self.username:
|
|
80
|
+
result["username"] = self.username
|
|
81
|
+
if self.credential:
|
|
82
|
+
result["credential"] = self.credential
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class AgoraResponse:
|
|
88
|
+
"""Parsed response from Agora WebRTC API.
|
|
89
|
+
|
|
90
|
+
When requesting multiple service flags, contains separate entries for
|
|
91
|
+
each flag in the responses dict.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
code: int
|
|
95
|
+
addresses: list[EdgeAddress]
|
|
96
|
+
ticket: str
|
|
97
|
+
uid: int
|
|
98
|
+
cid: int
|
|
99
|
+
cname: str
|
|
100
|
+
server_ts: int
|
|
101
|
+
detail: dict
|
|
102
|
+
flag: int
|
|
103
|
+
opid: int
|
|
104
|
+
responses: dict = None # Multi-flag responses: {flag: response_dict}
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_api_response(cls, response_data: dict) -> "AgoraResponse":
|
|
108
|
+
"""Parse API response into AgoraResponse object.
|
|
109
|
+
|
|
110
|
+
Handles both single and multiple service flag responses.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
response_data: Raw response from `/api/v2/transpond/webrtc?v=2` endpoint
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Parsed AgoraResponse with extracted addresses and ticket
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
# Extract response body
|
|
120
|
+
response_body = response_data.get("response_body", [])
|
|
121
|
+
detail = response_data.get("detail", {})
|
|
122
|
+
if not response_body:
|
|
123
|
+
raise ValueError("No response_body in API response")
|
|
124
|
+
|
|
125
|
+
print(response_data)
|
|
126
|
+
# Parse all responses by flag
|
|
127
|
+
responses_by_flag = {}
|
|
128
|
+
first_buffer = None
|
|
129
|
+
|
|
130
|
+
for response_item in response_body:
|
|
131
|
+
buffer = response_item.get("buffer", {})
|
|
132
|
+
code = buffer.get("code", -1)
|
|
133
|
+
|
|
134
|
+
if code != 0:
|
|
135
|
+
raise Exception(f"Agora API returned error code: {code}")
|
|
136
|
+
|
|
137
|
+
flag = buffer.get("flag", 0)
|
|
138
|
+
ticket = buffer.get("cert", "")
|
|
139
|
+
edges_services = buffer.get("edges_services", [])
|
|
140
|
+
detail = {**detail, **buffer.get("detail", {})}
|
|
141
|
+
uid = buffer.get("uid", 0)
|
|
142
|
+
|
|
143
|
+
# Derive credentials from UID (matches JavaScript SDK behavior)
|
|
144
|
+
# When ENCRYPT_PROXY_USERNAME_AND_PSW feature flag is enabled:
|
|
145
|
+
# - username = uid as string
|
|
146
|
+
# - password = SHA-256(uid as string)
|
|
147
|
+
# Fallback to detail fields if uid not available
|
|
148
|
+
if uid:
|
|
149
|
+
username = str(uid)
|
|
150
|
+
credentials = derive_password(uid)
|
|
151
|
+
else:
|
|
152
|
+
# Fallback: use detail fields
|
|
153
|
+
username = detail.get("8", "")
|
|
154
|
+
credentials = detail.get("4", "")
|
|
155
|
+
|
|
156
|
+
addresses = [
|
|
157
|
+
EdgeAddress(
|
|
158
|
+
ip=edge["ip"],
|
|
159
|
+
port=edge["port"],
|
|
160
|
+
username=username,
|
|
161
|
+
credentials=credentials,
|
|
162
|
+
ticket=ticket,
|
|
163
|
+
)
|
|
164
|
+
for edge in edges_services
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
# Store all responses with complete data
|
|
168
|
+
responses_by_flag[flag] = {
|
|
169
|
+
"code": code,
|
|
170
|
+
"addresses": addresses,
|
|
171
|
+
"ticket": ticket,
|
|
172
|
+
"uid": buffer.get("uid", 0),
|
|
173
|
+
"cid": buffer.get("cid", 0),
|
|
174
|
+
"cname": buffer.get("cname", ""),
|
|
175
|
+
"detail": detail,
|
|
176
|
+
"flag": flag,
|
|
177
|
+
"edges_services": edges_services, # Preserve raw edges_services
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Use first response for primary fields
|
|
181
|
+
if first_buffer is None:
|
|
182
|
+
first_buffer = buffer
|
|
183
|
+
|
|
184
|
+
if first_buffer is None:
|
|
185
|
+
raise ValueError("No valid buffer in response_body")
|
|
186
|
+
|
|
187
|
+
# Get the first flag's response data (already parsed with addresses)
|
|
188
|
+
first_flag = first_buffer.get("flag", 0)
|
|
189
|
+
first_response = responses_by_flag.get(first_flag, {})
|
|
190
|
+
|
|
191
|
+
# Create response with primary fields from first buffer
|
|
192
|
+
return cls(
|
|
193
|
+
code=first_buffer.get("code", -1),
|
|
194
|
+
addresses=first_response.get("addresses", []), # Use already-created addresses
|
|
195
|
+
ticket=first_buffer.get("cert", ""),
|
|
196
|
+
uid=first_buffer.get("uid", 0),
|
|
197
|
+
cid=first_buffer.get("cid", 0),
|
|
198
|
+
cname=first_buffer.get("cname", ""),
|
|
199
|
+
server_ts=response_data.get("enter_ts", int(time.time() * 1000)),
|
|
200
|
+
detail=first_buffer.get("detail", {}),
|
|
201
|
+
flag=first_flag,
|
|
202
|
+
opid=response_data.get("opid", 0),
|
|
203
|
+
responses=responses_by_flag if len(responses_by_flag) > 1 else None,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def get_ice_servers(self, turn_port_udp: int = 3478, turn_port_tcp: int = 3433) -> list[ICEServer]:
|
|
207
|
+
"""Convert addresses to ICE server configuration.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
turn_port_udp: UDP TURN port (default: 3478)
|
|
211
|
+
turn_port_tcp: TCP TURN port (default: 3433)
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of ICEServer objects ready for RTCPeerConnection
|
|
215
|
+
|
|
216
|
+
"""
|
|
217
|
+
ice_servers = []
|
|
218
|
+
|
|
219
|
+
for addr in self.addresses:
|
|
220
|
+
# UDP TURN server
|
|
221
|
+
ice_servers.append(
|
|
222
|
+
ICEServer(
|
|
223
|
+
urls=f"turn:{addr.ip.replace('.', '-')}.edge.agora.io:{turn_port_udp}?transport=udp",
|
|
224
|
+
username=addr.username,
|
|
225
|
+
credential=addr.credentials,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# TCP TURN server
|
|
230
|
+
ice_servers.append(
|
|
231
|
+
ICEServer(
|
|
232
|
+
urls=f"turn:{addr.ip.replace('.', '-')}.edge.agora.io:{turn_port_tcp}?transport=tcp",
|
|
233
|
+
username=addr.username,
|
|
234
|
+
credential=addr.credentials,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# TLS/TURNS server
|
|
239
|
+
ice_servers.append(
|
|
240
|
+
ICEServer(
|
|
241
|
+
urls=f"turns:{addr.ip.replace('.', '-')}.edge.agora.io:443?transport=tcp",
|
|
242
|
+
username=addr.username,
|
|
243
|
+
credential=addr.credentials,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
ice_servers.append(
|
|
247
|
+
ICEServer(
|
|
248
|
+
urls=f"stun:{addr.ip.replace('.', '-')}.edge.agora.io:{turn_port_tcp}",
|
|
249
|
+
username=addr.username,
|
|
250
|
+
credential=addr.credentials,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return ice_servers
|
|
255
|
+
|
|
256
|
+
def get_responses_by_flag(self, flag: int) -> dict | None:
|
|
257
|
+
"""Get response data for a specific service flag.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
flag: Service flag (e.g., SERVICE_FLAGS["CHOOSE_SERVER"])
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Response data for that flag, or None if not available
|
|
264
|
+
|
|
265
|
+
"""
|
|
266
|
+
if not self.responses:
|
|
267
|
+
return None
|
|
268
|
+
return self.responses.get(flag)
|
|
269
|
+
|
|
270
|
+
def to_ap_response(self, flag: int | None = None) -> dict:
|
|
271
|
+
"""Format response data for websocket join_v3 ap_response field.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
flag: Specific service flag to format. If None, uses primary response.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Dictionary formatted for ap_response in join_v3 websocket call
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
# Get data for specific flag or use primary response
|
|
281
|
+
if flag is not None and self.responses:
|
|
282
|
+
response_data = self.responses.get(flag)
|
|
283
|
+
if not response_data:
|
|
284
|
+
raise ValueError(f"No response data for flag {flag}")
|
|
285
|
+
return {
|
|
286
|
+
"code": response_data["code"],
|
|
287
|
+
"server_ts": self.server_ts,
|
|
288
|
+
"uid": response_data["uid"],
|
|
289
|
+
"cid": response_data["cid"],
|
|
290
|
+
"cname": response_data["cname"],
|
|
291
|
+
"detail": response_data["detail"],
|
|
292
|
+
"flag": response_data["flag"],
|
|
293
|
+
"opid": self.opid,
|
|
294
|
+
"cert": response_data["ticket"],
|
|
295
|
+
"ticket": response_data["ticket"],
|
|
296
|
+
}
|
|
297
|
+
# Use primary response data
|
|
298
|
+
return {
|
|
299
|
+
"code": self.code,
|
|
300
|
+
"server_ts": self.server_ts,
|
|
301
|
+
"uid": self.uid,
|
|
302
|
+
"cid": self.cid,
|
|
303
|
+
"cname": self.cname,
|
|
304
|
+
"detail": self.detail,
|
|
305
|
+
"flag": self.flag,
|
|
306
|
+
"opid": self.opid,
|
|
307
|
+
"cert": self.ticket,
|
|
308
|
+
"ticket": self.ticket,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class AgoraAPIClient:
|
|
313
|
+
"""Client for Agora WebRTC API.
|
|
314
|
+
|
|
315
|
+
This client handles creating properly formatted requests to the Agora
|
|
316
|
+
WebRTC server discovery endpoint and parsing responses.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
# List of edge servers from Agora
|
|
320
|
+
WEBCS_DOMAIN = [
|
|
321
|
+
"webrtc2-ap-web-1.agora.io",
|
|
322
|
+
"webrtc2-ap-web-2.agora.io",
|
|
323
|
+
"webrtc2-ap-web-3.agora.io",
|
|
324
|
+
"webrtc2-ap-web-4.agora.io",
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
WEBCS_DOMAIN_BACKUP = [
|
|
328
|
+
"webrtc2-ap-web-5.agora.io",
|
|
329
|
+
"webrtc2-ap-web-6.agora.io",
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
def __init__(self, session: aiohttp.ClientSession | None = None) -> None:
|
|
333
|
+
"""Initialize Agora API client.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
session: Optional aiohttp ClientSession. If not provided, one will be
|
|
337
|
+
created for each request.
|
|
338
|
+
|
|
339
|
+
"""
|
|
340
|
+
self.session = session
|
|
341
|
+
self._own_session = session is None
|
|
342
|
+
|
|
343
|
+
async def __aenter__(self):
|
|
344
|
+
"""Context manager entry."""
|
|
345
|
+
return self
|
|
346
|
+
|
|
347
|
+
async def __aexit__(self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
|
|
348
|
+
"""Context manager exit - close session if we created it."""
|
|
349
|
+
if self._own_session and self.session:
|
|
350
|
+
await self.session.close()
|
|
351
|
+
|
|
352
|
+
async def choose_server(
|
|
353
|
+
self,
|
|
354
|
+
app_id: str,
|
|
355
|
+
token: str,
|
|
356
|
+
channel_name: str,
|
|
357
|
+
user_id: int,
|
|
358
|
+
string_uid: str | None = None,
|
|
359
|
+
role: int = 2,
|
|
360
|
+
area_code: str = "CN,GLOBAL",
|
|
361
|
+
service_flags: list[int] | None = None,
|
|
362
|
+
sid: str | None = None,
|
|
363
|
+
proxy_server: str | None = None,
|
|
364
|
+
) -> AgoraResponse:
|
|
365
|
+
"""Make a request to choose a server (URI 22).
|
|
366
|
+
|
|
367
|
+
This is the initial "choose server" request that returns gateway addresses.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
app_id: Agora application ID
|
|
371
|
+
token: Authentication token
|
|
372
|
+
channel_name: Channel name to join
|
|
373
|
+
user_id: Numeric user ID
|
|
374
|
+
string_uid: Optional string user ID (defaults to str(user_id))
|
|
375
|
+
role: User role - 1 for host, 2 for audience (default: 2)
|
|
376
|
+
area_code: Preferred area code (default: "CN,GLOBAL")
|
|
377
|
+
service_flags: List of service flags (default: [CHOOSE_SERVER])
|
|
378
|
+
sid: Session ID (generated if not provided)
|
|
379
|
+
proxy_server: Optional HTTP proxy server URL
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
AgoraResponse with edge server addresses and ticket
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
Exception: If API call fails or returns error code
|
|
386
|
+
|
|
387
|
+
"""
|
|
388
|
+
if string_uid is None:
|
|
389
|
+
string_uid = str(user_id)
|
|
390
|
+
|
|
391
|
+
if service_flags is None:
|
|
392
|
+
service_flags = [11, 26]
|
|
393
|
+
|
|
394
|
+
if sid is None:
|
|
395
|
+
sid = str(randint(0, 2**31 - 1))
|
|
396
|
+
|
|
397
|
+
# Build request payload
|
|
398
|
+
request_payload = self._build_request_payload(
|
|
399
|
+
app_id=app_id,
|
|
400
|
+
token=token,
|
|
401
|
+
channel_name=channel_name,
|
|
402
|
+
user_id=user_id,
|
|
403
|
+
string_uid=string_uid,
|
|
404
|
+
role=role,
|
|
405
|
+
area_code=area_code,
|
|
406
|
+
service_flags=service_flags,
|
|
407
|
+
sid=sid,
|
|
408
|
+
uri=22, # Choose server operation
|
|
409
|
+
)
|
|
410
|
+
print(request_payload)
|
|
411
|
+
# Make API call
|
|
412
|
+
response = await self._make_api_call(request_payload, proxy_server=proxy_server)
|
|
413
|
+
|
|
414
|
+
# Parse response
|
|
415
|
+
return AgoraResponse.from_api_response(response)
|
|
416
|
+
|
|
417
|
+
async def update_ticket(
|
|
418
|
+
self,
|
|
419
|
+
app_id: str,
|
|
420
|
+
token: str,
|
|
421
|
+
channel_name: str,
|
|
422
|
+
user_id: int,
|
|
423
|
+
string_uid: str | None = None,
|
|
424
|
+
edge_addresses: list[dict] | None = None,
|
|
425
|
+
sid: str | None = None,
|
|
426
|
+
service_flags: list[int] | None = None,
|
|
427
|
+
proxy_server: str | None = None,
|
|
428
|
+
) -> AgoraResponse:
|
|
429
|
+
"""Make a request to update ticket (URI 28).
|
|
430
|
+
|
|
431
|
+
This is a follow-up request after initial server selection to refresh
|
|
432
|
+
the connection ticket.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
app_id: Agora application ID
|
|
436
|
+
token: Authentication token
|
|
437
|
+
channel_name: Channel name
|
|
438
|
+
user_id: Numeric user ID
|
|
439
|
+
string_uid: Optional string user ID
|
|
440
|
+
edge_addresses: List of edge server addresses from previous response
|
|
441
|
+
sid: Session ID
|
|
442
|
+
service_flags: List of service flags
|
|
443
|
+
proxy_server: Optional HTTP proxy server URL
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
AgoraResponse with updated ticket
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
Exception: If API call fails or returns error code
|
|
450
|
+
|
|
451
|
+
"""
|
|
452
|
+
if string_uid is None:
|
|
453
|
+
string_uid = str(user_id)
|
|
454
|
+
|
|
455
|
+
if service_flags is None:
|
|
456
|
+
service_flags = [SERVICE_FLAGS["CHOOSE_SERVER"]]
|
|
457
|
+
|
|
458
|
+
if edge_addresses is None:
|
|
459
|
+
edge_addresses = []
|
|
460
|
+
|
|
461
|
+
# Build request payload
|
|
462
|
+
request_payload = self._build_request_payload(
|
|
463
|
+
app_id=app_id,
|
|
464
|
+
token=token,
|
|
465
|
+
channel_name=channel_name,
|
|
466
|
+
user_id=user_id,
|
|
467
|
+
string_uid=string_uid,
|
|
468
|
+
edge_addresses=edge_addresses,
|
|
469
|
+
service_flags=service_flags,
|
|
470
|
+
sid=sid,
|
|
471
|
+
uri=28, # Ticket update operation
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
print(request_payload)
|
|
475
|
+
|
|
476
|
+
# Make API call
|
|
477
|
+
response = await self._make_api_call(request_payload, proxy_server=proxy_server)
|
|
478
|
+
|
|
479
|
+
# Parse response
|
|
480
|
+
return AgoraResponse.from_api_response(response)
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def merge_objects(*objects):
|
|
484
|
+
"""Merge multiple dictionaries, filtering out None values.
|
|
485
|
+
|
|
486
|
+
Python equivalent of the JavaScript mF function used in Agora SDK.
|
|
487
|
+
Merges objects left to right, skipping None/undefined values.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
*objects: Variable number of dictionaries to merge
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
Merged dictionary with None values filtered out
|
|
494
|
+
|
|
495
|
+
"""
|
|
496
|
+
result = {}
|
|
497
|
+
for obj in objects:
|
|
498
|
+
if obj is not None:
|
|
499
|
+
# Merge object, filtering out None values (equivalent to undefined in JS)
|
|
500
|
+
for key, value in obj.items():
|
|
501
|
+
if value is not None:
|
|
502
|
+
result[key] = value
|
|
503
|
+
return result
|
|
504
|
+
|
|
505
|
+
def _build_request_payload(
|
|
506
|
+
self,
|
|
507
|
+
app_id: str,
|
|
508
|
+
token: str,
|
|
509
|
+
channel_name: str,
|
|
510
|
+
user_id: int,
|
|
511
|
+
string_uid: str,
|
|
512
|
+
service_flags: list[int],
|
|
513
|
+
sid: str,
|
|
514
|
+
uri: int,
|
|
515
|
+
role: int = 2,
|
|
516
|
+
area_code: str = "CN,GLOBAL",
|
|
517
|
+
edge_addresses: list[dict] | None = None,
|
|
518
|
+
) -> dict:
|
|
519
|
+
"""Build the request payload for Agora API.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
app_id: Application ID
|
|
523
|
+
token: Auth token
|
|
524
|
+
channel_name: Channel name
|
|
525
|
+
user_id: Numeric user ID
|
|
526
|
+
string_uid: String user ID
|
|
527
|
+
service_flags: Service flags
|
|
528
|
+
sid: Session ID
|
|
529
|
+
uri: Operation URI (22 for choose server, 28 for update ticket)
|
|
530
|
+
role: User role (default: 2 for audience)
|
|
531
|
+
area_code: Area code (default: CN,GLOBAL)
|
|
532
|
+
edge_addresses: Edge addresses for ticket update
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
Properly formatted request payload
|
|
536
|
+
|
|
537
|
+
"""
|
|
538
|
+
client_ts = int(time.time() * 1000)
|
|
539
|
+
opid = randint(0, 10**12 - 1)
|
|
540
|
+
ap_rtm = None
|
|
541
|
+
# Build detail field - matches JavaScript SDK pattern
|
|
542
|
+
# mF(mF(mF({ 6: stringUid, 11: t, 12: USE_NEW_TOKEN ? "1" : undefined },
|
|
543
|
+
# r ? { 17: r } : {}), {}, { 22: t }, ...)
|
|
544
|
+
# if use new token add "12": "1"
|
|
545
|
+
# "6": string_uid,
|
|
546
|
+
detail = self.merge_objects(
|
|
547
|
+
{"11": area_code},
|
|
548
|
+
{"17": str(role)} if role else {},
|
|
549
|
+
{"22": area_code},
|
|
550
|
+
{"26": "RTM2"} if ap_rtm else {},
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
print(detail)
|
|
554
|
+
# Build buffer
|
|
555
|
+
buffer = {
|
|
556
|
+
"cname": channel_name,
|
|
557
|
+
"detail": detail,
|
|
558
|
+
"key": token,
|
|
559
|
+
"service_ids": service_flags,
|
|
560
|
+
"uid": user_id,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
# For ticket update, include edge services
|
|
564
|
+
if edge_addresses:
|
|
565
|
+
buffer["edges_services"] = edge_addresses
|
|
566
|
+
|
|
567
|
+
request_payload = {
|
|
568
|
+
"appid": app_id,
|
|
569
|
+
"client_ts": client_ts,
|
|
570
|
+
"opid": opid,
|
|
571
|
+
"sid": sid,
|
|
572
|
+
"request_bodies": [
|
|
573
|
+
{
|
|
574
|
+
"uri": uri,
|
|
575
|
+
"buffer": buffer,
|
|
576
|
+
}
|
|
577
|
+
],
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return request_payload
|
|
581
|
+
|
|
582
|
+
async def _make_api_call(self, request_payload: dict, proxy_server: str | None = None) -> dict:
|
|
583
|
+
"""Make the actual HTTP API call to Agora endpoint.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
request_payload: Request payload dictionary
|
|
587
|
+
proxy_server: Optional HTTP proxy URL
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
Parsed JSON response from API
|
|
591
|
+
|
|
592
|
+
Raises:
|
|
593
|
+
Exception: If request fails or response is invalid
|
|
594
|
+
|
|
595
|
+
"""
|
|
596
|
+
session = self.session
|
|
597
|
+
should_close = False
|
|
598
|
+
|
|
599
|
+
if session is None:
|
|
600
|
+
session = aiohttp.ClientSession()
|
|
601
|
+
should_close = True
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
# Try primary servers
|
|
605
|
+
for domain in self.WEBCS_DOMAIN:
|
|
606
|
+
try:
|
|
607
|
+
response = await self._call_endpoint(session, domain, request_payload, proxy_server)
|
|
608
|
+
return response
|
|
609
|
+
except (TimeoutError, aiohttp.ClientError, Exception):
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
# Fall back to backup servers
|
|
613
|
+
for domain in self.WEBCS_DOMAIN_BACKUP:
|
|
614
|
+
try:
|
|
615
|
+
response = await self._call_endpoint(session, domain, request_payload, proxy_server)
|
|
616
|
+
return response
|
|
617
|
+
except (TimeoutError, aiohttp.ClientError, Exception):
|
|
618
|
+
continue
|
|
619
|
+
|
|
620
|
+
raise Exception("All Agora API servers failed to respond")
|
|
621
|
+
|
|
622
|
+
finally:
|
|
623
|
+
if should_close:
|
|
624
|
+
await session.close()
|
|
625
|
+
|
|
626
|
+
async def _call_endpoint(
|
|
627
|
+
self,
|
|
628
|
+
session: aiohttp.ClientSession,
|
|
629
|
+
domain: str,
|
|
630
|
+
request_payload: dict,
|
|
631
|
+
proxy_server: str | None = None,
|
|
632
|
+
) -> dict:
|
|
633
|
+
"""Call a single Agora endpoint.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
session: aiohttp session
|
|
637
|
+
domain: Server domain
|
|
638
|
+
request_payload: Request payload
|
|
639
|
+
proxy_server: Optional proxy URL
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
Parsed JSON response
|
|
643
|
+
|
|
644
|
+
"""
|
|
645
|
+
url = f"https://{domain}/api/v2/transpond/webrtc?v=2"
|
|
646
|
+
|
|
647
|
+
if proxy_server:
|
|
648
|
+
url = f"https://{proxy_server}/ap/?url={domain}/api/v2/transpond/webrtc?v=2"
|
|
649
|
+
|
|
650
|
+
# Create FormData with JSON payload
|
|
651
|
+
form_data = aiohttp.FormData()
|
|
652
|
+
form_data.add_field("request", json.dumps(request_payload), content_type="application/json")
|
|
653
|
+
|
|
654
|
+
async with session.post(
|
|
655
|
+
url,
|
|
656
|
+
data=form_data,
|
|
657
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
658
|
+
ssl=False, # Note: In production, verify SSL certificates
|
|
659
|
+
) as resp:
|
|
660
|
+
if resp.status != 200:
|
|
661
|
+
raise Exception(f"HTTP {resp.status}: {await resp.text()}")
|
|
662
|
+
|
|
663
|
+
response_data = await resp.json()
|
|
664
|
+
return response_data
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
async def main() -> None:
|
|
668
|
+
"""Example usage of the Agora API client."""
|
|
669
|
+
# Example parameters
|
|
670
|
+
# mammotion_http = MammotionHTTP(EMAIL, PASSWORD)
|
|
671
|
+
# mammotion_http.login_info = LoginResponseData.from_dict(json.loads('LOGIN_RESPONSE_DATA'))
|
|
672
|
+
# mammotion_http.expires_in = 2591999 + time.time()
|
|
673
|
+
# stream = await mammotion_http.get_stream_subscription("CHANNEL_NAME/IOT_ID")
|
|
674
|
+
# print(stream)
|
|
675
|
+
|
|
676
|
+
channel_name = "CHANNEL_NAME"
|
|
677
|
+
# user_id = stream.data.uid
|
|
678
|
+
# app_id = stream.data.appid
|
|
679
|
+
# token = stream.data.token
|
|
680
|
+
user_id = 1223456
|
|
681
|
+
app_id = "APP_ID"
|
|
682
|
+
token = "TOKEN"
|
|
683
|
+
|
|
684
|
+
string_uid = "client_21231"
|
|
685
|
+
|
|
686
|
+
# Make request
|
|
687
|
+
async with AgoraAPIClient() as client:
|
|
688
|
+
try:
|
|
689
|
+
print("=== Example 1: Get media gateway only ===")
|
|
690
|
+
# Choose server - media gateway only
|
|
691
|
+
response = await client.choose_server(
|
|
692
|
+
app_id=app_id,
|
|
693
|
+
token=token,
|
|
694
|
+
channel_name=channel_name,
|
|
695
|
+
user_id=user_id,
|
|
696
|
+
string_uid=string_uid,
|
|
697
|
+
role=2, # 2 = audience
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
print(f"Successfully got {len(response.addresses)} edge addresses:")
|
|
701
|
+
for addr in response.addresses:
|
|
702
|
+
print(f" - {addr.ip.replace('.', '-')}.edge.agora.io:{addr.port}")
|
|
703
|
+
print(f"Ticket: {response.ticket[:50]}...")
|
|
704
|
+
|
|
705
|
+
print("\n=== Example 2: Get both media gateway AND TURN servers ===")
|
|
706
|
+
# Request with both service flags to get TURN servers
|
|
707
|
+
response = await client.choose_server(
|
|
708
|
+
app_id=app_id,
|
|
709
|
+
token=token,
|
|
710
|
+
channel_name=channel_name,
|
|
711
|
+
user_id=user_id,
|
|
712
|
+
string_uid=string_uid,
|
|
713
|
+
role=2,
|
|
714
|
+
service_flags=[
|
|
715
|
+
SERVICE_FLAGS["CHOOSE_SERVER"], # Media gateway
|
|
716
|
+
SERVICE_FLAGS["CLOUD_PROXY"], # TURN servers
|
|
717
|
+
],
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Get separate responses by flag
|
|
721
|
+
gateway_resp = response.get_responses_by_flag(SERVICE_FLAGS["CHOOSE_SERVER"])
|
|
722
|
+
turn_resp = response.get_responses_by_flag(SERVICE_FLAGS["CLOUD_PROXY"])
|
|
723
|
+
|
|
724
|
+
if gateway_resp:
|
|
725
|
+
print(f"\nGateway addresses: {len(gateway_resp['addresses'])}")
|
|
726
|
+
for addr in gateway_resp["addresses"]:
|
|
727
|
+
print(f" - {addr.ip.replace('.', '-')}.edge.agora.io:{addr.port}")
|
|
728
|
+
|
|
729
|
+
if turn_resp:
|
|
730
|
+
print(f"\nTURN addresses: {len(turn_resp['addresses'])}")
|
|
731
|
+
for addr in turn_resp["addresses"]:
|
|
732
|
+
print(f" - {addr.ip.replace('.', '-')}.edge.agora.io:{addr.port}")
|
|
733
|
+
|
|
734
|
+
# Get ICE servers (TURN) configuration
|
|
735
|
+
ice_servers = response.get_ice_servers()
|
|
736
|
+
print(f"\nICE Servers configuration ({len(ice_servers)} entries):")
|
|
737
|
+
for server in ice_servers[:3]: # Show first 3
|
|
738
|
+
print(f" - {server.urls}")
|
|
739
|
+
print(f" username: {server.username}")
|
|
740
|
+
print(f" credential: {server.credential}")
|
|
741
|
+
|
|
742
|
+
# Format for WebRTC RTCPeerConnection
|
|
743
|
+
rtc_config = {"iceServers": [server.to_dict() for server in ice_servers]}
|
|
744
|
+
print("\nRTC Configuration ready for RTCPeerConnection")
|
|
745
|
+
print(f"Total ICE servers: {len(rtc_config['iceServers'])}")
|
|
746
|
+
|
|
747
|
+
except Exception as e:
|
|
748
|
+
print(f"Error: {e}")
|
|
749
|
+
import traceback
|
|
750
|
+
|
|
751
|
+
traceback.print_exc()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
if __name__ == "__main__":
|
|
755
|
+
asyncio.run(main())
|