Catalog PyPI
Back to packageRELEASE DETAILS

0.5.0

PyPI

aiodahua

Async client for Dahua and white-label (Amcrest, Lorex, EmpireTech) IP cameras and NVRs

amcrestasynciodahuaempiretechhomeassistantip-cameralorexnvr
Published10 Oct 2026
Direct dependencies2
Transitive dependenciesNot calculated

README

Version 0.5.0 documentation

aiodahua

Async Python client for Dahua IP cameras and NVRs — and for the white-label brands built on them: Amcrest, Lorex, EmpireTech and friends.

They all speak the same CGI API, but they identify themselves differently and their firmware builds diverge in which endpoints actually exist. aiodahua handles both, and encodes the firmware quirks that otherwise cost you an afternoon each.

import asyncio
from pathlib import Path
from aiodahua import DahuaClient


async def main():
    async with DahuaClient("192.168.4.4", "admin", "secret") as dev:
        brand = await dev.async_identify()
        print(brand, brand.matched_on)  # Amcrest ('oem_code', 'serial', 'vendor')
        print(await dev.async_get_device_type())  # NV4116-HS

        for disk in await dev.async_get_storage_info():
            print(disk["name"], disk["state"], disk["total_human"], disk["healthy"])

        found, files = await dev.async_find_recordings(
            "2026-09-13 00:00:00", "2026-09-13 23:59:59", channel=1
        )
        print(found, files[0]["record_type"])  # 1 regular

        clip = await dev.async_download_clip(
            "2026-09-13 03:42:20", "2026-09-13 03:42:50", channel=1
        )
        Path("clip.dav").write_bytes(clip)  # ffmpeg -i clip.dav ...


asyncio.run(main())

Pulling recorded video

async_download_clip asks the recorder to cut a time range, rather than fetching a whole stored segment — those run an hour per channel at roughly 1.8 GB, while 30 seconds of 4K HEVC is about 15 MB.

What it returns can be handed straight to ffmpeg, because the preamble is already gone. loadfile.cgi prefixes the stream with a run of binary that is not video: served as Content-Type: application/http, but containing no status line, no headers, no boundary and no ASCII at all. Its length is not fixed — 9733 and 12770 bytes were seen minutes apart from one NV4108E-HS — so it has to be located rather than assumed, which is what strip_dhav_preamble does.

Leaving it in place fails quietly, which is the reason this is handled for you. ffmpeg probes such a file as raw hevc instead of dhav, prints PPS id out of range for every frame, reports no duration, and still writes images that look fine.

Channel numbers

async_find_recordings and async_download_clip take a 1-based channel, while the Encode[n] config sections are 0-based.

A channel the recorder does not have, or that is disabled, is refused with Error\nBad Request! — the same response the firmware gives for an endpoint it lacks. Do not read one as the other: an NV4108E-HS refusing mediaFileFind.cgi on channels 6 and 7 looks like a white-label firmware limitation, and is really a five-camera recorder being asked about its sixth. RemoteDevice says which channels are populated; channel titles do not, and are frequently stale.

Install

pip install aiodahua

Brand identification

Dahua devices don't advertise their brand consistently — Amcrest recorders answer AC to getVendor while Amcrest cameras answer Amcrest. So three independent signals are combined:

SignalGroupExampleNotes
Firmware OEM codefirmware4.000.00**AC**000.0The two letters after 00; absent on generic builds
getVendorfirmwareAC, Amcrest, Dahua, Lorex, GeneralWeaker: often left at the factory default
Serial prefixhardwareAMC…, AMR…, ND…Amcrest cameras / recorders, Lorex. Survives a reflash

brand reports the firmware, because the firmware is what decides which endpoints exist and how they misbehave. The serial identifies the metal, which is not always the same story — this hardware gets cross-flashed constantly:

match = identify_brand(
    vendor="Dahua", version="4.001.0000005.1", serial="AMR013C3556656F6E1"
)
match.brand  # Brand.DAHUA -- generic Dahua firmware
match.hardware_brand  # Brand.AMCREST -- an NV4108E-HS underneath
match.is_cross_flashed  # True

That is a real device: an Amcrest recorder reflashed with Dahua firmware. It answers getVendor=Dahua, carries no OEM code, and talks to Dahua's own easy4ip P2P service, while its AMR serial and NV4108E-HS model name are pure Amcrest. Where the firmware gives nothing away at all, the serial decides.

from aiodahua import identify_brand

match = identify_brand(vendor="AC", version="4.000.00AC000.0", serial="AMR0142…")
match.brand  # Brand.AMCREST
match.matched_on  # ('oem_code', 'serial', 'vendor')
match.is_confident  # True — more than one signal agreed
match.profile.prefers_audio_backchannel  # False (True for Lorex)

Identification never fails. An unrecognised device returns Brand.UNKNOWN with the raw strings preserved, and every other feature keeps working — brand is used to predict quirks, never to gate functionality.

Profiles carry a hardware_verified flag. Amcrest and Lorex are True: those strings came off real NV4116-HS / NV5232 / NV4432E-HS / NV4108E-HS recorders and IP8M/IP5M cameras, and off E891AB cameras and an N841A8 recorder. Dahua and EmpireTech are from community reports — accurate as far as we know, but say so honestly. PRs adding verified brands are very welcome; PROFILES in brands.py is a plain dict, and scripts/interview.py collects everything a new entry needs.

See docs/hardware.md for the devices this library has actually been run against, and what their firmware does and does not implement.

Firmware quirks this library handles for you

All confirmed against an Amcrest NV4116-HS, not taken from documentation.

Clearing a config field silently does nothing. Sending Key= returns OK and leaves the value unchanged — so code that checks for OK reports success while nothing happened. Pass "" and the library sends a single space, which the device trims back to "".

& cannot appear in a config value. The firmware percent-decodes the whole query string before splitting it on &, so even an encoded & terminates the value: HTTP 400, or silent truncation. There is no encoding that survives, so DahuaValueError is raised rather than writing corrupted data.

await dev.async_set_channel_title(0, "Lot #2")  # fine — encoded
await dev.async_set_channel_title(0, "")  # genuinely clears it
await dev.async_set_channel_title(0, "Bill & Ted")  # DahuaValueError

#, +, %, =, / and ' all round-trip correctly once encoded. Left raw they corrupt silently: # truncates at the fragment, + arrives as a space, % breaks decoding.

used == total does not mean the disk is full. A recorder pre-allocates the whole drive into fixed-size blocks when it formats, so a brand new disk reports 100% used. async_get_storage_info() attaches a note explaining this. Use async_find_recordings() to confirm footage is actually landing.

Missing endpoints return Bad Request, not 404. Older firmware is missing a lot — an NV4116-HS on 2020 firmware has no getSmartInfo and no upgrader.cgi. Newer firmware says Not Implemented with HTTP 501 instead: an IPC-B54IR-ASE-S3 on 2024 firmware answers 501 where the 2019 camera beside it answers 400, for the same endpoint. Both raise DahuaNotSupportedError.

Some failures arrive with HTTP 200. An LTN6416 asked for a config section it does not have answers Error: Error -1 getting param in name=Lighting[0][0] — which parses into a perfectly plausible-looking dict. That raises DahuaResponseError rather than handing you junk.

Asking a Lorex for audio.cgi reboots it. One GET takes an E891AB off the network, HTTP and RTSP both, for ~105 seconds; the camera's own log records Abort and then Start up / Reboot Mark: Abort. async_get_audio_input() and async_post_audio() both raise DahuaUnsafeOperationError on brands whose profile records this, rather than sending the request — use async_post_audio_backchannel() there, which is the path the profile prefers anyway. Pass force=True if you own the device and accept the reboot.

find_recordings and audio.cgi channels are 1-based, unlike the Encode[n] config sections. find_recordings rejects channel 0 outright; audio.cgi answers 401 stale=TRUE and keeps answering it for every retry, which reads as a hang.

Devices that speak HTTPS present a self-signed certificate, and some redirect port 80 to it — an NV4108E-HS answers 302 to https://<host>:443/, so a request addressed to plain HTTP fails in the TLS handshake. Pass verify_ssl=False:

DahuaClient("192.168.4.4", "admin", "secret", port=443, verify_ssl=False)

API

MethodPurpose
async_identify()Brand, with the signals that matched
async_get_device_type() / async_get_serial_number() / async_get_software_version() / async_get_machine_name() / async_get_system_info()Device identity
async_get_config(name) / async_set_config(params)Raw config read/write
async_set_machine_name() / async_set_channel_title()Common config writes
async_get_storage_info()Disk capacity, state, health
async_find_recordings()Confirm a recorder is recording
async_get_snapshot(channel)JPEG bytes
get_rtsp_url(channel, subtype)Stream URL (credentials escaped)
async_get_text() / async_get() / async_get_bytes()Escape hatch for any CGI endpoint

The client does not close a session you pass in, so it is safe to hand it Home Assistant's shared aiohttp session.

Full device API

v0.2.0 carries across the complete client from the Dahua Home Assistant integration — 89 methods in total — so the integration can adopt this library without a behavioural diff. That includes the parts that were the hardest to get right:

  • Speaker audio out — async_post_audio() (multipart MIME with per-frame ADTS delivery, digest priming, explicit Content-Length because many cameras reject chunked encoding) and async_post_audio_backchannel() (RTSP ONVIF backchannel over TCP-interleaved RTP, for firmware that resets audio.cgi — notably Lorex).
  • Event streaming — stream_events() long-poll.
  • PTZ, lighting v1/v2, floodlight and siren, IVS rules, privacy masking, day/night switching, video overlays, coaxial control, disarming linkage, record mode, door open.

Keys are returned exactly as the device sends them, including the table. prefix on configManager reads. Renaming them would quietly break callers that index the literal response; pass strip_prefix=True to parse_kv if you want them trimmed.

The audio paths and event streaming are carried over unchanged and are exercised in production by the HA integration, but this library's own live testing covered identity, config, storage, recordings, snapshots, logs and RTSP against Amcrest hardware. The two speaker paths have not been run against a speaker — the devices here have microphones but no audio output.

To close that gap on hardware that does have a speaker:

python scripts/verify_speaker.py 192.168.1.50 admin secret
python scripts/verify_speaker.py 192.168.1.50 admin secret --path backchannel

It reports the brand, whether audio encoding is on, and exercises audio.cgi and the RTSP backchannel in turn. It makes audible noise. Note that neither transport reports whether the speaker actually sounded — the camera accepts the stream either way — so judge by ear, not exit code.

Interviewing a device

scripts/interview.py runs every read-only capability probe this library knows about and prints what the firmware supports, plus a Markdown row for docs/hardware.md:

python scripts/interview.py 192.168.1.50 admin secret --json my-camera.json

It covers identity, video codecs, stream counts, SNMP, storage, recordings, PTZ, motorised lens, white light and siren, smart motion, IVS, audio and the event stream. Secrets are redacted: serials are truncated to the prefix brand identification uses, and SNMP community strings are never printed.

Authentication

Digest, with a one-shot fallback to basic when a device rejects it -- some firmware, and some accounts on otherwise digest-capable firmware, accept nothing else. The switch is sticky per client, so a device that wants basic only pays for the negotiation once. Pass basic_auth_fallback=False to keep the password off the wire in a form the device can log verbatim.

Credits

The digest-auth implementation and a good deal of protocol knowledge come from rroller/dahua (MIT), the Dahua Home Assistant integration. The config-encoding and storage/recording work came out of dahua-mcp.

License

MIT

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Declared dependencies 2

Native requirements
2 direct targetsNot calculated transitive count
Package / targetRequirementRelationship
aiohttp
Environment
{
  "extras": "",
  "marker": null,
  "requires_python": ">=3.11",
  "evaluation": "NOT_EVALUATED"
}
aiohttp>=3.9runtime
yarl
Environment
{
  "extras": "",
  "marker": null,
  "requires_python": ">=3.11",
  "evaluation": "NOT_EVALUATED"
}
yarl>=1.9runtime

Distribution files

2 collected
aiodahua-0.5.0-py3-none-any.whl

bdist_wheel · 53.11 KB · Python py3

Source file
aiodahua-0.5.0.tar.gz

sdist · 148.13 KB · Python source

Source file

Repository

PENDING
brianegge/aiodahua

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 21 Oct, 11:48 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package_version:5d2bb9c7ffa5edde48b0ce41792ffb78d2ff3d68db492bf4b065c226ddbee447

Other collected versions

View all collected versions →