aiodahua 0.5.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.
@@ -0,0 +1,71 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ # Lets a branch be checked before it has a PR. The branch that became PR #1
8
+ # sat unformatted for a day because nothing ran on it.
9
+ workflow_dispatch:
10
+
11
+ concurrency:
12
+ group: ci-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ test:
20
+ runs-on: ubuntu-latest
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ python-version: ["3.11", "3.12", "3.13"]
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: astral-sh/setup-uv@v5
28
+ with:
29
+ enable-cache: true
30
+ - name: Set up Python ${{ matrix.python-version }}
31
+ run: uv python install ${{ matrix.python-version }}
32
+ - name: Install
33
+ run: uv sync --all-extras --dev
34
+ - name: Lint
35
+ run: uv run ruff check .
36
+ - name: Format check
37
+ run: uv run ruff format --check .
38
+ - name: Test
39
+ run: uv run pytest -q
40
+
41
+ package:
42
+ # Home Assistant installs this from PyPI, so a package that does not build
43
+ # or import cleanly from the wheel breaks the integration at startup rather
44
+ # than in CI. Checked here instead of at publish time.
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ - uses: astral-sh/setup-uv@v5
49
+ with:
50
+ enable-cache: true
51
+ - name: Build sdist and wheel
52
+ run: uv build
53
+ - name: Verify metadata
54
+ run: uvx twine check dist/*
55
+ - name: Install the wheel into a clean venv and import it
56
+ run: |
57
+ uv venv /tmp/wheeltest
58
+ uv pip install --python /tmp/wheeltest/bin/python dist/*.whl
59
+ /tmp/wheeltest/bin/python -c "
60
+ import aiodahua
61
+ from aiodahua import DahuaClient, identify_brand
62
+ print('version', aiodahua.__version__)
63
+ m = identify_brand(vendor='AC', version='4.000.00AC000.0')
64
+ assert str(m) == 'Amcrest', m
65
+ assert DahuaClient('h', 'u', 'p')._base == 'http://h:80'
66
+ print('wheel imports and works')
67
+ "
68
+ - uses: actions/upload-artifact@v4
69
+ with:
70
+ name: dist
71
+ path: dist/
@@ -0,0 +1,108 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI via trusted publishing (OIDC), so no API token is stored
4
+ # anywhere. See RELEASING.md for the one-time PyPI configuration this needs.
5
+ #
6
+ # The filename matters: PyPI matches it against the OIDC claim, so the trusted
7
+ # publisher must be registered with Workflow name `workflow.yml`. Renaming this
8
+ # file breaks publishing until the publisher is updated to match.
9
+ #
10
+ # Trigger by publishing a GitHub Release whose tag is v<version>, matching the
11
+ # version in pyproject.toml. The tag and the packaged version are compared
12
+ # before anything is uploaded, because a PyPI version can never be reused.
13
+
14
+ on:
15
+ release:
16
+ types: [published]
17
+ workflow_dispatch:
18
+ inputs:
19
+ repository:
20
+ description: Where to publish
21
+ required: true
22
+ default: testpypi
23
+ type: choice
24
+ options: [testpypi, pypi]
25
+
26
+ permissions: {}
27
+
28
+ jobs:
29
+ build:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: astral-sh/setup-uv@v5
34
+ with:
35
+ enable-cache: true
36
+
37
+ - name: Check the tag matches the packaged version
38
+ if: github.event_name == 'release'
39
+ run: |
40
+ version=$(uv run python -c "
41
+ import tomllib
42
+ print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])
43
+ ")
44
+ tag="${GITHUB_REF_NAME#v}"
45
+ echo "tag=$tag pyproject=$version"
46
+ if [ "$tag" != "$version" ]; then
47
+ echo "::error::Tag $GITHUB_REF_NAME does not match pyproject version $version." \
48
+ "A PyPI version cannot be reused, so this stops before uploading."
49
+ exit 1
50
+ fi
51
+
52
+ - name: Check this version is not already on PyPI
53
+ run: |
54
+ version=$(uv run python -c "
55
+ import tomllib
56
+ print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])
57
+ ")
58
+ code=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/aiodahua/$version/json")
59
+ if [ "$code" = "200" ]; then
60
+ echo "::error::aiodahua $version is already on PyPI. Bump the version."
61
+ exit 1
62
+ fi
63
+ echo "aiodahua $version is not published yet (HTTP $code)"
64
+
65
+ - name: Build
66
+ run: uv build
67
+
68
+ - name: Validate metadata
69
+ run: uvx twine check dist/*
70
+
71
+ - name: Install the wheel clean and import it
72
+ run: |
73
+ uv venv /tmp/wheeltest
74
+ uv pip install --python /tmp/wheeltest/bin/python dist/*.whl
75
+ /tmp/wheeltest/bin/python -c "
76
+ import aiodahua
77
+ from aiodahua import DahuaClient, identify_brand
78
+ assert str(identify_brand(vendor='AC', version='4.000.00AC000.0')) == 'Amcrest'
79
+ assert DahuaClient('h', 'u', 'p')._base == 'http://h:80'
80
+ print('ok', aiodahua.__version__)
81
+ "
82
+
83
+ - uses: actions/upload-artifact@v4
84
+ with:
85
+ name: dist
86
+ path: dist/
87
+
88
+ publish:
89
+ needs: build
90
+ runs-on: ubuntu-latest
91
+ # The environment is where the trusted-publisher binding lives, and it is
92
+ # also the place to add a manual approval gate if you ever want one.
93
+ environment:
94
+ name: ${{ github.event_name == 'workflow_dispatch' && inputs.repository || 'pypi' }}
95
+ url: https://pypi.org/p/aiodahua
96
+ permissions:
97
+ id-token: write # the OIDC token that replaces an API token
98
+ steps:
99
+ - uses: actions/download-artifact@v4
100
+ with:
101
+ name: dist
102
+ path: dist/
103
+ - name: Publish
104
+ uses: pypa/gh-action-pypi-publish@release/v1
105
+ with:
106
+ repository-url: >-
107
+ ${{ (github.event_name == 'workflow_dispatch' && inputs.repository == 'testpypi')
108
+ && 'https://test.pypi.org/legacy/' || '' }}
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
aiodahua-0.5.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Egge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,332 @@
1
+ Metadata-Version: 2.5
2
+ Name: aiodahua
3
+ Version: 0.5.0
4
+ Summary: Async client for Dahua and white-label (Amcrest, Lorex, EmpireTech) IP cameras and NVRs
5
+ Project-URL: Homepage, https://github.com/brianegge/aiodahua
6
+ Project-URL: Issues, https://github.com/brianegge/aiodahua/issues
7
+ Author-email: Brian Egge <brianegge@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Brian Egge
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: amcrest,asyncio,dahua,empiretech,homeassistant,ip-camera,lorex,nvr
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Framework :: AsyncIO
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3 :: Only
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Topic :: Home Automation
40
+ Classifier: Topic :: Multimedia :: Video
41
+ Classifier: Topic :: System :: Networking
42
+ Requires-Python: >=3.11
43
+ Requires-Dist: aiohttp>=3.9
44
+ Requires-Dist: yarl>=1.9
45
+ Description-Content-Type: text/markdown
46
+
47
+ # aiodahua
48
+
49
+ Async Python client for Dahua IP cameras and NVRs — **and for the white-label
50
+ brands built on them**: Amcrest, Lorex, EmpireTech and friends.
51
+
52
+ They all speak the same CGI API, but they identify themselves differently and
53
+ their firmware builds diverge in which endpoints actually exist. `aiodahua`
54
+ handles both, and encodes the firmware quirks that otherwise cost you an
55
+ afternoon each.
56
+
57
+ ```python
58
+ import asyncio
59
+ from pathlib import Path
60
+ from aiodahua import DahuaClient
61
+
62
+
63
+ async def main():
64
+ async with DahuaClient("192.168.4.4", "admin", "secret") as dev:
65
+ brand = await dev.async_identify()
66
+ print(brand, brand.matched_on) # Amcrest ('oem_code', 'serial', 'vendor')
67
+ print(await dev.async_get_device_type()) # NV4116-HS
68
+
69
+ for disk in await dev.async_get_storage_info():
70
+ print(disk["name"], disk["state"], disk["total_human"], disk["healthy"])
71
+
72
+ found, files = await dev.async_find_recordings(
73
+ "2026-09-13 00:00:00", "2026-09-13 23:59:59", channel=1
74
+ )
75
+ print(found, files[0]["record_type"]) # 1 regular
76
+
77
+ clip = await dev.async_download_clip(
78
+ "2026-09-13 03:42:20", "2026-09-13 03:42:50", channel=1
79
+ )
80
+ Path("clip.dav").write_bytes(clip) # ffmpeg -i clip.dav ...
81
+
82
+
83
+ asyncio.run(main())
84
+ ```
85
+
86
+
87
+ ## Pulling recorded video
88
+
89
+ `async_download_clip` asks the recorder to cut a time range, rather than
90
+ fetching a whole stored segment — those run an hour per channel at roughly
91
+ 1.8 GB, while 30 seconds of 4K HEVC is about 15 MB.
92
+
93
+ What it returns can be handed straight to ffmpeg, because the preamble is
94
+ already gone. `loadfile.cgi` prefixes the stream with a run of binary that is
95
+ not video: served as `Content-Type: application/http`, but containing no
96
+ status line, no headers, no boundary and no ASCII at all. Its length is not
97
+ fixed — 9733 and 12770 bytes were seen minutes apart from one NV4108E-HS — so
98
+ it has to be located rather than assumed, which is what
99
+ `strip_dhav_preamble` does.
100
+
101
+ Leaving it in place fails quietly, which is the reason this is handled for
102
+ you. ffmpeg probes such a file as raw `hevc` instead of `dhav`, prints
103
+ `PPS id out of range` for every frame, reports no duration, and still writes
104
+ images that look fine.
105
+
106
+ ### Channel numbers
107
+
108
+ `async_find_recordings` and `async_download_clip` take a **1-based** channel,
109
+ while the `Encode[n]` config sections are 0-based.
110
+
111
+ A channel the recorder does not have, or that is disabled, is refused with
112
+ `Error\nBad Request!` — the same response the firmware gives for an endpoint
113
+ it lacks. Do not read one as the other: an NV4108E-HS refusing
114
+ `mediaFileFind.cgi` on channels 6 and 7 looks like a white-label firmware
115
+ limitation, and is really a five-camera recorder being asked about its sixth.
116
+ `RemoteDevice` says which channels are populated; channel titles do not, and
117
+ are frequently stale.
118
+
119
+ ## Install
120
+
121
+ ```bash
122
+ pip install aiodahua
123
+ ```
124
+
125
+ ## Brand identification
126
+
127
+ Dahua devices don't advertise their brand consistently — Amcrest *recorders*
128
+ answer `AC` to `getVendor` while Amcrest *cameras* answer `Amcrest`. So three
129
+ independent signals are combined:
130
+
131
+ | Signal | Group | Example | Notes |
132
+ |---|---|---|---|
133
+ | Firmware OEM code | firmware | `4.000.00**AC**000.0` | The two letters after `00`; absent on generic builds |
134
+ | `getVendor` | firmware | `AC`, `Amcrest`, `Dahua`, `Lorex`, `General` | Weaker: often left at the factory default |
135
+ | Serial prefix | hardware | `AMC…`, `AMR…`, `ND…` | Amcrest cameras / recorders, Lorex. Survives a reflash |
136
+
137
+ `brand` reports the **firmware**, because the firmware is what decides which
138
+ endpoints exist and how they misbehave. The serial identifies the metal, which
139
+ is not always the same story — this hardware gets cross-flashed constantly:
140
+
141
+ ```python
142
+ match = identify_brand(
143
+ vendor="Dahua", version="4.001.0000005.1", serial="AMR013C3556656F6E1"
144
+ )
145
+ match.brand # Brand.DAHUA -- generic Dahua firmware
146
+ match.hardware_brand # Brand.AMCREST -- an NV4108E-HS underneath
147
+ match.is_cross_flashed # True
148
+ ```
149
+
150
+ That is a real device: an Amcrest recorder reflashed with Dahua firmware. It
151
+ answers `getVendor=Dahua`, carries no OEM code, and talks to Dahua's own
152
+ easy4ip P2P service, while its `AMR` serial and `NV4108E-HS` model name are
153
+ pure Amcrest. Where the firmware gives nothing away at all, the serial decides.
154
+
155
+ ```python
156
+ from aiodahua import identify_brand
157
+
158
+ match = identify_brand(vendor="AC", version="4.000.00AC000.0", serial="AMR0142…")
159
+ match.brand # Brand.AMCREST
160
+ match.matched_on # ('oem_code', 'serial', 'vendor')
161
+ match.is_confident # True — more than one signal agreed
162
+ match.profile.prefers_audio_backchannel # False (True for Lorex)
163
+ ```
164
+
165
+ Identification **never fails**. An unrecognised device returns `Brand.UNKNOWN`
166
+ with the raw strings preserved, and every other feature keeps working — brand
167
+ is used to *predict* quirks, never to gate functionality.
168
+
169
+ Profiles carry a `hardware_verified` flag. Amcrest and Lorex are `True`: those
170
+ strings came off real NV4116-HS / NV5232 / NV4432E-HS / NV4108E-HS recorders
171
+ and IP8M/IP5M cameras, and off E891AB cameras and an N841A8 recorder. Dahua and
172
+ EmpireTech are from community reports — accurate as far as we know, but say so
173
+ honestly. **PRs adding verified brands are very welcome**; `PROFILES` in
174
+ `brands.py` is a plain dict, and `scripts/interview.py` collects everything a
175
+ new entry needs.
176
+
177
+ See [docs/hardware.md](docs/hardware.md) for the devices this library has
178
+ actually been run against, and what their firmware does and does not
179
+ implement.
180
+
181
+ ## Firmware quirks this library handles for you
182
+
183
+ All confirmed against an Amcrest NV4116-HS, not taken from documentation.
184
+
185
+ **Clearing a config field silently does nothing.** Sending `Key=` returns `OK`
186
+ and leaves the value unchanged — so code that checks for `OK` reports success
187
+ while nothing happened. Pass `""` and the library sends a single space, which
188
+ the device trims back to `""`.
189
+
190
+ **`&` cannot appear in a config value.** The firmware percent-decodes the whole
191
+ query string *before* splitting it on `&`, so even an encoded `&` terminates
192
+ the value: HTTP 400, or silent truncation. There is no encoding that survives,
193
+ so `DahuaValueError` is raised rather than writing corrupted data.
194
+
195
+ ```python
196
+ await dev.async_set_channel_title(0, "Lot #2") # fine — encoded
197
+ await dev.async_set_channel_title(0, "") # genuinely clears it
198
+ await dev.async_set_channel_title(0, "Bill & Ted") # DahuaValueError
199
+ ```
200
+
201
+ `#`, `+`, `%`, `=`, `/` and `'` all round-trip correctly *once encoded*. Left
202
+ raw they corrupt silently: `#` truncates at the fragment, `+` arrives as a
203
+ space, `%` breaks decoding.
204
+
205
+ **`used == total` does not mean the disk is full.** A recorder pre-allocates
206
+ the whole drive into fixed-size blocks when it formats, so a brand new disk
207
+ reports 100% used. `async_get_storage_info()` attaches a `note` explaining
208
+ this. Use `async_find_recordings()` to confirm footage is actually landing.
209
+
210
+ **Missing endpoints return `Bad Request`, not 404.** Older firmware is missing
211
+ a lot — an NV4116-HS on 2020 firmware has no `getSmartInfo` and no
212
+ `upgrader.cgi`. Newer firmware says `Not Implemented` with HTTP 501 instead: an
213
+ IPC-B54IR-ASE-S3 on 2024 firmware answers 501 where the 2019 camera beside it
214
+ answers 400, for the same endpoint. Both raise `DahuaNotSupportedError`.
215
+
216
+ **Some failures arrive with HTTP 200.** An LTN6416 asked for a config section
217
+ it does not have answers `Error: Error -1 getting param in name=Lighting[0][0]`
218
+ — which parses into a perfectly plausible-looking dict. That raises
219
+ `DahuaResponseError` rather than handing you junk.
220
+
221
+ **Asking a Lorex for `audio.cgi` reboots it.** One GET takes an E891AB off the
222
+ network, HTTP and RTSP both, for ~105 seconds; the camera's own log records
223
+ `Abort` and then `Start up / Reboot Mark: Abort`. `async_get_audio_input()` and
224
+ `async_post_audio()` both raise `DahuaUnsafeOperationError` on brands whose
225
+ profile records this, rather than sending the request — use
226
+ `async_post_audio_backchannel()` there, which is the path the profile prefers
227
+ anyway. Pass `force=True` if you own the device and accept the reboot.
228
+
229
+ **`find_recordings` and `audio.cgi` channels are 1-based**, unlike the
230
+ `Encode[n]` config sections. `find_recordings` rejects channel `0` outright;
231
+ `audio.cgi` answers `401 stale=TRUE` and keeps answering it for every retry,
232
+ which reads as a hang.
233
+
234
+ **Devices that speak HTTPS present a self-signed certificate**, and some
235
+ redirect port 80 to it — an NV4108E-HS answers `302` to `https://<host>:443/`,
236
+ so a request addressed to plain HTTP fails in the TLS handshake. Pass
237
+ `verify_ssl=False`:
238
+
239
+ ```python
240
+ DahuaClient("192.168.4.4", "admin", "secret", port=443, verify_ssl=False)
241
+ ```
242
+
243
+ ## API
244
+
245
+ | Method | Purpose |
246
+ |---|---|
247
+ | `async_identify()` | Brand, with the signals that matched |
248
+ | `async_get_device_type()` / `async_get_serial_number()` / `async_get_software_version()` / `async_get_machine_name()` / `async_get_system_info()` | Device identity |
249
+ | `async_get_config(name)` / `async_set_config(params)` | Raw config read/write |
250
+ | `async_set_machine_name()` / `async_set_channel_title()` | Common config writes |
251
+ | `async_get_storage_info()` | Disk capacity, state, health |
252
+ | `async_find_recordings()` | Confirm a recorder is recording |
253
+ | `async_get_snapshot(channel)` | JPEG bytes |
254
+ | `get_rtsp_url(channel, subtype)` | Stream URL (credentials escaped) |
255
+ | `async_get_text()` / `async_get()` / `async_get_bytes()` | Escape hatch for any CGI endpoint |
256
+
257
+ The client does not close a session you pass in, so it is safe to hand it Home
258
+ Assistant's shared `aiohttp` session.
259
+
260
+ ## Full device API
261
+
262
+ v0.2.0 carries across the complete client from the Dahua Home Assistant
263
+ integration — 89 methods in total — so the integration can adopt this library
264
+ without a behavioural diff. That includes the parts that were the hardest to
265
+ get right:
266
+
267
+ - **Speaker audio out** — `async_post_audio()` (multipart MIME with per-frame
268
+ ADTS delivery, digest priming, explicit `Content-Length` because many cameras
269
+ reject chunked encoding) and `async_post_audio_backchannel()` (RTSP ONVIF
270
+ backchannel over TCP-interleaved RTP, for firmware that resets `audio.cgi` —
271
+ notably Lorex).
272
+ - **Event streaming** — `stream_events()` long-poll.
273
+ - **PTZ**, lighting v1/v2, floodlight and siren, IVS rules, privacy masking,
274
+ day/night switching, video overlays, coaxial control, disarming linkage,
275
+ record mode, door open.
276
+
277
+ Keys are returned **exactly as the device sends them**, including the `table.`
278
+ prefix on `configManager` reads. Renaming them would quietly break callers that
279
+ index the literal response; pass `strip_prefix=True` to `parse_kv` if you want
280
+ them trimmed.
281
+
282
+ > The audio paths and event streaming are carried over unchanged and are
283
+ > exercised in production by the HA integration, but this library's own live
284
+ > testing covered identity, config, storage, recordings, snapshots, logs and
285
+ > RTSP against Amcrest hardware. **The two speaker paths have not been run
286
+ > against a speaker** — the devices here have microphones but no audio output.
287
+
288
+ To close that gap on hardware that does have a speaker:
289
+
290
+ ```bash
291
+ python scripts/verify_speaker.py 192.168.1.50 admin secret
292
+ python scripts/verify_speaker.py 192.168.1.50 admin secret --path backchannel
293
+ ```
294
+
295
+ It reports the brand, whether audio encoding is on, and exercises
296
+ `audio.cgi` and the RTSP backchannel in turn. **It makes audible noise.**
297
+ Note that neither transport reports whether the speaker actually sounded —
298
+ the camera accepts the stream either way — so judge by ear, not exit code.
299
+
300
+ ## Interviewing a device
301
+
302
+ `scripts/interview.py` runs every read-only capability probe this library
303
+ knows about and prints what the firmware supports, plus a Markdown row for
304
+ [docs/hardware.md](docs/hardware.md):
305
+
306
+ ```bash
307
+ python scripts/interview.py 192.168.1.50 admin secret --json my-camera.json
308
+ ```
309
+
310
+ It covers identity, video codecs, stream counts, SNMP, storage, recordings,
311
+ PTZ, motorised lens, white light and siren, smart motion, IVS, audio and the
312
+ event stream. Secrets are redacted: serials are truncated to the prefix brand
313
+ identification uses, and SNMP community strings are never printed.
314
+
315
+ ## Authentication
316
+
317
+ Digest, with a one-shot fallback to basic when a device rejects it -- some
318
+ firmware, and some accounts on otherwise digest-capable firmware, accept
319
+ nothing else. The switch is sticky per client, so a device that wants basic
320
+ only pays for the negotiation once. Pass `basic_auth_fallback=False` to keep
321
+ the password off the wire in a form the device can log verbatim.
322
+
323
+ ## Credits
324
+
325
+ The digest-auth implementation and a good deal of protocol knowledge come from
326
+ [rroller/dahua](https://github.com/rroller/dahua) (MIT), the Dahua Home
327
+ Assistant integration. The config-encoding and storage/recording work came out
328
+ of [dahua-mcp](https://github.com/brianegge/dahua-mcp).
329
+
330
+ ## License
331
+
332
+ MIT