pycloudedge 0.1.4.dev4__tar.gz → 0.1.6.dev0__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.dev4 → pycloudedge-0.1.6.dev0}/PKG-INFO +4 -1
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/__init__.py +4 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/client.py +164 -2
- pycloudedge-0.1.6.dev0/cloudedge/mqtt.py +241 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/pycloudedge.egg-info/PKG-INFO +4 -1
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/pycloudedge.egg-info/SOURCES.txt +1 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/pycloudedge.egg-info/requires.txt +4 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/pyproject.toml +4 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/.env.example +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/.gitignore +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/LICENSE +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/MANIFEST.in +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/README.md +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/constants.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/examples/README.md +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/examples/device_control.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/requirements-dev.txt +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/requirements.txt +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/setup.cfg +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/setup.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/tests/test_basic.py +0 -0
- {pycloudedge-0.1.4.dev4 → pycloudedge-0.1.6.dev0}/tests/test_improvements.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycloudedge
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6.dev0
|
|
4
4
|
Summary: Python library for CloudEdge cameras
|
|
5
5
|
Home-page: https://github.com/fradaloisio/pycloudedge
|
|
6
6
|
Author: Francesco D'Aloisio
|
|
@@ -29,7 +29,10 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
License-File: LICENSE
|
|
30
30
|
Requires-Dist: requests>=2.25.0
|
|
31
31
|
Requires-Dist: cryptography>=3.4.0
|
|
32
|
+
Requires-Dist: pycryptodome>=3.15.0
|
|
32
33
|
Requires-Dist: python-dotenv>=0.19.0
|
|
34
|
+
Provides-Extra: mqtt
|
|
35
|
+
Requires-Dist: paho-mqtt>=2.0.0; extra == "mqtt"
|
|
33
36
|
Provides-Extra: dev
|
|
34
37
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
35
38
|
Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
|
|
@@ -15,6 +15,7 @@ from .client import (
|
|
|
15
15
|
DEVICE_STATUS_DORMANCY,
|
|
16
16
|
DEVICE_STATUS_OFFLINE,
|
|
17
17
|
)
|
|
18
|
+
from .mqtt import CloudEdgeMqttListener, ALARM_TYPE_NAMES, MOTION_ALARM_TYPES
|
|
18
19
|
from .exceptions import (
|
|
19
20
|
CloudEdgeError, AuthenticationError, DeviceNotFoundError,
|
|
20
21
|
ConfigurationError, NetworkError, ValidationError, RateLimitError
|
|
@@ -32,6 +33,9 @@ __all__ = [
|
|
|
32
33
|
'DEVICE_STATUS_ONLINE',
|
|
33
34
|
'DEVICE_STATUS_DORMANCY',
|
|
34
35
|
'DEVICE_STATUS_OFFLINE',
|
|
36
|
+
'CloudEdgeMqttListener',
|
|
37
|
+
'ALARM_TYPE_NAMES',
|
|
38
|
+
'MOTION_ALARM_TYPES',
|
|
35
39
|
'CloudEdgeError',
|
|
36
40
|
'AuthenticationError',
|
|
37
41
|
'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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
31
|
+
__version__ = version = '0.1.6.dev0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 6, 'dev0')
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g782ac8af8'
|
|
@@ -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...")
|
|
@@ -653,9 +660,15 @@ class CloudEdgeClient:
|
|
|
653
660
|
"caKey": ca_key,
|
|
654
661
|
"loginTime": int(time.time()),
|
|
655
662
|
"apiServer": self.BASE_URL,
|
|
656
|
-
"iotPlatformKeys": iot_platform_keys
|
|
663
|
+
"iotPlatformKeys": iot_platform_keys,
|
|
657
664
|
}
|
|
658
|
-
|
|
665
|
+
|
|
666
|
+
# Fetch MQTT / IoT platform config (host, port, mqtt signature)
|
|
667
|
+
try:
|
|
668
|
+
self._fetch_iot_config()
|
|
669
|
+
except Exception as exc:
|
|
670
|
+
self._log(f"IoT config fetch failed (non-fatal): {exc}")
|
|
671
|
+
|
|
659
672
|
self._save_session_cache(self.session_data)
|
|
660
673
|
return True
|
|
661
674
|
else:
|
|
@@ -671,6 +684,155 @@ class CloudEdgeClient:
|
|
|
671
684
|
except json.JSONDecodeError:
|
|
672
685
|
raise AuthenticationError("Failed to parse login response")
|
|
673
686
|
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _aes_cbc_decrypt(ciphertext_b64: str, key_str: str) -> str:
|
|
689
|
+
"""AES-CBC decrypt (key == IV, PKCS7 padding).
|
|
690
|
+
|
|
691
|
+
Uses PyCryptodome when available (more lenient on padding edge cases
|
|
692
|
+
found in Meari responses), falls back to ``cryptography``.
|
|
693
|
+
"""
|
|
694
|
+
key = key_str.encode("utf-8")
|
|
695
|
+
pad_len = 4 - len(ciphertext_b64) % 4 if len(ciphertext_b64) % 4 else 0
|
|
696
|
+
ct = base64.b64decode(ciphertext_b64 + "=" * pad_len)
|
|
697
|
+
|
|
698
|
+
try:
|
|
699
|
+
from Crypto.Cipher import AES
|
|
700
|
+
from Crypto.Util.Padding import unpad
|
|
701
|
+
cipher = AES.new(key, AES.MODE_CBC, key)
|
|
702
|
+
return unpad(cipher.decrypt(ct), AES.block_size).decode("utf-8")
|
|
703
|
+
except ImportError:
|
|
704
|
+
from cryptography.hazmat.primitives.ciphers import (
|
|
705
|
+
Cipher, algorithms, modes,
|
|
706
|
+
)
|
|
707
|
+
from cryptography.hazmat.primitives import padding
|
|
708
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(key))
|
|
709
|
+
decryptor = cipher.decryptor()
|
|
710
|
+
padded = decryptor.update(ct) + decryptor.finalize()
|
|
711
|
+
unpadder = padding.PKCS7(128).unpadder()
|
|
712
|
+
return (unpadder.update(padded) + unpadder.finalize()).decode("utf-8")
|
|
713
|
+
|
|
714
|
+
def _fetch_iot_config(self) -> None:
|
|
715
|
+
"""Fetch MQTT and OpenAPI config from ``/v2/app/config/pf/init``.
|
|
716
|
+
|
|
717
|
+
Decrypts the platform signature to obtain the real ``access_id`` and
|
|
718
|
+
``access_key`` required by the MQTT broker. Stores results in
|
|
719
|
+
``self.session_data["mqtt"]``.
|
|
720
|
+
|
|
721
|
+
Called automatically after a successful :meth:`authenticate`.
|
|
722
|
+
"""
|
|
723
|
+
if not self.session_data:
|
|
724
|
+
return
|
|
725
|
+
|
|
726
|
+
user_token = self.session_data["userToken"]
|
|
727
|
+
user_id = self.session_data["userID"]
|
|
728
|
+
ts = int(time.time() * 1000)
|
|
729
|
+
|
|
730
|
+
params = {
|
|
731
|
+
'appVer': '5.5.1', 'appVerCode': '551', 'lngType': 'en',
|
|
732
|
+
'phoneType': 'a', 'sdkVer': '1.0.0', 'sourceApp': '8',
|
|
733
|
+
'countryCode': self.country_code,
|
|
734
|
+
'phoneCode': self.phone_code,
|
|
735
|
+
'iotType': '4',
|
|
736
|
+
'signatureMethod': 'HMAC-SHA1', 'signatureVersion': '1.0',
|
|
737
|
+
'signatureNonce': str(ts),
|
|
738
|
+
't': str(ts), 'timestamp': str(ts),
|
|
739
|
+
'userID': str(user_id),
|
|
740
|
+
}
|
|
741
|
+
sorted_keys = sorted(params.keys())
|
|
742
|
+
content = "&".join(f"{k}={params[k]}" for k in sorted_keys)
|
|
743
|
+
params['signature'] = base64.b64encode(
|
|
744
|
+
hmac.new(user_token.encode(), content.encode(), hashlib.sha1).digest()
|
|
745
|
+
).decode()
|
|
746
|
+
|
|
747
|
+
xca_headers = self._generate_xca_headers(
|
|
748
|
+
f"api=/ppstrongs/v2/app/config/pf/init"
|
|
749
|
+
f"|X-Ca-Key={user_token}"
|
|
750
|
+
f"|X-Ca-Timestamp={str(ts)}"
|
|
751
|
+
f"|X-Ca-Nonce={str(ts % 100000000)}",
|
|
752
|
+
user_token,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
resp = self._session.get(
|
|
757
|
+
f"{self.BASE_URL}/v2/app/config/pf/init",
|
|
758
|
+
params=params,
|
|
759
|
+
headers={**DEFAULT_HEADERS, **xca_headers},
|
|
760
|
+
timeout=DEFAULT_TIMEOUT,
|
|
761
|
+
)
|
|
762
|
+
data = resp.json()
|
|
763
|
+
if data.get("resultCode") != "1001":
|
|
764
|
+
self._log(f"IoT config response: {data.get('resultCode')}")
|
|
765
|
+
return
|
|
766
|
+
|
|
767
|
+
pf = data.get("result", {}).get("pfApi", {})
|
|
768
|
+
|
|
769
|
+
# ── Decrypt platform signature to get real access_id/key ─────
|
|
770
|
+
#
|
|
771
|
+
# The MQTT broker authenticates with the *decrypted* access_id
|
|
772
|
+
# (not the raw "mearicloud" value from the login response).
|
|
773
|
+
# Key derivation: base64(userID + PARTNER_ID + TTID + expireTime)[:16]
|
|
774
|
+
PARTNER_ID = "8"
|
|
775
|
+
TTID = "a"
|
|
776
|
+
|
|
777
|
+
access_id = ""
|
|
778
|
+
access_key = ""
|
|
779
|
+
platform = pf.get("platform", {})
|
|
780
|
+
plat_sig = platform.get("signature", "")
|
|
781
|
+
expire_time = str(platform.get("expireTime", ""))
|
|
782
|
+
|
|
783
|
+
if plat_sig and expire_time:
|
|
784
|
+
try:
|
|
785
|
+
key_raw = f"{user_id}{PARTNER_ID}{TTID}{expire_time}"
|
|
786
|
+
key16 = base64.b64encode(key_raw.encode()).decode().rstrip("=")[:16]
|
|
787
|
+
decrypted = self._aes_cbc_decrypt(plat_sig, key16)
|
|
788
|
+
info_b64 = decrypted.split("-")[0]
|
|
789
|
+
pad = 4 - len(info_b64) % 4 if len(info_b64) % 4 else 0
|
|
790
|
+
info_json = base64.b64decode(info_b64 + "=" * pad).decode()
|
|
791
|
+
info = json.loads(info_json)
|
|
792
|
+
access_id = info.get("accessid", "")
|
|
793
|
+
access_key = info.get("accesskey", "")
|
|
794
|
+
self._log(f"Decrypted MQTT access_id: {access_id[:12]}...")
|
|
795
|
+
except Exception as exc:
|
|
796
|
+
self._log(f"Platform signature decryption failed: {exc}")
|
|
797
|
+
|
|
798
|
+
mqtt_cfg = pf.get("mqtt", {})
|
|
799
|
+
self.session_data["mqtt"] = {
|
|
800
|
+
"mqtt_host": mqtt_cfg.get("host", ""),
|
|
801
|
+
"mqtt_port": int(mqtt_cfg.get("port", 1883)),
|
|
802
|
+
"mqtt_signature": pf.get("mqttSignature", ""),
|
|
803
|
+
"mqtt_access_id": access_id,
|
|
804
|
+
"mqtt_access_key": access_key,
|
|
805
|
+
}
|
|
806
|
+
self._log(
|
|
807
|
+
f"MQTT config: {self.session_data['mqtt']['mqtt_host']}"
|
|
808
|
+
f":{self.session_data['mqtt']['mqtt_port']}"
|
|
809
|
+
)
|
|
810
|
+
except Exception as exc:
|
|
811
|
+
self._log(f"Failed to fetch IoT config: {exc}")
|
|
812
|
+
|
|
813
|
+
def get_mqtt_config(self) -> Optional[Dict[str, Any]]:
|
|
814
|
+
"""Return MQTT connection parameters, or ``None`` if unavailable.
|
|
815
|
+
|
|
816
|
+
The returned dict contains:
|
|
817
|
+
|
|
818
|
+
* ``mqtt_host`` (str)
|
|
819
|
+
* ``mqtt_port`` (int)
|
|
820
|
+
* ``mqtt_signature`` (str) — password for the MQTT broker
|
|
821
|
+
* ``mqtt_access_id`` (str) — username for the MQTT broker
|
|
822
|
+
* ``user_id`` (int | str) — used in the topic path
|
|
823
|
+
|
|
824
|
+
Call :meth:`authenticate` first.
|
|
825
|
+
"""
|
|
826
|
+
if not self.session_data:
|
|
827
|
+
return None
|
|
828
|
+
mqtt = self.session_data.get("mqtt")
|
|
829
|
+
if not mqtt or not mqtt.get("mqtt_host"):
|
|
830
|
+
return None
|
|
831
|
+
return {
|
|
832
|
+
**mqtt,
|
|
833
|
+
"user_id": self.session_data.get("userID"),
|
|
834
|
+
}
|
|
835
|
+
|
|
674
836
|
def _generate_device_body(self, extra_params: Optional[Dict] = None) -> Dict:
|
|
675
837
|
"""Generate device body for API requests."""
|
|
676
838
|
if not self.session_data:
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""CloudEdge MQTT event listener.
|
|
2
|
+
|
|
3
|
+
Subscribes to the Meari/CloudEdge MQTT broker and dispatches push events
|
|
4
|
+
(motion detection, camera wake, tamper, etc.) in real-time.
|
|
5
|
+
|
|
6
|
+
Requires ``paho-mqtt`` (optional dependency)::
|
|
7
|
+
|
|
8
|
+
pip install paho-mqtt
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from cloudedge import CloudEdgeClient
|
|
13
|
+
from cloudedge.mqtt import CloudEdgeMqttListener
|
|
14
|
+
|
|
15
|
+
client = CloudEdgeClient(...)
|
|
16
|
+
client.authenticate()
|
|
17
|
+
|
|
18
|
+
def on_event(device_id, event_name, event_type, is_motion):
|
|
19
|
+
print(f"{device_id}: {event_name} motion={is_motion}")
|
|
20
|
+
|
|
21
|
+
listener = CloudEdgeMqttListener(client, on_event=on_event)
|
|
22
|
+
listener.start() # non-blocking (background thread)
|
|
23
|
+
...
|
|
24
|
+
listener.stop()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import ssl
|
|
32
|
+
from typing import Any, Callable, Dict, Optional
|
|
33
|
+
|
|
34
|
+
_LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
ALARM_TYPE_NAMES: Dict[int, str] = {
|
|
37
|
+
1: "PIR",
|
|
38
|
+
2: "Motion",
|
|
39
|
+
3: "Visitor",
|
|
40
|
+
6: "Noise",
|
|
41
|
+
7: "Baby cry",
|
|
42
|
+
8: "Face",
|
|
43
|
+
9: "Call",
|
|
44
|
+
10: "Tamper",
|
|
45
|
+
11: "Human body",
|
|
46
|
+
12: "Face detected",
|
|
47
|
+
14: "Dog bark",
|
|
48
|
+
17: "Cat",
|
|
49
|
+
18: "Pet",
|
|
50
|
+
19: "Package",
|
|
51
|
+
20: "Person",
|
|
52
|
+
21: "SD card removed",
|
|
53
|
+
39: "Fire",
|
|
54
|
+
41: "Cat meow",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
MOTION_ALARM_TYPES: frozenset[int] = frozenset({1, 2, 11, 20})
|
|
58
|
+
|
|
59
|
+
OnEventCallback = Callable[[str, str, int, bool], None]
|
|
60
|
+
"""Signature: (device_id, event_name, event_type_int, is_motion) -> None"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CloudEdgeMqttListener:
|
|
64
|
+
"""Subscribe to the CloudEdge/Meari MQTT broker for push events.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
client: An authenticated :class:`~cloudedge.client.CloudEdgeClient`.
|
|
68
|
+
on_event: Callback invoked for **every** event.
|
|
69
|
+
on_motion: Callback invoked only for motion-related events.
|
|
70
|
+
on_connect: Called when the MQTT connection is established.
|
|
71
|
+
on_disconnect: Called when the MQTT connection drops.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
client: Any,
|
|
77
|
+
*,
|
|
78
|
+
on_event: Optional[OnEventCallback] = None,
|
|
79
|
+
on_motion: Optional[OnEventCallback] = None,
|
|
80
|
+
on_connect: Optional[Callable[[], None]] = None,
|
|
81
|
+
on_disconnect: Optional[Callable[[], None]] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
mqtt_cfg = client.get_mqtt_config()
|
|
84
|
+
if not mqtt_cfg:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"MQTT config not available — call client.authenticate() first"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self._host = mqtt_cfg["mqtt_host"]
|
|
90
|
+
self._port = mqtt_cfg["mqtt_port"]
|
|
91
|
+
self._username = mqtt_cfg["mqtt_access_id"]
|
|
92
|
+
self._password = mqtt_cfg["mqtt_signature"]
|
|
93
|
+
self._user_id = str(mqtt_cfg["user_id"])
|
|
94
|
+
self._topic = (
|
|
95
|
+
f"$bsssvr/iot/{self._user_id}/{self._user_id}/event/update/accepted"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.on_event = on_event
|
|
99
|
+
self.on_motion = on_motion
|
|
100
|
+
self._on_connect_cb = on_connect
|
|
101
|
+
self._on_disconnect_cb = on_disconnect
|
|
102
|
+
|
|
103
|
+
self._client: Any = None
|
|
104
|
+
self._connected = False
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def connected(self) -> bool:
|
|
108
|
+
"""``True`` if the MQTT connection is active."""
|
|
109
|
+
return self._connected
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def topic(self) -> str:
|
|
113
|
+
"""The MQTT topic being subscribed to."""
|
|
114
|
+
return self._topic
|
|
115
|
+
|
|
116
|
+
def start(self) -> bool:
|
|
117
|
+
"""Start the MQTT background loop (non-blocking).
|
|
118
|
+
|
|
119
|
+
Returns ``True`` if the connection was initiated successfully.
|
|
120
|
+
Raises ``ImportError`` if ``paho-mqtt`` is not installed.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
import paho.mqtt.client as mqtt
|
|
124
|
+
except ImportError:
|
|
125
|
+
raise ImportError(
|
|
126
|
+
"paho-mqtt is required for MQTT support: pip install paho-mqtt"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _on_connect(client, userdata, flags, rc, *args):
|
|
130
|
+
# paho-mqtt v2 passes rc as ReasonCode object, not int
|
|
131
|
+
rc_ok = (rc == 0) if isinstance(rc, int) else rc.is_failure is False
|
|
132
|
+
if rc_ok:
|
|
133
|
+
self._connected = True
|
|
134
|
+
client.subscribe(self._topic, qos=2)
|
|
135
|
+
_LOGGER.info("MQTT connected — topic: %s", self._topic)
|
|
136
|
+
if self._on_connect_cb:
|
|
137
|
+
self._on_connect_cb()
|
|
138
|
+
else:
|
|
139
|
+
_LOGGER.warning("MQTT connect failed: rc=%s", rc)
|
|
140
|
+
|
|
141
|
+
def _on_disconnect(client, userdata, *args):
|
|
142
|
+
self._connected = False
|
|
143
|
+
_LOGGER.debug("MQTT disconnected")
|
|
144
|
+
if self._on_disconnect_cb:
|
|
145
|
+
self._on_disconnect_cb()
|
|
146
|
+
|
|
147
|
+
def _on_message(client, userdata, msg):
|
|
148
|
+
try:
|
|
149
|
+
self._dispatch(msg.payload)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
_LOGGER.debug("MQTT message parse error: %s", exc)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
client = mqtt.Client(
|
|
155
|
+
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
|
156
|
+
client_id=self._user_id,
|
|
157
|
+
clean_session=True,
|
|
158
|
+
protocol=mqtt.MQTTv311,
|
|
159
|
+
)
|
|
160
|
+
except (AttributeError, TypeError):
|
|
161
|
+
client = mqtt.Client(
|
|
162
|
+
client_id=self._user_id,
|
|
163
|
+
clean_session=True,
|
|
164
|
+
protocol=mqtt.MQTTv311,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
client.username_pw_set(self._username, self._password)
|
|
168
|
+
|
|
169
|
+
ssl_ctx = ssl.create_default_context()
|
|
170
|
+
ssl_ctx.check_hostname = False
|
|
171
|
+
ssl_ctx.verify_mode = ssl.CERT_NONE
|
|
172
|
+
client.tls_set_context(ssl_ctx)
|
|
173
|
+
|
|
174
|
+
client.on_connect = _on_connect
|
|
175
|
+
client.on_disconnect = _on_disconnect
|
|
176
|
+
client.on_message = _on_message
|
|
177
|
+
client.reconnect_delay_set(min_delay=3, max_delay=60)
|
|
178
|
+
|
|
179
|
+
self._client = client
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
client.connect_async(self._host, self._port, keepalive=300)
|
|
183
|
+
client.loop_start()
|
|
184
|
+
return True
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
_LOGGER.error("MQTT connection failed: %s", exc)
|
|
187
|
+
self._connected = False
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def stop(self) -> None:
|
|
191
|
+
"""Stop the MQTT background loop and disconnect."""
|
|
192
|
+
if self._client:
|
|
193
|
+
try:
|
|
194
|
+
self._client.loop_stop()
|
|
195
|
+
self._client.disconnect()
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
self._client = None
|
|
199
|
+
self._connected = False
|
|
200
|
+
|
|
201
|
+
def _dispatch(self, payload: bytes) -> None:
|
|
202
|
+
"""Parse an MQTT payload and invoke callbacks."""
|
|
203
|
+
try:
|
|
204
|
+
data: dict = json.loads(payload.decode("utf-8"))
|
|
205
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Unwrap nested envelope: params → data → msg
|
|
209
|
+
for key in ("params", "data"):
|
|
210
|
+
if key in data and isinstance(data[key], dict):
|
|
211
|
+
data = data[key]
|
|
212
|
+
if "msg" in data and isinstance(data["msg"], dict):
|
|
213
|
+
data = data["msg"]
|
|
214
|
+
|
|
215
|
+
evt_raw = data.get("evt", data.get("eventType", ""))
|
|
216
|
+
device_id = str(data.get("deviceID", data.get("deviceId", "")))
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
evt_int = int(evt_raw)
|
|
220
|
+
except (ValueError, TypeError):
|
|
221
|
+
evt_int = -1
|
|
222
|
+
|
|
223
|
+
evt_name = ALARM_TYPE_NAMES.get(evt_int, f"type={evt_raw}")
|
|
224
|
+
is_motion = evt_int in MOTION_ALARM_TYPES
|
|
225
|
+
|
|
226
|
+
_LOGGER.info(
|
|
227
|
+
"MQTT event: %s device=%s motion=%s",
|
|
228
|
+
evt_name, device_id, is_motion,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if self.on_event:
|
|
232
|
+
try:
|
|
233
|
+
self.on_event(device_id, evt_name, evt_int, is_motion)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
_LOGGER.debug("on_event callback error: %s", exc)
|
|
236
|
+
|
|
237
|
+
if is_motion and self.on_motion:
|
|
238
|
+
try:
|
|
239
|
+
self.on_motion(device_id, evt_name, evt_int, is_motion)
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
_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.
|
|
3
|
+
Version: 0.1.6.dev0
|
|
4
4
|
Summary: Python library for CloudEdge cameras
|
|
5
5
|
Home-page: https://github.com/fradaloisio/pycloudedge
|
|
6
6
|
Author: Francesco D'Aloisio
|
|
@@ -29,7 +29,10 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
License-File: LICENSE
|
|
30
30
|
Requires-Dist: requests>=2.25.0
|
|
31
31
|
Requires-Dist: cryptography>=3.4.0
|
|
32
|
+
Requires-Dist: pycryptodome>=3.15.0
|
|
32
33
|
Requires-Dist: python-dotenv>=0.19.0
|
|
34
|
+
Provides-Extra: mqtt
|
|
35
|
+
Requires-Dist: paho-mqtt>=2.0.0; extra == "mqtt"
|
|
33
36
|
Provides-Extra: dev
|
|
34
37
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
35
38
|
Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
|
|
@@ -34,10 +34,14 @@ classifiers = [
|
|
|
34
34
|
dependencies = [
|
|
35
35
|
"requests>=2.25.0",
|
|
36
36
|
"cryptography>=3.4.0",
|
|
37
|
+
"pycryptodome>=3.15.0",
|
|
37
38
|
"python-dotenv>=0.19.0",
|
|
38
39
|
]
|
|
39
40
|
|
|
40
41
|
[project.optional-dependencies]
|
|
42
|
+
mqtt = [
|
|
43
|
+
"paho-mqtt>=2.0.0",
|
|
44
|
+
]
|
|
41
45
|
dev = [
|
|
42
46
|
"pytest>=6.0",
|
|
43
47
|
"pytest-asyncio>=0.18.0",
|
|
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
|