pycloudedge 0.1.6.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.
Files changed (35) hide show
  1. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/PKG-INFO +1 -1
  2. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/__init__.py +5 -1
  3. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/_version.py +3 -3
  4. pycloudedge-0.1.6.dev1/cloudedge/image_decrypt.py +96 -0
  5. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/mqtt.py +53 -8
  6. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/PKG-INFO +1 -1
  7. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/SOURCES.txt +1 -0
  8. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/.env.example +0 -0
  9. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/.gitignore +0 -0
  10. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/LICENSE +0 -0
  11. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/MANIFEST.in +0 -0
  12. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/README.md +0 -0
  13. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/cli.py +0 -0
  14. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/client.py +0 -0
  15. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/constants.py +0 -0
  16. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/exceptions.py +0 -0
  17. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/iot_parameters.py +0 -0
  18. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/logging_config.py +0 -0
  19. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/utils.py +0 -0
  20. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/cloudedge/validators.py +0 -0
  21. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/examples/README.md +0 -0
  22. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/examples/basic_example.py +0 -0
  23. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/examples/device_control.py +0 -0
  24. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/examples/network_ping_status.py +0 -0
  25. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/dependency_links.txt +0 -0
  26. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/entry_points.txt +0 -0
  27. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/requires.txt +0 -0
  28. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/pycloudedge.egg-info/top_level.txt +0 -0
  29. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/pyproject.toml +0 -0
  30. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/requirements-dev.txt +0 -0
  31. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/requirements.txt +0 -0
  32. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/setup.cfg +0 -0
  33. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/setup.py +0 -0
  34. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/tests/test_basic.py +0 -0
  35. {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev1}/tests/test_improvements.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.6.dev0
3
+ Version: 0.1.6.dev1
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -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.6.dev0'
32
- __version_tuple__ = version_tuple = (0, 1, 6, 'dev0')
31
+ __version__ = version = '0.1.6.dev1'
32
+ __version_tuple__ = version_tuple = (0, 1, 6, 'dev1')
33
33
 
34
- __commit_id__ = commit_id = 'g782ac8af8'
34
+ __commit_id__ = commit_id = None
@@ -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
- # Unwrap nested envelope: params data → msg
209
- for key in ("params", "data"):
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
- evt_raw = data.get("evt", data.get("eventType", ""))
216
- device_id = str(data.get("deviceID", data.get("deviceId", "")))
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.6.dev0
3
+ Version: 0.1.6.dev1
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -13,6 +13,7 @@ cloudedge/cli.py
13
13
  cloudedge/client.py
14
14
  cloudedge/constants.py
15
15
  cloudedge/exceptions.py
16
+ cloudedge/image_decrypt.py
16
17
  cloudedge/iot_parameters.py
17
18
  cloudedge/logging_config.py
18
19
  cloudedge/mqtt.py