"""Support for Minut Point.""" import asyncio import logging from pypoint import PointSession import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, CONF_WEBHOOK_ID, ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp from . import config_flow from .const import ( CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, POINT_DISCOVERY_NEW, SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK, ) _LOGGER = logging.getLogger(__name__) DATA_CONFIG_ENTRY_LOCK = "point_config_entry_lock" CONFIG_ENTRY_IS_SETUP = "point_config_entry_is_setup" CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, } ) }, extra=vol.ALLOW_EXTRA, ) async def async_setup(hass, config): """Set up the Minut Point component.""" if DOMAIN not in config: return True conf = config[DOMAIN] config_flow.register_flow_implementation( hass, DOMAIN, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET] ) hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) ) return True async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Set up Point from a config entry.""" def token_saver(token): _LOGGER.debug("Saving updated token") hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_TOKEN: token} ) # Force token update. entry.data[CONF_TOKEN]["expires_in"] = -1 session = PointSession( entry.data["refresh_args"][CONF_CLIENT_ID], token=entry.data[CONF_TOKEN], auto_refresh_kwargs=entry.data["refresh_args"], token_saver=token_saver, ) if not session.is_authorized: _LOGGER.error("Authentication Error") return False hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() hass.data[CONFIG_ENTRY_IS_SETUP] = set() await async_setup_webhook(hass, entry, session) client = MinutPointClient(hass, entry, session) hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) hass.async_create_task(client.update()) return True async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, session): """Set up a webhook to handle binary sensor events.""" if CONF_WEBHOOK_ID not in entry.data: webhook_id = hass.components.webhook.async_generate_id() webhook_url = hass.components.webhook.async_generate_url(webhook_id) _LOGGER.info("Registering new webhook at: %s", webhook_url) hass.config_entries.async_update_entry( entry, data={ **entry.data, CONF_WEBHOOK_ID: webhook_id, CONF_WEBHOOK_URL: webhook_url, }, ) await hass.async_add_executor_job( session.update_webhook, entry.data[CONF_WEBHOOK_URL], entry.data[CONF_WEBHOOK_ID], ["*"], ) hass.components.webhook.async_register( DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook ) async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) session = hass.data[DOMAIN].pop(entry.entry_id) await hass.async_add_executor_job(session.remove_webhook) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) for component in ("binary_sensor", "sensor"): await hass.config_entries.async_forward_entry_unload(entry, component) return True async def handle_webhook(hass, webhook_id, request): """Handle webhook callback.""" try: data = await request.json() _LOGGER.debug("Webhook %s: %s", webhook_id, data) except ValueError: return None if isinstance(data, dict): data["webhook_id"] = webhook_id async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get("hook_id")) hass.bus.async_fire(EVENT_RECEIVED, data) class MinutPointClient: """Get the latest data and update the states.""" def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry, session): """Initialize the Minut data object.""" self._known_devices = set() self._known_homes = set() self._hass = hass self._config_entry = config_entry self._is_available = True self._client = session async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) async def update(self, *args): """Periodically poll the cloud for current state.""" await self._sync() async def _sync(self): """Update local list of devices.""" if ( not await self._hass.async_add_executor_job(self._client.update) and self._is_available ): self._is_available = False _LOGGER.warning("Device is unavailable") return async def new_device(device_id, component): """Load new device.""" config_entries_key = f"{component}.{DOMAIN}" async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: if config_entries_key not in self._hass.data[CONFIG_ENTRY_IS_SETUP]: await self._hass.config_entries.async_forward_entry_setup( self._config_entry, component ) self._hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) async_dispatcher_send( self._hass, POINT_DISCOVERY_NEW.format(component, DOMAIN), device_id ) self._is_available = True for home_id in self._client.homes: if home_id not in self._known_homes: await new_device(home_id, "alarm_control_panel") self._known_homes.add(home_id) for device in self._client.devices: if device.device_id not in self._known_devices: for component in ("sensor", "binary_sensor"): await new_device(device.device_id, component) self._known_devices.add(device.device_id) async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) def device(self, device_id): """Return device representation.""" return self._client.device(device_id) def is_available(self, device_id): """Return device availability.""" return device_id in self._client.device_ids def remove_webhook(self): """Remove the session webhook.""" return self._client.remove_webhook() @property def homes(self): """Return known homes.""" return self._client.homes def alarm_disarm(self, home_id): """Send alarm disarm command.""" return self._client.alarm_disarm(home_id) def alarm_arm(self, home_id): """Send alarm arm command.""" return self._client.alarm_arm(home_id) class MinutPointEntity(Entity): """Base Entity used by the sensors.""" def __init__(self, point_client, device_id, device_class): """Initialize the entity.""" self._async_unsub_dispatcher_connect = None self._client = point_client self._id = device_id self._name = self.device.name self._device_class = device_class self._updated = utc_from_timestamp(0) self._value = None def __str__(self): """Return string representation of device.""" return f"MinutPoint {self.name}" async def async_added_to_hass(self): """Call when entity is added to hass.""" _LOGGER.debug("Created device %s", self) self._async_unsub_dispatcher_connect = async_dispatcher_connect( self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback ) await self._update_callback() async def async_will_remove_from_hass(self): """Disconnect dispatcher listener when removed.""" if self._async_unsub_dispatcher_connect: self._async_unsub_dispatcher_connect() async def _update_callback(self): """Update the value of the sensor.""" @property def available(self): """Return true if device is not offline.""" return self._client.is_available(self.device_id) @property def device(self): """Return the representation of the device.""" return self._client.device(self.device_id) @property def device_class(self): """Return the device class.""" return self._device_class @property def device_id(self): """Return the id of the device.""" return self._id @property def device_state_attributes(self): """Return status of device.""" attrs = self.device.device_status attrs["last_heard_from"] = as_local(self.last_update).strftime( "%Y-%m-%d %H:%M:%S" ) return attrs @property def device_info(self): """Return a device description for device registry.""" device = self.device.device return { "connections": {("mac", device["device_mac"])}, "identifieres": device["device_id"], "manufacturer": "Minut", "model": f"Point v{device['hardware_version']}", "name": device["description"], "sw_version": device["firmware"]["installed"], "via_device": (DOMAIN, device["home"]), } @property def name(self): """Return the display name of this device.""" return f"{self._name} {self.device_class.capitalize()}" @property def is_updated(self): """Return true if sensor have been updated.""" return self.last_update > self._updated @property def last_update(self): """Return the last_update time for the device.""" last_update = parse_datetime(self.device.last_update) return last_update @property def should_poll(self): """No polling needed for point.""" return False @property def unique_id(self): """Return the unique id of the sensor.""" return f"point.{self._id}-{self.device_class}" @property def value(self): """Return the sensor value.""" return self._value