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.
Files changed (88) hide show
  1. python_aidot_cameras-0.10.0/PKG-INFO +177 -0
  2. python_aidot_cameras-0.10.0/README.md +149 -0
  3. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/pyproject.toml +7 -1
  4. python_aidot_cameras-0.10.0/src/aidot/__main__.py +268 -0
  5. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/client.py +123 -21
  6. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/lan_control.py +4 -2
  7. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/playback.py +33 -4
  8. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/protocol.py +83 -5
  9. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/sdes_open.py +71 -40
  10. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/tutk.py +1 -1
  11. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/webrtc_open.py +236 -54
  12. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/credentials.py +16 -1
  13. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/device_client.py +2 -1
  14. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/exceptions.py +9 -0
  15. python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info/PKG-INFO +177 -0
  16. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/SOURCES.txt +15 -1
  17. python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info/entry_points.txt +2 -0
  18. python_aidot_cameras-0.10.0/tests/test_device_user_info_cache.py +60 -0
  19. python_aidot_cameras-0.10.0/tests/test_dtls_not_ready_burst.py +65 -0
  20. python_aidot_cameras-0.10.0/tests/test_dtls_skip_signaling_wait.py +50 -0
  21. python_aidot_cameras-0.10.0/tests/test_egress_guard.py +50 -0
  22. python_aidot_cameras-0.10.0/tests/test_go2rtc_cli.py +55 -0
  23. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_highport_nomination.py +0 -14
  24. python_aidot_cameras-0.10.0/tests/test_keyframe_prompter.py +48 -0
  25. python_aidot_cameras-0.10.0/tests/test_narrow_pc_ice.py +48 -0
  26. python_aidot_cameras-0.10.0/tests/test_open_gate_delay.py +40 -0
  27. python_aidot_cameras-0.10.0/tests/test_playback_tls.py +23 -0
  28. python_aidot_cameras-0.10.0/tests/test_retry_policy.py +23 -0
  29. python_aidot_cameras-0.10.0/tests/test_sdes_echo_wait_timeout.py +20 -0
  30. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_serve_relay.py +22 -0
  31. python_aidot_cameras-0.10.0/tests/test_wait_or_event.py +52 -0
  32. python_aidot_cameras-0.9.1/PKG-INFO +0 -145
  33. python_aidot_cameras-0.9.1/README.md +0 -117
  34. python_aidot_cameras-0.9.1/src/python_aidot_cameras.egg-info/PKG-INFO +0 -145
  35. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/LICENSE +0 -0
  36. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/MANIFEST.in +0 -0
  37. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/setup.cfg +0 -0
  38. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/__init__.py +0 -0
  39. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/aes_utils.py +0 -0
  40. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/__init__.py +0 -0
  41. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/constants.py +0 -0
  42. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/controls.py +0 -0
  43. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/go2rtc.py +0 -0
  44. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/models.py +0 -0
  45. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/sdes.py +0 -0
  46. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/camera/webrtc.py +0 -0
  47. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/client.py +0 -0
  48. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/const.py +0 -0
  49. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/discover.py +0 -0
  50. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/g711.py +0 -0
  51. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/login_const.py +0 -0
  52. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/__init__.py +0 -0
  53. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/device_client_model.py +0 -0
  54. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/device_model.py +0 -0
  55. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/models/discover_model.py +0 -0
  56. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/aidot/py.typed +0 -0
  57. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  58. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  59. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  60. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_aioice_compat.py +0 -0
  61. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_alarm_event.py +0 -0
  62. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_backoff.py +0 -0
  63. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_device_login_guard.py +0 -0
  64. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_go2rtc.py +0 -0
  65. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_ice_config_cache.py +0 -0
  66. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_lan_control.py +0 -0
  67. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_live_stream_param.py +0 -0
  68. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_motion_poll.py +0 -0
  69. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_no_undefined_names.py +0 -0
  70. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_persistent_mqtt.py +0 -0
  71. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_persistent_mqtt_robustness.py +0 -0
  72. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_post_merge_hardening.py +0 -0
  73. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_adaptive.py +0 -0
  74. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_fast_liveplay.py +0 -0
  75. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_idle_release.py +0 -0
  76. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_serve_audio.py +0 -0
  77. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_serve_cmd.py +0 -0
  78. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_sprop.py +0 -0
  79. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_talk.py +0 -0
  80. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_sdes_watchdog.py +0 -0
  81. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_session_stats.py +0 -0
  82. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_speak.py +0 -0
  83. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_stream_cap.py +0 -0
  84. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_stream_idle.py +0 -0
  85. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_stream_teardown.py +0 -0
  86. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_talk.py +0 -0
  87. {python_aidot_cameras-0.9.1 → python_aidot_cameras-0.10.0}/tests/test_terminal_ack.py +0 -0
  88. {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
+ [![PyPI version](https://img.shields.io/pypi/v/python-aidot-cameras)](https://pypi.org/project/python-aidot-cameras/)
32
+ [![Python versions](https://img.shields.io/pypi/pyversions/python-aidot-cameras)](https://pypi.org/project/python-aidot-cameras/)
33
+ [![License: MIT](https://img.shields.io/pypi/l/python-aidot-cameras)](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
+ [![PyPI version](https://img.shields.io/pypi/v/python-aidot-cameras)](https://pypi.org/project/python-aidot-cameras/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/python-aidot-cameras)](https://pypi.org/project/python-aidot-cameras/)
5
+ [![License: MIT](https://img.shields.io/pypi/l/python-aidot-cameras)](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.9.1"
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())