python-aidot-cameras 0.11.0__tar.gz → 0.11.1__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 (122) hide show
  1. {python_aidot_cameras-0.11.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.1}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/client.py +58 -3
  3. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.11.1/tests/test_offline_keepalive_pause.py +112 -0
  7. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/LICENSE +0 -0
  8. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/README.md +0 -0
  10. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/__init__.py +0 -0
  11. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/__main__.py +0 -0
  12. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/__init__.py +0 -0
  13. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/__init__.py +0 -0
  14. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/clock.py +0 -0
  15. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  16. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  17. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  18. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  19. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  20. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  21. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  22. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  23. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  24. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  25. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/events.py +0 -0
  26. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/exceptions.py +0 -0
  27. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  28. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  29. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rate.py +0 -0
  30. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  31. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  32. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  33. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  34. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  35. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  36. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  37. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  38. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  39. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  40. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  41. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtp.py +0 -0
  42. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/sdp.py +0 -0
  43. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/stats.py +0 -0
  44. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/utils.py +0 -0
  45. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/aes_utils.py +0 -0
  46. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/__init__.py +0 -0
  47. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/constants.py +0 -0
  48. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/controls.py +0 -0
  49. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/go2rtc.py +0 -0
  50. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/lan_control.py +0 -0
  51. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/models.py +0 -0
  52. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/playback.py +0 -0
  53. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/protocol.py +0 -0
  54. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/sdes.py +0 -0
  55. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/sdes_open.py +0 -0
  56. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/client.py +0 -0
  60. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/const.py +0 -0
  61. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/credentials.py +0 -0
  62. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/device_client.py +0 -0
  63. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/discover.py +0 -0
  64. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/exceptions.py +0 -0
  65. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/g711.py +0 -0
  66. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/login_const.py +0 -0
  67. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/models/__init__.py +0 -0
  68. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/models/device_client_model.py +0 -0
  69. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/models/device_model.py +0 -0
  70. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/models/discover_model.py +0 -0
  71. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/aidot/py.typed +0 -0
  72. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  73. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  74. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/requires.txt +0 -0
  75. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  76. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/setup.cfg +0 -0
  77. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_aioice_compat.py +0 -0
  78. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_alarm_event.py +0 -0
  79. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_backoff.py +0 -0
  80. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_deferred_hardening.py +0 -0
  81. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_device_login_guard.py +0 -0
  82. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_device_user_info_cache.py +0 -0
  83. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_dtls_not_ready_burst.py +0 -0
  84. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_dtls_pinning.py +0 -0
  85. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
  86. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_egress_guard.py +0 -0
  87. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_go2rtc.py +0 -0
  88. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_go2rtc_cli.py +0 -0
  89. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_keyframe_prompter.py +0 -0
  92. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_lan_control.py +0 -0
  93. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_live_stream_param.py +0 -0
  94. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_motion_poll.py +0 -0
  95. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_narrow_pc_ice.py +0 -0
  96. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_no_undefined_names.py +0 -0
  97. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_open_gate_delay.py +0 -0
  98. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_persistent_mqtt.py +0 -0
  99. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_persistent_mqtt_robustness.py +0 -0
  100. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_playback_tls.py +0 -0
  101. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_post_merge_hardening.py +0 -0
  102. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_retry_policy.py +0 -0
  103. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_adaptive.py +0 -0
  104. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
  105. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_fast_liveplay.py +0 -0
  106. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_idle_release.py +0 -0
  107. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_serve_audio.py +0 -0
  108. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_serve_cmd.py +0 -0
  109. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_sprop.py +0 -0
  110. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_talk.py +0 -0
  111. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_sdes_watchdog.py +0 -0
  112. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_security_hardening.py +0 -0
  113. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_serve_relay.py +0 -0
  114. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_session_stats.py +0 -0
  115. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_speak.py +0 -0
  116. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_stream_cap.py +0 -0
  117. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_stream_idle.py +0 -0
  118. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_stream_teardown.py +0 -0
  119. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_talk.py +0 -0
  120. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_terminal_ack.py +0 -0
  121. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_token_refresh.py +0 -0
  122. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.1}/tests/test_wait_or_event.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.11.0
3
+ Version: 0.11.1
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
@@ -86,6 +86,13 @@ from .protocol import ( # noqa: F401 - re-exported for device_client / back-com
86
86
 
87
87
  _LOGGER = logging.getLogger(__name__)
88
88
 
89
+ # Offline keepalive pause (see CameraMixin._backoff_or_offline_pause): while the
90
+ # cloud explicitly reports a device offline, failed-open retries re-check the
91
+ # flag every RECHECK seconds and only probe a real open every PROBE seconds,
92
+ # instead of consuming an open-gate slot on the normal reconnect cadence.
93
+ _OFFLINE_RECHECK_S = float(os.environ.get("AIDOT_OFFLINE_RECHECK_S", "30"))
94
+ _OFFLINE_PROBE_S = float(os.environ.get("AIDOT_OFFLINE_PROBE_S", "600"))
95
+
89
96
  # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
90
97
  # discarded task can be garbage-collected mid-flight. Discarded on completion.
91
98
  _BG_TASKS: set = set()
@@ -761,8 +768,56 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
761
768
  self.status.update_from_camera_attributes(props)
762
769
  if device.get("online") is not None:
763
770
  self.status.online = bool(device.get("online"))
771
+ # The cloud explicitly reported reachability - only then may the
772
+ # keepalive loops trust an offline flag (see
773
+ # _backoff_or_offline_pause; DeviceStatusData.online defaults to
774
+ # False, which must not read as "offline").
775
+ self._cloud_online_explicit = True
764
776
  return self.status
765
777
 
778
+ async def _backoff_or_offline_pause(self, delay: float) -> None:
779
+ """Sleep a reconnect backoff, extended while the device is cloud-offline.
780
+
781
+ Normal case (device online, or the cloud never reported an online
782
+ flag): plain ``asyncio.sleep(delay)`` - behavior unchanged.
783
+
784
+ When an open just failed AND the cloud has explicitly reported the
785
+ device offline (``update_status_from_device`` saw ``online: false``),
786
+ hold here instead of retrying on the pacer cadence: re-check the flag
787
+ every ``AIDOT_OFFLINE_RECHECK_S`` (default 30 s) and resume the moment
788
+ it flips back on, or fall through after ``AIDOT_OFFLINE_PROBE_S``
789
+ (default 600 s) so one probe open still runs in case the cloud flag is
790
+ stale. This keeps a dead/unpowered camera from consuming an open-gate
791
+ slot every backoff cycle and starving live cameras' cold opens
792
+ (observed live: two unpowered A000088s cycling 30 s open attempts
793
+ pushed a healthy camera's cold open past two minutes).
794
+ """
795
+ if self.status.online or not getattr(self, "_cloud_online_explicit", False):
796
+ await asyncio.sleep(delay)
797
+ return
798
+ _LOGGER.info(
799
+ "keepalive[%s]: device is cloud-offline - pausing open retries "
800
+ "(recheck %.0fs, probe %.0fs)",
801
+ self.device_id, _OFFLINE_RECHECK_S, _OFFLINE_PROBE_S,
802
+ )
803
+ _t0 = time.monotonic()
804
+ while (_elapsed := time.monotonic() - _t0) < _OFFLINE_PROBE_S:
805
+ await asyncio.sleep(
806
+ min(_OFFLINE_RECHECK_S, max(0.1, _OFFLINE_PROBE_S - _elapsed))
807
+ )
808
+ if not getattr(self, "_streaming_active", False):
809
+ return
810
+ if self.status.online:
811
+ _LOGGER.info(
812
+ "keepalive[%s]: device back online - resuming opens",
813
+ self.device_id,
814
+ )
815
+ return
816
+ _LOGGER.debug(
817
+ "keepalive[%s]: offline probe interval elapsed - probing one open",
818
+ self.device_id,
819
+ )
820
+
766
821
  # -- Camera helpers ------------------------------------------------------ #
767
822
 
768
823
  @property
@@ -2560,7 +2615,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2560
2615
  self.device_id, _delay, exc,
2561
2616
  )
2562
2617
  try:
2563
- await asyncio.sleep(_delay)
2618
+ await self._backoff_or_offline_pause(_delay)
2564
2619
  except asyncio.CancelledError:
2565
2620
  return
2566
2621
  continue
@@ -2735,7 +2790,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2735
2790
  self.device_id, _delay, exc,
2736
2791
  )
2737
2792
  try:
2738
- await asyncio.sleep(_delay)
2793
+ await self._backoff_or_offline_pause(_delay)
2739
2794
  except asyncio.CancelledError:
2740
2795
  return
2741
2796
  continue
@@ -3231,7 +3286,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3231
3286
  self.device_id, _delay, exc,
3232
3287
  )
3233
3288
  try:
3234
- await asyncio.sleep(_delay)
3289
+ await self._backoff_or_offline_pause(_delay)
3235
3290
  except asyncio.CancelledError:
3236
3291
  return
3237
3292
  continue
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.11.0"
7
+ version = "0.11.1"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.11.0
3
+ Version: 0.11.1
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
@@ -91,6 +91,7 @@ tests/test_live_stream_param.py
91
91
  tests/test_motion_poll.py
92
92
  tests/test_narrow_pc_ice.py
93
93
  tests/test_no_undefined_names.py
94
+ tests/test_offline_keepalive_pause.py
94
95
  tests/test_open_gate_delay.py
95
96
  tests/test_persistent_mqtt.py
96
97
  tests/test_persistent_mqtt_robustness.py
@@ -0,0 +1,112 @@
1
+ """Offline keepalive pause: failed-open retries hold while the device is
2
+ cloud-offline (CameraMixin._backoff_or_offline_pause) instead of consuming an
3
+ open-gate slot on the normal reconnect cadence."""
4
+
5
+ import asyncio
6
+ import time
7
+ from types import SimpleNamespace
8
+
9
+ import aidot.camera.client as camera_client
10
+ from aidot.camera.client import CameraMixin
11
+ from aidot.device_client import DeviceStatusData
12
+
13
+
14
+ def _stub(online: bool, explicit: bool, streaming: bool = True) -> SimpleNamespace:
15
+ s = SimpleNamespace(
16
+ status=DeviceStatusData(),
17
+ device_id="test-device",
18
+ _streaming_active=streaming,
19
+ )
20
+ s.status.online = online
21
+ if explicit:
22
+ s._cloud_online_explicit = True
23
+ return s
24
+
25
+
26
+ def _run(coro):
27
+ return asyncio.run(coro)
28
+
29
+
30
+ def test_online_device_sleeps_plain_delay():
31
+ stub = _stub(online=True, explicit=True)
32
+ t0 = time.monotonic()
33
+ _run(CameraMixin._backoff_or_offline_pause(stub, 0.05))
34
+ took = time.monotonic() - t0
35
+ assert 0.04 <= took < 1.0
36
+
37
+
38
+ def test_unreported_online_state_never_pauses(monkeypatch):
39
+ # DeviceStatusData.online defaults to False; without the explicit marker
40
+ # that must NOT read as "offline" (a long pause here would slow every
41
+ # camera whose device dict was never merged).
42
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 60.0)
43
+ stub = _stub(online=False, explicit=False)
44
+ t0 = time.monotonic()
45
+ _run(CameraMixin._backoff_or_offline_pause(stub, 0.05))
46
+ took = time.monotonic() - t0
47
+ assert took < 1.0
48
+
49
+
50
+ def test_offline_device_holds_then_probes(monkeypatch):
51
+ # Explicitly offline: the pause holds past the plain delay and falls
52
+ # through after the probe interval so one probe open still runs.
53
+ monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
54
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 0.3)
55
+ stub = _stub(online=False, explicit=True)
56
+ t0 = time.monotonic()
57
+ _run(CameraMixin._backoff_or_offline_pause(stub, 0.01))
58
+ took = time.monotonic() - t0
59
+ assert took >= 0.3 # held for the whole probe interval
60
+ assert took < 2.0
61
+
62
+
63
+ def test_offline_device_resumes_when_back_online(monkeypatch):
64
+ monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
65
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 30.0)
66
+ stub = _stub(online=False, explicit=True)
67
+
68
+ async def scenario():
69
+ async def flip():
70
+ await asyncio.sleep(0.15)
71
+ stub.status.online = True
72
+
73
+ t0 = time.monotonic()
74
+ await asyncio.gather(
75
+ CameraMixin._backoff_or_offline_pause(stub, 0.01), flip()
76
+ )
77
+ return time.monotonic() - t0
78
+
79
+ took = _run(scenario())
80
+ # resumed within a couple of recheck ticks of the flip, far short of PROBE
81
+ assert 0.15 <= took < 2.0
82
+
83
+
84
+ def test_offline_pause_exits_when_streaming_stops(monkeypatch):
85
+ monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
86
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 30.0)
87
+ stub = _stub(online=False, explicit=True)
88
+
89
+ async def scenario():
90
+ async def stop():
91
+ await asyncio.sleep(0.15)
92
+ stub._streaming_active = False
93
+
94
+ t0 = time.monotonic()
95
+ await asyncio.gather(
96
+ CameraMixin._backoff_or_offline_pause(stub, 0.01), stop()
97
+ )
98
+ return time.monotonic() - t0
99
+
100
+ took = _run(scenario())
101
+ assert took < 2.0
102
+
103
+
104
+ def test_update_status_sets_explicit_marker():
105
+ stub = SimpleNamespace(status=DeviceStatusData())
106
+ CameraMixin.update_status_from_device(stub, {"online": False})
107
+ assert stub.status.online is False
108
+ assert getattr(stub, "_cloud_online_explicit", False) is True
109
+
110
+ stub2 = SimpleNamespace(status=DeviceStatusData())
111
+ CameraMixin.update_status_from_device(stub2, {"name": "no online key"})
112
+ assert getattr(stub2, "_cloud_online_explicit", False) is False