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.
Files changed (41) hide show
  1. {qubopy-0.2.0/qubopy.egg-info → qubopy-0.2.2}/PKG-INFO +24 -8
  2. {qubopy-0.2.0 → qubopy-0.2.2}/README.md +23 -7
  3. {qubopy-0.2.0 → qubopy-0.2.2}/pyproject.toml +1 -1
  4. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/auth.py +32 -5
  5. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/cli.py +24 -2
  6. {qubopy-0.2.0 → qubopy-0.2.2/qubopy.egg-info}/PKG-INFO +24 -8
  7. {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_auth.py +20 -0
  8. {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_cli.py +43 -1
  9. {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_mqtt_live.py +40 -8
  10. {qubopy-0.2.0 → qubopy-0.2.2}/LICENSE +0 -0
  11. {qubopy-0.2.0 → qubopy-0.2.2}/MANIFEST.in +0 -0
  12. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/__init__.py +0 -0
  13. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/__main__.py +0 -0
  14. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/client.py +0 -0
  15. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/const.py +0 -0
  16. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/__init__.py +0 -0
  17. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/base.py +0 -0
  18. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/camera.py +0 -0
  19. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/dashcam.py +0 -0
  20. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/light.py +0 -0
  21. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/lock.py +0 -0
  22. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/plug.py +0 -0
  23. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/purifier.py +0 -0
  24. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/devices/sensor.py +0 -0
  25. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/exceptions.py +0 -0
  26. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/models.py +0 -0
  27. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/mqtt.py +0 -0
  28. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy/py.typed +0 -0
  29. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/SOURCES.txt +0 -0
  30. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/dependency_links.txt +0 -0
  31. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/entry_points.txt +0 -0
  32. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/requires.txt +0 -0
  33. {qubopy-0.2.0 → qubopy-0.2.2}/qubopy.egg-info/top_level.txt +0 -0
  34. {qubopy-0.2.0 → qubopy-0.2.2}/setup.cfg +0 -0
  35. {qubopy-0.2.0 → qubopy-0.2.2}/tests/__init__.py +0 -0
  36. {qubopy-0.2.0 → qubopy-0.2.2}/tests/conftest.py +0 -0
  37. {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_client.py +0 -0
  38. {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_devices.py +0 -0
  39. {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_lock_ble.py +0 -0
  40. {qubopy-0.2.0 → qubopy-0.2.2}/tests/test_mqtt.py +0 -0
  41. {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.0
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 the cloud.
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
- > Cloud Wowza URLs are single-consumer sessions. Opening the stream in a media player or restreaming software terminates any active stream session using the same token. Invoke `await camera.stop_stream()` when playback ends to release server-side stream session resources.
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 (can open in VLC or go2rtc)
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 (place -v or --debug before any command)
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 the cloud.
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
- > Cloud Wowza URLs are single-consumer sessions. Opening the stream in a media player or restreaming software terminates any active stream session using the same token. Invoke `await camera.stop_stream()` when playback ends to release server-side stream session resources.
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 (can open in VLC or go2rtc)
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 (place -v or --debug before any command)
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qubopy"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Asynchronous Python client library and local BLE enabler for Qubo smart home devices"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -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
- raise QuboAuthError(f"Email login failed (HTTP {response.status})")
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
- raise QuboAuthError(f"OTP request failed (HTTP {response.status})")
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
- raise QuboAuthError(f"OTP verification failed (HTTP {response.status})")
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 (HTTP {response.status}): "
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 (HTTP {response.status})")
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("\nYou can open this URL directly in VLC, ffplay, go2rtc, or Home Assistant.")
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.0
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 the cloud.
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
- > Cloud Wowza URLs are single-consumer sessions. Opening the stream in a media player or restreaming software terminates any active stream session using the same token. Invoke `await camera.stop_stream()` when playback ends to release server-side stream session resources.
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 (can open in VLC or go2rtc)
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 (place -v or --debug before any command)
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
- is_root = getattr(os, "geteuid", lambda: -1)() == 0
67
- root = "user root\n" if is_root else ""
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"{root}listener {self.port} 127.0.0.1\n"
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
- (tmp_path / "m.conf").write_text(conf_content, encoding="utf-8")
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.DEVNULL,
88
- stderr=subprocess.DEVNULL,
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
- raise RuntimeError("broker did not start")
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
- self.proc.wait()
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