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,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())