Bump screenlogicpy to v0.9.0 (#92475)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Kevin Worrel 2023-09-09 15:39:54 -07:00 committed by GitHub
parent 8de3945bd4
commit 092580a3ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 3821 additions and 652 deletions

View File

@ -1071,9 +1071,10 @@ omit =
homeassistant/components/saj/sensor.py homeassistant/components/saj/sensor.py
homeassistant/components/satel_integra/* homeassistant/components/satel_integra/*
homeassistant/components/schluter/* homeassistant/components/schluter/*
homeassistant/components/screenlogic/__init__.py
homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/binary_sensor.py
homeassistant/components/screenlogic/climate.py homeassistant/components/screenlogic/climate.py
homeassistant/components/screenlogic/coordinator.py
homeassistant/components/screenlogic/const.py
homeassistant/components/screenlogic/entity.py homeassistant/components/screenlogic/entity.py
homeassistant/components/screenlogic/light.py homeassistant/components/screenlogic/light.py
homeassistant/components/screenlogic/number.py homeassistant/components/screenlogic/number.py

View File

@ -1,27 +1,22 @@
"""The Screenlogic integration.""" """The Screenlogic integration."""
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from screenlogicpy import ScreenLogicError, ScreenLogicGateway from screenlogicpy import ScreenLogicError, ScreenLogicGateway
from screenlogicpy.const import ( from screenlogicpy.const.data import SHARED_VALUES
DATA as SL_DATA,
EQUIPMENT,
SL_GATEWAY_IP,
SL_GATEWAY_NAME,
SL_GATEWAY_PORT,
)
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify
from .config_flow import async_discover_gateways_by_unique_id, name_for_mac from .const import DOMAIN
from .const import DEFAULT_SCAN_INTERVAL, 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 .services import async_load_screenlogic_services, async_unload_screenlogic_services
from .util import generate_unique_id
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -44,12 +39,16 @@ PLATFORMS = [
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Screenlogic from a config entry.""" """Set up Screenlogic from a config entry."""
await _async_migrate_entries(hass, entry)
gateway = ScreenLogicGateway() gateway = ScreenLogicGateway()
connect_info = await async_get_connect_info(hass, entry) connect_info = await async_get_connect_info(hass, entry)
try: try:
await gateway.async_connect(**connect_info) await gateway.async_connect(**connect_info)
await gateway.async_update()
except ScreenLogicError as ex: except ScreenLogicError as ex:
raise ConfigEntryNotReady(ex.msg) from 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) await hass.config_entries.async_reload(entry.entry_id)
async def async_get_connect_info( async def _async_migrate_entries(
hass: HomeAssistant, entry: ConfigEntry hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, str | int]: ) -> None:
"""Construct connect_info from configuration entry and returns it to caller.""" """Migrate to new entity names."""
mac = entry.unique_id entity_registry = er.async_get(hass)
# 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") for entry in er.async_entries_for_config_entry(
# Static connection defined or fallback from discovery entity_registry, config_entry.entry_id
return { ):
SL_GATEWAY_NAME: name_for_mac(mac), source_mac, source_key = entry.unique_id.split("_", 1)
SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS],
SL_GATEWAY_PORT: entry.data[CONF_PORT],
}
source_index = None
if (
len(key_parts := source_key.rsplit("_", 1)) == 2
and key_parts[1].isdecimal()
):
source_key, source_index = key_parts
class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): _LOGGER.debug(
"""Class to manage the data update for the Screenlogic component.""" "Checking migration status for '%s' against key '%s'",
entry.unique_id,
def __init__( source_key,
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
),
) )
@property if source_key not in ENTITY_MIGRATIONS:
def gateway_data(self) -> dict[str | int, Any]: continue
"""Return the gateway data."""
return self.gateway.get_data()
async def _async_update_configured_data(self) -> None: _LOGGER.debug(
"""Update data sets based on equipment config.""" "Evaluating migration of '%s' from migration key '%s'",
equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] entry.entity_id,
if not self.gateway.is_client: source_key,
await self.gateway.async_get_status() )
if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: migrations = ENTITY_MIGRATIONS[source_key]
await self.gateway.async_get_chemistry() updates: dict[str, Any] = {}
new_key = migrations["new_key"]
await self.gateway.async_get_pumps() if new_key in SHARED_VALUES:
if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: if (device := migrations.get("device")) is None:
await self.gateway.async_get_scg() _LOGGER.debug(
"Shared key '%s' is missing required migration data 'device'",
async def _async_update_data(self) -> None: new_key,
"""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) 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)
await self._async_update_configured_data() if new_unique_id and new_unique_id != entry.unique_id:
except ScreenLogicError as ex: if existing_entity_id := entity_registry.async_get_entity_id(
if self.gateway.is_connected: entry.domain, entry.platform, new_unique_id
await self.gateway.async_disconnect() ):
raise UpdateFailed(ex.msg) from ex _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)

View File

@ -1,28 +1,97 @@
"""Support for a ScreenLogic Binary Sensor.""" """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 ( from homeassistant.components.binary_sensor import (
DOMAIN,
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath
from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import ScreenlogicEntity, ScreenLogicPushEntity 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} 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -30,132 +99,92 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up entry.""" """Set up entry."""
entities: list[ScreenLogicBinarySensorEntity] = [] entities: list[ScreenLogicBinarySensor] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id config_entry.entry_id
] ]
gateway_data = coordinator.gateway_data gateway = coordinator.gateway
config = gateway_data[SL_DATA.KEY_CONFIG] 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)
# Generic binary sensor device = data_path[0]
entities.append(
ScreenLogicStatusBinarySensor(coordinator, "chem_alarm", CODE.STATUS_CHANGED)
)
entities.extend( if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test(
[ gateway, data_path
ScreenlogicConfigBinarySensor(coordinator, cfg_sensor, CODE.STATUS_CHANGED) ):
for cfg_sensor in config cleanup_excluded_entity(coordinator, DOMAIN, entity_key)
if cfg_sensor in SUPPORTED_CONFIG_BINARY_SENSORS continue
]
)
if config["equipment_flags"] & EQUIPMENT.FLAG_INTELLICHEM: try:
chemistry = gateway_data[SL_DATA.KEY_CHEMISTRY] value_data = gateway.get_data(*data_path, strict=True)
# IntelliChem alarm sensors except KeyError:
entities.extend( _LOGGER.debug("Failed to find %s", data_path)
[ continue
ScreenlogicChemistryAlarmBinarySensor(
coordinator, chem_alarm, CODE.CHEMISTRY_CHANGED 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("_") else:
] entities.append(
) ScreenLogicBinarySensor(
coordinator,
# Intellichem notification sensors ScreenLogicBinarySensorDescription(**entity_description_kwargs),
entities.extend(
[
ScreenlogicChemistryNotificationBinarySensor(
coordinator, chem_notif, CODE.CHEMISTRY_CHANGED
) )
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) async_add_entities(entities)
class ScreenLogicBinarySensorEntity(ScreenlogicEntity, BinarySensorEntity): @dataclass
class ScreenLogicBinarySensorDescription(
BinarySensorEntityDescription, ScreenLogicEntityDescription
):
"""A class that describes ScreenLogic binary sensor eneites."""
class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity):
"""Base class for all ScreenLogic binary sensor entities.""" """Base class for all ScreenLogic binary sensor entities."""
entity_description: ScreenLogicBinarySensorDescription
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
@property
def name(self) -> str | None:
"""Return the sensor name."""
return self.sensor["name"]
@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)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Determine if the sensor is on.""" """Determine if the sensor is on."""
return self.sensor["value"] == ON_OFF.ON return self.entity_data[ATTR.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( @dataclass
ScreenLogicBinarySensorEntity, ScreenLogicPushEntity class ScreenLogicPushBinarySensorDescription(
ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription
): ):
"""Describes a ScreenLogicPushBinarySensor."""
class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor):
"""Representation of a basic ScreenLogic sensor entity.""" """Representation of a basic ScreenLogic sensor entity."""
entity_description: ScreenLogicPushBinarySensorDescription
class ScreenlogicChemistryAlarmBinarySensor(
ScreenLogicBinarySensorEntity, ScreenLogicPushEntity
):
"""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
]
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."""
@property
def sensor(self) -> dict:
"""Shortcut to access the sensor data."""
return self.gateway_data[SL_DATA.KEY_CONFIG][self._data_key]

View File

@ -1,12 +1,18 @@
"""Support for a ScreenLogic heating device.""" """Support for a ScreenLogic heating device."""
from dataclasses import dataclass
import logging import logging
from typing import Any 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 ( from homeassistant.components.climate import (
ATTR_PRESET_MODE, ATTR_PRESET_MODE,
ClimateEntity, ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature, ClimateEntityFeature,
HVACAction, HVACAction,
HVACMode, HVACMode,
@ -18,9 +24,9 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN as SL_DOMAIN
from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import ScreenLogicPushEntity from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -41,81 +47,88 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up entry.""" """Set up entry."""
entities = [] entities = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id config_entry.entry_id
] ]
for body in coordinator.gateway_data[SL_DATA.KEY_BODIES]: gateway = coordinator.gateway
entities.append(ScreenLogicClimate(coordinator, body))
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) async_add_entities(entities)
@dataclass
class ScreenLogicClimateDescription(
ClimateEntityDescription, ScreenLogicPushEntityDescription
):
"""Describes a ScreenLogic climate entity."""
class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
"""Represents a ScreenLogic climate entity.""" """Represents a ScreenLogic climate entity."""
_attr_has_entity_name = True entity_description: ScreenLogicClimateDescription
_attr_hvac_modes = SUPPORTED_MODES _attr_hvac_modes = SUPPORTED_MODES
_attr_supported_features = ( _attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
) )
def __init__(self, coordinator, body): def __init__(self, coordinator, entity_description) -> None:
"""Initialize a ScreenLogic climate entity.""" """Initialize a ScreenLogic climate entity."""
super().__init__(coordinator, body, CODE.STATUS_CHANGED) super().__init__(coordinator, entity_description)
self._configured_heat_modes = [] self._configured_heat_modes = []
# Is solar listed as available equipment? # 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( self._configured_heat_modes.extend(
[HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED] [HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED]
) )
self._configured_heat_modes.append(HEAT_MODE.HEATER) self._configured_heat_modes.append(HEAT_MODE.HEATER)
self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT]
self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT]
self._last_preset = None 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"]
@property @property
def current_temperature(self) -> float: def current_temperature(self) -> float:
"""Return water temperature.""" """Return water temperature."""
return self.body["last_temperature"]["value"] return self.entity_data[VALUE.LAST_TEMPERATURE][ATTR.VALUE]
@property @property
def target_temperature(self) -> float: def target_temperature(self) -> float:
"""Target temperature.""" """Target temperature."""
return self.body["heat_set_point"]["value"] return self.entity_data[VALUE.HEAT_SETPOINT][ATTR.VALUE]
@property @property
def temperature_unit(self) -> str: def temperature_unit(self) -> str:
"""Return the unit of measurement.""" """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.CELSIUS
return UnitOfTemperature.FAHRENHEIT return UnitOfTemperature.FAHRENHEIT
@property @property
def hvac_mode(self) -> HVACMode: def hvac_mode(self) -> HVACMode:
"""Return the current hvac mode.""" """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.HEAT
return HVACMode.OFF return HVACMode.OFF
@property @property
def hvac_action(self) -> HVACAction: def hvac_action(self) -> HVACAction:
"""Return the current action of the heater.""" """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 return HVACAction.HEATING
if self.hvac_mode == HVACMode.HEAT: if self.hvac_mode == HVACMode.HEAT:
return HVACAction.IDLE return HVACAction.IDLE
@ -125,15 +138,13 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
def preset_mode(self) -> str: def preset_mode(self) -> str:
"""Return current/last preset mode.""" """Return current/last preset mode."""
if self.hvac_mode == HVACMode.OFF: if self.hvac_mode == HVACMode.OFF:
return HEAT_MODE.NAME_FOR_NUM[self._last_preset] return HEAT_MODE(self._last_preset).title
return HEAT_MODE.NAME_FOR_NUM[self.body["heat_mode"]["value"]] return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title
@property @property
def preset_modes(self) -> list[str]: def preset_modes(self) -> list[str]:
"""All available presets.""" """All available presets."""
return [ return [HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes]
HEAT_MODE.NAME_FOR_NUM[mode_num] for mode_num in self._configured_heat_modes
]
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Change the setpoint of the heater.""" """Change the setpoint of the heater."""
@ -145,7 +156,7 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
): ):
raise HomeAssistantError( raise HomeAssistantError(
f"Failed to set_temperature {temperature} on body" 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) _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature)
@ -154,28 +165,33 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
if hvac_mode == HVACMode.OFF: if hvac_mode == HVACMode.OFF:
mode = HEAT_MODE.OFF mode = HEAT_MODE.OFF
else: 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( raise HomeAssistantError(
f"Failed to set_hvac_mode {mode} on body" f"Failed to set_hvac_mode {mode.name} on body"
f" {self.body['body_type']['value']}" 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: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode.""" """Set the preset mode."""
_LOGGER.debug("Setting last_preset to %s", HEAT_MODE.NUM_FOR_NAME[preset_mode]) mode = HEAT_MODE.parse(preset_mode)
self._last_preset = mode = HEAT_MODE.NUM_FOR_NAME[preset_mode] _LOGGER.debug("Setting last_preset to %s", mode.name)
self._last_preset = mode.value
if self.hvac_mode == HVACMode.OFF: if self.hvac_mode == HVACMode.OFF:
return 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( raise HomeAssistantError(
f"Failed to set_preset_mode {mode} on body" f"Failed to set_preset_mode {mode.name} on body"
f" {self.body['body_type']['value']}" 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: async def async_added_to_hass(self) -> None:
"""Run when entity is about to be added.""" """Run when entity is about to be added."""
@ -189,21 +205,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity):
prev_state is not None prev_state is not None
and prev_state.attributes.get(ATTR_PRESET_MODE) 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( _LOGGER.debug(
"Startup setting last_preset to %s from prev_state", "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[ self._last_preset = mode.value
prev_state.attributes.get(ATTR_PRESET_MODE)
]
else: else:
mode = HEAT_MODE.parse(self._configured_heat_modes[0])
_LOGGER.debug( _LOGGER.debug(
"Startup setting last_preset to default (%s)", "Startup setting last_preset to default (%s)",
self._configured_heat_modes[0], mode.name,
) )
self._last_preset = self._configured_heat_modes[0] self._last_preset = mode.value
@property
def body(self):
"""Shortcut to access body data."""
return self.gateway_data[SL_DATA.KEY_BODIES][self._data_key]

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from screenlogicpy import ScreenLogicError, discovery 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 from screenlogicpy.requests import login
import voluptuous as vol import voluptuous as vol
@ -64,10 +65,10 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
def __init__(self): def __init__(self) -> None:
"""Initialize ScreenLogic ConfigFlow.""" """Initialize ScreenLogic ConfigFlow."""
self.discovered_gateways = {} self.discovered_gateways: dict[str, dict[str, Any]] = {}
self.discovered_ip = None self.discovered_ip: str | None = None
@staticmethod @staticmethod
@callback @callback
@ -77,7 +78,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for ScreenLogic.""" """Get the options flow for ScreenLogic."""
return ScreenLogicOptionsFlowHandler(config_entry) 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.""" """Handle the start of the config flow."""
self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass) self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass)
return await self.async_step_gateway_select() return await self.async_step_gateway_select()
@ -93,7 +94,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = {"name": discovery_info.hostname} self.context["title_placeholders"] = {"name": discovery_info.hostname}
return await self.async_step_gateway_entry() 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.""" """Handle the selection of a discovered ScreenLogic gateway."""
existing = self._async_current_ids() existing = self._async_current_ids()
unconfigured_gateways = { unconfigured_gateways = {
@ -105,7 +106,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not unconfigured_gateways: if not unconfigured_gateways:
return await self.async_step_gateway_entry() return await self.async_step_gateway_entry()
errors = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
if user_input[GATEWAY_SELECT_KEY] == GATEWAY_MANUAL_ENTRY: if user_input[GATEWAY_SELECT_KEY] == GATEWAY_MANUAL_ENTRY:
return await self.async_step_gateway_entry() return await self.async_step_gateway_entry()
@ -140,9 +141,9 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
description_placeholders={}, 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.""" """Handle the manual entry of a ScreenLogic gateway."""
errors = {} errors: dict[str, str] = {}
ip_address = self.discovered_ip ip_address = self.discovered_ip
port = 80 port = 80
@ -186,7 +187,7 @@ class ScreenLogicOptionsFlowHandler(config_entries.OptionsFlow):
"""Init the screen logic options flow.""" """Init the screen logic options flow."""
self.config_entry = config_entry 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.""" """Manage the options."""
if user_input is not None: if user_input is not None:
return self.async_create_entry( return self.async_create_entry(

View File

@ -1,25 +1,48 @@
"""Constants for the ScreenLogic integration.""" """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 from homeassistant.util import slugify
ScreenLogicDataPath = tuple[str | int, ...]
DOMAIN = "screenlogic" DOMAIN = "screenlogic"
DEFAULT_SCAN_INTERVAL = 30 DEFAULT_SCAN_INTERVAL = 30
MIN_SCAN_INTERVAL = 10 MIN_SCAN_INTERVAL = 10
SERVICE_SET_COLOR_MODE = "set_color_mode" SERVICE_SET_COLOR_MODE = "set_color_mode"
ATTR_COLOR_MODE = "color_mode" ATTR_COLOR_MODE = "color_mode"
SUPPORTED_COLOR_MODES = { SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE}
slugify(name): num for num, name in COLOR_MODE.NAME_FOR_NUM.items()
}
LIGHT_CIRCUIT_FUNCTIONS = { LIGHT_CIRCUIT_FUNCTIONS = {
CIRCUIT_FUNCTION.COLOR_WHEEL, FUNCTION.COLOR_WHEEL,
CIRCUIT_FUNCTION.DIMMER, FUNCTION.DIMMER,
CIRCUIT_FUNCTION.INTELLIBRITE, FUNCTION.INTELLIBRITE,
CIRCUIT_FUNCTION.LIGHT, FUNCTION.LIGHT,
CIRCUIT_FUNCTION.MAGICSTREAM, FUNCTION.MAGICSTREAM,
CIRCUIT_FUNCTION.PHOTONGEN, FUNCTION.PHOTONGEN,
CIRCUIT_FUNCTION.SAL_LIGHT, FUNCTION.SAL_LIGHT,
CIRCUIT_FUNCTION.SAM_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,
} }

View File

@ -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

View File

@ -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",
},
}

View File

@ -5,8 +5,8 @@ from typing import Any
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import ScreenlogicDataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
from .coordinator import ScreenlogicDataUpdateCoordinator
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(

View File

@ -1,52 +1,65 @@
"""Base ScreenLogicEntity definitions.""" """Base ScreenLogicEntity definitions."""
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Any from typing import Any
from screenlogicpy import ScreenLogicGateway 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.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ScreenlogicDataUpdateCoordinator from .const import ScreenLogicDataPath
from .coordinator import ScreenlogicDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _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]): class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]):
"""Base class for all ScreenLogic entities.""" """Base class for all ScreenLogic entities."""
entity_description: ScreenLogicEntityDescription
_attr_has_entity_name = True
def __init__( def __init__(
self, self,
coordinator: ScreenlogicDataUpdateCoordinator, coordinator: ScreenlogicDataUpdateCoordinator,
data_key: str, entity_description: ScreenLogicEntityDescription,
enabled: bool = True,
) -> None: ) -> None:
"""Initialize of the entity.""" """Initialize of the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._data_key = data_key self.entity_description = entity_description
self._attr_entity_registry_enabled_default = enabled self._data_path = self.entity_description.data_path
self._attr_unique_id = f"{self.mac}_{self._data_key}" self._data_key = self._data_path[-1]
self._attr_unique_id = f"{self.mac}_{self.entity_description.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}"
mac = self.mac mac = self.mac
assert mac is not None assert mac is not None
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}, connections={(dr.CONNECTION_NETWORK_MAC, mac)},
manufacturer="Pentair", manufacturer="Pentair",
model=equipment_model, model=self.gateway.controller_model,
name=self.gateway_name, name=self.gateway.name,
sw_version=self.gateway.version, sw_version=self.gateway.version,
) )
@ -56,26 +69,11 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]):
assert self.coordinator.config_entry is not None assert self.coordinator.config_entry is not None
return self.coordinator.config_entry.unique_id 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 @property
def gateway(self) -> ScreenLogicGateway: def gateway(self) -> ScreenLogicGateway:
"""Return the gateway.""" """Return the gateway."""
return self.coordinator.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: async def _async_refresh(self) -> None:
"""Refresh the data from the gateway.""" """Refresh the data from the gateway."""
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
@ -87,20 +85,41 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]):
"""Refresh from a timed called.""" """Refresh from a timed called."""
await self.coordinator.async_request_refresh() 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): class ScreenLogicPushEntity(ScreenlogicEntity):
"""Base class for all ScreenLogic push entities.""" """Base class for all ScreenLogic push entities."""
entity_description: ScreenLogicPushEntityDescription
def __init__( def __init__(
self, self,
coordinator: ScreenlogicDataUpdateCoordinator, coordinator: ScreenlogicDataUpdateCoordinator,
data_key: str, entity_description: ScreenLogicPushEntityDescription,
message_code: CODE,
enabled: bool = True,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize of the entity."""
super().__init__(coordinator, data_key, enabled) super().__init__(coordinator, entity_description)
self._update_message_code = message_code
self._last_update_success = True self._last_update_success = True
@callback @callback
@ -114,7 +133,8 @@ class ScreenLogicPushEntity(ScreenlogicEntity):
await super().async_added_to_hass() await super().async_added_to_hass()
self.async_on_remove( self.async_on_remove(
await self.gateway.async_subscribe_client( 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 @@ class ScreenLogicPushEntity(ScreenlogicEntity):
class ScreenLogicCircuitEntity(ScreenLogicPushEntity): class ScreenLogicCircuitEntity(ScreenLogicPushEntity):
"""Base class for all ScreenLogic switch and light entities.""" """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 @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Get whether the switch is in on state.""" """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: async def async_turn_on(self, **kwargs: Any) -> None:
"""Send the ON command.""" """Send the ON command."""
@ -149,14 +162,9 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity):
"""Send the OFF command.""" """Send the OFF command."""
await self._async_set_circuit(ON_OFF.OFF) await self._async_set_circuit(ON_OFF.OFF)
async def _async_set_circuit(self, circuit_value: int) -> None: async def _async_set_circuit(self, state: ON_OFF) -> None:
if not await self.gateway.async_set_circuit(self._data_key, circuit_value): if not await self.gateway.async_set_circuit(self._data_key, state.value):
raise HomeAssistantError( 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) _LOGGER.debug("Set circuit %s %s", self._data_key, state.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]

View File

@ -1,16 +1,23 @@
"""Support for a ScreenLogic light 'circuit' switch.""" """Support for a ScreenLogic light 'circuit' switch."""
from dataclasses import dataclass
import logging 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS
from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import ScreenLogicCircuitEntity from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -21,26 +28,45 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up entry.""" """Set up entry."""
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ entities: list[ScreenLogicLight] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id config_entry.entry_id
] ]
circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] gateway = coordinator.gateway
async_add_entities( 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( ScreenLogicLight(
coordinator, coordinator,
circuit_num, ScreenLogicLightDescription(
CODE.STATUS_CHANGED, subscription_code=CODE.STATUS_CHANGED,
circuit["name"] not in GENERIC_CIRCUIT_NAMES, 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 ScreenLogicLight(ScreenLogicCircuitEntity, LightEntity):
"""Class to represent a ScreenLogic Light.""" """Class to represent a ScreenLogic Light."""
entity_description: ScreenLogicLightDescription
_attr_color_mode = ColorMode.ONOFF _attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF} _attr_supported_color_modes = {ColorMode.ONOFF}

View File

@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/screenlogic", "documentation": "https://www.home-assistant.io/integrations/screenlogic",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["screenlogicpy"], "loggers": ["screenlogicpy"],
"requirements": ["screenlogicpy==0.8.2"] "requirements": ["screenlogicpy==0.9.0"]
} }

View File

@ -1,25 +1,82 @@
"""Support for a ScreenLogic number entity.""" """Support for a ScreenLogic number entity."""
from collections.abc import Callable
from dataclasses import dataclass
import logging 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.config_entries import ConfigEntry
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath
from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import ScreenlogicEntity 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__) _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
SUPPORTED_SCG_NUMBERS = (
"scg_level1", @dataclass
"scg_level2", 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, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up entry.""" """Set up entry."""
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ entities: list[ScreenLogicNumber] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id config_entry.entry_id
] ]
equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] gateway = coordinator.gateway
if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: data_path: ScreenLogicDataPath
async_add_entities( value_params: SupportedNumberValueParameters
[ for data_path, value_params in iterate_expand_group_wildcard(
ScreenLogicNumber(coordinator, scg_level) gateway, SUPPORTED_DATA
for scg_level in coordinator.gateway_data[SL_DATA.KEY_SCG] ):
if scg_level in SUPPORTED_SCG_NUMBERS 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 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): def __init__(
"""Initialize of the entity.""" self,
super().__init__(coordinator, data_key, enabled) coordinator: ScreenlogicDataUpdateCoordinator,
self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) entity_description: ScreenLogicNumberDescription,
self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type] ) -> None:
self._attr_name = self.sensor["name"] """Initialize a ScreenLogic number entity."""
self._attr_native_unit_of_measurement = self.sensor["unit"] self._set_value_func = entity_description.set_value
self._attr_entity_category = EntityCategory.CONFIG self._set_value_params = entity_description.set_value_params
super().__init__(coordinator, entity_description)
@property @property
def native_value(self) -> float: def native_value(self) -> float:
"""Return the current value.""" """Return the current value."""
return self.sensor["value"] return self.entity_data[ATTR.VALUE]
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Update the current value.""" """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( # Current API requires certain values to be set at the same time. This
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], # gathers the existing values and updates the particular value being
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], # set by this entity.
): args = {}
_LOGGER.debug( for data_path in self._set_value_params:
"Set SCG to %i, %i", data_path = realize_path_template(data_path, self._data_path)
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], data_value = data_path[-1]
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], 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() await self._async_refresh()
else: else:
_LOGGER.warning( _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value)
"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]

View File

@ -1,76 +1,148 @@
"""Support for a ScreenLogic Sensor.""" """Support for a ScreenLogic Sensor."""
from typing import Any from collections.abc import Callable
from dataclasses import dataclass
import logging
from screenlogicpy.const import ( from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE
CHEM_DOSING_STATE, from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE
CODE, from screenlogicpy.device_const.chemistry import DOSE_STATE
DATA as SL_DATA, from screenlogicpy.device_const.pump import PUMP_TYPE
DEVICE_TYPE, from screenlogicpy.device_const.system import EQUIPMENT_FLAG
EQUIPMENT,
STATE_TYPE,
UNIT,
)
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
DOMAIN,
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath
from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import ScreenlogicEntity, ScreenLogicPushEntity from .data import (
DEVICE_INCLUSION_RULES,
SUPPORTED_BASIC_SENSORS = ( DEVICE_SUBSCRIPTION,
"air_temperature", PathPart,
"saturation", ScreenLogicDataRule,
ScreenLogicEquipmentRule,
SupportedValueParameters,
build_base_entity_description,
get_ha_unit,
iterate_expand_group_wildcard,
preprocess_supported_values,
) )
from .entity import (
SUPPORTED_BASIC_CHEM_SENSORS = ( ScreenlogicEntity,
"orp", ScreenLogicEntityDescription,
"ph", ScreenLogicPushEntity,
ScreenLogicPushEntityDescription,
) )
from .util import cleanup_excluded_entity, generate_unique_id
SUPPORTED_CHEM_SENSORS = ( _LOGGER = logging.getLogger(__name__)
"calcium_harness",
"current_orp",
"current_ph", @dataclass
"cya", class SupportedSensorValueParameters(SupportedValueParameters):
"orp_dosing_state", """Supported predefined data for a ScreenLogic sensor entity."""
"orp_last_dose_time",
"orp_last_dose_volume", device_class: SensorDeviceClass | None = None
"orp_setpoint", value_modification: Callable[[int], int | str] | None = lambda val: val
"orp_supply_level",
"ph_dosing_state",
"ph_last_dose_time", SUPPORTED_DATA: list[
"ph_last_dose_volume", tuple[ScreenLogicDataPath, SupportedValueParameters]
"ph_probe_water_temp", ] = preprocess_supported_values(
"ph_setpoint", {
"ph_supply_level", DEVICE.CONTROLLER: {
"salt_tds_ppm", GROUP.SENSOR: {
"total_alkalinity", 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(),
},
},
}
) )
SUPPORTED_SCG_SENSORS = (
"scg_salt_ppm",
"scg_super_chlor_timer",
)
SUPPORTED_PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM")
SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {
DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION,
DEVICE_TYPE.ENUM: SensorDeviceClass.ENUM, DEVICE_TYPE.ENUM: SensorDeviceClass.ENUM,
@ -85,18 +157,6 @@ SL_STATE_TYPE_TO_HA_STATE_CLASS = {
STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -104,171 +164,110 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up entry.""" """Set up entry."""
entities: list[ScreenLogicSensorEntity] = [] entities: list[ScreenLogicSensor] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id config_entry.entry_id
] ]
equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] 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)
# Generic push sensors device = data_path[0]
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)
)
# While these values exist in the chemistry data, their last value doesn't if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test(
# persist there when the pump is off/there is no flow. Pulling them from gateway, data_path
# 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
): ):
entities.append( cleanup_excluded_entity(coordinator, DOMAIN, entity_key)
ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) 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,
}
if (
sub_code := (
value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device)
) )
) is not None:
# Pump sensors entities.append(
for pump_num, pump_data in coordinator.gateway_data[SL_DATA.KEY_PUMPS].items(): ScreenLogicPushSensor(
if pump_data["data"] != 0 and "currentWatts" in pump_data: coordinator,
for pump_key in pump_data: ScreenLogicPushSensorDescription(
enabled = True subscription_code=sub_code,
# Assumptions for Intelliflow VF **entity_description_kwargs,
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
)
) )
)
# SCG sensors else:
if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: entities.append(
entities.extend( ScreenLogicSensor(
[ coordinator,
ScreenLogicSCGSensor(coordinator, scg_sensor) ScreenLogicSensorDescription(
for scg_sensor in coordinator.gateway_data[SL_DATA.KEY_SCG] **entity_description_kwargs,
if scg_sensor in SUPPORTED_SCG_SENSORS ),
] )
) )
async_add_entities(entities) async_add_entities(entities)
class ScreenLogicSensorEntity(ScreenlogicEntity, SensorEntity): @dataclass
"""Base class for all ScreenLogic sensor entities.""" class ScreenLogicSensorMixin:
"""Mixin for SecreenLogic sensor entity."""
value_mod: Callable[[int | str], int | str] | None = None
@dataclass
class ScreenLogicSensorDescription(
ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription
):
"""Describes a ScreenLogic sensor."""
class ScreenLogicSensor(ScreenlogicEntity, SensorEntity):
"""Representation of a ScreenLogic sensor entity."""
entity_description: ScreenLogicSensorDescription
_attr_has_entity_name = True _attr_has_entity_name = True
@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)
@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
)
@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")
@property @property
def native_value(self) -> str | int | float: def native_value(self) -> str | int | float:
"""State of the sensor.""" """State of the sensor."""
return self.sensor["value"] val = self.entity_data[ATTR.VALUE]
value_mod = self.entity_description.value_mod
@property return value_mod(val) if value_mod else val
def sensor(self) -> dict[str | int, Any]:
"""Shortcut to access the sensor data."""
return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key]
class ScreenLogicStatusSensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): @dataclass
"""Representation of a basic ScreenLogic sensor entity.""" class ScreenLogicPushSensorDescription(
ScreenLogicSensorDescription, ScreenLogicPushEntityDescription
):
"""Describes a ScreenLogic push sensor."""
class ScreenLogicPumpSensor(ScreenLogicSensorEntity): class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity):
"""Representation of a ScreenLogic pump sensor entity.""" """Representation of a ScreenLogic push sensor entity."""
def __init__(self, coordinator, pump, key, enabled=True): entity_description: ScreenLogicPushSensorDescription
"""Initialize of the pump sensor."""
super().__init__(coordinator, f"{key}_{pump}", enabled)
self._pump_id = pump
self._key = key
@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]

View File

@ -1,21 +1,19 @@
"""Support for a ScreenLogic 'circuit' switch.""" """Support for a ScreenLogic 'circuit' switch."""
from dataclasses import dataclass
import logging import logging
from screenlogicpy.const import ( from screenlogicpy.const.data import ATTR, DEVICE
CODE, from screenlogicpy.const.msg import CODE
DATA as SL_DATA, from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE
GENERIC_CIRCUIT_NAMES,
INTERFACE_GROUP,
)
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS
from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import ScreenLogicCircuitEntity from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -26,24 +24,43 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up entry.""" """Set up entry."""
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ entities: list[ScreenLogicSwitch] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id config_entry.entry_id
] ]
circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] gateway = coordinator.gateway
async_add_entities( 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( ScreenLogicSwitch(
coordinator, coordinator,
circuit_num, ScreenLogicSwitchDescription(
CODE.STATUS_CHANGED, subscription_code=CODE.STATUS_CHANGED,
circuit["name"] not in GENERIC_CIRCUIT_NAMES data_path=(DEVICE.CIRCUIT, circuit_index),
and circuit["interface"] != INTERFACE_GROUP.DONT_SHOW, 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 ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity):
"""Class to represent a ScreenLogic Switch.""" """Class to represent a ScreenLogic Switch."""
entity_description: ScreenLogicSwitchDescription

View File

@ -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)

View File

@ -2358,7 +2358,7 @@ satel-integra==0.3.7
scapy==2.5.0 scapy==2.5.0
# homeassistant.components.screenlogic # homeassistant.components.screenlogic
screenlogicpy==0.8.2 screenlogicpy==0.9.0
# homeassistant.components.scsgate # homeassistant.components.scsgate
scsgate==0.1.0 scsgate==0.1.0

View File

@ -1730,7 +1730,7 @@ samsungtvws[async,encrypted]==2.6.0
scapy==2.5.0 scapy==2.5.0
# homeassistant.components.screenlogic # homeassistant.components.screenlogic
screenlogicpy==0.8.2 screenlogicpy==0.9.0
# homeassistant.components.backup # homeassistant.components.backup
securetar==2023.3.0 securetar==2023.3.0

View File

@ -1 +1,67 @@
"""Tests for the Screenlogic integration.""" """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

View File

@ -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",
)

View File

@ -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
}
}

View File

@ -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": {}
}

View File

@ -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
}
}

View File

@ -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({
}),
})
# ---

View File

@ -2,7 +2,7 @@
from unittest.mock import patch from unittest.mock import patch
from screenlogicpy import ScreenLogicError from screenlogicpy import ScreenLogicError
from screenlogicpy.const import ( from screenlogicpy.const.common import (
SL_GATEWAY_IP, SL_GATEWAY_IP,
SL_GATEWAY_NAME, SL_GATEWAY_NAME,
SL_GATEWAY_PORT, SL_GATEWAY_PORT,

View File

@ -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),
)

View File

@ -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

View File

@ -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