pycloudedge 0.1.5.dev0__tar.gz → 0.1.6.dev1__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.5.dev0 → pycloudedge-0.1.6.dev1}/PKG-INFO +1 -1
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/__init__.py +5 -1
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/client.py +7 -0
- pycloudedge-0.1.6.dev1/cloudedge/image_decrypt.py +96 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/mqtt.py +53 -8
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/PKG-INFO +1 -1
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/SOURCES.txt +1 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/.env.example +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/.gitignore +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/LICENSE +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/MANIFEST.in +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/README.md +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/constants.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/examples/README.md +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/examples/device_control.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/requires.txt +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/pyproject.toml +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/requirements-dev.txt +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/requirements.txt +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/setup.cfg +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/setup.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/tests/test_basic.py +0 -0
- {pycloudedge-0.1.5.dev0 → pycloudedge-0.1.6.dev1}/tests/test_improvements.py +0 -0
|
@@ -16,6 +16,7 @@ from .client import (
|
|
|
16
16
|
DEVICE_STATUS_OFFLINE,
|
|
17
17
|
)
|
|
18
18
|
from .mqtt import CloudEdgeMqttListener, ALARM_TYPE_NAMES, MOTION_ALARM_TYPES
|
|
19
|
+
from .image_decrypt import decrypt_jpgx3, decrypt_jpgx3_from_url, verify_licence_for_url
|
|
19
20
|
from .exceptions import (
|
|
20
21
|
CloudEdgeError, AuthenticationError, DeviceNotFoundError,
|
|
21
22
|
ConfigurationError, NetworkError, ValidationError, RateLimitError
|
|
@@ -45,5 +46,8 @@ __all__ = [
|
|
|
45
46
|
'RateLimitError',
|
|
46
47
|
'IOT_PARAMETERS',
|
|
47
48
|
'get_parameter_name',
|
|
48
|
-
'format_parameter_value'
|
|
49
|
+
'format_parameter_value',
|
|
50
|
+
'decrypt_jpgx3',
|
|
51
|
+
'decrypt_jpgx3_from_url',
|
|
52
|
+
'verify_licence_for_url',
|
|
49
53
|
]
|
|
@@ -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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
31
|
+
__version__ = version = '0.1.6.dev1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 6, 'dev1')
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id =
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -562,6 +562,13 @@ class CloudEdgeClient:
|
|
|
562
562
|
self.session_data = self._load_session_cache()
|
|
563
563
|
if self.session_data:
|
|
564
564
|
self._log("Using cached session")
|
|
565
|
+
# Fetch MQTT config if not already present in the cached session
|
|
566
|
+
if not self.session_data.get("mqtt"):
|
|
567
|
+
try:
|
|
568
|
+
self._fetch_iot_config()
|
|
569
|
+
self._save_session_cache(self.session_data)
|
|
570
|
+
except Exception as exc:
|
|
571
|
+
self._log(f"IoT config fetch failed (non-fatal): {exc}")
|
|
565
572
|
return True
|
|
566
573
|
|
|
567
574
|
self._log("Performing CloudEdge login...")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Decrypt Meari/CloudEdge ``.jpgx3`` alarm images.
|
|
2
|
+
|
|
3
|
+
The CloudEdge MQTT push events include a URL to an encrypted JPEG snapshot
|
|
4
|
+
captured at the moment of the alarm. The file uses a simple XOR cipher
|
|
5
|
+
derived from the device's serial number (licence ID).
|
|
6
|
+
|
|
7
|
+
Algorithm (reverse-engineered from ``libmrplayer.so``):
|
|
8
|
+
|
|
9
|
+
1. Build the seed string ``"{licence_id}|{len(licence_id)}|meari.stream"``.
|
|
10
|
+
2. Compute ``MD5(seed)`` and represent it as a **32-char lowercase hex string**.
|
|
11
|
+
3. XOR the first ``min(len(data), 1024)`` bytes of the encrypted file with
|
|
12
|
+
the hex string, cycling every 32 bytes. Bytes beyond 1024 are unchanged.
|
|
13
|
+
|
|
14
|
+
The companion ``meari.key`` variant (same pattern but with ``"meari.key"``
|
|
15
|
+
instead of ``"meari.stream"``) produces the hash embedded in the filename,
|
|
16
|
+
which can be used to verify that the correct licence ID was used.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
_LOGGER = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_XOR_LIMIT = 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _meari_md5(licence_id: str, suffix: str) -> str:
|
|
31
|
+
"""Return ``MD5("{licence_id}|{len}|{suffix}")`` as a 32-char hex string."""
|
|
32
|
+
seed = f"{licence_id}|{len(licence_id)}|{suffix}"
|
|
33
|
+
return hashlib.md5(seed.encode("utf-8")).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def verify_licence_for_url(url: str, licence_id: str) -> bool:
|
|
37
|
+
"""Check whether *licence_id* matches the hash embedded in *url*."""
|
|
38
|
+
expected = _meari_md5(licence_id, "meari.key")
|
|
39
|
+
return expected in url
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decrypt_jpgx3(data: bytes, licence_id: str) -> bytes:
|
|
43
|
+
"""Decrypt a ``.jpgx3`` encrypted JPEG image.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
data: Raw (encrypted) bytes downloaded from the alarm URL.
|
|
47
|
+
licence_id: The device serial number / licence ID
|
|
48
|
+
(e.g. ``"ppsl6b9baee1884c4a9b"``).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Decrypted JPEG bytes.
|
|
52
|
+
"""
|
|
53
|
+
key_hex = _meari_md5(licence_id, "meari.stream").encode("ascii") # 32 bytes
|
|
54
|
+
xor_len = min(len(data), _XOR_LIMIT)
|
|
55
|
+
|
|
56
|
+
out = bytearray(data)
|
|
57
|
+
for i in range(xor_len):
|
|
58
|
+
out[i] ^= key_hex[i % 32]
|
|
59
|
+
|
|
60
|
+
return bytes(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decrypt_jpgx3_from_url(
|
|
64
|
+
url: str,
|
|
65
|
+
licence_id: str,
|
|
66
|
+
*,
|
|
67
|
+
timeout: int = 15,
|
|
68
|
+
session=None,
|
|
69
|
+
) -> Optional[bytes]:
|
|
70
|
+
"""Download and decrypt a ``.jpgx3`` alarm image.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
url: The full ``.jpgx3`` URL from the MQTT alarm payload.
|
|
74
|
+
licence_id: Device serial number / licence ID.
|
|
75
|
+
timeout: HTTP request timeout in seconds.
|
|
76
|
+
session: Optional ``requests.Session`` to reuse.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Decrypted JPEG bytes, or ``None`` on failure.
|
|
80
|
+
"""
|
|
81
|
+
import requests
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
r = (session or requests).get(url, timeout=timeout)
|
|
85
|
+
r.raise_for_status()
|
|
86
|
+
if len(r.content) < 100:
|
|
87
|
+
_LOGGER.debug("Alarm image too small (%d bytes)", len(r.content))
|
|
88
|
+
return None
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
_LOGGER.debug("Failed to download alarm image: %s", exc)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
if ".jpgx3" in url or ".jpgx2" in url:
|
|
94
|
+
return decrypt_jpgx3(r.content, licence_id)
|
|
95
|
+
|
|
96
|
+
return r.content
|
|
@@ -59,6 +59,13 @@ MOTION_ALARM_TYPES: frozenset[int] = frozenset({1, 2, 11, 20})
|
|
|
59
59
|
OnEventCallback = Callable[[str, str, int, bool], None]
|
|
60
60
|
"""Signature: (device_id, event_name, event_type_int, is_motion) -> None"""
|
|
61
61
|
|
|
62
|
+
OnEventCallbackEx = Callable[[str, str, int, bool, dict], None]
|
|
63
|
+
"""Extended signature: (device_id, event_name, event_type_int, is_motion, extra) -> None
|
|
64
|
+
|
|
65
|
+
``extra`` contains optional fields from the payload such as
|
|
66
|
+
``url``, ``alert``, ``deviceName``, ``licenseID``, ``msgDate``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
62
69
|
|
|
63
70
|
class CloudEdgeMqttListener:
|
|
64
71
|
"""Subscribe to the CloudEdge/Meari MQTT broker for push events.
|
|
@@ -205,15 +212,37 @@ class CloudEdgeMqttListener:
|
|
|
205
212
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
206
213
|
return
|
|
207
214
|
|
|
208
|
-
|
|
209
|
-
|
|
215
|
+
_LOGGER.info("MQTT raw payload: %s", json.dumps(data, default=str)[:2000])
|
|
216
|
+
|
|
217
|
+
original = data
|
|
218
|
+
|
|
219
|
+
# Unwrap nested envelope — walk into nested dicts looking for the event
|
|
220
|
+
# Real payload: { "event": "alarm", "params": { "result": { "evt": "1", "deviceID": "...", ... } } }
|
|
221
|
+
for key in ("params", "result", "data", "state", "reported"):
|
|
210
222
|
if key in data and isinstance(data[key], dict):
|
|
211
223
|
data = data[key]
|
|
212
224
|
if "msg" in data and isinstance(data["msg"], dict):
|
|
213
225
|
data = data["msg"]
|
|
214
226
|
|
|
215
|
-
|
|
216
|
-
|
|
227
|
+
for key in ("alarm", "alarmInfo"):
|
|
228
|
+
if key in data and isinstance(data[key], dict):
|
|
229
|
+
data = data[key]
|
|
230
|
+
|
|
231
|
+
evt_raw = (
|
|
232
|
+
data.get("evt")
|
|
233
|
+
or data.get("eventType")
|
|
234
|
+
or data.get("alarmType")
|
|
235
|
+
or data.get("type")
|
|
236
|
+
or ""
|
|
237
|
+
)
|
|
238
|
+
device_id = str(
|
|
239
|
+
data.get("deviceID")
|
|
240
|
+
or data.get("deviceId")
|
|
241
|
+
or data.get("devID")
|
|
242
|
+
or data.get("did")
|
|
243
|
+
or original.get("state", {}).get("reported", {}).get("deviceID", "")
|
|
244
|
+
or ""
|
|
245
|
+
)
|
|
217
246
|
|
|
218
247
|
try:
|
|
219
248
|
evt_int = int(evt_raw)
|
|
@@ -223,19 +252,35 @@ class CloudEdgeMqttListener:
|
|
|
223
252
|
evt_name = ALARM_TYPE_NAMES.get(evt_int, f"type={evt_raw}")
|
|
224
253
|
is_motion = evt_int in MOTION_ALARM_TYPES
|
|
225
254
|
|
|
255
|
+
extra: dict = {}
|
|
256
|
+
for field in ("url", "alert", "deviceName", "licenseID", "msgDate", "title"):
|
|
257
|
+
val = data.get(field)
|
|
258
|
+
if val:
|
|
259
|
+
extra[field] = str(val)
|
|
260
|
+
|
|
226
261
|
_LOGGER.info(
|
|
227
|
-
"MQTT event: %s device=%s motion=%s",
|
|
228
|
-
evt_name, device_id, is_motion,
|
|
262
|
+
"MQTT event: %s device=%s motion=%s url=%s",
|
|
263
|
+
evt_name, device_id, is_motion, bool(extra.get("url")),
|
|
229
264
|
)
|
|
230
265
|
|
|
231
266
|
if self.on_event:
|
|
232
267
|
try:
|
|
233
|
-
self.on_event(device_id, evt_name, evt_int, is_motion)
|
|
268
|
+
self.on_event(device_id, evt_name, evt_int, is_motion, extra)
|
|
269
|
+
except TypeError:
|
|
270
|
+
try:
|
|
271
|
+
self.on_event(device_id, evt_name, evt_int, is_motion)
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
_LOGGER.debug("on_event callback error: %s", exc)
|
|
234
274
|
except Exception as exc:
|
|
235
275
|
_LOGGER.debug("on_event callback error: %s", exc)
|
|
236
276
|
|
|
237
277
|
if is_motion and self.on_motion:
|
|
238
278
|
try:
|
|
239
|
-
self.on_motion(device_id, evt_name, evt_int, is_motion)
|
|
279
|
+
self.on_motion(device_id, evt_name, evt_int, is_motion, extra)
|
|
280
|
+
except TypeError:
|
|
281
|
+
try:
|
|
282
|
+
self.on_motion(device_id, evt_name, evt_int, is_motion)
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
_LOGGER.debug("on_motion callback error: %s", exc)
|
|
240
285
|
except Exception as exc:
|
|
241
286
|
_LOGGER.debug("on_motion callback error: %s", exc)
|
|
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
|