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.
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/.gitignore +3 -1
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/PKG-INFO +1 -1
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/__init__.py +9 -1
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/client.py +455 -72
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/PKG-INFO +1 -1
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/.env.example +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/LICENSE +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/MANIFEST.in +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/README.md +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/constants.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/README.md +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/device_control.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/SOURCES.txt +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/requires.txt +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/pyproject.toml +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/requirements-dev.txt +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/requirements.txt +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/setup.cfg +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/setup.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/tests/test_basic.py +0 -0
- {pycloudedge-0.1.4.dev1 → pycloudedge-0.1.4.dev3}/tests/test_improvements.py +0 -0
|
@@ -9,7 +9,12 @@ Author: Francesco D'Aloisio
|
|
|
9
9
|
Date: September 16, 2025
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
from .client import
|
|
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1, 4, '
|
|
31
|
+
__version__ = version = '0.1.4.dev3'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 4, 'dev3')
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
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':
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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(
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
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(
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
1242
|
-
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
|
|
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(
|
|
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]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|