├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ └── responses.py ├── helpers.py ├── test_experimental_settings.py ├── test_decorators.py ├── test_migrations.py ├── test_alarm_panel.py ├── test_services.py ├── test_helpers.py ├── test_sensors.py ├── test_switch.py ├── conftest.py ├── test_options_flow.py ├── test_config_flow.py ├── test_coordinator.py └── test_binary_sensors.py ├── custom_components ├── __init__.py └── econnect_metronet │ ├── icons.json │ ├── manifest.json │ ├── services.yaml │ ├── const.py │ ├── services.py │ ├── sensor.py │ ├── decorators.py │ ├── switch.py │ ├── helpers.py │ ├── alarm_control_panel.py │ ├── coordinator.py │ ├── __init__.py │ ├── config_flow.py │ ├── binary_sensor.py │ ├── strings.json │ └── translations │ ├── en.json │ └── it.json ├── images ├── homekit_alarm_panel.jpg ├── alarm_panel_entity_id.png └── homekit_alarm_panel_open.png ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── hassfest.yaml │ ├── linting.yaml │ ├── publish.yaml │ ├── testing.yaml │ └── building.yaml └── pull_request_template.md ├── hacs.json ├── tox.ini ├── compose.yaml ├── scripts ├── init └── download_fixtures.sh ├── LICENSE ├── .pre-commit-config.yaml ├── pyproject.toml ├── CONTRIBUTING.md ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/homekit_alarm_panel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palazzem/ha-econnect-alarm/HEAD/images/homekit_alarm_panel.jpg -------------------------------------------------------------------------------- /images/alarm_panel_entity_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palazzem/ha-econnect-alarm/HEAD/images/alarm_panel_entity_id.png -------------------------------------------------------------------------------- /images/homekit_alarm_panel_open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palazzem/ha-econnect-alarm/HEAD/images/homekit_alarm_panel_open.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Something unclear? Just ask to our community! 4 | url: https://discord.gg/NSmAPWw8tE 5 | about: Join our Discord channel! 6 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "arm_sectors": "mdi:shield-lock", 4 | "disarm_sectors": "mdi:shield-off", 5 | "update_state": "mdi:update" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Home Assistant e-Connect/Metronet Integration (Elmo)", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "hide_default_branch": true, 6 | "zip_release": true, 7 | "filename": "hacs_econnect_metronet.zip" 8 | } 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint 4 | python3.13 5 | 6 | [testenv] 7 | allowlist_externals = pytest 8 | deps = 9 | -e .[dev] 10 | commands = 11 | pytest tests --cov --cov-branch --cov-report=xml -vv 12 | 13 | [testenv:lint] 14 | skip_install = true 15 | deps = pre-commit 16 | commands = pre-commit run --all-files 17 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | homeassistant: 5 | container_name: hass-dev 6 | image: "ghcr.io/home-assistant/home-assistant:stable" 7 | volumes: 8 | - ${PWD}/config:/config 9 | - ${PWD}/custom_components:/config/custom_components 10 | - /etc/localtime:/etc/localtime:ro 11 | - /run/dbus:/run/dbus:ro 12 | ports: 13 | - "127.0.0.1:8123:8123" 14 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | def _(mock_path: str) -> str: 2 | """Helper to simplify Mock path strings. 3 | 4 | Args: 5 | mock_path (str): The partial path to be appended to the standard prefix for mock paths. 6 | 7 | Returns: 8 | str: The full mock path combined with the standard prefix. 9 | 10 | Example: 11 | >>> _("module.Class.method") 12 | "custom_components.econnect_metronet.module.Class.method" 13 | """ 14 | return f"custom_components.econnect_metronet.{mock_path}" 15 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | concurrency: 14 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | hassfest: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Check out the repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Test hassfest 26 | uses: home-assistant/actions/hassfest@master 27 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "econnect_metronet", 3 | "name": "e-Connect/Metronet Alarm", 4 | "after_dependencies": [], 5 | "codeowners": ["@palazzem"], 6 | "config_flow": true, 7 | "dependencies": [], 8 | "documentation": "https://github.com/palazzem/ha-econnect-alarm", 9 | "integration_type": "device", 10 | "iot_class": "cloud_polling", 11 | "issue_tracker": "https://github.com/palazzem/ha-econnect-alarm/issues", 12 | "loggers": ["custom_components.econnect_metronet", "elmo"], 13 | "requirements": ["econnect-python==0.14.1"], 14 | "version": "2.5.1" 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Related Issues 2 | 3 | - #issue-number 4 | 5 | ### Proposed Changes: 6 | 7 | 8 | 9 | ### Testing: 10 | 11 | 12 | ### Extra Notes (optional): 13 | 14 | 15 | 16 | ### Checklist 17 | 18 | - [ ] Related issues and proposed changes are filled 19 | - [ ] Tests are defining the correct and expected behavior 20 | - [ ] Code is well-documented via docstrings 21 | -------------------------------------------------------------------------------- /scripts/init: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Parameters 5 | VERSION=$1 6 | 7 | # Abort if no version is specified 8 | if [ -z "$VERSION" ]; then 9 | echo "Usage: ./scripts/init.sh " 10 | exit 1 11 | fi 12 | 13 | # Abort if `venv` folder already exists 14 | if [ -d "venv" ]; then 15 | echo "venv/ folder already exists. Deactivate your venv and remove venv/ folder." 16 | exit 1 17 | fi 18 | 19 | # Create and activate a new virtual environment 20 | python3 -m venv venv 21 | source venv/bin/activate 22 | 23 | # Upgrade pip and install all projects and their dependencies 24 | pip install --upgrade pip 25 | pip install -e '.[all]' 26 | 27 | # Override Home Assistant version 28 | pip install homeassistant==$VERSION 29 | ./scripts/download_fixtures.sh $VERSION 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Errors you encountered 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Error message** 14 | Error that was thrown (if available) 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here, like configuration you have in place, network connection, etc. 21 | 22 | **To Reproduce** 23 | Steps to reproduce the behavior 24 | 25 | **Environment** 26 | - Operating System (OS): 27 | - Library version (commit or version number): 28 | - Home Assistant version: 29 | - Last working Home Assistant release (if known): 30 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | name: 'Linting' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | concurrency: 14 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | econnect: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Check out repository code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: '3.13' 29 | 30 | - name: Upgrade pip and install required tools 31 | run: | 32 | pip install --upgrade pip 33 | pip install tox 34 | 35 | - name: Lint codebase 36 | run: tox -e lint 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: 'Publish' 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | concurrency: 9 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | release: 14 | name: HACS release 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | steps: 19 | - name: Checkout the repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Build release package (HACS) 23 | run: | 24 | cd ${{ github.workspace }}/custom_components/econnect_metronet 25 | zip hacs_econnect_metronet.zip -r ./ 26 | 27 | - name: Upload release packages 28 | uses: softprops/action-gh-release@v0.1.15 29 | with: 30 | files: | 31 | ${{ github.workspace }}/custom_components/econnect_metronet/hacs_econnect_metronet.zip 32 | -------------------------------------------------------------------------------- /tests/test_experimental_settings.py: -------------------------------------------------------------------------------- 1 | from custom_components.econnect_metronet.binary_sensor import AlertBinarySensor 2 | from custom_components.econnect_metronet.const import DOMAIN 3 | 4 | 5 | class TestExperimentalSettings: 6 | def test_sensor_force_update_default(self, coordinator, config_entry, alarm_device): 7 | # Ensure the default is to not force any update 8 | entity = AlertBinarySensor("device_tamper", 7, config_entry, "device_tamper", coordinator, alarm_device) 9 | assert entity._attr_force_update is False 10 | 11 | def test_sensor_force_update_on(self, hass, coordinator, config_entry, alarm_device): 12 | # Ensure you can force the entity update 13 | hass.data[DOMAIN] = { 14 | "experimental": { 15 | "force_update": True, 16 | } 17 | } 18 | entity = AlertBinarySensor("device_tamper", 7, config_entry, "device_tamper", coordinator, alarm_device) 19 | assert entity._attr_force_update is True 20 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available e-Connect services 2 | 3 | update_state: 4 | name: Update Alarm State 5 | description: Force an update of the alarm areas and inputs. 6 | 7 | arm_sectors: 8 | name: Arm Sectors 9 | description: Arm one or multiple sectors. 10 | target: 11 | entity: 12 | integration: econnect_metronet 13 | domain: binary_sensor 14 | device_class: "sector" 15 | fields: 16 | code: 17 | name: Code 18 | required: true 19 | description: A code to trigger the alarm control panel with. 20 | example: "1234" 21 | selector: 22 | text: 23 | 24 | disarm_sectors: 25 | name: Disarm Sectors 26 | description: Disarm one or multiple sectors. 27 | target: 28 | entity: 29 | integration: econnect_metronet 30 | domain: binary_sensor 31 | device_class: "sector" 32 | fields: 33 | code: 34 | name: Code 35 | required: true 36 | description: A code to trigger the alarm control panel with. 37 | example: "1234" 38 | selector: 39 | text: 40 | -------------------------------------------------------------------------------- /.github/workflows/testing.yaml: -------------------------------------------------------------------------------- 1 | name: 'Testing' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | concurrency: 14 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | econnect: 19 | runs-on: ubuntu-latest 20 | env: 21 | TOX_SKIP_ENV: lint 22 | 23 | steps: 24 | - name: Check out repository code 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: | 31 | 3.13 32 | 33 | - name: Upgrade pip and install required tools 34 | run: | 35 | pip install --upgrade pip 36 | pip install tox 37 | 38 | - name: Install Home Assistant testing platform 39 | run: | 40 | ./scripts/download_fixtures.sh $(curl --silent "https://api.github.com/repos/home-assistant/core/releases/latest" | grep -Po "(?<=\"tag_name\": \").*(?=\")") 41 | 42 | - name: Test with tox environments 43 | run: tox 44 | 45 | - name: Update Coveralls report 46 | uses: coverallsapp/github-action@v2 47 | -------------------------------------------------------------------------------- /.github/workflows/building.yaml: -------------------------------------------------------------------------------- 1 | name: 'Building release package' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | concurrency: 14 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Check out repository code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: '3.13' 29 | 30 | - name: Upgrade pip and install required tools 31 | run: | 32 | pip install --upgrade pip 33 | pip install hatch 34 | 35 | - name: Detect build version 36 | run: echo "PKG_VERSION=$(hatch version)" >> "$GITHUB_ENV" 37 | 38 | - name: Build test package 39 | run: hatch -v build -t sdist 40 | 41 | - name: Log package content 42 | run: tar -tvf dist/econnect_metronet-$PKG_VERSION.tar.gz 43 | 44 | - name: Install the package 45 | run: pip install dist/econnect_metronet-$PKG_VERSION.tar.gz 46 | 47 | - name: Test if the package is built correctly 48 | run: python -c "import custom_components.econnect_metronet" 49 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the E-connect Alarm integration.""" 2 | 3 | from elmo import systems as s 4 | 5 | SUPPORTED_SYSTEMS = { 6 | s.ELMO_E_CONNECT: "Elmo e-Connect", 7 | s.IESS_METRONET: "IESS Metronet", 8 | } 9 | CONF_DOMAIN = "domain" 10 | CONF_SYSTEM_URL = "system_base_url" 11 | CONF_SYSTEM_NAME = "system_name" 12 | CONF_AREAS_ARM_AWAY = "areas_arm_away" 13 | CONF_AREAS_ARM_HOME = "areas_arm_home" 14 | CONF_AREAS_ARM_NIGHT = "areas_arm_night" 15 | CONF_AREAS_ARM_VACATION = "areas_arm_vacation" 16 | CONF_SCAN_INTERVAL = "scan_interval" 17 | CONF_MANAGE_SECTORS = "managed_sectors" 18 | DEVICE_CLASS_SECTORS = "sector" 19 | DOMAIN = "econnect_metronet" 20 | NOTIFICATION_MESSAGE = ( 21 | "The switch cannot be used because it requires two settings to be configured in the Alarm Panel: " 22 | "'manual control' and 'activation without authentication'. " 23 | "While these settings can be enabled by your installer, this may not always be the case. " 24 | "Please contact your installer for further assistance" 25 | ) 26 | NOTIFICATION_TITLE = "Unable to toggle the switch" 27 | NOTIFICATION_IDENTIFIER = "econnect_metronet_output_fail" 28 | KEY_DEVICE = "device" 29 | KEY_COORDINATOR = "coordinator" 30 | KEY_UNSUBSCRIBER = "options_unsubscriber" 31 | # Defines the default scan interval in seconds. 32 | # Fast scanning is required for real-time updates of the alarm state. 33 | SCAN_INTERVAL_DEFAULT = 5 34 | POLLING_TIMEOUT = 20 35 | 36 | # Experimental Settings 37 | CONF_EXPERIMENTAL = "experimental" 38 | CONF_FORCE_UPDATE = "force_update" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Emanuele Palazzetti 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: check-ast 8 | - id: check-json 9 | - id: check-merge-conflict 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: end-of-file-fixer 13 | exclude: custom_components/econnect_metronet/manifest.json 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | 17 | - repo: https://github.com/PyCQA/isort 18 | rev: 5.13.2 19 | hooks: 20 | - id: isort 21 | exclude: tests/hass/ 22 | args: ["--profile", "black"] 23 | 24 | - repo: https://github.com/asottile/pyupgrade 25 | rev: v3.15.0 26 | hooks: 27 | - id: pyupgrade 28 | 29 | - repo: https://github.com/psf/black 30 | rev: 24.1.1 31 | hooks: 32 | - id: black 33 | exclude: tests/hass/ 34 | args: [--line-length=120] 35 | 36 | - repo: https://github.com/PyCQA/flake8 37 | rev: 7.0.0 38 | hooks: 39 | - id: flake8 40 | exclude: tests/hass/ 41 | args: [--max-line-length=120 ] 42 | 43 | - repo: https://github.com/PyCQA/bandit 44 | rev: '1.7.7' 45 | hooks: 46 | - id: bandit 47 | exclude: tests/|tests/hass/ 48 | 49 | - repo: https://github.com/astral-sh/ruff-pre-commit 50 | # Ruff version. 51 | rev: v0.1.15 52 | hooks: 53 | - id: ruff 54 | exclude: tests/hass/ 55 | args: [--line-length=120] 56 | 57 | - repo: https://github.com/pre-commit/mirrors-mypy 58 | rev: v1.8.0 59 | hooks: 60 | - id: mypy 61 | exclude: tests/|tests/hass/ 62 | additional_dependencies: [types-requests] 63 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/services.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.core import HomeAssistant, ServiceCall 4 | 5 | from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE 6 | from .decorators import retry_refresh_token_service 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | @retry_refresh_token_service 12 | async def arm_sectors(hass: HomeAssistant, config_id: str, call: ServiceCall): 13 | _LOGGER.debug(f"Service | Triggered action {call.service}") 14 | device = hass.data[DOMAIN][config_id][KEY_DEVICE] 15 | sectors = [device._sectors[x.split(".")[1]] for x in call.data["entity_id"]] 16 | code = call.data.get("code") 17 | _LOGGER.debug(f"Service | Arming sectors: {sectors}") 18 | await hass.async_add_executor_job(device.arm, code, sectors) 19 | 20 | 21 | @retry_refresh_token_service 22 | async def disarm_sectors(hass: HomeAssistant, config_id: str, call: ServiceCall): 23 | _LOGGER.debug(f"Service | Triggered action {call.service}") 24 | device = hass.data[DOMAIN][config_id][KEY_DEVICE] 25 | sectors = [device._sectors[x.split(".")[1]] for x in call.data["entity_id"]] 26 | code = call.data.get("code") 27 | _LOGGER.debug(f"Service | Disarming sectors: {sectors}") 28 | await hass.async_add_executor_job(device.disarm, code, sectors) 29 | 30 | 31 | @retry_refresh_token_service 32 | async def update_state(hass: HomeAssistant, config_id: str, call: ServiceCall): 33 | _LOGGER.debug(f"Service | Triggered action {call.service}") 34 | coordinator = hass.data[DOMAIN][config_id][KEY_COORDINATOR] 35 | _LOGGER.debug("Service | Updating alarm state...") 36 | await coordinator.async_refresh() 37 | -------------------------------------------------------------------------------- /scripts/download_fixtures.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script downloads a specified version of Home Assistant, 4 | # extracts its test suite, processes the files, and moves them into a 5 | # local 'tests/hass' directory for further use. 6 | # 7 | # Usage: ./scripts/download_fixtures.sh 8 | # Example: ./scripts/download_fixtures.sh 2021.3.4 9 | set -e 10 | 11 | # Parameters 12 | VERSION=$1 13 | 14 | # Abort if no version is specified 15 | if [ -z "$VERSION" ]; then 16 | echo "Usage: ./scripts/download_fixtures.sh " 17 | exit 1 18 | fi 19 | 20 | # Variables 21 | DOWNLOAD_FOLDER=$(mktemp -d) 22 | HASS_TESTS_FOLDER=$DOWNLOAD_FOLDER/core-$VERSION/tests/ 23 | 24 | # Remove previous folder if exists 25 | if [ -d "tests/hass" ]; then 26 | echo "Removing previous tests/hass/ folder" 27 | rm -rf tests/hass 28 | fi 29 | 30 | # Download HASS version 31 | echo "Downloading Home Assistant $VERSION in $DOWNLOAD_FOLDER" 32 | curl -L https://github.com/home-assistant/core/archive/refs/tags/$VERSION.tar.gz -o $DOWNLOAD_FOLDER/$VERSION.tar.gz 33 | 34 | # Extract HASS fixtures and tests helpers, excluding all components and actual tests 35 | echo "Extracting tests/ folder from $VERSION.tar.gz" 36 | tar -C $DOWNLOAD_FOLDER --exclude='*/components/*' --exclude='*/pylint/*' -xf $DOWNLOAD_FOLDER/$VERSION.tar.gz core-$VERSION/tests 37 | find $HASS_TESTS_FOLDER -type f -name "test_*.py" -delete 38 | 39 | # Recursively find and update imports 40 | find $HASS_TESTS_FOLDER -type f -exec sed -i 's/from tests\./from tests.hass./g' {} + 41 | mv $HASS_TESTS_FOLDER/conftest.py $HASS_TESTS_FOLDER/fixtures.py 42 | 43 | # Copy Home Assistant fixtures 44 | mv $HASS_TESTS_FOLDER ./tests/hass 45 | echo "Home Assistant $VERSION tests are now in tests/hass/" 46 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from elmo.api.exceptions import CodeError, LockError 3 | 4 | from custom_components.econnect_metronet.decorators import set_device_state 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_set_device_state_successful(panel): 9 | """Should update the device state to the new state.""" 10 | 11 | @set_device_state("new_state", "loader_state") 12 | async def test_func(self): 13 | pass 14 | 15 | # Test 16 | await test_func(panel) 17 | assert panel._device.state == "new_state" 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_set_device_state_lock_error(panel): 22 | """Should revert the device state to the previous state.""" 23 | 24 | @set_device_state("new_state", "loader_state") 25 | async def test_func(self): 26 | raise LockError() 27 | 28 | panel._device.state = "old_state" 29 | # Test 30 | await test_func(panel) 31 | assert panel._device.state == "old_state" 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_set_device_state_code_error(panel): 36 | """Should revert the device state to the previous state.""" 37 | 38 | @set_device_state("new_state", "loader_state") 39 | async def test_func(self): 40 | raise CodeError() 41 | 42 | panel._device.state = "old_state" 43 | # Test 44 | await test_func(panel) 45 | assert panel._device.state == "old_state" 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_set_device_state_loader_state(panel): 50 | """Should use the loader_state until the function is completed.""" 51 | 52 | @set_device_state("new_state", "loader_state") 53 | async def test_func(self): 54 | # Test (what runs here is before the function is completed) 55 | assert self._device.state == "loader_state" 56 | 57 | # Run test 58 | await test_func(panel) 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "hatchling", 4 | ] 5 | build-backend = "hatchling.build" 6 | 7 | [project] 8 | name = "econnect-metronet" 9 | dynamic = ["version"] 10 | description = "Home Assistant integration that provides a full-fledged Alarm Panel to control your Elmo/IESS alarm systems." 11 | readme = "README.md" 12 | requires-python = ">=3.11" 13 | license = "Apache-2.0" 14 | keywords = [ 15 | "python", 16 | "home-automation", 17 | "home-assistant", 18 | "alarm-system", 19 | "econnect", 20 | "elmo", 21 | "metronet", 22 | "iess", 23 | ] 24 | authors = [ 25 | { name = "Emanuele Palazzetti", email = "emanuele.palazzetti@gmail.com" }, 26 | ] 27 | classifiers = [ 28 | "Development Status :: 4 - Beta", 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3.13", 31 | "Programming Language :: Python :: Implementation :: CPython", 32 | ] 33 | dependencies = [ 34 | "econnect-python==0.14.1", 35 | "async_timeout", 36 | "homeassistant", 37 | ] 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | "mypy", 42 | "pre-commit", 43 | # Test 44 | "pytest", 45 | "pytest-asyncio", 46 | "pytest-cov", 47 | "pytest-mock", 48 | "responses", 49 | "tox", 50 | # Home Assistant fixtures 51 | "freezegun", 52 | "pytest-asyncio", 53 | "pytest-socket", 54 | "requests-mock", 55 | "syrupy", 56 | "respx", 57 | ] 58 | 59 | lint = [ 60 | "black", 61 | "flake8", 62 | ] 63 | 64 | all = [ 65 | "econnect-metronet[dev]", 66 | "econnect-metronet[lint]", 67 | ] 68 | 69 | [project.urls] 70 | Documentation = "https://github.com/palazzem/ha-econnect-alarm#readme" 71 | Issues = "https://github.com/palazzem/ha-econnect-alarm/issues" 72 | Source = "https://github.com/palazzem/ha-econnect-alarm" 73 | 74 | [tool.hatch.version] 75 | path = "custom_components/econnect_metronet/manifest.json" 76 | pattern = '"version":\s"(?P[^"]+)"' 77 | 78 | [tool.hatch.metadata] 79 | allow-direct-references = true 80 | 81 | [tool.pytest.ini_options] 82 | asyncio_mode = "auto" 83 | 84 | [tool.hatch.build.targets.sdist] 85 | only-include = ["custom_components/econnect_metronet"] 86 | 87 | [tool.coverage.run] 88 | omit = [ 89 | "tests/*", 90 | ] 91 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from custom_components.econnect_metronet import async_migrate_entry 2 | from custom_components.econnect_metronet.const import DOMAIN 3 | 4 | from .hass.fixtures import MockConfigEntry 5 | 6 | 7 | async def test_async_no_migrations(mocker, hass, config_entry): 8 | spy = mocker.spy(hass.config_entries, "async_update_entry") 9 | # Test 10 | result = await async_migrate_entry(hass, config_entry) 11 | assert result is True 12 | assert spy.call_count == 0 13 | 14 | 15 | async def test_async_migrate_from_v1(hass): 16 | config_entry = MockConfigEntry( 17 | version=1, 18 | domain=DOMAIN, 19 | entry_id="test_entry_id", 20 | options={}, 21 | data={ 22 | "username": "test_user", 23 | "password": "test_password", 24 | "domain": "econnect_metronet", 25 | }, 26 | ) 27 | config_entry.add_to_hass(hass) 28 | # Test 29 | result = await async_migrate_entry(hass, config_entry) 30 | assert result is True 31 | assert config_entry.version == 3 32 | assert config_entry.options == {} 33 | assert config_entry.data == { 34 | "username": "test_user", 35 | "password": "test_password", 36 | "domain": "econnect_metronet", 37 | "system_base_url": "https://connect.elmospa.com", 38 | } 39 | 40 | 41 | async def test_async_migrate_from_v2(hass): 42 | config_entry = MockConfigEntry( 43 | version=2, 44 | domain=DOMAIN, 45 | entry_id="test_entry_id", 46 | options={ 47 | "areas_arm_home": "1,2", 48 | "areas_arm_night": "1,2", 49 | "areas_arm_vacation": "1,2", 50 | "scan_interval": 60, 51 | }, 52 | data={ 53 | "username": "test_user", 54 | "password": "test_password", 55 | "domain": "econnect_metronet", 56 | "system_base_url": "https://example.com", 57 | }, 58 | ) 59 | config_entry.add_to_hass(hass) 60 | # Test 61 | result = await async_migrate_entry(hass, config_entry) 62 | assert result is True 63 | assert config_entry.version == 3 64 | assert config_entry.options == { 65 | "areas_arm_home": [1, 2], 66 | "areas_arm_night": [1, 2], 67 | "areas_arm_vacation": [1, 2], 68 | "scan_interval": 60, 69 | } 70 | -------------------------------------------------------------------------------- /tests/test_alarm_panel.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 5 | 6 | from custom_components.econnect_metronet.alarm_control_panel import EconnectAlarm 7 | from custom_components.econnect_metronet.devices import AlarmDevice 8 | 9 | 10 | def test_alarm_panel_name(client, hass, config_entry): 11 | # Ensure the Alarm Panel has the right name 12 | device = AlarmDevice(client) 13 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 14 | entity = EconnectAlarm("test_id", config_entry, device, coordinator) 15 | assert entity.name == "Alarm Panel test_user" 16 | 17 | 18 | def test_alarm_panel_name_with_system_name(client, hass, config_entry): 19 | # Ensure the Entity ID takes into consideration the system optional name 20 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 21 | device = AlarmDevice(client) 22 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 23 | entity = EconnectAlarm("test_id", config_entry, device, coordinator) 24 | assert entity.name == "Alarm Panel Home" 25 | 26 | 27 | def test_alarm_panel_entity_id(client, hass, config_entry): 28 | # Ensure the Alarm Panel has a valid Entity ID 29 | device = AlarmDevice(client) 30 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 31 | entity = EconnectAlarm("test_id", config_entry, device, coordinator) 32 | assert entity.entity_id == "econnect_metronet.econnect_metronet_test_user" 33 | 34 | 35 | def test_alarm_panel_entity_id_with_system_name(client, hass, config_entry): 36 | # Ensure the Entity ID takes into consideration the system name 37 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 38 | device = AlarmDevice(client) 39 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 40 | entity = EconnectAlarm("test_id", config_entry, device, coordinator) 41 | assert entity.entity_id == "econnect_metronet.econnect_metronet_home" 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_alarm_panel_arm_away(mocker, panel): 46 | # Ensure an empty AWAY config arms all sectors 47 | arm = mocker.patch.object(panel._device._connection, "arm", autopsec=True) 48 | # Test 49 | await panel.async_alarm_arm_away(code=42) 50 | assert arm.call_count == 1 51 | assert arm.call_args.kwargs["sectors"] == [] 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_alarm_panel_arm_away_with_options(mocker, panel): 56 | # Ensure an empty AWAY config arms all sectors 57 | arm = mocker.patch.object(panel._device._connection, "arm", autopsec=True) 58 | panel._device._sectors_away = [1, 2] 59 | # Test 60 | await panel.async_alarm_arm_away(code=42) 61 | assert arm.call_count == 1 62 | assert arm.call_args.kwargs["sectors"] == [1, 2] 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Home Assistant e-Connect integration 2 | 3 | This document provides guidelines for contributing to project. Before you begin, please follow the 4 | instructions below: 5 | 6 | 1. Prepare your [development environment](https://github.com/palazzem/ha-econnect-alarm#development). 7 | 2. Ensure that you have installed the `pre-commit` hooks. 8 | 3. Run `tox` to execute the full test suite. 9 | 10 | By following these steps, you can ensure that your contributions are of the highest quality and are properly tested 11 | before they are merged into the project. 12 | 13 | ## Issues 14 | 15 | If you're experiencing a problem or have a suggestion, we encourage you to open a 16 | [new issue](https://github.com/palazzem/ha-econnect-alarm/issues/new/choose). 17 | Please make sure to select the most appropriate type from the options provided: 18 | 19 | - **Bug Report**: If you've identified an issue with an existing feature that isn't performing as documented or expected, 20 | please select this option. This will help us identify and rectify problems more efficiently. 21 | 22 | - **Feature Request**: Opt for this if you have an idea for a new feature or an enhancement to the current ones. 23 | Additionally, if you feel that a certain feature could be optimized or modified to better suit specific scenarios, this is 24 | the right category to bring it to our attention. 25 | 26 | - **Join Discord channel**: If you are unsure, or if you have a general question, please join our [Discord channel](https://discord.gg/NSmAPWw8tE). 27 | 28 | After choosing an issue type, a pre-formatted template will appear. It's essential to provide as much detail as possible 29 | within this template. Your insights and contributions help in improving the project, and we genuinely appreciate your effort. 30 | 31 | ## Pull Requests 32 | 33 | ### PR Title 34 | 35 | We follow the [conventional commit convention](https://www.conventionalcommits.org/en/v1.0.0/) for our PR titles. 36 | The title should adhere to the structure below: 37 | 38 | ``` 39 | [optional scope]: 40 | ``` 41 | 42 | The common types are: 43 | - `feat` (enhancements) 44 | - `fix` (bug fixes) 45 | - `docs` (documentation changes) 46 | - `perf` (performance improvements) 47 | - `refactor` (major code refactorings) 48 | - `tests` (changes to tests) 49 | - `tools` (changes to package spec or tools in general) 50 | - `ci` (changes to our CI) 51 | - `deps` (changes to dependencies) 52 | 53 | If your change breaks backwards compatibility, indicate so by adding `!` after the type. 54 | 55 | Examples: 56 | - `feat(cli): add Transcribe command` 57 | - `fix: ensure hashing function returns correct value for random input` 58 | - `feat!: remove deprecated API` (a change that breaks backwards compatibility) 59 | 60 | ### PR Description 61 | 62 | After opening a new pull request, a pre-formatted template will appear. It's essential to provide as much detail as possible 63 | within this template. A good description and can speed up the review process to get your code merged. 64 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/sensor.py: -------------------------------------------------------------------------------- 1 | """Module for e-connect sensors (alert) """ 2 | 3 | from elmo import query as q 4 | from homeassistant.components.sensor import SensorEntity 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 8 | from homeassistant.helpers.update_coordinator import ( 9 | CoordinatorEntity, 10 | DataUpdateCoordinator, 11 | ) 12 | 13 | from .const import ( 14 | CONF_EXPERIMENTAL, 15 | CONF_FORCE_UPDATE, 16 | DOMAIN, 17 | KEY_COORDINATOR, 18 | KEY_DEVICE, 19 | ) 20 | from .devices import AlarmDevice 21 | from .helpers import generate_entity_id 22 | 23 | 24 | async def async_setup_entry( 25 | hass: HomeAssistant, 26 | entry: ConfigEntry, 27 | async_add_entities: AddEntitiesCallback, 28 | ) -> None: 29 | """Set up e-connect sensors from a config entry.""" 30 | device = hass.data[DOMAIN][entry.entry_id][KEY_DEVICE] 31 | coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] 32 | # Load all entities and register sectors and inputs 33 | 34 | sensors = [] 35 | 36 | # Iterate through the alerts of the provided device and create AlertSensor objects 37 | # only for alarm_led, inputs_led and tamper_led 38 | for alert_id, name in device.alerts: 39 | if name in ["alarm_led", "inputs_led", "tamper_led"]: 40 | unique_id = f"{entry.entry_id}_{DOMAIN}_{q.ALERTS}_{alert_id}" 41 | sensors.append(AlertSensor(unique_id, alert_id, entry, name, coordinator, device)) 42 | 43 | async_add_entities(sensors) 44 | 45 | 46 | class AlertSensor(CoordinatorEntity, SensorEntity): 47 | """Representation of a e-Connect alert sensor""" 48 | 49 | _attr_has_entity_name = True 50 | 51 | def __init__( 52 | self, 53 | unique_id: str, 54 | alert_id: int, 55 | config: ConfigEntry, 56 | name: str, 57 | coordinator: DataUpdateCoordinator, 58 | device: AlarmDevice, 59 | ) -> None: 60 | """Construct.""" 61 | # Enable experimental settings from the configuration file 62 | experimental = coordinator.hass.data[DOMAIN].get(CONF_EXPERIMENTAL, {}) 63 | self._attr_force_update = experimental.get(CONF_FORCE_UPDATE, False) 64 | 65 | super().__init__(coordinator) 66 | self.entity_id = generate_entity_id(config, name) 67 | self._name = name 68 | self._device = device 69 | self._unique_id = unique_id 70 | self._alert_id = alert_id 71 | 72 | @property 73 | def unique_id(self) -> str: 74 | """Return the unique identifier.""" 75 | return self._unique_id 76 | 77 | @property 78 | def translation_key(self) -> str: 79 | """Return the translation key to translate the entity's name and states.""" 80 | return self._name 81 | 82 | @property 83 | def icon(self) -> str: 84 | """Return the icon used by this entity.""" 85 | return "hass:alarm-light" 86 | 87 | @property 88 | def native_value(self) -> int | None: 89 | return self._device.get_status(q.ALERTS, self._alert_id) 90 | -------------------------------------------------------------------------------- /tests/test_services.py: -------------------------------------------------------------------------------- 1 | from homeassistant.core import ServiceCall 2 | 3 | from custom_components.econnect_metronet import services 4 | from custom_components.econnect_metronet.binary_sensor import SectorBinarySensor 5 | from custom_components.econnect_metronet.const import DOMAIN 6 | 7 | 8 | async def test_service_arm_sectors(hass, config_entry, alarm_device, coordinator, mocker): 9 | # Ensure `arm_sectors` activates the correct sectors 10 | arm = mocker.patch.object(alarm_device, "arm") 11 | SectorBinarySensor("test_id", 0, config_entry, "S1 Living Room", coordinator, alarm_device) 12 | SectorBinarySensor("test_id", 2, config_entry, "S3 Outdoor", coordinator, alarm_device) 13 | hass.data[DOMAIN][config_entry.entry_id] = { 14 | "device": alarm_device, 15 | "coordinator": coordinator, 16 | } 17 | call = ServiceCall( 18 | hass=hass, 19 | domain=DOMAIN, 20 | service="arm_sectors", 21 | data={ 22 | "entity_id": [ 23 | "binary_sensor.econnect_metronet_test_user_s1_living_room", 24 | "binary_sensor.econnect_metronet_test_user_s3_outdoor", 25 | ], 26 | "code": "1234", 27 | }, 28 | ) 29 | # Test 30 | await services.arm_sectors(hass, config_entry.entry_id, call) 31 | assert arm.call_count == 1 32 | assert arm.call_args[0][0] == "1234" 33 | assert arm.call_args[0][1] == [1, 3] 34 | 35 | 36 | async def test_service_disarm_sectors(hass, config_entry, alarm_device, coordinator, mocker): 37 | # Ensure `disarm_sectors` activates the correct sectors 38 | disarm = mocker.patch.object(alarm_device, "disarm") 39 | SectorBinarySensor("test_id", 0, config_entry, "S1 Living Room", coordinator, alarm_device) 40 | SectorBinarySensor("test_id", 2, config_entry, "S3 Outdoor", coordinator, alarm_device) 41 | hass.data[DOMAIN][config_entry.entry_id] = { 42 | "device": alarm_device, 43 | "coordinator": coordinator, 44 | } 45 | call = ServiceCall( 46 | hass=hass, 47 | domain=DOMAIN, 48 | service="disarm_sectors", 49 | data={ 50 | "entity_id": [ 51 | "binary_sensor.econnect_metronet_test_user_s1_living_room", 52 | "binary_sensor.econnect_metronet_test_user_s3_outdoor", 53 | ], 54 | "code": "1234", 55 | }, 56 | ) 57 | # Test 58 | await services.disarm_sectors(hass, config_entry.entry_id, call) 59 | assert disarm.call_count == 1 60 | assert disarm.call_args[0][0] == "1234" 61 | assert disarm.call_args[0][1] == [1, 3] 62 | 63 | 64 | async def test_service_update_state(hass, config_entry, alarm_device, coordinator, mocker): 65 | # Ensure `update_state` triggers a full refresh 66 | update = mocker.patch.object(alarm_device, "update") 67 | hass.data[DOMAIN][config_entry.entry_id] = { 68 | "device": alarm_device, 69 | "coordinator": coordinator, 70 | } 71 | call = ServiceCall( 72 | hass=hass, 73 | domain=DOMAIN, 74 | service="update_state", 75 | data={}, 76 | ) 77 | # Test 78 | await services.update_state(hass, config_entry.entry_id, call) 79 | assert update.call_count == 1 80 | assert update.call_args == () 81 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators used to share the same logic between functions.""" 2 | 3 | import functools 4 | import logging 5 | 6 | from elmo.api.exceptions import CodeError, InvalidToken, LockError 7 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 8 | 9 | from .const import DOMAIN, KEY_DEVICE 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | def set_device_state(new_state, loader_state): 15 | """Set a new Alarm device state, or revert to a previous state in case of error. 16 | 17 | This decorator is used to convert a library exception in a log warning, while 18 | improving the UX by setting an intermediate state while the Alarm device 19 | is arming/disarming. 20 | """ 21 | 22 | def decorator(func): 23 | @functools.wraps(func) 24 | async def func_wrapper(*args, **kwargs): 25 | self = args[0] 26 | previous_state = self._device.state 27 | self._device.state = loader_state 28 | self.async_write_ha_state() 29 | try: 30 | result = await func(*args, **kwargs) 31 | self._device.state = new_state 32 | self.async_write_ha_state() 33 | return result 34 | except LockError: 35 | _LOGGER.warning( 36 | "Impossible to obtain the lock. Be sure you inserted the code, or that nobody is using the panel." 37 | ) 38 | except CodeError: 39 | _LOGGER.warning("Inserted code is not correct. Retry.") 40 | except Exception as err: 41 | # All other exceptions are unexpected errors that must revert the state 42 | _LOGGER.error(f"Device | Error during operation '{func.__name__}': {err}") 43 | # Reverting the state in case of any error 44 | self._device.state = previous_state 45 | self.async_write_ha_state() 46 | 47 | return func_wrapper 48 | 49 | return decorator 50 | 51 | 52 | def retry_refresh_token(func): 53 | @functools.wraps(func) 54 | async def wrapper(self, *args, **kwargs): 55 | attempts = 0 56 | while attempts < 2: 57 | try: 58 | return await func(self, *args, **kwargs) 59 | except InvalidToken as err: 60 | _LOGGER.debug(f"Device | Invalid access token: {err}") 61 | if attempts < 1: 62 | username = self._config.data[CONF_USERNAME] 63 | password = self._config.data[CONF_PASSWORD] 64 | await self.hass.async_add_executor_job(self._device.connect, username, password) 65 | _LOGGER.debug("Device | Access token has been refreshed") 66 | attempts += 1 67 | 68 | return wrapper 69 | 70 | 71 | def retry_refresh_token_service(func): 72 | @functools.wraps(func) 73 | async def wrapper(*args, **kwargs): 74 | attempts = 0 75 | while attempts < 2: 76 | try: 77 | return await func(*args, **kwargs) 78 | except InvalidToken as err: 79 | _LOGGER.debug(f"Device | Invalid access token: {err}") 80 | if attempts < 1: 81 | hass, config_id, _ = args 82 | config = hass.config_entries.async_entries(DOMAIN)[0] 83 | device = hass.data[DOMAIN][config_id][KEY_DEVICE] 84 | username = config.data[CONF_USERNAME] 85 | password = config.data[CONF_PASSWORD] 86 | await hass.async_add_executor_job(device.connect, username, password) 87 | _LOGGER.debug("Device | Access token has been refreshed") 88 | attempts += 1 89 | 90 | return wrapper 91 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/switch.py: -------------------------------------------------------------------------------- 1 | from elmo import query as q 2 | from homeassistant.components import persistent_notification 3 | from homeassistant.components.switch import SwitchEntity 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 7 | from homeassistant.helpers.update_coordinator import ( 8 | CoordinatorEntity, 9 | DataUpdateCoordinator, 10 | ) 11 | 12 | from .const import ( 13 | CONF_EXPERIMENTAL, 14 | CONF_FORCE_UPDATE, 15 | DOMAIN, 16 | KEY_COORDINATOR, 17 | KEY_DEVICE, 18 | NOTIFICATION_IDENTIFIER, 19 | NOTIFICATION_MESSAGE, 20 | NOTIFICATION_TITLE, 21 | ) 22 | from .devices import AlarmDevice 23 | from .helpers import generate_entity_id 24 | 25 | 26 | async def async_setup_entry( 27 | hass: HomeAssistant, 28 | entry: ConfigEntry, 29 | async_add_entities: AddEntitiesCallback, 30 | ) -> None: 31 | device = hass.data[DOMAIN][entry.entry_id][KEY_DEVICE] 32 | coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] 33 | outputs = [] 34 | 35 | # Iterate through the outputs of the provided device and create OutputSwitch objects 36 | for output_id, name in device.outputs: 37 | unique_id = f"{entry.entry_id}_{DOMAIN}_{q.OUTPUTS}_{output_id}" 38 | outputs.append(OutputSwitch(unique_id, output_id, entry, name, coordinator, device)) 39 | 40 | async_add_entities(outputs) 41 | 42 | 43 | class OutputSwitch(CoordinatorEntity, SwitchEntity): 44 | """Representation of a e-connect output switch.""" 45 | 46 | _attr_has_entity_name = True 47 | 48 | def __init__( 49 | self, 50 | unique_id: str, 51 | output_id: int, 52 | config: ConfigEntry, 53 | name: str, 54 | coordinator: DataUpdateCoordinator, 55 | device: AlarmDevice, 56 | ) -> None: 57 | """Construct.""" 58 | # Enable experimental settings from the configuration file 59 | experimental = coordinator.hass.data[DOMAIN].get(CONF_EXPERIMENTAL, {}) 60 | self._attr_force_update = experimental.get(CONF_FORCE_UPDATE, False) 61 | 62 | super().__init__(coordinator) 63 | self.entity_id = generate_entity_id(config, name) 64 | self._name = name 65 | self._device = device 66 | self._unique_id = unique_id 67 | self._output_id = output_id 68 | 69 | @property 70 | def unique_id(self) -> str: 71 | """Return the unique identifier.""" 72 | return self._unique_id 73 | 74 | @property 75 | def name(self) -> str: 76 | """Return the name of this entity.""" 77 | return self._name 78 | 79 | @property 80 | def icon(self) -> str: 81 | """Return the icon used by this entity.""" 82 | return "hass:toggle-switch-variant" 83 | 84 | @property 85 | def is_on(self) -> bool: 86 | """Return the switch status (on/off).""" 87 | return bool(self._device.get_status(q.OUTPUTS, self._output_id)) 88 | 89 | async def async_turn_off(self): 90 | """Turn the entity off.""" 91 | if not await self.hass.async_add_executor_job(self._device.turn_off, self._output_id): # pragma: no cover 92 | persistent_notification.async_create( 93 | self.hass, NOTIFICATION_MESSAGE, NOTIFICATION_TITLE, NOTIFICATION_IDENTIFIER 94 | ) 95 | 96 | async def async_turn_on(self): 97 | """Turn the entity off.""" 98 | if not await self.hass.async_add_executor_job(self._device.turn_on, self._output_id): # pragma: no cover 99 | persistent_notification.async_create( 100 | self.hass, NOTIFICATION_MESSAGE, NOTIFICATION_TITLE, NOTIFICATION_IDENTIFIER 101 | ) 102 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from elmo.api.exceptions import CodeError 5 | from homeassistant.core import valid_entity_id 6 | 7 | from custom_components.econnect_metronet.helpers import generate_entity_id, split_code 8 | 9 | 10 | def test_generate_entity_name_empty(config_entry): 11 | entity_id = generate_entity_id(config_entry) 12 | assert entity_id == "econnect_metronet.econnect_metronet_test_user" 13 | assert valid_entity_id(entity_id) 14 | 15 | 16 | def test_generate_entity_name_with_name(config_entry): 17 | entity_id = generate_entity_id(config_entry, "window") 18 | assert entity_id == "econnect_metronet.econnect_metronet_test_user_window" 19 | assert valid_entity_id(entity_id) 20 | 21 | 22 | def test_generate_entity_name_with_none(config_entry): 23 | entity_id = generate_entity_id(config_entry, None) 24 | assert entity_id == "econnect_metronet.econnect_metronet_test_user" 25 | assert valid_entity_id(entity_id) 26 | 27 | 28 | def test_generate_entity_name_empty_system(hass, config_entry): 29 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 30 | entity_id = generate_entity_id(config_entry) 31 | assert entity_id == "econnect_metronet.econnect_metronet_home" 32 | assert valid_entity_id(entity_id) 33 | 34 | 35 | def test_generate_entity_name_with_name_system(hass, config_entry): 36 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 37 | entity_id = generate_entity_id(config_entry, "window") 38 | assert entity_id == "econnect_metronet.econnect_metronet_home_window" 39 | assert valid_entity_id(entity_id) 40 | 41 | 42 | def test_generate_entity_name_with_none_system(hass, config_entry): 43 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 44 | entity_id = generate_entity_id(config_entry, None) 45 | assert entity_id == "econnect_metronet.econnect_metronet_home" 46 | assert valid_entity_id(entity_id) 47 | 48 | 49 | def test_generate_entity_name_with_spaces(hass, config_entry): 50 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home Assistant"}) 51 | entity_id = generate_entity_id(config_entry) 52 | assert entity_id == "econnect_metronet.econnect_metronet_home_assistant" 53 | assert valid_entity_id(entity_id) 54 | 55 | 56 | def test_split_code_with_valid_digits(): 57 | # Should split the numeric user ID and code correctly 58 | code = "123456789012" 59 | assert split_code(code) == ("123456", "789012") 60 | 61 | 62 | def test_split_code_with_exact_six_chars_raises_error(caplog): 63 | # Should log a debug message and return a tuple with user ID 1 and the code 64 | caplog.set_level(logging.DEBUG) 65 | assert split_code("123456") == ("1", "123456") 66 | debug_msg = [record for record in caplog.records if record.levelname == "DEBUG"] 67 | assert len(debug_msg) == 1 68 | assert "format without spaces" in debug_msg[0].message 69 | 70 | 71 | def test_split_code_with_alphanumeric_user_id_raises_error(): 72 | # Should raise CodeError for alphanumeric user ID 73 | code = "USER123456" 74 | with pytest.raises(CodeError) as exc_info: 75 | split_code(code) 76 | assert "user ID and code must be numbers" in str(exc_info.value) 77 | 78 | 79 | def test_split_code_with_special_characters_raises_error(): 80 | # Should raise CodeError for code with special characters 81 | code = "12345@678901" 82 | with pytest.raises(CodeError) as exc_info: 83 | split_code(code) 84 | assert "user ID and code must be numbers" in str(exc_info.value) 85 | 86 | 87 | def test_split_code_with_empty_string_raises_error(): 88 | # Should raise CodeError for empty string 89 | with pytest.raises(CodeError) as exc_info: 90 | split_code("") 91 | assert "format without spaces" in str(exc_info.value) 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Home Assistant 2 | config/ 3 | tests/hass/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | # VSCode 167 | .vscode/ 168 | 169 | # OS X 170 | .DS_Store 171 | -------------------------------------------------------------------------------- /tests/test_sensors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 5 | 6 | from custom_components.econnect_metronet.const import DOMAIN 7 | from custom_components.econnect_metronet.sensor import AlertSensor, async_setup_entry 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_async_setup_entry_only_sensors(hass, config_entry, alarm_device, coordinator): 12 | # Ensure the async setup loads only alert sensors 13 | hass.data[DOMAIN][config_entry.entry_id] = { 14 | "device": alarm_device, 15 | "coordinator": coordinator, 16 | } 17 | 18 | # Test 19 | def ensure_only_sensors(sensors): 20 | assert len(sensors) == 3 21 | 22 | await async_setup_entry(hass, config_entry, ensure_only_sensors) 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_async_setup_entry_alerts_unique_id(hass, config_entry, alarm_device, coordinator): 27 | # Ensure that the alert sensors have the correct unique IDs 28 | hass.data[DOMAIN][config_entry.entry_id] = { 29 | "device": alarm_device, 30 | "coordinator": coordinator, 31 | } 32 | 33 | # Test 34 | def ensure_unique_id(sensors): 35 | assert sensors[1].unique_id == "test_entry_id_econnect_metronet_11_16" 36 | 37 | await async_setup_entry(hass, config_entry, ensure_unique_id) 38 | 39 | 40 | class TestAlertSensor: 41 | def test_sensor_native_value(self, hass, config_entry, alarm_device): 42 | # Ensure the sensor attribute native_value has the right status 43 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 44 | entity = AlertSensor("test_id", 16, config_entry, "input_led", coordinator, alarm_device) 45 | assert entity.native_value == 2 46 | 47 | def test_sensor_missing(self, hass, config_entry, alarm_device): 48 | # Ensure the sensor raise keyerror if the alert is missing 49 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 50 | entity = AlertSensor("test_id", 1000, config_entry, "test_id", coordinator, alarm_device) 51 | with pytest.raises(KeyError): 52 | assert entity.native_value == 1 53 | 54 | def test_sensor_name(self, hass, config_entry, alarm_device): 55 | # Ensure the alert has the right translation key 56 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 57 | entity = AlertSensor("test_id", 16, config_entry, "input_led", coordinator, alarm_device) 58 | assert entity.translation_key == "input_led" 59 | 60 | def test_binary_sensor_name_with_system_name(self, hass, config_entry, alarm_device): 61 | # The system name doesn't change the translation key 62 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 63 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 64 | entity = AlertSensor("test_id", 0, config_entry, "input_led", coordinator, alarm_device) 65 | assert entity.translation_key == "input_led" 66 | 67 | def test_sensor_entity_id(self, hass, config_entry, alarm_device): 68 | # Ensure the alert has a valid Entity ID 69 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 70 | entity = AlertSensor("test_id", 0, config_entry, "input_led", coordinator, alarm_device) 71 | assert entity.entity_id == "econnect_metronet.econnect_metronet_test_user_input_led" 72 | 73 | def test_sensor_entity_id_with_system_name(self, hass, config_entry, alarm_device): 74 | # Ensure the Entity ID takes into consideration the system name 75 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 76 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 77 | entity = AlertSensor("test_id", 0, config_entry, "input_led", coordinator, alarm_device) 78 | assert entity.entity_id == "econnect_metronet.econnect_metronet_home_input_led" 79 | 80 | def test_sensor_unique_id(self, hass, config_entry, alarm_device): 81 | # Ensure the alert has the right unique ID 82 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 83 | entity = AlertSensor("test_id", 0, config_entry, "input_led", coordinator, alarm_device) 84 | assert entity.unique_id == "test_id" 85 | 86 | def test_sensor_icon(self, hass, config_entry, alarm_device): 87 | # Ensure the sensor has the right icon 88 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 89 | entity = AlertSensor("test_id", 0, config_entry, "input_led", coordinator, alarm_device) 90 | assert entity.icon == "hass:alarm-light" 91 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Tuple, Union 3 | 4 | import voluptuous as vol 5 | from elmo.api.exceptions import CodeError 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import CONF_USERNAME 8 | from homeassistant.helpers.config_validation import multi_select 9 | from homeassistant.util import slugify 10 | 11 | from .const import CONF_SYSTEM_NAME, DOMAIN 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class select(multi_select): 17 | """Extension for the multi_select helper to handle selections of tuples. 18 | 19 | This class extends a multi_select helper class to support tuple-based 20 | selections, allowing for a more complex selection structure such as 21 | pairing an identifier with a descriptive string. 22 | 23 | Options are provided as a list of tuples with the following format: 24 | [(1, 'S1 Living Room'), (2, 'S2 Bedroom'), (3, 'S3 Outdoor')] 25 | 26 | Attributes: 27 | options (List[Tuple]): A list of tuple options for the select. 28 | allowed_values (set): A set of valid values (identifiers) that can be selected. 29 | """ 30 | 31 | def __init__(self, options: List[Tuple]) -> None: 32 | self.options = options 33 | self.allowed_values = {option[0] for option in options} 34 | 35 | def __call__(self, selected: list) -> list: 36 | """Validates the input list against the allowed values for selection. 37 | 38 | Args: 39 | selected: A list of values that have been selected. 40 | 41 | Returns: 42 | The same list if all selected values are valid. 43 | 44 | Raises: 45 | vol.Invalid: If the input is not a list or if any of the selected values 46 | are not in the allowed values for selection. 47 | """ 48 | if not isinstance(selected, list): 49 | raise vol.Invalid("Not a list") 50 | 51 | for value in selected: 52 | # Reject the value if it's not an option or its identifier 53 | if value not in self.allowed_values and value not in self.options: 54 | raise vol.Invalid(f"{value} is not a valid option") 55 | 56 | return selected 57 | 58 | 59 | def generate_entity_id(config: ConfigEntry, name: Union[str, None] = None) -> str: 60 | """Generate an entity ID based on system configuration or username. 61 | 62 | Args: 63 | config (ConfigEntry): The configuration entry from Home Assistant containing system 64 | configuration or username. 65 | name (Union[str, None]): Additional name component to be appended to the entity name. 66 | 67 | Returns: 68 | str: The generated entity id, which is a combination of the domain and either the configured 69 | system name or the username, optionally followed by the provided name. 70 | 71 | Example: 72 | >>> config.data = {"system_name": "Seaside Home"} 73 | >>> generate_entity_name(entry, "window") 74 | "elmo_iess_alarm.seaside_home_window" 75 | """ 76 | # Retrieve the system name or username from the ConfigEntry 77 | system_name = config.data.get(CONF_SYSTEM_NAME) or config.data.get(CONF_USERNAME) 78 | 79 | # Default to empty string if a name is not provided 80 | additional_name = name or "" 81 | 82 | # Generate the entity ID and use Home Assistant slugify to ensure it's a valid value 83 | # NOTE: We append DOMAIN twice as HA removes the domain from the entity ID name. This is unexpected 84 | # as it means we lose our namespacing, even though this is the suggested method explained in HA documentation. 85 | # See: https://www.home-assistant.io/faq/unique_id/#can-be-changed 86 | entity_name = slugify(f"{system_name}_{additional_name}") 87 | return f"{DOMAIN}.{DOMAIN}_{entity_name}" 88 | 89 | 90 | def split_code(code: str) -> Tuple[str, str]: 91 | """Splits the given code into two parts: user ID and code. 92 | 93 | The function returns a tuple containing the user ID and the code as separate strings. 94 | The code is expected to be in the format without spaces, with the CODE 95 | part being the last 6 characters of the string. 96 | 97 | Args: 98 | code: A string representing the combined user ID and code. 99 | 100 | Returns: 101 | A tuple of two strings: (user ID, code). 102 | 103 | Raises: 104 | CodeError: If the input code is less than 7 characters long, indicating it does not 105 | conform to the expected format. 106 | """ 107 | if not code: 108 | raise CodeError("Your code must be in the format without spaces.") 109 | 110 | if len(code) < 7: 111 | _LOGGER.debug("Client | Your configuration may require a code in the format without spaces.") 112 | return "1", code 113 | 114 | user_id_part, code_part = code[:-6], code[-6:] 115 | if not (user_id_part.isdigit() and code_part.isdigit()): 116 | raise CodeError("Both user ID and code must be numbers.") 117 | 118 | return user_id_part, code_part 119 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | """The E-connect Alarm Entity.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.alarm_control_panel import ( 6 | AlarmControlPanelEntity, 7 | AlarmControlPanelState, 8 | CodeFormat, 9 | ) 10 | from homeassistant.components.alarm_control_panel.const import ( 11 | AlarmControlPanelEntityFeature as AlarmFeatures, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import CONF_USERNAME 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 17 | 18 | from .const import CONF_SYSTEM_NAME, DOMAIN, KEY_COORDINATOR, KEY_DEVICE 19 | from .decorators import retry_refresh_token, set_device_state 20 | from .helpers import generate_entity_id 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_devices): 26 | """Platform setup with the forwarded config entry.""" 27 | device = hass.data[DOMAIN][entry.entry_id][KEY_DEVICE] 28 | coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] 29 | unique_id = f"{DOMAIN}_{entry.entry_id}" 30 | async_add_devices( 31 | [ 32 | EconnectAlarm( 33 | unique_id, 34 | entry, 35 | device, 36 | coordinator, 37 | ) 38 | ] 39 | ) 40 | 41 | 42 | class EconnectAlarm(CoordinatorEntity, AlarmControlPanelEntity): 43 | """E-connect alarm entity.""" 44 | 45 | _attr_has_entity_name = True 46 | 47 | def __init__(self, unique_id, config, device, coordinator): 48 | """Construct.""" 49 | super().__init__(coordinator) 50 | self.entity_id = generate_entity_id(config) 51 | self._config = config 52 | self._unique_id = unique_id 53 | self._name = f"Alarm Panel {config.data.get(CONF_SYSTEM_NAME) or config.data.get(CONF_USERNAME)}" 54 | self._device = device 55 | 56 | @property 57 | def unique_id(self): 58 | """Return the unique identifier.""" 59 | return self._unique_id 60 | 61 | @property 62 | def name(self): 63 | """Return the name of this entity.""" 64 | return self._name 65 | 66 | @property 67 | def icon(self): 68 | """Return the icon used by this entity.""" 69 | return "hass:shield-home" 70 | 71 | @property 72 | def alarm_state(self): 73 | """Return the state of the device.""" 74 | return self._device.state 75 | 76 | @property 77 | def code_format(self): 78 | """Define a number code format.""" 79 | return CodeFormat.NUMBER 80 | 81 | @property 82 | def supported_features(self): 83 | """Return the list of supported features.""" 84 | return AlarmFeatures.ARM_HOME | AlarmFeatures.ARM_AWAY | AlarmFeatures.ARM_NIGHT | AlarmFeatures.ARM_VACATION 85 | 86 | @set_device_state(AlarmControlPanelState.DISARMED, AlarmControlPanelState.DISARMING) 87 | @retry_refresh_token 88 | async def async_alarm_disarm(self, code=None): 89 | """Send disarm command.""" 90 | await self.hass.async_add_executor_job(self._device.disarm, code) 91 | 92 | @set_device_state(AlarmControlPanelState.ARMED_AWAY, AlarmControlPanelState.ARMING) 93 | @retry_refresh_token 94 | async def async_alarm_arm_away(self, code=None): 95 | """Send arm away command.""" 96 | await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_away) 97 | 98 | @set_device_state(AlarmControlPanelState.ARMED_HOME, AlarmControlPanelState.ARMING) 99 | @retry_refresh_token 100 | async def async_alarm_arm_home(self, code=None): 101 | """Send arm home command.""" 102 | if not self._device._sectors_home: 103 | _LOGGER.warning("Triggering ARM HOME without configuration. Use integration Options to configure it.") 104 | return 105 | 106 | await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_home) 107 | 108 | @set_device_state(AlarmControlPanelState.ARMED_NIGHT, AlarmControlPanelState.ARMING) 109 | @retry_refresh_token 110 | async def async_alarm_arm_night(self, code=None): 111 | """Send arm night command.""" 112 | if not self._device._sectors_night: 113 | _LOGGER.warning("Triggering ARM NIGHT without configuration. Use integration Options to configure it.") 114 | return 115 | 116 | await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_night) 117 | 118 | @set_device_state(AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMING) 119 | @retry_refresh_token 120 | async def async_alarm_arm_vacation(self, code=None): 121 | """Send arm vacation command.""" 122 | if not self._device._sectors_vacation: 123 | _LOGGER.warning("Triggering ARM VACATION without configuration. Use integration Options to configure it.") 124 | return 125 | 126 | await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_vacation) 127 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/coordinator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | from typing import Any, Dict, Optional 4 | 5 | import async_timeout 6 | from elmo.api.exceptions import DeviceDisconnectedError, InvalidToken 7 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 10 | 11 | from .const import DOMAIN, POLLING_TIMEOUT 12 | from .devices import AlarmDevice 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class AlarmCoordinator(DataUpdateCoordinator): 18 | def __init__(self, hass: HomeAssistant, device: AlarmDevice, scan_interval: int) -> None: 19 | # Store the device to update the state 20 | self._device = device 21 | 22 | # Configure the coordinator 23 | super().__init__( 24 | hass, 25 | _LOGGER, 26 | name=DOMAIN, 27 | update_interval=timedelta(seconds=scan_interval), 28 | ) 29 | 30 | async def _async_update_data(self) -> Optional[Dict[str, Any]]: 31 | """Update device data asynchronously using the long-polling method. 32 | 33 | This method uses the e-Connect long-polling API implemented in `device.has_updates` which 34 | blocks the thread for up to 15 seconds or until the backend pushes an update. 35 | A timeout ensures the method doesn't hang indefinitely. 36 | 37 | In case of invalid token, the method attempts to re-authenticate and get an update. 38 | If the update fails, the connection is reset to ensure proper data alignment in subsequent runs. 39 | 40 | Returns: 41 | A dictionary containing the updated data. 42 | 43 | Raises: 44 | InvalidToken: When the token used for the connection is invalid. 45 | UpdateFailed: When there's an error in updating the data. 46 | """ 47 | try: 48 | if self.data is None: 49 | # First update, no need to wait for changes 50 | username = self.config_entry.data[CONF_USERNAME] 51 | password = self.config_entry.data[CONF_PASSWORD] 52 | await self.hass.async_add_executor_job(self._device.connect, username, password) 53 | return await self.hass.async_add_executor_job(self._device.update) 54 | 55 | async with async_timeout.timeout(POLLING_TIMEOUT): 56 | if not self.last_update_success or not self._device.connected: 57 | # Force an update if at least one failed. This is required to prevent 58 | # a misalignment between the `AlarmDevice` and backend IDs, needed to implement 59 | # the long-polling strategy. If IDs are misaligned, then no updates happen and 60 | # the integration remains stuck. 61 | # See: https://github.com/palazzem/ha-econnect-alarm/issues/51 62 | _LOGGER.debug("Coordinator | Central unit disconnected, forcing a full update") 63 | return await self.hass.async_add_executor_job(self._device.update) 64 | 65 | # `device.has_updates` implements e-Connect long-polling API. This 66 | # action blocks the thread for 15 seconds, or when the backend publishes an update 67 | # POLLING_TIMEOUT ensures an upper bound regardless of the underlying implementation. 68 | _LOGGER.debug("Coordinator | Waiting for changes (long-polling)") 69 | status = await self.hass.async_add_executor_job(self._device.has_updates) 70 | if status["has_changes"]: 71 | _LOGGER.debug("Coordinator | Changes detected, sending an update") 72 | return await self.hass.async_add_executor_job(self._device.update) 73 | else: 74 | _LOGGER.debug("Coordinator | No changes detected") 75 | return {} 76 | except InvalidToken: 77 | # This exception is expected to happen when the token expires. In this case, 78 | # there is no need to re-raise the exception as it's a normal condition. 79 | _LOGGER.debug("Coordinator | Invalid token detected, authenticating") 80 | username = self.config_entry.data[CONF_USERNAME] 81 | password = self.config_entry.data[CONF_PASSWORD] 82 | await self.hass.async_add_executor_job(self._device.connect, username, password) 83 | _LOGGER.debug("Coordinator | Authentication completed with success") 84 | return await self.hass.async_add_executor_job(self._device.update) 85 | except DeviceDisconnectedError as err: 86 | # If the device is disconnected, we keep the previous state and try again later 87 | # This is required as the device might be temporarily disconnected, and we don't want 88 | # to make all entities unavailable for a temporary issue. Furthermore, if the device goes 89 | # in an unavailable state, it might trigger unwanted automations. 90 | # See: https://github.com/palazzem/ha-econnect-alarm/issues/148 91 | _LOGGER.error(f"Coordinator | {err}. Keeping the last known state.") 92 | return {} 93 | -------------------------------------------------------------------------------- /tests/test_switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 5 | 6 | from custom_components.econnect_metronet.const import DOMAIN 7 | from custom_components.econnect_metronet.switch import OutputSwitch, async_setup_entry 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_async_setup_entry_in_use(hass, config_entry, alarm_device, coordinator): 12 | # Ensure the async setup loads only outputs that are in use 13 | hass.data[DOMAIN][config_entry.entry_id] = { 14 | "device": alarm_device, 15 | "coordinator": coordinator, 16 | } 17 | 18 | # Test 19 | def ensure_only_in_use(outputs): 20 | assert len(outputs) == 3 21 | 22 | await async_setup_entry(hass, config_entry, ensure_only_in_use) 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_async_setup_entry_unused_output(hass, config_entry, alarm_device, coordinator): 27 | # Ensure the async setup don't load outputs that are not in use 28 | hass.data[DOMAIN][config_entry.entry_id] = { 29 | "device": alarm_device, 30 | "coordinator": coordinator, 31 | } 32 | 33 | # Test 34 | def ensure_unused_output(outputs): 35 | for output in outputs: 36 | assert output._name not in ["Output 4"] 37 | 38 | await async_setup_entry(hass, config_entry, ensure_unused_output) 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_async_setup_entry_outputs_unique_id(hass, config_entry, alarm_device, coordinator): 43 | # Ensure the async setup load correct unique_id for outputs 44 | hass.data[DOMAIN][config_entry.entry_id] = { 45 | "device": alarm_device, 46 | "coordinator": coordinator, 47 | } 48 | 49 | # Test 50 | def ensure_unique_id(outputs): 51 | assert outputs[2].unique_id == "test_entry_id_econnect_metronet_12_2" 52 | 53 | await async_setup_entry(hass, config_entry, ensure_unique_id) 54 | 55 | 56 | class TestOutputSwitch: 57 | def test_switch_name(self, hass, config_entry, alarm_device): 58 | # Ensure the switch has the right name 59 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 60 | entity = OutputSwitch("test_id", 1, config_entry, "Output 2", coordinator, alarm_device) 61 | assert entity.name == "Output 2" 62 | 63 | def test_switch_name_with_system_name(self, hass, config_entry, alarm_device): 64 | # The system name doesn't change the Entity name 65 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 66 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 67 | entity = OutputSwitch("test_id", 1, config_entry, "Output 2", coordinator, alarm_device) 68 | assert entity.name == "Output 2" 69 | 70 | def test_switch_entity_id(self, hass, config_entry, alarm_device): 71 | # Ensure the switch has a valid Entity ID 72 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 73 | entity = OutputSwitch("test_id", 1, config_entry, "Output 2", coordinator, alarm_device) 74 | assert entity.entity_id == "econnect_metronet.econnect_metronet_test_user_output_2" 75 | 76 | def test_switch_entity_id_with_system_name(self, hass, config_entry, alarm_device): 77 | # Ensure the Entity ID takes into consideration the system name 78 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 79 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 80 | entity = OutputSwitch("test_id", 1, config_entry, "Output 2", coordinator, alarm_device) 81 | assert entity.entity_id == "econnect_metronet.econnect_metronet_home_output_2" 82 | 83 | def test_switch_unique_id(self, hass, config_entry, alarm_device): 84 | # Ensure the switch has the right unique ID 85 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 86 | entity = OutputSwitch("test_id", 1, config_entry, "Output 2", coordinator, alarm_device) 87 | assert entity.unique_id == "test_id" 88 | 89 | def test_switch_icon(self, hass, config_entry, alarm_device): 90 | # Ensure the switch has the right icon 91 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 92 | entity = OutputSwitch("test_id", 1, config_entry, "Output 2", coordinator, alarm_device) 93 | assert entity.icon == "hass:toggle-switch-variant" 94 | 95 | def test_switch_is_off(self, hass, config_entry, alarm_device): 96 | # Ensure the switch attribute is_on has the right status False 97 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 98 | entity = OutputSwitch("test_id", 2, config_entry, "Output 3", coordinator, alarm_device) 99 | assert entity.is_on is False 100 | 101 | def test_switch_is_on(self, hass, config_entry, alarm_device): 102 | # Ensure the switch attribute is_on has the right status True 103 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 104 | entity = OutputSwitch("test_id", 1, config_entry, "Output 1", coordinator, alarm_device) 105 | assert entity.is_on is True 106 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/__init__.py: -------------------------------------------------------------------------------- 1 | """The E-connect Alarm integration.""" 2 | 3 | import asyncio 4 | import logging 5 | from functools import partial 6 | 7 | import voluptuous as vol 8 | from elmo.api.client import ElmoClient 9 | from elmo.systems import ELMO_E_CONNECT as E_CONNECT_DEFAULT 10 | from homeassistant.config_entries import ConfigEntry, ConfigType 11 | from homeassistant.core import HomeAssistant 12 | 13 | from . import services 14 | from .const import ( 15 | CONF_DOMAIN, 16 | CONF_EXPERIMENTAL, 17 | CONF_SCAN_INTERVAL, 18 | CONF_SYSTEM_URL, 19 | DOMAIN, 20 | KEY_COORDINATOR, 21 | KEY_DEVICE, 22 | KEY_UNSUBSCRIBER, 23 | SCAN_INTERVAL_DEFAULT, 24 | ) 25 | from .coordinator import AlarmCoordinator 26 | from .devices import AlarmDevice 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | # Allow experimental settings to be exposed in the YAML configuration 31 | # This part will be removed once the experimental settings are stable 32 | # and moved to the configuration flow. This is considered an internal 33 | # API so it will be removed without notice. 34 | CONFIG_SCHEMA = vol.Schema( 35 | { 36 | DOMAIN: vol.Schema( 37 | { 38 | vol.Optional("experimental"): vol.Schema({}, extra=vol.ALLOW_EXTRA), 39 | } 40 | ), 41 | }, 42 | extra=vol.ALLOW_EXTRA, 43 | ) 44 | 45 | PLATFORMS = ["alarm_control_panel", "binary_sensor", "sensor", "switch"] 46 | 47 | 48 | async def async_migrate_entry(hass, config: ConfigEntry): 49 | """Config flow migrations.""" 50 | _LOGGER.info(f"Migrating from version {config.version}") 51 | 52 | if config.version == 1: 53 | # Config initialization 54 | migrated_config = {**config.data} 55 | # Migration 56 | migrated_config[CONF_SYSTEM_URL] = E_CONNECT_DEFAULT 57 | hass.config_entries.async_update_entry(config, data=migrated_config, minor_version=1, version=2) 58 | 59 | if config.version == 2: 60 | # Config initialization 61 | options = {**config.options} 62 | options_to_migrate = ["areas_arm_home", "areas_arm_night", "areas_arm_vacation"] 63 | migrated_options = {} 64 | # Migration 65 | for key, value in options.items(): 66 | if key in options_to_migrate: 67 | migrated_options[key] = [int(area) for area in value.split(",")] 68 | else: 69 | migrated_options[key] = value 70 | hass.config_entries.async_update_entry(config, options=migrated_options, minor_version=1, version=3) 71 | 72 | _LOGGER.info(f"Migration to version {config.version} successful") 73 | return True 74 | 75 | 76 | async def async_setup(hass: HomeAssistant, config: ConfigType): 77 | """Initialize the E-connect Alarm integration. 78 | 79 | This method exposes eventual YAML configuration options under the DOMAIN key. 80 | Use YAML configurations only to expose experimental settings, otherwise use 81 | the configuration flow. 82 | """ 83 | hass.data[DOMAIN] = config.get(DOMAIN, {}) 84 | return True 85 | 86 | 87 | async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: 88 | """Set up a configuration entry for the alarm device in Home Assistant. 89 | 90 | This asynchronous method initializes an AlarmDevice instance to access the cloud service. 91 | It uses a DataUpdateCoordinator to aggregate status updates from different entities 92 | into a single request. The method also registers a listener to track changes in the configuration options. 93 | 94 | Args: 95 | hass (HomeAssistant): The Home Assistant instance. 96 | config (ConfigEntry): The configuration entry containing the setup details for the alarm device. 97 | 98 | Returns: 99 | bool: True if the setup was successful, False otherwise. 100 | 101 | Raises: 102 | Any exceptions raised by the coordinator or the setup process will be propagated up to the caller. 103 | """ 104 | # Enable experimental settings from the configuration file 105 | # NOTE: While it's discouraged to use YAML configurations for integrations, this approach 106 | # ensures that we can experiment with new settings we can break without notice. 107 | experimental = hass.data[DOMAIN].get(CONF_EXPERIMENTAL, {}) 108 | 109 | # Initialize Components 110 | scan_interval = config.options.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL_DEFAULT) 111 | client = ElmoClient(config.data[CONF_SYSTEM_URL], config.data[CONF_DOMAIN]) 112 | device = AlarmDevice(client, {**config.options, **experimental}) 113 | coordinator = AlarmCoordinator(hass, device, scan_interval) 114 | await coordinator.async_config_entry_first_refresh() 115 | 116 | # Store an AlarmDevice instance to access the cloud service. 117 | # It includes a DataUpdateCoordinator shared across entities to get a full 118 | # status update with a single request. 119 | hass.data[DOMAIN][config.entry_id] = { 120 | KEY_DEVICE: device, 121 | KEY_COORDINATOR: coordinator, 122 | } 123 | 124 | # Register a listener when option changes 125 | unsub = config.add_update_listener(options_update_listener) 126 | hass.data[DOMAIN][config.entry_id][KEY_UNSUBSCRIBER] = unsub 127 | 128 | # Register e-Connect services 129 | # We use a `partial` function to pass the `hass` and `config.entry_id` arguments 130 | # as we need both to access the integration configurations. 131 | hass.services.async_register(DOMAIN, "arm_sectors", partial(services.arm_sectors, hass, config.entry_id)) 132 | hass.services.async_register(DOMAIN, "disarm_sectors", partial(services.disarm_sectors, hass, config.entry_id)) 133 | hass.services.async_register(DOMAIN, "update_state", partial(services.update_state, hass, config.entry_id)) 134 | 135 | await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) 136 | 137 | return True 138 | 139 | 140 | async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry): 141 | """Unload a config entry.""" 142 | unload_ok = all( 143 | await asyncio.gather( 144 | *[hass.config_entries.async_forward_entry_unload(config, component) for component in PLATFORMS] 145 | ) 146 | ) 147 | if unload_ok: 148 | # Call the options unsubscriber and remove the configuration 149 | hass.data[DOMAIN][config.entry_id][KEY_UNSUBSCRIBER]() 150 | hass.data[DOMAIN].pop(config.entry_id) 151 | 152 | return unload_ok 153 | 154 | 155 | async def options_update_listener(hass: HomeAssistant, config: ConfigEntry): 156 | """Handle options update.""" 157 | await hass.config_entries.async_reload(config.entry_id) 158 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import voluptuous as vol 4 | from elmo import query as q 5 | from elmo.api.client import ElmoClient 6 | from elmo.api.exceptions import CredentialError 7 | from elmo.systems import ELMO_E_CONNECT as E_CONNECT_DEFAULT 8 | from homeassistant import config_entries 9 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 10 | from homeassistant.core import callback 11 | from requests.exceptions import ConnectionError, HTTPError 12 | 13 | from .const import ( 14 | CONF_AREAS_ARM_AWAY, 15 | CONF_AREAS_ARM_HOME, 16 | CONF_AREAS_ARM_NIGHT, 17 | CONF_AREAS_ARM_VACATION, 18 | CONF_DOMAIN, 19 | CONF_SCAN_INTERVAL, 20 | CONF_SYSTEM_NAME, 21 | CONF_SYSTEM_URL, 22 | DOMAIN, 23 | KEY_DEVICE, 24 | SUPPORTED_SYSTEMS, 25 | ) 26 | from .helpers import select 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | class EconnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore 32 | """Handle a config flow for E-connect Alarm.""" 33 | 34 | VERSION = 3 35 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 36 | 37 | @staticmethod 38 | @callback 39 | def async_get_options_flow(config_entry): 40 | """Implement OptionsFlow.""" 41 | return OptionsFlowHandler(config_entry) 42 | 43 | async def async_step_user(self, user_input=None): 44 | """Handle the initial configuration.""" 45 | errors = {} 46 | if user_input is not None: 47 | try: 48 | # Validate credentials 49 | client = ElmoClient(user_input.get(CONF_SYSTEM_URL), domain=user_input.get(CONF_DOMAIN)) 50 | await self.hass.async_add_executor_job( 51 | client.auth, user_input.get(CONF_USERNAME), user_input.get(CONF_PASSWORD) 52 | ) 53 | except ConnectionError: 54 | errors["base"] = "cannot_connect" 55 | except CredentialError: 56 | errors["base"] = "invalid_auth" 57 | except HTTPError as err: 58 | if 400 <= err.response.status_code <= 499: 59 | errors["base"] = "client_error" 60 | elif 500 <= err.response.status_code <= 599: 61 | errors["base"] = "server_error" 62 | else: 63 | _LOGGER.error("Unexpected exception %s", err) 64 | errors["base"] = "unknown" 65 | except Exception as err: # pylint: disable=broad-except 66 | _LOGGER.error("Unexpected exception %s", err) 67 | errors["base"] = "unknown" 68 | else: 69 | return self.async_create_entry(title="e-Connect/Metronet Alarm", data=user_input) 70 | 71 | # Populate with latest changes 72 | user_input = user_input or {CONF_DOMAIN: "default"} 73 | return self.async_show_form( 74 | step_id="user", 75 | data_schema=vol.Schema( 76 | { 77 | vol.Required( 78 | CONF_USERNAME, 79 | description={"suggested_value": user_input.get(CONF_USERNAME)}, 80 | ): str, 81 | vol.Required( 82 | CONF_PASSWORD, 83 | description={"suggested_value": user_input.get(CONF_PASSWORD)}, 84 | ): str, 85 | vol.Required( 86 | CONF_SYSTEM_URL, 87 | default=E_CONNECT_DEFAULT, 88 | description={"suggested_value": user_input.get(CONF_SYSTEM_URL)}, 89 | ): vol.In(SUPPORTED_SYSTEMS), 90 | vol.Required( 91 | CONF_DOMAIN, 92 | description={"suggested_value": user_input.get(CONF_DOMAIN)}, 93 | ): str, 94 | vol.Optional( 95 | CONF_SYSTEM_NAME, 96 | description={"suggested_value": user_input.get(CONF_SYSTEM_NAME)}, 97 | ): str, 98 | } 99 | ), 100 | errors=errors, 101 | ) 102 | 103 | 104 | class OptionsFlowHandler(config_entries.OptionsFlow): 105 | """Reconfigure integration options. 106 | 107 | Available options are: 108 | * Areas armed in Arm Away state. If not set all sectors are armed. 109 | * Areas armed in Arm Home state 110 | * Areas armed in Arm Night state 111 | * Areas armed in Arm Vacation state 112 | """ 113 | 114 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 115 | """Construct.""" 116 | self.config_entry = config_entry 117 | 118 | async def async_step_init(self, user_input=None): 119 | """Manage the options.""" 120 | errors = {} 121 | if user_input is not None: 122 | return self.async_create_entry(title="e-Connect/Metronet Alarm", data=user_input) 123 | 124 | # Populate with latest changes or previous settings 125 | user_input = user_input or {} 126 | suggest_scan_interval = user_input.get(CONF_SCAN_INTERVAL) or self.config_entry.options.get(CONF_SCAN_INTERVAL) 127 | 128 | # Generate sectors list for user config options 129 | device = self.hass.data[DOMAIN][self.config_entry.entry_id][KEY_DEVICE] 130 | sectors = [(item["element"], item["name"]) for _, item in device.items(q.SECTORS)] 131 | 132 | return self.async_show_form( 133 | step_id="init", 134 | data_schema=vol.Schema( 135 | { 136 | vol.Optional( 137 | CONF_AREAS_ARM_AWAY, 138 | default=self.config_entry.options.get(CONF_AREAS_ARM_AWAY, []), 139 | ): select(sectors), 140 | vol.Optional( 141 | CONF_AREAS_ARM_HOME, 142 | default=self.config_entry.options.get(CONF_AREAS_ARM_HOME, []), 143 | ): select(sectors), 144 | vol.Optional( 145 | CONF_AREAS_ARM_NIGHT, 146 | default=self.config_entry.options.get(CONF_AREAS_ARM_NIGHT, []), 147 | ): select(sectors), 148 | vol.Optional( 149 | CONF_AREAS_ARM_VACATION, 150 | default=self.config_entry.options.get(CONF_AREAS_ARM_VACATION, []), 151 | ): select(sectors), 152 | vol.Optional( 153 | CONF_SCAN_INTERVAL, 154 | description={"suggested_value": suggest_scan_interval}, 155 | ): int, 156 | } 157 | ), 158 | errors=errors, 159 | ) 160 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | import responses 5 | from elmo.api.client import ElmoClient 6 | from homeassistant.config_entries import ConfigEntryState 7 | 8 | from custom_components.econnect_metronet import async_setup 9 | from custom_components.econnect_metronet.alarm_control_panel import EconnectAlarm 10 | from custom_components.econnect_metronet.config_flow import EconnectConfigFlow 11 | from custom_components.econnect_metronet.const import DOMAIN 12 | from custom_components.econnect_metronet.coordinator import AlarmCoordinator 13 | from custom_components.econnect_metronet.devices import AlarmDevice 14 | 15 | from .fixtures import responses as r 16 | from .hass.fixtures import MockConfigEntry 17 | 18 | pytest_plugins = ["tests.hass.fixtures"] 19 | 20 | 21 | def pytest_configure(config: pytest.Config) -> None: 22 | """Keeps the default log level to WARNING. 23 | 24 | Home Assistant sets the log level to `DEBUG` if the `--verbose` flag is used. 25 | Considering all the debug logs of this integration and `econnect-python`, this is very 26 | noisy. This is an override for `tests/hass/fixtures.py` as described in this 27 | issue: https://github.com/palazzem/ha-econnect-alarm/issues/134 28 | """ 29 | if config.getoption("verbose") > 0: 30 | logging.getLogger().setLevel(logging.WARNING) 31 | 32 | 33 | @pytest.fixture 34 | async def hass(hass): 35 | """Create a Home Assistant instance for testing. 36 | 37 | This fixture forces some settings to simulate a bootstrap process: 38 | - `custom_components` is reset to properly test the integration 39 | - `async_setup()` method is called 40 | """ 41 | await async_setup(hass, {}) 42 | hass.data["custom_components"] = None 43 | hass.data[DOMAIN]["test_entry_id"] = {} 44 | yield hass 45 | 46 | 47 | @pytest.fixture(scope="function") 48 | def alarm_device(client): 49 | """Yields an instance of AlarmDevice. 50 | 51 | This fixture provides a scoped instance of AlarmDevice initialized with 52 | the provided client. 53 | The device is connected and updated with mocked data 54 | 55 | Args: 56 | client: The client used to initialize the AlarmDevice. 57 | 58 | Yields: 59 | An instance of AlarmDevice. 60 | """ 61 | device = AlarmDevice(client) 62 | device.connect("username", "password") 63 | device.update() 64 | yield device 65 | 66 | 67 | @pytest.fixture(scope="function") 68 | def panel(hass, config_entry, alarm_device, coordinator): 69 | """Fixture to provide a test instance of the EconnectAlarm entity. 70 | 71 | This sets up an AlarmDevice and its corresponding DataUpdateCoordinator, 72 | then initializes the EconnectAlarm entity with a test name and ID. It also 73 | assigns the Home Assistant instance and a mock entity ID to the created entity. 74 | 75 | Args: 76 | hass: Mock Home Assistant instance. 77 | client: Mock client for the AlarmDevice. 78 | 79 | Yields: 80 | EconnectAlarm: Initialized test instance of the EconnectAlarm entity. 81 | """ 82 | entity = EconnectAlarm(unique_id="test_id", config=config_entry, device=alarm_device, coordinator=coordinator) 83 | entity.hass = hass 84 | return entity 85 | 86 | 87 | @pytest.fixture(scope="function") 88 | def coordinator(hass, config_entry, alarm_device): 89 | """Fixture to provide a test instance of the AlarmCoordinator. 90 | 91 | This sets up an AlarmDevice and its corresponding DataUpdateCoordinator. 92 | 93 | Args: 94 | hass: Mock Home Assistant instance. 95 | config_entry: Mock config entry. 96 | 97 | Yields: 98 | AlarmCoordinator: Initialized test instance of the AlarmCoordinator. 99 | """ 100 | coordinator = AlarmCoordinator(hass, alarm_device, 5) 101 | # Override Configuration and Device with mocked versions 102 | coordinator.config_entry = config_entry 103 | coordinator._device._connection._session_id = "test_token" 104 | # Initializes the Coordinator to skip the first setup 105 | coordinator.data = {} 106 | yield coordinator 107 | 108 | 109 | @pytest.fixture(scope="function") 110 | def client(socket_enabled): 111 | """Creates an instance of `ElmoClient` which emulates the behavior of a real client for 112 | testing purposes. 113 | 114 | Although this client instance operates with mocked calls, it is designed to function as 115 | if it were genuine. This ensures that the client's usage in tests accurately mirrors how it 116 | would be employed in real scenarios. 117 | 118 | Use it for integration tests where a realistic interaction with the `ElmoClient` is required 119 | without actual external calls. 120 | 121 | NOTE: `socket_enabled` fixture from `pytest-socket` is required for this fixture to work. After 122 | a change in `responses` (https://github.com/getsentry/responses/pull/685) available from 123 | release 0.24.0 onwards, any call to `ElmoClient` indirectly create a dummy socket: 124 | 125 | >>> socket.socket(socket.AF_INET, socket.SOCK_STREAM) 126 | 127 | forcing `pytest-socket` to fail with the following error: 128 | 129 | >>> pytest_socket.SocketBlockedError: A test tried to use socket.socket. 130 | 131 | Keep loading `socket_enabled` fixture until this issue is handled better in `responses` library. 132 | """ 133 | client = ElmoClient(base_url="https://example.com", domain="domain") 134 | with responses.RequestsMock(assert_all_requests_are_fired=False) as server: 135 | server.add(responses.GET, "https://example.com/api/login", body=r.LOGIN, status=200) 136 | server.add(responses.POST, "https://example.com/api/updates", body=r.UPDATES, status=200) 137 | server.add(responses.POST, "https://example.com/api/panel/syncLogin", body=r.SYNC_LOGIN, status=200) 138 | server.add(responses.POST, "https://example.com/api/panel/syncLogout", body=r.SYNC_LOGOUT, status=200) 139 | server.add( 140 | responses.POST, "https://example.com/api/panel/syncSendCommand", body=r.SYNC_SEND_COMMAND, status=200 141 | ) 142 | server.add(responses.POST, "https://example.com/api/strings", body=r.STRINGS, status=200) 143 | server.add(responses.POST, "https://example.com/api/areas", body=r.AREAS, status=200) 144 | server.add(responses.POST, "https://example.com/api/inputs", body=r.INPUTS, status=200) 145 | server.add(responses.POST, "https://example.com/api/outputs", body=r.OUTPUTS, status=200) 146 | server.add(responses.POST, "https://example.com/api/statusadv", body=r.STATUS, status=200) 147 | yield client 148 | 149 | 150 | @pytest.fixture(scope="function") 151 | def config_entry(hass): 152 | """Creates a mock config entry for testing purposes. 153 | 154 | This config entry is designed to emulate the behavior of a real config entry for 155 | testing purposes. 156 | """ 157 | config = MockConfigEntry( 158 | version=EconnectConfigFlow.VERSION, 159 | domain=DOMAIN, 160 | entry_id="test_entry_id", 161 | options={}, 162 | data={ 163 | "username": "test_user", 164 | "password": "test_password", 165 | "domain": "econnect_metronet", 166 | "system_base_url": "https://example.com", 167 | }, 168 | state=ConfigEntryState.SETUP_IN_PROGRESS, 169 | ) 170 | config.add_to_hass(hass) 171 | return config 172 | -------------------------------------------------------------------------------- /tests/test_options_flow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from homeassistant.data_entry_flow import InvalidData 3 | from homeassistant.helpers.config_validation import multi_select as select 4 | 5 | from custom_components.econnect_metronet.const import DOMAIN, KEY_DEVICE 6 | 7 | 8 | class TestOptionsFlow: 9 | @pytest.fixture(autouse=True) 10 | def setup(self, hass, config_entry, alarm_device): 11 | self.hass = hass 12 | self.config_entry = config_entry 13 | # Mock integration setup 14 | config_entry.add_to_hass(hass) 15 | hass.data[DOMAIN][config_entry.entry_id] = { 16 | KEY_DEVICE: alarm_device, 17 | } 18 | 19 | async def test_form_fields(self, hass, config_entry): 20 | # Ensure form is loaded with the correct fields 21 | form = await hass.config_entries.options.async_init( 22 | config_entry.entry_id, context={"show_advanced_options": False} 23 | ) 24 | assert form["type"] == "form" 25 | assert form["step_id"] == "init" 26 | assert form["errors"] == {} 27 | assert list(form["data_schema"].schema.keys()) == [ 28 | "areas_arm_away", 29 | "areas_arm_home", 30 | "areas_arm_night", 31 | "areas_arm_vacation", 32 | "scan_interval", 33 | ] 34 | assert isinstance(form["data_schema"].schema["areas_arm_away"], select) 35 | assert isinstance(form["data_schema"].schema["areas_arm_home"], select) 36 | assert isinstance(form["data_schema"].schema["areas_arm_night"], select) 37 | assert isinstance(form["data_schema"].schema["areas_arm_vacation"], select) 38 | assert form["data_schema"].schema["scan_interval"] == int 39 | 40 | async def test_form_submit_successful_empty(self, hass, config_entry): 41 | # Ensure an empty form can be submitted successfully 42 | form = await hass.config_entries.options.async_init( 43 | config_entry.entry_id, context={"show_advanced_options": False} 44 | ) 45 | # Test 46 | result = await hass.config_entries.options.async_configure( 47 | form["flow_id"], 48 | user_input={}, 49 | ) 50 | await hass.async_block_till_done() 51 | # Check HA config 52 | assert result["type"] == "create_entry" 53 | assert result["title"] == "e-Connect/Metronet Alarm" 54 | assert result["data"] == { 55 | "areas_arm_vacation": [], 56 | "areas_arm_home": [], 57 | "areas_arm_night": [], 58 | "areas_arm_away": [], 59 | } 60 | 61 | async def test_form_submit_invalid_type(self, hass, config_entry): 62 | # Ensure it fails if a user submits an option with an invalid type 63 | form = await hass.config_entries.options.async_init( 64 | config_entry.entry_id, context={"show_advanced_options": False} 65 | ) 66 | # Test 67 | with pytest.raises(InvalidData) as excinfo: 68 | await hass.config_entries.options.async_configure( 69 | form["flow_id"], 70 | user_input={ 71 | "areas_arm_home": "1", 72 | }, 73 | ) 74 | await hass.async_block_till_done() 75 | assert excinfo.value.schema_errors["areas_arm_home"] == "Not a list" 76 | 77 | async def test_form_submit_invalid_input(self, hass, config_entry): 78 | # Ensure it fails if a user submits an option not in the allowed list 79 | form = await hass.config_entries.options.async_init( 80 | config_entry.entry_id, context={"show_advanced_options": False} 81 | ) 82 | # Test 83 | with pytest.raises(InvalidData) as excinfo: 84 | await hass.config_entries.options.async_configure( 85 | form["flow_id"], 86 | user_input={ 87 | "areas_arm_home": [ 88 | (3, "Garden"), 89 | ], 90 | }, 91 | ) 92 | await hass.async_block_till_done() 93 | assert excinfo.value.schema_errors["areas_arm_home"] == "(3, 'Garden') is not a valid option" 94 | 95 | async def test_form_submit_successful_with_identifier(self, hass, config_entry): 96 | # Ensure users can submit an option just by using the option ID 97 | form = await hass.config_entries.options.async_init( 98 | config_entry.entry_id, context={"show_advanced_options": False} 99 | ) 100 | # Test 101 | result = await hass.config_entries.options.async_configure( 102 | form["flow_id"], 103 | user_input={ 104 | "areas_arm_home": [1], 105 | }, 106 | ) 107 | await hass.async_block_till_done() 108 | # Check HA config 109 | assert result["type"] == "create_entry" 110 | assert result["title"] == "e-Connect/Metronet Alarm" 111 | assert result["data"] == { 112 | "areas_arm_away": [], 113 | "areas_arm_home": [1], 114 | "areas_arm_night": [], 115 | "areas_arm_vacation": [], 116 | } 117 | assert result["result"] is True 118 | 119 | async def test_form_submit_successful_with_input(self, hass, config_entry): 120 | # Ensure users can submit an option that is available in the allowed list 121 | form = await hass.config_entries.options.async_init( 122 | config_entry.entry_id, context={"show_advanced_options": False} 123 | ) 124 | # Test 125 | result = await hass.config_entries.options.async_configure( 126 | form["flow_id"], 127 | user_input={ 128 | "areas_arm_home": [ 129 | (1, "S1 Living Room"), 130 | ], 131 | }, 132 | ) 133 | await hass.async_block_till_done() 134 | # Check HA config 135 | assert result["type"] == "create_entry" 136 | assert result["title"] == "e-Connect/Metronet Alarm" 137 | assert result["data"] == { 138 | "areas_arm_away": [], 139 | "areas_arm_home": [(1, "S1 Living Room")], 140 | "areas_arm_night": [], 141 | "areas_arm_vacation": [], 142 | } 143 | assert result["result"] is True 144 | 145 | async def test_form_submit_successful_with_multiple_inputs(self, hass, config_entry): 146 | # Ensure multiple options can be submitted at once 147 | form = await hass.config_entries.options.async_init( 148 | config_entry.entry_id, context={"show_advanced_options": False} 149 | ) 150 | # Test 151 | result = await hass.config_entries.options.async_configure( 152 | form["flow_id"], 153 | user_input={ 154 | "areas_arm_away": [ 155 | (2, "S2 Bedroom"), 156 | ], 157 | "areas_arm_home": [ 158 | (1, "S1 Living Room"), 159 | ], 160 | "areas_arm_night": [ 161 | (1, "S1 Living Room"), 162 | ], 163 | "areas_arm_vacation": [(1, "S1 Living Room"), (2, "S2 Bedroom")], 164 | }, 165 | ) 166 | await hass.async_block_till_done() 167 | # Check HA config 168 | assert result["type"] == "create_entry" 169 | assert result["title"] == "e-Connect/Metronet Alarm" 170 | assert result["data"] == { 171 | "areas_arm_away": [(2, "S2 Bedroom")], 172 | "areas_arm_home": [(1, "S1 Living Room")], 173 | "areas_arm_night": [(1, "S1 Living Room")], 174 | "areas_arm_vacation": [(1, "S1 Living Room"), (2, "S2 Bedroom")], 175 | } 176 | assert result["result"] is True 177 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Module for e-connect binary sensors (sectors, inputs and alert).""" 2 | 3 | from elmo import query as q 4 | from homeassistant.components.binary_sensor import ( 5 | BinarySensorDeviceClass, 6 | BinarySensorEntity, 7 | ) 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | from homeassistant.helpers.update_coordinator import ( 12 | CoordinatorEntity, 13 | DataUpdateCoordinator, 14 | ) 15 | 16 | from .const import ( 17 | CONF_EXPERIMENTAL, 18 | CONF_FORCE_UPDATE, 19 | DEVICE_CLASS_SECTORS, 20 | DOMAIN, 21 | KEY_COORDINATOR, 22 | KEY_DEVICE, 23 | ) 24 | from .devices import AlarmDevice 25 | from .helpers import generate_entity_id 26 | 27 | 28 | async def async_setup_entry( 29 | hass: HomeAssistant, 30 | entry: ConfigEntry, 31 | async_add_entities: AddEntitiesCallback, 32 | ) -> None: 33 | """Set up e-connect binary sensors from a config entry.""" 34 | device = hass.data[DOMAIN][entry.entry_id][KEY_DEVICE] 35 | coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] 36 | # Load all entities and register sectors and inputs 37 | 38 | sensors = [] 39 | 40 | # Iterate through the sectors of the provided device and create InputBinarySensor objects 41 | for sector_id, name in device.sectors: 42 | unique_id = f"{entry.entry_id}_{DOMAIN}_{q.SECTORS}_{sector_id}" 43 | sensors.append(SectorBinarySensor(unique_id, sector_id, entry, name, coordinator, device)) 44 | 45 | # Iterate through the inputs of the provided device and create InputBinarySensor objects 46 | for input_id, name in device.inputs: 47 | unique_id = f"{entry.entry_id}_{DOMAIN}_{q.INPUTS}_{input_id}" 48 | sensors.append(InputBinarySensor(unique_id, input_id, entry, name, coordinator, device)) 49 | 50 | # Iterate through the alerts of the provided device and create AlertBinarySensor objects 51 | # except for alarm_led, inputs_led and tamper_led as they have three states 52 | for alert_id, name in device.alerts: 53 | if name not in ["alarm_led", "inputs_led", "tamper_led"]: 54 | unique_id = f"{entry.entry_id}_{DOMAIN}_{name}" 55 | sensors.append(AlertBinarySensor(unique_id, alert_id, entry, name, coordinator, device)) 56 | 57 | # Binary sensor to keep track of the device connection status 58 | unique_id = f"{entry.entry_id}_{DOMAIN}_connection_status" 59 | sensors.append(AlertBinarySensor(unique_id, -1, entry, "connection_status", coordinator, device)) 60 | 61 | async_add_entities(sensors) 62 | 63 | 64 | class AlertBinarySensor(CoordinatorEntity, BinarySensorEntity): 65 | """Representation of a e-Connect alert binary sensor""" 66 | 67 | _attr_has_entity_name = True 68 | 69 | def __init__( 70 | self, 71 | unique_id: str, 72 | alert_id: int, 73 | config: ConfigEntry, 74 | name: str, 75 | coordinator: DataUpdateCoordinator, 76 | device: AlarmDevice, 77 | ) -> None: 78 | """Construct.""" 79 | # Enable experimental settings from the configuration file 80 | experimental = coordinator.hass.data[DOMAIN].get(CONF_EXPERIMENTAL, {}) 81 | self._attr_force_update = experimental.get(CONF_FORCE_UPDATE, False) 82 | 83 | super().__init__(coordinator) 84 | self.entity_id = generate_entity_id(config, name) 85 | self._name = name 86 | self._device = device 87 | self._unique_id = unique_id 88 | self._alert_id = alert_id 89 | 90 | @property 91 | def unique_id(self) -> str: 92 | """Return the unique identifier.""" 93 | return self._unique_id 94 | 95 | @property 96 | def translation_key(self) -> str: 97 | """Return the translation key to translate the entity's name and states.""" 98 | return self._name 99 | 100 | @property 101 | def icon(self) -> str: 102 | """Return the icon used by this entity.""" 103 | return "hass:alarm-light" 104 | 105 | @property 106 | def device_class(self) -> str: 107 | """Return the device class.""" 108 | return BinarySensorDeviceClass.PROBLEM 109 | 110 | @property 111 | def is_on(self) -> bool: 112 | """Return the binary sensor status (on/off).""" 113 | status = self._device.get_status(q.ALERTS, self._alert_id) 114 | if self._name == "anomalies_led": 115 | return status > 1 116 | else: 117 | return bool(status) 118 | 119 | 120 | class InputBinarySensor(CoordinatorEntity, BinarySensorEntity): 121 | """Representation of a e-connect input binary sensor.""" 122 | 123 | _attr_has_entity_name = True 124 | 125 | def __init__( 126 | self, 127 | unique_id: str, 128 | input_id: int, 129 | config: ConfigEntry, 130 | name: str, 131 | coordinator: DataUpdateCoordinator, 132 | device: AlarmDevice, 133 | ) -> None: 134 | """Construct.""" 135 | # Enable experimental settings from the configuration file 136 | experimental = coordinator.hass.data[DOMAIN].get(CONF_EXPERIMENTAL, {}) 137 | self._attr_force_update = experimental.get(CONF_FORCE_UPDATE, False) 138 | 139 | super().__init__(coordinator) 140 | self.entity_id = generate_entity_id(config, name) 141 | self._name = name 142 | self._device = device 143 | self._unique_id = unique_id 144 | self._input_id = input_id 145 | 146 | @property 147 | def unique_id(self) -> str: 148 | """Return the unique identifier.""" 149 | return self._unique_id 150 | 151 | @property 152 | def name(self) -> str: 153 | """Return the name of this entity.""" 154 | return self._name 155 | 156 | @property 157 | def icon(self) -> str: 158 | """Return the icon used by this entity.""" 159 | return "hass:electric-switch" 160 | 161 | @property 162 | def is_on(self) -> bool: 163 | """Return the binary sensor status (on/off).""" 164 | return bool(self._device.get_status(q.INPUTS, self._input_id)) 165 | 166 | 167 | class SectorBinarySensor(CoordinatorEntity, BinarySensorEntity): 168 | """Representation of a e-connect sector binary sensor.""" 169 | 170 | _attr_has_entity_name = True 171 | 172 | def __init__( 173 | self, 174 | unique_id: str, 175 | sector_id: int, 176 | config: ConfigEntry, 177 | name: str, 178 | coordinator: DataUpdateCoordinator, 179 | device: AlarmDevice, 180 | ) -> None: 181 | """Construct.""" 182 | # Enable experimental settings from the configuration file 183 | experimental = coordinator.hass.data[DOMAIN].get(CONF_EXPERIMENTAL, {}) 184 | self._attr_force_update = experimental.get(CONF_FORCE_UPDATE, False) 185 | 186 | super().__init__(coordinator) 187 | self.entity_id = generate_entity_id(config, name) 188 | self._name = name 189 | self._device = device 190 | self._unique_id = unique_id 191 | self._sector_id = sector_id 192 | 193 | # Register the sector with the device 194 | device._register_sector(self) 195 | 196 | @property 197 | def unique_id(self) -> str: 198 | """Return the unique identifier.""" 199 | return self._unique_id 200 | 201 | @property 202 | def device_class(self) -> str: 203 | """Return the device class.""" 204 | return DEVICE_CLASS_SECTORS 205 | 206 | @property 207 | def name(self) -> str: 208 | """Return the name of this entity.""" 209 | return self._name 210 | 211 | @property 212 | def icon(self) -> str: 213 | """Return the icon used by this entity.""" 214 | return "hass:shield-home-outline" 215 | 216 | @property 217 | def is_on(self) -> bool: 218 | """Return the binary sensor status (on/off).""" 219 | return bool(self._device.get_status(q.SECTORS, self._sector_id)) 220 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "domain": "Domain name", 7 | "username": "[%key:common::config_flow::data::username%]", 8 | "password": "[%key:common::config_flow::data::password%]", 9 | "system_name": "[%key:common::config_flow::data::system_name%]" 10 | }, 11 | "description": "Provide your credentials and the domain used to access your login page via web.\n\nIf you access to `https://connect.elmospa.com/vendor/`, you must set the domain to `vendor`. In case you don't have a vendor defined, leave it to `default`.\n\nYou can configure the system selecting \"Options\" after installing the integration.", 12 | "title": "Configure your Elmo/IESS system" 13 | } 14 | }, 15 | "error": { 16 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 17 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 18 | "invalid_areas": "Digited areas (home or night) are invalid", 19 | "unknown": "[%key:common::config_flow::error::unknown%]" 20 | }, 21 | "abort": { 22 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 23 | } 24 | }, 25 | "options": { 26 | "error": { 27 | "invalid_areas": "Selected sectors are invalid", 28 | "unknown": "Unexpected error: check your logs" 29 | }, 30 | "step": { 31 | "init": { 32 | "data": { 33 | "areas_arm_away": "Armed areas while away (unset to arm all areas)", 34 | "areas_arm_home": "Armed areas while at home (optional)", 35 | "areas_arm_night": "Armed areas at night (optional)", 36 | "areas_arm_vacation": "Armed areas when you are on vacation (optional)", 37 | "scan_interval": "Scan interval (e.g. 120 - optional)" 38 | }, 39 | "description": "Define sectors you want to arm in different modes. If AWAY section is unset, all sectors are armed.\n\nSet 'Scan Interval' value only if you want to reduce data usage, in case the system is connected through a mobile network. Leave it empty for real time updates, or set it to a value in seconds (e.g. 120 for one update every 2 minutes)", 40 | "title": "Configure your e-Connect/Metronet system" 41 | } 42 | } 43 | }, 44 | "entity": { 45 | "binary_sensor": { 46 | "connection_status": { 47 | "name": "Connection Status", 48 | "state": { 49 | "on": "Disconnected", 50 | "off": "Connected" 51 | } 52 | }, 53 | "anomalies_led": { 54 | "name": "General Anomaly", 55 | "state": { 56 | "on": "On", 57 | "off": "Off" 58 | } 59 | }, 60 | "inputs_led": { 61 | "name": "Zones Ready Status", 62 | "state": { 63 | "on": "On", 64 | "off": "Off" 65 | } 66 | }, 67 | "alarm_led": { 68 | "name": "General Alarm", 69 | "state": { 70 | "on": "On", 71 | "off": "Off" 72 | } 73 | }, 74 | "tamper_led": { 75 | "name": "General Tamper", 76 | "state": { 77 | "on": "On", 78 | "off": "Off" 79 | } 80 | }, 81 | "has_anomaly": { 82 | "name": "Anomalies", 83 | "state": { 84 | "on": "Detected", 85 | "off": "No" 86 | } 87 | }, 88 | "panel_tamper": { 89 | "name": "Panel Tamper", 90 | "state": { 91 | "on": "Tampered", 92 | "off": "Secure" 93 | } 94 | }, 95 | "panel_no_power": { 96 | "name": "System Main Power", 97 | "state": { 98 | "on": "No Power", 99 | "off": "Powered" 100 | } 101 | }, 102 | "panel_low_battery": { 103 | "name": "System Battery", 104 | "state": { 105 | "on": "Low Battery", 106 | "off": "OK" 107 | } 108 | }, 109 | "gsm_anomaly": { 110 | "name": "GSM Status", 111 | "state": { 112 | "on": "Anomaly", 113 | "off": "OK" 114 | } 115 | }, 116 | "gsm_low_balance": { 117 | "name": "GSM Plafond Balance", 118 | "state": { 119 | "on": "Low", 120 | "off": "OK" 121 | } 122 | }, 123 | "pstn_anomaly": { 124 | "name": "PSTN Anomaly", 125 | "state": { 126 | "on": "Detected", 127 | "off": "No" 128 | } 129 | }, 130 | "system_test": { 131 | "name": "System Test", 132 | "state": { 133 | "on": "Required", 134 | "off": "OK" 135 | } 136 | }, 137 | "module_registration": { 138 | "name": "Module Registration", 139 | "state": { 140 | "on": "Registering", 141 | "off": "Not Registering" 142 | } 143 | }, 144 | "rf_interference": { 145 | "name": "RF Interference", 146 | "state": { 147 | "on": "Detected", 148 | "off": "No" 149 | } 150 | }, 151 | "input_failure": { 152 | "name": "Input Failure", 153 | "state": { 154 | "on": "Detected", 155 | "off": "No" 156 | } 157 | }, 158 | "input_alarm": { 159 | "name": "Input Alarm", 160 | "state": { 161 | "on": "Triggered", 162 | "off": "No" 163 | } 164 | }, 165 | "input_bypass": { 166 | "name": "Input Bypass", 167 | "state": { 168 | "on": "Bypassed", 169 | "off": "No" 170 | } 171 | }, 172 | "input_low_battery": { 173 | "name": "Input Battery", 174 | "state": { 175 | "on": "Low Battery", 176 | "off": "OK" 177 | } 178 | }, 179 | "input_no_supervision": { 180 | "name": "Input No Supervision", 181 | "state": { 182 | "on": "Not Supervised", 183 | "off": "Supervised" 184 | } 185 | }, 186 | "device_tamper": { 187 | "name": "Device Protection", 188 | "state": { 189 | "on": "Tampered", 190 | "off": "Secure" 191 | } 192 | }, 193 | "device_failure": { 194 | "name": "Device Failure", 195 | "state": { 196 | "on": "Detected", 197 | "off": "No" 198 | } 199 | }, 200 | "device_no_power": { 201 | "name": "Device Power", 202 | "state": { 203 | "on": "No Power", 204 | "off": "Powered" 205 | } 206 | }, 207 | "device_low_battery": { 208 | "name": "Device Battery", 209 | "state": { 210 | "on": "Low Battery", 211 | "off": "OK" 212 | } 213 | }, 214 | "device_no_supervision": { 215 | "name": "Device No Supervision", 216 | "state": { 217 | "on": "Not Supervised", 218 | "off": "Supervised" 219 | } 220 | }, 221 | "device_system_block": { 222 | "name": "Device System Block", 223 | "state": { 224 | "on": "Blocked", 225 | "off": "Not Blocked" 226 | } 227 | } 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_auth": "Invalid authentication", 9 | "invalid_areas": "Selected sectors are invalid", 10 | "unknown": "Unexpected error: check your logs" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "domain": "Domain name", 16 | "username": "Username", 17 | "password": "Password", 18 | "system_name": "Name of the control panel (optional)" 19 | }, 20 | "description": "Provide your credentials and the domain used to access your login page via web.\n\nIf you access to `https://connect.elmospa.com/vendor/`, you must set the domain to `vendor`. In case you don't have a vendor defined, leave it to `default`.\n\nYou can configure the system selecting \"Options\" after installing the integration.", 21 | "title": "Configure your e-Connect/Metronet system" 22 | } 23 | } 24 | }, 25 | "options": { 26 | "error": { 27 | "invalid_areas": "Selected sectors are invalid", 28 | "unknown": "Unexpected error: check your logs" 29 | }, 30 | "step": { 31 | "init": { 32 | "data": { 33 | "areas_arm_away": "Armed areas while away (unset to arm all areas)", 34 | "areas_arm_home": "Armed areas while at home (optional)", 35 | "areas_arm_night": "Armed areas at night (optional)", 36 | "areas_arm_vacation": "Armed areas when you are on vacation (optional)", 37 | "scan_interval": "Scan interval (e.g. 120 - optional)" 38 | }, 39 | "description": "Define sectors you want to arm in different modes. If AWAY section is unset, all sectors are armed.\n\nSet 'Scan Interval' value only if you want to reduce data usage, in case the system is connected through a mobile network. Leave it empty for real time updates, or set it to a value in seconds (e.g. 120 for one update every 2 minutes)", 40 | "title": "Configure your e-Connect/Metronet system" 41 | } 42 | } 43 | }, 44 | "entity": { 45 | "sensor": { 46 | "inputs_led": { 47 | "name": "Zones Ready Status", 48 | "state": { 49 | "0": "Not ready", 50 | "1": "Ready", 51 | "2": "Partially ready" 52 | } 53 | }, 54 | "alarm_led": { 55 | "name": "General Alarm", 56 | "state": { 57 | "0": "No alarms", 58 | "1": "Alarm in progress", 59 | "2": "Alarm memory" 60 | } 61 | }, 62 | "tamper_led": { 63 | "name": "General Tamper", 64 | "state": { 65 | "0": "No tampers", 66 | "1": "Tamper in progress", 67 | "2": "Tamper memory" 68 | } 69 | } 70 | }, 71 | "binary_sensor": { 72 | "connection_status": { 73 | "name": "Connection Status", 74 | "state": { 75 | "on": "Disconnected", 76 | "off": "Connected" 77 | } 78 | }, 79 | "anomalies_led": { 80 | "name": "General Anomaly", 81 | "state": { 82 | "on": "On", 83 | "off": "Off" 84 | } 85 | }, 86 | "has_anomaly": { 87 | "name": "Anomalies", 88 | "state": { 89 | "on": "Detected", 90 | "off": "No" 91 | } 92 | }, 93 | "panel_tamper": { 94 | "name": "Panel Tamper", 95 | "state": { 96 | "on": "Tampered", 97 | "off": "Secure" 98 | } 99 | }, 100 | "panel_no_power": { 101 | "name": "System Main Power", 102 | "state": { 103 | "on": "No Power", 104 | "off": "Powered" 105 | } 106 | }, 107 | "panel_low_battery": { 108 | "name": "System Battery", 109 | "state": { 110 | "on": "Low Battery", 111 | "off": "OK" 112 | } 113 | }, 114 | "gsm_anomaly": { 115 | "name": "GSM Status", 116 | "state": { 117 | "on": "Anomaly", 118 | "off": "OK" 119 | } 120 | }, 121 | "gsm_low_balance": { 122 | "name": "GSM Plafond Balance", 123 | "state": { 124 | "on": "Low", 125 | "off": "OK" 126 | } 127 | }, 128 | "pstn_anomaly": { 129 | "name": "PSTN Anomaly", 130 | "state": { 131 | "on": "Detected", 132 | "off": "No" 133 | } 134 | }, 135 | "system_test": { 136 | "name": "System Test", 137 | "state": { 138 | "on": "Required", 139 | "off": "OK" 140 | } 141 | }, 142 | "module_registration": { 143 | "name": "Module Registration", 144 | "state": { 145 | "on": "Registering", 146 | "off": "Not Registering" 147 | } 148 | }, 149 | "rf_interference": { 150 | "name": "RF Interference", 151 | "state": { 152 | "on": "Detected", 153 | "off": "No" 154 | } 155 | }, 156 | "input_failure": { 157 | "name": "Input Failure", 158 | "state": { 159 | "on": "Detected", 160 | "off": "No" 161 | } 162 | }, 163 | "input_alarm": { 164 | "name": "Input Alarm", 165 | "state": { 166 | "on": "Triggered", 167 | "off": "No" 168 | } 169 | }, 170 | "input_bypass": { 171 | "name": "Input Bypass", 172 | "state": { 173 | "on": "Bypassed", 174 | "off": "No" 175 | } 176 | }, 177 | "input_low_battery": { 178 | "name": "Input Battery", 179 | "state": { 180 | "on": "Low Battery", 181 | "off": "OK" 182 | } 183 | }, 184 | "input_no_supervision": { 185 | "name": "Input No Supervision", 186 | "state": { 187 | "on": "Not Supervised", 188 | "off": "Supervised" 189 | } 190 | }, 191 | "device_tamper": { 192 | "name": "Device Protection", 193 | "state": { 194 | "on": "Tampered", 195 | "off": "Secure" 196 | } 197 | }, 198 | "device_failure": { 199 | "name": "Device Failure", 200 | "state": { 201 | "on": "Detected", 202 | "off": "No" 203 | } 204 | }, 205 | "device_no_power": { 206 | "name": "Device Power", 207 | "state": { 208 | "on": "No Power", 209 | "off": "Powered" 210 | } 211 | }, 212 | "device_low_battery": { 213 | "name": "Device Battery", 214 | "state": { 215 | "on": "Low Battery", 216 | "off": "OK" 217 | } 218 | }, 219 | "device_no_supervision": { 220 | "name": "Device No Supervision", 221 | "state": { 222 | "on": "Not Supervised", 223 | "off": "Supervised" 224 | } 225 | }, 226 | "device_system_block": { 227 | "name": "Device System Block", 228 | "state": { 229 | "on": "Blocked", 230 | "off": "Not Blocked" 231 | } 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /custom_components/econnect_metronet/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Dispositivo già configurato" 5 | }, 6 | "error": { 7 | "cannot_connect": "Connessione fallita", 8 | "invalid_auth": "Autenticazione non valida", 9 | "invalid_areas": "Settori selezionati non validi", 10 | "unknown": "Errore inaspettato: controlla i tuoi log" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "domain": "Nome dominio", 16 | "username": "Nome utente", 17 | "password": "Password", 18 | "system_name": "Nome dell'impianto (opzionale)" 19 | }, 20 | "description": "Fornisci le tue credenziali e il dominio utilizzato per accedere alla tua pagina di login via web.\n\nSe accedi a `https://connect.elmospa.com/installatore/`, devi impostare il dominio su `installatore`. Nel caso in cui tu non abbia un installatore impostato, lascia il valore su `default`.\n\nPuoi configurare il sistema selezionando \"Opzioni\" dopo aver installato l'integrazione.", 21 | "title": "Configura il tuo sistema e-Connect/Metronet" 22 | } 23 | } 24 | }, 25 | "options": { 26 | "error": { 27 | "invalid_areas": "Settori selezionati non validi", 28 | "unknown": "Errore inaspettato: controlla i tuoi log" 29 | }, 30 | "step": { 31 | "init": { 32 | "data": { 33 | "areas_arm_away": "Settori armati mentre sei fuori casa (attiva tutti i settori se non configurato)", 34 | "areas_arm_home": "Settori armati mentre sei a casa (opzionale)", 35 | "areas_arm_night": "Settori armati di notte (opzionale)", 36 | "areas_arm_vacation": "Settori armati quando sei in vacanza (opzionale)", 37 | "scan_interval": "Intervallo di scansione (es. 120 - opzionale)" 38 | }, 39 | "description": "Scegli, tra quelli proposti, i settori che desideri armare nelle diverse modalità. Se l'opzione FUORI CASA non venisse configurata, tutti i settori saranno attivati in allarme.\n\nImposta il valore 'Intervallo di scansione' solo se desideri ridurre l'utilizzo dei dati, nel caso in cui il sistema sia connesso tramite una rete mobile (SIM). Lascialo vuoto per aggiornamenti in tempo reale, oppure imposta un valore in secondi (es. 120 per un aggiornamento ogni 2 minuti)", 40 | "title": "Configura il tuo sistema e-Connect/Metronet" 41 | } 42 | } 43 | }, 44 | "entity": { 45 | "sensor": { 46 | "inputs_led": { 47 | "name": "Stato Inseribilità Zone", 48 | "state": { 49 | "0": "Non pronto", 50 | "1": "Pronto", 51 | "2": "Parzialmente pronto" 52 | } 53 | }, 54 | "alarm_led": { 55 | "name": "Allarme Generale", 56 | "state": { 57 | "0": "Nessun allarme", 58 | "1": "Allarme in corso", 59 | "2": "Memoria allarme" 60 | } 61 | }, 62 | "tamper_led": { 63 | "name": "Manomissione Generale", 64 | "state": { 65 | "0": "Nessuna manomissione", 66 | "1": "Manomissione in corso", 67 | "2": "Memoria manomissione" 68 | } 69 | } 70 | }, 71 | "binary_sensor": { 72 | "connection_status": { 73 | "name": "Stato Connessione", 74 | "state": { 75 | "on": "Disconnessa", 76 | "off": "Connessa" 77 | } 78 | }, 79 | "anomalies_led": { 80 | "name": "Anomalia Generale", 81 | "state": { 82 | "on": "On", 83 | "off": "Off" 84 | } 85 | }, 86 | "has_anomaly": { 87 | "name": "Anomalia", 88 | "state": { 89 | "on": "Rilevata", 90 | "off": "No" 91 | } 92 | }, 93 | "panel_tamper": { 94 | "name": "Manomissione Centrale", 95 | "state": { 96 | "on": "Manomesso", 97 | "off": "Sicuro" 98 | } 99 | }, 100 | "panel_no_power": { 101 | "name": "Alimentazione Sistema", 102 | "state": { 103 | "on": "Senza Alimentazione", 104 | "off": "Alimentato" 105 | } 106 | }, 107 | "panel_low_battery": { 108 | "name": "Batteria Sistema", 109 | "state": { 110 | "on": "Batteria Scarica", 111 | "off": "OK" 112 | } 113 | }, 114 | "gsm_anomaly": { 115 | "name": "Stato GSM", 116 | "state": { 117 | "on": "Anomalia", 118 | "off": "OK" 119 | } 120 | }, 121 | "gsm_low_balance": { 122 | "name": "Credito GSM", 123 | "state": { 124 | "on": "Scarso", 125 | "off": "OK" 126 | } 127 | }, 128 | "pstn_anomaly": { 129 | "name": "Anomalia PSTN", 130 | "state": { 131 | "on": "Rilevato", 132 | "off": "No" 133 | } 134 | }, 135 | "system_test": { 136 | "name": "Test Impianto", 137 | "state": { 138 | "on": "Richiesto", 139 | "off": "OK" 140 | } 141 | }, 142 | "module_registration": { 143 | "name": "Registrazione Modulo", 144 | "state": { 145 | "on": "In Registrazione", 146 | "off": "Non in Registrazione" 147 | } 148 | }, 149 | "rf_interference": { 150 | "name": "Interferenza RF", 151 | "state": { 152 | "on": "Rilevato", 153 | "off": "No" 154 | } 155 | }, 156 | "input_failure": { 157 | "name": "Guasto Ingresso", 158 | "state": { 159 | "on": "Rilevato", 160 | "off": "No" 161 | } 162 | }, 163 | "input_alarm": { 164 | "name": "Allarme Ingresso", 165 | "state": { 166 | "on": "Innesca", 167 | "off": "No" 168 | } 169 | }, 170 | "input_bypass": { 171 | "name": "Bypass Ingresso", 172 | "state": { 173 | "on": "Bypassato", 174 | "off": "No" 175 | } 176 | }, 177 | "input_low_battery": { 178 | "name": "Batteria Ingresso", 179 | "state": { 180 | "on": "Batteria Scarica", 181 | "off": "OK" 182 | } 183 | }, 184 | "input_no_supervision": { 185 | "name": "Mancata Supervisione Ingresso", 186 | "state": { 187 | "on": "Non Supervisionato", 188 | "off": "Supervisionato" 189 | } 190 | }, 191 | "device_tamper": { 192 | "name": "Manomissione Dispositivo", 193 | "state": { 194 | "on": "Manomesso", 195 | "off": "Sicuro" 196 | } 197 | }, 198 | "device_failure": { 199 | "name": "Guasto Dispositivo", 200 | "state": { 201 | "on": "Rilevato", 202 | "off": "No" 203 | } 204 | }, 205 | "device_no_power": { 206 | "name": "Alimentazione Dispositivo", 207 | "state": { 208 | "on": "Senza Alimentazione", 209 | "off": "Alimentato" 210 | } 211 | }, 212 | "device_low_battery": { 213 | "name": "Batteria Dispositivo", 214 | "state": { 215 | "on": "Batteria Scarica", 216 | "off": "OK" 217 | } 218 | }, 219 | "device_no_supervision": { 220 | "name": "Mancata Supervisione Dispositivo", 221 | "state": { 222 | "on": "Non Supervisionato", 223 | "off": "Supervisionato" 224 | } 225 | }, 226 | "device_system_block": { 227 | "name": "Blocco Sistema Dispositivo", 228 | "state": { 229 | "on": "Bloccato", 230 | "off": "Non Bloccato" 231 | } 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from elmo.api.exceptions import CredentialError 3 | from homeassistant import config_entries 4 | from homeassistant.data_entry_flow import InvalidData 5 | from requests.exceptions import ConnectionError, HTTPError 6 | from requests.models import Response 7 | 8 | from custom_components.econnect_metronet.const import DOMAIN 9 | 10 | from .helpers import _ 11 | 12 | 13 | async def test_form_fields(hass): 14 | # Ensure the form is properly generated with fields we expect 15 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 16 | assert form["type"] == "form" 17 | assert form["step_id"] == "user" 18 | assert form["errors"] == {} 19 | assert form["data_schema"].schema["username"] == str 20 | assert form["data_schema"].schema["password"] == str 21 | assert form["data_schema"].schema["domain"] == str 22 | 23 | 24 | async def test_form_submit_successful_with_input(hass, mocker): 25 | # Ensure a properly submitted form initializes an ElmoClient 26 | m_client = mocker.patch(_("config_flow.ElmoClient")) 27 | m_setup = mocker.patch(_("async_setup"), return_value=True) 28 | m_setup_entry = mocker.patch(_("async_setup_entry"), return_value=True) 29 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 30 | # Test 31 | result = await hass.config_entries.flow.async_configure( 32 | form["flow_id"], 33 | { 34 | "username": "test-username", 35 | "password": "test-password", 36 | "domain": "default", 37 | "system_base_url": "https://metronet.iessonline.com", 38 | }, 39 | ) 40 | await hass.async_block_till_done() 41 | # Check Client Authentication 42 | assert m_client.call_args.args == ("https://metronet.iessonline.com",) 43 | assert m_client.call_args.kwargs == {"domain": "default"} 44 | assert m_client().auth.call_count == 1 45 | assert m_client().auth.call_args.args == ("test-username", "test-password") 46 | # Check HA setup 47 | assert len(m_setup.mock_calls) == 1 48 | assert len(m_setup_entry.mock_calls) == 1 49 | assert result["type"] == "create_entry" 50 | assert result["title"] == "e-Connect/Metronet Alarm" 51 | assert result["data"] == { 52 | "username": "test-username", 53 | "password": "test-password", 54 | "domain": "default", 55 | "system_base_url": "https://metronet.iessonline.com", 56 | } 57 | 58 | 59 | async def test_form_submit_with_defaults(hass, mocker): 60 | # Ensure a properly submitted form with defaults 61 | m_client = mocker.patch(_("config_flow.ElmoClient")) 62 | m_setup = mocker.patch(_("async_setup"), return_value=True) 63 | m_setup_entry = mocker.patch(_("async_setup_entry"), return_value=True) 64 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 65 | # Test 66 | result = await hass.config_entries.flow.async_configure( 67 | form["flow_id"], 68 | { 69 | "username": "test-username", 70 | "password": "test-password", 71 | "domain": "default", 72 | }, 73 | ) 74 | await hass.async_block_till_done() 75 | assert result["type"] == "create_entry" 76 | assert result["title"] == "e-Connect/Metronet Alarm" 77 | assert result["data"] == { 78 | "username": "test-username", 79 | "password": "test-password", 80 | "domain": "default", 81 | "system_base_url": "https://connect.elmospa.com", 82 | } 83 | # Check Client Authentication 84 | assert m_client.call_args.args == ("https://connect.elmospa.com",) 85 | assert m_client.call_args.kwargs == {"domain": "default"} 86 | assert m_client().auth.call_count == 1 87 | assert m_client().auth.call_args.args == ("test-username", "test-password") 88 | # Check HA setup 89 | assert len(m_setup.mock_calls) == 1 90 | assert len(m_setup_entry.mock_calls) == 1 91 | 92 | 93 | async def test_form_supported_systems(hass): 94 | """Test supported systems are pre-loaded in the dropdown.""" 95 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 96 | supported_systems = form["data_schema"].schema["system_base_url"].container 97 | # Test 98 | assert supported_systems == { 99 | "https://connect.elmospa.com": "Elmo e-Connect", 100 | "https://metronet.iessonline.com": "IESS Metronet", 101 | } 102 | 103 | 104 | async def test_form_submit_required_fields(hass, mocker): 105 | # Ensure the form has the expected required fields 106 | mocker.patch(_("async_setup"), return_value=True) 107 | mocker.patch(_("async_setup_entry"), return_value=True) 108 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 109 | # Test 110 | with pytest.raises(InvalidData) as excinfo: 111 | await hass.config_entries.flow.async_configure(form["flow_id"], {}) 112 | await hass.async_block_till_done() 113 | assert len(excinfo.value.schema_errors) == 3 114 | assert excinfo.value.schema_errors["username"] == "required key not provided" 115 | assert excinfo.value.schema_errors["password"] == "required key not provided" 116 | assert excinfo.value.schema_errors["domain"] == "required key not provided" 117 | 118 | 119 | async def test_form_submit_wrong_credential(hass, mocker): 120 | # Ensure the right error is raised for CredentialError exception 121 | mocker.patch(_("config_flow.ElmoClient"), side_effect=CredentialError) 122 | mocker.patch(_("async_setup"), return_value=True) 123 | mocker.patch(_("async_setup_entry"), return_value=True) 124 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 125 | # Test 126 | result = await hass.config_entries.flow.async_configure( 127 | form["flow_id"], 128 | { 129 | "username": "test-username", 130 | "password": "test-password", 131 | "domain": "test-domain", 132 | }, 133 | ) 134 | await hass.async_block_till_done() 135 | assert result["type"] == "form" 136 | assert result["errors"]["base"] == "invalid_auth" 137 | 138 | 139 | async def test_form_submit_connection_error(hass, mocker): 140 | # Ensure the right error is raised for connection errors 141 | mocker.patch(_("config_flow.ElmoClient"), side_effect=ConnectionError) 142 | mocker.patch(_("async_setup"), return_value=True) 143 | mocker.patch(_("async_setup_entry"), return_value=True) 144 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 145 | # Test 146 | result = await hass.config_entries.flow.async_configure( 147 | form["flow_id"], 148 | { 149 | "username": "test-username", 150 | "password": "test-password", 151 | "domain": "test-domain", 152 | }, 153 | ) 154 | await hass.async_block_till_done() 155 | assert result["type"] == "form" 156 | assert result["errors"]["base"] == "cannot_connect" 157 | 158 | 159 | async def test_form_client_errors(hass, mocker): 160 | # Ensure the right error is raised for 4xx API errors 161 | mocker.patch(_("async_setup"), return_value=True) 162 | mocker.patch(_("async_setup_entry"), return_value=True) 163 | m_client = mocker.patch(_("config_flow.ElmoClient.auth")) 164 | err = HTTPError(response=Response()) 165 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 166 | # Test 400-499 status codes 167 | for code in range(400, 500): 168 | err.response.status_code = code 169 | m_client.side_effect = err 170 | result = await hass.config_entries.flow.async_configure( 171 | form["flow_id"], 172 | { 173 | "username": "test-username", 174 | "password": "test-password", 175 | "domain": "test-domain", 176 | }, 177 | ) 178 | await hass.async_block_till_done() 179 | assert result["type"] == "form" 180 | assert result["errors"]["base"] == "client_error" 181 | 182 | 183 | async def test_form_server_errors(hass, mocker): 184 | # Ensure the right error is raised for 5xx API errors 185 | mocker.patch(_("async_setup"), return_value=True) 186 | mocker.patch(_("async_setup_entry"), return_value=True) 187 | m_client = mocker.patch(_("config_flow.ElmoClient.auth")) 188 | err = HTTPError(response=Response()) 189 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 190 | # Test 500-599 status codes 191 | for code in range(500, 600): 192 | err.response.status_code = code 193 | m_client.side_effect = err 194 | result = await hass.config_entries.flow.async_configure( 195 | form["flow_id"], 196 | { 197 | "username": "test-username", 198 | "password": "test-password", 199 | "domain": "test-domain", 200 | }, 201 | ) 202 | await hass.async_block_till_done() 203 | assert result["type"] == "form" 204 | assert result["errors"]["base"] == "server_error" 205 | 206 | 207 | async def test_form_unknown_errors(hass, mocker): 208 | # Ensure we catch unexpected status codes 209 | mocker.patch(_("async_setup"), return_value=True) 210 | mocker.patch(_("async_setup_entry"), return_value=True) 211 | err = HTTPError(response=Response()) 212 | err.response.status_code = 999 213 | mocker.patch(_("config_flow.ElmoClient.auth"), side_effect=err) 214 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 215 | # Test non-error status codes 216 | result = await hass.config_entries.flow.async_configure( 217 | form["flow_id"], 218 | { 219 | "username": "test-username", 220 | "password": "test-password", 221 | "domain": "test-domain", 222 | }, 223 | ) 224 | await hass.async_block_till_done() 225 | assert result["type"] == "form" 226 | assert result["errors"]["base"] == "unknown" 227 | 228 | 229 | async def test_form_generic_exception(hass, mocker): 230 | # Ensure we catch unexpected exceptions 231 | mocker.patch(_("async_setup"), return_value=True) 232 | mocker.patch(_("async_setup_entry"), return_value=True) 233 | mocker.patch(_("config_flow.ElmoClient.auth"), side_effect=Exception("Random Exception")) 234 | form = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) 235 | # Test 236 | result = await hass.config_entries.flow.async_configure( 237 | form["flow_id"], 238 | { 239 | "username": "test-username", 240 | "password": "test-password", 241 | "domain": "test-domain", 242 | }, 243 | ) 244 | await hass.async_block_till_done() 245 | assert result["type"] == "form" 246 | assert result["errors"]["base"] == "unknown" 247 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Elmo/IESS Integration 2 | 3 | [![Linting](https://github.com/palazzem/ha-econnect-alarm/actions/workflows/linting.yaml/badge.svg)](https://github.com/palazzem/ha-econnect-alarm/actions/workflows/linting.yaml) 4 | [![Testing](https://github.com/palazzem/ha-econnect-alarm/actions/workflows/testing.yaml/badge.svg)](https://github.com/palazzem/ha-econnect-alarm/actions/workflows/testing.yaml) 5 | [![Coverage Status](https://coveralls.io/repos/github/palazzem/ha-econnect-alarm/badge.svg?branch=main)](https://coveralls.io/github/palazzem/ha-econnect-alarm?branch=main) 6 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/palazzem/ha-econnect-alarm) 7 | 8 | 9 | 10 | This project is a [Home Assistant](https://www.home-assistant.io/) integration for your Elmo/IESS Alarm connected to 11 | [e-Connect cloud](https://e-connect.elmospa.com/it/) or [Metronet](https://metronet.iessonline.com/). 12 | 13 | ## Supported Systems 14 | 15 | This integration supports Elmo/IESS alarm systems. The following systems are known to work: 16 | - [Elmo e-Connect](https://e-connect.elmospa.com/) 17 | - [IESS Metronet](https://www.iessonline.com/) 18 | 19 | **Available functionalities** 20 | - Configuration flow implemented to add login credentials and home and night areas 21 | - Arm and disarm the alarm based on sectors you have configured 22 | - Query the system and get the status of your sectors and inputs (e.g. doors, windows, etc.) 23 | and see if they are triggered or not 24 | - A service is available and you can configure automations via YAML or via UI 25 | - `EconnectAlarm` entity is available and you can use the `AlarmPanel` card to control it in lovelace 26 | 27 | **Alarm status** 28 | - Arm Away: arms all areas 29 | - Disarm: disarm all areas 30 | - Arm Home: based on the configuration, arms given areas (optional) 31 | - Arm Night: based on the configuration, arms given areas (optional) 32 | 33 | If you are curious about the project and want to know more, check out our [Discord channel](https://discord.gg/NSmAPWw8tE)! 34 | 35 | ## Installation 36 | 37 | ### Download the Integration 38 | 39 | 1. Create a new folder in your configuration folder (where the `configuration.yaml` lives) called `custom_components` 40 | 2. Download the [latest version](https://github.com/palazzem/ha-econnect-alarm/releases) into the `custom_components` 41 | folder so that the full path from your config folder is `custom_components/econnect_metronet/` 42 | 3. Restart Home Assistant. If it's your only custom component you'll see a warning in your logs. 43 | 4. Once Home Assistant is started, from the UI go to Configuration > Integrations > Add Integrations. Search for 44 | "Elmo/IESS Alarm". After selecting, dependencies will be downloaded and it could take up to a minute. 45 | 46 | ### Setup 47 | 48 | 49 | 50 | - Username: is your username to access Elmo/IESS via web or app. 51 | - Password: is your password to access Elmo/IESS via web or app. 52 | - System: pick the brand of alarm system you are using. 53 | - Domain name: domain used to access your login page via web. If you access to `https://connect.elmospa.com/vendor/`, 54 | you must set the domain to `vendor`. In case you don't have a vendor defined, leave it to `default`. 55 | 56 | ### Options 57 | 58 | In the option page you can configure your alarm presets in case you want to fine-tune which sectors are armed. To proceed with the configuration, 59 | open the integration page and click on Elmo/IESS integration. Once the page is opened, you should see the following integration page: 60 | 61 | 62 | 63 | To configure the integration, click on "Configure". 64 | 65 | 66 | 67 | 68 | You can now define your presets: 69 | - Armed areas while at home: list areas you want to arm when you select Arm Home. 70 | - Armed areas at night (optional): list areas you want to arm when you select Arm Night. 71 | - Armed areas on vacation (optional): list areas you want to arm when you select Arm Vacation. 72 | 73 | In case you don't define any sector for a given preset, no actions are taken when you use the preset from your alarm panel. 74 | 75 | ### Automations 76 | 77 | If you use automations, remember that in the payload you must send the `code` so that the system will be properly armed/disarmed. 78 | 79 | YAML example: 80 | 81 | ```yaml 82 | service: alarm_control_panel.alarm_arm_away 83 | target: 84 | entity_id: alarm_control_panel. 85 | data: 86 | code: !secret alarm_code # (check how to use secrets if you are not familiar) 87 | ``` 88 | 89 | You can find `` under *Settings > Devices & Services > e-Connect/Metronet Alarm* opening the list of entities and searching for *Alarm Panel* 90 | 91 | 92 | 93 | UI example: 94 | 95 | 96 | 97 | ### Apple Home integration (HomeKit) 98 | 99 | If you want to integrate your alarm with the Apple Home to use Siri or automations, follow these steps: 100 | 101 | 1) Add these entries inside `configuration.yaml` to let Home Assistant create a new HomeKit bridge with just the Alarm Panel exposed. 102 | 103 | ``` 104 | homekit: 105 | - name: HASS Bridge Alarm 106 | port: 21065 107 | filter: 108 | include_domains: 109 | - alarm_control_panel 110 | entity_config: 111 | alarm_control_panel.: 112 | code: 113 | ``` 114 | 115 | 1) Please replace `` with your specific alarm code and `` with your alarm entity id. 116 | 2) Reboot Home Assistant. 117 | 3) Scan the QR code available in your Home Assistant Notifications area (bottom-left badge) with your iPhone to add the alarm into the Apple Home app. 118 | 119 | **Multiple HomeKit bridges**: If you have others HomeKit integrations created via the UI (i.e., Settings > Devices & Services), be careful to not use the same `port` twice to prevent conflicts. Also note, if you don't specify a `port` the default `21063` is used. 120 | 121 | Please note that Apple Home requires you to confirm automations that involves security devices such as lockers and alarm systems. 122 | 123 | As a result of the integration you should have a similar configuration in your Home app: 124 | 125 | | Alarm panel | Alarm panel opened | 126 | |---|---| 127 | | | | 128 | 129 | ## Troubleshooting 130 | 131 | If you encounter an issue, providing `DEBUG` logs can greatly assist us in identifying the bug. Please follow these steps to send us the debug logs: 132 | 133 | 1. Navigate to the integration configuration page in Home Assistant: **Settings > Devices & Services > e-Connect/Metronet Alarm**. 134 | 2. Enable debug logging by clicking on **Enable debug logging**. 135 | 3. Restart the integration by selecting **Restart** from the three-dot menu. 136 | 4. Reproduce the error (e.g., arm the system or modify the configuration). 137 | 5. After reproducing the error, return to the Integration configuration page and click **Disable debug logging**. 138 | 6. Your browser will prompt you to download the log file. 139 | 7. Ensure that the logs do not contain sensitive information, as we do not log credentials or access tokens. 140 | 8. Send the logs to us via a secure method. **Do not post your logs on public platforms** like our Discord general channel or GitHub issues, as they are publicly accessible. 141 | 142 | 143 | 144 | ## Contributing 145 | 146 | We are very open to the community's contributions - be it a quick fix of a typo, or a completely new feature! 147 | You don't need to be a Python expert to provide meaningful improvements. To learn how to get started, check 148 | out our [Contributor Guidelines](https://github.com/palazzem/ha-econnect-alarm/blob/main/CONTRIBUTING.md) first, 149 | and ask for help in our [Discord channel](https://discord.gg/NSmAPWw8tE) if you have questions. 150 | 151 | ## Development 152 | 153 | We welcome external contributions, even though the project was initially intended for personal use. If you think some 154 | parts could be exposed with a more generic interface, please open a [GitHub issue](https://github.com/palazzem/ha-econnect-alarm/issues) 155 | to discuss your suggestion. 156 | 157 | ### Dev Environment 158 | 159 | To create a virtual environment and install the project and its dependencies, execute the following commands in your 160 | terminal: 161 | 162 | ```bash 163 | # Initialize the environment with the latest version of Home Assistant 164 | E_HASS_VERSION=$(curl --silent "https://api.github.com/repos/home-assistant/core/releases/latest" | grep -Po "(?<=\"tag_name\": \").*(?=\")") 165 | ./scripts/init $E_HASS_VERSION 166 | source venv/bin/activate 167 | 168 | # Install pre-commit hooks 169 | pre-commit install 170 | ``` 171 | 172 | Instead, if you want to develop and test this integration with a different Home Assistant version, just pass the 173 | version to the init script: 174 | ```bash 175 | # Initialize the environment Home Assistant 2024.1.1 176 | ./scripts/init 2024.1.1 177 | source venv/bin/activate 178 | 179 | # Install pre-commit hooks 180 | pre-commit install 181 | ``` 182 | 183 | ### Testing Changes in Home Assistant 184 | 185 | To test your changes in an actual Home Assistant environment, you may use the Docker container available in our 186 | `compose.yaml` file. Launch the container with the following command: 187 | 188 | ```bash 189 | docker compose up -d 190 | ``` 191 | 192 | Then, navigate to `http://localhost:8123` in your web browser to set up your Home Assistant instance. Follow the standard 193 | procedure to install the integration, as you would in a typical installation. 194 | 195 | The container is configured to automatically mount the `custom_components/` and `config/` directories from your local 196 | workspace. To see changes reflected in Home Assistant, make sure to restart the instance through the UI each time 197 | you update the integration. 198 | 199 | ### Coding Guidelines 200 | 201 | To maintain a consistent codebase, we utilize [flake8][1] and [black][2]. Consistency is crucial as it 202 | helps readability, reduces errors, and facilitates collaboration among developers. 203 | 204 | To ensure that every commit adheres to our coding standards, we've integrated [pre-commit hooks][3]. 205 | These hooks automatically run `flake8` and `black` before each commit, ensuring that all code changes 206 | are automatically checked and formatted. 207 | 208 | For details on how to set up your development environment to make use of these hooks, please refer to the 209 | [Development][4] section of our documentation. 210 | 211 | [1]: https://pypi.org/project/flake8/ 212 | [2]: https://github.com/ambv/black 213 | [3]: https://pre-commit.com/ 214 | [4]: https://github.com/palazzem/ha-econnect-alarm#development 215 | 216 | ### Testing 217 | 218 | Ensuring the robustness and reliability of our code is paramount. Therefore, all contributions must include 219 | at least one test to verify the intended behavior. 220 | 221 | To run tests locally, execute the test suite using `pytest` with the following command: 222 | ```bash 223 | pytest tests --cov --cov-branch -vv 224 | ``` 225 | 226 | For a comprehensive test that mirrors the Continuous Integration (CI) environment across all supported Python 227 | versions, use `tox`: 228 | ```bash 229 | tox 230 | ``` 231 | 232 | **Note**: To use `tox` effectively, ensure you have all the necessary Python versions installed. If any 233 | versions are missing, `tox` will provide relevant warnings. 234 | -------------------------------------------------------------------------------- /tests/test_coordinator.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from elmo.api.exceptions import CredentialError, DeviceDisconnectedError, InvalidToken 5 | from homeassistant.exceptions import ConfigEntryNotReady 6 | from requests.exceptions import HTTPError 7 | 8 | from custom_components.econnect_metronet.coordinator import AlarmCoordinator 9 | 10 | 11 | def test_coordinator_constructor(hass, alarm_device): 12 | # Ensure that the coordinator is initialized correctly 13 | coordinator = AlarmCoordinator(hass, alarm_device, 42) 14 | assert coordinator.name == "econnect_metronet" 15 | assert coordinator.update_interval == timedelta(seconds=42) 16 | assert coordinator._device is alarm_device 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_coordinator_async_update_no_data(mocker, coordinator): 21 | # Ensure that the coordinator returns an empty list if no changes are detected 22 | mocker.patch.object(coordinator._device, "has_updates") 23 | coordinator._device.has_updates.return_value = {"has_changes": False} 24 | # Test 25 | await coordinator.async_refresh() 26 | assert coordinator.data == {} 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_coordinator_async_update_with_data(mocker, coordinator): 31 | # Ensure that the coordinator returns data when changes are detected 32 | mocker.patch.object(coordinator._device, "has_updates") 33 | coordinator._device.has_updates.return_value = {"has_changes": True} 34 | # Test 35 | await coordinator.async_refresh() 36 | assert coordinator.data == { 37 | 0: { 38 | "additional_info_supported": 1, 39 | "areas": 4, 40 | "brand": 0, 41 | "build": 1, 42 | "connection_type": "EthernetWiFi", 43 | "description": "T-800 1.0.1", 44 | "device_class": 92, 45 | "inputs": 24, 46 | "is_fire_panel": False, 47 | "language": 0, 48 | "last_connection": "01/01/1984 13:27:28", 49 | "last_disconnection": "01/10/1984 13:27:18", 50 | "login_without_user_id": True, 51 | "major": 1, 52 | "minor": 0, 53 | "model": "T-800", 54 | "operators": 64, 55 | "outputs": 24, 56 | "revision": 1, 57 | "sectors_in_use": [ 58 | True, 59 | True, 60 | True, 61 | True, 62 | False, 63 | False, 64 | False, 65 | False, 66 | False, 67 | False, 68 | False, 69 | False, 70 | False, 71 | False, 72 | False, 73 | False, 74 | ], 75 | "sectors_per_area": 4, 76 | "source_ip": "10.0.0.1", 77 | "total_sectors": 16, 78 | }, 79 | 11: { 80 | 0: {"name": "alarm_led", "status": 0}, 81 | 1: {"name": "anomalies_led", "status": 1}, 82 | 2: {"name": "device_failure", "status": 0}, 83 | 3: {"name": "device_low_battery", "status": 0}, 84 | 4: {"name": "device_no_power", "status": 0}, 85 | 5: {"name": "device_no_supervision", "status": 0}, 86 | 6: {"name": "device_system_block", "status": 0}, 87 | 7: {"name": "device_tamper", "status": 1}, 88 | 8: {"name": "gsm_anomaly", "status": 0}, 89 | 9: {"name": "gsm_low_balance", "status": 0}, 90 | 10: {"name": "has_anomaly", "status": False}, 91 | 11: {"name": "input_alarm", "status": 0}, 92 | 12: {"name": "input_bypass", "status": 0}, 93 | 13: {"name": "input_failure", "status": 0}, 94 | 14: {"name": "input_low_battery", "status": 0}, 95 | 15: {"name": "input_no_supervision", "status": 0}, 96 | 16: {"name": "inputs_led", "status": 2}, 97 | 17: {"name": "module_registration", "status": 0}, 98 | 18: {"name": "panel_low_battery", "status": 0}, 99 | 19: {"name": "panel_no_power", "status": 0}, 100 | 20: {"name": "panel_tamper", "status": 0}, 101 | 21: {"name": "pstn_anomaly", "status": 0}, 102 | 22: {"name": "rf_interference", "status": 0}, 103 | 23: {"name": "system_test", "status": 0}, 104 | 24: {"name": "tamper_led", "status": 0}, 105 | }, 106 | 10: { 107 | 0: {"element": 1, "excluded": False, "id": 1, "index": 0, "name": "Entryway Sensor", "status": True}, 108 | 1: {"element": 2, "excluded": False, "id": 2, "index": 1, "name": "Outdoor Sensor 1", "status": True}, 109 | 2: {"element": 3, "excluded": True, "id": 3, "index": 2, "name": "Outdoor Sensor 2", "status": False}, 110 | }, 111 | 9: { 112 | 0: {"element": 1, "activable": True, "id": 1, "index": 0, "name": "S1 Living Room", "status": True}, 113 | 1: {"element": 2, "activable": True, "id": 2, "index": 1, "name": "S2 Bedroom", "status": True}, 114 | 2: {"element": 3, "activable": False, "id": 3, "index": 2, "name": "S3 Outdoor", "status": False}, 115 | }, 116 | 12: { 117 | 0: { 118 | "element": 1, 119 | "control_denied_to_users": False, 120 | "do_not_require_authentication": True, 121 | "id": 1, 122 | "index": 0, 123 | "name": "Output 1", 124 | "status": True, 125 | }, 126 | 1: { 127 | "element": 2, 128 | "control_denied_to_users": False, 129 | "do_not_require_authentication": False, 130 | "id": 2, 131 | "index": 1, 132 | "name": "Output 2", 133 | "status": True, 134 | }, 135 | 2: { 136 | "element": 3, 137 | "control_denied_to_users": True, 138 | "do_not_require_authentication": False, 139 | "id": 3, 140 | "index": 2, 141 | "name": "Output 3", 142 | "status": False, 143 | }, 144 | }, 145 | } 146 | 147 | 148 | @pytest.mark.asyncio 149 | async def test_coordinator_async_update_invalid_token(mocker, coordinator): 150 | # Ensure a new connection is established when the token is invalid 151 | # No exceptions must be raised as this is a normal condition 152 | mocker.patch.object(coordinator._device, "has_updates") 153 | coordinator._device.has_updates.side_effect = InvalidToken() 154 | mocker.spy(coordinator._device, "connect") 155 | # Test 156 | await coordinator._async_update_data() 157 | coordinator._device.connect.assert_called_once_with("test_user", "test_password") 158 | 159 | 160 | @pytest.mark.asyncio 161 | async def test_coordinator_async_update_failed(mocker, coordinator): 162 | # Resetting the connection, forces an update during the next run. This is required to prevent 163 | # a misalignment between the `AlarmDevice` and backend known IDs, needed to implement 164 | # the long-polling strategy. If IDs are misaligned, then no updates happen and 165 | # the integration remains stuck. 166 | # Regression test for: https://github.com/palazzem/ha-econnect-alarm/issues/51 167 | coordinator.last_update_success = False 168 | mocker.spy(coordinator._device, "update") 169 | mocker.spy(coordinator._device, "has_updates") 170 | # Test 171 | await coordinator._async_update_data() 172 | assert coordinator._device.update.call_count == 1 173 | assert coordinator._device.has_updates.call_count == 0 174 | 175 | 176 | @pytest.mark.asyncio 177 | async def test_coordinator_first_refresh_auth(mocker, coordinator): 178 | # Ensure the first refresh authenticates before joining the scheduler 179 | coordinator.data = None 180 | mocker.spy(coordinator._device, "connect") 181 | # Test 182 | await coordinator.async_config_entry_first_refresh() 183 | coordinator._device.connect.assert_called_once_with("test_user", "test_password") 184 | 185 | 186 | @pytest.mark.asyncio 187 | async def test_coordinator_first_refresh_update(mocker, coordinator): 188 | # Ensure the first refresh updates before joining the scheduler 189 | # This is required to avoid registering entities without a proper state 190 | coordinator.data = None 191 | mocker.patch.object(coordinator._device, "has_updates") 192 | coordinator._device.has_updates.return_value = {"has_changes": False} 193 | mocker.spy(coordinator._device, "update") 194 | # Test 195 | await coordinator.async_config_entry_first_refresh() 196 | assert coordinator._device.update.call_count == 1 197 | 198 | 199 | @pytest.mark.asyncio 200 | async def test_coordinator_first_refresh_auth_failed(mocker, coordinator): 201 | # Ensure a configuration exception is raised if the first refresh fails 202 | coordinator.data = None 203 | mocker.patch.object(coordinator._device, "connect") 204 | coordinator._device.connect.side_effect = CredentialError() 205 | # Test 206 | with pytest.raises(ConfigEntryNotReady): 207 | await coordinator.async_config_entry_first_refresh() 208 | 209 | 210 | @pytest.mark.asyncio 211 | async def test_coordinator_first_refresh_update_failed(mocker, coordinator): 212 | # Ensure a configuration exception is raised if the first refresh fails 213 | coordinator.data = None 214 | mocker.patch.object(coordinator._device, "update") 215 | coordinator._device.update.side_effect = HTTPError() 216 | # Test 217 | with pytest.raises(ConfigEntryNotReady): 218 | await coordinator.async_config_entry_first_refresh() 219 | 220 | 221 | @pytest.mark.asyncio 222 | async def test_coordinator_poll_with_disconnected_device(mocker, coordinator): 223 | # Ensure the coordinator handles a disconnected device during polling 224 | # Regression test for: https://github.com/palazzem/ha-econnect-alarm/issues/148 225 | query = mocker.patch.object(coordinator._device._connection, "query") 226 | coordinator._device._connection.query.side_effect = DeviceDisconnectedError() 227 | # Test 228 | await coordinator._async_update_data() 229 | assert query.call_count == 1 230 | assert coordinator._device.connected is False 231 | 232 | 233 | @pytest.mark.asyncio 234 | async def test_coordinator_poll_recover_disconnected_device(coordinator): 235 | # Ensure the coordinator recovers the connection state from a previous disconnected device error 236 | coordinator._device.connected = False 237 | # Test 238 | await coordinator._async_update_data() 239 | assert coordinator._device.connected is True 240 | 241 | 242 | @pytest.mark.asyncio 243 | async def test_coordinator_update_with_disconnected_device(mocker, coordinator): 244 | # Ensure the coordinator handles a disconnected device during updates 245 | # Regression test for: https://github.com/palazzem/ha-econnect-alarm/issues/148 246 | mocker.patch.object(coordinator._device, "has_updates") 247 | mocker.patch.object(coordinator._device._connection, "query") 248 | update = mocker.spy(coordinator._device, "update") 249 | coordinator._device.has_updates.return_value = {"has_changes": True} 250 | coordinator._device._connection.query.side_effect = DeviceDisconnectedError() 251 | # Test 252 | await coordinator._async_update_data() 253 | assert update.call_count == 1 254 | assert coordinator._device.connected is False 255 | 256 | 257 | @pytest.mark.asyncio 258 | async def test_coordinator_update_recover_disconnected_device(mocker, coordinator): 259 | # Ensure the coordinator recovers the connection state from a previous disconnected device error 260 | mocker.patch.object(coordinator._device, "has_updates") 261 | coordinator._device.has_updates.return_value = {"has_changes": True} 262 | coordinator._device.connected = False 263 | # Test 264 | await coordinator._async_update_data() 265 | assert coordinator._device.connected is True 266 | 267 | 268 | @pytest.mark.asyncio 269 | async def test_coordinator_async_update_disconnected_device(mocker, coordinator): 270 | # Ensure a full update is forced if the device was previously disconnected 271 | # Regression test for: https://github.com/palazzem/ha-econnect-alarm/issues/148 272 | coordinator._device.connected = False 273 | mocker.spy(coordinator._device, "update") 274 | mocker.spy(coordinator._device, "has_updates") 275 | # Test 276 | await coordinator._async_update_data() 277 | assert coordinator._device.update.call_count == 1 278 | assert coordinator._device.has_updates.call_count == 0 279 | -------------------------------------------------------------------------------- /tests/fixtures/responses.py: -------------------------------------------------------------------------------- 1 | """ 2 | Centralizes predefined responses intended for integration testing, especially when used with the `server` fixture. 3 | For standard tests, responses should be encapsulated within the test itself. This module should be referenced 4 | primarily when updating the responses for the integration test client, ensuring a consistent test environment. 5 | 6 | Key Benefits: 7 | - Central repository of standardized test responses. 8 | - Promotes consistent and maintainable testing practices. 9 | 10 | Usage: 11 | 1. Import the required response constant from this module: 12 | from tests.fixtures.responses import SECTORS 13 | 14 | 2. Incorporate the imported response in your test logic: 15 | def test_client_get_sectors_status(server): 16 | server.add(responses.POST, "https://example.com/api/areas", body=SECTORS, status=200) 17 | # Continue with the test... 18 | """ 19 | 20 | LOGIN = """ 21 | { 22 | "SessionId": "00000000-0000-0000-0000-000000000000", 23 | "Username": "test", 24 | "Domain": "domain", 25 | "Language": "en", 26 | "IsActivated": true, 27 | "ShowTimeZoneControls": true, 28 | "TimeZone": "(UTC+01:00) Amsterdam, Berlino, Berna, Roma, Stoccolma, Vienna", 29 | "ShowChronothermostat": false, 30 | "ShowThumbnails": false, 31 | "ShowExtinguish": false, 32 | "IsConnected": true, 33 | "IsLoggedIn": false, 34 | "IsLoginInProgress": false, 35 | "CanElevate": true, 36 | "Panel": { 37 | "Description": "T-800 1.0.1", 38 | "LastConnection": "01/01/1984 13:27:28", 39 | "LastDisconnection": "01/10/1984 13:27:18", 40 | "Major": 1, 41 | "Minor": 0, 42 | "SourceIP": "10.0.0.1", 43 | "ConnectionType": "EthernetWiFi", 44 | "DeviceClass": 92, 45 | "Revision": 1, 46 | "Build": 1, 47 | "Brand": 0, 48 | "Language": 0, 49 | "Areas": 4, 50 | "SectorsPerArea": 4, 51 | "TotalSectors": 16, 52 | "Inputs": 24, 53 | "Outputs": 24, 54 | "Operators": 64, 55 | "SectorsInUse": [ 56 | true, 57 | true, 58 | true, 59 | true, 60 | false, 61 | false, 62 | false, 63 | false, 64 | false, 65 | false, 66 | false, 67 | false, 68 | false, 69 | false, 70 | false, 71 | false 72 | ], 73 | "Model": "T-800", 74 | "LoginWithoutUserID": true, 75 | "AdditionalInfoSupported": 1, 76 | "IsFirePanel": false 77 | }, 78 | "AccountId": 100, 79 | "ManagedAccounts": [ 80 | { 81 | "Id": 1, 82 | "FullUsername": "domain\\\\test" 83 | } 84 | ], 85 | "IsManaged": false, 86 | "Message": "", 87 | "DVRPort": "", 88 | "ExtendedAreaInfoOnStatusPage": true, 89 | "DefaultPage": "Status", 90 | "NotificationTitle": "", 91 | "NotificationText": "", 92 | "NotificationDontShowAgain": true, 93 | "Redirect": false, 94 | "IsElevation": false, 95 | "InstallerForceSupervision": true, 96 | "PrivacyLink": "/PrivacyAndTerms/v1/Informativa_privacy_econnect_2020_09.pdf", 97 | "TermsLink": "/PrivacyAndTerms/v1/CONTRATTO_UTILIZZATORE_FINALE_2020_02_07.pdf" 98 | }""" 99 | UPDATES = """ 100 | { 101 | "ConnectionStatus": false, 102 | "CanElevate": false, 103 | "LoggedIn": false, 104 | "LoginInProgress": false, 105 | "Areas": true, 106 | "Events": false, 107 | "Inputs": true, 108 | "Outputs": false, 109 | "Anomalies": false, 110 | "ReadStringsInProgress": false, 111 | "ReadStringPercentage": 0, 112 | "Strings": 0, 113 | "ManagedAccounts": false, 114 | "Temperature": false, 115 | "StatusAdv": false, 116 | "Images": false, 117 | "AdditionalInfoSupported": true, 118 | "HasChanges": true 119 | } 120 | """ 121 | SYNC_LOGIN = """[ 122 | { 123 | "Poller": {"Poller": 1, "Panel": 1}, 124 | "CommandId": 5, 125 | "Successful": true 126 | } 127 | ]""" 128 | SYNC_LOGOUT = """[ 129 | { 130 | "Poller": {"Poller": 1, "Panel": 1}, 131 | "CommandId": 5, 132 | "Successful": true 133 | } 134 | ]""" 135 | SYNC_SEND_COMMAND = """[ 136 | { 137 | "Poller": {"Poller": 1, "Panel": 1}, 138 | "CommandId": 5, 139 | "Successful": true 140 | } 141 | ]""" 142 | STRINGS = """[ 143 | { 144 | "AccountId": 1, 145 | "Class": 9, 146 | "Index": 0, 147 | "Description": "S1 Living Room", 148 | "Created": "/Date(1546004120767+0100)/", 149 | "Version": "AAAAAAAAgPc=" 150 | }, 151 | { 152 | "AccountId": 1, 153 | "Class": 9, 154 | "Index": 1, 155 | "Description": "S2 Bedroom", 156 | "Created": "/Date(1546004120770+0100)/", 157 | "Version": "AAAAAAAAgPg=" 158 | }, 159 | { 160 | "AccountId": 1, 161 | "Class": 9, 162 | "Index": 2, 163 | "Description": "S3 Outdoor", 164 | "Created": "/Date(1546004147490+0100)/", 165 | "Version": "AAAAAAAAgRs=" 166 | }, 167 | { 168 | "AccountId": 1, 169 | "Class": 9, 170 | "Index": 3, 171 | "Description": "S4 Garage", 172 | "Created": "/Date(1546004147491+0100)/", 173 | "Version": "AAAAAAAAgRt=" 174 | }, 175 | { 176 | "AccountId": 1, 177 | "Class": 10, 178 | "Index": 0, 179 | "Description": "Entryway Sensor", 180 | "Created": "/Date(1546004147493+0100)/", 181 | "Version": "AAAAAAAAgRw=" 182 | }, 183 | { 184 | "AccountId": 1, 185 | "Class": 10, 186 | "Index": 1, 187 | "Description": "Outdoor Sensor 1", 188 | "Created": "/Date(1546004147493+0100)/", 189 | "Version": "AAAAAAAAgRw=" 190 | }, 191 | { 192 | "AccountId": 1, 193 | "Class": 10, 194 | "Index": 2, 195 | "Description": "Outdoor Sensor 2", 196 | "Created": "/Date(1546004147493+0100)/", 197 | "Version": "AAAAAAAAgRw=" 198 | }, 199 | { 200 | "AccountId": 1, 201 | "Class": 10, 202 | "Index": 3, 203 | "Description": "Outdoor Sensor 3", 204 | "Created": "/Date(1546004147493+0100)/", 205 | "Version": "AAAAAAAAgRw=" 206 | }, 207 | { 208 | "AccountId": 1, 209 | "Class": 12, 210 | "Index": 0, 211 | "Description": "Output 1", 212 | "Created": "/Date(1699548985673+0100)/", 213 | "Version": "AAAAAAAceCo=" 214 | }, 215 | { 216 | "AccountId": 1, 217 | "Class": 12, 218 | "Index": 1, 219 | "Description": "Output 2", 220 | "Created": "/Date(1699548985673+0100)/", 221 | "Version": "AAAAAAAceCs=" 222 | }, 223 | { 224 | "AccountId": 1, 225 | "Class": 12, 226 | "Index": 2, 227 | "Description": "Output 3", 228 | "Created": "/Date(1699548985673+0100)/", 229 | "Version": "AAAAAAAceCw=" 230 | }, 231 | { 232 | "AccountId": 1, 233 | "Class": 12, 234 | "Index": 3, 235 | "Description": "Output 4", 236 | "Created": "/Date(1699548985673+0100)/", 237 | "Version": "AAAAAAAceC0=" 238 | } 239 | ]""" 240 | AREAS = """[ 241 | { 242 | "Active": true, 243 | "ActivePartial": false, 244 | "Max": false, 245 | "Activable": true, 246 | "ActivablePartial": false, 247 | "InUse": true, 248 | "Id": 1, 249 | "Index": 0, 250 | "Element": 1, 251 | "CommandId": 0, 252 | "InProgress": false 253 | }, 254 | { 255 | "Active": true, 256 | "ActivePartial": false, 257 | "Max": false, 258 | "Activable": true, 259 | "ActivablePartial": false, 260 | "InUse": true, 261 | "Id": 2, 262 | "Index": 1, 263 | "Element": 2, 264 | "CommandId": 0, 265 | "InProgress": false 266 | }, 267 | { 268 | "Active": false, 269 | "ActivePartial": false, 270 | "Max": false, 271 | "Activable": false, 272 | "ActivablePartial": false, 273 | "InUse": true, 274 | "Id": 3, 275 | "Index": 2, 276 | "Element": 3, 277 | "CommandId": 0, 278 | "InProgress": false 279 | }, 280 | { 281 | "Active": false, 282 | "ActivePartial": false, 283 | "Max": false, 284 | "Activable": true, 285 | "ActivablePartial": false, 286 | "InUse": false, 287 | "Id": 4, 288 | "Index": 3, 289 | "Element": 5, 290 | "CommandId": 0, 291 | "InProgress": false 292 | } 293 | ]""" 294 | INPUTS = """[ 295 | { 296 | "Alarm": true, 297 | "MemoryAlarm": false, 298 | "Excluded": false, 299 | "InUse": true, 300 | "IsVideo": false, 301 | "Id": 1, 302 | "Index": 0, 303 | "Element": 1, 304 | "CommandId": 0, 305 | "InProgress": false 306 | }, 307 | { 308 | "Alarm": true, 309 | "MemoryAlarm": false, 310 | "Excluded": false, 311 | "InUse": true, 312 | "IsVideo": false, 313 | "Id": 2, 314 | "Index": 1, 315 | "Element": 2, 316 | "CommandId": 0, 317 | "InProgress": false 318 | }, 319 | { 320 | "Alarm": false, 321 | "MemoryAlarm": false, 322 | "Excluded": true, 323 | "InUse": true, 324 | "IsVideo": false, 325 | "Id": 3, 326 | "Index": 2, 327 | "Element": 3, 328 | "CommandId": 0, 329 | "InProgress": false 330 | }, 331 | { 332 | "Alarm": false, 333 | "MemoryAlarm": false, 334 | "Excluded": false, 335 | "InUse": false, 336 | "IsVideo": false, 337 | "Id": 42, 338 | "Index": 3, 339 | "Element": 4, 340 | "CommandId": 0, 341 | "InProgress": false 342 | } 343 | ]""" 344 | OUTPUTS = """[ 345 | { 346 | "Active": true, 347 | "InUse": true, 348 | "DoNotRequireAuthentication": true, 349 | "ControlDeniedToUsers": false, 350 | "Id": 1, 351 | "Index": 0, 352 | "Element": 1, 353 | "CommandId": 0, 354 | "InProgress": false 355 | }, 356 | { 357 | "Active": true, 358 | "InUse": true, 359 | "DoNotRequireAuthentication": false, 360 | "ControlDeniedToUsers": false, 361 | "Id": 2, 362 | "Index": 1, 363 | "Element": 2, 364 | "CommandId": 0, 365 | "InProgress": false 366 | }, 367 | { 368 | "Active": false, 369 | "InUse": true, 370 | "DoNotRequireAuthentication": false, 371 | "ControlDeniedToUsers": true, 372 | "Id": 3, 373 | "Index": 2, 374 | "Element": 3, 375 | "CommandId": 0, 376 | "InProgress": false 377 | }, 378 | { 379 | "Active": false, 380 | "InUse": false, 381 | "DoNotRequireAuthentication": false, 382 | "ControlDeniedToUsers": false, 383 | "Id": 4, 384 | "Index": 3, 385 | "Element": 4, 386 | "CommandId": 0, 387 | "InProgress": false 388 | } 389 | ]""" 390 | STATUS = """ 391 | { 392 | "StatusUid": 1, 393 | "PanelLeds": { 394 | "InputsLed": 2, 395 | "AnomaliesLed": 1, 396 | "AlarmLed": 0, 397 | "TamperLed": 0 398 | }, 399 | "PanelAnomalies": { 400 | "HasAnomaly": false, 401 | "PanelTamper": 0, 402 | "PanelNoPower": 0, 403 | "PanelLowBattery": 0, 404 | "GsmAnomaly": 0, 405 | "GsmLowBalance": 0, 406 | "PstnAnomaly": 0, 407 | "SystemTest": 0, 408 | "ModuleRegistration": 0, 409 | "RfInterference": 0, 410 | "InputFailure": 0, 411 | "InputAlarm": 0, 412 | "InputBypass": 0, 413 | "InputLowBattery": 0, 414 | "InputNoSupervision": 0, 415 | "DeviceTamper": 1, 416 | "DeviceFailure": 0, 417 | "DeviceNoPower": 0, 418 | "DeviceLowBattery": 0, 419 | "DeviceNoSupervision": 0, 420 | "DeviceSystemBlock": 0 421 | }, 422 | "PanelAlignmentAdv": { 423 | "ManualFwUpAvailable": false, 424 | "Id": 1, 425 | "Index": -1, 426 | "Element": 0 427 | } 428 | } 429 | """ 430 | -------------------------------------------------------------------------------- /tests/test_binary_sensors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 5 | 6 | from custom_components.econnect_metronet.binary_sensor import ( 7 | AlertBinarySensor, 8 | InputBinarySensor, 9 | SectorBinarySensor, 10 | async_setup_entry, 11 | ) 12 | from custom_components.econnect_metronet.const import DOMAIN 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_async_setup_entry_in_use(hass, config_entry, alarm_device, coordinator): 17 | # Ensure the async setup loads only sectors and sensors that are in use 18 | hass.data[DOMAIN][config_entry.entry_id] = { 19 | "device": alarm_device, 20 | "coordinator": coordinator, 21 | } 22 | 23 | # Test 24 | def ensure_only_in_use(sensors): 25 | assert len(sensors) == 29 26 | 27 | await async_setup_entry(hass, config_entry, ensure_only_in_use) 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_async_setup_entry_connection_status(hass, config_entry, alarm_device, coordinator): 32 | # Ensure the async setup loads the device connection status 33 | hass.data[DOMAIN][config_entry.entry_id] = { 34 | "device": alarm_device, 35 | "coordinator": coordinator, 36 | } 37 | 38 | # Test 39 | def check_connection_status(sensors): 40 | connection_status = sensors[-1] 41 | assert connection_status.unique_id == "test_entry_id_econnect_metronet_connection_status" 42 | assert connection_status.entity_id == "econnect_metronet.econnect_metronet_test_user_connection_status" 43 | assert connection_status.is_on is False 44 | alarm_device.connected = False 45 | assert connection_status.is_on is True 46 | 47 | await async_setup_entry(hass, config_entry, check_connection_status) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_async_setup_entry_unused_input(hass, config_entry, alarm_device, coordinator): 52 | # Ensure the async setup don't load inputs that are not in use 53 | hass.data[DOMAIN][config_entry.entry_id] = { 54 | "device": alarm_device, 55 | "coordinator": coordinator, 56 | } 57 | 58 | # Test 59 | def ensure_unused_input(sensors): 60 | for sensor in sensors: 61 | assert sensor._name not in ["Outdoor Sensor 3"] 62 | 63 | await async_setup_entry(hass, config_entry, ensure_unused_input) 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_async_setup_entry_unused_sector(hass, config_entry, alarm_device, coordinator): 68 | # Ensure the async setup don't load sectors that are not in use 69 | hass.data[DOMAIN][config_entry.entry_id] = { 70 | "device": alarm_device, 71 | "coordinator": coordinator, 72 | } 73 | 74 | # Test 75 | def ensure_unused_sectors(sensors): 76 | for sensor in sensors: 77 | assert sensor._name not in ["S4 Garage"] 78 | 79 | await async_setup_entry(hass, config_entry, ensure_unused_sectors) 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_async_setup_entry_alerts_unique_id(hass, config_entry, alarm_device, coordinator): 84 | # Regression test: changing this unique ID format is a breaking change 85 | hass.data[DOMAIN][config_entry.entry_id] = { 86 | "device": alarm_device, 87 | "coordinator": coordinator, 88 | } 89 | 90 | # Test 91 | def ensure_unique_id(sensors): 92 | assert sensors[27].unique_id == "test_entry_id_econnect_metronet_system_test" 93 | 94 | await async_setup_entry(hass, config_entry, ensure_unique_id) 95 | 96 | 97 | class TestAlertBinarySensor: 98 | def test_binary_sensor_is_on(self, hass, config_entry, alarm_device): 99 | # Ensure the sensor attribute is_on has the right status True 100 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 101 | entity = AlertBinarySensor("device_tamper", 7, config_entry, "device_tamper", coordinator, alarm_device) 102 | assert entity.is_on is True 103 | 104 | def test_binary_sensor_is_off(self, hass, config_entry, alarm_device): 105 | # Ensure the sensor attribute is_on has the right status False 106 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 107 | entity = AlertBinarySensor("device_failure", 2, config_entry, "device_failure", coordinator, alarm_device) 108 | assert entity.is_on is False 109 | 110 | def test_binary_sensor_missing(self, hass, config_entry, alarm_device): 111 | # Ensure the sensor raise keyerror if the alert is missing 112 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 113 | entity = AlertBinarySensor("test_id", 1000, config_entry, "test_id", coordinator, alarm_device) 114 | with pytest.raises(KeyError): 115 | assert entity.is_on is False 116 | 117 | def test_binary_sensor_anomalies_led_is_off(self, hass, config_entry, alarm_device): 118 | # Ensure the sensor attribute is_on has the right status False 119 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 120 | entity = AlertBinarySensor("anomalies_led", 1, config_entry, "anomalies_led", coordinator, alarm_device) 121 | assert entity.is_on is False 122 | 123 | def test_binary_sensor_anomalies_led_is_on(self, hass, config_entry, alarm_device): 124 | # Ensure the sensor attribute is_on has the right status True 125 | alarm_device._inventory[11][1]["status"] = 2 126 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 127 | entity = AlertBinarySensor("anomalies_led", 1, config_entry, "anomalies_led", coordinator, alarm_device) 128 | assert entity.is_on is True 129 | 130 | def test_binary_sensor_name(self, hass, config_entry, alarm_device): 131 | # Ensure the alert has the right translation key 132 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 133 | entity = AlertBinarySensor("test_id", 0, config_entry, "has_anomalies", coordinator, alarm_device) 134 | assert entity.translation_key == "has_anomalies" 135 | 136 | def test_binary_sensor_name_with_system_name(self, hass, config_entry, alarm_device): 137 | # The system name doesn't change the translation key 138 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 139 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 140 | entity = AlertBinarySensor("test_id", 0, config_entry, "has_anomalies", coordinator, alarm_device) 141 | assert entity.translation_key == "has_anomalies" 142 | 143 | def test_binary_sensor_entity_id(self, hass, config_entry, alarm_device): 144 | # Ensure the alert has a valid Entity ID 145 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 146 | entity = AlertBinarySensor("test_id", 0, config_entry, "has_anomalies", coordinator, alarm_device) 147 | assert entity.entity_id == "econnect_metronet.econnect_metronet_test_user_has_anomalies" 148 | 149 | def test_binary_sensor_entity_id_with_system_name(self, hass, config_entry, alarm_device): 150 | # Ensure the Entity ID takes into consideration the system name 151 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 152 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 153 | entity = AlertBinarySensor("test_id", 0, config_entry, "has_anomalies", coordinator, alarm_device) 154 | assert entity.entity_id == "econnect_metronet.econnect_metronet_home_has_anomalies" 155 | 156 | def test_binary_sensor_unique_id(self, hass, config_entry, alarm_device): 157 | # Ensure the alert has the right unique ID 158 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 159 | entity = AlertBinarySensor("test_id", 0, config_entry, "has_anomalies", coordinator, alarm_device) 160 | assert entity.unique_id == "test_id" 161 | 162 | def test_binary_sensor_icon(self, hass, config_entry, alarm_device): 163 | # Ensure the sensor has the right icon 164 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 165 | entity = AlertBinarySensor("test_id", 0, config_entry, "has_anomalies", coordinator, alarm_device) 166 | assert entity.icon == "hass:alarm-light" 167 | 168 | def test_binary_sensor_device_class(self, hass, config_entry, alarm_device): 169 | # Ensure the sensor has the right device class 170 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 171 | entity = AlertBinarySensor("test_id", 0, config_entry, "has_anomalies", coordinator, alarm_device) 172 | assert entity.device_class == "problem" 173 | 174 | 175 | class TestInputBinarySensor: 176 | def test_binary_sensor_name(self, hass, config_entry, alarm_device): 177 | # Ensure the sensor has the right name 178 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 179 | entity = InputBinarySensor("test_id", 1, config_entry, "1 Tamper Sirena", coordinator, alarm_device) 180 | assert entity.name == "1 Tamper Sirena" 181 | 182 | def test_binary_sensor_name_with_system_name(self, hass, config_entry, alarm_device): 183 | # The system name doesn't change the Entity name 184 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 185 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 186 | entity = InputBinarySensor("test_id", 1, config_entry, "1 Tamper Sirena", coordinator, alarm_device) 187 | assert entity.name == "1 Tamper Sirena" 188 | 189 | def test_binary_sensor_entity_id(self, hass, config_entry, alarm_device): 190 | # Ensure the sensor has a valid Entity ID 191 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 192 | entity = InputBinarySensor("test_id", 1, config_entry, "1 Tamper Sirena", coordinator, alarm_device) 193 | assert entity.entity_id == "econnect_metronet.econnect_metronet_test_user_1_tamper_sirena" 194 | 195 | def test_binary_sensor_entity_id_with_system_name(self, hass, config_entry, alarm_device): 196 | # Ensure the Entity ID takes into consideration the system name 197 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 198 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 199 | entity = InputBinarySensor("test_id", 1, config_entry, "1 Tamper Sirena", coordinator, alarm_device) 200 | assert entity.entity_id == "econnect_metronet.econnect_metronet_home_1_tamper_sirena" 201 | 202 | def test_binary_sensor_unique_id(self, hass, config_entry, alarm_device): 203 | # Ensure the sensor has the right unique ID 204 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 205 | entity = InputBinarySensor("test_id", 1, config_entry, "1 Tamper Sirena", coordinator, alarm_device) 206 | assert entity.unique_id == "test_id" 207 | 208 | def test_binary_sensor_icon(self, hass, config_entry, alarm_device): 209 | # Ensure the sensor has the right icon 210 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 211 | entity = InputBinarySensor("test_id", 1, config_entry, "1 Tamper Sirena", coordinator, alarm_device) 212 | assert entity.icon == "hass:electric-switch" 213 | 214 | def test_binary_sensor_off(self, hass, config_entry, alarm_device): 215 | # Ensure the sensor attribute is_on has the right status False 216 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 217 | entity = InputBinarySensor("test_id", 2, config_entry, "Outdoor Sensor 2", coordinator, alarm_device) 218 | assert entity.is_on is False 219 | 220 | def test_binary_sensor_on(self, hass, config_entry, alarm_device): 221 | # Ensure the sensor attribute is_on has the right status True 222 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 223 | entity = InputBinarySensor("test_id", 1, config_entry, "Outdoor Sensor 1", coordinator, alarm_device) 224 | assert entity.is_on is True 225 | 226 | 227 | class TestSectorBinarySensor: 228 | def test_binary_sensor_device_class(self, hass, config_entry, alarm_device): 229 | # Ensure sectors provide the device class name 230 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 231 | entity = SectorBinarySensor("test_id", 1, config_entry, "1 S1 Living Room", coordinator, alarm_device) 232 | assert entity.device_class == "sector" 233 | 234 | def test_binary_sensor_input_name(self, hass, config_entry, alarm_device): 235 | # Ensure the sensor has the right name 236 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 237 | entity = SectorBinarySensor("test_id", 1, config_entry, "1 S1 Living Room", coordinator, alarm_device) 238 | assert entity.name == "1 S1 Living Room" 239 | 240 | def test_binary_sensor_input_name_with_system_name(self, hass, config_entry, alarm_device): 241 | # The system name doesn't change the Entity name 242 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 243 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 244 | entity = SectorBinarySensor("test_id", 1, config_entry, "1 S1 Living Room", coordinator, alarm_device) 245 | assert entity.name == "1 S1 Living Room" 246 | 247 | def test_binary_sensor_input_entity_id(self, hass, config_entry, alarm_device): 248 | # Ensure the sensor has a valid Entity ID 249 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 250 | entity = SectorBinarySensor("test_id", 1, config_entry, "1 S1 Living Room", coordinator, alarm_device) 251 | assert entity.entity_id == "econnect_metronet.econnect_metronet_test_user_1_s1_living_room" 252 | 253 | def test_binary_sensor_input_entity_id_with_system_name(self, hass, config_entry, alarm_device): 254 | # Ensure the Entity ID takes into consideration the system name 255 | hass.config_entries.async_update_entry(config_entry, data={"system_name": "Home"}) 256 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 257 | entity = SectorBinarySensor("test_id", 1, config_entry, "1 S1 Living Room", coordinator, alarm_device) 258 | assert entity.entity_id == "econnect_metronet.econnect_metronet_home_1_s1_living_room" 259 | 260 | def test_binary_sensor_input_unique_id(self, hass, config_entry, alarm_device): 261 | # Ensure the sensor has the right unique ID 262 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 263 | entity = SectorBinarySensor("test_id", 1, config_entry, "1 S1 Living Room", coordinator, alarm_device) 264 | assert entity.unique_id == "test_id" 265 | 266 | def test_binary_sensor_icon(self, hass, config_entry, alarm_device): 267 | # Ensure the sensor has the right icon 268 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 269 | entity = SectorBinarySensor("test_id", 1, config_entry, "S2 Bedroom", coordinator, alarm_device) 270 | assert entity.icon == "hass:shield-home-outline" 271 | 272 | def test_binary_sensor_off(self, hass, config_entry, alarm_device): 273 | # Ensure the sensor attribute is_on has the right status False 274 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 275 | entity = SectorBinarySensor("test_id", 2, config_entry, "S3 Outdoor", coordinator, alarm_device) 276 | assert entity.is_on is False 277 | 278 | def test_binary_sensor_on(self, hass, config_entry, alarm_device): 279 | # Ensure the sensor attribute is_on has the right status True 280 | coordinator = DataUpdateCoordinator(hass, logging.getLogger(__name__), name="econnect_metronet") 281 | entity = SectorBinarySensor("test_id", 1, config_entry, "S2 Bedroom", coordinator, alarm_device) 282 | assert entity.is_on is True 283 | --------------------------------------------------------------------------------