From 28b5de0005b603e4f2d053a0c88468080603305e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 24 Oct 2020 17:49:25 -0400 Subject: [PATCH 01/30] Informix UltraSync Hub Support Added --- CODEOWNERS | 1 + .../components/ultrasync/__init__.py | 169 +++++++ .../components/ultrasync/config_flow.py | 131 ++++++ homeassistant/components/ultrasync/const.py | 10 + .../components/ultrasync/coordinator.py | 94 ++++ .../components/ultrasync/device_tracker.py | 440 ++++++++++++++++++ .../components/ultrasync/manifest.json | 8 + homeassistant/components/ultrasync/sensor.py | 87 ++++ .../components/ultrasync/services.yaml | 10 + .../components/ultrasync/strings.json | 32 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + 12 files changed, 988 insertions(+) create mode 100644 homeassistant/components/ultrasync/__init__.py create mode 100644 homeassistant/components/ultrasync/config_flow.py create mode 100644 homeassistant/components/ultrasync/const.py create mode 100644 homeassistant/components/ultrasync/coordinator.py create mode 100644 homeassistant/components/ultrasync/device_tracker.py create mode 100644 homeassistant/components/ultrasync/manifest.json create mode 100644 homeassistant/components/ultrasync/sensor.py create mode 100644 homeassistant/components/ultrasync/services.yaml create mode 100644 homeassistant/components/ultrasync/strings.json diff --git a/CODEOWNERS b/CODEOWNERS index 18439b3bb83c1..b97d87653eb2d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -463,6 +463,7 @@ homeassistant/components/tts/* @pvizeli homeassistant/components/tuya/* @ollo69 homeassistant/components/twentemilieu/* @frenck homeassistant/components/ubee/* @mzdrale +homeassistant/components/ultrasync/* @caronc homeassistant/components/unifi/* @Kane610 homeassistant/components/unifiled/* @florisvdk homeassistant/components/upb/* @gwww diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py new file mode 100644 index 0000000000000..2369e15c31227 --- /dev/null +++ b/homeassistant/components/ultrasync/__init__.py @@ -0,0 +1,169 @@ +"""The Informix UltraSync Hub component.""" + +import asyncio + +import voluptuous as vol + +from ultrasync import AlarmScene +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + DOMAIN, + SERVICE_AWAY, + SERVICE_STAY, + SERVICE_DISARM, + DEFAULT_SCAN_INTERVAL, +) + +from homeassistant.const import ( + DATA_COORDINATOR, + DATA_UNDO_UPDATE_LISTENER, + CONF_HOST, + CONF_ID, + CONF_PIN, + CONF_SCAN_INTERVAL, +) + +from .coordinator import UltraSyncDataUpdateCoordinator + +PLATFORMS = ["sensor"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Optional(CONF_ID): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: dict) -> bool: + """Set up the UltraSync integration.""" + hass.data.setdefault(DOMAIN, {}) + + if hass.config_entries.async_entries(DOMAIN): + return True + + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up UltraSync from a config entry.""" + if not entry.options: + options = { + CONF_SCAN_INTERVAL: entry.data.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + } + hass.config_entries.async_update_entry(entry, options=options) + + coordinator = UltraSyncDataUpdateCoordinator( + hass, + config=entry.data, + options=entry.options, + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + undo_listener = entry.add_update_listener(_async_update_listener) + + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + DATA_UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + _async_register_services(hass, coordinator) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +def _async_register_services( + hass: HomeAssistantType, + coordinator: UltraSyncDataUpdateCoordinator, +) -> None: + """Register integration-level services.""" + + def away(call) -> None: + """Service call to set alarm system to 'away' mode in UltraSync Hub.""" + coordinator.hub.set(state=AlarmScene.AWAY) + + def stay(call) -> None: + """Service call to set alarm system to 'stay' mode in UltraSync Hub.""" + coordinator.hub.set(state=AlarmScene.STAY) + + def disarm(call) -> None: + """Service call to disable alarm in UltraSync Hub.""" + coordinator.hub.set(state=AlarmScene.DISARMED) + + hass.services.async_register(DOMAIN, SERVICE_AWAY, away, schema=vol.Schema({})) + hass.services.async_register(DOMAIN, SERVICE_STAY, stay, schema=vol.Schema({})) + hass.services.async_register(DOMAIN, SERVICE_DISARM, disarm, schema=vol.Schema({})) + + +async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class UltraSyncEntity(CoordinatorEntity): + """Defines a base UltraSync entity.""" + + def __init__( + self, *, entry_id: str, name: str, coordinator: UltraSyncDataUpdateCoordinator + ) -> None: + """Initialize the UltraSync entity.""" + super().__init__(coordinator) + self._name = name + self._entry_id = entry_id + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py new file mode 100644 index 0000000000000..5c3f0137194e1 --- /dev/null +++ b/homeassistant/components/ultrasync/config_flow.py @@ -0,0 +1,131 @@ +"""Config flow for Informix Ultrasync Hub.""" +import logging +from typing import Any, Dict, Optional +from ultrasync import UltraSync +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_SCAN_INTERVAL, + CONF_HOST, + CONF_PIN, + CONF_ID, +) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant import config_entries + +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: + """Validate the user input allows us to connect. + + """ + data[CONF_ID] + data[CONF_PIN] + data[CONF_HOST] + usync = UltraSync() + + # validate by attempting to authenticate with our hub + return usync.login() + + return True + + +class UltraSyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """UltraSync config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return UltraSyncOptionsFlowHandler(config_entry) + + async def async_step_import( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by configuration file.""" + if CONF_SCAN_INTERVAL in user_input: + user_input[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL].seconds + + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + validate_input, self.hass, user_input + ) + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_ID): str, + vol.Required(CONF_PIN): str, + }), + errors=errors, + ) + + async def async_step_init(self, user_input: Optional[ConfigType] = None): + """Manage UltraSync options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + + +class UltraSyncOptionsFlowHandler(config_entries.OptionsFlow): + """Handle UltraSync client options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input: Optional[ConfigType] = None): + """Manage UltraSync options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py new file mode 100644 index 0000000000000..b9c1779fad6e9 --- /dev/null +++ b/homeassistant/components/ultrasync/const.py @@ -0,0 +1,10 @@ +"""Constants for Informix UltraSync Hub""" +DOMAIN = "ultrasync" + +# Scan Time (in seconds) +DEFAULT_SCAN_INTERVAL = 5 + +# Services +SERVICE_AWAY = "away" +SERVICE_STAY = "stay" +SERVICE_DISARM = "disarm" diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py new file mode 100644 index 0000000000000..3c9be308b79c6 --- /dev/null +++ b/homeassistant/components/ultrasync/coordinator.py @@ -0,0 +1,94 @@ +"""Provides the UltraSync DataUpdateCoordinator.""" +from datetime import timedelta +import logging + +from ultrasync import UltraSync + +from async_timeout import timeout + +from homeassistant.const import ( + CONF_SCAN_INTERVAL, + CONF_PIN, + CONF_ID, + CONF_HOST, +) +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class UltraSyncDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching UltraSync data.""" + + def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): + """Initialize global UltraSync data updater.""" + self.hub = UltraSync( + user=config[CONF_ID], + pin=config[CONF_PIN], + host=config[CONF_HOST], + ) + + self._init = False + + # Used to track delta (for change tracking) + self._area_delta = {} + self._zone_delta = {} + + update_interval = timedelta(seconds=options[CONF_SCAN_INTERVAL]) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict: + """Fetch data from UltraSync Hub.""" + + def _update_data() -> dict: + """Fetch data from UltraSync via sync functions.""" + + # Update our details + + response = self.hub.details() + if response: + for bank, zone in self.hub.zones.items(): + if self._zone_delta.get(zone['bank']) != zone['sequence']: + self.hass.bus.fire( + "ultrasync_sensor_update", + { + "sensor": zone['bank'] + 1, + "name": zone['name'], + "status": zone['status'], + }, + ) + + # Update our sequence + self._zone_delta[zone['bank']] = zone['sequence'] + + for area in response.get('areas', []): + if self._area_delta.get(area['bank']) != area['sequence']: + self.hass.bus.fire( + "ultrasync_area_update", + { + "area": area['bank'] + 1, + "name": area['name'], + "status": area['status'], + }, + ) + + # Update our sequence + self._area_delta[area['bank']] = area['sequence'] + + self._init = True + + return response if response else {} + + # The hub can sometimes take a very long time to respond; wait + # 10 seconds before giving up + async with timeout(10): + return await self.hass.async_add_executor_job(_update_data) diff --git a/homeassistant/components/ultrasync/device_tracker.py b/homeassistant/components/ultrasync/device_tracker.py new file mode 100644 index 0000000000000..31383cbbdf5d3 --- /dev/null +++ b/homeassistant/components/ultrasync/device_tracker.py @@ -0,0 +1,440 @@ +"""Support for Ultrasync device tracking.""" +from datetime import datetime, timedelta +import logging + +from pytraccar.api import API +from stringcase import camelcase +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.const import ( + CONF_EVENT, + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify + +from . import DOMAIN, TRACKER_UPDATE +from .const import ( + ATTR_ACCURACY, + ATTR_ADDRESS, + ATTR_ALTITUDE, + ATTR_BATTERY, + ATTR_BEARING, + ATTR_CATEGORY, + ATTR_GEOFENCE, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MOTION, + ATTR_SPEED, + ATTR_STATUS, + ATTR_TRACCAR_ID, + ATTR_TRACKER, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_ON, + EVENT_ALARM, + EVENT_ALL_EVENTS, + EVENT_COMMAND_RESULT, + EVENT_DEVICE_FUEL_DROP, + EVENT_DEVICE_MOVING, + EVENT_DEVICE_OFFLINE, + EVENT_DEVICE_ONLINE, + EVENT_DEVICE_OVERSPEED, + EVENT_DEVICE_STOPPED, + EVENT_DEVICE_UNKNOWN, + EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_ENTER, + EVENT_GEOFENCE_EXIT, + EVENT_IGNITION_OFF, + EVENT_IGNITION_ON, + EVENT_MAINTENANCE, + EVENT_TEXT_MESSAGE, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=8082): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_MAX_ACCURACY, default=0): cv.positive_int, + vol.Optional(CONF_SKIP_ACCURACY_ON, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EVENT, default=[]): vol.All( + cv.ensure_list, + [ + vol.Any( + EVENT_DEVICE_MOVING, + EVENT_COMMAND_RESULT, + EVENT_DEVICE_FUEL_DROP, + EVENT_GEOFENCE_ENTER, + EVENT_DEVICE_OFFLINE, + EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_EXIT, + EVENT_DEVICE_OVERSPEED, + EVENT_DEVICE_ONLINE, + EVENT_DEVICE_STOPPED, + EVENT_MAINTENANCE, + EVENT_ALARM, + EVENT_TEXT_MESSAGE, + EVENT_DEVICE_UNKNOWN, + EVENT_IGNITION_OFF, + EVENT_IGNITION_ON, + EVENT_ALL_EVENTS, + ) + ], + ), + } +) + + +async def async_setup_entry(hass: HomeAssistantType, entry, async_add_entities): + """Configure a dispatcher connection based on a config entry.""" + + @callback + def _receive_data(device, latitude, longitude, battery, accuracy, attrs): + """Receive set location.""" + if device in hass.data[DOMAIN]["devices"]: + return + + hass.data[DOMAIN]["devices"].add(device) + + async_add_entities( + [TraccarEntity(device, latitude, longitude, battery, accuracy, attrs)] + ) + + hass.data[DOMAIN]["unsub_device_tracker"][ + entry.entry_id + ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == DOMAIN + } + if not dev_ids: + return + + entities = [] + for dev_id in dev_ids: + hass.data[DOMAIN]["devices"].add(dev_id) + entity = TraccarEntity(dev_id, None, None, None, None, None) + entities.append(entity) + + async_add_entities(entities) + + +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Validate the configuration and return a Traccar scanner.""" + + session = async_get_clientsession(hass, config[CONF_VERIFY_SSL]) + + api = API( + hass.loop, + session, + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_HOST], + config[CONF_PORT], + config[CONF_SSL], + ) + + scanner = TraccarScanner( + api, + hass, + async_see, + config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), + config[CONF_MAX_ACCURACY], + config[CONF_SKIP_ACCURACY_ON], + config[CONF_MONITORED_CONDITIONS], + config[CONF_EVENT], + ) + + return await scanner.async_init() + + +class TraccarScanner: + """Define an object to retrieve Traccar data.""" + + def __init__( + self, + api, + hass, + async_see, + scan_interval, + max_accuracy, + skip_accuracy_on, + custom_attributes, + event_types, + ): + """Initialize.""" + + self._event_types = {camelcase(evt): evt for evt in event_types} + self._custom_attributes = custom_attributes + self._scan_interval = scan_interval + self._async_see = async_see + self._api = api + self.connected = False + self._hass = hass + self._max_accuracy = max_accuracy + self._skip_accuracy_on = skip_accuracy_on + + async def async_init(self): + """Further initialize connection to Traccar.""" + await self._api.test_connection() + if self._api.connected and not self._api.authenticated: + _LOGGER.error("Authentication for Traccar failed") + return False + + await self._async_update() + async_track_time_interval(self._hass, self._async_update, self._scan_interval) + return True + + async def _async_update(self, now=None): + """Update info from Traccar.""" + if not self.connected: + _LOGGER.debug("Testing connection to Traccar") + await self._api.test_connection() + self.connected = self._api.connected + if self.connected: + _LOGGER.info("Connection to Traccar restored") + else: + return + _LOGGER.debug("Updating device data") + await self._api.get_device_info(self._custom_attributes) + self._hass.async_create_task(self.import_device_data()) + if self._event_types: + self._hass.async_create_task(self.import_events()) + self.connected = self._api.connected + + async def import_device_data(self): + """Import device data from Traccar.""" + for device_unique_id in self._api.device_info: + device_info = self._api.device_info[device_unique_id] + device = None + attr = {} + skip_accuracy_filter = False + + attr[ATTR_TRACKER] = "traccar" + if device_info.get("address") is not None: + attr[ATTR_ADDRESS] = device_info["address"] + if device_info.get("geofence") is not None: + attr[ATTR_GEOFENCE] = device_info["geofence"] + if device_info.get("category") is not None: + attr[ATTR_CATEGORY] = device_info["category"] + if device_info.get("speed") is not None: + attr[ATTR_SPEED] = device_info["speed"] + if device_info.get("motion") is not None: + attr[ATTR_MOTION] = device_info["motion"] + if device_info.get("traccar_id") is not None: + attr[ATTR_TRACCAR_ID] = device_info["traccar_id"] + for dev in self._api.devices: + if dev["id"] == device_info["traccar_id"]: + device = dev + break + if device is not None and device.get("status") is not None: + attr[ATTR_STATUS] = device["status"] + for custom_attr in self._custom_attributes: + if device_info.get(custom_attr) is not None: + attr[custom_attr] = device_info[custom_attr] + if custom_attr in self._skip_accuracy_on: + skip_accuracy_filter = True + + accuracy = 0.0 + if device_info.get("accuracy") is not None: + accuracy = device_info["accuracy"] + if ( + not skip_accuracy_filter + and self._max_accuracy > 0 + and accuracy > self._max_accuracy + ): + _LOGGER.debug( + "Excluded position by accuracy filter: %f (%s)", + accuracy, + attr[ATTR_TRACCAR_ID], + ) + continue + + await self._async_see( + dev_id=slugify(device_info["device_id"]), + gps=(device_info.get("latitude"), device_info.get("longitude")), + gps_accuracy=accuracy, + battery=device_info.get("battery"), + attributes=attr, + ) + + async def import_events(self): + """Import events from Traccar.""" + device_ids = [device["id"] for device in self._api.devices] + end_interval = datetime.utcnow() + start_interval = end_interval - self._scan_interval + events = await self._api.get_events( + device_ids=device_ids, + from_time=start_interval, + to_time=end_interval, + event_types=self._event_types.keys(), + ) + if events is not None: + for event in events: + device_name = next( + ( + dev.get("name") + for dev in self._api.devices + if dev.get("id") == event["deviceId"] + ), + None, + ) + self._hass.bus.async_fire( + f"traccar_{self._event_types.get(event['type'])}", + { + "device_traccar_id": event["deviceId"], + "device_name": device_name, + "type": event["type"], + "serverTime": event["serverTime"], + "attributes": event["attributes"], + }, + ) + + +class TraccarEntity(TrackerEntity, RestoreEntity): + """Represent a tracked device.""" + + def __init__(self, device, latitude, longitude, battery, accuracy, attributes): + """Set up Geofency entity.""" + self._accuracy = accuracy + self._attributes = attributes + self._name = device + self._battery = battery + self._latitude = latitude + self._longitude = longitude + self._unsub_dispatcher = None + self._unique_id = device + + @property + def battery_level(self): + """Return battery value of the device.""" + return self._battery + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._longitude + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return {"name": self._name, "identifiers": {(DOMAIN, self._unique_id)}} + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + await super().async_added_to_hass() + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data + ) + + # don't restore if we got created with data + if self._latitude is not None or self._longitude is not None: + return + + state = await self.async_get_last_state() + if state is None: + self._latitude = None + self._longitude = None + self._accuracy = None + self._attributes = { + ATTR_ALTITUDE: None, + ATTR_BEARING: None, + ATTR_SPEED: None, + } + self._battery = None + return + + attr = state.attributes + self._latitude = attr.get(ATTR_LATITUDE) + self._longitude = attr.get(ATTR_LONGITUDE) + self._accuracy = attr.get(ATTR_ACCURACY) + self._attributes = { + ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), + ATTR_BEARING: attr.get(ATTR_BEARING), + ATTR_SPEED: attr.get(ATTR_SPEED), + } + self._battery = attr.get(ATTR_BATTERY) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() + self._unsub_dispatcher() + + @callback + def _async_receive_data( + self, device, latitude, longitude, battery, accuracy, attributes + ): + """Mark the device as seen.""" + if device != self.name: + return + + self._latitude = latitude + self._longitude = longitude + self._battery = battery + self._accuracy = accuracy + self._attributes.update(attributes) + self.async_write_ha_state() diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json new file mode 100644 index 0000000000000..0343dfba1e34f --- /dev/null +++ b/homeassistant/components/ultrasync/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ultrasync", + "name": "Informix Ultrasync Hub", + "documentation": "https://www.home-assistant.io/integrations/ultrasync", + "requirements": ["ultrasync==0.8.0"], + "codeowners": ["@caronc"], + "config_flow": true +} diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py new file mode 100644 index 0000000000000..dfcd0b058ec92 --- /dev/null +++ b/homeassistant/components/ultrasync/sensor.py @@ -0,0 +1,87 @@ +"""Monitor the Informix UltraSync Hub""" + +import logging +from homeassistant.const import CONF_ID +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from typing import Callable, List, Optional + +from . import UltraSyncEntity +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import UltraSyncDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + "area01_state": ["Area1State", "Area 1 State", None], + "area02_state": ["Area2State", "Area 2 State", None], + "area03_state": ["Area3State", "Area 3 State", None], + "area04_state": ["Area4State", "Area 4 State", None], +} + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up UltraSync sensor based on a config entry.""" + coordinator: UltraSyncDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + sensors = [] + + for sensor_config in SENSOR_TYPES.values(): + sensors.append( + UltraSyncSensor( + coordinator, + entry.entry_id, + entry.data[CONF_ID], + sensor_config[0], + sensor_config[1], + sensor_config[2], + ) + ) + + async_add_entities(sensors) + + +class UltraSyncSensor(UltraSyncEntity): + """Representation of a UltraSync sensor.""" + + def __init__( + self, + coordinator: UltraSyncDataUpdateCoordinator, + entry_id: str, + entry_name: str, + sensor_type: str, + sensor_name: str, + unit_of_measurement: Optional[str] = None, + ): + """Initialize a new UltraSync sensor.""" + self._sensor_type = sensor_type + self._unique_id = f"{entry_id}_{sensor_type}" + self._unit_of_measurement = unit_of_measurement + + super().__init__( + coordinator=coordinator, + entry_id=entry_id, + name=f"{entry_name} {sensor_name}", + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._unique_id + + @property + def state(self): + """Return the state of the sensor.""" + value = self.coordinator.data["status"].get(self._sensor_type) + + if value is None: + _LOGGER.warning("Unable to locate value for %s", self._sensor_type) + return None + + return value diff --git a/homeassistant/components/ultrasync/services.yaml b/homeassistant/components/ultrasync/services.yaml new file mode 100644 index 0000000000000..4e337458948ec --- /dev/null +++ b/homeassistant/components/ultrasync/services.yaml @@ -0,0 +1,10 @@ +# Describes the format for available ultrasync services + +away: + description: Set Alarm to Away + +stay: + description: Set the Alarm to Stay + +disarm: + description: Disarm the Alarm diff --git a/homeassistant/components/ultrasync/strings.json b/homeassistant/components/ultrasync/strings.json new file mode 100644 index 0000000000000..808fe70e4df46 --- /dev/null +++ b/homeassistant/components/ultrasync/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "flow_title": "UltraSync Hub: {name}", + "step": { + "user": { + "title": "Connect to UltraSync Hub", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "host": "[%key:common::config_flow::data::host%]", + "id": "[%key:common::config_flow::data::id%]", + "pin": "[%key:common::config_flow::data::pin%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency (seconds)" + } + } + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index b2a4502d94e1a..6fb4b793bae72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2203,6 +2203,9 @@ twentemilieu==0.3.0 # homeassistant.components.twilio twilio==6.32.0 +# homeassistant.components.ultrasync +ultrasync==0.8.0 + # homeassistant.components.rainforest_eagle uEagle==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 073b9822d87ae..172ca1d0b34f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1047,6 +1047,9 @@ twentemilieu==0.3.0 # homeassistant.components.twilio twilio==6.32.0 +# homeassistant.components.ultrasync +ultrasync==0.8.0 + # homeassistant.components.upb upb_lib==0.4.11 From 1a65f38abf14435475ffbe1fb02d33285cf27f39 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 24 Oct 2020 17:49:25 -0400 Subject: [PATCH 02/30] Informix UltraSync Hub Support Added --- CODEOWNERS | 1 + .../components/ultrasync/__init__.py | 169 +++++++ .../components/ultrasync/config_flow.py | 131 ++++++ homeassistant/components/ultrasync/const.py | 14 + .../components/ultrasync/coordinator.py | 105 +++++ .../components/ultrasync/device_tracker.py | 440 ++++++++++++++++++ .../components/ultrasync/manifest.json | 8 + homeassistant/components/ultrasync/sensor.py | 86 ++++ .../components/ultrasync/services.yaml | 10 + .../components/ultrasync/strings.json | 32 ++ homeassistant/generated/config_flows.py | 2 +- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- 13 files changed, 1001 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/ultrasync/__init__.py create mode 100644 homeassistant/components/ultrasync/config_flow.py create mode 100644 homeassistant/components/ultrasync/const.py create mode 100644 homeassistant/components/ultrasync/coordinator.py create mode 100644 homeassistant/components/ultrasync/device_tracker.py create mode 100644 homeassistant/components/ultrasync/manifest.json create mode 100644 homeassistant/components/ultrasync/sensor.py create mode 100644 homeassistant/components/ultrasync/services.yaml create mode 100644 homeassistant/components/ultrasync/strings.json diff --git a/CODEOWNERS b/CODEOWNERS index 7f2dfc1454e31..f22fa7a500b58 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -520,6 +520,7 @@ homeassistant/components/tuya/* @ollo69 homeassistant/components/twentemilieu/* @frenck homeassistant/components/twinkly/* @dr1rrb homeassistant/components/ubus/* @noltari +homeassistant/components/ultrasync/* @caronc homeassistant/components/unifi/* @Kane610 homeassistant/components/unifiled/* @florisvdk homeassistant/components/upb/* @gwww diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py new file mode 100644 index 0000000000000..079ea042b89c8 --- /dev/null +++ b/homeassistant/components/ultrasync/__init__.py @@ -0,0 +1,169 @@ +"""The Informix UltraSync Hub component.""" + +import asyncio + +import voluptuous as vol + +from ultrasync import AlarmScene +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + DATA_COORDINATOR, + DATA_UNDO_UPDATE_LISTENER, + DOMAIN, + SERVICE_AWAY, + SERVICE_STAY, + SERVICE_DISARM, + DEFAULT_SCAN_INTERVAL, +) + +from homeassistant.const import ( + CONF_HOST, + CONF_ID, + CONF_PIN, + CONF_SCAN_INTERVAL, +) + +from .coordinator import UltraSyncDataUpdateCoordinator + +PLATFORMS = ["sensor"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Optional(CONF_ID): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: dict) -> bool: + """Set up the UltraSync integration.""" + hass.data.setdefault(DOMAIN, {}) + + if hass.config_entries.async_entries(DOMAIN): + return True + + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up UltraSync from a config entry.""" + if not entry.options: + options = { + CONF_SCAN_INTERVAL: entry.data.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + } + hass.config_entries.async_update_entry(entry, options=options) + + coordinator = UltraSyncDataUpdateCoordinator( + hass, + config=entry.data, + options=entry.options, + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + undo_listener = entry.add_update_listener(_async_update_listener) + + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + DATA_UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + _async_register_services(hass, coordinator) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +def _async_register_services( + hass: HomeAssistantType, + coordinator: UltraSyncDataUpdateCoordinator, +) -> None: + """Register integration-level services.""" + + def away(call) -> None: + """Service call to set alarm system to 'away' mode in UltraSync Hub.""" + coordinator.hub.set(state=AlarmScene.AWAY) + + def stay(call) -> None: + """Service call to set alarm system to 'stay' mode in UltraSync Hub.""" + coordinator.hub.set(state=AlarmScene.STAY) + + def disarm(call) -> None: + """Service call to disable alarm in UltraSync Hub.""" + coordinator.hub.set(state=AlarmScene.DISARMED) + + hass.services.async_register(DOMAIN, SERVICE_AWAY, away, schema=vol.Schema({})) + hass.services.async_register(DOMAIN, SERVICE_STAY, stay, schema=vol.Schema({})) + hass.services.async_register(DOMAIN, SERVICE_DISARM, disarm, schema=vol.Schema({})) + + +async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class UltraSyncEntity(CoordinatorEntity): + """Defines a base UltraSync entity.""" + + def __init__( + self, *, entry_id: str, name: str, coordinator: UltraSyncDataUpdateCoordinator + ) -> None: + """Initialize the UltraSync entity.""" + super().__init__(coordinator) + self._name = name + self._entry_id = entry_id + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py new file mode 100644 index 0000000000000..5c3f0137194e1 --- /dev/null +++ b/homeassistant/components/ultrasync/config_flow.py @@ -0,0 +1,131 @@ +"""Config flow for Informix Ultrasync Hub.""" +import logging +from typing import Any, Dict, Optional +from ultrasync import UltraSync +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_SCAN_INTERVAL, + CONF_HOST, + CONF_PIN, + CONF_ID, +) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant import config_entries + +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: + """Validate the user input allows us to connect. + + """ + data[CONF_ID] + data[CONF_PIN] + data[CONF_HOST] + usync = UltraSync() + + # validate by attempting to authenticate with our hub + return usync.login() + + return True + + +class UltraSyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """UltraSync config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return UltraSyncOptionsFlowHandler(config_entry) + + async def async_step_import( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by configuration file.""" + if CONF_SCAN_INTERVAL in user_input: + user_input[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL].seconds + + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + validate_input, self.hass, user_input + ) + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_ID): str, + vol.Required(CONF_PIN): str, + }), + errors=errors, + ) + + async def async_step_init(self, user_input: Optional[ConfigType] = None): + """Manage UltraSync options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + + +class UltraSyncOptionsFlowHandler(config_entries.OptionsFlow): + """Handle UltraSync client options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input: Optional[ConfigType] = None): + """Manage UltraSync options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py new file mode 100644 index 0000000000000..025dc26cae74d --- /dev/null +++ b/homeassistant/components/ultrasync/const.py @@ -0,0 +1,14 @@ +"""Constants for Informix UltraSync Hub""" + +DOMAIN = "ultrasync" + +# Scan Time (in seconds) +DEFAULT_SCAN_INTERVAL = 5 + +# Services +SERVICE_AWAY = "away" +SERVICE_STAY = "stay" +SERVICE_DISARM = "disarm" + +DATA_COORDINATOR = "coordinator" +DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py new file mode 100644 index 0000000000000..14dddfa4483d2 --- /dev/null +++ b/homeassistant/components/ultrasync/coordinator.py @@ -0,0 +1,105 @@ +"""Provides the UltraSync DataUpdateCoordinator.""" +from datetime import timedelta +import logging + +from ultrasync import UltraSync + +from async_timeout import timeout + +from homeassistant.const import ( + CONF_SCAN_INTERVAL, + CONF_PIN, + CONF_ID, + CONF_HOST, +) +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class UltraSyncDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching UltraSync data.""" + + def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): + """Initialize global UltraSync data updater.""" + self.hub = UltraSync( + user=config[CONF_ID], + pin=config[CONF_PIN], + host=config[CONF_HOST], + ) + + self._init = False + + # Used to track delta (for change tracking) + self._area_delta = {} + self._zone_delta = {} + + update_interval = timedelta(seconds=options[CONF_SCAN_INTERVAL]) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict: + """Fetch data from UltraSync Hub.""" + + def _update_data() -> dict: + """Fetch data from UltraSync via sync functions.""" + + # initialize our response + response = { + 'Area1State': 'unknown', + 'Area2State': 'unknown', + 'Area3State': 'unknown', + 'Area4State': 'unknown', + } + + # Update our details + details = self.hub.details() + if details: + for bank, zone in self.hub.zones.items(): + if self._zone_delta.get(zone['bank']) != zone['sequence']: + self.hass.bus.fire( + "ultrasync_sensor_update", + { + "sensor": zone['bank'] + 1, + "name": zone['name'], + "status": zone['status'], + }, + ) + + # Update our sequence + self._zone_delta[zone['bank']] = zone['sequence'] + + for area in details.get('areas', []): + if self._area_delta.get(area['bank']) != area['sequence']: + self.hass.bus.fire( + "ultrasync_area_update", + { + "area": area['bank'] + 1, + "name": area['name'], + "status": area['status'], + }, + ) + + # Update our sequence + self._area_delta[area['bank']] = area['sequence'] + + # Set our state: + response['Area{}State'.format(area['bank'] + 1)] = area['status'] + + self._init = True + + # Return our response + return response + + # The hub can sometimes take a very long time to respond; wait + # 10 seconds before giving up + async with timeout(10): + return await self.hass.async_add_executor_job(_update_data) diff --git a/homeassistant/components/ultrasync/device_tracker.py b/homeassistant/components/ultrasync/device_tracker.py new file mode 100644 index 0000000000000..31383cbbdf5d3 --- /dev/null +++ b/homeassistant/components/ultrasync/device_tracker.py @@ -0,0 +1,440 @@ +"""Support for Ultrasync device tracking.""" +from datetime import datetime, timedelta +import logging + +from pytraccar.api import API +from stringcase import camelcase +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.const import ( + CONF_EVENT, + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify + +from . import DOMAIN, TRACKER_UPDATE +from .const import ( + ATTR_ACCURACY, + ATTR_ADDRESS, + ATTR_ALTITUDE, + ATTR_BATTERY, + ATTR_BEARING, + ATTR_CATEGORY, + ATTR_GEOFENCE, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MOTION, + ATTR_SPEED, + ATTR_STATUS, + ATTR_TRACCAR_ID, + ATTR_TRACKER, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_ON, + EVENT_ALARM, + EVENT_ALL_EVENTS, + EVENT_COMMAND_RESULT, + EVENT_DEVICE_FUEL_DROP, + EVENT_DEVICE_MOVING, + EVENT_DEVICE_OFFLINE, + EVENT_DEVICE_ONLINE, + EVENT_DEVICE_OVERSPEED, + EVENT_DEVICE_STOPPED, + EVENT_DEVICE_UNKNOWN, + EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_ENTER, + EVENT_GEOFENCE_EXIT, + EVENT_IGNITION_OFF, + EVENT_IGNITION_ON, + EVENT_MAINTENANCE, + EVENT_TEXT_MESSAGE, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=8082): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_MAX_ACCURACY, default=0): cv.positive_int, + vol.Optional(CONF_SKIP_ACCURACY_ON, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EVENT, default=[]): vol.All( + cv.ensure_list, + [ + vol.Any( + EVENT_DEVICE_MOVING, + EVENT_COMMAND_RESULT, + EVENT_DEVICE_FUEL_DROP, + EVENT_GEOFENCE_ENTER, + EVENT_DEVICE_OFFLINE, + EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_EXIT, + EVENT_DEVICE_OVERSPEED, + EVENT_DEVICE_ONLINE, + EVENT_DEVICE_STOPPED, + EVENT_MAINTENANCE, + EVENT_ALARM, + EVENT_TEXT_MESSAGE, + EVENT_DEVICE_UNKNOWN, + EVENT_IGNITION_OFF, + EVENT_IGNITION_ON, + EVENT_ALL_EVENTS, + ) + ], + ), + } +) + + +async def async_setup_entry(hass: HomeAssistantType, entry, async_add_entities): + """Configure a dispatcher connection based on a config entry.""" + + @callback + def _receive_data(device, latitude, longitude, battery, accuracy, attrs): + """Receive set location.""" + if device in hass.data[DOMAIN]["devices"]: + return + + hass.data[DOMAIN]["devices"].add(device) + + async_add_entities( + [TraccarEntity(device, latitude, longitude, battery, accuracy, attrs)] + ) + + hass.data[DOMAIN]["unsub_device_tracker"][ + entry.entry_id + ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == DOMAIN + } + if not dev_ids: + return + + entities = [] + for dev_id in dev_ids: + hass.data[DOMAIN]["devices"].add(dev_id) + entity = TraccarEntity(dev_id, None, None, None, None, None) + entities.append(entity) + + async_add_entities(entities) + + +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Validate the configuration and return a Traccar scanner.""" + + session = async_get_clientsession(hass, config[CONF_VERIFY_SSL]) + + api = API( + hass.loop, + session, + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_HOST], + config[CONF_PORT], + config[CONF_SSL], + ) + + scanner = TraccarScanner( + api, + hass, + async_see, + config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), + config[CONF_MAX_ACCURACY], + config[CONF_SKIP_ACCURACY_ON], + config[CONF_MONITORED_CONDITIONS], + config[CONF_EVENT], + ) + + return await scanner.async_init() + + +class TraccarScanner: + """Define an object to retrieve Traccar data.""" + + def __init__( + self, + api, + hass, + async_see, + scan_interval, + max_accuracy, + skip_accuracy_on, + custom_attributes, + event_types, + ): + """Initialize.""" + + self._event_types = {camelcase(evt): evt for evt in event_types} + self._custom_attributes = custom_attributes + self._scan_interval = scan_interval + self._async_see = async_see + self._api = api + self.connected = False + self._hass = hass + self._max_accuracy = max_accuracy + self._skip_accuracy_on = skip_accuracy_on + + async def async_init(self): + """Further initialize connection to Traccar.""" + await self._api.test_connection() + if self._api.connected and not self._api.authenticated: + _LOGGER.error("Authentication for Traccar failed") + return False + + await self._async_update() + async_track_time_interval(self._hass, self._async_update, self._scan_interval) + return True + + async def _async_update(self, now=None): + """Update info from Traccar.""" + if not self.connected: + _LOGGER.debug("Testing connection to Traccar") + await self._api.test_connection() + self.connected = self._api.connected + if self.connected: + _LOGGER.info("Connection to Traccar restored") + else: + return + _LOGGER.debug("Updating device data") + await self._api.get_device_info(self._custom_attributes) + self._hass.async_create_task(self.import_device_data()) + if self._event_types: + self._hass.async_create_task(self.import_events()) + self.connected = self._api.connected + + async def import_device_data(self): + """Import device data from Traccar.""" + for device_unique_id in self._api.device_info: + device_info = self._api.device_info[device_unique_id] + device = None + attr = {} + skip_accuracy_filter = False + + attr[ATTR_TRACKER] = "traccar" + if device_info.get("address") is not None: + attr[ATTR_ADDRESS] = device_info["address"] + if device_info.get("geofence") is not None: + attr[ATTR_GEOFENCE] = device_info["geofence"] + if device_info.get("category") is not None: + attr[ATTR_CATEGORY] = device_info["category"] + if device_info.get("speed") is not None: + attr[ATTR_SPEED] = device_info["speed"] + if device_info.get("motion") is not None: + attr[ATTR_MOTION] = device_info["motion"] + if device_info.get("traccar_id") is not None: + attr[ATTR_TRACCAR_ID] = device_info["traccar_id"] + for dev in self._api.devices: + if dev["id"] == device_info["traccar_id"]: + device = dev + break + if device is not None and device.get("status") is not None: + attr[ATTR_STATUS] = device["status"] + for custom_attr in self._custom_attributes: + if device_info.get(custom_attr) is not None: + attr[custom_attr] = device_info[custom_attr] + if custom_attr in self._skip_accuracy_on: + skip_accuracy_filter = True + + accuracy = 0.0 + if device_info.get("accuracy") is not None: + accuracy = device_info["accuracy"] + if ( + not skip_accuracy_filter + and self._max_accuracy > 0 + and accuracy > self._max_accuracy + ): + _LOGGER.debug( + "Excluded position by accuracy filter: %f (%s)", + accuracy, + attr[ATTR_TRACCAR_ID], + ) + continue + + await self._async_see( + dev_id=slugify(device_info["device_id"]), + gps=(device_info.get("latitude"), device_info.get("longitude")), + gps_accuracy=accuracy, + battery=device_info.get("battery"), + attributes=attr, + ) + + async def import_events(self): + """Import events from Traccar.""" + device_ids = [device["id"] for device in self._api.devices] + end_interval = datetime.utcnow() + start_interval = end_interval - self._scan_interval + events = await self._api.get_events( + device_ids=device_ids, + from_time=start_interval, + to_time=end_interval, + event_types=self._event_types.keys(), + ) + if events is not None: + for event in events: + device_name = next( + ( + dev.get("name") + for dev in self._api.devices + if dev.get("id") == event["deviceId"] + ), + None, + ) + self._hass.bus.async_fire( + f"traccar_{self._event_types.get(event['type'])}", + { + "device_traccar_id": event["deviceId"], + "device_name": device_name, + "type": event["type"], + "serverTime": event["serverTime"], + "attributes": event["attributes"], + }, + ) + + +class TraccarEntity(TrackerEntity, RestoreEntity): + """Represent a tracked device.""" + + def __init__(self, device, latitude, longitude, battery, accuracy, attributes): + """Set up Geofency entity.""" + self._accuracy = accuracy + self._attributes = attributes + self._name = device + self._battery = battery + self._latitude = latitude + self._longitude = longitude + self._unsub_dispatcher = None + self._unique_id = device + + @property + def battery_level(self): + """Return battery value of the device.""" + return self._battery + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._longitude + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return {"name": self._name, "identifiers": {(DOMAIN, self._unique_id)}} + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + await super().async_added_to_hass() + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data + ) + + # don't restore if we got created with data + if self._latitude is not None or self._longitude is not None: + return + + state = await self.async_get_last_state() + if state is None: + self._latitude = None + self._longitude = None + self._accuracy = None + self._attributes = { + ATTR_ALTITUDE: None, + ATTR_BEARING: None, + ATTR_SPEED: None, + } + self._battery = None + return + + attr = state.attributes + self._latitude = attr.get(ATTR_LATITUDE) + self._longitude = attr.get(ATTR_LONGITUDE) + self._accuracy = attr.get(ATTR_ACCURACY) + self._attributes = { + ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), + ATTR_BEARING: attr.get(ATTR_BEARING), + ATTR_SPEED: attr.get(ATTR_SPEED), + } + self._battery = attr.get(ATTR_BATTERY) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() + self._unsub_dispatcher() + + @callback + def _async_receive_data( + self, device, latitude, longitude, battery, accuracy, attributes + ): + """Mark the device as seen.""" + if device != self.name: + return + + self._latitude = latitude + self._longitude = longitude + self._battery = battery + self._accuracy = accuracy + self._attributes.update(attributes) + self.async_write_ha_state() diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json new file mode 100644 index 0000000000000..0343dfba1e34f --- /dev/null +++ b/homeassistant/components/ultrasync/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ultrasync", + "name": "Informix Ultrasync Hub", + "documentation": "https://www.home-assistant.io/integrations/ultrasync", + "requirements": ["ultrasync==0.8.0"], + "codeowners": ["@caronc"], + "config_flow": true +} diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py new file mode 100644 index 0000000000000..d2cf30977b97d --- /dev/null +++ b/homeassistant/components/ultrasync/sensor.py @@ -0,0 +1,86 @@ +"""Monitor the Informix UltraSync Hub""" + +import logging +from homeassistant.const import CONF_ID +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from typing import Callable, List, Optional + +from . import UltraSyncEntity +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import UltraSyncDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + "area01_state": ["Area1State", "Area 1 State", None], + "area02_state": ["Area2State", "Area 2 State", None], + "area03_state": ["Area3State", "Area 3 State", None], + "area04_state": ["Area4State", "Area 4 State", None], +} + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up UltraSync sensor based on a config entry.""" + coordinator: UltraSyncDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + sensors = [] + + for sensor_config in SENSOR_TYPES.values(): + sensors.append( + UltraSyncSensor( + coordinator, + entry.entry_id, + entry.data[CONF_ID], + sensor_config[0], + sensor_config[1], + sensor_config[2], + ) + ) + + async_add_entities(sensors) + + +class UltraSyncSensor(UltraSyncEntity): + """Representation of a UltraSync sensor.""" + + def __init__( + self, + coordinator: UltraSyncDataUpdateCoordinator, + entry_id: str, + entry_name: str, + sensor_type: str, + sensor_name: str, + unit_of_measurement: Optional[str] = None, + ): + """Initialize a new UltraSync sensor.""" + self._sensor_type = sensor_type + self._unique_id = f"{entry_id}_{sensor_type}" + self._unit_of_measurement = unit_of_measurement + + super().__init__( + coordinator=coordinator, + entry_id=entry_id, + name=f"{entry_name} {sensor_name}", + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._unique_id + + @property + def state(self): + """Return the state of the sensor.""" + value = self.coordinator.data.get(self._sensor_type) + if value is None: + _LOGGER.warning("Unable to locate value for %s", self._sensor_type) + return None + + return value diff --git a/homeassistant/components/ultrasync/services.yaml b/homeassistant/components/ultrasync/services.yaml new file mode 100644 index 0000000000000..4e337458948ec --- /dev/null +++ b/homeassistant/components/ultrasync/services.yaml @@ -0,0 +1,10 @@ +# Describes the format for available ultrasync services + +away: + description: Set Alarm to Away + +stay: + description: Set the Alarm to Stay + +disarm: + description: Disarm the Alarm diff --git a/homeassistant/components/ultrasync/strings.json b/homeassistant/components/ultrasync/strings.json new file mode 100644 index 0000000000000..808fe70e4df46 --- /dev/null +++ b/homeassistant/components/ultrasync/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "flow_title": "UltraSync Hub: {name}", + "step": { + "user": { + "title": "Connect to UltraSync Hub", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "host": "[%key:common::config_flow::data::host%]", + "id": "[%key:common::config_flow::data::id%]", + "pin": "[%key:common::config_flow::data::pin%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency (seconds)" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 79245491a7ea9..cfb1b2ee05f0e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,7 +264,7 @@ "tuya", "twentemilieu", "twilio", - "twinkly", + "ultrasync", "unifi", "upb", "upcloud", diff --git a/requirements_all.txt b/requirements_all.txt index 3caa27823272d..390e38d429e46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2278,8 +2278,8 @@ twentemilieu==0.3.0 # homeassistant.components.twilio twilio==6.32.0 -# homeassistant.components.twinkly -twinkly-client==0.0.2 +# homeassistant.components.ultrasync +ultrasync==0.8.0 # homeassistant.components.rainforest_eagle uEagle==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 940ab10cb8765..cb157627a997b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1226,8 +1226,8 @@ twentemilieu==0.3.0 # homeassistant.components.twilio twilio==6.32.0 -# homeassistant.components.twinkly -twinkly-client==0.0.2 +# homeassistant.components.ultrasync +ultrasync==0.8.0 # homeassistant.components.upb upb_lib==0.4.12 From e277a5219cc57ee5a5ce5b63e8e92c41b46b64fe Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Wed, 28 Oct 2020 18:11:32 -0400 Subject: [PATCH 03/30] english translations added --- .../components/ultrasync/__init__.py | 4 +-- .../components/ultrasync/config_flow.py | 6 ++-- .../components/ultrasync/coordinator.py | 4 +-- homeassistant/components/ultrasync/sensor.py | 4 +-- .../components/ultrasync/strings.json | 7 ++--- .../components/ultrasync/translations/en.json | 31 +++++++++++++++++++ 6 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/ultrasync/translations/en.json diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index 079ea042b89c8..e7d328821b803 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_HOST, - CONF_ID, + CONF_USERNAME, CONF_PIN, CONF_SCAN_INTERVAL, ) @@ -38,7 +38,7 @@ { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PIN): cv.string, - vol.Optional(CONF_ID): cv.string, + vol.Optional(CONF_USERNAME): cv.string, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.time_period, diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 5c3f0137194e1..74bc6d78b54e2 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -9,7 +9,7 @@ CONF_SCAN_INTERVAL, CONF_HOST, CONF_PIN, - CONF_ID, + CONF_USERNAME, ) from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant import config_entries @@ -26,7 +26,7 @@ def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: """Validate the user input allows us to connect. """ - data[CONF_ID] + data[CONF_USERNAME] data[CONF_PIN] data[CONF_HOST] usync = UltraSync() @@ -84,7 +84,7 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=vol.Schema({ vol.Required(CONF_HOST): str, - vol.Required(CONF_ID): str, + vol.Required(CONF_USERNAME): str, vol.Required(CONF_PIN): str, }), errors=errors, diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index 14dddfa4483d2..825e9bc40e691 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -9,7 +9,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_PIN, - CONF_ID, + CONF_USERNAME, CONF_HOST, ) from homeassistant.helpers.typing import HomeAssistantType @@ -26,7 +26,7 @@ class UltraSyncDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): """Initialize global UltraSync data updater.""" self.hub = UltraSync( - user=config[CONF_ID], + user=config[CONF_USERNAME], pin=config[CONF_PIN], host=config[CONF_HOST], ) diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index d2cf30977b97d..604056d115395 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -1,7 +1,7 @@ """Monitor the Informix UltraSync Hub""" import logging -from homeassistant.const import CONF_ID +from homeassistant.const import CONF_USERNAME from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType @@ -37,7 +37,7 @@ async def async_setup_entry( UltraSyncSensor( coordinator, entry.entry_id, - entry.data[CONF_ID], + entry.data[CONF_USERNAME], sensor_config[0], sensor_config[1], sensor_config[2], diff --git a/homeassistant/components/ultrasync/strings.json b/homeassistant/components/ultrasync/strings.json index 808fe70e4df46..9447a1454c40a 100644 --- a/homeassistant/components/ultrasync/strings.json +++ b/homeassistant/components/ultrasync/strings.json @@ -1,13 +1,12 @@ { "config": { - "flow_title": "UltraSync Hub: {name}", + "flow_title": "Informix UltraSync Hub: {name}", "step": { "user": { - "title": "Connect to UltraSync Hub", + "title": "Connect to Informix UltraSync Hub", "data": { - "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", - "id": "[%key:common::config_flow::data::id%]", + "username": "[%key:common::config_flow::data::username%]", "pin": "[%key:common::config_flow::data::pin%]" } } diff --git a/homeassistant/components/ultrasync/translations/en.json b/homeassistant/components/ultrasync/translations/en.json new file mode 100644 index 0000000000000..bce8e236d22d8 --- /dev/null +++ b/homeassistant/components/ultrasync/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "Informix UltraSync Hub: {name}", + "step": { + "user": { + "data": { + "host": "Host", + "pin": "PIN Code", + "username": "Username" + }, + "title": "Connect to Informix UltraSync Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency (seconds)" + } + } + } + } +} \ No newline at end of file From ad12e1234a96cac67027306c1e56f796915ecc2a Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Wed, 28 Oct 2020 20:27:38 -0400 Subject: [PATCH 04/30] bumped version to handle new ultrasync (ha compatible) --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index 390e38d429e46..df0bc60b90240 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2279,7 +2279,7 @@ twentemilieu==0.3.0 twilio==6.32.0 # homeassistant.components.ultrasync -ultrasync==0.8.0 +ultrasync==0.8.1 # homeassistant.components.rainforest_eagle uEagle==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb157627a997b..c62572dd8e98a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ twentemilieu==0.3.0 twilio==6.32.0 # homeassistant.components.ultrasync -ultrasync==0.8.0 +ultrasync==0.8.1 # homeassistant.components.upb upb_lib==0.4.12 From 0423a28b4ff1d14538bf049556a6569719115c2e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 29 Oct 2020 15:07:12 -0400 Subject: [PATCH 05/30] support black formatting --- .../components/ultrasync/config_flow.py | 17 +++++----- .../components/ultrasync/coordinator.py | 32 +++++++++---------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 74bc6d78b54e2..231ba692f60fc 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -23,9 +23,7 @@ def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: - """Validate the user input allows us to connect. - - """ + """Validate the user input allows us to connect.""" data[CONF_USERNAME] data[CONF_PIN] data[CONF_HOST] @@ -82,11 +80,14 @@ async def async_step_user( ) return self.async_show_form( - step_id="user", data_schema=vol.Schema({ - vol.Required(CONF_HOST): str, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PIN): str, - }), + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PIN): str, + } + ), errors=errors, ) diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index 825e9bc40e691..2c24086d0a7c1 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -54,45 +54,45 @@ def _update_data() -> dict: # initialize our response response = { - 'Area1State': 'unknown', - 'Area2State': 'unknown', - 'Area3State': 'unknown', - 'Area4State': 'unknown', + "Area1State": "unknown", + "Area2State": "unknown", + "Area3State": "unknown", + "Area4State": "unknown", } # Update our details details = self.hub.details() if details: for bank, zone in self.hub.zones.items(): - if self._zone_delta.get(zone['bank']) != zone['sequence']: + if self._zone_delta.get(zone["bank"]) != zone["sequence"]: self.hass.bus.fire( "ultrasync_sensor_update", { - "sensor": zone['bank'] + 1, - "name": zone['name'], - "status": zone['status'], + "sensor": zone["bank"] + 1, + "name": zone["name"], + "status": zone["status"], }, ) # Update our sequence - self._zone_delta[zone['bank']] = zone['sequence'] + self._zone_delta[zone["bank"]] = zone["sequence"] - for area in details.get('areas', []): - if self._area_delta.get(area['bank']) != area['sequence']: + for area in details.get("areas", []): + if self._area_delta.get(area["bank"]) != area["sequence"]: self.hass.bus.fire( "ultrasync_area_update", { - "area": area['bank'] + 1, - "name": area['name'], - "status": area['status'], + "area": area["bank"] + 1, + "name": area["name"], + "status": area["status"], }, ) # Update our sequence - self._area_delta[area['bank']] = area['sequence'] + self._area_delta[area["bank"]] = area["sequence"] # Set our state: - response['Area{}State'.format(area['bank'] + 1)] = area['status'] + response["Area{}State".format(area["bank"] + 1)] = area["status"] self._init = True From 8a21c0cbf02e471da90a8fb99723373c04b8b4ce Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 29 Oct 2020 20:50:45 -0400 Subject: [PATCH 06/30] tests and sanity added --- .../components/ultrasync/__init__.py | 6 +- .../components/ultrasync/config_flow.py | 37 +++-- homeassistant/components/ultrasync/const.py | 2 + .../components/ultrasync/coordinator.py | 6 +- homeassistant/components/ultrasync/sensor.py | 26 ++-- .../components/ultrasync/strings.json | 1 + .../components/ultrasync/translations/en.json | 1 + requirements_all.txt | 6 +- requirements_test_all.txt | 2 +- tests/components/ultrasync/__init__.py | 80 +++++++++++ tests/components/ultrasync/conftest.py | 24 ++++ .../components/ultrasync/test_config_flow.py | 131 ++++++++++++++++++ tests/components/ultrasync/test_init.py | 59 ++++++++ tests/components/ultrasync/test_sensor.py | 24 ++++ 14 files changed, 372 insertions(+), 33 deletions(-) create mode 100644 tests/components/ultrasync/__init__.py create mode 100644 tests/components/ultrasync/conftest.py create mode 100644 tests/components/ultrasync/test_config_flow.py create mode 100644 tests/components/ultrasync/test_init.py create mode 100644 tests/components/ultrasync/test_sensor.py diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index e7d328821b803..37f992a6c169a 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -11,6 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.exceptions import ConfigEntryNotReady + from .const import ( DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, @@ -18,10 +19,12 @@ SERVICE_AWAY, SERVICE_STAY, SERVICE_DISARM, + DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, ) from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PIN, @@ -36,9 +39,10 @@ { DOMAIN: vol.Schema( { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PIN): cv.string, - vol.Optional(CONF_USERNAME): cv.string, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.time_period, diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 231ba692f60fc..deb99208e024a 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -1,12 +1,13 @@ """Config flow for Informix Ultrasync Hub.""" import logging from typing import Any, Dict, Optional -from ultrasync import UltraSync +import ultrasync import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ( CONF_SCAN_INTERVAL, + CONF_NAME, CONF_HOST, CONF_PIN, CONF_USERNAME, @@ -16,21 +17,31 @@ from .const import ( DEFAULT_SCAN_INTERVAL, + DEFAULT_NAME, DOMAIN, ) _LOGGER = logging.getLogger(__name__) +class AuthFailureException(IOError): + """A general exception we can throw and track Authentication + failures. + """ + + def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: """Validate the user input allows us to connect.""" - data[CONF_USERNAME] - data[CONF_PIN] - data[CONF_HOST] - usync = UltraSync() + + usync = ultrasync.UltraSync( + host=data[CONF_HOST], user=data[CONF_USERNAME], pin=data[CONF_PIN] + ) # validate by attempting to authenticate with our hub - return usync.login() + + if not usync.login(): + # report our connection issue + raise AuthFailureException() return True @@ -70,19 +81,23 @@ async def async_step_user( validate_input, self.hass, user_input ) + except AuthFailureException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") - - return self.async_create_entry( - title=user_input[CONF_HOST], - data=user_input, - ) + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) return self.async_show_form( step_id="user", data_schema=vol.Schema( { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_HOST): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PIN): str, diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py index 025dc26cae74d..19f50b63dff4c 100644 --- a/homeassistant/components/ultrasync/const.py +++ b/homeassistant/components/ultrasync/const.py @@ -5,6 +5,8 @@ # Scan Time (in seconds) DEFAULT_SCAN_INTERVAL = 5 +DEFAULT_NAME = "UltraSync" + # Services SERVICE_AWAY = "away" SERVICE_STAY = "stay" diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index 2c24086d0a7c1..4d7df135297bc 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from ultrasync import UltraSync +import ultrasync from async_timeout import timeout @@ -25,7 +25,7 @@ class UltraSyncDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): """Initialize global UltraSync data updater.""" - self.hub = UltraSync( + self.hub = ultrasync.UltraSync( user=config[CONF_USERNAME], pin=config[CONF_PIN], host=config[CONF_HOST], @@ -68,7 +68,7 @@ def _update_data() -> dict: self.hass.bus.fire( "ultrasync_sensor_update", { - "sensor": zone["bank"] + 1, + "sensor": bank + 1, "name": zone["name"], "status": zone["status"], }, diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index 604056d115395..bc937068a4625 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -1,11 +1,11 @@ """Monitor the Informix UltraSync Hub""" import logging -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_NAME from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType -from typing import Callable, List, Optional +from typing import Callable, List from . import UltraSyncEntity from .const import DATA_COORDINATOR, DOMAIN @@ -13,11 +13,11 @@ _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "area01_state": ["Area1State", "Area 1 State", None], - "area02_state": ["Area2State", "Area 2 State", None], - "area03_state": ["Area3State", "Area 3 State", None], - "area04_state": ["Area4State", "Area 4 State", None], +SENSORS = { + "area01_state": "Area1State", + "area02_state": "Area2State", + "area03_state": "Area3State", + "area04_state": "Area4State", } @@ -32,15 +32,14 @@ async def async_setup_entry( ] sensors = [] - for sensor_config in SENSOR_TYPES.values(): + for sensor_type, sensor_name in SENSORS.items(): sensors.append( UltraSyncSensor( coordinator, entry.entry_id, - entry.data[CONF_USERNAME], - sensor_config[0], - sensor_config[1], - sensor_config[2], + entry.data[CONF_NAME], + sensor_type, + sensor_name, ) ) @@ -57,12 +56,11 @@ def __init__( entry_name: str, sensor_type: str, sensor_name: str, - unit_of_measurement: Optional[str] = None, ): """Initialize a new UltraSync sensor.""" + self._sensor_type = sensor_type self._unique_id = f"{entry_id}_{sensor_type}" - self._unit_of_measurement = unit_of_measurement super().__init__( coordinator=coordinator, diff --git a/homeassistant/components/ultrasync/strings.json b/homeassistant/components/ultrasync/strings.json index 9447a1454c40a..39337ecdc840a 100644 --- a/homeassistant/components/ultrasync/strings.json +++ b/homeassistant/components/ultrasync/strings.json @@ -5,6 +5,7 @@ "user": { "title": "Connect to Informix UltraSync Hub", "data": { + "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "pin": "[%key:common::config_flow::data::pin%]" diff --git a/homeassistant/components/ultrasync/translations/en.json b/homeassistant/components/ultrasync/translations/en.json index bce8e236d22d8..286d1034b80e7 100644 --- a/homeassistant/components/ultrasync/translations/en.json +++ b/homeassistant/components/ultrasync/translations/en.json @@ -12,6 +12,7 @@ "user": { "data": { "host": "Host", + "name": "Name", "pin": "PIN Code", "username": "Username" }, diff --git a/requirements_all.txt b/requirements_all.txt index df0bc60b90240..4981a6a7a2efa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2278,12 +2278,12 @@ twentemilieu==0.3.0 # homeassistant.components.twilio twilio==6.32.0 -# homeassistant.components.ultrasync -ultrasync==0.8.1 - # homeassistant.components.rainforest_eagle uEagle==0.0.2 +# homeassistant.components.ultrasync +ultrasync==0.8.0 + # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c62572dd8e98a..cb157627a997b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ twentemilieu==0.3.0 twilio==6.32.0 # homeassistant.components.ultrasync -ultrasync==0.8.1 +ultrasync==0.8.0 # homeassistant.components.upb upb_lib==0.4.12 diff --git a/tests/components/ultrasync/__init__.py b/tests/components/ultrasync/__init__.py new file mode 100644 index 0000000000000..84ef039165f96 --- /dev/null +++ b/tests/components/ultrasync/__init__.py @@ -0,0 +1,80 @@ +"""Tests for the UltraSync integration.""" +from datetime import timedelta + +from homeassistant.components.ultrasync.const import DOMAIN +from homeassistant.const import ( + CONF_NAME, + CONF_HOST, + CONF_PIN, + CONF_USERNAME, + CONF_SCAN_INTERVAL, +) + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +ENTRY_CONFIG = { + CONF_NAME: "UltraSync", + CONF_HOST: "127.0.0.1", + CONF_USERNAME: "User 1", + CONF_PIN: "1234", +} + +ENTRY_OPTIONS = {CONF_SCAN_INTERVAL: 5} + +USER_INPUT = { + CONF_NAME: "UltraSyncUser", + CONF_HOST: "127.0.0.2", + CONF_USERNAME: "User 2", + CONF_PIN: "5678", +} + +YAML_CONFIG = { + CONF_NAME: "UltraSyncYAML", + CONF_HOST: "127.0.0.3", + CONF_USERNAME: "User 3", + CONF_PIN: "9876", + CONF_SCAN_INTERVAL: timedelta(seconds=5), +} + +MOCK_VERSION = "21.0" + +MOCK_AREAS = [ + {"bank": 0, "name": "Area 1", "sequence": 30, "status": "Ready"}, +] + + +MOCK_ZONES = [ + {"bank": 0, "name": "Front door", "sequence": 1, "status": "Ready"}, + {"bank": 1, "name": "Back door", "sequence": 1, "status": "Ready"}, +] + + +async def init_integration( + hass, + *, + data: dict = ENTRY_CONFIG, + options: dict = ENTRY_OPTIONS, +) -> MockConfigEntry: + """Set up the UltraSync integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data=data, options=options) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def _patch_async_setup(return_value=True): + return patch( + "homeassistant.components.ultrasync.async_setup", + return_value=return_value, + ) + + +def _patch_async_setup_entry(return_value=True): + return patch( + "homeassistant.components.ultrasync.async_setup_entry", + return_value=return_value, + ) diff --git a/tests/components/ultrasync/conftest.py b/tests/components/ultrasync/conftest.py new file mode 100644 index 0000000000000..eb25f084cff6d --- /dev/null +++ b/tests/components/ultrasync/conftest.py @@ -0,0 +1,24 @@ +"""Define fixtures available for all tests.""" +from pytest import fixture + +from . import MOCK_AREAS, MOCK_ZONES + +from tests.async_mock import MagicMock, patch + + +@fixture +def ultrasync_api(hass): + """Mock UltraSync for easier testing.""" + + with patch("ultrasync.UltraSync") as mock_api: + instance = mock_api.return_value + instance.login = MagicMock(return_value=True) + instance.details = MagicMock( + return_value={ + "areas": MOCK_AREAS, + "zones": MOCK_ZONES, + } + ) + instance.areas = MagicMock(return_value=list(MOCK_AREAS)) + instance.zones = MagicMock(return_value=list(MOCK_ZONES)) + yield mock_api diff --git a/tests/components/ultrasync/test_config_flow.py b/tests/components/ultrasync/test_config_flow.py new file mode 100644 index 0000000000000..c4fcd75f8c74d --- /dev/null +++ b/tests/components/ultrasync/test_config_flow.py @@ -0,0 +1,131 @@ +"""Test the UltraSync config flow.""" + +from homeassistant.components.ultrasync.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.setup import async_setup_component + +from tests.async_mock import MagicMock, patch + +from . import ( + ENTRY_CONFIG, + USER_INPUT, + _patch_async_setup, + _patch_async_setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_user_form(hass, ultrasync_api): + """Test we get the user initiated form.""" + await async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "127.0.0.2" + assert result["data"] == USER_INPUT + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("ultrasync.UltraSync") as mock_api: + instance = MagicMock() + instance.login = MagicMock(return_value=False) + mock_api.return_value = instance + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_form_unexpected_exception(hass, ultrasync_api): + """Test we handle unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("ultrasync.UltraSync") as mock_api: + instance = MagicMock() + instance.login = MagicMock(side_effect=Exception()) + mock_api.return_value = instance + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" + + +async def test_user_form_single_instance_allowed(hass, ultrasync_api): + """Test that configuring more than one instance is rejected.""" + entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_INPUT, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow(hass, ultrasync_api): + """Test updating options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options={CONF_SCAN_INTERVAL: 5}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.ultrasync.PLATFORMS", []): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.options[CONF_SCAN_INTERVAL] == 5 + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + with _patch_async_setup(), _patch_async_setup_entry(): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SCAN_INTERVAL: 15}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_SCAN_INTERVAL] == 15 diff --git a/tests/components/ultrasync/test_init.py b/tests/components/ultrasync/test_init.py new file mode 100644 index 0000000000000..77a286af9672c --- /dev/null +++ b/tests/components/ultrasync/test_init.py @@ -0,0 +1,59 @@ +"""Test the UltraSync config flow.""" +from homeassistant.components.ultrasync.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PIN, CONF_NAME +from homeassistant.setup import async_setup_component + +from . import ( + ENTRY_CONFIG, + YAML_CONFIG, + _patch_async_setup_entry, + init_integration, +) + +from tests.async_mock import patch +from tests.common import MockConfigEntry + + +async def test_import_from_yaml(hass, ultrasync_api) -> None: + """Test import from YAML.""" + with _patch_async_setup_entry(): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG}) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert entries[0].data[CONF_NAME] == "UltraSyncYAML" + assert entries[0].data[CONF_USERNAME] == "User 3" + assert entries[0].data[CONF_HOST] == "127.0.0.3" + assert entries[0].data[CONF_PIN] == "9876" + + +async def test_unload_entry(hass, ultrasync_api): + """Test successful unload of entry.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_raises_entry_not_ready(hass): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) + config_entry.add_to_hass(hass) + + with patch("ultrasync.UltraSync.login", side_effect=False): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state == ENTRY_STATE_SETUP_RETRY diff --git a/tests/components/ultrasync/test_sensor.py b/tests/components/ultrasync/test_sensor.py new file mode 100644 index 0000000000000..8869830cb09b7 --- /dev/null +++ b/tests/components/ultrasync/test_sensor.py @@ -0,0 +1,24 @@ +"""Test the UltraSync sensors.""" +from . import init_integration + + +async def test_sensors(hass, ultrasync_api) -> None: + """Test the creation and the initial values of the sensors.""" + entry = await init_integration(hass) + registry = await hass.helpers.entity_registry.async_get_registry() + + sensors = { + "area01_state": ("area1state", "unknown"), + "area02_state": ("area2state", "unknown"), + "area03_state": ("area3state", "unknown"), + "area04_state": ("area4state", "unknown"), + } + + for (sensor_id, data) in sensors.items(): + entity_entry = registry.async_get(f"sensor.ultrasync_{data[0]}") + assert entity_entry + assert entity_entry.unique_id == f"{entry.entry_id}_{sensor_id}" + + state = hass.states.get(f"sensor.ultrasync_{data[0]}") + assert state + assert state.state == data[1] From 3b23444760284699bbb41100230dbafe5604af8d Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 29 Oct 2020 21:03:08 -0400 Subject: [PATCH 07/30] isort and flake8/linting issues fixed --- .../components/ultrasync/__init__.py | 29 +++++++++---------- .../components/ultrasync/config_flow.py | 20 +++++-------- homeassistant/components/ultrasync/const.py | 2 +- .../components/ultrasync/coordinator.py | 10 ++----- homeassistant/components/ultrasync/sensor.py | 7 +++-- tests/components/ultrasync/__init__.py | 4 +-- .../components/ultrasync/test_config_flow.py | 10 ++----- tests/components/ultrasync/test_init.py | 9 ++---- 8 files changed, 34 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index 37f992a6c169a..8d67505c1247c 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -2,35 +2,32 @@ import asyncio +from ultrasync import AlarmScene import voluptuous as vol -from ultrasync import AlarmScene from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PIN, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.exceptions import ConfigEntryNotReady - from .const import ( DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, + DEFAULT_NAME, + DEFAULT_SCAN_INTERVAL, DOMAIN, SERVICE_AWAY, - SERVICE_STAY, SERVICE_DISARM, - DEFAULT_NAME, - DEFAULT_SCAN_INTERVAL, -) - -from homeassistant.const import ( - CONF_NAME, - CONF_HOST, - CONF_USERNAME, - CONF_PIN, - CONF_SCAN_INTERVAL, + SERVICE_STAY, ) - from .coordinator import UltraSyncDataUpdateCoordinator PLATFORMS = ["sensor"] diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index deb99208e024a..9052a51b3fdc4 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -1,33 +1,28 @@ """Config flow for Informix Ultrasync Hub.""" import logging from typing import Any, Dict, Optional + import ultrasync import voluptuous as vol -from homeassistant.core import callback +from homeassistant import config_entries from homeassistant.const import ( - CONF_SCAN_INTERVAL, - CONF_NAME, CONF_HOST, + CONF_NAME, CONF_PIN, + CONF_SCAN_INTERVAL, CONF_USERNAME, ) +from homeassistant.core import callback from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant import config_entries -from .const import ( - DEFAULT_SCAN_INTERVAL, - DEFAULT_NAME, - DOMAIN, -) +from .const import DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) class AuthFailureException(IOError): - """A general exception we can throw and track Authentication - failures. - """ + """A general exception we can use to track Authentication failures.""" def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: @@ -70,6 +65,7 @@ async def async_step_import( async def async_step_user( self, user_input: Optional[ConfigType] = None ) -> Dict[str, Any]: + """Handle user flow.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py index 19f50b63dff4c..53f9be7919074 100644 --- a/homeassistant/components/ultrasync/const.py +++ b/homeassistant/components/ultrasync/const.py @@ -1,4 +1,4 @@ -"""Constants for Informix UltraSync Hub""" +"""Constants for Informix UltraSync Hub.""" DOMAIN = "ultrasync" diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index 4d7df135297bc..662419bd3c8ad 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -2,16 +2,10 @@ from datetime import timedelta import logging -import ultrasync - from async_timeout import timeout +import ultrasync -from homeassistant.const import ( - CONF_SCAN_INTERVAL, - CONF_PIN, - CONF_USERNAME, - CONF_HOST, -) +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index bc937068a4625..b725a5f2ac6b4 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -1,11 +1,12 @@ -"""Monitor the Informix UltraSync Hub""" +"""Monitor the Informix UltraSync Hub.""" import logging -from homeassistant.const import CONF_NAME +from typing import Callable, List + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType -from typing import Callable, List from . import UltraSyncEntity from .const import DATA_COORDINATOR, DOMAIN diff --git a/tests/components/ultrasync/__init__.py b/tests/components/ultrasync/__init__.py index 84ef039165f96..acb1c0ece28d1 100644 --- a/tests/components/ultrasync/__init__.py +++ b/tests/components/ultrasync/__init__.py @@ -3,11 +3,11 @@ from homeassistant.components.ultrasync.const import DOMAIN from homeassistant.const import ( - CONF_NAME, CONF_HOST, + CONF_NAME, CONF_PIN, - CONF_USERNAME, CONF_SCAN_INTERVAL, + CONF_USERNAME, ) from tests.async_mock import patch diff --git a/tests/components/ultrasync/test_config_flow.py b/tests/components/ultrasync/test_config_flow.py index c4fcd75f8c74d..cb4a743b10f4b 100644 --- a/tests/components/ultrasync/test_config_flow.py +++ b/tests/components/ultrasync/test_config_flow.py @@ -10,15 +10,9 @@ ) from homeassistant.setup import async_setup_component -from tests.async_mock import MagicMock, patch - -from . import ( - ENTRY_CONFIG, - USER_INPUT, - _patch_async_setup, - _patch_async_setup_entry, -) +from . import ENTRY_CONFIG, USER_INPUT, _patch_async_setup, _patch_async_setup_entry +from tests.async_mock import MagicMock, patch from tests.common import MockConfigEntry diff --git a/tests/components/ultrasync/test_init.py b/tests/components/ultrasync/test_init.py index 77a286af9672c..5d763a94045a0 100644 --- a/tests/components/ultrasync/test_init.py +++ b/tests/components/ultrasync/test_init.py @@ -5,15 +5,10 @@ ENTRY_STATE_NOT_LOADED, ENTRY_STATE_SETUP_RETRY, ) -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PIN, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_USERNAME from homeassistant.setup import async_setup_component -from . import ( - ENTRY_CONFIG, - YAML_CONFIG, - _patch_async_setup_entry, - init_integration, -) +from . import ENTRY_CONFIG, YAML_CONFIG, _patch_async_setup_entry, init_integration from tests.async_mock import patch from tests.common import MockConfigEntry From ba1c170bdff8e8ce8d4bf4f6407d92c02c2b9e35 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 29 Oct 2020 21:43:30 -0400 Subject: [PATCH 08/30] typo; company is Interlogix --- homeassistant/components/ultrasync/__init__.py | 2 +- homeassistant/components/ultrasync/config_flow.py | 2 +- homeassistant/components/ultrasync/const.py | 2 +- homeassistant/components/ultrasync/manifest.json | 2 +- homeassistant/components/ultrasync/sensor.py | 2 +- homeassistant/components/ultrasync/strings.json | 4 ++-- homeassistant/components/ultrasync/translations/en.json | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index 8d67505c1247c..f4a8973b4eaee 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -1,4 +1,4 @@ -"""The Informix UltraSync Hub component.""" +"""The Interlogix UltraSync Hub component.""" import asyncio diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 9052a51b3fdc4..347b0110f03d0 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Informix Ultrasync Hub.""" +"""Config flow for Interlogix Ultrasync Hub.""" import logging from typing import Any, Dict, Optional diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py index 53f9be7919074..aae461f0e41ad 100644 --- a/homeassistant/components/ultrasync/const.py +++ b/homeassistant/components/ultrasync/const.py @@ -1,4 +1,4 @@ -"""Constants for Informix UltraSync Hub.""" +"""Constants for Interlogix UltraSync Hub.""" DOMAIN = "ultrasync" diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json index 0343dfba1e34f..a3aa6ea3da47f 100644 --- a/homeassistant/components/ultrasync/manifest.json +++ b/homeassistant/components/ultrasync/manifest.json @@ -1,6 +1,6 @@ { "domain": "ultrasync", - "name": "Informix Ultrasync Hub", + "name": "Interlogix Ultrasync Hub", "documentation": "https://www.home-assistant.io/integrations/ultrasync", "requirements": ["ultrasync==0.8.0"], "codeowners": ["@caronc"], diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index b725a5f2ac6b4..37699cee45beb 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -1,4 +1,4 @@ -"""Monitor the Informix UltraSync Hub.""" +"""Monitor the Interlogix UltraSync Hub.""" import logging from typing import Callable, List diff --git a/homeassistant/components/ultrasync/strings.json b/homeassistant/components/ultrasync/strings.json index 39337ecdc840a..b1c1808db4ea5 100644 --- a/homeassistant/components/ultrasync/strings.json +++ b/homeassistant/components/ultrasync/strings.json @@ -1,9 +1,9 @@ { "config": { - "flow_title": "Informix UltraSync Hub: {name}", + "flow_title": "Interlogix UltraSync Hub: {name}", "step": { "user": { - "title": "Connect to Informix UltraSync Hub", + "title": "Connect to Interlogix UltraSync Hub", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/ultrasync/translations/en.json b/homeassistant/components/ultrasync/translations/en.json index 286d1034b80e7..bfb8c87c5e302 100644 --- a/homeassistant/components/ultrasync/translations/en.json +++ b/homeassistant/components/ultrasync/translations/en.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Failed to connect" }, - "flow_title": "Informix UltraSync Hub: {name}", + "flow_title": "Interlogix UltraSync Hub: {name}", "step": { "user": { "data": { @@ -29,4 +29,4 @@ } } } -} \ No newline at end of file +} From 2770ea3110b8f437aef150d171df25b4ac7ff1dc Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 29 Oct 2020 22:39:07 -0400 Subject: [PATCH 09/30] not using device tracker; removed --- .../components/ultrasync/device_tracker.py | 440 ------------------ 1 file changed, 440 deletions(-) delete mode 100644 homeassistant/components/ultrasync/device_tracker.py diff --git a/homeassistant/components/ultrasync/device_tracker.py b/homeassistant/components/ultrasync/device_tracker.py deleted file mode 100644 index 31383cbbdf5d3..0000000000000 --- a/homeassistant/components/ultrasync/device_tracker.py +++ /dev/null @@ -1,440 +0,0 @@ -"""Support for Ultrasync device tracking.""" -from datetime import datetime, timedelta -import logging - -from pytraccar.api import API -from stringcase import camelcase -import voluptuous as vol - -from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS -from homeassistant.components.device_tracker.config_entry import TrackerEntity -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_SSL, - CONF_USERNAME, - CONF_VERIFY_SSL, -) -from homeassistant.core import callback -from homeassistant.helpers import device_registry -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.util import slugify - -from . import DOMAIN, TRACKER_UPDATE -from .const import ( - ATTR_ACCURACY, - ATTR_ADDRESS, - ATTR_ALTITUDE, - ATTR_BATTERY, - ATTR_BEARING, - ATTR_CATEGORY, - ATTR_GEOFENCE, - ATTR_LATITUDE, - ATTR_LONGITUDE, - ATTR_MOTION, - ATTR_SPEED, - ATTR_STATUS, - ATTR_TRACCAR_ID, - ATTR_TRACKER, - CONF_MAX_ACCURACY, - CONF_SKIP_ACCURACY_ON, - EVENT_ALARM, - EVENT_ALL_EVENTS, - EVENT_COMMAND_RESULT, - EVENT_DEVICE_FUEL_DROP, - EVENT_DEVICE_MOVING, - EVENT_DEVICE_OFFLINE, - EVENT_DEVICE_ONLINE, - EVENT_DEVICE_OVERSPEED, - EVENT_DEVICE_STOPPED, - EVENT_DEVICE_UNKNOWN, - EVENT_DRIVER_CHANGED, - EVENT_GEOFENCE_ENTER, - EVENT_GEOFENCE_EXIT, - EVENT_IGNITION_OFF, - EVENT_IGNITION_ON, - EVENT_MAINTENANCE, - EVENT_TEXT_MESSAGE, -) - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) -SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=8082): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Required(CONF_MAX_ACCURACY, default=0): cv.positive_int, - vol.Optional(CONF_SKIP_ACCURACY_ON, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_EVENT, default=[]): vol.All( - cv.ensure_list, - [ - vol.Any( - EVENT_DEVICE_MOVING, - EVENT_COMMAND_RESULT, - EVENT_DEVICE_FUEL_DROP, - EVENT_GEOFENCE_ENTER, - EVENT_DEVICE_OFFLINE, - EVENT_DRIVER_CHANGED, - EVENT_GEOFENCE_EXIT, - EVENT_DEVICE_OVERSPEED, - EVENT_DEVICE_ONLINE, - EVENT_DEVICE_STOPPED, - EVENT_MAINTENANCE, - EVENT_ALARM, - EVENT_TEXT_MESSAGE, - EVENT_DEVICE_UNKNOWN, - EVENT_IGNITION_OFF, - EVENT_IGNITION_ON, - EVENT_ALL_EVENTS, - ) - ], - ), - } -) - - -async def async_setup_entry(hass: HomeAssistantType, entry, async_add_entities): - """Configure a dispatcher connection based on a config entry.""" - - @callback - def _receive_data(device, latitude, longitude, battery, accuracy, attrs): - """Receive set location.""" - if device in hass.data[DOMAIN]["devices"]: - return - - hass.data[DOMAIN]["devices"].add(device) - - async_add_entities( - [TraccarEntity(device, latitude, longitude, battery, accuracy, attrs)] - ) - - hass.data[DOMAIN]["unsub_device_tracker"][ - entry.entry_id - ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) - - # Restore previously loaded devices - dev_reg = await device_registry.async_get_registry(hass) - dev_ids = { - identifier[1] - for device in dev_reg.devices.values() - for identifier in device.identifiers - if identifier[0] == DOMAIN - } - if not dev_ids: - return - - entities = [] - for dev_id in dev_ids: - hass.data[DOMAIN]["devices"].add(dev_id) - entity = TraccarEntity(dev_id, None, None, None, None, None) - entities.append(entity) - - async_add_entities(entities) - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Validate the configuration and return a Traccar scanner.""" - - session = async_get_clientsession(hass, config[CONF_VERIFY_SSL]) - - api = API( - hass.loop, - session, - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_HOST], - config[CONF_PORT], - config[CONF_SSL], - ) - - scanner = TraccarScanner( - api, - hass, - async_see, - config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), - config[CONF_MAX_ACCURACY], - config[CONF_SKIP_ACCURACY_ON], - config[CONF_MONITORED_CONDITIONS], - config[CONF_EVENT], - ) - - return await scanner.async_init() - - -class TraccarScanner: - """Define an object to retrieve Traccar data.""" - - def __init__( - self, - api, - hass, - async_see, - scan_interval, - max_accuracy, - skip_accuracy_on, - custom_attributes, - event_types, - ): - """Initialize.""" - - self._event_types = {camelcase(evt): evt for evt in event_types} - self._custom_attributes = custom_attributes - self._scan_interval = scan_interval - self._async_see = async_see - self._api = api - self.connected = False - self._hass = hass - self._max_accuracy = max_accuracy - self._skip_accuracy_on = skip_accuracy_on - - async def async_init(self): - """Further initialize connection to Traccar.""" - await self._api.test_connection() - if self._api.connected and not self._api.authenticated: - _LOGGER.error("Authentication for Traccar failed") - return False - - await self._async_update() - async_track_time_interval(self._hass, self._async_update, self._scan_interval) - return True - - async def _async_update(self, now=None): - """Update info from Traccar.""" - if not self.connected: - _LOGGER.debug("Testing connection to Traccar") - await self._api.test_connection() - self.connected = self._api.connected - if self.connected: - _LOGGER.info("Connection to Traccar restored") - else: - return - _LOGGER.debug("Updating device data") - await self._api.get_device_info(self._custom_attributes) - self._hass.async_create_task(self.import_device_data()) - if self._event_types: - self._hass.async_create_task(self.import_events()) - self.connected = self._api.connected - - async def import_device_data(self): - """Import device data from Traccar.""" - for device_unique_id in self._api.device_info: - device_info = self._api.device_info[device_unique_id] - device = None - attr = {} - skip_accuracy_filter = False - - attr[ATTR_TRACKER] = "traccar" - if device_info.get("address") is not None: - attr[ATTR_ADDRESS] = device_info["address"] - if device_info.get("geofence") is not None: - attr[ATTR_GEOFENCE] = device_info["geofence"] - if device_info.get("category") is not None: - attr[ATTR_CATEGORY] = device_info["category"] - if device_info.get("speed") is not None: - attr[ATTR_SPEED] = device_info["speed"] - if device_info.get("motion") is not None: - attr[ATTR_MOTION] = device_info["motion"] - if device_info.get("traccar_id") is not None: - attr[ATTR_TRACCAR_ID] = device_info["traccar_id"] - for dev in self._api.devices: - if dev["id"] == device_info["traccar_id"]: - device = dev - break - if device is not None and device.get("status") is not None: - attr[ATTR_STATUS] = device["status"] - for custom_attr in self._custom_attributes: - if device_info.get(custom_attr) is not None: - attr[custom_attr] = device_info[custom_attr] - if custom_attr in self._skip_accuracy_on: - skip_accuracy_filter = True - - accuracy = 0.0 - if device_info.get("accuracy") is not None: - accuracy = device_info["accuracy"] - if ( - not skip_accuracy_filter - and self._max_accuracy > 0 - and accuracy > self._max_accuracy - ): - _LOGGER.debug( - "Excluded position by accuracy filter: %f (%s)", - accuracy, - attr[ATTR_TRACCAR_ID], - ) - continue - - await self._async_see( - dev_id=slugify(device_info["device_id"]), - gps=(device_info.get("latitude"), device_info.get("longitude")), - gps_accuracy=accuracy, - battery=device_info.get("battery"), - attributes=attr, - ) - - async def import_events(self): - """Import events from Traccar.""" - device_ids = [device["id"] for device in self._api.devices] - end_interval = datetime.utcnow() - start_interval = end_interval - self._scan_interval - events = await self._api.get_events( - device_ids=device_ids, - from_time=start_interval, - to_time=end_interval, - event_types=self._event_types.keys(), - ) - if events is not None: - for event in events: - device_name = next( - ( - dev.get("name") - for dev in self._api.devices - if dev.get("id") == event["deviceId"] - ), - None, - ) - self._hass.bus.async_fire( - f"traccar_{self._event_types.get(event['type'])}", - { - "device_traccar_id": event["deviceId"], - "device_name": device_name, - "type": event["type"], - "serverTime": event["serverTime"], - "attributes": event["attributes"], - }, - ) - - -class TraccarEntity(TrackerEntity, RestoreEntity): - """Represent a tracked device.""" - - def __init__(self, device, latitude, longitude, battery, accuracy, attributes): - """Set up Geofency entity.""" - self._accuracy = accuracy - self._attributes = attributes - self._name = device - self._battery = battery - self._latitude = latitude - self._longitude = longitude - self._unsub_dispatcher = None - self._unique_id = device - - @property - def battery_level(self): - """Return battery value of the device.""" - return self._battery - - @property - def device_state_attributes(self): - """Return device specific attributes.""" - return self._attributes - - @property - def latitude(self): - """Return latitude value of the device.""" - return self._latitude - - @property - def longitude(self): - """Return longitude value of the device.""" - return self._longitude - - @property - def location_accuracy(self): - """Return the gps accuracy of the device.""" - return self._accuracy - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info.""" - return {"name": self._name, "identifiers": {(DOMAIN, self._unique_id)}} - - @property - def source_type(self): - """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_GPS - - async def async_added_to_hass(self): - """Register state update callback.""" - await super().async_added_to_hass() - self._unsub_dispatcher = async_dispatcher_connect( - self.hass, TRACKER_UPDATE, self._async_receive_data - ) - - # don't restore if we got created with data - if self._latitude is not None or self._longitude is not None: - return - - state = await self.async_get_last_state() - if state is None: - self._latitude = None - self._longitude = None - self._accuracy = None - self._attributes = { - ATTR_ALTITUDE: None, - ATTR_BEARING: None, - ATTR_SPEED: None, - } - self._battery = None - return - - attr = state.attributes - self._latitude = attr.get(ATTR_LATITUDE) - self._longitude = attr.get(ATTR_LONGITUDE) - self._accuracy = attr.get(ATTR_ACCURACY) - self._attributes = { - ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), - ATTR_BEARING: attr.get(ATTR_BEARING), - ATTR_SPEED: attr.get(ATTR_SPEED), - } - self._battery = attr.get(ATTR_BATTERY) - - async def async_will_remove_from_hass(self): - """Clean up after entity before removal.""" - await super().async_will_remove_from_hass() - self._unsub_dispatcher() - - @callback - def _async_receive_data( - self, device, latitude, longitude, battery, accuracy, attributes - ): - """Mark the device as seen.""" - if device != self.name: - return - - self._latitude = latitude - self._longitude = longitude - self._battery = battery - self._accuracy = accuracy - self._attributes.update(attributes) - self.async_write_ha_state() From 4bdf0649147ee9fded51d619032b6947f7a33b44 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 30 Oct 2020 10:23:00 -0400 Subject: [PATCH 10/30] to use ultrasync v 0.8.1 --- homeassistant/components/ultrasync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json index a3aa6ea3da47f..3d6e472761d29 100644 --- a/homeassistant/components/ultrasync/manifest.json +++ b/homeassistant/components/ultrasync/manifest.json @@ -2,7 +2,7 @@ "domain": "ultrasync", "name": "Interlogix Ultrasync Hub", "documentation": "https://www.home-assistant.io/integrations/ultrasync", - "requirements": ["ultrasync==0.8.0"], + "requirements": ["ultrasync==0.8.1"], "codeowners": ["@caronc"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 4981a6a7a2efa..68b36cec300d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2282,7 +2282,7 @@ twilio==6.32.0 uEagle==0.0.2 # homeassistant.components.ultrasync -ultrasync==0.8.0 +ultrasync==0.8.1 # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb157627a997b..c62572dd8e98a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ twentemilieu==0.3.0 twilio==6.32.0 # homeassistant.components.ultrasync -ultrasync==0.8.0 +ultrasync==0.8.1 # homeassistant.components.upb upb_lib==0.4.12 From 4d7e22d83a3e06e3cf07ad995e8534739bb20dd5 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 30 Oct 2020 10:50:49 -0400 Subject: [PATCH 11/30] ha python 3.7 unit tests fix --- homeassistant/components/ultrasync/config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 347b0110f03d0..5f3180379d965 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -16,7 +16,8 @@ from homeassistant.core import callback from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .const import DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_NAME, DEFAULT_SCAN_INTERVAL +from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -47,6 +48,10 @@ class UltraSyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + @staticmethod @callback def async_get_options_flow(config_entry): From c0a19f4c39be09f9a102b3d628af0b1c18dcec03 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 31 Oct 2020 12:33:12 -0400 Subject: [PATCH 12/30] yaml configuration reference removed --- .../components/ultrasync/__init__.py | 41 +------------------ .../components/ultrasync/config_flow.py | 20 +-------- tests/components/ultrasync/__init__.py | 12 ------ tests/components/ultrasync/test_init.py | 19 +-------- 4 files changed, 4 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index f4a8973b4eaee..1bf8cec0459e1 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -5,23 +5,15 @@ from ultrasync import AlarmScene import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PIN, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, - DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN, SERVICE_AWAY, @@ -32,40 +24,11 @@ PLATFORMS = ["sensor"] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PIN): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - async def async_setup(hass: HomeAssistantType, config: dict) -> bool: """Set up the UltraSync integration.""" hass.data.setdefault(DOMAIN, {}) - if hass.config_entries.async_entries(DOMAIN): - return True - - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - return True diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 5f3180379d965..f3e5a104f79bf 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -48,25 +48,12 @@ class UltraSyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - def __init__(self, config_entry): - """Initialize options flow.""" - self.config_entry = config_entry - @staticmethod @callback def async_get_options_flow(config_entry): """Get the options flow for this handler.""" return UltraSyncOptionsFlowHandler(config_entry) - async def async_step_import( - self, user_input: Optional[ConfigType] = None - ) -> Dict[str, Any]: - """Handle a flow initiated by configuration file.""" - if CONF_SCAN_INTERVAL in user_input: - user_input[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL].seconds - - return await self.async_step_user(user_input) - async def async_step_user( self, user_input: Optional[ConfigType] = None ) -> Dict[str, Any]: @@ -113,12 +100,7 @@ async def async_step_init(self, user_input: Optional[ConfigType] = None): return self.async_create_entry(title="", data=user_input) options = { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): int, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/tests/components/ultrasync/__init__.py b/tests/components/ultrasync/__init__.py index acb1c0ece28d1..4972defe46ab4 100644 --- a/tests/components/ultrasync/__init__.py +++ b/tests/components/ultrasync/__init__.py @@ -1,6 +1,4 @@ """Tests for the UltraSync integration.""" -from datetime import timedelta - from homeassistant.components.ultrasync.const import DOMAIN from homeassistant.const import ( CONF_HOST, @@ -29,16 +27,6 @@ CONF_PIN: "5678", } -YAML_CONFIG = { - CONF_NAME: "UltraSyncYAML", - CONF_HOST: "127.0.0.3", - CONF_USERNAME: "User 3", - CONF_PIN: "9876", - CONF_SCAN_INTERVAL: timedelta(seconds=5), -} - -MOCK_VERSION = "21.0" - MOCK_AREAS = [ {"bank": 0, "name": "Area 1", "sequence": 30, "status": "Ready"}, ] diff --git a/tests/components/ultrasync/test_init.py b/tests/components/ultrasync/test_init.py index 5d763a94045a0..815cf008d4ebc 100644 --- a/tests/components/ultrasync/test_init.py +++ b/tests/components/ultrasync/test_init.py @@ -5,30 +5,13 @@ ENTRY_STATE_NOT_LOADED, ENTRY_STATE_SETUP_RETRY, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_USERNAME -from homeassistant.setup import async_setup_component -from . import ENTRY_CONFIG, YAML_CONFIG, _patch_async_setup_entry, init_integration +from . import ENTRY_CONFIG, init_integration from tests.async_mock import patch from tests.common import MockConfigEntry -async def test_import_from_yaml(hass, ultrasync_api) -> None: - """Test import from YAML.""" - with _patch_async_setup_entry(): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_NAME] == "UltraSyncYAML" - assert entries[0].data[CONF_USERNAME] == "User 3" - assert entries[0].data[CONF_HOST] == "127.0.0.3" - assert entries[0].data[CONF_PIN] == "9876" - - async def test_unload_entry(hass, ultrasync_api): """Test successful unload of entry.""" entry = await init_integration(hass) From 13b8e902adbfea4a701afc44489c80c424054dd0 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 31 Oct 2020 14:28:24 -0400 Subject: [PATCH 13/30] sensor tracking slightly updated --- homeassistant/components/ultrasync/coordinator.py | 13 +++++++------ tests/components/ultrasync/test_sensor.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index 662419bd3c8ad..32b0744a24416 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -48,10 +48,10 @@ def _update_data() -> dict: # initialize our response response = { - "Area1State": "unknown", - "Area2State": "unknown", - "Area3State": "unknown", - "Area4State": "unknown", + "area01_state": "unknown", + "area02_state": "unknown", + "area03_state": "unknown", + "area04_state": "unknown", } # Update our details @@ -86,8 +86,9 @@ def _update_data() -> dict: self._area_delta[area["bank"]] = area["sequence"] # Set our state: - response["Area{}State".format(area["bank"] + 1)] = area["status"] - + response["area{:0>2}_state".format(area["bank"] + 1)] = area[ + "status" + ] self._init = True # Return our response diff --git a/tests/components/ultrasync/test_sensor.py b/tests/components/ultrasync/test_sensor.py index 8869830cb09b7..99e6061ac8f82 100644 --- a/tests/components/ultrasync/test_sensor.py +++ b/tests/components/ultrasync/test_sensor.py @@ -8,7 +8,7 @@ async def test_sensors(hass, ultrasync_api) -> None: registry = await hass.helpers.entity_registry.async_get_registry() sensors = { - "area01_state": ("area1state", "unknown"), + "area01_state": ("area1state", "Ready"), "area02_state": ("area2state", "unknown"), "area03_state": ("area3state", "unknown"), "area04_state": ("area4state", "unknown"), From d7643ffba00ace2d45c1facaf7362959d3f85260 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Wed, 11 Nov 2020 18:02:22 -0500 Subject: [PATCH 14/30] bumped version to 0.9.0 --- homeassistant/components/ultrasync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json index 3d6e472761d29..bf1b3a418f77e 100644 --- a/homeassistant/components/ultrasync/manifest.json +++ b/homeassistant/components/ultrasync/manifest.json @@ -2,7 +2,7 @@ "domain": "ultrasync", "name": "Interlogix Ultrasync Hub", "documentation": "https://www.home-assistant.io/integrations/ultrasync", - "requirements": ["ultrasync==0.8.1"], + "requirements": ["ultrasync==0.9.0"], "codeowners": ["@caronc"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 68b36cec300d9..b8fc045f58919 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2282,7 +2282,7 @@ twilio==6.32.0 uEagle==0.0.2 # homeassistant.components.ultrasync -ultrasync==0.8.1 +ultrasync==0.9.0 # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c62572dd8e98a..d2b25ec51d768 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ twentemilieu==0.3.0 twilio==6.32.0 # homeassistant.components.ultrasync -ultrasync==0.8.1 +ultrasync==0.9.0 # homeassistant.components.upb upb_lib==0.4.12 From 009eb10b5a9ee652e45d9177d766bf15ff12bec4 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 13 Nov 2020 13:33:18 -0500 Subject: [PATCH 15/30] improved test coverage --- .../components/ultrasync/__init__.py | 2 +- .../components/ultrasync/config_flow.py | 2 +- homeassistant/components/ultrasync/const.py | 2 +- homeassistant/components/ultrasync/sensor.py | 2 +- tests/components/ultrasync/__init__.py | 10 ---- tests/components/ultrasync/conftest.py | 48 +++++++++++++++---- .../components/ultrasync/test_config_flow.py | 32 ++++++++++++- tests/components/ultrasync/test_sensor.py | 26 ++++++++++ 8 files changed, 98 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index 1bf8cec0459e1..c38fedee622ad 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -1,4 +1,4 @@ -"""The Interlogix UltraSync Hub component.""" +"""The Interlogix/Hills ComNav UltraSync Hub component.""" import asyncio diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index f3e5a104f79bf..5b8d5ab8054cb 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Interlogix Ultrasync Hub.""" +"""Config flow for the Interlogix/Hills ComNav UltraSync Hub.""" import logging from typing import Any, Dict, Optional diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py index aae461f0e41ad..8150f56985e74 100644 --- a/homeassistant/components/ultrasync/const.py +++ b/homeassistant/components/ultrasync/const.py @@ -1,4 +1,4 @@ -"""Constants for Interlogix UltraSync Hub.""" +"""Constants the Interlogix/Hills ComNav UltraSync Hub.""" DOMAIN = "ultrasync" diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index 37699cee45beb..25ba82d7a5db3 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -1,4 +1,4 @@ -"""Monitor the Interlogix UltraSync Hub.""" +"""Monitor the Interlogix/Hills ComNav UltraSync Hub.""" import logging from typing import Callable, List diff --git a/tests/components/ultrasync/__init__.py b/tests/components/ultrasync/__init__.py index 4972defe46ab4..d37889704dc6a 100644 --- a/tests/components/ultrasync/__init__.py +++ b/tests/components/ultrasync/__init__.py @@ -27,16 +27,6 @@ CONF_PIN: "5678", } -MOCK_AREAS = [ - {"bank": 0, "name": "Area 1", "sequence": 30, "status": "Ready"}, -] - - -MOCK_ZONES = [ - {"bank": 0, "name": "Front door", "sequence": 1, "status": "Ready"}, - {"bank": 1, "name": "Back door", "sequence": 1, "status": "Ready"}, -] - async def init_integration( hass, diff --git a/tests/components/ultrasync/conftest.py b/tests/components/ultrasync/conftest.py index eb25f084cff6d..4429351705ec2 100644 --- a/tests/components/ultrasync/conftest.py +++ b/tests/components/ultrasync/conftest.py @@ -1,10 +1,43 @@ """Define fixtures available for all tests.""" from pytest import fixture -from . import MOCK_AREAS, MOCK_ZONES - from tests.async_mock import MagicMock, patch +MOCK_AREAS_0 = [ + {"bank": 0, "name": "Area 1", "sequence": 30, "status": "Ready"}, +] + +MOCK_AREAS_1 = [ + {"bank": 0, "name": "Area 1", "sequence": 31, "status": "Not Ready"}, + # A dummy invalid bank to trigger throwing an invalid sensor update + # for test coverage... + {"bank": 98, "name": "Invalid", "sequence": 0, "status": "Ready"}, +] + +MOCK_ZONES_0 = [ + {"bank": 0, "name": "Front door", "sequence": 1, "status": "Ready"}, + {"bank": 1, "name": "Back door", "sequence": 1, "status": "Ready"}, +] + +MOCK_ZONES_1 = [ + {"bank": 0, "name": "Front door", "sequence": 2, "status": "Not Ready"}, + {"bank": 1, "name": "Back door", "sequence": 1, "status": "Ready"}, + # A dummy invalid bank to trigger throwing an invalid sensor update + # for test coverage... + {"bank": 98, "name": "Invalid", "sequence": 0, "status": "Ready"}, +] + +MOCK_RESPONSES = ( + { + "areas": MOCK_AREAS_0, + "zones": MOCK_ZONES_0, + }, + { + "areas": MOCK_AREAS_1, + "zones": MOCK_ZONES_1, + }, +) + @fixture def ultrasync_api(hass): @@ -13,12 +46,7 @@ def ultrasync_api(hass): with patch("ultrasync.UltraSync") as mock_api: instance = mock_api.return_value instance.login = MagicMock(return_value=True) - instance.details = MagicMock( - return_value={ - "areas": MOCK_AREAS, - "zones": MOCK_ZONES, - } - ) - instance.areas = MagicMock(return_value=list(MOCK_AREAS)) - instance.zones = MagicMock(return_value=list(MOCK_ZONES)) + instance.details = MagicMock(side_effect=MOCK_RESPONSES) + instance.areas = MagicMock(return_value=list(MOCK_AREAS_0)) + instance.zones = MagicMock(return_value=list(MOCK_ZONES_0)) yield mock_api diff --git a/tests/components/ultrasync/test_config_flow.py b/tests/components/ultrasync/test_config_flow.py index cb4a743b10f4b..7fae004aac4aa 100644 --- a/tests/components/ultrasync/test_config_flow.py +++ b/tests/components/ultrasync/test_config_flow.py @@ -1,8 +1,10 @@ """Test the UltraSync config flow.""" -from homeassistant.components.ultrasync.const import DOMAIN +from homeassistant import data_entry_flow +from homeassistant.components.ultrasync import config_flow +from homeassistant.components.ultrasync.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY, @@ -123,3 +125,29 @@ async def test_options_flow(hass, ultrasync_api): assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["data"][CONF_SCAN_INTERVAL] == 15 + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.UltraSyncConfigFlow() + flow.hass = hass + return flow + + +async def test_options(hass): + """Test updating options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CONF_NAME, + data=ENTRY_CONFIG, + options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, + ) + flow = init_config_flow(hass) + options_flow = flow.async_get_options_flow(entry) + + result = await options_flow.async_step_init() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + result = await options_flow.async_step_init({CONF_SCAN_INTERVAL: 10}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_SCAN_INTERVAL] == 10 diff --git a/tests/components/ultrasync/test_sensor.py b/tests/components/ultrasync/test_sensor.py index 99e6061ac8f82..b28b7b26a1fcb 100644 --- a/tests/components/ultrasync/test_sensor.py +++ b/tests/components/ultrasync/test_sensor.py @@ -1,6 +1,12 @@ """Test the UltraSync sensors.""" +from datetime import timedelta + +from homeassistant.util import dt as dt_util + from . import init_integration +from tests.common import async_fire_time_changed + async def test_sensors(hass, ultrasync_api) -> None: """Test the creation and the initial values of the sensors.""" @@ -22,3 +28,23 @@ async def test_sensors(hass, ultrasync_api) -> None: state = hass.states.get(f"sensor.ultrasync_{data[0]}") assert state assert state.state == data[1] + + # trigger an update + async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) + await hass.async_block_till_done() + + sensors = { + "area01_state": ("area1state", "Not Ready"), + "area02_state": ("area2state", "unknown"), + "area03_state": ("area3state", "unknown"), + "area04_state": ("area4state", "unknown"), + } + + for (sensor_id, data) in sensors.items(): + entity_entry = registry.async_get(f"sensor.ultrasync_{data[0]}") + assert entity_entry + assert entity_entry.unique_id == f"{entry.entry_id}_{sensor_id}" + + state = hass.states.get(f"sensor.ultrasync_{data[0]}") + assert state + assert state.state == data[1] From 55b4332f5e49222d0079012c143be4285f60919b Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 13 Nov 2020 14:37:43 -0500 Subject: [PATCH 16/30] better zone testing coverage --- homeassistant/components/ultrasync/coordinator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index 32b0744a24416..c3148e01083af 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -55,14 +55,14 @@ def _update_data() -> dict: } # Update our details - details = self.hub.details() + details = self.hub.details(max_age_sec=0) if details: - for bank, zone in self.hub.zones.items(): + for zone in details["zones"]: if self._zone_delta.get(zone["bank"]) != zone["sequence"]: self.hass.bus.fire( "ultrasync_sensor_update", { - "sensor": bank + 1, + "sensor": zone["bank"] + 1, "name": zone["name"], "status": zone["status"], }, @@ -71,7 +71,7 @@ def _update_data() -> dict: # Update our sequence self._zone_delta[zone["bank"]] = zone["sequence"] - for area in details.get("areas", []): + for area in details["areas"]: if self._area_delta.get(area["bank"]) != area["sequence"]: self.hass.bus.fire( "ultrasync_area_update", From c97a2e009550b282384bed1fd2e8bbd42bff8acb Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 13 Nov 2020 15:12:12 -0500 Subject: [PATCH 17/30] removed unreference block of code --- homeassistant/components/ultrasync/config_flow.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 5b8d5ab8054cb..fda5b80a6f2f4 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -94,17 +94,6 @@ async def async_step_user( errors=errors, ) - async def async_step_init(self, user_input: Optional[ConfigType] = None): - """Manage UltraSync options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - options = { - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) - class UltraSyncOptionsFlowHandler(config_entries.OptionsFlow): """Handle UltraSync client options.""" From 6a2c66c4979f0eec6e9caaff42eec8550b0a5055 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 22 Nov 2020 12:07:25 -0500 Subject: [PATCH 18/30] bumped version of ultrasync to v0.9.1 --- homeassistant/components/ultrasync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json index bf1b3a418f77e..0a74c5b801641 100644 --- a/homeassistant/components/ultrasync/manifest.json +++ b/homeassistant/components/ultrasync/manifest.json @@ -2,7 +2,7 @@ "domain": "ultrasync", "name": "Interlogix Ultrasync Hub", "documentation": "https://www.home-assistant.io/integrations/ultrasync", - "requirements": ["ultrasync==0.9.0"], + "requirements": ["ultrasync==0.9.1"], "codeowners": ["@caronc"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index b8fc045f58919..185d69961959e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2282,7 +2282,7 @@ twilio==6.32.0 uEagle==0.0.2 # homeassistant.components.ultrasync -ultrasync==0.9.0 +ultrasync==0.9.1 # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2b25ec51d768..126f4e82e4234 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ twentemilieu==0.3.0 twilio==6.32.0 # homeassistant.components.ultrasync -ultrasync==0.9.0 +ultrasync==0.9.1 # homeassistant.components.upb upb_lib==0.4.12 From f123eab5eeb37038d11a452bb8ab3bdc5ff679a2 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 6 Dec 2020 14:23:02 -0500 Subject: [PATCH 19/30] bumped version to 0.9.2 --- homeassistant/components/ultrasync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json index 0a74c5b801641..deb2bcebab753 100644 --- a/homeassistant/components/ultrasync/manifest.json +++ b/homeassistant/components/ultrasync/manifest.json @@ -2,7 +2,7 @@ "domain": "ultrasync", "name": "Interlogix Ultrasync Hub", "documentation": "https://www.home-assistant.io/integrations/ultrasync", - "requirements": ["ultrasync==0.9.1"], + "requirements": ["ultrasync==0.9.2"], "codeowners": ["@caronc"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 185d69961959e..2e69ffb731747 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2282,7 +2282,7 @@ twilio==6.32.0 uEagle==0.0.2 # homeassistant.components.ultrasync -ultrasync==0.9.1 +ultrasync==0.9.2 # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 126f4e82e4234..9e531c1d2b0fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ twentemilieu==0.3.0 twilio==6.32.0 # homeassistant.components.ultrasync -ultrasync==0.9.1 +ultrasync==0.9.2 # homeassistant.components.upb upb_lib==0.4.12 From db83a0ee1ba01e2ccab4db63853de9c9fa284acc Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 13 Dec 2020 12:36:42 -0500 Subject: [PATCH 20/30] backported enhancements in HACS branch to core --- .../components/ultrasync/__init__.py | 19 ++- .../components/ultrasync/config_flow.py | 8 +- homeassistant/components/ultrasync/const.py | 6 +- .../components/ultrasync/coordinator.py | 37 +++--- .../components/ultrasync/manifest.json | 2 +- homeassistant/components/ultrasync/sensor.py | 119 +++++++++++++++--- tests/components/ultrasync/conftest.py | 14 +++ tests/components/ultrasync/test_sensor.py | 37 +++++- 8 files changed, 183 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index c38fedee622ad..a377118a452e6 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -2,20 +2,20 @@ import asyncio -from ultrasync import AlarmScene -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from ultrasync import AlarmScene +import voluptuous as vol from .const import ( DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, DEFAULT_SCAN_INTERVAL, DOMAIN, + SENSORS, SERVICE_AWAY, SERVICE_DISARM, SERVICE_STAY, @@ -43,9 +43,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool hass.config_entries.async_update_entry(entry, options=options) coordinator = UltraSyncDataUpdateCoordinator( - hass, - config=entry.data, - options=entry.options, + hass, config=entry.data, options=entry.options, ) await coordinator.async_refresh() @@ -57,7 +55,8 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool hass.data[DOMAIN][entry.entry_id] = { DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, + DATA_UNDO_UPDATE_LISTENER: [undo_listener], + SENSORS: {}, } for component in PLATFORMS: @@ -82,15 +81,15 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo ) if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + for unsub in hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]: + unsub() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok def _async_register_services( - hass: HomeAssistantType, - coordinator: UltraSyncDataUpdateCoordinator, + hass: HomeAssistantType, coordinator: UltraSyncDataUpdateCoordinator, ) -> None: """Register integration-level services.""" diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index fda5b80a6f2f4..9c2eac029e606 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -2,9 +2,6 @@ import logging from typing import Any, Dict, Optional -import ultrasync -import voluptuous as vol - from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, @@ -15,6 +12,8 @@ ) from homeassistant.core import callback from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import ultrasync +import voluptuous as vol from .const import DEFAULT_NAME, DEFAULT_SCAN_INTERVAL from .const import DOMAIN # pylint: disable=unused-import @@ -77,8 +76,7 @@ async def async_step_user( return self.async_abort(reason="unknown") else: return self.async_create_entry( - title=user_input[CONF_HOST], - data=user_input, + title=user_input[CONF_HOST], data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py index 8150f56985e74..a55952d256389 100644 --- a/homeassistant/components/ultrasync/const.py +++ b/homeassistant/components/ultrasync/const.py @@ -3,7 +3,7 @@ DOMAIN = "ultrasync" # Scan Time (in seconds) -DEFAULT_SCAN_INTERVAL = 5 +DEFAULT_SCAN_INTERVAL = 1 DEFAULT_NAME = "UltraSync" @@ -12,5 +12,9 @@ SERVICE_STAY = "stay" SERVICE_DISARM = "disarm" +# Index used for managing loaded sensors +SENSORS = "sensors" + DATA_COORDINATOR = "coordinator" DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" +SENSOR_UPDATE_LISTENER = "ultrasync_update_sensors" diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index c3148e01083af..0635a68beb9dd 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -3,13 +3,13 @@ import logging from async_timeout import timeout -import ultrasync - from homeassistant.const import CONF_HOST, CONF_PIN, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import ultrasync -from .const import DOMAIN +from .const import DOMAIN, SENSOR_UPDATE_LISTENER _LOGGER = logging.getLogger(__name__) @@ -20,9 +20,7 @@ class UltraSyncDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): """Initialize global UltraSync data updater.""" self.hub = ultrasync.UltraSync( - user=config[CONF_USERNAME], - pin=config[CONF_PIN], - host=config[CONF_HOST], + user=config[CONF_USERNAME], pin=config[CONF_PIN], host=config[CONF_HOST], ) self._init = False @@ -34,10 +32,7 @@ def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): update_interval = timedelta(seconds=options[CONF_SCAN_INTERVAL]) super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=update_interval, + hass, _LOGGER, name=DOMAIN, update_interval=update_interval, ) async def _async_update_data(self) -> dict: @@ -47,20 +42,22 @@ def _update_data() -> dict: """Fetch data from UltraSync via sync functions.""" # initialize our response - response = { - "area01_state": "unknown", - "area02_state": "unknown", - "area03_state": "unknown", - "area04_state": "unknown", - } + response = {} # Update our details details = self.hub.details(max_age_sec=0) if details: + async_dispatcher_send( + self.hass, + SENSOR_UPDATE_LISTENER, + details["areas"], + details["zones"], + ) + for zone in details["zones"]: if self._zone_delta.get(zone["bank"]) != zone["sequence"]: self.hass.bus.fire( - "ultrasync_sensor_update", + "ultrasync_zone_update", { "sensor": zone["bank"] + 1, "name": zone["name"], @@ -71,6 +68,11 @@ def _update_data() -> dict: # Update our sequence self._zone_delta[zone["bank"]] = zone["sequence"] + # Set our state: + response["zone{:0>2}_state".format(zone["bank"] + 1)] = zone[ + "status" + ] + for area in details["areas"]: if self._area_delta.get(area["bank"]) != area["sequence"]: self.hass.bus.fire( @@ -89,6 +91,7 @@ def _update_data() -> dict: response["area{:0>2}_state".format(area["bank"] + 1)] = area[ "status" ] + self._init = True # Return our response diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json index deb2bcebab753..6df629edfe6d2 100644 --- a/homeassistant/components/ultrasync/manifest.json +++ b/homeassistant/components/ultrasync/manifest.json @@ -1,6 +1,6 @@ { "domain": "ultrasync", - "name": "Interlogix Ultrasync Hub", + "name": "UltraSync Alarm Panel", "documentation": "https://www.home-assistant.io/integrations/ultrasync", "requirements": ["ultrasync==0.9.2"], "codeowners": ["@caronc"], diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index 25ba82d7a5db3..43cb50e6c05b2 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -5,22 +5,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType from . import UltraSyncEntity -from .const import DATA_COORDINATOR, DOMAIN +from .const import ( + DATA_COORDINATOR, + DATA_UNDO_UPDATE_LISTENER, + DOMAIN, + SENSOR_UPDATE_LISTENER, + SENSORS, +) from .coordinator import UltraSyncDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -SENSORS = { - "area01_state": "Area1State", - "area02_state": "Area2State", - "area03_state": "Area3State", - "area04_state": "Area4State", -} - async def async_setup_entry( hass: HomeAssistantType, @@ -31,20 +32,88 @@ async def async_setup_entry( coordinator: UltraSyncDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ DATA_COORDINATOR ] - sensors = [] - - for sensor_type, sensor_name in SENSORS.items(): - sensors.append( - UltraSyncSensor( - coordinator, - entry.entry_id, - entry.data[CONF_NAME], - sensor_type, - sensor_name, + + # At least one sensor must be pre-created or Home Assistant will not + # call any updates + hass.data[DOMAIN][entry.entry_id][SENSORS]["area01_state"] = UltraSyncSensor( + coordinator, entry.entry_id, entry.data[CONF_NAME], "area01_state", "Area1State" + ) + + async_add_entities([hass.data[DOMAIN][entry.entry_id][SENSORS]["area01_state"]]) + + @callback + def _auto_manage_sensors(areas: dict, zones: dict) -> None: + """Dynamically create/delete sensors based on what was detected by the hub.""" + + _LOGGER.debug( + "Entering _auto_manage_sensors with {} area(s), and {} zone(s)".format( + len(areas), len(zones) ) ) - async_add_entities(sensors) + # our list of sensors to add + new_sensors = [] + + # A pointer to our sensors + sensors = hass.data[DOMAIN][entry.entry_id][SENSORS] + + for meta in areas: + bank_no = meta["bank"] + sensor_id = "area{:0>2}_state".format(bank_no + 1) + if sensor_id not in sensors: + # hash our entry + sensors[sensor_id] = UltraSyncSensor( + coordinator, + entry.entry_id, + entry.data[CONF_NAME], + sensor_id, + # Friendly Name + "Area{}State".format(bank_no + 1), + ) + + # Add our new area sensor + new_sensors.append(sensors[sensor_id]) + _LOGGER.debug( + "Detected {}.Area{}State".format(entry.data[CONF_NAME], bank_no + 1) + ) + + # Update our meta information + for key, value in meta.items(): + sensors[sensor_id][key] = value + + for meta in zones: + bank_no = meta["bank"] + sensor_id = "zone{:0>2}_state".format(bank_no + 1) + if sensor_id not in sensors: + # hash our entry + sensors[sensor_id] = UltraSyncSensor( + coordinator, + entry.entry_id, + entry.data[CONF_NAME], + sensor_id, + # Friendly Name + "Zone{}State".format(bank_no + 1), + ) + + # Add our new zone sensor + new_sensors.append(sensors[sensor_id]) + _LOGGER.debug( + "Detected {}.Zone{}State".format(entry.data[CONF_NAME], bank_no + 1) + ) + + # Update our meta information + for key, value in meta.items(): + sensors[sensor_id][key] = value + + if new_sensors: + # Add our newly detected sensors + async_add_entities(new_sensors) + + # register our callback which will be called the second we make a + # connection to our panel + hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER].append( + async_dispatcher_connect(hass, SENSOR_UPDATE_LISTENER, _auto_manage_sensors) + ) class UltraSyncSensor(UltraSyncEntity): @@ -63,17 +132,29 @@ def __init__( self._sensor_type = sensor_type self._unique_id = f"{entry_id}_{sensor_type}" + # Initialize our Attributes + self.__attributes = {} + super().__init__( coordinator=coordinator, entry_id=entry_id, name=f"{entry_name} {sensor_name}", ) + def __setitem__(self, key, value): + """Sets our attributes.""" + self.__attributes[key] = value + @property def unique_id(self) -> str: """Return the unique ID of the sensor.""" return self._unique_id + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return self.__attributes + @property def state(self): """Return the state of the sensor.""" diff --git a/tests/components/ultrasync/conftest.py b/tests/components/ultrasync/conftest.py index 4429351705ec2..dcb81be7c214c 100644 --- a/tests/components/ultrasync/conftest.py +++ b/tests/components/ultrasync/conftest.py @@ -14,6 +14,11 @@ {"bank": 98, "name": "Invalid", "sequence": 0, "status": "Ready"}, ] +MOCK_AREAS_2 = [ + # We return to a ready state + {"bank": 0, "name": "Area 1", "sequence": 32, "status": "Ready"}, +] + MOCK_ZONES_0 = [ {"bank": 0, "name": "Front door", "sequence": 1, "status": "Ready"}, {"bank": 1, "name": "Back door", "sequence": 1, "status": "Ready"}, @@ -27,6 +32,11 @@ {"bank": 98, "name": "Invalid", "sequence": 0, "status": "Ready"}, ] +MOCK_ZONES_2 = [ + # Backdoor sensor was removed + {"bank": 0, "name": "Front door", "sequence": 3, "status": "Ready"}, +] + MOCK_RESPONSES = ( { "areas": MOCK_AREAS_0, @@ -36,6 +46,10 @@ "areas": MOCK_AREAS_1, "zones": MOCK_ZONES_1, }, + { + "areas": MOCK_AREAS_2, + "zones": MOCK_ZONES_2, + }, ) diff --git a/tests/components/ultrasync/test_sensor.py b/tests/components/ultrasync/test_sensor.py index b28b7b26a1fcb..8b1d2069bda46 100644 --- a/tests/components/ultrasync/test_sensor.py +++ b/tests/components/ultrasync/test_sensor.py @@ -15,9 +15,6 @@ async def test_sensors(hass, ultrasync_api) -> None: sensors = { "area01_state": ("area1state", "Ready"), - "area02_state": ("area2state", "unknown"), - "area03_state": ("area3state", "unknown"), - "area04_state": ("area4state", "unknown"), } for (sensor_id, data) in sensors.items(): @@ -29,15 +26,43 @@ async def test_sensors(hass, ultrasync_api) -> None: assert state assert state.state == data[1] + # These sensors have not been registered at this point yet: + sensors = ("zone1state", "zone2state") + for ident in sensors: + entity_entry = registry.async_get(f"sensor.ultrasync_{ident}") + assert entity_entry is None + state = hass.states.get(f"sensor.ultrasync_{ident}") + assert state is None + # trigger an update async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) await hass.async_block_till_done() + # Our dynamic sensors would have been created after our first connection sensors = { "area01_state": ("area1state", "Not Ready"), - "area02_state": ("area2state", "unknown"), - "area03_state": ("area3state", "unknown"), - "area04_state": ("area4state", "unknown"), + "zone01_state": ("zone1state", "Not Ready"), + "zone02_state": ("zone2state", "Ready"), + } + + for (sensor_id, data) in sensors.items(): + entity_entry = registry.async_get(f"sensor.ultrasync_{data[0]}") + assert entity_entry + assert entity_entry.unique_id == f"{entry.entry_id}_{sensor_id}" + + state = hass.states.get(f"sensor.ultrasync_{data[0]}") + assert state + assert state.state == data[1] + + # trigger an update + async_fire_time_changed(hass, dt_util.now() + timedelta(days=4)) + await hass.async_block_till_done() + + # Zone02 is gone now + sensors = { + "area01_state": ("area1state", "Ready"), + "zone01_state": ("zone1state", "Ready"), + "zone02_state": ("zone2state", "unknown"), } for (sensor_id, data) in sensors.items(): From f40a0afd408a2916223f8dbc67e4ba0e496b8c12 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 13 Dec 2020 14:43:29 -0500 Subject: [PATCH 21/30] black, isort and pep8 fixes --- homeassistant/components/ultrasync/__init__.py | 12 ++++++++---- homeassistant/components/ultrasync/config_flow.py | 8 +++++--- homeassistant/components/ultrasync/coordinator.py | 12 +++++++++--- homeassistant/components/ultrasync/sensor.py | 12 ++++++------ 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index a377118a452e6..d86e1576048e5 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -2,13 +2,14 @@ import asyncio +from ultrasync import AlarmScene +import voluptuous as vol + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ultrasync import AlarmScene -import voluptuous as vol from .const import ( DATA_COORDINATOR, @@ -43,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool hass.config_entries.async_update_entry(entry, options=options) coordinator = UltraSyncDataUpdateCoordinator( - hass, config=entry.data, options=entry.options, + hass, + config=entry.data, + options=entry.options, ) await coordinator.async_refresh() @@ -89,7 +92,8 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo def _async_register_services( - hass: HomeAssistantType, coordinator: UltraSyncDataUpdateCoordinator, + hass: HomeAssistantType, + coordinator: UltraSyncDataUpdateCoordinator, ) -> None: """Register integration-level services.""" diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 9c2eac029e606..fda5b80a6f2f4 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -2,6 +2,9 @@ import logging from typing import Any, Dict, Optional +import ultrasync +import voluptuous as vol + from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, @@ -12,8 +15,6 @@ ) from homeassistant.core import callback from homeassistant.helpers.typing import ConfigType, HomeAssistantType -import ultrasync -import voluptuous as vol from .const import DEFAULT_NAME, DEFAULT_SCAN_INTERVAL from .const import DOMAIN # pylint: disable=unused-import @@ -76,7 +77,8 @@ async def async_step_user( return self.async_abort(reason="unknown") else: return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input, + title=user_input[CONF_HOST], + data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index 0635a68beb9dd..aba6d3ee0b405 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -3,11 +3,12 @@ import logging from async_timeout import timeout +import ultrasync + from homeassistant.const import CONF_HOST, CONF_PIN, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import ultrasync from .const import DOMAIN, SENSOR_UPDATE_LISTENER @@ -20,7 +21,9 @@ class UltraSyncDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): """Initialize global UltraSync data updater.""" self.hub = ultrasync.UltraSync( - user=config[CONF_USERNAME], pin=config[CONF_PIN], host=config[CONF_HOST], + user=config[CONF_USERNAME], + pin=config[CONF_PIN], + host=config[CONF_HOST], ) self._init = False @@ -32,7 +35,10 @@ def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): update_interval = timedelta(seconds=options[CONF_SCAN_INTERVAL]) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=update_interval, + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, ) async def _async_update_data(self) -> dict: diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index 43cb50e6c05b2..1c268f2e571e4 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -46,9 +46,9 @@ def _auto_manage_sensors(areas: dict, zones: dict) -> None: """Dynamically create/delete sensors based on what was detected by the hub.""" _LOGGER.debug( - "Entering _auto_manage_sensors with {} area(s), and {} zone(s)".format( - len(areas), len(zones) - ) + "Entering _auto_manage_sensors with %d area(s), and %d zone(s)", + len(areas), + len(zones), ) # our list of sensors to add @@ -74,7 +74,7 @@ def _auto_manage_sensors(areas: dict, zones: dict) -> None: # Add our new area sensor new_sensors.append(sensors[sensor_id]) _LOGGER.debug( - "Detected {}.Area{}State".format(entry.data[CONF_NAME], bank_no + 1) + "Detected %s.Area%dState", entry.data[CONF_NAME], bank_no + 1 ) # Update our meta information @@ -98,7 +98,7 @@ def _auto_manage_sensors(areas: dict, zones: dict) -> None: # Add our new zone sensor new_sensors.append(sensors[sensor_id]) _LOGGER.debug( - "Detected {}.Zone{}State".format(entry.data[CONF_NAME], bank_no + 1) + "Detected %s.Zone%dState", entry.data[CONF_NAME], bank_no + 1 ) # Update our meta information @@ -142,7 +142,7 @@ def __init__( ) def __setitem__(self, key, value): - """Sets our attributes.""" + """Set our sensor attributes.""" self.__attributes[key] = value @property From ceb672b01c99a04998d4dafef90880366d2de89c Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Mon, 14 Dec 2020 12:59:21 -0500 Subject: [PATCH 22/30] automatically remove sensors no longer being monitored --- homeassistant/components/ultrasync/sensor.py | 10 ++++++++++ tests/components/ultrasync/test_sensor.py | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py index 1c268f2e571e4..4a177b75b187d 100644 --- a/homeassistant/components/ultrasync/sensor.py +++ b/homeassistant/components/ultrasync/sensor.py @@ -57,9 +57,13 @@ def _auto_manage_sensors(areas: dict, zones: dict) -> None: # A pointer to our sensors sensors = hass.data[DOMAIN][entry.entry_id][SENSORS] + # Track our detected sensors (for automatic updates if required) + detected_sensors = set() + for meta in areas: bank_no = meta["bank"] sensor_id = "area{:0>2}_state".format(bank_no + 1) + detected_sensors.add(sensor_id) if sensor_id not in sensors: # hash our entry sensors[sensor_id] = UltraSyncSensor( @@ -84,6 +88,7 @@ def _auto_manage_sensors(areas: dict, zones: dict) -> None: for meta in zones: bank_no = meta["bank"] sensor_id = "zone{:0>2}_state".format(bank_no + 1) + detected_sensors.add(sensor_id) if sensor_id not in sensors: # hash our entry sensors[sensor_id] = UltraSyncSensor( @@ -109,6 +114,11 @@ def _auto_manage_sensors(areas: dict, zones: dict) -> None: # Add our newly detected sensors async_add_entities(new_sensors) + for sensor_id in set(sensors.keys()).difference(detected_sensors): + # Tidy up sensors leaving our listing + hass.async_create_task(sensors[sensor_id].async_remove()) + del sensors[sensor_id] + # register our callback which will be called the second we make a # connection to our panel hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER].append( diff --git a/tests/components/ultrasync/test_sensor.py b/tests/components/ultrasync/test_sensor.py index 8b1d2069bda46..a78de8ce5988c 100644 --- a/tests/components/ultrasync/test_sensor.py +++ b/tests/components/ultrasync/test_sensor.py @@ -62,7 +62,6 @@ async def test_sensors(hass, ultrasync_api) -> None: sensors = { "area01_state": ("area1state", "Ready"), "zone01_state": ("zone1state", "Ready"), - "zone02_state": ("zone2state", "unknown"), } for (sensor_id, data) in sensors.items(): @@ -73,3 +72,9 @@ async def test_sensors(hass, ultrasync_api) -> None: state = hass.states.get(f"sensor.ultrasync_{data[0]}") assert state assert state.state == data[1] + + # Verify Zone02 is gone (safely unregistered) + entity_entry = registry.async_get("sensor.ultrasync_zone02_state") + assert entity_entry is None + state = hass.states.get("sensor.ultrasync_zone02_state") + assert state is None From df39b5617e6a9e5d49c358663ff542da02c6366e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 31 Jan 2021 11:17:23 -0500 Subject: [PATCH 23/30] bumped requried version of ultrasync --- homeassistant/components/ultrasync/__init__.py | 6 +++--- homeassistant/components/ultrasync/manifest.json | 3 ++- homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 5 ++++- requirements_test_all.txt | 5 ++++- tests/components/ultrasync/__init__.py | 3 ++- tests/components/ultrasync/conftest.py | 12 ++++++------ tests/components/ultrasync/test_config_flow.py | 11 ++++++----- tests/components/ultrasync/test_init.py | 3 ++- 9 files changed, 30 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index d86e1576048e5..3646de3e5aae4 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -99,15 +99,15 @@ def _async_register_services( def away(call) -> None: """Service call to set alarm system to 'away' mode in UltraSync Hub.""" - coordinator.hub.set(state=AlarmScene.AWAY) + coordinator.hub.set_alarm(state=AlarmScene.AWAY) def stay(call) -> None: """Service call to set alarm system to 'stay' mode in UltraSync Hub.""" - coordinator.hub.set(state=AlarmScene.STAY) + coordinator.hub.set_alarm(state=AlarmScene.STAY) def disarm(call) -> None: """Service call to disable alarm in UltraSync Hub.""" - coordinator.hub.set(state=AlarmScene.DISARMED) + coordinator.hub.set_alarm(state=AlarmScene.DISARMED) hass.services.async_register(DOMAIN, SERVICE_AWAY, away, schema=vol.Schema({})) hass.services.async_register(DOMAIN, SERVICE_STAY, stay, schema=vol.Schema({})) diff --git a/homeassistant/components/ultrasync/manifest.json b/homeassistant/components/ultrasync/manifest.json index 6df629edfe6d2..892042d40e6c9 100644 --- a/homeassistant/components/ultrasync/manifest.json +++ b/homeassistant/components/ultrasync/manifest.json @@ -2,7 +2,8 @@ "domain": "ultrasync", "name": "UltraSync Alarm Panel", "documentation": "https://www.home-assistant.io/integrations/ultrasync", - "requirements": ["ultrasync==0.9.2"], + "issue_tracker": "https://github.com/caronc/ha-ultrasync/issues", + "requirements": ["ultrasync==0.9.3"], "codeowners": ["@caronc"], "config_flow": true } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cfb1b2ee05f0e..2f0aa2ed2a0e6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,6 +264,7 @@ "tuya", "twentemilieu", "twilio", + "twinkly", "ultrasync", "unifi", "upb", diff --git a/requirements_all.txt b/requirements_all.txt index 2e69ffb731747..aaf7fedfe2bab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2278,11 +2278,14 @@ twentemilieu==0.3.0 # homeassistant.components.twilio twilio==6.32.0 +# homeassistant.components.twinkly +twinkly-client==0.0.2 + # homeassistant.components.rainforest_eagle uEagle==0.0.2 # homeassistant.components.ultrasync -ultrasync==0.9.2 +ultrasync==0.9.3 # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e531c1d2b0fe..bf5b655324d6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1226,8 +1226,11 @@ twentemilieu==0.3.0 # homeassistant.components.twilio twilio==6.32.0 +# homeassistant.components.twinkly +twinkly-client==0.0.2 + # homeassistant.components.ultrasync -ultrasync==0.9.2 +ultrasync==0.9.3 # homeassistant.components.upb upb_lib==0.4.12 diff --git a/tests/components/ultrasync/__init__.py b/tests/components/ultrasync/__init__.py index d37889704dc6a..5defcfd906589 100644 --- a/tests/components/ultrasync/__init__.py +++ b/tests/components/ultrasync/__init__.py @@ -1,4 +1,6 @@ """Tests for the UltraSync integration.""" +from unittest.mock import patch + from homeassistant.components.ultrasync.const import DOMAIN from homeassistant.const import ( CONF_HOST, @@ -8,7 +10,6 @@ CONF_USERNAME, ) -from tests.async_mock import patch from tests.common import MockConfigEntry ENTRY_CONFIG = { diff --git a/tests/components/ultrasync/conftest.py b/tests/components/ultrasync/conftest.py index dcb81be7c214c..000ac6ad11565 100644 --- a/tests/components/ultrasync/conftest.py +++ b/tests/components/ultrasync/conftest.py @@ -1,7 +1,7 @@ """Define fixtures available for all tests.""" -from pytest import fixture +from unittest.mock import Mock, patch -from tests.async_mock import MagicMock, patch +from pytest import fixture MOCK_AREAS_0 = [ {"bank": 0, "name": "Area 1", "sequence": 30, "status": "Ready"}, @@ -59,8 +59,8 @@ def ultrasync_api(hass): with patch("ultrasync.UltraSync") as mock_api: instance = mock_api.return_value - instance.login = MagicMock(return_value=True) - instance.details = MagicMock(side_effect=MOCK_RESPONSES) - instance.areas = MagicMock(return_value=list(MOCK_AREAS_0)) - instance.zones = MagicMock(return_value=list(MOCK_ZONES_0)) + instance.login = Mock(return_value=True) + instance.details = Mock(side_effect=MOCK_RESPONSES) + instance.areas = Mock(return_value=list(MOCK_AREAS_0)) + instance.zones = Mock(return_value=list(MOCK_ZONES_0)) yield mock_api diff --git a/tests/components/ultrasync/test_config_flow.py b/tests/components/ultrasync/test_config_flow.py index 7fae004aac4aa..e0f40ebc953dc 100644 --- a/tests/components/ultrasync/test_config_flow.py +++ b/tests/components/ultrasync/test_config_flow.py @@ -1,5 +1,7 @@ """Test the UltraSync config flow.""" +from unittest.mock import Mock, patch + from homeassistant import data_entry_flow from homeassistant.components.ultrasync import config_flow from homeassistant.components.ultrasync.const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -14,7 +16,6 @@ from . import ENTRY_CONFIG, USER_INPUT, _patch_async_setup, _patch_async_setup_entry -from tests.async_mock import MagicMock, patch from tests.common import MockConfigEntry @@ -50,8 +51,8 @@ async def test_user_form_cannot_connect(hass): ) with patch("ultrasync.UltraSync") as mock_api: - instance = MagicMock() - instance.login = MagicMock(return_value=False) + instance = Mock() + instance.login = Mock(return_value=False) mock_api.return_value = instance result = await hass.config_entries.flow.async_configure( @@ -70,8 +71,8 @@ async def test_user_form_unexpected_exception(hass, ultrasync_api): ) with patch("ultrasync.UltraSync") as mock_api: - instance = MagicMock() - instance.login = MagicMock(side_effect=Exception()) + instance = Mock() + instance.login = Mock(side_effect=Exception()) mock_api.return_value = instance result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/ultrasync/test_init.py b/tests/components/ultrasync/test_init.py index 815cf008d4ebc..788ecedba50aa 100644 --- a/tests/components/ultrasync/test_init.py +++ b/tests/components/ultrasync/test_init.py @@ -1,4 +1,6 @@ """Test the UltraSync config flow.""" +from unittest.mock import patch + from homeassistant.components.ultrasync.const import DOMAIN from homeassistant.config_entries import ( ENTRY_STATE_LOADED, @@ -8,7 +10,6 @@ from . import ENTRY_CONFIG, init_integration -from tests.async_mock import patch from tests.common import MockConfigEntry From 9d917086715ea5cf66639c07f21c4ae293cb8b19 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 30 May 2021 14:22:50 -0400 Subject: [PATCH 24/30] code cleanup as per review --- homeassistant/components/ultrasync/config_flow.py | 5 +++-- homeassistant/components/ultrasync/coordinator.py | 4 ---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index fda5b80a6f2f4..5a338ed522599 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -22,8 +22,9 @@ _LOGGER = logging.getLogger(__name__) -class AuthFailureException(IOError): +class AuthFailureException(Exception): """A general exception we can use to track Authentication failures.""" + pass def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: @@ -70,7 +71,7 @@ async def async_step_user( ) except AuthFailureException: - errors["base"] = "cannot_connect" + errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/ultrasync/coordinator.py b/homeassistant/components/ultrasync/coordinator.py index aba6d3ee0b405..79d92fda0747c 100644 --- a/homeassistant/components/ultrasync/coordinator.py +++ b/homeassistant/components/ultrasync/coordinator.py @@ -26,8 +26,6 @@ def __init__(self, hass: HomeAssistantType, *, config: dict, options: dict): host=config[CONF_HOST], ) - self._init = False - # Used to track delta (for change tracking) self._area_delta = {} self._zone_delta = {} @@ -98,8 +96,6 @@ def _update_data() -> dict: "status" ] - self._init = True - # Return our response return response From 945e1aea20106ac0803be777159fc0518212d9e2 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 30 May 2021 14:28:25 -0400 Subject: [PATCH 25/30] allow multiple panels per host as per review comments --- homeassistant/components/ultrasync/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 5a338ed522599..52538a0e1d030 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -59,12 +59,13 @@ async def async_step_user( self, user_input: Optional[ConfigType] = None ) -> Dict[str, Any]: """Handle user flow.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") errors = {} if user_input is not None: + await self.async_set_unique_id(user_input.get(CONF_HOST)) + self._abort_if_unique_id_configured() + try: await self.hass.async_add_executor_job( validate_input, self.hass, user_input From fc511afd65663243f02fef9c447901bfe0f5453a Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 30 May 2021 14:31:55 -0400 Subject: [PATCH 26/30] updated exeption/error handling --- homeassistant/components/ultrasync/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 52538a0e1d030..86ea95a6eeaf1 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -76,7 +76,8 @@ async def async_step_user( except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") - return self.async_abort(reason="unknown") + errors["base"] = "unknown" + else: return self.async_create_entry( title=user_input[CONF_HOST], From 9a0ecd498a6afe3bc383f10bff9bffad8c5d5429 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 30 May 2021 14:46:56 -0400 Subject: [PATCH 27/30] updated to use refresh() instead of reload() --- homeassistant/components/ultrasync/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index 3646de3e5aae4..00a6c736bd14f 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -2,6 +2,7 @@ import asyncio +from datetime import timedelta from ultrasync import AlarmScene import voluptuous as vol @@ -116,7 +117,11 @@ def disarm(call) -> None: async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None: """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) + if entry.options[CONF_SCAN_INTERVAL]: + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + coordinator.update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) + + await coordinator.async_refresh() class UltraSyncEntity(CoordinatorEntity): From e465097478267057e3856914eae386a4e26b6a94 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 30 May 2021 17:28:17 -0400 Subject: [PATCH 28/30] black validation added --- homeassistant/components/ultrasync/__init__.py | 4 +++- homeassistant/components/ultrasync/config_flow.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index 00a6c736bd14f..d8d5c5d4097d1 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -119,7 +119,9 @@ async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> """Handle options update.""" if entry.options[CONF_SCAN_INTERVAL]: coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] - coordinator.update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) + coordinator.update_interval = timedelta( + seconds=entry.options[CONF_SCAN_INTERVAL] + ) await coordinator.async_refresh() diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index 86ea95a6eeaf1..eece7ab6ef250 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -24,6 +24,7 @@ class AuthFailureException(Exception): """A general exception we can use to track Authentication failures.""" + pass From b348fc070519bfdb6152905a80a6edf8153be623 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 30 May 2021 18:02:24 -0400 Subject: [PATCH 29/30] handle isort configuration --- homeassistant/components/ultrasync/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index d8d5c5d4097d1..b85a30cc2ae22 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -1,8 +1,8 @@ """The Interlogix/Hills ComNav UltraSync Hub component.""" import asyncio - from datetime import timedelta + from ultrasync import AlarmScene import voluptuous as vol From ea0452bfc802e7ae80ec72aa9a3a2aa4f6aec62f Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 19 Sep 2021 20:49:37 -0400 Subject: [PATCH 30/30] added updates as per comments during review --- .../components/ultrasync/__init__.py | 29 ++++--------------- .../components/ultrasync/config_flow.py | 8 ++--- homeassistant/components/ultrasync/const.py | 2 +- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ultrasync/__init__.py b/homeassistant/components/ultrasync/__init__.py index b85a30cc2ae22..b2d66a52990ed 100644 --- a/homeassistant/components/ultrasync/__init__.py +++ b/homeassistant/components/ultrasync/__init__.py @@ -1,6 +1,5 @@ """The Interlogix/Hills ComNav UltraSync Hub component.""" -import asyncio from datetime import timedelta from ultrasync import AlarmScene @@ -8,7 +7,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,6 +34,8 @@ async def async_setup(hass: HomeAssistantType, config: dict) -> bool: async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up UltraSync from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) if not entry.options: options = { CONF_SCAN_INTERVAL: entry.data.get( @@ -50,39 +50,22 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool options=entry.options, ) - await coordinator.async_refresh() + await coordinator.async_config_entry_first_refresh() - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - undo_listener = entry.add_update_listener(_async_update_listener) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) hass.data[DOMAIN][entry.entry_id] = { DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: [undo_listener], SENSORS: {}, } - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - - _async_register_services(hass, coordinator) - + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS - ] - ) - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: for unsub in hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]: diff --git a/homeassistant/components/ultrasync/config_flow.py b/homeassistant/components/ultrasync/config_flow.py index eece7ab6ef250..e3ee5968fd918 100644 --- a/homeassistant/components/ultrasync/config_flow.py +++ b/homeassistant/components/ultrasync/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Interlogix/Hills ComNav UltraSync Hub.""" import logging from typing import Any, Dict, Optional +import homeassistant.helpers.config_validation as cv import ultrasync import voluptuous as vol @@ -41,8 +42,6 @@ def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: # report our connection issue raise AuthFailureException() - return True - class UltraSyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """UltraSync config flow.""" @@ -92,7 +91,8 @@ async def async_step_user( vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_HOST): str, vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PIN): str, + vol.Required(CONF_PIN): vol.All(vol.Coerce(str), + vol.Match(r"\d{4}")), } ), errors=errors, @@ -117,7 +117,7 @@ async def async_step_init(self, user_input: Optional[ConfigType] = None): default=self.config_entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), - ): int, + ): cv.positive_int, } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/ultrasync/const.py b/homeassistant/components/ultrasync/const.py index a55952d256389..1231aac121b7e 100644 --- a/homeassistant/components/ultrasync/const.py +++ b/homeassistant/components/ultrasync/const.py @@ -3,7 +3,7 @@ DOMAIN = "ultrasync" # Scan Time (in seconds) -DEFAULT_SCAN_INTERVAL = 1 +DEFAULT_SCAN_INTERVAL = 5 DEFAULT_NAME = "UltraSync"