pycloudedge 0.1.6.dev2__tar.gz → 0.1.6.dev4__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 (39) hide show
  1. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/PKG-INFO +1 -1
  2. pycloudedge-0.1.6.dev4/cloudedge/_version.py +24 -0
  3. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/client.py +24 -22
  4. pycloudedge-0.1.6.dev4/cloudedge/constants.py +35 -0
  5. pycloudedge-0.1.6.dev4/docs/plans/2026-03-30-streaming-video.md +176 -0
  6. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/pycloudedge.egg-info/PKG-INFO +1 -1
  7. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/pycloudedge.egg-info/SOURCES.txt +2 -0
  8. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/pyproject.toml +2 -1
  9. pycloudedge-0.1.6.dev4/test_3region_login.py +58 -0
  10. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/tests/test_basic.py +47 -46
  11. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/tests/test_improvements.py +9 -0
  12. pycloudedge-0.1.6.dev2/cloudedge/_version.py +0 -34
  13. pycloudedge-0.1.6.dev2/cloudedge/constants.py +0 -101
  14. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/.env.example +0 -0
  15. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/.gitignore +0 -0
  16. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/LICENSE +0 -0
  17. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/MANIFEST.in +0 -0
  18. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/README.md +0 -0
  19. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/__init__.py +0 -0
  20. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/cli.py +0 -0
  21. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/exceptions.py +0 -0
  22. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/image_decrypt.py +0 -0
  23. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/iot_parameters.py +0 -0
  24. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/logging_config.py +0 -0
  25. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/mqtt.py +0 -0
  26. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/utils.py +0 -0
  27. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/cloudedge/validators.py +0 -0
  28. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/examples/README.md +0 -0
  29. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/examples/basic_example.py +0 -0
  30. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/examples/device_control.py +0 -0
  31. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/examples/network_ping_status.py +0 -0
  32. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/pycloudedge.egg-info/dependency_links.txt +0 -0
  33. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/pycloudedge.egg-info/entry_points.txt +0 -0
  34. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/pycloudedge.egg-info/requires.txt +0 -0
  35. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/pycloudedge.egg-info/top_level.txt +0 -0
  36. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/requirements-dev.txt +0 -0
  37. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/requirements.txt +0 -0
  38. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/setup.cfg +0 -0
  39. {pycloudedge-0.1.6.dev2 → pycloudedge-0.1.6.dev4}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.6.dev2
3
+ Version: 0.1.6.dev4
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.6.dev4'
22
+ __version_tuple__ = version_tuple = (0, 1, 6, 'dev4')
23
+
24
+ __commit_id__ = commit_id = 'ge76f5b0fa'
@@ -37,9 +37,7 @@ from .iot_parameters import (
37
37
  )
38
38
  from .constants import (
39
39
  CA_KEY, CA_SECRET, DEFAULT_HEADERS, DEFAULT_TIMEOUT,
40
- REGION_ENDPOINTS, REGION_EU, REGION_US, REGION_AP,
41
40
  REDIRECT_URL,
42
- region_for_country,
43
41
  )
44
42
  from .validators import validate_email, validate_country_code, validate_phone_code
45
43
  from .logging_config import get_logger
@@ -132,7 +130,6 @@ class CloudEdgeClient:
132
130
  session_cache_file: str = ".cloudedge_session_cache",
133
131
  enable_network_ping: bool = True,
134
132
  ping_timeout: float = 2.0,
135
- region: Optional[str] = None,
136
133
  ):
137
134
  """
138
135
  Initialize CloudEdge API client.
@@ -146,10 +143,6 @@ class CloudEdgeClient:
146
143
  session_cache_file (str): Path to session cache file
147
144
  enable_network_ping (bool): Enable ping-based online status when on same network
148
145
  ping_timeout (float): Ping timeout in seconds
149
- region (str, optional): Force a specific region ("eu", "us", or "ap").
150
- When *None* the region is derived automatically from *country_code*:
151
- European countries → EU, Asia-Pacific/Oceania/ME/Africa → AP
152
- (Singapore), everything else → US.
153
146
 
154
147
  Raises:
155
148
  ValidationError: If input validation fails
@@ -173,17 +166,18 @@ class CloudEdgeClient:
173
166
  details={"field": "phone_code", "value": phone_code}
174
167
  )
175
168
 
169
+ normalized_country_code = country_code.upper()
170
+ if normalized_country_code == "UK":
171
+ normalized_country_code = "GB"
172
+
176
173
  self.username = username
177
174
  self.password = password
178
- self.country_code = country_code.upper()
175
+ self.country_code = normalized_country_code
179
176
  self.phone_code = phone_code if phone_code.startswith('+') else f'+{phone_code}'
180
177
 
181
- # Resolve region and endpoints
182
- resolved_region = region if region in REGION_ENDPOINTS else region_for_country(self.country_code)
183
- endpoints = REGION_ENDPOINTS[resolved_region]
184
- self.region = resolved_region
185
- self.BASE_URL = endpoints["base_url"]
186
- self.OPENAPI_BASE_URL = endpoints["openapi_base_url"]
178
+ # Endpoints are discovered dynamically via _discover_endpoints()
179
+ self.BASE_URL: Optional[str] = None
180
+ self.OPENAPI_BASE_URL: Optional[str] = None
187
181
 
188
182
  # Setup proper logging
189
183
  self.logger = get_logger("client")
@@ -557,8 +551,11 @@ class CloudEdgeClient:
557
551
  contains the regional ``apiServer`` and ``openapi`` domain that must be
558
552
  used for the given account/country.
559
553
 
560
- On success, ``self.BASE_URL`` and ``self.OPENAPI_BASE_URL`` are updated.
561
- On failure the previously configured (static) endpoints are kept.
554
+ On success, ``self.BASE_URL`` and ``self.OPENAPI_BASE_URL`` are set.
555
+
556
+ Raises:
557
+ NetworkError: If the redirect API call fails or returns an
558
+ unexpected response and no endpoints were previously set.
562
559
  """
563
560
  import random
564
561
 
@@ -593,9 +590,6 @@ class CloudEdgeClient:
593
590
  ).hexdigest()
594
591
 
595
592
  # 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
593
  ca_sign_data = (
600
594
  f"api=/ppstrongs//ppstrongs/redirect"
601
595
  f"|X-Ca-Key={CA_KEY}"
@@ -620,8 +614,9 @@ class CloudEdgeClient:
620
614
  )
621
615
  data = resp.json()
622
616
  if data.get("resultCode") != "1001":
623
- self._log(f"Redirect discovery returned {data.get('resultCode')}")
624
- return
617
+ raise NetworkError(
618
+ f"Redirect discovery returned unexpected code: {data.get('resultCode')}"
619
+ )
625
620
 
626
621
  result = data.get("result", {})
627
622
  api_server = result.get("apiServer") or result.get("gwUrl") or ""
@@ -635,8 +630,15 @@ class CloudEdgeClient:
635
630
  self.OPENAPI_BASE_URL = openapi_domain.rstrip("/")
636
631
  self._log(f"Discovered OpenAPI server: {self.OPENAPI_BASE_URL}")
637
632
 
633
+ if not self.BASE_URL:
634
+ raise NetworkError(
635
+ "Redirect API did not return an apiServer"
636
+ )
637
+
638
+ except NetworkError:
639
+ raise
638
640
  except Exception as exc:
639
- self._log(f"Endpoint discovery failed (will use static endpoints): {exc}")
641
+ raise NetworkError(f"Endpoint discovery failed: {exc}") from exc
640
642
 
641
643
  def authenticate(self) -> bool:
642
644
  """
@@ -0,0 +1,35 @@
1
+ """
2
+ Configuration constants for CloudEdge API
3
+ """
4
+
5
+ # Global redirect endpoint — used to discover the correct regional server.
6
+ # This is the single source of truth: the redirect API returns the correct
7
+ # ``apiServer`` and ``openapi`` domain for the given account/country.
8
+ REDIRECT_URL = "https://apis.cloudedge360.com/ppstrongs/redirect"
9
+
10
+ # API Keys (these are public keys from the mobile app)
11
+ CA_KEY = "bc29be30292a4309877807e101afbd51"
12
+ CA_SECRET = "35a69fd1-6527-4566-b190-921f9a651488"
13
+
14
+ # Default Headers
15
+ DEFAULT_HEADERS = {
16
+ "Accept-Language": "en-US,en;q=0.8",
17
+ "User-Agent": "Mozilla/5.0 (Linux; U; Android 10; en-us; Android SDK built for arm64 Build/QSR1.211112.002) AppleWebKit/533.1 (KHTML, like Gecko) Version/5.0 Mobile Safari/533.1",
18
+ "Content-Type": "application/x-www-form-urlencoded",
19
+ "Accept-Encoding": "gzip, deflate, br"
20
+ }
21
+
22
+ # API Constants
23
+ PHONE_TYPE = "a"
24
+ SOURCE_APP = "8"
25
+ APP_VERSION = "5.5.1"
26
+ IOT_TYPE = "4"
27
+ APP_VERSION_CODE = "551"
28
+ DEFAULT_LANGUAGE = "en"
29
+
30
+ # Timeout values (seconds)
31
+ DEFAULT_TIMEOUT = 30
32
+ PING_TIMEOUT = 2.0
33
+
34
+ # Cache settings
35
+ DEFAULT_CACHE_FILE = ".cloudedge_session_cache"
@@ -0,0 +1,176 @@
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycloudedge
3
- Version: 0.1.6.dev2
3
+ Version: 0.1.6.dev4
4
4
  Summary: Python library for CloudEdge cameras
5
5
  Home-page: https://github.com/fradaloisio/pycloudedge
6
6
  Author: Francesco D'Aloisio
@@ -7,6 +7,7 @@ pyproject.toml
7
7
  requirements-dev.txt
8
8
  requirements.txt
9
9
  setup.py
10
+ test_3region_login.py
10
11
  cloudedge/__init__.py
11
12
  cloudedge/_version.py
12
13
  cloudedge/cli.py
@@ -19,6 +20,7 @@ cloudedge/logging_config.py
19
20
  cloudedge/mqtt.py
20
21
  cloudedge/utils.py
21
22
  cloudedge/validators.py
23
+ docs/plans/2026-03-30-streaming-video.md
22
24
  examples/README.md
23
25
  examples/basic_example.py
24
26
  examples/device_control.py
@@ -66,6 +66,7 @@ cloudedge = "cloudedge.cli:main"
66
66
 
67
67
  [tool.setuptools_scm]
68
68
  write_to = "cloudedge/_version.py"
69
+ version_scheme = "only-version"
69
70
 
70
71
  [tool.setuptools.packages.find]
71
72
  where = ["."]
@@ -113,4 +114,4 @@ testpaths = [
113
114
  ]
114
115
  python_files = "test_*.py"
115
116
  python_classes = "Test*"
116
- python_functions = "test_*"
117
+ python_functions = "test_*"
@@ -0,0 +1,58 @@
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}")
@@ -21,32 +21,32 @@ from cloudedge.iot_parameters import get_parameter_name, format_parameter_value
21
21
 
22
22
  class TestIoTParameters(unittest.TestCase):
23
23
  """Test IoT parameter functions."""
24
-
24
+
25
25
  def test_get_parameter_name(self):
26
26
  """Test parameter name retrieval."""
27
27
  # Test known parameter
28
28
  self.assertEqual(get_parameter_name("167"), "FRONT_LIGHT_SWITCH")
29
29
  self.assertEqual(get_parameter_name("103"), "LED_ENABLE")
30
-
30
+
31
31
  # Test unknown parameter
32
32
  self.assertEqual(get_parameter_name("99999"), "iot_99999")
33
-
33
+
34
34
  def test_format_parameter_value(self):
35
35
  """Test parameter value formatting."""
36
36
  # Test boolean parameter
37
37
  self.assertEqual(format_parameter_value("LED_ENABLE", "1"), "Enabled")
38
38
  self.assertEqual(format_parameter_value("LED_ENABLE", "0"), "Disabled")
39
-
39
+
40
40
  # Test percentage parameter
41
41
  self.assertEqual(format_parameter_value("BATTERY_PERCENT", 85), "85%")
42
-
42
+
43
43
  # Test regular value
44
44
  self.assertEqual(format_parameter_value("UNKNOWN_PARAM", "test"), "test")
45
45
 
46
46
 
47
47
  class TestCloudEdgeClient(unittest.TestCase):
48
48
  """Test CloudEdge client functionality."""
49
-
49
+
50
50
  def setUp(self):
51
51
  """Set up test client."""
52
52
  self.client = CloudEdgeClient(
@@ -54,9 +54,9 @@ class TestCloudEdgeClient(unittest.TestCase):
54
54
  password="testpass",
55
55
  country_code="US",
56
56
  phone_code="+1",
57
- debug=False
57
+ debug=False,
58
58
  )
59
-
59
+
60
60
  def test_client_initialization(self):
61
61
  """Test client initialization."""
62
62
  self.assertEqual(self.client.username, "test@example.com")
@@ -64,19 +64,19 @@ class TestCloudEdgeClient(unittest.TestCase):
64
64
  self.assertEqual(self.client.phone_code, "+1")
65
65
  self.assertFalse(self.client.debug)
66
66
  self.assertIsNone(self.client.session_data)
67
-
67
+
68
68
  def test_format_sn(self):
69
69
  """Test serial number formatting."""
70
70
  # Test 9-digit SN
71
71
  self.assertEqual(self.client._format_sn("123456789"), "0000000123456789")
72
-
72
+
73
73
  # Test longer SN
74
74
  self.assertEqual(self.client._format_sn("ABCD123456789"), "123456789")
75
-
75
+
76
76
  # Test empty SN
77
77
  self.assertEqual(self.client._format_sn(""), "")
78
-
79
- @patch('cloudedge.client.requests.Session.post')
78
+
79
+ @patch("cloudedge.client.requests.Session.post")
80
80
  async def test_authenticate_success(self, mock_post):
81
81
  """Test successful authentication."""
82
82
  # Mock successful login response
@@ -90,23 +90,23 @@ class TestCloudEdgeClient(unittest.TestCase):
90
90
  "iot": {
91
91
  "pfKey": {
92
92
  "accessid": "test_access_id",
93
- "accesskey": "test_access_key"
93
+ "accesskey": "test_access_key",
94
94
  }
95
- }
96
- }
95
+ },
96
+ },
97
97
  }
98
98
  mock_response.raise_for_status.return_value = None
99
99
  mock_post.return_value = mock_response
100
-
100
+
101
101
  # Test authentication
102
102
  success = await self.client.authenticate()
103
-
103
+
104
104
  self.assertTrue(success)
105
105
  self.assertIsNotNone(self.client.session_data)
106
106
  self.assertEqual(self.client.session_data["userToken"], "test_token")
107
107
  self.assertEqual(self.client.session_data["userID"], "test_user_id")
108
-
109
- @patch('cloudedge.client.requests.Session.post')
108
+
109
+ @patch("cloudedge.client.requests.Session.post")
110
110
  async def test_authenticate_failure(self, mock_post):
111
111
  """Test failed authentication."""
112
112
  # Mock failed login response
@@ -114,29 +114,26 @@ class TestCloudEdgeClient(unittest.TestCase):
114
114
  mock_response.status_code = 200
115
115
  mock_response.json.return_value = {
116
116
  "resultCode": "1002",
117
- "resultMsg": "Invalid credentials"
117
+ "resultMsg": "Invalid credentials",
118
118
  }
119
119
  mock_response.raise_for_status.return_value = None
120
120
  mock_post.return_value = mock_response
121
-
121
+
122
122
  # Test authentication failure
123
123
  with self.assertRaises(AuthenticationError):
124
124
  await self.client.authenticate()
125
-
125
+
126
126
  async def test_get_devices_not_authenticated(self):
127
127
  """Test getting devices without authentication."""
128
128
  with self.assertRaises(AuthenticationError):
129
129
  await self.client.get_devices()
130
-
131
- @patch('cloudedge.client.requests.Session.post')
130
+
131
+ @patch("cloudedge.client.requests.Session.post")
132
132
  async def test_get_devices_success(self, mock_post):
133
133
  """Test successful device retrieval."""
134
134
  # Set up authenticated session
135
- self.client.session_data = {
136
- "userToken": "test_token",
137
- "userID": "test_user_id"
138
- }
139
-
135
+ self.client.session_data = {"userToken": "test_token", "userID": "test_user_id"}
136
+
140
137
  # Mock device list response
141
138
  mock_response = Mock()
142
139
  mock_response.status_code = 200
@@ -151,42 +148,46 @@ class TestCloudEdgeClient(unittest.TestCase):
151
148
  "deviceTypeName": "Camera",
152
149
  "devTypeID": "1",
153
150
  "hostKey": "hostkey123",
154
- "onLine": 1
151
+ "onLine": 1,
155
152
  }
156
153
  ]
157
- }
154
+ },
158
155
  }
159
156
  mock_response.raise_for_status.return_value = None
160
157
  mock_post.return_value = mock_response
161
-
158
+
162
159
  # Test device retrieval
163
160
  devices = await self.client.get_devices()
164
-
161
+
165
162
  self.assertEqual(len(devices), 1)
166
163
  device = devices[0]
167
164
  self.assertEqual(device["device_id"], "device1")
168
165
  self.assertEqual(device["name"], "Test Camera")
169
166
  self.assertTrue(device["online"])
170
-
167
+
171
168
  async def test_find_device_by_name_not_authenticated(self):
172
169
  """Test finding device without authentication."""
173
170
  # Mock get_devices to raise AuthenticationError
174
- with patch.object(self.client, 'get_devices', side_effect=AuthenticationError("Not authenticated")):
171
+ with patch.object(
172
+ self.client,
173
+ "get_devices",
174
+ side_effect=AuthenticationError("Not authenticated"),
175
+ ):
175
176
  result = await self.client.find_device_by_name("Test Device")
176
177
  self.assertIsNone(result)
177
178
 
178
179
 
179
180
  class TestAsyncMethods(unittest.IsolatedAsyncioTestCase):
180
181
  """Test async methods with proper async test support."""
181
-
182
+
182
183
  async def test_client_methods_with_mock(self):
183
184
  """Test client methods with mocked dependencies."""
184
- client = CloudEdgeClient("test", "test", "US", "+1")
185
-
185
+ client = CloudEdgeClient("test@example.com", "test", "US", "+1")
186
+
186
187
  # Test that methods require authentication
187
188
  with self.assertRaises(AuthenticationError):
188
189
  await client.get_devices()
189
-
190
+
190
191
  with self.assertRaises(AuthenticationError):
191
192
  await client.get_device_status("device1")
192
193
 
@@ -195,28 +196,28 @@ def run_tests():
195
196
  """Run all tests."""
196
197
  loader = unittest.TestLoader()
197
198
  suite = unittest.TestSuite()
198
-
199
+
199
200
  # Add test classes
200
201
  suite.addTests(loader.loadTestsFromTestCase(TestIoTParameters))
201
202
  suite.addTests(loader.loadTestsFromTestCase(TestCloudEdgeClient))
202
203
  suite.addTests(loader.loadTestsFromTestCase(TestAsyncMethods))
203
-
204
+
204
205
  # Run tests
205
206
  runner = unittest.TextTestRunner(verbosity=2)
206
207
  result = runner.run(suite)
207
-
208
+
208
209
  return result.wasSuccessful()
209
210
 
210
211
 
211
212
  if __name__ == "__main__":
212
213
  print("CloudEdge API Library - Basic Tests")
213
214
  print("=" * 50)
214
-
215
+
215
216
  success = run_tests()
216
-
217
+
217
218
  if success:
218
219
  print("\n✅ All tests passed!")
219
220
  exit(0)
220
221
  else:
221
222
  print("\n❌ Some tests failed!")
222
- exit(1)
223
+ exit(1)
@@ -59,6 +59,15 @@ class TestPhase1InputValidation:
59
59
  assert client.country_code == "US"
60
60
  assert client.phone_code == "+1"
61
61
 
62
+ def test_uk_country_code_normalizes_to_gb(self):
63
+ """Test that UK input normalizes to GB for CloudEdge APIs."""
64
+ client = CloudEdgeClient("user@example.com", "password", "UK", "+44")
65
+
66
+ assert client.country_code == "GB"
67
+ assert client.phone_code == "+44"
68
+ assert client.BASE_URL is None
69
+ assert client.OPENAPI_BASE_URL is None
70
+
62
71
 
63
72
  class TestPhase1Logging:
64
73
  """Test Phase 1: Proper logging implementation"""
@@ -1,34 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '0.1.6.dev2'
32
- __version_tuple__ = version_tuple = (0, 1, 6, 'dev2')
33
-
34
- __commit_id__ = commit_id = None
@@ -1,101 +0,0 @@
1
- """
2
- Configuration constants for CloudEdge API
3
- """
4
-
5
- # Region identifiers
6
- REGION_EU = "eu"
7
- REGION_US = "us"
8
- REGION_AP = "ap"
9
-
10
- # Per-region API endpoints (static fallback when redirect discovery fails)
11
- REGION_ENDPOINTS = {
12
- REGION_EU: {
13
- "base_url": "https://apis-eu-frankfurt.cloudedge360.com",
14
- "openapi_base_url": "https://openapi-euce.mearicloud.com",
15
- },
16
- REGION_US: {
17
- "base_url": "https://apis-us-west.cloudedge360.com",
18
- "openapi_base_url": "https://openapi-uswe.mearicloud.com",
19
- },
20
- REGION_AP: {
21
- "base_url": "https://apis-as-singapore.cloudedge360.com",
22
- "openapi_base_url": "https://openapi-usce.mearicloud.com",
23
- },
24
- }
25
-
26
- # Global redirect endpoint — used to discover the correct regional server
27
- REDIRECT_URL = "https://apis.cloudedge360.com/ppstrongs/redirect"
28
-
29
- # Default endpoints (EU) – kept for backward compatibility
30
- BASE_URL = REGION_ENDPOINTS[REGION_EU]["base_url"]
31
- OPENAPI_BASE_URL = REGION_ENDPOINTS[REGION_EU]["openapi_base_url"]
32
-
33
- # European country codes (ISO 3166-1 alpha-2)
34
- EU_COUNTRY_CODES = {
35
- "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
36
- "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
37
- "PL", "PT", "RO", "SK", "SI", "ES", "SE",
38
- # EEA + EFTA + UK
39
- "IS", "LI", "NO", "CH", "GB",
40
- # Other European countries
41
- "AL", "AD", "AM", "AZ", "BA", "BY", "GE", "GI", "XK", "MD",
42
- "MC", "ME", "MK", "RS", "RU", "SM", "TR", "UA", "VA",
43
- }
44
-
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
-
67
- def region_for_country(country_code: str) -> str:
68
- """Return the region key for a given ISO 3166-1 alpha-2 country code."""
69
- cc = country_code.upper()
70
- if cc in EU_COUNTRY_CODES:
71
- return REGION_EU
72
- if cc in AP_COUNTRY_CODES:
73
- return REGION_AP
74
- return REGION_US
75
-
76
- # API Keys (these are public keys from the mobile app)
77
- CA_KEY = "bc29be30292a4309877807e101afbd51"
78
- CA_SECRET = "35a69fd1-6527-4566-b190-921f9a651488"
79
-
80
- # Default Headers
81
- DEFAULT_HEADERS = {
82
- "Accept-Language": "en-US,en;q=0.8",
83
- "User-Agent": "Mozilla/5.0 (Linux; U; Android 10; en-us; Android SDK built for arm64 Build/QSR1.211112.002) AppleWebKit/533.1 (KHTML, like Gecko) Version/5.0 Mobile Safari/533.1",
84
- "Content-Type": "application/x-www-form-urlencoded",
85
- "Accept-Encoding": "gzip, deflate, br"
86
- }
87
-
88
- # API Constants
89
- PHONE_TYPE = "a"
90
- SOURCE_APP = "8"
91
- APP_VERSION = "5.5.1"
92
- IOT_TYPE = "4"
93
- APP_VERSION_CODE = "551"
94
- DEFAULT_LANGUAGE = "en"
95
-
96
- # Timeout values (seconds)
97
- DEFAULT_TIMEOUT = 30
98
- PING_TIMEOUT = 2.0
99
-
100
- # Cache settings
101
- DEFAULT_CACHE_FILE = ".cloudedge_session_cache"