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/satel_integra/*
homeassistant/components/schluter/*
homeassistant/components/screenlogic/__init__.py
homeassistant/components/screenlogic/binary_sensor.py
homeassistant/components/screenlogic/climate.py
homeassistant/components/screenlogic/coordinator.py
homeassistant/components/screenlogic/const.py
homeassistant/components/screenlogic/entity.py
homeassistant/components/screenlogic/light.py
homeassistant/components/screenlogic/number.py

View file

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

View file

@ -1,28 +1,97 @@
"""Support for a ScreenLogic Binary Sensor."""
from screenlogicpy.const import CODE, DATA as SL_DATA, DEVICE_TYPE, EQUIPMENT, ON_OFF
from dataclasses import dataclass
import logging
from screenlogicpy.const.common import DEVICE_TYPE, ON_OFF
from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE
from homeassistant.components.binary_sensor import (
DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator
from .const import DOMAIN
from .entity import ScreenlogicEntity, ScreenLogicPushEntity
from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath
from .coordinator import ScreenlogicDataUpdateCoordinator
from .data import (
DEVICE_INCLUSION_RULES,
DEVICE_SUBSCRIPTION,
SupportedValueParameters,
build_base_entity_description,
iterate_expand_group_wildcard,
preprocess_supported_values,
)
from .entity import (
ScreenlogicEntity,
ScreenLogicEntityDescription,
ScreenLogicPushEntity,
ScreenLogicPushEntityDescription,
)
from .util import cleanup_excluded_entity, generate_unique_id
_LOGGER = logging.getLogger(__name__)
@dataclass
class SupportedBinarySensorValueParameters(SupportedValueParameters):
"""Supported predefined data for a ScreenLogic binary sensor entity."""
device_class: BinarySensorDeviceClass | None = None
SUPPORTED_DATA: list[
tuple[ScreenLogicDataPath, SupportedValueParameters]
] = preprocess_supported_values(
{
DEVICE.CONTROLLER: {
GROUP.SENSOR: {
VALUE.ACTIVE_ALERT: SupportedBinarySensorValueParameters(),
VALUE.CLEANER_DELAY: SupportedBinarySensorValueParameters(),
VALUE.FREEZE_MODE: SupportedBinarySensorValueParameters(),
VALUE.POOL_DELAY: SupportedBinarySensorValueParameters(),
VALUE.SPA_DELAY: SupportedBinarySensorValueParameters(),
},
},
DEVICE.PUMP: {
"*": {
VALUE.STATE: SupportedBinarySensorValueParameters(),
},
},
DEVICE.INTELLICHEM: {
GROUP.ALARM: {
VALUE.FLOW_ALARM: SupportedBinarySensorValueParameters(),
VALUE.ORP_HIGH_ALARM: SupportedBinarySensorValueParameters(),
VALUE.ORP_LOW_ALARM: SupportedBinarySensorValueParameters(),
VALUE.ORP_SUPPLY_ALARM: SupportedBinarySensorValueParameters(),
VALUE.PH_HIGH_ALARM: SupportedBinarySensorValueParameters(),
VALUE.PH_LOW_ALARM: SupportedBinarySensorValueParameters(),
VALUE.PH_SUPPLY_ALARM: SupportedBinarySensorValueParameters(),
VALUE.PROBE_FAULT_ALARM: SupportedBinarySensorValueParameters(),
},
GROUP.ALERT: {
VALUE.ORP_LIMIT: SupportedBinarySensorValueParameters(),
VALUE.PH_LIMIT: SupportedBinarySensorValueParameters(),
VALUE.PH_LOCKOUT: SupportedBinarySensorValueParameters(),
},
GROUP.WATER_BALANCE: {
VALUE.CORROSIVE: SupportedBinarySensorValueParameters(),
VALUE.SCALING: SupportedBinarySensorValueParameters(),
},
},
DEVICE.SCG: {
GROUP.SENSOR: {
VALUE.STATE: SupportedBinarySensorValueParameters(),
},
},
}
)
SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM}
SUPPORTED_CONFIG_BINARY_SENSORS = (
"freeze_mode",
"pool_delay",
"spa_delay",
"cleaner_delay",
)
async def async_setup_entry(
hass: HomeAssistant,
@ -30,132 +99,92 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
entities: list[ScreenLogicBinarySensorEntity] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][
entities: list[ScreenLogicBinarySensor] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id
]
gateway_data = coordinator.gateway_data
config = gateway_data[SL_DATA.KEY_CONFIG]
gateway = coordinator.gateway
data_path: ScreenLogicDataPath
value_params: SupportedBinarySensorValueParameters
for data_path, value_params in iterate_expand_group_wildcard(
gateway, SUPPORTED_DATA
):
entity_key = generate_unique_id(*data_path)
# Generic binary sensor
entities.append(
ScreenLogicStatusBinarySensor(coordinator, "chem_alarm", CODE.STATUS_CHANGED)
)
device = data_path[0]
entities.extend(
[
ScreenlogicConfigBinarySensor(coordinator, cfg_sensor, CODE.STATUS_CHANGED)
for cfg_sensor in config
if cfg_sensor in SUPPORTED_CONFIG_BINARY_SENSORS
]
)
if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test(
gateway, data_path
):
cleanup_excluded_entity(coordinator, DOMAIN, entity_key)
continue
if config["equipment_flags"] & EQUIPMENT.FLAG_INTELLICHEM:
chemistry = gateway_data[SL_DATA.KEY_CHEMISTRY]
# IntelliChem alarm sensors
entities.extend(
[
ScreenlogicChemistryAlarmBinarySensor(
coordinator, chem_alarm, CODE.CHEMISTRY_CHANGED
try:
value_data = gateway.get_data(*data_path, strict=True)
except KeyError:
_LOGGER.debug("Failed to find %s", data_path)
continue
entity_description_kwargs = {
**build_base_entity_description(
gateway, entity_key, data_path, value_data, value_params
),
"device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(
value_data.get(ATTR.DEVICE_TYPE)
),
}
if (
sub_code := (
value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device)
)
) is not None:
entities.append(
ScreenLogicPushBinarySensor(
coordinator,
ScreenLogicPushBinarySensorDescription(
subscription_code=sub_code, **entity_description_kwargs
),
)
for chem_alarm in chemistry[SL_DATA.KEY_ALERTS]
if not chem_alarm.startswith("_")
]
)
# Intellichem notification sensors
entities.extend(
[
ScreenlogicChemistryNotificationBinarySensor(
coordinator, chem_notif, CODE.CHEMISTRY_CHANGED
)
else:
entities.append(
ScreenLogicBinarySensor(
coordinator,
ScreenLogicBinarySensorDescription(**entity_description_kwargs),
)
for chem_notif in chemistry[SL_DATA.KEY_NOTIFICATIONS]
if not chem_notif.startswith("_")
]
)
if config["equipment_flags"] & EQUIPMENT.FLAG_CHLORINATOR:
# SCG binary sensor
entities.append(ScreenlogicSCGBinarySensor(coordinator, "scg_status"))
)
async_add_entities(entities)
class ScreenLogicBinarySensorEntity(ScreenlogicEntity, BinarySensorEntity):
@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."""
entity_description: ScreenLogicBinarySensorDescription
_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
def is_on(self) -> bool:
"""Determine if the sensor is on."""
return self.sensor["value"] == ON_OFF.ON
@property
def sensor(self) -> dict:
"""Shortcut to access the sensor data."""
return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key]
return self.entity_data[ATTR.VALUE] == ON_OFF.ON
class ScreenLogicStatusBinarySensor(
ScreenLogicBinarySensorEntity, ScreenLogicPushEntity
@dataclass
class ScreenLogicPushBinarySensorDescription(
ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription
):
"""Describes a ScreenLogicPushBinarySensor."""
class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor):
"""Representation of a basic ScreenLogic sensor entity."""
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]
entity_description: ScreenLogicPushBinarySensorDescription

View file

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

View file

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

View file

@ -1,25 +1,48 @@
"""Constants for the ScreenLogic integration."""
from screenlogicpy.const import CIRCUIT_FUNCTION, COLOR_MODE
from screenlogicpy.const.common import UNIT
from screenlogicpy.device_const.circuit import FUNCTION
from screenlogicpy.device_const.system import COLOR_MODE
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
UnitOfElectricPotential,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.util import slugify
ScreenLogicDataPath = tuple[str | int, ...]
DOMAIN = "screenlogic"
DEFAULT_SCAN_INTERVAL = 30
MIN_SCAN_INTERVAL = 10
SERVICE_SET_COLOR_MODE = "set_color_mode"
ATTR_COLOR_MODE = "color_mode"
SUPPORTED_COLOR_MODES = {
slugify(name): num for num, name in COLOR_MODE.NAME_FOR_NUM.items()
}
SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE}
LIGHT_CIRCUIT_FUNCTIONS = {
CIRCUIT_FUNCTION.COLOR_WHEEL,
CIRCUIT_FUNCTION.DIMMER,
CIRCUIT_FUNCTION.INTELLIBRITE,
CIRCUIT_FUNCTION.LIGHT,
CIRCUIT_FUNCTION.MAGICSTREAM,
CIRCUIT_FUNCTION.PHOTONGEN,
CIRCUIT_FUNCTION.SAL_LIGHT,
CIRCUIT_FUNCTION.SAM_LIGHT,
FUNCTION.COLOR_WHEEL,
FUNCTION.DIMMER,
FUNCTION.INTELLIBRITE,
FUNCTION.LIGHT,
FUNCTION.MAGICSTREAM,
FUNCTION.PHOTONGEN,
FUNCTION.SAL_LIGHT,
FUNCTION.SAM_LIGHT,
}
SL_UNIT_TO_HA_UNIT = {
UNIT.CELSIUS: UnitOfTemperature.CELSIUS,
UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT,
UNIT.WATT: UnitOfPower.WATT,
UNIT.HOUR: UnitOfTime.HOURS,
UNIT.SECOND: UnitOfTime.SECONDS,
UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE,
UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION,
UNIT.PERCENT: PERCENTAGE,
}

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.core import HomeAssistant
from . import ScreenlogicDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import ScreenlogicDataUpdateCoordinator
async def async_get_config_entry_diagnostics(

View file

@ -1,52 +1,65 @@
"""Base ScreenLogicEntity definitions."""
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import Any
from screenlogicpy import ScreenLogicGateway
from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF
from screenlogicpy.const.common import ON_OFF
from screenlogicpy.const.data import ATTR
from screenlogicpy.const.msg import CODE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ScreenlogicDataUpdateCoordinator
from .const import ScreenLogicDataPath
from .coordinator import ScreenlogicDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass
class ScreenLogicEntityRequiredKeyMixin:
"""Mixin for required ScreenLogic entity key."""
data_path: ScreenLogicDataPath
@dataclass
class ScreenLogicEntityDescription(
EntityDescription, ScreenLogicEntityRequiredKeyMixin
):
"""Base class for a ScreenLogic entity description."""
class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]):
"""Base class for all ScreenLogic entities."""
entity_description: ScreenLogicEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: ScreenlogicDataUpdateCoordinator,
data_key: str,
enabled: bool = True,
entity_description: ScreenLogicEntityDescription,
) -> None:
"""Initialize of the entity."""
super().__init__(coordinator)
self._data_key = data_key
self._attr_entity_registry_enabled_default = enabled
self._attr_unique_id = f"{self.mac}_{self._data_key}"
controller_type = self.config_data["controller_type"]
hardware_type = self.config_data["hardware_type"]
try:
equipment_model = EQUIPMENT.CONTROLLER_HARDWARE[controller_type][
hardware_type
]
except KeyError:
equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}"
self.entity_description = entity_description
self._data_path = self.entity_description.data_path
self._data_key = self._data_path[-1]
self._attr_unique_id = f"{self.mac}_{self.entity_description.key}"
mac = self.mac
assert mac is not None
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
manufacturer="Pentair",
model=equipment_model,
name=self.gateway_name,
model=self.gateway.controller_model,
name=self.gateway.name,
sw_version=self.gateway.version,
)
@ -56,26 +69,11 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]):
assert self.coordinator.config_entry is not None
return self.coordinator.config_entry.unique_id
@property
def config_data(self) -> dict[str | int, Any]:
"""Shortcut for config data."""
return self.gateway_data[SL_DATA.KEY_CONFIG]
@property
def gateway(self) -> ScreenLogicGateway:
"""Return the gateway."""
return self.coordinator.gateway
@property
def gateway_data(self) -> dict[str | int, Any]:
"""Return the gateway data."""
return self.gateway.get_data()
@property
def gateway_name(self) -> str:
"""Return the configured name of the gateway."""
return self.gateway.name
async def _async_refresh(self) -> None:
"""Refresh the data from the gateway."""
await self.coordinator.async_refresh()
@ -87,20 +85,41 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]):
"""Refresh from a timed called."""
await self.coordinator.async_request_refresh()
@property
def entity_data(self) -> dict:
"""Shortcut to the data for this entity."""
if (data := self.gateway.get_data(*self._data_path)) is None:
raise KeyError(f"Data not found: {self._data_path}")
return data
@dataclass
class ScreenLogicPushEntityRequiredKeyMixin:
"""Mixin for required key for ScreenLogic push entities."""
subscription_code: CODE
@dataclass
class ScreenLogicPushEntityDescription(
ScreenLogicEntityDescription,
ScreenLogicPushEntityRequiredKeyMixin,
):
"""Base class for a ScreenLogic push entity description."""
class ScreenLogicPushEntity(ScreenlogicEntity):
"""Base class for all ScreenLogic push entities."""
entity_description: ScreenLogicPushEntityDescription
def __init__(
self,
coordinator: ScreenlogicDataUpdateCoordinator,
data_key: str,
message_code: CODE,
enabled: bool = True,
entity_description: ScreenLogicPushEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, data_key, enabled)
self._update_message_code = message_code
"""Initialize of the entity."""
super().__init__(coordinator, entity_description)
self._last_update_success = True
@callback
@ -114,7 +133,8 @@ class ScreenLogicPushEntity(ScreenlogicEntity):
await super().async_added_to_hass()
self.async_on_remove(
await self.gateway.async_subscribe_client(
self._async_data_updated, self._update_message_code
self._async_data_updated,
self.entity_description.subscription_code,
)
)
@ -129,17 +149,10 @@ class ScreenLogicPushEntity(ScreenlogicEntity):
class ScreenLogicCircuitEntity(ScreenLogicPushEntity):
"""Base class for all ScreenLogic switch and light entities."""
_attr_has_entity_name = True
@property
def name(self) -> str:
"""Get the name of the switch."""
return self.circuit["name"]
@property
def is_on(self) -> bool:
"""Get whether the switch is in on state."""
return self.circuit["value"] == ON_OFF.ON
return self.entity_data[ATTR.VALUE] == ON_OFF.ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Send the ON command."""
@ -149,14 +162,9 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity):
"""Send the OFF command."""
await self._async_set_circuit(ON_OFF.OFF)
async def _async_set_circuit(self, circuit_value: int) -> None:
if not await self.gateway.async_set_circuit(self._data_key, circuit_value):
async def _async_set_circuit(self, state: ON_OFF) -> None:
if not await self.gateway.async_set_circuit(self._data_key, state.value):
raise HomeAssistantError(
f"Failed to set_circuit {self._data_key} {circuit_value}"
f"Failed to set_circuit {self._data_key} {state.value}"
)
_LOGGER.debug("Turn %s %s", self._data_key, circuit_value)
@property
def circuit(self) -> dict[str | int, Any]:
"""Shortcut to access the circuit."""
return self.gateway_data[SL_DATA.KEY_CIRCUITS][self._data_key]
_LOGGER.debug("Set circuit %s %s", self._data_key, state.value)

View file

@ -1,16 +1,23 @@
"""Support for a ScreenLogic light 'circuit' switch."""
from dataclasses import dataclass
import logging
from screenlogicpy.const import CODE, DATA as SL_DATA, GENERIC_CIRCUIT_NAMES
from screenlogicpy.const.data import ATTR, DEVICE
from screenlogicpy.const.msg import CODE
from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.light import (
ColorMode,
LightEntity,
LightEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator
from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS
from .entity import ScreenLogicCircuitEntity
from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS
from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription
_LOGGER = logging.getLogger(__name__)
@ -21,26 +28,45 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][
entities: list[ScreenLogicLight] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id
]
circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS]
async_add_entities(
[
gateway = coordinator.gateway
for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items():
if circuit_data[ATTR.FUNCTION] not in LIGHT_CIRCUIT_FUNCTIONS:
continue
circuit_name = circuit_data[ATTR.NAME]
circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE])
entities.append(
ScreenLogicLight(
coordinator,
circuit_num,
CODE.STATUS_CHANGED,
circuit["name"] not in GENERIC_CIRCUIT_NAMES,
ScreenLogicLightDescription(
subscription_code=CODE.STATUS_CHANGED,
data_path=(DEVICE.CIRCUIT, circuit_index),
key=circuit_index,
name=circuit_name,
entity_registry_enabled_default=(
circuit_name not in GENERIC_CIRCUIT_NAMES
and circuit_interface != INTERFACE.DONT_SHOW
),
),
)
for circuit_num, circuit in circuits.items()
if circuit["function"] in LIGHT_CIRCUIT_FUNCTIONS
]
)
)
async_add_entities(entities)
@dataclass
class ScreenLogicLightDescription(
LightEntityDescription, ScreenLogicPushEntityDescription
):
"""Describes a ScreenLogic light entity."""
class ScreenLogicLight(ScreenLogicCircuitEntity, LightEntity):
"""Class to represent a ScreenLogic Light."""
entity_description: ScreenLogicLightDescription
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}

View file

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

View file

@ -1,25 +1,82 @@
"""Support for a ScreenLogic number entity."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from screenlogicpy.const import BODY_TYPE, DATA as SL_DATA, EQUIPMENT, SCG
from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE
from homeassistant.components.number import NumberEntity
from homeassistant.components.number import (
DOMAIN,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator
from .const import DOMAIN
from .entity import ScreenlogicEntity
from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath
from .coordinator import ScreenlogicDataUpdateCoordinator
from .data import (
DEVICE_INCLUSION_RULES,
PathPart,
SupportedValueParameters,
build_base_entity_description,
get_ha_unit,
iterate_expand_group_wildcard,
preprocess_supported_values,
realize_path_template,
)
from .entity import ScreenlogicEntity, ScreenLogicEntityDescription
from .util import cleanup_excluded_entity, generate_unique_id
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
SUPPORTED_SCG_NUMBERS = (
"scg_level1",
"scg_level2",
@dataclass
class SupportedNumberValueParametersMixin:
"""Mixin for supported predefined data for a ScreenLogic number entity."""
set_value_config: tuple[str, tuple[tuple[PathPart | str | int, ...], ...]]
device_class: NumberDeviceClass | None = None
@dataclass
class SupportedNumberValueParameters(
SupportedValueParameters, SupportedNumberValueParametersMixin
):
"""Supported predefined data for a ScreenLogic number entity."""
SET_SCG_CONFIG_FUNC_DATA = (
"async_set_scg_config",
(
(DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT),
(DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT),
),
)
SUPPORTED_DATA: list[
tuple[ScreenLogicDataPath, SupportedValueParameters]
] = preprocess_supported_values(
{
DEVICE.SCG: {
GROUP.CONFIGURATION: {
VALUE.POOL_SETPOINT: SupportedNumberValueParameters(
entity_category=EntityCategory.CONFIG,
set_value_config=SET_SCG_CONFIG_FUNC_DATA,
),
VALUE.SPA_SETPOINT: SupportedNumberValueParameters(
entity_category=EntityCategory.CONFIG,
set_value_config=SET_SCG_CONFIG_FUNC_DATA,
),
}
}
}
)
@ -29,66 +86,113 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][
entities: list[ScreenLogicNumber] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id
]
equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"]
if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR:
async_add_entities(
[
ScreenLogicNumber(coordinator, scg_level)
for scg_level in coordinator.gateway_data[SL_DATA.KEY_SCG]
if scg_level in SUPPORTED_SCG_NUMBERS
]
gateway = coordinator.gateway
data_path: ScreenLogicDataPath
value_params: SupportedNumberValueParameters
for data_path, value_params in iterate_expand_group_wildcard(
gateway, SUPPORTED_DATA
):
entity_key = generate_unique_id(*data_path)
device = data_path[0]
if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test(
gateway, data_path
):
cleanup_excluded_entity(coordinator, DOMAIN, entity_key)
continue
try:
value_data = gateway.get_data(*data_path, strict=True)
except KeyError:
_LOGGER.debug("Failed to find %s", data_path)
continue
set_value_str, set_value_params = value_params.set_value_config
set_value_func = getattr(gateway, set_value_str)
entity_description_kwargs = {
**build_base_entity_description(
gateway, entity_key, data_path, value_data, value_params
),
"device_class": value_params.device_class,
"native_unit_of_measurement": get_ha_unit(value_data),
"native_max_value": value_data.get(ATTR.MAX_SETPOINT),
"native_min_value": value_data.get(ATTR.MIN_SETPOINT),
"native_step": value_data.get(ATTR.STEP),
"set_value": set_value_func,
"set_value_params": set_value_params,
}
entities.append(
ScreenLogicNumber(
coordinator,
ScreenLogicNumberDescription(**entity_description_kwargs),
)
)
async_add_entities(entities)
@dataclass
class ScreenLogicNumberRequiredMixin:
"""Describes a required mixin for a ScreenLogic number entity."""
set_value: Callable[..., bool]
set_value_params: tuple[tuple[str | int, ...], ...]
@dataclass
class ScreenLogicNumberDescription(
NumberEntityDescription,
ScreenLogicEntityDescription,
ScreenLogicNumberRequiredMixin,
):
"""Describes a ScreenLogic number entity."""
class ScreenLogicNumber(ScreenlogicEntity, NumberEntity):
"""Class to represent a ScreenLogic Number."""
"""Class to represent a ScreenLogic Number entity."""
_attr_has_entity_name = True
entity_description: ScreenLogicNumberDescription
def __init__(self, coordinator, data_key, enabled=True):
"""Initialize of the entity."""
super().__init__(coordinator, data_key, enabled)
self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key)
self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type]
self._attr_name = self.sensor["name"]
self._attr_native_unit_of_measurement = self.sensor["unit"]
self._attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
coordinator: ScreenlogicDataUpdateCoordinator,
entity_description: ScreenLogicNumberDescription,
) -> None:
"""Initialize a ScreenLogic number entity."""
self._set_value_func = entity_description.set_value
self._set_value_params = entity_description.set_value_params
super().__init__(coordinator, entity_description)
@property
def native_value(self) -> float:
"""Return the current value."""
return self.sensor["value"]
return self.entity_data[ATTR.VALUE]
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
# Need to set both levels at the same time, so we gather
# both existing level values and override the one that changed.
levels = {}
for level in SUPPORTED_SCG_NUMBERS:
levels[level] = self.gateway_data[SL_DATA.KEY_SCG][level]["value"]
levels[self._data_key] = int(value)
if await self.coordinator.gateway.async_set_scg_config(
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]],
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]],
):
_LOGGER.debug(
"Set SCG to %i, %i",
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]],
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]],
# Current API requires certain values to be set at the same time. This
# gathers the existing values and updates the particular value being
# set by this entity.
args = {}
for data_path in self._set_value_params:
data_path = realize_path_template(data_path, self._data_path)
data_value = data_path[-1]
args[data_value] = self.coordinator.gateway.get_value(
*data_path, strict=True
)
args[self._data_key] = value
if self._set_value_func(*args.values()):
_LOGGER.debug("Set '%s' to %s", self._data_key, value)
await self._async_refresh()
else:
_LOGGER.warning(
"Failed to set_scg to %i, %i",
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]],
levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]],
)
@property
def sensor(self) -> dict:
"""Shortcut to access the level sensor data."""
return self.gateway_data[SL_DATA.KEY_SCG][self._data_key]
_LOGGER.debug("Failed to set '%s' to %s", self._data_key, value)

View file

@ -1,76 +1,148 @@
"""Support for a ScreenLogic Sensor."""
from typing import Any
from collections.abc import Callable
from dataclasses import dataclass
import logging
from screenlogicpy.const import (
CHEM_DOSING_STATE,
CODE,
DATA as SL_DATA,
DEVICE_TYPE,
EQUIPMENT,
STATE_TYPE,
UNIT,
)
from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE
from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE
from screenlogicpy.device_const.chemistry import DOSE_STATE
from screenlogicpy.device_const.pump import PUMP_TYPE
from screenlogicpy.device_const.system import EQUIPMENT_FLAG
from homeassistant.components.sensor import (
DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
UnitOfElectricPotential,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ScreenlogicDataUpdateCoordinator
from .const import DOMAIN
from .entity import ScreenlogicEntity, ScreenLogicPushEntity
SUPPORTED_BASIC_SENSORS = (
"air_temperature",
"saturation",
from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath
from .coordinator import ScreenlogicDataUpdateCoordinator
from .data import (
DEVICE_INCLUSION_RULES,
DEVICE_SUBSCRIPTION,
PathPart,
ScreenLogicDataRule,
ScreenLogicEquipmentRule,
SupportedValueParameters,
build_base_entity_description,
get_ha_unit,
iterate_expand_group_wildcard,
preprocess_supported_values,
)
SUPPORTED_BASIC_CHEM_SENSORS = (
"orp",
"ph",
from .entity import (
ScreenlogicEntity,
ScreenLogicEntityDescription,
ScreenLogicPushEntity,
ScreenLogicPushEntityDescription,
)
from .util import cleanup_excluded_entity, generate_unique_id
SUPPORTED_CHEM_SENSORS = (
"calcium_harness",
"current_orp",
"current_ph",
"cya",
"orp_dosing_state",
"orp_last_dose_time",
"orp_last_dose_volume",
"orp_setpoint",
"orp_supply_level",
"ph_dosing_state",
"ph_last_dose_time",
"ph_last_dose_volume",
"ph_probe_water_temp",
"ph_setpoint",
"ph_supply_level",
"salt_tds_ppm",
"total_alkalinity",
_LOGGER = logging.getLogger(__name__)
@dataclass
class SupportedSensorValueParameters(SupportedValueParameters):
"""Supported predefined data for a ScreenLogic sensor entity."""
device_class: SensorDeviceClass | None = None
value_modification: Callable[[int], int | str] | None = lambda val: val
SUPPORTED_DATA: list[
tuple[ScreenLogicDataPath, SupportedValueParameters]
] = preprocess_supported_values(
{
DEVICE.CONTROLLER: {
GROUP.SENSOR: {
VALUE.AIR_TEMPERATURE: SupportedSensorValueParameters(
device_class=SensorDeviceClass.TEMPERATURE, entity_category=None
),
VALUE.ORP: SupportedSensorValueParameters(
included=ScreenLogicEquipmentRule(
lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags
)
),
VALUE.PH: SupportedSensorValueParameters(
included=ScreenLogicEquipmentRule(
lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags
)
),
},
},
DEVICE.PUMP: {
"*": {
VALUE.WATTS_NOW: SupportedSensorValueParameters(),
VALUE.GPM_NOW: SupportedSensorValueParameters(
enabled=ScreenLogicDataRule(
lambda pump_data: pump_data[VALUE.TYPE]
!= PUMP_TYPE.INTELLIFLO_VS,
(PathPart.DEVICE, PathPart.INDEX),
)
),
VALUE.RPM_NOW: SupportedSensorValueParameters(
enabled=ScreenLogicDataRule(
lambda pump_data: pump_data[VALUE.TYPE]
!= PUMP_TYPE.INTELLIFLO_VF,
(PathPart.DEVICE, PathPart.INDEX),
)
),
},
},
DEVICE.INTELLICHEM: {
GROUP.SENSOR: {
VALUE.ORP_NOW: SupportedSensorValueParameters(),
VALUE.ORP_SUPPLY_LEVEL: SupportedSensorValueParameters(
value_modification=lambda val: val - 1
),
VALUE.PH_NOW: SupportedSensorValueParameters(),
VALUE.PH_PROBE_WATER_TEMP: SupportedSensorValueParameters(),
VALUE.PH_SUPPLY_LEVEL: SupportedSensorValueParameters(
value_modification=lambda val: val - 1
),
VALUE.SATURATION: SupportedSensorValueParameters(),
},
GROUP.CONFIGURATION: {
VALUE.CALCIUM_HARNESS: SupportedSensorValueParameters(),
VALUE.CYA: SupportedSensorValueParameters(),
VALUE.ORP_SETPOINT: SupportedSensorValueParameters(),
VALUE.PH_SETPOINT: SupportedSensorValueParameters(),
VALUE.SALT_TDS_PPM: SupportedSensorValueParameters(
included=ScreenLogicEquipmentRule(
lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags
and EQUIPMENT_FLAG.CHLORINATOR not in flags,
)
),
VALUE.TOTAL_ALKALINITY: SupportedSensorValueParameters(),
},
GROUP.DOSE_STATUS: {
VALUE.ORP_DOSING_STATE: SupportedSensorValueParameters(
value_modification=lambda val: DOSE_STATE(val).title,
),
VALUE.ORP_LAST_DOSE_TIME: SupportedSensorValueParameters(),
VALUE.ORP_LAST_DOSE_VOLUME: SupportedSensorValueParameters(),
VALUE.PH_DOSING_STATE: SupportedSensorValueParameters(
value_modification=lambda val: DOSE_STATE(val).title,
),
VALUE.PH_LAST_DOSE_TIME: SupportedSensorValueParameters(),
VALUE.PH_LAST_DOSE_VOLUME: SupportedSensorValueParameters(),
},
},
DEVICE.SCG: {
GROUP.SENSOR: {
VALUE.SALT_PPM: SupportedSensorValueParameters(),
},
GROUP.CONFIGURATION: {
VALUE.SUPER_CHLOR_TIMER: SupportedSensorValueParameters(),
},
},
}
)
SUPPORTED_SCG_SENSORS = (
"scg_salt_ppm",
"scg_super_chlor_timer",
)
SUPPORTED_PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM")
SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {
DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION,
DEVICE_TYPE.ENUM: SensorDeviceClass.ENUM,
@ -85,18 +157,6 @@ SL_STATE_TYPE_TO_HA_STATE_CLASS = {
STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING,
}
SL_UNIT_TO_HA_UNIT = {
UNIT.CELSIUS: UnitOfTemperature.CELSIUS,
UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT,
UNIT.WATT: UnitOfPower.WATT,
UNIT.HOUR: UnitOfTime.HOURS,
UNIT.SECOND: UnitOfTime.SECONDS,
UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE,
UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION,
UNIT.PERCENT: PERCENTAGE,
}
async def async_setup_entry(
hass: HomeAssistant,
@ -104,171 +164,110 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
entities: list[ScreenLogicSensorEntity] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][
entities: list[ScreenLogicSensor] = []
coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][
config_entry.entry_id
]
equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"]
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
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)
)
device = data_path[0]
# While these values exist in the chemistry data, their last value doesn't
# persist there when the pump is off/there is no flow. Pulling them from
# the basic sensors keeps the 'last' value and is better for graphs.
if (
equipment_flags & EQUIPMENT.FLAG_INTELLICHEM
and sensor_name in SUPPORTED_BASIC_CHEM_SENSORS
if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test(
gateway, data_path
):
entities.append(
ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED)
cleanup_excluded_entity(coordinator, DOMAIN, entity_key)
continue
try:
value_data = gateway.get_data(*data_path, strict=True)
except KeyError:
_LOGGER.debug("Failed to find %s", data_path)
continue
entity_description_kwargs = {
**build_base_entity_description(
gateway, entity_key, data_path, value_data, value_params
),
"device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(
value_data.get(ATTR.DEVICE_TYPE)
),
"native_unit_of_measurement": get_ha_unit(value_data),
"options": value_data.get(ATTR.ENUM_OPTIONS),
"state_class": SL_STATE_TYPE_TO_HA_STATE_CLASS.get(
value_data.get(ATTR.STATE_TYPE)
),
"value_mod": value_params.value_modification,
}
if (
sub_code := (
value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device)
)
# Pump sensors
for pump_num, pump_data in coordinator.gateway_data[SL_DATA.KEY_PUMPS].items():
if pump_data["data"] != 0 and "currentWatts" in pump_data:
for pump_key in pump_data:
enabled = True
# Assumptions for Intelliflow VF
if pump_data["pumpType"] == 1 and pump_key == "currentRPM":
enabled = False
# Assumptions for Intelliflow VS
if pump_data["pumpType"] == 2 and pump_key == "currentGPM":
enabled = False
if pump_key in SUPPORTED_PUMP_SENSORS:
entities.append(
ScreenLogicPumpSensor(coordinator, pump_num, pump_key, enabled)
)
# IntelliChem sensors
if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM:
for chem_sensor_name in coordinator.gateway_data[SL_DATA.KEY_CHEMISTRY]:
enabled = True
if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR:
if chem_sensor_name in ("salt_tds_ppm",):
enabled = False
if chem_sensor_name in SUPPORTED_CHEM_SENSORS:
entities.append(
ScreenLogicChemistrySensor(
coordinator, chem_sensor_name, CODE.CHEMISTRY_CHANGED, enabled
)
) is not None:
entities.append(
ScreenLogicPushSensor(
coordinator,
ScreenLogicPushSensorDescription(
subscription_code=sub_code,
**entity_description_kwargs,
),
)
# SCG sensors
if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR:
entities.extend(
[
ScreenLogicSCGSensor(coordinator, scg_sensor)
for scg_sensor in coordinator.gateway_data[SL_DATA.KEY_SCG]
if scg_sensor in SUPPORTED_SCG_SENSORS
]
)
)
else:
entities.append(
ScreenLogicSensor(
coordinator,
ScreenLogicSensorDescription(
**entity_description_kwargs,
),
)
)
async_add_entities(entities)
class ScreenLogicSensorEntity(ScreenlogicEntity, SensorEntity):
"""Base class for all ScreenLogic sensor entities."""
@dataclass
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
@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
def native_value(self) -> str | int | float:
"""State of the sensor."""
return self.sensor["value"]
@property
def sensor(self) -> dict[str | int, Any]:
"""Shortcut to access the sensor data."""
return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key]
val = self.entity_data[ATTR.VALUE]
value_mod = self.entity_description.value_mod
return value_mod(val) if value_mod else val
class ScreenLogicStatusSensor(ScreenLogicSensorEntity, ScreenLogicPushEntity):
"""Representation of a basic ScreenLogic sensor entity."""
@dataclass
class ScreenLogicPushSensorDescription(
ScreenLogicSensorDescription, ScreenLogicPushEntityDescription
):
"""Describes a ScreenLogic push sensor."""
class ScreenLogicPumpSensor(ScreenLogicSensorEntity):
"""Representation of a ScreenLogic pump sensor entity."""
class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity):
"""Representation of a ScreenLogic push sensor entity."""
def __init__(self, coordinator, pump, key, enabled=True):
"""Initialize of the pump sensor."""
super().__init__(coordinator, f"{key}_{pump}", enabled)
self._pump_id = pump
self._key = key
@property
def sensor(self) -> dict[str | int, Any]:
"""Shortcut to access the pump sensor data."""
return self.gateway_data[SL_DATA.KEY_PUMPS][self._pump_id][self._key]
class ScreenLogicChemistrySensor(ScreenLogicSensorEntity, ScreenLogicPushEntity):
"""Representation of a ScreenLogic IntelliChem sensor entity."""
def __init__(self, coordinator, key, message_code, enabled=True):
"""Initialize of the pump sensor."""
super().__init__(coordinator, f"chem_{key}", message_code, enabled)
self._key = key
@property
def native_value(self) -> str | int | float:
"""State of the sensor."""
value = self.sensor["value"]
if "dosing_state" in self._key:
return CHEM_DOSING_STATE.NAME_FOR_NUM[value]
return (value - 1) if "supply" in self._data_key else value
@property
def sensor(self) -> dict[str | int, Any]:
"""Shortcut to access the pump sensor data."""
return self.gateway_data[SL_DATA.KEY_CHEMISTRY][self._key]
class ScreenLogicSCGSensor(ScreenLogicSensorEntity):
"""Representation of ScreenLogic SCG sensor entity."""
@property
def sensor(self) -> dict[str | int, Any]:
"""Shortcut to access the pump sensor data."""
return self.gateway_data[SL_DATA.KEY_SCG][self._data_key]
entity_description: ScreenLogicPushSensorDescription

View file

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

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
# homeassistant.components.screenlogic
screenlogicpy==0.8.2
screenlogicpy==0.9.0
# homeassistant.components.scsgate
scsgate==0.1.0

View file

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

View file

@ -1 +1,67 @@
"""Tests for the Screenlogic integration."""
from collections.abc import Callable
import logging
from tests.common import load_json_object_fixture
MOCK_ADAPTER_NAME = "Pentair DD-EE-FF"
MOCK_ADAPTER_MAC = "aa:bb:cc:dd:ee:ff"
MOCK_ADAPTER_IP = "127.0.0.1"
MOCK_ADAPTER_PORT = 80
_LOGGER = logging.getLogger(__name__)
GATEWAY_DISCOVERY_IMPORT_PATH = "homeassistant.components.screenlogic.coordinator.async_discover_gateways_by_unique_id"
def num_key_string_to_int(data: dict) -> None:
"""Convert all string number dict keys to integer.
This needed for screenlogicpy's data dict format.
"""
rpl = []
for key, value in data.items():
if isinstance(value, dict):
num_key_string_to_int(value)
if isinstance(key, str) and key.isnumeric():
rpl.append(key)
for k in rpl:
data[int(k)] = data.pop(k)
return data
DATA_FULL_CHEM = num_key_string_to_int(
load_json_object_fixture("screenlogic/data_full_chem.json")
)
DATA_MIN_MIGRATION = num_key_string_to_int(
load_json_object_fixture("screenlogic/data_min_migration.json")
)
DATA_MIN_ENTITY_CLEANUP = num_key_string_to_int(
load_json_object_fixture("screenlogic/data_min_entity_cleanup.json")
)
async def stub_async_connect(
data,
self,
ip=None,
port=None,
gtype=None,
gsubtype=None,
name=MOCK_ADAPTER_NAME,
connection_closed_callback: Callable = None,
) -> bool:
"""Initialize minimum attributes needed for tests."""
self._ip = ip
self._port = port
self._type = gtype
self._subtype = gsubtype
self._name = name
self._custom_connection_closed_callback = connection_closed_callback
self._mac = MOCK_ADAPTER_MAC
self._data = data
_LOGGER.debug("Gateway mock connected")
return True

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 screenlogicpy import ScreenLogicError
from screenlogicpy.const import (
from screenlogicpy.const.common import (
SL_GATEWAY_IP,
SL_GATEWAY_NAME,
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