init
This commit is contained in:
578
custom_components/alarmo/__init__.py
Normal file
578
custom_components/alarmo/__init__.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user