python-aidot-cameras 0.11.0__tar.gz → 0.11.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 (123) hide show
  1. {python_aidot_cameras-0.11.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.2}/PKG-INFO +9 -1
  2. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/README.md +8 -0
  3. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/__main__.py +14 -4
  4. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/client.py +71 -7
  5. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/client.py +23 -0
  6. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/const.py +15 -0
  7. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2/python_aidot_cameras.egg-info}/PKG-INFO +9 -1
  9. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  10. python_aidot_cameras-0.11.2/tests/test_offline_keepalive_pause.py +112 -0
  11. python_aidot_cameras-0.11.2/tests/test_serializable_login_info.py +82 -0
  12. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/LICENSE +0 -0
  13. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/MANIFEST.in +0 -0
  14. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/__init__.py +0 -0
  15. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/__init__.py +0 -0
  16. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/__init__.py +0 -0
  17. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/clock.py +0 -0
  18. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  19. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  20. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  21. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  22. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  23. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  24. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  25. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  26. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  27. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  28. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/events.py +0 -0
  29. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/exceptions.py +0 -0
  30. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  31. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  32. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rate.py +0 -0
  33. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  34. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  35. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  36. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  37. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  38. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  39. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  40. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  41. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  42. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  43. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  44. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtp.py +0 -0
  45. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/sdp.py +0 -0
  46. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/stats.py +0 -0
  47. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/utils.py +0 -0
  48. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/aes_utils.py +0 -0
  49. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/__init__.py +0 -0
  50. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/constants.py +0 -0
  51. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/controls.py +0 -0
  52. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/go2rtc.py +0 -0
  53. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/lan_control.py +0 -0
  54. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/models.py +0 -0
  55. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/playback.py +0 -0
  56. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/protocol.py +0 -0
  57. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/sdes.py +0 -0
  58. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/sdes_open.py +0 -0
  59. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/tutk.py +0 -0
  60. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/webrtc.py +0 -0
  61. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/camera/webrtc_open.py +0 -0
  62. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/credentials.py +0 -0
  63. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/device_client.py +0 -0
  64. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/discover.py +0 -0
  65. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/exceptions.py +0 -0
  66. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/g711.py +0 -0
  67. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/login_const.py +0 -0
  68. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/models/__init__.py +0 -0
  69. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/models/device_client_model.py +0 -0
  70. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/models/device_model.py +0 -0
  71. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/models/discover_model.py +0 -0
  72. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/aidot/py.typed +0 -0
  73. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  74. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  75. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  76. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  77. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/setup.cfg +0 -0
  78. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_aioice_compat.py +0 -0
  79. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_alarm_event.py +0 -0
  80. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_backoff.py +0 -0
  81. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_deferred_hardening.py +0 -0
  82. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_device_login_guard.py +0 -0
  83. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_device_user_info_cache.py +0 -0
  84. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_dtls_not_ready_burst.py +0 -0
  85. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_dtls_pinning.py +0 -0
  86. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  87. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_egress_guard.py +0 -0
  88. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_go2rtc.py +0 -0
  89. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_go2rtc_cli.py +0 -0
  90. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_highport_nomination.py +0 -0
  91. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_ice_config_cache.py +0 -0
  92. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_keyframe_prompter.py +0 -0
  93. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_lan_control.py +0 -0
  94. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_live_stream_param.py +0 -0
  95. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_motion_poll.py +0 -0
  96. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_narrow_pc_ice.py +0 -0
  97. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_no_undefined_names.py +0 -0
  98. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_open_gate_delay.py +0 -0
  99. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_persistent_mqtt.py +0 -0
  100. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_persistent_mqtt_robustness.py +0 -0
  101. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_playback_tls.py +0 -0
  102. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_post_merge_hardening.py +0 -0
  103. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_retry_policy.py +0 -0
  104. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_adaptive.py +0 -0
  105. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  106. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_fast_liveplay.py +0 -0
  107. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_idle_release.py +0 -0
  108. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_serve_audio.py +0 -0
  109. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_serve_cmd.py +0 -0
  110. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_sprop.py +0 -0
  111. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_talk.py +0 -0
  112. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_sdes_watchdog.py +0 -0
  113. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_security_hardening.py +0 -0
  114. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_serve_relay.py +0 -0
  115. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_session_stats.py +0 -0
  116. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_speak.py +0 -0
  117. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_stream_cap.py +0 -0
  118. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_stream_idle.py +0 -0
  119. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_stream_teardown.py +0 -0
  120. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_talk.py +0 -0
  121. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_terminal_ack.py +0 -0
  122. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/tests/test_token_refresh.py +0 -0
  123. {python_aidot_cameras-0.11.0 → python_aidot_cameras-0.11.2}/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.2
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
@@ -94,10 +94,18 @@ pipx install "python-aidot-cameras[webrtc]"
94
94
  # or:
95
95
  uv tool install "python-aidot-cameras[webrtc]"
96
96
 
97
+ export AIDOT_USERNAME=... AIDOT_PASSWORD=... # or AIDOT_TOKEN_FILE, see below
97
98
  aidot-go2rtc --list # discover cameras + their transport
98
99
  aidot-go2rtc <device_id> '{output}' # stream one camera (as a go2rtc exec: source)
99
100
  ```
100
101
 
102
+ Authenticates via `AIDOT_USERNAME`/`AIDOT_PASSWORD` (`AIDOT_COUNTRY`, default
103
+ `US`) - a dedicated login is recommended for long-running standalone use, so
104
+ it doesn't fight Home Assistant over a shared rotating refresh token. Set
105
+ `AIDOT_TOKEN_FILE=/path/to/token.json` to cache the login across restarts;
106
+ token rotations are written back automatically. Run `aidot-go2rtc --help` for
107
+ the full list of authentication and stream env vars.
108
+
101
109
  ## Usage
102
110
 
103
111
  Open a live WebRTC stream from a camera device client:
@@ -64,10 +64,18 @@ pipx install "python-aidot-cameras[webrtc]"
64
64
  # or:
65
65
  uv tool install "python-aidot-cameras[webrtc]"
66
66
 
67
+ export AIDOT_USERNAME=... AIDOT_PASSWORD=... # or AIDOT_TOKEN_FILE, see below
67
68
  aidot-go2rtc --list # discover cameras + their transport
68
69
  aidot-go2rtc <device_id> '{output}' # stream one camera (as a go2rtc exec: source)
69
70
  ```
70
71
 
72
+ Authenticates via `AIDOT_USERNAME`/`AIDOT_PASSWORD` (`AIDOT_COUNTRY`, default
73
+ `US`) - a dedicated login is recommended for long-running standalone use, so
74
+ it doesn't fight Home Assistant over a shared rotating refresh token. Set
75
+ `AIDOT_TOKEN_FILE=/path/to/token.json` to cache the login across restarts;
76
+ token rotations are written back automatically. Run `aidot-go2rtc --help` for
77
+ the full list of authentication and stream env vars.
78
+
71
79
  ## Usage
72
80
 
73
81
  Open a live WebRTC stream from a camera device client:
@@ -102,9 +102,17 @@ def _install_token_cache(client: AidotClient, path: str) -> None:
102
102
  """
103
103
  def _cb() -> None:
104
104
  try:
105
- _write_token_file(path, client.login_info)
105
+ _write_token_file(path, client.serializable_login_info())
106
106
  _LOGGER.debug("Cached refreshed token to %s", path)
107
- except OSError as exc:
107
+ except (OSError, TypeError) as exc:
108
+ # TypeError: a defensive belt-and-suspenders catch alongside
109
+ # serializable_login_info() itself - if a future runtime-only key
110
+ # is ever added to login_info without also being added to
111
+ # RUNTIME_ONLY_LOGIN_INFO_KEYS, this keeps a caching bug from
112
+ # ever propagating out of a callback and interrupting a token
113
+ # refresh that had otherwise already succeeded (confirmed live:
114
+ # this exact TypeError - "Object of type Lock is not JSON
115
+ # serializable" - previously escaped this callback uncaught).
108
116
  _LOGGER.warning("Could not cache refreshed token to %s: %s", path, exc)
109
117
 
110
118
  client.set_token_fresh_cb(_cb)
@@ -149,8 +157,10 @@ async def _make_client(session: aiohttp.ClientSession) -> AidotClient:
149
157
  if token_file:
150
158
  _install_token_cache(client, token_file)
151
159
  try:
152
- await loop.run_in_executor(None, _write_token_file, token_file, client.login_info)
153
- except OSError as exc:
160
+ await loop.run_in_executor(
161
+ None, _write_token_file, token_file, client.serializable_login_info()
162
+ )
163
+ except (OSError, TypeError) as exc:
154
164
  _LOGGER.warning("Could not seed token cache %s: %s", token_file, exc)
155
165
  return client
156
166
 
@@ -10,6 +10,10 @@ from typing import Any, Callable, List, Optional
10
10
 
11
11
  from ..exceptions import AidotCameraBusy, AidotCameraNotReady
12
12
  from ..login_const import APP_ID as _AIDOT_APP_ID
13
+ from ..const import (
14
+ LOGIN_INFO_PERSISTENT_MQTT_KEY,
15
+ LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
16
+ )
13
17
 
14
18
  # Wire/protocol constants live in constants.py; re-imported here so all
15
19
  # in-module references (and any external importers) keep resolving.
@@ -86,6 +90,13 @@ from .protocol import ( # noqa: F401 - re-exported for device_client / back-com
86
90
 
87
91
  _LOGGER = logging.getLogger(__name__)
88
92
 
93
+ # Offline keepalive pause (see CameraMixin._backoff_or_offline_pause): while the
94
+ # cloud explicitly reports a device offline, failed-open retries re-check the
95
+ # flag every RECHECK seconds and only probe a real open every PROBE seconds,
96
+ # instead of consuming an open-gate slot on the normal reconnect cadence.
97
+ _OFFLINE_RECHECK_S = float(os.environ.get("AIDOT_OFFLINE_RECHECK_S", "30"))
98
+ _OFFLINE_PROBE_S = float(os.environ.get("AIDOT_OFFLINE_PROBE_S", "600"))
99
+
89
100
  # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
90
101
  # discarded task can be garbage-collected mid-flight. Discarded on completion.
91
102
  _BG_TASKS: set = set()
@@ -761,8 +772,56 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
761
772
  self.status.update_from_camera_attributes(props)
762
773
  if device.get("online") is not None:
763
774
  self.status.online = bool(device.get("online"))
775
+ # The cloud explicitly reported reachability - only then may the
776
+ # keepalive loops trust an offline flag (see
777
+ # _backoff_or_offline_pause; DeviceStatusData.online defaults to
778
+ # False, which must not read as "offline").
779
+ self._cloud_online_explicit = True
764
780
  return self.status
765
781
 
782
+ async def _backoff_or_offline_pause(self, delay: float) -> None:
783
+ """Sleep a reconnect backoff, extended while the device is cloud-offline.
784
+
785
+ Normal case (device online, or the cloud never reported an online
786
+ flag): plain ``asyncio.sleep(delay)`` - behavior unchanged.
787
+
788
+ When an open just failed AND the cloud has explicitly reported the
789
+ device offline (``update_status_from_device`` saw ``online: false``),
790
+ hold here instead of retrying on the pacer cadence: re-check the flag
791
+ every ``AIDOT_OFFLINE_RECHECK_S`` (default 30 s) and resume the moment
792
+ it flips back on, or fall through after ``AIDOT_OFFLINE_PROBE_S``
793
+ (default 600 s) so one probe open still runs in case the cloud flag is
794
+ stale. This keeps a dead/unpowered camera from consuming an open-gate
795
+ slot every backoff cycle and starving live cameras' cold opens
796
+ (observed live: two unpowered A000088s cycling 30 s open attempts
797
+ pushed a healthy camera's cold open past two minutes).
798
+ """
799
+ if self.status.online or not getattr(self, "_cloud_online_explicit", False):
800
+ await asyncio.sleep(delay)
801
+ return
802
+ _LOGGER.info(
803
+ "keepalive[%s]: device is cloud-offline - pausing open retries "
804
+ "(recheck %.0fs, probe %.0fs)",
805
+ self.device_id, _OFFLINE_RECHECK_S, _OFFLINE_PROBE_S,
806
+ )
807
+ _t0 = time.monotonic()
808
+ while (_elapsed := time.monotonic() - _t0) < _OFFLINE_PROBE_S:
809
+ await asyncio.sleep(
810
+ min(_OFFLINE_RECHECK_S, max(0.1, _OFFLINE_PROBE_S - _elapsed))
811
+ )
812
+ if not getattr(self, "_streaming_active", False):
813
+ return
814
+ if self.status.online:
815
+ _LOGGER.info(
816
+ "keepalive[%s]: device back online - resuming opens",
817
+ self.device_id,
818
+ )
819
+ return
820
+ _LOGGER.debug(
821
+ "keepalive[%s]: offline probe interval elapsed - probing one open",
822
+ self.device_id,
823
+ )
824
+
766
825
  # -- Camera helpers ------------------------------------------------------ #
767
826
 
768
827
  @property
@@ -2560,7 +2619,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2560
2619
  self.device_id, _delay, exc,
2561
2620
  )
2562
2621
  try:
2563
- await asyncio.sleep(_delay)
2622
+ await self._backoff_or_offline_pause(_delay)
2564
2623
  except asyncio.CancelledError:
2565
2624
  return
2566
2625
  continue
@@ -2735,7 +2794,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2735
2794
  self.device_id, _delay, exc,
2736
2795
  )
2737
2796
  try:
2738
- await asyncio.sleep(_delay)
2797
+ await self._backoff_or_offline_pause(_delay)
2739
2798
  except asyncio.CancelledError:
2740
2799
  return
2741
2800
  continue
@@ -3041,7 +3100,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3041
3100
  li = self._user_info if isinstance(getattr(self, "_user_info", None), dict) else None
3042
3101
  if li is None:
3043
3102
  return None
3044
- pm = li.get("_persistent_mqtt")
3103
+ # These two keys are deliberately live runtime objects (a connection,
3104
+ # its guarding lock), NOT persistable state - see
3105
+ # RUNTIME_ONLY_LOGIN_INFO_KEYS and AidotClient.serializable_login_info(),
3106
+ # which strips them before this shared dict is ever written to
3107
+ # disk/config storage.
3108
+ pm = li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY)
3045
3109
  if pm is not None:
3046
3110
  return pm
3047
3111
  # Serialize get-or-create. Without this, two concurrent first-callers
@@ -3050,9 +3114,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3050
3114
  # clobbering the first and orphaning a connection on the single-client_id
3051
3115
  # broker. dict.setdefault is atomic (no await between create and insert),
3052
3116
  # so every caller for this account shares the one lock.
3053
- lock = li.setdefault("_persistent_mqtt_lock", asyncio.Lock())
3117
+ lock = li.setdefault(LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY, asyncio.Lock())
3054
3118
  async with lock:
3055
- pm = li.get("_persistent_mqtt") # re-check under the lock
3119
+ pm = li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) # re-check under the lock
3056
3120
  if pm is not None:
3057
3121
  return pm
3058
3122
  smarthome_auth = await self._async_get_smarthome_auth()
@@ -3064,7 +3128,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3064
3128
  return None
3065
3129
  from .protocol import _PersistentMqtt
3066
3130
  pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
3067
- li["_persistent_mqtt"] = pm
3131
+ li[LOGIN_INFO_PERSISTENT_MQTT_KEY] = pm
3068
3132
  return pm
3069
3133
 
3070
3134
  @staticmethod
@@ -3231,7 +3295,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3231
3295
  self.device_id, _delay, exc,
3232
3296
  )
3233
3297
  try:
3234
- await asyncio.sleep(_delay)
3298
+ await self._backoff_or_offline_pause(_delay)
3235
3299
  except asyncio.CancelledError:
3236
3300
  return
3237
3301
  continue
@@ -38,6 +38,7 @@ from .const import (
38
38
  SUPPORTED_COUNTRYS,
39
39
  DEFAULT_COUNTRY_CODE,
40
40
  CONF_IS_OWNER,
41
+ RUNTIME_ONLY_LOGIN_INFO_KEYS,
41
42
  ServerErrorCode,
42
43
  )
43
44
 
@@ -346,6 +347,28 @@ class AidotClient:
346
347
  raise AidotAuthFailed
347
348
  return None
348
349
 
350
+ def serializable_login_info(self) -> dict[str, Any]:
351
+ """A JSON-safe copy of ``login_info`` for persisting to disk/config storage.
352
+
353
+ ``login_info`` doubles as the account-shared cache for the
354
+ persistent-MQTT connection and its guarding ``asyncio.Lock`` (see
355
+ ``camera/client.py``'s ``_get_persistent_mqtt``, which reads/writes
356
+ this identical dict object via ``DeviceClient._user_info``) - live
357
+ runtime objects that raise ``TypeError: Object of type Lock is not
358
+ JSON serializable`` if ever handed to ``json.dump`` directly.
359
+ Confirmed live: a token-refresh callback that persisted the raw
360
+ ``login_info`` dict hit exactly this once a persistent MQTT
361
+ connection was active (the default since 2026-06-17).
362
+
363
+ Anything that persists ``login_info`` - this library's own
364
+ standalone CLI, or an integration's config-entry storage - should
365
+ call this instead of serializing ``login_info`` directly.
366
+ """
367
+ return {
368
+ k: v for k, v in self.login_info.items()
369
+ if k not in RUNTIME_ONLY_LOGIN_INFO_KEYS
370
+ }
371
+
349
372
  async def async_ensure_token(self) -> bool:
350
373
  """Force a fresh access token for camera/smarthome HTTP calls.
351
374
 
@@ -230,6 +230,21 @@ class ServerErrorCode(IntEnum):
230
230
  USER_PWD_INCORRECT = 560080
231
231
 
232
232
 
233
+ # AidotClient.login_info is also used as the account-shared cache for the
234
+ # persistent-MQTT connection + its guarding asyncio.Lock (see
235
+ # camera/client.py's _get_persistent_mqtt: one connection per account,
236
+ # looked up on this same shared dict via DeviceClient._user_info - the
237
+ # identical object, not a copy). Those are live runtime objects, never
238
+ # persistable state. Anything that serializes login_info to disk (this
239
+ # library's own standalone CLI, or an integration's config-entry storage)
240
+ # must exclude these keys first - see AidotClient.serializable_login_info().
241
+ LOGIN_INFO_PERSISTENT_MQTT_KEY = "_persistent_mqtt"
242
+ LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY = "_persistent_mqtt_lock"
243
+ RUNTIME_ONLY_LOGIN_INFO_KEYS = frozenset(
244
+ {LOGIN_INFO_PERSISTENT_MQTT_KEY, LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY}
245
+ )
246
+
247
+
233
248
  # -- Cloud API credentials ---------------------------------------------------- #
234
249
 
235
250
  from .login_const import APP_ID # noqa: E402, F401 - back-compat re-export
@@ -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.2"
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.2
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
@@ -94,10 +94,18 @@ pipx install "python-aidot-cameras[webrtc]"
94
94
  # or:
95
95
  uv tool install "python-aidot-cameras[webrtc]"
96
96
 
97
+ export AIDOT_USERNAME=... AIDOT_PASSWORD=... # or AIDOT_TOKEN_FILE, see below
97
98
  aidot-go2rtc --list # discover cameras + their transport
98
99
  aidot-go2rtc <device_id> '{output}' # stream one camera (as a go2rtc exec: source)
99
100
  ```
100
101
 
102
+ Authenticates via `AIDOT_USERNAME`/`AIDOT_PASSWORD` (`AIDOT_COUNTRY`, default
103
+ `US`) - a dedicated login is recommended for long-running standalone use, so
104
+ it doesn't fight Home Assistant over a shared rotating refresh token. Set
105
+ `AIDOT_TOKEN_FILE=/path/to/token.json` to cache the login across restarts;
106
+ token rotations are written back automatically. Run `aidot-go2rtc --help` for
107
+ the full list of authentication and stream env vars.
108
+
101
109
  ## Usage
102
110
 
103
111
  Open a live WebRTC stream from a camera device client:
@@ -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
@@ -107,6 +108,7 @@ tests/test_sdes_sprop.py
107
108
  tests/test_sdes_talk.py
108
109
  tests/test_sdes_watchdog.py
109
110
  tests/test_security_hardening.py
111
+ tests/test_serializable_login_info.py
110
112
  tests/test_serve_relay.py
111
113
  tests/test_session_stats.py
112
114
  tests/test_speak.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
@@ -0,0 +1,82 @@
1
+ """Regression test for a live bug: AidotClient.login_info doubles as the
2
+ account-shared cache for the persistent-MQTT connection and its guarding
3
+ asyncio.Lock (camera/client.py's _get_persistent_mqtt), so anything that
4
+ json.dumps login_info directly - once a persistent MQTT connection has been
5
+ created (the default since 2026-06-17) - hits
6
+ "TypeError: Object of type Lock is not JSON serializable".
7
+
8
+ Confirmed live (2026-07-07): a standalone aidot-go2rtc run's token-refresh
9
+ callback threw exactly this while persisting client.login_info to disk.
10
+ serializable_login_info() is the fix: a JSON-safe view that excludes the
11
+ known runtime-only keys.
12
+ """
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import sys
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ from aidot.client import AidotClient
21
+ from aidot.const import (
22
+ LOGIN_INFO_PERSISTENT_MQTT_KEY,
23
+ LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
24
+ RUNTIME_ONLY_LOGIN_INFO_KEYS,
25
+ )
26
+
27
+
28
+ def _client_with_login_info(extra: dict) -> AidotClient:
29
+ c = AidotClient.__new__(AidotClient)
30
+ c.login_info = {
31
+ "id": "u1",
32
+ "accessToken": "tok",
33
+ "refreshToken": "rtok",
34
+ "mqttPassword": "mp",
35
+ **extra,
36
+ }
37
+ return c
38
+
39
+
40
+ def test_serializable_login_info_strips_runtime_keys():
41
+ c = _client_with_login_info({
42
+ LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY: asyncio.Lock(),
43
+ LOGIN_INFO_PERSISTENT_MQTT_KEY: object(), # stand-in for a live _PersistentMqtt
44
+ })
45
+ result = c.serializable_login_info()
46
+ assert LOGIN_INFO_PERSISTENT_MQTT_KEY not in result
47
+ assert LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY not in result
48
+ # Everything else survives unchanged.
49
+ assert result == {
50
+ "id": "u1", "accessToken": "tok", "refreshToken": "rtok",
51
+ "mqttPassword": "mp",
52
+ }
53
+
54
+
55
+ def test_serializable_login_info_is_actually_json_dumpable():
56
+ # The exact failure mode this fixes: json.dumps on the raw dict must
57
+ # raise, but on the filtered view it must not.
58
+ c = _client_with_login_info({
59
+ LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY: asyncio.Lock(),
60
+ })
61
+ try:
62
+ json.dumps(c.login_info)
63
+ raise AssertionError("expected TypeError serializing the raw login_info")
64
+ except TypeError:
65
+ pass
66
+ json.dumps(c.serializable_login_info()) # must not raise
67
+
68
+
69
+ def test_serializable_login_info_noop_when_no_runtime_keys_present():
70
+ # Common case (no persistent-MQTT connection created yet): unaffected.
71
+ c = _client_with_login_info({})
72
+ assert c.serializable_login_info() == c.login_info
73
+
74
+
75
+ def test_runtime_only_keys_constant_matches_persistent_mqtt_key_names():
76
+ # Locks the two literal key names camera/client.py's _get_persistent_mqtt
77
+ # actually writes (see LOGIN_INFO_PERSISTENT_MQTT_KEY /
78
+ # LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY) against the shared frozenset, so a
79
+ # rename on one side without the other can't silently reopen this bug.
80
+ assert RUNTIME_ONLY_LOGIN_INFO_KEYS == {
81
+ LOGIN_INFO_PERSISTENT_MQTT_KEY, LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
82
+ }