pycloudedge 0.1.4.dev1__tar.gz → 0.1.4.dev3__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 (33) hide show
  1. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/.gitignore +3 -1
  2. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/PKG-INFO +1 -1
  3. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/__init__.py +9 -1
  4. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/_version.py +3 -3
  5. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/client.py +455 -72
  6. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/PKG-INFO +1 -1
  7. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/.env.example +0 -0
  8. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/LICENSE +0 -0
  9. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/MANIFEST.in +0 -0
  10. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/README.md +0 -0
  11. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/cli.py +0 -0
  12. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/constants.py +0 -0
  13. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/exceptions.py +0 -0
  14. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/iot_parameters.py +0 -0
  15. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/logging_config.py +0 -0
  16. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/utils.py +0 -0
  17. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/validators.py +0 -0
  18. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/README.md +0 -0
  19. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/basic_example.py +0 -0
  20. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/device_control.py +0 -0
  21. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/network_ping_status.py +0 -0
  22. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/SOURCES.txt +0 -0
  23. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/dependency_links.txt +0 -0
  24. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/entry_points.txt +0 -0
  25. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/requires.txt +0 -0
  26. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/top_level.txt +0 -0
  27. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pyproject.toml +0 -0
  28. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/requirements-dev.txt +0 -0
  29. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/requirements.txt +0 -0
  30. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/setup.cfg +0 -0
  31. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/setup.py +0 -0
  32. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/tests/test_basic.py +0 -0
  33. {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/tests/test_improvements.py +0 -0
@@ -118,4 +118,6 @@ cloudedge/_version.py
118
118
 
119
119
  # CloudEdge specific
120
120
  .cloudedge_session_cache
121
- .home_id
121
+ .home_id
122
+
123
+ publish.sh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.4.dev1
3
+ Version: 0.1.4.dev3
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -9,7 +9,12 @@ Author: Francesco D'Aloisio
9
9
  Date: September 16, 2025
10
10
  """
11
11
 
12
- from .client import CloudEdgeClient
12
+ from .client import (
13
+ CloudEdgeClient,
14
+ DEVICE_STATUS_ONLINE,
15
+ DEVICE_STATUS_DORMANCY,
16
+ DEVICE_STATUS_OFFLINE,
17
+ )
13
18
  from .exceptions import (
14
19
  CloudEdgeError, AuthenticationError, DeviceNotFoundError,
15
20
  ConfigurationError, NetworkError, ValidationError, RateLimitError
@@ -24,6 +29,9 @@ __author__ = "Francesco D'Aloisio"
24
29
 
25
30
  __all__ = [
26
31
  'CloudEdgeClient',
32
+ 'DEVICE_STATUS_ONLINE',
33
+ 'DEVICE_STATUS_DORMANCY',
34
+ 'DEVICE_STATUS_OFFLINE',
27
35
  'CloudEdgeError',
28
36
  'AuthenticationError',
29
37
  'DeviceNotFoundError',
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.4.dev1'
32
- __version_tuple__ = version_tuple = (0, 1, 4, 'dev1')
31
+ __version__ = version = '0.1.4.dev3'
32
+ __version_tuple__ = version_tuple = (0, 1, 4, 'dev3')
33
33
 
34
- __commit_id__ = commit_id = 'gbd742badc'
34
+ __commit_id__ = commit_id = 'gc5b1ac2e4'
@@ -44,6 +44,59 @@ from .validators import validate_email, validate_country_code, validate_phone_co
44
44
  from .logging_config import get_logger
45
45
  from .utils import retry_on_failure
46
46
 
47
+ # Device online status values returned by get_device_online_status()
48
+ DEVICE_STATUS_ONLINE = "online"
49
+ DEVICE_STATUS_DORMANCY = "dormancy"
50
+ DEVICE_STATUS_OFFLINE = "offline"
51
+
52
+ # API list keys → readable product type when deviceTypeName is a CDN image URL
53
+ _DEVICE_LIST_CATEGORY_LABELS = {
54
+ "snap": "Camera",
55
+ "ipc": "Camera",
56
+ "nvr": "NVR",
57
+ "doorbell": "Doorbell",
58
+ "chime": "Chime",
59
+ }
60
+
61
+
62
+ def _device_icon_url_from_type_name(device_type_name: Any) -> Optional[str]:
63
+ """Return URL if deviceTypeName is an http(s) icon URL (Meari/OSS), else None."""
64
+ if not isinstance(device_type_name, str):
65
+ return None
66
+ s = device_type_name.strip()
67
+ if s.startswith(("http://", "https://")):
68
+ return s
69
+ return None
70
+
71
+
72
+ def _human_type_and_icon_url(
73
+ device: Dict[str, Any],
74
+ list_category: Optional[str] = None,
75
+ ) -> tuple[str, Optional[str]]:
76
+ """
77
+ CloudEdge often puts a product image URL in deviceTypeName instead of a label.
78
+ Return a human-readable type for UIs (e.g. Home Assistant model) and optional icon URL.
79
+ """
80
+ raw = device.get("deviceTypeName")
81
+ icon_url = _device_icon_url_from_type_name(raw)
82
+ if icon_url is not None:
83
+ label = _DEVICE_LIST_CATEGORY_LABELS.get(list_category or "", "SmartEye Camera")
84
+ lower = icon_url.lower()
85
+ if "doorbell" in lower:
86
+ label = "Doorbell"
87
+ elif "chime" in lower:
88
+ label = "Chime"
89
+ elif "nvr" in lower:
90
+ label = "NVR"
91
+ elif "snap" in lower or "ipc" in lower:
92
+ label = "Camera"
93
+ return label, icon_url
94
+ if raw is not None and str(raw).strip():
95
+ return str(raw).strip(), None
96
+ if list_category:
97
+ return _DEVICE_LIST_CATEGORY_LABELS.get(list_category, "Unknown"), None
98
+ return "Unknown", None
99
+
47
100
 
48
101
  class CloudEdgeClient:
49
102
  """
@@ -787,15 +840,19 @@ class CloudEdgeClient:
787
840
  device_list = response_data[device_type]
788
841
  if isinstance(device_list, list):
789
842
  for device in device_list:
843
+ type_str, icon_url = _human_type_and_icon_url(
844
+ device, list_category=device_type
845
+ )
790
846
  device_dict = {
791
847
  'device_id': device.get('deviceID'),
792
848
  'serial_number': device.get('snNum'),
793
849
  'name': device.get('deviceName', 'Unnamed'),
794
- 'type': device.get('deviceTypeName', 'Unknown'),
850
+ 'type': type_str,
795
851
  'type_id': device.get('devTypeID'),
796
852
  'host_key': device.get('hostKey'),
797
853
  'online': device.get('devStatus') == 1, # Store original API status
798
- 'home_id': home_id
854
+ 'home_id': home_id,
855
+ 'device_icon_url': icon_url,
799
856
  }
800
857
 
801
858
  # Get enhanced online status
@@ -920,43 +977,54 @@ class CloudEdgeClient:
920
977
  import json
921
978
  self._log(f"API Response structure: {json.dumps(response_data, indent=2)}")
922
979
 
923
- devices = []
924
-
925
- # Check for devices in different device type keys (working format)
926
980
  device_types = ['nvr', 'ipc', 'chime', 'doorbell', 'snap']
927
-
928
- for device_type in device_types:
929
- if device_type in response_data and response_data[device_type]:
930
- device_list = response_data[device_type]
931
- if isinstance(device_list, list):
932
- self._log(f"Found {len(device_list)} devices under '{device_type}' key")
933
- devices.extend(device_list)
934
-
935
- # Fallback: check for devices in result.deviceList (older format)
936
- if not devices:
937
- device_list = response_data.get("result", {}).get("deviceList", [])
938
- if isinstance(device_list, list) and device_list:
939
- self._log(f"Found {len(device_list)} devices under 'result.deviceList' key")
940
- devices.extend(device_list)
941
-
942
- # Convert to standardized format
943
981
  standardized_devices = []
944
- for device in devices:
945
- device_dict = {
946
- 'device_id': device.get('deviceID'),
947
- 'serial_number': device.get('snNum'),
948
- 'name': device.get('deviceName', 'Unnamed'),
949
- 'type': device.get('deviceTypeName', 'Unknown'),
950
- 'type_id': device.get('devTypeID'),
951
- 'host_key': device.get('hostKey'),
952
- 'online': device.get('onLine') == 1 # Store original API status
953
- }
954
-
955
- # Get enhanced online status
956
- device_dict['online'] = self._get_enhanced_device_status(device_dict)
957
-
958
- standardized_devices.append(device_dict)
959
-
982
+
983
+ # Modern API: devices grouped by category key
984
+ for device_type in device_types:
985
+ device_list = response_data.get(device_type)
986
+ if not isinstance(device_list, list) or not device_list:
987
+ continue
988
+ self._log(f"Found {len(device_list)} devices under '{device_type}' key")
989
+ for device in device_list:
990
+ type_str, icon_url = _human_type_and_icon_url(
991
+ device, list_category=device_type
992
+ )
993
+ device_dict = {
994
+ 'device_id': device.get('deviceID'),
995
+ 'serial_number': device.get('snNum'),
996
+ 'name': device.get('deviceName', 'Unnamed'),
997
+ 'type': type_str,
998
+ 'type_id': device.get('devTypeID'),
999
+ 'host_key': device.get('hostKey'),
1000
+ 'online': device.get('onLine') == 1,
1001
+ 'device_icon_url': icon_url,
1002
+ }
1003
+ device_dict['online'] = self._get_enhanced_device_status(device_dict)
1004
+ standardized_devices.append(device_dict)
1005
+
1006
+ # Older API fallback: devices under result.deviceList
1007
+ if not standardized_devices:
1008
+ fallback_list = response_data.get("result", {}).get("deviceList", [])
1009
+ if isinstance(fallback_list, list) and fallback_list:
1010
+ self._log(f"Found {len(fallback_list)} devices under 'result.deviceList' key")
1011
+ for device in fallback_list:
1012
+ type_str, icon_url = _human_type_and_icon_url(
1013
+ device, list_category=None
1014
+ )
1015
+ device_dict = {
1016
+ 'device_id': device.get('deviceID'),
1017
+ 'serial_number': device.get('snNum'),
1018
+ 'name': device.get('deviceName', 'Unnamed'),
1019
+ 'type': type_str,
1020
+ 'type_id': device.get('devTypeID'),
1021
+ 'host_key': device.get('hostKey'),
1022
+ 'online': device.get('onLine') == 1,
1023
+ 'device_icon_url': icon_url,
1024
+ }
1025
+ device_dict['online'] = self._get_enhanced_device_status(device_dict)
1026
+ standardized_devices.append(device_dict)
1027
+
960
1028
  return standardized_devices
961
1029
  else:
962
1030
  error_msg = response_data.get('resultMsg', 'Unknown error')
@@ -1028,7 +1096,279 @@ class CloudEdgeClient:
1028
1096
  raise NetworkError(f"Device status request failed: {e}")
1029
1097
  except json.JSONDecodeError:
1030
1098
  raise CloudEdgeError("Failed to parse device status response")
1031
-
1099
+
1100
+ def get_device_online_status(self, serial_number: str) -> str:
1101
+ """Query the precise online state of a device via OpenAPI.
1102
+
1103
+ Unlike :meth:`get_device_status` (which uses a legacy endpoint and
1104
+ only distinguishes online/offline), this method calls
1105
+ ``/openapi/device/status`` and can distinguish three states:
1106
+
1107
+ * ``"online"`` – camera is awake and reachable
1108
+ * ``"dormancy"`` – battery camera is asleep (must be woken before commands)
1109
+ * ``"offline"`` – camera is unreachable
1110
+
1111
+ Use the module-level constants :data:`DEVICE_STATUS_ONLINE`,
1112
+ :data:`DEVICE_STATUS_DORMANCY`, :data:`DEVICE_STATUS_OFFLINE` for
1113
+ comparisons.
1114
+
1115
+ Args:
1116
+ serial_number (str): Device serial number (snNum).
1117
+
1118
+ Returns:
1119
+ str: ``"online"``, ``"dormancy"``, ``"offline"``, or ``"unknown"``.
1120
+
1121
+ Raises:
1122
+ AuthenticationError: If not authenticated.
1123
+ NetworkError: If the HTTP request fails.
1124
+ """
1125
+ if not self.session_data:
1126
+ raise AuthenticationError("Not authenticated - call authenticate() first")
1127
+
1128
+ iot_keys = self.session_data.get('iotPlatformKeys', {})
1129
+ if not iot_keys or 'accessid' not in iot_keys or 'accesskey' not in iot_keys:
1130
+ self._log("No OpenAPI credentials — cannot query dormancy status")
1131
+ return "unknown"
1132
+
1133
+ access_id = iot_keys['accessid']
1134
+ access_key = iot_keys['accesskey']
1135
+ formatted_sn = self._format_sn(serial_number)
1136
+
1137
+ signature, timeout = self._get_signature_for_openapi(
1138
+ '/openapi/device/status', 'query', access_key
1139
+ )
1140
+ params = {
1141
+ 'accessid': access_id,
1142
+ 'expires': timeout,
1143
+ 'signature': signature,
1144
+ 'action': 'query',
1145
+ 'deviceid': formatted_sn,
1146
+ }
1147
+ headers = {
1148
+ "Accept": "*/*",
1149
+ "User-Agent": DEFAULT_HEADERS['User-Agent'],
1150
+ }
1151
+
1152
+ try:
1153
+ response = self._make_request(
1154
+ 'GET',
1155
+ f"{self.OPENAPI_BASE_URL}/openapi/device/status",
1156
+ headers=headers,
1157
+ params=params,
1158
+ timeout=DEFAULT_TIMEOUT,
1159
+ )
1160
+ data = response.json()
1161
+ status = data.get('status', 'unknown')
1162
+ self._log(f"Device {serial_number} online status: {status}")
1163
+ return status
1164
+ except requests.exceptions.RequestException as e:
1165
+ raise NetworkError(f"Device status request failed: {e}")
1166
+ except json.JSONDecodeError:
1167
+ raise CloudEdgeError("Failed to parse device status response")
1168
+
1169
+ def wake_device(
1170
+ self,
1171
+ serial_number: str,
1172
+ device_id: Optional[Union[int, str]] = None,
1173
+ ) -> bool:
1174
+ """Send a wake signal to a dormant battery camera.
1175
+
1176
+ Uses two independent mechanisms for reliability:
1177
+
1178
+ 1. **OpenAPI** ``/openapi/device/awaken`` — cloud-to-camera push via
1179
+ the IoT platform (requires OpenAPI credentials).
1180
+ 2. **REST** ``/v1/app/bell/remote/wake`` — legacy bell/doorbell wake
1181
+ signal (requires *device_id*).
1182
+
1183
+ Both methods are attempted; success is reported if at least one
1184
+ succeeds. Call :meth:`wait_for_online` or :meth:`ensure_online`
1185
+ afterwards to confirm the camera has woken.
1186
+
1187
+ Args:
1188
+ serial_number (str): Device serial number (snNum).
1189
+ device_id (int | str | None): Numeric device ID (deviceID).
1190
+ Optional but recommended — without it only method 1 is used.
1191
+
1192
+ Returns:
1193
+ bool: ``True`` if at least one wake method succeeded.
1194
+
1195
+ Raises:
1196
+ AuthenticationError: If not authenticated.
1197
+ """
1198
+ if not self.session_data:
1199
+ raise AuthenticationError("Not authenticated - call authenticate() first")
1200
+
1201
+ success = False
1202
+
1203
+ # Method 1 — OpenAPI /openapi/device/awaken
1204
+ iot_keys = self.session_data.get('iotPlatformKeys', {})
1205
+ if iot_keys and 'accessid' in iot_keys and 'accesskey' in iot_keys:
1206
+ try:
1207
+ access_id = iot_keys['accessid']
1208
+ access_key = iot_keys['accesskey']
1209
+ formatted_sn = self._format_sn(serial_number)
1210
+ sid = (formatted_sn + str(int(time.time() * 1000)))[:30]
1211
+
1212
+ signature, timeout = self._get_signature_for_openapi(
1213
+ '/openapi/device/awaken', 'set', access_key
1214
+ )
1215
+ params = {
1216
+ 'accessid': access_id,
1217
+ 'expires': timeout,
1218
+ 'signature': signature,
1219
+ 'action': 'set',
1220
+ 'deviceid': formatted_sn,
1221
+ 'sid': sid,
1222
+ }
1223
+ headers = {
1224
+ "Accept": "*/*",
1225
+ "User-Agent": DEFAULT_HEADERS['User-Agent'],
1226
+ }
1227
+ response = self._make_request(
1228
+ 'GET',
1229
+ f"{self.OPENAPI_BASE_URL}/openapi/device/awaken",
1230
+ headers=headers,
1231
+ params=params,
1232
+ timeout=DEFAULT_TIMEOUT,
1233
+ )
1234
+ if response.status_code == 200:
1235
+ self._log(f"OpenAPI wake sent for {serial_number}")
1236
+ success = True
1237
+ except Exception as e:
1238
+ self._log(f"OpenAPI wake failed: {e}")
1239
+
1240
+ # Method 2 — /v1/app/bell/remote/wake (requires device_id)
1241
+ if device_id is not None:
1242
+ try:
1243
+ device_body = self._generate_device_body({'deviceID': str(device_id)})
1244
+ headers = {
1245
+ "Accept": "*/*",
1246
+ "Content-Type": "application/x-www-form-urlencoded",
1247
+ "User-Agent": DEFAULT_HEADERS['User-Agent'],
1248
+ }
1249
+ response = self._make_request(
1250
+ 'POST',
1251
+ f"{self.BASE_URL}/v1/app/bell/remote/wake",
1252
+ headers=headers,
1253
+ data=device_body,
1254
+ timeout=DEFAULT_TIMEOUT,
1255
+ )
1256
+ if response.json().get('resultCode') in ('1001', '0'):
1257
+ self._log(f"Bell wake sent for device_id={device_id}")
1258
+ success = True
1259
+ except Exception as e:
1260
+ self._log(f"Bell wake failed: {e}")
1261
+
1262
+ if not success:
1263
+ self._log(
1264
+ f"All wake methods failed for {serial_number} — device may be offline"
1265
+ )
1266
+ return success
1267
+
1268
+ def wait_for_online(
1269
+ self,
1270
+ serial_number: str,
1271
+ timeout: float = 30.0,
1272
+ poll_interval: float = 2.0,
1273
+ ) -> bool:
1274
+ """Poll until the device reports ``"online"`` status or the timeout expires.
1275
+
1276
+ Typically called right after :meth:`wake_device` to confirm the camera
1277
+ has woken up before issuing commands.
1278
+
1279
+ Args:
1280
+ serial_number (str): Device serial number.
1281
+ timeout (float): Maximum seconds to wait (default 30).
1282
+ poll_interval (float): Seconds between status checks (default 2).
1283
+
1284
+ Returns:
1285
+ bool: ``True`` if the camera came online within *timeout* seconds.
1286
+ """
1287
+ deadline = time.time() + timeout
1288
+ while time.time() < deadline:
1289
+ try:
1290
+ status = self.get_device_online_status(serial_number)
1291
+ if status == DEVICE_STATUS_ONLINE:
1292
+ self._log(f"Device {serial_number} is now online")
1293
+ return True
1294
+ self._log(
1295
+ f"Device {serial_number} status={status}, "
1296
+ f"waiting ({deadline - time.time():.0f}s left)..."
1297
+ )
1298
+ except Exception as e:
1299
+ self._log(f"Status check error: {e}")
1300
+
1301
+ remaining = deadline - time.time()
1302
+ if remaining > 0:
1303
+ time.sleep(min(poll_interval, remaining))
1304
+
1305
+ self._log(f"Timeout waiting for {serial_number} to come online")
1306
+ return False
1307
+
1308
+ def ensure_online(
1309
+ self,
1310
+ serial_number: str,
1311
+ device_id: Optional[Union[int, str]] = None,
1312
+ timeout: float = 35.0,
1313
+ auto_wake: bool = True,
1314
+ ) -> bool:
1315
+ """Ensure a camera is online, waking it first if dormant.
1316
+
1317
+ This is the recommended guard to call before any command that requires
1318
+ the camera to be awake (e.g. :meth:`set_device_config`,
1319
+ :meth:`get_device_config` on live parameters).
1320
+
1321
+ Behaviour:
1322
+
1323
+ * **online** → returns ``True`` immediately.
1324
+ * **dormancy** → sends a wake signal (if *auto_wake* is ``True``),
1325
+ then polls until online or *timeout* expires.
1326
+ * **offline** → returns ``False`` immediately (offline cameras cannot
1327
+ be woken over the cloud).
1328
+ * **unknown** → returns ``False`` (credentials or network issue).
1329
+
1330
+ Args:
1331
+ serial_number (str): Device serial number.
1332
+ device_id (int | str | None): Numeric device ID, passed to
1333
+ :meth:`wake_device` for the secondary wake method.
1334
+ timeout (float): Max seconds to wait after waking (default 35).
1335
+ auto_wake (bool): Send wake signal when dormant (default ``True``).
1336
+
1337
+ Returns:
1338
+ bool: ``True`` if the camera is (or becomes) online.
1339
+
1340
+ Example::
1341
+
1342
+ if client.ensure_online(sn, device_id):
1343
+ client.set_device_config(sn, {"1": 1})
1344
+ else:
1345
+ print("Camera unreachable — skipping command")
1346
+ """
1347
+ status = self.get_device_online_status(serial_number)
1348
+
1349
+ if status == DEVICE_STATUS_ONLINE:
1350
+ return True
1351
+
1352
+ if status == DEVICE_STATUS_OFFLINE:
1353
+ self._log(f"Device {serial_number} is offline — cannot wake")
1354
+ return False
1355
+
1356
+ if status == DEVICE_STATUS_DORMANCY:
1357
+ if not auto_wake:
1358
+ self._log(
1359
+ f"Device {serial_number} is dormant but auto_wake=False"
1360
+ )
1361
+ return False
1362
+ self._log(f"Device {serial_number} is dormant — sending wake signal")
1363
+ self.wake_device(serial_number, device_id)
1364
+ return self.wait_for_online(serial_number, timeout=timeout)
1365
+
1366
+ # Unknown status
1367
+ self._log(
1368
+ f"Device {serial_number} status={status!r} — cannot determine reachability"
1369
+ )
1370
+ return False
1371
+
1032
1372
  def get_device_config(self, device_serial: str,
1033
1373
  parameter_codes: Optional[List[str]] = None) -> Optional[Dict]:
1034
1374
  """
@@ -1115,24 +1455,58 @@ class CloudEdgeClient:
1115
1455
  except json.JSONDecodeError as e:
1116
1456
  raise ConfigurationError(f"Failed to parse config response: {e}")
1117
1457
 
1118
- def set_device_config(self, device_serial: str, parameters: Dict[str, Any]) -> bool:
1119
- """
1120
- Set device configuration parameters.
1121
-
1458
+ def set_device_config(
1459
+ self,
1460
+ device_serial: str,
1461
+ parameters: Dict[str, Any],
1462
+ auto_wake: bool = True,
1463
+ device_id: Optional[Union[int, str]] = None,
1464
+ ) -> bool:
1465
+ """Set device configuration parameters.
1466
+
1467
+ For battery cameras that may be in ``dormancy`` state, pass
1468
+ ``auto_wake=True`` (default) together with *device_id* so the method
1469
+ will automatically wake the camera and wait for it to come online
1470
+ before sending the command.
1471
+
1122
1472
  Args:
1123
- device_serial (str): Device serial number
1124
- parameters (Dict[str, Any]): Parameter codes and values to set
1125
-
1473
+ device_serial (str): Device serial number.
1474
+ parameters (Dict[str, Any]): Parameter codes and values to set,
1475
+ e.g. ``{"1": 1}`` or ``{"PIR_SWITCH": 1}``.
1476
+ auto_wake (bool): Wake a dormant camera automatically before
1477
+ issuing the command (default ``True``).
1478
+ device_id (int | str | None): Numeric device ID used by the
1479
+ secondary wake method. Ignored when *auto_wake* is ``False``.
1480
+
1126
1481
  Returns:
1127
- bool: True if successful, False otherwise
1128
-
1482
+ bool: ``True`` if successful.
1483
+
1129
1484
  Raises:
1130
- AuthenticationError: If not authenticated
1131
- ConfigurationError: If configuration setting fails
1485
+ AuthenticationError: If not authenticated.
1486
+ ConfigurationError: If configuration setting fails.
1132
1487
  """
1133
1488
  if not self.session_data:
1134
1489
  raise AuthenticationError("Not authenticated - call authenticate() first")
1135
-
1490
+
1491
+ # Wake dormant battery cameras before sending the command
1492
+ if auto_wake:
1493
+ status = self.get_device_online_status(device_serial)
1494
+ if status == DEVICE_STATUS_DORMANCY:
1495
+ self._log(
1496
+ f"set_device_config: device {device_serial} is dormant — waking first"
1497
+ )
1498
+ self.wake_device(device_serial, device_id)
1499
+ if not self.wait_for_online(device_serial):
1500
+ raise ConfigurationError(
1501
+ "Device did not come online after wake signal",
1502
+ details={"device_serial": device_serial},
1503
+ )
1504
+ elif status == DEVICE_STATUS_OFFLINE:
1505
+ raise ConfigurationError(
1506
+ "Device is offline — cannot send configuration",
1507
+ details={"device_serial": device_serial},
1508
+ )
1509
+
1136
1510
  self._log(f"Setting device configuration for SN: {device_serial}")
1137
1511
 
1138
1512
  # Check if we have OpenAPI credentials
@@ -1231,45 +1605,54 @@ class CloudEdgeClient:
1231
1605
 
1232
1606
  return None
1233
1607
 
1234
- def set_device_parameter(self, device_name: str, parameter_name: str,
1235
- value: Union[int, str, float]) -> bool:
1236
- """
1237
- Set a single device parameter by name.
1238
-
1608
+ def set_device_parameter(
1609
+ self,
1610
+ device_name: str,
1611
+ parameter_name: str,
1612
+ value: Union[int, str, float],
1613
+ auto_wake: bool = True,
1614
+ ) -> bool:
1615
+ """Set a single device parameter by name.
1616
+
1239
1617
  Args:
1240
- device_name (str): Device name
1241
- parameter_name (str): Parameter name (e.g., "FRONT_LIGHT_SWITCH")
1242
- value (Union[int, str, float]): Parameter value
1243
-
1618
+ device_name (str): Device name.
1619
+ parameter_name (str): Parameter name, e.g. ``"FRONT_LIGHT_SWITCH"``.
1620
+ value (int | str | float): Parameter value.
1621
+ auto_wake (bool): Wake a dormant camera before sending the command
1622
+ (default ``True``). The device's numeric ID is resolved
1623
+ automatically from the device list.
1624
+
1244
1625
  Returns:
1245
- bool: True if successful, False otherwise
1246
-
1626
+ bool: ``True`` if successful.
1627
+
1247
1628
  Raises:
1248
- DeviceNotFoundError: If device not found
1249
- ConfigurationError: If parameter is invalid or setting fails
1629
+ DeviceNotFoundError: If device not found.
1630
+ ConfigurationError: If parameter is invalid or setting fails.
1250
1631
  """
1251
- # Find device
1252
1632
  device = self.find_device_by_name(device_name)
1253
1633
  if not device:
1254
1634
  raise DeviceNotFoundError(f"Device '{device_name}' not found")
1255
-
1256
- # Get parameter code
1635
+
1257
1636
  parameter_code = get_parameter_code_by_name(parameter_name)
1258
1637
  if not parameter_code:
1259
1638
  raise ConfigurationError(
1260
1639
  f"Unknown parameter: {parameter_name}",
1261
- details={"parameter_name": parameter_name, "device": device_name}
1640
+ details={"parameter_name": parameter_name, "device": device_name},
1262
1641
  )
1263
-
1264
- # Set parameter
1642
+
1265
1643
  parameters = {parameter_code: value}
1266
- success = self.set_device_config(device['serial_number'], parameters)
1267
-
1644
+ success = self.set_device_config(
1645
+ device['serial_number'],
1646
+ parameters,
1647
+ auto_wake=auto_wake,
1648
+ device_id=device.get('device_id'),
1649
+ )
1650
+
1268
1651
  if success:
1269
1652
  param_display = get_parameter_name(parameter_code)
1270
1653
  formatted_value = format_parameter_value(param_display, value)
1271
1654
  self._log(f"Set {param_display} = {formatted_value} on device '{device_name}'")
1272
-
1655
+
1273
1656
  return success
1274
1657
 
1275
1658
  def get_device_info(self, device_name: str, include_config: bool = True) -> Optional[Dict]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.4.dev1
3
+ Version: 0.1.4.dev3
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio