python-aidot-cameras 0.11.2__tar.gz → 0.11.5__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 (125) hide show
  1. {python_aidot_cameras-0.11.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.5}/PKG-INFO +3 -3
  2. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/README.md +1 -1
  3. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/__main__.py +31 -8
  4. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/client.py +103 -10
  5. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/lan_control.py +4 -0
  6. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/playback.py +19 -1
  7. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/protocol.py +6 -1
  8. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/sdes_open.py +15 -0
  9. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/webrtc_open.py +9 -25
  10. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/client.py +8 -1
  11. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/pyproject.toml +2 -2
  12. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5/python_aidot_cameras.egg-info}/PKG-INFO +3 -3
  13. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  14. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/requires.txt +1 -1
  15. python_aidot_cameras-0.11.5/tests/test_keepalive_renew.py +119 -0
  16. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_lan_control.py +20 -0
  17. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_motion_poll.py +1 -1
  18. python_aidot_cameras-0.11.5/tests/test_token_file_robustness.py +43 -0
  19. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/LICENSE +0 -0
  20. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/MANIFEST.in +0 -0
  21. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/__init__.py +0 -0
  22. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/__init__.py +0 -0
  23. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/__init__.py +0 -0
  24. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/clock.py +0 -0
  25. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  26. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  27. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  28. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  29. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  30. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  31. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  32. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  33. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  34. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  35. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/events.py +0 -0
  36. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/exceptions.py +0 -0
  37. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  38. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  39. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rate.py +0 -0
  40. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  41. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  42. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  43. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  44. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  45. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  46. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  47. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  48. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  49. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  50. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  51. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtp.py +0 -0
  52. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/sdp.py +0 -0
  53. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/stats.py +0 -0
  54. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/utils.py +0 -0
  55. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/aes_utils.py +0 -0
  56. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/__init__.py +0 -0
  57. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/constants.py +0 -0
  58. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/controls.py +0 -0
  59. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/go2rtc.py +0 -0
  60. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/models.py +0 -0
  61. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/sdes.py +0 -0
  62. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/tutk.py +0 -0
  63. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/camera/webrtc.py +0 -0
  64. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/const.py +0 -0
  65. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/credentials.py +0 -0
  66. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/device_client.py +0 -0
  67. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/discover.py +0 -0
  68. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/exceptions.py +0 -0
  69. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/g711.py +0 -0
  70. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/login_const.py +0 -0
  71. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/models/__init__.py +0 -0
  72. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/models/device_client_model.py +0 -0
  73. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/models/device_model.py +0 -0
  74. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/models/discover_model.py +0 -0
  75. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/aidot/py.typed +0 -0
  76. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  77. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  78. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  79. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/setup.cfg +0 -0
  80. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_aioice_compat.py +0 -0
  81. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_alarm_event.py +0 -0
  82. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_backoff.py +0 -0
  83. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_deferred_hardening.py +0 -0
  84. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_device_login_guard.py +0 -0
  85. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_device_user_info_cache.py +0 -0
  86. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_dtls_not_ready_burst.py +0 -0
  87. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_dtls_pinning.py +0 -0
  88. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_dtls_skip_signaling_wait.py +0 -0
  89. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_egress_guard.py +0 -0
  90. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_go2rtc.py +0 -0
  91. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_go2rtc_cli.py +0 -0
  92. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_highport_nomination.py +0 -0
  93. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_ice_config_cache.py +0 -0
  94. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_keyframe_prompter.py +0 -0
  95. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_live_stream_param.py +0 -0
  96. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_narrow_pc_ice.py +0 -0
  97. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_no_undefined_names.py +0 -0
  98. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_offline_keepalive_pause.py +0 -0
  99. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_open_gate_delay.py +0 -0
  100. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_persistent_mqtt.py +0 -0
  101. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_persistent_mqtt_robustness.py +0 -0
  102. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_playback_tls.py +0 -0
  103. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_post_merge_hardening.py +0 -0
  104. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_retry_policy.py +0 -0
  105. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_adaptive.py +0 -0
  106. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_echo_wait_timeout.py +0 -0
  107. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_fast_liveplay.py +0 -0
  108. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_idle_release.py +0 -0
  109. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_serve_audio.py +0 -0
  110. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_serve_cmd.py +0 -0
  111. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_sprop.py +0 -0
  112. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_talk.py +0 -0
  113. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_sdes_watchdog.py +0 -0
  114. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_security_hardening.py +0 -0
  115. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_serializable_login_info.py +0 -0
  116. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_serve_relay.py +0 -0
  117. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_session_stats.py +0 -0
  118. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_speak.py +0 -0
  119. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_stream_cap.py +0 -0
  120. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_stream_idle.py +0 -0
  121. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_stream_teardown.py +0 -0
  122. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_talk.py +0 -0
  123. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_terminal_ack.py +0 -0
  124. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/tests/test_token_refresh.py +0 -0
  125. {python_aidot_cameras-0.11.2 → python_aidot_cameras-0.11.5}/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.5
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
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: aiohttp>=3.9
15
- Requires-Dist: paho-mqtt>=2.0
15
+ Requires-Dist: paho-mqtt>=1.6.1
16
16
  Requires-Dist: cryptography>=42.0
17
17
  Requires-Dist: pycryptodome>=3.20
18
18
  Requires-Dist: dacite>=1.8
@@ -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(
@@ -565,6 +565,14 @@ def _get_stream_slots() -> "asyncio.Semaphore":
565
565
 
566
566
 
567
567
 
568
+ # Battery cameras re-sleep on their own low-power timer ~25 s after the last
569
+ # keep-alive, even mid-view. Renew setKeepAliveTime INSIDE that window so there
570
+ # is no sleep gap (the official app renews every 30 s, which leaves a ~5 s gap
571
+ # against the 25 s window). See CameraMixin._keepalive_renew_loop.
572
+ _KEEPALIVE_WINDOW_S = 25
573
+ _KEEPALIVE_RENEW_S = 20
574
+
575
+
568
576
  class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
569
577
  """All camera/streaming methods, mixed into DeviceClient via inheritance."""
570
578
 
@@ -695,7 +703,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
695
703
  self._motion_active: bool = False
696
704
  self._motion_cb: Optional[Callable] = None
697
705
  self._motion_task: Optional["asyncio.Task[None]"] = None
698
- self._motion_seen: set = set()
706
+ # Insertion-ordered set (dict keys) so the memory-bound trim can evict
707
+ # the OLDEST uids deterministically rather than arbitrary ones.
708
+ self._motion_seen: dict = {}
699
709
  self._motion_interval: float = 30.0
700
710
 
701
711
  # -- Pre-0.6 core API, kept for existing consumers ----------------------- #
@@ -1020,9 +1030,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1020
1030
  if not mqtt_host:
1021
1031
  # Fall back to the regional MQTT broker URL known to work from
1022
1032
  # the AiDot web client: wss://{region}-mqtt.arnoo.com:8443/mqtt
1033
+ # Redacted: `data`/`body` carries mqttPassword; log only the
1034
+ # non-sensitive key names present, mirroring the hasPwd-style
1035
+ # redaction of the debug log below.
1023
1036
  _LOGGER.warning(
1024
1037
  "getServerUrlConfig returned no mqttServerUrl; "
1025
- "using regional fallback. body=%s", body
1038
+ "using regional fallback. keys=%s", sorted(data)
1026
1039
  )
1027
1040
  mqtt_host = f"wss://{self._region}-mqtt.arnoo.com:8443/mqtt"
1028
1041
 
@@ -2116,11 +2129,16 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2116
2129
  return
2117
2130
  frame_event.set()
2118
2131
 
2119
- session = await self.async_open_webrtc_stream(
2120
- on_frame=_on_frame,
2121
- timeout=timeout,
2122
- status_callback=status_callback,
2123
- )
2132
+ try:
2133
+ session = await self.async_open_webrtc_stream(
2134
+ on_frame=_on_frame,
2135
+ timeout=timeout,
2136
+ status_callback=status_callback,
2137
+ )
2138
+ except Exception as _snap_exc:
2139
+ _LOGGER.error(
2140
+ "async_snapshot DTLS failed for %s: %s", self.device_id, _snap_exc)
2141
+ return False
2124
2142
  try:
2125
2143
  try:
2126
2144
  await _asyncio.wait_for(frame_event.wait(), timeout=timeout)
@@ -2159,6 +2177,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2159
2177
  if self._stream_task is not None and not self._stream_task.done():
2160
2178
  return
2161
2179
  self._streaming_active = True
2180
+ self._start_keepalive_renew() # battery: renew keep-alive on this path too
2162
2181
  self._stream_task = asyncio.ensure_future(self._streaming_loop())
2163
2182
 
2164
2183
  async def async_stop_streaming(self) -> None:
@@ -2186,6 +2205,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2186
2205
  await task
2187
2206
  except (asyncio.CancelledError, Exception):
2188
2207
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
2208
+ ka_task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
2209
+ if ka_task is not None and not ka_task.done():
2210
+ ka_task.cancel()
2211
+ try:
2212
+ await ka_task
2213
+ except (asyncio.CancelledError, Exception):
2214
+ _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
2189
2215
  # Reap a persistent-MQTT stream drain that no session stopped (e.g. an
2190
2216
  # open cancelled mid-handshake) so its handler is removed from the shared
2191
2217
  # connection and its blocked executor thread is released.
@@ -2243,7 +2269,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2243
2269
  if self._motion_task is not None and not self._motion_task.done():
2244
2270
  return
2245
2271
  self._motion_active = True
2246
- self._motion_seen = set()
2272
+ self._motion_seen = {}
2247
2273
  self._motion_task = asyncio.ensure_future(self._motion_poll_loop(lookback_s))
2248
2274
 
2249
2275
  async def async_stop_motion_polling(self) -> None:
@@ -2273,7 +2299,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2273
2299
  uid = it.get("eventUuid") or it.get("eventId") or it.get("recordId")
2274
2300
  if not uid or uid in self._motion_seen:
2275
2301
  continue
2276
- self._motion_seen.add(uid)
2302
+ self._motion_seen[uid] = None
2277
2303
  fresh.append(it)
2278
2304
  if primed and fresh and self._motion_cb is not None:
2279
2305
  for it in sorted(fresh, key=lambda x: x.get("eventTime") or 0):
@@ -2285,7 +2311,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2285
2311
  _LOGGER.debug("motion callback raised", exc_info=True)
2286
2312
  primed = True
2287
2313
  if len(self._motion_seen) > 1000: # bound memory
2288
- self._motion_seen = set(list(self._motion_seen)[-400:])
2314
+ # Keep the 400 most-recent uids (insertion order), evicting
2315
+ # the oldest so still-in-window events stay deduplicated.
2316
+ self._motion_seen = dict.fromkeys(list(self._motion_seen)[-400:])
2289
2317
  except asyncio.CancelledError:
2290
2318
  return
2291
2319
  except Exception as exc:
@@ -2295,6 +2323,65 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2295
2323
  except asyncio.CancelledError:
2296
2324
  return
2297
2325
 
2326
+ async def _async_set_keep_alive(self, keep_alive_s: int = _KEEPALIVE_WINDOW_S) -> None:
2327
+ """POST setKeepAliveTime so a battery camera stays awake for keep_alive_s
2328
+ seconds. Best-effort (errors swallowed). Wire format matches the app
2329
+ (n.java: keepAliveTime=25)."""
2330
+ try:
2331
+ import aiohttp as _aiohttp
2332
+ async with _aiohttp.ClientSession() as _s:
2333
+ async with _s.post(
2334
+ f"{self._aidot_v32_base}/devices/{self.device_id}/setKeepAliveTime",
2335
+ json={"keepAliveTime": keep_alive_s},
2336
+ headers=self._aidot_headers(),
2337
+ timeout=_aiohttp.ClientTimeout(total=8),
2338
+ ) as _r:
2339
+ _LOGGER.debug(
2340
+ "setKeepAliveTime HTTP %d for %s", _r.status, self.device_id
2341
+ )
2342
+ except Exception as _exc:
2343
+ _LOGGER.debug(
2344
+ "setKeepAliveTime HTTP failed for %s: %s", self.device_id, _exc
2345
+ )
2346
+
2347
+ def _start_keepalive_renew(self) -> None:
2348
+ """Start the battery keep-alive renew loop - battery-only and
2349
+ single-instance: cancel any prior loop first so a re-view within the
2350
+ renew sleep window can't orphan one (an orphan keeps POSTing
2351
+ setKeepAliveTime until _streaming_active next clears). No-op for mains
2352
+ cameras, which never sleep."""
2353
+ if not self.is_battery_camera:
2354
+ return
2355
+ prev = getattr(self, "_keepalive_task", None)
2356
+ if prev is not None and not prev.done():
2357
+ prev.cancel()
2358
+ self._keepalive_task = asyncio.ensure_future(self._keepalive_renew_loop())
2359
+
2360
+ def _cancel_keepalive_renew(self) -> None:
2361
+ """Cancel the battery keep-alive renew loop if running (fire-and-forget)."""
2362
+ task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
2363
+ if task is not None and not task.done():
2364
+ task.cancel()
2365
+
2366
+ async def _keepalive_renew_loop(self) -> None:
2367
+ """Renew setKeepAliveTime while a BATTERY camera is streaming (app parity).
2368
+
2369
+ The camera's own low-power timer returns it to sleep ~25 s after the last
2370
+ keep-alive even mid-view, so a one-shot keep-alive at open lets the stream
2371
+ drop partway through. The official app renews it throughout a live view;
2372
+ we renew every _KEEPALIVE_RENEW_S seconds (inside the 25 s window, so no
2373
+ sleep gap). Mains cameras never sleep, so this is battery-only."""
2374
+ if not self.is_battery_camera:
2375
+ return
2376
+ try:
2377
+ while self._streaming_active:
2378
+ await asyncio.sleep(_KEEPALIVE_RENEW_S)
2379
+ if not self._streaming_active:
2380
+ break
2381
+ await self._async_set_keep_alive()
2382
+ except asyncio.CancelledError:
2383
+ pass
2384
+
2298
2385
  async def start_keepalive(
2299
2386
  self,
2300
2387
  rtsp_push_url: Optional[str] = None,
@@ -2369,6 +2456,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2369
2456
  self._keepalive_rtsp_url = rtsp_push_url
2370
2457
  self._go2rtc_url = go2rtc_url
2371
2458
  self._streaming_active = True
2459
+ # App parity: keep a battery camera awake for the whole view (mains cams
2460
+ # never sleep, so this is a no-op there). Single-instance; cancelled in
2461
+ # stop / idle-release.
2462
+ self._start_keepalive_renew()
2372
2463
  if self.is_sdes_camera:
2373
2464
  self._stream_task = asyncio.ensure_future(self._sdes_keepalive_loop())
2374
2465
  elif rtsp_push_url and rtsp_push_url.startswith("http"):
@@ -2691,6 +2782,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2691
2782
  except Exception:
2692
2783
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_sdes_keepalive_loop', exc_info=True)
2693
2784
  self._streaming_active = False
2785
+ self._cancel_keepalive_renew()
2694
2786
  self._keepalive_rtsp_url = None
2695
2787
  self._serve_ready.clear()
2696
2788
  _LOGGER.debug(
@@ -3478,6 +3570,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3478
3570
  # No viewer - go dormant; camera.stream_source() restarts us when
3479
3571
  # someone opens the live view again.
3480
3572
  self._streaming_active = False
3573
+ self._cancel_keepalive_renew()
3481
3574
  self._keepalive_rtsp_url = None
3482
3575
  self._serve_ready.clear()
3483
3576
  _LOGGER.debug(
@@ -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
 
@@ -1219,7 +1219,12 @@ def _mqtt_session_sync(
1219
1219
  else str(msg.payload))
1220
1220
  msg_q.put((msg.topic, payload))
1221
1221
 
1222
- def _on_disconnect(c, ud, disconnect_flags=None, reason_code=None, props=None):
1222
+ def _on_disconnect(c, ud, *args):
1223
+ # paho >=2 calls (disconnect_flags, reason_code, properties); paho <2
1224
+ # calls (rc,). Pull the reason code from whichever slot it lands in so
1225
+ # the log is truthful on both (a fixed 5-arg signature put paho 1.x's rc
1226
+ # in disconnect_flags and logged reason_code as None).
1227
+ reason_code = args[1] if len(args) >= 2 else (args[0] if args else None)
1223
1228
  # If _on_connect was never fired (WebSocket upgrade failed, auth refused
1224
1229
  # at TCP level, etc.) signal conn_ev now so the caller doesn't time out.
1225
1230
  if not conn_ev.is_set():
@@ -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:
@@ -1000,32 +1000,13 @@ class _WebRTCOpenMixin:
1000
1000
  "lowPowerActiveState HTTP failed for %s: %s", device_id, _we
1001
1001
  )
1002
1002
 
1003
- async def _http_keepalive() -> None:
1004
- # setKeepAliveTime keeps the camera in active state for 25 s
1005
- # after wake (n.java:224 - keepAliveTime=25, not 20).
1006
- # Without this call the camera's built-in timer may return it
1007
- # to sleep before SCTP + LIVING completes (~10-15 s).
1008
- try:
1009
- import aiohttp as _aiohttp_k
1010
- async with _aiohttp_k.ClientSession() as _ks:
1011
- async with _ks.post(
1012
- f"{self._aidot_v32_base}/devices/{device_id}"
1013
- "/setKeepAliveTime",
1014
- json={"keepAliveTime": 25},
1015
- headers=self._aidot_headers(),
1016
- timeout=_aiohttp_k.ClientTimeout(total=8),
1017
- ) as _kr:
1018
- _LOGGER.debug(
1019
- "setKeepAliveTime HTTP %d for %s",
1020
- _kr.status, device_id,
1021
- )
1022
- except Exception as _ke:
1023
- _LOGGER.debug(
1024
- "setKeepAliveTime HTTP failed for %s: %s", device_id, _ke
1025
- )
1026
-
1027
1003
  _spawn_bg(_http_wake())
1028
- _spawn_bg(_http_keepalive())
1004
+ # setKeepAliveTime keeps the camera active for ~25 s after wake so the
1005
+ # SCTP + LIVING handshake can complete. For BATTERY cameras a renewal
1006
+ # loop (CameraMixin._keepalive_renew_loop, started by start_keepalive)
1007
+ # then re-issues it every 20 s for the whole view - app parity so the
1008
+ # camera doesn't return to sleep mid-stream.
1009
+ _spawn_bg(self._async_set_keep_alive())
1029
1010
 
1030
1011
  outgoing_q.put_nowait(
1031
1012
  (f"iot/v1/s/{user_id}/IPC/getIceConfigReq", _ice_req_payload)
@@ -1156,6 +1137,7 @@ class _WebRTCOpenMixin:
1156
1137
  # transient on battery cameras and recover via ICE; abort
1157
1138
  # there would spuriously kill otherwise-good streams.
1158
1139
  if _lp_on == 0:
1140
+ outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
1159
1141
  raise RuntimeError(
1160
1142
  f"livePlay refused by camera (livePlay=0, code={_lp_code})"
1161
1143
  )
@@ -1207,6 +1189,7 @@ class _WebRTCOpenMixin:
1207
1189
  _lp_code2 = int(_lp_resp2.get("code", 200))
1208
1190
  _lp_on2 = int(_lp_resp2.get("livePlay", 1))
1209
1191
  if _lp_on2 == 0:
1192
+ outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
1210
1193
  raise RuntimeError(
1211
1194
  f"livePlay refused by camera (livePlay=0, code={_lp_code2})"
1212
1195
  )
@@ -3210,6 +3193,7 @@ class _WebRTCOpenMixin:
3210
3193
  # encoder is still cold. Tear down and raise so the serve loop
3211
3194
  # fast-retries in a bounded burst instead of letting DTLS
3212
3195
  # co-fail into the full 15s-gated generic retry.
3196
+ outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
3213
3197
  try:
3214
3198
  await pc.close()
3215
3199
  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.5"
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"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  # after re-validating against it.
23
23
  dependencies = [
24
24
  "aiohttp>=3.9",
25
- "paho-mqtt>=2.0",
25
+ "paho-mqtt>=1.6.1",
26
26
  "cryptography>=42.0",
27
27
  "pycryptodome>=3.20",
28
28
  "dacite>=1.8",
@@ -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.5
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
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: aiohttp>=3.9
15
- Requires-Dist: paho-mqtt>=2.0
15
+ Requires-Dist: paho-mqtt>=1.6.1
16
16
  Requires-Dist: cryptography>=42.0
17
17
  Requires-Dist: pycryptodome>=3.20
18
18
  Requires-Dist: dacite>=1.8
@@ -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
 
@@ -85,6 +85,7 @@ tests/test_go2rtc.py
85
85
  tests/test_go2rtc_cli.py
86
86
  tests/test_highport_nomination.py
87
87
  tests/test_ice_config_cache.py
88
+ tests/test_keepalive_renew.py
88
89
  tests/test_keyframe_prompter.py
89
90
  tests/test_lan_control.py
90
91
  tests/test_live_stream_param.py
@@ -117,5 +118,6 @@ tests/test_stream_idle.py
117
118
  tests/test_stream_teardown.py
118
119
  tests/test_talk.py
119
120
  tests/test_terminal_ack.py
121
+ tests/test_token_file_robustness.py
120
122
  tests/test_token_refresh.py
121
123
  tests/test_wait_or_event.py
@@ -1,5 +1,5 @@
1
1
  aiohttp>=3.9
2
- paho-mqtt>=2.0
2
+ paho-mqtt>=1.6.1
3
3
  cryptography>=42.0
4
4
  pycryptodome>=3.20
5
5
  dacite>=1.8
@@ -0,0 +1,119 @@
1
+ """Battery-camera setKeepAliveTime renewal during streaming (app parity).
2
+
3
+ The official app re-issues setKeepAliveTime throughout a live view so a battery
4
+ camera's low-power timer doesn't return it to sleep mid-stream. We renew inside
5
+ the 25 s window; mains cameras never sleep and are skipped.
6
+ """
7
+ import asyncio
8
+ from unittest.mock import AsyncMock, MagicMock, patch
9
+
10
+ from aidot.camera.client import CameraMixin
11
+
12
+
13
+ def _obj(battery=True, streaming=True):
14
+ o = MagicMock()
15
+ o.is_battery_camera = battery
16
+ o._streaming_active = streaming
17
+ o.device_id = "cam1"
18
+ o._async_set_keep_alive = AsyncMock()
19
+ return o
20
+
21
+
22
+ def test_renew_loop_skips_mains_cameras():
23
+ o = _obj(battery=False)
24
+ asyncio.run(CameraMixin._keepalive_renew_loop(o))
25
+ o._async_set_keep_alive.assert_not_awaited()
26
+
27
+
28
+ def test_renew_loop_renews_battery_until_stream_stops():
29
+ o = _obj(battery=True)
30
+ n = {"i": 0}
31
+
32
+ async def fake_sleep(_s):
33
+ n["i"] += 1
34
+ if n["i"] >= 3:
35
+ o._streaming_active = False # stop after the 3rd sleep
36
+
37
+ with patch("aidot.camera.client.asyncio.sleep", fake_sleep):
38
+ asyncio.run(CameraMixin._keepalive_renew_loop(o))
39
+ # sleep(renew), sleep(renew), sleep(stop -> break): 2 renewals
40
+ assert o._async_set_keep_alive.await_count == 2
41
+
42
+
43
+ def test_renew_loop_swallows_cancel():
44
+ o = _obj(battery=True)
45
+
46
+ async def _run():
47
+ t = asyncio.ensure_future(CameraMixin._keepalive_renew_loop(o))
48
+ await asyncio.sleep(0) # let it reach the first (real) sleep
49
+ t.cancel() # cancel interrupts the sleep immediately
50
+ await t # CancelledError is swallowed inside the loop
51
+
52
+ asyncio.run(_run())
53
+ o._async_set_keep_alive.assert_not_awaited()
54
+
55
+
56
+ def test_start_renew_is_noop_for_mains_cameras():
57
+ # Mains cameras never sleep - no throwaway task should be scheduled.
58
+ o = _obj(battery=False)
59
+ with patch("aidot.camera.client.asyncio.ensure_future") as ef:
60
+ CameraMixin._start_keepalive_renew(o)
61
+ ef.assert_not_called()
62
+
63
+
64
+ def test_start_renew_cancels_a_still_running_prior_loop():
65
+ # Re-view within the renew sleep window must not orphan the prior loop.
66
+ o = _obj(battery=True)
67
+ prior = MagicMock()
68
+ prior.done.return_value = False
69
+ o._keepalive_task = prior
70
+ with patch("aidot.camera.client.asyncio.ensure_future", return_value="new") as ef:
71
+ CameraMixin._start_keepalive_renew(o)
72
+ prior.cancel.assert_called_once() # old loop stopped
73
+ ef.assert_called_once() # exactly one new loop
74
+ assert o._keepalive_task == "new"
75
+
76
+
77
+ def test_cancel_renew_stops_the_loop_and_clears_the_ref():
78
+ o = _obj(battery=True)
79
+ task = MagicMock()
80
+ task.done.return_value = False
81
+ o._keepalive_task = task
82
+ CameraMixin._cancel_keepalive_renew(o)
83
+ task.cancel.assert_called_once()
84
+ assert o._keepalive_task is None
85
+
86
+
87
+ def test_set_keep_alive_posts_expected_request():
88
+ o = MagicMock()
89
+ o.device_id = "cam1"
90
+ o._aidot_v32_base = "https://api.example/v3.2"
91
+ o._aidot_headers = MagicMock(return_value={"Authorization": "x"})
92
+ cap = {}
93
+
94
+ class _Resp:
95
+ status = 200
96
+
97
+ async def __aenter__(self):
98
+ return self
99
+
100
+ async def __aexit__(self, *a):
101
+ return False
102
+
103
+ class _Sess:
104
+ async def __aenter__(self):
105
+ return self
106
+
107
+ async def __aexit__(self, *a):
108
+ return False
109
+
110
+ def post(self, url, json=None, headers=None, timeout=None):
111
+ cap.update(url=url, json=json, headers=headers)
112
+ return _Resp()
113
+
114
+ with patch("aiohttp.ClientSession", lambda: _Sess()):
115
+ asyncio.run(CameraMixin._async_set_keep_alive(o))
116
+
117
+ assert cap["url"] == "https://api.example/v3.2/devices/cam1/setKeepAliveTime"
118
+ assert cap["json"] == {"keepAliveTime": 25}
119
+ assert cap["headers"] == {"Authorization": "x"}
@@ -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))