diff --git a/.coveragerc b/.coveragerc index d6bdfb9b091..8f609ec5b22 100644 --- a/.coveragerc +++ b/.coveragerc @@ -842,6 +842,11 @@ omit = homeassistant/components/satel_integra/* homeassistant/components/schluter/* homeassistant/components/scrape/sensor.py + homeassistant/components/screenlogic/__init__.py + homeassistant/components/screenlogic/binary_sensor.py + homeassistant/components/screenlogic/sensor.py + homeassistant/components/screenlogic/switch.py + homeassistant/components/screenlogic/water_heater.py homeassistant/components/scsgate/* homeassistant/components/scsgate/cover.py homeassistant/components/sendgrid/notify.py diff --git a/CODEOWNERS b/CODEOWNERS index 263e5337c58..1b0f2747dee 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -401,6 +401,7 @@ homeassistant/components/samsungtv/* @escoand homeassistant/components/scene/* @home-assistant/core homeassistant/components/schluter/* @prairieapps homeassistant/components/scrape/* @fabaff +homeassistant/components/screenlogic/* @dieselrabbit homeassistant/components/script/* @home-assistant/core homeassistant/components/search/* @home-assistant/core homeassistant/components/sense/* @kbickar diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py new file mode 100644 index 00000000000..720b59f80b9 --- /dev/null +++ b/homeassistant/components/screenlogic/__init__.py @@ -0,0 +1,202 @@ +"""The Screenlogic integration.""" +import asyncio +from collections import defaultdict +from datetime import timedelta +import logging + +from screenlogicpy import ScreenLogicError, ScreenLogicGateway +from screenlogicpy.const import ( + CONTROLLER_HARDWARE, + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .config_flow import async_discover_gateways_by_unique_id, name_for_mac +from .const import DEFAULT_SCAN_INTERVAL, DISCOVERED_GATEWAYS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["switch", "sensor", "binary_sensor", "water_heater"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Screenlogic component.""" + domain_data = hass.data[DOMAIN] = {} + domain_data[DISCOVERED_GATEWAYS] = await async_discover_gateways_by_unique_id(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Screenlogic from a config entry.""" + mac = entry.unique_id + # Attempt to re-discover named gateway to follow IP changes + discovered_gateways = hass.data[DOMAIN][DISCOVERED_GATEWAYS] + if mac in discovered_gateways: + connect_info = discovered_gateways[mac] + else: + _LOGGER.warning("Gateway rediscovery failed.") + # Static connection defined or fallback from discovery + connect_info = { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } + + try: + gateway = ScreenLogicGateway(**connect_info) + except ScreenLogicError as ex: + _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) + raise ConfigEntryNotReady from ex + except AttributeError as ex: + _LOGGER.exception( + "Unexpected error while connecting to the gateway %s", connect_info + ) + raise ConfigEntryNotReady from ex + + coordinator = ScreenlogicDataUpdateCoordinator( + hass, config_entry=entry, gateway=gateway + ) + + device_data = defaultdict(list) + + await coordinator.async_refresh() + + for circuit in coordinator.data["circuits"]: + device_data["switch"].append(circuit) + + for sensor in coordinator.data["sensors"]: + if sensor == "chem_alarm": + device_data["binary_sensor"].append(sensor) + else: + if coordinator.data["sensors"][sensor]["value"] != 0: + device_data["sensor"].append(sensor) + + for pump in coordinator.data["pumps"]: + if ( + coordinator.data["pumps"][pump]["data"] != 0 + and "currentWatts" in coordinator.data["pumps"][pump] + ): + device_data["pump"].append(pump) + + for body in coordinator.data["bodies"]: + device_data["water_heater"].append(body) + + hass.data[DOMAIN][entry.entry_id] = { + "coordinator": coordinator, + "devices": device_data, + "listener": entry.add_update_listener(async_update_listener), + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + hass.data[DOMAIN][entry.entry_id]["listener"]() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage the data update for the Screenlogic component.""" + + def __init__(self, hass, *, config_entry, gateway): + """Initialize the Screenlogic Data Update Coordinator.""" + self.config_entry = config_entry + self.gateway = gateway + self.screenlogic_data = {} + interval = timedelta( + seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + ) + + async def _async_update_data(self): + """Fetch data from the Screenlogic gateway.""" + try: + await self.hass.async_add_executor_job(self.gateway.update) + return self.gateway.get_data() + except ScreenLogicError as error: + raise UpdateFailed(error) from error + + +class ScreenlogicEntity(CoordinatorEntity): + """Base class for all ScreenLogic entities.""" + + def __init__(self, coordinator, datakey): + """Initialize of the entity.""" + super().__init__(coordinator) + self._data_key = datakey + + @property + def mac(self): + """Mac address.""" + return self.coordinator.config_entry.unique_id + + @property + def unique_id(self): + """Entity Unique ID.""" + return f"{self.mac}_{self._data_key}" + + @property + def config_data(self): + """Shortcut for config data.""" + return self.coordinator.data["config"] + + @property + def gateway(self): + """Return the gateway.""" + return self.coordinator.gateway + + @property + def gateway_name(self): + """Return the configured name of the gateway.""" + return self.gateway.name + + @property + def device_info(self): + """Return device information for the controller.""" + controller_type = self.config_data["controler_type"] + hardware_type = self.config_data["hardware_type"] + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self.mac)}, + "name": self.gateway_name, + "manufacturer": "Pentair", + "model": CONTROLLER_HARDWARE[controller_type][hardware_type], + } diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py new file mode 100644 index 00000000000..fa8d63ee5e6 --- /dev/null +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -0,0 +1,54 @@ +"""Support for a ScreenLogic Binary Sensor.""" +import logging + +from screenlogicpy.const import ON_OFF + +from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity + +from . import ScreenlogicEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + entities = [] + data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = data["coordinator"] + + for binary_sensor in data["devices"]["binary_sensor"]: + entities.append(ScreenLogicBinarySensor(coordinator, binary_sensor)) + async_add_entities(entities, True) + + +class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): + """Representation of a ScreenLogic binary sensor entity.""" + + @property + def name(self): + """Return the sensor name.""" + return f"{self.gateway_name} {self.sensor['name']}" + + @property + def device_class(self): + """Return the device class.""" + device_class = self.sensor.get("hass_type") + if device_class in DEVICE_CLASSES: + return device_class + return None + + @property + def is_on(self) -> bool: + """Determine if the sensor is on.""" + return self.sensor["value"] == ON_OFF.ON + + @property + def sensor(self): + """Shortcut to access the sensor data.""" + return self.sensor_data[self._data_key] + + @property + def sensor_data(self): + """Shortcut to access the sensors data.""" + return self.coordinator.data["sensors"] diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py new file mode 100644 index 00000000000..865c0fdbbf4 --- /dev/null +++ b/homeassistant/components/screenlogic/config_flow.py @@ -0,0 +1,218 @@ +"""Config flow for ScreenLogic.""" +import logging + +from screenlogicpy import ScreenLogicError, discover +from screenlogicpy.const import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.requests import login +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import DEFAULT_SCAN_INTERVAL, MIN_SCAN_INTERVAL +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +GATEWAY_SELECT_KEY = "selected_gateway" +GATEWAY_MANUAL_ENTRY = "manual" + +PENTAIR_OUI = "00-C0-33" + + +async def async_discover_gateways_by_unique_id(hass): + """Discover gateways and return a dict of them by unique id.""" + discovered_gateways = {} + try: + hosts = await hass.async_add_executor_job(discover) + _LOGGER.debug("Discovered hosts: %s", hosts) + except ScreenLogicError as ex: + _LOGGER.debug(ex) + return discovered_gateways + + for host in hosts: + mac = _extract_mac_from_name(host[SL_GATEWAY_NAME]) + discovered_gateways[mac] = host + + _LOGGER.debug("Discovered gateways: %s", discovered_gateways) + return discovered_gateways + + +def _extract_mac_from_name(name): + return format_mac(f"{PENTAIR_OUI}-{name.split(':')[1].strip()}") + + +def short_mac(mac): + """Short version of the mac as seen in the app.""" + return "-".join(mac.split(":")[3:]).upper() + + +def name_for_mac(mac): + """Derive the gateway name from the mac.""" + return f"Pentair: {short_mac(mac)}" + + +async def async_get_mac_address(hass, ip_address, port): + """Connect to a screenlogic gateway and return the mac address.""" + connected_socket = await hass.async_add_executor_job( + login.create_socket, + ip_address, + port, + ) + if not connected_socket: + raise ScreenLogicError("Unknown socket error") + return await hass.async_add_executor_job(login.gateway_connect, connected_socket) + + +class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow to setup screen logic devices.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize ScreenLogic ConfigFlow.""" + self.discovered_gateways = {} + self.discovered_ip = None + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for ScreenLogic.""" + return ScreenLogicOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass) + return await self.async_step_gateway_select() + + async def async_step_dhcp(self, dhcp_discovery): + """Handle dhcp discovery.""" + mac = _extract_mac_from_name(dhcp_discovery[HOSTNAME]) + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: dhcp_discovery[IP_ADDRESS]} + ) + self.discovered_ip = dhcp_discovery[IP_ADDRESS] + self.context["title_placeholders"] = {"name": dhcp_discovery[HOSTNAME]} + return await self.async_step_gateway_entry() + + async def async_step_gateway_select(self, user_input=None): + """Handle the selection of a discovered ScreenLogic gateway.""" + existing = self._async_current_ids() + unconfigured_gateways = { + mac: gateway[SL_GATEWAY_NAME] + for mac, gateway in self.discovered_gateways.items() + if mac not in existing + } + + if not unconfigured_gateways: + return await self.async_step_gateway_entry() + + errors = {} + if user_input is not None: + if user_input[GATEWAY_SELECT_KEY] == GATEWAY_MANUAL_ENTRY: + return await self.async_step_gateway_entry() + + mac = user_input[GATEWAY_SELECT_KEY] + selected_gateway = self.discovered_gateways[mac] + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name_for_mac(mac), + data={ + CONF_IP_ADDRESS: selected_gateway[SL_GATEWAY_IP], + CONF_PORT: selected_gateway[SL_GATEWAY_PORT], + }, + ) + + return self.async_show_form( + step_id="gateway_select", + data_schema=vol.Schema( + { + vol.Required(GATEWAY_SELECT_KEY): vol.In( + { + **unconfigured_gateways, + GATEWAY_MANUAL_ENTRY: "Manually configure a ScreenLogic gateway", + } + ) + } + ), + errors=errors, + description_placeholders={}, + ) + + async def async_step_gateway_entry(self, user_input=None): + """Handle the manual entry of a ScreenLogic gateway.""" + errors = {} + ip_address = self.discovered_ip + port = 80 + + if user_input is not None: + ip_address = user_input[CONF_IP_ADDRESS] + port = user_input[CONF_PORT] + try: + mac = format_mac( + await async_get_mac_address(self.hass, ip_address, port) + ) + except ScreenLogicError as ex: + _LOGGER.debug(ex) + errors[CONF_IP_ADDRESS] = "cannot_connect" + + if not errors: + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name_for_mac(mac), + data={ + CONF_IP_ADDRESS: ip_address, + CONF_PORT: port, + }, + ) + + return self.async_show_form( + step_id="gateway_entry", + data_schema=vol.Schema( + { + vol.Required(CONF_IP_ADDRESS, default=ip_address): str, + vol.Required(CONF_PORT, default=port): int, + } + ), + errors=errors, + description_placeholders={}, + ) + + +class ScreenLogicOptionsFlowHandler(config_entries.OptionsFlow): + """Handles the options for the ScreenLogic integration.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Init the screen logic options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry( + title=self.config_entry.title, data=user_input + ) + + current_interval = self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SCAN_INTERVAL, + default=current_interval, + ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)) + } + ), + description_placeholders={"gateway_name": self.config_entry.title}, + ) diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py new file mode 100644 index 00000000000..d777dc6ddc5 --- /dev/null +++ b/homeassistant/components/screenlogic/const.py @@ -0,0 +1,7 @@ +"""Constants for the ScreenLogic integration.""" + +DOMAIN = "screenlogic" +DEFAULT_SCAN_INTERVAL = 30 +MIN_SCAN_INTERVAL = 10 + +DISCOVERED_GATEWAYS = "_discovered_gateways" diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json new file mode 100644 index 00000000000..2ff3f2c683d --- /dev/null +++ b/homeassistant/components/screenlogic/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "screenlogic", + "name": "Pentair ScreenLogic", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/screenlogic", + "requirements": ["screenlogicpy==0.1.2"], + "codeowners": [ + "@dieselrabbit" + ], + "dhcp": [{"hostname":"pentair: *","macaddress":"00C033*"}] +} \ No newline at end of file diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py new file mode 100644 index 00000000000..f4f86cdd2aa --- /dev/null +++ b/homeassistant/components/screenlogic/sensor.py @@ -0,0 +1,107 @@ +"""Support for a ScreenLogic Sensor.""" +import logging + +from homeassistant.components.sensor import DEVICE_CLASSES + +from . import ScreenlogicEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM") + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + entities = [] + data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = data["coordinator"] + # Generic sensors + for sensor in data["devices"]["sensor"]: + entities.append(ScreenLogicSensor(coordinator, sensor)) + for pump in data["devices"]["pump"]: + for pump_key in PUMP_SENSORS: + entities.append(ScreenLogicPumpSensor(coordinator, pump, pump_key)) + + async_add_entities(entities, True) + + +class ScreenLogicSensor(ScreenlogicEntity): + """Representation of a ScreenLogic sensor entity.""" + + @property + def name(self): + """Name of the sensor.""" + return f"{self.gateway_name} {self.sensor['name']}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.sensor.get("unit") + + @property + def device_class(self): + """Device class of the sensor.""" + device_class = self.sensor.get("hass_type") + if device_class in DEVICE_CLASSES: + return device_class + return None + + @property + def state(self): + """State of the sensor.""" + value = self.sensor["value"] + return (value - 1) if "supply" in self._data_key else value + + @property + def sensor(self): + """Shortcut to access the sensor data.""" + return self.sensor_data[self._data_key] + + @property + def sensor_data(self): + """Shortcut to access the sensors data.""" + return self.coordinator.data["sensors"] + + +class ScreenLogicPumpSensor(ScreenlogicEntity): + """Representation of a ScreenLogic pump sensor entity.""" + + def __init__(self, coordinator, pump, key): + """Initialize of the pump sensor.""" + super().__init__(coordinator, f"{key}_{pump}") + self._pump_id = pump + self._key = key + + @property + def name(self): + """Return the pump sensor name.""" + return f"{self.gateway_name} {self.pump_sensor['name']}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.pump_sensor.get("unit") + + @property + def device_class(self): + """Return the device class.""" + device_class = self.pump_sensor.get("hass_type") + if device_class in DEVICE_CLASSES: + return device_class + return None + + @property + def state(self): + """State of the pump sensor.""" + return self.pump_sensor["value"] + + @property + def pump_sensor(self): + """Shortcut to access the pump sensor data.""" + return self.pumps_data[self._pump_id][self._key] + + @property + def pumps_data(self): + """Shortcut to access the pump data.""" + return self.coordinator.data["pumps"] diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json new file mode 100644 index 00000000000..155eeb3043e --- /dev/null +++ b/homeassistant/components/screenlogic/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "flow_title": "ScreenLogic {name}", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "gateway_entry": { + "title": "ScreenLogic", + "description": "Enter your ScreenLogic Gateway information.", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "gateway_select": { + "title": "ScreenLogic", + "description": "The following ScreenLogic gateways were discovered. Please select one to configure, or choose to manually configure a ScreenLogic gateway.", + "data": { + "selected_gateway": "Gateway" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options":{ + "step": { + "init": { + "title": "ScreenLogic", + "description": "Specify settings for {gateway_name}", + "data": { + "scan_interval": "Seconds between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py new file mode 100644 index 00000000000..aa1e643681e --- /dev/null +++ b/homeassistant/components/screenlogic/switch.py @@ -0,0 +1,63 @@ +"""Support for a ScreenLogic 'circuit' switch.""" +import logging + +from screenlogicpy.const import ON_OFF + +from homeassistant.components.switch import SwitchEntity + +from . import ScreenlogicEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + entities = [] + data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = data["coordinator"] + + for switch in data["devices"]["switch"]: + entities.append(ScreenLogicSwitch(coordinator, switch)) + async_add_entities(entities, True) + + +class ScreenLogicSwitch(ScreenlogicEntity, SwitchEntity): + """ScreenLogic switch entity.""" + + @property + def name(self): + """Get the name of the switch.""" + return f"{self.gateway_name} {self.circuit['name']}" + + @property + def is_on(self) -> bool: + """Get whether the switch is in on state.""" + return self.circuit["value"] == 1 + + async def async_turn_on(self, **kwargs) -> None: + """Send the ON command.""" + return await self._async_set_circuit(ON_OFF.ON) + + async def async_turn_off(self, **kwargs) -> None: + """Send the OFF command.""" + return await self._async_set_circuit(ON_OFF.OFF) + + async def _async_set_circuit(self, circuit_value) -> None: + if await self.hass.async_add_executor_job( + self.gateway.set_circuit, self._data_key, circuit_value + ): + _LOGGER.info("screenlogic turn %s %s", circuit_value, self._data_key) + await self.coordinator.async_request_refresh() + else: + _LOGGER.info("screenlogic turn %s %s error", circuit_value, self._data_key) + + @property + def circuit(self): + """Shortcut to access the circuit.""" + return self.circuits_data[self._data_key] + + @property + def circuits_data(self): + """Shortcut to access the circuits data.""" + return self.coordinator.data["circuits"] diff --git a/homeassistant/components/screenlogic/translations/en.json b/homeassistant/components/screenlogic/translations/en.json new file mode 100644 index 00000000000..2572fdf38fa --- /dev/null +++ b/homeassistant/components/screenlogic/translations/en.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "ScreenLogic {name}", + "step": { + "gateway_entry": { + "data": { + "ip_address": "IP Address", + "port": "Port" + }, + "description": "Enter your ScreenLogic Gateway information.", + "title": "ScreenLogic" + }, + "gateway_select": { + "data": { + "selected_gateway": "Gateway" + }, + "description": "The following ScreenLogic gateways were discovered. Please select one to configure, or choose to manually configure a ScreenLogic gateway.", + "title": "ScreenLogic" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between scans" + }, + "description": "Specify settings for {gateway_name}", + "title": "ScreenLogic" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/screenlogic/water_heater.py b/homeassistant/components/screenlogic/water_heater.py new file mode 100644 index 00000000000..2a0a8e82c80 --- /dev/null +++ b/homeassistant/components/screenlogic/water_heater.py @@ -0,0 +1,128 @@ +"""Support for a ScreenLogic Water Heater.""" +import logging + +from screenlogicpy.const import HEAT_MODE + +from homeassistant.components.water_heater import ( + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterEntity, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from . import ScreenlogicEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE + +HEAT_MODE_NAMES = HEAT_MODE.Names + +MODE_NAME_TO_MODE_NUM = { + HEAT_MODE_NAMES[num]: num for num in range(len(HEAT_MODE_NAMES)) +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + entities = [] + data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = data["coordinator"] + + for body in data["devices"]["water_heater"]: + entities.append(ScreenLogicWaterHeater(coordinator, body)) + async_add_entities(entities, True) + + +class ScreenLogicWaterHeater(ScreenlogicEntity, WaterHeaterEntity): + """Represents the heating functions for a body of water.""" + + @property + def name(self) -> str: + """Name of the water heater.""" + ent_name = self.body["heat_status"]["name"] + return f"{self.gateway_name} {ent_name}" + + @property + def state(self) -> str: + """State of the water heater.""" + return HEAT_MODE.GetFriendlyName(self.body["heat_status"]["value"]) + + @property + def min_temp(self) -> float: + """Minimum allowed temperature.""" + return self.body["min_set_point"]["value"] + + @property + def max_temp(self) -> float: + """Maximum allowed temperature.""" + return self.body["max_set_point"]["value"] + + @property + def current_temperature(self) -> float: + """Return water temperature.""" + return self.body["last_temperature"]["value"] + + @property + def target_temperature(self) -> float: + """Target temperature.""" + return self.body["heat_set_point"]["value"] + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + if self.config_data["is_celcius"]["value"] == 1: + return TEMP_CELSIUS + return TEMP_FAHRENHEIT + + @property + def current_operation(self) -> str: + """Return operation.""" + return HEAT_MODE_NAMES[self.body["heat_mode"]["value"]] + + @property + def operation_list(self): + """All available operations.""" + supported_heat_modes = [HEAT_MODE.OFF] + # Is solar listed as available equipment? + if self.coordinator.data["config"]["equipment_flags"] & 0x1: + supported_heat_modes.extend([HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERED]) + supported_heat_modes.append(HEAT_MODE.HEATER) + + return [HEAT_MODE_NAMES[mode_num] for mode_num in supported_heat_modes] + + @property + def supported_features(self): + """Supported features of the water heater.""" + return SUPPORTED_FEATURES + + async def async_set_temperature(self, **kwargs) -> None: + """Change the setpoint of the heater.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if await self.hass.async_add_executor_job( + self.gateway.set_heat_temp, int(self._data_key), int(temperature) + ): + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("screenlogic set_temperature error") + + async def async_set_operation_mode(self, operation_mode) -> None: + """Set the operation mode.""" + mode = MODE_NAME_TO_MODE_NUM[operation_mode] + if await self.hass.async_add_executor_job( + self.gateway.set_heat_mode, int(self._data_key), int(mode) + ): + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("screenlogic set_operation_mode error") + + @property + def body(self): + """Shortcut to access body data.""" + return self.bodies_data[self._data_key] + + @property + def bodies_data(self): + """Shortcut to access bodies data.""" + return self.coordinator.data["bodies"] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a3bcef9047f..b6799f59a04 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -195,6 +195,7 @@ FLOWS = [ "rpi_power", "ruckus_unleashed", "samsungtv", + "screenlogic", "sense", "sentry", "sharkiq", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b3e10c90621..b5d419662ff 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -109,6 +109,11 @@ DHCP = [ "hostname": "irobot-*", "macaddress": "501479*" }, + { + "domain": "screenlogic", + "hostname": "pentair: *", + "macaddress": "00C033*" + }, { "domain": "sense", "hostname": "sense-*", diff --git a/requirements_all.txt b/requirements_all.txt index 1f0cc6ab7ca..a3788d7493a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2008,6 +2008,9 @@ scapy==2.4.4 # homeassistant.components.deutsche_bahn schiene==0.23 +# homeassistant.components.screenlogic +screenlogicpy==0.1.2 + # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7dd403f6bd1..216a7e0c0b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1039,6 +1039,9 @@ samsungtvws==1.6.0 # homeassistant.components.dhcp scapy==2.4.4 +# homeassistant.components.screenlogic +screenlogicpy==0.1.2 + # homeassistant.components.emulated_kasa # homeassistant.components.sense sense_energy==0.9.0 diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py new file mode 100644 index 00000000000..ad2b82960f0 --- /dev/null +++ b/tests/components/screenlogic/__init__.py @@ -0,0 +1 @@ +"""Tests for the Screenlogic integration.""" diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py new file mode 100644 index 00000000000..6d2c1ee2595 --- /dev/null +++ b/tests/components/screenlogic/test_config_flow.py @@ -0,0 +1,249 @@ +"""Test the Pentair ScreenLogic config flow.""" +from unittest.mock import patch + +from screenlogicpy import ScreenLogicError +from screenlogicpy.const import ( + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, + SL_GATEWAY_SUBTYPE, + SL_GATEWAY_TYPE, +) + +from homeassistant import config_entries, setup +from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS +from homeassistant.components.screenlogic.config_flow import ( + GATEWAY_MANUAL_ENTRY, + GATEWAY_SELECT_KEY, +) +from homeassistant.components.screenlogic.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + MIN_SCAN_INTERVAL, +) +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL + +from tests.common import MockConfigEntry + + +async def test_flow_discovery(hass): + """Test the flow works with basic discovery.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.screenlogic.config_flow.discover", + return_value=[ + { + SL_GATEWAY_IP: "1.1.1.1", + SL_GATEWAY_PORT: 80, + SL_GATEWAY_TYPE: 12, + SL_GATEWAY_SUBTYPE: 2, + SL_GATEWAY_NAME: "Pentair: 01-01-01", + }, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "gateway_select" + + with patch( + "homeassistant.components.screenlogic.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.screenlogic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={GATEWAY_SELECT_KEY: "00:c0:33:01:01:01"} + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Pentair: 01-01-01" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_discover_none(hass): + """Test when nothing is discovered.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.screenlogic.config_flow.discover", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "gateway_entry" + + +async def test_flow_discover_error(hass): + """Test when discovery errors.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.screenlogic.config_flow.discover", + side_effect=ScreenLogicError("Fake error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "gateway_entry" + + +async def test_dhcp(hass): + """Test DHCP discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "dhcp"}, + data={ + HOSTNAME: "Pentair: 01-01-01", + IP_ADDRESS: "1.1.1.1", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "gateway_entry" + + +async def test_form_manual_entry(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.screenlogic.config_flow.discover", + return_value=[ + { + SL_GATEWAY_IP: "1.1.1.1", + SL_GATEWAY_PORT: 80, + SL_GATEWAY_TYPE: 12, + SL_GATEWAY_SUBTYPE: 2, + SL_GATEWAY_NAME: "Pentair: 01-01-01", + }, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "gateway_select" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={GATEWAY_SELECT_KEY: GATEWAY_MANUAL_ENTRY} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {} + assert result2["step_id"] == "gateway_entry" + + with patch( + "homeassistant.components.screenlogic.config_flow.login.create_socket", + return_value=True, + ), patch( + "homeassistant.components.screenlogic.config_flow.login.gateway_connect", + return_value="00-C0-33-01-01-01", + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == "Pentair: 01-01-01" + assert result3["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + } + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.screenlogic.config_flow.login.create_socket", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} + + +async def test_option_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SCAN_INTERVAL: 15}, + ) + assert result["type"] == "create_entry" + assert result["data"] == {CONF_SCAN_INTERVAL: 15} + + +async def test_option_flow_defaults(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + } + + +async def test_option_flow_input_floor(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 1} + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL, + }