diff --git a/CODEOWNERS b/CODEOWNERS index 7f2dfc1454e310..f22fa7a500b58b 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 00000000000000..b2d66a52990edc --- /dev/null +++ b/homeassistant/components/ultrasync/__init__.py @@ -0,0 +1,126 @@ +"""The Interlogix/Hills ComNav UltraSync Hub component.""" + +from datetime import timedelta + +from ultrasync import AlarmScene +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DATA_COORDINATOR, + DATA_UNDO_UPDATE_LISTENER, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + SENSORS, + SERVICE_AWAY, + SERVICE_DISARM, + SERVICE_STAY, +) +from .coordinator import UltraSyncDataUpdateCoordinator + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistantType, config: dict) -> bool: + """Set up the UltraSync integration.""" + hass.data.setdefault(DOMAIN, {}) + + return True + + +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( + 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_config_entry_first_refresh() + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + SENSORS: {}, + } + + 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 = 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]: + unsub() + 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_alarm(state=AlarmScene.AWAY) + + def stay(call) -> None: + """Service call to set alarm system to 'stay' mode in UltraSync Hub.""" + coordinator.hub.set_alarm(state=AlarmScene.STAY) + + def disarm(call) -> None: + """Service call to disable alarm in UltraSync Hub.""" + 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({})) + hass.services.async_register(DOMAIN, SERVICE_DISARM, disarm, schema=vol.Schema({})) + + +async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None: + """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] + ) + + await coordinator.async_refresh() + + +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 00000000000000..e3ee5968fd9188 --- /dev/null +++ b/homeassistant/components/ultrasync/config_flow.py @@ -0,0 +1,123 @@ +"""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 + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PIN, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import DEFAULT_NAME, DEFAULT_SCAN_INTERVAL +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class AuthFailureException(Exception): + """A general exception we can use to track Authentication failures.""" + + pass + + +def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: + """Validate the user input allows us to connect.""" + + usync = ultrasync.UltraSync( + host=data[CONF_HOST], user=data[CONF_USERNAME], pin=data[CONF_PIN] + ) + + # validate by attempting to authenticate with our hub + + if not usync.login(): + # report our connection issue + raise AuthFailureException() + + +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_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle user flow.""" + + 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 + ) + + except AuthFailureException: + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + 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): vol.All(vol.Coerce(str), + vol.Match(r"\d{4}")), + } + ), + errors=errors, + ) + + +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 + ), + ): 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 new file mode 100644 index 00000000000000..1231aac121b7e6 --- /dev/null +++ b/homeassistant/components/ultrasync/const.py @@ -0,0 +1,20 @@ +"""Constants the Interlogix/Hills ComNav UltraSync Hub.""" + +DOMAIN = "ultrasync" + +# Scan Time (in seconds) +DEFAULT_SCAN_INTERVAL = 5 + +DEFAULT_NAME = "UltraSync" + +# Services +SERVICE_AWAY = "away" +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 new file mode 100644 index 00000000000000..79d92fda0747ce --- /dev/null +++ b/homeassistant/components/ultrasync/coordinator.py @@ -0,0 +1,105 @@ +"""Provides the UltraSync DataUpdateCoordinator.""" +from datetime import timedelta +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 + +from .const import DOMAIN, SENSOR_UPDATE_LISTENER + +_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.UltraSync( + user=config[CONF_USERNAME], + pin=config[CONF_PIN], + host=config[CONF_HOST], + ) + + # 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 = {} + + # 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_zone_update", + { + "sensor": zone["bank"] + 1, + "name": zone["name"], + "status": zone["status"], + }, + ) + + # 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( + "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{:0>2}_state".format(area["bank"] + 1)] = area[ + "status" + ] + + # 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 00000000000000..31383cbbdf5d3d --- /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 00000000000000..892042d40e6c93 --- /dev/null +++ b/homeassistant/components/ultrasync/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ultrasync", + "name": "UltraSync Alarm Panel", + "documentation": "https://www.home-assistant.io/integrations/ultrasync", + "issue_tracker": "https://github.com/caronc/ha-ultrasync/issues", + "requirements": ["ultrasync==0.9.3"], + "codeowners": ["@caronc"], + "config_flow": true +} diff --git a/homeassistant/components/ultrasync/sensor.py b/homeassistant/components/ultrasync/sensor.py new file mode 100644 index 00000000000000..4a177b75b187d5 --- /dev/null +++ b/homeassistant/components/ultrasync/sensor.py @@ -0,0 +1,176 @@ +"""Monitor the Interlogix/Hills ComNav UltraSync Hub.""" + +import logging +from typing import Callable, List + +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, + DATA_UNDO_UPDATE_LISTENER, + DOMAIN, + SENSOR_UPDATE_LISTENER, + SENSORS, +) +from .coordinator import UltraSyncDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +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 + ] + + # 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 %d area(s), and %d zone(s)", + len(areas), + len(zones), + ) + + # our list of sensors to add + new_sensors = [] + + # 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( + 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 %s.Area%dState", 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) + detected_sensors.add(sensor_id) + 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 %s.Zone%dState", 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) + + 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( + async_dispatcher_connect(hass, SENSOR_UPDATE_LISTENER, _auto_manage_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, + ): + """Initialize a new UltraSync sensor.""" + + 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): + """Set our sensor 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.""" + 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 00000000000000..4e337458948ecd --- /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 00000000000000..b1c1808db4ea59 --- /dev/null +++ b/homeassistant/components/ultrasync/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "flow_title": "Interlogix UltraSync Hub: {name}", + "step": { + "user": { + "title": "Connect to Interlogix 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%]" + } + } + }, + "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/components/ultrasync/translations/en.json b/homeassistant/components/ultrasync/translations/en.json new file mode 100644 index 00000000000000..bfb8c87c5e3022 --- /dev/null +++ b/homeassistant/components/ultrasync/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "Interlogix UltraSync Hub: {name}", + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "pin": "PIN Code", + "username": "Username" + }, + "title": "Connect to Informix UltraSync Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency (seconds)" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 79245491a7ea9e..2f0aa2ed2a0e60 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -265,6 +265,7 @@ "twentemilieu", "twilio", "twinkly", + "ultrasync", "unifi", "upb", "upcloud", diff --git a/requirements_all.txt b/requirements_all.txt index 3caa27823272da..aaf7fedfe2bab0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2284,6 +2284,9 @@ twinkly-client==0.0.2 # homeassistant.components.rainforest_eagle uEagle==0.0.2 +# homeassistant.components.ultrasync +ultrasync==0.9.3 + # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 940ab10cb87653..bf5b655324d6a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1229,6 +1229,9 @@ twilio==6.32.0 # homeassistant.components.twinkly twinkly-client==0.0.2 +# homeassistant.components.ultrasync +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 new file mode 100644 index 00000000000000..5defcfd9065894 --- /dev/null +++ b/tests/components/ultrasync/__init__.py @@ -0,0 +1,59 @@ +"""Tests for the UltraSync integration.""" +from unittest.mock import patch + +from homeassistant.components.ultrasync.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PIN, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +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", +} + + +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 00000000000000..000ac6ad11565b --- /dev/null +++ b/tests/components/ultrasync/conftest.py @@ -0,0 +1,66 @@ +"""Define fixtures available for all tests.""" +from unittest.mock import Mock, patch + +from pytest import fixture + +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_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"}, +] + +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_ZONES_2 = [ + # Backdoor sensor was removed + {"bank": 0, "name": "Front door", "sequence": 3, "status": "Ready"}, +] + +MOCK_RESPONSES = ( + { + "areas": MOCK_AREAS_0, + "zones": MOCK_ZONES_0, + }, + { + "areas": MOCK_AREAS_1, + "zones": MOCK_ZONES_1, + }, + { + "areas": MOCK_AREAS_2, + "zones": MOCK_ZONES_2, + }, +) + + +@fixture +def ultrasync_api(hass): + """Mock UltraSync for easier testing.""" + + with patch("ultrasync.UltraSync") as mock_api: + instance = mock_api.return_value + 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 new file mode 100644 index 00000000000000..e0f40ebc953dcb --- /dev/null +++ b/tests/components/ultrasync/test_config_flow.py @@ -0,0 +1,154 @@ +"""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 +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_NAME, 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 . 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 = Mock() + instance.login = Mock(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 = Mock() + instance.login = Mock(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 + + +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_init.py b/tests/components/ultrasync/test_init.py new file mode 100644 index 00000000000000..788ecedba50aae --- /dev/null +++ b/tests/components/ultrasync/test_init.py @@ -0,0 +1,38 @@ +"""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, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) + +from . import ENTRY_CONFIG, init_integration + +from tests.common import MockConfigEntry + + +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 00000000000000..a78de8ce5988c2 --- /dev/null +++ b/tests/components/ultrasync/test_sensor.py @@ -0,0 +1,80 @@ +"""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.""" + entry = await init_integration(hass) + registry = await hass.helpers.entity_registry.async_get_registry() + + sensors = { + "area01_state": ("area1state", "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] + + # 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"), + "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"), + } + + 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] + + # 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