├── .github ├── ISSUE_TEMPLATE │ ├── issue.md │ └── feature_request.md └── workflows │ ├── pull.yml │ └── push.yml ├── requirements_dev.txt ├── .gitattributes ├── tests ├── __init__.py ├── README.md └── test_api.py ├── custom_components ├── __init__.py └── dahua │ ├── manifest.json │ ├── button.py │ ├── models.py │ ├── translations │ ├── en.json │ ├── nl.json │ ├── it.json │ ├── ca.json │ ├── pt-BR.json │ ├── bg.json │ ├── es.json │ ├── fr.json │ └── pt.json │ ├── entity.py │ ├── const.py │ ├── dahua_utils.py │ ├── select.py │ ├── rpc2.py │ ├── thread.py │ ├── binary_sensor.py │ ├── digest.py │ ├── config_flow.py │ ├── switch.py │ ├── services.yaml │ ├── light.py │ ├── vto.py │ ├── camera.py │ └── __init__.py ├── requirements_test.txt ├── static └── setup1.png ├── requirements.txt ├── scripts ├── lint ├── setup └── develop ├── hacs.json ├── renovate.json ├── config └── configuration.yaml ├── .gitignore ├── .vscode ├── settings.json ├── launch.json └── tasks.json ├── LICENSE ├── setup.cfg ├── .devcontainer.json └── README.md /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for dahua integration.""" 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" 2 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest-homeassistant-custom-component==0.13.286 2 | -------------------------------------------------------------------------------- /static/setup1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rroller/dahua/HEAD/static/setup1.png -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | See https://github.com/custom-components/integration_blueprint/blob/master/tests/README.md -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | homeassistant~=2025.1.2 2 | ha-ffmpeg==3.2.2 3 | colorlog==6.8.2 4 | pip>=24.3.1 5 | ruff==0.7.3 -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dahua", 3 | "hacs": "1.6.0", 4 | "homeassistant": "2025.1.2", 5 | "render_readme": true 6 | } 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --upgrade pip 8 | python3 -m pip install --requirement requirements.txt -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for dahua api.""" 2 | 3 | async def test_api(hass, aioclient_mock, caplog): 4 | """Test calls.""" 5 | 6 | # TODO: Add tests 7 | assert (True and "Expected True") -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.dahua: debug 7 | custom_components.integration_blueprint: debug 8 | 9 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 10 | # debugpy: 11 | 12 | ffmpeg: 13 | stream: 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | coverage.xml 13 | .idea 14 | 15 | 16 | # Home Assistant configuration 17 | config/* 18 | config/home-assistant.log 19 | config/home-assistant_v2.db-shm 20 | config/home-assistant_v2.db-wal 21 | !config/configuration.yaml -------------------------------------------------------------------------------- /custom_components/dahua/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "dahua", 3 | "name": "Dahua", 4 | "after_dependencies": [ 5 | "tag" 6 | ], 7 | "codeowners": [ 8 | "@rroller" 9 | ], 10 | "config_flow": true, 11 | "documentation": "https://github.com/rroller/dahua", 12 | "iot_class": "local_polling", 13 | "issue_tracker": "https://github.com/rroller/dahua/issues", 14 | "version": "0.9.76" 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.wordWrap": "wordWrapColumn", 3 | "editor.wordWrapColumn": 180, 4 | "python.linting.pylintEnabled": true, 5 | "python.linting.enabled": true, 6 | "python.formatting.provider": "autopep8", 7 | "files.associations": { 8 | "*.yaml": "home-assistant" 9 | }, 10 | "python.linting.pylintArgs": [ 11 | "--max-line-length=180" 12 | ], 13 | "python.formatting.autopep8Args": [ 14 | "--max-line-length=180" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/dahua/button.py: -------------------------------------------------------------------------------- 1 | """ 2 | Button entity platform for Dahua. 3 | https://developers.home-assistant.io/docs/core/entity/button 4 | Buttons require HomeAssistant 2021.12 or greater 5 | """ 6 | from homeassistant.core import HomeAssistant 7 | 8 | 9 | async def async_setup_entry(hass: HomeAssistant, entry, async_add_devices): 10 | """Setup the button platform.""" 11 | # TODO: Add some buttons. This requires a pretty recent version of HomeAssistant so I'm waiting a bit longer 12 | # before adding buttons 13 | -------------------------------------------------------------------------------- /custom_components/dahua/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, InitVar 2 | from typing import Any 3 | 4 | 5 | @dataclass(unsafe_hash=True) 6 | class CoaxialControlIOStatus: 7 | speaker: bool = False 8 | white_light: bool = False 9 | api_response: InitVar[Any] = None 10 | 11 | def __post_init__(self, api_response): 12 | if api_response is not None: 13 | self.speaker = api_response["params"]["status"]["Speaker"] == "On" 14 | self.white_light = api_response["params"]["status"]["WhiteLight"] == "On" 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | }, 28 | { 29 | "label": "Run Home Assistant on port 8123", 30 | "type": "shell", 31 | "command": "scripts/develop", 32 | "problemMatcher": [] 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joakim Sørensen @ludeeus 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. -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | # splits long import on multiple lines indented by 4 spaces 22 | multi_line_output = 3 23 | include_trailing_comma=True 24 | force_grid_wrap=0 25 | use_parentheses=True 26 | line_length=180 27 | indent = " " 28 | # by default isort don't check module indexes 29 | not_skip = __init__.py 30 | # will group `import x` and `from x import` of the same module. 31 | force_sort_within_sections = true 32 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 33 | default_section = THIRDPARTY 34 | known_first_party = custom_components.dahua, tests 35 | combine_as_imports = true 36 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: Pull actions 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | validate: 8 | runs-on: "ubuntu-latest" 9 | name: Validate 10 | steps: 11 | - uses: "actions/checkout@v2" 12 | 13 | - name: HACS validation 14 | uses: "hacs/action@main" 15 | with: 16 | category: "integration" 17 | 18 | - name: Hassfest validation 19 | uses: "home-assistant/actions/hassfest@master" 20 | 21 | style: 22 | runs-on: "ubuntu-latest" 23 | name: Check style formatting 24 | steps: 25 | - uses: "actions/checkout@v2" 26 | - uses: "actions/setup-python@v1" 27 | with: 28 | python-version: "3.x" 29 | - run: python3 -m pip install black 30 | - run: black . 31 | 32 | tests: 33 | runs-on: "ubuntu-latest" 34 | name: Run tests 35 | steps: 36 | - name: Check out code from GitHub 37 | uses: "actions/checkout@v2" 38 | - name: "Set up Python" 39 | uses: actions/setup-python@v5.3.0 40 | with: 41 | python-version: "3.12" 42 | cache: "pip" 43 | - name: Install requirements 44 | run: python3 -m pip install -r requirements_test.txt 45 | - name: Run tests 46 | run: | 47 | pytest \ 48 | -qq \ 49 | --timeout=9 \ 50 | --durations=10 \ 51 | -n auto \ 52 | --cov custom_components.dahua \ 53 | -o console_output_style=count \ 54 | -p no:sugar \ 55 | tests 56 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push actions 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | name: Validate 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | 16 | - name: HACS validation 17 | uses: "hacs/action@main" 18 | with: 19 | category: "integration" 20 | 21 | - name: Hassfest validation 22 | uses: "home-assistant/actions/hassfest@master" 23 | 24 | style: 25 | runs-on: "ubuntu-latest" 26 | name: Check style formatting 27 | steps: 28 | - uses: "actions/checkout@v2" 29 | - uses: "actions/setup-python@v1" 30 | with: 31 | python-version: "3.x" 32 | - run: python3 -m pip install black 33 | - run: black . 34 | 35 | tests: 36 | runs-on: "ubuntu-latest" 37 | name: Run tests 38 | steps: 39 | - name: Check out code from GitHub 40 | uses: "actions/checkout@v2" 41 | - name: Setup Python 42 | uses: "actions/setup-python@v1" 43 | with: 44 | python-version: "3.8" 45 | - name: Install requirements 46 | run: python3 -m pip install -r requirements_test.txt 47 | - name: Run tests 48 | run: | 49 | pytest \ 50 | -qq \ 51 | --timeout=9 \ 52 | --durations=10 \ 53 | -n auto \ 54 | --cov custom_components.dahua \ 55 | -o console_output_style=count \ 56 | -p no:sugar \ 57 | tests -------------------------------------------------------------------------------- /custom_components/dahua/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Add Dahua Camera", 6 | "description": "Example address: 192.168.1.108", 7 | "data": { 8 | "username": "Username", 9 | "password": "Password", 10 | "address": "Address", 11 | "port": "Port", 12 | "rtsp_port": "RTSP Port", 13 | "streams": "RTSP Streams", 14 | "events": "Events", 15 | "channel": "Channel (single cameras are 0, NVRs are the 0 based index)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configure Device Name", 20 | "data": { 21 | "name": "Device Name" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Username, Password, or Address is wrong." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Only a single instance is allowed.", 30 | "already_configured": "Only a single instance of a device is allowed." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Binary sensor enabled", 38 | "sensor": "Sensor enabled", 39 | "switch": "Switch enabled", 40 | "light": "Light enabled", 41 | "select": "Select enabled", 42 | "camera": "Camera enabled" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/dahua/entity.py: -------------------------------------------------------------------------------- 1 | """DahuaBaseEntity class""" 2 | from custom_components.dahua import DahuaDataUpdateCoordinator 3 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 4 | from .const import DOMAIN, ATTRIBUTION 5 | 6 | """ 7 | For a list of entity types, see https://developers.home-assistant.io/docs/core/entity/ 8 | """ 9 | class DahuaBaseEntity(CoordinatorEntity): 10 | """ 11 | DahuaBaseEntity is the base entity for all Dahua entities 12 | """ 13 | 14 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, config_entry): 15 | super().__init__(coordinator) 16 | self.config_entry = config_entry 17 | self._coordinator = coordinator 18 | 19 | # https://developers.home-assistant.io/docs/entity_registry_index 20 | @property 21 | def unique_id(self): 22 | """Return a unique ID to use for this entity.""" 23 | return self._coordinator.get_serial_number() 24 | 25 | # https://developers.home-assistant.io/docs/device_registry_index 26 | @property 27 | def device_info(self): 28 | return { 29 | "identifiers": {(DOMAIN, self._coordinator.get_serial_number())}, 30 | "name": self._coordinator.get_device_name(), 31 | "model": self._coordinator.get_model(), 32 | "manufacturer": "Dahua", 33 | "configuration_url": "http://" + self._coordinator.get_address(), 34 | "sw_version": self._coordinator.get_firmware_version(), 35 | } 36 | 37 | @property 38 | def extra_state_attributes(self): 39 | """Return the state attributes.""" 40 | return { 41 | "id": str(self.coordinator.data.get("id")), 42 | "integration": DOMAIN, 43 | } 44 | -------------------------------------------------------------------------------- /custom_components/dahua/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Dahua Camera toevoegen", 6 | "description": "Voorbeeld adres: 192.168.1.108", 7 | "data": { 8 | "username": "Gebruikersnaam", 9 | "password": "Wachtwoord", 10 | "address": "Adres", 11 | "port": "Poort", 12 | "rtsp_port": "RTSP Poort", 13 | "streams": "RTSP Streams", 14 | "events": "Events", 15 | "channel": "Channel (Usually 0 unless you use an nvr)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configureer Apparaatnaam", 20 | "data": { 21 | "name": "Apparaatnaam" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Gebruikersnaam, Wachtwoord, of Adres is verkeerd." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Er is maar 1 integratie toegestaan.", 30 | "already_configured": "Er is al een integratie geconfigureerd." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Binary sensor actief", 38 | "sensor": "Sensor actief", 39 | "switch": "Schakelaar actief", 40 | "light": "Lamp actief", 41 | "select": "Select enabled", 42 | "camera": "Camera actief" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rroller/dahua", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.12-bullseye", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "ms-python.python", 18 | "github.vscode-pull-request-github", 19 | "ryanluker.vscode-coverage-gutters", 20 | "ms-python.vscode-pylance" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 4, 25 | "python.pythonPath": "/usr/bin/python3", 26 | "python.analysis.autoSearchPaths": false, 27 | "python.linting.pylintEnabled": true, 28 | "python.linting.enabled": true, 29 | "python.formatting.provider": "black", 30 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 31 | "editor.formatOnPaste": false, 32 | "editor.formatOnSave": true, 33 | "editor.formatOnType": true, 34 | "files.trimTrailingWhitespace": true, 35 | "terminal.integrated.shell.linux": "bash" 36 | } 37 | } 38 | }, 39 | "containerUser": "vscode", 40 | "updateRemoteUserUID": true, 41 | "containerEnv": { 42 | "HOME": "/home/vscode" 43 | }, 44 | "remoteUser": "vscode", 45 | "features": { 46 | "ghcr.io/devcontainers/features/rust:1": {} 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /custom_components/dahua/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Aggiungi Telecamera Dahua", 6 | "description": "Esempio di indirizzo: 192.168.1.108", 7 | "data": { 8 | "username": "Utente", 9 | "password": "Password", 10 | "address": "Indirizzo", 11 | "port": "Porta", 12 | "rtsp_port": "Porta RTSP", 13 | "streams": "Stream RTSP", 14 | "events": "Eventi", 15 | "channel": "Canale (Telecamere Singole 0, Numero Base per NVR 0)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configura Nome Dispositivo", 20 | "data": { 21 | "name": "Nome Dispositivo" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Utente, Password e/o Indirizzo errati." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Solo una istanza è consentita.", 30 | "already_configured": "Solo una istanza è consentita." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Binary sensor abilitato", 38 | "sensor": "Sensore abilitato", 39 | "switch": "Switch abilitato", 40 | "light": "Luce abilitata", 41 | "select": "Select abilitato", 42 | "camera": "Telecamera abilitata" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/dahua/translations/ca.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Afegiu una càmera Dahua", 6 | "description": "Adreça d'exemple: 192.168.1.108", 7 | "data": { 8 | "username": "Usuari", 9 | "password": "Contrasenya", 10 | "address": "Adreça IP", 11 | "port": "Port", 12 | "rtsp_port": "Port RTSP", 13 | "streams": "Seqüència RTSP", 14 | "events": "Esdeveniments", 15 | "channel": "Channel (Usually 0 unless you use an nvr)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configureu el nom del dispositiu", 20 | "data": { 21 | "name": "Nom del dispositiu" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "L'usuari, la contrasenya o l'adreça IP són incorrectes." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Només es permet una única instància.", 30 | "already_configured": "Només es permet una única instància per dispositiu." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Sensor binari habilitat", 38 | "sensor": "Sensor habilitat", 39 | "switch": "Interruptor habilitat", 40 | "light": "Llum habilitat", 41 | "select": "Select enabled", 42 | "camera": "Càmera habilitat" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/dahua/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Adicionar Câmera Dahua", 6 | "description": "Example de endereço: 192.168.1.108", 7 | "data": { 8 | "username": "Nome de usuário", 9 | "password": "Senha", 10 | "address": "Endereço", 11 | "port": "Porta", 12 | "rtsp_port": "Porta RTSP", 13 | "streams": "Streams RTSP", 14 | "events": "Eventos", 15 | "channel": "Canal (câmeras individuais são 0, NVRs são o índice baseado em 0)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configurar nome do dispositivo", 20 | "data": { 21 | "name": "Nome do dispositivo" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Nome de usuário, senha, ou Endereço está errado." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Apenas uma única instância é permitida.", 30 | "already_configured": "Apenas uma única instância de um dispositivo é permitida." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Binary sensor ativado", 38 | "sensor": "Sensor ativado", 39 | "switch": "Interruptor ativado", 40 | "light": "Luz ativada", 41 | "select": "Selecione ativado", 42 | "camera": "Câmera ativada" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/dahua/translations/bg.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Добавяне на камера Dahua", 6 | "description": "Примерен адрес: 192.168.1.108", 7 | "data": { 8 | "username": "Потребителско име", 9 | "password": "Парола", 10 | "address": "Адрес", 11 | "port": "Порт", 12 | "rtsp_port": "RTSP порт", 13 | "streams": "RTSP потоци", 14 | "events": "Събития", 15 | "channel": "Канал (единичните камери са 0, NVR са индексът, базиран на 0)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Конфигуриране на името на устройството", 20 | "data": { 21 | "name": "Име на устройството" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Потребителско име, парола или адрес са грешни." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Разрешен е само един екземпляр.", 30 | "already_configured": "Разрешено е само едно копие на устройство." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Двоичен сензор е активиран", 38 | "sensor": "Сензорът е активиран", 39 | "switch": "Превключването е разрешено", 40 | "light": "Светлината е активирана", 41 | "select": "Изборът е активиран", 42 | "camera": "Камерата е активирана" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/dahua/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Agregar una Camara Dahua", 6 | "description": "Dirección de ejemplo: 192.168.1.108", 7 | "data": { 8 | "username": "Usuario", 9 | "password": "Contraseña", 10 | "address": "Dirección IP", 11 | "port": "Puerto", 12 | "rtsp_port": "Puerto RTSP", 13 | "streams": "Secuencia RTSP", 14 | "events": "Eventos", 15 | "channel": "Canal (Generalmente es el 0 a menos que uses un grabador)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configurar el nombre del dispositivo ", 20 | "data": { 21 | "name": "Nombre del dispositivo" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Usuario, Contraseña o dirección IP no son correctas." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Solo se permite una instancia.", 30 | "already_configured": "Solo se permite una única instancia por dispositivo." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Sensor binario habilitado", 38 | "sensor": "Sensor habilitado", 39 | "switch": "Interruptor habilitado", 40 | "light": "Luz habilitada", 41 | "select": "Select enabled", 42 | "camera": "Camara habilitada" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/dahua/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Ajouter une caméra Dahua", 6 | "description": "Adresse exemple : 192.168.1.108", 7 | "data": { 8 | "username": "Nom d'utilisateur", 9 | "password": "Mot de passe", 10 | "address": "Adresse", 11 | "port": "Port", 12 | "rtsp_port": "Port RTSP", 13 | "streams": "Flux RTSP", 14 | "events": "Événements", 15 | "channel": "Canal (les caméras individuelles sont 0, les NVR sont indexés à partir de 0)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configurer le nom du périphérique", 20 | "data": { 21 | "name": "Nom du périphérique" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Nom d'utilisateur, mot de passe ou adresse incorrect." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Une seule instance est autorisée.", 30 | "already_configured": "Une seule instance d'un périphérique est autorisée." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Capteur binaire activé", 38 | "sensor": "Capteur activé", 39 | "switch": "Interrupteur activé", 40 | "light": "Lumière activée", 41 | "select": "Sélection activée", 42 | "camera": "Caméra activée" 43 | } 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /custom_components/dahua/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Adicionar Câmera Dahua", 6 | "description": "Exemplo de endereço: 192.168.1.108", 7 | "data": { 8 | "username": "Usuário", 9 | "password": "Senha", 10 | "address": "Endereço", 11 | "port": "Porta", 12 | "rtsp_port": "Porta RTSP", 13 | "streams": "RTSP Streams", 14 | "events": "Eventos", 15 | "channel": "Channel (Usually 0 unless you use an nvr)" 16 | } 17 | }, 18 | "name": { 19 | "title": "Configuração do dispositivo", 20 | "data": { 21 | "name": "Nome do dispositivo" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "auth": "Usuário, senha ou endereço está incorreto." 27 | }, 28 | "abort": { 29 | "single_instance_allowed": "Apenas uma instância é permitida.", 30 | "already_configured": "Apenas uma única instância de um dispositivo é permitida." 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "user": { 36 | "data": { 37 | "binary_sensor": "Ativar entidades Binary Sensor", 38 | "sensor": "Ativar entidades do tipo Sensor", 39 | "switch": "Ativar entidades do tipo Switch", 40 | "light": "Ativar entidades do tipo Light", 41 | "select": "Select enabled", 42 | "camera": "Ativar entidades do tipo Camera" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/dahua/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Dahua.""" 2 | # Base component constants 3 | NAME = "Dahua" 4 | DOMAIN = "dahua" 5 | DOMAIN_DATA = f"{DOMAIN}_data" 6 | ATTRIBUTION = "Data provided by https://ronnieroller.com" 7 | ISSUE_URL = "https://github.com/rroller/dahua/issues" 8 | 9 | # Icons - https://materialdesignicons.com/ 10 | ICON = "mdi:format-quote-close" 11 | MOTION_DETECTION_ICON = "mdi:motion-sensor" 12 | SECURITY_LIGHT_ICON = "mdi:alarm-light-outline" 13 | SIREN_ICON = "mdi:bullhorn" 14 | INFRARED_ICON = "mdi:weather-night" 15 | DISARMING_ICON = "mdi:alarm-check" 16 | VOLUME_HIGH_ICON = "mdi:volume-high" 17 | BELL_ICON = "mdi:bell-ring" 18 | 19 | # Device classes - https://www.home-assistant.io/integrations/binary_sensor/#device-class 20 | MOTION_SENSOR_DEVICE_CLASS = "motion" 21 | SAFETY_DEVICE_CLASS = "safety" 22 | CONNECTIVITY_DEVICE_CLASS = "connectivity" 23 | SOUND_DEVICE_CLASS = "sound" 24 | DOOR_DEVICE_CLASS = "door" 25 | 26 | # Platforms 27 | BINARY_SENSOR = "binary_sensor" 28 | SWITCH = "switch" 29 | LIGHT = "light" 30 | CAMERA = "camera" 31 | SELECT = "select" 32 | PLATFORMS = [BINARY_SENSOR, SWITCH, LIGHT, CAMERA, SELECT] 33 | 34 | 35 | # Configuration and options 36 | CONF_ENABLED = "enabled" 37 | CONF_USERNAME = "username" 38 | CONF_PASSWORD = "password" 39 | CONF_ADDRESS = "address" 40 | CONF_PORT = "port" 41 | CONF_RTSP_PORT = "rtsp_port" 42 | CONF_STREAMS = "streams" 43 | CONF_EVENTS = "events" 44 | CONF_NAME = "name" 45 | CONF_CHANNEL = "channel" 46 | 47 | # Defaults 48 | DEFAULT_NAME = "Dahua" 49 | 50 | STARTUP_MESSAGE = f""" 51 | ------------------------------------------------------------------- 52 | {NAME} 53 | This is a custom integration for Dahua cameras! 54 | If you have any issues with this you need to open an issue here: 55 | {ISSUE_URL} 56 | ------------------------------------------------------------------- 57 | """ 58 | -------------------------------------------------------------------------------- /custom_components/dahua/dahua_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Various utilities for Dahua cameras 3 | """ 4 | import json 5 | import re 6 | 7 | 8 | def dahua_brightness_to_hass_brightness(bri_str: str) -> int: 9 | """ 10 | Converts a dahua brightness (which is 0 to 100 inclusive) and converts it to what HASS 11 | expects, which is 0 to 255 inclusive 12 | """ 13 | bri = 100 14 | if bri_str: 15 | bri = int(bri_str) 16 | 17 | current = bri / 100 18 | return int(current * 255) 19 | 20 | 21 | def hass_brightness_to_dahua_brightness(hass_brightness: int) -> int: 22 | """ 23 | Converts a HASS brightness (which is 0 to 255 inclusive) to a Dahua brightness (which is 0 to 100 inclusive) 24 | """ 25 | if hass_brightness is None: 26 | hass_brightness = 100 27 | return int((hass_brightness / 255) * 100) 28 | 29 | 30 | # https://github.com/rroller/dahua/issues/166 31 | def parse_event(data: str) -> list[dict[str, any]]: 32 | # This will turn the event stream data into a list of events, where each item in the list is a dictionary and where 33 | # the key of the dictionary is the key is for example "Code" and the value is "VideoMotion", etc 34 | # That's a little hard to explain... so look at this example... 35 | # Code=VideoMotion;action=Start;index=0;data={ 36 | # "Id" : [ 0 ], 37 | # "RegionName" : [ "Region1" ], 38 | # "SmartMotionEnable" : true 39 | # } 40 | # will be turned into 41 | # [{ 42 | # "Code":"VideoMotion", 43 | # "action":"Start", 44 | # "index":"0", 45 | # ... 46 | # }] 47 | 48 | # We will split on "--myboundary" and then skip the first 3 lines so we end up with a string that starts with Code= 49 | event_blocks = re.split(r'--myboundary\n', data) 50 | 51 | events = [] 52 | 53 | for event_block in event_blocks: 54 | # Skip the first 3 lines... the first line looks like: Content-Type: text/plain 55 | s = event_block.split("\n", 3) 56 | if len(s) < 3: 57 | continue 58 | event_block = s[3].strip() 59 | if not event_block.startswith("Code="): 60 | continue 61 | 62 | # At this point we'll have something that looks like this... 63 | # Code=VideoMotion;action=Start;index=0;data={ 64 | # "Id" : [ 0 ], 65 | # "RegionName" : [ "Region1" ], 66 | # "SmartMotionEnable" : true 67 | # } 68 | # And we want to put each key/value pair into a dictionary... 69 | event = dict() 70 | for key_value in event_block.split(';'): 71 | key, value = key_value.split('=') 72 | event[key] = value 73 | 74 | # data is a json string, convert it to real json and add it back to the output dic 75 | if "data" in event: 76 | try: 77 | data = json.loads(event["data"]) 78 | event["data"] = data 79 | except Exception: # pylint: disable=broad-except 80 | pass 81 | events.append(event) 82 | 83 | return events 84 | -------------------------------------------------------------------------------- /custom_components/dahua/select.py: -------------------------------------------------------------------------------- 1 | """ 2 | Select entity platform for dahua. 3 | https://developers.home-assistant.io/docs/core/entity/select 4 | Requires HomeAssistant 2021.7.0 or greater 5 | """ 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.components.select import SelectEntity 8 | from custom_components.dahua import DahuaDataUpdateCoordinator 9 | 10 | from .const import DOMAIN 11 | from .entity import DahuaBaseEntity 12 | 13 | 14 | async def async_setup_entry(hass: HomeAssistant, entry, async_add_devices): 15 | """Setup select platform.""" 16 | coordinator: DahuaDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 17 | 18 | devices = [] 19 | 20 | if coordinator.is_amcrest_doorbell() and coordinator.supports_security_light(): 21 | devices.append(DahuaDoorbellLightSelect(coordinator, entry)) 22 | 23 | #if coordinator._supports_ptz_position: 24 | devices.append(DahuaCameraPresetPositionSelect(coordinator, entry)) 25 | 26 | async_add_devices(devices) 27 | 28 | 29 | class DahuaDoorbellLightSelect(DahuaBaseEntity, SelectEntity): 30 | """allows one to turn the doorbell light on/off/strobe""" 31 | 32 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, config_entry): 33 | DahuaBaseEntity.__init__(self, coordinator, config_entry) 34 | SelectEntity.__init__(self) 35 | self._coordinator = coordinator 36 | self._attr_name = f"{coordinator.get_device_name()} Security Light" 37 | self._attr_unique_id = f"{coordinator.get_serial_number()}_security_light" 38 | self._attr_options = ["Off", "On", "Strobe"] 39 | 40 | @property 41 | def current_option(self) -> str: 42 | mode = self._coordinator.data.get("table.Lighting_V2[0][0][1].Mode", "") 43 | state = self._coordinator.data.get("table.Lighting_V2[0][0][1].State", "") 44 | 45 | if mode == "ForceOn" and state == "On": 46 | return "On" 47 | 48 | if mode == "ForceOn" and state == "Flicker": 49 | return "Strobe" 50 | 51 | return "Off" 52 | 53 | async def async_select_option(self, option: str) -> None: 54 | await self._coordinator.client.async_set_lighting_v2_for_amcrest_doorbells(option) 55 | await self._coordinator.async_refresh() 56 | 57 | @property 58 | def name(self): 59 | return self._attr_name 60 | 61 | @property 62 | def unique_id(self): 63 | """ https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements """ 64 | return self._attr_unique_id 65 | 66 | 67 | class DahuaCameraPresetPositionSelect(DahuaBaseEntity, SelectEntity): 68 | """allows """ 69 | 70 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, config_entry): 71 | DahuaBaseEntity.__init__(self, coordinator, config_entry) 72 | SelectEntity.__init__(self) 73 | self._coordinator = coordinator 74 | self._attr_name = f"{coordinator.get_device_name()} Preset Position" 75 | self._attr_unique_id = f"{coordinator.get_serial_number()}_preset_position" 76 | self._attr_options = ["Manual","1","2","3","4","5","6","7","8","9","10"] 77 | 78 | @property 79 | def current_option(self) -> str: 80 | presetID = self._coordinator.data.get("status.PresetID", "0") 81 | if presetID == "0": 82 | return "Manual" 83 | return presetID 84 | 85 | async def async_select_option(self, option: str) -> None: 86 | channel = self._coordinator.get_channel() 87 | await self._coordinator.client.async_goto_preset_position(channel, int(option)) 88 | await self._coordinator.async_refresh() 89 | 90 | @property 91 | def name(self): 92 | return self._attr_name 93 | 94 | @property 95 | def unique_id(self): 96 | """ https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements """ 97 | return self._attr_unique_id 98 | -------------------------------------------------------------------------------- /custom_components/dahua/rpc2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dahua RPC2 API Client 3 | 4 | Auth taken and modified and added to, from https://gist.github.com/gxfxyz/48072a72be3a169bc43549e676713201 5 | """ 6 | import hashlib 7 | import json 8 | import logging 9 | import sys 10 | 11 | import aiohttp 12 | 13 | from custom_components.dahua.models import CoaxialControlIOStatus 14 | 15 | _LOGGER: logging.Logger = logging.getLogger(__package__) 16 | 17 | if sys.version_info > (3, 0): 18 | unicode = str 19 | 20 | 21 | class DahuaRpc2Client: 22 | def __init__( 23 | self, 24 | username: str, 25 | password: str, 26 | address: str, 27 | port: int, 28 | rtsp_port: int, 29 | session: aiohttp.ClientSession 30 | ) -> None: 31 | self._username = username 32 | self._password = password 33 | self._session = session 34 | self._rtsp_port = rtsp_port 35 | self._session_id = None 36 | self._id = 0 37 | protocol = "https" if int(port) == 443 else "http" 38 | self._base = "{0}://{1}:{2}".format(protocol, address, port) 39 | 40 | async def request(self, method, params=None, object_id=None, extra=None, url=None, verify_result=True): 41 | """Make an RPC request.""" 42 | self._id += 1 43 | data = {'method': method, 'id': self._id} 44 | if params is not None: 45 | data['params'] = params 46 | if object_id: 47 | data['object'] = object_id 48 | if extra is not None: 49 | data.update(extra) 50 | if self._session_id: 51 | data['session'] = self._session_id 52 | if not url: 53 | url = "{0}/RPC2".format(self._base) 54 | 55 | resp = await self._session.post(url, data=json.dumps(data)) 56 | resp_json = json.loads(await resp.text()) 57 | 58 | if verify_result and resp_json['result'] is False: 59 | raise ConnectionError(str(resp)) 60 | 61 | return resp_json 62 | 63 | async def login(self): 64 | """Dahua RPC login. 65 | Reversed from rpcCore.js (login, getAuth & getAuthByType functions). 66 | Also referenced: 67 | https://gist.github.com/avelardi/1338d9d7be0344ab7f4280618930cd0d 68 | """ 69 | 70 | # login1: get session, realm & random for real login 71 | self._session_id = None 72 | self._id = 0 73 | url = '{0}/RPC2_Login'.format(self._base) 74 | method = "global.login" 75 | params = {'userName': self._username, 76 | 'password': "", 77 | 'clientType': "Dahua3.0-Web3.0"} 78 | r = await self.request(method=method, params=params, url=url, verify_result=False) 79 | 80 | self._session_id = r['session'] 81 | realm = r['params']['realm'] 82 | random = r['params']['random'] 83 | 84 | # Password encryption algorithm. Reversed from rpcCore.getAuthByType 85 | pwd_phrase = self._username + ":" + realm + ":" + self._password 86 | if isinstance(pwd_phrase, unicode): 87 | pwd_phrase = pwd_phrase.encode('utf-8') 88 | pwd_hash = hashlib.md5(pwd_phrase).hexdigest().upper() 89 | pass_phrase = self._username + ':' + random + ':' + pwd_hash 90 | if isinstance(pass_phrase, unicode): 91 | pass_phrase = pass_phrase.encode('utf-8') 92 | pass_hash = hashlib.md5(pass_phrase).hexdigest().upper() 93 | 94 | # login2: the real login 95 | params = {'userName': self._username, 96 | 'password': pass_hash, 97 | 'clientType': "Dahua3.0-Web3.0", 98 | 'authorityType': "Default", 99 | 'passwordType': "Default"} 100 | return await self.request(method=method, params=params, url=url) 101 | 102 | async def logout(self) -> bool: 103 | """Logs out of the current session. Returns true if the logout was successful""" 104 | try: 105 | response = await self.request(method="global.logout") 106 | if response['result'] is True: 107 | return True 108 | else: 109 | _LOGGER.debug("Failed to log out of Dahua device %s", self._base) 110 | return False 111 | except Exception as exception: 112 | return False 113 | 114 | async def current_time(self): 115 | """Get the current time on the device.""" 116 | response = await self.request(method="global.getCurrentTime") 117 | return response['params']['time'] 118 | 119 | async def get_serial_number(self) -> str: 120 | """Gets the serial number of the device.""" 121 | response = await self.request(method="magicBox.getSerialNo") 122 | return response['params']['sn'] 123 | 124 | async def get_config(self, params): 125 | """Gets config for the supplied params """ 126 | response = await self.request(method="configManager.getConfig", params=params) 127 | return response['params'] 128 | 129 | async def get_device_name(self) -> str: 130 | """Get the device name""" 131 | data = await self.get_config({"name": "General"}) 132 | return data["table"]["MachineName"] 133 | 134 | async def get_coaxial_control_io_status(self, channel: int) -> CoaxialControlIOStatus: 135 | """ async_get_coaxial_control_io_status returns the the current state of the speaker and white light. """ 136 | response = await self.request(method="CoaxialControlIO.getStatus", params={"channel": channel}) 137 | return CoaxialControlIOStatus(response) 138 | -------------------------------------------------------------------------------- /custom_components/dahua/thread.py: -------------------------------------------------------------------------------- 1 | """ Dahua Thread """ 2 | 3 | import asyncio 4 | import sys 5 | import threading 6 | import logging 7 | import time 8 | 9 | from homeassistant.core import HomeAssistant 10 | from custom_components.dahua.client import DahuaClient 11 | from custom_components.dahua.vto import DahuaVTOClient 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | 16 | class DahuaEventThread(threading.Thread): 17 | """Connects to device and subscribes to events. Mainly to capture motion detection events. """ 18 | 19 | def __init__(self, hass: HomeAssistant, client: DahuaClient, on_receive, events: list, channel: int): 20 | """Construct a thread listening for events.""" 21 | threading.Thread.__init__(self) 22 | self.hass = hass 23 | self.stopped = threading.Event() 24 | self.on_receive = on_receive 25 | self.client = client 26 | self.events = events 27 | self.started = False 28 | self.channel = channel 29 | 30 | def run(self): 31 | """Fetch events""" 32 | self.started = True 33 | _LOGGER.info("Starting DahuaEventThread") 34 | 35 | while True: 36 | if not self.started: 37 | _LOGGER.debug("Exiting DahuaEventThread") 38 | return 39 | # submit the coroutine to the event loop thread 40 | coro = self.client.stream_events(self.on_receive, self.events, self.channel) 41 | future = asyncio.run_coroutine_threadsafe(coro, self.hass.loop) 42 | start_time = int(time.time()) 43 | 44 | try: 45 | # wait for the coroutine to finish 46 | future.result() 47 | except asyncio.TimeoutError as ex: 48 | _LOGGER.warning("TimeoutError connecting to camera") 49 | future.cancel() 50 | except Exception as ex: # pylint: disable=broad-except 51 | _LOGGER.debug("%s", ex) 52 | 53 | if not self.started: 54 | _LOGGER.debug("Exiting DahuaEventThread") 55 | return 56 | 57 | end_time = int(time.time()) 58 | if (end_time - start_time) < 10: 59 | # We are failing fast when trying to connect to the camera. Let's retry slowly 60 | time.sleep(60) 61 | 62 | _LOGGER.debug("reconnecting to camera's event stream...") 63 | 64 | def stop(self): 65 | """ Signals to the thread loop that we should stop """ 66 | if self.started: 67 | _LOGGER.info("Stopping DahuaEventThread") 68 | self.stopped.set() 69 | self.started = False 70 | 71 | 72 | class DahuaVtoEventThread(threading.Thread): 73 | """Connects to device and subscribes to events. Mainly to capture motion detection events. """ 74 | 75 | def __init__(self, hass: HomeAssistant, client: DahuaClient, on_receive_vto_event, host: str, 76 | port: int, username: str, password: str): 77 | """Construct a thread listening for events.""" 78 | threading.Thread.__init__(self) 79 | self.hass = hass 80 | self.stopped = threading.Event() 81 | self.on_receive_vto_event = on_receive_vto_event 82 | self.client = client 83 | self.started = False 84 | self._host = host 85 | self._port = port 86 | self._username = username 87 | self._password = password 88 | self._is_ssl = False 89 | self.vto_client = None 90 | 91 | def run(self): 92 | """Fetch VTO events""" 93 | self.started = True 94 | _LOGGER.info("Starting DahuaVtoEventThread") 95 | 96 | while True: 97 | try: 98 | if not self.started: 99 | _LOGGER.debug("Exiting DahuaVtoEventThread") 100 | return 101 | 102 | _LOGGER.debug("Connecting to VTO event stream") 103 | 104 | # TODO: How do I integrate this in with the HA loop? Does it even matter? I think so because 105 | # how well do we know when we are shutting down HA? 106 | loop = asyncio.new_event_loop() 107 | 108 | def vto_client_lambda(): 109 | # Notice how we set vto_client client here. This is so nasty, I'm embarrassed to put this into the 110 | # code, but I'm not a python expert and it works well enough and this is just a spare time project 111 | # so here it is. We need to capture an instance of the DahuaVTOClient so we can use it later on 112 | # in switches to execute commands on the VTO. We need the client connected to the event loop 113 | # which is done through loop.create_connection. This makes it awkward to capture... which is why 114 | # I've done this. I'm sure there's a better way :) 115 | self.vto_client = DahuaVTOClient(self._host, self._username, self._password, self._is_ssl, 116 | self.on_receive_vto_event) 117 | return self.vto_client 118 | 119 | client = loop.create_connection(vto_client_lambda, host=self._host, port=self._port) 120 | 121 | loop.run_until_complete(client) 122 | loop.run_forever() 123 | loop.close() 124 | 125 | _LOGGER.warning("Disconnected from VTO, will try to connect in 5 seconds") 126 | 127 | time.sleep(5) 128 | 129 | except Exception as ex: 130 | if not self.started: 131 | _LOGGER.debug("Exiting DahuaVtoEventThread") 132 | return 133 | exc_type, exc_obj, exc_tb = sys.exc_info() 134 | line = exc_tb.tb_lineno 135 | 136 | _LOGGER.error(f"Connection to VTO failed will try to connect in 30 seconds, error: {ex}, Line: {line}") 137 | 138 | time.sleep(30) 139 | 140 | def stop(self): 141 | """ Signals to the thread loop that we should stop """ 142 | if self.started: 143 | _LOGGER.info("Stopping DahuaVtoEventThread") 144 | self.stopped.set() 145 | self.started = False 146 | -------------------------------------------------------------------------------- /custom_components/dahua/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for dahua.""" 2 | import re 3 | 4 | from homeassistant.components.binary_sensor import BinarySensorEntity 5 | from homeassistant.core import HomeAssistant 6 | from custom_components.dahua import DahuaDataUpdateCoordinator 7 | 8 | from .const import ( 9 | MOTION_SENSOR_DEVICE_CLASS, 10 | DOMAIN, SAFETY_DEVICE_CLASS, CONNECTIVITY_DEVICE_CLASS, SOUND_DEVICE_CLASS, DOOR_DEVICE_CLASS, VOLUME_HIGH_ICON, 11 | ) 12 | from .entity import DahuaBaseEntity 13 | 14 | # Override event names. Otherwise we'll generate the name from the event name for example SmartMotionHuman will 15 | # become "Smart Motion Human" 16 | NAME_OVERRIDES = { 17 | "VideoMotion": "Motion Alarm", 18 | "CrossLineDetection": "Cross Line Alarm", 19 | "DoorbellPressed": "Button Pressed", # For VTO/Doorbell devices 20 | } 21 | 22 | # Override the device class for events 23 | DEVICE_CLASS_OVERRIDES = { 24 | "VideoMotion": MOTION_SENSOR_DEVICE_CLASS, 25 | "CrossLineDetection": MOTION_SENSOR_DEVICE_CLASS, 26 | "AlarmLocal": SAFETY_DEVICE_CLASS, 27 | "VideoLoss": SAFETY_DEVICE_CLASS, 28 | "VideoBlind": SAFETY_DEVICE_CLASS, 29 | "StorageNotExist": CONNECTIVITY_DEVICE_CLASS, 30 | "StorageFailure": CONNECTIVITY_DEVICE_CLASS, 31 | "StorageLowSpace": SAFETY_DEVICE_CLASS, 32 | "FireWarning": SAFETY_DEVICE_CLASS, 33 | "DoorbellPressed": SOUND_DEVICE_CLASS, 34 | "DoorStatus": DOOR_DEVICE_CLASS, 35 | "AudioMutation": SOUND_DEVICE_CLASS, 36 | } 37 | 38 | ICON_OVERRIDES = { 39 | "AudioAnomaly": VOLUME_HIGH_ICON, 40 | "AudioMutation": VOLUME_HIGH_ICON, 41 | } 42 | 43 | 44 | async def async_setup_entry(hass: HomeAssistant, entry, async_add_devices): 45 | """Setup binary_sensor platform.""" 46 | coordinator: DahuaDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 47 | 48 | sensors: list[DahuaEventSensor] = [] 49 | for event_name in coordinator.get_event_list(): 50 | sensors.append(DahuaEventSensor(coordinator, entry, event_name)) 51 | 52 | # For doorbells we'll just add these since most people will want them 53 | if coordinator.is_doorbell(): 54 | sensors.append(DahuaEventSensor(coordinator, entry, "DoorbellPressed")) 55 | sensors.append(DahuaEventSensor(coordinator, entry, "Invite")) 56 | sensors.append(DahuaEventSensor(coordinator, entry, "DoorStatus")) 57 | sensors.append(DahuaEventSensor(coordinator, entry, "CallNoAnswered")) 58 | 59 | if sensors: 60 | async_add_devices(sensors) 61 | 62 | 63 | class DahuaEventSensor(DahuaBaseEntity, BinarySensorEntity): 64 | """ 65 | dahua binary_sensor class to record events. Many of these events are configured in the camera UI by going to: 66 | Setting -> Event -> IVS -> and adding a tripwire rule, etc. See the DahuaEventThread in thread.py on how we connect 67 | to the cammera to listen to events. 68 | """ 69 | 70 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, config_entry, event_name: str): 71 | DahuaBaseEntity.__init__(self, coordinator, config_entry) 72 | BinarySensorEntity.__init__(self) 73 | 74 | # event_name is the event name, example: VideoMotion, CrossLineDetection, SmartMotionHuman, etc 75 | self._event_name = event_name 76 | 77 | self._coordinator = coordinator 78 | self._device_name = coordinator.get_device_name() 79 | self._device_class = DEVICE_CLASS_OVERRIDES.get(event_name, MOTION_SENSOR_DEVICE_CLASS) 80 | self._icon_override = ICON_OVERRIDES.get(event_name, None) 81 | 82 | # name is the friendly name, example: Cross Line Alarm. If the name is not found in the override it will be 83 | # generated from the event_name. For example SmartMotionHuman will become "Smart Motion Human" 84 | # https://stackoverflow.com/questions/25674532/pythonic-way-to-add-space-before-capital-letter-if-and-only-if-previous-letter-i/25674575 85 | default_name = re.sub(r"(? str: 113 | return self._icon_override 114 | 115 | @property 116 | def is_on(self): 117 | """ 118 | Return true if the event is activated. 119 | 120 | This is the magic part of this sensor along with the async_added_to_hass method below. 121 | The async_added_to_hass method adds a listener to the coordinator so when the event is started or stopped 122 | it calls the schedule_update_ha_state function. schedule_update_ha_state gets the current value from this is_on method. 123 | """ 124 | return self._coordinator.get_event_timestamp(self._event_name) > 0 125 | 126 | async def async_added_to_hass(self): 127 | """Connect to dispatcher listening for entity data notifications.""" 128 | self._coordinator.add_dahua_event_listener(self._event_name, self.schedule_update_ha_state) 129 | 130 | @property 131 | def should_poll(self) -> bool: 132 | """Return True if entity has to be polled for state. False if entity pushes its state to HA""" 133 | return False 134 | -------------------------------------------------------------------------------- /custom_components/dahua/digest.py: -------------------------------------------------------------------------------- 1 | """Dahua Digest Auth Support""" 2 | import os 3 | import time 4 | import hashlib 5 | import aiohttp 6 | from aiohttp.client_reqrep import ClientResponse 7 | from aiohttp.client_exceptions import ClientError 8 | from yarl import URL 9 | 10 | 11 | # Seems that aiohttp doesn't support Diegest Auth, which Dahua cams require. So I had to bake it in here. 12 | # Copied and then modified from https://github.com/aio-libs/aiohttp/pull/2213 13 | # I really wish this was baked into aiohttp :-( 14 | 15 | 16 | class DigestAuth: 17 | """HTTP digest authentication helper. 18 | The work here is based off of 19 | https://github.com/requests/requests/blob/v2.18.4/requests/auth.py. 20 | """ 21 | 22 | def __init__(self, username: str, password: str, session: aiohttp.ClientSession, previous=None): 23 | if previous is None: 24 | previous = {} 25 | 26 | self.username = username 27 | self.password = password 28 | self.last_nonce = previous.get("last_nonce", "") 29 | self.nonce_count = previous.get("nonce_count", 0) 30 | self.challenge = previous.get("challenge") 31 | self.args = {} 32 | self.session = session 33 | 34 | async def request(self, method, url, *, headers=None, **kwargs): 35 | """Makes a request""" 36 | if headers is None: 37 | headers = {} 38 | 39 | # Save the args so we can re-run the request 40 | self.args = {"method": method, "url": url, "headers": headers, "kwargs": kwargs} 41 | 42 | if self.challenge: 43 | authorization = self._build_digest_header(method.upper(), url) 44 | headers["AUTHORIZATION"] = authorization 45 | 46 | response = await self.session.request(method, url, headers=headers, **kwargs) 47 | 48 | # Only try performing digest authentication if the response status is from 401 49 | if response.status == 401: 50 | return await self._handle_401(response) 51 | 52 | return response 53 | 54 | def _build_digest_header(self, method, url): 55 | """ 56 | :rtype: str 57 | """ 58 | 59 | realm = self.challenge["realm"] 60 | nonce = self.challenge["nonce"] 61 | qop = self.challenge.get("qop") 62 | algorithm = self.challenge.get("algorithm", "MD5").upper() 63 | opaque = self.challenge.get("opaque") 64 | 65 | if qop and not (qop == "auth" or "auth" in qop.split(",")): 66 | raise ClientError("Unsupported qop value: %s" % qop) 67 | 68 | # lambdas assume digest modules are imported at the top level 69 | if algorithm == "MD5" or algorithm == "MD5-SESS": 70 | hash_fn = hashlib.md5 71 | elif algorithm == "SHA": 72 | hash_fn = hashlib.sha1 73 | else: 74 | return "" 75 | 76 | def H(x): 77 | return hash_fn(x.encode()).hexdigest() 78 | 79 | def KD(s, d): 80 | return H("%s:%s" % (s, d)) 81 | 82 | path = URL(url).path_qs 83 | A1 = "%s:%s:%s" % (self.username, realm, self.password) 84 | A2 = "%s:%s" % (method, path) 85 | 86 | HA1 = H(A1) 87 | HA2 = H(A2) 88 | 89 | if nonce == self.last_nonce: 90 | self.nonce_count += 1 91 | else: 92 | self.nonce_count = 1 93 | 94 | self.last_nonce = nonce 95 | 96 | ncvalue = "%08x" % self.nonce_count 97 | 98 | # cnonce is just a random string generated by the client. 99 | cnonce_data = "".join( 100 | [ 101 | str(self.nonce_count), 102 | nonce, 103 | time.ctime(), 104 | os.urandom(8).decode(errors="ignore"), 105 | ] 106 | ).encode() 107 | cnonce = hashlib.sha1(cnonce_data).hexdigest()[:16] 108 | 109 | if algorithm == "MD5-SESS": 110 | HA1 = H("%s:%s:%s" % (HA1, nonce, cnonce)) 111 | 112 | # This assumes qop was validated to be 'auth' above. If 'auth-int' 113 | # support is added this will need to change. 114 | if qop: 115 | noncebit = ":".join([nonce, ncvalue, cnonce, "auth", HA2]) 116 | response_digest = KD(HA1, noncebit) 117 | else: 118 | response_digest = KD(HA1, "%s:%s" % (nonce, HA2)) 119 | 120 | base = ", ".join( 121 | [ 122 | 'username="%s"' % self.username, 123 | 'realm="%s"' % realm, 124 | 'nonce="%s"' % nonce, 125 | 'uri="%s"' % path, 126 | 'response="%s"' % response_digest, 127 | 'algorithm="%s"' % algorithm, 128 | ] 129 | ) 130 | if opaque: 131 | base += ', opaque="%s"' % opaque 132 | if qop: 133 | base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) 134 | 135 | return "Digest %s" % base 136 | 137 | async def _handle_401(self, response: ClientResponse): 138 | """ 139 | Takes the given response and tries digest-auth, if needed. 140 | :rtype: ClientResponse 141 | """ 142 | auth_header = response.headers.get("www-authenticate", "") 143 | 144 | parts = auth_header.split(" ", 1) 145 | if "digest" == parts[0].lower() and len(parts) > 1: 146 | # Close the initial response since we are going making another request and return that response 147 | response.close() 148 | 149 | self.challenge = parse_key_value_list(parts[1]) 150 | 151 | return await self.request( 152 | self.args["method"], 153 | self.args["url"], 154 | headers=self.args["headers"], 155 | **self.args["kwargs"], 156 | ) 157 | 158 | return response 159 | 160 | 161 | def parse_pair(pair): 162 | key, value = pair.strip().split("=", 1) 163 | 164 | # If it has a trailing comma, remove it. 165 | if value[-1] == ",": 166 | value = value[:-1] 167 | 168 | # If it is quoted, then remove them. 169 | if value[0] == value[-1] == '"': 170 | value = value[1:-1] 171 | 172 | return key, value 173 | 174 | 175 | def parse_key_value_list(header): 176 | return { 177 | key: value 178 | for key, value in [parse_pair(header_pair) for header_pair in header.split(",")] 179 | } 180 | -------------------------------------------------------------------------------- /custom_components/dahua/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow (UI flow) for Dahua IP cameras.""" 2 | import logging 3 | import ssl 4 | 5 | import voluptuous as vol 6 | 7 | from aiohttp import ClientSession, TCPConnector 8 | 9 | from homeassistant import config_entries 10 | from homeassistant.core import callback 11 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 12 | from homeassistant.helpers import config_validation as cv 13 | 14 | from .client import DahuaClient 15 | from .const import ( 16 | CONF_PASSWORD, 17 | CONF_USERNAME, 18 | CONF_ADDRESS, 19 | CONF_RTSP_PORT, 20 | CONF_PORT, 21 | CONF_EVENTS, 22 | CONF_NAME, 23 | DOMAIN, 24 | PLATFORMS, 25 | CONF_CHANNEL, 26 | ) 27 | 28 | """ 29 | https://developers.home-assistant.io/docs/config_entries_config_flow_handler 30 | https://developers.home-assistant.io/docs/data_entry_flow_index/ 31 | """ 32 | 33 | SSL_CONTEXT = ssl.create_default_context() 34 | #SSL_CONTEXT.minimum_version = ssl.TLSVersion.TLSv1_2 35 | SSL_CONTEXT.set_ciphers("DEFAULT") 36 | SSL_CONTEXT.check_hostname = False 37 | SSL_CONTEXT.verify_mode = ssl.CERT_NONE 38 | 39 | _LOGGER: logging.Logger = logging.getLogger(__package__) 40 | 41 | DEFAULT_EVENTS = ["VideoMotion", "CrossLineDetection", "AlarmLocal", "VideoLoss", "VideoBlind", "AudioMutation", 42 | "CrossRegionDetection", "SmartMotionHuman", "SmartMotionVehicle"] 43 | 44 | ALL_EVENTS = ["VideoMotion", 45 | "VideoLoss", 46 | "AlarmLocal", 47 | "CrossLineDetection", 48 | "CrossRegionDetection", 49 | "AudioMutation", 50 | "SmartMotionHuman", 51 | "SmartMotionVehicle", 52 | "VideoBlind", 53 | "AudioAnomaly", 54 | "VideoMotionInfo", 55 | "NewFile", 56 | "IntelliFrame", 57 | "LeftDetection", 58 | "TakenAwayDetection", 59 | "VideoAbnormalDetection", 60 | "FaceDetection", 61 | "VideoUnFocus", 62 | "WanderDetection", 63 | "RioterDetection", 64 | "ParkingDetection", 65 | "MoveDetection", 66 | "StorageNotExist", 67 | "StorageFailure", 68 | "StorageLowSpace", 69 | "AlarmOutput", 70 | "InterVideoAccess", 71 | "NTPAdjustTime", 72 | "TimeChange", 73 | "MDResult", 74 | "HeatImagingTemper", 75 | "CrowdDetection", 76 | "FireWarning", 77 | "FireWarningInfo", 78 | "ObjectPlacementDetection", 79 | "ObjectRemovalDetection", 80 | ] 81 | 82 | """ 83 | https://developers.home-assistant.io/docs/data_entry_flow_index 84 | """ 85 | 86 | 87 | class DahuaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 88 | """Config flow for Dahua Camera API.""" 89 | 90 | VERSION = 1 91 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 92 | 93 | def __init__(self): 94 | """Initialize.""" 95 | self.dahua_config = {} 96 | self._errors = {} 97 | self.init_info = None 98 | 99 | async def async_step_user(self, user_input=None): 100 | """Handle a flow initialized by the user to add a camera.""" 101 | self._errors = {} 102 | 103 | # Uncomment the next 2 lines if only a single instance of the integration is allowed: 104 | # if self._async_current_entries(): 105 | # return self.async_abort(reason="single_instance_allowed") 106 | 107 | if user_input is not None: 108 | data = await self._test_credentials( 109 | user_input[CONF_USERNAME], 110 | user_input[CONF_PASSWORD], 111 | user_input[CONF_ADDRESS], 112 | user_input[CONF_PORT], 113 | user_input[CONF_RTSP_PORT], 114 | user_input[CONF_CHANNEL], 115 | ) 116 | if data is not None: 117 | # Only allow a camera to be setup once 118 | if "serialNumber" in data and data["serialNumber"] is not None: 119 | channel = int(user_input[CONF_CHANNEL]) 120 | unique_id = data["serialNumber"] 121 | if channel > 0: 122 | unique_id = unique_id + "_" + str(channel) 123 | await self.async_set_unique_id(unique_id) 124 | self._abort_if_unique_id_configured() 125 | 126 | user_input[CONF_NAME] = data["name"] 127 | self.init_info = user_input 128 | return await self._show_config_form_name(user_input) 129 | else: 130 | self._errors["base"] = "auth" 131 | 132 | return await self._show_config_form_user(user_input) 133 | 134 | async def async_step_name(self, user_input=None): 135 | """Handle a flow to configure the camera name.""" 136 | self._errors = {} 137 | 138 | if user_input is not None: 139 | if self.init_info is not None: 140 | self.init_info.update(user_input) 141 | return self.async_create_entry( 142 | title=self.init_info["name"], 143 | data=self.init_info, 144 | ) 145 | 146 | return await self._show_config_form_name(user_input) 147 | 148 | @staticmethod 149 | @callback 150 | def async_get_options_flow(config_entry): 151 | return DahuaOptionsFlowHandler() 152 | 153 | async def _show_config_form_user(self, user_input): # pylint: disable=unused-argument 154 | """Show the configuration form to edit camera name.""" 155 | return self.async_show_form( 156 | step_id="user", 157 | data_schema=vol.Schema( 158 | { 159 | vol.Required(CONF_USERNAME): str, 160 | vol.Required(CONF_PASSWORD): str, 161 | vol.Required(CONF_ADDRESS): str, 162 | vol.Required(CONF_PORT, default="80"): str, 163 | vol.Required(CONF_RTSP_PORT, default="554"): str, 164 | vol.Required(CONF_CHANNEL, default=0): int, 165 | vol.Optional(CONF_EVENTS, default=DEFAULT_EVENTS): cv.multi_select(ALL_EVENTS), 166 | } 167 | ), 168 | errors=self._errors, 169 | ) 170 | 171 | async def _show_config_form_name(self, user_input): # pylint: disable=unused-argument 172 | """Show the configuration form to edit location data.""" 173 | return self.async_show_form( 174 | step_id="name", 175 | data_schema=vol.Schema( 176 | { 177 | vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, 178 | } 179 | ), 180 | errors=self._errors, 181 | ) 182 | 183 | async def _test_credentials(self, username, password, address, port, rtsp_port, channel): 184 | """Return name and serialNumber if credentials is valid.""" 185 | # Self signed certs are used over HTTPS so we'll disable SSL verification 186 | connector = TCPConnector(enable_cleanup_closed=True, ssl=SSL_CONTEXT) 187 | session = ClientSession(connector=connector) 188 | try: 189 | client = DahuaClient(username, password, address, port, rtsp_port, session) 190 | data = await client.get_machine_name() 191 | serial = await client.async_get_system_info() 192 | data.update(serial) 193 | if "name" in data: 194 | return data 195 | except Exception as exception: # pylint: disable=broad-except 196 | _LOGGER.error("Could not connect to Dahua device. For iMou devices see " + 197 | "https://github.com/rroller/dahua/issues/6", exc_info=exception) 198 | 199 | 200 | class DahuaOptionsFlowHandler(config_entries.OptionsFlow): 201 | """Dahua config flow options handler.""" 202 | 203 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 204 | """Manage the options.""" 205 | self.options = dict(self.config_entry.options) 206 | return await self.async_step_user() 207 | 208 | async def async_step_user(self, user_input=None): 209 | """Handle a flow initialized by the user.""" 210 | if user_input is not None: 211 | self.options.update(user_input) 212 | return await self._update_options() 213 | 214 | return self.async_show_form( 215 | step_id="user", 216 | data_schema=vol.Schema( 217 | { 218 | vol.Required(x, default=self.options.get(x, True)): bool 219 | for x in sorted(PLATFORMS) 220 | } 221 | ), 222 | ) 223 | 224 | async def _update_options(self): 225 | """Update config entry options.""" 226 | return self.async_create_entry( 227 | title=self.config_entry.data.get(CONF_USERNAME), data=self.options 228 | ) 229 | -------------------------------------------------------------------------------- /custom_components/dahua/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for dahua.""" 2 | from aiohttp import ClientError 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.components.switch import SwitchEntity 5 | from custom_components.dahua import DahuaDataUpdateCoordinator 6 | 7 | from .const import DOMAIN, DISARMING_ICON, MOTION_DETECTION_ICON, SIREN_ICON, BELL_ICON 8 | from .entity import DahuaBaseEntity 9 | from .client import SIREN_TYPE 10 | 11 | 12 | async def async_setup_entry(hass: HomeAssistant, entry, async_add_devices): 13 | """Setup sensor platform.""" 14 | coordinator: DahuaDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 15 | 16 | # I think most cameras have a motion sensor so we'll blindly add a switch for it 17 | devices = [ 18 | DahuaMotionDetectionBinarySwitch(coordinator, entry), 19 | ] 20 | 21 | # But only some cams have a siren, very few do actually 22 | if coordinator.supports_siren(): 23 | devices.append(DahuaSirenBinarySwitch(coordinator, entry)) 24 | if coordinator.supports_smart_motion_detection() or coordinator.supports_smart_motion_detection_amcrest(): 25 | devices.append(DahuaSmartMotionDetectionBinarySwitch(coordinator, entry)) 26 | 27 | try: 28 | await coordinator.client.async_get_disarming_linkage() 29 | devices.append(DahuaDisarmingLinkageBinarySwitch(coordinator, entry)) 30 | devices.append(DahuaDisarmingEventNotificationsLinkageBinarySwitch(coordinator, entry)) 31 | except ClientError as exception: 32 | pass 33 | 34 | async_add_devices(devices) 35 | 36 | 37 | class DahuaMotionDetectionBinarySwitch(DahuaBaseEntity, SwitchEntity): 38 | """dahua motion detection switch class. Used to enable or disable motion detection""" 39 | 40 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 41 | """Turn on/enable motion detection.""" 42 | channel = self._coordinator.get_channel() 43 | await self._coordinator.client.enable_motion_detection(channel, True) 44 | await self._coordinator.async_refresh() 45 | 46 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 47 | """Turn off/disable motion detection.""" 48 | channel = self._coordinator.get_channel() 49 | await self._coordinator.client.enable_motion_detection(channel, False) 50 | await self._coordinator.async_refresh() 51 | 52 | @property 53 | def name(self): 54 | """Return the name of the switch.""" 55 | return self._coordinator.get_device_name() + " " + "Motion Detection" 56 | 57 | @property 58 | def unique_id(self): 59 | """ 60 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be configurable by the user or be changeable 61 | see https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 62 | """ 63 | return self._coordinator.get_serial_number() + "_motion_detection" 64 | 65 | @property 66 | def icon(self): 67 | """Return the icon of this switch.""" 68 | return MOTION_DETECTION_ICON 69 | 70 | @property 71 | def is_on(self): 72 | """ 73 | Return true if the switch is on. 74 | Value is fetched from api.get_motion_detection_config 75 | """ 76 | return self._coordinator.is_motion_detection_enabled() 77 | 78 | 79 | class DahuaDisarmingLinkageBinarySwitch(DahuaBaseEntity, SwitchEntity): 80 | """will set the camera's disarming linkage (Event -> Disarming in the UI)""" 81 | 82 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 83 | """Turn on/enable linkage""" 84 | channel = self._coordinator.get_channel() 85 | await self._coordinator.client.async_set_disarming_linkage(channel, True) 86 | await self._coordinator.async_refresh() 87 | 88 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 89 | """Turn off/disable linkage""" 90 | channel = self._coordinator.get_channel() 91 | await self._coordinator.client.async_set_disarming_linkage(channel, False) 92 | await self._coordinator.async_refresh() 93 | 94 | @property 95 | def name(self): 96 | """Return the name of the switch.""" 97 | return self._coordinator.get_device_name() + " " + "Disarming" 98 | 99 | @property 100 | def unique_id(self): 101 | """ 102 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be 103 | configurable by the user or be changeable see 104 | https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 105 | """ 106 | return self._coordinator.get_serial_number() + "_disarming" 107 | 108 | @property 109 | def icon(self): 110 | """Return the icon of this switch.""" 111 | return DISARMING_ICON 112 | 113 | @property 114 | def is_on(self): 115 | """ 116 | Return true if the switch is on. 117 | Value is fetched from client.async_get_linkage 118 | """ 119 | return self._coordinator.is_disarming_linkage_enabled() 120 | 121 | class DahuaDisarmingEventNotificationsLinkageBinarySwitch(DahuaBaseEntity, SwitchEntity): 122 | """will set the camera's event notifications when device is disarmed (Event -> Disarming -> Event Notifications in the UI)""" 123 | 124 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 125 | """Turn on/enable event notifications""" 126 | channel = self._coordinator.get_channel() 127 | await self._coordinator.client.async_set_event_notifications(channel, True) 128 | await self._coordinator.async_refresh() 129 | 130 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 131 | """Turn off/disable event notifications""" 132 | channel = self._coordinator.get_channel() 133 | await self._coordinator.client.async_set_event_notifications(channel, False) 134 | await self._coordinator.async_refresh() 135 | 136 | @property 137 | def name(self): 138 | """Return the name of the switch.""" 139 | return self._coordinator.get_device_name() + " " + "Event Notifications" 140 | 141 | @property 142 | def unique_id(self): 143 | """ 144 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be 145 | configurable by the user or be changeable see 146 | https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 147 | """ 148 | return self._coordinator.get_serial_number() + "_event_notifications" 149 | 150 | @property 151 | def icon(self): 152 | """Return the icon of this switch.""" 153 | return BELL_ICON 154 | 155 | @property 156 | def is_on(self): 157 | """ 158 | Return true if the switch is on. 159 | """ 160 | return self._coordinator.is_event_notifications_enabled() 161 | 162 | class DahuaSmartMotionDetectionBinarySwitch(DahuaBaseEntity, SwitchEntity): 163 | """Enables or disables the Smart Motion Detection option in the camera""" 164 | 165 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 166 | """Turn on SmartMotionDetect""" 167 | if self._coordinator.supports_smart_motion_detection_amcrest(): 168 | await self._coordinator.client.async_set_ivs_rule(0, 0, True) 169 | else: 170 | await self._coordinator.client.async_enabled_smart_motion_detection(True) 171 | await self._coordinator.async_refresh() 172 | 173 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 174 | """Turn off SmartMotionDetect""" 175 | if self._coordinator.supports_smart_motion_detection_amcrest(): 176 | await self._coordinator.client.async_set_ivs_rule(0, 0, False) 177 | else: 178 | await self._coordinator.client.async_enabled_smart_motion_detection(False) 179 | await self._coordinator.async_refresh() 180 | 181 | @property 182 | def name(self): 183 | """Return the name of the switch.""" 184 | return self._coordinator.get_device_name() + " " + "Smart Motion Detection" 185 | 186 | @property 187 | def unique_id(self): 188 | """ 189 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be 190 | configurable by the user or be changeable see 191 | https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 192 | """ 193 | return self._coordinator.get_serial_number() + "_smart_motion_detection" 194 | 195 | @property 196 | def icon(self): 197 | """Return the icon of this switch.""" 198 | return MOTION_DETECTION_ICON 199 | 200 | @property 201 | def is_on(self): 202 | """ Return true if the switch is on. """ 203 | return self._coordinator.is_smart_motion_detection_enabled() 204 | 205 | 206 | class DahuaSirenBinarySwitch(DahuaBaseEntity, SwitchEntity): 207 | """dahua siren switch class. Used to enable or disable camera built in sirens""" 208 | 209 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 210 | """Turn on/enable the camera's siren""" 211 | channel = self._coordinator.get_channel() 212 | await self._coordinator.client.async_set_coaxial_control_state(channel, SIREN_TYPE, True) 213 | await self._coordinator.async_refresh() 214 | 215 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 216 | """Turn off/disable camera siren""" 217 | channel = self._coordinator.get_channel() 218 | await self._coordinator.client.async_set_coaxial_control_state(channel, SIREN_TYPE, False) 219 | await self._coordinator.async_refresh() 220 | 221 | @property 222 | def name(self): 223 | """Return the name of the switch.""" 224 | return self._coordinator.get_device_name() + " Siren" 225 | 226 | @property 227 | def unique_id(self): 228 | """ 229 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be configurable by the user or be changeable 230 | see https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 231 | """ 232 | return self._coordinator.get_serial_number() + "_siren" 233 | 234 | @property 235 | def icon(self): 236 | """Return the icon of this switch.""" 237 | return SIREN_ICON 238 | 239 | @property 240 | def is_on(self): 241 | """ 242 | Return true if the siren is on. 243 | Value is fetched from api.get_motion_detection_config 244 | """ 245 | return self._coordinator.is_siren_on() 246 | -------------------------------------------------------------------------------- /custom_components/dahua/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available Dahua services 2 | # https://developers.home-assistant.io/docs/dev_101_services/ 3 | 4 | set_infrared_mode: 5 | name: Set Infrared Mode on Dahua Camera 6 | description: Set the infrared light settings on a Dahua camera 7 | target: 8 | entity: 9 | integration: dahua 10 | domain: camera 11 | fields: 12 | mode: 13 | name: Mode 14 | description: "The infrared mode: Auto, On, Off" 15 | example: "Auto" 16 | default: "Auto" 17 | selector: 18 | select: 19 | options: 20 | - "Auto" 21 | - "On" 22 | - "Off" 23 | brightness: 24 | name: Brightness 25 | description: The infrared brightness, from 0 to 100 inclusive. 100 is the brightest 26 | example: 100 27 | default: 100 28 | selector: 29 | number: 30 | min: 0 31 | max: 100 32 | step: 1 33 | mode: slider 34 | 35 | 36 | set_video_profile_mode: 37 | name: Set Dahua Video Profile Mode To Day or Night 38 | description: Sets the video profile mode to day or night 39 | target: 40 | entity: 41 | integration: dahua 42 | domain: camera 43 | fields: 44 | mode: 45 | name: Mode 46 | description: "The profile: Day, Night" 47 | example: "Day" 48 | selector: 49 | select: 50 | options: 51 | - "Day" 52 | - "Night" 53 | 54 | 55 | enable_channel_title: 56 | name: Enable Channel Title Overlay 57 | description: Enables or disable the channel title video overaly 58 | target: 59 | entity: 60 | integration: dahua 61 | domain: camera 62 | fields: 63 | enabled: 64 | name: Enabled 65 | description: "If the overlay is enabled or not" 66 | example: true 67 | required: true 68 | default: true 69 | selector: 70 | boolean: 71 | 72 | 73 | enable_time_overlay: 74 | name: Enable Channel Time Overlay 75 | description: Enables or disable the channel time video overaly 76 | target: 77 | entity: 78 | integration: dahua 79 | domain: camera 80 | fields: 81 | enabled: 82 | name: Enabled 83 | description: "If the overlay is enabled or not" 84 | example: true 85 | required: true 86 | default: true 87 | selector: 88 | boolean: 89 | 90 | 91 | enable_text_overlay: 92 | name: Enable Channel Text Overlay 93 | description: Enables or disable the channel text video overaly 94 | target: 95 | entity: 96 | integration: dahua 97 | domain: camera 98 | fields: 99 | group: 100 | name: Group 101 | description: "Multiple text overlay groups can exist. The default 1 should be used in most cases" 102 | example: 1 103 | required: true 104 | default: 1 105 | selector: 106 | number: 107 | mode: box 108 | min: 0 109 | max: 100 110 | enabled: 111 | name: Enabled 112 | description: "If the overlay is enabled or not" 113 | example: true 114 | required: true 115 | default: false 116 | selector: 117 | boolean: 118 | 119 | 120 | enable_custom_overlay: 121 | name: Enable Channel Custom Text Overlay 122 | description: Enables or disable the channel custom text video overaly 123 | target: 124 | entity: 125 | integration: dahua 126 | domain: camera 127 | fields: 128 | group: 129 | name: Group 130 | description: "Multiple custom text groups can exist. The default 0 should be used in most cases" 131 | example: 0 132 | required: true 133 | default: 0 134 | selector: 135 | number: 136 | mode: box 137 | min: 0 138 | max: 100 139 | enabled: 140 | name: Enabled 141 | description: "If the overlay is enabled or not" 142 | example: true 143 | required: true 144 | default: false 145 | selector: 146 | boolean: 147 | 148 | set_custom_overlay: 149 | name: Set Custom Text Overlay 150 | description: Sets a custom text overlay on the video 151 | target: 152 | entity: 153 | integration: dahua 154 | domain: camera 155 | fields: 156 | group: 157 | name: Group 158 | description: "Multiple custom text groups can exist. The default 0 should be used in most cases" 159 | example: 0 160 | required: true 161 | default: 0 162 | selector: 163 | number: 164 | mode: box 165 | min: 0 166 | max: 100 167 | text1: 168 | name: Text 1 169 | description: "Custom overlay 1" 170 | example: "Text 1" 171 | required: false 172 | default: "" 173 | selector: 174 | text: 175 | text2: 176 | name: Text 2 177 | description: "Custom overlay 2" 178 | example: "Text 2" 179 | required: false 180 | default: "" 181 | selector: 182 | text: 183 | 184 | 185 | set_channel_title: 186 | name: Sets Channel Title 187 | description: Sets a title on the video 188 | target: 189 | entity: 190 | integration: dahua 191 | domain: camera 192 | fields: 193 | text1: 194 | name: Text 1 195 | description: "The first title" 196 | example: "Front Porch" 197 | required: false 198 | default: "" 199 | selector: 200 | text: 201 | text2: 202 | name: Text 2 203 | description: "The second title" 204 | example: "House" 205 | required: false 206 | default: "" 207 | selector: 208 | text: 209 | 210 | 211 | set_text_overlay: 212 | name: Set text overlay 213 | description: Sets a text overlay on the video 214 | target: 215 | entity: 216 | integration: dahua 217 | domain: camera 218 | fields: 219 | group: 220 | name: Group 221 | description: "Multiple custom text groups can exist. The default 1 should be used in most cases" 222 | example: 1 223 | required: true 224 | default: 1 225 | selector: 226 | number: 227 | mode: box 228 | min: 0 229 | max: 100 230 | text1: 231 | name: Text 1 232 | description: "Text overlay 1" 233 | example: "Text 1" 234 | required: false 235 | default: "" 236 | selector: 237 | text: 238 | text2: 239 | name: Text 2 240 | description: "Text overlay 2" 241 | example: "Text 2" 242 | required: false 243 | default: "" 244 | selector: 245 | text: 246 | text3: 247 | name: Text 3 248 | description: "Text overlay 3" 249 | example: "Text 3" 250 | required: false 251 | default: "" 252 | selector: 253 | text: 254 | text4: 255 | name: Text 4 256 | description: "Text overlay 4" 257 | example: "Text 4" 258 | required: false 259 | default: "" 260 | selector: 261 | text: 262 | 263 | set_video_in_day_night_mode: 264 | name: Set the Day or Night Color Mode 265 | description: "Set the camera's Day/Night Mode. For example, Color, BlackWhite, or Auto" 266 | target: 267 | entity: 268 | integration: dahua 269 | domain: camera 270 | fields: 271 | config_type: 272 | name: Config Type 273 | description: "The config type: general, day, night" 274 | example: "general" 275 | default: "general" 276 | selector: 277 | select: 278 | options: 279 | - "general" 280 | - "day" 281 | - "night" 282 | mode: 283 | name: Mode 284 | description: "The mode: Auto, Color, BlackWhite. Note Auto is also known as Brightness by Dahua" 285 | example: "Auto" 286 | default: "Auto" 287 | selector: 288 | select: 289 | options: 290 | - "Auto" 291 | - "Color" 292 | - "BlackWhite" 293 | 294 | reboot: 295 | name: Reboots the device 296 | description: "Reboots the device" 297 | target: 298 | entity: 299 | integration: dahua 300 | domain: camera 301 | 302 | set_record_mode: 303 | name: Set Record Mode on Dahua Camera 304 | description: Sets the record mode (on/off or auto). On is always on recording. Off is always off. Auto based on motion settings, etc. 305 | target: 306 | entity: 307 | integration: dahua 308 | domain: camera 309 | fields: 310 | mode: 311 | name: Mode 312 | description: "The mode: Auto, On, Off" 313 | example: "Auto" 314 | default: "Auto" 315 | selector: 316 | select: 317 | options: 318 | - "Auto" 319 | - "On" 320 | - "Off" 321 | 322 | enable_all_ivs_rules: 323 | name: Enable or Disable All IVS rules 324 | description: Enables of disables all IVS rules based on the supplied 'enabled' param 325 | target: 326 | entity: 327 | integration: dahua 328 | domain: camera 329 | fields: 330 | enabled: 331 | name: Enabled 332 | description: "If true all IVS rules are enabled. If false, all are disabled" 333 | example: true 334 | required: true 335 | default: true 336 | selector: 337 | boolean: 338 | 339 | enable_ivs_rule: 340 | name: Enable or Disable IVS rule 341 | description: Enables of disable a single IVS rule based on the supplied 'enabled' param 342 | target: 343 | entity: 344 | integration: dahua 345 | domain: camera 346 | fields: 347 | index: 348 | name: Index 349 | description: "The rule index. 0 is a hidden rule, so usually the first rule is rule 1" 350 | example: 1 351 | required: true 352 | default: 1 353 | selector: 354 | number: 355 | mode: box 356 | min: 0 357 | max: 100 358 | enabled: 359 | name: Enabled 360 | description: "If true enables the IVS rule, otherwise disables it" 361 | example: true 362 | required: true 363 | default: true 364 | selector: 365 | boolean: 366 | 367 | vto_open_door: 368 | name: Open a door via a VTO 369 | description: Open a door via a VTO (Doorbell) for supported devices 370 | target: 371 | entity: 372 | integration: dahua 373 | domain: camera 374 | fields: 375 | door_id: 376 | name: Door ID 377 | description: "The door ID. Default is 1" 378 | example: 1 379 | required: true 380 | default: 1 381 | selector: 382 | number: 383 | mode: box 384 | min: 0 385 | max: 100 386 | 387 | vto_cancel_call: 388 | name: Cancel VTO call 389 | description: Cancels a VTO call 390 | target: 391 | entity: 392 | integration: dahua 393 | domain: camera 394 | 395 | set_focus_zoom: 396 | name: Set the Dahua Focus and Zoom 397 | description: Sets the camera's focus and zoom 398 | target: 399 | entity: 400 | integration: dahua 401 | domain: camera 402 | fields: 403 | focus: 404 | name: focus 405 | description: "Decimal Value for Focus" 406 | example: "0.758333" 407 | required: true 408 | default: "0.758333" 409 | selector: 410 | text: 411 | zoom: 412 | name: zoom 413 | description: "Decimal value for zoom" 414 | example: "0.898502" 415 | required: true 416 | default: "0.898502" 417 | selector: 418 | text: 419 | 420 | set_privacy_masking: 421 | name: Set the Dahua Privacy Masking 422 | description: Enables or disabled the cameras privacy masking 423 | target: 424 | entity: 425 | integration: dahua 426 | domain: camera 427 | fields: 428 | index: 429 | name: Index 430 | description: "The mask index. 0 is the first mask" 431 | example: 0 432 | required: true 433 | default: 0 434 | selector: 435 | number: 436 | mode: box 437 | min: 0 438 | max: 100 439 | enabled: 440 | name: Enabled 441 | description: "If true enables the mask, otherwise disables it" 442 | example: true 443 | required: true 444 | default: true 445 | selector: 446 | boolean: 447 | 448 | goto_preset_position: 449 | name: Go to a preset position 450 | description: Go to a position already preset 451 | target: 452 | entity: 453 | integration: dahua 454 | domain: camera 455 | fields: 456 | position: 457 | name: Position 458 | description: Position number, from 1 to 10 inclusive. 459 | example: 1 460 | default: 1 461 | selector: 462 | number: 463 | min: 1 464 | max: 10 465 | mode: box -------------------------------------------------------------------------------- /custom_components/dahua/light.py: -------------------------------------------------------------------------------- 1 | """ 2 | Illuminator for for Dahua cameras that have white light illuminators. 3 | 4 | See https://developers.home-assistant.io/docs/core/entity/light 5 | """ 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.components.light import ( 9 | ATTR_BRIGHTNESS, 10 | LightEntity, LightEntityFeature, ColorMode, 11 | ) 12 | 13 | from . import DahuaDataUpdateCoordinator, dahua_utils 14 | from .const import DOMAIN, SECURITY_LIGHT_ICON, INFRARED_ICON 15 | from .entity import DahuaBaseEntity 16 | from .client import SECURITY_LIGHT_TYPE 17 | 18 | 19 | async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): 20 | """Setup light platform.""" 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | 23 | entities = [] 24 | if coordinator.supports_infrared_light(): 25 | entities.append(DahuaInfraredLight(coordinator, entry, "Infrared")) 26 | 27 | if coordinator.supports_illuminator(): 28 | entities.append(DahuaIlluminator(coordinator, entry, "Illuminator")) 29 | 30 | if coordinator.is_flood_light(): 31 | entities.append(FloodLight(coordinator, entry, "Flood Light")) 32 | 33 | if coordinator.supports_security_light() and not coordinator.is_amcrest_doorbell(): 34 | # The Amcrest doorbell works a little different and is added in select.py 35 | entities.append(DahuaSecurityLight(coordinator, entry, "Security Light")) 36 | 37 | if coordinator.is_amcrest_doorbell(): 38 | entities.append(AmcrestRingLight(coordinator, entry, "Ring Light")) 39 | 40 | async_add_entities(entities) 41 | 42 | 43 | class DahuaInfraredLight(DahuaBaseEntity, LightEntity): 44 | """Representation of a Dahua infrared light (for cameras that have them)""" 45 | 46 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, entry, name): 47 | super().__init__(coordinator, entry) 48 | self._name = name 49 | self._coordinator = coordinator 50 | 51 | @property 52 | def name(self): 53 | """Return the name of the light.""" 54 | return self._coordinator.get_device_name() + " " + self._name 55 | 56 | @property 57 | def unique_id(self): 58 | """ 59 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be configurable by the user or be changeable 60 | see https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 61 | """ 62 | return self._coordinator.get_serial_number() + "_infrared" 63 | 64 | @property 65 | def is_on(self): 66 | """Return true if the light is on""" 67 | return self._coordinator.is_infrared_light_on() 68 | 69 | @property 70 | def brightness(self): 71 | """Return the brightness of this light between 0..255 inclusive""" 72 | return self._coordinator.get_infrared_brightness() 73 | 74 | @property 75 | def color_mode(self) -> ColorMode | str | None: 76 | """Return the color mode of the light.""" 77 | return ColorMode.BRIGHTNESS 78 | 79 | @property 80 | def supported_color_modes(self) -> set[str]: 81 | """Flag supported color modes.""" 82 | return {self.color_mode} 83 | 84 | @property 85 | def supported_features(self): 86 | """Flag supported features.""" 87 | return LightEntityFeature.EFFECT 88 | 89 | @property 90 | def should_poll(self): 91 | """Don't poll.""" 92 | return False 93 | 94 | async def async_turn_on(self, **kwargs): 95 | """Turn the light on with the current brightness""" 96 | hass_brightness = kwargs.get(ATTR_BRIGHTNESS) 97 | dahua_brightness = dahua_utils.hass_brightness_to_dahua_brightness(hass_brightness) 98 | channel = self._coordinator.get_channel() 99 | await self._coordinator.client.async_set_lighting_v1(channel, True, dahua_brightness) 100 | await self.coordinator.async_refresh() 101 | 102 | async def async_turn_off(self, **kwargs): 103 | """Turn the light off""" 104 | hass_brightness = kwargs.get(ATTR_BRIGHTNESS) 105 | dahua_brightness = dahua_utils.hass_brightness_to_dahua_brightness(hass_brightness) 106 | channel = self._coordinator.get_channel() 107 | await self._coordinator.client.async_set_lighting_v1(channel, False, dahua_brightness) 108 | await self.coordinator.async_refresh() 109 | 110 | @property 111 | def icon(self): 112 | """Return the icon of this switch.""" 113 | return INFRARED_ICON 114 | 115 | 116 | class DahuaIlluminator(DahuaBaseEntity, LightEntity): 117 | """Representation of a Dahua light (for cameras that have them)""" 118 | 119 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, entry, name): 120 | super().__init__(coordinator, entry) 121 | self._name = name 122 | self._coordinator = coordinator 123 | 124 | @property 125 | def name(self): 126 | """Return the name of the light.""" 127 | return self._coordinator.get_device_name() + " " + self._name 128 | 129 | @property 130 | def unique_id(self): 131 | """ 132 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be configurable by the user or be changeable 133 | see https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 134 | """ 135 | return self._coordinator.get_serial_number() + "_illuminator" 136 | 137 | @property 138 | def is_on(self): 139 | """Return true if the light is on""" 140 | return self._coordinator.is_illuminator_on() 141 | 142 | @property 143 | def brightness(self): 144 | """Return the brightness of this light between 0..255 inclusive""" 145 | 146 | return self._coordinator.get_illuminator_brightness() 147 | 148 | @property 149 | def color_mode(self) -> ColorMode | str | None: 150 | """Return the color mode of the light.""" 151 | return ColorMode.BRIGHTNESS 152 | 153 | @property 154 | def supported_color_modes(self) -> set[str]: 155 | """Flag supported color modes.""" 156 | return {self.color_mode} 157 | 158 | @property 159 | def should_poll(self): 160 | """Don't poll.""" 161 | return False 162 | 163 | async def async_turn_on(self, **kwargs): 164 | """Turn the light on with the current brightness""" 165 | hass_brightness = kwargs.get(ATTR_BRIGHTNESS) 166 | dahua_brightness = dahua_utils.hass_brightness_to_dahua_brightness(hass_brightness) 167 | channel = self._coordinator.get_channel() 168 | profile_mode = self._coordinator.get_profile_mode() 169 | await self._coordinator.client.async_set_lighting_v2(channel, True, dahua_brightness, profile_mode) 170 | await self._coordinator.async_refresh() 171 | 172 | async def async_turn_off(self, **kwargs): 173 | """Turn the light off""" 174 | hass_brightness = kwargs.get(ATTR_BRIGHTNESS) 175 | dahua_brightness = dahua_utils.hass_brightness_to_dahua_brightness(hass_brightness) 176 | channel = self._coordinator.get_channel() 177 | profile_mode = self._coordinator.get_profile_mode() 178 | await self._coordinator.client.async_set_lighting_v2(channel, False, dahua_brightness, profile_mode) 179 | await self._coordinator.async_refresh() 180 | 181 | 182 | class AmcrestRingLight(DahuaBaseEntity, LightEntity): 183 | """Representation of a Amcrest ring light""" 184 | 185 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, entry, name): 186 | super().__init__(coordinator, entry) 187 | self._name = name 188 | self._coordinator = coordinator 189 | 190 | @property 191 | def name(self): 192 | """Return the name of the light.""" 193 | return self._coordinator.get_device_name() + " " + self._name 194 | 195 | @property 196 | def unique_id(self): 197 | """ 198 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). 199 | Should not be configurable by the user or be changeable 200 | see https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 201 | """ 202 | return self._coordinator.get_serial_number() + "_ring_light" 203 | 204 | @property 205 | def is_on(self): 206 | """Return true if the light is on""" 207 | return self._coordinator.is_ring_light_on() 208 | 209 | async def async_turn_on(self, **kwargs): 210 | """Turn the light on""" 211 | await self._coordinator.client.async_set_light_global_enabled(True) 212 | await self._coordinator.async_refresh() 213 | 214 | async def async_turn_off(self, **kwargs): 215 | """Turn the light off""" 216 | await self._coordinator.client.async_set_light_global_enabled(False) 217 | await self._coordinator.async_refresh() 218 | 219 | @property 220 | def color_mode(self) -> ColorMode | str | None: 221 | """Return the color mode of the light.""" 222 | return ColorMode.ONOFF 223 | 224 | @property 225 | def supported_color_modes(self) -> set[str]: 226 | """Flag supported color modes.""" 227 | return {self.color_mode} 228 | 229 | 230 | class FloodLight(DahuaBaseEntity, LightEntity): 231 | """ 232 | Representation of a Amcrest, Dahua, and Lorex Flood Light (for cameras that have them) 233 | Unlike the 'Dahua Illuminator', Amcrest Flood Lights do not play nicely 234 | with adjusting the 'White Light' brightness. 235 | """ 236 | 237 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, entry, name): 238 | super().__init__(coordinator, entry) 239 | self._name = name 240 | self._coordinator = coordinator 241 | 242 | @property 243 | def name(self): 244 | """Return the name of the light.""" 245 | return self._coordinator.get_device_name() + " " + self._name 246 | 247 | @property 248 | def unique_id(self): 249 | """ 250 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be configurable by the user or be changeable 251 | see https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 252 | """ 253 | return self._coordinator.get_serial_number() + "_flood_light" 254 | 255 | @property 256 | def is_on(self): 257 | """Return true if the light is on""" 258 | return self._coordinator.is_flood_light_on() 259 | 260 | @property 261 | def supported_features(self): 262 | """Flag supported features.""" 263 | return LightEntityFeature.EFFECT 264 | 265 | @property 266 | def color_mode(self) -> ColorMode | str | None: 267 | """Return the color mode of the light.""" 268 | return ColorMode.ONOFF 269 | 270 | @property 271 | def supported_color_modes(self) -> set[str]: 272 | """Flag supported color modes.""" 273 | return {self.color_mode} 274 | 275 | @property 276 | def should_poll(self): 277 | """Don't poll.""" 278 | return False 279 | 280 | async def async_turn_on(self, **kwargs): 281 | """Turn the light on""" 282 | if self._coordinator._supports_floodlightmode: 283 | channel = self._coordinator.get_channel() 284 | self._coordinator._floodlight_mode = await self._coordinator.client.async_get_floodlightmode() 285 | await self._coordinator.client.async_set_floodlightmode(2) 286 | await self._coordinator.client.async_set_coaxial_control_state(channel, SECURITY_LIGHT_TYPE, True) 287 | await self._coordinator.async_refresh() 288 | else: 289 | channel = self._coordinator.get_channel() 290 | profile_mode = self._coordinator.get_profile_mode() 291 | await self._coordinator.client.async_set_lighting_v2_for_flood_lights(channel, True, profile_mode) 292 | await self._coordinator.async_refresh() 293 | 294 | async def async_turn_off(self, **kwargs): 295 | """Turn the light off""" 296 | if self._coordinator._supports_floodlightmode: 297 | channel = self._coordinator.get_channel() 298 | await self._coordinator.client.async_set_coaxial_control_state(channel, SECURITY_LIGHT_TYPE, False) 299 | await self._coordinator.client.async_set_floodlightmode(self._coordinator._floodlight_mode) 300 | await self._coordinator.async_refresh() 301 | else: 302 | channel = self._coordinator.get_channel() 303 | profile_mode = self._coordinator.get_profile_mode() 304 | await self._coordinator.client.async_set_lighting_v2_for_flood_lights(channel, False, profile_mode) 305 | await self._coordinator.async_refresh() 306 | 307 | 308 | class DahuaSecurityLight(DahuaBaseEntity, LightEntity): 309 | """ 310 | Representation of a Dahua light (for cameras that have them). This is the red/blue flashing lights. 311 | The camera will only keep this light on for a few seconds before it automatically turns off. 312 | """ 313 | 314 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, entry, name): 315 | super().__init__(coordinator, entry) 316 | self._name = name 317 | self._coordinator = coordinator 318 | 319 | @property 320 | def name(self): 321 | """Return the name of the light.""" 322 | return self._coordinator.get_device_name() + " " + self._name 323 | 324 | @property 325 | def unique_id(self): 326 | """ 327 | A unique identifier for this entity. Needs to be unique within a platform (ie light.hue). Should not be configurable by the user or be changeable 328 | see https://developers.home-assistant.io/docs/entity_registry_index/#unique-id-requirements 329 | """ 330 | return self._coordinator.get_serial_number() + "_security" 331 | 332 | @property 333 | def is_on(self): 334 | """Return true if the light is on""" 335 | return self._coordinator.is_security_light_on() 336 | 337 | @property 338 | def should_poll(self): 339 | """Don't poll.""" 340 | return False 341 | 342 | async def async_turn_on(self, **kwargs): 343 | """Turn the light on""" 344 | channel = self._coordinator.get_channel() 345 | await self._coordinator.client.async_set_coaxial_control_state(channel, SECURITY_LIGHT_TYPE, True) 346 | await self._coordinator.async_refresh() 347 | 348 | async def async_turn_off(self, **kwargs): 349 | """Turn the light off""" 350 | channel = self._coordinator.get_channel() 351 | await self._coordinator.client.async_set_coaxial_control_state(channel, SECURITY_LIGHT_TYPE, False) 352 | await self._coordinator.async_refresh() 353 | 354 | @property 355 | def icon(self): 356 | """Return the icon of this switch.""" 357 | return SECURITY_LIGHT_ICON 358 | 359 | @property 360 | def color_mode(self) -> ColorMode | str | None: 361 | """Return the color mode of the light.""" 362 | return ColorMode.ONOFF 363 | 364 | @property 365 | def supported_color_modes(self) -> set[str]: 366 | """Flag supported color modes.""" 367 | return {self.color_mode} 368 | -------------------------------------------------------------------------------- /custom_components/dahua/vto.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copied and modified from https://github.com/elad-bar/DahuaVTO2MQTT 3 | Thanks to @elad-bar 4 | """ 5 | import struct 6 | import sys 7 | import logging 8 | import json 9 | import asyncio 10 | import hashlib 11 | from json import JSONDecoder 12 | from threading import Timer 13 | from typing import Optional, Callable 14 | from requests.auth import HTTPDigestAuth 15 | 16 | PROTOCOLS = { 17 | True: "https", 18 | False: "http" 19 | } 20 | 21 | _LOGGER: logging.Logger = logging.getLogger(__package__) 22 | 23 | DAHUA_DEVICE_TYPE = "deviceType" 24 | DAHUA_SERIAL_NUMBER = "serialNumber" 25 | DAHUA_VERSION = "version" 26 | DAHUA_BUILD_DATE = "buildDate" 27 | 28 | DAHUA_GLOBAL_LOGIN = "global.login" 29 | DAHUA_GLOBAL_KEEPALIVE = "global.keepAlive" 30 | DAHUA_EVENT_MANAGER_ATTACH = "eventManager.attach" 31 | DAHUA_CONFIG_MANAGER_GETCONFIG = "configManager.getConfig" 32 | DAHUA_MAGICBOX_GETSOFTWAREVERSION = "magicBox.getSoftwareVersion" 33 | DAHUA_MAGICBOX_GETDEVICETYPE = "magicBox.getDeviceType" 34 | 35 | DAHUA_ALLOWED_DETAILS = [ 36 | DAHUA_DEVICE_TYPE, 37 | DAHUA_SERIAL_NUMBER 38 | ] 39 | 40 | 41 | class DahuaVTOClient(asyncio.Protocol): 42 | requestId: int 43 | sessionId: int 44 | keep_alive_interval: int 45 | username: str 46 | password: str 47 | realm: Optional[str] 48 | random: Optional[str] 49 | messages: [] 50 | dahua_details: {} 51 | base_url: str 52 | hold_time: int 53 | lock_status: {} 54 | auth: HTTPDigestAuth 55 | data_handlers: {} 56 | buffer: bytearray 57 | 58 | def __init__(self, host: str, username: str, password: str, is_ssl: bool, on_receive_vto_event): 59 | self.dahua_details = {} 60 | self.host = host 61 | self.username = username 62 | self.password = password 63 | self.is_ssl = is_ssl 64 | self.base_url = f"{PROTOCOLS[self.is_ssl]}://{self.host}/cgi-bin/" 65 | self.auth = HTTPDigestAuth(self.username, self.password) 66 | self.realm = None 67 | self.random = None 68 | self.request_id = 1 69 | self.sessionId = 0 70 | self.keep_alive_interval = 0 71 | self.transport = None 72 | self.hold_time = 0 73 | self.lock_status = {} 74 | self.data_handlers = {} 75 | self.buffer = bytearray() 76 | 77 | # This is the hook back into HA 78 | self.on_receive_vto_event = on_receive_vto_event 79 | self._loop = asyncio.get_event_loop() 80 | 81 | def connection_made(self, transport): 82 | _LOGGER.debug("VTO connection established") 83 | 84 | try: 85 | self.transport = transport 86 | self.pre_login() 87 | 88 | except Exception as ex: 89 | exc_type, exc_obj, exc_tb = sys.exc_info() 90 | 91 | _LOGGER.error(f"Failed to handle message, error: {ex}, Line: {exc_tb.tb_lineno}") 92 | 93 | def data_received(self, data): 94 | _LOGGER.debug(f"Event data {self.host}: '{data}'") 95 | 96 | self.buffer += data 97 | 98 | while b'\n' in self.buffer: 99 | 100 | newline_index = self.buffer.find(b'\n') + 1 101 | packet = self.buffer[:newline_index] 102 | self.buffer = self.buffer[newline_index:] 103 | 104 | try: 105 | messages = self.parse_response(packet) 106 | for message in messages: 107 | if message is None: 108 | continue 109 | 110 | message_id = message.get("id") 111 | 112 | handler: Callable = self.data_handlers.get(message_id, self.handle_default) 113 | handler(message) 114 | except Exception as ex: 115 | exc_type, exc_obj, exc_tb = sys.exc_info() 116 | 117 | _LOGGER.error(f"Failed to handle message, error: {ex}, Line: {exc_tb.tb_lineno}") 118 | 119 | def handle_notify_event_stream(self, params): 120 | try: 121 | if params is None: 122 | return 123 | event_list = params.get("eventList") 124 | 125 | for message in event_list: 126 | for k in self.dahua_details: 127 | if k in DAHUA_ALLOWED_DETAILS: 128 | message[k] = self.dahua_details.get(k) 129 | 130 | self.on_receive_vto_event(message) 131 | 132 | except Exception as ex: 133 | exc_type, exc_obj, exc_tb = sys.exc_info() 134 | 135 | _LOGGER.error(f"Failed to handle event, error: {ex}, Line: {exc_tb.tb_lineno}") 136 | 137 | def handle_default(self, message): 138 | _LOGGER.info(f"Data received without handler: {message}") 139 | 140 | def eof_received(self): 141 | _LOGGER.info('Server sent EOF message') 142 | 143 | self._loop.stop() 144 | 145 | def connection_lost(self, exc): 146 | _LOGGER.error('server closed the connection') 147 | 148 | self._loop.stop() 149 | 150 | def send(self, action, handler, params=None): 151 | if params is None: 152 | params = {} 153 | 154 | self.request_id += 1 155 | 156 | message_data = { 157 | "id": self.request_id, 158 | "session": self.sessionId, 159 | "magic": "0x1234", 160 | "method": action, 161 | "params": params 162 | } 163 | 164 | self.data_handlers[self.request_id] = handler 165 | 166 | if not self.transport.is_closing(): 167 | message = self.convert_message(message_data) 168 | 169 | self.transport.write(message) 170 | 171 | @staticmethod 172 | def convert_message(data): 173 | message_data = json.dumps(data, indent=4) 174 | 175 | header = struct.pack(">L", 0x20000000) 176 | header += struct.pack(">L", 0x44484950) 177 | header += struct.pack(">d", 0) 178 | header += struct.pack(" `Integrations` -> `ADD INTERATIONS` button, search for `Dahua` and configure the camera. 31 | 32 | ### Manual install 33 | To manually install: 34 | 35 | ```bash 36 | # Download a copy of this repository 37 | $ wget https://github.com/rroller/dahua/archive/dahua-main.zip 38 | 39 | # Unzip the archive 40 | $ unzip dahua-main.zip 41 | 42 | # Move the dahua directory into your custom_components directory in your Home Assistant install 43 | $ mv dahua-main/custom_components/dahua /config/custom_components/ 44 | ``` 45 | 46 | > :warning: **After executing one of the above installation methods, restart Home Assistant. Also clear your browser cache before proceeding to the next step, as the integration may not be visible otherwise.** 47 | 48 | ### Setup 49 | 1. Now the integration is added to HACS and available in the normal HA integration installation, so... 50 | 2. In the HA left menu, click `Configuration` 51 | 3. Click `Integrations` 52 | 4. Click `ADD INTEGRATION` 53 | 5. Type `Dahua` and select it 54 | 6. Enter the details: 55 | 1. **Username**: Your camera's username 56 | 2. **Password**: Your camera's password 57 | 3. **Address**: Your camera's address, typically just the IP address 58 | 4. **Port**: Your camera's HTTP port. Default is `80` 59 | 5. **RTSP Port**: Your camera's RTSP port, default is `554`. Used to live stream your camera in HA 60 | 6. **Events**: The integration will keep a connection open to the camera to capture motion events, alarm events, etc. 61 | You can select which events you want to monitor and report in HA. If no events are selected then the connection will no be created. 62 | If you want a specific event that's not listed here open an issue and I'll add it. 63 | 64 | NOTE: All streams will be added, even if not enabled in the camera. Just remove the ones you don't want. 65 | 66 | ![Dahua Setup](static/setup1.png) 67 | 68 | 69 | # Known supported cameras 70 | This integration should word with most Dahua cameras and doorbells. It has been tested with very old and very new Dahua cameras. 71 | 72 | Doorbells will have a binary sensor that captures the doorbell pressed event. 73 | 74 | * **Please let me know if you've tested with additional cameras** 75 | 76 | These devices are confirmed as working: 77 | 78 | ## Dahua cameras 79 | 80 | 81 | ## Dahua cameras 82 | 83 | Series | 2 Megapixels | 4 Megapixels | 5 Megapixels | 8 Megapixels 84 | :------------ | :------------ | :------------ | :------------- | :------------- 85 | | *Consumer Series* | 86 | | | A26 | | | 87 | | *1-Series* | 88 | | | HFW1230S | HFW1435S-W | | 89 | | | HDBW1230E-S2 | HFW1435S-W-S2 | | 90 | | | |HDBW1431EP-S-0360B | | 91 | | *2-/3-Series* | 92 | | | HDW2831T-ZS-S2 | | HDW3549HP-AS-PV | HDW3849HP-AS-PV 93 | | | HDBW2231FP-AS-0280B-S2 | 94 | | *4-/5-Series* | 95 | | | HDW4231EM-ASE | HFW4433F-ZSA | | HDW5831R-ZE 96 | | | HDBW4231F-AS | HDBW5421E-Z | | 97 | | | HDW4233C-A | T5442T-ZE | 98 | | | HDBW4239R-ASE | 99 | | | HDBW4239RP-ASE | 100 | | *6-/7-Series* | 101 | | | HDPW7564N-SP | 102 | | *Panoramic Series* | 103 | | | | | EW5531-AS | 104 | 105 | ## Other brand cameras 106 | 107 | Brand | 2 Megapixels | 4 Megapixels | 5 Megapixels | 8 Megapixels 108 | :------------ | :------------ | :------------ | :------------- | :------------- 109 | | *IMOU* | 110 | | | IMOU IPC-A26Z / Ranger Pro Z | | IMOU DB61i 111 | | | IMOU IPC-C26E-V2 * | 112 | | | IMOU IPC-K22A / Cube PoE-322A | 113 | | *Lorex* | 114 | | | | | | Lorex E891AB 115 | | | | | | Lorex LNB8005-C 116 | | | | | | Lorex LNE8964AB 117 | 118 | * partial support 119 | 120 | ## Doorbell cameras 121 | 122 | Brand | 2 Megapixels | 4 Megapixels | 5 Megapixels | 8 Megapixels 123 | :------------ | :------------ | :------------ | :------------- | :------------- 124 | | *Amcrest* | 125 | | | Amcrest AD110 | Amcrest AD410 126 | | *Dahua* | 127 | | | DHI-VTO2202F-P | 128 | | | DHI-VTO2211G-P | 129 | | | DHI-VTO3311Q-WP | 130 | | *IMOU* | 131 | | | IMOU C26EP-V2 | | IMOU DB61i 132 | 133 | # Known Issues 134 | * IPC-D2B20-ZS doesn't work. Needs a [wrapper](https://gist.github.com/gxfxyz/48072a72be3a169bc43549e676713201), [7](https://github.com/bp2008/DahuaSunriseSunset/issues/7#issuecomment-829513144), [8](https://github.com/mcw0/Tools/issues/8#issuecomment-830669237) 135 | 136 | # Events 137 | Events are streamed from the device and fired on the Home Assistant event bus. 138 | 139 | Here's example event data: 140 | 141 | ```json 142 | { 143 | "event_type": "dahua_event_received", 144 | "data": { 145 | "name": "Cam13", 146 | "Code": "VideoMotion", 147 | "action": "Start", 148 | "index": "0", 149 | "data": { 150 | "Id": [ 151 | 0 152 | ], 153 | "RegionName": [ 154 | "Region1" 155 | ], 156 | "SmartMotionEnable": false 157 | }, 158 | "DeviceName": "Cam13" 159 | }, 160 | "origin": "LOCAL", 161 | "time_fired": "2021-06-30T04:00:28.605290+00:00", 162 | "context": { 163 | "id": "199542fe3f404f2a0a81031ee495bdd1", 164 | "parent_id": null, 165 | "user_id": null 166 | } 167 | } 168 | ``` 169 | 170 | And here's how you configure and event trigger in an automation: 171 | ```yaml 172 | platform: event 173 | event_type: dahua_event_received 174 | event_data: 175 | name: Cam13 176 | Code: VideoMotion 177 | action: Start 178 | ``` 179 | 180 | And that's it! You can enable debug logging (See at the end of this readme) to print out events to the Home Assisant log 181 | as they fire. That can help you understand the events. Or you can HA and open Developer Tools -> Events -> and under 182 | "Listen to events" enter `dahua_event_received` and then click "Start Listening" and wait for events to fire (you might 183 | need to walk in front of your cam to make motion events fire, or press a button, etc) 184 | 185 | ## Example Code Events 186 | | Code | Description | 187 | | ----- | ----------- | 188 | | BackKeyLight | Unit Events, See Below States | 189 | | VideoMotion | motion detection event | 190 | | VideoLoss | video loss detection event | 191 | | VideoBlind | video blind detection event | 192 | | AlarmLocal | alarm detection event | 193 | | CrossLineDetection | tripwire event | 194 | | CrossRegionDetection | intrusion event | 195 | | LeftDetection | abandoned object detection | 196 | | TakenAwayDetection | missing object detection | 197 | | VideoAbnormalDetection | scene change event | 198 | | FaceDetection | face detect event | 199 | | AudioMutation | intensity change | 200 | | AudioAnomaly | input abnormal | 201 | | VideoUnFocus | defocus detect event | 202 | | WanderDetection | loitering detection event | 203 | | RioterDetection | People Gathering event | 204 | | ParkingDetection | parking detection event | 205 | | MoveDetection | fast moving event | 206 | | MDResult | motion detection data reporting event. The motion detect window contains 18 rows and 22 columns. The event info contains motion detect data with mask of every row | 207 | | HeatImagingTemper | temperature alarm event | 208 | 209 | ## BackKeyLight States 210 | | State | Description | 211 | | ----- | ----------- | 212 | | 0 | OK, No Call/Ring | 213 | | 1, 2 | Call/Ring | 214 | | 4 | Voice message | 215 | | 5 | Call answered from VTH | 216 | | 6 | Call **not** answered | 217 | | 7 | VTH calling VTO | 218 | | 8 | Unlock | 219 | | 9 | Unlock failed | 220 | | 11 | Device rebooted | 221 | 222 | # Services and Entities 223 | Note for ease of use, the integration tries to determine if your device supports certain services, entities and will conditionally add them. But that's sometimes a little hard so it'll just add the entity even if your devices doesn't support. 224 | I'd rather opt into extra entities than to create a complicated flow to determine what's supported and what isn't. You can simply disable the entities you don't want. An example of this is the "door open state" for doorbells. Not all doorbells support this. 225 | 226 | ## Services 227 | Service | Parameters | Description 228 | :------------ | :------------ | :------------- 229 | `camera.enable_motion_detection` | | Enables motion detection 230 | `camera.disable_motion_detection` | | Disabled motion detection 231 | `dahua.set_infrared_mode` | `target`: camera.cam13_main
`mode`: Auto, On, Off
`brightness`: 0 - 100 inclusive| Sets the infrared mode. Useful to set the mode back to Auto 232 | `dahua.goto_preset_position` | `target`: camera.cam13_main
`position`: 1 - 10 inclusive| Go to a preset position 233 | `dahua.set_video_profile_mode` | `target`: camera.cam13_main
`mode`: Day, Night| Sets the video profile mode to day or night 234 | `dahua.set_focus_zoom` | `target`: camera.cam13_main
`focus`: The focus level, e.g.: 0.81 0 - 1 inclusive
`zoom`: The zoom level, e.g.: 0.72 0 - 1 inclusive | Sets the focus and zoom level 235 | `dahua.set_channel_title` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`text1`: The text 1
`text2`: The text 2| Sets the channel title 236 | `dahua.set_text_overlay` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`group`: The group, used to apply multiple of text as an overly, e.g.: 1
`text1`: The text 1
`text3`: The text 3
`text4`: The text 4
`text2`: The text 2 | Sets the text overlay on the video 237 | `dahua.set_custom_overlay` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`group`: The group, used to apply multiple of text as an overly, e.g.: 0
`text1`: The text 1
`text2`: The text 2 | Sets the custom overlay on the video 238 | `dahua.enable_channel_title` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`enabled`: True to enable, False to disable | Enables or disables the channel title overlay on the video 239 | `dahua.enable_time_overlay` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`enabled`: True to enable, False to disable | Enables or disables the time overlay on the video 240 | `dahua.enable_text_overlay` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`group`: The group, used to apply multiple of text as an overly, e.g.: 0
`enabled`: True to enable, False to disable | Enables or disables the text overlay on the video 241 | `dahua.enable_custom_overlay` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`group`: The group, used to apply multiple of text as an overly, e.g.: 0
`enabled`: True to enable, False to disable | Enables or disables the custom overlay on the video 242 | `dahua.set_privacy_masking` | `target`: camera.cam13_main
`index`: The mask index, e.g.: 0
`enabled`: True to enable, False to disable | Enables or disabled a privacy mask on the camera 243 | `dahua.set_record_mode` | `target`: camera.cam13_main
`mode`: Auto, On, Off | Sets the record mode. On is always on recording. Off is always off. Auto based on motion settings, etc. 244 | `dahua.enable_all_ivs_rules` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`enabled`: True to enable all IVS rules, False to disable all IVS rules | Enables or disables all IVS rules 245 | `dahua.enable_ivs_rule` | `target`: camera.cam13_main
`channel`: The camera channel, e.g.: 0
`index`: The rule index
enabled`: True to enable the IVS rule, False to disable the IVS rule | Enable or disable an IVS rule 246 | `dahua.vto_open_door` | `target`: camera.cam13_main
`door_id`: The door ID to open, e.g.: 1
Opens a door via a VTO 247 | `dahua.vto_cancel_call` | `target`: camera.cam13_main
Cancels a call on a VTO device (Doorbell) 248 | `dahua.set_video_in_day_night_mode` | `target`: camera.cam13_main
`config_type`: The config type: general, day, night
`mode`: The mode: Auto, Color, BlackWhite. Note Auto is also known as Brightness by Dahua|Set the camera's Day/Night Mode. For example, Color, BlackWhite, or Auto 249 | `dahua.reboot` | `target`: camera.cam13_main
Reboots the device 250 | 251 | 252 | ## Camera 253 | This will provide a normal HA camera entity (can take snapshots, etc) 254 | 255 | ## Switches 256 | Switch | Description | 257 | :------------ | :------------ | 258 | Motion | Enables or disables motion detection on the camera 259 | Siren | If the camera has a siren, will turn on the siren. Note, it seems sirens only stay on for 10 to 15 seconds before switching off 260 | Disarming Linkage | Newer firmwares have a "disarming" feature (not sure what it is, but some people use it). This allows one to turn it on/off. This is found in the UI by going to Event -> Disarming 261 | 262 | ## Lights 263 | Light | Description | 264 | :------------ | :------------ | 265 | Infrared | Turns on/off the infrared light. Using this switch will disable the "auto" mode. If you want to enable auto mode again then use the service to enable auto. When in auto, this switch will not report the on/off state. 266 | Illuminator | If the camera has one, turns on/off the illuminator light (white light). Using this switch will disable the "auto" mode. If you want to enable auto mode again then use the service to enable auto. When in auto, this switch will not report the on/off state. 267 | Security | If the camera has one, turns on/off the security light (red/blue flashing light). This light stays on for 10 to 15 seconds before the camera auto turns it off. 268 | 269 | ## Binary Sensors 270 | Sensor | Description | 271 | :------------ | :------------ | 272 | Motion | A sensor that turns on when the camera detects motion 273 | Button Pressed | A sensor that turns on when a doorbell button is pressed 274 | Others | A binary senor is created for evey event type selected when setting up the camera (Such as cross line, and face detection) 275 | 276 | # Local development 277 | If you wish to work on this component, the easiest way is to follow [HACS Dev Container README](https://github.com/custom-components/integration_blueprint/blob/master/.devcontainer/README.md). In short: 278 | 279 | * Install Docker 280 | * Install Visual Studio Code 281 | * Install the devcontainer Visual Code plugin 282 | * Clone this repo and open it in Visual Studio Code 283 | * View -> Command Palette. Type `Tasks: Run Task` and select it, then click `Run Home Assistant on port 9123` 284 | * Open Home Assistant at http://localhost:9123 285 | 286 | # Debugging 287 | Add to your configuration.yaml: 288 | 289 | ```yaml 290 | logger: 291 | default: info 292 | logs: 293 | custom_components.dahua: debug 294 | ``` 295 | 296 | # Curl/HTTP commands 297 | 298 | ```bash 299 | # Stream events 300 | curl -s --digest -u admin:$DAHUA_PASSWORD "http://192.168.1.203/cgi-bin/eventManager.cgi?action=attach&codes=[All]&heartbeat=5" 301 | 302 | # List IVS rules 303 | http://192.168.1.203/cgi-bin/configManager.cgi?action=getConfig&name=VideoAnalyseRule 304 | 305 | # Enable/Disable IVS rules for [0][3] ... 0 is the channel, 3 is the rule index. Use the right index as required 306 | http://192.168.1.203/cgi-bin/configManager.cgi?action=setConfig&VideoAnalyseRule[0][3].Enable=false 307 | 308 | # Enable/disable Audio Linkage for an IVS rule 309 | http://192.168.1.203/cgi-bin/configManager.cgi?action=setConfig&VideoAnalyseRule[0][3].EventHandler.VoiceEnable=false 310 | ``` 311 | 312 | # References and thanks 313 | * Thanks to @elad-ba for his work on https://github.com/elad-bar/DahuaVTO2MQTT which was copied and modified and then used here for VTO devices 314 | * Thanks for the DAHUA_HTTP_API_V2.76.pdf API reference found at http://www.ipcamtalk.com 315 | * Thanks to all the people opening issues, reporting bugs, pasting commands, etc 316 | -------------------------------------------------------------------------------- /custom_components/dahua/camera.py: -------------------------------------------------------------------------------- 1 | """This component provides basic support for Dahua IP cameras.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | import voluptuous as vol 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers import entity_platform 9 | from homeassistant.components.camera import Camera, CameraEntityFeature 10 | 11 | from custom_components.dahua import DahuaDataUpdateCoordinator 12 | from custom_components.dahua.entity import DahuaBaseEntity 13 | 14 | from .const import ( 15 | DOMAIN, 16 | ) 17 | 18 | _LOGGER: logging.Logger = logging.getLogger(__package__) 19 | 20 | # This service handled setting the infrared mode on the camera to Off, Auto, or Manual... along with the brightness 21 | SERVICE_SET_INFRARED_MODE = "set_infrared_mode" 22 | # This service handles setting the video profile mode to day or night 23 | SERVICE_SET_VIDEO_PROFILE_MODE = "set_video_profile_mode" 24 | SERVICE_SET_FOCUS_ZOOM = "set_focus_zoom" 25 | SERVICE_SET_PRIVACY_MASKING = "set_privacy_masking" 26 | SERVICE_SET_CHANNEL_TITLE = "set_channel_title" 27 | SERVICE_SET_TEXT_OVERLAY = "set_text_overlay" 28 | SERVICE_SET_CUSTOM_OVERLAY = "set_custom_overlay" 29 | SERVICE_SET_RECORD_MODE = "set_record_mode" 30 | SERVICE_ENABLE_CHANNEL_TITLE = "enable_channel_title" 31 | SERVICE_ENABLE_TIME_OVERLay = "enable_time_overlay" 32 | SERVICE_ENABLE_TEXT_OVERLAY = "enable_text_overlay" 33 | SERVICE_ENABLE_CUSTOM_OVERLAY = "enable_custom_overlay" 34 | SERVICE_ENABLE_ALL_IVS_RULES = "enable_all_ivs_rules" 35 | SERVICE_ENABLE_IVS_RULE = "enable_ivs_rule" 36 | SERVICE_VTO_OPEN_DOOR = "vto_open_door" 37 | SERVICE_VTO_CANCEL_CALL = "vto_cancel_call" 38 | SERVICE_SET_DAY_NIGHT_MODE = "set_video_in_day_night_mode" 39 | SERVICE_REBOOT = "reboot" 40 | SERVICE_GOTO_PRESET_POSITION = "goto_preset_position" 41 | 42 | 43 | async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): 44 | """Add a Dahua IP camera from a config entry.""" 45 | 46 | coordinator: DahuaDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] 47 | max_streams = coordinator.get_max_streams() 48 | 49 | # Note the stream_index is 0 based. The main stream is index 0 50 | for stream_index in range(max_streams): 51 | async_add_entities( 52 | [ 53 | DahuaCamera( 54 | coordinator, 55 | stream_index, 56 | config_entry, 57 | ) 58 | ] 59 | ) 60 | 61 | platform = entity_platform.async_get_current_platform() 62 | 63 | # https://developers.home-assistant.io/docs/dev_101_services/ 64 | # "async_set_video_profile_mode" is called upon calling the service. Defined below in the DahuaCamera class 65 | platform.async_register_entity_service( 66 | SERVICE_SET_VIDEO_PROFILE_MODE, 67 | { 68 | vol.Required("mode"): vol.In( 69 | [ 70 | "Day", 71 | "day", 72 | "Night", 73 | "night", 74 | ]) 75 | }, 76 | "async_set_video_profile_mode" 77 | ) 78 | 79 | platform.async_register_entity_service( 80 | SERVICE_SET_FOCUS_ZOOM, 81 | { 82 | vol.Required("focus", default=""): str, 83 | vol.Required("zoom", default=""): str, 84 | }, 85 | "async_adjustfocus" 86 | ) 87 | 88 | platform.async_register_entity_service( 89 | SERVICE_SET_PRIVACY_MASKING, 90 | { 91 | vol.Required("index", default=0): int, 92 | vol.Required("enabled", default=False): bool, 93 | }, 94 | "async_set_privacy_masking" 95 | ) 96 | 97 | platform.async_register_entity_service( 98 | SERVICE_ENABLE_CHANNEL_TITLE, 99 | { 100 | vol.Required("enabled", default=True): bool, 101 | }, 102 | "async_set_enable_channel_title" 103 | ) 104 | 105 | platform.async_register_entity_service( 106 | SERVICE_ENABLE_TIME_OVERLay, 107 | { 108 | vol.Required("enabled", default=True): bool, 109 | }, 110 | "async_set_enable_time_overlay" 111 | ) 112 | 113 | platform.async_register_entity_service( 114 | SERVICE_ENABLE_TEXT_OVERLAY, 115 | { 116 | vol.Required("group", default=1): int, 117 | vol.Required("enabled", default=False): bool, 118 | }, 119 | "async_set_enable_text_overlay" 120 | ) 121 | 122 | platform.async_register_entity_service( 123 | SERVICE_ENABLE_CUSTOM_OVERLAY, 124 | { 125 | vol.Required("group", default=0): int, 126 | vol.Required("enabled", default=False): bool, 127 | }, 128 | "async_set_enable_custom_overlay" 129 | ) 130 | 131 | platform.async_register_entity_service( 132 | SERVICE_ENABLE_ALL_IVS_RULES, 133 | { 134 | vol.Required("enabled", default=True): bool, 135 | }, 136 | "async_set_enable_all_ivs_rules" 137 | ) 138 | 139 | platform.async_register_entity_service( 140 | SERVICE_ENABLE_IVS_RULE, 141 | { 142 | vol.Required("index", default=1): int, 143 | vol.Required("enabled", default=True): bool, 144 | }, 145 | "async_enable_ivs_rule" 146 | ) 147 | 148 | platform.async_register_entity_service( 149 | SERVICE_VTO_OPEN_DOOR, 150 | { 151 | vol.Required("door_id", default=1): int, 152 | }, 153 | "async_vto_open_door" 154 | ) 155 | 156 | platform.async_register_entity_service( 157 | SERVICE_VTO_CANCEL_CALL, 158 | {}, 159 | "async_vto_cancel_call" 160 | ) 161 | 162 | platform.async_register_entity_service( 163 | SERVICE_SET_CHANNEL_TITLE, 164 | { 165 | vol.Optional("text1", default=""): str, 166 | vol.Optional("text2", default=""): str, 167 | }, 168 | "async_set_service_set_channel_title" 169 | ) 170 | platform.async_register_entity_service( 171 | SERVICE_SET_TEXT_OVERLAY, 172 | { 173 | vol.Required("group", default=0): int, 174 | vol.Optional("text1", default=""): str, 175 | vol.Optional("text2", default=""): str, 176 | vol.Optional("text3", default=""): str, 177 | vol.Optional("text4", default=""): str, 178 | }, 179 | "async_set_service_set_text_overlay" 180 | ) 181 | 182 | platform.async_register_entity_service( 183 | SERVICE_SET_CUSTOM_OVERLAY, 184 | { 185 | vol.Required("group", default=0): int, 186 | vol.Optional("text1", default=""): str, 187 | vol.Optional("text2", default=""): str, 188 | }, 189 | "async_set_service_set_custom_overlay" 190 | ) 191 | 192 | platform.async_register_entity_service( 193 | SERVICE_SET_DAY_NIGHT_MODE, 194 | { 195 | vol.Required("config_type"): vol.In(["general", "General", "day", "Day", "night", "Night", "0", "1", "2"]), 196 | vol.Required("mode"): vol.In(["color", "Color", "brightness", "Brightness", "blackwhite", "BlackWhite", 197 | "Auto", "auto"]) 198 | }, 199 | "async_set_video_in_day_night_mode" 200 | ) 201 | 202 | platform.async_register_entity_service( 203 | SERVICE_REBOOT, 204 | {}, 205 | "async_reboot" 206 | ) 207 | 208 | platform.async_register_entity_service( 209 | SERVICE_SET_RECORD_MODE, 210 | { 211 | vol.Required("mode"): vol.In(["On", "on", "Off", "off", "Auto", "auto", "0", "1", "2", ]) 212 | }, 213 | "async_set_record_mode" 214 | ) 215 | 216 | # Exposes a service to enable setting the cameras infrared light to Auto, Manual, and Off along with the brightness 217 | if coordinator.supports_infrared_light(): 218 | # "async_set_infrared_mode" is the method called upon calling the service. Defined below in DahuaCamera class 219 | platform.async_register_entity_service( 220 | SERVICE_SET_INFRARED_MODE, 221 | { 222 | vol.Required("mode"): vol.In(["On", "on", "Off", "off", "Auto", "auto"]), 223 | vol.Optional('brightness', default=100): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), 224 | }, 225 | "async_set_infrared_mode" 226 | ) 227 | 228 | platform.async_register_entity_service( 229 | SERVICE_GOTO_PRESET_POSITION, 230 | { 231 | vol.Required('position', default=1): vol.All(vol.Coerce(int), vol.Range(min=1, max=10)), 232 | }, 233 | "async_goto_preset_position" 234 | ) 235 | 236 | class DahuaCamera(DahuaBaseEntity, Camera): 237 | """An implementation of a Dahua IP camera.""" 238 | 239 | def __init__(self, coordinator: DahuaDataUpdateCoordinator, stream_index: int, config_entry): 240 | """Initialize the Dahua camera.""" 241 | DahuaBaseEntity.__init__(self, coordinator, config_entry) 242 | Camera.__init__(self) 243 | 244 | name = coordinator.client.to_stream_name(stream_index) 245 | self._channel_number = coordinator.get_channel_number() 246 | self._coordinator = coordinator 247 | self._name = "{0} {1}".format(config_entry.title, name) 248 | self._unique_id = coordinator.get_serial_number() + "_" + name 249 | self._stream_index = stream_index 250 | self._motion_status = False 251 | self._stream_source = coordinator.client.get_rtsp_stream_url(self._channel_number, stream_index) 252 | 253 | @property 254 | def unique_id(self): 255 | """Return the entity unique ID.""" 256 | return self._unique_id 257 | 258 | async def async_camera_image(self, width: int | None = None, height: int | None = None): 259 | """Return a still image response from the camera.""" 260 | # Send the request to snap a picture and return raw jpg data 261 | return await self._coordinator.client.async_get_snapshot(self._channel_number) 262 | 263 | @property 264 | def supported_features(self): 265 | """Flag supported features.""" 266 | return CameraEntityFeature.STREAM 267 | 268 | async def stream_source(self): 269 | """Return the RTSP stream source.""" 270 | return self._stream_source 271 | 272 | @property 273 | def motion_detection_enabled(self): 274 | """Camera Motion Detection Status.""" 275 | return self._coordinator.is_motion_detection_enabled() 276 | 277 | async def async_enable_motion_detection(self): 278 | """Enable motion detection in camera.""" 279 | try: 280 | channel = self._coordinator.get_channel() 281 | await self._coordinator.client.enable_motion_detection(channel, True) 282 | await self._coordinator.async_refresh() 283 | except TypeError: 284 | _LOGGER.debug("Failed enabling motion detection on '%s'. Is it supported by the device?", self._name) 285 | 286 | async def async_disable_motion_detection(self): 287 | """Disable motion detection.""" 288 | try: 289 | channel = self._coordinator.get_channel() 290 | await self._coordinator.client.enable_motion_detection(channel, False) 291 | await self._coordinator.async_refresh() 292 | except TypeError: 293 | _LOGGER.debug("Failed disabling motion detection on '%s'. Is it supported by the device?", self._name) 294 | 295 | @property 296 | def name(self): 297 | """Return the name of this camera.""" 298 | return self._name 299 | 300 | async def async_set_infrared_mode(self, mode: str, brightness: int): 301 | """ Handles the service call from SERVICE_SET_INFRARED_MODE to set infrared mode and brightness """ 302 | channel = self._coordinator.get_channel() 303 | await self._coordinator.client.async_set_lighting_v1_mode(channel, mode, brightness) 304 | await self._coordinator.async_refresh() 305 | 306 | async def async_goto_preset_position(self, position: int): 307 | """ Handles the service call from SERVICE_GOTO_PRESET_POSITION to go to a specific preset position """ 308 | channel = self._coordinator.get_channel() 309 | await self._coordinator.client.async_goto_preset_position(channel, position) 310 | await self._coordinator.async_refresh() 311 | 312 | async def async_set_video_in_day_night_mode(self, config_type: str, mode: str): 313 | """ Handles the service call from SERVICE_SET_DAY_NIGHT_MODE to set the day/night color mode """ 314 | channel = self._coordinator.get_channel() 315 | await self._coordinator.client.async_set_video_in_day_night_mode(channel, config_type, mode) 316 | await self._coordinator.async_refresh() 317 | 318 | async def async_reboot(self): 319 | """ Handles the service call from SERVICE_REBOOT to reboot the device """ 320 | await self._coordinator.client.reboot() 321 | 322 | async def async_set_record_mode(self, mode: str): 323 | """ Handles the service call from SERVICE_SET_RECORD_MODE to set the record mode """ 324 | channel = self._coordinator.get_channel() 325 | await self._coordinator.client.async_set_record_mode(channel, mode) 326 | await self._coordinator.async_refresh() 327 | 328 | async def async_set_video_profile_mode(self, mode: str): 329 | """ Handles the service call from SERVICE_SET_VIDEO_PROFILE_MODE to set profile mode to day/night """ 330 | channel = self._coordinator.get_channel() 331 | model = self._coordinator.get_model() 332 | # Some NVRs like the Lorex DHI-NVR4108HS-8P-4KS2 change the day/night mode through a switch 333 | if any(substring in model for substring in ['NVR4108HS', 'IPC-Color4K']): 334 | await self._coordinator.client.async_set_night_switch_mode(channel, mode) 335 | else: 336 | await self._coordinator.client.async_set_video_profile_mode(channel, mode) 337 | 338 | async def async_adjustfocus(self, focus: str, zoom: str): 339 | """ Handles the service call from SERVICE_SET_INFRARED_MODE to set zoom and focus """ 340 | await self._coordinator.client.async_adjustfocus_v1(focus, zoom) 341 | await self._coordinator.async_refresh() 342 | 343 | async def async_set_privacy_masking(self, index: int, enabled: bool): 344 | """ Handles the service call from SERVICE_SET_PRIVACY_MASKING to control the privacy masking """ 345 | await self._coordinator.client.async_setprivacymask(index, enabled) 346 | 347 | async def async_set_enable_channel_title(self, enabled: bool): 348 | """ Handles the service call from SERVICE_ENABLE_CHANNEL_TITLE """ 349 | channel = self._coordinator.get_channel() 350 | await self._coordinator.client.async_enable_channel_title(channel, enabled) 351 | 352 | async def async_set_enable_time_overlay(self, enabled: bool): 353 | """ Handles the service call from SERVICE_ENABLE_TIME_OVERLAY """ 354 | channel = self._coordinator.get_channel() 355 | await self._coordinator.client.async_enable_time_overlay(channel, enabled) 356 | 357 | async def async_set_enable_text_overlay(self, group: int, enabled: bool): 358 | """ Handles the service call from SERVICE_ENABLE_TEXT_OVERLAY """ 359 | channel = self._coordinator.get_channel() 360 | await self._coordinator.client.async_enable_text_overlay(channel, group, enabled) 361 | 362 | async def async_set_enable_custom_overlay(self, group: int, enabled: bool): 363 | """ Handles the service call from SERVICE_ENABLE_CUSTOM_OVERLAY """ 364 | channel = self._coordinator.get_channel() 365 | await self._coordinator.client.async_enable_custom_overlay(channel, group, enabled) 366 | 367 | async def async_set_enable_all_ivs_rules(self, enabled: bool): 368 | """ Handles the service call from SERVICE_ENABLE_ALL_IVS_RULES """ 369 | channel = self._coordinator.get_channel() 370 | await self._coordinator.client.async_set_all_ivs_rules(channel, enabled) 371 | 372 | async def async_enable_ivs_rule(self, index: int, enabled: bool): 373 | """ Handles the service call from SERVICE_ENABLE_IVS_RULE """ 374 | channel = self._coordinator.get_channel() 375 | await self._coordinator.client.async_set_ivs_rule(channel, index, enabled) 376 | 377 | async def async_vto_open_door(self, door_id: int): 378 | """ Handles the service call from SERVICE_VTO_OPEN_DOOR """ 379 | await self._coordinator.client.async_access_control_open_door(door_id) 380 | 381 | async def async_vto_cancel_call(self): 382 | """ Handles the service call from SERVICE_VTO_CANCEL_CALL to cancel VTO calls """ 383 | await self._coordinator.get_vto_client().cancel_call() 384 | 385 | async def async_set_service_set_channel_title(self, text1: str, text2: str): 386 | """ Handles the service call from SERVICE_SET_CHANNEL_TITLE to set profile mode to day/night """ 387 | channel = self._coordinator.get_channel() 388 | await self._coordinator.client.async_set_service_set_channel_title(channel, text1, text2) 389 | 390 | async def async_set_service_set_text_overlay(self, group: int, text1: str, text2: str, text3: str, 391 | text4: str): 392 | """ Handles the service call from SERVICE_SET_TEXT_OVERLAY to set profile mode to day/night """ 393 | channel = self._coordinator.get_channel() 394 | await self._coordinator.client.async_set_service_set_text_overlay(channel, group, text1, text2, text3, text4) 395 | 396 | async def async_set_service_set_custom_overlay(self, group: int, text1: str, text2: str): 397 | """ Handles the service call from SERVICE_SET_CUSTOM_OVERLAY to set profile mode to day/night """ 398 | channel = self._coordinator.get_channel() 399 | await self._coordinator.client.async_set_service_set_custom_overlay(channel, group, text1, text2) 400 | -------------------------------------------------------------------------------- /custom_components/dahua/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate Dahua cameras with Home Assistant. 3 | """ 4 | import asyncio 5 | from typing import Any, Dict 6 | import logging 7 | import ssl 8 | import time 9 | 10 | from datetime import timedelta 11 | 12 | from homeassistant.components.tag import async_scan_tag 13 | import hashlib 14 | 15 | from aiohttp import ClientError, ClientResponseError, ClientSession, TCPConnector 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.core import CALLBACK_TYPE, HomeAssistant 18 | from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady 19 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 20 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 21 | from homeassistant.helpers.typing import ConfigType 22 | from homeassistant.const import EVENT_HOMEASSISTANT_STOP 23 | 24 | from custom_components.dahua.thread import DahuaEventThread, DahuaVtoEventThread 25 | from . import dahua_utils 26 | from .client import DahuaClient 27 | 28 | from .const import ( 29 | CONF_EVENTS, 30 | CONF_PASSWORD, 31 | CONF_PORT, 32 | CONF_USERNAME, 33 | CONF_ADDRESS, 34 | CONF_NAME, 35 | DOMAIN, 36 | PLATFORMS, 37 | CONF_RTSP_PORT, 38 | STARTUP_MESSAGE, 39 | CONF_CHANNEL, 40 | ) 41 | from .dahua_utils import parse_event 42 | from .vto import DahuaVTOClient 43 | 44 | SCAN_INTERVAL_SECONDS = timedelta(seconds=30) 45 | 46 | SSL_CONTEXT = ssl.create_default_context() 47 | SSL_CONTEXT.set_ciphers("DEFAULT") 48 | SSL_CONTEXT.check_hostname = False 49 | SSL_CONTEXT.verify_mode = ssl.CERT_NONE 50 | 51 | _LOGGER: logging.Logger = logging.getLogger(__package__) 52 | 53 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 54 | """Set up this integration using UI.""" 55 | if hass.data.get(DOMAIN) is None: 56 | hass.data.setdefault(DOMAIN, {}) 57 | _LOGGER.info(STARTUP_MESSAGE) 58 | 59 | username = entry.data.get(CONF_USERNAME) 60 | password = entry.data.get(CONF_PASSWORD) 61 | address = entry.data.get(CONF_ADDRESS) 62 | port = int(entry.data.get(CONF_PORT)) 63 | rtsp_port = int(entry.data.get(CONF_RTSP_PORT)) 64 | events = entry.data.get(CONF_EVENTS) 65 | name = entry.data.get(CONF_NAME) 66 | channel = entry.data.get(CONF_CHANNEL, 0) 67 | 68 | coordinator = DahuaDataUpdateCoordinator(hass, events=events, address=address, port=port, rtsp_port=rtsp_port, 69 | username=username, password=password, name=name, channel=channel) 70 | await coordinator.async_config_entry_first_refresh() 71 | 72 | if not coordinator.last_update_success: 73 | _LOGGER.warning("dahua async_setup_entry for init, data not ready") 74 | raise ConfigEntryNotReady 75 | 76 | hass.data[DOMAIN][entry.entry_id] = coordinator 77 | 78 | # https://developers.home-assistant.io/docs/config_entries_index/ 79 | for platform in PLATFORMS: 80 | if entry.options.get(platform, True): 81 | coordinator.platforms.append(platform) 82 | await hass.config_entries.async_forward_entry_setups(entry, [platform]) 83 | 84 | entry.add_update_listener(async_reload_entry) 85 | 86 | entry.async_on_unload( 87 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_stop) 88 | ) 89 | 90 | return True 91 | 92 | 93 | class DahuaDataUpdateCoordinator(DataUpdateCoordinator): 94 | """Class to manage fetching data from the API.""" 95 | 96 | def __init__(self, hass: HomeAssistant, events: list, address: str, port: int, rtsp_port: int, username: str, 97 | password: str, name: str, channel: int) -> None: 98 | """Initialize the coordinator.""" 99 | # Self signed certs are used over HTTPS so we'll disable SSL verification 100 | connector = TCPConnector(enable_cleanup_closed=True, ssl=SSL_CONTEXT) 101 | self._session = ClientSession(connector=connector) 102 | 103 | # The client used to communicate with Dahua devices 104 | self.client: DahuaClient = DahuaClient(username, password, address, port, rtsp_port, self._session) 105 | 106 | self.platforms = [] 107 | self.initialized = False 108 | self.model = "" 109 | self.connected = None 110 | self.events: list = events 111 | self._supports_coaxial_control = False 112 | self._supports_disarming_linkage = False 113 | self._supports_event_notifications = False 114 | self._supports_smart_motion_detection = False 115 | self._supports_ptz_position = False 116 | self._supports_lighting = False 117 | self._supports_floodlightmode = False 118 | self._serial_number: str 119 | self._profile_mode = "0" 120 | self._preset_position = "0" 121 | self._supports_profile_mode = False 122 | self._channel = channel 123 | self._address = address 124 | self._max_streams = 3 # 1 main stream + 2 sub-streams by default 125 | 126 | self._supports_lighting_v2 = False 127 | 128 | # channel_number is not the channel_index. channel_number is the index + 1. 129 | # So channel index 0 is channel number 1. Except for some older firmwares where channel 130 | # and channel number are the same! We check for this in _async_update_data and adjust the 131 | # channel number as needed. 132 | self._channel_number = channel + 1 133 | 134 | # This is the name for the device given by the user during setup 135 | self._name = name 136 | 137 | # This is the name as reported from the camera itself 138 | self.machine_name = "" 139 | 140 | # This thread is what connects to the cameras event stream and fires on_receive when there's an event 141 | self.dahua_event_thread = DahuaEventThread(hass, self.client, self.on_receive, events, self._channel) 142 | 143 | # This thread will connect to VTO devices (Dahua doorbells) 144 | self.dahua_vto_event_thread = DahuaVtoEventThread(hass, self.client, self.on_receive_vto_event, host=address, 145 | port=5000, username=username, password=password) 146 | 147 | # A dictionary of event name (CrossLineDetection, VideoMotion, etc) to a listener for that event 148 | # The key will be formed from self.get_event_key(event_name) and includes the channel 149 | self._dahua_event_listeners: Dict[str, CALLBACK_TYPE] = dict() 150 | 151 | # A dictionary of event name (CrossLineDetection, VideoMotion, etc) to the time the event fire or was cleared. 152 | # If cleared the time will be 0. The time unit is seconds epoch 153 | self._dahua_event_timestamp: Dict[str, int] = dict() 154 | 155 | self._floodlight_mode = 2 156 | 157 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL_SECONDS) 158 | 159 | async def async_start_event_listener(self): 160 | """ Starts the event listeners for IP cameras (this does not work for doorbells (VTO)) """ 161 | if self.events is not None: 162 | self.dahua_event_thread.start() 163 | 164 | async def async_start_vto_event_listener(self): 165 | """ Starts the event listeners for doorbells (VTO). This will not work for IP cameras""" 166 | if self.dahua_vto_event_thread is not None: 167 | self.dahua_vto_event_thread.start() 168 | 169 | async def async_stop(self, event: Any): 170 | """ Stop anything we need to stop """ 171 | self.dahua_event_thread.stop() 172 | self.dahua_vto_event_thread.stop() 173 | await self._close_session() 174 | 175 | async def _close_session(self) -> None: 176 | _LOGGER.debug("Closing Session") 177 | if self._session != None: 178 | try: 179 | await self._session.close() 180 | self._session = None 181 | except Exception as e: 182 | _LOGGER.exception("serverConnect - failed to close session") 183 | 184 | async def _async_update_data(self): 185 | """Reload the camera information""" 186 | data = {} 187 | 188 | # Do the one time initialization (do this when Home Assistant starts) 189 | if not self.initialized: 190 | try: 191 | # Find the max number of streams. 1 main stream + n number of sub-streams 192 | self._max_streams = await self.client.get_max_extra_streams() + 1 193 | _LOGGER.info("Using max streams %s", self._max_streams) 194 | 195 | machine_name = await self.client.async_get_machine_name() 196 | sys_info = await self.client.async_get_system_info() 197 | version = await self.client.get_software_version() 198 | data.update(machine_name) 199 | data.update(sys_info) 200 | data.update(version) 201 | 202 | device_type = data.get("deviceType", None) 203 | # Lorex NVRs return deviceType=31, but the model is in the updateSerial 204 | # /cgi-bin/magicBox.cgi?action=getSystemInfo" 205 | # deviceType=31 206 | # processor=ST7108 207 | # serialNumber=ND0219110NNNNN 208 | # updateSerial=DHI-NVR4108HS-8P-4KS2 209 | if device_type in ["IP Camera", "31"] or device_type is None: 210 | # Some firmwares put the device type in the "updateSerial" field. Weird. 211 | device_type = data.get("updateSerial", None) 212 | if device_type is None: 213 | # If it's still none, then call the device type API 214 | dt = await self.client.get_device_type() 215 | device_type = dt.get("type") 216 | data["model"] = device_type 217 | self.model = device_type 218 | self.machine_name = data.get("table.General.MachineName") 219 | self._serial_number = data.get("serialNumber") 220 | 221 | try: 222 | await self.client.async_get_snapshot(0) 223 | # If able to take a snapshot with index 0 then most likely this cams channel needs to be reset 224 | # but check if unit is not a doorbell first as channel 0 doesnt exist for VTOs 225 | if not self.is_doorbell(): 226 | self._channel_number = self._channel 227 | except ClientError: 228 | pass 229 | _LOGGER.info("Using channel number %s", self._channel_number) 230 | 231 | try: 232 | await self.client.async_get_coaxial_control_io_status() 233 | self._supports_coaxial_control = True 234 | except ClientResponseError: 235 | self._supports_coaxial_control = False 236 | _LOGGER.info("Device supports Coaxial Control=%s", self._supports_coaxial_control) 237 | 238 | try: 239 | await self.client.async_get_disarming_linkage() 240 | self._supports_disarming_linkage = True 241 | except ClientError: 242 | self._supports_disarming_linkage = False 243 | _LOGGER.info("Device supports disarming linkage=%s", self._supports_disarming_linkage) 244 | 245 | try: 246 | await self.client.async_get_event_notifications() 247 | self._supports_event_notifications = True 248 | except ClientError: 249 | self._supports_event_notifications = False 250 | _LOGGER.info("Device supports event notifications=%s", self._supports_event_notifications) 251 | 252 | # PTZ 253 | # The following lines are for Dahua devices 254 | try: 255 | await self.client.async_get_ptz_position() 256 | self._supports_ptz_position = True 257 | except ClientError: 258 | self._supports_ptz_position = False 259 | _LOGGER.info("Device supports PTZ position=%s", self._supports_ptz_position) 260 | 261 | # Smart motion detection is enabled/disabled/fetched differently on Dahua devices compared to Amcrest 262 | # The following lines are for Dahua devices 263 | try: 264 | await self.client.async_get_smart_motion_detection() 265 | self._supports_smart_motion_detection = True 266 | except ClientError: 267 | self._supports_smart_motion_detection = False 268 | _LOGGER.info("Device supports smart motion detection=%s", self._supports_smart_motion_detection) 269 | 270 | is_doorbell = self.is_doorbell() 271 | _LOGGER.info("Device is a doorbell=%s", is_doorbell) 272 | 273 | is_flood_light = self.is_flood_light() 274 | _LOGGER.info("Device is a floodlight=%s", is_flood_light) 275 | 276 | self._supports_floodlightmode = self.supports_floodlightmode() 277 | 278 | try: 279 | await self.client.async_get_config_lighting(self._channel, self._profile_mode) 280 | self._supports_lighting = True 281 | except ClientError: 282 | self._supports_lighting = False 283 | pass 284 | _LOGGER.info("Device supports infrared lighting=%s", self.supports_infrared_light()) 285 | 286 | #Checking lighting_v2 support 287 | try: 288 | await self.client.async_get_lighting_v2() 289 | self._supports_lighting_v2 = True 290 | except ClientError: 291 | self._supports_lighting_v2 = False 292 | pass 293 | _LOGGER.info("Device supports Lighting_V2=%s", self._supports_lighting_v2) 294 | 295 | 296 | if not is_doorbell: 297 | # Start the event listeners for IP cameras 298 | await self.async_start_event_listener() 299 | 300 | try: 301 | # Some cams don't support profile modes, check and see... use 2 to check 302 | conf = await self.client.async_get_config("Lighting[0][2]") 303 | # We'll get back an error like this if it doesn't work: 304 | # Error: Error -1 getting param in name=Lighting[0][1] 305 | # Otherwise we'll get multiple lines of config back 306 | self._supports_profile_mode = len(conf) > 1 307 | except ClientError: 308 | _LOGGER.info("Cam does not support profile mode. Will use mode 0") 309 | self._supports_profile_mode = False 310 | _LOGGER.info("Device supports profile mode=%s", self._supports_profile_mode) 311 | else: 312 | # Start the event listeners for doorbells (VTO) 313 | await self.async_start_vto_event_listener() 314 | 315 | self.initialized = True 316 | except Exception as exception: 317 | _LOGGER.error("Failed to initialize device at %s", self._address, exc_info=exception) 318 | raise PlatformNotReady("Dahua device at " + self._address + " isn't fully initialized yet") 319 | 320 | # This is the event loop code that's called every n seconds 321 | try: 322 | # We need the profile mode (0=day, 1=night, 2=scene) 323 | if self._supports_profile_mode and not self.is_doorbell(): 324 | try: 325 | mode_data = await self.client.async_get_video_in_mode() 326 | data.update(mode_data) 327 | self._profile_mode = mode_data.get("table.VideoInMode[0].Config[0]", "0") 328 | if not self._profile_mode: 329 | self._profile_mode = "0" 330 | except Exception as exception: 331 | # I believe this API is missing on some cameras so we'll just ignore it and move on 332 | _LOGGER.debug("Could not get profile mode", exc_info=exception) 333 | pass 334 | 335 | # We need the ptz status 336 | if self._supports_ptz_position: 337 | try: 338 | ptz_data = await self.client.async_get_ptz_position() 339 | data.update(ptz_data) 340 | self._preset_position = ptz_data.get("status.PresetID", "0") 341 | if not self._preset_position: 342 | self._preset_position = "0" 343 | except Exception as exception: 344 | # I believe this API is missing on some cameras so we'll just ignore it and move on 345 | _LOGGER.debug("Could not get preset position", exc_info=exception) 346 | pass 347 | 348 | # Figure out which APIs we need to call and then fan out and gather the results 349 | coros = [ 350 | asyncio.ensure_future(self.client.async_get_config_motion_detection()), 351 | ] 352 | if self.supports_infrared_light(): 353 | coros.append( 354 | asyncio.ensure_future(self.client.async_get_config_lighting(self._channel, self._profile_mode))) 355 | if self._supports_disarming_linkage: 356 | coros.append(asyncio.ensure_future(self.client.async_get_disarming_linkage())) 357 | if self._supports_event_notifications: 358 | coros.append(asyncio.ensure_future(self.client.async_get_event_notifications())) 359 | if self._supports_coaxial_control: 360 | coros.append(asyncio.ensure_future(self.client.async_get_coaxial_control_io_status())) 361 | if self._supports_smart_motion_detection: 362 | coros.append(asyncio.ensure_future(self.client.async_get_smart_motion_detection())) 363 | if self.supports_smart_motion_detection_amcrest(): 364 | coros.append(asyncio.ensure_future(self.client.async_get_video_analyse_rules_for_amcrest())) 365 | if self.is_amcrest_doorbell(): 366 | coros.append(asyncio.ensure_future(self.client.async_get_light_global_enabled())) 367 | if self._supports_lighting_v2: #add lighing_v2 API if it is supported 368 | coros.append(asyncio.ensure_future(self.client.async_get_lighting_v2())) 369 | 370 | 371 | # Gather results and update the data map 372 | results = await asyncio.gather(*coros) 373 | for result in results: 374 | if result is not None: 375 | data.update(result) 376 | 377 | if self.supports_security_light() or self.is_flood_light(): 378 | light_v2 = await self.client.async_get_lighting_v2() 379 | if light_v2 is not None: 380 | data.update(light_v2) 381 | 382 | return data 383 | except Exception as exception: 384 | _LOGGER.warning("Failed to sync device state for %s. See README to enable debug logs to get full exception", 385 | self._address) 386 | _LOGGER.debug("Failed to sync device state for %s", self._address, exc_info=exception) 387 | raise UpdateFailed() from exception 388 | 389 | def on_receive_vto_event(self, event: dict): 390 | event["DeviceName"] = self.get_device_name() 391 | _LOGGER.debug(f"VTO Data received: {event}") 392 | self.hass.bus.fire("dahua_event_received", event) 393 | 394 | # Example events: 395 | # { 396 | # "Code":"VideoMotion", 397 | # "Action":"Start", 398 | # "Data":{ 399 | # "LocaleTime":"2021-06-19 15:36:58", 400 | # "UTC":1624088218.0 401 | # } 402 | # 403 | # { 404 | # "Code":"DoorStatus", 405 | # "Action":"Pulse", 406 | # "Data":{ 407 | # "LocaleTime":"2021-04-11 21:34:52", 408 | # "Status":"Close", 409 | # "UTC":1618148092 410 | # }, 411 | # "Index":0 412 | # } 413 | # 414 | # { 415 | # "Code":"BackKeyLight", 416 | # "Action":"Pulse", 417 | # "Data":{ 418 | # "LocaleTime":"2021-06-20 13:52:20", 419 | # "State":1, 420 | # "UTC":1624168340.0 421 | # }, 422 | # "Index":-1 423 | # } 424 | 425 | # This is the event code, example: VideoMotion, CrossLineDetection, BackKeyLight, PhoneCallDetect, DoorStatus, etc 426 | code = self.translate_event_code(event) 427 | event_key = self.get_event_key(code) 428 | 429 | if code == "AccessControl": 430 | card_id = event.get("Data", {}).get("CardNo", "") 431 | if card_id: 432 | card_id_md5 = hashlib.md5(card_id.encode()).hexdigest() 433 | asyncio.run_coroutine_threadsafe( 434 | async_scan_tag(self.hass, card_id_md5, self.get_device_name()), self.hass.loop 435 | ).result() 436 | 437 | listener = self._dahua_event_listeners.get(event_key) 438 | if listener is not None: 439 | action = event.get("Action", "") 440 | if action == "Start": 441 | self._dahua_event_timestamp[event_key] = int(time.time()) 442 | listener() 443 | elif action == "Stop": 444 | self._dahua_event_timestamp[event_key] = 0 445 | listener() 446 | elif action == "Pulse": 447 | if code == "DoorStatus": 448 | if event.get("Data", {}).get("Status", "") == "Open": 449 | self._dahua_event_timestamp[event_key] = int(time.time()) 450 | else: 451 | self._dahua_event_timestamp[event_key] = 0 452 | else: 453 | state = event.get("Data", {}).get("State", 0) 454 | if state == 1: 455 | # button pressed 456 | self._dahua_event_timestamp[event_key] = int(time.time()) 457 | else: 458 | self._dahua_event_timestamp[event_key] = 0 459 | listener() 460 | 461 | def on_receive(self, data_bytes: bytes, channel: int): 462 | """ 463 | Takes in bytes from the Dahua event stream, converts to a string, parses to a dict and fires an event with the data on the HA event bus 464 | Example input: 465 | 466 | b'Code=VideoMotion;action=Start;index=0;data={\n' 467 | b' "Id" : [ 0 ],\n' 468 | b' "RegionName" : [ "Region1" ]\n' 469 | b'}\n' 470 | b'\r\n' 471 | 472 | 473 | Example events that are fired on the HA event bus: 474 | {'name': 'Cam13', 'Code': 'VideoMotion', 'action': 'Start', 'index': '0', 'data': {'Id': [0], 'RegionName': ['Region1'], 'SmartMotionEnable': False}} 475 | {'name': 'Cam13', 'Code': 'VideoMotion', 'action': 'Stop', 'index': '0', 'data': {'Id': [0], 'RegionName': ['Region1'], 'SmartMotionEnable': False}} 476 | { 477 | 'name': 'Cam8', 'Code': 'CrossLineDetection', 'action': 'Start', 'index': '0', 'data': {'Class': 'Normal', 'DetectLine': [[18, 4098], [8155, 5549]], 'Direction': 'RightToLeft', 'EventSeq': 40, 'FrameSequence': 549073, 'GroupID': 40, 'Mark': 0, 'Name': 'Rule1', 'Object': {'Action': 'Appear', 'BoundingBox': [4816, 4552, 5248, 5272], 'Center': [5032, 4912], 'Confidence': 0, 'FrameSequence': 0, 'ObjectID': 542, 'ObjectType': 'Unknown', 'RelativeID': 0, 'Source': 0.0, 'Speed': 0, 'SpeedTypeInternal': 0}, 'PTS': 42986015370.0, 'RuleId': 1, 'Source': 51190936.0, 'Track': None, 'UTC': 1620477656, 'UTCMS': 180} 478 | } 479 | """ 480 | data = data_bytes.decode("utf-8", errors="ignore") 481 | events = parse_event(data) 482 | 483 | if len(events) == 0: 484 | return 485 | 486 | _LOGGER.debug(f"Events received from {self.get_address()} on channel {channel}: {events}") 487 | 488 | for event in events: 489 | index = 0 490 | if "index" in event: 491 | try: 492 | index = int(event["index"]) 493 | except ValueError: 494 | index = 0 495 | 496 | # This is a short term fix. Right now for NVRs this integration creates a thread per channel to listen to events. Every thread gets the same response. We need to 497 | # discard events not for this channel. Longer term work should create only a single thread per channel. 498 | if index != self._channel: 499 | continue 500 | 501 | # Put the vent on the HA event bus 502 | event["name"] = self.get_device_name() 503 | event["DeviceName"] = self.get_device_name() 504 | self.hass.bus.fire("dahua_event_received", event) 505 | 506 | # When there's an event start we'll update the a map x to the current timestamp in seconds for the event. 507 | # We'll reset it to 0 when the event stops. 508 | # We'll use these timestamps in binary_sensor to know how long to trigger the sensor 509 | 510 | # This is the event code, example: VideoMotion, CrossLineDetection, etc 511 | event_name = self.translate_event_code(event) 512 | 513 | event_key = self.get_event_key(event_name) 514 | listener = self._dahua_event_listeners.get(event_key) 515 | if listener is not None: 516 | action = event["action"] 517 | if action == "Start": 518 | self._dahua_event_timestamp[event_key] = int(time.time()) 519 | listener() 520 | elif action == "Stop": 521 | self._dahua_event_timestamp[event_key] = 0 522 | listener() 523 | 524 | def translate_event_code(self, event: dict): 525 | """ 526 | translate_event_code will try to convert the event code to a less specific event code if the device doesn't have a listener for the more specific type 527 | Example event codes: VideoMotion, CrossLineDetection, BackKeyLight, DoorStatus 528 | """ 529 | code = event.get("Code", "") 530 | 531 | # For CrossLineDetection, the event data will look like this... and if there's a human detected then we'll use the SmartMotionHuman code instead 532 | # { 533 | # "Code": "CrossLineDetection", 534 | # "Data": { 535 | # "Object": { 536 | # "ObjectType": "Human", 537 | # } 538 | # } 539 | # } 540 | if code == "CrossLineDetection" or code == "CrossRegionDetection": 541 | data = event.get("data", event.get("Data", {})) 542 | is_human = data.get("Object", {}).get("ObjectType", "").lower() == "human" 543 | if is_human and self._dahua_event_listeners.get(self.get_event_key(code)) is None: 544 | return "SmartMotionHuman" 545 | 546 | # Convert doorbell pressed related events to common event name, DoorbellPressed. 547 | # VTO devices will use the event BackKeyLight and the Amcrest devices seem to use PhoneCallDetect 548 | if code == "BackKeyLight" or code == "PhoneCallDetect": 549 | code = "DoorbellPressed" 550 | 551 | return code 552 | 553 | def get_event_timestamp(self, event_name: str) -> int: 554 | """ 555 | Returns the event timestamp. If the event is firing then it will be the time of the firing. Otherwise returns 0. 556 | event_name: the event name, example: CrossLineDetection 557 | """ 558 | event_key = self.get_event_key(event_name) 559 | return self._dahua_event_timestamp.get(event_key, 0) 560 | 561 | def add_dahua_event_listener(self, event_name: str, listener: CALLBACK_TYPE): 562 | """ Adds an event listener for the given event (CrossLineDetection, etc). 563 | This callback will be called when the event fire """ 564 | event_key = self.get_event_key(event_name) 565 | self._dahua_event_listeners[event_key] = listener 566 | 567 | def supports_siren(self) -> bool: 568 | """ 569 | Returns true if this camera has a siren. For example, the IPC-HDW3849HP-AS-PV does 570 | https://dahuawiki.com/Template:NameConvention 571 | """ 572 | m = self.model.upper() 573 | return "-AS-PV" in m or "L46N" in m or m.startswith("W452ASD") 574 | 575 | def supports_security_light(self) -> bool: 576 | """ 577 | Returns true if this camera has the red/blue flashing security light feature. For example, the 578 | IPC-HDW3849HP-AS-PV does https://dahuawiki.com/Template:NameConvention 579 | Addressed issue https://github.com/rroller/dahua/pull/405 580 | """ 581 | return "-AS-PV" in self.model or self.model == "AD410" or self.model == "DB61i" or self.model.startswith("IP8M-2796E") 582 | 583 | def is_doorbell(self) -> bool: 584 | """ Returns true if this is a doorbell (VTO) """ 585 | m = self.model.upper() 586 | return m.startswith("VTO") or m.startswith("DH-VTO") or ( 587 | "NVR" not in m and m.startswith("DHI")) or self.is_amcrest_doorbell() or self.is_empiretech_doorbell() or self.is_avaloidgoliath_doorbell() 588 | 589 | def is_amcrest_doorbell(self) -> bool: 590 | """ Returns true if this is an Amcrest doorbell - IMOU DB61i is identical """ 591 | return self.model.upper().startswith("AD") or self.model.upper().startswith("DB6") 592 | 593 | def is_empiretech_doorbell(self) -> bool: 594 | """ Returns true if this is an EmpireTech doorbell """ 595 | return self.model.upper().startswith("DB2X") 596 | 597 | def is_avaloidgoliath_doorbell(self) -> bool: 598 | """ Returns true if this is an Avaloid Goliath doorbell """ 599 | return self.model.upper().startswith("AV-V") 600 | 601 | def is_flood_light(self) -> bool: 602 | """ Returns true if this camera is an floodlight camera (eg.ASH26-W) """ 603 | m = self.model.upper() 604 | return m.startswith("ASH26") or "L26N" in m or "L46N" in m or m.startswith("V261LC") or m.startswith("W452ASD") 605 | 606 | def supports_infrared_light(self) -> bool: 607 | """ 608 | Returns true if this camera has an infrared light. For example, the IPC-HDW3849HP-AS-PV does not, but most 609 | others do. I don't know of a better way to detect this 610 | """ 611 | if not self._supports_lighting: 612 | return False 613 | return "-AS-PV" not in self.model and "-AS-NI" not in self.model and "LED-S2" not in self.model #IPC-HFW2439SP-SA-LED-S2 also has no infrared light 614 | 615 | def supports_floodlightmode(self) -> bool: 616 | """ Returns true if this camera supports floodlight mode """ 617 | return "W452ASD" in self.model.upper() or "L46N" in self.model.upper() 618 | 619 | def supports_illuminator(self) -> bool: 620 | """ 621 | Returns true if this camera has an illuminator (white light for color cameras). For example, the 622 | IPC-HDW3849HP-AS-PV does 623 | """ 624 | return not (self.is_amcrest_doorbell() or self.is_flood_light()) and "table.Lighting_V2[{0}][0][0].Mode".format(self._channel) in self.data 625 | 626 | def supports_ptz_position(self) -> bool: 627 | """ 628 | Returns true if this camera supports PTZ preset position 629 | """ 630 | return not (self.is_amcrest_doorbell() or self.is_flood_light()) and "table.Lighting_V2[{0}][0][0].Mode".format(self._channel) in self.data 631 | 632 | def is_motion_detection_enabled(self) -> bool: 633 | """ Returns true if motion detection is enabled for the camera """ 634 | return self.data.get("table.MotionDetect[{0}].Enable".format(self._channel), "").lower() == "true" 635 | 636 | def is_disarming_linkage_enabled(self) -> bool: 637 | """ Returns true if disarming linkage is enable """ 638 | return self.data.get("table.DisableLinkage.Enable", "").lower() == "true" 639 | 640 | def is_event_notifications_enabled(self) -> bool: 641 | """ Returns true if event notifications is enable """ 642 | return self.data.get("table.DisableEventNotify.Enable", "").lower() == "false" 643 | 644 | def is_smart_motion_detection_enabled(self) -> bool: 645 | """ Returns true if smart motion detection is enabled """ 646 | if self.supports_smart_motion_detection_amcrest(): 647 | return self.data.get("table.VideoAnalyseRule[0][0].Enable", "").lower() == "true" 648 | else: 649 | return self.data.get("table.SmartMotionDetect[0].Enable", "").lower() == "true" 650 | 651 | def is_siren_on(self) -> bool: 652 | """ Returns true if the camera siren is on """ 653 | return self.data.get("status.status.Speaker", "").lower() == "on" 654 | 655 | def get_device_name(self) -> str: 656 | """ returns the device name, e.g. Cam 2 """ 657 | if self._name is not None: 658 | return self._name 659 | # Earlier releases of this integration didn't allow for setting the camera name, it always used the machine name 660 | # Now we fall back to the machine name if that wasn't supplied at config time. 661 | return self.machine_name 662 | 663 | def get_model(self) -> str: 664 | """ returns the device model, e.g. IPC-HDW3849HP-AS-PV """ 665 | return self.model 666 | 667 | def get_firmware_version(self) -> str: 668 | """ returns the device firmware e.g. """ 669 | return self.data.get("version") 670 | 671 | def get_serial_number(self) -> str: 672 | """ returns the device serial number. This is unique per device """ 673 | if self._channel > 0: 674 | # We need a unique identifier. For NVRs we get back the same serial, so add the channel to the end of the sn 675 | return "{0}_{1}".format(self._serial_number, self._channel) 676 | return self._serial_number 677 | 678 | def get_event_list(self) -> list: 679 | """ 680 | Returns the list of events selected when configuring the camera in Home Assistant. For example: 681 | [VideoMotion, VideoLoss, CrossLineDetection] 682 | """ 683 | return self.events 684 | 685 | def is_infrared_light_on(self) -> bool: 686 | """ returns true if the infrared light is on """ 687 | return self.data.get("table.Lighting[{0}][0].Mode".format(self._channel),"") == "Manual" 688 | 689 | def get_infrared_brightness(self) -> int: 690 | """Return the brightness of this light, as reported by the camera itself, between 0..255 inclusive""" 691 | 692 | bri = self.data.get("table.Lighting[{0}][0].MiddleLight[0].Light".format(self._channel)) 693 | return dahua_utils.dahua_brightness_to_hass_brightness(bri) 694 | 695 | def is_illuminator_on(self) -> bool: 696 | """Return true if the illuminator light is on""" 697 | # profile_mode 0=day, 1=night, 2=scene 698 | profile_mode = self.get_profile_mode() 699 | return self.data.get("table.Lighting_V2[{0}][{1}][0].Mode".format(self._channel, profile_mode), "") == "Manual" 700 | 701 | def is_flood_light_on(self) -> bool: 702 | 703 | if self._supports_floodlightmode: 704 | # 'coaxialControlIO.cgi?action=getStatus&channel=1' 705 | return self.data.get("status.status.WhiteLight", "") == "On" 706 | else: 707 | """Return true if the amcrest flood light light is on""" 708 | # profile_mode 0=day, 1=night, 2=scene 709 | profile_mode = self.get_profile_mode() 710 | return self.data.get(f'table.Lighting_V2[{self._channel}][{profile_mode}][1].Mode') == "Manual" 711 | 712 | def is_ring_light_on(self) -> bool: 713 | """Return true if ring light is on for an Amcrest Doorbell""" 714 | return self.data.get("table.LightGlobal[0].Enable") == "true" 715 | 716 | def get_illuminator_brightness(self) -> int: 717 | """Return the brightness of the illuminator light, as reported by the camera itself, between 0..255 inclusive""" 718 | 719 | bri = self.data.get("table.Lighting_V2[{0}][0][0].MiddleLight[0].Light".format(self._channel)) 720 | return dahua_utils.dahua_brightness_to_hass_brightness(bri) 721 | 722 | def is_security_light_on(self) -> bool: 723 | """Return true if the security light is on. This is the red/blue flashing light""" 724 | return self.data.get("status.status.WhiteLight", "") == "On" 725 | 726 | def get_profile_mode(self) -> str: 727 | # profile_mode 0=day, 1=night, 2=scene 728 | return self._profile_mode 729 | 730 | def get_channel(self) -> int: 731 | """returns the channel index of this camera. 0 based. Channel index 0 is channel number 1""" 732 | return self._channel 733 | 734 | def get_channel_number(self) -> int: 735 | """returns the channel number of this camera""" 736 | return self._channel_number 737 | 738 | def get_event_key(self, event_name: str) -> str: 739 | """returns the event key we use for listeners. It uses the channel index to support multiple channels""" 740 | return "{0}-{1}".format(event_name, self._channel) 741 | 742 | def get_address(self) -> str: 743 | """returns the IP address of this camera""" 744 | return self._address 745 | 746 | def get_max_streams(self) -> int: 747 | """Returns the max number of streams supported by the device. All streams might not be enabled though""" 748 | return self._max_streams 749 | 750 | def supports_smart_motion_detection(self) -> bool: 751 | """ True if smart motion detection is supported""" 752 | return self._supports_smart_motion_detection 753 | 754 | def supports_smart_motion_detection_amcrest(self) -> bool: 755 | """ True if smart motion detection is supported for an amcrest device""" 756 | return self.model == "AD410" or self.model == "DB61i" 757 | 758 | def get_vto_client(self) -> DahuaVTOClient: 759 | """ 760 | Returns an instance of the connected VTO client if this is a VTO device. We need this because there's different 761 | ways to call a VTO device and the VTO client will handle that. For example, to hang up a call 762 | """ 763 | return self.dahua_vto_event_thread.vto_client 764 | 765 | 766 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 767 | """Handle removal of an entry.""" 768 | coordinator = hass.data[DOMAIN][entry.entry_id] 769 | coordinator.dahua_event_thread.stop() 770 | coordinator.dahua_vto_event_thread.stop() 771 | unloaded = all( 772 | await asyncio.gather( 773 | *[ 774 | hass.config_entries.async_forward_entry_unload(entry, platform) 775 | for platform in PLATFORMS 776 | if platform in coordinator.platforms 777 | ] 778 | ) 779 | ) 780 | if unloaded: 781 | hass.data[DOMAIN].pop(entry.entry_id) 782 | 783 | return unloaded 784 | 785 | 786 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 787 | """Reload config entry.""" 788 | await async_unload_entry(hass, entry) 789 | await async_setup_entry(hass, entry) 790 | --------------------------------------------------------------------------------