├── .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 | [](https://github.com/hacs/integration)
4 | [](https://github.com/hacs/integration)
5 | [](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 |
--------------------------------------------------------------------------------