python-aidot-cameras 0.11.1__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.1/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.2}/PKG-INFO +9 -1
  2. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/README.md +8 -0
  3. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/__main__.py +14 -4
  4. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/client.py +13 -4
  5. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/client.py +23 -0
  6. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/const.py +15 -0
  7. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2/python_aidot_cameras.egg-info}/PKG-INFO +9 -1
  9. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  10. python_aidot_cameras-0.11.2/tests/test_serializable_login_info.py +82 -0
  11. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/LICENSE +0 -0
  12. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/MANIFEST.in +0 -0
  13. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/__init__.py +0 -0
  14. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/__init__.py +0 -0
  15. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/__init__.py +0 -0
  16. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/clock.py +0 -0
  17. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  18. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  19. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  20. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  21. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  22. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  23. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  24. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  25. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  26. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  27. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/events.py +0 -0
  28. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/exceptions.py +0 -0
  29. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  30. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  31. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rate.py +0 -0
  32. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  33. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  34. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  35. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  36. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  37. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  38. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  39. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  40. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  41. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  42. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  43. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/rtp.py +0 -0
  44. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/sdp.py +0 -0
  45. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/stats.py +0 -0
  46. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/_vendor/aiortc/utils.py +0 -0
  47. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/aes_utils.py +0 -0
  48. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/__init__.py +0 -0
  49. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/constants.py +0 -0
  50. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/controls.py +0 -0
  51. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/go2rtc.py +0 -0
  52. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/lan_control.py +0 -0
  53. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/models.py +0 -0
  54. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/playback.py +0 -0
  55. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/protocol.py +0 -0
  56. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/sdes.py +0 -0
  57. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/sdes_open.py +0 -0
  58. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/tutk.py +0 -0
  59. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/webrtc.py +0 -0
  60. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/camera/webrtc_open.py +0 -0
  61. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/credentials.py +0 -0
  62. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/device_client.py +0 -0
  63. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/discover.py +0 -0
  64. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/exceptions.py +0 -0
  65. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/g711.py +0 -0
  66. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/login_const.py +0 -0
  67. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/models/__init__.py +0 -0
  68. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/models/device_client_model.py +0 -0
  69. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/models/device_model.py +0 -0
  70. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/models/discover_model.py +0 -0
  71. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/aidot/py.typed +0 -0
  72. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  73. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  74. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  75. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  76. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/setup.cfg +0 -0
  77. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_aioice_compat.py +0 -0
  78. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_alarm_event.py +0 -0
  79. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_backoff.py +0 -0
  80. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_deferred_hardening.py +0 -0
  81. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_device_login_guard.py +0 -0
  82. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_device_user_info_cache.py +0 -0
  83. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_dtls_not_ready_burst.py +0 -0
  84. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_dtls_pinning.py +0 -0
  85. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  86. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_egress_guard.py +0 -0
  87. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_go2rtc.py +0 -0
  88. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_go2rtc_cli.py +0 -0
  89. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_keyframe_prompter.py +0 -0
  92. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_lan_control.py +0 -0
  93. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_live_stream_param.py +0 -0
  94. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_motion_poll.py +0 -0
  95. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_narrow_pc_ice.py +0 -0
  96. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_no_undefined_names.py +0 -0
  97. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_offline_keepalive_pause.py +0 -0
  98. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_open_gate_delay.py +0 -0
  99. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_persistent_mqtt.py +0 -0
  100. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_persistent_mqtt_robustness.py +0 -0
  101. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_playback_tls.py +0 -0
  102. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_post_merge_hardening.py +0 -0
  103. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_retry_policy.py +0 -0
  104. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_adaptive.py +0 -0
  105. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  106. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_fast_liveplay.py +0 -0
  107. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_idle_release.py +0 -0
  108. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_serve_audio.py +0 -0
  109. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_serve_cmd.py +0 -0
  110. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_sprop.py +0 -0
  111. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_talk.py +0 -0
  112. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_sdes_watchdog.py +0 -0
  113. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_security_hardening.py +0 -0
  114. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_serve_relay.py +0 -0
  115. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_session_stats.py +0 -0
  116. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_speak.py +0 -0
  117. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_stream_cap.py +0 -0
  118. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_stream_idle.py +0 -0
  119. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_stream_teardown.py +0 -0
  120. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_talk.py +0 -0
  121. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_terminal_ack.py +0 -0
  122. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.2}/tests/test_token_refresh.py +0 -0
  123. {python_aidot_cameras-0.11.1 → 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.1
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.
@@ -3096,7 +3100,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3096
3100
  li = self._user_info if isinstance(getattr(self, "_user_info", None), dict) else None
3097
3101
  if li is None:
3098
3102
  return None
3099
- 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)
3100
3109
  if pm is not None:
3101
3110
  return pm
3102
3111
  # Serialize get-or-create. Without this, two concurrent first-callers
@@ -3105,9 +3114,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3105
3114
  # clobbering the first and orphaning a connection on the single-client_id
3106
3115
  # broker. dict.setdefault is atomic (no await between create and insert),
3107
3116
  # so every caller for this account shares the one lock.
3108
- lock = li.setdefault("_persistent_mqtt_lock", asyncio.Lock())
3117
+ lock = li.setdefault(LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY, asyncio.Lock())
3109
3118
  async with lock:
3110
- pm = li.get("_persistent_mqtt") # re-check under the lock
3119
+ pm = li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) # re-check under the lock
3111
3120
  if pm is not None:
3112
3121
  return pm
3113
3122
  smarthome_auth = await self._async_get_smarthome_auth()
@@ -3119,7 +3128,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3119
3128
  return None
3120
3129
  from .protocol import _PersistentMqtt
3121
3130
  pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
3122
- li["_persistent_mqtt"] = pm
3131
+ li[LOGIN_INFO_PERSISTENT_MQTT_KEY] = pm
3123
3132
  return pm
3124
3133
 
3125
3134
  @staticmethod
@@ -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.1"
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.1
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:
@@ -108,6 +108,7 @@ tests/test_sdes_sprop.py
108
108
  tests/test_sdes_talk.py
109
109
  tests/test_sdes_watchdog.py
110
110
  tests/test_security_hardening.py
111
+ tests/test_serializable_login_info.py
111
112
  tests/test_serve_relay.py
112
113
  tests/test_session_stats.py
113
114
  tests/test_speak.py
@@ -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
+ }