Updated apps
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
"""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")
|
||||
|
||||
async def _validate_user_code(self, user_id: str, data: dict):
|
||||
user_with_code = await self.async_authenticate_user(data[ATTR_CODE])
|
||||
if user_id:
|
||||
if const.ATTR_OLD_CODE not in data:
|
||||
return "No code provided"
|
||||
if not await 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
|
||||
|
||||
async 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 = await 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])
|
||||
|
||||
def _hash_code(code):
|
||||
hashed = bcrypt.hashpw(
|
||||
code.encode("utf-8"),
|
||||
bcrypt.gensalt(rounds=BCRYPT_NUM_ROUNDS),
|
||||
)
|
||||
return base64.b64encode(hashed).decode()
|
||||
|
||||
data[ATTR_CODE] = await self.hass.async_add_executor_job(
|
||||
_hash_code, data[ATTR_CODE]
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
async def async_authenticate_user(self, code, user_id=None):
|
||||
"""Run user authentication in the executor.
|
||||
|
||||
This wrapper ensures that the blocking bcrypt-based authentication
|
||||
is executed outside of the event loop, making it safe to call
|
||||
from async context.
|
||||
"""
|
||||
|
||||
def _authenticate_user_sync():
|
||||
"""Perform user authentication using blocking bcrypt operations."""
|
||||
|
||||
def check_user_code(user, code):
|
||||
"""Returns None or the supplied user object if the code matches."""
|
||||
if not user[const.ATTR_ENABLED]:
|
||||
return None
|
||||
elif not user[ATTR_CODE] and not code:
|
||||
return user
|
||||
elif user[ATTR_CODE]:
|
||||
try:
|
||||
hash = base64.b64decode(user[ATTR_CODE])
|
||||
except Exception:
|
||||
return None
|
||||
if bcrypt.checkpw((code or "").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()
|
||||
|
||||
return await self.hass.async_add_executor_job(_authenticate_user_sync)
|
||||
|
||||
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")
|
||||
await 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")
|
||||
await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
|
||||
elif action == const.EVENT_ACTION_DISARM:
|
||||
_LOGGER.info("Received request for disarming")
|
||||
await alarm_entity.async_alarm_disarm(None, skip_code=True)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Received request for arming with mode %s",
|
||||
arm_mode,
|
||||
)
|
||||
await 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,
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
"""Automations."""
|
||||
|
||||
import re
|
||||
import copy
|
||||
import logging
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_TYPE,
|
||||
ATTR_SERVICE,
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_SERVICE_DATA,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.template import Template, is_template_string
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from homeassistant.components.binary_sensor.device_condition import (
|
||||
ENTITY_CONDITIONS,
|
||||
)
|
||||
|
||||
from . import const
|
||||
from .helpers import (
|
||||
friendly_name_for_entity_id,
|
||||
)
|
||||
from .sensors import (
|
||||
STATE_OPEN,
|
||||
STATE_CLOSED,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from .alarm_control_panel import AlarmoBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EVENT_ARM_FAILURE = "arm_failure"
|
||||
|
||||
|
||||
def validate_area(trigger, area_id, hass):
|
||||
"""Validate area for trigger."""
|
||||
if const.ATTR_AREA not in trigger:
|
||||
return False
|
||||
elif trigger[const.ATTR_AREA]:
|
||||
return trigger[const.ATTR_AREA] == area_id
|
||||
elif len(hass.data[const.DOMAIN]["areas"]) == 1:
|
||||
return True
|
||||
else:
|
||||
return area_id is None
|
||||
|
||||
|
||||
def validate_modes(trigger, mode):
|
||||
"""Validate modes for trigger."""
|
||||
if const.ATTR_MODES not in trigger:
|
||||
return False
|
||||
elif not trigger[const.ATTR_MODES]:
|
||||
return True
|
||||
else:
|
||||
return mode in trigger[const.ATTR_MODES]
|
||||
|
||||
|
||||
def validate_trigger(trigger, to_state, from_state=None):
|
||||
"""Validate trigger condition."""
|
||||
if const.ATTR_EVENT not in trigger:
|
||||
return False
|
||||
elif trigger[const.ATTR_EVENT] == "untriggered" and from_state == "triggered":
|
||||
return True
|
||||
elif trigger[const.ATTR_EVENT] == to_state:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class AutomationHandler:
|
||||
"""Handle automations."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
"""Initialize automation handler."""
|
||||
self.hass = hass
|
||||
self._config = None
|
||||
self._subscriptions = []
|
||||
self._sensorTranslationCache = {}
|
||||
self._alarmTranslationCache = {}
|
||||
self._sensorTranslationLang = None
|
||||
self._alarmTranslationLang = None
|
||||
|
||||
def async_update_config():
|
||||
"""Automation config updated, reload the configuration."""
|
||||
self._config = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_automations()
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
hass, "alarmo_automations_updated", async_update_config
|
||||
)
|
||||
)
|
||||
async_update_config()
|
||||
|
||||
@callback
|
||||
async def async_alarm_state_changed(
|
||||
area_id: str, old_state: str, new_state: str
|
||||
):
|
||||
if not old_state:
|
||||
# ignore automations at startup/restoring
|
||||
return
|
||||
|
||||
if area_id:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
else:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
||||
|
||||
if not alarm_entity:
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"state of %s is updated from %s to %s",
|
||||
alarm_entity.entity_id,
|
||||
old_state,
|
||||
new_state,
|
||||
)
|
||||
|
||||
if new_state in const.ARM_MODES:
|
||||
# we don't distinguish between armed modes for automations
|
||||
# they are handled separately
|
||||
new_state = "armed"
|
||||
|
||||
for automation_id, config in self._config.items():
|
||||
if not config[const.ATTR_ENABLED]:
|
||||
continue
|
||||
for trigger in config[const.ATTR_TRIGGERS]:
|
||||
if (
|
||||
validate_area(trigger, area_id, self.hass)
|
||||
and validate_modes(trigger, alarm_entity._arm_mode)
|
||||
and validate_trigger(trigger, new_state, old_state)
|
||||
):
|
||||
await self.async_execute_automation(automation_id, alarm_entity)
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
self.hass, "alarmo_state_updated", async_alarm_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
async def async_handle_event(event: str, area_id: str, args: dict = {}):
|
||||
if event != const.EVENT_FAILED_TO_ARM:
|
||||
return
|
||||
if area_id:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
else:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s has failed to arm",
|
||||
alarm_entity.entity_id,
|
||||
)
|
||||
|
||||
for automation_id, config in self._config.items():
|
||||
if not config[const.ATTR_ENABLED]:
|
||||
continue
|
||||
for trigger in config[const.ATTR_TRIGGERS]:
|
||||
if (
|
||||
validate_area(trigger, area_id, self.hass)
|
||||
and validate_modes(trigger, alarm_entity._arm_mode)
|
||||
and validate_trigger(trigger, EVENT_ARM_FAILURE)
|
||||
):
|
||||
await self.async_execute_automation(automation_id, alarm_entity)
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(self.hass, "alarmo_event", async_handle_event)
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
"""Prepare for removal."""
|
||||
while len(self._subscriptions):
|
||||
self._subscriptions.pop()()
|
||||
|
||||
async def async_execute_automation(
|
||||
self, automation_id: str, alarm_entity: AlarmoBaseEntity
|
||||
):
|
||||
"""Execute the specified automation."""
|
||||
# automation is a dict of AutomationEntry
|
||||
_LOGGER.debug(
|
||||
"Executing automation %s",
|
||||
automation_id,
|
||||
)
|
||||
|
||||
actions = self._config[automation_id][const.ATTR_ACTIONS]
|
||||
for action in actions:
|
||||
try:
|
||||
service_data = copy.copy(action[CONF_SERVICE_DATA])
|
||||
|
||||
if action.get(ATTR_ENTITY_ID):
|
||||
service_data[ATTR_ENTITY_ID] = action[ATTR_ENTITY_ID]
|
||||
|
||||
if self._config[automation_id][CONF_TYPE] == const.ATTR_NOTIFICATION:
|
||||
# recursively process all service_data (supports deep nesting)
|
||||
service_data = await self._process_service_data(
|
||||
service_data,
|
||||
alarm_entity,
|
||||
)
|
||||
|
||||
domain, service = action[ATTR_SERVICE].split(".")
|
||||
|
||||
await self.hass.async_create_task(
|
||||
self.hass.services.async_call(
|
||||
domain,
|
||||
service,
|
||||
service_data,
|
||||
blocking=False,
|
||||
context={},
|
||||
)
|
||||
)
|
||||
except HomeAssistantError as e:
|
||||
_LOGGER.error(
|
||||
"Execution of action %s failed, reason: %s",
|
||||
automation_id,
|
||||
e,
|
||||
)
|
||||
|
||||
async def _process_service_data(
|
||||
self, obj, alarm_entity, depth: int = 0, max_depth: int = 10
|
||||
):
|
||||
"""Recursively process service_data replacing wildcards and templates safely."""
|
||||
if depth > max_depth:
|
||||
return obj
|
||||
|
||||
# Handle strings
|
||||
if isinstance(obj, str):
|
||||
return await self.replace_wildcards_in_string(obj, alarm_entity)
|
||||
|
||||
# Handle dictionaries
|
||||
if isinstance(obj, dict):
|
||||
processed = {}
|
||||
for k, v in obj.items():
|
||||
processed[k] = await self._process_service_data(
|
||||
v,
|
||||
alarm_entity,
|
||||
depth + 1,
|
||||
max_depth,
|
||||
)
|
||||
return processed
|
||||
|
||||
# Handle lists
|
||||
if isinstance(obj, list):
|
||||
return [
|
||||
await self._process_service_data(
|
||||
item,
|
||||
alarm_entity,
|
||||
depth + 1,
|
||||
max_depth,
|
||||
)
|
||||
for item in obj
|
||||
]
|
||||
# Other types (int, float, bool, None, etc.)
|
||||
return obj
|
||||
|
||||
def get_automations_by_area(self, area_id: str):
|
||||
"""Get automations for specified area."""
|
||||
result = []
|
||||
for automation_id, config in self._config.items():
|
||||
if any(
|
||||
el[const.ATTR_AREA] == area_id for el in config[const.ATTR_TRIGGERS]
|
||||
):
|
||||
result.append(automation_id)
|
||||
|
||||
return result
|
||||
|
||||
async def replace_wildcards_in_string(
|
||||
self, input: str, alarm_entity: AlarmoBaseEntity
|
||||
):
|
||||
"""Look for wildcards in string and replace them with content."""
|
||||
# process wildcard '{{open_sensors}}'
|
||||
res = re.search(r"{{open_sensors(\|lang=([^}]+))?(\|format=short)?}}", input)
|
||||
if res:
|
||||
lang = res.group(2) if res.group(2) else "en"
|
||||
names_only = True if res.group(3) else False
|
||||
|
||||
open_sensors = ""
|
||||
if alarm_entity.open_sensors:
|
||||
parts = []
|
||||
for entity_id, status in alarm_entity.open_sensors.items():
|
||||
if names_only:
|
||||
parts.append(friendly_name_for_entity_id(entity_id, self.hass))
|
||||
else:
|
||||
parts.append(
|
||||
await self.async_get_open_sensor_string(
|
||||
entity_id, status, lang
|
||||
)
|
||||
)
|
||||
open_sensors = ", ".join(parts)
|
||||
input = input.replace(res.group(0), open_sensors)
|
||||
|
||||
# process wildcard '{{bypassed_sensors}}'
|
||||
if "{{bypassed_sensors}}" in input:
|
||||
bypassed_sensors = ""
|
||||
if alarm_entity.bypassed_sensors and len(alarm_entity.bypassed_sensors):
|
||||
parts = []
|
||||
for entity_id in alarm_entity.bypassed_sensors:
|
||||
name = friendly_name_for_entity_id(entity_id, self.hass)
|
||||
parts.append(name)
|
||||
bypassed_sensors = ", ".join(parts)
|
||||
input = input.replace("{{bypassed_sensors}}", bypassed_sensors)
|
||||
|
||||
# process wildcard '{{arm_mode}}'
|
||||
res = re.search(r"{{arm_mode(\|lang=([^}]+))?}}", input)
|
||||
if res:
|
||||
lang = res.group(2) if res.group(2) else "en"
|
||||
arm_mode = await self.async_get_arm_mode_string(alarm_entity.arm_mode, lang)
|
||||
|
||||
input = input.replace(res.group(0), arm_mode)
|
||||
|
||||
# process wildcard '{{changed_by}}'
|
||||
if "{{changed_by}}" in input:
|
||||
changed_by = alarm_entity.changed_by if alarm_entity.changed_by else ""
|
||||
input = input.replace("{{changed_by}}", changed_by)
|
||||
|
||||
# process wildcard '{{delay}}'
|
||||
if "{{delay}}" in input:
|
||||
delay = str(alarm_entity.delay) if alarm_entity.delay else ""
|
||||
input = input.replace("{{delay}}", delay)
|
||||
|
||||
# process HA templates
|
||||
if is_template_string(input):
|
||||
input = Template(input, self.hass).async_render()
|
||||
_LOGGER.debug("Processed HA template, result:(%s) %s", type(input), input)
|
||||
|
||||
return input
|
||||
|
||||
async def async_get_open_sensor_string(
|
||||
self, entity_id: str, state: str, language: str
|
||||
):
|
||||
"""Get translation for sensor states."""
|
||||
if self._sensorTranslationCache and self._sensorTranslationLang == language:
|
||||
translations = self._sensorTranslationCache
|
||||
else:
|
||||
translations = await async_get_translations(
|
||||
self.hass, language, "device_automation", ["binary_sensor"]
|
||||
)
|
||||
|
||||
self._sensorTranslationCache = translations
|
||||
self._sensorTranslationLang = language
|
||||
|
||||
entity = self.hass.states.get(entity_id)
|
||||
|
||||
device_type = (
|
||||
entity.attributes["device_class"]
|
||||
if entity and "device_class" in entity.attributes
|
||||
else None
|
||||
)
|
||||
|
||||
if state == STATE_OPEN:
|
||||
translation_key = (
|
||||
f"component.binary_sensor.device_automation.condition_type.{ENTITY_CONDITIONS[device_type][0]['type']}"
|
||||
if device_type in ENTITY_CONDITIONS
|
||||
else None
|
||||
)
|
||||
if translation_key and translation_key in translations:
|
||||
string = translations[translation_key]
|
||||
else:
|
||||
string = "{entity_name} is open"
|
||||
elif state == STATE_CLOSED:
|
||||
translation_key = (
|
||||
f"component.binary_sensor.device_automation.condition_type.{ENTITY_CONDITIONS[device_type][1]['type']}"
|
||||
if device_type in ENTITY_CONDITIONS
|
||||
else None
|
||||
)
|
||||
if translation_key and translation_key in translations:
|
||||
string = translations[translation_key]
|
||||
else:
|
||||
string = "{entity_name} is closed"
|
||||
|
||||
elif state == STATE_UNAVAILABLE:
|
||||
string = "{entity_name} is unavailable"
|
||||
|
||||
else:
|
||||
string = "{entity_name} is unknown"
|
||||
|
||||
name = friendly_name_for_entity_id(entity_id, self.hass)
|
||||
string = string.replace("{entity_name}", name)
|
||||
|
||||
return string
|
||||
|
||||
async def async_get_arm_mode_string(self, arm_mode: str, language: str):
|
||||
"""Get translation for alarm arm mode."""
|
||||
if self._alarmTranslationCache and self._alarmTranslationLang == language:
|
||||
translations = self._alarmTranslationCache
|
||||
else:
|
||||
translations = await async_get_translations(
|
||||
self.hass, language, "entity_component", ["alarm_control_panel"]
|
||||
)
|
||||
|
||||
self._alarmTranslationCache = translations
|
||||
self._alarmTranslationLang = language
|
||||
|
||||
translation_key = (
|
||||
f"component.alarm_control_panel.entity_component._.state.{arm_mode}"
|
||||
if arm_mode
|
||||
else None
|
||||
)
|
||||
|
||||
if translation_key and translation_key in translations:
|
||||
return translations[translation_key]
|
||||
elif arm_mode:
|
||||
return " ".join(w.capitalize() for w in arm_mode.split("_"))
|
||||
else:
|
||||
return ""
|
||||
@@ -0,0 +1,34 @@
|
||||
"""WebSocket handler and registration for Alarmo card update events."""
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.components.websocket_api import decorators, async_register_command
|
||||
|
||||
|
||||
@decorators.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "alarmo_updated",
|
||||
}
|
||||
)
|
||||
@decorators.async_response
|
||||
async def handle_subscribe_updates(hass, connection, msg):
|
||||
"""Handle subscribe updates."""
|
||||
|
||||
@callback
|
||||
def handle_event(event: str, area_id: str, args: dict = {}):
|
||||
"""Forward events to websocket."""
|
||||
data = dict(**args, **{"event": event, "area_id": area_id})
|
||||
connection.send_message(
|
||||
{"id": msg["id"], "type": "event", "event": {"data": data}}
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
|
||||
hass, "alarmo_event", handle_event
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
async def async_register_card(hass):
|
||||
"""Publish event to lovelace when alarm changes."""
|
||||
async_register_command(hass, handle_subscribe_updates)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Config flow for the Alarmo component."""
|
||||
|
||||
import secrets
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
from .const import (
|
||||
NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class AlarmoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Alarmo."""
|
||||
|
||||
VERSION = "1.0.0"
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
# Only a single instance of the integration
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
id = secrets.token_hex(6)
|
||||
|
||||
await self.async_set_unique_id(id)
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
|
||||
return self.async_create_entry(title=NAME, data={})
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Store constants."""
|
||||
|
||||
import datetime
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.const import (
|
||||
ATTR_NAME,
|
||||
CONF_CODE,
|
||||
CONF_MODE,
|
||||
ATTR_ENTITY_ID,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
AlarmControlPanelEntityFeature,
|
||||
)
|
||||
|
||||
VERSION = "1.10.18"
|
||||
NAME = "Alarmo"
|
||||
MANUFACTURER = "@nielsfaber"
|
||||
|
||||
DOMAIN = "alarmo"
|
||||
|
||||
CUSTOM_COMPONENTS = "custom_components"
|
||||
INTEGRATION_FOLDER = DOMAIN
|
||||
PANEL_FOLDER = "frontend"
|
||||
PANEL_FILENAME = "dist/alarm-panel.js"
|
||||
|
||||
PANEL_URL = "/api/panel_custom/alarmo"
|
||||
PANEL_TITLE = NAME
|
||||
PANEL_ICON = "mdi:shield-home"
|
||||
PANEL_NAME = "alarm-panel"
|
||||
|
||||
INITIALIZATION_TIME = datetime.timedelta(seconds=60)
|
||||
SENSOR_ARM_TIME = datetime.timedelta(seconds=5)
|
||||
|
||||
STATES = [
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelState.DISARMED,
|
||||
AlarmControlPanelState.TRIGGERED,
|
||||
AlarmControlPanelState.PENDING,
|
||||
AlarmControlPanelState.ARMING,
|
||||
]
|
||||
|
||||
ARM_MODES = [
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
]
|
||||
|
||||
ARM_MODE_TO_STATE = {
|
||||
"away": AlarmControlPanelState.ARMED_AWAY,
|
||||
"home": AlarmControlPanelState.ARMED_HOME,
|
||||
"night": AlarmControlPanelState.ARMED_NIGHT,
|
||||
"custom": AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
"vacation": AlarmControlPanelState.ARMED_VACATION,
|
||||
}
|
||||
|
||||
STATE_TO_ARM_MODE = {
|
||||
AlarmControlPanelState.ARMED_AWAY: "away",
|
||||
AlarmControlPanelState.ARMED_HOME: "home",
|
||||
AlarmControlPanelState.ARMED_NIGHT: "night",
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS: "custom",
|
||||
AlarmControlPanelState.ARMED_VACATION: "vacation",
|
||||
}
|
||||
|
||||
COMMAND_ARM_NIGHT = "arm_night"
|
||||
COMMAND_ARM_AWAY = "arm_away"
|
||||
COMMAND_ARM_HOME = "arm_home"
|
||||
COMMAND_ARM_CUSTOM_BYPASS = "arm_custom_bypass"
|
||||
COMMAND_ARM_VACATION = "arm_vacation"
|
||||
COMMAND_DISARM = "disarm"
|
||||
|
||||
COMMANDS = [
|
||||
COMMAND_DISARM,
|
||||
COMMAND_ARM_AWAY,
|
||||
COMMAND_ARM_NIGHT,
|
||||
COMMAND_ARM_HOME,
|
||||
COMMAND_ARM_CUSTOM_BYPASS,
|
||||
COMMAND_ARM_VACATION,
|
||||
]
|
||||
|
||||
EVENT_DISARM = "disarm"
|
||||
EVENT_LEAVE = "leave"
|
||||
EVENT_ARM = "arm"
|
||||
EVENT_ENTRY = "entry"
|
||||
EVENT_TRIGGER = "trigger"
|
||||
EVENT_FAILED_TO_ARM = "failed_to_arm"
|
||||
EVENT_COMMAND_NOT_ALLOWED = "command_not_allowed"
|
||||
EVENT_INVALID_CODE_PROVIDED = "invalid_code_provided"
|
||||
EVENT_NO_CODE_PROVIDED = "no_code_provided"
|
||||
EVENT_TRIGGER_TIME_EXPIRED = "trigger_time_expired"
|
||||
EVENT_READY_TO_ARM_MODES_CHANGED = "ready_to_arm_modes_changed"
|
||||
|
||||
ATTR_MODES = "modes"
|
||||
ATTR_ARM_MODE = "arm_mode"
|
||||
ATTR_CODE_DISARM_REQUIRED = "code_disarm_required"
|
||||
ATTR_CODE_MODE_CHANGE_REQUIRED = "code_mode_change_required"
|
||||
ATTR_REMOVE = "remove"
|
||||
ATTR_OLD_CODE = "old_code"
|
||||
|
||||
ATTR_TRIGGER_TIME = "trigger_time"
|
||||
ATTR_EXIT_TIME = "exit_time"
|
||||
ATTR_ENTRY_TIME = "entry_time"
|
||||
|
||||
ATTR_ENABLED = "enabled"
|
||||
ATTR_USER_ID = "user_id"
|
||||
|
||||
ATTR_CAN_ARM = "can_arm"
|
||||
ATTR_CAN_DISARM = "can_disarm"
|
||||
ATTR_DISARM_AFTER_TRIGGER = "disarm_after_trigger"
|
||||
ATTR_IGNORE_BLOCKING_SENSORS_AFTER_TRIGGER = "ignore_blocking_sensors_after_trigger"
|
||||
|
||||
ATTR_REMOVE = "remove"
|
||||
ATTR_IS_OVERRIDE_CODE = "is_override_code"
|
||||
ATTR_AREA_LIMIT = "area_limit"
|
||||
ATTR_CODE_FORMAT = "code_format"
|
||||
ATTR_CODE_LENGTH = "code_length"
|
||||
|
||||
ATTR_AUTOMATION_ID = "automation_id"
|
||||
|
||||
ATTR_TYPE = "type"
|
||||
ATTR_AREA = "area"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
ATTR_TRIGGERS = "triggers"
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_EVENT = "event"
|
||||
ATTR_REQUIRE_CODE = "require_code"
|
||||
|
||||
ATTR_NOTIFICATION = "notification"
|
||||
ATTR_VERSION = "version"
|
||||
ATTR_STATE_PAYLOAD = "state_payload"
|
||||
ATTR_COMMAND_PAYLOAD = "command_payload"
|
||||
|
||||
ATTR_FORCE = "force"
|
||||
ATTR_SKIP_DELAY = "skip_delay"
|
||||
ATTR_CONTEXT_ID = "context_id"
|
||||
|
||||
PUSH_EVENT = "mobile_app_notification_action"
|
||||
|
||||
EVENT_ACTION_FORCE_ARM = "ALARMO_FORCE_ARM"
|
||||
EVENT_ACTION_RETRY_ARM = "ALARMO_RETRY_ARM"
|
||||
EVENT_ACTION_DISARM = "ALARMO_DISARM"
|
||||
EVENT_ACTION_ARM_AWAY = "ALARMO_ARM_AWAY"
|
||||
EVENT_ACTION_ARM_HOME = "ALARMO_ARM_HOME"
|
||||
EVENT_ACTION_ARM_NIGHT = "ALARMO_ARM_NIGHT"
|
||||
EVENT_ACTION_ARM_VACATION = "ALARMO_ARM_VACATION"
|
||||
EVENT_ACTION_ARM_CUSTOM_BYPASS = "ALARMO_ARM_CUSTOM_BYPASS"
|
||||
|
||||
EVENT_ACTIONS = [
|
||||
EVENT_ACTION_FORCE_ARM,
|
||||
EVENT_ACTION_RETRY_ARM,
|
||||
EVENT_ACTION_DISARM,
|
||||
EVENT_ACTION_ARM_AWAY,
|
||||
EVENT_ACTION_ARM_HOME,
|
||||
EVENT_ACTION_ARM_NIGHT,
|
||||
EVENT_ACTION_ARM_VACATION,
|
||||
EVENT_ACTION_ARM_CUSTOM_BYPASS,
|
||||
]
|
||||
|
||||
MODES_TO_SUPPORTED_FEATURES = {
|
||||
AlarmControlPanelState.ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY,
|
||||
AlarmControlPanelState.ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, # noqa: E501
|
||||
AlarmControlPanelState.ARMED_VACATION: AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
}
|
||||
|
||||
SERVICE_ARM = "arm"
|
||||
SERVICE_DISARM = "disarm"
|
||||
SERVICE_SKIP_DELAY = "skip_delay"
|
||||
|
||||
CONF_ALARM_ARMED_AWAY = "armed_away"
|
||||
CONF_ALARM_ARMED_CUSTOM_BYPASS = "armed_custom_bypass"
|
||||
CONF_ALARM_ARMED_HOME = "armed_home"
|
||||
CONF_ALARM_ARMED_NIGHT = "armed_night"
|
||||
CONF_ALARM_ARMED_VACATION = "armed_vacation"
|
||||
CONF_ALARM_ARMING = "arming"
|
||||
CONF_ALARM_DISARMED = "disarmed"
|
||||
CONF_ALARM_PENDING = "pending"
|
||||
CONF_ALARM_TRIGGERED = "triggered"
|
||||
|
||||
SERVICE_ARM_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_CODE, default=""): cv.string,
|
||||
vol.Optional(CONF_MODE, default=AlarmControlPanelState.ARMED_AWAY): vol.In(
|
||||
[
|
||||
"away",
|
||||
"home",
|
||||
"night",
|
||||
"custom",
|
||||
"vacation",
|
||||
CONF_ALARM_ARMED_AWAY,
|
||||
CONF_ALARM_ARMED_HOME,
|
||||
CONF_ALARM_ARMED_NIGHT,
|
||||
CONF_ALARM_ARMED_CUSTOM_BYPASS,
|
||||
CONF_ALARM_ARMED_VACATION,
|
||||
]
|
||||
),
|
||||
vol.Optional(ATTR_SKIP_DELAY, default=False): cv.boolean,
|
||||
vol.Optional(ATTR_FORCE, default=False): cv.boolean,
|
||||
vol.Optional(ATTR_CONTEXT_ID): int,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_DISARM_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_CODE, default=""): cv.string,
|
||||
vol.Optional(ATTR_CONTEXT_ID): int,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_SKIP_DELAY_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_ENABLE_USER = "enable_user"
|
||||
SERVICE_DISABLE_USER = "disable_user"
|
||||
SERVICE_TOGGLE_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_NAME, default=""): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""fire events in HA for use with automations."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from . import const
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventHandler:
|
||||
"""Class to handle events from Alarmo and fire HA events."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Class constructor."""
|
||||
self.hass = hass
|
||||
self._subscription = async_dispatcher_connect(
|
||||
self.hass, "alarmo_event", self.async_handle_event
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
"""Class destructor."""
|
||||
self._subscription()
|
||||
|
||||
@callback
|
||||
def async_handle_event(self, event: str, area_id: str, args: dict = {}):
|
||||
"""Handle event."""
|
||||
if area_id:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
else:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
||||
|
||||
if alarm_entity is None:
|
||||
_LOGGER.warning(
|
||||
"Cannot resolve alarm_entity for area_id: %s (event: %s)",
|
||||
area_id,
|
||||
event,
|
||||
)
|
||||
return
|
||||
|
||||
if event in [
|
||||
const.EVENT_FAILED_TO_ARM,
|
||||
const.EVENT_COMMAND_NOT_ALLOWED,
|
||||
const.EVENT_INVALID_CODE_PROVIDED,
|
||||
const.EVENT_NO_CODE_PROVIDED,
|
||||
]:
|
||||
reasons = {
|
||||
const.EVENT_FAILED_TO_ARM: "open_sensors",
|
||||
const.EVENT_COMMAND_NOT_ALLOWED: "not_allowed",
|
||||
const.EVENT_INVALID_CODE_PROVIDED: "invalid_code",
|
||||
const.EVENT_NO_CODE_PROVIDED: "invalid_code",
|
||||
}
|
||||
|
||||
data = dict(
|
||||
**args,
|
||||
**{
|
||||
"area_id": area_id,
|
||||
"entity_id": alarm_entity.entity_id,
|
||||
"reason": reasons[event],
|
||||
},
|
||||
)
|
||||
if "open_sensors" in data:
|
||||
data["sensors"] = list(data["open_sensors"].keys())
|
||||
del data["open_sensors"]
|
||||
|
||||
self.hass.bus.async_fire("alarmo_failed_to_arm", data)
|
||||
|
||||
elif event in [const.EVENT_ARM, const.EVENT_DISARM]:
|
||||
data = dict(
|
||||
**args,
|
||||
**{
|
||||
"area_id": area_id,
|
||||
"entity_id": alarm_entity.entity_id,
|
||||
"action": event,
|
||||
},
|
||||
)
|
||||
if "arm_mode" in data:
|
||||
data["mode"] = const.STATE_TO_ARM_MODE[data["arm_mode"]]
|
||||
del data["arm_mode"]
|
||||
|
||||
self.hass.bus.async_fire("alarmo_command_success", data)
|
||||
|
||||
elif event == const.EVENT_READY_TO_ARM_MODES_CHANGED:
|
||||
supported_modes = dict(
|
||||
filter(
|
||||
lambda el: el[1] & alarm_entity.supported_features,
|
||||
const.MODES_TO_SUPPORTED_FEATURES.items(),
|
||||
)
|
||||
)
|
||||
modes = {
|
||||
k.value: (k.value in args["modes"]) for k in supported_modes.keys()
|
||||
}
|
||||
data = {
|
||||
"area_id": area_id,
|
||||
"entity_id": alarm_entity.entity_id,
|
||||
**modes,
|
||||
}
|
||||
|
||||
self.hass.bus.async_fire("alarmo_ready_to_arm_modes_updated", data)
|
||||
+3390
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
"""Helper functions for Alarmo integration."""
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
)
|
||||
|
||||
|
||||
def friendly_name_for_entity_id(entity_id: str, hass: HomeAssistant):
|
||||
"""Helper to get friendly name for entity."""
|
||||
state = hass.states.get(entity_id)
|
||||
if state and state.attributes.get("friendly_name"):
|
||||
return state.attributes["friendly_name"]
|
||||
|
||||
return entity_id
|
||||
|
||||
|
||||
def omit(obj: dict, blacklisted_keys: list):
|
||||
"""Helper to omit blacklisted keys from a dict."""
|
||||
return {key: val for key, val in obj.items() if key not in blacklisted_keys}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"arm": "mdi:shield-lock",
|
||||
"disarm": "mdi:shield-off",
|
||||
"enable_user": "mdi:account-lock-open",
|
||||
"disable_user": "mdi:account-lock-closed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"domain": "alarmo",
|
||||
"name": "Alarmo",
|
||||
"after_dependencies": [
|
||||
"mqtt",
|
||||
"notify"
|
||||
],
|
||||
"codeowners": [
|
||||
"@nielsfaber"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"http",
|
||||
"panel_custom"
|
||||
],
|
||||
"documentation": "https://github.com/nielsfaber/alarmo",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/nielsfaber/alarmo/issues",
|
||||
"requirements": [],
|
||||
"version": "1.10.18"
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Class to handle MQTT integration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
from homeassistant.components.mqtt import (
|
||||
DOMAIN as ATTR_MQTT,
|
||||
)
|
||||
from homeassistant.components.mqtt import (
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from . import const
|
||||
from .helpers import (
|
||||
friendly_name_for_entity_id,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CONF_EVENT_TOPIC = "event_topic"
|
||||
|
||||
|
||||
class MqttHandler:
|
||||
"""Class to handle MQTT integration."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant): # noqa: PLR0915
|
||||
"""Class constructor."""
|
||||
self.hass = hass
|
||||
self._config = None
|
||||
self._subscribed_topics = []
|
||||
self._subscriptions = []
|
||||
|
||||
@callback
|
||||
def async_update_config(_args=None):
|
||||
"""Mqtt config updated, reload the configuration."""
|
||||
old_config = self._config
|
||||
new_config = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_config()
|
||||
|
||||
if old_config and old_config[ATTR_MQTT] == new_config[ATTR_MQTT]:
|
||||
# only update MQTT config if some parameters are changed
|
||||
return
|
||||
|
||||
self._config = new_config
|
||||
|
||||
if (
|
||||
not old_config
|
||||
or old_config[ATTR_MQTT][CONF_COMMAND_TOPIC]
|
||||
!= new_config[ATTR_MQTT][CONF_COMMAND_TOPIC]
|
||||
):
|
||||
# re-subscribing is only needed if the command topic has changed
|
||||
self.hass.add_job(self._async_subscribe_topics())
|
||||
|
||||
_LOGGER.debug("MQTT config was (re)loaded")
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(hass, "alarmo_config_updated", async_update_config)
|
||||
)
|
||||
async_update_config()
|
||||
|
||||
@callback
|
||||
def async_alarm_state_changed(area_id: str, old_state: str, new_state: str):
|
||||
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
|
||||
return
|
||||
|
||||
topic = self._config[ATTR_MQTT][CONF_STATE_TOPIC]
|
||||
|
||||
if not topic: # do not publish if no topic is provided
|
||||
return
|
||||
|
||||
if area_id and len(self.hass.data[const.DOMAIN]["areas"]) > 1:
|
||||
# handle the sending of a state update for a specific area
|
||||
area = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
topic = topic.rsplit("/", 1)
|
||||
topic.insert(1, slugify(area.name))
|
||||
topic = "/".join(topic)
|
||||
|
||||
payload_config = self._config[ATTR_MQTT][const.ATTR_STATE_PAYLOAD]
|
||||
if payload_config.get(new_state):
|
||||
message = payload_config[new_state]
|
||||
else:
|
||||
message = new_state
|
||||
|
||||
hass.async_create_task(
|
||||
mqtt.async_publish(self.hass, topic, message, retain=True)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Published state '%s' on topic '%s'",
|
||||
message,
|
||||
topic,
|
||||
)
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
self.hass, "alarmo_state_updated", async_alarm_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_handle_event(event: str, area_id: str, args: dict = {}):
|
||||
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
|
||||
return
|
||||
|
||||
topic = self._config[ATTR_MQTT][CONF_EVENT_TOPIC]
|
||||
|
||||
if not topic: # do not publish if no topic is provided
|
||||
return
|
||||
|
||||
if area_id and len(self.hass.data[const.DOMAIN]["areas"]) > 1:
|
||||
# handle the sending of a state update for a specific area
|
||||
area = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
topic = topic.rsplit("/", 1)
|
||||
topic.insert(1, slugify(area.name))
|
||||
topic = "/".join(topic)
|
||||
|
||||
if event == const.EVENT_ARM:
|
||||
payload = {
|
||||
"event": f"{event.upper()}_{args['arm_mode'].split('_', 1).pop(1).upper()}", # noqa: E501
|
||||
"delay": args["delay"],
|
||||
}
|
||||
elif event == const.EVENT_TRIGGER:
|
||||
payload = {
|
||||
"event": event.upper(),
|
||||
"delay": args["delay"],
|
||||
"sensors": [
|
||||
{
|
||||
"entity_id": entity,
|
||||
"name": friendly_name_for_entity_id(entity, self.hass),
|
||||
}
|
||||
for (entity, state) in args["open_sensors"].items()
|
||||
],
|
||||
}
|
||||
elif event == const.EVENT_FAILED_TO_ARM:
|
||||
payload = {
|
||||
"event": event.upper(),
|
||||
"sensors": [
|
||||
{
|
||||
"entity_id": entity,
|
||||
"name": friendly_name_for_entity_id(entity, self.hass),
|
||||
}
|
||||
for (entity, state) in args["open_sensors"].items()
|
||||
],
|
||||
}
|
||||
elif event == const.EVENT_COMMAND_NOT_ALLOWED:
|
||||
payload = {
|
||||
"event": event.upper(),
|
||||
"state": args["state"],
|
||||
"command": args["command"].upper(),
|
||||
}
|
||||
elif event in [
|
||||
const.EVENT_INVALID_CODE_PROVIDED,
|
||||
const.EVENT_NO_CODE_PROVIDED,
|
||||
]:
|
||||
payload = {"event": event.upper()}
|
||||
else:
|
||||
return
|
||||
|
||||
payload = json.dumps(payload, cls=JSONEncoder)
|
||||
hass.async_create_task(mqtt.async_publish(self.hass, topic, payload))
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(self.hass, "alarmo_event", async_handle_event)
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
"""Prepare for removal."""
|
||||
while len(self._subscribed_topics):
|
||||
self._subscribed_topics.pop()()
|
||||
while len(self._subscriptions):
|
||||
self._subscriptions.pop()()
|
||||
|
||||
async def _async_subscribe_topics(self):
|
||||
"""Install a listener for the command topic."""
|
||||
if len(self._subscribed_topics):
|
||||
while len(self._subscribed_topics):
|
||||
self._subscribed_topics.pop()()
|
||||
_LOGGER.debug("Removed subscribed topics")
|
||||
|
||||
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
|
||||
return
|
||||
|
||||
self._subscribed_topics.append(
|
||||
await mqtt.async_subscribe(
|
||||
self.hass,
|
||||
self._config[ATTR_MQTT][CONF_COMMAND_TOPIC],
|
||||
self.async_message_received,
|
||||
)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Subscribed to topic %s",
|
||||
self._config[ATTR_MQTT][CONF_COMMAND_TOPIC],
|
||||
)
|
||||
|
||||
@callback
|
||||
async def async_message_received(self, msg): # noqa: PLR0915, PLR0912
|
||||
"""Handle new MQTT messages."""
|
||||
command = None
|
||||
code = None
|
||||
area = None
|
||||
bypass_open_sensors = False
|
||||
skip_delay = False
|
||||
|
||||
try:
|
||||
payload = json.loads(msg.payload)
|
||||
payload = {k.lower(): v for k, v in payload.items()}
|
||||
|
||||
if "command" in payload:
|
||||
command = payload["command"]
|
||||
elif "cmd" in payload:
|
||||
command = payload["cmd"]
|
||||
elif "action" in payload:
|
||||
command = payload["action"]
|
||||
elif "state" in payload:
|
||||
command = payload["state"]
|
||||
|
||||
if "code" in payload:
|
||||
code = payload["code"]
|
||||
elif "pin" in payload:
|
||||
code = payload["pin"]
|
||||
elif "password" in payload:
|
||||
code = payload["password"]
|
||||
elif "pincode" in payload:
|
||||
code = payload["pincode"]
|
||||
|
||||
if payload.get("area"):
|
||||
area = payload["area"]
|
||||
|
||||
if (payload.get("bypass_open_sensors")) or (payload.get("force")):
|
||||
bypass_open_sensors = payload["bypass_open_sensors"]
|
||||
|
||||
if payload.get(const.ATTR_SKIP_DELAY):
|
||||
skip_delay = payload[const.ATTR_SKIP_DELAY]
|
||||
|
||||
except ValueError:
|
||||
# no JSON structure found
|
||||
command = msg.payload
|
||||
code = None
|
||||
|
||||
if type(command) is str:
|
||||
command = command.lower()
|
||||
else:
|
||||
_LOGGER.warning("Received unexpected command")
|
||||
return
|
||||
|
||||
payload_config = self._config[ATTR_MQTT][const.ATTR_COMMAND_PAYLOAD]
|
||||
skip_code = not self._config[ATTR_MQTT][const.ATTR_REQUIRE_CODE]
|
||||
|
||||
command_payloads = {}
|
||||
for item in const.COMMANDS:
|
||||
if payload_config.get(item):
|
||||
command_payloads[item] = payload_config[item].lower()
|
||||
else:
|
||||
command_payloads[item] = item.lower()
|
||||
|
||||
if command not in list(command_payloads.values()):
|
||||
_LOGGER.warning("Received unexpected command: %s", command)
|
||||
return
|
||||
|
||||
if area:
|
||||
res = list(
|
||||
filter(
|
||||
lambda el: slugify(el.name) == area,
|
||||
self.hass.data[const.DOMAIN]["areas"].values(),
|
||||
)
|
||||
)
|
||||
if not res:
|
||||
_LOGGER.warning(
|
||||
"Area %s does not exist",
|
||||
area,
|
||||
)
|
||||
return
|
||||
entity = res[0]
|
||||
elif (
|
||||
self._config[const.ATTR_MASTER][const.ATTR_ENABLED]
|
||||
and len(self.hass.data[const.DOMAIN]["areas"]) > 1
|
||||
):
|
||||
entity = self.hass.data[const.DOMAIN]["master"]
|
||||
elif len(self.hass.data[const.DOMAIN]["areas"]) == 1:
|
||||
entity = next(iter(self.hass.data[const.DOMAIN]["areas"].values()))
|
||||
else:
|
||||
_LOGGER.warning("No area specified")
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"Received command %s",
|
||||
command,
|
||||
)
|
||||
|
||||
if command == command_payloads[const.COMMAND_DISARM]:
|
||||
entity.alarm_disarm(code, skip_code=skip_code)
|
||||
elif command == command_payloads[const.COMMAND_ARM_AWAY]:
|
||||
await entity.async_alarm_arm_away(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_NIGHT]:
|
||||
await entity.async_alarm_arm_night(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_HOME]:
|
||||
await entity.async_alarm_arm_home(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_CUSTOM_BYPASS]:
|
||||
await entity.async_alarm_arm_custom_bypass(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_VACATION]:
|
||||
await entity.async_alarm_arm_vacation(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Panel registration for Alarmo integration."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from homeassistant.components import frontend, panel_custom
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
VERSION,
|
||||
PANEL_URL,
|
||||
PANEL_ICON,
|
||||
PANEL_NAME,
|
||||
PANEL_TITLE,
|
||||
PANEL_FOLDER,
|
||||
PANEL_FILENAME,
|
||||
CUSTOM_COMPONENTS,
|
||||
INTEGRATION_FOLDER,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_register_panel(hass):
|
||||
"""Register the panel."""
|
||||
root_dir = os.path.join(hass.config.path(CUSTOM_COMPONENTS), INTEGRATION_FOLDER)
|
||||
panel_dir = os.path.join(root_dir, PANEL_FOLDER)
|
||||
view_url = os.path.join(panel_dir, PANEL_FILENAME)
|
||||
|
||||
try:
|
||||
cache_bust = int(os.path.getmtime(view_url))
|
||||
except OSError:
|
||||
cache_bust = 0
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(PANEL_URL, view_url, cache_headers=False)]
|
||||
)
|
||||
|
||||
await panel_custom.async_register_panel(
|
||||
hass,
|
||||
webcomponent_name=PANEL_NAME,
|
||||
frontend_url_path=DOMAIN,
|
||||
module_url=f"{PANEL_URL}?v={VERSION}&m={cache_bust}",
|
||||
sidebar_title=PANEL_TITLE,
|
||||
sidebar_icon=PANEL_ICON,
|
||||
require_admin=True,
|
||||
config={},
|
||||
config_panel_domain=DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
def async_unregister_panel(hass):
|
||||
"""Unregister the panel."""
|
||||
frontend.async_remove_panel(hass, DOMAIN)
|
||||
_LOGGER.debug("Removing panel")
|
||||
@@ -0,0 +1,774 @@
|
||||
"""Sensor handling for Alarmo integration."""
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.core import (
|
||||
CoreState,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
STATE_ON,
|
||||
ATTR_NAME,
|
||||
STATE_OFF,
|
||||
ATTR_STATE,
|
||||
STATE_OPEN,
|
||||
STATE_CLOSED,
|
||||
STATE_UNKNOWN,
|
||||
STATE_UNAVAILABLE,
|
||||
ATTR_LAST_TRIP_TIME,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
from homeassistant.components.lock import LockState
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
)
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
|
||||
|
||||
from . import const
|
||||
|
||||
ATTR_USE_EXIT_DELAY = "use_exit_delay"
|
||||
ATTR_USE_ENTRY_DELAY = "use_entry_delay"
|
||||
ATTR_ALWAYS_ON = "always_on"
|
||||
ATTR_ARM_ON_CLOSE = "arm_on_close"
|
||||
ATTR_ALLOW_OPEN = "allow_open"
|
||||
ATTR_TRIGGER_UNAVAILABLE = "trigger_unavailable"
|
||||
ATTR_AUTO_BYPASS = "auto_bypass"
|
||||
ATTR_AUTO_BYPASS_MODES = "auto_bypass_modes"
|
||||
ATTR_GROUP = "group"
|
||||
ATTR_GROUP_ID = "group_id"
|
||||
ATTR_TIMEOUT = "timeout"
|
||||
ATTR_EVENT_COUNT = "event_count"
|
||||
ATTR_ENTITIES = "entities"
|
||||
ATTR_NEW_ENTITY_ID = "new_entity_id"
|
||||
ATTR_ENTRY_DELAY = "entry_delay"
|
||||
ATTR_DELAY_ON = "delay_on"
|
||||
|
||||
SENSOR_STATES_OPEN = [STATE_ON, STATE_OPEN, LockState.UNLOCKED]
|
||||
SENSOR_STATES_CLOSED = [STATE_OFF, STATE_CLOSED, LockState.LOCKED]
|
||||
|
||||
|
||||
SENSOR_TYPE_DOOR = "door"
|
||||
SENSOR_TYPE_WINDOW = "window"
|
||||
SENSOR_TYPE_MOTION = "motion"
|
||||
SENSOR_TYPE_TAMPER = "tamper"
|
||||
SENSOR_TYPE_ENVIRONMENTAL = "environmental"
|
||||
SENSOR_TYPE_OTHER = "other"
|
||||
SENSOR_TYPES = [
|
||||
SENSOR_TYPE_DOOR,
|
||||
SENSOR_TYPE_WINDOW,
|
||||
SENSOR_TYPE_MOTION,
|
||||
SENSOR_TYPE_TAMPER,
|
||||
SENSOR_TYPE_ENVIRONMENTAL,
|
||||
SENSOR_TYPE_OTHER,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_sensor_state(state):
|
||||
"""Parse the state of a sensor into open/closed/unavailable/unknown."""
|
||||
if not state or not state.state:
|
||||
return STATE_UNAVAILABLE
|
||||
elif state.state == STATE_UNAVAILABLE:
|
||||
return STATE_UNAVAILABLE
|
||||
elif state.state in SENSOR_STATES_OPEN:
|
||||
return STATE_OPEN
|
||||
elif state.state in SENSOR_STATES_CLOSED:
|
||||
return STATE_CLOSED
|
||||
else:
|
||||
return STATE_UNKNOWN
|
||||
|
||||
|
||||
def sensor_state_allowed(state, sensor_config, alarm_state): # noqa: PLR0911
|
||||
"""Return whether the sensor state is permitted or a state change should occur."""
|
||||
if state != STATE_OPEN and (
|
||||
state != STATE_UNAVAILABLE or not sensor_config[ATTR_TRIGGER_UNAVAILABLE]
|
||||
):
|
||||
# sensor has the safe state
|
||||
return True
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.TRIGGERED:
|
||||
# alarm is already triggered
|
||||
return True
|
||||
|
||||
elif sensor_config[ATTR_ALWAYS_ON]:
|
||||
# alarm should always be triggered by always-on sensor
|
||||
return False
|
||||
|
||||
elif (
|
||||
alarm_state == AlarmControlPanelState.ARMING
|
||||
and not sensor_config[ATTR_USE_EXIT_DELAY]
|
||||
):
|
||||
# arming should be aborted if sensor without exit delay is active
|
||||
return False
|
||||
|
||||
elif alarm_state in const.ARM_MODES:
|
||||
# normal triggering case
|
||||
return False
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.PENDING:
|
||||
# Allow both immediate and delayed sensors
|
||||
# during pending for timer shortening/immediate trigger
|
||||
# This enables per-sensor entry delay logic
|
||||
# to process subsequent triggers during countdown
|
||||
return False
|
||||
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class SensorHandler:
|
||||
"""Class to handle sensors for Alarmo."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
"""Initialize the sensor handler."""
|
||||
self._config = None
|
||||
self.hass = hass
|
||||
self._state_listener = None
|
||||
self._subscriptions = []
|
||||
self._arm_timers = {}
|
||||
self._delay_on_timers = {}
|
||||
self._groups = {}
|
||||
self._group_events = {}
|
||||
self._startup_complete = False
|
||||
self._unavailable_state_mem = {}
|
||||
|
||||
@callback
|
||||
def async_update_sensor_config():
|
||||
"""Sensor config updated, reload the configuration."""
|
||||
self._config = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_sensors()
|
||||
self._groups = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_sensor_groups()
|
||||
self._group_events = {}
|
||||
self.async_watch_sensor_states()
|
||||
|
||||
# Store the callback for later registration
|
||||
self._async_update_sensor_config = async_update_sensor_config
|
||||
|
||||
@callback
|
||||
def _setup_sensor_listeners():
|
||||
"""Register sensor listeners and perform initial setup."""
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
hass, "alarmo_state_updated", self.async_watch_sensor_states
|
||||
)
|
||||
)
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
hass, "alarmo_sensors_updated", self._async_update_sensor_config
|
||||
)
|
||||
)
|
||||
# Do the initial sensor setup now that HA is running
|
||||
self._async_update_sensor_config()
|
||||
|
||||
# Evaluate initial sensor states for all areas on startup
|
||||
for area_id in self.hass.data[const.DOMAIN]["areas"].keys():
|
||||
self.update_ready_to_arm_status(area_id)
|
||||
# If area is armed, validate sensors and trigger if needed
|
||||
# Schedule this to run in the event loop since it may call async methods
|
||||
hass.async_create_task(
|
||||
self._async_evaluate_armed_state_on_startup(area_id)
|
||||
)
|
||||
|
||||
def handle_startup(_event):
|
||||
self._startup_complete = True
|
||||
# Schedule the setup to run in the event loop (from thread pool executor)
|
||||
hass.loop.call_soon_threadsafe(_setup_sensor_listeners)
|
||||
|
||||
if hass.state == CoreState.running:
|
||||
self._startup_complete = True
|
||||
# Schedule in event loop since we're in __init__ (sync context)
|
||||
hass.loop.call_soon_threadsafe(_setup_sensor_listeners)
|
||||
else:
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, handle_startup)
|
||||
|
||||
def __del__(self):
|
||||
"""Prepare for removal."""
|
||||
if self._state_listener:
|
||||
self._state_listener()
|
||||
self._state_listener = None
|
||||
while len(self._subscriptions):
|
||||
self._subscriptions.pop()()
|
||||
|
||||
def async_watch_sensor_states(
|
||||
self,
|
||||
area_id: str | None = None,
|
||||
old_state: str | None = None,
|
||||
state: str | None = None,
|
||||
):
|
||||
"""Watch sensors based on the state of the alarm entities."""
|
||||
watched_sensors_list = []
|
||||
for area in self.hass.data[const.DOMAIN]["areas"].keys():
|
||||
watched_sensors_list.extend(
|
||||
self.active_sensors_for_alarm_state(area, None, True)
|
||||
)
|
||||
|
||||
if self._state_listener:
|
||||
self._state_listener()
|
||||
|
||||
if watched_sensors_list:
|
||||
self._state_listener = async_track_state_change_event(
|
||||
self.hass, watched_sensors_list, self.async_sensor_state_changed
|
||||
)
|
||||
else:
|
||||
self._state_listener = None
|
||||
|
||||
# clear previous sensor group events that are not active for current alarm state
|
||||
if self._group_events:
|
||||
active_sensors_list = []
|
||||
for area in self.hass.data[const.DOMAIN]["areas"].keys():
|
||||
active_sensors_list.extend(
|
||||
self.active_sensors_for_alarm_state(area, None, False)
|
||||
)
|
||||
for group_id in self._group_events.keys():
|
||||
self._group_events[group_id] = dict(
|
||||
filter(
|
||||
lambda el: el[0] in active_sensors_list,
|
||||
self._group_events[group_id].items(),
|
||||
)
|
||||
)
|
||||
|
||||
# handle initial sensor states
|
||||
if area_id and old_state is None:
|
||||
sensors_list = self.active_sensors_for_alarm_state(area_id)
|
||||
for entity in sensors_list:
|
||||
state = self.hass.states.get(entity)
|
||||
sensor_state = parse_sensor_state(state)
|
||||
if state and state.state and sensor_state != STATE_UNKNOWN:
|
||||
_LOGGER.debug(
|
||||
"Initial state for %s is %s",
|
||||
entity,
|
||||
parse_sensor_state(state),
|
||||
)
|
||||
|
||||
if area_id:
|
||||
self.update_ready_to_arm_status(area_id)
|
||||
|
||||
def active_sensors_for_alarm_state(
|
||||
self,
|
||||
area_id: str,
|
||||
to_state: str | None = None,
|
||||
watch_ready_to_arm: bool = False,
|
||||
):
|
||||
"""Compose a list of sensors that are active for the state."""
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
|
||||
if to_state:
|
||||
state = to_state
|
||||
else:
|
||||
state = (
|
||||
alarm_entity.arm_mode if alarm_entity.arm_mode else alarm_entity.state
|
||||
)
|
||||
|
||||
entities = []
|
||||
for entity, config in self._config.items():
|
||||
if config["area"] != area_id or not config["enabled"]:
|
||||
continue
|
||||
elif (
|
||||
alarm_entity.bypassed_sensors
|
||||
and entity in alarm_entity.bypassed_sensors
|
||||
):
|
||||
continue
|
||||
elif state in config[const.ATTR_MODES] or config[ATTR_ALWAYS_ON]:
|
||||
entities.append(entity)
|
||||
elif (
|
||||
not to_state
|
||||
and config["type"] != SENSOR_TYPE_MOTION
|
||||
and watch_ready_to_arm
|
||||
):
|
||||
# always watch all sensors other than motion sensors,
|
||||
# to indicate readiness for arming
|
||||
entities.append(entity)
|
||||
|
||||
return entities
|
||||
|
||||
def validate_arming_event(
|
||||
self, area_id: str, target_state: str | None = None, **kwargs
|
||||
):
|
||||
"""Check whether all sensors have the correct state prior to arming."""
|
||||
use_delay = kwargs.get("use_delay", False)
|
||||
bypass_open_sensors = kwargs.get("bypass_open_sensors", False)
|
||||
|
||||
sensors_list = self.active_sensors_for_alarm_state(area_id, target_state)
|
||||
open_sensors = {}
|
||||
bypassed_sensors = []
|
||||
|
||||
alarm_state = target_state
|
||||
if use_delay and alarm_state in const.ARM_MODES:
|
||||
alarm_state = AlarmControlPanelState.ARMING
|
||||
elif use_delay and alarm_state == AlarmControlPanelState.TRIGGERED:
|
||||
alarm_state = AlarmControlPanelState.PENDING
|
||||
|
||||
for entity in sensors_list:
|
||||
sensor_config = self._config[entity]
|
||||
state = self.hass.states.get(entity)
|
||||
sensor_state = parse_sensor_state(state)
|
||||
if not state or not state.state:
|
||||
# entity does not exist in HA
|
||||
res = False
|
||||
else:
|
||||
res = sensor_state_allowed(sensor_state, sensor_config, alarm_state)
|
||||
|
||||
if not res and target_state in const.ARM_MODES:
|
||||
# sensor is active while arming
|
||||
if bypass_open_sensors or (
|
||||
sensor_config[ATTR_AUTO_BYPASS]
|
||||
and target_state in sensor_config[ATTR_AUTO_BYPASS_MODES]
|
||||
):
|
||||
# sensor may be bypassed
|
||||
bypassed_sensors.append(entity)
|
||||
elif sensor_config[ATTR_ALLOW_OPEN] and sensor_state == STATE_OPEN:
|
||||
# sensor is permitted to be open during/after arming
|
||||
continue
|
||||
else:
|
||||
open_sensors[entity] = sensor_state
|
||||
|
||||
return (open_sensors, bypassed_sensors)
|
||||
|
||||
def get_entry_delay_for_trigger(
|
||||
self, open_sensors: dict[str, str], area_id: str, arm_mode: str
|
||||
) -> int | None:
|
||||
"""Calculate entry delay based on type of sensor trigger."""
|
||||
# Check if this is a group trigger
|
||||
if ATTR_GROUP_ID in open_sensors:
|
||||
# For groups: only check for immediate triggers, otherwise use area default
|
||||
for entity_id in open_sensors:
|
||||
if entity_id != ATTR_GROUP_ID and entity_id in self._config:
|
||||
sensor_config = self._config[entity_id]
|
||||
if not sensor_config[ATTR_USE_ENTRY_DELAY]:
|
||||
return 0
|
||||
|
||||
# Groups always use area default (maintainer's preference)
|
||||
return None
|
||||
else:
|
||||
# Individual sensor trigger
|
||||
entity_id = next(iter(open_sensors.keys()))
|
||||
sensor_config = self._config[entity_id]
|
||||
|
||||
if not sensor_config[ATTR_USE_ENTRY_DELAY]:
|
||||
return 0
|
||||
|
||||
# Use sensor's entry delay if set
|
||||
if (
|
||||
ATTR_ENTRY_DELAY in sensor_config
|
||||
and sensor_config[ATTR_ENTRY_DELAY] is not None
|
||||
):
|
||||
return sensor_config[ATTR_ENTRY_DELAY]
|
||||
|
||||
# Fall back to area default (None means use area default)
|
||||
return None
|
||||
|
||||
@callback
|
||||
def async_sensor_state_changed(self, event): # noqa: PLR0912
|
||||
"""Callback fired when a sensor state has changed."""
|
||||
entity = event.data["entity_id"]
|
||||
old_state = parse_sensor_state(event.data["old_state"])
|
||||
new_state = parse_sensor_state(event.data["new_state"])
|
||||
sensor_config = self._config[entity]
|
||||
if old_state == STATE_UNKNOWN:
|
||||
# sensor is unknown at startup,
|
||||
# state which comes after is considered as initial state
|
||||
_LOGGER.debug(
|
||||
"Initial state for %s is %s",
|
||||
entity,
|
||||
new_state,
|
||||
)
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
if old_state == new_state:
|
||||
# not a state change - ignore
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"entity %s changed: old_state=%s, new_state=%s",
|
||||
entity,
|
||||
old_state,
|
||||
new_state,
|
||||
)
|
||||
|
||||
# Cancel any pending delay_on timer when sensor turns off
|
||||
if new_state == STATE_CLOSED and entity in self._delay_on_timers:
|
||||
self._stop_entity_timer(self._delay_on_timers, entity)
|
||||
|
||||
if (
|
||||
new_state == STATE_UNAVAILABLE
|
||||
and not sensor_config[ATTR_TRIGGER_UNAVAILABLE]
|
||||
):
|
||||
# temporarily store the prior state until the sensor becomes available again
|
||||
self._unavailable_state_mem[entity] = old_state
|
||||
elif entity in self._unavailable_state_mem:
|
||||
# if sensor was unavailable, check the state before that,
|
||||
# do not act if the sensor reverted to its prior state.
|
||||
prior_state = self._unavailable_state_mem.pop(entity)
|
||||
if old_state == STATE_UNAVAILABLE and prior_state == new_state:
|
||||
_LOGGER.debug(
|
||||
"state transition from %s to %s to %s detected, ignoring.",
|
||||
prior_state,
|
||||
old_state,
|
||||
new_state,
|
||||
)
|
||||
return
|
||||
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
|
||||
alarm_state = alarm_entity.state
|
||||
|
||||
if (
|
||||
alarm_entity.arm_mode
|
||||
and alarm_entity.arm_mode not in sensor_config[const.ATTR_MODES]
|
||||
and not sensor_config[ATTR_ALWAYS_ON]
|
||||
):
|
||||
# sensor is not active in this arm mode, ignore
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
res = sensor_state_allowed(new_state, sensor_config, alarm_state)
|
||||
|
||||
if (
|
||||
sensor_config[ATTR_ARM_ON_CLOSE]
|
||||
and alarm_state == AlarmControlPanelState.ARMING
|
||||
):
|
||||
# we are arming and sensor is configured to arm on closing
|
||||
if new_state == STATE_CLOSED:
|
||||
self.start_arm_timer(entity)
|
||||
else:
|
||||
self.stop_arm_timer(entity)
|
||||
|
||||
if res:
|
||||
# nothing to do here, sensor state is OK
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
# Check if delay_on is configured
|
||||
delay_on = sensor_config.get(ATTR_DELAY_ON) or 0
|
||||
|
||||
if delay_on > 0:
|
||||
# Start delay_on timer instead of immediate trigger
|
||||
if self._start_delay_on_timer(entity, new_state):
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
# No trigger delay or timer failed to start - execute immediately
|
||||
self._execute_sensor_trigger(entity, new_state)
|
||||
|
||||
def _start_entity_timer(
|
||||
self,
|
||||
timers: dict,
|
||||
entity: str,
|
||||
delay: datetime.timedelta,
|
||||
timer_callback,
|
||||
) -> None:
|
||||
"""Start a timer for an entity, cancelling any existing timer first."""
|
||||
self._stop_entity_timer(timers, entity)
|
||||
|
||||
@callback
|
||||
def timer_finished(_now):
|
||||
timers.pop(entity, None)
|
||||
timer_callback()
|
||||
|
||||
timers[entity] = async_track_point_in_time(
|
||||
self.hass, timer_finished, dt_util.utcnow() + delay
|
||||
)
|
||||
|
||||
def _stop_entity_timer(self, timers: dict, entity: str | None = None) -> None:
|
||||
"""Cancel timer(s) for entities."""
|
||||
if entity:
|
||||
if cancel := timers.pop(entity, None):
|
||||
cancel()
|
||||
else:
|
||||
for cancel in timers.values():
|
||||
cancel()
|
||||
timers.clear()
|
||||
|
||||
def start_arm_timer(self, entity):
|
||||
"""Start timer for automatical arming."""
|
||||
|
||||
def on_arm_timer_finished():
|
||||
_LOGGER.debug("arm timer finished")
|
||||
sensor_config = self._config[entity]
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
|
||||
if alarm_entity.state == AlarmControlPanelState.ARMING:
|
||||
alarm_entity.async_arm(alarm_entity.arm_mode, skip_delay=True)
|
||||
|
||||
self._start_entity_timer(
|
||||
self._arm_timers, entity, const.SENSOR_ARM_TIME, on_arm_timer_finished
|
||||
)
|
||||
|
||||
def stop_arm_timer(self, entity=None):
|
||||
"""Cancel timer(s) for automatical arming."""
|
||||
self._stop_entity_timer(self._arm_timers, entity)
|
||||
|
||||
def _start_delay_on_timer(self, entity: str, new_state: str):
|
||||
"""Start timer for delayed sensor trigger."""
|
||||
sensor_config = self._config[entity]
|
||||
delay = sensor_config.get(ATTR_DELAY_ON) or 0
|
||||
|
||||
if delay <= 0:
|
||||
return False
|
||||
|
||||
def on_delay_on_finished():
|
||||
"""Handle delay_on timer expiration."""
|
||||
# Re-check if sensor is still in violation state
|
||||
current_state = self.hass.states.get(entity)
|
||||
current_sensor_state = parse_sensor_state(current_state)
|
||||
if current_sensor_state != STATE_OPEN and not (
|
||||
current_sensor_state == STATE_UNAVAILABLE
|
||||
and sensor_config[ATTR_TRIGGER_UNAVAILABLE]
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"delay_on finished for %s but sensor no longer in violation",
|
||||
entity,
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"delay_on finished for %s, proceeding with trigger",
|
||||
entity,
|
||||
)
|
||||
self._execute_sensor_trigger(entity, new_state)
|
||||
|
||||
self._start_entity_timer(
|
||||
self._delay_on_timers,
|
||||
entity,
|
||||
datetime.timedelta(seconds=delay),
|
||||
on_delay_on_finished,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Started delay_on timer for %s (%s seconds)",
|
||||
entity,
|
||||
delay,
|
||||
)
|
||||
return True
|
||||
|
||||
def _execute_sensor_trigger(self, entity: str, new_state: str):
|
||||
"""Execute sensor trigger logic (called directly or after trigger delay)."""
|
||||
sensor_config = self._config[entity]
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
|
||||
alarm_state = alarm_entity.state
|
||||
|
||||
open_sensors = self.process_group_event(entity, new_state)
|
||||
if not open_sensors:
|
||||
# triggered sensor is part of a group and should be ignored
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
if sensor_config[ATTR_ALWAYS_ON]:
|
||||
# immediate trigger due to always on sensor
|
||||
_LOGGER.info(
|
||||
"Alarm is triggered due to an always-on sensor: %s",
|
||||
entity,
|
||||
)
|
||||
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.ARMING:
|
||||
# sensor triggered while arming, abort arming
|
||||
_LOGGER.debug(
|
||||
"Arming was aborted due to a sensor being active: %s",
|
||||
entity,
|
||||
)
|
||||
alarm_entity.async_arm_failure(open_sensors)
|
||||
|
||||
elif alarm_state in const.ARM_MODES:
|
||||
# standard alarm trigger - calculate entry delay override
|
||||
_LOGGER.info(
|
||||
"Alarm is triggered due to sensor: %s",
|
||||
entity,
|
||||
)
|
||||
entry_delay = self.get_entry_delay_for_trigger(
|
||||
open_sensors, sensor_config["area"], alarm_entity.arm_mode
|
||||
)
|
||||
# remove group_id from open_sensors (only used for entry delay calculation)
|
||||
open_sensors.pop(ATTR_GROUP_ID, None)
|
||||
if entry_delay == 0:
|
||||
# immediate trigger (no entry delay)
|
||||
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
|
||||
else:
|
||||
# use calculated delay (could be None for area default)
|
||||
alarm_entity.async_trigger(
|
||||
entry_delay=entry_delay, open_sensors=open_sensors
|
||||
)
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.PENDING:
|
||||
# trigger while in pending state
|
||||
# calculate entry delay for possible timer shortening
|
||||
_LOGGER.info(
|
||||
"Alarm is triggered due to sensor: %s",
|
||||
entity,
|
||||
)
|
||||
entry_delay = self.get_entry_delay_for_trigger(
|
||||
open_sensors, sensor_config["area"], alarm_entity.arm_mode
|
||||
)
|
||||
# remove group_id from open_sensors (only used for entry delay calculation)
|
||||
open_sensors.pop(ATTR_GROUP_ID, None)
|
||||
|
||||
if entry_delay == 0:
|
||||
# immediate trigger
|
||||
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
|
||||
else:
|
||||
# use calculated delay for possible timer shortening
|
||||
alarm_entity.async_trigger(
|
||||
entry_delay=entry_delay, open_sensors=open_sensors
|
||||
)
|
||||
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
|
||||
def process_group_event(self, entity: str, state: str) -> dict:
|
||||
"""Check if sensor entity is member of a group to evaluate trigger."""
|
||||
group_id = None
|
||||
for group in self._groups.values():
|
||||
if entity in group[ATTR_ENTITIES]:
|
||||
group_id = group[ATTR_GROUP_ID]
|
||||
break
|
||||
|
||||
open_sensors = {entity: state}
|
||||
if group_id is None:
|
||||
return open_sensors
|
||||
|
||||
group = self._groups[group_id]
|
||||
group_events = (
|
||||
self._group_events[group_id]
|
||||
if group_id in self._group_events.keys()
|
||||
else {}
|
||||
)
|
||||
now = dt_util.now()
|
||||
group_events[entity] = {ATTR_STATE: state, ATTR_LAST_TRIP_TIME: now}
|
||||
self._group_events[group_id] = group_events
|
||||
recent_events = {
|
||||
entity: (now - event[ATTR_LAST_TRIP_TIME]).total_seconds()
|
||||
for (entity, event) in group_events.items()
|
||||
}
|
||||
recent_events = dict(
|
||||
filter(lambda el: el[1] <= group[ATTR_TIMEOUT], recent_events.items())
|
||||
)
|
||||
if len(recent_events.keys()) < group[ATTR_EVENT_COUNT]:
|
||||
_LOGGER.debug(
|
||||
"tripped sensor %s was ignored since it belongs to group %s",
|
||||
entity,
|
||||
group[ATTR_NAME],
|
||||
)
|
||||
return {}
|
||||
else:
|
||||
# add all (recently) triggered sensors to open_sensors
|
||||
for entity_id in recent_events.keys():
|
||||
open_sensors[entity_id] = group_events[entity_id][ATTR_STATE]
|
||||
|
||||
# Add group info for override delay calculation
|
||||
open_sensors[ATTR_GROUP_ID] = group_id
|
||||
_LOGGER.debug(
|
||||
"tripped sensor %s caused the triggering of group %s",
|
||||
entity,
|
||||
group[ATTR_NAME],
|
||||
)
|
||||
return open_sensors
|
||||
|
||||
def update_ready_to_arm_status(self, area_id):
|
||||
"""Calculate whether the system is ready for arming."""
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
|
||||
arm_modes = [
|
||||
mode
|
||||
for (mode, config) in alarm_entity._config[const.ATTR_MODES].items()
|
||||
if config[const.ATTR_ENABLED]
|
||||
]
|
||||
|
||||
if alarm_entity.state in const.ARM_MODES or (
|
||||
alarm_entity.state == AlarmControlPanelState.ARMING
|
||||
and alarm_entity.arm_mode
|
||||
):
|
||||
arm_modes.remove(alarm_entity.arm_mode)
|
||||
|
||||
def arm_mode_is_ready(mode):
|
||||
(blocking_sensors, _bypassed_sensors) = self.validate_arming_event(
|
||||
area_id, mode
|
||||
)
|
||||
if alarm_entity.state == AlarmControlPanelState.DISARMED:
|
||||
# exclude motion sensors when determining readiness
|
||||
blocking_sensors = dict(
|
||||
filter(
|
||||
lambda el: self._config[el[0]]["type"] != SENSOR_TYPE_MOTION,
|
||||
blocking_sensors.items(),
|
||||
)
|
||||
)
|
||||
result = not (blocking_sensors)
|
||||
return result
|
||||
|
||||
arm_modes = list(filter(arm_mode_is_ready, arm_modes))
|
||||
prev_arm_modes = alarm_entity._ready_to_arm_modes
|
||||
|
||||
if arm_modes != prev_arm_modes:
|
||||
alarm_entity.update_ready_to_arm_modes(arm_modes)
|
||||
|
||||
async def _async_evaluate_armed_state_on_startup(self, area_id):
|
||||
"""Evaluate sensors when alarm is armed on startup and trigger if necessary.
|
||||
|
||||
On startup, we don't know the actual previous state of sensors
|
||||
(they might have changed while HA was down).
|
||||
This method simulates state changes for all sensors currently in violation,
|
||||
allowing the standard async_sensor_state_changed logic to re-evaluate them
|
||||
with full group logic, entry delays, etc.
|
||||
"""
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
|
||||
# Only evaluate if the alarm is in an armed state
|
||||
if alarm_entity.state not in const.ARM_MODES:
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"Evaluating sensors on startup for area %s (state: %s)",
|
||||
area_id,
|
||||
alarm_entity.state,
|
||||
)
|
||||
|
||||
# Get all active sensors for the current armed mode
|
||||
sensors_list = self.active_sensors_for_alarm_state(area_id)
|
||||
|
||||
for entity_id in sensors_list:
|
||||
sensor_config = self._config[entity_id]
|
||||
state = self.hass.states.get(entity_id)
|
||||
sensor_state = parse_sensor_state(state)
|
||||
|
||||
if sensor_state == STATE_UNKNOWN:
|
||||
# Skip unknown sensors - they'll be handled when they become known
|
||||
continue
|
||||
|
||||
# Check if sensor state is allowed in current alarm state
|
||||
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
|
||||
|
||||
if not res:
|
||||
# Sensor is in a violation state
|
||||
# (open or unavailable when it shouldn't be)
|
||||
# Simulate a state change to trigger standard processing
|
||||
_LOGGER.info(
|
||||
"Sensor %s is %s on startup while alarm is %s - simulating state change for evaluation", # noqa: E501
|
||||
entity_id,
|
||||
sensor_state,
|
||||
alarm_entity.state,
|
||||
)
|
||||
|
||||
# Create a synthetic event that mimics
|
||||
# a state change from closed to current state
|
||||
# We use STATE_CLOSED as old state
|
||||
# (not STATE_UNKNOWN which would trigger early return)
|
||||
old_state = SimpleNamespace(state=STATE_CLOSED)
|
||||
|
||||
# Create event with the structure expected by async_sensor_state_changed
|
||||
event = SimpleNamespace(
|
||||
data={
|
||||
"entity_id": entity_id,
|
||||
"old_state": old_state,
|
||||
"new_state": state,
|
||||
}
|
||||
)
|
||||
|
||||
# Process through the standard sensor state change handler
|
||||
# This will handle groups, entry delays, always-on sensors, etc.
|
||||
self.async_sensor_state_changed(event)
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
arm:
|
||||
fields:
|
||||
entity_id:
|
||||
example: "alarm_control_panel.alarm"
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: alarmo
|
||||
domain: alarm_control_panel
|
||||
code:
|
||||
example: "1234"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
mode:
|
||||
example: "away"
|
||||
required: false
|
||||
default: away
|
||||
selector:
|
||||
select:
|
||||
translation_key: "arm_mode"
|
||||
options:
|
||||
- away
|
||||
- night
|
||||
- home
|
||||
- vacation
|
||||
- custom
|
||||
skip_delay:
|
||||
example: false
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
force:
|
||||
example: false
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
disarm:
|
||||
fields:
|
||||
entity_id:
|
||||
example: "alarm_control_panel.alarm"
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: alarmo
|
||||
domain: alarm_control_panel
|
||||
code:
|
||||
example: "1234"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
skip_delay:
|
||||
fields:
|
||||
entity_id:
|
||||
example: "alarm_control_panel.alarm"
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: alarmo
|
||||
domain: alarm_control_panel
|
||||
enable_user:
|
||||
fields:
|
||||
name:
|
||||
example: "Frank"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
disable_user:
|
||||
fields:
|
||||
name:
|
||||
example: "Frank"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,722 @@
|
||||
"""Storage handler for Alarmo integration."""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import cast
|
||||
from collections import OrderedDict
|
||||
from collections.abc import MutableMapping
|
||||
|
||||
import attr
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.components.alarm_control_panel import CodeFormat
|
||||
|
||||
from . import const
|
||||
from .helpers import omit
|
||||
from .sensors import (
|
||||
SENSOR_TYPE_OTHER,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_REGISTRY = f"{const.DOMAIN}_storage"
|
||||
STORAGE_KEY = f"{const.DOMAIN}.storage"
|
||||
STORAGE_VERSION_MAJOR = 6
|
||||
STORAGE_VERSION_MINOR = 3
|
||||
SAVE_DELAY = 10
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class ModeEntry:
|
||||
"""Mode storage Entry."""
|
||||
|
||||
enabled = attr.ib(type=bool, default=False)
|
||||
exit_time = attr.ib(type=int, default=None)
|
||||
entry_time = attr.ib(type=int, default=None)
|
||||
trigger_time = attr.ib(type=int, default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class MqttConfig:
|
||||
"""MQTT storage Entry."""
|
||||
|
||||
enabled = attr.ib(type=bool, default=False)
|
||||
state_topic = attr.ib(type=str, default="alarmo/state")
|
||||
state_payload = attr.ib(type=dict, default={})
|
||||
command_topic = attr.ib(type=str, default="alarmo/command")
|
||||
command_payload = attr.ib(type=dict, default={})
|
||||
require_code = attr.ib(type=bool, default=True)
|
||||
event_topic = attr.ib(type=str, default="alarmo/event")
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class MasterConfig:
|
||||
"""Master storage Entry."""
|
||||
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
name = attr.ib(type=str, default="master")
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class AreaEntry:
|
||||
"""Area storage Entry."""
|
||||
|
||||
area_id = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default=None)
|
||||
modes = attr.ib(
|
||||
type=[str, ModeEntry],
|
||||
default={
|
||||
const.CONF_ALARM_ARMED_AWAY: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_HOME: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_NIGHT: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_CUSTOM_BYPASS: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_VACATION: ModeEntry(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class Config:
|
||||
"""(General) Config storage Entry."""
|
||||
|
||||
code_arm_required = attr.ib(type=bool, default=False)
|
||||
code_mode_change_required = attr.ib(type=bool, default=False)
|
||||
code_disarm_required = attr.ib(type=bool, default=False)
|
||||
code_format = attr.ib(type=str, default=CodeFormat.NUMBER)
|
||||
disarm_after_trigger = attr.ib(type=bool, default=False)
|
||||
ignore_blocking_sensors_after_trigger = attr.ib(type=bool, default=False)
|
||||
master = attr.ib(type=MasterConfig, default=MasterConfig())
|
||||
mqtt = attr.ib(type=MqttConfig, default=MqttConfig())
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class SensorEntry:
|
||||
"""Sensor storage Entry."""
|
||||
|
||||
entity_id = attr.ib(type=str, default=None)
|
||||
type = attr.ib(type=str, default=SENSOR_TYPE_OTHER)
|
||||
modes = attr.ib(type=list, default=[])
|
||||
use_exit_delay = attr.ib(type=bool, default=True)
|
||||
use_entry_delay = attr.ib(type=bool, default=True)
|
||||
always_on = attr.ib(type=bool, default=False)
|
||||
arm_on_close = attr.ib(type=bool, default=False)
|
||||
allow_open = attr.ib(type=bool, default=False)
|
||||
trigger_unavailable = attr.ib(type=bool, default=False)
|
||||
auto_bypass = attr.ib(type=bool, default=False)
|
||||
auto_bypass_modes = attr.ib(type=list, default=[])
|
||||
area = attr.ib(type=str, default=None)
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
entry_delay = attr.ib(type=int, default=None)
|
||||
delay_on = attr.ib(type=int, default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class UserEntry:
|
||||
"""User storage Entry."""
|
||||
|
||||
user_id = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default="")
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
code = attr.ib(type=str, default="")
|
||||
can_arm = attr.ib(type=bool, default=False)
|
||||
can_disarm = attr.ib(type=bool, default=False)
|
||||
is_override_code = attr.ib(type=bool, default=False)
|
||||
code_format = attr.ib(type=str, default="")
|
||||
code_length = attr.ib(type=int, default=0)
|
||||
area_limit = attr.ib(type=list, default=[])
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class AlarmoTriggerEntry:
|
||||
"""Trigger storage Entry."""
|
||||
|
||||
event = attr.ib(type=str, default="")
|
||||
area = attr.ib(type=str, default=None)
|
||||
modes = attr.ib(type=list, default=[])
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class EntityTriggerEntry:
|
||||
"""Trigger storage Entry."""
|
||||
|
||||
entity_id = attr.ib(type=str, default=None)
|
||||
state = attr.ib(type=str, default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class ActionEntry:
|
||||
"""Action storage Entry."""
|
||||
|
||||
service = attr.ib(type=str, default="")
|
||||
entity_id = attr.ib(type=str, default=None)
|
||||
data = attr.ib(type=dict, default={})
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class AutomationEntry:
|
||||
"""Automation storage Entry."""
|
||||
|
||||
automation_id = attr.ib(type=str, default=None)
|
||||
type = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default="")
|
||||
triggers = attr.ib(type=[AlarmoTriggerEntry], default=[])
|
||||
actions = attr.ib(type=[ActionEntry], default=[])
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class SensorGroupEntry:
|
||||
"""Sensor group storage Entry."""
|
||||
|
||||
group_id = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default="")
|
||||
entities = attr.ib(type=list, default=[])
|
||||
timeout = attr.ib(type=int, default=0)
|
||||
event_count = attr.ib(type=int, default=2)
|
||||
|
||||
|
||||
def parse_automation_entry(data: dict):
|
||||
"""Parse automation entry from dict to proper types."""
|
||||
|
||||
def create_trigger_entity(config: dict):
|
||||
if "event" in config:
|
||||
return AlarmoTriggerEntry(**config)
|
||||
else:
|
||||
return EntityTriggerEntry(**config)
|
||||
|
||||
output = {}
|
||||
if "triggers" in data:
|
||||
output["triggers"] = list(map(create_trigger_entity, data["triggers"]))
|
||||
if "actions" in data:
|
||||
output["actions"] = list(map(lambda el: ActionEntry(**el), data["actions"]))
|
||||
if "automation_id" in data:
|
||||
output["automation_id"] = data["automation_id"]
|
||||
if "name" in data:
|
||||
output["name"] = data["name"]
|
||||
if "type" in data:
|
||||
output["type"] = data["type"]
|
||||
if "enabled" in data:
|
||||
output["enabled"] = data["enabled"]
|
||||
return output
|
||||
|
||||
|
||||
class MigratableStore(Store):
|
||||
"""Storage class that can migrate data between versions."""
|
||||
|
||||
async def _async_migrate_func(
|
||||
self, old_major_version: int, old_minor_version: int, data: dict
|
||||
):
|
||||
def migrate_automation(data):
|
||||
if old_major_version <= 2:
|
||||
data["triggers"] = [
|
||||
{
|
||||
"event": el["state"] if "state" in el else el["event"],
|
||||
"area": el.get("area"),
|
||||
"modes": data["modes"],
|
||||
}
|
||||
for el in data["triggers"]
|
||||
]
|
||||
|
||||
data["type"] = (
|
||||
"notification" if data.get("is_notification") else "action"
|
||||
)
|
||||
|
||||
if old_major_version <= 5:
|
||||
data["actions"] = [
|
||||
{
|
||||
"service": el.get("service"),
|
||||
"entity_id": el.get("entity_id"),
|
||||
"data": el.get("service_data"),
|
||||
}
|
||||
for el in data["actions"]
|
||||
]
|
||||
|
||||
return attr.asdict(AutomationEntry(**parse_automation_entry(data)))
|
||||
|
||||
if old_major_version == 1:
|
||||
area_id = str(int(time.time()))
|
||||
data["areas"] = [
|
||||
attr.asdict(
|
||||
AreaEntry(
|
||||
**{
|
||||
"name": "Alarmo",
|
||||
"modes": {
|
||||
mode: attr.asdict(
|
||||
ModeEntry(
|
||||
enabled=bool(config["enabled"]),
|
||||
exit_time=int(config["leave_time"] or 0),
|
||||
entry_time=int(config["entry_time"] or 0),
|
||||
trigger_time=int(
|
||||
data["config"]["trigger_time"] or 0
|
||||
),
|
||||
)
|
||||
)
|
||||
for (mode, config) in data["config"]["modes"].items()
|
||||
},
|
||||
},
|
||||
area_id=area_id,
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
if "sensors" in data:
|
||||
for sensor in data["sensors"]:
|
||||
sensor["area"] = area_id
|
||||
|
||||
if old_major_version <= 3:
|
||||
data["sensors"] = [
|
||||
attr.asdict(
|
||||
SensorEntry(
|
||||
**{
|
||||
**omit(sensor, ["immediate", "name"]),
|
||||
"use_exit_delay": not sensor["immediate"]
|
||||
and not sensor["always_on"],
|
||||
"use_entry_delay": not sensor["immediate"]
|
||||
and not sensor["always_on"],
|
||||
"auto_bypass_modes": sensor["modes"]
|
||||
if sensor.get("auto_bypass")
|
||||
else [],
|
||||
}
|
||||
)
|
||||
)
|
||||
for sensor in data["sensors"]
|
||||
]
|
||||
|
||||
if old_major_version <= 4:
|
||||
data["sensors"] = [
|
||||
attr.asdict(
|
||||
SensorEntry(
|
||||
**omit(sensor, ["name"]),
|
||||
)
|
||||
)
|
||||
for sensor in data["sensors"]
|
||||
]
|
||||
|
||||
data["automations"] = [
|
||||
migrate_automation(automation) for automation in data["automations"]
|
||||
]
|
||||
|
||||
if old_major_version <= 5 or (old_major_version == 6 and old_minor_version < 2):
|
||||
data["config"] = attr.asdict(
|
||||
Config(
|
||||
**omit(data["config"], ["code_mode_change_required"]),
|
||||
code_mode_change_required=data["config"]["code_arm_required"],
|
||||
)
|
||||
)
|
||||
|
||||
if old_major_version <= 5 or (old_major_version == 6 and old_minor_version < 3):
|
||||
data["sensor_groups"] = [
|
||||
attr.asdict(
|
||||
SensorGroupEntry(
|
||||
**{
|
||||
**omit(sensorGroup, ["entities"]),
|
||||
"entities": list(set(sensorGroup["entities"])),
|
||||
}
|
||||
)
|
||||
)
|
||||
for sensorGroup in data["sensor_groups"]
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class AlarmoStorage:
|
||||
"""Class to hold alarmo configuration data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the storage."""
|
||||
self.hass = hass
|
||||
self.config: Config = Config()
|
||||
self.areas: MutableMapping[str, AreaEntry] = {}
|
||||
self.sensors: MutableMapping[str, SensorEntry] = {}
|
||||
self.users: MutableMapping[str, UserEntry] = {}
|
||||
self.automations: MutableMapping[str, AutomationEntry] = {}
|
||||
self.sensor_groups: MutableMapping[str, SensorGroupEntry] = {}
|
||||
self._store = MigratableStore(
|
||||
hass,
|
||||
STORAGE_VERSION_MAJOR,
|
||||
STORAGE_KEY,
|
||||
minor_version=STORAGE_VERSION_MINOR,
|
||||
)
|
||||
|
||||
async def async_load(self) -> None: # noqa: PLR0912
|
||||
"""Load the registry of schedule entries."""
|
||||
data = await self._store.async_load()
|
||||
config: Config = Config()
|
||||
areas: OrderedDict[str, AreaEntry] = OrderedDict()
|
||||
sensors: OrderedDict[str, SensorEntry] = OrderedDict()
|
||||
users: OrderedDict[str, UserEntry] = OrderedDict()
|
||||
automations: OrderedDict[str, AutomationEntry] = OrderedDict()
|
||||
sensor_groups: OrderedDict[str, SensorGroupEntry] = OrderedDict()
|
||||
|
||||
if data is not None:
|
||||
config = Config(
|
||||
code_arm_required=data["config"]["code_arm_required"],
|
||||
code_mode_change_required=data["config"]["code_mode_change_required"],
|
||||
code_disarm_required=data["config"]["code_disarm_required"],
|
||||
code_format=data["config"]["code_format"],
|
||||
disarm_after_trigger=data["config"]["disarm_after_trigger"],
|
||||
ignore_blocking_sensors_after_trigger=data["config"].get(
|
||||
"ignore_blocking_sensors_after_trigger", False
|
||||
),
|
||||
)
|
||||
|
||||
if "mqtt" in data["config"]:
|
||||
config = attr.evolve(
|
||||
config,
|
||||
**{
|
||||
"mqtt": MqttConfig(**data["config"]["mqtt"]),
|
||||
},
|
||||
)
|
||||
|
||||
if "master" in data["config"]:
|
||||
config = attr.evolve(
|
||||
config,
|
||||
**{
|
||||
"master": MasterConfig(**data["config"]["master"]),
|
||||
},
|
||||
)
|
||||
|
||||
if "areas" in data:
|
||||
for area in data["areas"]:
|
||||
modes = {
|
||||
mode: ModeEntry(
|
||||
enabled=config["enabled"],
|
||||
exit_time=config["exit_time"],
|
||||
entry_time=config["entry_time"],
|
||||
trigger_time=config["trigger_time"],
|
||||
)
|
||||
for (mode, config) in area["modes"].items()
|
||||
}
|
||||
areas[area["area_id"]] = AreaEntry(
|
||||
area_id=area["area_id"], name=area["name"], modes=modes
|
||||
)
|
||||
|
||||
if "sensors" in data:
|
||||
for sensor in data["sensors"]:
|
||||
sensors[sensor["entity_id"]] = SensorEntry(**sensor)
|
||||
|
||||
if "users" in data:
|
||||
for user in data["users"]:
|
||||
users[user["user_id"]] = UserEntry(**omit(user, ["is_admin"]))
|
||||
|
||||
if "automations" in data:
|
||||
for automation in data["automations"]:
|
||||
automations[automation["automation_id"]] = AutomationEntry(
|
||||
**parse_automation_entry(automation)
|
||||
)
|
||||
|
||||
if "sensor_groups" in data:
|
||||
for group in data["sensor_groups"]:
|
||||
sensor_groups[group["group_id"]] = SensorGroupEntry(**group)
|
||||
|
||||
self.config = config
|
||||
self.areas = areas
|
||||
self.sensors = sensors
|
||||
self.automations = automations
|
||||
self.users = users
|
||||
self.sensor_groups = sensor_groups
|
||||
|
||||
if not areas:
|
||||
await self.async_factory_default()
|
||||
|
||||
async def async_factory_default(self):
|
||||
"""Reset to factory default configuration."""
|
||||
self.async_create_area(
|
||||
{
|
||||
"name": "Alarmo",
|
||||
"modes": {
|
||||
const.CONF_ALARM_ARMED_AWAY: attr.asdict(
|
||||
ModeEntry(
|
||||
enabled=True, exit_time=60, entry_time=60, trigger_time=1800
|
||||
)
|
||||
),
|
||||
const.CONF_ALARM_ARMED_HOME: attr.asdict(
|
||||
ModeEntry(enabled=True, trigger_time=1800)
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_schedule_save(self) -> None:
|
||||
"""Schedule saving the registry of alarmo."""
|
||||
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
|
||||
|
||||
async def async_save(self) -> None:
|
||||
"""Save the registry of alarmo."""
|
||||
await self._store.async_save(self._data_to_save())
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> dict:
|
||||
"""Return data for the registry for alarmo to store in a file."""
|
||||
store_data = {
|
||||
"config": attr.asdict(self.config),
|
||||
}
|
||||
|
||||
store_data["areas"] = [attr.asdict(entry) for entry in self.areas.values()]
|
||||
store_data["sensors"] = [attr.asdict(entry) for entry in self.sensors.values()]
|
||||
store_data["users"] = [attr.asdict(entry) for entry in self.users.values()]
|
||||
store_data["automations"] = [
|
||||
attr.asdict(entry) for entry in self.automations.values()
|
||||
]
|
||||
store_data["sensor_groups"] = [
|
||||
attr.asdict(entry) for entry in self.sensor_groups.values()
|
||||
]
|
||||
|
||||
return store_data
|
||||
|
||||
async def async_delete(self):
|
||||
"""Delete config."""
|
||||
_LOGGER.warning("Removing alarmo configuration data!")
|
||||
await self._store.async_remove()
|
||||
self.config = Config()
|
||||
self.areas = {}
|
||||
self.sensors = {}
|
||||
self.users = {}
|
||||
self.automations = {}
|
||||
self.sensor_groups = {}
|
||||
await self.async_factory_default()
|
||||
|
||||
@callback
|
||||
def async_get_config(self):
|
||||
"""Get current config."""
|
||||
return attr.asdict(self.config)
|
||||
|
||||
@callback
|
||||
def async_update_config(self, changes: dict):
|
||||
"""Update existing config."""
|
||||
old = self.config
|
||||
new = self.config = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return attr.asdict(new)
|
||||
|
||||
@callback
|
||||
def async_update_mode_config(self, mode: str, changes: dict):
|
||||
"""Update existing config."""
|
||||
modes = self.config.modes
|
||||
old = self.config.modes[mode] if mode in self.config.modes else ModeEntry()
|
||||
new = attr.evolve(old, **changes)
|
||||
modes[mode] = new
|
||||
self.config = attr.evolve(self.config, **{"modes": modes})
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_area(self, area_id) -> AreaEntry:
|
||||
"""Get an existing AreaEntry by id."""
|
||||
res = self.areas.get(area_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_areas(self):
|
||||
"""Get an existing AreaEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.areas.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_area(self, data: dict) -> AreaEntry:
|
||||
"""Create a new AreaEntry."""
|
||||
area_id = str(int(time.time()))
|
||||
new_area = AreaEntry(**data, area_id=area_id)
|
||||
self.areas[area_id] = new_area
|
||||
self.async_schedule_save()
|
||||
return attr.asdict(new_area)
|
||||
|
||||
@callback
|
||||
def async_delete_area(self, area_id: str) -> None:
|
||||
"""Delete AreaEntry."""
|
||||
if area_id in self.areas:
|
||||
del self.areas[area_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_area(self, area_id: str, changes: dict) -> AreaEntry:
|
||||
"""Update existing self."""
|
||||
old = self.areas[area_id]
|
||||
new = self.areas[area_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return attr.asdict(new)
|
||||
|
||||
@callback
|
||||
def async_get_sensor(self, entity_id) -> SensorEntry:
|
||||
"""Get an existing SensorEntry by id."""
|
||||
res = self.sensors.get(entity_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_sensors(self):
|
||||
"""Get an existing SensorEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.sensors.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_sensor(self, entity_id: str, data: dict) -> SensorEntry:
|
||||
"""Create a new SensorEntry."""
|
||||
if entity_id in self.sensors:
|
||||
return False
|
||||
new_sensor = SensorEntry(**data, entity_id=entity_id)
|
||||
self.sensors[entity_id] = new_sensor
|
||||
self.async_schedule_save()
|
||||
return new_sensor
|
||||
|
||||
@callback
|
||||
def async_delete_sensor(self, entity_id: str) -> None:
|
||||
"""Delete SensorEntry."""
|
||||
if entity_id in self.sensors:
|
||||
del self.sensors[entity_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_sensor(self, entity_id: str, changes: dict) -> SensorEntry:
|
||||
"""Update existing SensorEntry."""
|
||||
old = self.sensors[entity_id]
|
||||
new = self.sensors[entity_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_user(self, user_id) -> UserEntry:
|
||||
"""Get an existing UserEntry by id."""
|
||||
res = self.users.get(user_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_users(self):
|
||||
"""Get an existing UserEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.users.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_user(self, data: dict) -> UserEntry:
|
||||
"""Create a new UserEntry."""
|
||||
user_id = str(int(time.time()))
|
||||
new_user = UserEntry(**data, user_id=user_id)
|
||||
self.users[user_id] = new_user
|
||||
self.async_schedule_save()
|
||||
return new_user
|
||||
|
||||
@callback
|
||||
def async_delete_user(self, user_id: str) -> None:
|
||||
"""Delete UserEntry."""
|
||||
if user_id in self.users:
|
||||
del self.users[user_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_user(self, user_id: str, changes: dict) -> UserEntry:
|
||||
"""Update existing UserEntry."""
|
||||
old = self.users[user_id]
|
||||
new = self.users[user_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_automations(self):
|
||||
"""Get an existing AutomationEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.automations.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_automation(self, data: dict) -> AutomationEntry:
|
||||
"""Create a new AutomationEntry."""
|
||||
automation_id = str(int(time.time()))
|
||||
new_automation = AutomationEntry(
|
||||
**parse_automation_entry(data), automation_id=automation_id
|
||||
)
|
||||
self.automations[automation_id] = new_automation
|
||||
self.async_schedule_save()
|
||||
return new_automation
|
||||
|
||||
@callback
|
||||
def async_delete_automation(self, automation_id: str) -> None:
|
||||
"""Delete AutomationEntry."""
|
||||
if automation_id in self.automations:
|
||||
del self.automations[automation_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_automation(
|
||||
self, automation_id: str, changes: dict
|
||||
) -> AutomationEntry:
|
||||
"""Update existing AutomationEntry."""
|
||||
old = self.automations[automation_id]
|
||||
new = self.automations[automation_id] = attr.evolve(
|
||||
old, **parse_automation_entry(changes)
|
||||
)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_sensor_group(self, group_id) -> SensorGroupEntry:
|
||||
"""Get an existing SensorGroupEntry by id."""
|
||||
res = self.sensor_groups.get(group_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_sensor_groups(self):
|
||||
"""Get an existing SensorGroupEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.sensor_groups.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_sensor_group(self, data: dict) -> SensorGroupEntry:
|
||||
"""Create a new SensorGroupEntry."""
|
||||
group_id = str(int(time.time()))
|
||||
new_group = SensorGroupEntry(**data, group_id=group_id)
|
||||
self.sensor_groups[group_id] = new_group
|
||||
self.async_schedule_save()
|
||||
return group_id
|
||||
|
||||
@callback
|
||||
def async_delete_sensor_group(self, group_id: str) -> None:
|
||||
"""Delete SensorGroupEntry."""
|
||||
if group_id in self.sensor_groups:
|
||||
del self.sensor_groups[group_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_sensor_group(
|
||||
self, group_id: str, changes: dict
|
||||
) -> SensorGroupEntry:
|
||||
"""Update existing SensorGroupEntry."""
|
||||
old = self.sensor_groups[group_id]
|
||||
new = self.sensor_groups[group_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
|
||||
async def async_get_registry(hass: HomeAssistant) -> AlarmoStorage:
|
||||
"""Return alarmo storage instance."""
|
||||
task = hass.data.get(DATA_REGISTRY)
|
||||
|
||||
if task is None:
|
||||
|
||||
async def _load_reg() -> AlarmoStorage:
|
||||
registry = AlarmoStorage(hass)
|
||||
await registry.async_load()
|
||||
return registry
|
||||
|
||||
task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg())
|
||||
|
||||
return cast(AlarmoStorage, await task)
|
||||
@@ -0,0 +1,596 @@
|
||||
"""WebSocket handler and registration for Alarmo configuration management."""
|
||||
|
||||
import voluptuous as vol
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
ATTR_CODE,
|
||||
ATTR_NAME,
|
||||
ATTR_STATE,
|
||||
ATTR_SERVICE,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_CODE_FORMAT,
|
||||
CONF_SERVICE_DATA,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.mqtt import (
|
||||
DOMAIN as ATTR_MQTT,
|
||||
)
|
||||
from homeassistant.components.mqtt import (
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
async_dispatcher_connect,
|
||||
)
|
||||
from homeassistant.components.websocket_api import decorators, async_register_command
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
ATTR_CODE_ARM_REQUIRED,
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
|
||||
from . import const
|
||||
from .mqtt import (
|
||||
CONF_EVENT_TOPIC,
|
||||
)
|
||||
from .sensors import (
|
||||
ATTR_GROUP,
|
||||
ATTR_TIMEOUT,
|
||||
SENSOR_TYPES,
|
||||
ATTR_DELAY_ON,
|
||||
ATTR_ENTITIES,
|
||||
ATTR_GROUP_ID,
|
||||
ATTR_ALWAYS_ON,
|
||||
ATTR_ALLOW_OPEN,
|
||||
ATTR_AUTO_BYPASS,
|
||||
ATTR_ENTRY_DELAY,
|
||||
ATTR_EVENT_COUNT,
|
||||
ATTR_ARM_ON_CLOSE,
|
||||
ATTR_NEW_ENTITY_ID,
|
||||
ATTR_USE_EXIT_DELAY,
|
||||
ATTR_USE_ENTRY_DELAY,
|
||||
ATTR_AUTO_BYPASS_MODES,
|
||||
ATTR_TRIGGER_UNAVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@decorators.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "alarmo_config_updated",
|
||||
}
|
||||
)
|
||||
@decorators.async_response
|
||||
async def handle_subscribe_updates(hass, connection, msg):
|
||||
"""Handle subscribe updates."""
|
||||
|
||||
@callback
|
||||
def async_handle_event():
|
||||
"""Forward events to websocket."""
|
||||
connection.send_message(
|
||||
{
|
||||
"id": msg["id"],
|
||||
"type": "event",
|
||||
}
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
|
||||
hass, "alarmo_update_frontend", async_handle_event
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
class AlarmoConfigView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/config"
|
||||
name = "api:alarmo:config"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_CODE_ARM_REQUIRED): cv.boolean,
|
||||
vol.Optional(const.ATTR_CODE_DISARM_REQUIRED): cv.boolean,
|
||||
vol.Optional(
|
||||
const.ATTR_IGNORE_BLOCKING_SENSORS_AFTER_TRIGGER
|
||||
): cv.boolean,
|
||||
vol.Optional(const.ATTR_CODE_MODE_CHANGE_REQUIRED): cv.boolean,
|
||||
vol.Optional(ATTR_CODE_FORMAT): vol.In(
|
||||
[CodeFormat.NUMBER, CodeFormat.TEXT]
|
||||
),
|
||||
vol.Optional(const.ATTR_TRIGGER_TIME): cv.positive_int,
|
||||
vol.Optional(const.ATTR_DISARM_AFTER_TRIGGER): cv.boolean,
|
||||
vol.Optional(ATTR_MQTT): vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Required(CONF_STATE_TOPIC): cv.string,
|
||||
vol.Optional(const.ATTR_STATE_PAYLOAD): vol.Schema(
|
||||
{
|
||||
vol.Optional(const.CONF_ALARM_DISARMED): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_HOME): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_AWAY): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_NIGHT): cv.string,
|
||||
vol.Optional(
|
||||
const.CONF_ALARM_ARMED_CUSTOM_BYPASS
|
||||
): cv.string,
|
||||
vol.Optional(
|
||||
const.CONF_ALARM_ARMED_VACATION
|
||||
): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_PENDING): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMING): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_TRIGGERED): cv.string,
|
||||
}
|
||||
),
|
||||
vol.Required(CONF_COMMAND_TOPIC): cv.string,
|
||||
vol.Optional(const.ATTR_COMMAND_PAYLOAD): vol.Schema(
|
||||
{
|
||||
vol.Optional(const.COMMAND_ARM_AWAY): cv.string,
|
||||
vol.Optional(const.COMMAND_ARM_HOME): cv.string,
|
||||
vol.Optional(const.COMMAND_ARM_NIGHT): cv.string,
|
||||
vol.Optional(
|
||||
const.COMMAND_ARM_CUSTOM_BYPASS
|
||||
): cv.string,
|
||||
vol.Optional(const.COMMAND_ARM_VACATION): cv.string,
|
||||
vol.Optional(const.COMMAND_DISARM): cv.string,
|
||||
}
|
||||
),
|
||||
vol.Required(const.ATTR_REQUIRE_CODE): cv.boolean,
|
||||
vol.Required(CONF_EVENT_TOPIC): cv.string,
|
||||
}
|
||||
),
|
||||
vol.Optional(const.ATTR_MASTER): vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
await coordinator.async_update_config(data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoAreaView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/area"
|
||||
name = "api:alarmo:area"
|
||||
|
||||
mode_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Required(const.ATTR_EXIT_TIME): vol.Any(cv.positive_int, None),
|
||||
vol.Required(const.ATTR_ENTRY_TIME): vol.Any(cv.positive_int, None),
|
||||
vol.Optional(const.ATTR_TRIGGER_TIME): vol.Any(cv.positive_int, None),
|
||||
}
|
||||
)
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("area_id"): cv.string,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
vol.Optional(const.ATTR_MODES): vol.Schema(
|
||||
{
|
||||
vol.Optional(const.CONF_ALARM_ARMED_AWAY): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_HOME): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_NIGHT): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_CUSTOM_BYPASS): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_VACATION): mode_schema,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
if "area_id" in data:
|
||||
area = data["area_id"]
|
||||
del data["area_id"]
|
||||
else:
|
||||
area = None
|
||||
await coordinator.async_update_area_config(area, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoSensorView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/sensors"
|
||||
name = "api:alarmo:sensors"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
vol.Optional(const.ATTR_TYPE): vol.In(SENSOR_TYPES),
|
||||
vol.Optional(const.ATTR_MODES): vol.All(
|
||||
cv.ensure_list, [vol.In(const.ARM_MODES)]
|
||||
),
|
||||
vol.Optional(ATTR_USE_EXIT_DELAY): cv.boolean,
|
||||
vol.Optional(ATTR_USE_ENTRY_DELAY): cv.boolean,
|
||||
vol.Optional(ATTR_ARM_ON_CLOSE): cv.boolean,
|
||||
vol.Optional(ATTR_ALLOW_OPEN): cv.boolean,
|
||||
vol.Optional(ATTR_ALWAYS_ON): cv.boolean,
|
||||
vol.Optional(ATTR_TRIGGER_UNAVAILABLE): cv.boolean,
|
||||
vol.Optional(ATTR_AUTO_BYPASS): cv.boolean,
|
||||
vol.Optional(ATTR_AUTO_BYPASS_MODES): vol.All(
|
||||
cv.ensure_list, [vol.In(const.ARM_MODES)]
|
||||
),
|
||||
vol.Optional(const.ATTR_AREA): cv.string,
|
||||
vol.Optional(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_GROUP): vol.Any(cv.string, None),
|
||||
vol.Optional(ATTR_ENTRY_DELAY): vol.Any(cv.positive_int, None),
|
||||
vol.Optional(ATTR_DELAY_ON): vol.Any(cv.positive_int, None),
|
||||
vol.Optional(ATTR_NEW_ENTITY_ID): cv.string,
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
entity = data[ATTR_ENTITY_ID]
|
||||
del data[ATTR_ENTITY_ID]
|
||||
coordinator.async_update_sensor_config(entity, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoUserView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/users"
|
||||
name = "api:alarmo:users"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(const.ATTR_USER_ID): cv.string,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_CODE): cv.string,
|
||||
vol.Optional(const.ATTR_OLD_CODE): cv.string,
|
||||
vol.Optional(const.ATTR_CAN_ARM): cv.boolean,
|
||||
vol.Optional(const.ATTR_CAN_DISARM): cv.boolean,
|
||||
vol.Optional(const.ATTR_IS_OVERRIDE_CODE): cv.boolean,
|
||||
vol.Optional(const.ATTR_AREA_LIMIT): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
user_id = None
|
||||
if const.ATTR_USER_ID in data:
|
||||
user_id = data[const.ATTR_USER_ID]
|
||||
del data[const.ATTR_USER_ID]
|
||||
err = await coordinator.async_update_user_config(user_id, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": not isinstance(err, str), "error": err})
|
||||
|
||||
|
||||
class AlarmoAutomationView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/automations"
|
||||
name = "api:alarmo:automations"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(const.ATTR_AUTOMATION_ID): cv.string,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(const.ATTR_TYPE): cv.string,
|
||||
vol.Optional(const.ATTR_TRIGGERS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.Any(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_EVENT): cv.string,
|
||||
vol.Optional(const.ATTR_AREA): vol.Any(
|
||||
int,
|
||||
cv.string,
|
||||
),
|
||||
vol.Optional(const.ATTR_MODES): vol.All(
|
||||
cv.ensure_list, [vol.In(const.ARM_MODES)]
|
||||
),
|
||||
}
|
||||
),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.string,
|
||||
vol.Required(ATTR_STATE): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
vol.Optional(const.ATTR_ACTIONS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.string,
|
||||
vol.Required(ATTR_SERVICE): cv.string,
|
||||
vol.Optional(CONF_SERVICE_DATA): dict,
|
||||
}
|
||||
)
|
||||
],
|
||||
),
|
||||
vol.Optional(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
automation_id = None
|
||||
if const.ATTR_AUTOMATION_ID in data:
|
||||
automation_id = data[const.ATTR_AUTOMATION_ID]
|
||||
del data[const.ATTR_AUTOMATION_ID]
|
||||
coordinator.async_update_automation_config(automation_id, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoSensorGroupView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/sensor_groups"
|
||||
name = "api:alarmo:sensor_groups"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_GROUP_ID): cv.string,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(ATTR_ENTITIES): vol.All(
|
||||
cv.ensure_list, vol.Unique(), [cv.string]
|
||||
),
|
||||
vol.Optional(ATTR_TIMEOUT): cv.positive_int,
|
||||
vol.Optional(ATTR_EVENT_COUNT): cv.positive_int,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
group_id = None
|
||||
if ATTR_GROUP_ID in data:
|
||||
group_id = data[ATTR_GROUP_ID]
|
||||
del data[ATTR_GROUP_ID]
|
||||
coordinator.async_update_sensor_group_config(group_id, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_config(hass, connection, msg):
|
||||
"""Publish config data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
config = coordinator.store.async_get_config()
|
||||
connection.send_result(msg["id"], config)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_areas(hass, connection, msg):
|
||||
"""Publish area data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
areas = coordinator.store.async_get_areas()
|
||||
connection.send_result(msg["id"], areas)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_sensors(hass, connection, msg):
|
||||
"""Publish sensor data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
sensors = coordinator.store.async_get_sensors()
|
||||
for entity_id in sensors.keys():
|
||||
group = coordinator.async_get_group_for_sensor(entity_id)
|
||||
sensors[entity_id]["group"] = group
|
||||
connection.send_result(msg["id"], sensors)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_users(hass, connection, msg):
|
||||
"""Publish user data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
users = coordinator.store.async_get_users()
|
||||
connection.send_result(msg["id"], users)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_automations(hass, connection, msg):
|
||||
"""Publish automations data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
automations = coordinator.store.async_get_automations()
|
||||
connection.send_result(msg["id"], automations)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_alarm_entities(hass, connection, msg):
|
||||
"""Publish alarm entity data."""
|
||||
result = [
|
||||
{"entity_id": entity.entity_id, "area_id": area_id}
|
||||
for (area_id, entity) in hass.data[const.DOMAIN]["areas"].items()
|
||||
]
|
||||
if hass.data[const.DOMAIN]["master"]:
|
||||
result.append(
|
||||
{"entity_id": hass.data[const.DOMAIN]["master"].entity_id, "area_id": 0}
|
||||
)
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_sensor_groups(hass, connection, msg):
|
||||
"""Publish sensor_group data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
groups = coordinator.store.async_get_sensor_groups()
|
||||
connection.send_result(msg["id"], groups)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_countdown(hass, connection, msg):
|
||||
"""Publish countdown time for alarm entity."""
|
||||
entity_id = msg["entity_id"]
|
||||
item = next(
|
||||
(
|
||||
entity
|
||||
for entity in hass.data[const.DOMAIN]["areas"].values()
|
||||
if entity.entity_id == entity_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
hass.data[const.DOMAIN]["master"]
|
||||
and not item
|
||||
and hass.data[const.DOMAIN]["master"].entity_id == entity_id
|
||||
):
|
||||
item = hass.data[const.DOMAIN]["master"]
|
||||
|
||||
data = {
|
||||
"delay": item.delay if item else 0,
|
||||
"remaining": round((item.expiration - dt_util.utcnow()).total_seconds(), 2)
|
||||
if item and item.expiration
|
||||
else 0,
|
||||
}
|
||||
connection.send_result(msg["id"], data)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_ready_to_arm_modes(hass, connection, msg):
|
||||
"""Publish ready_to_arm_modes for alarm entity."""
|
||||
entity_id = msg["entity_id"]
|
||||
item = next(
|
||||
(
|
||||
entity
|
||||
for entity in hass.data[const.DOMAIN]["areas"].values()
|
||||
if entity.entity_id == entity_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
hass.data[const.DOMAIN]["master"]
|
||||
and not item
|
||||
and hass.data[const.DOMAIN]["master"].entity_id == entity_id
|
||||
):
|
||||
item = hass.data[const.DOMAIN]["master"]
|
||||
|
||||
data = {"modes": item._ready_to_arm_modes if item else None}
|
||||
connection.send_result(msg["id"], data)
|
||||
|
||||
|
||||
async def async_register_websockets(hass):
|
||||
"""Register websocket handlers."""
|
||||
hass.http.register_view(AlarmoConfigView)
|
||||
hass.http.register_view(AlarmoSensorView)
|
||||
hass.http.register_view(AlarmoUserView)
|
||||
hass.http.register_view(AlarmoAutomationView)
|
||||
hass.http.register_view(AlarmoAreaView)
|
||||
hass.http.register_view(AlarmoSensorGroupView)
|
||||
|
||||
async_register_command(hass, handle_subscribe_updates)
|
||||
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/config",
|
||||
websocket_get_config,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/config"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/areas",
|
||||
websocket_get_areas,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/areas"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/sensors",
|
||||
websocket_get_sensors,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/sensors"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/users",
|
||||
websocket_get_users,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/users"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/automations",
|
||||
websocket_get_automations,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/automations"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/entities",
|
||||
websocket_get_alarm_entities,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/entities"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/sensor_groups",
|
||||
websocket_get_sensor_groups,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/sensor_groups"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/countdown",
|
||||
websocket_get_countdown,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): "alarmo/countdown",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/ready_to_arm_modes",
|
||||
websocket_get_ready_to_arm_modes,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): "alarmo/ready_to_arm_modes",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user