python-aidot-cameras 0.7.18__tar.gz → 0.7.19__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 (58) hide show
  1. {python_aidot_cameras-0.7.18/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.19}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/client.py +83 -8
  4. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.7.19/tests/test_stream_idle.py +69 -0
  7. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/LICENSE +0 -0
  8. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/README.md +0 -0
  10. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/protocol.py +0 -0
  21. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_live_stream_param.py +0 -0
  46. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_motion_poll.py +0 -0
  47. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_no_undefined_names.py +0 -0
  48. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_post_merge_hardening.py +0 -0
  49. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_sdes_idle_release.py +0 -0
  50. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_sdes_sprop.py +0 -0
  51. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_sdes_talk.py +0 -0
  52. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_sdes_watchdog.py +0 -0
  53. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_serve_relay.py +0 -0
  54. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_speak.py +0 -0
  55. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_stream_cap.py +0 -0
  56. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_talk.py +0 -0
  57. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/tests/test_terminal_ack.py +0 -0
  58. {python_aidot_cameras-0.7.18 → python_aidot_cameras-0.7.19}/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.18
3
+ Version: 0.7.19
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.7.18"
7
+ version = "0.7.19"
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"
@@ -508,6 +508,9 @@ class CameraMixin(_CameraControlsMixin):
508
508
  # Cold-start serve-port relay (holds the public port connectable through
509
509
  # the handshake so an eager go2rtc pull waits instead of being refused).
510
510
  self._serve_relay: "Optional[_ServeRelay]" = None
511
+ # Cold-start instrumentation: monotonic at the start of an open, so any
512
+ # thread (signaling, bridge, serve) can log elapsed-ms phase markers.
513
+ self._cold_open_t0: "Optional[float]" = None
511
514
  # Motion/event polling (cloud event list). NOTE: the camera does NOT push motion
512
515
  # to a passive MQTT subscriber - alarmType is only emitted during an active live
513
516
  # view (decompiled NewLiveFragment) - so real-time-ish motion for HA comes from
@@ -1946,6 +1949,7 @@ class CameraMixin(_CameraControlsMixin):
1946
1949
  go2rtc_url: Optional[str] = None,
1947
1950
  live_stream_param: Optional[bool] = None,
1948
1951
  serve_relay: Optional[bool] = None,
1952
+ stream_idle_s: Optional[float] = None,
1949
1953
  ) -> None:
1950
1954
  """Start a persistent stream that keeps the camera session alive.
1951
1955
 
@@ -1974,6 +1978,14 @@ class CameraMixin(_CameraControlsMixin):
1974
1978
  ``AIDOT_SERVE_RELAY`` env var (default on); set False to serve ffmpeg
1975
1979
  directly on the public port (the pre-0.7.18 behaviour).
1976
1980
 
1981
+ ``stream_idle_s`` sets the no-viewer idle-release window in seconds,
1982
+ overriding the ``AIDOT_STREAM_IDLE_S`` env default (120). ``0`` (or
1983
+ negative) keeps the warm WebRTC session forever so re-views are instant
1984
+ (app-like) - sensible for mains cameras (no battery cost), but it holds a
1985
+ concurrent-stream slot for the camera's lifetime, so don't pin more
1986
+ cameras than ``AIDOT_MAX_CONCURRENT_STREAMS`` allows. Leave None for
1987
+ battery cameras (motion-prewarm + idle-release preserves battery).
1988
+
1977
1989
  Safe to call multiple times - does nothing if already running.
1978
1990
  """
1979
1991
  if fast_connect is not None:
@@ -1984,6 +1996,8 @@ class CameraMixin(_CameraControlsMixin):
1984
1996
  self._live_stream_param_opt = live_stream_param
1985
1997
  if serve_relay is not None:
1986
1998
  self._serve_relay_opt = serve_relay
1999
+ if stream_idle_s is not None:
2000
+ self._stream_idle_opt = stream_idle_s
1987
2001
  if self._stream_task is not None and not self._stream_task.done():
1988
2002
  return
1989
2003
  self._keepalive_rtsp_url = rtsp_push_url
@@ -2236,8 +2250,10 @@ class CameraMixin(_CameraControlsMixin):
2236
2250
  # window as DTLS; the next view re-runs camera.stream_source(). Fail
2237
2251
  # safe: unknown (non-Linux /proc) never releases. Escape hatch:
2238
2252
  # AIDOT_SDES_IDLE_RELEASE=0.
2239
- _idle_on = os.environ.get("AIDOT_SDES_IDLE_RELEASE", "1") != "0"
2240
- _idle_secs = float(os.environ.get("AIDOT_STREAM_IDLE_S", "120"))
2253
+ # stream_idle_s / AIDOT_STREAM_IDLE_S override; <= 0 = never release.
2254
+ _idle_secs = self._resolve_idle_secs()
2255
+ _idle_on = (os.environ.get("AIDOT_SDES_IDLE_RELEASE", "1") != "0"
2256
+ and _idle_secs > 0)
2241
2257
  _serve_port = _sdes_serve_port(self._keepalive_rtsp_url)
2242
2258
  _last_consumer = _started_at # grace: count idle from session open
2243
2259
  try:
@@ -2480,6 +2496,38 @@ class CameraMixin(_CameraControlsMixin):
2480
2496
  got_a = self._install_encoded_tap(_r, aq, False)
2481
2497
  return got_v
2482
2498
 
2499
+ def _cold_phase(self, label: str) -> None:
2500
+ """Log elapsed-ms since the open began (cold-start instrumentation).
2501
+
2502
+ Defensive + best-effort: never raises, no-op if no open is in flight.
2503
+ Emits a single greppable INFO line per phase so a cold connect's timeline
2504
+ (open -> webrtcReq -> first-media -> serving) can be measured without a
2505
+ debugger or ad-hoc log scraping. Grep ``cold-start[``."""
2506
+ try:
2507
+ t0 = self._cold_open_t0
2508
+ if t0 is None:
2509
+ return
2510
+ _LOGGER.info(
2511
+ "cold-start[%s] %s +%dms",
2512
+ self.device_id, label, int((time.monotonic() - t0) * 1000),
2513
+ )
2514
+ except Exception:
2515
+ pass
2516
+
2517
+ def _resolve_idle_secs(self) -> float:
2518
+ """Serve idle-release window (seconds). A per-camera ``stream_idle_s``
2519
+ (set via start_keepalive) overrides the ``AIDOT_STREAM_IDLE_S`` env
2520
+ default of 120 s. <= 0 means *never* idle-release (keep the warm session
2521
+ for instant re-views — sensible for mains cameras, which have no battery
2522
+ cost; note it holds a concurrent-stream slot for the camera's lifetime)."""
2523
+ opt = getattr(self, "_stream_idle_opt", None)
2524
+ if opt is not None:
2525
+ return float(opt)
2526
+ try:
2527
+ return float(os.environ.get("AIDOT_STREAM_IDLE_S", "120"))
2528
+ except (TypeError, ValueError):
2529
+ return 120.0
2530
+
2483
2531
  def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
2484
2532
  """Hold the public serve port via a _ServeRelay so an eager go2rtc pull
2485
2533
  connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
@@ -2646,7 +2694,9 @@ class CameraMixin(_CameraControlsMixin):
2646
2694
  # Must exceed HA's stream-worker reconnect interval (~40s) or the serve
2647
2695
  # is torn down between a viewer's retries -> "Connection refused". 120s
2648
2696
  # survives several retries while still releasing an unviewed camera.
2649
- idle_secs = float(os.environ.get("AIDOT_STREAM_IDLE_S", "120"))
2697
+ # stream_idle_s / AIDOT_STREAM_IDLE_S override; <= 0 = never release
2698
+ # (keep warm for instant re-views; mains cameras only - see P1).
2699
+ idle_secs = self._resolve_idle_secs()
2650
2700
  proc = wfile = stop_flag = mux_thread = None
2651
2701
  cancelled = idle_release = False
2652
2702
  try:
@@ -2705,6 +2755,7 @@ class CameraMixin(_CameraControlsMixin):
2705
2755
  # client and tear down before go2rtc connects.)
2706
2756
  await asyncio.sleep(0.3)
2707
2757
  self._serve_ready.set()
2758
+ self._cold_phase("serving (dtls)")
2708
2759
  # Periodic keyframe request shortens the camera's GOP so HA's
2709
2760
  # HLS segmenter (which cuts on keyframes) produces short
2710
2761
  # segments -> far less player buffering (the ~20s HLS lag is
@@ -2722,8 +2773,9 @@ class CameraMixin(_CameraControlsMixin):
2722
2773
  await self._send_video_pli(pc)
2723
2774
  _last_gop_pli = loop.time()
2724
2775
  # No consumer -> the pipe fills, the mux blocks, progress
2725
- # goes stale. A real viewer keeps it fresh.
2726
- if loop.time() - progress[0] > idle_secs:
2776
+ # goes stale. A real viewer keeps it fresh. idle_secs<=0
2777
+ # disables release entirely (keep warm for instant views).
2778
+ if idle_secs > 0 and loop.time() - progress[0] > idle_secs:
2727
2779
  idle_release = True
2728
2780
  break
2729
2781
  # Tear down this ffmpeg+mux cycle before the next.
@@ -3236,6 +3288,10 @@ class CameraMixin(_CameraControlsMixin):
3236
3288
  """
3237
3289
  import queue as _q_mod
3238
3290
 
3291
+ # Cold-start instrumentation: stamp t0 so phase markers below (and in the
3292
+ # bridge/serve threads) log elapsed-ms. Grep ``cold-start[``.
3293
+ self._cold_open_t0 = time.monotonic()
3294
+
3239
3295
  use_sdes = force_sdes if force_sdes is not None else self.is_sdes_camera
3240
3296
 
3241
3297
  # Battery cameras must have their live-stream session provisioned by the
@@ -4912,6 +4968,7 @@ class CameraMixin(_CameraControlsMixin):
4912
4968
  },
4913
4969
  })
4914
4970
  outgoing_q.put_nowait((webrtc_req_topic, webrtc_req_payload))
4971
+ self._cold_phase("webrtcReq")
4915
4972
  _status(f"webrtcReq sent peerid={peer_id}"
4916
4973
  f" IceServerList×{len(_ice_server_list)}"
4917
4974
  f" payload={len(webrtc_req_payload)}B")
@@ -8404,9 +8461,25 @@ class CameraMixin(_CameraControlsMixin):
8404
8461
  # on the camera's own GOP). We can't decode here to detect the
8405
8462
  # first keyframe, so we time-box the burst and keep a slow
8406
8463
  # safety PLI in case the camera's GOP is long.
8407
- _PLI_BURST_N = 3 # 5 s-spaced startup PLIs (t≈0,5,10)
8464
+ # P2: front-load the burst so the first decodable IDR arrives
8465
+ # sooner on a cold open. Was 3 PLIs at a flat 5 s (first IDR
8466
+ # up to ~10 s); now a denser early ramp, then the same 30 s
8467
+ # safety PLI. AIDOT_SDES_PLI_GAPS overrides the early gaps
8468
+ # (comma-sep seconds; e.g. "5,5,5" restores the old cadence).
8469
+ _pli_gaps = getattr(_bridge_fn, '_pli_gaps', None)
8470
+ if _pli_gaps is None:
8471
+ try:
8472
+ _pli_gaps = tuple(
8473
+ float(_x) for _x in os.environ.get(
8474
+ "AIDOT_SDES_PLI_GAPS", "0,1.5,2,3").split(",")
8475
+ if _x.strip()
8476
+ ) or (0.0, 1.5, 2.0, 3.0)
8477
+ except ValueError:
8478
+ _pli_gaps = (0.0, 1.5, 2.0, 3.0)
8479
+ _bridge_fn._pli_gaps = _pli_gaps
8408
8480
  _pli_done = getattr(_bridge_fn, '_pli_count', 0)
8409
- _pli_interval = 5.0 if _pli_done < _PLI_BURST_N else 30.0
8481
+ _pli_interval = (_pli_gaps[_pli_done]
8482
+ if _pli_done < len(_pli_gaps) else 30.0)
8410
8483
  if (hasattr(_bridge_fn, '_cam_video_ssrc')
8411
8484
  and hasattr(_bridge_fn, '_cam_srtp_sock')
8412
8485
  and _time_br.time() - getattr(
@@ -8455,7 +8528,7 @@ class CameraMixin(_CameraControlsMixin):
8455
8528
  _bridge_fn._last_pli_ts = _time_br.time()
8456
8529
  _pli_n = getattr(_bridge_fn, '_pli_count', 0) + 1
8457
8530
  _bridge_fn._pli_count = _pli_n
8458
- if _pli_n <= 3:
8531
+ if _pli_n <= len(_pli_gaps):
8459
8532
  _status(
8460
8533
  f"SDES: sent RTCP PLI #{_pli_n}"
8461
8534
  f" → SSRC=0x{_pli_media_ssrc:08x}"
@@ -9334,6 +9407,7 @@ class CameraMixin(_CameraControlsMixin):
9334
9407
  _bridge_fn._srtp_rx_sess = _plsrtp_rx.Session(
9335
9408
  policy=_rx_pol
9336
9409
  )
9410
+ self._cold_phase("first-media")
9337
9411
  _status("bridge: SRTP RX session ready (cam→us)")
9338
9412
  except Exception as _srx_e:
9339
9413
  _status(f"bridge: SRTP RX init failed: {_srx_e}")
@@ -9740,6 +9814,7 @@ class CameraMixin(_CameraControlsMixin):
9740
9814
  await asyncio.sleep(0.05)
9741
9815
 
9742
9816
  _bind_ms = int((time.monotonic() - _t0) * 1000)
9817
+ self._cold_phase("serving (sdes ffmpeg bound)")
9743
9818
  if _bound:
9744
9819
  _status(
9745
9820
  f"SDES ffmpeg ready - loopback audio={_lo_audio_port}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.18
3
+ Version: 0.7.19
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
@@ -50,6 +50,7 @@ tests/test_sdes_watchdog.py
50
50
  tests/test_serve_relay.py
51
51
  tests/test_speak.py
52
52
  tests/test_stream_cap.py
53
+ tests/test_stream_idle.py
53
54
  tests/test_talk.py
54
55
  tests/test_terminal_ack.py
55
56
  tests/test_token_refresh.py
@@ -0,0 +1,69 @@
1
+ """Unit tests for _resolve_idle_secs - the serve idle-release policy (P1).
2
+
3
+ The no-viewer idle window decides when a warm WebRTC session is torn down. A
4
+ per-camera ``stream_idle_s`` (from start_keepalive) overrides AIDOT_STREAM_IDLE_S
5
+ (default 120 s); <= 0 means never release (keep mains cameras warm for instant
6
+ re-views). No network/camera: drives a bare instance.
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
12
+
13
+ import aidot.camera.client as cc
14
+
15
+ _CAM = next(v for v in vars(cc).values()
16
+ if isinstance(v, type) and "_resolve_idle_secs" in v.__dict__)
17
+
18
+
19
+ def _cam():
20
+ return _CAM.__new__(_CAM)
21
+
22
+
23
+ def test_default_is_120_when_unset(monkeypatch):
24
+ monkeypatch.delenv("AIDOT_STREAM_IDLE_S", raising=False)
25
+ assert _cam()._resolve_idle_secs() == 120.0
26
+
27
+
28
+ def test_env_override(monkeypatch):
29
+ monkeypatch.setenv("AIDOT_STREAM_IDLE_S", "300")
30
+ assert _cam()._resolve_idle_secs() == 300.0
31
+
32
+
33
+ def test_kwarg_option_wins_over_env(monkeypatch):
34
+ monkeypatch.setenv("AIDOT_STREAM_IDLE_S", "300")
35
+ cam = _cam()
36
+ cam._stream_idle_opt = 45.0 # what start_keepalive(stream_idle_s=45) sets
37
+ assert cam._resolve_idle_secs() == 45.0
38
+
39
+
40
+ def test_zero_means_never_release():
41
+ cam = _cam()
42
+ cam._stream_idle_opt = 0.0
43
+ # The serve loops treat <= 0 as "never release"; the resolver just returns it.
44
+ assert cam._resolve_idle_secs() == 0.0
45
+
46
+
47
+ def test_bad_env_falls_back_to_120(monkeypatch):
48
+ monkeypatch.setenv("AIDOT_STREAM_IDLE_S", "not-a-number")
49
+ assert _cam()._resolve_idle_secs() == 120.0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ import traceback
54
+
55
+ class _MP: # minimal monkeypatch stand-in for direct `python` runs
56
+ def setenv(self, k, v): os.environ[k] = v
57
+ def delenv(self, k, raising=False): os.environ.pop(k, None)
58
+
59
+ _fail = 0
60
+ for _k, _v in sorted(globals().items()):
61
+ if _k.startswith("test_"):
62
+ try:
63
+ _v(_MP()) if _v.__code__.co_argcount else _v()
64
+ print(f"PASS {_k}")
65
+ except Exception:
66
+ _fail += 1
67
+ print(f"FAIL {_k}")
68
+ traceback.print_exc()
69
+ raise SystemExit(1 if _fail else 0)