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.
- {python_aidot_cameras-0.11.1/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.3}/PKG-INFO +10 -2
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/README.md +9 -1
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/__main__.py +45 -12
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/client.py +35 -14
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/lan_control.py +4 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/playback.py +19 -1
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/sdes_open.py +15 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/webrtc_open.py +3 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/client.py +31 -1
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/const.py +15 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/pyproject.toml +1 -1
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info}/PKG-INFO +10 -2
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_lan_control.py +20 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_motion_poll.py +1 -1
- python_aidot_cameras-0.11.3/tests/test_serializable_login_info.py +82 -0
- python_aidot_cameras-0.11.3/tests/test_token_file_robustness.py +43 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/LICENSE +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/protocol.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/discover.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/g711.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/py.typed +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/setup.cfg +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.11.1/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.3}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.11.
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
os.chmod
|
|
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.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
153
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
3129
|
+
lock = li.setdefault(LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY, asyncio.Lock())
|
|
3109
3130
|
async with lock:
|
|
3110
|
-
pm = li.get(
|
|
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[
|
|
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", '
|
|
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 =
|
|
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.
|
|
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"
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.11.
|
|
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
|
|
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))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/base.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g711.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/g722.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/h264.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/opus.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/codecs/vpx.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/contrib/media.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/exceptions.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/jitterbuffer.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/mediastreams.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcconfiguration.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdatachannel.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcdtlstransport.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcicetransport.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpparameters.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpreceiver.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcrtpsender.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/_vendor/aiortc/rtcsctptransport.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_deferred_hardening.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_device_login_guard.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_device_user_info_cache.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_not_ready_burst.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_dtls_skip_signaling_wait.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_offline_keepalive_pause.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_persistent_mqtt_robustness.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_echo_wait_timeout.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_sdes_fast_liveplay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.1 → python_aidot_cameras-0.11.3}/tests/test_security_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|