pycloudedge 0.1.8__tar.gz → 0.1.9__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.8 → pycloudedge-0.1.9}/PKG-INFO +1 -1
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/_version.py +3 -3
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/client.py +46 -20
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/PKG-INFO +1 -1
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/SOURCES.txt +1 -6
- pycloudedge-0.1.9/tests/test_reliability.py +94 -0
- pycloudedge-0.1.8/.gitignore +0 -124
- pycloudedge-0.1.8/docs/plans/2026-03-30-streaming-video.md +0 -176
- pycloudedge-0.1.8/pycloudedge.egg-info/scm_file_list.json +0 -44
- pycloudedge-0.1.8/pycloudedge.egg-info/scm_version.json +0 -8
- pycloudedge-0.1.8/requirements-dev.txt +0 -11
- pycloudedge-0.1.8/test_3region_login.py +0 -58
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/.env.example +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/LICENSE +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/MANIFEST.in +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/README.md +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/__init__.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/cli.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/constants.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/exceptions.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/image_decrypt.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/iot_parameters.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/logging_config.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/mqtt.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/__init__.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/kcp_tunnel.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/meari_signaling.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/p2p_streamer.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/root_discovery.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/turn_client.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/stream_profiles.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/utils.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/validators.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/README.md +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/basic_example.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/device_control.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/network_ping_status.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/stream_test.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/dependency_links.txt +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/entry_points.txt +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/requires.txt +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/top_level.txt +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pyproject.toml +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/requirements.txt +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/setup.cfg +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/setup.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_basic.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_improvements.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_kcp_tunnel.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_mqtt.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_stream_profiles.py +0 -0
- {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_streaming_helpers.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.9'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 9)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id =
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -17,6 +17,7 @@ import subprocess
|
|
|
17
17
|
import socket
|
|
18
18
|
import ipaddress
|
|
19
19
|
import logging
|
|
20
|
+
import math
|
|
20
21
|
import tempfile
|
|
21
22
|
from typing import Dict, List, Optional, Union, Any
|
|
22
23
|
from urllib.parse import quote, urlencode
|
|
@@ -512,10 +513,15 @@ class CloudEdgeClient:
|
|
|
512
513
|
iv = "01234567".encode('utf-8')
|
|
513
514
|
|
|
514
515
|
try:
|
|
515
|
-
from cryptography.hazmat.primitives.ciphers import Cipher,
|
|
516
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, modes
|
|
516
517
|
from cryptography.hazmat.primitives import padding
|
|
517
518
|
|
|
518
|
-
|
|
519
|
+
try:
|
|
520
|
+
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
|
|
521
|
+
except ImportError: # cryptography < 43
|
|
522
|
+
from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES
|
|
523
|
+
|
|
524
|
+
algorithm = TripleDES(key)
|
|
519
525
|
cipher = Cipher(algorithm, modes.CBC(iv))
|
|
520
526
|
encryptor = cipher.encryptor()
|
|
521
527
|
|
|
@@ -606,14 +612,24 @@ class CloudEdgeClient:
|
|
|
606
612
|
with open(self.session_cache_file, 'r') as f:
|
|
607
613
|
session_data = json.load(f)
|
|
608
614
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
615
|
+
if not isinstance(session_data, dict):
|
|
616
|
+
return None
|
|
617
|
+
token = session_data.get("userToken")
|
|
618
|
+
user_id = session_data.get("userID")
|
|
619
|
+
if not isinstance(token, str) or not token or not isinstance(user_id, (str, int)) or not user_id:
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
login_time = session_data.get("loginTime")
|
|
623
|
+
if (
|
|
624
|
+
not isinstance(login_time, (int, float))
|
|
625
|
+
or not math.isfinite(login_time)
|
|
626
|
+
or not 0 <= time.time() - login_time < 86400
|
|
627
|
+
):
|
|
628
|
+
self._log("Session cache expired or has an invalid timestamp")
|
|
613
629
|
return None
|
|
614
630
|
|
|
615
631
|
return session_data
|
|
616
|
-
except (json.JSONDecodeError,
|
|
632
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
|
617
633
|
self._log("Invalid or unreadable session cache file")
|
|
618
634
|
return None
|
|
619
635
|
|
|
@@ -769,6 +785,13 @@ class CloudEdgeClient:
|
|
|
769
785
|
self.BASE_URL = self.session_data["apiServer"]
|
|
770
786
|
if self.session_data.get("openapiServer"):
|
|
771
787
|
self.OPENAPI_BASE_URL = self.session_data["openapiServer"]
|
|
788
|
+
# Older caches may predate endpoint discovery. Keep the valid token,
|
|
789
|
+
# but discover its regional URLs before making authenticated requests.
|
|
790
|
+
if not self.BASE_URL or not self.OPENAPI_BASE_URL:
|
|
791
|
+
self._discover_endpoints()
|
|
792
|
+
self.session_data["apiServer"] = self.BASE_URL
|
|
793
|
+
self.session_data["openapiServer"] = self.OPENAPI_BASE_URL
|
|
794
|
+
self._save_session_cache(self.session_data)
|
|
772
795
|
# Fetch MQTT config if not already present in the cached session
|
|
773
796
|
if not self.session_data.get("mqtt"):
|
|
774
797
|
try:
|
|
@@ -1307,7 +1330,11 @@ class CloudEdgeClient:
|
|
|
1307
1330
|
except AuthenticationError:
|
|
1308
1331
|
raise
|
|
1309
1332
|
except Exception as e:
|
|
1310
|
-
|
|
1333
|
+
# A partial inventory would make callers remove devices
|
|
1334
|
+
# (and stop streams) during a temporary API outage.
|
|
1335
|
+
raise CloudEdgeError(
|
|
1336
|
+
f"Failed to get devices from home '{home_name}': {e}"
|
|
1337
|
+
) from e
|
|
1311
1338
|
|
|
1312
1339
|
return all_devices
|
|
1313
1340
|
|
|
@@ -1993,18 +2020,17 @@ class CloudEdgeClient:
|
|
|
1993
2020
|
# First try the get_all_devices method for comprehensive search
|
|
1994
2021
|
devices = self.get_all_devices()
|
|
1995
2022
|
|
|
1996
|
-
|
|
1997
|
-
for device in devices
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2023
|
+
name = device_name.lower()
|
|
2024
|
+
matches = [device for device in devices if device.get("name", "").lower() == name]
|
|
2025
|
+
if not matches:
|
|
2026
|
+
matches = [device for device in devices if name in device.get("name", "").lower()]
|
|
2027
|
+
if len(matches) > 1:
|
|
2028
|
+
raise DeviceNotFoundError(
|
|
2029
|
+
f"Device name '{device_name}' is ambiguous; use a unique device name",
|
|
2030
|
+
details={"device_name": device_name, "matches": len(matches)},
|
|
2031
|
+
)
|
|
2032
|
+
return matches[0] if matches else None
|
|
2033
|
+
|
|
2008
2034
|
def set_device_parameter(
|
|
2009
2035
|
self,
|
|
2010
2036
|
device_name: str,
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
.env.example
|
|
2
|
-
.gitignore
|
|
3
2
|
LICENSE
|
|
4
3
|
MANIFEST.in
|
|
5
4
|
README.md
|
|
6
5
|
pyproject.toml
|
|
7
|
-
requirements-dev.txt
|
|
8
6
|
requirements.txt
|
|
9
7
|
setup.py
|
|
10
|
-
test_3region_login.py
|
|
11
8
|
cloudedge/__init__.py
|
|
12
9
|
cloudedge/_version.py
|
|
13
10
|
cloudedge/cli.py
|
|
@@ -27,7 +24,6 @@ cloudedge/p2p/meari_signaling.py
|
|
|
27
24
|
cloudedge/p2p/p2p_streamer.py
|
|
28
25
|
cloudedge/p2p/root_discovery.py
|
|
29
26
|
cloudedge/p2p/turn_client.py
|
|
30
|
-
docs/plans/2026-03-30-streaming-video.md
|
|
31
27
|
examples/README.md
|
|
32
28
|
examples/basic_example.py
|
|
33
29
|
examples/device_control.py
|
|
@@ -38,12 +34,11 @@ pycloudedge.egg-info/SOURCES.txt
|
|
|
38
34
|
pycloudedge.egg-info/dependency_links.txt
|
|
39
35
|
pycloudedge.egg-info/entry_points.txt
|
|
40
36
|
pycloudedge.egg-info/requires.txt
|
|
41
|
-
pycloudedge.egg-info/scm_file_list.json
|
|
42
|
-
pycloudedge.egg-info/scm_version.json
|
|
43
37
|
pycloudedge.egg-info/top_level.txt
|
|
44
38
|
tests/test_basic.py
|
|
45
39
|
tests/test_improvements.py
|
|
46
40
|
tests/test_kcp_tunnel.py
|
|
47
41
|
tests/test_mqtt.py
|
|
42
|
+
tests/test_reliability.py
|
|
48
43
|
tests/test_stream_profiles.py
|
|
49
44
|
tests/test_streaming_helpers.py
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Regressions for malformed caches and incomplete device discovery."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from unittest.mock import Mock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from cloudedge import CloudEdgeClient, CloudEdgeError, NetworkError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def client(tmp_path):
|
|
13
|
+
return CloudEdgeClient(
|
|
14
|
+
"user@example.test", "password", "IT", "+39",
|
|
15
|
+
session_cache_file=str(tmp_path / "session.json"),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.parametrize("data", [
|
|
20
|
+
[], ["token"], None, "invalid", 42,
|
|
21
|
+
*({"loginTime": stamp, "userToken": "token", "userID": "id"}
|
|
22
|
+
for stamp in ("yesterday", None, float("nan"), float("inf"))),
|
|
23
|
+
{"loginTime": time.time() + 86400, "userToken": "token", "userID": "id"},
|
|
24
|
+
{"loginTime": time.time()},
|
|
25
|
+
{"loginTime": time.time(), "userToken": "token"},
|
|
26
|
+
{"loginTime": time.time(), "userToken": 123, "userID": "id"},
|
|
27
|
+
])
|
|
28
|
+
def test_invalid_cache_is_ignored(client, data):
|
|
29
|
+
with open(client.session_cache_file, "w") as file:
|
|
30
|
+
json.dump(data, file)
|
|
31
|
+
assert client._load_session_cache() is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_valid_cache_is_reused(client):
|
|
35
|
+
session = {"loginTime": time.time(), "userToken": "token", "userID": "id"}
|
|
36
|
+
client._save_session_cache(session)
|
|
37
|
+
assert client._load_session_cache() == session
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_legacy_cache_discovers_missing_endpoints_without_logging_in(client):
|
|
41
|
+
client._save_session_cache({
|
|
42
|
+
"loginTime": time.time(), "userToken": "token", "userID": "id", "mqtt": {"host": "host"},
|
|
43
|
+
})
|
|
44
|
+
def discover():
|
|
45
|
+
client.BASE_URL = "https://api.example.test"
|
|
46
|
+
client.OPENAPI_BASE_URL = "https://openapi.example.test"
|
|
47
|
+
client._discover_endpoints = Mock(side_effect=discover)
|
|
48
|
+
client._session.post = Mock(side_effect=AssertionError("Must reuse the token"))
|
|
49
|
+
assert client.authenticate() is True
|
|
50
|
+
assert client.BASE_URL == "https://api.example.test"
|
|
51
|
+
assert client.OPENAPI_BASE_URL == "https://openapi.example.test"
|
|
52
|
+
assert client._load_session_cache()["apiServer"] == client.BASE_URL
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_failed_home_discovery_does_not_return_partial_inventory(client):
|
|
56
|
+
client.session_data = {"userToken": "token"}
|
|
57
|
+
client.get_devices = Mock(return_value=[])
|
|
58
|
+
client.get_homes = Mock(return_value=[
|
|
59
|
+
{"home_id": "a", "name": "A"}, {"home_id": "b", "name": "B"},
|
|
60
|
+
])
|
|
61
|
+
client.get_devices_by_home = Mock(side_effect=[
|
|
62
|
+
[{"serial_number": "sn-a"}], NetworkError("temporarily unavailable"),
|
|
63
|
+
])
|
|
64
|
+
with pytest.raises(CloudEdgeError, match="temporarily unavailable"):
|
|
65
|
+
client.get_all_devices()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize("names, query", [
|
|
69
|
+
(["Camera", "Camera"], "Camera"),
|
|
70
|
+
(["Camera North", "Camera South"], "Camera"),
|
|
71
|
+
])
|
|
72
|
+
def test_ambiguous_device_names_do_not_control_arbitrary_camera(client, names, query):
|
|
73
|
+
from cloudedge import DeviceNotFoundError
|
|
74
|
+
client.get_all_devices = Mock(return_value=[{"name": name} for name in names])
|
|
75
|
+
client.set_device_config = Mock()
|
|
76
|
+
with pytest.raises(DeviceNotFoundError, match="ambiguous"):
|
|
77
|
+
client.set_device_parameter(query, "LED_ENABLE", 1)
|
|
78
|
+
client.set_device_config.assert_not_called()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_exact_name_takes_precedence_over_partial_match(client):
|
|
82
|
+
exact = {"name": "Camera", "serial_number": "sn"}
|
|
83
|
+
client.get_all_devices = Mock(return_value=[{"name": "Camera North"}, exact])
|
|
84
|
+
assert client.find_device_by_name("camera") == exact
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_password_encryption_matches_independent_3des_implementation(client):
|
|
88
|
+
import base64
|
|
89
|
+
from Crypto.Cipher import DES
|
|
90
|
+
from Crypto.Util.Padding import pad
|
|
91
|
+
# The vendor key repeats the same 8-byte block, so EDE reduces to DES.
|
|
92
|
+
cipher = DES.new(b"12345678", DES.MODE_CBC, b"01234567")
|
|
93
|
+
expected = base64.b64encode(cipher.encrypt(pad(b"password", 8))).decode()
|
|
94
|
+
assert client._des_encode("password") == expected
|
pycloudedge-0.1.8/.gitignore
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
# Byte-compiled / optimized / DLL files
|
|
2
|
-
__pycache__/
|
|
3
|
-
*.py[cod]
|
|
4
|
-
*$py.class
|
|
5
|
-
|
|
6
|
-
# C extensions
|
|
7
|
-
*.so
|
|
8
|
-
|
|
9
|
-
# Distribution / packaging
|
|
10
|
-
.Python
|
|
11
|
-
build/
|
|
12
|
-
develop-eggs/
|
|
13
|
-
dist/
|
|
14
|
-
downloads/
|
|
15
|
-
eggs/
|
|
16
|
-
.eggs/
|
|
17
|
-
lib/
|
|
18
|
-
lib64/
|
|
19
|
-
parts/
|
|
20
|
-
sdist/
|
|
21
|
-
var/
|
|
22
|
-
wheels/
|
|
23
|
-
*.egg-info/
|
|
24
|
-
.installed.cfg
|
|
25
|
-
*.egg
|
|
26
|
-
MANIFEST
|
|
27
|
-
|
|
28
|
-
# PyInstaller
|
|
29
|
-
# Usually these files are written by a python script from a template
|
|
30
|
-
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
31
|
-
*.manifest
|
|
32
|
-
*.spec
|
|
33
|
-
|
|
34
|
-
# Installer logs
|
|
35
|
-
pip-log.txt
|
|
36
|
-
pip-delete-this-directory.txt
|
|
37
|
-
|
|
38
|
-
# Unit test / coverage reports
|
|
39
|
-
htmlcov/
|
|
40
|
-
.tox/
|
|
41
|
-
.nox/
|
|
42
|
-
.coverage
|
|
43
|
-
.coverage.*
|
|
44
|
-
.cache
|
|
45
|
-
nosetests.xml
|
|
46
|
-
coverage.xml
|
|
47
|
-
*.cover
|
|
48
|
-
.hypothesis/
|
|
49
|
-
.pytest_cache/
|
|
50
|
-
|
|
51
|
-
# Translations
|
|
52
|
-
*.mo
|
|
53
|
-
*.pot
|
|
54
|
-
|
|
55
|
-
# Django stuff:
|
|
56
|
-
*.log
|
|
57
|
-
local_settings.py
|
|
58
|
-
db.sqlite3
|
|
59
|
-
|
|
60
|
-
# Flask stuff:
|
|
61
|
-
instance/
|
|
62
|
-
.webassets-cache
|
|
63
|
-
|
|
64
|
-
# Scrapy stuff:
|
|
65
|
-
.scrapy
|
|
66
|
-
|
|
67
|
-
# Sphinx documentation
|
|
68
|
-
docs/_build/
|
|
69
|
-
|
|
70
|
-
# PyBuilder
|
|
71
|
-
target/
|
|
72
|
-
|
|
73
|
-
# Jupyter Notebook
|
|
74
|
-
.ipynb_checkpoints
|
|
75
|
-
|
|
76
|
-
# IPython
|
|
77
|
-
profile_default/
|
|
78
|
-
ipython_config.py
|
|
79
|
-
|
|
80
|
-
# pyenv
|
|
81
|
-
.python-version
|
|
82
|
-
|
|
83
|
-
# celery beat schedule file
|
|
84
|
-
celerybeat-schedule
|
|
85
|
-
|
|
86
|
-
# SageMath parsed files
|
|
87
|
-
*.sage.py
|
|
88
|
-
|
|
89
|
-
# Environments
|
|
90
|
-
.env
|
|
91
|
-
.venv
|
|
92
|
-
env/
|
|
93
|
-
venv/
|
|
94
|
-
ENV/
|
|
95
|
-
env.bak/
|
|
96
|
-
venv.bak/
|
|
97
|
-
.env_*
|
|
98
|
-
|
|
99
|
-
# Spyder project settings
|
|
100
|
-
.spyderproject
|
|
101
|
-
.spyproject
|
|
102
|
-
|
|
103
|
-
# Rope project settings
|
|
104
|
-
.ropeproject
|
|
105
|
-
|
|
106
|
-
# mkdocs documentation
|
|
107
|
-
/site
|
|
108
|
-
|
|
109
|
-
# mypy
|
|
110
|
-
.mypy_cache/
|
|
111
|
-
.dmypy.json
|
|
112
|
-
dmypy.json
|
|
113
|
-
|
|
114
|
-
# Pyre type checker
|
|
115
|
-
.pyre/
|
|
116
|
-
|
|
117
|
-
# setuptools_scm generated version file
|
|
118
|
-
cloudedge/_version.py
|
|
119
|
-
|
|
120
|
-
# CloudEdge specific
|
|
121
|
-
.cloudedge_session_cache
|
|
122
|
-
.home_id
|
|
123
|
-
|
|
124
|
-
publish.sh
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
# Streaming Video Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
-
|
|
5
|
-
**Goal:** Deliver a real end-to-end live video stream for the Garage camera on branch `streming_video`, first in `pycloudedge`, then through `cloudedge-ha`.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Keep `pycloudedge` as the proprietary P2P/HEVC extractor and keep `cloudedge-ha` as the Home Assistant bridge that remuxes raw HEVC into a local MPEG-TS `stream_source`. Validate the path incrementally: baseline tests, direct Garage streaming, then Home Assistant bridge integration.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Python, pytest, CloudEdge OpenAPI + proprietary P2P signaling, HEVC, ffmpeg, Home Assistant camera `stream` integration.
|
|
10
|
-
|
|
11
|
-
### Task 1: Restore a trustworthy `pycloudedge` baseline
|
|
12
|
-
|
|
13
|
-
**Files:**
|
|
14
|
-
- Modify: `tests/test_basic.py`
|
|
15
|
-
- Test: `tests/test_basic.py::TestAsyncMethods::test_client_methods_with_mock`
|
|
16
|
-
- Test: `tests/test_basic.py`
|
|
17
|
-
|
|
18
|
-
**Step 1: Confirm the failing test is a real baseline regression**
|
|
19
|
-
|
|
20
|
-
Run: `.venv/bin/pytest tests/test_basic.py::TestAsyncMethods::test_client_methods_with_mock -v`
|
|
21
|
-
Expected: FAIL with `ValidationError: Invalid email format: test`
|
|
22
|
-
|
|
23
|
-
**Step 2: Apply the minimal fix in the test**
|
|
24
|
-
|
|
25
|
-
Replace the invalid username with a valid email literal already accepted by the constructor.
|
|
26
|
-
|
|
27
|
-
```python
|
|
28
|
-
client = CloudEdgeClient("test@example.com", "test", "US", "+1")
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
**Step 3: Re-run the targeted test**
|
|
32
|
-
|
|
33
|
-
Run: `.venv/bin/pytest tests/test_basic.py::TestAsyncMethods::test_client_methods_with_mock -v`
|
|
34
|
-
Expected: PASS
|
|
35
|
-
|
|
36
|
-
**Step 4: Re-run the full `pycloudedge` suite**
|
|
37
|
-
|
|
38
|
-
Run: `.venv/bin/pytest`
|
|
39
|
-
Expected: all tests pass, or only pre-existing warnings remain
|
|
40
|
-
|
|
41
|
-
### Task 2: Make direct Garage streaming reproducible from `pycloudedge`
|
|
42
|
-
|
|
43
|
-
**Files:**
|
|
44
|
-
- Modify: `tests/test_improvements.py` or create `tests/test_streaming_helpers.py`
|
|
45
|
-
- Modify if needed: `examples/stream_test.py`
|
|
46
|
-
- Modify if needed: `cloudedge/client.py`
|
|
47
|
-
- Modify if needed: `cloudedge/p2p/p2p_streamer.py`
|
|
48
|
-
|
|
49
|
-
**Step 1: Write a failing helper-level test for the smallest bug you need to fix**
|
|
50
|
-
|
|
51
|
-
Examples:
|
|
52
|
-
- Annex B conversion for one access unit
|
|
53
|
-
- Keyframe gating behavior
|
|
54
|
-
- signaling candidate ordering
|
|
55
|
-
- phone code normalization or env parsing if that blocks the real script
|
|
56
|
-
|
|
57
|
-
Use a focused unit test instead of mocking the whole live session.
|
|
58
|
-
|
|
59
|
-
**Step 2: Run the focused test and watch it fail**
|
|
60
|
-
|
|
61
|
-
Run: `.venv/bin/pytest <targeted test path> -v`
|
|
62
|
-
Expected: FAIL for the exact missing behavior
|
|
63
|
-
|
|
64
|
-
**Step 3: Implement the minimal code change**
|
|
65
|
-
|
|
66
|
-
Touch only the production function proved by the test.
|
|
67
|
-
|
|
68
|
-
**Step 4: Re-run the focused test and then the full suite**
|
|
69
|
-
|
|
70
|
-
Run:
|
|
71
|
-
- `.venv/bin/pytest <targeted test path> -v`
|
|
72
|
-
- `.venv/bin/pytest`
|
|
73
|
-
|
|
74
|
-
Expected: PASS
|
|
75
|
-
|
|
76
|
-
**Step 5: Validate against the real Garage camera**
|
|
77
|
-
|
|
78
|
-
Run in order:
|
|
79
|
-
- `.venv/bin/pip install -e .[examples,mqtt]`
|
|
80
|
-
- `.venv/bin/python examples/stream_test.py /Users/fdaloisio/mygit/pycloudedge/.env`
|
|
81
|
-
|
|
82
|
-
Success criteria:
|
|
83
|
-
- authentication succeeds
|
|
84
|
-
- Garage is selected
|
|
85
|
-
- stream login succeeds
|
|
86
|
-
- continuous video frames are received
|
|
87
|
-
|
|
88
|
-
If playback inspection is needed and the environment supports it:
|
|
89
|
-
- `.venv/bin/python examples/stream_test.py /Users/fdaloisio/mygit/pycloudedge/.env --ffplay`
|
|
90
|
-
|
|
91
|
-
### Task 3: Capture objective evidence that the live stream is real
|
|
92
|
-
|
|
93
|
-
**Files:**
|
|
94
|
-
- Create if needed: `examples/stream_record.py`
|
|
95
|
-
- Modify if needed: `examples/stream_test.py`
|
|
96
|
-
|
|
97
|
-
**Step 1: Write a failing test for any new helper introduced for recording/probing**
|
|
98
|
-
|
|
99
|
-
If you need a new helper to write frames to `ffmpeg`, test that helper first. Keep the test offline and deterministic.
|
|
100
|
-
|
|
101
|
-
**Step 2: Implement the smallest helper needed**
|
|
102
|
-
|
|
103
|
-
Examples:
|
|
104
|
-
- write Annex B frames to ffmpeg stdin
|
|
105
|
-
- stop after first valid keyframe plus N seconds
|
|
106
|
-
- emit a non-secret summary line for verification
|
|
107
|
-
|
|
108
|
-
**Step 3: Run a real recording/probe command**
|
|
109
|
-
|
|
110
|
-
Examples:
|
|
111
|
-
- `.venv/bin/python examples/stream_test.py /Users/fdaloisio/mygit/pycloudedge/.env`
|
|
112
|
-
- `ffmpeg -hide_banner -loglevel warning -f hevc -i <captured stream> -t 10 -f null -`
|
|
113
|
-
- `ffprobe <captured artifact>`
|
|
114
|
-
|
|
115
|
-
Expected: ffmpeg/ffprobe recognizes a valid video stream and reports video packets/frames.
|
|
116
|
-
|
|
117
|
-
### Task 4: Verify and harden the Home Assistant bridge path
|
|
118
|
-
|
|
119
|
-
**Files:**
|
|
120
|
-
- Modify if needed: `/Users/fdaloisio/.config/superpowers/worktrees/cloudedge-ha/streming_video/custom_components/cloudedge/stream_bridge.py`
|
|
121
|
-
- Modify if needed: `/Users/fdaloisio/.config/superpowers/worktrees/cloudedge-ha/streming_video/custom_components/cloudedge/camera.py`
|
|
122
|
-
- Modify if needed: `/Users/fdaloisio/.config/superpowers/worktrees/cloudedge-ha/streming_video/custom_components/cloudedge/__init__.py`
|
|
123
|
-
|
|
124
|
-
**Step 1: Write a failing unit test for the smallest HA-side bug you discover**
|
|
125
|
-
|
|
126
|
-
Focus on pure logic where possible:
|
|
127
|
-
- `stream_source()` behavior
|
|
128
|
-
- keyframe gating
|
|
129
|
-
- bootstrap keyframe handling
|
|
130
|
-
- bridge lifecycle / idle stop behavior
|
|
131
|
-
|
|
132
|
-
**Step 2: Run the targeted test and watch it fail**
|
|
133
|
-
|
|
134
|
-
Use the project’s chosen Python test command once test scaffolding exists.
|
|
135
|
-
|
|
136
|
-
**Step 3: Implement the minimal bridge fix**
|
|
137
|
-
|
|
138
|
-
Keep the architecture unchanged: `pycloudedge` provides raw frames, `cloudedge-ha` remuxes them and exposes `tcp://127.0.0.1:<port>`.
|
|
139
|
-
|
|
140
|
-
**Step 4: Validate the runtime path**
|
|
141
|
-
|
|
142
|
-
At minimum verify:
|
|
143
|
-
- `async_get_stream_source()` returns a local TCP source
|
|
144
|
-
- `ffmpeg` starts successfully
|
|
145
|
-
- the bridge accepts a client and emits MPEG-TS bytes
|
|
146
|
-
|
|
147
|
-
If a local HA harness must be created, keep it minimal and non-destructive.
|
|
148
|
-
|
|
149
|
-
### Task 5: Document and verify the finished streaming path
|
|
150
|
-
|
|
151
|
-
**Files:**
|
|
152
|
-
- Modify: `README.md` in `pycloudedge` if examples change
|
|
153
|
-
- Modify: `README.md` in `cloudedge-ha` if runtime requirements or caveats change
|
|
154
|
-
- Modify: `docs/plans/2026-03-30-streaming-video.md`
|
|
155
|
-
|
|
156
|
-
**Step 1: Re-run all relevant automated checks**
|
|
157
|
-
|
|
158
|
-
Run:
|
|
159
|
-
- `.venv/bin/pytest`
|
|
160
|
-
- any new targeted HA-side tests
|
|
161
|
-
|
|
162
|
-
**Step 2: Re-run the real Garage validation**
|
|
163
|
-
|
|
164
|
-
Run the final direct streaming command again and confirm frame/stream evidence still holds.
|
|
165
|
-
|
|
166
|
-
**Step 3: Update docs with the exact supported path**
|
|
167
|
-
|
|
168
|
-
Document:
|
|
169
|
-
- branch requirements
|
|
170
|
-
- local dependency requirements (`ffmpeg`, local `pycloudedge` build)
|
|
171
|
-
- what works now
|
|
172
|
-
- what remains unsupported, especially audio if still absent
|
|
173
|
-
|
|
174
|
-
**Step 4: Record final verification evidence**
|
|
175
|
-
|
|
176
|
-
Capture the exact commands run and the observed non-secret success signals so the work can be reproduced.
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"files": [
|
|
3
|
-
".env.example",
|
|
4
|
-
".gitignore",
|
|
5
|
-
"LICENSE",
|
|
6
|
-
"MANIFEST.in",
|
|
7
|
-
"README.md",
|
|
8
|
-
"cloudedge/__init__.py",
|
|
9
|
-
"cloudedge/cli.py",
|
|
10
|
-
"cloudedge/client.py",
|
|
11
|
-
"cloudedge/constants.py",
|
|
12
|
-
"cloudedge/exceptions.py",
|
|
13
|
-
"cloudedge/image_decrypt.py",
|
|
14
|
-
"cloudedge/iot_parameters.py",
|
|
15
|
-
"cloudedge/logging_config.py",
|
|
16
|
-
"cloudedge/mqtt.py",
|
|
17
|
-
"cloudedge/p2p/__init__.py",
|
|
18
|
-
"cloudedge/p2p/kcp_tunnel.py",
|
|
19
|
-
"cloudedge/p2p/meari_signaling.py",
|
|
20
|
-
"cloudedge/p2p/p2p_streamer.py",
|
|
21
|
-
"cloudedge/p2p/root_discovery.py",
|
|
22
|
-
"cloudedge/p2p/turn_client.py",
|
|
23
|
-
"cloudedge/stream_profiles.py",
|
|
24
|
-
"cloudedge/utils.py",
|
|
25
|
-
"cloudedge/validators.py",
|
|
26
|
-
"docs/plans/2026-03-30-streaming-video.md",
|
|
27
|
-
"examples/README.md",
|
|
28
|
-
"examples/basic_example.py",
|
|
29
|
-
"examples/device_control.py",
|
|
30
|
-
"examples/network_ping_status.py",
|
|
31
|
-
"examples/stream_test.py",
|
|
32
|
-
"pyproject.toml",
|
|
33
|
-
"requirements-dev.txt",
|
|
34
|
-
"requirements.txt",
|
|
35
|
-
"setup.py",
|
|
36
|
-
"test_3region_login.py",
|
|
37
|
-
"tests/test_basic.py",
|
|
38
|
-
"tests/test_improvements.py",
|
|
39
|
-
"tests/test_kcp_tunnel.py",
|
|
40
|
-
"tests/test_mqtt.py",
|
|
41
|
-
"tests/test_stream_profiles.py",
|
|
42
|
-
"tests/test_streaming_helpers.py"
|
|
43
|
-
]
|
|
44
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Test login with 3 region accounts after endpoint refactoring."""
|
|
3
|
-
import sys, os
|
|
4
|
-
|
|
5
|
-
PROJECT_ROOT = "/Users/fdaloisio/mygit/pycloudedge"
|
|
6
|
-
sys.path.insert(0, PROJECT_ROOT)
|
|
7
|
-
|
|
8
|
-
from cloudedge import CloudEdgeClient
|
|
9
|
-
|
|
10
|
-
ACCOUNTS = [
|
|
11
|
-
(".env", "EU/Italy"),
|
|
12
|
-
(".env_usa", "US"),
|
|
13
|
-
(".env_australia", "Australia"),
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
def load_env(path):
|
|
17
|
-
env = {}
|
|
18
|
-
with open(path) as f:
|
|
19
|
-
for line in f:
|
|
20
|
-
line = line.strip()
|
|
21
|
-
if line and not line.startswith("#") and "=" in line:
|
|
22
|
-
k, v = line.split("=", 1)
|
|
23
|
-
env[k] = v
|
|
24
|
-
return env
|
|
25
|
-
|
|
26
|
-
for env_file, label in ACCOUNTS:
|
|
27
|
-
env_path = os.path.join(PROJECT_ROOT, env_file)
|
|
28
|
-
env = load_env(env_path)
|
|
29
|
-
# Use a separate cache file per account to avoid cross-contamination
|
|
30
|
-
cache_file = f"/tmp/.cloudedge_cache_{env['CLOUDEDGE_COUNTRY_CODE'].lower()}"
|
|
31
|
-
# Remove old cache to force fresh discovery
|
|
32
|
-
if os.path.exists(cache_file):
|
|
33
|
-
os.remove(cache_file)
|
|
34
|
-
print(f"\n{'='*60}")
|
|
35
|
-
print(f" Testing {label} ({env['CLOUDEDGE_COUNTRY_CODE']})")
|
|
36
|
-
print(f"{'='*60}")
|
|
37
|
-
try:
|
|
38
|
-
client = CloudEdgeClient(
|
|
39
|
-
username=env["CLOUDEDGE_USERNAME"],
|
|
40
|
-
password=env["CLOUDEDGE_PASSWORD"],
|
|
41
|
-
country_code=env["CLOUDEDGE_COUNTRY_CODE"],
|
|
42
|
-
phone_code=env["CLOUDEDGE_PHONE_CODE"],
|
|
43
|
-
debug=True,
|
|
44
|
-
session_cache_file=cache_file,
|
|
45
|
-
)
|
|
46
|
-
success = client.authenticate()
|
|
47
|
-
print(f" ✅ Login OK")
|
|
48
|
-
print(f" BASE_URL: {client.BASE_URL}")
|
|
49
|
-
print(f" OPENAPI_BASE_URL: {client.OPENAPI_BASE_URL}")
|
|
50
|
-
|
|
51
|
-
# Get list of cameras
|
|
52
|
-
devices = client.get_devices()
|
|
53
|
-
print(f" Found {len(devices)} devices:")
|
|
54
|
-
for dev in devices:
|
|
55
|
-
status = "Online" if dev.get("online") else "Offline"
|
|
56
|
-
print(f" - {dev.get('name', 'Unknown')} (SN: {dev.get('serial_number', 'Unknown')}) [{status}]")
|
|
57
|
-
except Exception as e:
|
|
58
|
-
print(f" ❌ Login FAILED: {e}")
|
|
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
|
|
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
|