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.
Files changed (51) hide show
  1. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/PKG-INFO +1 -1
  2. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/__init__.py +13 -1
  3. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/_version.py +3 -3
  4. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/client.py +151 -15
  5. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/constants.py +6 -0
  6. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/mqtt.py +28 -6
  7. pycloudedge-0.1.8/cloudedge/p2p/__init__.py +3 -0
  8. pycloudedge-0.1.8/cloudedge/p2p/kcp_tunnel.py +554 -0
  9. pycloudedge-0.1.8/cloudedge/p2p/meari_signaling.py +406 -0
  10. pycloudedge-0.1.8/cloudedge/p2p/p2p_streamer.py +1801 -0
  11. pycloudedge-0.1.8/cloudedge/p2p/root_discovery.py +230 -0
  12. pycloudedge-0.1.8/cloudedge/p2p/turn_client.py +548 -0
  13. pycloudedge-0.1.8/cloudedge/stream_profiles.py +209 -0
  14. pycloudedge-0.1.8/examples/stream_test.py +263 -0
  15. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/PKG-INFO +1 -1
  16. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/SOURCES.txt +15 -1
  17. pycloudedge-0.1.8/pycloudedge.egg-info/scm_file_list.json +44 -0
  18. pycloudedge-0.1.8/pycloudedge.egg-info/scm_version.json +8 -0
  19. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/requirements.txt +2 -1
  20. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/tests/test_basic.py +34 -38
  21. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/tests/test_improvements.py +103 -0
  22. pycloudedge-0.1.8/tests/test_kcp_tunnel.py +127 -0
  23. pycloudedge-0.1.8/tests/test_mqtt.py +46 -0
  24. pycloudedge-0.1.8/tests/test_stream_profiles.py +178 -0
  25. pycloudedge-0.1.8/tests/test_streaming_helpers.py +901 -0
  26. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/.env.example +0 -0
  27. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/.gitignore +0 -0
  28. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/LICENSE +0 -0
  29. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/MANIFEST.in +0 -0
  30. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/README.md +0 -0
  31. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/cli.py +0 -0
  32. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/exceptions.py +0 -0
  33. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/image_decrypt.py +0 -0
  34. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/iot_parameters.py +0 -0
  35. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/logging_config.py +0 -0
  36. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/utils.py +0 -0
  37. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/cloudedge/validators.py +0 -0
  38. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/docs/plans/2026-03-30-streaming-video.md +0 -0
  39. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/README.md +0 -0
  40. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/basic_example.py +0 -0
  41. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/device_control.py +0 -0
  42. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/examples/network_ping_status.py +0 -0
  43. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/dependency_links.txt +0 -0
  44. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/entry_points.txt +0 -0
  45. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/requires.txt +0 -0
  46. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pycloudedge.egg-info/top_level.txt +0 -0
  47. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/pyproject.toml +0 -0
  48. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/requirements-dev.txt +0 -0
  49. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/setup.cfg +0 -0
  50. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/setup.py +0 -0
  51. {pycloudedge-0.1.6.dev4 → pycloudedge-0.1.8}/test_3region_login.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.6.dev4
3
+ Version: 0.1.8
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -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.6.dev4'
22
- __version_tuple__ = version_tuple = (0, 1, 6, 'dev4')
21
+ __version__ = version = '0.1.8'
22
+ __version_tuple__ = version_tuple = (0, 1, 8)
23
23
 
24
- __commit_id__ = commit_id = 'ge76f5b0fa'
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
- with open(self.session_cache_file, 'w') as f:
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
- ssl_ctx = ssl.create_default_context()
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.info("MQTT raw payload: %s", json.dumps(data, default=str)[:2000])
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.info(
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
  )
@@ -0,0 +1,3 @@
1
+ from .p2p_streamer import P2PStreamer
2
+
3
+ __all__ = ["P2PStreamer"]