python-aidot-cameras 0.7.14__tar.gz → 0.7.16__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 (56) hide show
  1. {python_aidot_cameras-0.7.14/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.16}/PKG-INFO +2 -1
  2. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/README.md +1 -0
  3. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/client.py +69 -0
  5. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -1
  6. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. python_aidot_cameras-0.7.16/tests/test_live_stream_param.py +75 -0
  8. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/LICENSE +0 -0
  9. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/protocol.py +0 -0
  21. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_motion_poll.py +0 -0
  46. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_no_undefined_names.py +0 -0
  47. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_post_merge_hardening.py +0 -0
  48. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_sdes_idle_release.py +0 -0
  49. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_sdes_sprop.py +0 -0
  50. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_sdes_talk.py +0 -0
  51. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_sdes_watchdog.py +0 -0
  52. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_speak.py +0 -0
  53. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_stream_cap.py +0 -0
  54. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_talk.py +0 -0
  55. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_terminal_ack.py +0 -0
  56. {python_aidot_cameras-0.7.14 → python_aidot_cameras-0.7.16}/tests/test_token_refresh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.14
3
+ Version: 0.7.16
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -129,6 +129,7 @@ chosen to work out of the box; override only when tuning.
129
129
  | `AIDOT_ICE_DISCONNECT_S` | ICE-disconnect debounce, in seconds, before tearing down. | `8` |
130
130
  | `AIDOT_DTLS_RETRY_GATE_S` | Minimum spacing, in seconds, between DTLS open retries. | `15` |
131
131
  | `AIDOT_BUSY_RETRY_S` | Delay, in seconds, before retrying when a camera reports busy. | `45` |
132
+ | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
132
133
  | `AIDOT_GOP_PLI_S` | Interval, in seconds, between PLI (keyframe) requests. | `2.0` |
133
134
  | `AIDOT_AUDIO_TARGET_DBFS` | Target loudness (dBFS) for two-way audio normalization. | `-15` |
134
135
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
@@ -102,6 +102,7 @@ chosen to work out of the box; override only when tuning.
102
102
  | `AIDOT_ICE_DISCONNECT_S` | ICE-disconnect debounce, in seconds, before tearing down. | `8` |
103
103
  | `AIDOT_DTLS_RETRY_GATE_S` | Minimum spacing, in seconds, between DTLS open retries. | `15` |
104
104
  | `AIDOT_BUSY_RETRY_S` | Delay, in seconds, before retrying when a camera reports busy. | `45` |
105
+ | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
105
106
  | `AIDOT_GOP_PLI_S` | Interval, in seconds, between PLI (keyframe) requests. | `2.0` |
106
107
  | `AIDOT_AUDIO_TARGET_DBFS` | Target loudness (dBFS) for two-way audio normalization. | `-15` |
107
108
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.7.14"
7
+ version = "0.7.16"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -678,6 +678,60 @@ class CameraMixin(_CameraControlsMixin):
678
678
  _LOGGER.debug("http wake failed for %s: %s", self.device_id, exc)
679
679
  return False
680
680
 
681
+ def _live_stream_param_request(self):
682
+ """Build ``(url, headers, body)`` for the liveStreamParam provision call.
683
+
684
+ Pure/deterministic - unit-tested in tests/test_live_stream_param.py. The
685
+ body MUST be a JSON array of device ids (object body -> HTTP 500) and the
686
+ ``owner`` header is required (as on the wake endpoint).
687
+ """
688
+ headers = self._leedarson_headers()
689
+ headers["owner"] = (
690
+ self._user_info.get("owner")
691
+ or self._user_info.get("id")
692
+ or self._user_info.get("userId")
693
+ or str(self.user_id)
694
+ )
695
+ url = f"{self._smarthome_base}/api/ipc/liveStream/liveStreamParam"
696
+ return url, headers, json.dumps([self.device_id])
697
+
698
+ async def _async_fetch_live_stream_param(self) -> bool:
699
+ """Provision the camera's live-stream session via the cloud (app parity).
700
+
701
+ Battery cameras waking from deep sleep reject the MQTT ``livePlayReq``
702
+ with code ``-50019`` ("not ready") and then never run ICE - so no media
703
+ ever flows - unless the app's pre-connect HTTP call has armed the session
704
+ first. The official app (``KVSPreConnectStrategy.fetchKvsParams``) POSTs
705
+ the device id to ``/api/ipc/liveStream/liveStreamParam``; the cloud
706
+ provisions the session (and brings the camera online) and returns AWS KVS
707
+ credentials. We only need the provisioning side effect - the library
708
+ streams over the proprietary MQTT/SDES path, not AWS KVS - so the returned
709
+ credentials are ignored.
710
+
711
+ The request body is a JSON **array** of device ids (an object body returns
712
+ HTTP 500) and needs the ``owner`` header (like the wake endpoint).
713
+ Best-effort: returns True on HTTP 200, never raises.
714
+ """
715
+ import aiohttp
716
+
717
+ url, headers, body = self._live_stream_param_request()
718
+ try:
719
+ async with aiohttp.ClientSession() as session:
720
+ async with session.post(
721
+ url, headers=headers, data=body,
722
+ timeout=aiohttp.ClientTimeout(total=10),
723
+ ) as resp:
724
+ status = resp.status
725
+ data = await resp.json(content_type=None)
726
+ code = data.get("code") if isinstance(data, dict) else None
727
+ ok = status == 200 and (code in (None, 0, 200, "0", "200"))
728
+ _LOGGER.debug("liveStreamParam %s: status=%s code=%s ok=%s",
729
+ self.device_id, status, code, ok)
730
+ return bool(ok)
731
+ except Exception as exc:
732
+ _LOGGER.debug("liveStreamParam failed for %s: %s", self.device_id, exc)
733
+ return False
734
+
681
735
  async def async_wake_camera(self, retries: int = 2) -> bool:
682
736
  """Wake a battery camera on demand via the cloud HTTP low-power endpoint.
683
737
 
@@ -3108,6 +3162,21 @@ class CameraMixin(_CameraControlsMixin):
3108
3162
 
3109
3163
  use_sdes = force_sdes if force_sdes is not None else self.is_sdes_camera
3110
3164
 
3165
+ # Battery cameras must have their live-stream session provisioned by the
3166
+ # cloud BEFORE MQTT signaling, or the camera rejects livePlayReq with
3167
+ # -50019 ("not ready") and never runs ICE -> no media. App parity:
3168
+ # KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first. We
3169
+ # only need the provisioning side effect (we stream over MQTT/SDES, not
3170
+ # AWS KVS). Mains cameras are always stream-ready, so skip them. Disable
3171
+ # via AIDOT_LIVESTREAM_PARAM=0. Validated live on L2_170 (A001513): with
3172
+ # this call livePlay succeeds and decrypted RTP flows; without it,
3173
+ # persistent -50019.
3174
+ if (self.is_battery_camera
3175
+ and os.environ.get("AIDOT_LIVESTREAM_PARAM", "1") != "0"):
3176
+ _lsp_ok = await self._async_fetch_live_stream_param()
3177
+ _LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
3178
+ self.device_id, _lsp_ok)
3179
+
3111
3180
  # AIDOT_FAST_CONNECT (default off): LAN-direct mode. Both transports stall
3112
3181
  # the offer on a TURN relay allocation to the cloud TURN server before ICE
3113
3182
  # can even start - for DTLS, aiortc's setLocalDescription blocks until ICE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.14
3
+ Version: 0.7.16
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -129,6 +129,7 @@ chosen to work out of the box; override only when tuning.
129
129
  | `AIDOT_ICE_DISCONNECT_S` | ICE-disconnect debounce, in seconds, before tearing down. | `8` |
130
130
  | `AIDOT_DTLS_RETRY_GATE_S` | Minimum spacing, in seconds, between DTLS open retries. | `15` |
131
131
  | `AIDOT_BUSY_RETRY_S` | Delay, in seconds, before retrying when a camera reports busy. | `45` |
132
+ | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
132
133
  | `AIDOT_GOP_PLI_S` | Interval, in seconds, between PLI (keyframe) requests. | `2.0` |
133
134
  | `AIDOT_AUDIO_TARGET_DBFS` | Target loudness (dBFS) for two-way audio normalization. | `-15` |
134
135
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
@@ -39,6 +39,7 @@ tests/test_backoff.py
39
39
  tests/test_go2rtc.py
40
40
  tests/test_highport_nomination.py
41
41
  tests/test_lan_control.py
42
+ tests/test_live_stream_param.py
42
43
  tests/test_motion_poll.py
43
44
  tests/test_no_undefined_names.py
44
45
  tests/test_post_merge_hardening.py
@@ -0,0 +1,75 @@
1
+ """Unit tests for the liveStreamParam provision-request builder.
2
+
3
+ Battery cameras (e.g. L2_170, A001513) must have their live-stream session
4
+ provisioned by the cloud before MQTT signaling, or they reject livePlayReq with
5
+ -50019 and never stream. The request shape is exact: a JSON ARRAY body of device
6
+ ids (an object body returns HTTP 500) plus an ``owner`` header. This locks that
7
+ format in. No network: builds the request from a bare instance.
8
+ """
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
14
+
15
+ import aidot.camera.client as cc
16
+
17
+ # The camera mixin class that defines the builder (avoid hard-coding the name).
18
+ _CAM = next(v for v in vars(cc).values()
19
+ if isinstance(v, type) and "_live_stream_param_request" in v.__dict__)
20
+
21
+
22
+ def _stub(**over):
23
+ cam = _CAM.__new__(_CAM)
24
+ cam._user_info = over.get("user_info", {"accessToken": "TKN", "id": "owner-123"})
25
+ cam._region = "us" # _smarthome_base is a property derived from _region
26
+ cam.device_id = over.get("device_id", "DEV1234")
27
+ cam.user_id = "U1"
28
+ return cam
29
+
30
+
31
+ def test_url_is_livestreamparam_endpoint():
32
+ url, _, _ = _stub()._live_stream_param_request()
33
+ assert url.endswith("/api/ipc/liveStream/liveStreamParam")
34
+ assert url.startswith("https://")
35
+
36
+
37
+ def test_body_is_json_array_of_device_id():
38
+ # MUST be a JSON array - an object body ({"deviceId": id}) returns HTTP 500.
39
+ _, _, body = _stub()._live_stream_param_request()
40
+ parsed = json.loads(body)
41
+ assert isinstance(parsed, list)
42
+ assert parsed == ["DEV1234"]
43
+
44
+
45
+ def test_owner_header_required():
46
+ # The endpoint needs owner (like the wake endpoint); falls back across keys.
47
+ _, headers, _ = _stub()._live_stream_param_request()
48
+ assert headers.get("owner") == "owner-123"
49
+
50
+
51
+ def test_owner_falls_back_to_user_id():
52
+ cam = _stub(user_info={"accessToken": "T"}) # no id/owner/userId
53
+ _, headers, _ = cam._live_stream_param_request()
54
+ assert headers.get("owner") == "U1"
55
+
56
+
57
+ def test_auth_headers_carried():
58
+ _, headers, _ = _stub()._live_stream_param_request()
59
+ assert headers.get("token") == "TKN"
60
+ assert headers.get("terminal") == "app"
61
+
62
+
63
+ if __name__ == "__main__":
64
+ import traceback
65
+ _fail = 0
66
+ for _k, _v in sorted(globals().items()):
67
+ if _k.startswith("test_"):
68
+ try:
69
+ _v()
70
+ print(f"PASS {_k}")
71
+ except Exception:
72
+ _fail += 1
73
+ print(f"FAIL {_k}")
74
+ traceback.print_exc()
75
+ raise SystemExit(1 if _fail else 0)