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.
Files changed (88) hide show
  1. python_aidot_cameras-0.10.2/MANIFEST.in +1 -0
  2. {python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.10.2}/PKG-INFO +9 -9
  3. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/README.md +8 -8
  4. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/client.py +52 -32
  5. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/controls.py +5 -5
  6. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/lan_control.py +13 -2
  7. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/models.py +47 -32
  8. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/protocol.py +15 -6
  9. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/sdes_open.py +46 -46
  10. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/webrtc.py +1 -1
  11. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/webrtc_open.py +135 -79
  12. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/client.py +16 -4
  13. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/const.py +1 -1
  14. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/credentials.py +19 -8
  15. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/device_client.py +7 -1
  16. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/pyproject.toml +5 -4
  17. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2/python_aidot_cameras.egg-info}/PKG-INFO +9 -9
  18. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/SOURCES.txt +38 -36
  19. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_backoff.py +1 -1
  20. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_device_login_guard.py +1 -1
  21. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_device_user_info_cache.py +1 -1
  22. python_aidot_cameras-0.10.2/tests/test_dtls_pinning.py +63 -0
  23. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_dtls_skip_signaling_wait.py +1 -1
  24. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_go2rtc.py +1 -1
  25. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_go2rtc_cli.py +1 -1
  26. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_ice_config_cache.py +1 -1
  27. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_keyframe_prompter.py +1 -1
  28. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_lan_control.py +1 -1
  29. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_live_stream_param.py +1 -1
  30. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_narrow_pc_ice.py +3 -3
  31. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_open_gate_delay.py +1 -1
  32. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_persistent_mqtt.py +1 -1
  33. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_persistent_mqtt_robustness.py +1 -1
  34. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_post_merge_hardening.py +1 -1
  35. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_retry_policy.py +1 -1
  36. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_adaptive.py +1 -1
  37. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_fast_liveplay.py +2 -2
  38. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_idle_release.py +4 -4
  39. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_serve_audio.py +1 -1
  40. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_serve_cmd.py +1 -1
  41. python_aidot_cameras-0.10.2/tests/test_security_hardening.py +67 -0
  42. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_serve_relay.py +1 -1
  43. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_session_stats.py +1 -1
  44. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_stream_idle.py +1 -1
  45. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_stream_teardown.py +1 -1
  46. python_aidot_cameras-0.10.0/MANIFEST.in +0 -1
  47. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/LICENSE +0 -0
  48. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/__init__.py +0 -0
  49. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/__main__.py +0 -0
  50. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/aes_utils.py +0 -0
  51. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/__init__.py +0 -0
  52. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/constants.py +0 -0
  53. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/go2rtc.py +0 -0
  54. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/playback.py +0 -0
  55. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/sdes.py +0 -0
  56. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/discover.py +0 -0
  58. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/exceptions.py +0 -0
  59. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/g711.py +0 -0
  60. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/login_const.py +0 -0
  61. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/__init__.py +0 -0
  62. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/device_client_model.py +0 -0
  63. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/device_model.py +0 -0
  64. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/models/discover_model.py +0 -0
  65. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/aidot/py.typed +0 -0
  66. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  67. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  68. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  69. {python_aidot_cameras-0.10.0/src → python_aidot_cameras-0.10.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  70. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/setup.cfg +0 -0
  71. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_aioice_compat.py +0 -0
  72. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_alarm_event.py +0 -0
  73. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_dtls_not_ready_burst.py +0 -0
  74. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_egress_guard.py +0 -0
  75. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_highport_nomination.py +0 -0
  76. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_motion_poll.py +0 -0
  77. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_no_undefined_names.py +0 -0
  78. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_playback_tls.py +0 -0
  79. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  80. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_sprop.py +0 -0
  81. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_talk.py +0 -0
  82. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_sdes_watchdog.py +0 -0
  83. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_speak.py +0 -0
  84. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_stream_cap.py +0 -0
  85. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_talk.py +0 -0
  86. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_terminal_ack.py +0 -0
  87. {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.2}/tests/test_token_refresh.py +0 -0
  88. {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.0
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) 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).
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, ) needed for live
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 [`src/aidot/credentials.py`](src/aidot/credentials.py).
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 off-subnet/strict-NAT viewers must leave it off. | unset (off) |
156
- | `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~23 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) |
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%→~19%); 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%->~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) 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).
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, ) needed for live
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 [`src/aidot/credentials.py`](src/aidot/credentials.py).
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 off-subnet/strict-NAT viewers must leave it off. | unset (off) |
128
- | `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~23 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) |
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%→~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
132
+ | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soak cut SDES NO_MEDIA ~57%->~19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
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) nSID
366
- # avClientStart2(nSID, "admin", "admin123", ...) avIndex
367
- # avSendIOCtrl(avIndex, 511, ...) start video (IOTYPE_USER_IPCAM_START)
368
- # avRecvFrameData2(avIndex, ...) frame data loop
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 LDSTCPManager
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_*, ). This
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, ) plus a top-level ``online`` flag. This is the reliable,
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=… (Leedarson smarthome)
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 %s seq=%s", attr, value, device_id, seq)
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 %s", action, params, device_id)
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
- # ── SDES path: stream briefly to a temp TS file, extract one JPEG ──── #
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
- _ffmpeg_snap = _sp.run(
1974
- ["ffmpeg", "-y", "-i", _tmp_ts,
1975
- "-frames:v", "1", "-f", "image2", output_path],
1976
- capture_output=True, timeout=15,
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
- if _ffmpeg_snap.returncode == 0 and _os.path.exists(output_path):
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
- _ffmpeg_snap.stderr.decode(errors="replace")[-200:],
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
- # ── DTLS path: on_frame callback delivers frames from aiortc ─────── #
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
- return _save_frame_as_jpeg(captured[0], output_path)
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 viewercamera (push-to-talk / announce) audio through the speaker.
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 a viewer is pulling; stay alive
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) don't release
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 sensible for mains cameras, which have no battery
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 reset debounce
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
- # ── PTZ physical pan/tilt (A001064) ─────────────────────────────────── #
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) POST /v21/devices/batchGetDeviceUserInfo
3557
- # IOTC_Connect_ByUID_Parallel(uid) nSID
3558
- # avClientStart2(nSID, "admin", "admin123") avIndex
3559
- # avSendIOCtrl(avIndex, 511, ...) start video stream
3560
- # avRecvFrameData2(avIndex, ...) frame loop
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 use str
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() avSendIOCtrl(4097, 8B payload)
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) %s",
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() X2(800, SetStreamCtrlReq.parseContent(0, quality)).
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) %s",
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 login command(s) close*, serialized by a per-camera lock.
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`` ) or ``None`` if nothing answered.
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
- if (v := attr.get("MotionDetection_Enable")) is not None:
78
- self.motion_detection = bool(int(v))
79
- if (v := attr.get("MotionDetection_Sen")) is not None:
80
- self.motion_sensitivity = int(v)
81
- if (v := attr.get("LedOnOff")) is not None:
82
- self.status_led = bool(int(v))
83
- if (v := attr.get("micEnable")) is not None:
84
- self.microphone = bool(int(v))
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
- try:
87
- nv = int(v)
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
- except (ValueError, TypeError):
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 (v := attr.get("nightVisionIRLight")) is not None:
93
- self.ir_light = bool(int(v))
94
- if (v := attr.get("LightOnOff")) is not None:
95
- self.floodlight = bool(int(v))
96
- if (v := attr.get("trackingMode")) is not None:
97
- self.ptz_tracking = bool(int(v))
98
- if (v := attr.get("SoundLevel")) is not None:
99
- self.speaker_volume = int(v)
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 (v := attr.get("Battery_remaining")) is not None:
102
- try:
103
- self.battery_remaining = int(v)
104
- except (ValueError, TypeError):
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 (v := attr.get("networkRssi")) is not None:
111
- try:
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 viewercamera talk audio.
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
- return os.path.join(_SPROP_DIR, f"{devid}.sprop")
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 2.0 (VERSION2) and <2.0
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 2 passes ReasonCode; paho <2 passes int
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 required by RFC 8841 §4.3.1
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 §7.1.3 requires all bundled m-sections to carry the same
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