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.
- aiodahua-0.5.0/.github/workflows/ci.yml +71 -0
- aiodahua-0.5.0/.github/workflows/workflow.yml +108 -0
- aiodahua-0.5.0/.gitignore +9 -0
- aiodahua-0.5.0/LICENSE +21 -0
- aiodahua-0.5.0/PKG-INFO +332 -0
- aiodahua-0.5.0/README.md +286 -0
- aiodahua-0.5.0/RELEASING.md +57 -0
- aiodahua-0.5.0/docs/hardware.md +125 -0
- aiodahua-0.5.0/pyproject.toml +80 -0
- aiodahua-0.5.0/renovate.json +6 -0
- aiodahua-0.5.0/scripts/interview.py +329 -0
- aiodahua-0.5.0/scripts/verify_speaker.py +192 -0
- aiodahua-0.5.0/src/aiodahua/__init__.py +108 -0
- aiodahua-0.5.0/src/aiodahua/brands.py +314 -0
- aiodahua-0.5.0/src/aiodahua/client.py +2066 -0
- aiodahua-0.5.0/src/aiodahua/config.py +71 -0
- aiodahua-0.5.0/src/aiodahua/digest.py +215 -0
- aiodahua-0.5.0/src/aiodahua/discovery.py +472 -0
- aiodahua-0.5.0/src/aiodahua/exceptions.py +134 -0
- aiodahua-0.5.0/src/aiodahua/exposure.py +155 -0
- aiodahua-0.5.0/src/aiodahua/parsers.py +316 -0
- aiodahua-0.5.0/tests/test_brands.py +228 -0
- aiodahua-0.5.0/tests/test_client.py +267 -0
- aiodahua-0.5.0/tests/test_config_and_parsers.py +284 -0
- aiodahua-0.5.0/tests/test_discovery.py +282 -0
- aiodahua-0.5.0/tests/test_exposure.py +151 -0
- aiodahua-0.5.0/tests/test_packaging.py +41 -0
- aiodahua-0.5.0/tests/test_transport.py +513 -0
- aiodahua-0.5.0/uv.lock +960 -0
|
@@ -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/' || '' }}
|
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.
|
aiodahua-0.5.0/PKG-INFO
ADDED
|
@@ -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
|