python-aidot-cameras 0.7.31__tar.gz → 0.7.33__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 (62) hide show
  1. {python_aidot_cameras-0.7.31/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.33}/PKG-INFO +2 -2
  2. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/README.md +1 -1
  3. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/client.py +15 -12
  5. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/sdes.py +11 -3
  6. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/device_client.py +23 -2
  7. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
  8. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  9. python_aidot_cameras-0.7.33/tests/test_device_login_guard.py +95 -0
  10. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_sdes_fast_liveplay.py +9 -8
  11. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/LICENSE +0 -0
  12. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/MANIFEST.in +0 -0
  13. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/setup.cfg +0 -0
  14. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/__init__.py +0 -0
  15. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/aes_utils.py +0 -0
  16. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/__init__.py +0 -0
  17. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/constants.py +0 -0
  18. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/controls.py +0 -0
  19. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/go2rtc.py +0 -0
  20. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/lan_control.py +0 -0
  21. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/models.py +0 -0
  22. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/playback.py +0 -0
  23. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/protocol.py +0 -0
  24. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/tutk.py +0 -0
  25. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/camera/webrtc.py +0 -0
  26. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/client.py +0 -0
  27. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/const.py +0 -0
  28. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/credentials.py +0 -0
  29. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/discover.py +0 -0
  30. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/exceptions.py +0 -0
  31. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/g711.py +0 -0
  32. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/login_const.py +0 -0
  33. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/models/__init__.py +0 -0
  34. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/models/device_client_model.py +0 -0
  35. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/models/device_model.py +0 -0
  36. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/models/discover_model.py +0 -0
  37. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/aidot/py.typed +0 -0
  38. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  39. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  40. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  41. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_alarm_event.py +0 -0
  42. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_backoff.py +0 -0
  43. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_go2rtc.py +0 -0
  44. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_highport_nomination.py +0 -0
  45. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_lan_control.py +0 -0
  46. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_live_stream_param.py +0 -0
  47. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_motion_poll.py +0 -0
  48. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_no_undefined_names.py +0 -0
  49. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_persistent_mqtt.py +0 -0
  50. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_post_merge_hardening.py +0 -0
  51. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_sdes_adaptive.py +0 -0
  52. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_sdes_idle_release.py +0 -0
  53. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_sdes_sprop.py +0 -0
  54. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_sdes_talk.py +0 -0
  55. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_sdes_watchdog.py +0 -0
  56. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_serve_relay.py +0 -0
  57. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_speak.py +0 -0
  58. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_stream_cap.py +0 -0
  59. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_stream_idle.py +0 -0
  60. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_talk.py +0 -0
  61. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/tests/test_terminal_ack.py +0 -0
  62. {python_aidot_cameras-0.7.31 → python_aidot_cameras-0.7.33}/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.31
3
+ Version: 0.7.33
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
@@ -124,7 +124,7 @@ chosen to work out of the box; override only when tuning.
124
124
  | `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
125
125
  | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands, attribute fetches, AND stream-open signaling (matching the official app) instead of connecting per operation, cutting cloud connect churn. **On by default** (the app's behaviour; live soak cut SDES NO_MEDIA ~57%→~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
126
126
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
127
- | `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
127
+ | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start), going straight to webrtcReq/ICE — what the official app does (it never waits for livePlayResp). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
128
128
  | `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
129
129
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
130
130
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
@@ -97,7 +97,7 @@ chosen to work out of the box; override only when tuning.
97
97
  | `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
98
98
  | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands, attribute fetches, AND stream-open signaling (matching the official app) instead of connecting per operation, cutting cloud connect churn. **On by default** (the app's behaviour; live soak cut SDES NO_MEDIA ~57%→~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
99
99
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
100
- | `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
100
+ | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start), going straight to webrtcReq/ICE — what the official app does (it never waits for livePlayResp). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
101
101
  | `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
102
102
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
103
103
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.7.31"
7
+ version = "0.7.33"
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"
@@ -2607,16 +2607,19 @@ class CameraMixin(_CameraControlsMixin):
2607
2607
  return 120.0
2608
2608
 
2609
2609
  def _resolve_sdes_fast_liveplay(self) -> bool:
2610
- """Whether to use SDES fast-liveplay (shorten the always-timing-out
2611
- echo/livePlayResp signaling) - validated in soak, opt-in, default off.
2612
-
2613
- Per-camera ``sdes_fast_liveplay`` (set via start_keepalive) wins; else the
2614
- ``AIDOT_SDES_FAST_LIVEPLAY`` env (truthy = 1/true/yes/on), default off.
2615
- Only consulted for SDES cameras (the caller gates on is_sdes_camera).
2616
-
2617
- Role-reversal models (``_NO_FAST_LIVEPLAY_MODELS``, e.g. A001064) are always
2618
- excluded - their handshake needs the camera armed before our webrtcReq, so
2619
- the flag's early webrtcReq degrades their media reliability."""
2610
+ """Whether to use SDES fast-liveplay: don't block on the always-timing-out
2611
+ echo/livePlayResp signaling, go straight to webrtcReq/ICE.
2612
+
2613
+ **Default ON** - this is exactly what the official app does (it never waits
2614
+ for/parses livePlayResp; fire-and-forget). Disable via
2615
+ ``AIDOT_SDES_FAST_LIVEPLAY`` in {0,false,no,off} or per-camera
2616
+ ``_sdes_fast_liveplay_opt=False`` (the explicit opt always wins).
2617
+
2618
+ Role-reversal models (``_NO_FAST_LIVEPLAY_MODELS``, e.g. A001064) are ALWAYS
2619
+ excluded regardless of the flag - their handshake needs the camera armed
2620
+ before our webrtcReq, so the early webrtcReq degrades their media (a
2621
+ correctness exclusion, not a default choice). Only consulted for SDES
2622
+ cameras (the caller gates on is_sdes_camera)."""
2620
2623
  model = getattr(getattr(self, "info", None), "model_id", None)
2621
2624
  if model in self._NO_FAST_LIVEPLAY_MODELS:
2622
2625
  return False
@@ -2626,8 +2629,8 @@ class CameraMixin(_CameraControlsMixin):
2626
2629
  ov = getattr(self, "_fast_attempt_override", None)
2627
2630
  if ov is not None:
2628
2631
  return bool(ov)
2629
- return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() in (
2630
- "1", "true", "yes", "on")
2632
+ return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() not in (
2633
+ "0", "false", "no", "off")
2631
2634
 
2632
2635
  def _resolve_sdes_skip_turn(self) -> bool:
2633
2636
  """EXPERIMENTAL (opt-in, default off): skip the blocking SDES TURN relay
@@ -181,15 +181,23 @@ class SdesSession:
181
181
  self._talk_state["stop"] = True
182
182
  self._talk_state["provider"] = None
183
183
  self._proc.terminate()
184
+ _stop_loop = asyncio.get_running_loop()
184
185
  try:
185
- _stop_loop = asyncio.get_running_loop()
186
186
  await _stop_loop.run_in_executor(None, lambda: self._proc.wait(5))
187
187
  except Exception:
188
188
  self._proc.kill()
189
+ # Read drained stderr in the executor with a hard timeout: proc.stderr.read()
190
+ # blocks until EOF, which never arrives if the killed process is still a
191
+ # zombie / stuck in uninterruptible I/O - doing it inline would hang the
192
+ # whole event loop (and thus all of teardown). Bound it so a wedged ffmpeg
193
+ # can't stall the close; we lose only the diagnostic stderr in that case.
189
194
  stderr_bytes = b""
190
195
  try:
191
- stderr_bytes = self._proc.stderr.read()
192
- except Exception:
196
+ stderr_bytes = await asyncio.wait_for(
197
+ _stop_loop.run_in_executor(None, self._proc.stderr.read),
198
+ timeout=2.0,
199
+ )
200
+ except Exception: # incl. asyncio.TimeoutError - never let teardown hang here
193
201
  _LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
194
202
  if stderr_bytes:
195
203
  _LOGGER.warning("ffmpeg SDES stderr:\n%s", stderr_bytes.decode(errors="replace"))
@@ -229,14 +229,35 @@ class DeviceClient(CameraMixin):
229
229
  if ip is None:
230
230
  return
231
231
  self._ip_address = ip
232
- if self._connecting is not True and self._connect_and_login is not True:
233
- self._login_task = asyncio.create_task(self.async_login())
232
+ if self._connecting is True or self._connect_and_login is True:
233
+ return
234
+ # Throttle (shared 30s window with the reconnect chain): a repeated
235
+ # discovery - or a camera that slipped the _is_camera gate because its
236
+ # model was momentarily unknown - must not spawn a fresh TCP:10000 login
237
+ # every broadcast tick. That hammers a device whose control port refuses
238
+ # (e.g. a battery camera) and spams "login read status error". A device
239
+ # that genuinely needs to connect still does so within 30s.
240
+ now = time.monotonic()
241
+ if now - self._last_login_attempt < 30:
242
+ return
243
+ self._last_login_attempt = now
244
+ self._login_task = asyncio.create_task(self.async_login())
234
245
 
235
246
 
236
247
  async def async_login(self) -> None:
237
248
  """Connect and log in using the last-known IP if not already connected."""
238
249
  if self._ip_address is None:
239
250
  return
251
+ # The base TCP:10000 control channel is the LIGHT protocol. Cameras never
252
+ # use it - their local control is the separate CameraLanClient
253
+ # (camera/lan_control.py) and their LAN IP comes from WebRTC signaling. A
254
+ # camera reaching here means a discovered IP slipped the _is_camera gate;
255
+ # logging in would hammer a port the camera doesn't serve and spam
256
+ # "login read status error" (and never connect). This is the single
257
+ # chokepoint for both the discovery and reconnect-chain login paths.
258
+ model = getattr(getattr(self, "info", None), "model_id", "") or ""
259
+ if "IPC" in model:
260
+ return
240
261
  if self._connecting is not True and self._connect_and_login is not True:
241
262
  await self.connect(self._ip_address)
242
263
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.31
3
+ Version: 0.7.33
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
@@ -124,7 +124,7 @@ chosen to work out of the box; override only when tuning.
124
124
  | `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
125
125
  | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands, attribute fetches, AND stream-open signaling (matching the official app) instead of connecting per operation, cutting cloud connect churn. **On by default** (the app's behaviour; live soak cut SDES NO_MEDIA ~57%→~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
126
126
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
127
- | `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
127
+ | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start), going straight to webrtcReq/ICE — what the official app does (it never waits for livePlayResp). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
128
128
  | `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
129
129
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
130
130
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
@@ -36,6 +36,7 @@ src/python_aidot_cameras.egg-info/requires.txt
36
36
  src/python_aidot_cameras.egg-info/top_level.txt
37
37
  tests/test_alarm_event.py
38
38
  tests/test_backoff.py
39
+ tests/test_device_login_guard.py
39
40
  tests/test_go2rtc.py
40
41
  tests/test_highport_nomination.py
41
42
  tests/test_lan_control.py
@@ -0,0 +1,95 @@
1
+ """Unit tests for the base-DeviceClient local-login guard + throttle.
2
+
3
+ The TCP:10000 control channel is the LIGHT protocol; cameras must never use it
4
+ (they use the separate CameraLanClient + WebRTC signaling for their LAN IP). A
5
+ camera reaching async_login would hammer a refusing port and spam
6
+ "login read status error". These tests lock the camera-exclusion and the
7
+ re-login throttle without a real socket.
8
+ """
9
+ import asyncio
10
+ import os
11
+ import sys
12
+ from types import SimpleNamespace
13
+
14
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
15
+
16
+ import aidot.device_client as dc_mod
17
+
18
+ DeviceClient = dc_mod.DeviceClient
19
+
20
+
21
+ def _client(model_id):
22
+ c = DeviceClient.__new__(DeviceClient)
23
+ c.info = SimpleNamespace(model_id=model_id)
24
+ c._ip_address = "192.168.1.50"
25
+ c._connecting = False
26
+ c._connect_and_login = False
27
+ c._last_login_attempt = 0.0
28
+ c._connected = [] # record connect() calls
29
+ async def _fake_connect(ip):
30
+ c._connected.append(ip)
31
+ c.connect = _fake_connect
32
+ return c
33
+
34
+
35
+ def test_camera_never_does_base_login():
36
+ cam = _client("LK.IPC.A001513") # battery SDES camera
37
+ asyncio.run(cam.async_login())
38
+ assert cam._connected == [] # camera excluded - no TCP:10000 login
39
+
40
+
41
+ def test_ptz_camera_excluded():
42
+ cam = _client("LK.IPC.A001064")
43
+ asyncio.run(cam.async_login())
44
+ assert cam._connected == []
45
+
46
+
47
+ def test_light_still_logs_in():
48
+ light = _client("lk.WIFI-RGBWLight-D0006")
49
+ asyncio.run(light.async_login())
50
+ assert light._connected == ["192.168.1.50"] # lights keep the base login
51
+
52
+
53
+ def test_unknown_model_logs_in():
54
+ # No "IPC" in model -> treated as a (light) device that uses the base channel.
55
+ dev = _client("")
56
+ asyncio.run(dev.async_login())
57
+ assert dev._connected == ["192.168.1.50"]
58
+
59
+
60
+ def test_update_ip_address_throttles_relogin():
61
+ light = _client("lk.WIFI-RGBWLight-D0006")
62
+ spawned = []
63
+ # update_ip_address schedules async_login via create_task; capture the calls
64
+ # by counting how many times the throttle lets a login attempt through.
65
+ light._login_task = None
66
+ orig = dc_mod.asyncio.create_task
67
+
68
+ def _count_task(coro):
69
+ spawned.append(1)
70
+ coro.close() # don't actually run the login
71
+ return SimpleNamespace(done=lambda: True, cancel=lambda: None)
72
+
73
+ dc_mod.asyncio.create_task = _count_task
74
+ try:
75
+ light.update_ip_address("192.168.1.50") # first: allowed
76
+ light.update_ip_address("192.168.1.50") # immediate repeat: throttled
77
+ light.update_ip_address("192.168.1.50") # still throttled
78
+ finally:
79
+ dc_mod.asyncio.create_task = orig
80
+ assert len(spawned) == 1, f"expected 1 login within the 30s window, got {len(spawned)}"
81
+
82
+
83
+ if __name__ == "__main__":
84
+ import traceback
85
+ _fail = 0
86
+ for _k, _v in sorted(globals().items()):
87
+ if _k.startswith("test_"):
88
+ try:
89
+ _v()
90
+ print(f"PASS {_k}")
91
+ except Exception:
92
+ _fail += 1
93
+ print(f"FAIL {_k}")
94
+ traceback.print_exc()
95
+ raise SystemExit(1 if _fail else 0)
@@ -21,21 +21,22 @@ def _cam():
21
21
  return _CAM.__new__(_CAM)
22
22
 
23
23
 
24
- def test_default_off(monkeypatch):
24
+ def test_default_on(monkeypatch):
25
+ # default ON (matches the app — it never waits for livePlayResp)
25
26
  monkeypatch.delenv("AIDOT_SDES_FAST_LIVEPLAY", raising=False)
26
- assert _cam()._resolve_sdes_fast_liveplay() is False
27
+ assert _cam()._resolve_sdes_fast_liveplay() is True
27
28
 
28
29
 
29
- def test_env_truthy_values(monkeypatch):
30
- for val in ("1", "true", "TRUE", "yes", "on", " On "):
30
+ def test_env_disables(monkeypatch):
31
+ for val in ("0", "false", "no", "off", " Off "):
31
32
  monkeypatch.setenv("AIDOT_SDES_FAST_LIVEPLAY", val)
32
- assert _cam()._resolve_sdes_fast_liveplay() is True, val
33
+ assert _cam()._resolve_sdes_fast_liveplay() is False, val
33
34
 
34
35
 
35
- def test_env_falsey_values(monkeypatch):
36
- for val in ("0", "false", "no", "off", ""):
36
+ def test_env_truthy_or_unknown_stays_on(monkeypatch):
37
+ for val in ("1", "true", "yes", "on", "", "anything"):
37
38
  monkeypatch.setenv("AIDOT_SDES_FAST_LIVEPLAY", val)
38
- assert _cam()._resolve_sdes_fast_liveplay() is False, val
39
+ assert _cam()._resolve_sdes_fast_liveplay() is True, val
39
40
 
40
41
 
41
42
  def test_role_reversal_model_always_excluded(monkeypatch):