diff --git a/.coveragerc b/.coveragerc index d9cb511e86e4e6..ecc835106ffbf9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1071,9 +1071,10 @@ omit = homeassistant/components/saj/sensor.py homeassistant/components/satel_integra/* homeassistant/components/schluter/* - homeassistant/components/screenlogic/__init__.py homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/climate.py + homeassistant/components/screenlogic/coordinator.py + homeassistant/components/screenlogic/const.py homeassistant/components/screenlogic/entity.py homeassistant/components/screenlogic/light.py homeassistant/components/screenlogic/number.py diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 3370c196c3c902..298e1c1ca0010f 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -1,27 +1,22 @@ """The Screenlogic integration.""" -from datetime import timedelta import logging from typing import Any from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const import ( - DATA as SL_DATA, - EQUIPMENT, - SL_GATEWAY_IP, - SL_GATEWAY_NAME, - SL_GATEWAY_PORT, -) +from screenlogicpy.const.data import SHARED_VALUES from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify -from .config_flow import async_discover_gateways_by_unique_id, name_for_mac -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info +from .data import ENTITY_MIGRATIONS from .services import async_load_screenlogic_services, async_unload_screenlogic_services +from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -44,12 +39,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" + + await _async_migrate_entries(hass, entry) + gateway = ScreenLogicGateway() connect_info = await async_get_connect_info(hass, entry) try: await gateway.async_connect(**connect_info) + await gateway.async_update() except ScreenLogicError as ex: raise ConfigEntryNotReady(ex.msg) from ex @@ -88,83 +87,88 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def async_get_connect_info( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, str | int]: - """Construct connect_info from configuration entry and returns it to caller.""" - mac = entry.unique_id - # Attempt to rediscover gateway to follow IP changes - discovered_gateways = await async_discover_gateways_by_unique_id(hass) - if mac in discovered_gateways: - return discovered_gateways[mac] - - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - return { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } - - -class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage the data update for the Screenlogic component.""" - - def __init__( - self, - hass: HomeAssistant, - *, - config_entry: ConfigEntry, - gateway: ScreenLogicGateway, - ) -> None: - """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry - self.gateway = gateway - - interval = timedelta( - seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=interval, - # Debounced option since the device takes - # a moment to reflect the knock-on changes - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False - ), +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate to new entity names.""" + entity_registry = er.async_get(hass) + + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ): + source_mac, source_key = entry.unique_id.split("_", 1) + + source_index = None + if ( + len(key_parts := source_key.rsplit("_", 1)) == 2 + and key_parts[1].isdecimal() + ): + source_key, source_index = key_parts + + _LOGGER.debug( + "Checking migration status for '%s' against key '%s'", + entry.unique_id, + source_key, ) - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() - - async def _async_update_configured_data(self) -> None: - """Update data sets based on equipment config.""" - equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] - if not self.gateway.is_client: - await self.gateway.async_get_status() - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - await self.gateway.async_get_chemistry() - - await self.gateway.async_get_pumps() - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - await self.gateway.async_get_scg() - - async def _async_update_data(self) -> None: - """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None - try: - if not self.gateway.is_connected: - connect_info = await async_get_connect_info( - self.hass, self.config_entry - ) - await self.gateway.async_connect(**connect_info) + if source_key not in ENTITY_MIGRATIONS: + continue - await self._async_update_configured_data() - except ScreenLogicError as ex: - if self.gateway.is_connected: - await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + _LOGGER.debug( + "Evaluating migration of '%s' from migration key '%s'", + entry.entity_id, + source_key, + ) + migrations = ENTITY_MIGRATIONS[source_key] + updates: dict[str, Any] = {} + new_key = migrations["new_key"] + if new_key in SHARED_VALUES: + if (device := migrations.get("device")) is None: + _LOGGER.debug( + "Shared key '%s' is missing required migration data 'device'", + new_key, + ) + continue + assert device is not None and ( + device != "pump" or (device == "pump" and source_index is not None) + ) + new_unique_id = ( + f"{source_mac}_{generate_unique_id(device, source_index, new_key)}" + ) + else: + new_unique_id = entry.unique_id.replace(source_key, new_key) + + if new_unique_id and new_unique_id != entry.unique_id: + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + _LOGGER.debug( + "Cannot migrate '%s' to unique_id '%s', already exists for entity '%s'. Aborting", + entry.unique_id, + new_unique_id, + existing_entity_id, + ) + continue + updates["new_unique_id"] = new_unique_id + + if (old_name := migrations.get("old_name")) is not None: + assert old_name + new_name = migrations["new_name"] + if (s_old_name := slugify(old_name)) in entry.entity_id: + new_entity_id = entry.entity_id.replace(s_old_name, slugify(new_name)) + if new_entity_id and new_entity_id != entry.entity_id: + updates["new_entity_id"] = new_entity_id + + if entry.original_name and old_name in entry.original_name: + new_original_name = entry.original_name.replace(old_name, new_name) + if new_original_name and new_original_name != entry.original_name: + updates["original_name"] = new_original_name + + if updates: + _LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entry.entity_id, **updates) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 305775844942a2..337d308d8d9437 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,28 +1,97 @@ """Support for a ScreenLogic Binary Sensor.""" -from screenlogicpy.const import CODE, DATA as SL_DATA, DEVICE_TYPE, EQUIPMENT, ON_OFF +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import DEVICE_TYPE, ON_OFF +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from homeassistant.components.binary_sensor import ( + DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + SupportedValueParameters, + build_base_entity_description, + iterate_expand_group_wildcard, + preprocess_supported_values, +) +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, +) +from .util import cleanup_excluded_entity, generate_unique_id + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SupportedBinarySensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic binary sensor entity.""" + + device_class: BinarySensorDeviceClass | None = None + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.ACTIVE_ALERT: SupportedBinarySensorValueParameters(), + VALUE.CLEANER_DELAY: SupportedBinarySensorValueParameters(), + VALUE.FREEZE_MODE: SupportedBinarySensorValueParameters(), + VALUE.POOL_DELAY: SupportedBinarySensorValueParameters(), + VALUE.SPA_DELAY: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.ALARM: { + VALUE.FLOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PROBE_FAULT_ALARM: SupportedBinarySensorValueParameters(), + }, + GROUP.ALERT: { + VALUE.ORP_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LOCKOUT: SupportedBinarySensorValueParameters(), + }, + GROUP.WATER_BALANCE: { + VALUE.CORROSIVE: SupportedBinarySensorValueParameters(), + VALUE.SCALING: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + } +) SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM} -SUPPORTED_CONFIG_BINARY_SENSORS = ( - "freeze_mode", - "pool_delay", - "spa_delay", - "cleaner_delay", -) - async def async_setup_entry( hass: HomeAssistant, @@ -30,132 +99,92 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicBinarySensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicBinarySensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - gateway_data = coordinator.gateway_data - config = gateway_data[SL_DATA.KEY_CONFIG] - - # Generic binary sensor - entities.append( - ScreenLogicStatusBinarySensor(coordinator, "chem_alarm", CODE.STATUS_CHANGED) - ) - - entities.extend( - [ - ScreenlogicConfigBinarySensor(coordinator, cfg_sensor, CODE.STATUS_CHANGED) - for cfg_sensor in config - if cfg_sensor in SUPPORTED_CONFIG_BINARY_SENSORS - ] - ) - - if config["equipment_flags"] & EQUIPMENT.FLAG_INTELLICHEM: - chemistry = gateway_data[SL_DATA.KEY_CHEMISTRY] - # IntelliChem alarm sensors - entities.extend( - [ - ScreenlogicChemistryAlarmBinarySensor( - coordinator, chem_alarm, CODE.CHEMISTRY_CHANGED + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedBinarySensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + } + + if ( + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + ) + ) is not None: + entities.append( + ScreenLogicPushBinarySensor( + coordinator, + ScreenLogicPushBinarySensorDescription( + subscription_code=sub_code, **entity_description_kwargs + ), ) - for chem_alarm in chemistry[SL_DATA.KEY_ALERTS] - if not chem_alarm.startswith("_") - ] - ) - - # Intellichem notification sensors - entities.extend( - [ - ScreenlogicChemistryNotificationBinarySensor( - coordinator, chem_notif, CODE.CHEMISTRY_CHANGED + ) + else: + entities.append( + ScreenLogicBinarySensor( + coordinator, + ScreenLogicBinarySensorDescription(**entity_description_kwargs), ) - for chem_notif in chemistry[SL_DATA.KEY_NOTIFICATIONS] - if not chem_notif.startswith("_") - ] - ) - - if config["equipment_flags"] & EQUIPMENT.FLAG_CHLORINATOR: - # SCG binary sensor - entities.append(ScreenlogicSCGBinarySensor(coordinator, "scg_status")) + ) async_add_entities(entities) -class ScreenLogicBinarySensorEntity(ScreenlogicEntity, BinarySensorEntity): - """Base class for all ScreenLogic binary sensor entities.""" +@dataclass +class ScreenLogicBinarySensorDescription( + BinarySensorEntityDescription, ScreenLogicEntityDescription +): + """A class that describes ScreenLogic binary sensor eneites.""" - _attr_has_entity_name = True - _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def name(self) -> str | None: - """Return the sensor name.""" - return self.sensor["name"] +class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): + """Base class for all ScreenLogic binary sensor entities.""" - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) + entity_description: ScreenLogicBinarySensorDescription + _attr_has_entity_name = True @property def is_on(self) -> bool: """Determine if the sensor is on.""" - return self.sensor["value"] == ON_OFF.ON - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] - - -class ScreenLogicStatusBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a basic ScreenLogic sensor entity.""" + return self.entity_data[ATTR.VALUE] == ON_OFF.ON -class ScreenlogicChemistryAlarmBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity +@dataclass +class ScreenLogicPushBinarySensorDescription( + ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): - """Representation of a ScreenLogic IntelliChem alarm binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_ALERTS][ - self._data_key - ] + """Describes a ScreenLogicPushBinarySensor.""" -class ScreenlogicChemistryNotificationBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic IntelliChem notification binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_NOTIFICATIONS][ - self._data_key - ] - - -class ScreenlogicSCGBinarySensor(ScreenLogicBinarySensorEntity): - """Representation of a ScreenLogic SCG binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] - - -class ScreenlogicConfigBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic config data binary sensor entity.""" +class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor): + """Representation of a basic ScreenLogic sensor entity.""" - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG][self._data_key] + entity_description: ScreenLogicPushBinarySensorDescription diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index cea546262aea2d..889c8617274421 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -1,12 +1,18 @@ """Support for a ScreenLogic heating device.""" +from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, HEAT_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.heat import HEAT_MODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.climate import ( ATTR_PRESET_MODE, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, @@ -18,9 +24,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -41,81 +47,88 @@ async def async_setup_entry( ) -> None: """Set up entry.""" entities = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - for body in coordinator.gateway_data[SL_DATA.KEY_BODIES]: - entities.append(ScreenLogicClimate(coordinator, body)) + gateway = coordinator.gateway + + for body_index, body_data in gateway.get_data(DEVICE.BODY).items(): + body_path = (DEVICE.BODY, body_index) + entities.append( + ScreenLogicClimate( + coordinator, + ScreenLogicClimateDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=body_path, + key=body_index, + name=body_data[VALUE.HEAT_STATE][ATTR.NAME], + ), + ) + ) async_add_entities(entities) +@dataclass +class ScreenLogicClimateDescription( + ClimateEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic climate entity.""" + + class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): """Represents a ScreenLogic climate entity.""" - _attr_has_entity_name = True - + entity_description: ScreenLogicClimateDescription _attr_hvac_modes = SUPPORTED_MODES _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - def __init__(self, coordinator, body): + def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" - super().__init__(coordinator, body, CODE.STATUS_CHANGED) + super().__init__(coordinator, entity_description) self._configured_heat_modes = [] # Is solar listed as available equipment? - if self.gateway_data["config"]["equipment_flags"] & EQUIPMENT.FLAG_SOLAR: + if EQUIPMENT_FLAG.SOLAR in self.gateway.equipment_flags: self._configured_heat_modes.extend( [HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED] ) self._configured_heat_modes.append(HEAT_MODE.HEATER) - self._last_preset = None - - @property - def name(self) -> str: - """Name of the heater.""" - return self.body["heat_status"]["name"] - @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"] + self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] + self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] + self._last_preset = None @property def current_temperature(self) -> float: """Return water temperature.""" - return self.body["last_temperature"]["value"] + return self.entity_data[VALUE.LAST_TEMPERATURE][ATTR.VALUE] @property def target_temperature(self) -> float: """Target temperature.""" - return self.body["heat_set_point"]["value"] + return self.entity_data[VALUE.HEAT_SETPOINT][ATTR.VALUE] @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - if self.config_data["is_celsius"]["value"] == 1: + if self.gateway.temperature_unit == UNIT.CELSIUS: return UnitOfTemperature.CELSIUS return UnitOfTemperature.FAHRENHEIT @property def hvac_mode(self) -> HVACMode: """Return the current hvac mode.""" - if self.body["heat_mode"]["value"] > 0: + if self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE] > 0: return HVACMode.HEAT return HVACMode.OFF @property def hvac_action(self) -> HVACAction: """Return the current action of the heater.""" - if self.body["heat_status"]["value"] > 0: + if self.entity_data[VALUE.HEAT_STATE][ATTR.VALUE] > 0: return HVACAction.HEATING if self.hvac_mode == HVACMode.HEAT: return HVACAction.IDLE @@ -125,15 +138,13 @@ def hvac_action(self) -> HVACAction: def preset_mode(self) -> str: """Return current/last preset mode.""" if self.hvac_mode == HVACMode.OFF: - return HEAT_MODE.NAME_FOR_NUM[self._last_preset] - return HEAT_MODE.NAME_FOR_NUM[self.body["heat_mode"]["value"]] + return HEAT_MODE(self._last_preset).title + return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title @property def preset_modes(self) -> list[str]: """All available presets.""" - return [ - HEAT_MODE.NAME_FOR_NUM[mode_num] for mode_num in self._configured_heat_modes - ] + return [HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes] async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" @@ -145,7 +156,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: ): raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.body['body_type']['value']}" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) @@ -154,28 +165,33 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: mode = HEAT_MODE.OFF else: - mode = HEAT_MODE.NUM_FOR_NAME[self.preset_mode] + mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_hvac_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_hvac_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode) + _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - _LOGGER.debug("Setting last_preset to %s", HEAT_MODE.NUM_FOR_NAME[preset_mode]) - self._last_preset = mode = HEAT_MODE.NUM_FOR_NAME[preset_mode] + mode = HEAT_MODE.parse(preset_mode) + _LOGGER.debug("Setting last_preset to %s", mode.name) + self._last_preset = mode.value if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_preset_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_preset_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode) + _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: """Run when entity is about to be added.""" @@ -189,21 +205,16 @@ async def async_added_to_hass(self) -> None: prev_state is not None and prev_state.attributes.get(ATTR_PRESET_MODE) is not None ): + mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE)) _LOGGER.debug( "Startup setting last_preset to %s from prev_state", - HEAT_MODE.NUM_FOR_NAME[prev_state.attributes.get(ATTR_PRESET_MODE)], + mode.name, ) - self._last_preset = HEAT_MODE.NUM_FOR_NAME[ - prev_state.attributes.get(ATTR_PRESET_MODE) - ] + self._last_preset = mode.value else: + mode = HEAT_MODE.parse(self._configured_heat_modes[0]) _LOGGER.debug( "Startup setting last_preset to default (%s)", - self._configured_heat_modes[0], + mode.name, ) - self._last_preset = self._configured_heat_modes[0] - - @property - def body(self): - """Shortcut to access body data.""" - return self.gateway_data[SL_DATA.KEY_BODIES][self._data_key] + self._last_preset = mode.value diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 77040bdb21682e..25d00e3a2ce102 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -2,9 +2,10 @@ from __future__ import annotations import logging +from typing import Any from screenlogicpy import ScreenLogicError, discovery -from screenlogicpy.const import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT from screenlogicpy.requests import login import voluptuous as vol @@ -64,10 +65,10 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize ScreenLogic ConfigFlow.""" - self.discovered_gateways = {} - self.discovered_ip = None + self.discovered_gateways: dict[str, dict[str, Any]] = {} + self.discovered_ip: str | None = None @staticmethod @callback @@ -77,7 +78,7 @@ def async_get_options_flow( """Get the options flow for ScreenLogic.""" return ScreenLogicOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """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() @@ -93,7 +94,7 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes self.context["title_placeholders"] = {"name": discovery_info.hostname} return await self.async_step_gateway_entry() - async def async_step_gateway_select(self, user_input=None): + async def async_step_gateway_select(self, user_input=None) -> FlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" existing = self._async_current_ids() unconfigured_gateways = { @@ -105,7 +106,7 @@ async def async_step_gateway_select(self, user_input=None): if not unconfigured_gateways: return await self.async_step_gateway_entry() - errors = {} + errors: dict[str, str] = {} if user_input is not None: if user_input[GATEWAY_SELECT_KEY] == GATEWAY_MANUAL_ENTRY: return await self.async_step_gateway_entry() @@ -140,9 +141,9 @@ async def async_step_gateway_select(self, user_input=None): description_placeholders={}, ) - async def async_step_gateway_entry(self, user_input=None): + async def async_step_gateway_entry(self, user_input=None) -> FlowResult: """Handle the manual entry of a ScreenLogic gateway.""" - errors = {} + errors: dict[str, str] = {} ip_address = self.discovered_ip port = 80 @@ -186,7 +187,7 @@ def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Init the screen logic options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index e4a5ea82186e8f..8181e0f612aa10 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -1,25 +1,48 @@ """Constants for the ScreenLogic integration.""" -from screenlogicpy.const import CIRCUIT_FUNCTION, COLOR_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.device_const.circuit import FUNCTION +from screenlogicpy.device_const.system import COLOR_MODE +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.util import slugify +ScreenLogicDataPath = tuple[str | int, ...] + DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 10 SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" -SUPPORTED_COLOR_MODES = { - slugify(name): num for num, name in COLOR_MODE.NAME_FOR_NUM.items() -} +SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} LIGHT_CIRCUIT_FUNCTIONS = { - CIRCUIT_FUNCTION.COLOR_WHEEL, - CIRCUIT_FUNCTION.DIMMER, - CIRCUIT_FUNCTION.INTELLIBRITE, - CIRCUIT_FUNCTION.LIGHT, - CIRCUIT_FUNCTION.MAGICSTREAM, - CIRCUIT_FUNCTION.PHOTONGEN, - CIRCUIT_FUNCTION.SAL_LIGHT, - CIRCUIT_FUNCTION.SAM_LIGHT, + FUNCTION.COLOR_WHEEL, + FUNCTION.DIMMER, + FUNCTION.INTELLIBRITE, + FUNCTION.LIGHT, + FUNCTION.MAGICSTREAM, + FUNCTION.PHOTONGEN, + FUNCTION.SAL_LIGHT, + FUNCTION.SAM_LIGHT, +} + +SL_UNIT_TO_HA_UNIT = { + UNIT.CELSIUS: UnitOfTemperature.CELSIUS, + UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, + UNIT.WATT: UnitOfPower.WATT, + UNIT.HOUR: UnitOfTime.HOURS, + UNIT.SECOND: UnitOfTime.SECONDS, + UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, + UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + UNIT.PERCENT: PERCENTAGE, } diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py new file mode 100644 index 00000000000000..74f4992717152b --- /dev/null +++ b/homeassistant/components/screenlogic/coordinator.py @@ -0,0 +1,97 @@ +"""ScreenlogicDataUpdateCoordinator definition.""" +from datetime import timedelta +import logging + +from screenlogicpy import ScreenLogicError, ScreenLogicGateway +from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.device_const.system import EQUIPMENT_FLAG + +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.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .config_flow import async_discover_gateways_by_unique_id, name_for_mac +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +REQUEST_REFRESH_DELAY = 2 +HEATER_COOLDOWN_DELAY = 6 + + +async def async_get_connect_info( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, str | int]: + """Construct connect_info from configuration entry and returns it to caller.""" + mac = entry.unique_id + # Attempt to rediscover gateway to follow IP changes + discovered_gateways = await async_discover_gateways_by_unique_id(hass) + if mac in discovered_gateways: + return discovered_gateways[mac] + + _LOGGER.debug("Gateway rediscovery failed for %s", entry.title) + # Static connection defined or fallback from discovery + return { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } + + +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage the data update for the Screenlogic component.""" + + def __init__( + self, + hass: HomeAssistant, + *, + config_entry: ConfigEntry, + gateway: ScreenLogicGateway, + ) -> None: + """Initialize the Screenlogic Data Update Coordinator.""" + self.config_entry = config_entry + self.gateway = gateway + + interval = timedelta( + seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + # Debounced option since the device takes + # a moment to reflect the knock-on changes + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def _async_update_configured_data(self) -> None: + """Update data sets based on equipment config.""" + if not self.gateway.is_client: + await self.gateway.async_get_status() + if EQUIPMENT_FLAG.INTELLICHEM in self.gateway.equipment_flags: + await self.gateway.async_get_chemistry() + + await self.gateway.async_get_pumps() + if EQUIPMENT_FLAG.CHLORINATOR in self.gateway.equipment_flags: + await self.gateway.async_get_scg() + + async def _async_update_data(self) -> None: + """Fetch data from the Screenlogic gateway.""" + assert self.config_entry is not None + try: + if not self.gateway.is_connected: + connect_info = await async_get_connect_info( + self.hass, self.config_entry + ) + await self.gateway.async_connect(**connect_info) + + await self._async_update_configured_data() + except ScreenLogicError as ex: + if self.gateway.is_connected: + await self.gateway.async_disconnect() + raise UpdateFailed(ex.msg) from ex diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py new file mode 100644 index 00000000000000..5679b7e4dc99d3 --- /dev/null +++ b/homeassistant/components/screenlogic/data.py @@ -0,0 +1,304 @@ +"""Support for configurable supported data values for the ScreenLogic integration.""" +from collections.abc import Callable, Generator +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG + +from homeassistant.const import EntityCategory + +from .const import SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath + + +class PathPart(StrEnum): + """Placeholders for local data_path values.""" + + DEVICE = "!device" + KEY = "!key" + INDEX = "!index" + VALUE = "!sensor" + + +ScreenLogicDataPathTemplate = tuple[PathPart | str | int, ...] + + +class ScreenLogicRule: + """Represents a base default passing rule.""" + + def __init__( + self, test: Callable[..., bool] = lambda gateway, data_path: True + ) -> None: + """Initialize a ScreenLogic rule.""" + self._test = test + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Method to check the rule.""" + return self._test(gateway, data_path) + + +class ScreenLogicDataRule(ScreenLogicRule): + """Represents a data rule.""" + + def __init__( + self, test: Callable[..., bool], test_path_template: tuple[PathPart, ...] + ) -> None: + """Initialize a ScreenLogic data rule.""" + self._test_path_template = test_path_template + super().__init__(test) + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's data.""" + test_path = realize_path_template(self._test_path_template, data_path) + return self._test(gateway.get_data(*test_path)) + + +class ScreenLogicEquipmentRule(ScreenLogicRule): + """Represents an equipment flag rule.""" + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's equipment flags.""" + return self._test(gateway.equipment_flags) + + +@dataclass +class SupportedValueParameters: + """Base supported values for ScreenLogic Entities.""" + + enabled: ScreenLogicRule = ScreenLogicRule() + included: ScreenLogicRule = ScreenLogicRule() + subscription_code: int | None = None + entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + + +SupportedValueDescriptions = dict[str, SupportedValueParameters] + +SupportedGroupDescriptions = dict[int | str, SupportedValueDescriptions] + +SupportedDeviceDescriptions = dict[str, SupportedGroupDescriptions] + + +DEVICE_INCLUSION_RULES = { + DEVICE.PUMP: ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.DATA] != 0, + (PathPart.DEVICE, PathPart.INDEX), + ), + DEVICE.INTELLICHEM: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags, + ), + DEVICE.SCG: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.CHLORINATOR in flags, + ), +} + +DEVICE_SUBSCRIPTION = { + DEVICE.CONTROLLER: CODE.STATUS_CHANGED, + DEVICE.INTELLICHEM: CODE.CHEMISTRY_CHANGED, +} + + +# not run-time +def get_ha_unit(entity_data: dict) -> StrEnum | str | None: + """Return a Home Assistant unit of measurement from a UNIT.""" + sl_unit = entity_data.get(ATTR.UNIT) + return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) + + +# partial run-time +def realize_path_template( + template_path: ScreenLogicDataPathTemplate, data_path: ScreenLogicDataPath +) -> ScreenLogicDataPath: + """Create a new data path using a template and an existing data path. + + Construct new ScreenLogicDataPath from data_path using + template_path to specify values from data_path. + """ + if not data_path or len(data_path) < 3: + raise KeyError( + f"Missing or invalid required parameter: 'data_path' for template path '{template_path}'" + ) + device, group, data_key = data_path + realized_path: list[str | int] = [] + for part in template_path: + match part: + case PathPart.DEVICE: + realized_path.append(device) + case PathPart.INDEX | PathPart.KEY: + realized_path.append(group) + case PathPart.VALUE: + realized_path.append(data_key) + case _: + realized_path.append(part) + + return tuple(realized_path) + + +def preprocess_supported_values( + supported_devices: SupportedDeviceDescriptions, +) -> list[tuple[ScreenLogicDataPath, Any]]: + """Expand config dict into list of ScreenLogicDataPaths and settings.""" + processed: list[tuple[ScreenLogicDataPath, Any]] = [] + for device, device_groups in supported_devices.items(): + for group, group_values in device_groups.items(): + for value_key, value_params in group_values.items(): + value_data_path = (device, group, value_key) + processed.append((value_data_path, value_params)) + return processed + + +def iterate_expand_group_wildcard( + gateway: ScreenLogicGateway, + preprocessed_data: list[tuple[ScreenLogicDataPath, Any]], +) -> Generator[tuple[ScreenLogicDataPath, Any], None, None]: + """Iterate and expand any group wildcards to all available entries in gateway.""" + for data_path, value_params in preprocessed_data: + device, group, value_key = data_path + if group == "*": + for index in gateway.get_data(device): + yield ((device, index, value_key), value_params) + else: + yield (data_path, value_params) + + +def build_base_entity_description( + gateway: ScreenLogicGateway, + entity_key: str, + data_path: ScreenLogicDataPath, + value_data: dict, + value_params: SupportedValueParameters, +) -> dict: + """Build base entity description. + + Returns a dict of entity description key value pairs common to all entities. + """ + return { + "data_path": data_path, + "key": entity_key, + "entity_category": value_params.entity_category, + "entity_registry_enabled_default": value_params.enabled.test( + gateway, data_path + ), + "name": value_data.get(ATTR.NAME), + } + + +ENTITY_MIGRATIONS = { + "chem_alarm": { + "new_key": VALUE.ACTIVE_ALERT, + "old_name": "Chemistry Alarm", + "new_name": "Active Alert", + }, + "chem_calcium_harness": { + "new_key": VALUE.CALCIUM_HARNESS, + }, + "chem_current_orp": { + "new_key": VALUE.ORP_NOW, + "old_name": "Current ORP", + "new_name": "ORP Now", + }, + "chem_current_ph": { + "new_key": VALUE.PH_NOW, + "old_name": "Current pH", + "new_name": "pH Now", + }, + "chem_cya": { + "new_key": VALUE.CYA, + }, + "chem_orp_dosing_state": { + "new_key": VALUE.ORP_DOSING_STATE, + }, + "chem_orp_last_dose_time": { + "new_key": VALUE.ORP_LAST_DOSE_TIME, + }, + "chem_orp_last_dose_volume": { + "new_key": VALUE.ORP_LAST_DOSE_VOLUME, + }, + "chem_orp_setpoint": { + "new_key": VALUE.ORP_SETPOINT, + }, + "chem_orp_supply_level": { + "new_key": VALUE.ORP_SUPPLY_LEVEL, + }, + "chem_ph_dosing_state": { + "new_key": VALUE.PH_DOSING_STATE, + }, + "chem_ph_last_dose_time": { + "new_key": VALUE.PH_LAST_DOSE_TIME, + }, + "chem_ph_last_dose_volume": { + "new_key": VALUE.PH_LAST_DOSE_VOLUME, + }, + "chem_ph_probe_water_temp": { + "new_key": VALUE.PH_PROBE_WATER_TEMP, + }, + "chem_ph_setpoint": { + "new_key": VALUE.PH_SETPOINT, + }, + "chem_ph_supply_level": { + "new_key": VALUE.PH_SUPPLY_LEVEL, + }, + "chem_salt_tds_ppm": { + "new_key": VALUE.SALT_TDS_PPM, + }, + "chem_total_alkalinity": { + "new_key": VALUE.TOTAL_ALKALINITY, + }, + "currentGPM": { + "new_key": VALUE.GPM_NOW, + "old_name": "Current GPM", + "new_name": "GPM Now", + "device": DEVICE.PUMP, + }, + "currentRPM": { + "new_key": VALUE.RPM_NOW, + "old_name": "Current RPM", + "new_name": "RPM Now", + "device": DEVICE.PUMP, + }, + "currentWatts": { + "new_key": VALUE.WATTS_NOW, + "old_name": "Current Watts", + "new_name": "Watts Now", + "device": DEVICE.PUMP, + }, + "orp_alarm": { + "new_key": VALUE.ORP_LOW_ALARM, + "old_name": "ORP Alarm", + "new_name": "ORP LOW Alarm", + }, + "ph_alarm": { + "new_key": VALUE.PH_HIGH_ALARM, + "old_name": "pH Alarm", + "new_name": "pH HIGH Alarm", + }, + "scg_status": { + "new_key": VALUE.STATE, + "old_name": "SCG Status", + "new_name": "Chlorinator", + "device": DEVICE.SCG, + }, + "scg_level1": { + "new_key": VALUE.POOL_SETPOINT, + "old_name": "Pool SCG Level", + "new_name": "Pool Chlorinator Setpoint", + }, + "scg_level2": { + "new_key": VALUE.SPA_SETPOINT, + "old_name": "Spa SCG Level", + "new_name": "Spa Chlorinator Setpoint", + }, + "scg_salt_ppm": { + "new_key": VALUE.SALT_PPM, + "old_name": "SCG Salt", + "new_name": "Chlorinator Salt", + "device": DEVICE.SCG, + }, + "scg_super_chlor_timer": { + "new_key": VALUE.SUPER_CHLOR_TIMER, + "old_name": "SCG Super Chlorination Timer", + "new_name": "Super Chlorination Timer", + }, +} diff --git a/homeassistant/components/screenlogic/diagnostics.py b/homeassistant/components/screenlogic/diagnostics.py index ca949c4514c7f2..92e700239ff72d 100644 --- a/homeassistant/components/screenlogic/diagnostics.py +++ b/homeassistant/components/screenlogic/diagnostics.py @@ -5,8 +5,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 955b73262a1647..a29aaa9125b480 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,52 +1,65 @@ """Base ScreenLogicEntity definitions.""" +from dataclasses import dataclass from datetime import datetime import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF +from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.data import ATTR +from screenlogicpy.const.msg import CODE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ScreenlogicDataUpdateCoordinator +from .const import ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +@dataclass +class ScreenLogicEntityRequiredKeyMixin: + """Mixin for required ScreenLogic entity key.""" + + data_path: ScreenLogicDataPath + + +@dataclass +class ScreenLogicEntityDescription( + EntityDescription, ScreenLogicEntityRequiredKeyMixin +): + """Base class for a ScreenLogic entity description.""" + + class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" + entity_description: ScreenLogicEntityDescription + _attr_has_entity_name = True + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - enabled: bool = True, + entity_description: ScreenLogicEntityDescription, ) -> None: """Initialize of the entity.""" super().__init__(coordinator) - self._data_key = data_key - self._attr_entity_registry_enabled_default = enabled - self._attr_unique_id = f"{self.mac}_{self._data_key}" - - controller_type = self.config_data["controller_type"] - hardware_type = self.config_data["hardware_type"] - try: - equipment_model = EQUIPMENT.CONTROLLER_HARDWARE[controller_type][ - hardware_type - ] - except KeyError: - equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}" + self.entity_description = entity_description + self._data_path = self.entity_description.data_path + self._data_key = self._data_path[-1] + self._attr_unique_id = f"{self.mac}_{self.entity_description.key}" mac = self.mac assert mac is not None self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, manufacturer="Pentair", - model=equipment_model, - name=self.gateway_name, + model=self.gateway.controller_model, + name=self.gateway.name, sw_version=self.gateway.version, ) @@ -56,26 +69,11 @@ def mac(self) -> str | None: assert self.coordinator.config_entry is not None return self.coordinator.config_entry.unique_id - @property - def config_data(self) -> dict[str | int, Any]: - """Shortcut for config data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG] - @property def gateway(self) -> ScreenLogicGateway: """Return the gateway.""" return self.coordinator.gateway - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() - - @property - def gateway_name(self) -> str: - """Return the configured name of the gateway.""" - return self.gateway.name - async def _async_refresh(self) -> None: """Refresh the data from the gateway.""" await self.coordinator.async_refresh() @@ -87,20 +85,41 @@ async def _async_refresh_timed(self, now: datetime) -> None: """Refresh from a timed called.""" await self.coordinator.async_request_refresh() + @property + def entity_data(self) -> dict: + """Shortcut to the data for this entity.""" + if (data := self.gateway.get_data(*self._data_path)) is None: + raise KeyError(f"Data not found: {self._data_path}") + return data + + +@dataclass +class ScreenLogicPushEntityRequiredKeyMixin: + """Mixin for required key for ScreenLogic push entities.""" + + subscription_code: CODE + + +@dataclass +class ScreenLogicPushEntityDescription( + ScreenLogicEntityDescription, + ScreenLogicPushEntityRequiredKeyMixin, +): + """Base class for a ScreenLogic push entity description.""" + class ScreenLogicPushEntity(ScreenlogicEntity): """Base class for all ScreenLogic push entities.""" + entity_description: ScreenLogicPushEntityDescription + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - message_code: CODE, - enabled: bool = True, + entity_description: ScreenLogicPushEntityDescription, ) -> None: - """Initialize the entity.""" - super().__init__(coordinator, data_key, enabled) - self._update_message_code = message_code + """Initialize of the entity.""" + super().__init__(coordinator, entity_description) self._last_update_success = True @callback @@ -114,7 +133,8 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self.async_on_remove( await self.gateway.async_subscribe_client( - self._async_data_updated, self._update_message_code + self._async_data_updated, + self.entity_description.subscription_code, ) ) @@ -129,17 +149,10 @@ def _handle_coordinator_update(self) -> None: class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Base class for all ScreenLogic switch and light entities.""" - _attr_has_entity_name = True - - @property - def name(self) -> str: - """Get the name of the switch.""" - return self.circuit["name"] - @property def is_on(self) -> bool: """Get whether the switch is in on state.""" - return self.circuit["value"] == ON_OFF.ON + return self.entity_data[ATTR.VALUE] == ON_OFF.ON async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" @@ -149,14 +162,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value: int) -> None: - if not await self.gateway.async_set_circuit(self._data_key, circuit_value): + async def _async_set_circuit(self, state: ON_OFF) -> None: + if not await self.gateway.async_set_circuit(self._data_key, state.value): raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {circuit_value}" + f"Failed to set_circuit {self._data_key} {state.value}" ) - _LOGGER.debug("Turn %s %s", self._data_key, circuit_value) - - @property - def circuit(self) -> dict[str | int, Any]: - """Shortcut to access the circuit.""" - return self.gateway_data[SL_DATA.KEY_CIRCUITS][self._data_key] + _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 3eae12178decdd..3875e34fbaabc2 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -1,16 +1,23 @@ """Support for a ScreenLogic light 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import CODE, DATA as SL_DATA, GENERIC_CIRCUIT_NAMES +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -21,26 +28,45 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicLight] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] not in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicLight( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES, + ScreenLogicLightDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicLightDescription( + LightEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic light entity.""" class ScreenLogicLight(ScreenLogicCircuitEntity, LightEntity): """Class to represent a ScreenLogic Light.""" + entity_description: ScreenLogicLightDescription _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 5b8b83694274c8..9fc103dc8a8a7a 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.8.2"] + "requirements": ["screenlogicpy==0.9.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index e0d5d0e6a671f0..22805ffc3c1470 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,25 +1,82 @@ """Support for a ScreenLogic number entity.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from screenlogicpy.const import BODY_TYPE, DATA as SL_DATA, EQUIPMENT, SCG +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import ( + DOMAIN, + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + PathPart, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, + realize_path_template, +) +from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .util import cleanup_excluded_entity, generate_unique_id _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -SUPPORTED_SCG_NUMBERS = ( - "scg_level1", - "scg_level2", + +@dataclass +class SupportedNumberValueParametersMixin: + """Mixin for supported predefined data for a ScreenLogic number entity.""" + + set_value_config: tuple[str, tuple[tuple[PathPart | str | int, ...], ...]] + device_class: NumberDeviceClass | None = None + + +@dataclass +class SupportedNumberValueParameters( + SupportedValueParameters, SupportedNumberValueParametersMixin +): + """Supported predefined data for a ScreenLogic number entity.""" + + +SET_SCG_CONFIG_FUNC_DATA = ( + "async_set_scg_config", + ( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), +) + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.SCG: { + GROUP.CONFIGURATION: { + VALUE.POOL_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + VALUE.SPA_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + } + } + } ) @@ -29,66 +86,113 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicNumber] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - async_add_entities( - [ - ScreenLogicNumber(coordinator, scg_level) - for scg_level in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_level in SUPPORTED_SCG_NUMBERS - ] + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedNumberValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + set_value_str, set_value_params = value_params.set_value_config + set_value_func = getattr(gateway, set_value_str) + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": value_params.device_class, + "native_unit_of_measurement": get_ha_unit(value_data), + "native_max_value": value_data.get(ATTR.MAX_SETPOINT), + "native_min_value": value_data.get(ATTR.MIN_SETPOINT), + "native_step": value_data.get(ATTR.STEP), + "set_value": set_value_func, + "set_value_params": set_value_params, + } + + entities.append( + ScreenLogicNumber( + coordinator, + ScreenLogicNumberDescription(**entity_description_kwargs), + ) ) + async_add_entities(entities) + + +@dataclass +class ScreenLogicNumberRequiredMixin: + """Describes a required mixin for a ScreenLogic number entity.""" + + set_value: Callable[..., bool] + set_value_params: tuple[tuple[str | int, ...], ...] + + +@dataclass +class ScreenLogicNumberDescription( + NumberEntityDescription, + ScreenLogicEntityDescription, + ScreenLogicNumberRequiredMixin, +): + """Describes a ScreenLogic number entity.""" + class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): - """Class to represent a ScreenLogic Number.""" + """Class to represent a ScreenLogic Number entity.""" - _attr_has_entity_name = True + entity_description: ScreenLogicNumberDescription - def __init__(self, coordinator, data_key, enabled=True): - """Initialize of the entity.""" - super().__init__(coordinator, data_key, enabled) - self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) - self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type] - self._attr_name = self.sensor["name"] - self._attr_native_unit_of_measurement = self.sensor["unit"] - self._attr_entity_category = EntityCategory.CONFIG + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicNumberDescription, + ) -> None: + """Initialize a ScreenLogic number entity.""" + self._set_value_func = entity_description.set_value + self._set_value_params = entity_description.set_value_params + super().__init__(coordinator, entity_description) @property def native_value(self) -> float: """Return the current value.""" - return self.sensor["value"] + return self.entity_data[ATTR.VALUE] async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Need to set both levels at the same time, so we gather - # both existing level values and override the one that changed. - levels = {} - for level in SUPPORTED_SCG_NUMBERS: - levels[level] = self.gateway_data[SL_DATA.KEY_SCG][level]["value"] - levels[self._data_key] = int(value) - - if await self.coordinator.gateway.async_set_scg_config( - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ): - _LOGGER.debug( - "Set SCG to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], + + # Current API requires certain values to be set at the same time. This + # gathers the existing values and updates the particular value being + # set by this entity. + args = {} + for data_path in self._set_value_params: + data_path = realize_path_template(data_path, self._data_path) + data_value = data_path[-1] + args[data_value] = self.coordinator.gateway.get_value( + *data_path, strict=True ) + + args[self._data_key] = value + + if self._set_value_func(*args.values()): + _LOGGER.debug("Set '%s' to %s", self._data_key, value) await self._async_refresh() else: - _LOGGER.warning( - "Failed to set_scg to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ) - - @property - def sensor(self) -> dict: - """Shortcut to access the level sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 3a9bc3cbee97cc..39805173961358 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,75 +1,147 @@ """Support for a ScreenLogic Sensor.""" -from typing import Any - -from screenlogicpy.const import ( - CHEM_DOSING_STATE, - CODE, - DATA as SL_DATA, - DEVICE_TYPE, - EQUIPMENT, - STATE_TYPE, - UNIT, -) +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.device_const.chemistry import DOSE_STATE +from screenlogicpy.device_const.pump import PUMP_TYPE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.sensor import ( + DOMAIN, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, - PERCENTAGE, - REVOLUTIONS_PER_MINUTE, - EntityCategory, - UnitOfElectricPotential, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity - -SUPPORTED_BASIC_SENSORS = ( - "air_temperature", - "saturation", +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + PathPart, + ScreenLogicDataRule, + ScreenLogicEquipmentRule, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, ) - -SUPPORTED_BASIC_CHEM_SENSORS = ( - "orp", - "ph", +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, ) +from .util import cleanup_excluded_entity, generate_unique_id -SUPPORTED_CHEM_SENSORS = ( - "calcium_harness", - "current_orp", - "current_ph", - "cya", - "orp_dosing_state", - "orp_last_dose_time", - "orp_last_dose_volume", - "orp_setpoint", - "orp_supply_level", - "ph_dosing_state", - "ph_last_dose_time", - "ph_last_dose_volume", - "ph_probe_water_temp", - "ph_setpoint", - "ph_supply_level", - "salt_tds_ppm", - "total_alkalinity", -) +_LOGGER = logging.getLogger(__name__) -SUPPORTED_SCG_SENSORS = ( - "scg_salt_ppm", - "scg_super_chlor_timer", -) -SUPPORTED_PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM") +@dataclass +class SupportedSensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic sensor entity.""" + + device_class: SensorDeviceClass | None = None + value_modification: Callable[[int], int | str] | None = lambda val: val + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.AIR_TEMPERATURE: SupportedSensorValueParameters( + device_class=SensorDeviceClass.TEMPERATURE, entity_category=None + ), + VALUE.ORP: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + VALUE.PH: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.WATTS_NOW: SupportedSensorValueParameters(), + VALUE.GPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VS, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + VALUE.RPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VF, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.SENSOR: { + VALUE.ORP_NOW: SupportedSensorValueParameters(), + VALUE.ORP_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.PH_NOW: SupportedSensorValueParameters(), + VALUE.PH_PROBE_WATER_TEMP: SupportedSensorValueParameters(), + VALUE.PH_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.SATURATION: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.CALCIUM_HARNESS: SupportedSensorValueParameters(), + VALUE.CYA: SupportedSensorValueParameters(), + VALUE.ORP_SETPOINT: SupportedSensorValueParameters(), + VALUE.PH_SETPOINT: SupportedSensorValueParameters(), + VALUE.SALT_TDS_PPM: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + and EQUIPMENT_FLAG.CHLORINATOR not in flags, + ) + ), + VALUE.TOTAL_ALKALINITY: SupportedSensorValueParameters(), + }, + GROUP.DOSE_STATUS: { + VALUE.ORP_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.ORP_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.ORP_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + VALUE.PH_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.PH_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.PH_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.SALT_PPM: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.SUPER_CHLOR_TIMER: SupportedSensorValueParameters(), + }, + }, + } +) SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, @@ -85,18 +157,6 @@ STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, } -SL_UNIT_TO_HA_UNIT = { - UNIT.CELSIUS: UnitOfTemperature.CELSIUS, - UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, - UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, - UNIT.WATT: UnitOfPower.WATT, - UNIT.HOUR: UnitOfTime.HOURS, - UNIT.SECOND: UnitOfTime.SECONDS, - UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, - UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, - UNIT.PERCENT: PERCENTAGE, -} - async def async_setup_entry( hass: HomeAssistant, @@ -104,171 +164,110 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicSensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] - - # Generic push sensors - for sensor_name in coordinator.gateway_data[SL_DATA.KEY_SENSORS]: - if sensor_name in SUPPORTED_BASIC_SENSORS: - entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) - ) + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedSensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + "native_unit_of_measurement": get_ha_unit(value_data), + "options": value_data.get(ATTR.ENUM_OPTIONS), + "state_class": SL_STATE_TYPE_TO_HA_STATE_CLASS.get( + value_data.get(ATTR.STATE_TYPE) + ), + "value_mod": value_params.value_modification, + } - # While these values exist in the chemistry data, their last value doesn't - # persist there when the pump is off/there is no flow. Pulling them from - # the basic sensors keeps the 'last' value and is better for graphs. if ( - equipment_flags & EQUIPMENT.FLAG_INTELLICHEM - and sensor_name in SUPPORTED_BASIC_CHEM_SENSORS - ): + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + ) + ) is not None: entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) + ScreenLogicPushSensor( + coordinator, + ScreenLogicPushSensorDescription( + subscription_code=sub_code, + **entity_description_kwargs, + ), + ) ) - - # Pump sensors - for pump_num, pump_data in coordinator.gateway_data[SL_DATA.KEY_PUMPS].items(): - if pump_data["data"] != 0 and "currentWatts" in pump_data: - for pump_key in pump_data: - enabled = True - # Assumptions for Intelliflow VF - if pump_data["pumpType"] == 1 and pump_key == "currentRPM": - enabled = False - # Assumptions for Intelliflow VS - if pump_data["pumpType"] == 2 and pump_key == "currentGPM": - enabled = False - if pump_key in SUPPORTED_PUMP_SENSORS: - entities.append( - ScreenLogicPumpSensor(coordinator, pump_num, pump_key, enabled) - ) - - # IntelliChem sensors - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - for chem_sensor_name in coordinator.gateway_data[SL_DATA.KEY_CHEMISTRY]: - enabled = True - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - if chem_sensor_name in ("salt_tds_ppm",): - enabled = False - if chem_sensor_name in SUPPORTED_CHEM_SENSORS: - entities.append( - ScreenLogicChemistrySensor( - coordinator, chem_sensor_name, CODE.CHEMISTRY_CHANGED, enabled - ) + else: + entities.append( + ScreenLogicSensor( + coordinator, + ScreenLogicSensorDescription( + **entity_description_kwargs, + ), ) - - # SCG sensors - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - entities.extend( - [ - ScreenLogicSCGSensor(coordinator, scg_sensor) - for scg_sensor in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_sensor in SUPPORTED_SCG_SENSORS - ] - ) + ) async_add_entities(entities) -class ScreenLogicSensorEntity(ScreenlogicEntity, SensorEntity): - """Base class for all ScreenLogic sensor entities.""" +@dataclass +class ScreenLogicSensorMixin: + """Mixin for SecreenLogic sensor entity.""" - _attr_has_entity_name = True + value_mod: Callable[[int | str], int | str] | None = None - @property - def name(self) -> str | None: - """Name of the sensor.""" - return self.sensor["name"] - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - sl_unit = self.sensor.get("unit") - return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) +@dataclass +class ScreenLogicSensorDescription( + ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription +): + """Describes a ScreenLogic sensor.""" - @property - def device_class(self) -> SensorDeviceClass | None: - """Device class of the sensor.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) - @property - def entity_category(self) -> EntityCategory | None: - """Entity Category of the sensor.""" - return ( - None if self._data_key == "air_temperature" else EntityCategory.DIAGNOSTIC - ) +class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): + """Representation of a ScreenLogic sensor entity.""" - @property - def state_class(self) -> SensorStateClass | None: - """Return the state class of the sensor.""" - state_type = self.sensor.get("state_type") - if self._data_key == "scg_super_chlor_timer": - return None - return SL_STATE_TYPE_TO_HA_STATE_CLASS.get(state_type) - - @property - def options(self) -> list[str] | None: - """Return a set of possible options.""" - return self.sensor.get("enum_options") + entity_description: ScreenLogicSensorDescription + _attr_has_entity_name = True @property def native_value(self) -> str | int | float: """State of the sensor.""" - return self.sensor["value"] - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] - + val = self.entity_data[ATTR.VALUE] + value_mod = self.entity_description.value_mod + return value_mod(val) if value_mod else val -class ScreenLogicStatusSensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a basic ScreenLogic sensor entity.""" +@dataclass +class ScreenLogicPushSensorDescription( + ScreenLogicSensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic push sensor.""" -class ScreenLogicPumpSensor(ScreenLogicSensorEntity): - """Representation of a ScreenLogic pump sensor entity.""" - def __init__(self, coordinator, pump, key, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"{key}_{pump}", enabled) - self._pump_id = pump - self._key = key +class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity): + """Representation of a ScreenLogic push sensor entity.""" - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_PUMPS][self._pump_id][self._key] - - -class ScreenLogicChemistrySensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a ScreenLogic IntelliChem sensor entity.""" - - def __init__(self, coordinator, key, message_code, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"chem_{key}", message_code, enabled) - self._key = key - - @property - def native_value(self) -> str | int | float: - """State of the sensor.""" - value = self.sensor["value"] - if "dosing_state" in self._key: - return CHEM_DOSING_STATE.NAME_FOR_NUM[value] - return (value - 1) if "supply" in self._data_key else value - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][self._key] - - -class ScreenLogicSCGSensor(ScreenLogicSensorEntity): - """Representation of ScreenLogic SCG sensor entity.""" - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + entity_description: ScreenLogicPushSensorDescription diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 96bced70867f2c..247ec4f2f03419 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -1,21 +1,19 @@ """Support for a ScreenLogic 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import ( - CODE, - DATA as SL_DATA, - GENERIC_CIRCUIT_NAMES, - INTERFACE_GROUP, -) +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -26,24 +24,43 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSwitch] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicSwitch( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES - and circuit["interface"] != INTERFACE_GROUP.DONT_SHOW, + ScreenLogicSwitchDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] not in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicSwitchDescription( + SwitchEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic switch entity.""" class ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity): """Class to represent a ScreenLogic Switch.""" + + entity_description: ScreenLogicSwitchDescription diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py new file mode 100644 index 00000000000000..c8d9d5f0f771f9 --- /dev/null +++ b/homeassistant/components/screenlogic/util.py @@ -0,0 +1,40 @@ +"""Utility functions for the ScreenLogic integration.""" +import logging + +from screenlogicpy.const.data import SHARED_VALUES + +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def generate_unique_id( + device: str | int, group: str | int | None, data_key: str | int +) -> str: + """Generate new unique_id for a screenlogic entity from specified parameters.""" + if data_key in SHARED_VALUES and device is not None: + if group is not None and (isinstance(group, int) or group.isdigit()): + return f"{device}_{group}_{data_key}" + return f"{device}_{data_key}" + return str(data_key) + + +def cleanup_excluded_entity( + coordinator: ScreenlogicDataUpdateCoordinator, + platform_domain: str, + entity_key: str, +) -> None: + """Remove excluded entity if it exists.""" + assert coordinator.config_entry + entity_registry = er.async_get(coordinator.hass) + unique_id = f"{coordinator.config_entry.unique_id}_{entity_key}" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, SL_DOMAIN, unique_id + ): + _LOGGER.debug( + "Removing existing entity '%s' per data inclusion rule", entity_id + ) + entity_registry.async_remove(entity_id) diff --git a/requirements_all.txt b/requirements_all.txt index 89dbf774fe7567..94499edd23eb93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2358,7 +2358,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ea3661450ca08..c8267f6c484782 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.backup securetar==2023.3.0 diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py index ad2b82960f0662..48362722312f01 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -1 +1,67 @@ """Tests for the Screenlogic integration.""" +from collections.abc import Callable +import logging + +from tests.common import load_json_object_fixture + +MOCK_ADAPTER_NAME = "Pentair DD-EE-FF" +MOCK_ADAPTER_MAC = "aa:bb:cc:dd:ee:ff" +MOCK_ADAPTER_IP = "127.0.0.1" +MOCK_ADAPTER_PORT = 80 + +_LOGGER = logging.getLogger(__name__) + + +GATEWAY_DISCOVERY_IMPORT_PATH = "homeassistant.components.screenlogic.coordinator.async_discover_gateways_by_unique_id" + + +def num_key_string_to_int(data: dict) -> None: + """Convert all string number dict keys to integer. + + This needed for screenlogicpy's data dict format. + """ + rpl = [] + for key, value in data.items(): + if isinstance(value, dict): + num_key_string_to_int(value) + if isinstance(key, str) and key.isnumeric(): + rpl.append(key) + for k in rpl: + data[int(k)] = data.pop(k) + + return data + + +DATA_FULL_CHEM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_chem.json") +) +DATA_MIN_MIGRATION = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_migration.json") +) +DATA_MIN_ENTITY_CLEANUP = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_entity_cleanup.json") +) + + +async def stub_async_connect( + data, + self, + ip=None, + port=None, + gtype=None, + gsubtype=None, + name=MOCK_ADAPTER_NAME, + connection_closed_callback: Callable = None, +) -> bool: + """Initialize minimum attributes needed for tests.""" + self._ip = ip + self._port = port + self._type = gtype + self._subtype = gsubtype + self._name = name + self._custom_connection_closed_callback = connection_closed_callback + self._mac = MOCK_ADAPTER_MAC + self._data = data + _LOGGER.debug("Gateway mock connected") + + return True diff --git a/tests/components/screenlogic/conftest.py b/tests/components/screenlogic/conftest.py new file mode 100644 index 00000000000000..3795df3dddc858 --- /dev/null +++ b/tests/components/screenlogic/conftest.py @@ -0,0 +1,27 @@ +"""Setup fixtures for ScreenLogic integration tests.""" +import pytest + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL + +from . import MOCK_ADAPTER_IP, MOCK_ADAPTER_MAC, MOCK_ADAPTER_NAME, MOCK_ADAPTER_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + title=MOCK_ADAPTER_NAME, + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: MOCK_ADAPTER_IP, + CONF_PORT: MOCK_ADAPTER_PORT, + }, + options={ + CONF_SCAN_INTERVAL: 30, + }, + unique_id=MOCK_ADAPTER_MAC, + entry_id="screenlogictest", + ) diff --git a/tests/components/screenlogic/fixtures/data_full_chem.json b/tests/components/screenlogic/fixtures/data_full_chem.json new file mode 100644 index 00000000000000..6c9ece22fcfc11 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_chem.json @@ -0,0 +1,880 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 98360, + "list": [ + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM", + "HYBRID_HEATER" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 7.61, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Pool Low Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 0, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json new file mode 100644 index 00000000000000..40f7dbe4ad50b5 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json @@ -0,0 +1,38 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { "min_setpoint": 40, "max_setpoint": 104 }, + "1": { "min_setpoint": 40, "max_setpoint": 104 } + }, + "is_celsius": { "name": "Is Celsius", "value": 0 }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { "name": "Model", "value": "EasyTouch2 8" }, + "equipment": { + "flags": 24 + } + }, + "circuit": {}, + "pump": { + "0": { "data": 0 }, + "1": { "data": 0 }, + "2": { "data": 0 }, + "3": { "data": 0 }, + "4": { "data": 0 }, + "5": { "data": 0 }, + "6": { "data": 0 }, + "7": { "data": 0 } + }, + "body": {}, + "intellichem": {}, + "scg": {} +} diff --git a/tests/components/screenlogic/fixtures/data_min_migration.json b/tests/components/screenlogic/fixtures/data_min_migration.json new file mode 100644 index 00000000000000..335c98db0ae7bb --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_migration.json @@ -0,0 +1,151 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 32796 + }, + "sensor": { + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": {}, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + } + }, + "1": { + "data": 0 + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": {}, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + } + } + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..05320c147e5a79 --- /dev/null +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -0,0 +1,960 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'ip_address': '127.0.0.1', + 'port': 80, + }), + 'disabled_by': None, + 'domain': 'screenlogic', + 'entry_id': 'screenlogictest', + 'options': dict({ + 'scan_interval': 30, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Pentair DD-EE-FF', + 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'version': 1, + }), + 'data': dict({ + 'adapter': dict({ + 'firmware': dict({ + 'name': 'Protocol Adapter Firmware', + 'value': 'POOL: 5.2 Build 736.0 Rel', + }), + }), + 'body': dict({ + '0': dict({ + 'body_type': 0, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Cool Set Point', + 'unit': '°F', + 'value': 100, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Pool Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Heat Set Point', + 'unit': '°F', + 'value': 83, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Pool Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Pool Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Pool', + }), + '1': dict({ + 'body_type': 1, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Cool Set Point', + 'unit': '°F', + 'value': 69, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Spa Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Heat Set Point', + 'unit': '°F', + 'value': 94, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Spa Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Spa Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 84, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Spa', + }), + }), + 'circuit': dict({ + '500': dict({ + 'circuit_id': 500, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 71, + 'unknown_at_offset_62': 0, + 'unknown_at_offset_63': 0, + }), + 'device_id': 1, + 'function': 1, + 'interface': 1, + 'name': 'Spa', + 'value': 0, + }), + '501': dict({ + 'circuit_id': 501, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 85, + 'unknown_at_offset_94': 0, + 'unknown_at_offset_95': 0, + }), + 'device_id': 2, + 'function': 0, + 'interface': 2, + 'name': 'Waterfall', + 'value': 0, + }), + '502': dict({ + 'circuit_id': 502, + 'color': dict({ + 'color_position': 0, + 'color_set': 2, + 'color_stagger': 2, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 62, + 'unknown_at_offset_126': 0, + 'unknown_at_offset_127': 0, + }), + 'device_id': 3, + 'function': 16, + 'interface': 3, + 'name': 'Pool Light', + 'value': 0, + }), + '503': dict({ + 'circuit_id': 503, + 'color': dict({ + 'color_position': 1, + 'color_set': 6, + 'color_stagger': 10, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 73, + 'unknown_at_offset_158': 0, + 'unknown_at_offset_159': 0, + }), + 'device_id': 4, + 'function': 16, + 'interface': 3, + 'name': 'Spa Light', + 'value': 0, + }), + '504': dict({ + 'circuit_id': 504, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 240, + 'delay': 0, + 'flags': 0, + 'name_index': 21, + 'unknown_at_offset_186': 0, + 'unknown_at_offset_187': 0, + }), + 'device_id': 5, + 'function': 5, + 'interface': 0, + 'name': 'Cleaner', + 'value': 0, + }), + '505': dict({ + 'circuit_id': 505, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 63, + 'unknown_at_offset_214': 0, + 'unknown_at_offset_215': 0, + }), + 'device_id': 6, + 'function': 2, + 'interface': 0, + 'name': 'Pool Low', + 'value': 0, + }), + '506': dict({ + 'circuit_id': 506, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 91, + 'unknown_at_offset_246': 0, + 'unknown_at_offset_247': 0, + }), + 'device_id': 7, + 'function': 7, + 'interface': 4, + 'name': 'Yard Light', + 'value': 0, + }), + '507': dict({ + 'circuit_id': 507, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 1620, + 'delay': 0, + 'flags': 0, + 'name_index': 101, + 'unknown_at_offset_274': 0, + 'unknown_at_offset_275': 0, + }), + 'device_id': 8, + 'function': 0, + 'interface': 2, + 'name': 'Cameras', + 'value': 1, + }), + '508': dict({ + 'circuit_id': 508, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_306': 0, + 'unknown_at_offset_307': 0, + }), + 'device_id': 9, + 'function': 0, + 'interface': 0, + 'name': 'Pool High', + 'value': 0, + }), + '510': dict({ + 'circuit_id': 510, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 78, + 'unknown_at_offset_334': 0, + 'unknown_at_offset_335': 0, + }), + 'device_id': 11, + 'function': 14, + 'interface': 1, + 'name': 'Spillway', + 'value': 0, + }), + '511': dict({ + 'circuit_id': 511, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_366': 0, + 'unknown_at_offset_367': 0, + }), + 'device_id': 12, + 'function': 0, + 'interface': 5, + 'name': 'Pool High', + 'value': 0, + }), + }), + 'controller': dict({ + 'configuration': dict({ + 'body_type': dict({ + '0': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + '1': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + }), + 'circuit_count': 11, + 'color': list([ + dict({ + 'name': 'White', + 'value': list([ + 255, + 255, + 255, + ]), + }), + dict({ + 'name': 'Light Green', + 'value': list([ + 160, + 255, + 160, + ]), + }), + dict({ + 'name': 'Green', + 'value': list([ + 0, + 255, + 80, + ]), + }), + dict({ + 'name': 'Cyan', + 'value': list([ + 0, + 255, + 200, + ]), + }), + dict({ + 'name': 'Blue', + 'value': list([ + 100, + 140, + 255, + ]), + }), + dict({ + 'name': 'Lavender', + 'value': list([ + 230, + 130, + 255, + ]), + }), + dict({ + 'name': 'Magenta', + 'value': list([ + 255, + 0, + 128, + ]), + }), + dict({ + 'name': 'Light Magenta', + 'value': list([ + 255, + 180, + 210, + ]), + }), + ]), + 'color_count': 8, + 'controller_data': 0, + 'controller_type': 13, + 'generic_circuit_name': 'Water Features', + 'hardware_type': 0, + 'interface_tab_flags': 127, + 'is_celsius': dict({ + 'name': 'Is Celsius', + 'value': 0, + }), + 'remotes': 0, + 'show_alarms': 0, + 'unknown_at_offset_09': 0, + 'unknown_at_offset_10': 0, + 'unknown_at_offset_11': 0, + }), + 'controller_id': 100, + 'equipment': dict({ + 'flags': 98360, + 'list': list([ + 'INTELLIBRITE', + 'INTELLIFLO_0', + 'INTELLIFLO_1', + 'INTELLICHEM', + 'HYBRID_HEATER', + ]), + }), + 'model': dict({ + 'name': 'Model', + 'value': 'EasyTouch2 8', + }), + 'sensor': dict({ + 'active_alert': dict({ + 'device_type': 'alarm', + 'name': 'Active Alert', + 'value': 0, + }), + 'air_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Air Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 69, + }), + 'cleaner_delay': dict({ + 'name': 'Cleaner Delay', + 'value': 0, + }), + 'freeze_mode': dict({ + 'name': 'Freeze Mode', + 'value': 0, + }), + 'orp': dict({ + 'name': 'ORP', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 728, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph': dict({ + 'name': 'pH', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 7.61, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'pool_delay': dict({ + 'name': 'Pool Delay', + 'value': 0, + }), + 'salt_ppm': dict({ + 'name': 'Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + 'spa_delay': dict({ + 'name': 'Spa Delay', + 'value': 0, + }), + 'state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Unknown', + 'Ready', + 'Sync', + 'Service', + ]), + 'name': 'Controller State', + 'value': 1, + }), + }), + }), + 'intellichem': dict({ + 'alarm': dict({ + 'flags': 1, + 'flow_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Flow Alarm', + 'value': 1, + }), + 'orp_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP HIGH Alarm', + 'value': 0, + }), + 'orp_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP LOW Alarm', + 'value': 0, + }), + 'orp_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP Supply Alarm', + 'value': 0, + }), + 'ph_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH HIGH Alarm', + 'value': 0, + }), + 'ph_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH LOW Alarm', + 'value': 0, + }), + 'ph_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH Supply Alarm', + 'value': 0, + }), + 'probe_fault_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Probe Fault', + 'value': 0, + }), + }), + 'alert': dict({ + 'flags': 0, + 'orp_limit': dict({ + 'name': 'ORP Dose Limit Reached', + 'value': 0, + }), + 'ph_limit': dict({ + 'name': 'pH Dose Limit Reached', + 'value': 0, + }), + 'ph_lockout': dict({ + 'name': 'pH Lockout', + 'value': 0, + }), + }), + 'configuration': dict({ + 'calcium_harness': dict({ + 'name': 'Calcium Hardness', + 'unit': 'ppm', + 'value': 800, + }), + 'cya': dict({ + 'name': 'Cyanuric Acid', + 'unit': 'ppm', + 'value': 45, + }), + 'flags': 32, + 'orp_setpoint': dict({ + 'name': 'ORP Setpoint', + 'unit': 'mV', + 'value': 720, + }), + 'ph_setpoint': dict({ + 'name': 'pH Setpoint', + 'unit': 'pH', + 'value': 7.6, + }), + 'probe_is_celsius': 0, + 'salt_tds_ppm': dict({ + 'name': 'Salt/TDS', + 'unit': 'ppm', + 'value': 1000, + }), + 'total_alkalinity': dict({ + 'name': 'Total Alkalinity', + 'unit': 'ppm', + 'value': 45, + }), + }), + 'dose_status': dict({ + 'flags': 149, + 'orp_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'ORP Dosing State', + 'value': 2, + }), + 'orp_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last ORP Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 4, + }), + 'orp_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last ORP Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + 'ph_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'pH Dosing State', + 'value': 1, + }), + 'ph_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last pH Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 5, + }), + 'ph_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last pH Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + }), + 'firmware': dict({ + 'name': 'IntelliChem Firmware', + 'value': '1.060', + }), + 'sensor': dict({ + 'orp_now': dict({ + 'name': 'ORP Now', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 0, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph_now': dict({ + 'name': 'pH Now', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 0.0, + }), + 'ph_probe_water_temp': dict({ + 'device_type': 'temperature', + 'name': 'pH Probe Water Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + }), + 'unknown_at_offset_00': 42, + 'unknown_at_offset_04': 0, + 'unknown_at_offset_44': 0, + 'unknown_at_offset_45': 0, + 'unknown_at_offset_46': 0, + 'water_balance': dict({ + 'corrosive': dict({ + 'device_type': 'alarm', + 'name': 'SI Corrosive', + 'value': 0, + }), + 'flags': 0, + 'scaling': dict({ + 'device_type': 'alarm', + 'name': 'SI Scaling', + 'value': 0, + }), + }), + }), + 'pump': dict({ + '0': dict({ + 'data': 70, + 'gpm_now': dict({ + 'name': 'Pool Low Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 6, + 'is_rpm': 0, + 'setpoint': 63, + }), + '1': dict({ + 'device_id': 9, + 'is_rpm': 0, + 'setpoint': 72, + }), + '2': dict({ + 'device_id': 1, + 'is_rpm': 1, + 'setpoint': 3450, + }), + '3': dict({ + 'device_id': 130, + 'is_rpm': 0, + 'setpoint': 75, + }), + '4': dict({ + 'device_id': 12, + 'is_rpm': 0, + 'setpoint': 72, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Pool Low Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Pool Low Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Pool Low Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '1': dict({ + 'data': 66, + 'gpm_now': dict({ + 'name': 'Waterfall Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 2, + 'is_rpm': 1, + 'setpoint': 2700, + }), + '1': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '2': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '3': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '4': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Waterfall Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Waterfall Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Waterfall Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '2': dict({ + 'data': 0, + }), + '3': dict({ + 'data': 0, + }), + '4': dict({ + 'data': 0, + }), + '5': dict({ + 'data': 0, + }), + '6': dict({ + 'data': 0, + }), + '7': dict({ + 'data': 0, + }), + }), + 'scg': dict({ + 'configuration': dict({ + 'pool_setpoint': dict({ + 'body_type': 0, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Pool Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 51, + }), + 'spa_setpoint': dict({ + 'body_type': 1, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Spa Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 0, + }), + 'super_chlor_timer': dict({ + 'max_setpoint': 72, + 'min_setpoint': 1, + 'name': 'Super Chlorination Timer', + 'step': 1, + 'unit': 'hr', + 'value': 0, + }), + }), + 'flags': 0, + 'scg_present': 0, + 'sensor': dict({ + 'salt_ppm': dict({ + 'name': 'Chlorinator Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Chlorinator', + 'value': 0, + }), + }), + }), + }), + 'debug': dict({ + }), + }) +# --- diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index f2c39e05b4873d..14488c66564703 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch from screenlogicpy import ScreenLogicError -from screenlogicpy.const import ( +from screenlogicpy.const.common import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, diff --git a/tests/components/screenlogic/test_data.py b/tests/components/screenlogic/test_data.py new file mode 100644 index 00000000000000..9686dc81586031 --- /dev/null +++ b/tests/components/screenlogic/test_data.py @@ -0,0 +1,91 @@ +"""Tests for ScreenLogic integration data processing.""" +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.screenlogic.data import PathPart, realize_path_template +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ( + DATA_MIN_ENTITY_CLEANUP, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +async def test_async_cleanup_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cleanup of unused entities.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_UNUSED_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_saturation", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Saturation Index", + "disabled_by": None, + "has_entity_name": True, + "original_name": "Saturation Index", + } + + unused_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_UNUSED_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + assert unused_entity + assert unused_entity.unique_id == TEST_UNUSED_ENTRY["unique_id"] + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_MIN_ENTITY_CLEANUP, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + deleted_entity = entity_registry.async_get(unused_entity.entity_id) + assert deleted_entity is None + + +def test_realize_path_templates() -> None: + """Test path template realization.""" + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX), (DEVICE.PUMP, 0, VALUE.WATTS_NOW) + ) == (DEVICE.PUMP, 0) + + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX, PathPart.VALUE, ATTR.NAME_INDEX), + (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION), + ) == (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION, ATTR.NAME_INDEX) + + with pytest.raises(KeyError): + realize_path_template( + (PathPart.DEVICE, PathPart.KEY, ATTR.VALUE), + (DEVICE.ADAPTER, VALUE.FIRMWARE), + ) diff --git a/tests/components/screenlogic/test_diagnostics.py b/tests/components/screenlogic/test_diagnostics.py new file mode 100644 index 00000000000000..dcbca954730dd1 --- /dev/null +++ b/tests/components/screenlogic/test_diagnostics.py @@ -0,0 +1,56 @@ +"""Testing for ScreenLogic diagnostics.""" +from unittest.mock import DEFAULT, patch + +from screenlogicpy import ScreenLogicGateway +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import ( + DATA_FULL_CHEM, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + stub_async_connect, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_config_entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_FULL_CHEM, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + get_debug=lambda self: {}, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diag == snapshot diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py new file mode 100644 index 00000000000000..3b99354a1df4f0 --- /dev/null +++ b/tests/components/screenlogic/test_init.py @@ -0,0 +1,236 @@ +"""Tests for ScreenLogic integration init.""" +from dataclasses import dataclass +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from . import ( + DATA_MIN_MIGRATION, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +@dataclass +class EntityMigrationData: + """Class to organize minimum entity data.""" + + old_name: str + old_key: str + new_name: str + new_key: str + domain: str + + +TEST_MIGRATING_ENTITIES = [ + EntityMigrationData( + "Chemistry Alarm", + "chem_alarm", + "Active Alert", + "active_alert", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Pool Low Pump Current Watts", + "currentWatts_0", + "Pool Low Pump Watts Now", + "pump_0_watts_now", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "SCG Status", + "scg_status", + "Chlorinator", + "scg_state", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Non-Migrating Sensor", + "nonmigrating", + "Non-Migrating Sensor", + "nonmigrating", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Cyanuric Acid", + "chem_cya", + "Cyanuric Acid", + "chem_cya", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Old Sensor", + "old_sensor", + "Old Sensor", + "old_sensor", + SENSOR_DOMAIN, + ), +] + +MIGRATION_CONNECT = lambda *args, **kwargs: stub_async_connect( + DATA_MIN_MIGRATION, *args, **kwargs +) + + +@pytest.mark.parametrize( + ("entity_def", "ent_data"), + [ + ( + { + "domain": ent_data.domain, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} {ent_data.old_name}", + "disabled_by": None, + "has_entity_name": True, + "original_name": ent_data.old_name, + }, + ent_data, + ) + for ent_data in TEST_MIGRATING_ENTITIES + ], + ids=[ent_data.old_name for ent_data in TEST_MIGRATING_ENTITIES], +) +async def test_async_migrate_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_def: dict, + ent_data: EntityMigrationData, +) -> None: + """Test migration to new entity names.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_cya", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} CYA", + "disabled_by": None, + "has_entity_name": True, + "original_name": "CYA", + } + + entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entity_def, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.old_name}')}" + old_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}" + new_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.new_name}')}" + new_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.new_key}" + + assert entity.unique_id == old_uid + assert entity.entity_id == old_eid + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(new_eid) + assert entity_migrated + assert entity_migrated.entity_id == new_eid + assert entity_migrated.unique_id == new_uid + assert entity_migrated.original_name == ent_data.new_name + + +async def test_entity_migration_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ENTITY_MIGRATION data guards.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_missing_device", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Missing Migration Device", + "disabled_by": None, + "has_entity_name": True, + "original_name": "EMissing Migration Device", + } + + original_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = original_entity.entity_id + old_uid = original_entity.unique_id + + assert old_uid == f"{MOCK_ADAPTER_MAC}_missing_device" + assert ( + old_eid + == f"{SENSOR_DOMAIN}.{slugify(f'{MOCK_ADAPTER_NAME} Missing Migration Device')}" + ) + + # This patch simulates bad data being added to ENTITY_MIGRATIONS + with patch.dict( + "homeassistant.components.screenlogic.data.ENTITY_MIGRATIONS", + { + "missing_device": { + "new_key": "state", + "old_name": "Missing Migration Device", + "new_name": "Bad ENTITY_MIGRATIONS Entry", + }, + }, + ), patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get( + slugify(f"{MOCK_ADAPTER_NAME} Bad ENTITY_MIGRATIONS Entry") + ) + assert entity_migrated is None + + entity_not_migrated = entity_registry.async_get(old_eid) + assert entity_not_migrated == original_entity