python-aidot-cameras 0.10.0__tar.gz → 0.10.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_aidot_cameras-0.10.2/MANIFEST.in +1 -0
- {python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.10.2}/PKG-INFO +9 -9
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/README.md +8 -8
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/client.py +52 -32
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/controls.py +5 -5
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/lan_control.py +13 -2
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/models.py +47 -32
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/protocol.py +15 -6
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/sdes_open.py +46 -46
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/webrtc.py +1 -1
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/webrtc_open.py +135 -79
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/client.py +16 -4
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/const.py +1 -1
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/credentials.py +19 -8
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/device_client.py +7 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/pyproject.toml +5 -4
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2/python_aidot_cameras.egg-info}/PKG-INFO +9 -9
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/SOURCES.txt +38 -36
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_backoff.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_device_login_guard.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_device_user_info_cache.py +1 -1
- python_aidot_cameras-0.10.2/tests/test_dtls_pinning.py +63 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_dtls_skip_signaling_wait.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_go2rtc.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_go2rtc_cli.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_ice_config_cache.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_keyframe_prompter.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_lan_control.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_live_stream_param.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_narrow_pc_ice.py +3 -3
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_open_gate_delay.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_persistent_mqtt.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_persistent_mqtt_robustness.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_post_merge_hardening.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_retry_policy.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_adaptive.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_fast_liveplay.py +2 -2
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_idle_release.py +4 -4
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_serve_audio.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_serve_cmd.py +1 -1
- python_aidot_cameras-0.10.2/tests/test_security_hardening.py +67 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_serve_relay.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_session_stats.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_stream_idle.py +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_stream_teardown.py +1 -1
- python_aidot_cameras-0.10.0/MANIFEST.in +0 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/LICENSE +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/__main__.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/discover.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/g711.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/py.typed +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/setup.cfg +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_wait_or_event.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include aidot/py.typed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.2
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -49,9 +49,9 @@ on this library.
|
|
|
49
49
|
|
|
50
50
|
The streaming transport is auto-selected per camera from its model id:
|
|
51
51
|
|
|
52
|
-
- **A000088** (M3 Pro)
|
|
53
|
-
- **A001513** ("L2")
|
|
54
|
-
- **A001064** (PTZ)
|
|
52
|
+
- **A000088** (M3 Pro) - DTLS-SRTP, wired/mains.
|
|
53
|
+
- **A001513** ("L2") - SDES-SRTP, **battery** (woken on demand; validated end-to-end).
|
|
54
|
+
- **A001064** (PTZ) - SDES-SRTP, wired/mains (role-reversal handshake).
|
|
55
55
|
|
|
56
56
|
Other battery models (A001108, A001360) are recognized in code with the same
|
|
57
57
|
battery handling. See [`docs/CAMERAS.md`](docs/CAMERAS.md#supported-cameras) for
|
|
@@ -68,7 +68,7 @@ pip install python-aidot-cameras
|
|
|
68
68
|
pip install python-aidot-cameras[webrtc]
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
`[webrtc]` pulls in the extra dependencies (aiortc, av,
|
|
71
|
+
`[webrtc]` pulls in the extra dependencies (aiortc, av, ...) needed for live
|
|
72
72
|
streaming, snapshots, and two-way audio. Without it you still get lights plus
|
|
73
73
|
the camera cloud/control APIs, but not live media.
|
|
74
74
|
|
|
@@ -132,7 +132,7 @@ The library reads the following environment variables.
|
|
|
132
132
|
### Credentials
|
|
133
133
|
|
|
134
134
|
Used by the credential helper (`aidot.credentials`); they take priority over any
|
|
135
|
-
stored credentials file. See [`
|
|
135
|
+
stored credentials file. See [`aidot/credentials.py`](aidot/credentials.py).
|
|
136
136
|
|
|
137
137
|
| Variable | Purpose | Default |
|
|
138
138
|
| --- | --- | --- |
|
|
@@ -152,12 +152,12 @@ audio, idle release, the sprop cache path) are documented in
|
|
|
152
152
|
| --- | --- | --- |
|
|
153
153
|
| `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently across all cameras. | `2` |
|
|
154
154
|
| `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
|
|
155
|
-
| `AIDOT_FAST_CONNECT` | Enable LAN-direct "fast connect" (STUN-only, skips several cloud signaling waits) when truthy. On-LAN only
|
|
156
|
-
| `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2
|
|
155
|
+
| `AIDOT_FAST_CONNECT` | Enable LAN-direct "fast connect" (STUN-only, skips several cloud signaling waits) when truthy. On-LAN only - off-subnet/strict-NAT viewers must leave it off. | unset (off) |
|
|
156
|
+
| `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2-3 s of cold-start latency) so signaling goes straight out with the host candidate. Faster on a LAN, at the cost of no relay fallback for a camera on a different segment / behind strict NAT. Experimental, opt-in (truthy = `1`/`true`/`yes`/`on`). | unset (off) |
|
|
157
157
|
| `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) |
|
|
158
158
|
| `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) |
|
|
159
159
|
| `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) |
|
|
160
|
-
| `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
|
|
160
|
+
| `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) |
|
|
161
161
|
| `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) |
|
|
162
162
|
| `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) |
|
|
163
163
|
|
|
@@ -21,9 +21,9 @@ on this library.
|
|
|
21
21
|
|
|
22
22
|
The streaming transport is auto-selected per camera from its model id:
|
|
23
23
|
|
|
24
|
-
- **A000088** (M3 Pro)
|
|
25
|
-
- **A001513** ("L2")
|
|
26
|
-
- **A001064** (PTZ)
|
|
24
|
+
- **A000088** (M3 Pro) - DTLS-SRTP, wired/mains.
|
|
25
|
+
- **A001513** ("L2") - SDES-SRTP, **battery** (woken on demand; validated end-to-end).
|
|
26
|
+
- **A001064** (PTZ) - SDES-SRTP, wired/mains (role-reversal handshake).
|
|
27
27
|
|
|
28
28
|
Other battery models (A001108, A001360) are recognized in code with the same
|
|
29
29
|
battery handling. See [`docs/CAMERAS.md`](docs/CAMERAS.md#supported-cameras) for
|
|
@@ -40,7 +40,7 @@ pip install python-aidot-cameras
|
|
|
40
40
|
pip install python-aidot-cameras[webrtc]
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
`[webrtc]` pulls in the extra dependencies (aiortc, av,
|
|
43
|
+
`[webrtc]` pulls in the extra dependencies (aiortc, av, ...) needed for live
|
|
44
44
|
streaming, snapshots, and two-way audio. Without it you still get lights plus
|
|
45
45
|
the camera cloud/control APIs, but not live media.
|
|
46
46
|
|
|
@@ -104,7 +104,7 @@ The library reads the following environment variables.
|
|
|
104
104
|
### Credentials
|
|
105
105
|
|
|
106
106
|
Used by the credential helper (`aidot.credentials`); they take priority over any
|
|
107
|
-
stored credentials file. See [`
|
|
107
|
+
stored credentials file. See [`aidot/credentials.py`](aidot/credentials.py).
|
|
108
108
|
|
|
109
109
|
| Variable | Purpose | Default |
|
|
110
110
|
| --- | --- | --- |
|
|
@@ -124,12 +124,12 @@ audio, idle release, the sprop cache path) are documented in
|
|
|
124
124
|
| --- | --- | --- |
|
|
125
125
|
| `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently across all cameras. | `2` |
|
|
126
126
|
| `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
|
|
127
|
-
| `AIDOT_FAST_CONNECT` | Enable LAN-direct "fast connect" (STUN-only, skips several cloud signaling waits) when truthy. On-LAN only
|
|
128
|
-
| `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2
|
|
127
|
+
| `AIDOT_FAST_CONNECT` | Enable LAN-direct "fast connect" (STUN-only, skips several cloud signaling waits) when truthy. On-LAN only - off-subnet/strict-NAT viewers must leave it off. | unset (off) |
|
|
128
|
+
| `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2-3 s of cold-start latency) so signaling goes straight out with the host candidate. Faster on a LAN, at the cost of no relay fallback for a camera on a different segment / behind strict NAT. Experimental, opt-in (truthy = `1`/`true`/`yes`/`on`). | unset (off) |
|
|
129
129
|
| `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
130
|
| `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
131
|
| `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
132
|
-
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soak cut SDES NO_MEDIA ~57
|
|
132
|
+
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soak cut SDES NO_MEDIA ~57%->~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
133
133
|
| `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
134
|
| `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
135
|
|
|
@@ -213,6 +213,7 @@ def _save_frame_as_jpeg(image_data: Any, output_path: str) -> bool:
|
|
|
213
213
|
],
|
|
214
214
|
input=image_data.tobytes(),
|
|
215
215
|
capture_output=True,
|
|
216
|
+
timeout=15, # bound the hang; a wedged ffmpeg must not block forever
|
|
216
217
|
)
|
|
217
218
|
return r.returncode == 0
|
|
218
219
|
except Exception as exc:
|
|
@@ -362,10 +363,10 @@ def _build_sdes_serve_cmd(
|
|
|
362
363
|
# Use DeviceClient.async_open_live_stream() to obtain an instance.
|
|
363
364
|
#
|
|
364
365
|
# Protocol source: classes.jar.decompiled.zip / TutkManager.java
|
|
365
|
-
# IOTC_Connect_ByUID_Parallel(uid, sid)
|
|
366
|
-
# avClientStart2(nSID, "admin", "admin123", ...)
|
|
367
|
-
# avSendIOCtrl(avIndex, 511, ...)
|
|
368
|
-
# avRecvFrameData2(avIndex, ...)
|
|
366
|
+
# IOTC_Connect_ByUID_Parallel(uid, sid) -> nSID
|
|
367
|
+
# avClientStart2(nSID, "admin", "admin123", ...) -> avIndex
|
|
368
|
+
# avSendIOCtrl(avIndex, 511, ...) -> start video (IOTYPE_USER_IPCAM_START)
|
|
369
|
+
# avRecvFrameData2(avIndex, ...) -> frame data loop
|
|
369
370
|
#
|
|
370
371
|
# Requires: libIOTCAPIs.so + libAVAPIs.so from the TUTK SDK.
|
|
371
372
|
# Obtain them from the TUTK SDK distribution or an extracted AiDot APK.
|
|
@@ -379,7 +380,7 @@ def _build_sdes_serve_cmd(
|
|
|
379
380
|
# Manages a single live-stream TCP session for a Leedarson/AiDot camera.
|
|
380
381
|
# Use DeviceClient.async_open_live_stream() to obtain an instance.
|
|
381
382
|
#
|
|
382
|
-
# Protocol source: iOS LDSXplayer startRealPlay
|
|
383
|
+
# Protocol source: iOS LDSXplayer startRealPlay -> LDSTCPManager
|
|
383
384
|
# connectHost:port:sessionId:aesKey:heartbeat:msg:cmd:subCmd:cmdParam:tls:
|
|
384
385
|
#
|
|
385
386
|
# Wire format: same 37-byte header + payload as CloudPlaybackSession, but:
|
|
@@ -595,7 +596,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
595
596
|
self._raw_device: dict = device
|
|
596
597
|
|
|
597
598
|
# Seed camera/diagnostic status from the cloud device "properties" payload
|
|
598
|
-
# (Battery_remaining, Occupancy, SDcardStatus, MotionDetection_*,
|
|
599
|
+
# (Battery_remaining, Occupancy, SDcardStatus, MotionDetection_*, ...). This
|
|
599
600
|
# is the authoritative, always-current source the official app itself reads
|
|
600
601
|
# - cameras do not push these reliably over MQTT - so sensors populate
|
|
601
602
|
# immediately on load, before any poll. No-op for devices without it.
|
|
@@ -722,7 +723,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
722
723
|
|
|
723
724
|
The device-list payload carries every camera attribute under
|
|
724
725
|
``properties`` (battery, SD-card, occupancy, motion/night-vision
|
|
725
|
-
settings,
|
|
726
|
+
settings, ...) plus a top-level ``online`` flag. This is the reliable,
|
|
726
727
|
always-current source the official app reads, so HA populates camera
|
|
727
728
|
sensors and control-entity states from it instead of an MQTT push the
|
|
728
729
|
camera never sends. Returns the updated status.
|
|
@@ -1248,7 +1249,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1248
1249
|
|
|
1249
1250
|
Tries several sources in order:
|
|
1250
1251
|
1. POST /v21/devices/batchGetDeviceUserInfo (AiDot platform API)
|
|
1251
|
-
2. POST /v5/deviceController/getP2pId?deviceId
|
|
1252
|
+
2. POST /v5/deviceController/getP2pId?deviceId=... (Leedarson smarthome)
|
|
1252
1253
|
3. AiDot v32 IPC device detail (fallback)
|
|
1253
1254
|
"""
|
|
1254
1255
|
import aiohttp
|
|
@@ -1516,7 +1517,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1516
1517
|
"payload": inner,
|
|
1517
1518
|
})
|
|
1518
1519
|
pub_topic = f"iot/v1/c/{device_id}/device/setDevAttrReq"
|
|
1519
|
-
_LOGGER.info("setDevAttrReq: %s=%s
|
|
1520
|
+
_LOGGER.info("setDevAttrReq: %s=%s -> %s seq=%s", attr, value, device_id, seq)
|
|
1520
1521
|
ok = await self._mqtt_device_cmd(
|
|
1521
1522
|
pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr")
|
|
1522
1523
|
if ok:
|
|
@@ -1561,7 +1562,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1561
1562
|
},
|
|
1562
1563
|
})
|
|
1563
1564
|
pub_topic = f"iot/v1/s/{user_id}/device/devActionReq"
|
|
1564
|
-
_LOGGER.info("devActionReq: %s %s
|
|
1565
|
+
_LOGGER.info("devActionReq: %s %s -> %s", action, params, device_id)
|
|
1565
1566
|
return await self._mqtt_device_cmd(
|
|
1566
1567
|
pub_topic, payload, timeout=timeout, ack_keyword="devAction")
|
|
1567
1568
|
|
|
@@ -1926,10 +1927,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1926
1927
|
"""
|
|
1927
1928
|
import asyncio as _asyncio
|
|
1928
1929
|
|
|
1929
|
-
#
|
|
1930
|
+
# -- SDES path: stream briefly to a temp TS file, extract one JPEG ---- #
|
|
1930
1931
|
if self.is_sdes_camera:
|
|
1931
1932
|
import os as _os
|
|
1932
|
-
import subprocess as _sp
|
|
1933
1933
|
import tempfile as _tf
|
|
1934
1934
|
|
|
1935
1935
|
with _tf.NamedTemporaryFile(suffix=".ts", delete=False) as _tmpf:
|
|
@@ -1970,17 +1970,33 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1970
1970
|
if _ffmpeg_path() is None:
|
|
1971
1971
|
_LOGGER.error("async_snapshot SDES: %s", _FFMPEG_MISSING_MSG)
|
|
1972
1972
|
return False
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1973
|
+
# Async subprocess (not subprocess.run): a synchronous ffmpeg
|
|
1974
|
+
# here blocks the whole event loop - every camera, keepalive and
|
|
1975
|
+
# MQTT drain - for up to the timeout.
|
|
1976
|
+
_snap_proc = await _asyncio.create_subprocess_exec(
|
|
1977
|
+
"ffmpeg", "-y", "-i", _tmp_ts,
|
|
1978
|
+
"-frames:v", "1", "-f", "image2", output_path,
|
|
1979
|
+
stdout=_asyncio.subprocess.DEVNULL,
|
|
1980
|
+
stderr=_asyncio.subprocess.PIPE,
|
|
1977
1981
|
)
|
|
1978
|
-
|
|
1982
|
+
try:
|
|
1983
|
+
_, _snap_err = await _asyncio.wait_for(
|
|
1984
|
+
_snap_proc.communicate(), timeout=15
|
|
1985
|
+
)
|
|
1986
|
+
except TimeoutError:
|
|
1987
|
+
_snap_proc.kill()
|
|
1988
|
+
await _snap_proc.communicate()
|
|
1989
|
+
_LOGGER.warning(
|
|
1990
|
+
"async_snapshot SDES: ffmpeg frame extract timed out for %s",
|
|
1991
|
+
self.device_id,
|
|
1992
|
+
)
|
|
1993
|
+
return False
|
|
1994
|
+
if _snap_proc.returncode == 0 and _os.path.exists(output_path):
|
|
1979
1995
|
return True
|
|
1980
1996
|
_LOGGER.warning(
|
|
1981
1997
|
"async_snapshot SDES: ffmpeg frame extract failed for %s: %s",
|
|
1982
1998
|
self.device_id,
|
|
1983
|
-
|
|
1999
|
+
(_snap_err or b"").decode(errors="replace")[-200:],
|
|
1984
2000
|
)
|
|
1985
2001
|
return False
|
|
1986
2002
|
except Exception as _snap_exc:
|
|
@@ -1993,7 +2009,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1993
2009
|
except Exception:
|
|
1994
2010
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_snapshot', exc_info=True)
|
|
1995
2011
|
|
|
1996
|
-
#
|
|
2012
|
+
# -- DTLS path: on_frame callback delivers frames from aiortc ------- #
|
|
1997
2013
|
frame_event = _asyncio.Event()
|
|
1998
2014
|
captured: list = [None] # stores PIL Image or ndarray once decoded
|
|
1999
2015
|
|
|
@@ -2032,7 +2048,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2032
2048
|
|
|
2033
2049
|
if captured[0] is None:
|
|
2034
2050
|
return False
|
|
2035
|
-
|
|
2051
|
+
# _save_frame_as_jpeg does a blocking PIL encode (or a blocking ffmpeg
|
|
2052
|
+
# fallback); run it off the event loop so it can't stall other cameras.
|
|
2053
|
+
return await _asyncio.get_running_loop().run_in_executor(
|
|
2054
|
+
None, _save_frame_as_jpeg, captured[0], output_path
|
|
2055
|
+
)
|
|
2036
2056
|
|
|
2037
2057
|
async def async_start_streaming(self) -> None:
|
|
2038
2058
|
"""Start a persistent background WebRTC stream that updates latest_jpeg.
|
|
@@ -2362,7 +2382,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2362
2382
|
open_timeout: float = 30.0,
|
|
2363
2383
|
retries: int = 3,
|
|
2364
2384
|
) -> bool:
|
|
2365
|
-
"""Play viewer
|
|
2385
|
+
"""Play viewer->camera (push-to-talk / announce) audio through the speaker.
|
|
2366
2386
|
|
|
2367
2387
|
``pcm_provider()`` is polled every 20 ms and must return 320 bytes of s16le
|
|
2368
2388
|
PCM @ 8 kHz mono (a short/empty frame is padded to silence) and ``None``
|
|
@@ -2558,13 +2578,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2558
2578
|
break
|
|
2559
2579
|
if _idle_on and _serve_port is not None:
|
|
2560
2580
|
_present = self._sdes_serve_consumer_present(_serve_port)
|
|
2561
|
-
if _present: # True
|
|
2581
|
+
if _present: # True -> a viewer is pulling; stay alive
|
|
2562
2582
|
_last_consumer = time.monotonic()
|
|
2563
2583
|
elif _idle_release_due(_present, _last_consumer,
|
|
2564
2584
|
time.monotonic(), _idle_secs):
|
|
2565
2585
|
_idle_release = True
|
|
2566
2586
|
break
|
|
2567
|
-
# _present is None (unreadable table)
|
|
2587
|
+
# _present is None (unreadable table) -> don't release
|
|
2568
2588
|
except asyncio.CancelledError:
|
|
2569
2589
|
_done.cancel()
|
|
2570
2590
|
self._stream_session = None
|
|
@@ -2816,7 +2836,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2816
2836
|
"""Serve idle-release window (seconds). A per-camera ``stream_idle_s``
|
|
2817
2837
|
(set via start_keepalive) overrides the ``AIDOT_STREAM_IDLE_S`` env
|
|
2818
2838
|
default of 120 s. <= 0 means *never* idle-release (keep the warm session
|
|
2819
|
-
for instant re-views
|
|
2839
|
+
for instant re-views - sensible for mains cameras, which have no battery
|
|
2820
2840
|
cost; note it holds a concurrent-stream slot for the camera's lifetime)."""
|
|
2821
2841
|
opt = getattr(self, "_stream_idle_opt", None)
|
|
2822
2842
|
if opt is not None:
|
|
@@ -3104,7 +3124,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3104
3124
|
import queue as _queue
|
|
3105
3125
|
serve_url = self._keepalive_rtsp_url
|
|
3106
3126
|
# APK parity: the official app gates re-connects to ~15 s (f0.java I1=15000)
|
|
3107
|
-
# and never hammers. We previously floored at 5 s and a partial-success
|
|
3127
|
+
# and never hammers. We previously floored at 5 s and a partial-success ->
|
|
3108
3128
|
# reset path could drop the effective spacing even lower, pounding a flaky
|
|
3109
3129
|
# A000088 camera fast enough to wedge its DTLS stack (ICE completes but DTLS
|
|
3110
3130
|
# never fires until a power-cycle). Floor the backoff at the 15 s gate and,
|
|
@@ -3213,7 +3233,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3213
3233
|
elif _t - _disc_since[0] >= _DISC_DEBOUNCE:
|
|
3214
3234
|
return True
|
|
3215
3235
|
else:
|
|
3216
|
-
_disc_since[0] = None # connected/connecting
|
|
3236
|
+
_disc_since[0] = None # connected/connecting -> reset debounce
|
|
3217
3237
|
return False
|
|
3218
3238
|
|
|
3219
3239
|
for _ in range(40): # ~12s for the receiver/track(s) to exist
|
|
@@ -3416,7 +3436,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3416
3436
|
_LOGGER.warning("DTLS serve: ffmpeg launch failed: %s", exc)
|
|
3417
3437
|
return None
|
|
3418
3438
|
|
|
3419
|
-
#
|
|
3439
|
+
# -- PTZ physical pan/tilt (A001064) ----------------------------------- #
|
|
3420
3440
|
# Uses IOCtrl cmd=4097 (IOTYPE_USER_IPCAM_PTZ_COMMAND) - NOT MQTT.
|
|
3421
3441
|
# DTLS path: sent via WebRTC DataChannel. SDES path: sent via encrypted
|
|
3422
3442
|
# SCTP cmd_chan. Requires an active stream session (_stream_session).
|
|
@@ -3553,11 +3573,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3553
3573
|
# Returns a running TutkStreamSession, or None on failure.
|
|
3554
3574
|
#
|
|
3555
3575
|
# Protocol: TUTK IOTC P2P (confirmed from classes.jar.decompiled.zip).
|
|
3556
|
-
# p2pId (TUTK UID)
|
|
3557
|
-
# IOTC_Connect_ByUID_Parallel(uid)
|
|
3558
|
-
# avClientStart2(nSID, "admin", "admin123")
|
|
3559
|
-
# avSendIOCtrl(avIndex, 511, ...)
|
|
3560
|
-
# avRecvFrameData2(avIndex, ...)
|
|
3576
|
+
# p2pId (TUTK UID) <- POST /v21/devices/batchGetDeviceUserInfo
|
|
3577
|
+
# IOTC_Connect_ByUID_Parallel(uid) -> nSID
|
|
3578
|
+
# avClientStart2(nSID, "admin", "admin123") -> avIndex
|
|
3579
|
+
# avSendIOCtrl(avIndex, 511, ...) -> start video stream
|
|
3580
|
+
# avRecvFrameData2(avIndex, ...) -> frame loop
|
|
3561
3581
|
#
|
|
3562
3582
|
# Requires libIOTCAPIs.so + libAVAPIs.so from the TUTK SDK.
|
|
3563
3583
|
|
|
@@ -19,7 +19,7 @@ class _CameraControlsMixin:
|
|
|
19
19
|
|
|
20
20
|
async def async_set_motion_detection(self, enabled: bool) -> bool:
|
|
21
21
|
"""Enable or disable the camera's motion detection."""
|
|
22
|
-
# MotionDetection_Enable is read as getString() in IpcServiceImpl
|
|
22
|
+
# MotionDetection_Enable is read as getString() in IpcServiceImpl -> use str
|
|
23
23
|
return await self.async_set_device_attribute(
|
|
24
24
|
"MotionDetection_Enable", "1" if enabled else "0")
|
|
25
25
|
|
|
@@ -108,7 +108,7 @@ class _CameraControlsMixin:
|
|
|
108
108
|
speed: 0-255, default 4 (matches app default)
|
|
109
109
|
preset: preset slot for "goto" command
|
|
110
110
|
|
|
111
|
-
Source: a.java d1() / f0.java A2()
|
|
111
|
+
Source: a.java d1() / f0.java A2() -> avSendIOCtrl(4097, 8B payload)
|
|
112
112
|
Payload: [direction_code, speed, preset, 0, 0, 0, 0, 0]
|
|
113
113
|
|
|
114
114
|
Direction codes (AVIOCTRLDEFs.java):
|
|
@@ -128,7 +128,7 @@ class _CameraControlsMixin:
|
|
|
128
128
|
return False
|
|
129
129
|
ok = session._avio_cmd(4097, payload)
|
|
130
130
|
_LOGGER.debug(
|
|
131
|
-
"PTZ %s (code=%d speed=%d preset=%d)
|
|
131
|
+
"PTZ %s (code=%d speed=%d preset=%d) -> %s",
|
|
132
132
|
direction, code, speed, preset, "sent" if ok else "no channel yet",
|
|
133
133
|
)
|
|
134
134
|
return ok
|
|
@@ -144,7 +144,7 @@ class _CameraControlsMixin:
|
|
|
144
144
|
mirroring the official app's HD/SD toggle. Rides the active stream
|
|
145
145
|
session (SETSTREAMCTRL=800), so the camera must be streaming.
|
|
146
146
|
|
|
147
|
-
Source: f0.java g3()
|
|
147
|
+
Source: f0.java g3() -> X2(800, SetStreamCtrlReq.parseContent(0, quality)).
|
|
148
148
|
Payload <IB3x> = channel(0) + quality byte + 3 reserved.
|
|
149
149
|
"""
|
|
150
150
|
q = _STREAM_QUALITY.get(quality.lower())
|
|
@@ -158,7 +158,7 @@ class _CameraControlsMixin:
|
|
|
158
158
|
return False
|
|
159
159
|
ok = session._avio_cmd(SETSTREAMCTRL_CMD, payload)
|
|
160
160
|
_LOGGER.debug(
|
|
161
|
-
"set resolution %s (quality=%d)
|
|
161
|
+
"set resolution %s (quality=%d) -> %s",
|
|
162
162
|
quality, q, "sent" if ok else "no channel yet",
|
|
163
163
|
)
|
|
164
164
|
return ok
|
|
@@ -11,7 +11,7 @@ Design constraints (validated against LK.IPC.A000088 firmware):
|
|
|
11
11
|
AES-ECB-encrypted JSON body, keyed by the device's 16-char ``aesKey``.
|
|
12
12
|
* It is **single-session**: a second ``loginReq`` evicts the first. So this client
|
|
13
13
|
never holds a socket open - every operation is a short-lived
|
|
14
|
-
*connect
|
|
14
|
+
*connect -> login -> command(s) -> close*, serialized by a per-camera lock.
|
|
15
15
|
* The camera acks changes with ``setDevAttrResp`` but emits no ``setDevAttrNotif``,
|
|
16
16
|
so there is no push; status is obtained by polling :meth:`async_get_attributes`.
|
|
17
17
|
* Only cameras that advertise ``localCtrFlag == 1`` on unicast discovery AND are
|
|
@@ -59,6 +59,11 @@ ATTR_KEYS = {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
# Control-channel bodies are small JSON; reject anything wildly larger than that
|
|
63
|
+
# so a hostile/broken LAN peer can't drive memory exhaustion via the size field.
|
|
64
|
+
_MAX_FRAME_BODY = 1024 * 1024 # 1 MiB
|
|
65
|
+
|
|
66
|
+
|
|
62
67
|
def _pack(msgtype: int, body: bytes) -> bytes:
|
|
63
68
|
return struct.pack(">Hhi", _MAGIC, msgtype, len(body)) + body
|
|
64
69
|
|
|
@@ -69,6 +74,12 @@ async def _read_frame(reader: asyncio.StreamReader, timeout: float) -> bytes:
|
|
|
69
74
|
The caller decrypts and parses the JSON (the AES key lives on the client)."""
|
|
70
75
|
header = await asyncio.wait_for(reader.readexactly(8), timeout)
|
|
71
76
|
_magic, _mtype, bodysize = struct.unpack(">HHI", header)
|
|
77
|
+
# Cap the server-supplied body size: `bodysize` is an unsigned 32-bit field
|
|
78
|
+
# (up to ~4 GiB) from a LAN host, and control-channel bodies are small JSON.
|
|
79
|
+
# Reject an implausibly large frame instead of allocating on a malicious or
|
|
80
|
+
# malfunctioning peer (mirrors the 4 MiB cap in playback._read_frame).
|
|
81
|
+
if bodysize > _MAX_FRAME_BODY:
|
|
82
|
+
raise ValueError(f"LAN control frame body too large: {bodysize} bytes")
|
|
72
83
|
body = await asyncio.wait_for(reader.readexactly(bodysize), timeout)
|
|
73
84
|
return body # caller decrypts (key is on the client)
|
|
74
85
|
|
|
@@ -78,7 +89,7 @@ async def discover_unicast(ip: str, timeout: float = 2.0) -> Optional[dict]:
|
|
|
78
89
|
|
|
79
90
|
Cameras ignore the broadcast sweep but answer a unicast probe. Returns the
|
|
80
91
|
``payload`` dict (``devId``, ``mac``, ``productModel``, ``lanMode``,
|
|
81
|
-
``localCtrFlag``
|
|
92
|
+
``localCtrFlag`` ...) or ``None`` if nothing answered.
|
|
82
93
|
"""
|
|
83
94
|
msg = {
|
|
84
95
|
"protocolVer": "2.0.0",
|
|
@@ -30,6 +30,24 @@ from .constants import (
|
|
|
30
30
|
_LOGGER = logging.getLogger(__name__)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _as_int(v: Any) -> Optional[int]:
|
|
34
|
+
"""Coerce a cloud-supplied attr value to int, or None if not numeric.
|
|
35
|
+
|
|
36
|
+
Cloud/device attributes are untrusted: a non-numeric value must be skipped,
|
|
37
|
+
not raise ValueError/TypeError out of the status-refresh path.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
return int(v)
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _as_bool(v: Any) -> Optional[bool]:
|
|
46
|
+
"""Coerce a 0/1 (int or numeric string) attr to bool, or None if not numeric."""
|
|
47
|
+
i = _as_int(v)
|
|
48
|
+
return None if i is None else bool(i)
|
|
49
|
+
|
|
50
|
+
|
|
33
51
|
class CameraStatusData(DeviceStatusData):
|
|
34
52
|
"""Core status plus camera fields; accepts both typed-model and dict updates."""
|
|
35
53
|
|
|
@@ -74,44 +92,41 @@ class CameraStatusData(DeviceStatusData):
|
|
|
74
92
|
# feeders either strip them (update_from_camera_attributes) or have
|
|
75
93
|
# already applied them via the model (receive_data raw-dict pass).
|
|
76
94
|
# Camera attributes
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
# Every conversion goes through _as_int/_as_bool so a malformed value
|
|
96
|
+
# from the cloud (e.g. "on" where 0/1 is expected) is skipped rather than
|
|
97
|
+
# raising out of update() and aborting the whole status refresh.
|
|
98
|
+
if (b := _as_bool(attr.get("MotionDetection_Enable"))) is not None:
|
|
99
|
+
self.motion_detection = b
|
|
100
|
+
if (i := _as_int(attr.get("MotionDetection_Sen"))) is not None:
|
|
101
|
+
self.motion_sensitivity = i
|
|
102
|
+
if (b := _as_bool(attr.get("LedOnOff"))) is not None:
|
|
103
|
+
self.status_led = b
|
|
104
|
+
if (b := _as_bool(attr.get("micEnable"))) is not None:
|
|
105
|
+
self.microphone = b
|
|
85
106
|
if (v := attr.get("nightVisionMode")) is not None:
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
nv = _as_int(v)
|
|
108
|
+
if nv is not None:
|
|
88
109
|
self.night_vision_mode = {0: "auto", 1: "on", 2: "off"}.get(nv, str(nv))
|
|
89
|
-
|
|
110
|
+
else:
|
|
90
111
|
# Camera may send string "on"/"off"/"auto" instead of 0/1/2
|
|
91
112
|
self.night_vision_mode = str(v)
|
|
92
|
-
if (
|
|
93
|
-
self.ir_light =
|
|
94
|
-
if (
|
|
95
|
-
self.floodlight =
|
|
96
|
-
if (
|
|
97
|
-
self.ptz_tracking =
|
|
98
|
-
if (
|
|
99
|
-
self.speaker_volume =
|
|
113
|
+
if (b := _as_bool(attr.get("nightVisionIRLight"))) is not None:
|
|
114
|
+
self.ir_light = b
|
|
115
|
+
if (b := _as_bool(attr.get("LightOnOff"))) is not None:
|
|
116
|
+
self.floodlight = b
|
|
117
|
+
if (b := _as_bool(attr.get("trackingMode"))) is not None:
|
|
118
|
+
self.ptz_tracking = b
|
|
119
|
+
if (i := _as_int(attr.get("SoundLevel"))) is not None:
|
|
120
|
+
self.speaker_volume = i
|
|
100
121
|
# Diagnostic / read-only
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
_LOGGER.debug("swallowed exception in %s", 'update', exc_info=True)
|
|
106
|
-
if (v := attr.get("Occupancy")) is not None:
|
|
107
|
-
self.occupancy = bool(int(v))
|
|
122
|
+
if (i := _as_int(attr.get("Battery_remaining"))) is not None:
|
|
123
|
+
self.battery_remaining = i
|
|
124
|
+
if (b := _as_bool(attr.get("Occupancy"))) is not None:
|
|
125
|
+
self.occupancy = b
|
|
108
126
|
if (v := attr.get("SDcardStatus")) is not None:
|
|
109
127
|
self.sd_card_status = str(v)
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
self.wifi_rssi = int(v)
|
|
113
|
-
except (ValueError, TypeError):
|
|
114
|
-
_LOGGER.debug("swallowed exception in %s", 'update', exc_info=True)
|
|
128
|
+
if (i := _as_int(attr.get("networkRssi"))) is not None:
|
|
129
|
+
self.wifi_rssi = i
|
|
115
130
|
|
|
116
131
|
# Cloud "properties" keys that belong to lights, not cameras. A camera's
|
|
117
132
|
# image "Dimming" must not be read as a light brightness (and could TypeError
|
|
@@ -123,7 +138,7 @@ class CameraStatusData(DeviceStatusData):
|
|
|
123
138
|
|
|
124
139
|
Accepts either a setDevAttrNotif ``attr`` dict or a cloud device
|
|
125
140
|
``properties`` dict - both share the same camera attribute keys
|
|
126
|
-
(Battery_remaining, Occupancy, SDcardStatus, MotionDetection_*,
|
|
141
|
+
(Battery_remaining, Occupancy, SDcardStatus, MotionDetection_*, ...).
|
|
127
142
|
"""
|
|
128
143
|
self.update({
|
|
129
144
|
k: v for k, v in attrs.items()
|
|
@@ -8,11 +8,13 @@ imports client.py -- the import edge is one-way (client -> protocol).
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
+
import hashlib
|
|
11
12
|
import ipaddress
|
|
12
13
|
import json
|
|
13
14
|
import logging
|
|
14
15
|
import os
|
|
15
16
|
import random
|
|
17
|
+
import re
|
|
16
18
|
import select
|
|
17
19
|
import socket
|
|
18
20
|
import struct
|
|
@@ -326,7 +328,7 @@ def _compress_sdp_for_camera(sdp: str) -> str:
|
|
|
326
328
|
|
|
327
329
|
|
|
328
330
|
def _make_talk_audio_track(pcm_provider: Callable[[], "Optional[bytes]"]):
|
|
329
|
-
"""Build an aiortc MediaStreamTrack that emits viewer
|
|
331
|
+
"""Build an aiortc MediaStreamTrack that emits viewer->camera talk audio.
|
|
330
332
|
|
|
331
333
|
Mirrors the official app (f0.java:695): a real audio track is present on the
|
|
332
334
|
PeerConnection from the start, so the camera sees a genuine sender. The
|
|
@@ -552,7 +554,14 @@ def _build_sprop(sps: bytes, pps: bytes) -> str:
|
|
|
552
554
|
|
|
553
555
|
|
|
554
556
|
def _sprop_cache_path(devid: str) -> str:
|
|
555
|
-
|
|
557
|
+
# `devid` originates from the cloud/device and is interpolated into a
|
|
558
|
+
# filesystem path, so a value containing "/", "\\", ".." or an absolute path
|
|
559
|
+
# could escape _SPROP_DIR (arbitrary read/write). Reduce it to a safe,
|
|
560
|
+
# collision-resistant filename component.
|
|
561
|
+
safe = re.sub(r"[^A-Za-z0-9_-]", "_", devid or "")
|
|
562
|
+
if not safe or len(devid or "") > 128:
|
|
563
|
+
safe = hashlib.sha256((devid or "").encode("utf-8", "replace")).hexdigest()
|
|
564
|
+
return os.path.join(_SPROP_DIR, f"{safe}.sprop")
|
|
556
565
|
|
|
557
566
|
|
|
558
567
|
def _load_sprop(devid: str) -> "Optional[str]":
|
|
@@ -1176,7 +1185,7 @@ def _mqtt_session_sync(
|
|
|
1176
1185
|
conn_ev = threading.Event()
|
|
1177
1186
|
status = {"connected": False, "rc": None, "rc_str": "", "error": None, "log": []}
|
|
1178
1187
|
|
|
1179
|
-
# Build client - handle paho
|
|
1188
|
+
# Build client - handle paho >=2.0 (VERSION2) and <2.0
|
|
1180
1189
|
try:
|
|
1181
1190
|
client = _paho.Client(
|
|
1182
1191
|
callback_api_version=_paho.CallbackAPIVersion.VERSION2,
|
|
@@ -1194,7 +1203,7 @@ def _mqtt_session_sync(
|
|
|
1194
1203
|
client.tls_set_context(ctx)
|
|
1195
1204
|
|
|
1196
1205
|
def _on_connect(c, ud, flags, reason_code, props=None):
|
|
1197
|
-
# paho
|
|
1206
|
+
# paho >=2 passes ReasonCode; paho <2 passes int
|
|
1198
1207
|
try:
|
|
1199
1208
|
rc = int(reason_code)
|
|
1200
1209
|
except (TypeError, ValueError):
|
|
@@ -1733,7 +1742,7 @@ def _upgrade_sctp(sdp: str) -> str:
|
|
|
1733
1742
|
RFC 8841 (and cameras / modern browsers) expect:
|
|
1734
1743
|
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
|
|
1735
1744
|
a=sctp-port:5000
|
|
1736
|
-
a=max-message-size:65536
|
|
1745
|
+
a=max-message-size:65536 <- required by RFC 8841 section 4.3.1
|
|
1737
1746
|
"""
|
|
1738
1747
|
import re as _re
|
|
1739
1748
|
out = []
|
|
@@ -1750,7 +1759,7 @@ def _upgrade_sctp(sdp: str) -> str:
|
|
|
1750
1759
|
def _normalize_bundle_ice_credentials(sdp: str) -> str:
|
|
1751
1760
|
"""Unify all m-section ICE credentials to match the BUNDLE master (mid:0).
|
|
1752
1761
|
|
|
1753
|
-
RFC 8843
|
|
1762
|
+
RFC 8843 section 7.1.3 requires all bundled m-sections to carry the same
|
|
1754
1763
|
ice-ufrag and ice-pwd. aiortc generates a separate ICETransport per
|
|
1755
1764
|
transceiver, giving each a unique credential pair. Cameras that
|
|
1756
1765
|
validate this requirement silently reject offers with mismatched
|