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.
Files changed (52) hide show
  1. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/PKG-INFO +1 -1
  2. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/__init__.py +13 -1
  3. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/_version.py +3 -3
  4. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/client.py +116 -33
  5. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/constants.py +6 -0
  6. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/mqtt.py +28 -6
  7. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/kcp_tunnel.py +167 -113
  8. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/p2p_streamer.py +344 -183
  9. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/turn_client.py +86 -21
  10. pycloudedge-0.1.9/cloudedge/stream_profiles.py +209 -0
  11. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/PKG-INFO +1 -1
  12. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/SOURCES.txt +5 -6
  13. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/requirements.txt +2 -1
  14. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/tests/test_basic.py +34 -38
  15. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/tests/test_improvements.py +103 -0
  16. pycloudedge-0.1.9/tests/test_kcp_tunnel.py +127 -0
  17. pycloudedge-0.1.9/tests/test_mqtt.py +46 -0
  18. pycloudedge-0.1.9/tests/test_reliability.py +94 -0
  19. pycloudedge-0.1.9/tests/test_stream_profiles.py +178 -0
  20. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/tests/test_streaming_helpers.py +416 -3
  21. pycloudedge-0.1.7/.gitignore +0 -124
  22. pycloudedge-0.1.7/docs/plans/2026-03-30-streaming-video.md +0 -176
  23. pycloudedge-0.1.7/pycloudedge.egg-info/scm_file_list.json +0 -40
  24. pycloudedge-0.1.7/pycloudedge.egg-info/scm_version.json +0 -8
  25. pycloudedge-0.1.7/requirements-dev.txt +0 -11
  26. pycloudedge-0.1.7/test_3region_login.py +0 -58
  27. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/.env.example +0 -0
  28. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/LICENSE +0 -0
  29. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/MANIFEST.in +0 -0
  30. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/README.md +0 -0
  31. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/cli.py +0 -0
  32. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/exceptions.py +0 -0
  33. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/image_decrypt.py +0 -0
  34. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/iot_parameters.py +0 -0
  35. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/logging_config.py +0 -0
  36. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/__init__.py +0 -0
  37. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/meari_signaling.py +0 -0
  38. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/p2p/root_discovery.py +0 -0
  39. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/utils.py +0 -0
  40. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/cloudedge/validators.py +0 -0
  41. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/README.md +0 -0
  42. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/basic_example.py +0 -0
  43. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/device_control.py +0 -0
  44. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/network_ping_status.py +0 -0
  45. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/examples/stream_test.py +0 -0
  46. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/dependency_links.txt +0 -0
  47. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/entry_points.txt +0 -0
  48. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/requires.txt +0 -0
  49. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pycloudedge.egg-info/top_level.txt +0 -0
  50. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/pyproject.toml +0 -0
  51. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/setup.cfg +0 -0
  52. {pycloudedge-0.1.7 → pycloudedge-0.1.9}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.7
3
+ Version: 0.1.9
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.7'
22
- __version_tuple__ = version_tuple = (0, 1, 7)
21
+ __version__ = version = '0.1.9'
22
+ __version_tuple__ = version_tuple = (0, 1, 9)
23
23
 
24
- __commit_id__ = commit_id = 'ga9a42e171'
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 = 0,
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, algorithms, modes
516
+ from cryptography.hazmat.primitives.ciphers import Cipher, modes
510
517
  from cryptography.hazmat.primitives import padding
511
518
 
512
- algorithm = algorithms.TripleDES(key)
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
- # Check if session is still valid (not older than 24 hours)
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, KeyError):
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
- with open(self.session_cache_file, 'w') as f:
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
- self._log(f"Failed to get devices from home '{home_name}': {e}")
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
- # Search for exact match first
1940
- for device in devices:
1941
- if device.get('name', '').lower() == device_name.lower():
1942
- return device
1943
-
1944
- # If exact match not found, try partial match
1945
- for device in devices:
1946
- if device_name.lower() in device.get('name', '').lower():
1947
- return device
1948
-
1949
- return None
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
- 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
  )