From c48b3024f9eb9e5e5d2d0102e0129e29ed38e0a3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Vaillant Date: Mon, 28 Dec 2020 15:18:26 -0800 Subject: [PATCH] Significant refactoring Changed the boot sequence of the ModelController. Added ability to fine tune the objects/attributes of interest. Added support for Light effects on Light Groups. --- .pre-commit-config.yaml | 2 +- custom_components/intellicenter/__init__.py | 114 ++++-- .../intellicenter/binary_sensor.py | 4 +- custom_components/intellicenter/light.py | 87 +++-- .../intellicenter/pyintellicenter/__init__.py | 3 +- .../pyintellicenter/attributes.py | 346 ++++++++++++++++++ .../pyintellicenter/controller.py | 116 +++--- .../intellicenter/pyintellicenter/model.py | 219 +++++------ custom_components/intellicenter/sensor.py | 37 +- custom_components/intellicenter/switch.py | 18 +- .../intellicenter/water_heater.py | 26 +- 11 files changed, 691 insertions(+), 281 deletions(-) create mode 100644 custom_components/intellicenter/pyintellicenter/attributes.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb7bb17..a470af3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: - id: black args: - --safe - - --quiet + # - --quiet - repo: https://github.com/codespell-project/codespell rev: v1.17.1 hooks: diff --git a/custom_components/intellicenter/__init__.py b/custom_components/intellicenter/__init__.py index b2469eb..bbce969 100644 --- a/custom_components/intellicenter/__init__.py +++ b/custom_components/intellicenter/__init__.py @@ -9,7 +9,12 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + EVENT_HOMEASSISTANT_STOP, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import dispatcher @@ -17,7 +22,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .pyintellicenter import ConnectionHandler, ModelController, PoolModel +from .pyintellicenter import ConnectionHandler, ModelController, PoolModel, PoolObject _LOGGER = logging.getLogger(__name__) @@ -39,24 +44,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IntelliCenter integration from a config entry.""" - # we are only interested in a subset of all objects - def filterFunc(object): - """Return true for the objects we care about.""" - return object.status and object.objtype in [ - "BODY", - "SENSE", - "PUMP", - "HEATER", - "CIRCUIT", - ] + # we don't need some of the system objects + def ignoreFunc(object): + """Return False for the objects we want to ignore.""" + + return ( + object.objtype + in [ + "PANEL", + "MODULE", + "PERMIT", + "SYSTIM", + ] + or object.subtype in ["LEGACY"] + ) - model = PoolModel(filterFunc) + attributes_map = { + "BODY": {"SNAME", "HEATER", "HTMODE", "LOTMP", "LSTTMP", "STATUS"}, + "CIRCUIT": {"SNAME", "STATUS", "USE", "SUBTYPE", "FEATR"}, + "CIRCGRP": {"CIRCUIT"}, + "HEATER": {"SNAME", "BODY"}, + "PUMP": {"SNAME", "STATUS", "PWR", "RPM", "GPM"}, + "SENSE": {"SNAME", "SOURCE"}, + } + model = PoolModel(attributes_map) controller = ModelController(entry.data[CONF_HOST], model, loop=hass.loop) class Handler(ConnectionHandler): UPDATE_SIGNAL = DOMAIN + "_UPDATE_" + entry.entry_id + CONNECTION_SIGNAL = DOMAIN + "_CONNECTION_" + entry.entry_id def started(self, controller): @@ -74,15 +92,15 @@ async def setup_platforms(): ] ) + # dispatcher.async_dispatcher_send(hass, self.CONNECTION_SIGNAL, True) + hass.async_create_task(setup_platforms()) @callback def reconnected(self, controller): """Handle reconnection from the Pentair system.""" _LOGGER.info(f"reconnected to system: '{controller.systemInfo.propName}'") - dispatcher.async_dispatcher_send( - hass, self.UPDATE_SIGNAL, controller.model.objectList - ) + dispatcher.async_dispatcher_send(hass, self.CONNECTION_SIGNAL, True) @callback def disconnected(self, controller, exc): @@ -90,16 +108,13 @@ def disconnected(self, controller, exc): _LOGGER.info( f"disconnected from system: '{controller.systemInfo.propName}'" ) - dispatcher.async_dispatcher_send( - hass, DOMAIN + "_DISCONNECT_" + entry.entry_id - ) + dispatcher.async_dispatcher_send(hass, self.CONNECTION_SIGNAL, False) @callback - def updated(self, controller, changes): + def updated(self, controller, updates: Dict[str, PoolObject]): """Handle updates from the Pentair system.""" - for object in changes: - _LOGGER.debug(f"received update for {object}") - dispatcher.async_dispatcher_send(hass, self.UPDATE_SIGNAL, changes) + _LOGGER.debug(f"received update for {len(updates)} pool objects") + dispatcher.async_dispatcher_send(hass, self.UPDATE_SIGNAL, updates) try: @@ -155,13 +170,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class PoolEntity(Entity): """Representation of an Pool entity linked to an pool object.""" - def __init__(self, entry: ConfigEntry, controller, poolObject): + def __init__( + self, + entry: ConfigEntry, + controller: ModelController, + poolObject: PoolObject, + attribute_key="STATUS", + name_suffix="", + ): """Initialize a Pool entity.""" self._entry_id = entry.entry_id self._controller = controller self._poolObject = poolObject self._available = True self._extraStateAttributes = [] + self._name_suffix = name_suffix + self._attribute_key = attribute_key _LOGGER.debug(f"mapping {poolObject}") @@ -176,8 +200,8 @@ async def async_added_to_hass(self): self.async_on_remove( dispatcher.async_dispatcher_connect( self.hass, - DOMAIN + "_DISCONNECT_" + self._entry_id, - self._disconnect_callback, + DOMAIN + "_CONNECTION_" + self._entry_id, + self._connection_callback, ) ) @@ -193,12 +217,18 @@ def available(self): @property def name(self): """Return the name of the entity.""" - return self._poolObject.sname + name = self._poolObject.sname + if self._name_suffix: + name += " " + self._name_suffix + return name @property def unique_id(self): """Return a unique ID.""" - return self._entry_id + self._poolObject.objnam + my_id = self._entry_id + self._poolObject.objnam + if self._attribute_key != "STATUS": + my_id += self._attribute_key + return my_id @property def should_poll(self): @@ -250,15 +280,29 @@ def requestChanges(self, changes: dict) -> None: ) @callback - def _update_callback(self, changes): + def _update_callback(self, updates: Dict[str, Dict[str, str]]): """Update the entity if its underlying pool object has changed.""" - for object in changes: - if object.objnam == self._poolObject.objnam: - self._available = True - self.async_write_ha_state() + + if self._attribute_key in updates.get(self._poolObject.objnam, {}): + self._available = True + my_updates = updates.get(self._poolObject.objnam) + _LOGGER.debug(f"updating {self} from {my_updates}") + self.async_write_ha_state() @callback - def _disconnect_callback(self): + def _connection_callback(self, is_connected): """Mark the entity as unavailable after being disconnected from the server.""" - self._available = False + if is_connected: + self._poolObject = self._controller.model[self._poolObject.objnam] + if not self._poolObject: + # this is for the rare case where the object the entity is mapped to + # had been removed from the Pentair system while we were disconnected + return + self._available = is_connected self.async_write_ha_state() + + def pentairTemperatureSettings(self): + """Return the temperature units from the Pentair system.""" + return ( + TEMP_CELSIUS if self._controller.systemInfo.usesMetric else TEMP_FAHRENHEIT + ) diff --git a/custom_components/intellicenter/binary_sensor.py b/custom_components/intellicenter/binary_sensor.py index 3123525..ccbf91d 100644 --- a/custom_components/intellicenter/binary_sensor.py +++ b/custom_components/intellicenter/binary_sensor.py @@ -8,6 +8,7 @@ from . import PoolEntity from .const import DOMAIN +from .pyintellicenter import ModelController, PoolObject _LOGGER = logging.getLogger(__name__) @@ -17,10 +18,11 @@ async def async_setup_entry( ): """Load pool sensors based on a config entry.""" - controller = hass.data[DOMAIN][entry.entry_id].controller + controller: ModelController = hass.data[DOMAIN][entry.entry_id].controller sensors = [] + object: PoolObject for object in controller.model.objectList: if ( object.objtype == "CIRCUIT" and object.subtype == "FRZ" diff --git a/custom_components/intellicenter/light.py b/custom_components/intellicenter/light.py index b3e854f..18c9e00 100644 --- a/custom_components/intellicenter/light.py +++ b/custom_components/intellicenter/light.py @@ -1,10 +1,12 @@ """Pentair Intellicenter lights.""" +from functools import reduce import logging -from typing import Any +from typing import Any, Dict from homeassistant.components.light import ATTR_EFFECT, SUPPORT_EFFECT, LightEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback from homeassistant.helpers.typing import HomeAssistantType from . import PoolEntity @@ -13,44 +15,72 @@ _LOGGER = logging.getLogger(__name__) +LIGHTS_EFFECTS = { + "PARTY": "Party Mode", + "CARIB": "Caribbean", + "SSET": "Sunset", + "ROMAN": "Romance", + "AMERCA": "American", + "ROYAL": "Royal", + "WHITER": "White", + "REDR": "Red", + "BLUER": "Blue", + "GREENR": "Green", + "MAGNTAR": "Magenta", +} + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ): """Load pool lights based on a config entry.""" - controller = hass.data[DOMAIN][entry.entry_id].controller + controller: ModelController = hass.data[DOMAIN][entry.entry_id].controller lights = [] + object: PoolObject for object in controller.model.objectList: if object.isALight: - lights.append(PoolLight(entry, controller, object)) - async_add_entities(lights) - + lights.append( + PoolLight( + entry, + controller, + object, + LIGHTS_EFFECTS if object.supportColorEffects else None, + ) + ) + elif object.isALightShow: + + supportColorEffects = reduce( + lambda x, y: x and y, + map( + lambda obj: controller.model[obj["CIRCUIT"]].supportColorEffects, + controller.model.getChildren(object), + ), + True, + ) + lights.append( + PoolLight( + entry, + controller, + object, + LIGHTS_EFFECTS if supportColorEffects else None, + ) + ) -LIGHTS_EFFECTS_BY_TYPE = { - "INTELLI": { - "PARTY": "Party Mode", - "CARIB": "Caribbean", - "SSET": "Sunset", - "ROMAN": "Romance", - "AMERCA": "American", - "ROYAL": "Royal", - "WHITER": "White", - "REDR": "Red", - "BLUER": "Blue", - "GREENR": "Green", - "MAGNTAR": "Magenta", - } -} + async_add_entities(lights) class PoolLight(PoolEntity, LightEntity): """Representation of an Pentair light.""" def __init__( - self, entry: ConfigEntry, controller: ModelController, poolObject: PoolObject + self, + entry: ConfigEntry, + controller: ModelController, + poolObject: PoolObject, + colorEffects: dict = None, ): """Initialize.""" super().__init__(entry, controller, poolObject) @@ -59,8 +89,10 @@ def __init__( self._features = 0 - self._lightEffects = LIGHTS_EFFECTS_BY_TYPE.get(poolObject.subtype, {}) - self._reversedLightEffects = dict(map(reversed, self._lightEffects.items())) + self._lightEffects = colorEffects + self._reversedLightEffects = ( + dict(map(reversed, colorEffects.items())) if colorEffects else None + ) if self._lightEffects: self._features |= SUPPORT_EFFECT @@ -101,3 +133,12 @@ def turn_on(self, **kwargs: Any) -> None: changes["ACT"] = new_use self.requestChanges(changes) + + @callback + def _update_callback(self, updates: Dict[str, PoolObject]): + """Update the entity if its underlying pool object has changed.""" + + if self._poolObject.objnam in updates: + self._available = True + _LOGGER.debug(f"updating {self} from {self._poolObject}") + self.async_write_ha_state() diff --git a/custom_components/intellicenter/pyintellicenter/__init__.py b/custom_components/intellicenter/pyintellicenter/__init__.py index 01cba51..1cbd1b2 100644 --- a/custom_components/intellicenter/pyintellicenter/__init__.py +++ b/custom_components/intellicenter/pyintellicenter/__init__.py @@ -7,7 +7,7 @@ ModelController, SystemInfo, ) -from .model import ALL_KNOWN_ATTRIBUTES, PoolModel, PoolObject +from .model import PoolModel, PoolObject __all__ = [ BaseController, @@ -15,7 +15,6 @@ ConnectionHandler, ModelController, SystemInfo, - ALL_KNOWN_ATTRIBUTES, PoolModel, PoolObject, ] diff --git a/custom_components/intellicenter/pyintellicenter/attributes.py b/custom_components/intellicenter/pyintellicenter/attributes.py new file mode 100644 index 0000000..a812e43 --- /dev/null +++ b/custom_components/intellicenter/pyintellicenter/attributes.py @@ -0,0 +1,346 @@ +"""Definition of all the attributes per OBJTYP.""" + +USER_PRIVILEGES = { + "p": "Pool Access", + "P": "Pool temperature", + "h": "Pool Heat Mode", + "m": "Spa Access", + "S": "Spa Temperature", + "n": "Spa Heat Mode", + "e": "Schedule Access", + "v": "Vacation Mode", + "f": "Features Access", + "l": "Lights Access", + "c": "Chemistry Access", + "u": "Usage Access", + "C": "System Configuration", + "o": "Support", + "q": "Alerts and Notifications", + "i": "User Portal", + "k": "Groups", + "a": "Advanced Settings", + "t": "Status", + "x": "Service Mode Circuits", + "g": "General Settings", +} + +# represents a body of water (pool or spa) +BODY_ATTRIBUTES = { + "ACT1", # (int) ??? + "ACT2", # (int) ??? + "ACT3", # (int) ??? + "ACT4", # (int) ??? + "HEATER", # (objnam) + "HITMP", # (int) maximum temperature to set + "HNAME", # equals to OBJNAM + "HTMODE", # (int) 1 if currently heating, 0 if not + "HTSRC", # (objnam) the heating source (or '00000') + "LISTORD", # (int) used to order in UI + "LOTMP", # (int) desired temperature + "LSTTMP", # (int) last recorded temperature + "MANHT", # Manual heating ??? + "MANUAL", # (int) ??? + "PARENT", # (objnam) parent object + "PRIM", # (int) ??? + "READY", # (ON/OFF) ??? + "SEC", # (int) ??? + "SETPT", # (int) set point (same as 'LOTMP' AFAIK) + "SHARE", # (objnam) sharing with that other body? + "SNAME", # (str) friendly name + "STATIC", # (ON/OFF) 'OFF' + "STATUS", # (ON/OFF) 'ON' is body is "active" + "SUBTYP", # 'POOL' or 'SPA' + "VOL", # (int) Volume in Gallons +} + +CIRCGRP_ATTRIBUTES = {"ACT", "CIRCUIT", "DLY", "LISTORD", "PARENT", "READY", "STATIC"} + +CIRCUIT_ATTRIBUTES = { + "ACT", # to be set for changing USE attribute + "BODY", + "CHILD", + "COVER", + "DNTSTP", # (ON/OFF) "Don't Stop", disable egg timer + "FEATR", # (ON/OFF) Featured + "FREEZE", # (ON/OFF) Freeze Protection + "HNAME", # equals to OBJNAM + "LIMIT", + "LISTORD", # (int) used to order in UI + "OBJLIST", + "PARENT", # OBJNAM of the parent object + "READY", # (ON/OFF) ?? + "SELECT", # ??? + "SET", # (ON/OFF) for light groups only + "SHOMNU", # (str) permissions + "SNAME", # (str) friendly name + "STATIC", # (ON/OFF) ?? + "STATUS", # (ON/OFF) 'ON' if circuit is active + "SUBTYP", # subtype can be '? + "SWIM", # (ON/OFF) for light groups only + "SYNC", # (ON/OFF) for light groups only + "TIME", # (int) Egg Timer, number of minutes + "USAGE", + "USE", # for lights with light effects, indicate the 'color' +} + +# represents External Equipment like covers +EXTINSTR_ATRIBUTES = { + "BODY", # (objnam) which body it covers + "HNAME", # equals to OBJNAM + "LISTORD", # (int) used to order in UI + "NORMAL", # (ON/OFF) 'ON' for Cover State Normally On + "PARENT", # (objnam) + "READY", # (ON/OFF) ??? + "SNAME", # (str) friendly name + "STATIC", # (ON/OFF) 'OFF' + "STATUS", # (ON/OFF) 'ON' if cover enabled + "SUBTYP", # only seen 'COVER' +} + +# no idea what this represents +FEATR_ATTRIBUTES = {"HNAME", "LISTORD", "READY", "SNAME", "SOURCE", "STATIC"} + +HEATER_ATTRIBUTES = { + "BODY", # the objnam of the body the pump serves or a list (separated by a space) + "BOOST", # (int) ?? + "COMUART", # X25 related? + "COOL", # (ON/OFF) + "DLY", # (int) ?? + "HNAME", # equals to OBJNAM + "HTMODE", # (int) ?? + "LISTORD", # (int) used to order in UI + "PARENT", # (objnam) parent (module) for this heater + "READY", # (ON/OFF) + "SHOMNU", # (str) permissions + "SNAME", # (str) friendly name + "START", # (int) ?? + "STATIC", # (ON/OFF) 'OFF' + "STATUS", # (ON/OFF) only seen 'ON' + "STOP", # (int) ?? + "SUBTYP", # type of heater 'GENERIC','SOLAR','ULTRA','HEATER' + "TIME", # (int) ?? +} + +MODULE_ATTRUBUTES = { + "CIRCUITS", # [ objects ] the objects that the module controls + "PARENT", # (objnam) the parent (PANEL) of the module + "PORT", # (int) no idea + "SNAME", # friendly name + "STATIC", # (ON/OFF) 'ON' + "SUBTYP", # type of the module (like 'I5P' or 'I8PS') + "VER", # (str) the version of the firmware for this module +} + +PANEL_ATTRIBUTES = { + "HNAME", # equals to OBJNAM + "LISTORD", # (int) used to order in UI + "OBJLIST", # [ (objnam) ] the elements managed by the panel + "PANID", # ??? only seen 'SHARE' + "SNAME", # friendly name + "STATIC", # only seen 'ON' + "SUBTYP", # only seen 'OCP' +} + +# represent a USER for the system +PERMIT_ATTRIBUTES = { + "ENABLE", # (ON/OFF) ON if user is enabled + "PASSWRD", # 4 digit code or '' + "SHOMNU", # privileges associated with this user + "SNAME", # friendly name + "STATIC", # (ON/OFF) only seen ON + "SUBTYP", # ADV for administrator, BASIC for guest + "TIMOUT", # (int) in minutes, timeout for user session +} + +# represents a PUMP setting +PMPCIRC_ATTRIBUTES = { + "BODY", # not sure, I've only see '00000' + "CIRCUIT", # (objnam) the circuit this setting is for + "GPM", # (int): the flow setting for the pump if select is GPM + "LISTORD", # (int) used to order in UI + "PARENT", # (objnam) the pump the setting belongs to + "SPEED", # (int): the speed setting for the pump if select is RPM + "SELECT", # 'RPM' or 'GPM' +} + +# no idea what this object type represents +# only seem to be one instance of it +PRESS_ATTRIBUTES = { + "SHOMNU", # (ON/OFF) ??? + "SNAME", # seems equal to objnam + "STATIC", # (ON/OFF) only seen ON +} + +# represents a PUMP +PUMP_ATTRIBUTES = { + "BODY", # the objnam of the body the pump serves or a list (separated by a space) + "CIRCUIT", # (int) ??? only seen 1 + "COMUART", # X25 related? + "HNAME", # same as objnam + "GPM", # (int) when applicable, real time Gallon Per Minute + "LISTORD", # (int) used to order in UI + "MAX", # (int) maximum RPM + "MAXF", # (int) maximum GPM (if applicable, 0 otherwise) + "MIN", # (int) minimum RPM + "MINF", # (int) minimum GPM (if applicable, 0 otherwise) + "NAME", # seems to equal OBJNAM + "OBJLIST", # ([ objnam] ) a list of PMPCIRC settings + "PRIMFLO", # (int) Priming Speed + "PRIMTIM", # (int) Priming Time in minutes + "PRIOR", # (int) ??? + "PWR", # (int) when applicable, real time Power usage in Watts + "RPM", # (int) when applicable, real time Rotation Per Minute + "SETTMP", # (int) Step size for RPM + "SETTMPNC", # (int) ??? + "SNAME", # friendly name + "STATUS", # only seen 10 for on, 4 for off + "SUBTYP", # type of pump: 'SPEED' (variable speed), 'FLOW' (variable flow), 'VSF' (both) + "SYSTIM", # (int) ??? +} + + +# represents a mapping between a remote button and a circuit +REMBTN_ATTRIBUTES = { + "CIRCUIT", # (objnam) the circuit triggered by the button + "LISTORD", # (int) which button on the remote (1 to 4) + "PARENT" # (objnam) the remote this button is associated with + "STATIC", # (ON/OFF) not sure, only seen 'ON' +} + +# represents a REMOTE +REMOTE_ATTRIBUTES = { + "BODY", # (objnam) the body the remote controls + "COMUART", # X25 address? + "ENABLE", # (ON/OFF) 'ON' if the remote is set to active + "HNAME", # same as objnam + "LISTORD", # number likely used to order things in UI + "SNAME", # friendly name + "STATIC", # (ON/OFF) not sure, only seen 'OFF' + "SUBTYP", # type of the remote, I've only seen IS4 +} + +# represents a SCHEDULE +SCHED_ATTRIBUTES = { + "ACT", + "CIRCUIT", # (objnam) the circuit controlled by this schedule + "DAY", # the days this schedule run (example: 'MTWRFAU' for every day, 'AU' for weekends) + "DNTSTP", # 'ON' or 'OFF" means Don't Stop. Set to ON to never end... + "HEATER", # set to a HEATER objnam is the schedule should trigger heating, '00000' for off, '00001' for Don't Change + "HNAME", # same as objnam + "HITMP", # number but not sure + "LISTORD", # number likely used to order things in UI + "LOTMP", # number. when heater is set, that is the desired temperature + "SINGLE", # 'ON' if the schedule is not to repeat + "SNAME", # the friendly name of the schedule + "START", # start time mode + # 'ABSTIM' means absolute and 'TIME' will be the startime + # 'SRIS' means Sunrise, 'SSET' means Sunset + "STATIC", # (ON/OFF) not sure, only seen 'OFF' + "STATUS", # 'ON' if schedule is active, 'OFF' otherwise + "STOP", # stop time mode ('ABSTIME','SRIS' or 'SSET') + "TIME", # time the schedule starts in 'HH,MM,SS' format (24h clock) + "TIMOUT", # time the schedule stops in 'HH,MM,SS' format (24h clock) + "VACFLO", # ON/OFF not sure of the meaning... +} + +# represents a SENSOR +SENSE_ATTRIBUTES = { + "CALIB", # (int) calibration value + "HNAME", # same as objnam + "LISTORD", # number likely used to order things in UI + "MODE", # I've only seen 'OFF' so far + "NAME", # I've only seen '00000' + "PARENT", # the parent's objnam + "PROBE", # the uncalibrated reading of the sensor + "SNAME", # friendly name + "SOURCE", # the calibrated reading of the sensor + "STATIC", # (ON/OFF) not sure, only seen 'ON' + "STATUS", # I've only seen 'OK' so far + "SUBTYP", # 'SOLAR','POOL' (for water), 'AIR' +} + +# represent the (unique instance of) SYSTEM object +SYSTEM_ATTRIBUTES = [ + "ACT", # ON/OFF but not sure what it does + "ADDRESS", # Pool Address + "AVAIL", # ON/OFF but not sure what it does + "CITY", # Pool City + "COUNTRY", # Country obviously (example 'United States') + "EMAIL", # primary email for the owner + "EMAIL2", # secondary email for the owner + "HEATING", # ON/OFF: Pump On During Heater Cool-Down Delay + "HNAME", # same as objnam + "LOCX", # (float) longitude + "LOCY", # (float) latitude + "MANHT", # ON/OFF: Manual Heat + "MODE", # unit system, 'METRIC' or 'ENGLISH' + "NAME", # name of the owner + "PASSWRD", # a 4 digit password or '' + "PHONE", # primary phone number for the owner + "PHONE2", # secondary phone number for the owner + "PROPNAME", # name of the property + "SERVICE", # 'AUTO' for automatic + "SNAME", # a crazy looking string I assume to be unique to this system + "START", # almost looks like a date but no idea + "STATE", # Pool State + "STATUS", # ON/OFF + "STOP", # same value as START + "TEMPNC", # ON/OFF + "TIMZON", # (int) Time Zone (example '-8' for US Pacific) + "VACFLO", # ON/OFF, vacation mode + "VACTIM", # ON/OFF + "VALVE", # ON/OFF: Pump Off During Valve Action + "VER", # (str) software version + "ZIP", # Pool Zip Code +] + +# represents the system CLOCK +# note that there are 2 clocks in the system +# one only contains the SOURCE attribute +# the other everything but SOURCE +SYSTIM_ATTRIBUTES = { + "CLK24A", # clock mode, 'AMPM' or 'HR24' + "DAY", # in 'MM,DD,YY' format + "DLSTIM", # ON/OFF, ON for following DST + "HNAME", # same as objnam + "LOCX", # (float) longitude + "LOCY", # (float) latitude + "MIN", # in 'HH,MM,SS' format (24h clock) + "SNAME", # unused really, likely equals to OBJNAM + "SOURCE", # set to URL if time is from the internet + "STATIC", # (ON/OFF) not sure, only seen 'ON' + "TIMZON", # (int) timezone (example '-8' for US Pacific) + "ZIP", # ZipCode +} + +VALVE_ATTRIBUTES = { + "ASSIGN", # 'NONE', 'INTAKE' or 'RETURN' + "CIRCUIT", # I've only seen '00000' + "DLY", # (ON/OFF) + "HNAME", # same as objnam + "PARENT", # (objnam) parent (a module) + "SNAME", # friendly name + "STATIC", # (ON/OFF) I've only seen 'OFF' + "SUBTYP", # I've only seen 'LEGACY' +} + +ALL_ATTRIBUTES_BY_TYPE = { + "BODY": BODY_ATTRIBUTES, + "CIRCGRP": CIRCGRP_ATTRIBUTES, + "CIRCUIT": CIRCUIT_ATTRIBUTES, + "EXTINSTR": EXTINSTR_ATRIBUTES, + "FEATR": FEATR_ATTRIBUTES, + "HEATER": HEATER_ATTRIBUTES, + "MODULE": MODULE_ATTRUBUTES, + "PANEL": PANEL_ATTRIBUTES, + "PMPCIRC": PMPCIRC_ATTRIBUTES, + "PRESS": PRESS_ATTRIBUTES, + "PUMP": PUMP_ATTRIBUTES, + "REMBTN": REMBTN_ATTRIBUTES, + "REMOTE": REMOTE_ATTRIBUTES, + "SCHED": SCHED_ATTRIBUTES, + "SENSE": SENSE_ATTRIBUTES, + "SYSTEM": SYSTEM_ATTRIBUTES, + "SYSTIM": SYSTIM_ATTRIBUTES, + "VALVE": VALVE_ATTRIBUTES, +} diff --git a/custom_components/intellicenter/pyintellicenter/controller.py b/custom_components/intellicenter/pyintellicenter/controller.py index 3920cbd..67e8c76 100644 --- a/custom_components/intellicenter/pyintellicenter/controller.py +++ b/custom_components/intellicenter/pyintellicenter/controller.py @@ -4,9 +4,10 @@ from asyncio import Future from hashlib import blake2b import logging -from typing import Optional +import traceback +from typing import Dict, Optional -from .model import ALL_KNOWN_ATTRIBUTES +from .model import PoolModel from .protocol import ICProtocol _LOGGER = logging.getLogger(__name__) @@ -32,11 +33,12 @@ def errorCode(self): class SystemInfo: """Represents minimal information about a Pentair system.""" - def __init__(self, params: dict): + def __init__(self, objnam: str, params: dict): """Initialize from a dictionary.""" + self._objnam = objnam self._propName = params["PROPNAME"] self._sw_version = params["VER"] - + self._mode = params["MODE"] # here we compute what is expected to be a unique_id # from the internal name of the system object h = blake2b(digest_size=8) @@ -53,11 +55,23 @@ def swVersion(self): """Return the software version of the system.""" return self._sw_version + @property + def usesMetric(self): + """Return True is the system uses metric for temperature units.""" + return self._mode == "METRIC" + @property def uniqueID(self): """Return a unique id for that system.""" return self._unique_id + def update(self, updates): + """Update the object from a set of key/value pairs.""" + _LOGGER.debug(f"updating system info with {updates}") + self._propName = updates.get("PROPNAME", self._propName) + self._sw_version = updates.get("VER", self._sw_version) + self._mode = updates.get("MODE", self._mode) + # ------------------------------------------------------------------------------------- @@ -94,7 +108,7 @@ def __init__(self, host, port=6681, loop=None): self._requests = {} @property - def host(self): + def host(self) -> str: """Return the host the controller is connected to.""" return self._host @@ -121,12 +135,13 @@ async def start(self) -> None: { "condition": "OBJTYP=SYSTEM", "objectList": [ - {"objnam": "INCR", "keys": ["SNAME", "VER", "PROPNAME"]} + {"objnam": "INCR", "keys": ["SNAME", "VER", "PROPNAME", "MODE"]} ], }, ) - self._systemInfo = SystemInfo(msg["objectList"][0]["params"]) + info = msg["objectList"][0] + self._systemInfo = SystemInfo(info["objnam"], info["params"]) def stop(self): """Stop all activities from this controller and disconnect.""" @@ -167,7 +182,7 @@ def requestChanges( waitForResponse=waitForResponse, ) - async def getAllObjects(self, attributeList: list = ALL_KNOWN_ATTRIBUTES): + async def getAllObjects(self, attributeList: list): """Return the values of given attributes for all objects in the system.""" result = await self.sendCmd( @@ -261,14 +276,12 @@ class ModelController(BaseController): def __init__(self, host, model, port=6681, loop=None): """Initialize the controller.""" super().__init__(host, port, loop) - self._model = model - - self._systemInitialized = False + self._model: PoolModel = model self._updatedCallback = None @property - def model(self): + def model(self) -> PoolModel: """Return the model this controller manages.""" return self._model @@ -276,13 +289,12 @@ async def start(self): """Start the controller, fetch and start monitoring the model.""" await super().start() - # with the connection now established we first retrieve all the objects - allObjects = await self.getAllObjects() - - _LOGGER.debug(f"objects received: {allObjects}") - + # now we retrieve all the objects type, subtype, sname and parent + allObjects = await self.getAllObjects(["OBJTYP", "SUBTYP", "SNAME", "PARENT"]) # and process that list into our model - self.receivedSystemConfig(allObjects) + self.model.addObjects(allObjects) + + # _LOGGER.debug(f"objects received: {allObjects}") _LOGGER.info(f"model now contains {self.model.numObjects} objects") @@ -290,24 +302,27 @@ async def start(self): # now that I have my object loaded in the model # build a query to monitors all their relevant attributes + attributes = self._model.attributesToTrack() + query = [] - for object in self.model: - attributes = object.attributes - if attributes: - query.append({"objnam": object.objnam, "keys": attributes}) + numAttributes = 0 + for items in attributes: + query.append(items) + numAttributes += len(items["keys"]) # a query too large can choke the protocol... - # we split them in maximum of 30 objects (arbitrary but seems to work) - if len(query) >= 30: - msg = await self.sendCmd("RequestParamList", {"objectList": query}) - self.receivedNotifyList(msg["objectList"]) + # we split them in maximum of 250 attributes (arbitrary but seems to work) + if numAttributes >= 250: + res = await self.sendCmd("RequestParamList", {"objectList": query}) + self._applyUpdates(res["objectList"]) query = [] - + numAttributes = 0 # and issue the remaining elements if any if query: - msg = await self.sendCmd("RequestParamList", {"objectList": query}) - self.receivedNotifyList(msg["objectList"]) + res = await self.sendCmd("RequestParamList", {"objectList": query}) + self._applyUpdates(res["objectList"]) except Exception as err: + traceback.print_exc() raise err def receivedQueryResult(self, queryName: str, answer): @@ -319,20 +334,28 @@ def receivedQueryResult(self, queryName: str, answer): pass + def _applyUpdates(self, changesAsList): + """Apply updates received to the model.""" + + updates = self._model.processUpdates(changesAsList) + + # if an update happens on the SYSTEM object + # also applies it to our cached SystemInfo + systemObjnam = self._systemInfo._objnam + if systemObjnam in updates: + self._systemInfo.update(updates[systemObjnam]) + + if updates and self._updatedCallback: + self._updatedCallback(self, updates) + + return updates + def receivedNotifyList(self, changes): """Handle the notifications from IntelliCenter when tracked objects are modified.""" try: # apply the changes back to the model - - updatedList = self._model.processUpdates(changes) - - _LOGGER.debug( - f"CONTROLLER: received NotifyList: {len(updatedList)} objects updated" - ) - - if self._updatedCallback: - self._updatedCallback(self, updatedList) + self._applyUpdates(changes) except Exception as err: _LOGGER.error(f"CONTROLLER: receivedNotifyList {err}") @@ -340,24 +363,21 @@ def receivedNotifyList(self, changes): def receivedWriteParamList(self, changes): """Handle the response to a change requested on an object.""" - # similar to the receivedNotifyList except try: - # print(f"receivedWriteParamList {len(changes)} for {self._model.numObjects} objects") - updatedList = self._model.processUpdates(changes) - if self._updatedCallback: - self._updatedCallback(self, updatedList) + self._applyUpdates(changes) + except Exception as err: _LOGGER.error(f"CONTROLLER: receivedWriteParamList {err}") def receivedSystemConfig(self, objectList): - """Handle the response for the initial request for all objects.""" + """Handle the response for a request for objects.""" _LOGGER.debug( f"CONTROLLER: received SystemConfig for {len(objectList)} object(s)" ) - for object in prune(objectList): - self.model.addObject(object["objnam"], object["params"]) + # note that here we might create new objects + self.model.addObjects(objectList) def processMessage(self, command: str, msg): """Handle the callback for an incoming message.""" @@ -374,7 +394,7 @@ def processMessage(self, command: str, msg): elif command == "SendParamList": self.receivedSystemConfig(msg["objectList"]) else: - print(f"ignoring {command}") + _LOGGER.debug(f"no handler for {command}") except Exception as err: _LOGGER.error(f"error {err} while processing {msg}") # traceback.print_exc() @@ -476,7 +496,7 @@ def retrying(self, delay): """Handle the fact that we will retry connection in {delay} seconds.""" _LOGGER.info(f"will attempt to reconnect in {delay}s") - def updated(self, controller, changes): + def updated(self, controller: ModelController, updates: Dict): """Handle the callback that our underlying system has been modified. only invoked if the controller has a _updatedCallback attribute diff --git a/custom_components/intellicenter/pyintellicenter/model.py b/custom_components/intellicenter/pyintellicenter/model.py index c8d74ab..b6913c8 100644 --- a/custom_components/intellicenter/pyintellicenter/model.py +++ b/custom_components/intellicenter/pyintellicenter/model.py @@ -3,88 +3,12 @@ import logging from typing import List +from .attributes import ALL_ATTRIBUTES_BY_TYPE + _LOGGER = logging.getLogger(__name__) # --------------------------------------------------------------------------- -# cSpell:disable - -ALL_KNOWN_ATTRIBUTES = [ - "ACT", - "ADDRESS", - "AVAIL", - "BODY", - "BOOST", - "CHILD", - "CIRCUIT", - "CITY", - "CLK24A", - "COUNTRY", - "DAY", - "DLSTIM", - "DNTSTP", - "EMAIL", - "EMAIL2", - "FEATR", - "GPM", - "HEATER", - "HEATING", - "HITMP", - "HTMODE", - "HTSRC", - "LIMIT", - "LISTORD", - "LOCX", - "LOCY", - "LOTMP", - "LSTTMP", - "MANHT", - "MANUAL", - "MIN", - "MODE", - "NAME", - "OBJTYP", - "OFFSET", - "PARENT", - "PASSWRD", - "PHONE", - "PHONE2", - "PROBE", - "PROPNAME", - "PWR", - "RLY", - "RPM", - "SELECT", - "SERVICE", - "SET", - "SHOMNU", - "SINGLE", - "SNAME", - "SPEED", - "SRIS", - "SSET", - "START", - "STATE", - "STATUS", - "STOP", - "SUBTYP", - "SWIM", - "SYNC", - "TEMPNC", - "TIME", - "TIMOUT", - "TIMZON", - "USAGE", - "USE", - "VACFLO", - "VACTIM", - "VALVE", - "VER", - "VOL", - "ZIP", -] -# cSpell:enable - class PoolObject: """Representation of an object in the Pentair system.""" @@ -117,34 +41,44 @@ def subtype(self): return self._subtyp @property - def status(self): + def status(self) -> str: """Return the object status.""" return self._properties.get("STATUS") @property - def offStatus(self): + def offStatus(self) -> str: """Return the value of an OFF status.""" return "4" if self.objtype == "PUMP" else "OFF" @property - def onStatus(self): + def onStatus(self) -> str: """Return the value of an ON status.""" return "10" if self.objtype == "PUMP" else "ON" @property - def isALight(self): + def isALight(self) -> bool: """Return True is the object is a light.""" return self.objtype == "CIRCUIT" and self.subtype in [ - "LITSHO", "LIGHT", "INTELLI", "GLOW", "GLOWT", "DIMMER", + "MAGIC2", ] @property - def isFeatured(self): + def supportColorEffects(self) -> bool: + """Return True is object is a light that support color effects.""" + return self.isALight and self.subtype in ["INTELLI", "MAGIC2"] + + @property + def isALightShow(self) -> bool: + """Return True is the object is a light show.""" + return self.objtype == "CIRCUIT" and self.subtype == "LITSHO" + + @property + def isFeatured(self) -> bool: """Return True is the object is Featured.""" return self["FEATR"] == "ON" @@ -154,33 +88,44 @@ def __getitem__(self, key): def __str__(self): """Return a friendly string representation.""" - str = f"{self.objnam} " - str += ( + result = f"{self.objnam} " + result += ( f"({self.objtype}/{self.subtype}):" if self.subtype else f"({self.objtype}):" ) - for (key, value) in self._properties.items(): - str += f" {key}: {value}" - return str + for key in sorted(set(self._properties.keys())): + value = self._properties[key] + if type(value) is list: + value = "[" + ",".join(map(lambda v: f"{ {str(v)} }", value)) + "]" + result += f" {key}: {value}" + return result @property - def attributes(self): + def attributes(self) -> list: """Return the list of attributes for this object.""" return list(self._properties.keys()) def update(self, updates): - """Update the object from a set of key/value pairs, return true is the object HAS changed.""" + """Update the object from a set of key/value pairs, return the changed attributes.""" - changed = False + changed = {} for (key, value) in updates.items(): + if key in self._properties: if self._properties[key] == value: # ignore unchanged existing value continue - self._properties[key] = value - changed = True + + # there are a few case when we receive the type/subtype in an update + if key == "OBJTYP": + self._objtyp = value + elif key == "SUBTYP": + self._subtyp = value + else: + self._properties[key] = value + changed[key] = value return changed @@ -191,11 +136,11 @@ def update(self, updates): class PoolModel: """Representation of a subset of the underlying Pentair system.""" - def __init__(self, filterFunc=lambda object: True): + def __init__(self, attributeMap=ALL_ATTRIBUTES_BY_TYPE): """Initialize.""" - self._objects = {} - self._system = None - self._filterFunc = filterFunc + self._objects: dict[str, PoolObject] = {} + self._systemObject: PoolObject = None + self._attributeMap = attributeMap @property def objectList(self): @@ -203,7 +148,12 @@ def objectList(self): return self._objects.values() @property - def numObjects(self): + def objects(self): + """Return the dictionary of objects contained in the model.""" + return self._objects + + @property + def numObjects(self) -> int: """Return the number of objects contained in the model.""" return len(self._objects) @@ -211,7 +161,7 @@ def __iter__(self): """Allow iteration over all values.""" return iter(self._objects.values()) - def __getitem__(self, key): + def __getitem__(self, key) -> PoolObject: """Return an object based on its objnam.""" return self._objects.get(key) @@ -230,39 +180,54 @@ def getByType(self, type: str, subtype: str = None) -> List[PoolObject]: ) ) + def getChildren(self, object: PoolObject) -> List[PoolObject]: + """Return the children of a given object.""" + return list(filter(lambda v: v["PARENT"] == object.objnam, self)) + def addObject(self, objnam, params): - """Add a new object to the model based on its name and its initial values.""" + """Update the model with a new object.""" # because the controller may be started more than once # we don't override existing objects - if not self._objects.get(objnam): - object = PoolObject(objnam, self.filterOutAttributes(params)) - if self._filterFunc(object): + object = self._objects.get(objnam) + + if not object: + object = PoolObject(objnam, params) + if object.objtype == "SYSTEM": + self._systemObject = object + if object.objtype in self._attributeMap: self._objects[objnam] = object else: - _LOGGER.debug(f"not adding object to model: {object}") - - # def buildTrackingQuery(self): - # query = [] - # for object in self.objectList: - # attributes = object.attributes - # if attributes: - # query.append({"objnam": object.objnam, "keys": attributes}) - # return query - - def processUpdates(self, updateList): + object = None + else: + object.update(params) + return object + + def addObjects(self, objList: list): + """Create or update from all the objects in the list.""" + for elt in objList: + self.addObject(elt["objnam"], elt["params"]) + + def attributesToTrack(self): + """Return all the object/attributes we want to track.""" + query = [] + for object in self.objectList: + attributes = self._attributeMap.get(object.objtype) + if not attributes: + # if we don't specify a set of attributes for this object type + # we will default to all know attributes for this type + attributes = ALL_ATTRIBUTES_BY_TYPE.get(object.objtype) + if attributes: + query.append({"objnam": object.objnam, "keys": list(attributes)}) + return query + + def processUpdates(self, updates: list): """Update the state of the objects in the model.""" - updatedList = [] - for update in updateList: + updated = {} + for update in updates: objnam = update["objnam"] object = self._objects.get(objnam) if object: - if object.update(update["params"]): - updatedList.append(object) - return updatedList - - @staticmethod - def filterOutAttributes(params): - """Filter out undefined attributes.""" - # Pentair returns value equals to the parameter when it - # does not have a value for it. No point in keeping these - return {k: v for k, v in params.items() if k != v} + changed = object.update(update["params"]) + if changed: + updated[objnam] = changed + return updated diff --git a/custom_components/intellicenter/sensor.py b/custom_components/intellicenter/sensor.py index 8751382..5a57d96 100644 --- a/custom_components/intellicenter/sensor.py +++ b/custom_components/intellicenter/sensor.py @@ -8,12 +8,12 @@ DEVICE_CLASS_ENERGY, DEVICE_CLASS_TEMPERATURE, POWER_WATT, - TEMP_FAHRENHEIT, ) from homeassistant.helpers.typing import HomeAssistantType from . import PoolEntity from .const import CONST_GPM, CONST_RPM, DOMAIN +from .pyintellicenter import ModelController, PoolObject _LOGGER = logging.getLogger(__name__) @@ -23,10 +23,11 @@ async def async_setup_entry( ): """Load pool sensors based on a config entry.""" - controller = hass.data[DOMAIN][entry.entry_id].controller + controller: ModelController = hass.data[DOMAIN][entry.entry_id].controller sensors = [] + object: PoolObject for object in controller.model.objectList: if object.objtype == "SENSE": sensors.append( @@ -35,8 +36,7 @@ async def async_setup_entry( controller, object, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_FAHRENHEIT, - attribute_key="PROBE", + attribute_key="SOURCE", ) ) elif object.objtype == "PUMP": @@ -84,7 +84,6 @@ async def async_setup_entry( controller, object, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_FAHRENHEIT, attribute_key="LSTTMP", name_suffix="last temp", ) @@ -95,7 +94,6 @@ async def async_setup_entry( controller, object, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_FAHRENHEIT, attribute_key="LOTMP", name_suffix="desired temp", ) @@ -109,36 +107,19 @@ class PoolSensor(PoolEntity): def __init__( self, entry: ConfigEntry, - controller, - poolObject, + controller: ModelController, + poolObject: PoolObject, device_class: str, - unit_of_measurement: str, - attribute_key: str, - name_suffix="", + unit_of_measurement: str = None, rounding_factor: int = 0, - **kwargs + **kwargs, ): """Initialize.""" super().__init__(entry, controller, poolObject, **kwargs) - self._attribute_key = attribute_key - self._name_suffix = name_suffix self._device_class = device_class self._unit_of_measurement = unit_of_measurement self._rounding_factor = rounding_factor - @property - def name(self): - """Return the name of the entity.""" - name = super().name - if self._name_suffix: - name += " " + self._name_suffix - return name - - @property - def unique_id(self): - """Return a unique ID.""" - return super().unique_id + self._attribute_key - @property def device_class(self) -> Optional[str]: """Return the class of this device, from component DEVICE_CLASSES.""" @@ -162,4 +143,6 @@ def state(self): @property def unit_of_measurement(self) -> Optional[str]: """Return the unit of measurement of this entity, if any.""" + if self._device_class == DEVICE_CLASS_TEMPERATURE: + return self.pentairTemperatureSettings() return self._unit_of_measurement diff --git a/custom_components/intellicenter/switch.py b/custom_components/intellicenter/switch.py index cf6666c..650bb01 100644 --- a/custom_components/intellicenter/switch.py +++ b/custom_components/intellicenter/switch.py @@ -9,6 +9,7 @@ from . import PoolEntity from .const import DOMAIN +from .pyintellicenter import ModelController, PoolObject _LOGGER = logging.getLogger(__name__) @@ -17,34 +18,39 @@ async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ): """Load a Pentair switch based on a config entry.""" - controller = hass.data[DOMAIN][entry.entry_id].controller + controller: ModelController = hass.data[DOMAIN][entry.entry_id].controller switches = [] + object: PoolObject for object in controller.model.objectList: if object.objtype == "BODY": switches.append(PoolBody(entry, controller, object)) - elif object.objtype == "CIRCUIT" and not object.isALight and object.isFeatured: + elif ( + object.objtype == "CIRCUIT" + and not (object.isALight or object.isALightShow) + and object.isFeatured + ): switches.append(PoolCircuit(entry, controller, object)) async_add_entities(switches) class PoolCircuit(PoolEntity, SwitchEntity): - """Representation of an pool circuit.""" + """Representation of an standard pool circuit.""" @property def is_on(self) -> bool: """Return the state of the circuit.""" - return self._poolObject.status == self._poolObject.onStatus + return self._poolObject[self._attribute_key] == self._poolObject.onStatus def turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - self.requestChanges({"STATUS": self._poolObject.offStatus}) + self.requestChanges({self._attribute_key: self._poolObject.offStatus}) def turn_on(self, **kwargs: Any) -> None: """Turn off the switch.""" - self.requestChanges({"STATUS": self._poolObject.onStatus}) + self.requestChanges({self._attribute_key: self._poolObject.onStatus}) class PoolBody(PoolCircuit): diff --git a/custom_components/intellicenter/water_heater.py b/custom_components/intellicenter/water_heater.py index c562e44..414ce6a 100644 --- a/custom_components/intellicenter/water_heater.py +++ b/custom_components/intellicenter/water_heater.py @@ -1,6 +1,7 @@ """Pentair Intellicenter water heaters.""" import logging +from typing import Dict from homeassistant.components.water_heater import ( SUPPORT_OPERATION_MODE, @@ -8,13 +9,8 @@ WaterHeaterEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - STATE_IDLE, - STATE_OFF, - STATE_ON, - TEMP_FAHRENHEIT, -) +from homeassistant.const import ATTR_TEMPERATURE, STATE_IDLE, STATE_OFF, STATE_ON +from homeassistant.core import callback from homeassistant.helpers.typing import HomeAssistantType from . import PoolEntity @@ -92,18 +88,17 @@ def icon(self): @property def temperature_unit(self): """Return the unit of measurement used by the platform.""" - return TEMP_FAHRENHEIT + return self.pentairTemperatureSettings() @property def min_temp(self): """Return the minimum value.""" - # this is totally arbitrary... - return 40.0 + return 5.0 if self._controller.systemInfo.usesMetric else 4.0 @property def max_temp(self): """Return the maximum temperature.""" - return float(self._poolObject["HITMP"]) + return 40.0 if self._controller.systemInfo.usesMetric else 104.0 @property def current_temperature(self): @@ -138,3 +133,12 @@ def set_operation_mode(self, operation_mode): """Set new target operation mode.""" value = self._heater_id if operation_mode == STATE_ON else "00000" self.requestChanges({"HEATER": value}) + + @callback + def _update_callback(self, updates: Dict[str, PoolObject]): + """Update the entity if its underlying pool object has changed.""" + + if self._poolObject.objnam in updates: + self._available = True + _LOGGER.debug(f"updating {self} from {self._poolObject}") + self.async_write_ha_state()