python-aidot-cameras 0.9.1__tar.gz → 0.10.0__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.0/PKG-INFO +177 -0
- python_aidot_cameras-0.10.0/README.md +149 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/pyproject.toml +7 -1
- python_aidot_cameras-0.10.0/src/aidot/__main__.py +268 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/client.py +123 -21
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/lan_control.py +4 -2
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/playback.py +33 -4
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/protocol.py +83 -5
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/sdes_open.py +71 -40
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/tutk.py +1 -1
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/webrtc_open.py +236 -54
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/credentials.py +16 -1
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/device_client.py +2 -1
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/exceptions.py +9 -0
- python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info/PKG-INFO +177 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/SOURCES.txt +15 -1
- python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info/entry_points.txt +2 -0
- python_aidot_cameras-0.10.0/tests/test_device_user_info_cache.py +60 -0
- python_aidot_cameras-0.10.0/tests/test_dtls_not_ready_burst.py +65 -0
- python_aidot_cameras-0.10.0/tests/test_dtls_skip_signaling_wait.py +50 -0
- python_aidot_cameras-0.10.0/tests/test_egress_guard.py +50 -0
- python_aidot_cameras-0.10.0/tests/test_go2rtc_cli.py +55 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_highport_nomination.py +0 -14
- python_aidot_cameras-0.10.0/tests/test_keyframe_prompter.py +48 -0
- python_aidot_cameras-0.10.0/tests/test_narrow_pc_ice.py +48 -0
- python_aidot_cameras-0.10.0/tests/test_open_gate_delay.py +40 -0
- python_aidot_cameras-0.10.0/tests/test_playback_tls.py +23 -0
- python_aidot_cameras-0.10.0/tests/test_retry_policy.py +23 -0
- python_aidot_cameras-0.10.0/tests/test_sdes_echo_wait_timeout.py +20 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_serve_relay.py +22 -0
- python_aidot_cameras-0.10.0/tests/test_wait_or_event.py +52 -0
- python_aidot_cameras-0.9.1/PKG-INFO +0 -145
- python_aidot_cameras-0.9.1/README.md +0 -117
- python_aidot_cameras-0.9.1/src/python_aidot_cameras.egg-info/PKG-INFO +0 -145
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/LICENSE +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_token_refresh.py +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-aidot-cameras
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
|
+
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cbrightly/python-aidot-cameras
|
|
8
|
+
Project-URL: Issue Tracker, https://github.com/cbrightly/python-aidot-cameras/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: aiohttp>=3.9
|
|
15
|
+
Requires-Dist: paho-mqtt>=2.0
|
|
16
|
+
Requires-Dist: cryptography>=42.0
|
|
17
|
+
Requires-Dist: pycryptodome>=3.20
|
|
18
|
+
Requires-Dist: dacite>=1.8
|
|
19
|
+
Provides-Extra: webrtc
|
|
20
|
+
Requires-Dist: aiortc>=1.9.0; extra == "webrtc"
|
|
21
|
+
Requires-Dist: aioice<0.12,>=0.9.0; extra == "webrtc"
|
|
22
|
+
Requires-Dist: av; extra == "webrtc"
|
|
23
|
+
Requires-Dist: pylibsrtp; extra == "webrtc"
|
|
24
|
+
Requires-Dist: pyopenssl; extra == "webrtc"
|
|
25
|
+
Requires-Dist: numpy; extra == "webrtc"
|
|
26
|
+
Requires-Dist: Pillow; extra == "webrtc"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# python-aidot-cameras
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/python-aidot-cameras/)
|
|
32
|
+
[](https://pypi.org/project/python-aidot-cameras/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
Control AIDOT WiFi lights **and cameras** from Python.
|
|
36
|
+
|
|
37
|
+
This is a camera-capable fork of the upstream lights-only
|
|
38
|
+
[`python-aidot`](https://github.com/AiDot-Development-Team/python-AiDot). It adds
|
|
39
|
+
live WebRTC video streaming (DTLS and SDES-SRTP paths), snapshots, PTZ, camera
|
|
40
|
+
controls, cloud recordings/thumbnails, and two-way (push-to-talk) audio.
|
|
41
|
+
|
|
42
|
+
This repository is the **library** (distribution name `python-aidot-cameras`).
|
|
43
|
+
The Home Assistant custom component (`custom_components/aidot/`) lives in the
|
|
44
|
+
companion integration repo
|
|
45
|
+
[`cbrightly/hass-aidot-cameras`](https://github.com/cbrightly/hass-aidot-cameras), which depends
|
|
46
|
+
on this library.
|
|
47
|
+
|
|
48
|
+
## Supported cameras
|
|
49
|
+
|
|
50
|
+
The streaming transport is auto-selected per camera from its model id:
|
|
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).
|
|
55
|
+
|
|
56
|
+
Other battery models (A001108, A001360) are recognized in code with the same
|
|
57
|
+
battery handling. See [`docs/CAMERAS.md`](docs/CAMERAS.md#supported-cameras) for
|
|
58
|
+
the authoritative table and per-model notes.
|
|
59
|
+
|
|
60
|
+
## Library install
|
|
61
|
+
|
|
62
|
+
Install from PyPI (the simplest, recommended method):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# lights + camera cloud/control only:
|
|
66
|
+
pip install python-aidot-cameras
|
|
67
|
+
# add live WebRTC streaming, snapshots, and two-way audio:
|
|
68
|
+
pip install python-aidot-cameras[webrtc]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`[webrtc]` pulls in the extra dependencies (aiortc, av, …) needed for live
|
|
72
|
+
streaming, snapshots, and two-way audio. Without it you still get lights plus
|
|
73
|
+
the camera cloud/control APIs, but not live media.
|
|
74
|
+
|
|
75
|
+
For the latest unreleased code, install straight from the GitHub repo instead:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# lights + camera cloud/control only:
|
|
79
|
+
pip install "git+https://github.com/cbrightly/python-aidot-cameras"
|
|
80
|
+
# add live WebRTC streaming, snapshots, and two-way audio:
|
|
81
|
+
pip install "python-aidot-cameras[webrtc] @ git+https://github.com/cbrightly/python-aidot-cameras"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Standalone CLI: `aidot-go2rtc`
|
|
85
|
+
|
|
86
|
+
Bridge a camera into [go2rtc](https://github.com/AlexxIT/go2rtc) (or any
|
|
87
|
+
RTSP/HTTP consumer) **without Home Assistant**. Installing the package provides
|
|
88
|
+
the `aidot-go2rtc` console script; for an isolated tool install use pipx or uv:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pipx install "python-aidot-cameras[webrtc]"
|
|
92
|
+
# or:
|
|
93
|
+
uv tool install "python-aidot-cameras[webrtc]"
|
|
94
|
+
|
|
95
|
+
aidot-go2rtc --list # discover cameras + their transport
|
|
96
|
+
aidot-go2rtc <device_id> '{output}' # stream one camera (as a go2rtc exec: source)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
Open a live WebRTC stream from a camera device client:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
session = await device_client.async_open_webrtc_stream(on_frame=cb, timeout=30.0)
|
|
105
|
+
# ... session.stop() when done
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Two-way (push-to-talk) audio:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
session = await device_client.async_open_webrtc_stream(..., talk=True)
|
|
112
|
+
await session.async_start_talk(pcm_provider) # provider() -> 320B s16le PCM (20ms @ 8kHz), or None
|
|
113
|
+
# ... speak ...
|
|
114
|
+
await session.async_stop_talk()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
See [`docs/CAMERAS.md`](docs/CAMERAS.md) for the full camera API (streaming,
|
|
118
|
+
snapshots, recordings, motion polling, two-way audio, and LAN-direct media).
|
|
119
|
+
|
|
120
|
+
## Home Assistant component and CLI
|
|
121
|
+
|
|
122
|
+
The Home Assistant custom component (`custom_components/aidot/`) is **not** part
|
|
123
|
+
of this library repo - it lives in the companion integration repo
|
|
124
|
+
[`cbrightly/hass-aidot-cameras`](https://github.com/cbrightly/hass-aidot-cameras), which depends
|
|
125
|
+
on this library. See that repo for installing the component (via HACS or by
|
|
126
|
+
copying `custom_components/aidot/`).
|
|
127
|
+
|
|
128
|
+
## Environment variables
|
|
129
|
+
|
|
130
|
+
The library reads the following environment variables.
|
|
131
|
+
|
|
132
|
+
### Credentials
|
|
133
|
+
|
|
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).
|
|
136
|
+
|
|
137
|
+
| Variable | Purpose | Default |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| `AIDOT_USERNAME` | AiDot account username/email. Used with `AIDOT_PASSWORD`. | (none) |
|
|
140
|
+
| `AIDOT_PASSWORD` | AiDot account password. Used with `AIDOT_USERNAME`. | (none) |
|
|
141
|
+
| `AIDOT_COUNTRY` | Account region/country code. | `US` |
|
|
142
|
+
|
|
143
|
+
### Camera streaming / tuning
|
|
144
|
+
|
|
145
|
+
The most useful knobs read by the camera client (`aidot.camera.client`). Defaults
|
|
146
|
+
are chosen to work out of the box; override only when tuning. Finer-grained
|
|
147
|
+
internal knobs (audio normalization, keyframe/PLI cadence, retry timing, SDES
|
|
148
|
+
audio, idle release, the sprop cache path) are documented in
|
|
149
|
+
[`docs/CAMERAS.md`](docs/CAMERAS.md#advanced-tuning-environment-variables).
|
|
150
|
+
|
|
151
|
+
| Variable | Purpose | Default |
|
|
152
|
+
| --- | --- | --- |
|
|
153
|
+
| `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently across all cameras. | `2` |
|
|
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 (~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
|
+
| `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
|
+
| `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
|
+
| `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) |
|
|
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
|
+
| `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
|
+
|
|
164
|
+
### Security hardening
|
|
165
|
+
|
|
166
|
+
Opt-in knobs that tighten the camera transport. Defaults preserve current
|
|
167
|
+
behavior (the firmware's signaling doesn't carry verifiable material, so strict
|
|
168
|
+
modes are off until you pin a value); each emits a one-time warning when left at
|
|
169
|
+
the permissive default.
|
|
170
|
+
|
|
171
|
+
| Variable | Purpose | Default |
|
|
172
|
+
| --- | --- | --- |
|
|
173
|
+
| `AIDOT_DTLS_PINNED_FP` | Pin the camera's DTLS certificate `sha-256` fingerprint (colon-separated hex). When set, a camera presenting a different cert fails the handshake instead of being accepted. The camera echoes our own fingerprint over signaling, so without a pin the media channel is **not** authenticated against an on-path MITM. | unset (accept-any + warn) |
|
|
174
|
+
| `AIDOT_PLAYBACK_TLS_VERIFY` | Set to `1` to require full certificate + hostname verification on the TCP playback/live-stream TLS connection. Needs a trust anchor the camera's cert chains to; off by default because the camera presents a self-signed cert. | unset (no verification + warn) |
|
|
175
|
+
| `AIDOT_ALLOW_LAN_SERVE` | Silences the warning emitted when decrypted media is served on a non-loopback bind (e.g. `0.0.0.0`), where any host on the LAN can read the unencrypted stream. Set when an exposed bind is intentional. | unset (warn on non-loopback) |
|
|
176
|
+
| `AIDOT_SDES_HOLEPUNCH_HOST` | Override the NAT hole-punch target used when the cloud supplies no TURN entry. By default a STUN packet goes to a hardcoded vendor TURN host; set this to a host of your choice, or empty (`AIDOT_SDES_HOLEPUNCH_HOST=`) to disable the hardcoded fallback entirely. | unset (hardcoded vendor host + warn) |
|
|
177
|
+
| `AIDOT_CRED_KEY_FILE` | Path to the Fernet key file for stored credentials. Point it outside the config dir (ideally a separate secret store) so the key isn't co-located with the ciphertext. Applies to the default credentials path only (ignored when an explicit `creds_path` is passed). | `$XDG_CONFIG_HOME/aidot/.key` (falls back to `~/.config/aidot/.key`) |
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# python-aidot-cameras
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/python-aidot-cameras/)
|
|
4
|
+
[](https://pypi.org/project/python-aidot-cameras/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Control AIDOT WiFi lights **and cameras** from Python.
|
|
8
|
+
|
|
9
|
+
This is a camera-capable fork of the upstream lights-only
|
|
10
|
+
[`python-aidot`](https://github.com/AiDot-Development-Team/python-AiDot). It adds
|
|
11
|
+
live WebRTC video streaming (DTLS and SDES-SRTP paths), snapshots, PTZ, camera
|
|
12
|
+
controls, cloud recordings/thumbnails, and two-way (push-to-talk) audio.
|
|
13
|
+
|
|
14
|
+
This repository is the **library** (distribution name `python-aidot-cameras`).
|
|
15
|
+
The Home Assistant custom component (`custom_components/aidot/`) lives in the
|
|
16
|
+
companion integration repo
|
|
17
|
+
[`cbrightly/hass-aidot-cameras`](https://github.com/cbrightly/hass-aidot-cameras), which depends
|
|
18
|
+
on this library.
|
|
19
|
+
|
|
20
|
+
## Supported cameras
|
|
21
|
+
|
|
22
|
+
The streaming transport is auto-selected per camera from its model id:
|
|
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).
|
|
27
|
+
|
|
28
|
+
Other battery models (A001108, A001360) are recognized in code with the same
|
|
29
|
+
battery handling. See [`docs/CAMERAS.md`](docs/CAMERAS.md#supported-cameras) for
|
|
30
|
+
the authoritative table and per-model notes.
|
|
31
|
+
|
|
32
|
+
## Library install
|
|
33
|
+
|
|
34
|
+
Install from PyPI (the simplest, recommended method):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# lights + camera cloud/control only:
|
|
38
|
+
pip install python-aidot-cameras
|
|
39
|
+
# add live WebRTC streaming, snapshots, and two-way audio:
|
|
40
|
+
pip install python-aidot-cameras[webrtc]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`[webrtc]` pulls in the extra dependencies (aiortc, av, …) needed for live
|
|
44
|
+
streaming, snapshots, and two-way audio. Without it you still get lights plus
|
|
45
|
+
the camera cloud/control APIs, but not live media.
|
|
46
|
+
|
|
47
|
+
For the latest unreleased code, install straight from the GitHub repo instead:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# lights + camera cloud/control only:
|
|
51
|
+
pip install "git+https://github.com/cbrightly/python-aidot-cameras"
|
|
52
|
+
# add live WebRTC streaming, snapshots, and two-way audio:
|
|
53
|
+
pip install "python-aidot-cameras[webrtc] @ git+https://github.com/cbrightly/python-aidot-cameras"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Standalone CLI: `aidot-go2rtc`
|
|
57
|
+
|
|
58
|
+
Bridge a camera into [go2rtc](https://github.com/AlexxIT/go2rtc) (or any
|
|
59
|
+
RTSP/HTTP consumer) **without Home Assistant**. Installing the package provides
|
|
60
|
+
the `aidot-go2rtc` console script; for an isolated tool install use pipx or uv:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pipx install "python-aidot-cameras[webrtc]"
|
|
64
|
+
# or:
|
|
65
|
+
uv tool install "python-aidot-cameras[webrtc]"
|
|
66
|
+
|
|
67
|
+
aidot-go2rtc --list # discover cameras + their transport
|
|
68
|
+
aidot-go2rtc <device_id> '{output}' # stream one camera (as a go2rtc exec: source)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Open a live WebRTC stream from a camera device client:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
session = await device_client.async_open_webrtc_stream(on_frame=cb, timeout=30.0)
|
|
77
|
+
# ... session.stop() when done
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Two-way (push-to-talk) audio:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
session = await device_client.async_open_webrtc_stream(..., talk=True)
|
|
84
|
+
await session.async_start_talk(pcm_provider) # provider() -> 320B s16le PCM (20ms @ 8kHz), or None
|
|
85
|
+
# ... speak ...
|
|
86
|
+
await session.async_stop_talk()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See [`docs/CAMERAS.md`](docs/CAMERAS.md) for the full camera API (streaming,
|
|
90
|
+
snapshots, recordings, motion polling, two-way audio, and LAN-direct media).
|
|
91
|
+
|
|
92
|
+
## Home Assistant component and CLI
|
|
93
|
+
|
|
94
|
+
The Home Assistant custom component (`custom_components/aidot/`) is **not** part
|
|
95
|
+
of this library repo - it lives in the companion integration repo
|
|
96
|
+
[`cbrightly/hass-aidot-cameras`](https://github.com/cbrightly/hass-aidot-cameras), which depends
|
|
97
|
+
on this library. See that repo for installing the component (via HACS or by
|
|
98
|
+
copying `custom_components/aidot/`).
|
|
99
|
+
|
|
100
|
+
## Environment variables
|
|
101
|
+
|
|
102
|
+
The library reads the following environment variables.
|
|
103
|
+
|
|
104
|
+
### Credentials
|
|
105
|
+
|
|
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).
|
|
108
|
+
|
|
109
|
+
| Variable | Purpose | Default |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `AIDOT_USERNAME` | AiDot account username/email. Used with `AIDOT_PASSWORD`. | (none) |
|
|
112
|
+
| `AIDOT_PASSWORD` | AiDot account password. Used with `AIDOT_USERNAME`. | (none) |
|
|
113
|
+
| `AIDOT_COUNTRY` | Account region/country code. | `US` |
|
|
114
|
+
|
|
115
|
+
### Camera streaming / tuning
|
|
116
|
+
|
|
117
|
+
The most useful knobs read by the camera client (`aidot.camera.client`). Defaults
|
|
118
|
+
are chosen to work out of the box; override only when tuning. Finer-grained
|
|
119
|
+
internal knobs (audio normalization, keyframe/PLI cadence, retry timing, SDES
|
|
120
|
+
audio, idle release, the sprop cache path) are documented in
|
|
121
|
+
[`docs/CAMERAS.md`](docs/CAMERAS.md#advanced-tuning-environment-variables).
|
|
122
|
+
|
|
123
|
+
| Variable | Purpose | Default |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently across all cameras. | `2` |
|
|
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 (~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
|
+
| `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
|
+
| `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
|
+
| `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) |
|
|
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
|
+
| `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
|
+
|
|
136
|
+
### Security hardening
|
|
137
|
+
|
|
138
|
+
Opt-in knobs that tighten the camera transport. Defaults preserve current
|
|
139
|
+
behavior (the firmware's signaling doesn't carry verifiable material, so strict
|
|
140
|
+
modes are off until you pin a value); each emits a one-time warning when left at
|
|
141
|
+
the permissive default.
|
|
142
|
+
|
|
143
|
+
| Variable | Purpose | Default |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `AIDOT_DTLS_PINNED_FP` | Pin the camera's DTLS certificate `sha-256` fingerprint (colon-separated hex). When set, a camera presenting a different cert fails the handshake instead of being accepted. The camera echoes our own fingerprint over signaling, so without a pin the media channel is **not** authenticated against an on-path MITM. | unset (accept-any + warn) |
|
|
146
|
+
| `AIDOT_PLAYBACK_TLS_VERIFY` | Set to `1` to require full certificate + hostname verification on the TCP playback/live-stream TLS connection. Needs a trust anchor the camera's cert chains to; off by default because the camera presents a self-signed cert. | unset (no verification + warn) |
|
|
147
|
+
| `AIDOT_ALLOW_LAN_SERVE` | Silences the warning emitted when decrypted media is served on a non-loopback bind (e.g. `0.0.0.0`), where any host on the LAN can read the unencrypted stream. Set when an exposed bind is intentional. | unset (warn on non-loopback) |
|
|
148
|
+
| `AIDOT_SDES_HOLEPUNCH_HOST` | Override the NAT hole-punch target used when the cloud supplies no TURN entry. By default a STUN packet goes to a hardcoded vendor TURN host; set this to a host of your choice, or empty (`AIDOT_SDES_HOLEPUNCH_HOST=`) to disable the hardcoded fallback entirely. | unset (hardcoded vendor host + warn) |
|
|
149
|
+
| `AIDOT_CRED_KEY_FILE` | Path to the Fernet key file for stored credentials. Point it outside the config dir (ideally a separate secret store) so the key isn't co-located with the ciphertext. Applies to the default credentials path only (ignored when an explicit `creds_path` is passed). | `$XDG_CONFIG_HOME/aidot/.key` (falls back to `~/.config/aidot/.key`) |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
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"
|
|
@@ -45,6 +45,12 @@ webrtc = [
|
|
|
45
45
|
"Pillow",
|
|
46
46
|
]
|
|
47
47
|
|
|
48
|
+
[project.scripts]
|
|
49
|
+
# Standalone bridge: stream an AiDot camera into go2rtc (or any RTSP/HTTP
|
|
50
|
+
# consumer) without Home Assistant. Streaming needs the `webrtc` extra
|
|
51
|
+
# (aiortc et al.); `--list` works with the core deps alone.
|
|
52
|
+
aidot-go2rtc = "aidot.__main__:main"
|
|
53
|
+
|
|
48
54
|
[project.urls]
|
|
49
55
|
Homepage = "https://github.com/cbrightly/python-aidot-cameras"
|
|
50
56
|
"Issue Tracker" = "https://github.com/cbrightly/python-aidot-cameras/issues"
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Bridge an AiDot camera into go2rtc (or any RTSP/HTTP consumer), without HA.
|
|
2
|
+
|
|
3
|
+
AiDot cameras expose no RTSP/ONVIF endpoint - the only way to get video is the
|
|
4
|
+
cloud signaling + WebRTC (DTLS/SDES) handshake and SRTP decrypt this library
|
|
5
|
+
does via aiortc. This entrypoint is the thin glue that lets a standalone go2rtc
|
|
6
|
+
use them: it authenticates to the AiDot cloud, opens the keepalive session for
|
|
7
|
+
one camera, and pushes/serves the decrypted H.264 (+ G711 audio on the RTSP-push
|
|
8
|
+
path) to a URL the consumer reads.
|
|
9
|
+
|
|
10
|
+
Run it as ``aidot-go2rtc`` (console script) or ``python -m aidot``.
|
|
11
|
+
|
|
12
|
+
Two modes:
|
|
13
|
+
|
|
14
|
+
* --list Print every camera on the account with its device id
|
|
15
|
+
and transport (SDES = RTSP-push capable, DTLS =
|
|
16
|
+
HTTP-pull only). Use this to fill in go2rtc.yaml.
|
|
17
|
+
|
|
18
|
+
* <dev_id> <output_url> Stream one camera. Meant to be invoked by go2rtc as
|
|
19
|
+
an ``exec:`` source - go2rtc substitutes ``{output}``
|
|
20
|
+
with its own publish URL.
|
|
21
|
+
|
|
22
|
+
Authentication (in precedence order):
|
|
23
|
+
|
|
24
|
+
AIDOT_TOKEN_FILE=/path/to/token.json A stored login_info dict (the same one
|
|
25
|
+
the HA integration persists). Token
|
|
26
|
+
rotations are written back to this file
|
|
27
|
+
(set_token_fresh_cb), so a standalone
|
|
28
|
+
run survives refreshes and restarts.
|
|
29
|
+
AIDOT_USERNAME / AIDOT_PASSWORD / AIDOT_COUNTRY (default US)
|
|
30
|
+
Full login. RECOMMENDED for long-running
|
|
31
|
+
standalone use: a dedicated login avoids
|
|
32
|
+
fighting HA over a shared (rotating)
|
|
33
|
+
refresh token. Pair with AIDOT_TOKEN_FILE
|
|
34
|
+
to cache the result across restarts.
|
|
35
|
+
|
|
36
|
+
Optional stream knobs:
|
|
37
|
+
|
|
38
|
+
AIDOT_FAST_CONNECT=1 LAN-direct mode (skip TURN relay; same-subnet only)
|
|
39
|
+
AIDOT_SDES_SERVE_AUDIO=1 include audio on the SDES *http-serve* path (off by
|
|
40
|
+
default - the AAC-under-loss deadlock). The RTSP-push
|
|
41
|
+
path always carries G711 audio regardless.
|
|
42
|
+
|
|
43
|
+
go2rtc.yaml example (SDES camera - native RTSP push, video + audio):
|
|
44
|
+
|
|
45
|
+
streams:
|
|
46
|
+
frontdoor: exec:aidot-go2rtc 8d2521ea... {output}
|
|
47
|
+
|
|
48
|
+
For a DTLS camera (A000088) there is no RTSP-push path; pass an http serve URL
|
|
49
|
+
(http://127.0.0.1:PORT/name.ts) and add it to go2rtc as an http-mpegts source -
|
|
50
|
+
but this process must stay running to keep that serve bound.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import argparse
|
|
56
|
+
import asyncio
|
|
57
|
+
import json
|
|
58
|
+
import logging
|
|
59
|
+
import os
|
|
60
|
+
import signal
|
|
61
|
+
import sys
|
|
62
|
+
|
|
63
|
+
import aiohttp
|
|
64
|
+
|
|
65
|
+
from .client import AidotClient
|
|
66
|
+
from .const import CONF_DEVICE_LIST, CONF_ID, CONF_MODEL_ID
|
|
67
|
+
|
|
68
|
+
_LOGGER = logging.getLogger("aidot.go2rtc")
|
|
69
|
+
|
|
70
|
+
DEFAULT_COUNTRY = os.environ.get("AIDOT_COUNTRY", "US")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _env_bool(name: str) -> bool | None:
|
|
74
|
+
"""Tri-state env flag: '1' -> True, '0' -> False, unset -> None (library default)."""
|
|
75
|
+
raw = os.environ.get(name)
|
|
76
|
+
if raw is None:
|
|
77
|
+
return None
|
|
78
|
+
return raw == "1"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_token_file(path: str) -> dict:
|
|
82
|
+
"""Blocking read of a stored login_info dict (run via executor from async)."""
|
|
83
|
+
with open(path, encoding="utf-8") as fh:
|
|
84
|
+
return json.load(fh)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_token_file(path: str, data: dict) -> None:
|
|
88
|
+
"""Blocking write of login_info to ``path`` with 0600 perms."""
|
|
89
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
90
|
+
json.dump(data, fh)
|
|
91
|
+
os.chmod(path, 0o600)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _install_token_cache(client: AidotClient, path: str) -> None:
|
|
95
|
+
"""Persist rotated tokens to ``path`` so refreshes survive across restarts.
|
|
96
|
+
|
|
97
|
+
The library calls this no-arg callback after every successful token refresh
|
|
98
|
+
(client.py), having already updated ``client.login_info``. Mirrors HA's
|
|
99
|
+
coordinator.token_fresh_cb, but writes to our own file instead of an HA
|
|
100
|
+
config entry - so a standalone run never loses auth to a rotation and never
|
|
101
|
+
fights HA over a shared refresh token.
|
|
102
|
+
"""
|
|
103
|
+
def _cb() -> None:
|
|
104
|
+
try:
|
|
105
|
+
_write_token_file(path, client.login_info)
|
|
106
|
+
_LOGGER.debug("Cached refreshed token to %s", path)
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
_LOGGER.warning("Could not cache refreshed token to %s: %s", path, exc)
|
|
109
|
+
|
|
110
|
+
client.set_token_fresh_cb(_cb)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _make_client(session: aiohttp.ClientSession) -> AidotClient:
|
|
114
|
+
"""Authenticate to the AiDot cloud from the environment.
|
|
115
|
+
|
|
116
|
+
Prefers a stored token (AIDOT_TOKEN_FILE) - the same login_info dict the HA
|
|
117
|
+
integration persists - which carries access/refresh tokens so no password
|
|
118
|
+
round-trip is needed. Falls back to username/password login otherwise. In
|
|
119
|
+
both cases, if AIDOT_TOKEN_FILE is set we register a write-back cache so
|
|
120
|
+
rotations persist.
|
|
121
|
+
"""
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
token_file = os.environ.get("AIDOT_TOKEN_FILE")
|
|
124
|
+
username = os.environ.get("AIDOT_USERNAME")
|
|
125
|
+
password = os.environ.get("AIDOT_PASSWORD")
|
|
126
|
+
|
|
127
|
+
if token_file and os.path.exists(token_file):
|
|
128
|
+
token = await loop.run_in_executor(None, _read_token_file, token_file)
|
|
129
|
+
client = AidotClient(session=session, token=token)
|
|
130
|
+
_install_token_cache(client, token_file)
|
|
131
|
+
return client
|
|
132
|
+
|
|
133
|
+
if not username or not password:
|
|
134
|
+
sys.exit(
|
|
135
|
+
"Set AIDOT_TOKEN_FILE, or AIDOT_USERNAME and AIDOT_PASSWORD, in the "
|
|
136
|
+
"environment."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
client = AidotClient(
|
|
140
|
+
session=session,
|
|
141
|
+
country_code=DEFAULT_COUNTRY,
|
|
142
|
+
username=username,
|
|
143
|
+
password=password,
|
|
144
|
+
)
|
|
145
|
+
await client.async_post_login()
|
|
146
|
+
# If a token file path was given (but didn't exist yet), seed it now and
|
|
147
|
+
# register the write-back cache so a dedicated-login run persists its
|
|
148
|
+
# rotations across restarts.
|
|
149
|
+
if token_file:
|
|
150
|
+
_install_token_cache(client, token_file)
|
|
151
|
+
try:
|
|
152
|
+
await loop.run_in_executor(None, _write_token_file, token_file, client.login_info)
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
_LOGGER.warning("Could not seed token cache %s: %s", token_file, exc)
|
|
155
|
+
return client
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _cameras(client: AidotClient) -> list[dict]:
|
|
159
|
+
"""Return the raw device dicts for every camera on the account."""
|
|
160
|
+
data = await client.async_get_all_device()
|
|
161
|
+
return [
|
|
162
|
+
d for d in data[CONF_DEVICE_LIST]
|
|
163
|
+
if "IPC" in (d.get(CONF_MODEL_ID) or "").upper()
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def cmd_list() -> int:
|
|
168
|
+
"""Print device id + transport for every camera, for go2rtc.yaml."""
|
|
169
|
+
async with aiohttp.ClientSession() as session:
|
|
170
|
+
client = await _make_client(session)
|
|
171
|
+
cams = await _cameras(client)
|
|
172
|
+
if not cams:
|
|
173
|
+
print("No IPC cameras found on this account.")
|
|
174
|
+
return 1
|
|
175
|
+
print(f"{'device_id':<40} {'model':<14} transport")
|
|
176
|
+
print("-" * 72)
|
|
177
|
+
for d in cams:
|
|
178
|
+
dc = client.get_device_client(d)
|
|
179
|
+
transport = "SDES (rtsp-push)" if dc.is_sdes_camera else "DTLS (http-pull)"
|
|
180
|
+
print(f"{d[CONF_ID]:<40} {(d.get(CONF_MODEL_ID) or ''):<14} {transport}")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def cmd_stream(dev_id: str, output_url: str) -> int:
|
|
185
|
+
"""Open one camera's keepalive and push/serve it to ``output_url``."""
|
|
186
|
+
async with aiohttp.ClientSession() as session:
|
|
187
|
+
client = await _make_client(session)
|
|
188
|
+
cams = await _cameras(client)
|
|
189
|
+
device = next((d for d in cams if d[CONF_ID] == dev_id), None)
|
|
190
|
+
if device is None:
|
|
191
|
+
sys.exit(f"Camera {dev_id!r} not found. Run with --list to see ids.")
|
|
192
|
+
|
|
193
|
+
dc = client.get_device_client(device)
|
|
194
|
+
|
|
195
|
+
if not output_url.startswith("rtsp") and dc.is_sdes_camera:
|
|
196
|
+
_LOGGER.warning(
|
|
197
|
+
"SDES camera but output %r is not rtsp:// - the library will "
|
|
198
|
+
"HTTP-serve (video only by default) instead of pushing audio+video. "
|
|
199
|
+
"For go2rtc exec, pass {output}.",
|
|
200
|
+
output_url,
|
|
201
|
+
)
|
|
202
|
+
if output_url.startswith("rtsp") and not dc.is_sdes_camera:
|
|
203
|
+
sys.exit(
|
|
204
|
+
f"{dev_id} is a DTLS camera - the library has no RTSP-push path "
|
|
205
|
+
"for it. Use an http://host:port/name.ts serve URL instead."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
await dc.async_login()
|
|
209
|
+
await dc.start_keepalive(
|
|
210
|
+
rtsp_push_url=output_url,
|
|
211
|
+
fast_connect=_env_bool("AIDOT_FAST_CONNECT"),
|
|
212
|
+
sdes_audio=_env_bool("AIDOT_SDES_SERVE_AUDIO"),
|
|
213
|
+
)
|
|
214
|
+
_LOGGER.info("Streaming %s -> %s", dev_id, output_url)
|
|
215
|
+
|
|
216
|
+
# start_keepalive only launches the background task; hold the process open
|
|
217
|
+
# so the consumer keeps reading. Exit cleanly on SIGTERM/SIGINT (go2rtc
|
|
218
|
+
# kills the exec child when no client is watching) so the cloud session is
|
|
219
|
+
# released.
|
|
220
|
+
stop = asyncio.Event()
|
|
221
|
+
loop = asyncio.get_running_loop()
|
|
222
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
223
|
+
try:
|
|
224
|
+
loop.add_signal_handler(sig, stop.set)
|
|
225
|
+
except NotImplementedError: # pragma: no cover - non-POSIX
|
|
226
|
+
pass
|
|
227
|
+
try:
|
|
228
|
+
await stop.wait()
|
|
229
|
+
finally:
|
|
230
|
+
_LOGGER.info("Stopping stream for %s", dev_id)
|
|
231
|
+
try:
|
|
232
|
+
await dc.async_stop_streaming()
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
_LOGGER.debug("stop_streaming failed: %s", exc)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main(argv: list[str] | None = None) -> int:
|
|
239
|
+
parser = argparse.ArgumentParser(
|
|
240
|
+
prog="aidot-go2rtc",
|
|
241
|
+
description="Bridge an AiDot camera into go2rtc (HA-independent).",
|
|
242
|
+
epilog="Run with --list first to discover device ids and transports.",
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument("dev_id", nargs="?", help="camera device id (see --list)")
|
|
245
|
+
parser.add_argument(
|
|
246
|
+
"output_url",
|
|
247
|
+
nargs="?",
|
|
248
|
+
help="consumer publish/serve URL; pass {output} from the exec: source",
|
|
249
|
+
)
|
|
250
|
+
parser.add_argument("--list", action="store_true", help="list cameras and exit")
|
|
251
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
|
252
|
+
args = parser.parse_args(argv)
|
|
253
|
+
|
|
254
|
+
logging.basicConfig(
|
|
255
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
256
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
257
|
+
stream=sys.stderr, # keep stdout clean - the consumer may read it
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if args.list:
|
|
261
|
+
return asyncio.run(cmd_list())
|
|
262
|
+
if not args.dev_id or not args.output_url:
|
|
263
|
+
parser.error("dev_id and output_url are required (or use --list)")
|
|
264
|
+
return asyncio.run(cmd_stream(args.dev_id, args.output_url))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
raise SystemExit(main())
|