579 lines
21 KiB
Python
579 lines
21 KiB
Python
"""The Alarmo Integration."""
|
|
|
|
import re
|
|
import base64
|
|
import logging
|
|
import concurrent.futures
|
|
|
|
import bcrypt
|
|
from homeassistant.core import (
|
|
HomeAssistant,
|
|
asyncio,
|
|
callback,
|
|
)
|
|
from homeassistant.const import (
|
|
ATTR_CODE,
|
|
ATTR_NAME,
|
|
)
|
|
from homeassistant.helpers import device_registry as dr
|
|
from homeassistant.helpers import entity_registry as er
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.helpers.service import (
|
|
async_register_admin_service,
|
|
)
|
|
from homeassistant.helpers.dispatcher import (
|
|
async_dispatcher_send,
|
|
async_dispatcher_connect,
|
|
)
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
|
from homeassistant.components.alarm_control_panel import DOMAIN as PLATFORM
|
|
|
|
from . import const
|
|
from .card import async_register_card
|
|
from .mqtt import MqttHandler
|
|
from .event import EventHandler
|
|
from .panel import (
|
|
async_register_panel,
|
|
async_unregister_panel,
|
|
)
|
|
from .store import async_get_registry
|
|
from .sensors import (
|
|
ATTR_GROUP,
|
|
ATTR_ENTITIES,
|
|
ATTR_NEW_ENTITY_ID,
|
|
SensorHandler,
|
|
)
|
|
from .websockets import async_register_websockets
|
|
from .automations import AutomationHandler
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Max number of threads to start when checking user codes.
|
|
MAX_WORKERS = 4
|
|
# Number of rounds of hashing when computing user hashes.
|
|
BCRYPT_NUM_ROUNDS = 10
|
|
|
|
|
|
async def async_setup(hass, config):
|
|
"""Track states and offer events for sensors."""
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
|
"""Set up Alarmo integration from a config entry."""
|
|
session = async_get_clientsession(hass)
|
|
|
|
store = await async_get_registry(hass)
|
|
coordinator = AlarmoCoordinator(hass, session, entry, store)
|
|
|
|
device_registry = dr.async_get(hass)
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(const.DOMAIN, coordinator.id)},
|
|
name=const.NAME,
|
|
model=const.NAME,
|
|
sw_version=const.VERSION,
|
|
manufacturer=const.MANUFACTURER,
|
|
)
|
|
|
|
hass.data.setdefault(const.DOMAIN, {})
|
|
hass.data[const.DOMAIN] = {"coordinator": coordinator, "areas": {}, "master": None}
|
|
|
|
if entry.unique_id is None:
|
|
hass.config_entries.async_update_entry(entry, unique_id=coordinator.id, data={})
|
|
|
|
await hass.config_entries.async_forward_entry_setups(entry, [PLATFORM])
|
|
|
|
# Register the panel (frontend)
|
|
await async_register_panel(hass)
|
|
await async_register_card(hass)
|
|
|
|
# Websocket support
|
|
await async_register_websockets(hass)
|
|
|
|
# Register custom services
|
|
register_services(hass)
|
|
|
|
return True
|
|
|
|
|
|
async def async_unload_entry(hass, entry):
|
|
"""Unload Alarmo config entry."""
|
|
unload_ok = all(
|
|
await asyncio.gather(
|
|
*[hass.config_entries.async_forward_entry_unload(entry, PLATFORM)]
|
|
)
|
|
)
|
|
if not unload_ok:
|
|
return False
|
|
|
|
async_unregister_panel(hass)
|
|
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
|
await coordinator.async_unload()
|
|
return True
|
|
|
|
|
|
async def async_remove_entry(hass, entry):
|
|
"""Remove Alarmo config entry."""
|
|
async_unregister_panel(hass)
|
|
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
|
await coordinator.async_delete_config()
|
|
del hass.data[const.DOMAIN]
|
|
|
|
|
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Handle migration of config entry."""
|
|
return True
|
|
|
|
|
|
class AlarmoCoordinator(DataUpdateCoordinator):
|
|
"""Define an object to hold Alarmo device."""
|
|
|
|
def __init__(self, hass, session, entry, store):
|
|
"""Initialize."""
|
|
self.id = entry.unique_id
|
|
self.hass = hass
|
|
self.entry = entry
|
|
self.store = store
|
|
self._subscriptions = []
|
|
|
|
self._subscriptions.append(
|
|
async_dispatcher_connect(
|
|
hass, "alarmo_platform_loaded", self.setup_alarm_entities
|
|
)
|
|
)
|
|
self.register_events()
|
|
|
|
super().__init__(hass, _LOGGER, config_entry=entry, name=const.DOMAIN)
|
|
|
|
@callback
|
|
def setup_alarm_entities(self):
|
|
"""Set up alarm_control_panel entities based on areas in storage."""
|
|
self.hass.data[const.DOMAIN]["sensor_handler"] = SensorHandler(self.hass)
|
|
self.hass.data[const.DOMAIN]["automation_handler"] = AutomationHandler(
|
|
self.hass
|
|
)
|
|
self.hass.data[const.DOMAIN]["mqtt_handler"] = MqttHandler(self.hass)
|
|
self.hass.data[const.DOMAIN]["event_handler"] = EventHandler(self.hass)
|
|
|
|
areas = self.store.async_get_areas()
|
|
config = self.store.async_get_config()
|
|
|
|
for item in areas.values():
|
|
async_dispatcher_send(self.hass, "alarmo_register_entity", item)
|
|
|
|
if len(areas) > 1 and config["master"]["enabled"]:
|
|
async_dispatcher_send(self.hass, "alarmo_register_master", config["master"])
|
|
|
|
async def async_update_config(self, data):
|
|
"""Update the main configuration."""
|
|
if "master" in data:
|
|
old_config = self.store.async_get_config()
|
|
if old_config[const.ATTR_MASTER] != data["master"]:
|
|
if self.hass.data[const.DOMAIN]["master"]:
|
|
await self.async_remove_entity("master")
|
|
if data["master"]["enabled"]:
|
|
async_dispatcher_send(
|
|
self.hass, "alarmo_register_master", data["master"]
|
|
)
|
|
else:
|
|
automations = self.hass.data[const.DOMAIN][
|
|
"automation_handler"
|
|
].get_automations_by_area(None)
|
|
if len(automations):
|
|
for el in automations:
|
|
self.store.async_delete_automation(el)
|
|
async_dispatcher_send(self.hass, "alarmo_automations_updated")
|
|
|
|
self.store.async_update_config(data)
|
|
async_dispatcher_send(self.hass, "alarmo_config_updated")
|
|
|
|
async def async_update_area_config(
|
|
self,
|
|
area_id: str | None = None,
|
|
data: dict = {},
|
|
):
|
|
"""Update area configuration."""
|
|
if const.ATTR_REMOVE in data:
|
|
# delete an area
|
|
res = self.store.async_get_area(area_id)
|
|
if not res:
|
|
return
|
|
sensors = self.store.async_get_sensors()
|
|
sensors = dict(filter(lambda el: el[1]["area"] == area_id, sensors.items()))
|
|
if sensors:
|
|
for el in sensors.keys():
|
|
self.store.async_delete_sensor(el)
|
|
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
|
|
|
automations = self.hass.data[const.DOMAIN][
|
|
"automation_handler"
|
|
].get_automations_by_area(area_id)
|
|
if len(automations):
|
|
for el in automations:
|
|
self.store.async_delete_automation(el)
|
|
async_dispatcher_send(self.hass, "alarmo_automations_updated")
|
|
|
|
self.store.async_delete_area(area_id)
|
|
await self.async_remove_entity(area_id)
|
|
|
|
if (
|
|
len(self.store.async_get_areas()) == 1
|
|
and self.hass.data[const.DOMAIN]["master"]
|
|
):
|
|
await self.async_remove_entity("master")
|
|
|
|
elif self.store.async_get_area(area_id):
|
|
# modify an area
|
|
entry = self.store.async_update_area(area_id, data)
|
|
if "name" not in data:
|
|
async_dispatcher_send(self.hass, "alarmo_config_updated", area_id)
|
|
else:
|
|
await self.async_remove_entity(area_id)
|
|
async_dispatcher_send(self.hass, "alarmo_register_entity", entry)
|
|
else:
|
|
# create an area
|
|
entry = self.store.async_create_area(data)
|
|
async_dispatcher_send(self.hass, "alarmo_register_entity", entry)
|
|
|
|
config = self.store.async_get_config()
|
|
|
|
if len(self.store.async_get_areas()) == 2 and config["master"]["enabled"]:
|
|
async_dispatcher_send(
|
|
self.hass, "alarmo_register_master", config["master"]
|
|
)
|
|
|
|
def async_update_sensor_config(self, entity_id: str, data: dict):
|
|
"""Update sensor configuration."""
|
|
group = None
|
|
if ATTR_GROUP in data:
|
|
group = data[ATTR_GROUP]
|
|
del data[ATTR_GROUP]
|
|
|
|
if ATTR_NEW_ENTITY_ID in data:
|
|
# delete old sensor entry when changing the entity_id
|
|
new_entity_id = data[ATTR_NEW_ENTITY_ID]
|
|
del data[ATTR_NEW_ENTITY_ID]
|
|
self.store.async_delete_sensor(entity_id)
|
|
self.assign_sensor_to_group(new_entity_id, group)
|
|
self.assign_sensor_to_group(entity_id, None)
|
|
entity_id = new_entity_id
|
|
|
|
if const.ATTR_REMOVE in data:
|
|
self.store.async_delete_sensor(entity_id)
|
|
self.assign_sensor_to_group(entity_id, None)
|
|
elif self.store.async_get_sensor(entity_id):
|
|
self.store.async_update_sensor(entity_id, data)
|
|
self.assign_sensor_to_group(entity_id, group)
|
|
else:
|
|
self.store.async_create_sensor(entity_id, data)
|
|
self.assign_sensor_to_group(entity_id, group)
|
|
|
|
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
|
|
|
def _validate_user_code(self, user_id: str, data: dict):
|
|
user_with_code = self.async_authenticate_user(data[ATTR_CODE])
|
|
if user_id:
|
|
if const.ATTR_OLD_CODE not in data:
|
|
return "No code provided"
|
|
if not self.async_authenticate_user(data[const.ATTR_OLD_CODE], user_id):
|
|
return "Wrong code provided"
|
|
if user_with_code and user_with_code[const.ATTR_USER_ID] != user_id:
|
|
return "User with same code already exists"
|
|
elif user_with_code:
|
|
return "User with same code already exists"
|
|
return
|
|
|
|
def _validate_user_name(self, user_id: str, data: dict):
|
|
if not data[ATTR_NAME]:
|
|
return "User name must not be empty"
|
|
for user in self.store.async_get_users().values():
|
|
if (
|
|
data[ATTR_NAME] == user[ATTR_NAME]
|
|
and user_id != user[const.ATTR_USER_ID]
|
|
):
|
|
return "User with same name already exists"
|
|
return
|
|
|
|
def async_update_user_config(self, user_id: str | None = None, data: dict = {}):
|
|
"""Update user configuration."""
|
|
if const.ATTR_REMOVE in data:
|
|
self.store.async_delete_user(user_id)
|
|
return
|
|
|
|
if ATTR_NAME in data:
|
|
err = self._validate_user_name(user_id, data)
|
|
if err:
|
|
_LOGGER.error(err)
|
|
return err
|
|
if ATTR_CODE in data:
|
|
err = self._validate_user_code(user_id, data)
|
|
if err:
|
|
_LOGGER.error(err)
|
|
return err
|
|
|
|
if data.get(ATTR_CODE):
|
|
data[const.ATTR_CODE_FORMAT] = (
|
|
"number" if data[ATTR_CODE].isdigit() else "text"
|
|
)
|
|
data[const.ATTR_CODE_LENGTH] = len(data[ATTR_CODE])
|
|
hashed = bcrypt.hashpw(
|
|
data[ATTR_CODE].encode("utf-8"),
|
|
bcrypt.gensalt(rounds=BCRYPT_NUM_ROUNDS),
|
|
)
|
|
hashed = base64.b64encode(hashed)
|
|
data[ATTR_CODE] = hashed.decode()
|
|
|
|
if not user_id:
|
|
self.store.async_create_user(data)
|
|
return
|
|
else:
|
|
if ATTR_CODE in data:
|
|
del data[const.ATTR_OLD_CODE]
|
|
self.store.async_update_user(user_id, data)
|
|
return
|
|
|
|
def async_authenticate_user(self, code: str, user_id: str | None = None):
|
|
"""Authenticate a user by code."""
|
|
|
|
def check_user_code(user, code):
|
|
"""Returns the supplied user object if the code matches, None otherwise."""
|
|
if not user[const.ATTR_ENABLED]:
|
|
return
|
|
elif not user[ATTR_CODE] and not code:
|
|
return user
|
|
elif user[ATTR_CODE]:
|
|
hash = base64.b64decode(user[ATTR_CODE])
|
|
if bcrypt.checkpw(code.encode("utf-8"), hash):
|
|
return user
|
|
|
|
if user_id:
|
|
return check_user_code(self.store.async_get_user(user_id), code)
|
|
|
|
users = self.store.async_get_users()
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
|
futures = [
|
|
executor.submit(check_user_code, user, code) for user in users.values()
|
|
]
|
|
for future in concurrent.futures.as_completed(futures):
|
|
if future.result():
|
|
executor.shutdown(wait=False, cancel_futures=True)
|
|
return future.result()
|
|
|
|
def async_update_automation_config(
|
|
self,
|
|
automation_id: str | None = None,
|
|
data: dict = {},
|
|
):
|
|
"""Update automation configuration."""
|
|
if const.ATTR_REMOVE in data:
|
|
self.store.async_delete_automation(automation_id)
|
|
elif not automation_id:
|
|
self.store.async_create_automation(data)
|
|
else:
|
|
self.store.async_update_automation(automation_id, data)
|
|
|
|
async_dispatcher_send(self.hass, "alarmo_automations_updated")
|
|
|
|
def register_events(self):
|
|
"""Register event handlers."""
|
|
|
|
# handle push notifications with action buttons
|
|
@callback
|
|
async def async_handle_push_event(event):
|
|
if not event.data:
|
|
return
|
|
action = (
|
|
event.data.get("actionName")
|
|
if "actionName" in event.data
|
|
else event.data.get("action")
|
|
)
|
|
|
|
if action not in const.EVENT_ACTIONS:
|
|
return
|
|
|
|
if self.hass.data[const.DOMAIN]["master"]:
|
|
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
|
elif len(self.hass.data[const.DOMAIN]["areas"]) == 1:
|
|
alarm_entity = next(
|
|
iter(self.hass.data[const.DOMAIN]["areas"].values())
|
|
)
|
|
else:
|
|
_LOGGER.info(
|
|
"Cannot process the push action, since there are multiple areas."
|
|
)
|
|
return
|
|
|
|
arm_mode = (
|
|
alarm_entity._revert_state
|
|
if alarm_entity._revert_state in const.ARM_MODES
|
|
else alarm_entity._arm_mode
|
|
)
|
|
res = re.search(r"^ALARMO_ARM_", action)
|
|
if res:
|
|
arm_mode = action.replace("ALARMO_", "").lower().replace("arm", "armed")
|
|
if not arm_mode:
|
|
_LOGGER.info(
|
|
"Cannot process the push action, since the arm mode is not known."
|
|
)
|
|
return
|
|
|
|
if action == const.EVENT_ACTION_FORCE_ARM:
|
|
_LOGGER.info("Received request for force arming")
|
|
alarm_entity.async_handle_arm_request(
|
|
arm_mode, skip_code=True, bypass_open_sensors=True
|
|
)
|
|
elif action == const.EVENT_ACTION_RETRY_ARM:
|
|
_LOGGER.info("Received request for retry arming")
|
|
alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
|
|
elif action == const.EVENT_ACTION_DISARM:
|
|
_LOGGER.info("Received request for disarming")
|
|
alarm_entity.alarm_disarm(None, skip_code=True)
|
|
else:
|
|
_LOGGER.info(
|
|
"Received request for arming with mode %s",
|
|
arm_mode,
|
|
)
|
|
alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
|
|
|
|
self._subscriptions.append(
|
|
self.hass.bus.async_listen(const.PUSH_EVENT, async_handle_push_event)
|
|
)
|
|
|
|
async def async_remove_entity(self, area_id: str):
|
|
"""Remove an alarm_control_panel entity."""
|
|
entity_registry = er.async_get(self.hass)
|
|
if area_id == "master":
|
|
entity = self.hass.data[const.DOMAIN]["master"]
|
|
entity_registry.async_remove(entity.entity_id)
|
|
self.hass.data[const.DOMAIN]["master"] = None
|
|
else:
|
|
entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
|
entity_registry.async_remove(entity.entity_id)
|
|
self.hass.data[const.DOMAIN]["areas"].pop(area_id, None)
|
|
|
|
def async_get_sensor_groups(self):
|
|
"""Fetch a list of sensor groups (websocket API hook)."""
|
|
groups = self.store.async_get_sensor_groups()
|
|
return list(groups.values())
|
|
|
|
def async_get_group_for_sensor(self, entity_id: str):
|
|
"""Fetch the group ID for a given sensor."""
|
|
groups = self.async_get_sensor_groups()
|
|
result = next((el for el in groups if entity_id in el[ATTR_ENTITIES]), None)
|
|
return result["group_id"] if result else None
|
|
|
|
def assign_sensor_to_group(self, entity_id: str, group_id: str):
|
|
"""Assign a sensor to a group."""
|
|
updated = False
|
|
old_group = self.async_get_group_for_sensor(entity_id)
|
|
if old_group and group_id != old_group:
|
|
# remove sensor from group
|
|
el = self.store.async_get_sensor_group(old_group)
|
|
if len(el[ATTR_ENTITIES]) > 2:
|
|
self.store.async_update_sensor_group(
|
|
old_group,
|
|
{ATTR_ENTITIES: [x for x in el[ATTR_ENTITIES] if x != entity_id]},
|
|
)
|
|
else:
|
|
self.store.async_delete_sensor_group(old_group)
|
|
updated = True
|
|
if group_id:
|
|
# add sensor to group
|
|
group = self.store.async_get_sensor_group(group_id)
|
|
if not group:
|
|
_LOGGER.error(
|
|
"Failed to assign entity %s to group %s",
|
|
entity_id,
|
|
group_id,
|
|
)
|
|
elif entity_id not in group[ATTR_ENTITIES]:
|
|
self.store.async_update_sensor_group(
|
|
group_id, {ATTR_ENTITIES: group[ATTR_ENTITIES] + [entity_id]}
|
|
)
|
|
updated = True
|
|
if updated:
|
|
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
|
|
|
def async_update_sensor_group_config(
|
|
self,
|
|
group_id: str | None = None,
|
|
data: dict = {},
|
|
):
|
|
"""Update sensor group configuration."""
|
|
if const.ATTR_REMOVE in data:
|
|
self.store.async_delete_sensor_group(group_id)
|
|
elif not group_id:
|
|
self.store.async_create_sensor_group(data)
|
|
else:
|
|
self.store.async_update_sensor_group(group_id, data)
|
|
|
|
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
|
|
|
async def async_unload(self):
|
|
"""Remove all alarmo objects."""
|
|
# remove alarm_control_panel entities
|
|
areas = list(self.hass.data[const.DOMAIN]["areas"].keys())
|
|
for area in areas:
|
|
await self.async_remove_entity(area)
|
|
if self.hass.data[const.DOMAIN]["master"]:
|
|
await self.async_remove_entity("master")
|
|
|
|
del self.hass.data[const.DOMAIN]["sensor_handler"]
|
|
del self.hass.data[const.DOMAIN]["automation_handler"]
|
|
del self.hass.data[const.DOMAIN]["mqtt_handler"]
|
|
del self.hass.data[const.DOMAIN]["event_handler"]
|
|
|
|
# remove subscriptions for coordinator
|
|
while len(self._subscriptions):
|
|
self._subscriptions.pop()()
|
|
|
|
async def async_delete_config(self):
|
|
"""Wipe alarmo storage."""
|
|
await self.store.async_delete()
|
|
|
|
|
|
@callback
|
|
def register_services(hass):
|
|
"""Register services used by alarmo component."""
|
|
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
|
|
|
async def async_srv_toggle_user(call):
|
|
"""Enable a user by service call."""
|
|
name = call.data.get(ATTR_NAME)
|
|
enable = True if call.service == const.SERVICE_ENABLE_USER else False
|
|
users = coordinator.store.async_get_users()
|
|
user = next(
|
|
(item for item in list(users.values()) if item[ATTR_NAME] == name), None
|
|
)
|
|
if user is None:
|
|
_LOGGER.warning(
|
|
"Failed to %s user, no match for name '%s'",
|
|
"enable" if enable else "disable",
|
|
name,
|
|
)
|
|
return
|
|
|
|
coordinator.store.async_update_user(
|
|
user[const.ATTR_USER_ID], {const.ATTR_ENABLED: enable}
|
|
)
|
|
_LOGGER.debug(
|
|
"User user '%s' was %s", name, "enabled" if enable else "disabled"
|
|
)
|
|
|
|
async_register_admin_service(
|
|
hass,
|
|
const.DOMAIN,
|
|
const.SERVICE_ENABLE_USER,
|
|
async_srv_toggle_user,
|
|
schema=const.SERVICE_TOGGLE_USER_SCHEMA,
|
|
)
|
|
async_register_admin_service(
|
|
hass,
|
|
const.DOMAIN,
|
|
const.SERVICE_DISABLE_USER,
|
|
async_srv_toggle_user,
|
|
schema=const.SERVICE_TOGGLE_USER_SCHEMA,
|
|
)
|