Skip to content

Commit

Permalink
fix(entity): state transitions support users configurations (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
palazzem authored Sep 8, 2023
2 parents eb2b11a + 3abb5cd commit ace5d93
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 104 deletions.
2 changes: 1 addition & 1 deletion custom_components/econnect_alarm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
# and asks for the first update, hence why in `async_setup_entry` there is no need
# to call `coordinator.async_refresh()`.
client = ElmoClient(BASE_URL, entry.data[CONF_DOMAIN])
device = AlarmDevice(connection=client)
device = AlarmDevice(client, entry.options)
await hass.async_add_executor_job(device.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])

# Execute device update in a thread pool
Expand Down
43 changes: 18 additions & 25 deletions custom_components/econnect_alarm/alarm_control_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@
SUPPORT_ALARM_ARM_NIGHT,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_ARMING,
STATE_ALARM_DISARMED,
STATE_ALARM_DISARMING,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import (
CONF_AREAS_ARM_HOME,
CONF_AREAS_ARM_NIGHT,
DOMAIN,
KEY_COORDINATOR,
KEY_DEVICE,
)
from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE
from .decorators import set_device_state
from .helpers import parse_areas_config

_LOGGER = logging.getLogger(__name__)

Expand All @@ -33,18 +33,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_d
device = hass.data[DOMAIN][entry.entry_id][KEY_DEVICE]
coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR]
unique_id = entry.entry_id
# Optional arming areas
areas_home = parse_areas_config(entry.options.get(CONF_AREAS_ARM_HOME))
areas_night = parse_areas_config(entry.options.get(CONF_AREAS_ARM_NIGHT))
async_add_devices(
[
EconnectAlarm(
"Alarm Panel",
device,
coordinator,
unique_id,
areas_home=areas_home,
areas_night=areas_night,
)
]
)
Expand All @@ -53,14 +48,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_d
class EconnectAlarm(CoordinatorEntity, AlarmControlPanelEntity):
"""E-connect alarm entity."""

def __init__(self, name, device, coordinator, unique_id, areas_home=None, areas_night=None):
def __init__(self, name, device, coordinator, unique_id):
"""Construct."""
super().__init__(coordinator)
self._name = name
self._device = device
self._unique_id = unique_id
self._areas_home = areas_home
self._areas_night = areas_night

@property
def unique_id(self):
Expand Down Expand Up @@ -92,30 +85,30 @@ def supported_features(self):
"""Return the list of supported features."""
return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT

@set_device_state(STATE_ALARM_DISARMING)
@set_device_state(STATE_ALARM_DISARMED, STATE_ALARM_DISARMING)
async def async_alarm_disarm(self, code=None):
"""Send disarm command."""
await self.hass.async_add_executor_job(self._device.disarm, code)

@set_device_state(STATE_ALARM_ARMING)
@set_device_state(STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMING)
async def async_alarm_arm_away(self, code=None):
"""Send arm away command."""
await self.hass.async_add_executor_job(self._device.arm, code)

@set_device_state(STATE_ALARM_ARMING)
@set_device_state(STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING)
async def async_alarm_arm_home(self, code=None):
"""Send arm home command."""
if not self._areas_home:
if not self._device._sectors_home:
_LOGGER.warning("Triggering ARM HOME without configuration. Use integration Options to configure it.")
return

await self.hass.async_add_executor_job(self._device.arm, code, self._areas_home)
await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_home)

@set_device_state(STATE_ALARM_ARMING)
@set_device_state(STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMING)
async def async_alarm_arm_night(self, code=None):
"""Send arm night command."""
if not self._areas_night:
if not self._device._sectors_night:
_LOGGER.warning("Triggering ARM NIGHT without configuration. Use integration Options to configure it.")
return

await self.hass.async_add_executor_job(self._device.arm, code, self._areas_night)
await self.hass.async_add_executor_job(self._device.arm, code, self._device._sectors_night)
7 changes: 2 additions & 5 deletions custom_components/econnect_alarm/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
"""Module for e-connect binary sensors (sectors and inputs)."""
import logging

from elmo import query
from elmo.devices import AlarmDevice
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand All @@ -12,9 +9,9 @@
DataUpdateCoordinator,
)

from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE
from custom_components.econnect_alarm.devices import AlarmDevice

_LOGGER = logging.getLogger(__name__)
from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE


async def async_setup_entry(
Expand Down
5 changes: 3 additions & 2 deletions custom_components/econnect_alarm/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from .const import CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_DOMAIN, DOMAIN
from .exceptions import InvalidAreas
from .helpers import validate_areas, validate_credentials
from .helpers import parse_areas_config, validate_credentials

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -93,7 +93,8 @@ async def async_step_init(self, user_input=None):
errors = {}
if user_input is not None:
try:
await validate_areas(self.hass, user_input)
parse_areas_config(user_input.get(CONF_AREAS_ARM_HOME), raises=True)
parse_areas_config(user_input.get(CONF_AREAS_ARM_NIGHT), raises=True)
except InvalidAreas:
errors["base"] = "invalid_areas"
except Exception as err: # pylint: disable=broad-except
Expand Down
9 changes: 6 additions & 3 deletions custom_components/econnect_alarm/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
_LOGGER = logging.getLogger(__name__)


def set_device_state(new_state):
def set_device_state(new_state, loader_state):
"""Set a new Alarm device state, or revert to a previous state in case of error.
This decorator is used to convert a library exception in a log warning, while
Expand All @@ -20,10 +20,13 @@ def decorator(func):
async def func_wrapper(*args, **kwargs):
self = args[0]
previous_state = self._device.state
self._device.state = new_state
self._device.state = loader_state
self.async_write_ha_state()
try:
return await func(*args, **kwargs)
result = await func(*args, **kwargs)
self._device.state = new_state
self.async_write_ha_state()
return result
except LockError:
_LOGGER.warning(
"Impossible to obtain the lock. Be sure you inserted the code, or that nobody is using the panel."
Expand Down
45 changes: 42 additions & 3 deletions custom_components/econnect_alarm/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
from elmo.utils import _filter_data
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_DISARMED,
STATE_UNAVAILABLE,
)
from requests.exceptions import HTTPError

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

_LOGGER = logging.getLogger(__name__)


Expand All @@ -28,14 +33,21 @@ class AlarmDevice:
print(device.state)
"""

def __init__(self, connection):
def __init__(self, connection, config=None):
# Configuration and internals
self._connection = connection
self._sectors_home = []
self._sectors_night = []
self._lastIds = {
q.SECTORS: 0,
q.INPUTS: 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))

# Alarm state
self.state = STATE_UNAVAILABLE
self.sectors_armed = {}
Expand Down Expand Up @@ -78,6 +90,33 @@ def has_updates(self):
_LOGGER.error(f"Device | Error parsing the poll response: {err}")
raise err

def get_state(self):
"""Determine the alarm state based on the armed sectors.
This method evaluates the armed sectors and maps them to predefined
alarm states: home, night, or away. If no sectors are armed, it returns
a disarmed state. For accurate comparisons, the method sorts the sectors
internally, ensuring robustness against potentially unsorted input.
Returns:
str: One of the predefined HA alarm states.
"""
if not self.sectors_armed:
return STATE_ALARM_DISARMED

# Note: `element` is the sector ID you use to arm/disarm the sector.
sectors = [sectors["element"] for sectors in self.sectors_armed.values()]
# Sort lists here for robustness, ensuring accurate comparisons
# regardless of whether the input lists were pre-sorted or not.
sectors_armed_sorted = sorted(sectors)
if sectors_armed_sorted == sorted(self._sectors_home):
return STATE_ALARM_ARMED_HOME

if sectors_armed_sorted == sorted(self._sectors_night):
return STATE_ALARM_ARMED_NIGHT

return STATE_ALARM_ARMED_AWAY

def update(self):
"""Updates the internal state of the device based on the latest data.
Expand Down Expand Up @@ -116,8 +155,8 @@ def update(self):
self._lastIds[q.SECTORS] = sectors.get("last_id", 0)
self._lastIds[q.INPUTS] = inputs.get("last_id", 0)

# Update the internal state machine
self.state = STATE_ALARM_ARMED_AWAY if self.sectors_armed else STATE_ALARM_DISARMED
# Update the internal state machine (mapping state)
self.state = self.get_state()

def arm(self, code, sectors=None):
try:
Expand Down
71 changes: 29 additions & 42 deletions custom_components/econnect_alarm/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,46 @@
from homeassistant import core
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME

from .const import BASE_URL, CONF_AREAS_ARM_HOME, CONF_AREAS_ARM_NIGHT, CONF_DOMAIN
from .const import BASE_URL, CONF_DOMAIN
from .exceptions import InvalidAreas


def parse_areas_config(config, raises=False):
"""Parse area config that is represented as a comma separated value.
def parse_areas_config(config: str, raises: bool = False):
"""Parses a comma-separated string of area configurations into a list of integers.
Usage:
parse_areas_config("3,4") # Returns [3, 4]
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.
Args:
config: The string that is stored in the configuration registry.
raises: If set `True`, raises exceptions if they happen.
Raises:
ValueError: If given config is not a list of integers.
AttributeError: If given config is `None` object.
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`.
Returns:
A Python list with integers representing areas ID, such as `[3, 4]`,
or `None` if invalid.
list[int]: A list of integers representing area IDs. If parsing fails and `raises` is `False`,
returns an empty list.
Raises:
InvalidAreas: If there's a parsing error and the `raises` flag is set to `True`.
Examples:
>>> parse_areas_config("3,4")
[3, 4]
>>> parse_areas_config("3,a")
[]
"""
try:
return [int(x) for x in config.split(",")]
except (ValueError, AttributeError) as err:
except (ValueError, AttributeError):
if raises:
raise err
return None
raise InvalidAreas
return []


async def validate_credentials(hass: core.HomeAssistant, data):
async def validate_credentials(hass: core.HomeAssistant, config: dict):
"""Validate if user input includes valid credentials to connect.
Initialize the client with an API endpoint and a vendor and authenticate
Expand All @@ -49,30 +60,6 @@ async def validate_credentials(hass: core.HomeAssistant, data):
e-connect backend.
"""
# Check Credentials
client = ElmoClient(BASE_URL, domain=data.get(CONF_DOMAIN))
await hass.async_add_executor_job(client.auth, data[CONF_USERNAME], data[CONF_PASSWORD])
client = ElmoClient(BASE_URL, domain=config.get(CONF_DOMAIN))
await hass.async_add_executor_job(client.auth, config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
return True


async def validate_areas(hass: core.HomeAssistant, data):
"""Validate if user input is a valid list of areas.
Args:
hass: HomeAssistant instance.
data: data that needs validation (configured areas).
Raises:
InvalidAreas: if the given list of areas is not parsable in a
Python list.
Returns:
`True` if given `data` includes properly formatted areas.
"""

try:
# Check if areas are parsable
if data.get(CONF_AREAS_ARM_HOME):
parse_areas_config(data[CONF_AREAS_ARM_HOME], raises=True)
if data.get(CONF_AREAS_ARM_NIGHT):
parse_areas_config(data[CONF_AREAS_ARM_NIGHT], raises=True)
return True
except (ValueError, AttributeError):
raise InvalidAreas
Loading

0 comments on commit ace5d93

Please sign in to comment.