python-aidot-cameras 0.11.1__tar.gz → 0.11.3__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 (124) hide show
  1. {python_aidot_cameras-0.11.1/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.3}/PKG-INFO +10 -2
  2. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/README.md +9 -1
  3. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/__main__.py +45 -12
  4. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/client.py +35 -14
  5. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/lan_control.py +4 -0
  6. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/playback.py +19 -1
  7. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/sdes_open.py +15 -0
  8. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/webrtc_open.py +3 -0
  9. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/client.py +31 -1
  10. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/const.py +15 -0
  11. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/pyproject.toml +1 -1
  12. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info}/PKG-INFO +10 -2
  13. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  14. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_lan_control.py +20 -0
  15. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_motion_poll.py +1 -1
  16. python_aidot_cameras-0.11.3/tests/test_serializable_login_info.py +82 -0
  17. python_aidot_cameras-0.11.3/tests/test_token_file_robustness.py +43 -0
  18. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/LICENSE +0 -0
  19. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/MANIFEST.in +0 -0
  20. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/__init__.py +0 -0
  21. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/__init__.py +0 -0
  22. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/__init__.py +0 -0
  23. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/clock.py +0 -0
  24. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  25. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  26. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  27. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  28. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  29. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  30. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  31. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  32. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  33. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  34. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/events.py +0 -0
  35. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/exceptions.py +0 -0
  36. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  37. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  38. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rate.py +0 -0
  39. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  40. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  41. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  42. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  43. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  44. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  45. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  46. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  47. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  48. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  49. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  50. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtp.py +0 -0
  51. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/sdp.py +0 -0
  52. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/stats.py +0 -0
  53. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/utils.py +0 -0
  54. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/aes_utils.py +0 -0
  55. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/__init__.py +0 -0
  56. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/constants.py +0 -0
  57. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/controls.py +0 -0
  58. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/go2rtc.py +0 -0
  59. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/models.py +0 -0
  60. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/protocol.py +0 -0
  61. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/sdes.py +0 -0
  62. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/tutk.py +0 -0
  63. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/webrtc.py +0 -0
  64. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/credentials.py +0 -0
  65. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/device_client.py +0 -0
  66. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/discover.py +0 -0
  67. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/exceptions.py +0 -0
  68. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/g711.py +0 -0
  69. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/login_const.py +0 -0
  70. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/__init__.py +0 -0
  71. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/device_client_model.py +0 -0
  72. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/device_model.py +0 -0
  73. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/discover_model.py +0 -0
  74. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/py.typed +0 -0
  75. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  76. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  77. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/requires.txt +0 -0
  78. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  79. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/setup.cfg +0 -0
  80. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_aioice_compat.py +0 -0
  81. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_alarm_event.py +0 -0
  82. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_backoff.py +0 -0
  83. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_deferred_hardening.py +0 -0
  84. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_device_login_guard.py +0 -0
  85. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_device_user_info_cache.py +0 -0
  86. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_not_ready_burst.py +0 -0
  87. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_pinning.py +0 -0
  88. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_skip_signaling_wait.py +0 -0
  89. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_egress_guard.py +0 -0
  90. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_go2rtc.py +0 -0
  91. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_go2rtc_cli.py +0 -0
  92. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_highport_nomination.py +0 -0
  93. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_ice_config_cache.py +0 -0
  94. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_keyframe_prompter.py +0 -0
  95. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_live_stream_param.py +0 -0
  96. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_narrow_pc_ice.py +0 -0
  97. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_no_undefined_names.py +0 -0
  98. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_offline_keepalive_pause.py +0 -0
  99. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_open_gate_delay.py +0 -0
  100. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_persistent_mqtt.py +0 -0
  101. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_persistent_mqtt_robustness.py +0 -0
  102. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_playback_tls.py +0 -0
  103. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_post_merge_hardening.py +0 -0
  104. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_retry_policy.py +0 -0
  105. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_adaptive.py +0 -0
  106. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_echo_wait_timeout.py +0 -0
  107. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_fast_liveplay.py +0 -0
  108. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_idle_release.py +0 -0
  109. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_serve_audio.py +0 -0
  110. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_serve_cmd.py +0 -0
  111. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_sprop.py +0 -0
  112. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_talk.py +0 -0
  113. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_watchdog.py +0 -0
  114. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_security_hardening.py +0 -0
  115. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_serve_relay.py +0 -0
  116. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_session_stats.py +0 -0
  117. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_speak.py +0 -0
  118. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_stream_cap.py +0 -0
  119. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_stream_idle.py +0 -0
  120. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_stream_teardown.py +0 -0
  121. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_talk.py +0 -0
  122. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_terminal_ack.py +0 -0
  123. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_token_refresh.py +0 -0
  124. {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/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.3
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:
@@ -159,7 +167,7 @@ audio, idle release, the sprop cache path) are documented in
159
167
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. | unset (off) |
160
168
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
161
169
  | `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
162
- | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soak cut SDES NO_MEDIA ~57%->~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
170
+ | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
163
171
  | `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
164
172
  | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
165
173
 
@@ -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:
@@ -129,7 +137,7 @@ audio, idle release, the sprop cache path) are documented in
129
137
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. | unset (off) |
130
138
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
131
139
  | `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
132
- | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soak cut SDES NO_MEDIA ~57%->~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
140
+ | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
133
141
  | `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
134
142
  | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
135
143
 
@@ -59,6 +59,7 @@ import logging
59
59
  import os
60
60
  import signal
61
61
  import sys
62
+ import tempfile
62
63
 
63
64
  import aiohttp
64
65
 
@@ -85,10 +86,25 @@ def _read_token_file(path: str) -> dict:
85
86
 
86
87
 
87
88
  def _write_token_file(path: str, data: dict) -> None:
88
- """Blocking write of login_info to ``path`` with 0600 perms."""
89
- with open(path, "w", encoding="utf-8") as fh:
90
- json.dump(data, fh)
91
- os.chmod(path, 0o600)
89
+ """Atomic, 0600-secure write of login_info to ``path``.
90
+
91
+ Serialize into a temp file in the same directory (mkstemp creates it 0600,
92
+ closing the world-readable window os.chmod-after-write leaves) and only then
93
+ os.replace it over ``path`` - so a serialization failure never truncates the
94
+ previously-valid token. On any error the temp file is removed and re-raised.
95
+ """
96
+ directory = os.path.dirname(path) or "."
97
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".token-", suffix=".tmp")
98
+ try:
99
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
100
+ json.dump(data, fh)
101
+ os.replace(tmp, path)
102
+ except BaseException:
103
+ try:
104
+ os.unlink(tmp)
105
+ except OSError:
106
+ pass
107
+ raise
92
108
 
93
109
 
94
110
  def _install_token_cache(client: AidotClient, path: str) -> None:
@@ -102,9 +118,17 @@ def _install_token_cache(client: AidotClient, path: str) -> None:
102
118
  """
103
119
  def _cb() -> None:
104
120
  try:
105
- _write_token_file(path, client.login_info)
121
+ _write_token_file(path, client.serializable_login_info())
106
122
  _LOGGER.debug("Cached refreshed token to %s", path)
107
- except OSError as exc:
123
+ except (OSError, TypeError) as exc:
124
+ # TypeError: a defensive belt-and-suspenders catch alongside
125
+ # serializable_login_info() itself - if a future runtime-only key
126
+ # is ever added to login_info without also being added to
127
+ # RUNTIME_ONLY_LOGIN_INFO_KEYS, this keeps a caching bug from
128
+ # ever propagating out of a callback and interrupting a token
129
+ # refresh that had otherwise already succeeded (confirmed live:
130
+ # this exact TypeError - "Object of type Lock is not JSON
131
+ # serializable" - previously escaped this callback uncaught).
108
132
  _LOGGER.warning("Could not cache refreshed token to %s: %s", path, exc)
109
133
 
110
134
  client.set_token_fresh_cb(_cb)
@@ -125,10 +149,17 @@ async def _make_client(session: aiohttp.ClientSession) -> AidotClient:
125
149
  password = os.environ.get("AIDOT_PASSWORD")
126
150
 
127
151
  if token_file and os.path.exists(token_file):
128
- token = await loop.run_in_executor(None, _read_token_file, token_file)
129
- client = AidotClient(session=session, token=token)
130
- _install_token_cache(client, token_file)
131
- return client
152
+ try:
153
+ token = await loop.run_in_executor(None, _read_token_file, token_file)
154
+ except (OSError, ValueError) as exc:
155
+ # ValueError covers json.JSONDecodeError (empty/partial/corrupt
156
+ # cache). Fall through to the username/password path rather than
157
+ # crash CLI startup.
158
+ _LOGGER.warning("ignoring unreadable token cache %s: %s", token_file, exc)
159
+ else:
160
+ client = AidotClient(session=session, token=token)
161
+ _install_token_cache(client, token_file)
162
+ return client
132
163
 
133
164
  if not username or not password:
134
165
  sys.exit(
@@ -149,8 +180,10 @@ async def _make_client(session: aiohttp.ClientSession) -> AidotClient:
149
180
  if token_file:
150
181
  _install_token_cache(client, token_file)
151
182
  try:
152
- await loop.run_in_executor(None, _write_token_file, token_file, client.login_info)
153
- except OSError as exc:
183
+ await loop.run_in_executor(
184
+ None, _write_token_file, token_file, client.serializable_login_info()
185
+ )
186
+ except (OSError, TypeError) as exc:
154
187
  _LOGGER.warning("Could not seed token cache %s: %s", token_file, exc)
155
188
  return client
156
189
 
@@ -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.
@@ -691,7 +695,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
691
695
  self._motion_active: bool = False
692
696
  self._motion_cb: Optional[Callable] = None
693
697
  self._motion_task: Optional["asyncio.Task[None]"] = None
694
- self._motion_seen: set = set()
698
+ # Insertion-ordered set (dict keys) so the memory-bound trim can evict
699
+ # the OLDEST uids deterministically rather than arbitrary ones.
700
+ self._motion_seen: dict = {}
695
701
  self._motion_interval: float = 30.0
696
702
 
697
703
  # -- Pre-0.6 core API, kept for existing consumers ----------------------- #
@@ -1016,9 +1022,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1016
1022
  if not mqtt_host:
1017
1023
  # Fall back to the regional MQTT broker URL known to work from
1018
1024
  # the AiDot web client: wss://{region}-mqtt.arnoo.com:8443/mqtt
1025
+ # Redacted: `data`/`body` carries mqttPassword; log only the
1026
+ # non-sensitive key names present, mirroring the hasPwd-style
1027
+ # redaction of the debug log below.
1019
1028
  _LOGGER.warning(
1020
1029
  "getServerUrlConfig returned no mqttServerUrl; "
1021
- "using regional fallback. body=%s", body
1030
+ "using regional fallback. keys=%s", sorted(data)
1022
1031
  )
1023
1032
  mqtt_host = f"wss://{self._region}-mqtt.arnoo.com:8443/mqtt"
1024
1033
 
@@ -2112,11 +2121,16 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2112
2121
  return
2113
2122
  frame_event.set()
2114
2123
 
2115
- session = await self.async_open_webrtc_stream(
2116
- on_frame=_on_frame,
2117
- timeout=timeout,
2118
- status_callback=status_callback,
2119
- )
2124
+ try:
2125
+ session = await self.async_open_webrtc_stream(
2126
+ on_frame=_on_frame,
2127
+ timeout=timeout,
2128
+ status_callback=status_callback,
2129
+ )
2130
+ except Exception as _snap_exc:
2131
+ _LOGGER.error(
2132
+ "async_snapshot DTLS failed for %s: %s", self.device_id, _snap_exc)
2133
+ return False
2120
2134
  try:
2121
2135
  try:
2122
2136
  await _asyncio.wait_for(frame_event.wait(), timeout=timeout)
@@ -2239,7 +2253,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2239
2253
  if self._motion_task is not None and not self._motion_task.done():
2240
2254
  return
2241
2255
  self._motion_active = True
2242
- self._motion_seen = set()
2256
+ self._motion_seen = {}
2243
2257
  self._motion_task = asyncio.ensure_future(self._motion_poll_loop(lookback_s))
2244
2258
 
2245
2259
  async def async_stop_motion_polling(self) -> None:
@@ -2269,7 +2283,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2269
2283
  uid = it.get("eventUuid") or it.get("eventId") or it.get("recordId")
2270
2284
  if not uid or uid in self._motion_seen:
2271
2285
  continue
2272
- self._motion_seen.add(uid)
2286
+ self._motion_seen[uid] = None
2273
2287
  fresh.append(it)
2274
2288
  if primed and fresh and self._motion_cb is not None:
2275
2289
  for it in sorted(fresh, key=lambda x: x.get("eventTime") or 0):
@@ -2281,7 +2295,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2281
2295
  _LOGGER.debug("motion callback raised", exc_info=True)
2282
2296
  primed = True
2283
2297
  if len(self._motion_seen) > 1000: # bound memory
2284
- self._motion_seen = set(list(self._motion_seen)[-400:])
2298
+ # Keep the 400 most-recent uids (insertion order), evicting
2299
+ # the oldest so still-in-window events stay deduplicated.
2300
+ self._motion_seen = dict.fromkeys(list(self._motion_seen)[-400:])
2285
2301
  except asyncio.CancelledError:
2286
2302
  return
2287
2303
  except Exception as exc:
@@ -3096,7 +3112,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3096
3112
  li = self._user_info if isinstance(getattr(self, "_user_info", None), dict) else None
3097
3113
  if li is None:
3098
3114
  return None
3099
- pm = li.get("_persistent_mqtt")
3115
+ # These two keys are deliberately live runtime objects (a connection,
3116
+ # its guarding lock), NOT persistable state - see
3117
+ # RUNTIME_ONLY_LOGIN_INFO_KEYS and AidotClient.serializable_login_info(),
3118
+ # which strips them before this shared dict is ever written to
3119
+ # disk/config storage.
3120
+ pm = li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY)
3100
3121
  if pm is not None:
3101
3122
  return pm
3102
3123
  # Serialize get-or-create. Without this, two concurrent first-callers
@@ -3105,9 +3126,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3105
3126
  # clobbering the first and orphaning a connection on the single-client_id
3106
3127
  # broker. dict.setdefault is atomic (no await between create and insert),
3107
3128
  # so every caller for this account shares the one lock.
3108
- lock = li.setdefault("_persistent_mqtt_lock", asyncio.Lock())
3129
+ lock = li.setdefault(LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY, asyncio.Lock())
3109
3130
  async with lock:
3110
- pm = li.get("_persistent_mqtt") # re-check under the lock
3131
+ pm = li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) # re-check under the lock
3111
3132
  if pm is not None:
3112
3133
  return pm
3113
3134
  smarthome_auth = await self._async_get_smarthome_auth()
@@ -3119,7 +3140,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3119
3140
  return None
3120
3141
  from .protocol import _PersistentMqtt
3121
3142
  pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
3122
- li["_persistent_mqtt"] = pm
3143
+ li[LOGIN_INFO_PERSISTENT_MQTT_KEY] = pm
3123
3144
  return pm
3124
3145
 
3125
3146
  @staticmethod
@@ -309,6 +309,10 @@ class CameraLanClient:
309
309
  "attr": [], "channel": "tcp", "ascNumber": asc},
310
310
  "tst": int(time.time() * 1000), "deviceId": self.device_id})]
311
311
  replies = await self._session(build)
312
+ if not replies:
313
+ raise CameraLanError(
314
+ f"{self.device_id}: no reply to attribute query"
315
+ )
312
316
  return (replies[0].get("payload") or {}).get("attr") or {}
313
317
 
314
318
  async def async_set_attributes(self, attr: dict) -> bool:
@@ -193,15 +193,18 @@ class CloudPlaybackSession:
193
193
  )
194
194
  except TimeoutError:
195
195
  _LOGGER.error("Cloud playback: login response timed out")
196
+ await self._cleanup()
196
197
  return False
197
198
  except Exception as exc:
198
199
  _LOGGER.error("Cloud playback: login read error: %s", exc)
200
+ await self._cleanup()
199
201
  return False
200
202
 
201
203
  if hdr["cmd"] != _CMD_LOGIN_RES:
202
204
  _LOGGER.error(
203
205
  "Cloud playback: unexpected login response cmd=0x%04x", hdr["cmd"]
204
206
  )
207
+ await self._cleanup()
205
208
  return False
206
209
 
207
210
  try:
@@ -211,6 +214,7 @@ class CloudPlaybackSession:
211
214
  "Cloud playback: login rejected code=%s body=%s",
212
215
  body_obj.get("code"), body_obj,
213
216
  )
217
+ await self._cleanup()
214
218
  return False
215
219
  except (json.JSONDecodeError, ValueError):
216
220
  pass # some firmware sends no JSON body - treat as success
@@ -292,6 +296,13 @@ class CloudPlaybackSession:
292
296
  else:
293
297
  _LOGGER.warning("Cloud playback: unexpected stream result=%d", result)
294
298
 
299
+ # Loop exited on end-of-stream or a receive error/timeout (not via an
300
+ # external stop(), which cancels this task and owns its own teardown).
301
+ # Tear down here so the heartbeat loop wakes to a closed writer and
302
+ # exits, and the socket is not left open until stop() is called.
303
+ self._running = False
304
+ await self._cleanup()
305
+
295
306
  async def start(self) -> bool:
296
307
  self._running = True
297
308
  if not await self._connect_and_login():
@@ -326,12 +337,19 @@ class CloudPlaybackSession:
326
337
  pass
327
338
  self._hb_task = None
328
339
  self._rx_task = None
340
+ await self._cleanup()
341
+
342
+ async def _cleanup(self) -> None:
343
+ # Close the TCP writer and drop the reader/writer refs. Nulling the
344
+ # writer makes a subsequent stop() (or a second call) a safe no-op, so
345
+ # this is the single teardown path shared by the login-failure returns,
346
+ # the receive-loop exit, and stop().
329
347
  if self._writer is not None:
330
348
  try:
331
349
  self._writer.close()
332
350
  await self._writer.wait_closed()
333
351
  except Exception:
334
- _LOGGER.debug("swallowed exception in %s", 'stop', exc_info=True)
352
+ _LOGGER.debug("swallowed exception in %s", '_cleanup', exc_info=True)
335
353
  self._writer = None
336
354
  self._reader = None
337
355
 
@@ -672,6 +672,15 @@ class _SdesOpenMixin:
672
672
  # proceed rather than abort on a code we can't classify as terminal
673
673
  # (genuine terminal rejects are still caught on the webrtcResp ack).
674
674
  if _lp_on_sdes == 0:
675
+ # Match the sibling raises: close the reserved sockets and
676
+ # signal the MQTT thread before propagating. (No sdp_path
677
+ # unlink here - it is not created until later, below.)
678
+ for _rsock in (_audio_sock, _video_sock):
679
+ try:
680
+ _rsock.close()
681
+ except Exception:
682
+ _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_bridge_fn', exc_info=True)
683
+ outgoing_q.put_nowait(None) # stop MQTT thread
675
684
  raise RuntimeError(
676
685
  f"livePlay refused by camera (livePlay=0, code={_lp_code_sdes})")
677
686
  elif _lp_code_sdes not in (0, 200):
@@ -3604,6 +3613,12 @@ class _SdesOpenMixin:
3604
3613
  stdout=subprocess.DEVNULL,
3605
3614
  stderr=subprocess.PIPE,
3606
3615
  )
3616
+ # Point the shared holder at the live proc immediately.
3617
+ # The bridge thread polls _proc_holder[0]; if it still
3618
+ # sees the terminated old proc it logs "stream ended",
3619
+ # breaks, and closes the loopback sockets - starving the
3620
+ # restarted ffmpeg (0-frame stream).
3621
+ _proc_holder[0] = proc
3607
3622
  _status("ffmpeg restarted with camera's SRTP keys")
3608
3623
  except TimeoutError:
3609
3624
  if _cam_echo_received:
@@ -1156,6 +1156,7 @@ class _WebRTCOpenMixin:
1156
1156
  # transient on battery cameras and recover via ICE; abort
1157
1157
  # there would spuriously kill otherwise-good streams.
1158
1158
  if _lp_on == 0:
1159
+ outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
1159
1160
  raise RuntimeError(
1160
1161
  f"livePlay refused by camera (livePlay=0, code={_lp_code})"
1161
1162
  )
@@ -1207,6 +1208,7 @@ class _WebRTCOpenMixin:
1207
1208
  _lp_code2 = int(_lp_resp2.get("code", 200))
1208
1209
  _lp_on2 = int(_lp_resp2.get("livePlay", 1))
1209
1210
  if _lp_on2 == 0:
1211
+ outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
1210
1212
  raise RuntimeError(
1211
1213
  f"livePlay refused by camera (livePlay=0, code={_lp_code2})"
1212
1214
  )
@@ -3210,6 +3212,7 @@ class _WebRTCOpenMixin:
3210
3212
  # encoder is still cold. Tear down and raise so the serve loop
3211
3213
  # fast-retries in a bounded burst instead of letting DTLS
3212
3214
  # co-fail into the full 15s-gated generic retry.
3215
+ outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
3213
3216
  try:
3214
3217
  await pc.close()
3215
3218
  except Exception:
@@ -38,6 +38,9 @@ from .const import (
38
38
  SUPPORTED_COUNTRYS,
39
39
  DEFAULT_COUNTRY_CODE,
40
40
  CONF_IS_OWNER,
41
+ LOGIN_INFO_PERSISTENT_MQTT_KEY,
42
+ LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
43
+ RUNTIME_ONLY_LOGIN_INFO_KEYS,
41
44
  ServerErrorCode,
42
45
  )
43
46
 
@@ -346,6 +349,28 @@ class AidotClient:
346
349
  raise AidotAuthFailed
347
350
  return None
348
351
 
352
+ def serializable_login_info(self) -> dict[str, Any]:
353
+ """A JSON-safe copy of ``login_info`` for persisting to disk/config storage.
354
+
355
+ ``login_info`` doubles as the account-shared cache for the
356
+ persistent-MQTT connection and its guarding ``asyncio.Lock`` (see
357
+ ``camera/client.py``'s ``_get_persistent_mqtt``, which reads/writes
358
+ this identical dict object via ``DeviceClient._user_info``) - live
359
+ runtime objects that raise ``TypeError: Object of type Lock is not
360
+ JSON serializable`` if ever handed to ``json.dump`` directly.
361
+ Confirmed live: a token-refresh callback that persisted the raw
362
+ ``login_info`` dict hit exactly this once a persistent MQTT
363
+ connection was active (the default since 2026-06-17).
364
+
365
+ Anything that persists ``login_info`` - this library's own
366
+ standalone CLI, or an integration's config-entry storage - should
367
+ call this instead of serializing ``login_info`` directly.
368
+ """
369
+ return {
370
+ k: v for k, v in self.login_info.items()
371
+ if k not in RUNTIME_ONLY_LOGIN_INFO_KEYS
372
+ }
373
+
349
374
  async def async_ensure_token(self) -> bool:
350
375
  """Force a fresh access token for camera/smarthome HTTP calls.
351
376
 
@@ -544,7 +569,12 @@ class AidotClient:
544
569
  # Close the account-shared persistent MQTT connection, if one was opened
545
570
  # (AIDOT_PERSISTENT_MQTT). Stored on the shared login_info by the camera
546
571
  # command/attr paths; closing here stops its background paho loop.
547
- pm = self.login_info.pop("_persistent_mqtt", None) if isinstance(self.login_info, dict) else None
572
+ pm = None
573
+ if isinstance(self.login_info, dict):
574
+ pm = self.login_info.pop(LOGIN_INFO_PERSISTENT_MQTT_KEY, None)
575
+ # Also drop the lock so the asyncio.Lock does not linger on the
576
+ # account-shared login_info after close.
577
+ self.login_info.pop(LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY, None)
548
578
  if pm is not None:
549
579
  try:
550
580
  pm.close()
@@ -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.3"
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.3
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:
@@ -159,7 +167,7 @@ audio, idle release, the sprop cache path) are documented in
159
167
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. | unset (off) |
160
168
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
161
169
  | `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
162
- | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soak cut SDES NO_MEDIA ~57%->~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
170
+ | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
163
171
  | `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
164
172
  | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
165
173
 
@@ -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
@@ -116,5 +117,6 @@ tests/test_stream_idle.py
116
117
  tests/test_stream_teardown.py
117
118
  tests/test_talk.py
118
119
  tests/test_terminal_ack.py
120
+ tests/test_token_file_robustness.py
119
121
  tests/test_token_refresh.py
120
122
  tests/test_wait_or_event.py
@@ -80,3 +80,23 @@ def test_network_helpers_are_callable():
80
80
  assert inspect.iscoroutinefunction(discover_subnet)
81
81
  sig = inspect.signature(discover_subnet)
82
82
  assert list(sig.parameters) == ["cidr24", "timeout", "concurrency"]
83
+
84
+
85
+ def test_get_attributes_empty_replies_raises_lanerror():
86
+ # If the camera accepts login but never answers getDevAttrReq (read timeout),
87
+ # _session returns [] - async_get_attributes must raise CameraLanError (the
88
+ # contract callers rely on for cloud fallback), not an unguarded IndexError.
89
+ import asyncio
90
+
91
+ c = CameraLanClient(DEVICE, USER, ip="192.0.2.10")
92
+
93
+ async def _empty(_build):
94
+ return []
95
+
96
+ c._session = _empty
97
+
98
+ async def _go():
99
+ with pytest.raises(CameraLanError):
100
+ await c.async_get_attributes()
101
+
102
+ asyncio.run(_go())
@@ -24,7 +24,7 @@ class _FakeDC:
24
24
  self._call = 0
25
25
  self._motion_active = True
26
26
  self._motion_cb = None
27
- self._motion_seen = set()
27
+ self._motion_seen: dict = {} # insertion-ordered set (dict keys); see CameraMixin
28
28
  self._motion_interval = 0.01 # fast for the test
29
29
 
30
30
  async def async_get_cloud_recordings(self, *a, **k):
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ """Tests for the standalone CLI's token-cache read/write robustness.
2
+
3
+ Covers the 0.11.3 fixes: the token file is written atomically at 0600 (a
4
+ serialization failure must not truncate a previously-valid token), and a
5
+ corrupt/partial cache is a caught ValueError (so _make_client can fall back
6
+ to username/password login instead of crashing).
7
+ """
8
+ import os
9
+ import stat
10
+
11
+ import pytest
12
+
13
+ from aidot.__main__ import _read_token_file, _write_token_file
14
+
15
+
16
+ def test_write_token_file_round_trips_and_is_0600(tmp_path):
17
+ p = tmp_path / "token.json"
18
+ _write_token_file(str(p), {"accessToken": "abc", "id": "u1"})
19
+ assert _read_token_file(str(p)) == {"accessToken": "abc", "id": "u1"}
20
+ assert stat.S_IMODE(os.stat(p).st_mode) == 0o600
21
+
22
+
23
+ def test_write_token_file_preserves_previous_on_serialization_failure(tmp_path):
24
+ p = tmp_path / "token.json"
25
+ _write_token_file(str(p), {"accessToken": "good"}) # seed a valid token
26
+
27
+ # A set is not JSON-serializable, so json.dump raises mid-write. The atomic
28
+ # temp-then-replace write must leave the previously-valid token untouched.
29
+ with pytest.raises(TypeError):
30
+ _write_token_file(str(p), {"broken": {1, 2, 3}})
31
+
32
+ assert _read_token_file(str(p)) == {"accessToken": "good"}
33
+ # And it must not leave a stray temp file behind.
34
+ assert [f for f in os.listdir(tmp_path) if f.startswith(".token-")] == []
35
+
36
+
37
+ def test_read_token_file_raises_valueerror_on_corrupt(tmp_path):
38
+ p = tmp_path / "token.json"
39
+ p.write_text("{ not valid json", encoding="utf-8")
40
+ # json.JSONDecodeError is a subclass of ValueError - the exact type
41
+ # _make_client catches to fall back to password login rather than crash.
42
+ with pytest.raises(ValueError):
43
+ _read_token_file(str(p))