pycloudedge 0.1.6.dev4__tar.gz → 0.1.8__tar.gz
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.
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/PKG-INFO +1 -1
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/__init__.py +13 -1
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/client.py +151 -15
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/constants.py +6 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/mqtt.py +28 -6
- pycloudedge-0.1.8/cloudedge/p2p/__init__.py +3 -0
- pycloudedge-0.1.8/cloudedge/p2p/kcp_tunnel.py +554 -0
- pycloudedge-0.1.8/cloudedge/p2p/meari_signaling.py +406 -0
- pycloudedge-0.1.8/cloudedge/p2p/p2p_streamer.py +1801 -0
- pycloudedge-0.1.8/cloudedge/p2p/root_discovery.py +230 -0
- pycloudedge-0.1.8/cloudedge/p2p/turn_client.py +548 -0
- pycloudedge-0.1.8/cloudedge/stream_profiles.py +209 -0
- pycloudedge-0.1.8/examples/stream_test.py +263 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/PKG-INFO +1 -1
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/SOURCES.txt +15 -1
- pycloudedge-0.1.8/pycloudedge.egg-info/scm_file_list.json +44 -0
- pycloudedge-0.1.8/pycloudedge.egg-info/scm_version.json +8 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/requirements.txt +2 -1
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/tests/test_basic.py +34 -38
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/tests/test_improvements.py +103 -0
- pycloudedge-0.1.8/tests/test_kcp_tunnel.py +127 -0
- pycloudedge-0.1.8/tests/test_mqtt.py +46 -0
- pycloudedge-0.1.8/tests/test_stream_profiles.py +178 -0
- pycloudedge-0.1.8/tests/test_streaming_helpers.py +901 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/.env.example +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/.gitignore +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/LICENSE +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/MANIFEST.in +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/README.md +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/image_decrypt.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/docs/plans/2026-03-30-streaming-video.md +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/README.md +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/device_control.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/requires.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pyproject.toml +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/requirements-dev.txt +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/setup.cfg +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/setup.py +0 -0
- {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/test_3region_login.py +0 -0
|
@@ -22,6 +22,13 @@ from .exceptions import (
|
|
|
22
22
|
ConfigurationError, NetworkError, ValidationError, RateLimitError
|
|
23
23
|
)
|
|
24
24
|
from .iot_parameters import IOT_PARAMETERS, get_parameter_name, format_parameter_value
|
|
25
|
+
from .stream_profiles import (
|
|
26
|
+
get_available_live_stream_ids,
|
|
27
|
+
get_live_stream_profiles,
|
|
28
|
+
select_default_live_stream_id,
|
|
29
|
+
select_live_stream_id,
|
|
30
|
+
supports_adaptive_live_stream,
|
|
31
|
+
)
|
|
25
32
|
|
|
26
33
|
try:
|
|
27
34
|
from ._version import version as __version__
|
|
@@ -50,4 +57,9 @@ __all__ = [
|
|
|
50
57
|
'decrypt_jpgx3',
|
|
51
58
|
'decrypt_jpgx3_from_url',
|
|
52
59
|
'verify_licence_for_url',
|
|
53
|
-
|
|
60
|
+
'get_available_live_stream_ids',
|
|
61
|
+
'get_live_stream_profiles',
|
|
62
|
+
'select_default_live_stream_id',
|
|
63
|
+
'select_live_stream_id',
|
|
64
|
+
'supports_adaptive_live_stream',
|
|
65
|
+
]
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.8'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 8)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g47e440bf5'
|
|
@@ -17,6 +17,7 @@ import subprocess
|
|
|
17
17
|
import socket
|
|
18
18
|
import ipaddress
|
|
19
19
|
import logging
|
|
20
|
+
import tempfile
|
|
20
21
|
from typing import Dict, List, Optional, Union, Any
|
|
21
22
|
from urllib.parse import quote, urlencode
|
|
22
23
|
|
|
@@ -37,10 +38,11 @@ from .iot_parameters import (
|
|
|
37
38
|
)
|
|
38
39
|
from .constants import (
|
|
39
40
|
CA_KEY, CA_SECRET, DEFAULT_HEADERS, DEFAULT_TIMEOUT,
|
|
40
|
-
REDIRECT_URL,
|
|
41
|
+
REDIRECT_URL, SESSION_INVALID_RESULT_CODES,
|
|
41
42
|
)
|
|
42
43
|
from .validators import validate_email, validate_country_code, validate_phone_code
|
|
43
44
|
from .logging_config import get_logger
|
|
45
|
+
from .stream_profiles import extract_stream_capabilities
|
|
44
46
|
from .utils import retry_on_failure
|
|
45
47
|
|
|
46
48
|
# Device online status values returned by get_device_online_status()
|
|
@@ -57,6 +59,28 @@ _DEVICE_LIST_CATEGORY_LABELS = {
|
|
|
57
59
|
"chime": "Chime",
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
# Extra app fields needed by the native P2P streaming handshake.
|
|
63
|
+
_APP_STREAMING_METADATA_FIELDS = {
|
|
64
|
+
"device_uuid": "deviceUUID",
|
|
65
|
+
"p2p_init": "p2pInit",
|
|
66
|
+
"device_p2p": "deviceP2P",
|
|
67
|
+
"relay_license_id": "relayLicenseID",
|
|
68
|
+
"host_key1": "hostKey1",
|
|
69
|
+
"share_access_sign": "shareAccessSign",
|
|
70
|
+
"as_friend": "asFriend",
|
|
71
|
+
"device_region": "region",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_app_streaming_metadata(device: Dict[str, Any]) -> Dict[str, Any]:
|
|
76
|
+
metadata: Dict[str, Any] = {}
|
|
77
|
+
for normalized_key, source_key in _APP_STREAMING_METADATA_FIELDS.items():
|
|
78
|
+
value = device.get(source_key)
|
|
79
|
+
if value not in (None, ""):
|
|
80
|
+
metadata[normalized_key] = value
|
|
81
|
+
metadata.update(extract_stream_capabilities(device))
|
|
82
|
+
return metadata
|
|
83
|
+
|
|
60
84
|
|
|
61
85
|
def _device_icon_url_from_type_name(device_type_name: Any) -> Optional[str]:
|
|
62
86
|
"""Return URL if deviceTypeName is an http(s) icon URL (Meari/OSS), else None."""
|
|
@@ -197,6 +221,61 @@ class CloudEdgeClient:
|
|
|
197
221
|
self._local_network = None
|
|
198
222
|
self._network_detected = False
|
|
199
223
|
|
|
224
|
+
def create_streamer(
|
|
225
|
+
self,
|
|
226
|
+
device: Dict[str, Any],
|
|
227
|
+
on_video=None,
|
|
228
|
+
on_audio=None,
|
|
229
|
+
on_login=None,
|
|
230
|
+
on_disconnect=None,
|
|
231
|
+
remote: bool = False,
|
|
232
|
+
video_id: Optional[int] = None,
|
|
233
|
+
manage_stream_switch: bool = True,
|
|
234
|
+
):
|
|
235
|
+
"""Create a native P2P streamer for a CloudEdge camera device."""
|
|
236
|
+
from .p2p.p2p_streamer import P2PStreamer
|
|
237
|
+
|
|
238
|
+
return P2PStreamer(
|
|
239
|
+
api=self,
|
|
240
|
+
device=device,
|
|
241
|
+
on_video=on_video,
|
|
242
|
+
on_audio=on_audio,
|
|
243
|
+
on_login=on_login,
|
|
244
|
+
on_disconnect=on_disconnect,
|
|
245
|
+
remote=remote,
|
|
246
|
+
video_id=video_id,
|
|
247
|
+
manage_stream_switch=manage_stream_switch,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def refresh_streaming_metadata(self, device: Dict[str, Any]) -> Dict[str, Any]:
|
|
251
|
+
"""Refresh account-specific identifiers used by the P2P handshake.
|
|
252
|
+
|
|
253
|
+
The same physical camera can have different device IDs and host keys
|
|
254
|
+
when it is accessed through accounts in different regions. Always use
|
|
255
|
+
the values returned by the currently authenticated account.
|
|
256
|
+
"""
|
|
257
|
+
serial_number = device.get("serial_number")
|
|
258
|
+
if not serial_number:
|
|
259
|
+
return device
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
current_devices = self.get_all_devices()
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
self._log(
|
|
265
|
+
f"Could not refresh streaming metadata for {serial_number}: {exc}"
|
|
266
|
+
)
|
|
267
|
+
return device
|
|
268
|
+
|
|
269
|
+
for current_device in current_devices:
|
|
270
|
+
if current_device.get("serial_number") == serial_number:
|
|
271
|
+
device.update(current_device)
|
|
272
|
+
self._log(
|
|
273
|
+
f"Refreshed account-specific streaming metadata for {serial_number}"
|
|
274
|
+
)
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
return device
|
|
278
|
+
|
|
200
279
|
def _detect_local_network(self) -> Optional[str]:
|
|
201
280
|
"""Detect the local network subnet."""
|
|
202
281
|
if self._network_detected:
|
|
@@ -387,6 +466,9 @@ class CloudEdgeClient:
|
|
|
387
466
|
@retry_on_failure(max_attempts=3, delay=1.0)
|
|
388
467
|
def _make_request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
389
468
|
"""Make HTTP request with retry logic and error handling."""
|
|
469
|
+
# Never issue a request without a timeout: a hung connection would
|
|
470
|
+
# otherwise block the caller (and its thread) forever.
|
|
471
|
+
kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
|
|
390
472
|
try:
|
|
391
473
|
response = self._session.request(method, url, **kwargs)
|
|
392
474
|
response.raise_for_status()
|
|
@@ -523,25 +605,46 @@ class CloudEdgeClient:
|
|
|
523
605
|
try:
|
|
524
606
|
with open(self.session_cache_file, 'r') as f:
|
|
525
607
|
session_data = json.load(f)
|
|
526
|
-
|
|
608
|
+
|
|
527
609
|
# Check if session is still valid (not older than 24 hours)
|
|
528
610
|
login_time = session_data.get('loginTime', 0)
|
|
529
611
|
if time.time() - login_time > 86400: # 24 hours
|
|
530
612
|
self._log("Session cache expired")
|
|
531
613
|
return None
|
|
532
|
-
|
|
614
|
+
|
|
533
615
|
return session_data
|
|
534
|
-
except (json.JSONDecodeError, KeyError):
|
|
535
|
-
self._log("Invalid session cache file")
|
|
616
|
+
except (json.JSONDecodeError, KeyError, OSError):
|
|
617
|
+
self._log("Invalid or unreadable session cache file")
|
|
536
618
|
return None
|
|
537
|
-
|
|
619
|
+
|
|
538
620
|
def _save_session_cache(self, session_data: Dict) -> None:
|
|
539
|
-
"""Save session data to cache file.
|
|
621
|
+
"""Save session data to cache file.
|
|
622
|
+
|
|
623
|
+
The cache contains the account session token, so it is written
|
|
624
|
+
owner-only (0600) and atomically (temp file + rename) so a crash
|
|
625
|
+
mid-write can never leave a truncated file behind.
|
|
626
|
+
"""
|
|
627
|
+
cache_dir = os.path.dirname(os.path.abspath(self.session_cache_file))
|
|
628
|
+
cache_name = os.path.basename(self.session_cache_file)
|
|
629
|
+
tmp_path: Optional[str] = None
|
|
540
630
|
try:
|
|
541
|
-
|
|
631
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
632
|
+
dir=cache_dir,
|
|
633
|
+
prefix=f".{cache_name}.",
|
|
634
|
+
suffix=".tmp",
|
|
635
|
+
)
|
|
636
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
542
637
|
json.dump(session_data, f)
|
|
638
|
+
f.flush()
|
|
639
|
+
os.fsync(f.fileno())
|
|
640
|
+
os.replace(tmp_path, self.session_cache_file)
|
|
543
641
|
except Exception as e:
|
|
544
642
|
self._log(f"Failed to save session cache: {e}")
|
|
643
|
+
if tmp_path is not None:
|
|
644
|
+
try:
|
|
645
|
+
os.remove(tmp_path)
|
|
646
|
+
except OSError:
|
|
647
|
+
pass
|
|
545
648
|
|
|
546
649
|
def _discover_endpoints(self) -> None:
|
|
547
650
|
"""Call the global redirect API to discover the correct regional server.
|
|
@@ -640,19 +743,25 @@ class CloudEdgeClient:
|
|
|
640
743
|
except Exception as exc:
|
|
641
744
|
raise NetworkError(f"Endpoint discovery failed: {exc}") from exc
|
|
642
745
|
|
|
643
|
-
def authenticate(self) -> bool:
|
|
746
|
+
def authenticate(self, force_refresh: bool = False) -> bool:
|
|
644
747
|
"""
|
|
645
748
|
Authenticate with CloudEdge API.
|
|
646
|
-
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
force_refresh: Skip the cached session and perform a fresh
|
|
752
|
+
login. Use after the server rejected the cached token
|
|
753
|
+
(e.g. another device logged into the account): the cache
|
|
754
|
+
file on disk still looks valid but the session is dead.
|
|
755
|
+
|
|
647
756
|
Returns:
|
|
648
757
|
bool: True if authentication successful, False otherwise
|
|
649
|
-
|
|
758
|
+
|
|
650
759
|
Raises:
|
|
651
760
|
AuthenticationError: If authentication fails
|
|
652
761
|
NetworkError: If network request fails
|
|
653
762
|
"""
|
|
654
763
|
# Try to load cached session first
|
|
655
|
-
self.session_data = self._load_session_cache()
|
|
764
|
+
self.session_data = None if force_refresh else self._load_session_cache()
|
|
656
765
|
if self.session_data:
|
|
657
766
|
self._log("Using cached session")
|
|
658
767
|
# Restore discovered endpoints from cache if present
|
|
@@ -1032,6 +1141,14 @@ class CloudEdgeClient:
|
|
|
1032
1141
|
error_msg = response_data.get('resultMsg', 'Unknown error')
|
|
1033
1142
|
error_code = response_data.get('resultCode', 'unknown')
|
|
1034
1143
|
self._log(f"API error - Code: {error_code}, Message: {error_msg}, Full response: {response_data}")
|
|
1144
|
+
if str(error_code) in SESSION_INVALID_RESULT_CODES:
|
|
1145
|
+
# The server rejected our token (e.g. the account was
|
|
1146
|
+
# logged in elsewhere — one active session per account).
|
|
1147
|
+
# Raise the typed auth error so callers re-authenticate
|
|
1148
|
+
# instead of retrying forever with a dead session.
|
|
1149
|
+
raise AuthenticationError(
|
|
1150
|
+
f"Session rejected by API (Code: {error_code}): {error_msg}"
|
|
1151
|
+
)
|
|
1035
1152
|
raise CloudEdgeError(
|
|
1036
1153
|
f"Failed to retrieve homes: {error_msg} (Code: {error_code})",
|
|
1037
1154
|
details={"error_code": error_code, "message": error_msg, "response": response_data}
|
|
@@ -1117,6 +1234,7 @@ class CloudEdgeClient:
|
|
|
1117
1234
|
'online': device.get('devStatus') == 1, # Store original API status
|
|
1118
1235
|
'home_id': home_id,
|
|
1119
1236
|
'device_icon_url': icon_url,
|
|
1237
|
+
**_extract_app_streaming_metadata(device),
|
|
1120
1238
|
}
|
|
1121
1239
|
|
|
1122
1240
|
# Get enhanced online status
|
|
@@ -1128,6 +1246,10 @@ class CloudEdgeClient:
|
|
|
1128
1246
|
else:
|
|
1129
1247
|
error_msg = response_data.get('resultMsg', 'Unknown error')
|
|
1130
1248
|
error_code = response_data.get('resultCode', 'unknown')
|
|
1249
|
+
if str(error_code) in SESSION_INVALID_RESULT_CODES:
|
|
1250
|
+
raise AuthenticationError(
|
|
1251
|
+
f"Session rejected by API (Code: {error_code}): {error_msg}"
|
|
1252
|
+
)
|
|
1131
1253
|
raise CloudEdgeError(
|
|
1132
1254
|
f"Failed to retrieve home devices: {error_msg}",
|
|
1133
1255
|
details={"error_code": error_code, "message": error_msg, "home_id": home_id}
|
|
@@ -1163,6 +1285,8 @@ class CloudEdgeClient:
|
|
|
1163
1285
|
self._log(f"Found {len(default_devices)} devices via default home API")
|
|
1164
1286
|
all_devices.extend(default_devices)
|
|
1165
1287
|
return all_devices
|
|
1288
|
+
except AuthenticationError:
|
|
1289
|
+
raise
|
|
1166
1290
|
except Exception as e:
|
|
1167
1291
|
self._log(f"Default home API failed: {e}, trying home-based approach...")
|
|
1168
1292
|
|
|
@@ -1180,11 +1304,17 @@ class CloudEdgeClient:
|
|
|
1180
1304
|
home_devices = self.get_devices_by_home(home_id)
|
|
1181
1305
|
self._log(f"Found {len(home_devices)} devices in home '{home_name}'")
|
|
1182
1306
|
all_devices.extend(home_devices)
|
|
1307
|
+
except AuthenticationError:
|
|
1308
|
+
raise
|
|
1183
1309
|
except Exception as e:
|
|
1184
1310
|
self._log(f"Failed to get devices from home '{home_name}': {e}")
|
|
1185
1311
|
|
|
1186
1312
|
return all_devices
|
|
1187
|
-
|
|
1313
|
+
|
|
1314
|
+
except AuthenticationError:
|
|
1315
|
+
# Must propagate as-is: callers re-authenticate on this type,
|
|
1316
|
+
# wrapping it in CloudEdgeError would hide the recovery signal.
|
|
1317
|
+
raise
|
|
1188
1318
|
except Exception as e:
|
|
1189
1319
|
raise CloudEdgeError(f"Failed to get devices from homes: {e}")
|
|
1190
1320
|
|
|
@@ -1238,7 +1368,6 @@ class CloudEdgeClient:
|
|
|
1238
1368
|
|
|
1239
1369
|
# Debug: Log the actual response structure
|
|
1240
1370
|
if self.debug:
|
|
1241
|
-
import json
|
|
1242
1371
|
self._log(f"API Response structure: {json.dumps(response_data, indent=2)}")
|
|
1243
1372
|
|
|
1244
1373
|
device_types = ['nvr', 'ipc', 'chime', 'doorbell', 'snap']
|
|
@@ -1263,6 +1392,7 @@ class CloudEdgeClient:
|
|
|
1263
1392
|
'host_key': device.get('hostKey'),
|
|
1264
1393
|
'online': device.get('onLine') == 1,
|
|
1265
1394
|
'device_icon_url': icon_url,
|
|
1395
|
+
**_extract_app_streaming_metadata(device),
|
|
1266
1396
|
}
|
|
1267
1397
|
device_dict['online'] = self._get_enhanced_device_status(device_dict)
|
|
1268
1398
|
standardized_devices.append(device_dict)
|
|
@@ -1285,6 +1415,7 @@ class CloudEdgeClient:
|
|
|
1285
1415
|
'host_key': device.get('hostKey'),
|
|
1286
1416
|
'online': device.get('onLine') == 1,
|
|
1287
1417
|
'device_icon_url': icon_url,
|
|
1418
|
+
**_extract_app_streaming_metadata(device),
|
|
1288
1419
|
}
|
|
1289
1420
|
device_dict['online'] = self._get_enhanced_device_status(device_dict)
|
|
1290
1421
|
standardized_devices.append(device_dict)
|
|
@@ -1293,6 +1424,10 @@ class CloudEdgeClient:
|
|
|
1293
1424
|
else:
|
|
1294
1425
|
error_msg = response_data.get('resultMsg', 'Unknown error')
|
|
1295
1426
|
error_code = response_data.get('resultCode', 'unknown')
|
|
1427
|
+
if str(error_code) in SESSION_INVALID_RESULT_CODES:
|
|
1428
|
+
raise AuthenticationError(
|
|
1429
|
+
f"Session rejected by API (Code: {error_code}): {error_msg}"
|
|
1430
|
+
)
|
|
1296
1431
|
raise CloudEdgeError(
|
|
1297
1432
|
f"Failed to retrieve devices: {error_msg}",
|
|
1298
1433
|
details={"error_code": error_code, "message": error_msg}
|
|
@@ -1809,6 +1944,7 @@ class CloudEdgeClient:
|
|
|
1809
1944
|
'signature': signature,
|
|
1810
1945
|
'action': 'set',
|
|
1811
1946
|
'deviceid': formatted_sn,
|
|
1947
|
+
'target': 'server',
|
|
1812
1948
|
'params': params_b64
|
|
1813
1949
|
}
|
|
1814
1950
|
|
|
@@ -1996,4 +2132,4 @@ class CloudEdgeClient:
|
|
|
1996
2132
|
'ping_timeout': self.ping_timeout,
|
|
1997
2133
|
'local_network': self._detect_local_network(),
|
|
1998
2134
|
'network_detected': self._network_detected
|
|
1999
|
-
}
|
|
2135
|
+
}
|
|
@@ -31,5 +31,11 @@ DEFAULT_LANGUAGE = "en"
|
|
|
31
31
|
DEFAULT_TIMEOUT = 30
|
|
32
32
|
PING_TIMEOUT = 2.0
|
|
33
33
|
|
|
34
|
+
# API result codes that mean the session token was rejected by the server
|
|
35
|
+
# (e.g. the account logged in elsewhere — one active session per account).
|
|
36
|
+
# 1023 observed empirically: valid-looking cached token -> homes API 1023;
|
|
37
|
+
# fresh login -> works.
|
|
38
|
+
SESSION_INVALID_RESULT_CODES = {"1023"}
|
|
39
|
+
|
|
34
40
|
# Cache settings
|
|
35
41
|
DEFAULT_CACHE_FILE = ".cloudedge_session_cache"
|
|
@@ -76,6 +76,9 @@ class CloudEdgeMqttListener:
|
|
|
76
76
|
on_motion: Callback invoked only for motion-related events.
|
|
77
77
|
on_connect: Called when the MQTT connection is established.
|
|
78
78
|
on_disconnect: Called when the MQTT connection drops.
|
|
79
|
+
verify_tls: Verify the broker's TLS certificate (default ``True``).
|
|
80
|
+
Only set ``False`` if your regional broker presents an
|
|
81
|
+
unverifiable certificate — this exposes the connection to MITM.
|
|
79
82
|
"""
|
|
80
83
|
|
|
81
84
|
def __init__(
|
|
@@ -86,6 +89,7 @@ class CloudEdgeMqttListener:
|
|
|
86
89
|
on_motion: Optional[OnEventCallback] = None,
|
|
87
90
|
on_connect: Optional[Callable[[], None]] = None,
|
|
88
91
|
on_disconnect: Optional[Callable[[], None]] = None,
|
|
92
|
+
verify_tls: bool = True,
|
|
89
93
|
) -> None:
|
|
90
94
|
mqtt_cfg = client.get_mqtt_config()
|
|
91
95
|
if not mqtt_cfg:
|
|
@@ -109,6 +113,7 @@ class CloudEdgeMqttListener:
|
|
|
109
113
|
|
|
110
114
|
self._client: Any = None
|
|
111
115
|
self._connected = False
|
|
116
|
+
self._verify_tls = verify_tls
|
|
112
117
|
|
|
113
118
|
@property
|
|
114
119
|
def connected(self) -> bool:
|
|
@@ -173,10 +178,7 @@ class CloudEdgeMqttListener:
|
|
|
173
178
|
|
|
174
179
|
client.username_pw_set(self._username, self._password)
|
|
175
180
|
|
|
176
|
-
|
|
177
|
-
ssl_ctx.check_hostname = False
|
|
178
|
-
ssl_ctx.verify_mode = ssl.CERT_NONE
|
|
179
|
-
client.tls_set_context(ssl_ctx)
|
|
181
|
+
client.tls_set_context(self._build_ssl_context())
|
|
180
182
|
|
|
181
183
|
client.on_connect = _on_connect
|
|
182
184
|
client.on_disconnect = _on_disconnect
|
|
@@ -194,6 +196,26 @@ class CloudEdgeMqttListener:
|
|
|
194
196
|
self._connected = False
|
|
195
197
|
return False
|
|
196
198
|
|
|
199
|
+
def _build_ssl_context(self) -> ssl.SSLContext:
|
|
200
|
+
"""Return the TLS context for the broker connection.
|
|
201
|
+
|
|
202
|
+
Certificate verification is ON by default: the broker credentials
|
|
203
|
+
travel over this connection and unverified TLS allows MITM. If a
|
|
204
|
+
regional broker really presents an unverifiable certificate,
|
|
205
|
+
construct the listener with ``verify_tls=False`` (a warning is
|
|
206
|
+
logged); there is no silent automatic downgrade.
|
|
207
|
+
"""
|
|
208
|
+
ctx = ssl.create_default_context()
|
|
209
|
+
if not self._verify_tls:
|
|
210
|
+
_LOGGER.warning(
|
|
211
|
+
"MQTT TLS certificate verification DISABLED for %s:%s — "
|
|
212
|
+
"connection is exposed to man-in-the-middle attacks",
|
|
213
|
+
self._host, self._port,
|
|
214
|
+
)
|
|
215
|
+
ctx.check_hostname = False
|
|
216
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
217
|
+
return ctx
|
|
218
|
+
|
|
197
219
|
def stop(self) -> None:
|
|
198
220
|
"""Stop the MQTT background loop and disconnect."""
|
|
199
221
|
if self._client:
|
|
@@ -212,7 +234,7 @@ class CloudEdgeMqttListener:
|
|
|
212
234
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
213
235
|
return
|
|
214
236
|
|
|
215
|
-
_LOGGER.
|
|
237
|
+
_LOGGER.debug("MQTT raw payload: %s", json.dumps(data, default=str)[:2000])
|
|
216
238
|
|
|
217
239
|
original = data
|
|
218
240
|
|
|
@@ -258,7 +280,7 @@ class CloudEdgeMqttListener:
|
|
|
258
280
|
if val:
|
|
259
281
|
extra[field] = str(val)
|
|
260
282
|
|
|
261
|
-
_LOGGER.
|
|
283
|
+
_LOGGER.debug(
|
|
262
284
|
"MQTT event: %s device=%s motion=%s url=%s",
|
|
263
285
|
evt_name, device_id, is_motion, bool(extra.get("url")),
|
|
264
286
|
)
|