python-aidot-cameras 0.11.2__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.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.3}/PKG-INFO +2 -2
  2. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/README.md +1 -1
  3. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/__main__.py +31 -8
  4. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/client.py +22 -10
  5. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/lan_control.py +4 -0
  6. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/playback.py +19 -1
  7. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/sdes_open.py +15 -0
  8. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/webrtc_open.py +3 -0
  9. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/client.py +8 -1
  10. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/pyproject.toml +1 -1
  11. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
  12. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  13. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_lan_control.py +20 -0
  14. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_motion_poll.py +1 -1
  15. python_aidot_cameras-0.11.3/tests/test_token_file_robustness.py +43 -0
  16. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/LICENSE +0 -0
  17. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/MANIFEST.in +0 -0
  18. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/__init__.py +0 -0
  19. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/__init__.py +0 -0
  20. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/__init__.py +0 -0
  21. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/clock.py +0 -0
  22. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  23. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  24. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  25. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  26. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  27. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  28. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  29. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  30. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  31. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  32. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/events.py +0 -0
  33. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/exceptions.py +0 -0
  34. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  35. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  36. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rate.py +0 -0
  37. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  38. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  39. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  40. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  41. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  42. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  43. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  44. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  45. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  46. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  47. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  48. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtp.py +0 -0
  49. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/sdp.py +0 -0
  50. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/stats.py +0 -0
  51. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/utils.py +0 -0
  52. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/aes_utils.py +0 -0
  53. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/__init__.py +0 -0
  54. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/constants.py +0 -0
  55. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/controls.py +0 -0
  56. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/go2rtc.py +0 -0
  57. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/models.py +0 -0
  58. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/protocol.py +0 -0
  59. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/sdes.py +0 -0
  60. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/tutk.py +0 -0
  61. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/camera/webrtc.py +0 -0
  62. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/const.py +0 -0
  63. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/credentials.py +0 -0
  64. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/device_client.py +0 -0
  65. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/discover.py +0 -0
  66. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/exceptions.py +0 -0
  67. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/g711.py +0 -0
  68. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/login_const.py +0 -0
  69. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/models/__init__.py +0 -0
  70. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/models/device_client_model.py +0 -0
  71. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/models/device_model.py +0 -0
  72. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/models/discover_model.py +0 -0
  73. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/aidot/py.typed +0 -0
  74. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  75. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  76. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/requires.txt +0 -0
  77. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  78. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/setup.cfg +0 -0
  79. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_aioice_compat.py +0 -0
  80. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_alarm_event.py +0 -0
  81. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_backoff.py +0 -0
  82. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_deferred_hardening.py +0 -0
  83. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_device_login_guard.py +0 -0
  84. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_device_user_info_cache.py +0 -0
  85. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_dtls_not_ready_burst.py +0 -0
  86. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_dtls_pinning.py +0 -0
  87. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_dtls_skip_signaling_wait.py +0 -0
  88. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_egress_guard.py +0 -0
  89. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_go2rtc.py +0 -0
  90. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_go2rtc_cli.py +0 -0
  91. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_highport_nomination.py +0 -0
  92. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_ice_config_cache.py +0 -0
  93. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_keyframe_prompter.py +0 -0
  94. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_live_stream_param.py +0 -0
  95. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_narrow_pc_ice.py +0 -0
  96. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_no_undefined_names.py +0 -0
  97. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_offline_keepalive_pause.py +0 -0
  98. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_open_gate_delay.py +0 -0
  99. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_persistent_mqtt.py +0 -0
  100. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_persistent_mqtt_robustness.py +0 -0
  101. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_playback_tls.py +0 -0
  102. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_post_merge_hardening.py +0 -0
  103. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_retry_policy.py +0 -0
  104. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_adaptive.py +0 -0
  105. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_echo_wait_timeout.py +0 -0
  106. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_fast_liveplay.py +0 -0
  107. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_idle_release.py +0 -0
  108. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_serve_audio.py +0 -0
  109. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_serve_cmd.py +0 -0
  110. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_sprop.py +0 -0
  111. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_talk.py +0 -0
  112. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_sdes_watchdog.py +0 -0
  113. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_security_hardening.py +0 -0
  114. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_serializable_login_info.py +0 -0
  115. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_serve_relay.py +0 -0
  116. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_session_stats.py +0 -0
  117. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_speak.py +0 -0
  118. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_stream_cap.py +0 -0
  119. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_stream_idle.py +0 -0
  120. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_stream_teardown.py +0 -0
  121. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_talk.py +0 -0
  122. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_terminal_ack.py +0 -0
  123. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.3}/tests/test_token_refresh.py +0 -0
  124. {python_aidot_cameras-0.11.2 → 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.2
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
@@ -167,7 +167,7 @@ audio, idle release, the sprop cache path) are documented in
167
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) |
168
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) |
169
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) |
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 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) |
171
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) |
172
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) |
173
173
 
@@ -137,7 +137,7 @@ audio, idle release, the sprop cache path) are documented in
137
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) |
138
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) |
139
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) |
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 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) |
141
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) |
142
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) |
143
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:
@@ -133,10 +149,17 @@ async def _make_client(session: aiohttp.ClientSession) -> AidotClient:
133
149
  password = os.environ.get("AIDOT_PASSWORD")
134
150
 
135
151
  if token_file and os.path.exists(token_file):
136
- token = await loop.run_in_executor(None, _read_token_file, token_file)
137
- client = AidotClient(session=session, token=token)
138
- _install_token_cache(client, token_file)
139
- 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
140
163
 
141
164
  if not username or not password:
142
165
  sys.exit(
@@ -695,7 +695,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
695
695
  self._motion_active: bool = False
696
696
  self._motion_cb: Optional[Callable] = None
697
697
  self._motion_task: Optional["asyncio.Task[None]"] = None
698
- 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 = {}
699
701
  self._motion_interval: float = 30.0
700
702
 
701
703
  # -- Pre-0.6 core API, kept for existing consumers ----------------------- #
@@ -1020,9 +1022,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1020
1022
  if not mqtt_host:
1021
1023
  # Fall back to the regional MQTT broker URL known to work from
1022
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.
1023
1028
  _LOGGER.warning(
1024
1029
  "getServerUrlConfig returned no mqttServerUrl; "
1025
- "using regional fallback. body=%s", body
1030
+ "using regional fallback. keys=%s", sorted(data)
1026
1031
  )
1027
1032
  mqtt_host = f"wss://{self._region}-mqtt.arnoo.com:8443/mqtt"
1028
1033
 
@@ -2116,11 +2121,16 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2116
2121
  return
2117
2122
  frame_event.set()
2118
2123
 
2119
- session = await self.async_open_webrtc_stream(
2120
- on_frame=_on_frame,
2121
- timeout=timeout,
2122
- status_callback=status_callback,
2123
- )
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
2124
2134
  try:
2125
2135
  try:
2126
2136
  await _asyncio.wait_for(frame_event.wait(), timeout=timeout)
@@ -2243,7 +2253,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2243
2253
  if self._motion_task is not None and not self._motion_task.done():
2244
2254
  return
2245
2255
  self._motion_active = True
2246
- self._motion_seen = set()
2256
+ self._motion_seen = {}
2247
2257
  self._motion_task = asyncio.ensure_future(self._motion_poll_loop(lookback_s))
2248
2258
 
2249
2259
  async def async_stop_motion_polling(self) -> None:
@@ -2273,7 +2283,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2273
2283
  uid = it.get("eventUuid") or it.get("eventId") or it.get("recordId")
2274
2284
  if not uid or uid in self._motion_seen:
2275
2285
  continue
2276
- self._motion_seen.add(uid)
2286
+ self._motion_seen[uid] = None
2277
2287
  fresh.append(it)
2278
2288
  if primed and fresh and self._motion_cb is not None:
2279
2289
  for it in sorted(fresh, key=lambda x: x.get("eventTime") or 0):
@@ -2285,7 +2295,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2285
2295
  _LOGGER.debug("motion callback raised", exc_info=True)
2286
2296
  primed = True
2287
2297
  if len(self._motion_seen) > 1000: # bound memory
2288
- 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:])
2289
2301
  except asyncio.CancelledError:
2290
2302
  return
2291
2303
  except Exception as exc:
@@ -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,8 @@ 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,
41
43
  RUNTIME_ONLY_LOGIN_INFO_KEYS,
42
44
  ServerErrorCode,
43
45
  )
@@ -567,7 +569,12 @@ class AidotClient:
567
569
  # Close the account-shared persistent MQTT connection, if one was opened
568
570
  # (AIDOT_PERSISTENT_MQTT). Stored on the shared login_info by the camera
569
571
  # command/attr paths; closing here stops its background paho loop.
570
- 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)
571
578
  if pm is not None:
572
579
  try:
573
580
  pm.close()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.11.2"
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.2
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
@@ -167,7 +167,7 @@ audio, idle release, the sprop cache path) are documented in
167
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) |
168
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) |
169
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) |
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 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) |
171
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) |
172
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) |
173
173
 
@@ -117,5 +117,6 @@ tests/test_stream_idle.py
117
117
  tests/test_stream_teardown.py
118
118
  tests/test_talk.py
119
119
  tests/test_terminal_ack.py
120
+ tests/test_token_file_robustness.py
120
121
  tests/test_token_refresh.py
121
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,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))