Skip to content

Commit

Permalink
refactor: use multi select in setup options (#101)
Browse files Browse the repository at this point in the history
Co-authored-by: Emanuele Palazzetti <[email protected]>
  • Loading branch information
xtimmy86x and palazzem authored Nov 8, 2023
1 parent 4679107 commit becc4db
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 178 deletions.
14 changes: 14 additions & 0 deletions custom_components/econnect_metronet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ async def async_migrate_entry(hass, config: ConfigEntry):
config.version = 2
hass.config_entries.async_update_entry(config, data=migrated_config)

if config.version == 2:
# Config initialization
options = {**config.options}
options_to_migrate = ["areas_arm_home", "areas_arm_night", "areas_arm_vacation"]
migrated_options = {}
# Migration
config.version = 3
for key, value in options.items():
if key in options_to_migrate:
migrated_options[key] = [int(area) for area in value.split(",")]
else:
migrated_options[key] = value
hass.config_entries.async_update_entry(config, options=migrated_options)

_LOGGER.info(f"Migration to version {config.version} successful")
return True

Expand Down
41 changes: 16 additions & 25 deletions custom_components/econnect_metronet/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

import voluptuous as vol
from elmo import query as q
from elmo.api.client import ElmoClient
from elmo.api.exceptions import CredentialError
from elmo.systems import ELMO_E_CONNECT as E_CONNECT_DEFAULT
Expand All @@ -18,18 +19,18 @@
CONF_SYSTEM_NAME,
CONF_SYSTEM_URL,
DOMAIN,
KEY_DEVICE,
SUPPORTED_SYSTEMS,
)
from .exceptions import InvalidAreas
from .helpers import parse_areas_config
from .helpers import select

_LOGGER = logging.getLogger(__name__)


class EconnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore
"""Handle a config flow for E-connect Alarm."""

VERSION = 2
VERSION = 3
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

@staticmethod
Expand Down Expand Up @@ -116,42 +117,32 @@ async def async_step_init(self, user_input=None):
"""Manage the options."""
errors = {}
if user_input is not None:
try:
parse_areas_config(user_input.get(CONF_AREAS_ARM_HOME), raises=True)
parse_areas_config(user_input.get(CONF_AREAS_ARM_NIGHT), raises=True)
parse_areas_config(user_input.get(CONF_AREAS_ARM_VACATION), raises=True)
except InvalidAreas:
errors["base"] = "invalid_areas"
except Exception as err: # pylint: disable=broad-except
_LOGGER.error("Unexpected exception %s", err)
errors["base"] = "unknown"
else:
return self.async_create_entry(title="e-Connect/Metronet Alarm", data=user_input)
return self.async_create_entry(title="e-Connect/Metronet Alarm", data=user_input)

# Populate with latest changes or previous settings
user_input = user_input or {}
suggest_arm_home = user_input.get(CONF_AREAS_ARM_HOME) or self.config_entry.options.get(CONF_AREAS_ARM_HOME)
suggest_arm_night = user_input.get(CONF_AREAS_ARM_NIGHT) or self.config_entry.options.get(CONF_AREAS_ARM_NIGHT)
suggest_arm_vacation = user_input.get(CONF_AREAS_ARM_VACATION) or self.config_entry.options.get(
CONF_AREAS_ARM_VACATION
)
suggest_scan_interval = user_input.get(CONF_SCAN_INTERVAL) or self.config_entry.options.get(CONF_SCAN_INTERVAL)

# Generate sectors list for user config options
device = self.hass.data[DOMAIN][self.config_entry.entry_id][KEY_DEVICE]
sectors = [(item["element"], item["name"]) for _, item in device.items(q.SECTORS)]

return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_AREAS_ARM_HOME,
description={"suggested_value": suggest_arm_home},
): str,
default=self.config_entry.options.get(CONF_AREAS_ARM_HOME, []),
): select(sectors),
vol.Optional(
CONF_AREAS_ARM_NIGHT,
description={"suggested_value": suggest_arm_night},
): str,
default=self.config_entry.options.get(CONF_AREAS_ARM_NIGHT, []),
): select(sectors),
vol.Optional(
CONF_AREAS_ARM_VACATION,
description={"suggested_value": suggest_arm_vacation},
): str,
default=self.config_entry.options.get(CONF_AREAS_ARM_VACATION, []),
): select(sectors),
vol.Optional(
CONF_SCAN_INTERVAL,
description={"suggested_value": suggest_scan_interval},
Expand Down
14 changes: 5 additions & 9 deletions custom_components/econnect_metronet/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from requests.exceptions import HTTPError

from .const import CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_AREAS_ARM_VACATION
from .helpers import parse_areas_config

_LOGGER = logging.getLogger(__name__)

Expand All @@ -36,25 +35,22 @@ class AlarmDevice:

def __init__(self, connection, config=None):
# Configuration and internals
self._inventory = {}
self._connection = connection
self._sectors_home = []
self._sectors_night = []
self._sectors_vacation = []
self._last_ids = {
q.SECTORS: 0,
q.INPUTS: 0,
q.ALERTS: 0,
}

# Load user configuration
if config is not None:
self._sectors_home = parse_areas_config(config.get(CONF_AREAS_ARM_HOME))
self._sectors_night = parse_areas_config(config.get(CONF_AREAS_ARM_NIGHT))
self._sectors_vacation = parse_areas_config(config.get(CONF_AREAS_ARM_VACATION))
config = config or {}
self._sectors_home = config.get(CONF_AREAS_ARM_HOME) or []
self._sectors_night = config.get(CONF_AREAS_ARM_NIGHT) or []
self._sectors_vacation = config.get(CONF_AREAS_ARM_VACATION) or []

# Alarm state
self.state = STATE_UNAVAILABLE
self._inventory = {}

@property
def inputs(self):
Expand Down
5 changes: 0 additions & 5 deletions custom_components/econnect_metronet/exceptions.py

This file was deleted.

73 changes: 39 additions & 34 deletions custom_components/econnect_metronet/helpers.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,55 @@
from typing import Union
from typing import List, Tuple, Union

import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME
from homeassistant.helpers.config_validation import multi_select
from homeassistant.util import slugify

from .const import CONF_SYSTEM_NAME, DOMAIN
from .exceptions import InvalidAreas


def parse_areas_config(config: str, raises: bool = False):
"""Parses a comma-separated string of area configurations into a list of integers.
class select(multi_select):
"""Extension for the multi_select helper to handle selections of tuples.
Takes a string containing comma-separated area IDs and converts it to a list of integers.
In case of any parsing errors, either raises a custom `InvalidAreas` exception or returns an empty list
based on the `raises` flag.
This class extends a multi_select helper class to support tuple-based
selections, allowing for a more complex selection structure such as
pairing an identifier with a descriptive string.
Args:
config (str): A comma-separated string of area IDs, e.g., "3,4".
raises (bool, optional): Determines the error handling behavior. If `True`, the function
raises the `InvalidAreas` exception upon encountering a parsing error.
If `False`, it suppresses the error and returns an empty list.
Defaults to `False`.
Options are provided as a list of tuples with the following format:
[(1, 'S1 Living Room'), (2, 'S2 Bedroom'), (3, 'S3 Outdoor')]
Returns:
list[int]: A list of integers representing area IDs. If parsing fails and `raises` is `False`,
returns an empty list.
Attributes:
options (List[Tuple]): A list of tuple options for the select.
allowed_values (set): A set of valid values (identifiers) that can be selected.
"""

Raises:
InvalidAreas: If there's a parsing error and the `raises` flag is set to `True`.
def __init__(self, options: List[Tuple]) -> None:
self.options = options
self.allowed_values = {option[0] for option in options}

Examples:
>>> parse_areas_config("3,4")
[3, 4]
>>> parse_areas_config("3,a")
[]
"""
if config == "" or config is None:
# Empty config is considered valid (no sectors configured)
return []

try:
return [int(x) for x in config.split(",")]
except (ValueError, AttributeError):
if raises:
raise InvalidAreas
return []
def __call__(self, selected: list) -> list:
"""Validates the input list against the allowed values for selection.
Args:
selected: A list of values that have been selected.
Returns:
The same list if all selected values are valid.
Raises:
vol.Invalid: If the input is not a list or if any of the selected values
are not in the allowed values for selection.
"""
if not isinstance(selected, list):
raise vol.Invalid("Not a list")

for value in selected:
# Reject the value if it's not an option or its identifier
if value not in self.allowed_values and value not in self.options:
raise vol.Invalid(f"{value} is not a valid option")

return selected


def generate_entity_id(config: ConfigEntry, name: Union[str, None] = None) -> str:
Expand Down
10 changes: 5 additions & 5 deletions custom_components/econnect_metronet/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"system_name": "Name of the control panel (optional)"
},
"description": "Provide your credentials and the domain used to access your login page via web.\n\nFor instance, if you access to `https://connect.elmospa.com/vendor/`, you must set the domain to `vendor`. In case you don't have a vendor defined, set it to `default`.\n\nYou can configure the system selecting \"Options\" after installing the integration.",
"title": "Configure your Elmo/IESS system"
"title": "Configure your e-Connect/Metronet system"
}
}
},
Expand All @@ -30,13 +30,13 @@
"step": {
"init": {
"data": {
"areas_arm_home": "Armed areas while at home (e.g 3,4 - optional)",
"areas_arm_night": "Armed areas at night (e.g. 3,4 - optional)",
"areas_arm_vacation": "Armed areas when you are on vacation (e.g. 3,4 - optional)",
"areas_arm_home": "Armed areas while at home (optional)",
"areas_arm_night": "Armed areas at night (optional)",
"areas_arm_vacation": "Armed areas when you are on vacation (optional)",
"scan_interval": "Scan interval (e.g. 120 - optional)"
},
"description": "Define sectors you want to arm in different modes.\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)",
"title": "Configure your Elmo/IESS system"
"title": "Configure your e-Connect/Metronet system"
}
}
},
Expand Down
12 changes: 6 additions & 6 deletions custom_components/econnect_metronet/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"system_name": "Nome dell'impianto (opzionale)"
},
"description": "Fornisci le tue credenziali e il dominio utilizzato per accedere alla tua pagina di login via web.\n\nAd esempio, se accedi a `https://connect.elmospa.com/installatore/`, devi impostare il dominio su `installatore`. Nel caso in cui non hai un installatore definito, impostalo su `default`.\n\nPuoi configurare il sistema selezionando \"Opzioni\" dopo aver installato l'integrazione.",
"title": "Configura il tuo sistema Elmo/IESS"
"title": "Configura il tuo sistema e-Connect/Metronet"
}
}
},
Expand All @@ -30,13 +30,13 @@
"step": {
"init": {
"data": {
"areas_arm_home": "Settori armati mentre sei a casa (es. 3,4 - opzionale)",
"areas_arm_night": "Settori armati di notte (es. 3,4 - opzionale)",
"areas_arm_vacation": "Settori armati quando sei in vacanza (es. 3,4 - opzionale)",
"areas_arm_home": "Settori armati mentre sei a casa (opzionale)",
"areas_arm_night": "Settori armati di notte (opzionale)",
"areas_arm_vacation": "Settori armati quando sei in vacanza (opzionale)",
"scan_interval": "Intervallo di scansione (es. 120 - opzionale)"
},
"description": "Definisci i settori che desideri armare in diverse modalità.\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)",
"title": "Configura il tuo sistema Elmo/IESS"
"description": "Scegli, tra quelli proposti, i settori che desideri armare nelle diverse modalità.\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)",
"title": "Configura il tuo sistema e-Connect/Metronet"
}
}
},
Expand Down
24 changes: 21 additions & 3 deletions tests/test_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ def test_device_constructor(client):
def test_device_constructor_with_config(client):
"""Should initialize defaults attributes to run properly."""
config = {
CONF_AREAS_ARM_HOME: "3, 4",
CONF_AREAS_ARM_NIGHT: "1, 2, 3",
CONF_AREAS_ARM_VACATION: "5, 3",
CONF_AREAS_ARM_HOME: [3, 4],
CONF_AREAS_ARM_NIGHT: [1, 2, 3],
CONF_AREAS_ARM_VACATION: [5, 3],
}
device = AlarmDevice(client, config=config)
# Test
Expand All @@ -51,6 +51,24 @@ def test_device_constructor_with_config(client):
assert device.state == STATE_UNAVAILABLE


def test_device_constructor_with_config_empty(client):
"""Should initialize defaults attributes to run properly."""
config = {
CONF_AREAS_ARM_HOME: None,
CONF_AREAS_ARM_NIGHT: None,
CONF_AREAS_ARM_VACATION: None,
}
device = AlarmDevice(client, config=config)
# Test
assert device._connection == client
assert device._inventory == {}
assert device._last_ids == {10: 0, 9: 0, 11: 0}
assert device._sectors_home == []
assert device._sectors_night == []
assert device._sectors_vacation == []
assert device.state == STATE_UNAVAILABLE


class TestItemInputs:
def test_without_status(self, alarm_device):
"""Verify that querying items without specifying a status works correctly"""
Expand Down
36 changes: 1 addition & 35 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,6 @@
import pytest
from homeassistant.core import valid_entity_id

from custom_components.econnect_metronet.exceptions import InvalidAreas
from custom_components.econnect_metronet.helpers import (
generate_entity_id,
parse_areas_config,
)


def test_parse_areas_config_valid_input():
assert parse_areas_config("3,4") == [3, 4]
assert parse_areas_config("1,2,3,4,5") == [1, 2, 3, 4, 5]
assert parse_areas_config("10") == [10]
assert parse_areas_config("") == []


def test_parse_areas_config_valid_empty_input():
assert parse_areas_config("", raises=True) == []
assert parse_areas_config(None, raises=True) == []


def test_parse_areas_config_invalid_input():
assert parse_areas_config("3,a") == []
assert parse_areas_config("3.4") == []
assert parse_areas_config("3,") == []


def test_parse_areas_config_raises_value_error():
with pytest.raises(InvalidAreas):
parse_areas_config("3,a", raises=True)
with pytest.raises(InvalidAreas):
parse_areas_config("3.4", raises=True)


def test_parse_areas_config_whitespace():
assert parse_areas_config(" 3 , 4 ") == [3, 4]
from custom_components.econnect_metronet.helpers import generate_entity_id


def test_generate_entity_name_empty(config_entry):
Expand Down
Loading

0 comments on commit becc4db

Please sign in to comment.