qubopy 0.2.0__tar.gz → 0.2.2__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.
- {qubopy-0.2.0/qubopy.egg-info → qubopy-0.2.2}/PKG-INFO +24 -8
- {qubopy-0.2.0 → qubopy-0.2.2}/README.md +23 -7
- {qubopy-0.2.0 → qubopy-0.2.2}/pyproject.toml +1 -1
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/auth.py +32 -5
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/cli.py +24 -2
- {qubopy-0.2.0 → qubopy-0.2.2/qubopy.egg-info}/PKG-INFO +24 -8
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_auth.py +20 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_cli.py +43 -1
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_mqtt_live.py +40 -8
- {qubopy-0.2.0 → qubopy-0.2.2}/LICENSE +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/MANIFEST.in +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/__init__.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/__main__.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/client.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/const.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/__init__.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/base.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/camera.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/dashcam.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/light.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/lock.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/plug.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/purifier.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/sensor.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/exceptions.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/models.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/mqtt.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/py.typed +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/SOURCES.txt +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/dependency_links.txt +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/entry_points.txt +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/requires.txt +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/top_level.txt +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/setup.cfg +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/__init__.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/conftest.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_client.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_devices.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_lock_ble.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_mqtt.py +0 -0
- {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_routes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qubopy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Asynchronous Python client library and local BLE enabler for Qubo smart home devices
|
|
5
5
|
Author-email: Selva Kumaran K <selvakk2k@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -156,12 +156,12 @@ python -m qubopy --help
|
|
|
156
156
|
|
|
157
157
|
> [!IMPORTANT]
|
|
158
158
|
> **Email Account Registration Guidelines & Constraints**:
|
|
159
|
-
> - **Brand-New Email Address Required**: You **cannot** use an email address that was ever previously added or linked to any mobile phone account. It must be an email address that has **never been associated with any Qubo app or account**.
|
|
160
|
-
> - **Phone Number Re-use is Permitted**: When registering a new email account, the Qubo app will still prompt for a phone number for SMS verification during onboarding. You **can** safely provide the exact same mobile phone number that was used for your mobile account. Login will continue to work seamlessly with your email ID and password!
|
|
161
159
|
> - **Account Type Separation**: Qubo strictly routes authentication based on the registration method:
|
|
162
160
|
> - Accounts created via **email** authenticate using `qubopy login --email <email>` (continuous session with automatic refresh tokens).
|
|
163
161
|
> - Accounts created via **phone number** authenticate using `qubopy login-phone --phone <number>` (1-hour session without refresh tokens).
|
|
164
|
-
> - Attempting to use email and password credentials on an account registered with a phone number will be rejected by
|
|
162
|
+
> - Attempting to use email and password credentials on an account registered with a phone number will be rejected by Qubo with `HTTP 400: ERR_SM_002 - Invalid user`.
|
|
163
|
+
> - **Reusing an Email Linked to a Phone Account**: If your email address was previously added as a contact field under your phone account, Qubo will reject it during email registration. You can resolve this by removing/deleting the email from your profile in the Qubo mobile app, logging out, and then registering a fresh account using that email address.
|
|
164
|
+
> - **Phone Number Re-use is Permitted**: When registering a new email account, the Qubo app will prompt for a phone number for SMS verification during onboarding. You **can** safely provide the exact same mobile phone number that was used for your mobile account. Login will continue to work seamlessly with your email ID and password.
|
|
165
165
|
|
|
166
166
|
> [!WARNING]
|
|
167
167
|
> **Mobile App Session Revocation**: When you sign in via `qubopy`, the active session is visible in the official Qubo mobile app under **Account -> Logged In Devices** with the label `Qubopy` (or your custom `--device-name`).
|
|
@@ -411,7 +411,16 @@ await camera.stop_stream()
|
|
|
411
411
|
```
|
|
412
412
|
|
|
413
413
|
> [!NOTE]
|
|
414
|
-
>
|
|
414
|
+
> **Stream Playback & Transport Requirements**:
|
|
415
|
+
> - **VLC / ffplay (TCP Transport Required)**: Qubo's cloud Wowza relay on port 443 requires TCP interleaved transport. VLC defaults to UDP for RTSP, which fails to connect. Pass `--rtsp-tcp` when opening the stream:
|
|
416
|
+
> ```bash
|
|
417
|
+
> vlc --rtsp-tcp "<stream_url>"
|
|
418
|
+
> ffplay -rtsp_transport tcp "<stream_url>"
|
|
419
|
+
> ```
|
|
420
|
+
> - **Web Browsers**: Browsers and HTML5 video players do not natively support `rtsps://` streams. To view the stream in a browser or dashboard, use a translation proxy like [go2rtc](https://github.com/AlexxIT/go2rtc) or WebRTC.
|
|
421
|
+
> - **Paired Mobile Device Authorization**: The cloud stream manager requires a registered mobile app device ID (`userAppID`) attached to the account. `camera.start_stream()` and `qubopy stream` automatically detect and supply the account's registered mobile device ID, which can also be overridden via `user_app_id`.
|
|
422
|
+
> - **Single-Consumer Sessions**: Cloud Wowza URLs are single-consumer sessions. Opening the stream in another player terminates any active session using the same token. Invoke `await camera.stop_stream()` when playback ends to release server-side resources.
|
|
423
|
+
> - **Video Doorbells (`HCD` series)**: Doorbells support cloud Wowza RTSPS identical to cameras, but operate on a duty cycle—the doorbell stops transmitting video when idle, which causes players to disconnect when there is no active event. For direct local media transport over P2P UDP, an alternative pathway is also available (see Method 2 below and `ha-qubo`'s go2rtc client), which negotiates initial session signaling via the cloud before streaming media directly over local UDP.
|
|
415
424
|
|
|
416
425
|
##### 2. Local Peer-to-Peer (P2P) UDP Streaming (`camera.initiate_p2p_call()`) (`⚠️ Experimental`)
|
|
417
426
|
|
|
@@ -693,9 +702,13 @@ python -m qubopy tokens --json
|
|
|
693
702
|
python -m qubopy devices
|
|
694
703
|
python -m qubopy list-devices
|
|
695
704
|
|
|
696
|
-
# Mint live Wowza RTSPS stream URL for cameras
|
|
705
|
+
# Mint live Wowza RTSPS stream URL for cameras & doorbells (auto-resolves paired mobile userAppID)
|
|
697
706
|
python -m qubopy stream
|
|
698
|
-
python -m qubopy stream --device-id <camera_uuid> --quality high
|
|
707
|
+
python -m qubopy stream --device-id <camera_uuid> --quality high --user-app-id <mobile_id>
|
|
708
|
+
|
|
709
|
+
# Play the stream in VLC (TCP transport required) or ffplay
|
|
710
|
+
vlc --rtsp-tcp "<rtsps_url>"
|
|
711
|
+
ffplay -rtsp_transport tcp "<rtsps_url>"
|
|
699
712
|
|
|
700
713
|
# Fetch in-car Wi-Fi AP credentials (SSID and WPA2 password) for dashcam
|
|
701
714
|
python -m qubopy dashcam-wifi
|
|
@@ -707,7 +720,9 @@ python -m qubopy export-ble-keys --device-id <lock_uuid>
|
|
|
707
720
|
# Cloud unlock smart lock via paired in-home gateway
|
|
708
721
|
python -m qubopy unlock --device-id <lock_uuid>
|
|
709
722
|
|
|
710
|
-
# Enable verbose debug logging for troubleshooting (
|
|
723
|
+
# Enable verbose debug logging for troubleshooting (prints server error codes and bodies)
|
|
724
|
+
python -m qubopy --debug login --email <email>
|
|
725
|
+
python -m qubopy --debug stream
|
|
711
726
|
python -m qubopy --debug devices
|
|
712
727
|
```
|
|
713
728
|
|
|
@@ -1002,6 +1017,7 @@ mypy qubopy
|
|
|
1002
1017
|
### Author & Contributors
|
|
1003
1018
|
* **Architecture & Lead Engineering**: [@selvakk2k](https://github.com/selvakk2k)
|
|
1004
1019
|
* **Code Implementation & Engineering**: **Antigravity** (Google DeepMind)
|
|
1020
|
+
* **Automated Code Review & Auditing**: **CodeRabbit** ([@coderabbitai](https://github.com/coderabbitai))
|
|
1005
1021
|
* **Code Review & Pre-Release Auditing**: **Claude** (Anthropic)
|
|
1006
1022
|
|
|
1007
1023
|
Licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
|
|
@@ -125,12 +125,12 @@ python -m qubopy --help
|
|
|
125
125
|
|
|
126
126
|
> [!IMPORTANT]
|
|
127
127
|
> **Email Account Registration Guidelines & Constraints**:
|
|
128
|
-
> - **Brand-New Email Address Required**: You **cannot** use an email address that was ever previously added or linked to any mobile phone account. It must be an email address that has **never been associated with any Qubo app or account**.
|
|
129
|
-
> - **Phone Number Re-use is Permitted**: When registering a new email account, the Qubo app will still prompt for a phone number for SMS verification during onboarding. You **can** safely provide the exact same mobile phone number that was used for your mobile account. Login will continue to work seamlessly with your email ID and password!
|
|
130
128
|
> - **Account Type Separation**: Qubo strictly routes authentication based on the registration method:
|
|
131
129
|
> - Accounts created via **email** authenticate using `qubopy login --email <email>` (continuous session with automatic refresh tokens).
|
|
132
130
|
> - Accounts created via **phone number** authenticate using `qubopy login-phone --phone <number>` (1-hour session without refresh tokens).
|
|
133
|
-
> - Attempting to use email and password credentials on an account registered with a phone number will be rejected by
|
|
131
|
+
> - Attempting to use email and password credentials on an account registered with a phone number will be rejected by Qubo with `HTTP 400: ERR_SM_002 - Invalid user`.
|
|
132
|
+
> - **Reusing an Email Linked to a Phone Account**: If your email address was previously added as a contact field under your phone account, Qubo will reject it during email registration. You can resolve this by removing/deleting the email from your profile in the Qubo mobile app, logging out, and then registering a fresh account using that email address.
|
|
133
|
+
> - **Phone Number Re-use is Permitted**: When registering a new email account, the Qubo app will prompt for a phone number for SMS verification during onboarding. You **can** safely provide the exact same mobile phone number that was used for your mobile account. Login will continue to work seamlessly with your email ID and password.
|
|
134
134
|
|
|
135
135
|
> [!WARNING]
|
|
136
136
|
> **Mobile App Session Revocation**: When you sign in via `qubopy`, the active session is visible in the official Qubo mobile app under **Account -> Logged In Devices** with the label `Qubopy` (or your custom `--device-name`).
|
|
@@ -380,7 +380,16 @@ await camera.stop_stream()
|
|
|
380
380
|
```
|
|
381
381
|
|
|
382
382
|
> [!NOTE]
|
|
383
|
-
>
|
|
383
|
+
> **Stream Playback & Transport Requirements**:
|
|
384
|
+
> - **VLC / ffplay (TCP Transport Required)**: Qubo's cloud Wowza relay on port 443 requires TCP interleaved transport. VLC defaults to UDP for RTSP, which fails to connect. Pass `--rtsp-tcp` when opening the stream:
|
|
385
|
+
> ```bash
|
|
386
|
+
> vlc --rtsp-tcp "<stream_url>"
|
|
387
|
+
> ffplay -rtsp_transport tcp "<stream_url>"
|
|
388
|
+
> ```
|
|
389
|
+
> - **Web Browsers**: Browsers and HTML5 video players do not natively support `rtsps://` streams. To view the stream in a browser or dashboard, use a translation proxy like [go2rtc](https://github.com/AlexxIT/go2rtc) or WebRTC.
|
|
390
|
+
> - **Paired Mobile Device Authorization**: The cloud stream manager requires a registered mobile app device ID (`userAppID`) attached to the account. `camera.start_stream()` and `qubopy stream` automatically detect and supply the account's registered mobile device ID, which can also be overridden via `user_app_id`.
|
|
391
|
+
> - **Single-Consumer Sessions**: Cloud Wowza URLs are single-consumer sessions. Opening the stream in another player terminates any active session using the same token. Invoke `await camera.stop_stream()` when playback ends to release server-side resources.
|
|
392
|
+
> - **Video Doorbells (`HCD` series)**: Doorbells support cloud Wowza RTSPS identical to cameras, but operate on a duty cycle—the doorbell stops transmitting video when idle, which causes players to disconnect when there is no active event. For direct local media transport over P2P UDP, an alternative pathway is also available (see Method 2 below and `ha-qubo`'s go2rtc client), which negotiates initial session signaling via the cloud before streaming media directly over local UDP.
|
|
384
393
|
|
|
385
394
|
##### 2. Local Peer-to-Peer (P2P) UDP Streaming (`camera.initiate_p2p_call()`) (`⚠️ Experimental`)
|
|
386
395
|
|
|
@@ -662,9 +671,13 @@ python -m qubopy tokens --json
|
|
|
662
671
|
python -m qubopy devices
|
|
663
672
|
python -m qubopy list-devices
|
|
664
673
|
|
|
665
|
-
# Mint live Wowza RTSPS stream URL for cameras
|
|
674
|
+
# Mint live Wowza RTSPS stream URL for cameras & doorbells (auto-resolves paired mobile userAppID)
|
|
666
675
|
python -m qubopy stream
|
|
667
|
-
python -m qubopy stream --device-id <camera_uuid> --quality high
|
|
676
|
+
python -m qubopy stream --device-id <camera_uuid> --quality high --user-app-id <mobile_id>
|
|
677
|
+
|
|
678
|
+
# Play the stream in VLC (TCP transport required) or ffplay
|
|
679
|
+
vlc --rtsp-tcp "<rtsps_url>"
|
|
680
|
+
ffplay -rtsp_transport tcp "<rtsps_url>"
|
|
668
681
|
|
|
669
682
|
# Fetch in-car Wi-Fi AP credentials (SSID and WPA2 password) for dashcam
|
|
670
683
|
python -m qubopy dashcam-wifi
|
|
@@ -676,7 +689,9 @@ python -m qubopy export-ble-keys --device-id <lock_uuid>
|
|
|
676
689
|
# Cloud unlock smart lock via paired in-home gateway
|
|
677
690
|
python -m qubopy unlock --device-id <lock_uuid>
|
|
678
691
|
|
|
679
|
-
# Enable verbose debug logging for troubleshooting (
|
|
692
|
+
# Enable verbose debug logging for troubleshooting (prints server error codes and bodies)
|
|
693
|
+
python -m qubopy --debug login --email <email>
|
|
694
|
+
python -m qubopy --debug stream
|
|
680
695
|
python -m qubopy --debug devices
|
|
681
696
|
```
|
|
682
697
|
|
|
@@ -971,6 +986,7 @@ mypy qubopy
|
|
|
971
986
|
### Author & Contributors
|
|
972
987
|
* **Architecture & Lead Engineering**: [@selvakk2k](https://github.com/selvakk2k)
|
|
973
988
|
* **Code Implementation & Engineering**: **Antigravity** (Google DeepMind)
|
|
989
|
+
* **Automated Code Review & Auditing**: **CodeRabbit** ([@coderabbitai](https://github.com/coderabbitai))
|
|
974
990
|
* **Code Review & Pre-Release Auditing**: **Claude** (Anthropic)
|
|
975
991
|
|
|
976
992
|
Licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
|
|
@@ -71,6 +71,29 @@ def _decode_jwt_expiry(token: str) -> float | None:
|
|
|
71
71
|
return None
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _format_error_detail(status: int, text: str) -> str:
|
|
75
|
+
"""Format server error response into a human-readable detail string."""
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(text)
|
|
78
|
+
if isinstance(data, dict):
|
|
79
|
+
code = data.get("code") or data.get("error")
|
|
80
|
+
msg = data.get("message") or data.get("errorMessage") or data.get("error_description")
|
|
81
|
+
if isinstance(msg, list):
|
|
82
|
+
msg = ", ".join(str(m) for m in msg)
|
|
83
|
+
if code and msg:
|
|
84
|
+
return f" (HTTP {status}: {code} - {msg})"
|
|
85
|
+
if msg:
|
|
86
|
+
return f" (HTTP {status}: {msg})"
|
|
87
|
+
if code:
|
|
88
|
+
return f" (HTTP {status}: {code})"
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
cleaned = text.strip()
|
|
92
|
+
if cleaned and len(cleaned) < 120 and "\n" not in cleaned and not cleaned.startswith("<"):
|
|
93
|
+
return f" (HTTP {status}: {cleaned})"
|
|
94
|
+
return f" (HTTP {status})"
|
|
95
|
+
|
|
96
|
+
|
|
74
97
|
class QuboAuth:
|
|
75
98
|
"""Manages credentials, JWT token lifecycle, and session headers."""
|
|
76
99
|
|
|
@@ -191,7 +214,8 @@ class QuboAuth:
|
|
|
191
214
|
_LOGGER.debug(
|
|
192
215
|
"Email login error response (HTTP %s): %s", response.status, text[:200]
|
|
193
216
|
)
|
|
194
|
-
|
|
217
|
+
detail = _format_error_detail(response.status, text)
|
|
218
|
+
raise QuboAuthError(f"Email login failed{detail}")
|
|
195
219
|
data = json.loads(text)
|
|
196
220
|
if isinstance(data, list) and data:
|
|
197
221
|
data = data[0]
|
|
@@ -251,7 +275,8 @@ class QuboAuth:
|
|
|
251
275
|
_LOGGER.debug(
|
|
252
276
|
"OTP request error response (HTTP %s): %s", response.status, text[:200]
|
|
253
277
|
)
|
|
254
|
-
|
|
278
|
+
detail = _format_error_detail(response.status, text)
|
|
279
|
+
raise QuboAuthError(f"OTP request failed{detail}")
|
|
255
280
|
data = json.loads(text)
|
|
256
281
|
self._login_uuid = data.get("uuid")
|
|
257
282
|
return data
|
|
@@ -297,7 +322,8 @@ class QuboAuth:
|
|
|
297
322
|
_LOGGER.debug(
|
|
298
323
|
"OTP verification error response (HTTP %s): %s", response.status, text[:200]
|
|
299
324
|
)
|
|
300
|
-
|
|
325
|
+
detail = _format_error_detail(response.status, text)
|
|
326
|
+
raise QuboAuthError(f"OTP verification failed{detail}")
|
|
301
327
|
data = json.loads(text)
|
|
302
328
|
if isinstance(data, dict):
|
|
303
329
|
return [data]
|
|
@@ -438,13 +464,14 @@ class QuboAuth:
|
|
|
438
464
|
response.status,
|
|
439
465
|
text[:200],
|
|
440
466
|
)
|
|
467
|
+
detail = _format_error_detail(response.status, text)
|
|
441
468
|
if response.status in (400, 401, 403):
|
|
442
469
|
self._tokens = None
|
|
443
470
|
raise QuboAuthError(
|
|
444
|
-
f"Session refresh rejected
|
|
471
|
+
f"Session refresh rejected{detail}: "
|
|
445
472
|
"refresh token expired or invalid; re-login required"
|
|
446
473
|
)
|
|
447
|
-
raise QuboAuthError(f"Session refresh failed
|
|
474
|
+
raise QuboAuthError(f"Session refresh failed{detail}")
|
|
448
475
|
data = json.loads(text)
|
|
449
476
|
except aiohttp.ClientError as err:
|
|
450
477
|
raise QuboConnectionError(f"Network error during token refresh: {err}") from err
|
|
@@ -550,6 +550,7 @@ async def _cmd_stream(args: argparse.Namespace) -> int:
|
|
|
550
550
|
|
|
551
551
|
device_id = getattr(args, "device_id", None)
|
|
552
552
|
quality = getattr(args, "quality", "high") or "high"
|
|
553
|
+
user_app_id = getattr(args, "user_app_id", None)
|
|
553
554
|
try:
|
|
554
555
|
async with client:
|
|
555
556
|
devices = await client.get_all_devices()
|
|
@@ -566,11 +567,31 @@ async def _cmd_stream(args: argparse.Namespace) -> int:
|
|
|
566
567
|
print(f"Error: Device {target.name} is not a camera.", file=sys.stderr)
|
|
567
568
|
return 1
|
|
568
569
|
|
|
570
|
+
if not user_app_id:
|
|
571
|
+
for d in devices:
|
|
572
|
+
d_name = d.name or ""
|
|
573
|
+
if d_name.startswith("Mobile App - "):
|
|
574
|
+
user_app_id = d_name[len("Mobile App - "):]
|
|
575
|
+
break
|
|
576
|
+
if d_name.startswith("MobileApp_"):
|
|
577
|
+
user_app_id = d_name[len("MobileApp_"):]
|
|
578
|
+
break
|
|
579
|
+
|
|
569
580
|
print(f"Requesting live stream for {target.name} ({target.device_id}) ...")
|
|
570
|
-
stream_url = await target.start_stream(quality=quality)
|
|
581
|
+
stream_url = await target.start_stream(quality=quality, user_app_id=user_app_id)
|
|
571
582
|
print("\nLive RTSPS Stream URL:")
|
|
572
583
|
print(stream_url)
|
|
573
|
-
print("\
|
|
584
|
+
print("\nPlayback Instructions:")
|
|
585
|
+
print(" - VLC / ffplay: Must use TCP interleaved transport:")
|
|
586
|
+
print(f' vlc --rtsp-tcp "{stream_url}"')
|
|
587
|
+
print(f' ffplay -rtsp_transport tcp "{stream_url}"')
|
|
588
|
+
print(" - Web Browsers: Browsers do not support RTSP/RTSPS directly.")
|
|
589
|
+
print(
|
|
590
|
+
" Use go2rtc, WebRTC proxy, or Home Assistant Generic Camera / WebRTC card."
|
|
591
|
+
)
|
|
592
|
+
print(
|
|
593
|
+
" - Note: Video Doorbells (HCD) also support local P2P UDP streaming (see README)."
|
|
594
|
+
)
|
|
574
595
|
return 0
|
|
575
596
|
except QuboError as err:
|
|
576
597
|
_handle_cli_error(err)
|
|
@@ -718,6 +739,7 @@ def main() -> None:
|
|
|
718
739
|
p_stream.add_argument(
|
|
719
740
|
"--quality", default="high", choices=["high", "low"], help="Stream resolution/quality"
|
|
720
741
|
)
|
|
742
|
+
p_stream.add_argument("--user-app-id", help="Paired mobile app device ID override")
|
|
721
743
|
p_stream.add_argument("--token", help="Override JWT access token")
|
|
722
744
|
p_stream.add_argument("--user-uuid", help="Override User UUID")
|
|
723
745
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qubopy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Asynchronous Python client library and local BLE enabler for Qubo smart home devices
|
|
5
5
|
Author-email: Selva Kumaran K <selvakk2k@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -156,12 +156,12 @@ python -m qubopy --help
|
|
|
156
156
|
|
|
157
157
|
> [!IMPORTANT]
|
|
158
158
|
> **Email Account Registration Guidelines & Constraints**:
|
|
159
|
-
> - **Brand-New Email Address Required**: You **cannot** use an email address that was ever previously added or linked to any mobile phone account. It must be an email address that has **never been associated with any Qubo app or account**.
|
|
160
|
-
> - **Phone Number Re-use is Permitted**: When registering a new email account, the Qubo app will still prompt for a phone number for SMS verification during onboarding. You **can** safely provide the exact same mobile phone number that was used for your mobile account. Login will continue to work seamlessly with your email ID and password!
|
|
161
159
|
> - **Account Type Separation**: Qubo strictly routes authentication based on the registration method:
|
|
162
160
|
> - Accounts created via **email** authenticate using `qubopy login --email <email>` (continuous session with automatic refresh tokens).
|
|
163
161
|
> - Accounts created via **phone number** authenticate using `qubopy login-phone --phone <number>` (1-hour session without refresh tokens).
|
|
164
|
-
> - Attempting to use email and password credentials on an account registered with a phone number will be rejected by
|
|
162
|
+
> - Attempting to use email and password credentials on an account registered with a phone number will be rejected by Qubo with `HTTP 400: ERR_SM_002 - Invalid user`.
|
|
163
|
+
> - **Reusing an Email Linked to a Phone Account**: If your email address was previously added as a contact field under your phone account, Qubo will reject it during email registration. You can resolve this by removing/deleting the email from your profile in the Qubo mobile app, logging out, and then registering a fresh account using that email address.
|
|
164
|
+
> - **Phone Number Re-use is Permitted**: When registering a new email account, the Qubo app will prompt for a phone number for SMS verification during onboarding. You **can** safely provide the exact same mobile phone number that was used for your mobile account. Login will continue to work seamlessly with your email ID and password.
|
|
165
165
|
|
|
166
166
|
> [!WARNING]
|
|
167
167
|
> **Mobile App Session Revocation**: When you sign in via `qubopy`, the active session is visible in the official Qubo mobile app under **Account -> Logged In Devices** with the label `Qubopy` (or your custom `--device-name`).
|
|
@@ -411,7 +411,16 @@ await camera.stop_stream()
|
|
|
411
411
|
```
|
|
412
412
|
|
|
413
413
|
> [!NOTE]
|
|
414
|
-
>
|
|
414
|
+
> **Stream Playback & Transport Requirements**:
|
|
415
|
+
> - **VLC / ffplay (TCP Transport Required)**: Qubo's cloud Wowza relay on port 443 requires TCP interleaved transport. VLC defaults to UDP for RTSP, which fails to connect. Pass `--rtsp-tcp` when opening the stream:
|
|
416
|
+
> ```bash
|
|
417
|
+
> vlc --rtsp-tcp "<stream_url>"
|
|
418
|
+
> ffplay -rtsp_transport tcp "<stream_url>"
|
|
419
|
+
> ```
|
|
420
|
+
> - **Web Browsers**: Browsers and HTML5 video players do not natively support `rtsps://` streams. To view the stream in a browser or dashboard, use a translation proxy like [go2rtc](https://github.com/AlexxIT/go2rtc) or WebRTC.
|
|
421
|
+
> - **Paired Mobile Device Authorization**: The cloud stream manager requires a registered mobile app device ID (`userAppID`) attached to the account. `camera.start_stream()` and `qubopy stream` automatically detect and supply the account's registered mobile device ID, which can also be overridden via `user_app_id`.
|
|
422
|
+
> - **Single-Consumer Sessions**: Cloud Wowza URLs are single-consumer sessions. Opening the stream in another player terminates any active session using the same token. Invoke `await camera.stop_stream()` when playback ends to release server-side resources.
|
|
423
|
+
> - **Video Doorbells (`HCD` series)**: Doorbells support cloud Wowza RTSPS identical to cameras, but operate on a duty cycle—the doorbell stops transmitting video when idle, which causes players to disconnect when there is no active event. For direct local media transport over P2P UDP, an alternative pathway is also available (see Method 2 below and `ha-qubo`'s go2rtc client), which negotiates initial session signaling via the cloud before streaming media directly over local UDP.
|
|
415
424
|
|
|
416
425
|
##### 2. Local Peer-to-Peer (P2P) UDP Streaming (`camera.initiate_p2p_call()`) (`⚠️ Experimental`)
|
|
417
426
|
|
|
@@ -693,9 +702,13 @@ python -m qubopy tokens --json
|
|
|
693
702
|
python -m qubopy devices
|
|
694
703
|
python -m qubopy list-devices
|
|
695
704
|
|
|
696
|
-
# Mint live Wowza RTSPS stream URL for cameras
|
|
705
|
+
# Mint live Wowza RTSPS stream URL for cameras & doorbells (auto-resolves paired mobile userAppID)
|
|
697
706
|
python -m qubopy stream
|
|
698
|
-
python -m qubopy stream --device-id <camera_uuid> --quality high
|
|
707
|
+
python -m qubopy stream --device-id <camera_uuid> --quality high --user-app-id <mobile_id>
|
|
708
|
+
|
|
709
|
+
# Play the stream in VLC (TCP transport required) or ffplay
|
|
710
|
+
vlc --rtsp-tcp "<rtsps_url>"
|
|
711
|
+
ffplay -rtsp_transport tcp "<rtsps_url>"
|
|
699
712
|
|
|
700
713
|
# Fetch in-car Wi-Fi AP credentials (SSID and WPA2 password) for dashcam
|
|
701
714
|
python -m qubopy dashcam-wifi
|
|
@@ -707,7 +720,9 @@ python -m qubopy export-ble-keys --device-id <lock_uuid>
|
|
|
707
720
|
# Cloud unlock smart lock via paired in-home gateway
|
|
708
721
|
python -m qubopy unlock --device-id <lock_uuid>
|
|
709
722
|
|
|
710
|
-
# Enable verbose debug logging for troubleshooting (
|
|
723
|
+
# Enable verbose debug logging for troubleshooting (prints server error codes and bodies)
|
|
724
|
+
python -m qubopy --debug login --email <email>
|
|
725
|
+
python -m qubopy --debug stream
|
|
711
726
|
python -m qubopy --debug devices
|
|
712
727
|
```
|
|
713
728
|
|
|
@@ -1002,6 +1017,7 @@ mypy qubopy
|
|
|
1002
1017
|
### Author & Contributors
|
|
1003
1018
|
* **Architecture & Lead Engineering**: [@selvakk2k](https://github.com/selvakk2k)
|
|
1004
1019
|
* **Code Implementation & Engineering**: **Antigravity** (Google DeepMind)
|
|
1020
|
+
* **Automated Code Review & Auditing**: **CodeRabbit** ([@coderabbitai](https://github.com/coderabbitai))
|
|
1005
1021
|
* **Code Review & Pre-Release Auditing**: **Claude** (Anthropic)
|
|
1006
1022
|
|
|
1007
1023
|
Licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
|
|
@@ -92,6 +92,26 @@ async def test_email_login_invalid_credentials():
|
|
|
92
92
|
await auth.close()
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_email_login_server_error_detail():
|
|
97
|
+
"""Verify that HTTP 400 error surfaces the backend code and message."""
|
|
98
|
+
auth = QuboAuth()
|
|
99
|
+
mock_resp = MockClientResponse(
|
|
100
|
+
400, json.dumps({"code": "ERR_SM_002", "message": ["Invalid user"]})
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
with (
|
|
104
|
+
patch("aiohttp.ClientSession.post", return_value=mock_resp),
|
|
105
|
+
pytest.raises(
|
|
106
|
+
QuboAuthError,
|
|
107
|
+
match=r"Email login failed \(HTTP 400: ERR_SM_002 - Invalid user\)",
|
|
108
|
+
),
|
|
109
|
+
):
|
|
110
|
+
await auth.login_with_email("user@example.com", "wrong")
|
|
111
|
+
|
|
112
|
+
await auth.close()
|
|
113
|
+
|
|
114
|
+
|
|
95
115
|
@pytest.mark.asyncio
|
|
96
116
|
async def test_phone_otp_flow():
|
|
97
117
|
"""Verify phone OTP flow with required App-Id, Source, and deviceAttribute."""
|
|
@@ -5,7 +5,7 @@ import base64
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
7
|
import time
|
|
8
|
-
from unittest.mock import AsyncMock, patch
|
|
8
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
@@ -319,3 +319,45 @@ def test_cli_debug_flag_configures_logging():
|
|
|
319
319
|
assert exc_info.value.code == 0
|
|
320
320
|
mock_basic_config.assert_called_once()
|
|
321
321
|
assert mock_basic_config.call_args[1]["level"] == logging.DEBUG
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@pytest.mark.asyncio
|
|
325
|
+
async def test_cmd_stream_instructions(capsys):
|
|
326
|
+
"""Verify stream command resolves camera, uses user_app_id, and prints playback instructions."""
|
|
327
|
+
from qubopy.cli import _cmd_stream
|
|
328
|
+
from qubopy.devices import QuboCamera
|
|
329
|
+
from qubopy.models import DeviceModelInfo, DeviceShadow
|
|
330
|
+
|
|
331
|
+
mock_client = AsyncMock()
|
|
332
|
+
mock_client.auth.device_id = "test-auth-device-id"
|
|
333
|
+
|
|
334
|
+
cam_info = DeviceModelInfo(
|
|
335
|
+
deviceUUID="cam-01",
|
|
336
|
+
unitUUID="u-01",
|
|
337
|
+
entityUUID="e-01",
|
|
338
|
+
deviceType="ptzeCamera",
|
|
339
|
+
name="Living Room Cam",
|
|
340
|
+
)
|
|
341
|
+
cam = QuboCamera(cam_info, mock_client, DeviceShadow())
|
|
342
|
+
cam.start_stream = AsyncMock(return_value="rtsps://wowza.qubo.com/live/cam-01")
|
|
343
|
+
|
|
344
|
+
mock_mobile = MagicMock()
|
|
345
|
+
mock_mobile.device_id = "mob-01"
|
|
346
|
+
mock_mobile.name = "Mobile App - my-phone-id"
|
|
347
|
+
|
|
348
|
+
mock_client.get_all_devices = AsyncMock(return_value=[mock_mobile, cam])
|
|
349
|
+
|
|
350
|
+
args = argparse.Namespace(device_id="cam-01", quality="high", user_app_id=None)
|
|
351
|
+
|
|
352
|
+
with patch("qubopy.cli._get_authenticated_client", AsyncMock(return_value=mock_client)):
|
|
353
|
+
code = await _cmd_stream(args)
|
|
354
|
+
|
|
355
|
+
assert code == 0
|
|
356
|
+
cam.start_stream.assert_called_once_with(quality="high", user_app_id="my-phone-id")
|
|
357
|
+
captured = capsys.readouterr().out
|
|
358
|
+
assert "Live RTSPS Stream URL:" in captured
|
|
359
|
+
assert "rtsps://wowza.qubo.com/live/cam-01" in captured
|
|
360
|
+
assert "vlc --rtsp-tcp" in captured
|
|
361
|
+
assert "ffplay -rtsp_transport tcp" in captured
|
|
362
|
+
assert "Web Browsers: Browsers do not support RTSP/RTSPS directly" in captured
|
|
363
|
+
|
|
@@ -63,10 +63,15 @@ class Broker:
|
|
|
63
63
|
capture_output=True,
|
|
64
64
|
)
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
import getpass
|
|
67
|
+
try:
|
|
68
|
+
current_user = getpass.getuser()
|
|
69
|
+
user_line = f"user {current_user}\n"
|
|
70
|
+
except Exception:
|
|
71
|
+
user_line = ""
|
|
72
|
+
|
|
68
73
|
conf_content = (
|
|
69
|
-
f"{
|
|
74
|
+
f"{user_line}listener {self.port} 127.0.0.1\n"
|
|
70
75
|
f"cafile {ca_path}\n"
|
|
71
76
|
f"certfile {ca_path}\n"
|
|
72
77
|
f"keyfile {key_path}\n"
|
|
@@ -74,31 +79,58 @@ class Broker:
|
|
|
74
79
|
f"password_file {pw_path}\n"
|
|
75
80
|
f"persistence false\n"
|
|
76
81
|
)
|
|
77
|
-
|
|
82
|
+
conf_file = tmp_path / "m.conf"
|
|
83
|
+
conf_file.write_text(conf_content, encoding="utf-8")
|
|
84
|
+
try:
|
|
85
|
+
os.chmod(tmp_path, 0o777)
|
|
86
|
+
os.chmod(conf_file, 0o666)
|
|
87
|
+
os.chmod(ca_path, 0o666)
|
|
88
|
+
os.chmod(key_path, 0o666)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
78
91
|
|
|
79
92
|
def set_password(self, tok: str) -> None:
|
|
80
93
|
pw_path = (self.d / "pw").as_posix()
|
|
81
94
|
subprocess.run(["mosquitto_passwd", "-b", "-c", pw_path, "user1", tok], check=True)
|
|
95
|
+
try:
|
|
96
|
+
os.chmod(pw_path, 0o666)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
82
99
|
|
|
83
100
|
def start(self) -> None:
|
|
84
101
|
conf_path = (self.d / "m.conf").as_posix()
|
|
85
102
|
self.proc = subprocess.Popen(
|
|
86
103
|
["mosquitto", "-c", conf_path],
|
|
87
|
-
stdout=subprocess.
|
|
88
|
-
stderr=subprocess.
|
|
104
|
+
stdout=subprocess.PIPE,
|
|
105
|
+
stderr=subprocess.PIPE,
|
|
106
|
+
text=True,
|
|
89
107
|
)
|
|
90
108
|
for _ in range(50):
|
|
109
|
+
if self.proc.poll() is not None:
|
|
110
|
+
stdout, stderr = self.proc.communicate()
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
f"broker exited early with code {self.proc.returncode}:\n"
|
|
113
|
+
f"stderr: {stderr}\nstdout: {stdout}"
|
|
114
|
+
)
|
|
91
115
|
try:
|
|
92
116
|
socket.create_connection(("127.0.0.1", self.port), 0.2).close()
|
|
93
117
|
return
|
|
94
118
|
except OSError:
|
|
95
119
|
time.sleep(0.1)
|
|
96
|
-
|
|
120
|
+
if self.proc.poll() is not None:
|
|
121
|
+
stdout, stderr = self.proc.communicate()
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"broker did not start (code {self.proc.returncode}):\nstderr: {stderr}"
|
|
124
|
+
)
|
|
125
|
+
raise RuntimeError("broker did not start (timed out)")
|
|
97
126
|
|
|
98
127
|
def stop(self) -> None:
|
|
99
128
|
if self.proc:
|
|
100
129
|
self.proc.terminate()
|
|
101
|
-
|
|
130
|
+
try:
|
|
131
|
+
self.proc.wait(timeout=2)
|
|
132
|
+
except subprocess.TimeoutExpired:
|
|
133
|
+
self.proc.kill()
|
|
102
134
|
|
|
103
135
|
def publish(self, tok: str) -> None:
|
|
104
136
|
ca_path = (self.d / "ca.pem").as_posix()
|
|
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
|