pycloudedge 0.1.6.dev0__tar.gz → 0.1.6.dev2__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.6.dev0 → pycloudedge-0.1.6.dev2}/.gitignore +1 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/PKG-INFO +1 -1
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/__init__.py +5 -1
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/client.py +105 -5
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/constants.py +35 -2
- pycloudedge-0.1.6.dev2/cloudedge/image_decrypt.py +96 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/mqtt.py +53 -8
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/pycloudedge.egg-info/PKG-INFO +1 -1
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/pycloudedge.egg-info/SOURCES.txt +1 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/.env.example +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/LICENSE +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/MANIFEST.in +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/README.md +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/examples/README.md +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/examples/device_control.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/pycloudedge.egg-info/requires.txt +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/pyproject.toml +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/requirements-dev.txt +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/requirements.txt +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/setup.cfg +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/setup.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/tests/test_basic.py +0 -0
- {pycloudedge-0.1.6.dev0 → pycloudedge-0.1.6.dev2}/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.6.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1, 6, '
|
|
31
|
+
__version__ = version = '0.1.6.dev2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 6, 'dev2')
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id =
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -36,8 +36,9 @@ from .iot_parameters import (
|
|
|
36
36
|
format_parameter_value
|
|
37
37
|
)
|
|
38
38
|
from .constants import (
|
|
39
|
-
CA_KEY, DEFAULT_HEADERS, DEFAULT_TIMEOUT,
|
|
40
|
-
REGION_ENDPOINTS, REGION_EU, REGION_US,
|
|
39
|
+
CA_KEY, CA_SECRET, DEFAULT_HEADERS, DEFAULT_TIMEOUT,
|
|
40
|
+
REGION_ENDPOINTS, REGION_EU, REGION_US, REGION_AP,
|
|
41
|
+
REDIRECT_URL,
|
|
41
42
|
region_for_country,
|
|
42
43
|
)
|
|
43
44
|
from .validators import validate_email, validate_country_code, validate_phone_code
|
|
@@ -145,9 +146,10 @@ class CloudEdgeClient:
|
|
|
145
146
|
session_cache_file (str): Path to session cache file
|
|
146
147
|
enable_network_ping (bool): Enable ping-based online status when on same network
|
|
147
148
|
ping_timeout (float): Ping timeout in seconds
|
|
148
|
-
region (str, optional): Force a specific region ("eu" or "
|
|
149
|
+
region (str, optional): Force a specific region ("eu", "us", or "ap").
|
|
149
150
|
When *None* the region is derived automatically from *country_code*:
|
|
150
|
-
European countries
|
|
151
|
+
European countries → EU, Asia-Pacific/Oceania/ME/Africa → AP
|
|
152
|
+
(Singapore), everything else → US.
|
|
151
153
|
|
|
152
154
|
Raises:
|
|
153
155
|
ValidationError: If input validation fails
|
|
@@ -547,6 +549,95 @@ class CloudEdgeClient:
|
|
|
547
549
|
except Exception as e:
|
|
548
550
|
self._log(f"Failed to save session cache: {e}")
|
|
549
551
|
|
|
552
|
+
def _discover_endpoints(self) -> None:
|
|
553
|
+
"""Call the global redirect API to discover the correct regional server.
|
|
554
|
+
|
|
555
|
+
The CloudEdge mobile app calls ``/ppstrongs/redirect`` on the global
|
|
556
|
+
gateway (``apis.cloudedge360.com``) **before** login. The response
|
|
557
|
+
contains the regional ``apiServer`` and ``openapi`` domain that must be
|
|
558
|
+
used for the given account/country.
|
|
559
|
+
|
|
560
|
+
On success, ``self.BASE_URL`` and ``self.OPENAPI_BASE_URL`` are updated.
|
|
561
|
+
On failure the previously configured (static) endpoints are kept.
|
|
562
|
+
"""
|
|
563
|
+
import random
|
|
564
|
+
|
|
565
|
+
timestamp = int(time.time() * 1000)
|
|
566
|
+
encrypted_account = self._aes_encode_param(
|
|
567
|
+
self.username, "/ppstrongs/redirect", timestamp=timestamp,
|
|
568
|
+
)
|
|
569
|
+
query_nonce = "".join(
|
|
570
|
+
[str(random.randint(0, 9)) for _ in range(8)]
|
|
571
|
+
)
|
|
572
|
+
header_nonce = str(random.randint(100000, 999999))
|
|
573
|
+
|
|
574
|
+
phone_code_bare = self.phone_code.lstrip("+")
|
|
575
|
+
params = {
|
|
576
|
+
"phoneType": "a",
|
|
577
|
+
"sourceApp": "8",
|
|
578
|
+
"appVer": "5.5.1",
|
|
579
|
+
"appVerCode": "551",
|
|
580
|
+
"localTime": str(timestamp),
|
|
581
|
+
"t": str(timestamp),
|
|
582
|
+
"lngType": "en",
|
|
583
|
+
"countryCode": self.country_code,
|
|
584
|
+
"userAccount": encrypted_account,
|
|
585
|
+
"phoneCode": phone_code_bare,
|
|
586
|
+
"partnerId": "8",
|
|
587
|
+
"nonce": query_nonce,
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
# sign = MD5("GET|/ppstrongs/redirect|{timestamp}|apis.meari.com.cn")
|
|
591
|
+
params["sign"] = hashlib.md5(
|
|
592
|
+
f"GET|/ppstrongs/redirect|{timestamp}|apis.meari.com.cn".encode()
|
|
593
|
+
).hexdigest()
|
|
594
|
+
|
|
595
|
+
# X-Ca-Sign: HMAC-SHA1 with CA_SECRET as key
|
|
596
|
+
# The app builds: "api=/ppstrongs/" + getRealUrl(path) + "|X-Ca-Key=...|..."
|
|
597
|
+
# getRealUrl("/ppstrongs/redirect") returns "/ppstrongs/redirect" unchanged,
|
|
598
|
+
# so the full string is "api=/ppstrongs//ppstrongs/redirect|..."
|
|
599
|
+
ca_sign_data = (
|
|
600
|
+
f"api=/ppstrongs//ppstrongs/redirect"
|
|
601
|
+
f"|X-Ca-Key={CA_KEY}"
|
|
602
|
+
f"|X-Ca-Timestamp={timestamp}"
|
|
603
|
+
f"|X-Ca-Nonce={header_nonce}"
|
|
604
|
+
)
|
|
605
|
+
ca_signature = base64.b64encode(
|
|
606
|
+
hmac.new(CA_SECRET.encode(), ca_sign_data.encode(), hashlib.sha1).digest()
|
|
607
|
+
).decode()
|
|
608
|
+
|
|
609
|
+
headers = {
|
|
610
|
+
**DEFAULT_HEADERS,
|
|
611
|
+
"X-Ca-Timestamp": str(timestamp),
|
|
612
|
+
"X-Ca-Sign": ca_signature,
|
|
613
|
+
"X-Ca-Key": CA_KEY,
|
|
614
|
+
"X-Ca-Nonce": header_nonce,
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
resp = self._session.get(
|
|
619
|
+
REDIRECT_URL, params=params, headers=headers, timeout=DEFAULT_TIMEOUT,
|
|
620
|
+
)
|
|
621
|
+
data = resp.json()
|
|
622
|
+
if data.get("resultCode") != "1001":
|
|
623
|
+
self._log(f"Redirect discovery returned {data.get('resultCode')}")
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
result = data.get("result", {})
|
|
627
|
+
api_server = result.get("apiServer") or result.get("gwUrl") or ""
|
|
628
|
+
if api_server:
|
|
629
|
+
self.BASE_URL = api_server.rstrip("/")
|
|
630
|
+
self._log(f"Discovered API server: {self.BASE_URL}")
|
|
631
|
+
|
|
632
|
+
pf_api = result.get("pfApi", {})
|
|
633
|
+
openapi_domain = pf_api.get("openapi", {}).get("domain", "")
|
|
634
|
+
if openapi_domain:
|
|
635
|
+
self.OPENAPI_BASE_URL = openapi_domain.rstrip("/")
|
|
636
|
+
self._log(f"Discovered OpenAPI server: {self.OPENAPI_BASE_URL}")
|
|
637
|
+
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
self._log(f"Endpoint discovery failed (will use static endpoints): {exc}")
|
|
640
|
+
|
|
550
641
|
def authenticate(self) -> bool:
|
|
551
642
|
"""
|
|
552
643
|
Authenticate with CloudEdge API.
|
|
@@ -562,6 +653,11 @@ class CloudEdgeClient:
|
|
|
562
653
|
self.session_data = self._load_session_cache()
|
|
563
654
|
if self.session_data:
|
|
564
655
|
self._log("Using cached session")
|
|
656
|
+
# Restore discovered endpoints from cache if present
|
|
657
|
+
if self.session_data.get("apiServer"):
|
|
658
|
+
self.BASE_URL = self.session_data["apiServer"]
|
|
659
|
+
if self.session_data.get("openapiServer"):
|
|
660
|
+
self.OPENAPI_BASE_URL = self.session_data["openapiServer"]
|
|
565
661
|
# Fetch MQTT config if not already present in the cached session
|
|
566
662
|
if not self.session_data.get("mqtt"):
|
|
567
663
|
try:
|
|
@@ -570,7 +666,10 @@ class CloudEdgeClient:
|
|
|
570
666
|
except Exception as exc:
|
|
571
667
|
self._log(f"IoT config fetch failed (non-fatal): {exc}")
|
|
572
668
|
return True
|
|
573
|
-
|
|
669
|
+
|
|
670
|
+
# Discover the correct regional endpoints before login
|
|
671
|
+
self._discover_endpoints()
|
|
672
|
+
|
|
574
673
|
self._log("Performing CloudEdge login...")
|
|
575
674
|
|
|
576
675
|
# Encrypt credentials
|
|
@@ -660,6 +759,7 @@ class CloudEdgeClient:
|
|
|
660
759
|
"caKey": ca_key,
|
|
661
760
|
"loginTime": int(time.time()),
|
|
662
761
|
"apiServer": self.BASE_URL,
|
|
762
|
+
"openapiServer": self.OPENAPI_BASE_URL,
|
|
663
763
|
"iotPlatformKeys": iot_platform_keys,
|
|
664
764
|
}
|
|
665
765
|
|
|
@@ -5,8 +5,9 @@ Configuration constants for CloudEdge API
|
|
|
5
5
|
# Region identifiers
|
|
6
6
|
REGION_EU = "eu"
|
|
7
7
|
REGION_US = "us"
|
|
8
|
+
REGION_AP = "ap"
|
|
8
9
|
|
|
9
|
-
# Per-region API endpoints
|
|
10
|
+
# Per-region API endpoints (static fallback when redirect discovery fails)
|
|
10
11
|
REGION_ENDPOINTS = {
|
|
11
12
|
REGION_EU: {
|
|
12
13
|
"base_url": "https://apis-eu-frankfurt.cloudedge360.com",
|
|
@@ -16,8 +17,15 @@ REGION_ENDPOINTS = {
|
|
|
16
17
|
"base_url": "https://apis-us-west.cloudedge360.com",
|
|
17
18
|
"openapi_base_url": "https://openapi-uswe.mearicloud.com",
|
|
18
19
|
},
|
|
20
|
+
REGION_AP: {
|
|
21
|
+
"base_url": "https://apis-as-singapore.cloudedge360.com",
|
|
22
|
+
"openapi_base_url": "https://openapi-usce.mearicloud.com",
|
|
23
|
+
},
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
# Global redirect endpoint — used to discover the correct regional server
|
|
27
|
+
REDIRECT_URL = "https://apis.cloudedge360.com/ppstrongs/redirect"
|
|
28
|
+
|
|
21
29
|
# Default endpoints (EU) – kept for backward compatibility
|
|
22
30
|
BASE_URL = REGION_ENDPOINTS[REGION_EU]["base_url"]
|
|
23
31
|
OPENAPI_BASE_URL = REGION_ENDPOINTS[REGION_EU]["openapi_base_url"]
|
|
@@ -35,14 +43,39 @@ EU_COUNTRY_CODES = {
|
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
|
|
46
|
+
# Asia-Pacific / Oceania / Middle East / Africa country codes
|
|
47
|
+
# These are routed through the Singapore gateway
|
|
48
|
+
AP_COUNTRY_CODES = {
|
|
49
|
+
# East Asia
|
|
50
|
+
"JP", "KR", "TW", "HK", "MO",
|
|
51
|
+
# Southeast Asia
|
|
52
|
+
"SG", "MY", "TH", "VN", "PH", "ID", "MM", "KH", "LA", "BN", "TL",
|
|
53
|
+
# South Asia
|
|
54
|
+
"IN", "PK", "BD", "LK", "NP", "BT", "MV",
|
|
55
|
+
# Oceania
|
|
56
|
+
"AU", "NZ", "FJ", "PG", "WS", "TO", "VU", "SB", "KI", "MH", "FM",
|
|
57
|
+
"PW", "NR", "TV", "CK", "NU",
|
|
58
|
+
# Middle East
|
|
59
|
+
"AE", "SA", "QA", "KW", "BH", "OM", "JO", "LB", "IQ", "IR", "YE",
|
|
60
|
+
"SY", "PS", "IL",
|
|
61
|
+
# Africa
|
|
62
|
+
"ZA", "EG", "NG", "KE", "GH", "TZ", "UG", "ET", "MA", "DZ", "TN",
|
|
63
|
+
"SN", "CI", "CM", "AO", "MZ", "ZW", "BW", "NA", "MU", "MG", "RW",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
38
67
|
def region_for_country(country_code: str) -> str:
|
|
39
68
|
"""Return the region key for a given ISO 3166-1 alpha-2 country code."""
|
|
40
|
-
|
|
69
|
+
cc = country_code.upper()
|
|
70
|
+
if cc in EU_COUNTRY_CODES:
|
|
41
71
|
return REGION_EU
|
|
72
|
+
if cc in AP_COUNTRY_CODES:
|
|
73
|
+
return REGION_AP
|
|
42
74
|
return REGION_US
|
|
43
75
|
|
|
44
76
|
# API Keys (these are public keys from the mobile app)
|
|
45
77
|
CA_KEY = "bc29be30292a4309877807e101afbd51"
|
|
78
|
+
CA_SECRET = "35a69fd1-6527-4566-b190-921f9a651488"
|
|
46
79
|
|
|
47
80
|
# Default Headers
|
|
48
81
|
DEFAULT_HEADERS = {
|
|
@@ -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
|