pycloudedge 0.1.7__tar.gz → 0.1.9__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.7 → pycloudedge-0.1.9}/PKG-INFO +1 -1
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/__init__.py +13 -1
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/client.py +116 -33
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/constants.py +6 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/mqtt.py +28 -6
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/kcp_tunnel.py +167 -113
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/p2p_streamer.py +344 -183
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/turn_client.py +86 -21
- pycloudedge-0.1.9/cloudedge/stream_profiles.py +209 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/PKG-INFO +1 -1
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/SOURCES.txt +5 -6
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/requirements.txt +2 -1
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/tests/test_basic.py +34 -38
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/tests/test_improvements.py +103 -0
- pycloudedge-0.1.9/tests/test_kcp_tunnel.py +127 -0
- pycloudedge-0.1.9/tests/test_mqtt.py +46 -0
- pycloudedge-0.1.9/tests/test_reliability.py +94 -0
- pycloudedge-0.1.9/tests/test_stream_profiles.py +178 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/tests/test_streaming_helpers.py +416 -3
- pycloudedge-0.1.7/.gitignore +0 -124
- pycloudedge-0.1.7/docs/plans/2026-03-30-streaming-video.md +0 -176
- pycloudedge-0.1.7/pycloudedge.egg-info/scm_file_list.json +0 -40
- pycloudedge-0.1.7/pycloudedge.egg-info/scm_version.json +0 -8
- pycloudedge-0.1.7/requirements-dev.txt +0 -11
- pycloudedge-0.1.7/test_3region_login.py +0 -58
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/.env.example +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/LICENSE +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/MANIFEST.in +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/README.md +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/image_decrypt.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/__init__.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/meari_signaling.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/root_discovery.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/README.md +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/device_control.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/stream_test.py +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/requires.txt +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pyproject.toml +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/setup.cfg +0 -0
- {pycloudedge-0.1.7 → pycloudedge-0.1.9}/setup.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.9'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 9)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id =
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -17,6 +17,8 @@ import subprocess
|
|
|
17
17
|
import socket
|
|
18
18
|
import ipaddress
|
|
19
19
|
import logging
|
|
20
|
+
import math
|
|
21
|
+
import tempfile
|
|
20
22
|
from typing import Dict, List, Optional, Union, Any
|
|
21
23
|
from urllib.parse import quote, urlencode
|
|
22
24
|
|
|
@@ -37,10 +39,11 @@ from .iot_parameters import (
|
|
|
37
39
|
)
|
|
38
40
|
from .constants import (
|
|
39
41
|
CA_KEY, CA_SECRET, DEFAULT_HEADERS, DEFAULT_TIMEOUT,
|
|
40
|
-
REDIRECT_URL,
|
|
42
|
+
REDIRECT_URL, SESSION_INVALID_RESULT_CODES,
|
|
41
43
|
)
|
|
42
44
|
from .validators import validate_email, validate_country_code, validate_phone_code
|
|
43
45
|
from .logging_config import get_logger
|
|
46
|
+
from .stream_profiles import extract_stream_capabilities
|
|
44
47
|
from .utils import retry_on_failure
|
|
45
48
|
|
|
46
49
|
# Device online status values returned by get_device_online_status()
|
|
@@ -76,6 +79,7 @@ def _extract_app_streaming_metadata(device: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
76
79
|
value = device.get(source_key)
|
|
77
80
|
if value not in (None, ""):
|
|
78
81
|
metadata[normalized_key] = value
|
|
82
|
+
metadata.update(extract_stream_capabilities(device))
|
|
79
83
|
return metadata
|
|
80
84
|
|
|
81
85
|
|
|
@@ -226,7 +230,7 @@ class CloudEdgeClient:
|
|
|
226
230
|
on_login=None,
|
|
227
231
|
on_disconnect=None,
|
|
228
232
|
remote: bool = False,
|
|
229
|
-
video_id: int =
|
|
233
|
+
video_id: Optional[int] = None,
|
|
230
234
|
manage_stream_switch: bool = True,
|
|
231
235
|
):
|
|
232
236
|
"""Create a native P2P streamer for a CloudEdge camera device."""
|
|
@@ -463,6 +467,9 @@ class CloudEdgeClient:
|
|
|
463
467
|
@retry_on_failure(max_attempts=3, delay=1.0)
|
|
464
468
|
def _make_request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
465
469
|
"""Make HTTP request with retry logic and error handling."""
|
|
470
|
+
# Never issue a request without a timeout: a hung connection would
|
|
471
|
+
# otherwise block the caller (and its thread) forever.
|
|
472
|
+
kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
|
|
466
473
|
try:
|
|
467
474
|
response = self._session.request(method, url, **kwargs)
|
|
468
475
|
response.raise_for_status()
|
|
@@ -506,10 +513,15 @@ class CloudEdgeClient:
|
|
|
506
513
|
iv = "01234567".encode('utf-8')
|
|
507
514
|
|
|
508
515
|
try:
|
|
509
|
-
from cryptography.hazmat.primitives.ciphers import Cipher,
|
|
516
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, modes
|
|
510
517
|
from cryptography.hazmat.primitives import padding
|
|
511
518
|
|
|
512
|
-
|
|
519
|
+
try:
|
|
520
|
+
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
|
|
521
|
+
except ImportError: # cryptography < 43
|
|
522
|
+
from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES
|
|
523
|
+
|
|
524
|
+
algorithm = TripleDES(key)
|
|
513
525
|
cipher = Cipher(algorithm, modes.CBC(iv))
|
|
514
526
|
encryptor = cipher.encryptor()
|
|
515
527
|
|
|
@@ -599,25 +611,56 @@ class CloudEdgeClient:
|
|
|
599
611
|
try:
|
|
600
612
|
with open(self.session_cache_file, 'r') as f:
|
|
601
613
|
session_data = json.load(f)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
login_time = session_data.get('loginTime', 0)
|
|
605
|
-
if time.time() - login_time > 86400: # 24 hours
|
|
606
|
-
self._log("Session cache expired")
|
|
614
|
+
|
|
615
|
+
if not isinstance(session_data, dict):
|
|
607
616
|
return None
|
|
608
|
-
|
|
617
|
+
token = session_data.get("userToken")
|
|
618
|
+
user_id = session_data.get("userID")
|
|
619
|
+
if not isinstance(token, str) or not token or not isinstance(user_id, (str, int)) or not user_id:
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
login_time = session_data.get("loginTime")
|
|
623
|
+
if (
|
|
624
|
+
not isinstance(login_time, (int, float))
|
|
625
|
+
or not math.isfinite(login_time)
|
|
626
|
+
or not 0 <= time.time() - login_time < 86400
|
|
627
|
+
):
|
|
628
|
+
self._log("Session cache expired or has an invalid timestamp")
|
|
629
|
+
return None
|
|
630
|
+
|
|
609
631
|
return session_data
|
|
610
|
-
except (json.JSONDecodeError,
|
|
611
|
-
self._log("Invalid session cache file")
|
|
632
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
|
633
|
+
self._log("Invalid or unreadable session cache file")
|
|
612
634
|
return None
|
|
613
|
-
|
|
635
|
+
|
|
614
636
|
def _save_session_cache(self, session_data: Dict) -> None:
|
|
615
|
-
"""Save session data to cache file.
|
|
637
|
+
"""Save session data to cache file.
|
|
638
|
+
|
|
639
|
+
The cache contains the account session token, so it is written
|
|
640
|
+
owner-only (0600) and atomically (temp file + rename) so a crash
|
|
641
|
+
mid-write can never leave a truncated file behind.
|
|
642
|
+
"""
|
|
643
|
+
cache_dir = os.path.dirname(os.path.abspath(self.session_cache_file))
|
|
644
|
+
cache_name = os.path.basename(self.session_cache_file)
|
|
645
|
+
tmp_path: Optional[str] = None
|
|
616
646
|
try:
|
|
617
|
-
|
|
647
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
648
|
+
dir=cache_dir,
|
|
649
|
+
prefix=f".{cache_name}.",
|
|
650
|
+
suffix=".tmp",
|
|
651
|
+
)
|
|
652
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
618
653
|
json.dump(session_data, f)
|
|
654
|
+
f.flush()
|
|
655
|
+
os.fsync(f.fileno())
|
|
656
|
+
os.replace(tmp_path, self.session_cache_file)
|
|
619
657
|
except Exception as e:
|
|
620
658
|
self._log(f"Failed to save session cache: {e}")
|
|
659
|
+
if tmp_path is not None:
|
|
660
|
+
try:
|
|
661
|
+
os.remove(tmp_path)
|
|
662
|
+
except OSError:
|
|
663
|
+
pass
|
|
621
664
|
|
|
622
665
|
def _discover_endpoints(self) -> None:
|
|
623
666
|
"""Call the global redirect API to discover the correct regional server.
|
|
@@ -716,19 +759,25 @@ class CloudEdgeClient:
|
|
|
716
759
|
except Exception as exc:
|
|
717
760
|
raise NetworkError(f"Endpoint discovery failed: {exc}") from exc
|
|
718
761
|
|
|
719
|
-
def authenticate(self) -> bool:
|
|
762
|
+
def authenticate(self, force_refresh: bool = False) -> bool:
|
|
720
763
|
"""
|
|
721
764
|
Authenticate with CloudEdge API.
|
|
722
|
-
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
force_refresh: Skip the cached session and perform a fresh
|
|
768
|
+
login. Use after the server rejected the cached token
|
|
769
|
+
(e.g. another device logged into the account): the cache
|
|
770
|
+
file on disk still looks valid but the session is dead.
|
|
771
|
+
|
|
723
772
|
Returns:
|
|
724
773
|
bool: True if authentication successful, False otherwise
|
|
725
|
-
|
|
774
|
+
|
|
726
775
|
Raises:
|
|
727
776
|
AuthenticationError: If authentication fails
|
|
728
777
|
NetworkError: If network request fails
|
|
729
778
|
"""
|
|
730
779
|
# Try to load cached session first
|
|
731
|
-
self.session_data = self._load_session_cache()
|
|
780
|
+
self.session_data = None if force_refresh else self._load_session_cache()
|
|
732
781
|
if self.session_data:
|
|
733
782
|
self._log("Using cached session")
|
|
734
783
|
# Restore discovered endpoints from cache if present
|
|
@@ -736,6 +785,13 @@ class CloudEdgeClient:
|
|
|
736
785
|
self.BASE_URL = self.session_data["apiServer"]
|
|
737
786
|
if self.session_data.get("openapiServer"):
|
|
738
787
|
self.OPENAPI_BASE_URL = self.session_data["openapiServer"]
|
|
788
|
+
# Older caches may predate endpoint discovery. Keep the valid token,
|
|
789
|
+
# but discover its regional URLs before making authenticated requests.
|
|
790
|
+
if not self.BASE_URL or not self.OPENAPI_BASE_URL:
|
|
791
|
+
self._discover_endpoints()
|
|
792
|
+
self.session_data["apiServer"] = self.BASE_URL
|
|
793
|
+
self.session_data["openapiServer"] = self.OPENAPI_BASE_URL
|
|
794
|
+
self._save_session_cache(self.session_data)
|
|
739
795
|
# Fetch MQTT config if not already present in the cached session
|
|
740
796
|
if not self.session_data.get("mqtt"):
|
|
741
797
|
try:
|
|
@@ -1108,6 +1164,14 @@ class CloudEdgeClient:
|
|
|
1108
1164
|
error_msg = response_data.get('resultMsg', 'Unknown error')
|
|
1109
1165
|
error_code = response_data.get('resultCode', 'unknown')
|
|
1110
1166
|
self._log(f"API error - Code: {error_code}, Message: {error_msg}, Full response: {response_data}")
|
|
1167
|
+
if str(error_code) in SESSION_INVALID_RESULT_CODES:
|
|
1168
|
+
# The server rejected our token (e.g. the account was
|
|
1169
|
+
# logged in elsewhere — one active session per account).
|
|
1170
|
+
# Raise the typed auth error so callers re-authenticate
|
|
1171
|
+
# instead of retrying forever with a dead session.
|
|
1172
|
+
raise AuthenticationError(
|
|
1173
|
+
f"Session rejected by API (Code: {error_code}): {error_msg}"
|
|
1174
|
+
)
|
|
1111
1175
|
raise CloudEdgeError(
|
|
1112
1176
|
f"Failed to retrieve homes: {error_msg} (Code: {error_code})",
|
|
1113
1177
|
details={"error_code": error_code, "message": error_msg, "response": response_data}
|
|
@@ -1205,6 +1269,10 @@ class CloudEdgeClient:
|
|
|
1205
1269
|
else:
|
|
1206
1270
|
error_msg = response_data.get('resultMsg', 'Unknown error')
|
|
1207
1271
|
error_code = response_data.get('resultCode', 'unknown')
|
|
1272
|
+
if str(error_code) in SESSION_INVALID_RESULT_CODES:
|
|
1273
|
+
raise AuthenticationError(
|
|
1274
|
+
f"Session rejected by API (Code: {error_code}): {error_msg}"
|
|
1275
|
+
)
|
|
1208
1276
|
raise CloudEdgeError(
|
|
1209
1277
|
f"Failed to retrieve home devices: {error_msg}",
|
|
1210
1278
|
details={"error_code": error_code, "message": error_msg, "home_id": home_id}
|
|
@@ -1240,6 +1308,8 @@ class CloudEdgeClient:
|
|
|
1240
1308
|
self._log(f"Found {len(default_devices)} devices via default home API")
|
|
1241
1309
|
all_devices.extend(default_devices)
|
|
1242
1310
|
return all_devices
|
|
1311
|
+
except AuthenticationError:
|
|
1312
|
+
raise
|
|
1243
1313
|
except Exception as e:
|
|
1244
1314
|
self._log(f"Default home API failed: {e}, trying home-based approach...")
|
|
1245
1315
|
|
|
@@ -1257,11 +1327,21 @@ class CloudEdgeClient:
|
|
|
1257
1327
|
home_devices = self.get_devices_by_home(home_id)
|
|
1258
1328
|
self._log(f"Found {len(home_devices)} devices in home '{home_name}'")
|
|
1259
1329
|
all_devices.extend(home_devices)
|
|
1330
|
+
except AuthenticationError:
|
|
1331
|
+
raise
|
|
1260
1332
|
except Exception as e:
|
|
1261
|
-
|
|
1333
|
+
# A partial inventory would make callers remove devices
|
|
1334
|
+
# (and stop streams) during a temporary API outage.
|
|
1335
|
+
raise CloudEdgeError(
|
|
1336
|
+
f"Failed to get devices from home '{home_name}': {e}"
|
|
1337
|
+
) from e
|
|
1262
1338
|
|
|
1263
1339
|
return all_devices
|
|
1264
|
-
|
|
1340
|
+
|
|
1341
|
+
except AuthenticationError:
|
|
1342
|
+
# Must propagate as-is: callers re-authenticate on this type,
|
|
1343
|
+
# wrapping it in CloudEdgeError would hide the recovery signal.
|
|
1344
|
+
raise
|
|
1265
1345
|
except Exception as e:
|
|
1266
1346
|
raise CloudEdgeError(f"Failed to get devices from homes: {e}")
|
|
1267
1347
|
|
|
@@ -1371,6 +1451,10 @@ class CloudEdgeClient:
|
|
|
1371
1451
|
else:
|
|
1372
1452
|
error_msg = response_data.get('resultMsg', 'Unknown error')
|
|
1373
1453
|
error_code = response_data.get('resultCode', 'unknown')
|
|
1454
|
+
if str(error_code) in SESSION_INVALID_RESULT_CODES:
|
|
1455
|
+
raise AuthenticationError(
|
|
1456
|
+
f"Session rejected by API (Code: {error_code}): {error_msg}"
|
|
1457
|
+
)
|
|
1374
1458
|
raise CloudEdgeError(
|
|
1375
1459
|
f"Failed to retrieve devices: {error_msg}",
|
|
1376
1460
|
details={"error_code": error_code, "message": error_msg}
|
|
@@ -1936,18 +2020,17 @@ class CloudEdgeClient:
|
|
|
1936
2020
|
# First try the get_all_devices method for comprehensive search
|
|
1937
2021
|
devices = self.get_all_devices()
|
|
1938
2022
|
|
|
1939
|
-
|
|
1940
|
-
for device in devices
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2023
|
+
name = device_name.lower()
|
|
2024
|
+
matches = [device for device in devices if device.get("name", "").lower() == name]
|
|
2025
|
+
if not matches:
|
|
2026
|
+
matches = [device for device in devices if name in device.get("name", "").lower()]
|
|
2027
|
+
if len(matches) > 1:
|
|
2028
|
+
raise DeviceNotFoundError(
|
|
2029
|
+
f"Device name '{device_name}' is ambiguous; use a unique device name",
|
|
2030
|
+
details={"device_name": device_name, "matches": len(matches)},
|
|
2031
|
+
)
|
|
2032
|
+
return matches[0] if matches else None
|
|
2033
|
+
|
|
1951
2034
|
def set_device_parameter(
|
|
1952
2035
|
self,
|
|
1953
2036
|
device_name: str,
|
|
@@ -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
|
)
|