├── .gitattributes ├── requirements_dev.txt ├── custom_components ├── __init__.py └── imou_life │ ├── manifest.json │ ├── diagnostics.py │ ├── coordinator.py │ ├── binary_sensor.py │ ├── translations │ ├── en.json │ ├── it-IT.json │ ├── es-ES.json │ ├── ca.json │ ├── pt-BR.json │ ├── fr.json │ └── id.json │ ├── services.yaml │ ├── sensor.py │ ├── select.py │ ├── button.py │ ├── const.py │ ├── siren.py │ ├── entity.py │ ├── switch.py │ ├── camera.py │ ├── __init__.py │ └── config_flow.py ├── tests ├── __init__.py ├── const.py ├── test_init.py ├── test_switch.py ├── conftest.py └── test_config_flow.py ├── hacs.json ├── requirements_test.txt ├── .github ├── workflows │ ├── constraints.txt │ ├── release.yaml │ └── test.yaml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .cookiecutter.json ├── LICENSE ├── .pre-commit-config.yaml ├── setup.cfg ├── .gitignore ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """imou_life integration.""" 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for imou_life integration.""" 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Imou Life", 3 | "hacs": "1.6.0", 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements_dev.txt 2 | pytest-homeassistant-custom-component==0.13.249 3 | imouapi==1.0.15 4 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==25.1.1 2 | pre-commit==4.2.0 3 | black==25.1.0 4 | flake8==7.2.0 5 | isort==6.0.1 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "venv/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.imou_life: debug 7 | # If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 8 | # debugpy: 9 | -------------------------------------------------------------------------------- /custom_components/imou_life/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "imou_life", 3 | "name": "Imou Life", 4 | "codeowners": ["@user2684"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/user2684/imou_life", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/user2684/imou_life/issues", 10 | "requirements": ["imouapi==1.0.15"], 11 | "version": "1.0.16" 12 | } 13 | -------------------------------------------------------------------------------- /.cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "_output_dir": "/workspaces/homeassistant/cookie", 3 | "_template": "gh:oncleben31/cookiecutter-homeassistant-custom-component", 4 | "class_name_prefix": "ImouLife", 5 | "domain_name": "imou_life", 6 | "friendly_name": "Home Assistant custom component for controlling Imou devices", 7 | "github_user": "user2684", 8 | "project_name": "imou_life", 9 | "test_suite": "no", 10 | "version": "0.1.0" 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Describe alternatives you've considered** 13 | A clear and concise description of any alternative solutions or features you've considered. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.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 | } 30 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /custom_components/imou_life/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for imou_life.""" 2 | 3 | from typing import Any 4 | 5 | from homeassistant.components.diagnostics import async_redact_data 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | 9 | from .const import DOMAIN 10 | from .coordinator import ImouDataUpdateCoordinator 11 | 12 | 13 | async def async_get_config_entry_diagnostics( 14 | hass: HomeAssistant, entry: ConfigEntry 15 | ) -> dict[str, Any]: 16 | """Return diagnostics for a config entry.""" 17 | coordinator: ImouDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] 18 | to_redact = { 19 | "app_id", 20 | "app_secret", 21 | "access_token", 22 | "device_id", 23 | "entry_id", 24 | "unique_id", 25 | } 26 | return { 27 | "entry": async_redact_data(entry.as_dict(), to_redact), 28 | "device_info": async_redact_data( 29 | coordinator.device.get_diagnostics(), to_redact 30 | ), 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 15 | 16 | ## Version of the custom_component 17 | 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | Add your logs here. 26 | ``` 27 | 28 | ## Describe the bug 29 | 30 | A clear and concise description of what the bug is. 31 | 32 | ## Debug log 33 | 34 | 35 | 36 | ```text 37 | 38 | Add your logs here. 39 | 40 | ``` 41 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ludeeus/container:integration-debian", 4 | "name": "Home Assistant custom component for controlling Imou devices integration development", 5 | "context": "..", 6 | "appPort": ["9123:8123"], 7 | "postCreateCommand": "container install", 8 | "extensions": [ 9 | "ms-python.python", 10 | "github.vscode-pull-request-github", 11 | "ryanluker.vscode-coverage-gutters", 12 | "ms-python.vscode-pylance" 13 | ], 14 | "settings": { 15 | "files.eol": "\n", 16 | "editor.tabSize": 4, 17 | "terminal.integrated.shell.linux": "/bin/bash", 18 | "python.pythonPath": "/usr/bin/python3", 19 | "python.analysis.autoSearchPaths": false, 20 | "python.linting.pylintEnabled": true, 21 | "python.linting.enabled": true, 22 | "python.formatting.provider": "black", 23 | "editor.formatOnPaste": false, 24 | "editor.formatOnSave": true, 25 | "editor.formatOnType": true, 26 | "files.trimTrailingWhitespace": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 user2684 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. 22 | -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Constants for imou_life tests.""" 2 | 3 | from custom_components.imou_life.const import ( 4 | CONF_API_URL, 5 | CONF_APP_ID, 6 | CONF_APP_SECRET, 7 | CONF_DEVICE_ID, 8 | CONF_DEVICE_NAME, 9 | CONF_DISCOVERED_DEVICE, 10 | CONF_ENABLE_DISCOVER, 11 | ) 12 | 13 | MOCK_CONFIG_ENTRY = { 14 | CONF_API_URL: "http://api.url", 15 | CONF_APP_ID: "app_id", 16 | CONF_APP_SECRET: "app_secret", 17 | CONF_DEVICE_NAME: "device_name", 18 | CONF_DEVICE_ID: "device_id", 19 | } 20 | 21 | 22 | MOCK_LOGIN_WITH_DISCOVER = { 23 | CONF_API_URL: "http://api.url", 24 | CONF_APP_ID: "app_id", 25 | CONF_APP_SECRET: "app_secret", 26 | CONF_ENABLE_DISCOVER: True, 27 | } 28 | 29 | MOCK_LOGIN_WITHOUT_DISCOVER = { 30 | CONF_API_URL: "http://api.url", 31 | CONF_APP_ID: "app_id", 32 | CONF_APP_SECRET: "app_secret", 33 | CONF_ENABLE_DISCOVER: False, 34 | } 35 | 36 | MOCK_CREATE_ENTRY_FROM_DISCOVER = { 37 | CONF_DEVICE_NAME: "device_name", 38 | CONF_DISCOVERED_DEVICE: "device_id", 39 | } 40 | 41 | MOCK_CREATE_ENTRY_FROM_MANUAL = { 42 | CONF_DEVICE_ID: "device_id", 43 | CONF_DEVICE_NAME: "device_name", 44 | } 45 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.3.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: local 10 | hooks: 11 | - id: black 12 | name: black 13 | entry: black 14 | language: system 15 | types: [python] 16 | require_serial: true 17 | - id: flake8 18 | name: flake8 19 | entry: flake8 20 | language: system 21 | types: [python] 22 | require_serial: true 23 | - repo: https://github.com/PyCQA/isort 24 | rev: 5.12.0 25 | hooks: 26 | - id: isort 27 | - repo: https://github.com/codespell-project/codespell 28 | rev: v2.1.0 29 | hooks: 30 | - id: codespell 31 | args: 32 | - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,iif,ines,ist,lightsensor,mut,nd,pres,referer,rime,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort,ba,haa,pullrequests 33 | - --skip="./.*,*.csv,*.json" 34 | - --quiet-level=2 35 | exclude_types: [csv, json] 36 | exclude: ^tests/fixtures/|homeassistant/generated/ 37 | -------------------------------------------------------------------------------- /custom_components/imou_life/coordinator.py: -------------------------------------------------------------------------------- 1 | """Class to manage fetching data from the API.""" 2 | 3 | from datetime import timedelta 4 | import logging 5 | 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 8 | from imouapi.device import ImouDevice 9 | from imouapi.exceptions import ImouException 10 | 11 | from .const import DOMAIN 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | 16 | class ImouDataUpdateCoordinator(DataUpdateCoordinator): 17 | """Implement the DataUpdateCoordinator.""" 18 | 19 | def __init__( 20 | self, 21 | hass: HomeAssistant, 22 | device: ImouDevice, 23 | scan_interval: int, 24 | ) -> None: 25 | """Initialize.""" 26 | self.device = device 27 | self.scan_inteval = scan_interval 28 | self.platforms = [] 29 | self.entities = [] 30 | super().__init__( 31 | hass, 32 | _LOGGER, 33 | name=DOMAIN, 34 | update_interval=timedelta(seconds=self.scan_inteval), 35 | ) 36 | _LOGGER.debug( 37 | "Initialized coordinator. Scan internal %d seconds", self.scan_inteval 38 | ) 39 | 40 | async def _async_update_data(self): 41 | """HA calls this every DEFAULT_SCAN_INTERVAL to run the update.""" 42 | try: 43 | return await self.device.async_get_data() 44 | except ImouException as exception: 45 | _LOGGER.error(exception.to_string()) 46 | raise UpdateFailed() from exception 47 | -------------------------------------------------------------------------------- /custom_components/imou_life/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for Imou.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | 6 | from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN 11 | from .entity import ImouEntity 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | 16 | # async def async_setup_entry(hass, entry, async_add_devices): 17 | async def async_setup_entry( 18 | hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable 19 | ): 20 | """Configure platform.""" 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | device = coordinator.device 23 | sensors = [] 24 | for sensor_instance in device.get_sensors_by_platform("binary_sensor"): 25 | sensor = ImouBinarySensor(coordinator, entry, sensor_instance, ENTITY_ID_FORMAT) 26 | sensors.append(sensor) 27 | coordinator.entities.append(sensor) 28 | _LOGGER.debug( 29 | "[%s] Adding %s", device.get_name(), sensor_instance.get_description() 30 | ) 31 | async_add_devices(sensors) 32 | 33 | 34 | class ImouBinarySensor(ImouEntity, BinarySensorEntity): 35 | """imou binary sensor class.""" 36 | 37 | @property 38 | def is_on(self): 39 | """Return the state of the sensor.""" 40 | return self.sensor_instance.is_on() 41 | 42 | @property 43 | def device_class(self) -> str: 44 | """Device device class.""" 45 | if self.sensor_instance.get_name() == "motionAlarm": 46 | return "motion" 47 | return None 48 | -------------------------------------------------------------------------------- /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=88 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.imou_life 35 | combine_as_imports = true 36 | 37 | [tool:pytest] 38 | addopts = -qq --cov=custom_components.imou_life 39 | console_output_style = count 40 | 41 | [coverage:run] 42 | branch = False 43 | 44 | [coverage:report] 45 | exclude_lines = 46 | pragma: no cover 47 | def __repr__ 48 | if self.debug: 49 | if settings.DEBUG 50 | raise AssertionError 51 | raise NotImplementedError 52 | if 0: 53 | if __name__ == .__main__.: 54 | def main 55 | 56 | [tox:tox] 57 | isolated_build = true 58 | #envlist = py36, py37, py38, py39, format, lint, build 59 | envlist = py39, format, lint, build 60 | -------------------------------------------------------------------------------- /custom_components/imou_life/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "login": { 5 | "title": "Login", 6 | "data": { 7 | "api_url": "API Base URL", 8 | "app_id": "Imou App ID", 9 | "app_secret": "Imou App Secret", 10 | "enable_discover": "Discover registered devices" 11 | } 12 | }, 13 | "discover": { 14 | "title": "Discovered Devices", 15 | "data": { 16 | "discovered_device": "Select the device to add:", 17 | "device_name": "Rename as" 18 | } 19 | }, 20 | "manual": { 21 | "title": "Add Device", 22 | "data": { 23 | "device_id": "Device ID", 24 | "device_name": "Rename as" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "not_connected": "Action requested but not yet connected to the API", 30 | "connection_failed": "Failed to connect to the API", 31 | "invalid_configuration": "Invalid App Id or App Secret provided", 32 | "not_authorized": "Not authorized to operate on the device or invalid device id", 33 | "api_error": "Remote API error", 34 | "invalid_reponse": "Malformed or unexpected API response", 35 | "device_offline": "Device is offline", 36 | "generic_error": "An unknown error occurred" 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "data": { 43 | "scan_interval": "Polling interval (seconds)", 44 | "api_timeout": "API timeout (seconds)", 45 | "callback_url": "Callback URL", 46 | "camera_wait_before_download": "Wait before downloading camera snapshot (seconds)", 47 | "wait_after_wakeup": "Wait after waking up dormant device (seconds)" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/imou_life/services.yaml: -------------------------------------------------------------------------------- 1 | ptz_location: 2 | name: PTZ Location 3 | description: If your device supports PTZ, you will be able to move it to a specified location 4 | target: 5 | entity: 6 | integration: imou_life 7 | domain: camera 8 | fields: 9 | horizontal: 10 | name: Horizontal 11 | description: "Horizontal position." 12 | default: 0 13 | selector: 14 | number: 15 | min: -1 16 | max: 1 17 | step: 0.1 18 | vertical: 19 | name: Vertical 20 | description: "Vertical position." 21 | default: 0 22 | selector: 23 | number: 24 | min: -1 25 | max: 1 26 | step: 0.1 27 | zoom: 28 | name: Zoom 29 | description: "Zoom." 30 | default: 0 31 | selector: 32 | number: 33 | min: 0 34 | max: 1 35 | step: 0.1 36 | ptz_move: 37 | name: PTZ Move 38 | description: If your device supports PTZ, you will be able to move it around 39 | target: 40 | entity: 41 | integration: imou_life 42 | domain: camera 43 | fields: 44 | operation: 45 | name: Operation 46 | description: "Operation to execute." 47 | selector: 48 | select: 49 | options: 50 | - "UP" 51 | - "DOWN" 52 | - "LEFT" 53 | - "RIGHT" 54 | - "UPPER_LEFT" 55 | - "BOTTOM_LEFT" 56 | - "UPPER_RIGHT" 57 | - "BOTTOM_RIGHT" 58 | - "ZOOM_IN" 59 | - "ZOOM_OUT" 60 | - "STOP" 61 | duration: 62 | name: Duration 63 | description: "Duration in milliseconds." 64 | default: 1000 65 | selector: 66 | number: 67 | min: 100 68 | max: 10000 69 | step: 100 70 | -------------------------------------------------------------------------------- /custom_components/imou_life/translations/it-IT.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "login": { 5 | "title": "Login", 6 | "data": { 7 | "api_url": "URL delle API", 8 | "app_id": "Imou App ID", 9 | "app_secret": "Imou App Secret", 10 | "enable_discover": "Individua dispositivi registrati" 11 | } 12 | }, 13 | "discover": { 14 | "title": "Dispositivi Individuati", 15 | "data": { 16 | "discovered_device": "Seleziona il dispositivo da aggiungere:", 17 | "device_name": "Rinominalo in" 18 | } 19 | }, 20 | "manual": { 21 | "title": "Aggiungi dispositivo", 22 | "data": { 23 | "device_id": "ID dispositivo", 24 | "device_name": "Rinominalo in" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "not_connected": "Azione richiesta ma non ancora connesso alle API", 30 | "connection_failed": "Impossibile collegarsi alle API", 31 | "invalid_configuration": "App Id o App Secret non corretti", 32 | "not_authorized": "Non autorizzato ad operare sul dispositivo o ID dispositivo non valido", 33 | "api_error": "Errore API remote", 34 | "invalid_reponse": "Risposta malformata o inaspettata dalle API", 35 | "device_offline": "Dispositivo non in linea", 36 | "generic_error": "Errore sconosciuto" 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "data": { 43 | "scan_interval": "Intervallo di aggiornamento (secondi)", 44 | "api_timeout": "Timeout delle API (secondi)", 45 | "callback_url": "URL di callback", 46 | "camera_wait_before_download": "Attendi prima di scaricare l'immagine dalla camera (secondi)", 47 | "wait_after_wakeup": "Attendi dopo aver svegliato un dispositivo dormente (secondi)" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/imou_life/translations/es-ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "login": { 5 | "title": "Acceso", 6 | "data": { 7 | "api_url": "URL de la API", 8 | "app_id": "ID de app Imou", 9 | "app_secret": "Secreto de app Imou", 10 | "enable_discover": "Descubrir dispositivos registrados" 11 | } 12 | }, 13 | "discover": { 14 | "title": "Dispositivos descubiertos", 15 | "data": { 16 | "discovered_device": "Selecione el dispositivo a añadir:", 17 | "device_name": "Renombrar como" 18 | } 19 | }, 20 | "manual": { 21 | "title": "Añadir Dispositivo", 22 | "data": { 23 | "device_id": "ID de dispositivo", 24 | "device_name": "Renombrar como" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "not_connected": "Acción solicitada, pendiente de conexión con la API", 30 | "connection_failed": "Error al conectar con la API", 31 | "invalid_configuration": "ID de app o secreto inválidos", 32 | "not_authorized": "No autorizado a operar el dispositivo o ID de dispositivo inválido", 33 | "api_error": "Error remoto de API", 34 | "invalid_reponse": "Respuesta de API malformada o inesperada", 35 | "device_offline": "El dispositivo está desconectado", 36 | "generic_error": "Ha ocurrido un error inesperado" 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "data": { 43 | "scan_interval": "Intervalo de peticiones (segundos)", 44 | "api_timeout": "Tiempo límite de API (segundos)", 45 | "callback_url": "URL de retorno de llamada", 46 | "camera_wait_before_download": "Espere antes de descargar la instantánea de la cámara (segundos)", 47 | "wait_after_wakeup": "Espere después de activar el dispositivo inactivo (segundos)" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/imou_life/translations/ca.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "login": { 5 | "title": "Accés", 6 | "data": { 7 | "api_url": "URL base de l'API", 8 | "app_id": "Imou App ID", 9 | "app_secret": "Imou App Secret", 10 | "enable_discover": "Descobrir els dispositius registrats" 11 | } 12 | }, 13 | "discover": { 14 | "title": "Dispositius Descoberts", 15 | "data": { 16 | "discovered_device": "Seleccioneu el dispositiu que voleu afegir:", 17 | "device_name": "Reanomenar com" 18 | } 19 | }, 20 | "manual": { 21 | "title": "Afegir Dispositiu", 22 | "data": { 23 | "device_id": "ID del dispositiu", 24 | "device_name": "Reanomenar com" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "not_connected": "Acció sol·licitada, però encara no està connectat a l'API", 30 | "connection_failed": "Error en connectar-se a l'API", 31 | "invalid_configuration": "App Id o App Secret invàlids", 32 | "not_authorized": "No autoritzat per operar amb el dispositiu o ID del dispositiu no vàlida", 33 | "api_error": "Error de l'API remota", 34 | "invalid_reponse": "Resposta de l'API malformada o inesperada", 35 | "device_offline": "El dispositiu està fora de línia", 36 | "generic_error": "S'ha produït un error desconegut" 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "data": { 43 | "scan_interval": "Interval de peticions (segons)", 44 | "api_timeout": "Temps d'espera de l'API (segons)", 45 | "callback_url": "URL de retorn de trucada", 46 | "camera_wait_before_download": "Espereu abans de baixar la instantània de la càmera (segons)", 47 | "wait_after_wakeup": "Espereu després d'activar el dispositiu inactiu (segons)" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/imou_life/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "login": { 5 | "title": "Login", 6 | "data": { 7 | "api_url": "URL base da API", 8 | "app_id": "ID do app Imou", 9 | "app_secret": "Secret do app Imou", 10 | "enable_discover": "Descubra dispositivos registrados" 11 | } 12 | }, 13 | "discover": { 14 | "title": "Dispositivos descobertos", 15 | "data": { 16 | "discovered_device": "Selecione o dispositivo para adicionar:", 17 | "device_name": "Renomear como" 18 | } 19 | }, 20 | "manual": { 21 | "title": "Adicionar Dispositivo", 22 | "data": { 23 | "device_id": "ID de dispositivo", 24 | "device_name": "Renomear como" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "not_connected": "Ação solicitada, mas ainda não conectada à API", 30 | "connection_failed": "Falha ao conectar-se à API", 31 | "invalid_configuration": "ID de app inválido ou secret de app fornecido inválido", 32 | "not_authorized": "Não autorizado a operar no dispositivo ou ID de dispositivo inválido", 33 | "api_error": "Erro de API remota", 34 | "invalid_reponse": "Resposta de API malformada ou inesperada", 35 | "device_offline": "O dispositivo está offline", 36 | "generic_error": "Ocorreu um erro desconhecido" 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "data": { 43 | "scan_interval": "Intervalo de escaneamento (segundos)", 44 | "api_timeout": "Tempo limite da API (segundos)", 45 | "callback_url": "URL de retorno de chamada", 46 | "camera_wait_before_download": "Aguarde antes de baixar o instantâneo da câmera (segundos)", 47 | "wait_after_wakeup": "Aguarde depois de ativar o dispositivo inativo (segundos)" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | .python-version 4 | .coverage 5 | venv 6 | .venv 7 | 8 | # macOS 9 | .DS_Store 10 | .AppleDouble 11 | .LSOverride 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | env/ 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | downloads/ 28 | eggs/ 29 | .eggs/ 30 | lib/ 31 | lib64/ 32 | parts/ 33 | sdist/ 34 | var/ 35 | wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # celery beat schedule file 90 | celerybeat-schedule 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # dotenv 96 | .env 97 | 98 | # virtualenv 99 | .venv 100 | venv/ 101 | ENV/ 102 | 103 | # Spyder project settings 104 | .spyderproject 105 | .spyproject 106 | 107 | # Rope project settings 108 | .ropeproject 109 | 110 | # mkdocs documentation 111 | /site 112 | 113 | # mypy 114 | .mypy_cache/ 115 | 116 | # IDE settings 117 | .vscode/ 118 | .idea/ 119 | 120 | # mkdocs build dir 121 | site/ 122 | -------------------------------------------------------------------------------- /custom_components/imou_life/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "login": { 5 | "title": "Connexion", 6 | "data": { 7 | "api_url": "URL de base de l'API", 8 | "app_id": "ID de l'application Imou", 9 | "app_secret": "Secret de l'application Imou", 10 | "enable_discover": "Découvrir les appareils enregistrés" 11 | } 12 | }, 13 | "discover": { 14 | "title": "Appareils Découverts", 15 | "data": { 16 | "discovered_device": "Sélectionnez l'appareil à ajouter :", 17 | "device_name": "Renommer en tant que" 18 | } 19 | }, 20 | "manual": { 21 | "title": "Ajouter un Appareil", 22 | "data": { 23 | "device_id": "ID de l'appareil", 24 | "device_name": "Renommer en tant que" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "not_connected": "Action demandée mais pas encore connecté à l'API", 30 | "connection_failed": "Échec de la connexion à l'API", 31 | "invalid_configuration": "ID d'application ou secret d'application invalide", 32 | "not_authorized": "Non autorisé à opérer sur l'appareil ou ID d'appareil invalide", 33 | "api_error": "Erreur de l'API distante", 34 | "invalid_reponse": "Réponse de l'API malformée ou inattendue", 35 | "device_offline": "L'appareil est hors ligne", 36 | "generic_error": "Une erreur inconnue s'est produite" 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "data": { 43 | "scan_interval": "Intervalle de sondage (secondes)", 44 | "api_timeout": "Délai d'expiration de l'API (secondes)", 45 | "callback_url": "URL de rappel", 46 | "camera_wait_before_download": "Attendre avant de télécharger l'instantané de la caméra (secondes)", 47 | "wait_after_wakeup": "Attendre après le réveil d'un appareil en dormance (secondes)" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/imou_life/translations/id.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "login": { 5 | "title": "Masuk", 6 | "data": { 7 | "api_url": "URL API Base", 8 | "app_id": "App ID Imou", 9 | "app_secret": "App Secret Imou", 10 | "enable_discover": "Cari perangkat terdaftar" 11 | } 12 | }, 13 | "discover": { 14 | "title": "Perangkat Ditemukan", 15 | "data": { 16 | "discovered_device": "Pilih perangkat untuk ditambahkan:", 17 | "device_name": "Ganti nama sebagai" 18 | } 19 | }, 20 | "manual": { 21 | "title": "Tambah Perangkat", 22 | "data": { 23 | "device_id": "ID perangkat", 24 | "device_name": "Ganti nama sebagai" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "not_connected": "Tindakan diminta tetapi belum terhubung ke API", 30 | "connection_failed": "Gagal terhubung ke API", 31 | "invalid_configuration": "ID Aplikasi atau App Secret tidak valid", 32 | "not_authorized": "Tidak diizinkan untuk beroperasi pada perangkat atau ID perangkat tidak valid", 33 | "api_error": "Kesalahan API jarak jauh", 34 | "invalid_reponse": "Respon API cacat atau tidak terduga", 35 | "device_offline": "Perangkat tidak terhubung ke jaringan", 36 | "generic_error": "Terjadi kesalahan yang tidak diketahui" 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "data": { 43 | "scan_interval": "Interval pemindaian (detik)", 44 | "api_timeout": "Waktu tunggu API (detik)", 45 | "callback_url": "URL Callback", 46 | "camera_wait_before_download": "Tunggu sebelum mengunduh snapshot kamera (detik)", 47 | "wait_after_wakeup": "Tunggu setelah membangunkan perangkat dorman (detik)" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Publish a new release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | draft_release: 8 | name: Release Drafter 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out the repository 12 | uses: actions/checkout@v3.0.2 13 | 14 | - name: Get integration name 15 | id: information 16 | shell: bash 17 | run: | 18 | name=$(find custom_components/ -type d -maxdepth 1 | tail -n 1 | cut -d "/" -f2) 19 | echo "name: $name" 20 | echo "::set-output name=name::$name" 21 | 22 | - name: Get integration version from manifest 23 | id: version 24 | shell: bash 25 | run: | 26 | version=$(jq -r '.version' custom_components/${{ steps.information.outputs.name }}/manifest.json) 27 | echo "version: $version" 28 | echo "::set-output name=version::$version" 29 | 30 | - name: Get Changelog Entry 31 | id: changelog_reader 32 | uses: mindsers/changelog-reader-action@v2 33 | with: 34 | validation_depth: 10 35 | version: ${{ steps.version.outputs.version }} 36 | path: ./CHANGELOG.md 37 | 38 | - name: Create zip file for the integration 39 | run: | 40 | cd "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}" 41 | zip ${{ steps.information.outputs.name }}.zip -r ./ 42 | 43 | - name: draft github release 44 | id: draft_release 45 | uses: softprops/action-gh-release@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | name: ${{ steps.version.outputs.version }} 50 | tag_name: ${{ steps.version.outputs.version }} 51 | body: ${{ steps.changelog_reader.outputs.changes }} 52 | files: "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/${{ steps.information.outputs.name }}.zip" 53 | draft: true 54 | prerelease: false 55 | -------------------------------------------------------------------------------- /custom_components/imou_life/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for Imou.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | 6 | from homeassistant.components.sensor import ENTITY_ID_FORMAT 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN 11 | from .entity import ImouEntity 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | 16 | # async def async_setup_entry(hass, entry, async_add_devices): 17 | async def async_setup_entry( 18 | hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable 19 | ): 20 | """Configure platform.""" 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | device = coordinator.device 23 | sensors = [] 24 | for sensor_instance in device.get_sensors_by_platform("sensor"): 25 | sensor = ImouSensor(coordinator, entry, sensor_instance, ENTITY_ID_FORMAT) 26 | sensors.append(sensor) 27 | coordinator.entities.append(sensor) 28 | _LOGGER.debug( 29 | "[%s] Adding %s", device.get_name(), sensor_instance.get_description() 30 | ) 31 | async_add_devices(sensors) 32 | 33 | 34 | class ImouSensor(ImouEntity): 35 | """imou sensor class.""" 36 | 37 | @property 38 | def device_class(self) -> str: 39 | """Device device class.""" 40 | if self.sensor_instance.get_name() == "lastAlarm": 41 | return "timestamp" 42 | return None 43 | 44 | @property 45 | def unit_of_measurement(self) -> str: 46 | """Provide unit of measurement.""" 47 | if self.sensor_instance.get_name() == "storageUsed": 48 | return "%" 49 | if self.sensor_instance.get_name() == "battery": 50 | return "%" 51 | return None 52 | 53 | @property 54 | def state(self): 55 | """Return the state of the sensor.""" 56 | if self.sensor_instance.get_state() is None: 57 | self.entity_available = False 58 | return self.sensor_instance.get_state() 59 | -------------------------------------------------------------------------------- /custom_components/imou_life/select.py: -------------------------------------------------------------------------------- 1 | """Switch platform for Imou.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | 6 | from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN 11 | from .entity import ImouEntity 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | 16 | # async def async_setup_entry(hass, entry, async_add_devices): 17 | async def async_setup_entry( 18 | hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable 19 | ): 20 | """Configure platform.""" 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | device = coordinator.device 23 | sensors = [] 24 | for sensor_instance in device.get_sensors_by_platform("select"): 25 | sensor = ImouSelect(coordinator, entry, sensor_instance, ENTITY_ID_FORMAT) 26 | sensors.append(sensor) 27 | coordinator.entities.append(sensor) 28 | _LOGGER.debug( 29 | "[%s] Adding %s", device.get_name(), sensor_instance.get_description() 30 | ) 31 | async_add_devices(sensors) 32 | 33 | 34 | class ImouSelect(ImouEntity, SelectEntity): 35 | """imou select class.""" 36 | 37 | @property 38 | def current_option(self): 39 | """Return current option.""" 40 | return self.sensor_instance.get_current_option() 41 | 42 | @property 43 | def options(self): 44 | """Return available options.""" 45 | return self.sensor_instance.get_available_options() 46 | 47 | async def async_select_option(self, option: str) -> None: 48 | """Se the option.""" 49 | # control the switch 50 | await self.sensor_instance.async_select_option(option) 51 | # save the new state to the state machine (otherwise will be reset by HA and set to the correct value only upon the next update) 52 | self.async_write_ha_state() 53 | _LOGGER.debug( 54 | "[%s] Set %s to %s", 55 | self.device.get_name(), 56 | self.sensor_instance.get_description(), 57 | option, 58 | ) 59 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test imou setup process.""" 2 | 3 | from homeassistant.exceptions import ConfigEntryNotReady 4 | import pytest 5 | from pytest_homeassistant_custom_component.common import MockConfigEntry 6 | 7 | from custom_components.imou_life import ( 8 | async_reload_entry, 9 | async_setup_entry, 10 | async_unload_entry, 11 | ) 12 | from custom_components.imou_life.const import DOMAIN 13 | from custom_components.imou_life.coordinator import ImouDataUpdateCoordinator 14 | 15 | from .const import MOCK_CONFIG_ENTRY 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_setup_unload_and_reload_entry(hass, api_ok): 20 | """Test entry setup and unload.""" 21 | # Create a mock entry so we don't have to go through config flow 22 | config_entry = MockConfigEntry( 23 | domain=DOMAIN, data=MOCK_CONFIG_ENTRY, entry_id="test", version=3 24 | ) 25 | # test setup entry 26 | config_entry.add_to_hass(hass) 27 | assert await hass.config_entries.async_setup(config_entry.entry_id) 28 | assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] 29 | assert isinstance( 30 | hass.data[DOMAIN][config_entry.entry_id], ImouDataUpdateCoordinator 31 | ) 32 | 33 | # Reload the entry and assert that the data from above is still there 34 | assert await async_reload_entry(hass, config_entry) is None 35 | assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] 36 | assert isinstance( 37 | hass.data[DOMAIN][config_entry.entry_id], ImouDataUpdateCoordinator 38 | ) 39 | 40 | # Unload the entry and verify that the data has been removed 41 | assert await async_unload_entry(hass, config_entry) 42 | assert config_entry.entry_id not in hass.data[DOMAIN] 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_setup_entry_exception(hass, api_invalid_data): 47 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 48 | config_entry = MockConfigEntry( 49 | domain=DOMAIN, data=MOCK_CONFIG_ENTRY, entry_id="test" 50 | ) 51 | 52 | # In this case we are testing the condition where async_setup_entry raises ConfigEntryNotReady 53 | with pytest.raises(ConfigEntryNotReady): 54 | assert await async_setup_entry(hass, config_entry) 55 | -------------------------------------------------------------------------------- /custom_components/imou_life/button.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for Imou.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | 6 | from homeassistant.components.button import ENTITY_ID_FORMAT, ButtonEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN 11 | from .entity import ImouEntity 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | 16 | # async def async_setup_entry(hass, entry, async_add_devices): 17 | async def async_setup_entry( 18 | hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable 19 | ): 20 | """Configure platform.""" 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | device = coordinator.device 23 | sensors = [] 24 | for sensor_instance in device.get_sensors_by_platform("button"): 25 | sensor = ImouButton(coordinator, entry, sensor_instance, ENTITY_ID_FORMAT) 26 | sensors.append(sensor) 27 | coordinator.entities.append(sensor) 28 | _LOGGER.debug( 29 | "[%s] Adding %s", device.get_name(), sensor_instance.get_description() 30 | ) 31 | async_add_devices(sensors) 32 | 33 | 34 | class ImouButton(ImouEntity, ButtonEntity): 35 | """imou button class.""" 36 | 37 | async def async_press(self) -> None: 38 | """Handle the button press.""" 39 | # press the button 40 | await self.sensor_instance.async_press() 41 | _LOGGER.debug( 42 | "[%s] Pressed %s", 43 | self.device.get_name(), 44 | self.sensor_instance.get_description(), 45 | ) 46 | # ask the coordinator to refresh data to all the sensors 47 | if self.sensor_instance.get_name() == "refreshData": 48 | await self.coordinator.async_request_refresh() 49 | # refresh the motionAlarm sensor 50 | if self.sensor_instance.get_name() == "refreshAlarm": 51 | # update the motionAlarm sensor 52 | await self.coordinator.device.get_sensor_by_name( 53 | "motionAlarm" 54 | ).async_update() 55 | # ask HA to update its state based on the new value 56 | for entity in self.coordinator.entities: 57 | if entity.sensor_instance.get_name() in "motionAlarm": 58 | await entity.async_update_ha_state() 59 | 60 | @property 61 | def device_class(self) -> str: 62 | """Device device class.""" 63 | if self.sensor_instance.get_name() == "restartDevice": 64 | return "restart" 65 | return None 66 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: 9 | 10 | env: 11 | DEFAULT_PYTHON: 3.13 12 | 13 | jobs: 14 | pre-commit: 15 | runs-on: "ubuntu-latest" 16 | name: Pre-commit 17 | steps: 18 | - name: Check out the repository 19 | uses: actions/checkout@v3.0.2 20 | 21 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 22 | uses: actions/setup-python@v4.2.0 23 | with: 24 | python-version: ${{ env.DEFAULT_PYTHON }} 25 | 26 | - name: Upgrade pip 27 | run: | 28 | pip install --constraint=.github/workflows/constraints.txt pip 29 | pip --version 30 | 31 | - name: Install Python modules 32 | run: | 33 | pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 isort 34 | 35 | - name: Run pre-commit on all files 36 | run: | 37 | pre-commit run --all-files --show-diff-on-failure --color=always 38 | 39 | tests: 40 | runs-on: "ubuntu-latest" 41 | strategy: 42 | max-parallel: 4 43 | matrix: 44 | python-version: [3.13] 45 | name: Run tests 46 | steps: 47 | - name: Check out code from GitHub 48 | uses: "actions/checkout@v2.3.4" 49 | - name: Setup Python ${{ matrix.python-version }} 50 | uses: "actions/setup-python@v2.2.1" 51 | with: 52 | python-version: ${{ matrix.python-version }} 53 | - name: Install requirements 54 | run: | 55 | pip install --constraint=.github/workflows/constraints.txt pip 56 | pip install -r requirements_test.txt 57 | - name: Tests suite 58 | run: | 59 | pytest \ 60 | --asyncio-mode=auto \ 61 | --timeout=9 \ 62 | --durations=10 \ 63 | -n auto \ 64 | -p no:sugar \ 65 | tests 66 | - name: codecov 67 | uses: codecov/codecov-action@v2 68 | 69 | hacs: 70 | runs-on: "ubuntu-latest" 71 | name: HACS 72 | steps: 73 | - name: Check out the repository 74 | uses: "actions/checkout@v3.0.2" 75 | 76 | - name: HACS validation 77 | uses: "hacs/action@22.5.0" 78 | with: 79 | category: "integration" 80 | ignore: brands 81 | 82 | hassfest: 83 | runs-on: "ubuntu-latest" 84 | name: Hassfest 85 | steps: 86 | - name: Check out the repository 87 | uses: "actions/checkout@v3.0.2" 88 | 89 | - name: Hassfest validation 90 | uses: "home-assistant/actions/hassfest@master" 91 | -------------------------------------------------------------------------------- /custom_components/imou_life/const.py: -------------------------------------------------------------------------------- 1 | """Constants.""" 2 | 3 | # Internal constants 4 | DOMAIN = "imou_life" 5 | PLATFORMS = ["switch", "sensor", "binary_sensor", "select", "button", "siren", "camera"] 6 | 7 | # Configuration definitions 8 | CONF_API_URL = "api_url" 9 | CONF_DEVICE_NAME = "device_name" 10 | CONF_APP_ID = "app_id" 11 | CONF_APP_SECRET = "app_secret" 12 | CONF_ENABLE_DISCOVER = "enable_discover" 13 | CONF_DISCOVERED_DEVICE = "discovered_device" 14 | CONF_DEVICE_ID = "device_id" 15 | 16 | OPTION_SCAN_INTERVAL = "scan_interval" 17 | OPTION_API_TIMEOUT = "api_timeout" 18 | OPTION_CALLBACK_URL = "callback_url" 19 | OPTION_API_URL = "api_url" 20 | OPTION_CAMERA_WAIT_BEFORE_DOWNLOAD = "camera_wait_before_download" 21 | OPTION_WAIT_AFTER_WAKE_UP = "wait_after_wakeup" 22 | 23 | SERVIZE_PTZ_LOCATION = "ptz_location" 24 | SERVIZE_PTZ_MOVE = "ptz_move" 25 | ATTR_PTZ_HORIZONTAL = "horizontal" 26 | ATTR_PTZ_VERTICAL = "vertical" 27 | ATTR_PTZ_ZOOM = "zoom" 28 | ATTR_PTZ_OPERATION = "operation" 29 | ATTR_PTZ_DURATION = "duration" 30 | 31 | # Defaults 32 | DEFAULT_SCAN_INTERVAL = 15 * 60 33 | DEFAULT_API_URL = "https://openapi.easy4ip.com/openapi" 34 | 35 | # switches which are enabled by default 36 | ENABLED_SWITCHES = [ 37 | "motionDetect", 38 | "headerDetect", 39 | "abAlarmSound", 40 | "breathingLight", 41 | "closeCamera", 42 | "linkDevAlarm", 43 | "whiteLight", 44 | "smartTrack", 45 | "linkagewhitelight", 46 | "pushNotifications", 47 | ] 48 | 49 | # cameras which are enabled by default 50 | ENABLED_CAMERAS = [ 51 | "camera", 52 | ] 53 | 54 | # icons of the sensors 55 | SENSOR_ICONS = { 56 | "__default__": "mdi:bookmark", 57 | # sensors 58 | "lastAlarm": "mdi:timer", 59 | "storageUsed": "mdi:harddisk", 60 | "callbackUrl": "mdi:phone-incoming", 61 | "status": "mdi:lan-connect", 62 | "battery": "mdi:battery", 63 | # binary sensors 64 | "online": "mdi:check-circle", 65 | "motionAlarm": "mdi:motion-sensor", 66 | # select 67 | "nightVisionMode": "mdi:weather-night", 68 | # switches 69 | "motionDetect": "mdi:motion-sensor", 70 | "headerDetect": "mdi:human", 71 | "abAlarmSound": "mdi:account-voice", 72 | "breathingLight": "mdi:television-ambient-light", 73 | "closeCamera": "mdi:sleep", 74 | "linkDevAlarm": "mdi:bell", 75 | "whiteLight": "mdi:light-flood-down", 76 | "smartTrack": "mdi:radar", 77 | "linkagewhitelight": "mdi:light-flood-down", 78 | "pushNotifications": "mdi:webhook", 79 | # buttons 80 | "restartDevice": "mdi:restart", 81 | "refreshData": "mdi:refresh", 82 | "refreshAlarm": "mdi:refresh", 83 | # sirens 84 | "siren": "mdi:alarm-light", 85 | # cameras 86 | "camera": "mdi:video", 87 | "cameraSD": "mdi:video", 88 | } 89 | -------------------------------------------------------------------------------- /tests/test_switch.py: -------------------------------------------------------------------------------- 1 | """Test imou_life switch.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON 6 | from homeassistant.const import ATTR_ENTITY_ID 7 | import pytest 8 | from pytest_homeassistant_custom_component.common import MockConfigEntry 9 | 10 | from custom_components.imou_life.const import DOMAIN 11 | 12 | from .const import MOCK_CONFIG_ENTRY 13 | 14 | 15 | # This fixture bypasses the actual setup of the integration 16 | @pytest.fixture(autouse=True) 17 | def bypass_added_to_hass(): 18 | """Prevent added to hass.""" 19 | with ( 20 | patch( 21 | "custom_components.imou_life.entity.ImouEntity.async_added_to_hass", 22 | return_value=True, 23 | ), 24 | patch( 25 | "custom_components.imou_life.entity.ImouEntity.async_will_remove_from_hass", 26 | return_value=True, 27 | ), 28 | ): 29 | yield 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_switch(hass, api_ok): 34 | """Test switch services.""" 35 | # Create a mock entry so we don't have to go through config flow 36 | config_entry = MockConfigEntry( 37 | domain=DOMAIN, data=MOCK_CONFIG_ENTRY, entry_id="test", version=3 38 | ) 39 | config_entry.add_to_hass(hass) 40 | await hass.config_entries.async_setup(config_entry.entry_id) 41 | await hass.async_block_till_done() 42 | # check if the turn_on function is called when turning on the switch 43 | with ( 44 | patch( 45 | "custom_components.imou_life.switch.ImouSwitch.entity_registry_enabled_default", 46 | return_value=True, 47 | ), 48 | patch( 49 | "custom_components.imou_life.entity.ImouEntity.available", 50 | return_value=True, 51 | ), 52 | patch("imouapi.device_entity.ImouSwitch.async_turn_on") as turn_on_func, 53 | ): 54 | await hass.services.async_call( 55 | "switch", 56 | SERVICE_TURN_ON, 57 | service_data={ATTR_ENTITY_ID: "switch.device_name_motiondetect"}, 58 | blocking=True, 59 | ) 60 | assert turn_on_func.called 61 | # check if the turn_off function is called when turning off the switch 62 | with ( 63 | patch( 64 | "custom_components.imou_life.switch.ImouSwitch.entity_registry_enabled_default", 65 | return_value=True, 66 | ), 67 | patch( 68 | "custom_components.imou_life.entity.ImouEntity.available", 69 | return_value=True, 70 | ), 71 | patch("imouapi.device_entity.ImouSwitch.async_turn_off") as turn_off_func, 72 | ): 73 | await hass.services.async_call( 74 | "switch", 75 | SERVICE_TURN_OFF, 76 | service_data={ATTR_ENTITY_ID: "switch.device_name_motiondetect"}, 77 | blocking=True, 78 | ) 79 | assert turn_off_func.called 80 | -------------------------------------------------------------------------------- /custom_components/imou_life/siren.py: -------------------------------------------------------------------------------- 1 | """Siren platform for Imou.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | 6 | from homeassistant.components.siren import SirenEntity, SirenEntityFeature 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN 11 | from .entity import ImouEntity 12 | 13 | ENTITY_ID_FORMAT = "siren" + ".{}" 14 | 15 | _LOGGER: logging.Logger = logging.getLogger(__package__) 16 | 17 | 18 | # async def async_setup_entry(hass, entry, async_add_devices): 19 | async def async_setup_entry( 20 | hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable 21 | ): 22 | """Configure platform.""" 23 | coordinator = hass.data[DOMAIN][entry.entry_id] 24 | device = coordinator.device 25 | sensors = [] 26 | for sensor_instance in device.get_sensors_by_platform("siren"): 27 | sensor = ImouSiren(coordinator, entry, sensor_instance, ENTITY_ID_FORMAT) 28 | sensors.append(sensor) 29 | coordinator.entities.append(sensor) 30 | _LOGGER.debug( 31 | "[%s] Adding %s", device.get_name(), sensor_instance.get_description() 32 | ) 33 | async_add_devices(sensors) 34 | 35 | 36 | class ImouSiren(ImouEntity, SirenEntity): 37 | """imou siren class.""" 38 | 39 | # siren features 40 | _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON 41 | 42 | @property 43 | def is_on(self): 44 | """Return true if the siren is on.""" 45 | return self.sensor_instance.is_on() 46 | 47 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 48 | """Turn on the siren.""" 49 | await self.sensor_instance.async_turn_on() 50 | # save the new state to the state machine (otherwise will be reset by HA and set to the correct value only upon the next update) 51 | self.async_write_ha_state() 52 | _LOGGER.debug( 53 | "[%s] Turned %s ON", 54 | self.device.get_name(), 55 | self.sensor_instance.get_description(), 56 | ) 57 | 58 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 59 | """Turn off the siren.""" 60 | await self.sensor_instance.async_turn_off() 61 | # save the new state to the state machine (otherwise will be reset by HA and set to the correct value only upon the next update) 62 | self.async_write_ha_state() 63 | _LOGGER.debug( 64 | "[%s] Turned %s OFF", 65 | self.device.get_name(), 66 | self.sensor_instance.get_description(), 67 | ) 68 | 69 | async def async_toggle(self, **kwargs): # pylint: disable=unused-argument 70 | """Toggle the siren.""" 71 | await self.sensor_instance.async_toggle() 72 | # save the new state to the state machine (otherwise will be reset by HA and set to the correct value only upon the next update) 73 | self.async_write_ha_state() 74 | _LOGGER.debug( 75 | "[%s] Toggled", 76 | self.device.get_name(), 77 | ) 78 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for imou_life integration.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from imouapi.device import ImouDevice 6 | from imouapi.device_entity import ImouBinarySensor, ImouSensor, ImouSwitch 7 | from imouapi.exceptions import ImouException 8 | import pytest 9 | 10 | pytest_plugins = "pytest_homeassistant_custom_component" 11 | 12 | 13 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 14 | # notifications. These calls would fail without this fixture since the persistent_notification 15 | # integration is never loaded during a test. 16 | @pytest.fixture(name="skip_notifications", autouse=True) 17 | def skip_notifications_fixture(): 18 | """Skip notification calls.""" 19 | with patch("homeassistant.components.persistent_notification.async_create"), patch( 20 | "homeassistant.components.persistent_notification.async_dismiss" 21 | ): 22 | yield 23 | 24 | 25 | def mock_get_sensors_by_platform(platform): 26 | """Provide mock sensors by platform.""" 27 | if platform == "switch": 28 | return [ImouSwitch(None, "device_id", "device_name", "motionDetect")] 29 | elif platform == "sensor": 30 | return [ImouSensor(None, "device_id", "device_name", "lastAlarm")] 31 | elif platform == "binary_sensor": 32 | return [ImouBinarySensor(None, "device_id", "device_name", "online")] 33 | 34 | 35 | @pytest.fixture(name="api_ok") 36 | def bypass_get_data_fixture(): 37 | """Ensure all the calls to the underlying APIs are working fine.""" 38 | with patch("imouapi.device.ImouDevice.async_initialize"), patch( 39 | "imouapi.device.ImouDevice.async_get_data" 40 | ), patch("imouapi.api.ImouAPIClient.async_connect"), patch( 41 | "imouapi.device.ImouDiscoverService.async_discover_devices", 42 | return_value={"device_id": ImouDevice(None, None)}, 43 | ), patch( 44 | "imouapi.device.ImouDevice.get_name", 45 | return_value="device_name", 46 | ), patch( 47 | "imouapi.device.ImouDevice.get_device_id", 48 | return_value="device_id", 49 | ), patch( 50 | "imouapi.device.ImouDevice.get_sensors_by_platform", 51 | side_effect=mock_get_sensors_by_platform, 52 | ): 53 | yield 54 | 55 | 56 | @pytest.fixture(name="api_invalid_app_id") 57 | def error_invalid_app_id_fixture(): 58 | """Simulate error when retrieving data from API.""" 59 | with patch( 60 | "imouapi.exceptions.ImouException.get_title", 61 | return_value="invalid_configuration", 62 | ), patch("imouapi.api.ImouAPIClient.async_connect", side_effect=ImouException()): 63 | yield 64 | 65 | 66 | @pytest.fixture(name="api_invalid_data") 67 | def error_get_data_fixture(): 68 | """Simulate error when retrieving data from API.""" 69 | with patch("imouapi.device.ImouDevice.async_initialize"), patch( 70 | "imouapi.api.ImouAPIClient.async_connect" 71 | ), patch("imouapi.device.ImouDevice.async_get_data", side_effect=Exception()): 72 | yield 73 | 74 | 75 | @pytest.fixture(autouse=True) 76 | def auto_enable_custom_integrations(enable_custom_integrations): 77 | """Auto enable custom integration otherwise will result in IntegrationNotFound exception.""" 78 | yield 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People _love_ thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) 48 | to make sure the code follows the style. 49 | 50 | Or use the `pre-commit` settings implemented in this repository 51 | (see deicated section below). 52 | 53 | ## Test your code modification 54 | 55 | This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 56 | 57 | It comes with development environment in a container, easy to launch 58 | if you use Visual Studio Code. With this container you will have a stand alone 59 | Home Assistant instance running and already configured with the included 60 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 61 | file. 62 | 63 | You can use the `pre-commit` settings implemented in this repository to have 64 | linting tool checking your contributions (see deicated section below). 65 | 66 | ## Pre-commit 67 | 68 | You can use the [pre-commit](https://pre-commit.com/) settings included in the 69 | repostory to have code style and linting checks. 70 | 71 | With `pre-commit` tool already installed, 72 | activate the settings of the repository: 73 | 74 | ```console 75 | $ pre-commit install 76 | ``` 77 | 78 | Now the pre-commit tests will be done every time you commit. 79 | 80 | You can run the tests on all repository file with the command: 81 | 82 | ```console 83 | $ pre-commit run --all-files 84 | ``` 85 | 86 | ## License 87 | 88 | By contributing, you agree that your contributions will be licensed under its MIT License. 89 | -------------------------------------------------------------------------------- /custom_components/imou_life/entity.py: -------------------------------------------------------------------------------- 1 | """entity sensor platform for Imou.""" 2 | 3 | import logging 4 | 5 | from homeassistant.helpers.entity import async_generate_entity_id 6 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 7 | from imouapi.exceptions import ImouException 8 | 9 | from .const import DOMAIN, SENSOR_ICONS 10 | 11 | _LOGGER: logging.Logger = logging.getLogger(__package__) 12 | 13 | 14 | class ImouEntity(CoordinatorEntity): 15 | """imou entity class.""" 16 | 17 | def __init__(self, coordinator, config_entry, sensor_instance, entity_format): 18 | """Initialize.""" 19 | super().__init__(coordinator) 20 | self.config_entry = config_entry 21 | self.device = coordinator.device 22 | self.sensor_instance = sensor_instance 23 | self.entity_id = async_generate_entity_id( 24 | entity_format, 25 | f"{self.device.get_name()}_{self.sensor_instance.get_name()}", 26 | hass=coordinator.hass, 27 | ) 28 | self.entity_available = None 29 | 30 | @property 31 | def unique_id(self): 32 | """Return a unique ID to use for this entity.""" 33 | return self.config_entry.entry_id + "_" + self.sensor_instance.get_name() 34 | 35 | @property 36 | def device_info(self): 37 | """Return device information.""" 38 | return { 39 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 40 | "name": self.device.get_name(), 41 | "model": self.device.get_model(), 42 | "manufacturer": self.device.get_manufacturer(), 43 | "sw_version": self.device.get_firmware(), 44 | "hw_version": self.device.get_device_id(), 45 | } 46 | 47 | @property 48 | def available(self) -> bool: 49 | """Entity available.""" 50 | # if the availability of the sensor is set, return it 51 | if self.entity_available is not None: 52 | return self.entity_available 53 | # otherwise return the availability of the device 54 | return self.coordinator.device.get_status() 55 | 56 | @property 57 | def name(self): 58 | """Return the name of the sensor.""" 59 | return f"{self.device.get_name()} {self.sensor_instance.get_description()}" 60 | 61 | @property 62 | def icon(self): 63 | """Return the icon of this sensor.""" 64 | if self.sensor_instance.get_name() in SENSOR_ICONS: 65 | return SENSOR_ICONS[self.sensor_instance.get_name()] 66 | return SENSOR_ICONS["__default__"] 67 | 68 | @property 69 | def extra_state_attributes(self): 70 | """State attributes.""" 71 | return self.sensor_instance.get_attributes() 72 | 73 | async def async_added_to_hass(self): 74 | """Entity added to HA (at startup or when re-enabled).""" 75 | await super().async_added_to_hass() 76 | _LOGGER.debug("%s added to HA", self.name) 77 | self.sensor_instance.set_enabled(True) 78 | # request an update of this sensor 79 | try: 80 | await self.sensor_instance.async_update() 81 | except ImouException as exception: 82 | _LOGGER.error(exception.to_string()) 83 | 84 | async def async_will_remove_from_hass(self): 85 | """Entity removed from HA (when disabled).""" 86 | await super().async_will_remove_from_hass() 87 | _LOGGER.debug("%s removed from HA", self.name) 88 | self.sensor_instance.set_enabled(False) 89 | -------------------------------------------------------------------------------- /custom_components/imou_life/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for Imou.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | 6 | from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN, ENABLED_SWITCHES, OPTION_CALLBACK_URL 11 | from .entity import ImouEntity 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | 16 | # async def async_setup_entry(hass, entry, async_add_devices): 17 | async def async_setup_entry( 18 | hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable 19 | ): 20 | """Configure platform.""" 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | device = coordinator.device 23 | sensors = [] 24 | for sensor_instance in device.get_sensors_by_platform("switch"): 25 | sensor = ImouSwitch(coordinator, entry, sensor_instance, ENTITY_ID_FORMAT) 26 | sensors.append(sensor) 27 | coordinator.entities.append(sensor) 28 | _LOGGER.debug( 29 | "[%s] Adding %s", device.get_name(), sensor_instance.get_description() 30 | ) 31 | async_add_devices(sensors) 32 | 33 | 34 | class ImouSwitch(ImouEntity, SwitchEntity): 35 | """imou switch class.""" 36 | 37 | @property 38 | def entity_registry_enabled_default(self) -> bool: 39 | """If the entity is enabled by default.""" 40 | return self.sensor_instance.get_name() in ENABLED_SWITCHES 41 | 42 | @property 43 | def is_on(self): 44 | """Return true if the switch is on.""" 45 | return self.sensor_instance.is_on() 46 | 47 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 48 | """Turn on the switch.""" 49 | # pushNotifications switch 50 | if self.sensor_instance.get_name() == "pushNotifications": 51 | callback_url = None 52 | # if a callback url is provided as an option, use it as is 53 | if ( 54 | OPTION_CALLBACK_URL in self.config_entry.options 55 | and self.config_entry.options[OPTION_CALLBACK_URL] != "" 56 | ): 57 | callback_url = self.config_entry.options[OPTION_CALLBACK_URL] 58 | if callback_url is None: 59 | raise Exception("No callback url provided") 60 | _LOGGER.debug("Callback URL: %s", callback_url) 61 | await self.sensor_instance.async_turn_on(url=callback_url) 62 | # control all other switches 63 | else: 64 | await self.sensor_instance.async_turn_on() 65 | # save the new state to the state machine (otherwise will be reset by HA and set to the correct value only upon the next update) 66 | self.async_write_ha_state() 67 | _LOGGER.debug( 68 | "[%s] Turned %s ON", 69 | self.device.get_name(), 70 | self.sensor_instance.get_description(), 71 | ) 72 | 73 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 74 | """Turn off the switch.""" 75 | # control the switch 76 | await self.sensor_instance.async_turn_off() 77 | # save the new state to the state machine (otherwise will be reset by HA and set to the correct value only upon the next update) 78 | self.async_write_ha_state() 79 | _LOGGER.debug( 80 | "[%s] Turned %s OFF", 81 | self.device.get_name(), 82 | self.sensor_instance.get_description(), 83 | ) 84 | 85 | async def async_toggle(self, **kwargs): # pylint: disable=unused-argument 86 | """Toggle the switch.""" 87 | await self.sensor_instance.async_toggle() 88 | # save the new state to the state machine (otherwise will be reset by HA and set to the correct value only upon the next update) 89 | self.async_write_ha_state() 90 | _LOGGER.debug( 91 | "[%s] Toggled", 92 | self.device.get_name(), 93 | ) 94 | -------------------------------------------------------------------------------- /custom_components/imou_life/camera.py: -------------------------------------------------------------------------------- 1 | """Camera platform for Imou.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | 6 | from homeassistant.components.camera import ( 7 | ENTITY_ID_FORMAT, 8 | Camera, 9 | CameraEntityFeature, 10 | ) 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers import entity_platform 14 | from imouapi.const import PTZ_OPERATIONS 15 | import voluptuous as vol 16 | 17 | from .const import ( 18 | ATTR_PTZ_DURATION, 19 | ATTR_PTZ_HORIZONTAL, 20 | ATTR_PTZ_OPERATION, 21 | ATTR_PTZ_VERTICAL, 22 | ATTR_PTZ_ZOOM, 23 | DOMAIN, 24 | ENABLED_CAMERAS, 25 | SERVIZE_PTZ_LOCATION, 26 | SERVIZE_PTZ_MOVE, 27 | ) 28 | from .entity import ImouEntity 29 | 30 | _LOGGER: logging.Logger = logging.getLogger(__package__) 31 | 32 | 33 | # async def async_setup_entry(hass, entry, async_add_devices): 34 | async def async_setup_entry( 35 | hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable 36 | ): 37 | """Configure platform.""" 38 | platform = entity_platform.async_get_current_platform() 39 | 40 | # Create PTZ location service 41 | platform.async_register_entity_service( 42 | SERVIZE_PTZ_LOCATION, 43 | { 44 | vol.Required(ATTR_PTZ_HORIZONTAL, default=0): vol.Range(min=-1, max=1), 45 | vol.Required(ATTR_PTZ_VERTICAL, default=0): vol.Range(min=-1, max=1), 46 | vol.Required(ATTR_PTZ_ZOOM, default=0): vol.Range(min=0, max=1), 47 | }, 48 | "async_service_ptz_location", 49 | ) 50 | 51 | # Create PTZ move service 52 | platform.async_register_entity_service( 53 | SERVIZE_PTZ_MOVE, 54 | { 55 | vol.Required(ATTR_PTZ_OPERATION, default=0): vol.In(list(PTZ_OPERATIONS)), 56 | vol.Required(ATTR_PTZ_DURATION, default=1000): vol.Range( 57 | min=100, max=10000 58 | ), 59 | }, 60 | "async_service_ptz_move", 61 | ) 62 | 63 | coordinator = hass.data[DOMAIN][entry.entry_id] 64 | device = coordinator.device 65 | sensors = [] 66 | for sensor_instance in device.get_sensors_by_platform("camera"): 67 | sensor = ImouCamera(coordinator, entry, sensor_instance, ENTITY_ID_FORMAT) 68 | sensors.append(sensor) 69 | coordinator.entities.append(sensor) 70 | _LOGGER.debug( 71 | "[%s] Adding %s", device.get_name(), sensor_instance.get_description() 72 | ) 73 | async_add_devices(sensors) 74 | 75 | 76 | class ImouCamera(ImouEntity, Camera): 77 | """imou camera class.""" 78 | 79 | _attr_supported_features = CameraEntityFeature.STREAM 80 | 81 | def __init__(self, coordinator, config_entry, sensor_instance, entity_format): 82 | """Initialize.""" 83 | Camera.__init__(self) 84 | ImouEntity.__init__( 85 | self, coordinator, config_entry, sensor_instance, entity_format 86 | ) 87 | 88 | @property 89 | def entity_registry_enabled_default(self) -> bool: 90 | """If the entity is enabled by default.""" 91 | return self.sensor_instance.get_name() in ENABLED_CAMERAS 92 | 93 | async def async_camera_image(self, width=None, height=None) -> bytes: 94 | """Return bytes of camera image.""" 95 | _LOGGER.debug( 96 | "[%s] requested camera image", 97 | self.device.get_name(), 98 | ) 99 | return await self.sensor_instance.async_get_image() 100 | 101 | async def stream_source(self) -> str: 102 | """Return the source of the stream.""" 103 | _LOGGER.debug( 104 | "[%s] requested camera stream url", 105 | self.device.get_name(), 106 | ) 107 | return await self.sensor_instance.async_get_stream_url() 108 | 109 | async def async_service_ptz_location(self, horizontal, vertical, zoom): 110 | """Perform PTZ location action.""" 111 | _LOGGER.debug( 112 | "[%s] invoked PTZ location action horizontal:%f, vertical:%f, zoom:%f", 113 | self.device.get_name(), 114 | horizontal, 115 | vertical, 116 | zoom, 117 | ) 118 | await self.sensor_instance.async_service_ptz_location( 119 | horizontal, 120 | vertical, 121 | zoom, 122 | ) 123 | 124 | async def async_service_ptz_move(self, operation, duration): 125 | """Perform PTZ move action.""" 126 | _LOGGER.debug( 127 | "[%s] invoked PTZ move action operation:%s, duration:%i", 128 | self.device.get_name(), 129 | operation, 130 | duration, 131 | ) 132 | await self.sensor_instance.async_service_ptz_move( 133 | operation, 134 | duration, 135 | ) 136 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.0.16] (2025-06-06) 4 | ### Added 5 | - Disclaimer on the status of the integration 6 | ### Fixed 7 | - async_forward_entry_setup() deprecation (#124) 8 | - Sets option flow config_entry explicitly deprecation 9 | - Config alias deprecation 10 | - async_add_job() deprecation 11 | 12 | ## [1.0.15] (2024-01-27) 13 | ### Fixed 14 | - HACS failing installation: error 500 (#83 #84) 15 | 16 | ## [1.0.14] (2023-12-26) 17 | ### Added 18 | - French and Indonesian support 19 | - List with all the supported/tested models in the README file 20 | - Instructions on how to contribute in the README file 21 | ### Fixed 22 | - Improved support for Cell Go cameras (#55) 23 | - Discovery service now ignores with a warning unrecognized/unsupported devices instead of throwing an error (#47) 24 | ### Changed 25 | - Bump imouapi version: 1.0.13 → 1.0.14 26 | - Added a Wiki to Github with articles for end users, developers and maintainers 27 | 28 | ## [1.0.13] (2023-02-19) 29 | ### Added 30 | - Added battery sensor for dormant devices 31 | - Catalan translation 32 | ### Changed 33 | - Added new conditions to the Imou Push Notifications automation template in the README file to prevent too many refresh 34 | ### Fixed 35 | - Motion detection sensor now showing up for all devices 36 | 37 | ## [1.0.12] (2022-12-11) 38 | ### Fixed 39 | - Dormant device logic 40 | 41 | ## [1.0.11] (2022-12-11) 42 | ### Added 43 | - Support for dormant devices 44 | - `status` sensor 45 | - Options for customizing wait time for camera snapshot download and wait time after waking up dormant device 46 | ### Changed 47 | - Device is now marked online if either online or dormant 48 | 49 | ## [1.0.10] (2022-12-04) 50 | ### Added 51 | - Camera entity now supports snapshots and video streaming 52 | 53 | ## [1.0.9] (2022-11-26) 54 | ### Added 55 | - PTZ Support, exposed as `imou_life.ptz_location` and `imou_life.ptz_move` services 56 | - Camera entity, used for invoking the PTZ services 57 | 58 | ## [1.0.8] (2022-11-21) 59 | ### Fixed 60 | - "Failed to setup" error after upgrading to v1.0.7 (#37) 61 | 62 | ## [1.0.7] (2022-11-20) 63 | ### Added 64 | - Spanish and italian translations (#21) 65 | - Reverse proxy sample configuration for custom configuration of push notifications (#29) 66 | - Siren entity (#26) 67 | ### Changed 68 | - API URL is now part of the configuration flow (#16) 69 | - Bump imouapi version: 1.0.6 → 1.0.7 70 | ### Removed 71 | - `siren` switch, now exposed as a siren entity 72 | - API Base URL option, now part of the configuration flow 73 | ### Fixed 74 | - Entities not correctly removed from HA 75 | 76 | ## [1.0.6] (2022-11-19) 77 | ### Added 78 | - `motionAlarm` binary sensor which can be updated also via the `refreshAlarm` button 79 | ### Removed 80 | - `lastAlarm` sensor. The same information has been moved into the `alarm_time` attribute inside the `motionAlarm` binary sensor, together with `alarm_type` and `alarm_code` 81 | ### Changed 82 | - Bump imouapi version: 1.0.5 → 1.0.6 83 | - Updated README and link to the roadmap 84 | 85 | ## [1.0.5] (2022-11-13) 86 | ### Added 87 | - Switch for turning the Siren on/off for those devices supporting it 88 | - Buttons for restarting the device and manually refreshing device data in Home Assistant 89 | - Sensor with the callback url set for push notifications 90 | ### Changed 91 | - Bump imouapi version: 1.0.5 → 1.0.5 92 | - Reviewed instructions for setting up push notifications 93 | - Updated README with Roadmap 94 | - Deprecated "Callback Webhook ID" option for push notifications, use "Callback URL" instead 95 | - Reviewed switches' labels 96 | ### Fixed 97 | - Storage left sensor without SD card now reporting Unknown 98 | 99 | ## [1.0.4] (2022-11-12) 100 | ### Added 101 | - Brazilian Portuguese Translation 102 | - HACS Default repository 103 | ### Changed 104 | - Split Github action into test (on PR and push) and release (manual) 105 | 106 | ## [1.0.3] (2022-10-23) 107 | ### Added 108 | - Support white light on motion switch through imouapi 109 | - `linkagewhitelight` now among the switches enabled by default 110 | - Support for SelectEntity and `nightVisionMode` select 111 | - Support storage used through `storageUsed` sensor 112 | - Support for push notifications through `pushNotifications` switch 113 | - Options for configuring push notifications 114 | ### Changed 115 | - Bump imouapi version: 1.0.2 → 1.0.4 116 | - Redact device id and entry id from diagnostics 117 | 118 | ## [1.0.2] (2022-10-19) 119 | ### Changed 120 | - Bump imouapi version: 1.0.1 → 1.0.2 121 | 122 | ## [1.0.1] (2022-10-16) 123 | ### Added 124 | - Download diagnostics capability 125 | 126 | ## [1.0.0] (2022-10-15) 127 | ### Changed 128 | - Bump imouapi version: 0.2.2 → 1.0.0 129 | - Entity ID names are now based on the name of the sensor and not on the description 130 | 131 | ## [0.1.4] (2022-10-08) 132 | ### Added 133 | - Test suite 134 | ### Changed 135 | - Bump imouapi version: 0.2.1 → 0.2.2 136 | 137 | ## [0.1.3] (2022-10-04) 138 | ### Changed 139 | - Bump imouapi version: 0.2.0 → 0.2.1 140 | 141 | ## [0.1.2] (2022-10-03) 142 | ### Added 143 | - All the switches are now made available. Only a subset are then enabled in HA by default. 144 | - Sensors' icons and default icon 145 | ### Changed 146 | - Bump imouapi version: 0.1.5 → 0.2.0 and adapted the code accordingly 147 | - Introduced `ImouEntity` class for all the sensors derived subclasses 148 | 149 | ## [0.1.1] (2022-09-29) 150 | ### Changed 151 | - Bump imouapi version: 0.1.4 → 0.1.5 152 | - Improved README 153 | 154 | ## [0.1.0] (2022-09-29) 155 | ### Added 156 | - First release for beta testing 157 | -------------------------------------------------------------------------------- /custom_components/imou_life/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate Imou Life with Home Assistant. 3 | 4 | For more details about this integration, please refer to 5 | https://github.com/user2684/imou_life 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.exceptions import ConfigEntryNotReady 14 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 15 | from homeassistant.helpers.typing import ConfigType 16 | from imouapi.api import ImouAPIClient 17 | from imouapi.device import ImouDevice 18 | from imouapi.exceptions import ImouException 19 | 20 | from .const import ( 21 | CONF_API_URL, 22 | CONF_APP_ID, 23 | CONF_APP_SECRET, 24 | CONF_DEVICE_ID, 25 | CONF_DEVICE_NAME, 26 | DEFAULT_API_URL, 27 | DEFAULT_SCAN_INTERVAL, 28 | DOMAIN, 29 | OPTION_API_TIMEOUT, 30 | OPTION_API_URL, 31 | OPTION_CAMERA_WAIT_BEFORE_DOWNLOAD, 32 | OPTION_SCAN_INTERVAL, 33 | OPTION_WAIT_AFTER_WAKE_UP, 34 | PLATFORMS, 35 | ) 36 | from .coordinator import ImouDataUpdateCoordinator 37 | 38 | _LOGGER: logging.Logger = logging.getLogger(__package__) 39 | 40 | 41 | async def async_setup(hass: HomeAssistant, config: ConfigType): 42 | """Set up this integration using YAML is not supported.""" 43 | return True 44 | 45 | 46 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 47 | """Set up this integration using UI.""" 48 | if hass.data.get(DOMAIN) is None: 49 | hass.data.setdefault(DOMAIN, {}) 50 | session = async_get_clientsession(hass) 51 | 52 | # retrieve the configuration entry parameters 53 | _LOGGER.debug("Loading entry %s", entry.entry_id) 54 | name = entry.data.get(CONF_DEVICE_NAME) 55 | api_url = entry.data.get(CONF_API_URL) 56 | app_id = entry.data.get(CONF_APP_ID) 57 | app_secret = entry.data.get(CONF_APP_SECRET) 58 | device_id = entry.data.get(CONF_DEVICE_ID) 59 | _LOGGER.debug("Setting up device %s (%s)", name, device_id) 60 | 61 | # create an imou api client instance 62 | api_client = ImouAPIClient(app_id, app_secret, session) 63 | _LOGGER.debug("Setting API base url to %s", api_url) 64 | api_client.set_base_url(api_url) 65 | timeout = entry.options.get(OPTION_API_TIMEOUT, None) 66 | if isinstance(timeout, str): 67 | timeout = None if timeout == "" else int(timeout) 68 | if timeout is not None: 69 | _LOGGER.debug("Setting API timeout to %d", timeout) 70 | api_client.set_timeout(timeout) 71 | 72 | # create an imou device instance 73 | device = ImouDevice(api_client, device_id) 74 | if name is not None: 75 | device.set_name(name) 76 | camera_wait_before_download = entry.options.get( 77 | OPTION_CAMERA_WAIT_BEFORE_DOWNLOAD, None 78 | ) 79 | if camera_wait_before_download is not None: 80 | _LOGGER.debug( 81 | "Setting camera wait before download to %f", camera_wait_before_download 82 | ) 83 | device.set_camera_wait_before_download(camera_wait_before_download) 84 | wait_after_wakeup = entry.options.get(OPTION_WAIT_AFTER_WAKE_UP, None) 85 | if wait_after_wakeup is not None: 86 | _LOGGER.debug("Setting wait after wakeup to %f", wait_after_wakeup) 87 | device.set_wait_after_wakeup(wait_after_wakeup) 88 | 89 | # initialize the device so to discover all the sensors 90 | try: 91 | await device.async_initialize() 92 | except ImouException as exception: 93 | _LOGGER.error(exception.to_string()) 94 | raise ImouException() from exception 95 | # at this time, all sensors must be disabled (will be enabled individually by async_added_to_hass()) 96 | for sensor_instance in device.get_all_sensors(): 97 | sensor_instance.set_enabled(False) 98 | 99 | # create a coordinator 100 | coordinator = ImouDataUpdateCoordinator( 101 | hass, device, entry.options.get(OPTION_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) 102 | ) 103 | # fetch the data 104 | await coordinator.async_refresh() 105 | if not coordinator.last_update_success: 106 | raise ConfigEntryNotReady 107 | 108 | # store the coordinator so to be accessible by each platform 109 | hass.data[DOMAIN][entry.entry_id] = coordinator 110 | 111 | # for each enabled platform, forward the configuration entry for its setup 112 | for platform in PLATFORMS: 113 | coordinator.platforms.append(platform) 114 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 115 | entry.add_update_listener(async_reload_entry) 116 | return True 117 | 118 | 119 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 120 | """Handle removal of an entry.""" 121 | _LOGGER.debug("Unloading entry %s", entry.entry_id) 122 | coordinator = hass.data[DOMAIN][entry.entry_id] 123 | unloaded = all( 124 | await asyncio.gather( 125 | *[ 126 | hass.config_entries.async_forward_entry_unload(entry, platform) 127 | for platform in PLATFORMS 128 | if platform in coordinator.platforms 129 | ] 130 | ) 131 | ) 132 | if unloaded: 133 | hass.data[DOMAIN].pop(entry.entry_id) 134 | return unloaded 135 | 136 | 137 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 138 | """Reload config entry.""" 139 | await async_unload_entry(hass, entry) 140 | await async_setup_entry(hass, entry) 141 | 142 | 143 | async def async_migrate_entry(hass, config_entry: ConfigEntry) -> bool: 144 | """Migrate old entry.""" 145 | _LOGGER.debug("Migrating from version %s", config_entry.version) 146 | data = {**config_entry.data} 147 | options = {**config_entry.options} 148 | unique_id = data[CONF_DEVICE_ID] 149 | 150 | if config_entry.version == 1: 151 | # add the api url. If in option, use it, otherwise use the default one 152 | option_api_url = config_entry.options.get(OPTION_API_URL, None) 153 | api_url = DEFAULT_API_URL if option_api_url is None else option_api_url 154 | data[CONF_API_URL] = api_url 155 | config_entry.version = 2 156 | 157 | if config_entry.version == 2: 158 | # if api_url is empty, copy over the one in options 159 | if data[CONF_API_URL] == "": 160 | data[CONF_API_URL] = DEFAULT_API_URL 161 | if OPTION_API_URL in options: 162 | del options[OPTION_API_URL] 163 | config_entry.version = 3 164 | 165 | # update the config entry 166 | hass.config_entries.async_update_entry( 167 | config_entry, data=data, options=options, unique_id=unique_id 168 | ) 169 | _LOGGER.info("Migration to version %s successful", config_entry.version) 170 | return True 171 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test imou_life config flow.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from homeassistant import config_entries, data_entry_flow 6 | import pytest 7 | from pytest_homeassistant_custom_component.common import MockConfigEntry 8 | 9 | from custom_components.imou_life.const import ( 10 | CONF_API_URL, 11 | CONF_APP_ID, 12 | CONF_APP_SECRET, 13 | CONF_DEVICE_ID, 14 | DOMAIN, 15 | OPTION_API_TIMEOUT, 16 | OPTION_CALLBACK_URL, 17 | OPTION_SCAN_INTERVAL, 18 | ) 19 | 20 | from .const import ( 21 | CONF_DISCOVERED_DEVICE, 22 | MOCK_CONFIG_ENTRY, 23 | MOCK_CREATE_ENTRY_FROM_DISCOVER, 24 | MOCK_CREATE_ENTRY_FROM_MANUAL, 25 | MOCK_LOGIN_WITH_DISCOVER, 26 | MOCK_LOGIN_WITHOUT_DISCOVER, 27 | ) 28 | 29 | 30 | # This fixture bypasses the actual setup of the integration 31 | @pytest.fixture(autouse=True) 32 | def bypass_setup_fixture(): 33 | """Prevent setup.""" 34 | with ( 35 | patch( 36 | "custom_components.imou_life.async_setup", 37 | return_value=True, 38 | ), 39 | patch( 40 | "custom_components.imou_life.async_setup_entry", 41 | return_value=True, 42 | ), 43 | ): 44 | yield 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_discover_ok(hass, api_ok): 49 | """Test discover flow: ok.""" 50 | # Initialize a config flow as the user is clicking on add new integration 51 | result = await hass.config_entries.flow.async_init( 52 | DOMAIN, context={"source": config_entries.SOURCE_USER} 53 | ) 54 | # Check that the config flow shows the login form as the first step 55 | assert result["type"] == data_entry_flow.FlowResultType.FORM 56 | assert result["step_id"] == "login" 57 | # simulate the user entering app id, app secret and discover checked 58 | result = await hass.config_entries.flow.async_configure( 59 | result["flow_id"], user_input=MOCK_LOGIN_WITH_DISCOVER 60 | ) 61 | # ensure a new form is requested 62 | assert result["type"] == data_entry_flow.FlowResultType.FORM 63 | # get the next step in the flow 64 | next( 65 | flow 66 | for flow in hass.config_entries.flow.async_progress() 67 | if flow["flow_id"] == result["flow_id"] 68 | ) 69 | # ensure it is the discover step 70 | assert result["step_id"] == "discover" 71 | # submit the discover form 72 | result = await hass.config_entries.flow.async_configure( 73 | result["flow_id"], user_input=MOCK_CREATE_ENTRY_FROM_DISCOVER 74 | ) 75 | # check that the config flow is complete and a new entry is created 76 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 77 | assert result["data"][CONF_API_URL] == MOCK_LOGIN_WITH_DISCOVER[CONF_API_URL] 78 | assert result["data"][CONF_APP_ID] == MOCK_LOGIN_WITH_DISCOVER[CONF_APP_ID] 79 | assert result["data"][CONF_APP_SECRET] == MOCK_LOGIN_WITH_DISCOVER[CONF_APP_SECRET] 80 | assert ( 81 | result["data"][CONF_DEVICE_ID] 82 | == MOCK_CREATE_ENTRY_FROM_DISCOVER[CONF_DISCOVERED_DEVICE] 83 | ) 84 | assert result["result"] 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_login_error(hass, api_invalid_app_id): 89 | """Test config flow: invalid app id.""" 90 | result = await hass.config_entries.flow.async_init( 91 | DOMAIN, context={"source": config_entries.SOURCE_USER} 92 | ) 93 | result = await hass.config_entries.flow.async_configure( 94 | result["flow_id"], user_input=MOCK_LOGIN_WITH_DISCOVER 95 | ) 96 | assert result["type"] == data_entry_flow.FlowResultType.FORM 97 | assert result["errors"] == {"base": "invalid_configuration"} 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_manual_ok(hass, api_ok): 102 | """Test manual flow: ok.""" 103 | # Initialize a config flow as the user is clicking on add new integration 104 | result = await hass.config_entries.flow.async_init( 105 | DOMAIN, context={"source": config_entries.SOURCE_USER} 106 | ) 107 | # Check that the config flow shows the login form as the first step 108 | assert result["type"] == data_entry_flow.FlowResultType.FORM 109 | assert result["step_id"] == "login" 110 | # simulate the user entering app id, app secret and discover checked 111 | result = await hass.config_entries.flow.async_configure( 112 | result["flow_id"], user_input=MOCK_LOGIN_WITHOUT_DISCOVER 113 | ) 114 | # ensure a new form is requested 115 | assert result["type"] == data_entry_flow.FlowResultType.FORM 116 | # get the next step in the flow 117 | next( 118 | flow 119 | for flow in hass.config_entries.flow.async_progress() 120 | if flow["flow_id"] == result["flow_id"] 121 | ) 122 | # ensure it is the discover step 123 | assert result["step_id"] == "manual" 124 | # submit the discover form 125 | result = await hass.config_entries.flow.async_configure( 126 | result["flow_id"], user_input=MOCK_CREATE_ENTRY_FROM_MANUAL 127 | ) 128 | # check that the config flow is complete and a new entry is created 129 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 130 | assert result["data"][CONF_API_URL] == MOCK_LOGIN_WITHOUT_DISCOVER[CONF_API_URL] 131 | assert result["data"][CONF_APP_ID] == MOCK_LOGIN_WITHOUT_DISCOVER[CONF_APP_ID] 132 | assert ( 133 | result["data"][CONF_APP_SECRET] == MOCK_LOGIN_WITHOUT_DISCOVER[CONF_APP_SECRET] 134 | ) 135 | assert ( 136 | result["data"][CONF_DEVICE_ID] == MOCK_CREATE_ENTRY_FROM_MANUAL[CONF_DEVICE_ID] 137 | ) 138 | assert result["result"] 139 | 140 | 141 | @pytest.mark.asyncio 142 | async def test_options_flow(hass): 143 | """Test an options flow.""" 144 | entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_ENTRY, entry_id="test") 145 | entry.add_to_hass(hass) 146 | # Initialize an options flow 147 | await hass.config_entries.async_setup(entry.entry_id) 148 | result = await hass.config_entries.options.async_init(entry.entry_id) 149 | # Verify that the first options step is a user form 150 | assert result["type"] == data_entry_flow.FlowResultType.FORM 151 | assert result["step_id"] == "init" 152 | # Enter some fake data into the form 153 | result = await hass.config_entries.options.async_configure( 154 | result["flow_id"], 155 | user_input={ 156 | OPTION_SCAN_INTERVAL: 30, 157 | OPTION_API_TIMEOUT: "20", 158 | OPTION_CALLBACK_URL: "url", 159 | }, 160 | ) 161 | # Verify that the flow finishes 162 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 163 | # Verify that the options were updated 164 | assert entry.options[OPTION_SCAN_INTERVAL] == 30 165 | assert entry.options[OPTION_API_TIMEOUT] == "20" 166 | assert entry.options[OPTION_CALLBACK_URL] == "url" 167 | -------------------------------------------------------------------------------- /custom_components/imou_life/config_flow.py: -------------------------------------------------------------------------------- 1 | """Xonfig flow for Imou.""" 2 | 3 | import logging 4 | 5 | from homeassistant import config_entries 6 | from homeassistant.core import callback 7 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 8 | from imouapi.api import ImouAPIClient 9 | from imouapi.device import ImouDevice, ImouDiscoverService 10 | from imouapi.exceptions import ImouException 11 | import voluptuous as vol 12 | 13 | from .const import ( 14 | CONF_API_URL, 15 | CONF_APP_ID, 16 | CONF_APP_SECRET, 17 | CONF_DEVICE_ID, 18 | CONF_DEVICE_NAME, 19 | CONF_DISCOVERED_DEVICE, 20 | CONF_ENABLE_DISCOVER, 21 | DEFAULT_API_URL, 22 | DEFAULT_SCAN_INTERVAL, 23 | DOMAIN, 24 | OPTION_API_TIMEOUT, 25 | OPTION_CALLBACK_URL, 26 | OPTION_CAMERA_WAIT_BEFORE_DOWNLOAD, 27 | OPTION_SCAN_INTERVAL, 28 | OPTION_WAIT_AFTER_WAKE_UP, 29 | ) 30 | 31 | _LOGGER: logging.Logger = logging.getLogger(__package__) 32 | 33 | 34 | class ImouFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 35 | """Config flow for imou.""" 36 | 37 | VERSION = 3 38 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 39 | 40 | def __init__(self): 41 | """Initialize.""" 42 | self._api_url = None 43 | self._app_id = None 44 | self._app_secret = None 45 | self._api_client = None 46 | self._discover_service = None 47 | self._session = None 48 | self._discovered_devices = {} 49 | self._errors = {} 50 | 51 | async def async_step_user(self, user_input=None): 52 | """Handle a flow initialized by the user.""" 53 | self._session = async_create_clientsession(self.hass) 54 | return await self.async_step_login() 55 | 56 | # Step: login 57 | async def async_step_login(self, user_input=None): 58 | """Ask and validate app id and app secret.""" 59 | self._errors = {} 60 | if user_input is not None: 61 | # create an imou discovery service 62 | self._api_client = ImouAPIClient( 63 | user_input[CONF_APP_ID], user_input[CONF_APP_SECRET], self._session 64 | ) 65 | self._api_client.set_base_url(user_input[CONF_API_URL]) 66 | self._discover_service = ImouDiscoverService(self._api_client) 67 | valid = False 68 | # check if the provided credentails are working 69 | try: 70 | await self._api_client.async_connect() 71 | valid = True 72 | except ImouException as exception: 73 | self._errors["base"] = exception.get_title() 74 | _LOGGER.error(exception.to_string()) 75 | # valid credentials provided 76 | if valid: 77 | # store app id and secret for later steps 78 | self._api_url = user_input[CONF_API_URL] 79 | self._app_id = user_input[CONF_APP_ID] 80 | self._app_secret = user_input[CONF_APP_SECRET] 81 | # if discover is requested run the discover step, otherwise the manual step 82 | if user_input[CONF_ENABLE_DISCOVER]: 83 | return await self.async_step_discover() 84 | else: 85 | return await self.async_step_manual() 86 | 87 | # by default show up the form 88 | return self.async_show_form( 89 | step_id="login", 90 | data_schema=vol.Schema( 91 | { 92 | vol.Required(CONF_API_URL, default=DEFAULT_API_URL): str, 93 | vol.Required(CONF_APP_ID): str, 94 | vol.Required(CONF_APP_SECRET): str, 95 | vol.Required(CONF_ENABLE_DISCOVER, default=True): bool, 96 | } 97 | ), 98 | errors=self._errors, 99 | ) 100 | 101 | # Step: discover 102 | 103 | async def async_step_discover(self, user_input=None): 104 | """Discover devices and ask the user to select one.""" 105 | self._errors = {} 106 | if user_input is not None: 107 | # get the device instance from the selected input 108 | device = self._discovered_devices[user_input[CONF_DISCOVERED_DEVICE]] 109 | if device is not None: 110 | # set the name 111 | name = ( 112 | f"{user_input[CONF_DEVICE_NAME]}" 113 | if CONF_DEVICE_NAME in user_input 114 | and user_input[CONF_DEVICE_NAME] != "" 115 | else device.get_name() 116 | ) 117 | # create the entry 118 | data = { 119 | CONF_API_URL: self._api_url, 120 | CONF_DEVICE_NAME: name, 121 | CONF_APP_ID: self._app_id, 122 | CONF_APP_SECRET: self._app_secret, 123 | CONF_DEVICE_ID: device.get_device_id(), 124 | } 125 | await self.async_set_unique_id(device.get_device_id()) 126 | return self.async_create_entry(title=name, data=data) 127 | 128 | # discover registered devices 129 | try: 130 | self._discovered_devices = ( 131 | await self._discover_service.async_discover_devices() 132 | ) 133 | except ImouException as exception: 134 | self._errors["base"] = exception.get_title() 135 | _LOGGER.error(exception.to_string()) 136 | return self.async_show_form( 137 | step_id="discover", 138 | data_schema=vol.Schema( 139 | { 140 | vol.Required(CONF_DISCOVERED_DEVICE): vol.In( 141 | self._discovered_devices.keys() 142 | ), 143 | vol.Optional(CONF_DEVICE_NAME): str, 144 | } 145 | ), 146 | errors=self._errors, 147 | ) 148 | 149 | # Step: manual configuration 150 | 151 | async def async_step_manual(self, user_input=None): 152 | """Manually add a device by its device id.""" 153 | self._errors = {} 154 | if user_input is not None: 155 | # create an imou device instance 156 | device = ImouDevice(self._api_client, user_input[CONF_DEVICE_ID]) 157 | valid = False 158 | # check if the provided credentails are working 159 | try: 160 | await device.async_initialize() 161 | valid = True 162 | except ImouException as exception: 163 | self._errors["base"] = exception.get_title() 164 | _LOGGER.error(exception.to_string()) 165 | # valid credentials provided, create the entry 166 | if valid: 167 | # set the name 168 | name = ( 169 | f"{user_input[CONF_DEVICE_NAME]}" 170 | if CONF_DEVICE_NAME in user_input 171 | and user_input[CONF_DEVICE_NAME] != "" 172 | else device.get_name() 173 | ) 174 | # create the entry 175 | data = { 176 | CONF_API_URL: self._api_url, 177 | CONF_DEVICE_NAME: name, 178 | CONF_APP_ID: self._app_id, 179 | CONF_APP_SECRET: self._app_secret, 180 | CONF_DEVICE_ID: user_input[CONF_DEVICE_ID], 181 | } 182 | await self.async_set_unique_id(user_input[CONF_DEVICE_ID]) 183 | return self.async_create_entry(title=name, data=data) 184 | 185 | # by default show up the form 186 | return self.async_show_form( 187 | step_id="manual", 188 | data_schema=vol.Schema( 189 | { 190 | vol.Required(CONF_DEVICE_ID): str, 191 | vol.Optional(CONF_DEVICE_NAME): str, 192 | } 193 | ), 194 | errors=self._errors, 195 | ) 196 | 197 | @staticmethod 198 | @callback 199 | def async_get_options_flow(config_entry): 200 | """Return Option Handerl.""" 201 | return ImouOptionsFlowHandler() 202 | 203 | 204 | class ImouOptionsFlowHandler(config_entries.OptionsFlow): 205 | """Config flow options handler for imou.""" 206 | 207 | @property 208 | def config_entry(self): 209 | return self.hass.config_entries.async_get_entry(self.handler) 210 | 211 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 212 | """Manage the options.""" 213 | self.options = dict(self.config_entry.options) 214 | if user_input is not None: 215 | self.options.update(user_input) 216 | return await self._update_options() 217 | 218 | return self.async_show_form( 219 | step_id="init", 220 | data_schema=vol.Schema( 221 | { 222 | vol.Required( 223 | OPTION_SCAN_INTERVAL, 224 | default=self.options.get( 225 | OPTION_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 226 | ), 227 | ): int, 228 | vol.Optional( 229 | OPTION_API_TIMEOUT, 230 | default=self.options.get(OPTION_API_TIMEOUT, ""), 231 | ): str, 232 | vol.Optional( 233 | OPTION_CALLBACK_URL, 234 | default=self.options.get(OPTION_CALLBACK_URL, ""), 235 | ): str, 236 | vol.Optional( 237 | OPTION_CAMERA_WAIT_BEFORE_DOWNLOAD, 238 | default=self.options.get( 239 | OPTION_CAMERA_WAIT_BEFORE_DOWNLOAD, vol.UNDEFINED 240 | ), 241 | ): vol.Coerce(float), 242 | vol.Optional( 243 | OPTION_WAIT_AFTER_WAKE_UP, 244 | default=self.options.get( 245 | OPTION_WAIT_AFTER_WAKE_UP, vol.UNDEFINED 246 | ), 247 | ): vol.Coerce(float), 248 | } 249 | ), 250 | ) 251 | 252 | async def _update_options(self): 253 | """Update config entry options.""" 254 | return self.async_create_entry(title="", data=self.options) 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant custom component for controlling Imou devices 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) 4 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 5 | [![Donate](https://img.shields.io/badge/donate-BuyMeCoffee-yellow.svg)](https://www.buymeacoffee.com/user2684) 6 | 7 | **PLEASE NOTE this is an UNOFFICIAL integration, NOT supported or validated by Imou or linked in any way to Imou**. 8 | 9 | _This integration is the result of the analysis of the public documentation released by Imou for its open platform and created before the vendor published its own [official Home Assistant integration](https://github.com/Imou-OpenPlatform/Imou-Home-Assistant). Since this integration is based on information no more updated since 2021 it may not fully work with most recent devices and there is no way to support newer models since the documentation of the the APIs has not been made public by the vendor._ 10 | 11 | _For this reason, I have decided to ***stop actively developing the integration***. No new functionalities will be added or bugs fixed since it would require access to vendor's APIs which are neither public nor documented. Regardless, the integration is still supposed to work for those devices where it has been working so far. And I will keep the code up to date to ensure it will continue to run smoothly on newer version of Home Assistant._ 12 | 13 | _The official integration, supported by the vendor, is available for download [here](https://github.com/Imou-OpenPlatform/Imou-Home-Assistant)._ 14 | 15 | ## Overview 16 | 17 | This Home Assistant component helps in interacting with devices registered with the Imou Life App and specifically enabling/disabling motion detection, siren, and other switches. 18 | 19 | Despite Imou webcams video streams can be integrated in Home Assistant through Onvif, the only way to interact with the device as if using the Imou Life App is to leverage the Imou API, 20 | that is what this component under the hood does. 21 | 22 | Once an Imou device is added to Home Assistant, switches can be controlled through the frontend or convenientely used in your automations. 23 | 24 | ## Features 25 | 26 | - Configuration through the UI 27 | - Auto discover registered devices 28 | - Auto discover device capabilities and supported switches 29 | - Sensors, binary sensors, select, buttons to control key features of each device 30 | - Support for push notifications 31 | - Image snapshots, video streaming and PTZ controls 32 | - Support for dormant devices 33 | 34 | ## Supported Models 35 | 36 | You can find [here](https://github.com/user2684/imou_life/wiki/Supported-models) a list with all the Imou models which have been tested and if they are working or not with the integration. Please feel free to report any missing model or additional information. 37 | 38 | ## Installation 39 | 40 | ### [HACS](https://hacs.xyz/) (recommended) 41 | 42 | After installing and configuring HACS, go to "Integrations", "Explore & Download Repositories", search for "Imou Life" and download the integration. 43 | 44 | ### Manual installation 45 | 46 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 47 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 48 | 3. In the `custom_components` directory (folder) create a new folder called `imou_life`. 49 | 4. Download _all_ the files from the `custom_components/imou_life/` directory (folder) in this repository. 50 | 5. Place the files you downloaded in the new directory (folder) you created. 51 | 6. Restart Home Assistant 52 | 53 | This integration depends on the library `imouapi` for interacting with the end device. The library should be installed automatically by Home Assistante when initializing the integration. 54 | If this is not happening, install it manually with: 55 | 56 | ``` 57 | pip3 install imouapi 58 | ``` 59 | 60 | ## Requirements 61 | 62 | To interact with the Imou API, valid `App Id` and `App Secret` are **required**. 63 | 64 | In order to get them: 65 | 66 | - Register an account on Imou Life if not done already 67 | - Register a developer account on [https://open.imoulife.com](https://open.imoulife.com) 68 | - Open the Imou Console at [https://open.imoulife.com/consoleNew/myApp/appInfo](https://open.imoulife.com/consoleNew/myApp/appInfo) 69 | - Go to "My App", "App Information" and click on Edit 70 | - Fill in the required information and copy your AppId and AppSecret 71 | 72 | ## Configuration 73 | 74 | The configuration of the component is done entirely through the UI. 75 | 76 | 1. In the Home Assistant UI to "Configuration" -> "Integrations" click "+" and search for "Imou Life" 77 | 1. Fill in `App Id` and `App Secret` 78 | 1. If `Discover registered devices` is selected: 79 | - A list of all the devices associated with your account is presented 80 | - Select the device you want to add 81 | - Optionally provide a name (otherwise the same name used in Imou Life will be used) 82 | 1. If `Discover registered devices` is NOT selected: 83 | - Provide the Device ID of the device you want to add 84 | - Optionally provide a name (otherwise the same name used in Imou Life will be used) 85 | 86 | Once done, you should see the integration added to Home Assistant, a new device and a few entities associated with it. 87 | 88 | The following entities are created (if supported by the device): 89 | 90 | - Switches: 91 | - All of those supported by the remote device 92 | - Enable/disable push notifications 93 | - Sensors: 94 | - Storage Used on SD card 95 | - Callback URL used for push notifications 96 | - Binary Sensors: 97 | - Online 98 | - Motion alarm 99 | - Select: 100 | - Night Vision Mode 101 | - Buttons: 102 | - Restart device 103 | - Refresh all data 104 | - Refresh motion alarm sensor 105 | 106 | If you need to add another device, repeat the process above. 107 | 108 | ### Advanced Options 109 | 110 | The following options can be customized through the UI by clicking on the "Configure" link: 111 | 112 | - Polling interval - how often to refresh the data (in seconds, default 15 minutes) 113 | - API Base URL - th url of the Imou Life API (default https://openapi.easy4ip.com/openapi) 114 | - API Timeout - API call timeout in seconds (default 10 seconds) 115 | - Callback URL - when push notifications are enabled, full url to use as a callback for push notifications 116 | 117 | ### Motion Detection 118 | 119 | When adding a new device, a `Motion Alarm` binary sensor is created (provided your device supports motion detection). You can use it for building your automations, when its state changes, like any other motion sensors you are already familiar with. 120 | There are multiple options available to keep the motion alarm sensor regularly updated, described below. All the options will update the same "Motion Alarm" sensor, just the frequency changes depending on the option. Option 1 and 2 does not require Home Assistant to be exposed over the Internet, Option 3 does but ensure realtime updates of the sensor. 121 | 122 | #### Option 1 123 | 124 | If you do nothing, by default the sensor will be updated every 15 minutes, like any other sensor of the device. If a motion is detected, the sensor triggers and it will get back to a "Clear" state at the next update cycle (e.g. after other 15 minutes) 125 | 126 | #### Option 2 127 | 128 | If you want to increase the frequency, you can force an update of the "Motion Alarm" sensor state manually or automatically, through the "Refresh Alarm" button which is created in the same device. 129 | For example, you can create an automation like the below which press every 30 seconds the "Refresh Alarm" button for you, causing an update of the "Motion Alarm" sensor state (replace `button.webcam_refreshalarm` with the name of your entity): 130 | 131 | ``` 132 | alias: Imou - Refresh Alarm 133 | description: "" 134 | trigger: 135 | - platform: time_pattern 136 | seconds: "30" 137 | condition: [] 138 | action: 139 | - service: button.press 140 | data: {} 141 | target: 142 | entity_id: 143 | mode: single 144 | ``` 145 | 146 | Please note, the underlying Imou API is limited to 20000 calls per day. 147 | 148 | #### Option 3 149 | 150 | If you want relatime updates of the "Motion Alarm" sensor, you need to enable push notifications. In this scenario, upon an event occurs (e.g. alarm, device offline, etc.) a realtime notification is sent by the Imou API directly to your Home Assistance instance so you can immediately react upon it. 151 | Since this is happening via a direct HTTP call to your instance, your Home Assistance must be [exposed to the Internet](https://www.home-assistant.io/docs/configuration/remote/) as a requirement. 152 | Please note, push notification is a global configuration, not per device, meaning once enabled on one device it applies to ALL devices registered in your Imou account. 153 | 154 | **Requirements** 155 | 156 | - Home Assistant exposed to the Internet 157 | - Home Assistant behind a reverse proxy, due to malformed requests sent by the Imou API (see below for details and examples) 158 | 159 | **Configuration** 160 | 161 | - Ensure Home Assistant is exposed over the Internet, is behind a reverse proxy, and you have implemented the [security checklists](https://www.home-assistant.io/docs/configuration/securing/) 162 | - In Home Assistant, add at least a device through the Imou Life integration 163 | - Go to "Settings", "Devices & Services", select your Imou Life integration and click on "Configure" 164 | - In "Callback URL" add the external URL of your Home Assistant instance, followed by `/api/webhook/`, followed by a random string difficult to guess such as `imou_life_callback_123jkls` and save. For example `https://yourhomeassistant.duckdns.org/api/webhook/imou_life_callback_123jkls`. This will be name of the webhook which will be called when an event occurs. 165 | - Visit the Device page, you should see a "Push Notifications" switch 166 | - Enable the switch. Please remember **this is a global configuration, you just need to do it once** and in a SINGLE device only 167 | - Go to "Settings", "Automation & Scenes" and click on "Create Automation" and select "Start with an empty automation" 168 | - Click the three dots in the top-right of the screen, select "Edit in YAML", copy, paste the following and replace `` with yours (e.g. `imou_life_callback_123jkls`) and save it: 169 | 170 | ``` 171 | alias: Imou Push Notifications 172 | description: "Handle Imou push notifications by requesting to update the Motion Alarm sensor of the involved device (v1)" 173 | trigger: 174 | - platform: webhook 175 | webhook_id: 176 | condition: 177 | - condition: template 178 | value_template: | 179 | {% for entity_name in integration_entities("imou_life") %} 180 | {%- if entity_name is match('.+_refreshalarm$') and is_device_attr(entity_name, "hw_version", trigger.json.did) %} 181 | true 182 | {%-endif%} 183 | {% else %} 184 | false 185 | {%- endfor %} 186 | - condition: template 187 | value_template: |- 188 | {%- if trigger.json.msgType in ("videoMotion", "human", "openCamera") %} 189 | true 190 | {% else %} 191 | false 192 | {%-endif%} 193 | action: 194 | - service: button.press 195 | data: {} 196 | target: 197 | entity_id: | 198 | {% for entity_name in integration_entities("imou_life") %} 199 | {%- if entity_name is match('.+_refreshalarm$') and is_device_attr(entity_name, "hw_version", trigger.json.did) %} 200 | {{entity_name}} 201 | {%-endif%} 202 | {%- endfor %} 203 | enabled: true 204 | mode: queued 205 | max: 10 206 | ``` 207 | 208 | When using this option, please note the following: 209 | 210 | - The API for enabling/disabling push notification is currently limited to 10 times per day by Imou so do not perform too many consecutive changes. Keep also in mind that if you change the URL, sometimes it may take up to 5 minutes for a change to apply on Imou side 211 | - In Home Assistant you cannot have more than one webhook trigger with the same ID so customize the example above if you need to add any custom logic 212 | - Unfortunately HTTP requests sent by the Imou API server to Home Assistant are somehow malformed, causing HA to reject the request (404 error, without any evidence in the logs). A reverse proxy like NGINX in front of Home Assistant without any special configuration takes care of cleaning out the request, hence this is a requirement. Instructions on how to configured it and examples are available [here](https://github.com/user2684/imou_life/wiki/Reverse-proxy-configuration-for-push-notifications). 213 | 214 | ### PTZ Controls 215 | 216 | The integration exposes two services for interacting with the PTZ capabilities of the device: 217 | 218 | - `imou_life.ptz_location`: if the device supports PTZ, you will be able to move it to a specified location by providing horizontal (between -1 and 1), vertical (between -1 and 1) and zoom (between 0 and 1) 219 | - `imou_life.ptz_move` If the device supports PTZ, you will be able to move it around by providing an operation (one of "UP", "DOWN", "LEFT", "RIGHT", "UPPER_LEFT", "BOTTOM_LEFT", "UPPER_RIGHT", "BOTTOM_RIGHT", "ZOOM_IN", "ZOOM_OUT", "STOP") and a duration for the operation (in milliseconds) 220 | 221 | Those services can be invoked on the camera entity. 222 | To test this capability, in Home Assistant go to "Developer Tools", click on "Services", select one of the services above, select the target entity, provide the required information and click on "Call Service". If something will go wrong, have a look at the logs. 223 | 224 | Presets are instead not apparently supported by the Imou APIs but could be implemented by combining HA scripts and calls to the `imou_life.ptz_location` service. 225 | 226 | ## Limitations / Known Issues 227 | 228 | - The Imou API does not provide a stream of events, for this reason the integration periodically polls the devices to sync the configuration. So if you change anything from the Imou Life App, it could take a few minutes to be updated in HA. Use the "Refresh Data" button to refresh data for all the devices' sensors 229 | - Imou limits up to 5 the number of devices which can be controlled through a developer account (e.g. the method used by this integration). Additional devices are added successfully but they will throw a "APIError: FL1001: Insufficient remaining available licenses" error message whenever you try to interact. As a workaround, create another Imou account associated to a different e-mail address, take note of the new AppId and AppSecret, and bind the device there. Alternatively, you can also keep the device associated with the primary account, share it with the newly account and add it to HA through the integration 230 | - For every new device to be added, AppId and AppSecret are requested 231 | - Advanced options can be changed only after having added the device 232 | - Due to malformed requests sent by the Imou API server, in order for push notifications to work, Home Assistant must be behind a reverse proxy 233 | 234 | ## Troubleshooting 235 | 236 | If anything fails, you should find the error message and the full stack trace on your Home Assistant logs. This can be helpful for either troubleshoot the issue or reporting it. 237 | 238 | ### Device Diagnostics 239 | 240 | Diagnostics information is provided by visiting the device page in Home Assistant and clicking on "Download Diagnostics". 241 | 242 | ### Debugging 243 | 244 | To gain more insights on what the component is doing or why is failing, you can enable debug logging: 245 | 246 | ``` 247 | logger: 248 | default: info 249 | logs: 250 | custom_components.imou_life: debug 251 | ``` 252 | 253 | Since this integration depends on the library `imouapi` for interacting with the end device, you may want to enable debug level logging to the library itself: 254 | 255 | ``` 256 | logger: 257 | default: info 258 | logs: 259 | custom_components.imou_life: debug 260 | imouapi: debug 261 | ``` 262 | 263 | ## Bugs or feature requests 264 | 265 | Bugs and feature requests can be reported through [Github Issues](https://github.com/user2684/imou_life/issues). 266 | When reporting bugs, ensure to include also diagnostics and debug logs. Please review those logs to redact any potential sensitive information before submitting the request. 267 | 268 | ## Contributing 269 | 270 | Any contribution is more than welcome. Github is used to host the code, track issues and feature requests, as well as submitting pull requests. 271 | Detailed information on how the code is structured, how to setup a development environment and how to test your code before submitting pull requests is 272 | detailed [here](https://github.com/user2684/imou_life/wiki/How-to-contribute). 273 | 274 | ## Roadmap 275 | 276 | A high level roadmap of this integration can be found [here](https://github.com/users/user2684/projects/1) 277 | --------------------------------------------------------------------------------