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.
Files changed (52) hide show
  1. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/PKG-INFO +1 -1
  2. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/_version.py +3 -3
  3. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/client.py +46 -20
  4. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/PKG-INFO +1 -1
  5. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/SOURCES.txt +1 -6
  6. pycloudedge-0.1.9/tests/test_reliability.py +94 -0
  7. pycloudedge-0.1.8/.gitignore +0 -124
  8. pycloudedge-0.1.8/docs/plans/2026-03-30-streaming-video.md +0 -176
  9. pycloudedge-0.1.8/pycloudedge.egg-info/scm_file_list.json +0 -44
  10. pycloudedge-0.1.8/pycloudedge.egg-info/scm_version.json +0 -8
  11. pycloudedge-0.1.8/requirements-dev.txt +0 -11
  12. pycloudedge-0.1.8/test_3region_login.py +0 -58
  13. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/.env.example +0 -0
  14. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/LICENSE +0 -0
  15. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/MANIFEST.in +0 -0
  16. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/README.md +0 -0
  17. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/__init__.py +0 -0
  18. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/cli.py +0 -0
  19. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/constants.py +0 -0
  20. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/exceptions.py +0 -0
  21. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/image_decrypt.py +0 -0
  22. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/iot_parameters.py +0 -0
  23. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/logging_config.py +0 -0
  24. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/mqtt.py +0 -0
  25. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/__init__.py +0 -0
  26. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/kcp_tunnel.py +0 -0
  27. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/meari_signaling.py +0 -0
  28. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/p2p_streamer.py +0 -0
  29. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/root_discovery.py +0 -0
  30. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/p2p/turn_client.py +0 -0
  31. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/stream_profiles.py +0 -0
  32. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/utils.py +0 -0
  33. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/cloudedge/validators.py +0 -0
  34. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/README.md +0 -0
  35. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/basic_example.py +0 -0
  36. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/device_control.py +0 -0
  37. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/network_ping_status.py +0 -0
  38. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/examples/stream_test.py +0 -0
  39. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/dependency_links.txt +0 -0
  40. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/entry_points.txt +0 -0
  41. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/requires.txt +0 -0
  42. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pycloudedge.egg-info/top_level.txt +0 -0
  43. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/pyproject.toml +0 -0
  44. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/requirements.txt +0 -0
  45. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/setup.cfg +0 -0
  46. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/setup.py +0 -0
  47. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_basic.py +0 -0
  48. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_improvements.py +0 -0
  49. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_kcp_tunnel.py +0 -0
  50. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_mqtt.py +0 -0
  51. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_stream_profiles.py +0 -0
  52. {pycloudedge-0.1.8 → pycloudedge-0.1.9}/tests/test_streaming_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -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.8'
22
- __version_tuple__ = version_tuple = (0, 1, 8)
21
+ __version__ = version = '0.1.9'
22
+ __version_tuple__ = version_tuple = (0, 1, 9)
23
23
 
24
- __commit_id__ = commit_id = 'g47e440bf5'
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, algorithms, modes
516
+ from cryptography.hazmat.primitives.ciphers import Cipher, modes
516
517
  from cryptography.hazmat.primitives import padding
517
518
 
518
- algorithm = algorithms.TripleDES(key)
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
- # Check if session is still valid (not older than 24 hours)
610
- login_time = session_data.get('loginTime', 0)
611
- if time.time() - login_time > 86400: # 24 hours
612
- self._log("Session cache expired")
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, KeyError, OSError):
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
- self._log(f"Failed to get devices from home '{home_name}': {e}")
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
- # Search for exact match first
1997
- for device in devices:
1998
- if device.get('name', '').lower() == device_name.lower():
1999
- return device
2000
-
2001
- # If exact match not found, try partial match
2002
- for device in devices:
2003
- if device_name.lower() in device.get('name', '').lower():
2004
- return device
2005
-
2006
- return None
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -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
@@ -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,8 +0,0 @@
1
- {
2
- "tag": "0.1.8",
3
- "distance": 0,
4
- "node": "g47e440bf58e0591d17669ec5680062e56e5c1862",
5
- "dirty": false,
6
- "branch": "release/v0.1.8",
7
- "node_date": "2026-07-22"
8
- }
@@ -1,11 +0,0 @@
1
- # Core dependencies for CloudEdge API library
2
- requests>=2.25.0
3
- cryptography>=3.4.0
4
-
5
- # Optional dependencies for examples
6
- python-dotenv>=0.19.0\
7
-
8
- # dev dependencies
9
- pytest>=6.0.0
10
- twine>=6.2.0
11
- build>=1.3.0
@@ -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