diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d896e67b..d53641306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.14.5 - Smile: prepare for using the HA Core DataUpdateCoordintor in Plugwise-beta + +- Change the output to enable the use of the HA Core DUC in plugwise-beta. +- Change state_class to "total" for interval- and net_cumulative sensors (following the HA Core sensor platform updates). +- Remove all remnant code related to last_reset (log_date) +- Restructure: introduce additional classes: SmileComm and SmileConnect + ## v0.14.2 - Smile: fix P1 legacy location handling error ## v0.14.1 - Smile: removing further `last_reset`s diff --git a/plugwise/__init__.py b/plugwise/__init__.py index c480a6378..3d787869a 100644 --- a/plugwise/__init__.py +++ b/plugwise/__init__.py @@ -1,6 +1,6 @@ """Plugwise module.""" -__version__ = "0.14.2" +__version__ = "0.14.5" from plugwise.smile import Smile from plugwise.stick import Stick diff --git a/plugwise/constants.py b/plugwise/constants.py index b695880a4..4d004f120 100644 --- a/plugwise/constants.py +++ b/plugwise/constants.py @@ -611,7 +611,7 @@ ATTR_NAME: "Electricity Consumed Interval", ATTR_STATE: None, ATTR_DEVICE_CLASS: "energy", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: None, ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, } @@ -631,7 +631,7 @@ ATTR_NAME: "Electricity Consumed Off Peak Interval", ATTR_STATE: None, ATTR_DEVICE_CLASS: "energy", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: None, ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, } @@ -661,7 +661,7 @@ ATTR_NAME: "Electricity Consumed Peak Interval", ATTR_STATE: None, ATTR_DEVICE_CLASS: "energy", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: None, ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, } @@ -701,7 +701,7 @@ ATTR_NAME: "Electricity Produced Interval", ATTR_STATE: None, ATTR_DEVICE_CLASS: "energy", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: None, ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, } @@ -721,7 +721,7 @@ ATTR_NAME: "Electricity Produced Off Peak Interval", ATTR_STATE: None, ATTR_DEVICE_CLASS: "energy", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: None, ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, } @@ -751,7 +751,7 @@ ATTR_NAME: "Electricity Produced Peak Interval", ATTR_STATE: None, ATTR_DEVICE_CLASS: "energy", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: None, ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, } @@ -791,7 +791,7 @@ ATTR_NAME: "Gas Consumed Interval", ATTR_STATE: None, ATTR_DEVICE_CLASS: "gas", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: FLAME_ICON, ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, } @@ -841,7 +841,7 @@ ATTR_NAME: "Net Electricity Cumulative", ATTR_STATE: None, ATTR_DEVICE_CLASS: "energy", - ATTR_STATE_CLASS: "measurement", + ATTR_STATE_CLASS: "total", ATTR_ICON: None, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, } diff --git a/plugwise/entities.py b/plugwise/entities.py index 926d83d4f..5bf14c01c 100644 --- a/plugwise/entities.py +++ b/plugwise/entities.py @@ -21,10 +21,10 @@ class GWBinarySensor: """ Represent the Plugwise Smile/Stretch binary_sensor.""" - def __init__(self, api, dev_id, binary_sensor): + def __init__(self, data, dev_id, binary_sensor): """Initialize the Gateway.""" - self._api = api self._binary_sensor = binary_sensor + self._data = data self._dev_id = dev_id self._attributes = {} self._icon = None @@ -65,7 +65,7 @@ def _icon_selector(arg, state): def update_data(self): """Handle update callbacks.""" - data = self._api.gw_devices[self._dev_id] + data = self._data[1][self._dev_id] for key, _ in data.items(): if key != "binary_sensors": @@ -81,7 +81,7 @@ def update_data(self): if self._binary_sensor != "plugwise_notification": continue - notify = self._api.notifications + notify = self._data[0]["notifications"] self._notification = {} for severity in SEVERITIES: self._attributes[f"{severity.upper()}_msg"] = [] @@ -98,12 +98,12 @@ def update_data(self): class GWThermostat: """Represent a Plugwise Thermostat Device.""" - def __init__(self, api, dev_id): + def __init__(self, data, dev_id): """Initialize the Thermostat.""" - self._api = api self._compressor_state = None self._cooling_state = None + self._data = data self._dev_id = dev_id self._extra_state_attributes = None self._get_presets = None @@ -120,9 +120,9 @@ def __init__(self, api, dev_id): self._smile_class = None self._temperature = None - self._active_device = self._api._active_device_present - self._heater_id = self._api._heater_id - self._sm_thermostat = self._api.single_master_thermostat() + self._active_device = self._data[0]["active_device"] + self._heater_id = self._data[0]["heater_id"] + self._sm_thermostat = self._data[0]["single_master_thermostat"] @property def compressor_state(self): @@ -186,7 +186,7 @@ def extra_state_attributes(self): def update_data(self): """Handle update callbacks.""" - data = self._api.gw_devices[self._dev_id] + data = self._data[1][self._dev_id] # current & target_temps, heater_central data when required s_list = data["sensors"] @@ -197,7 +197,7 @@ def update_data(self): self._setpoint = s_list[idx][ATTR_STATE] self._schedule_temp = data.get("schedule_temperature") if self._active_device: - hc_data = self._api.gw_devices[self._heater_id] + hc_data = self._data[1][self._heater_id] self._compressor_state = hc_data.get("compressor_state") if self._sm_thermostat: self._cooling_state = hc_data.get("cooling_state") diff --git a/plugwise/helper.py b/plugwise/helper.py index 15a02f54c..116fd9c5f 100644 --- a/plugwise/helper.py +++ b/plugwise/helper.py @@ -5,6 +5,7 @@ import datetime as dt import logging +import aiohttp import async_timeout from dateutil import tz from dateutil.parser import parse @@ -68,7 +69,7 @@ def device_state_updater(data, devs, d_id, d_dict): - """Helper-function for _update_gw_devices(). + """Helper-function for async_update(). Update the Device_State sensor state. """ for idx, item in enumerate(d_dict["sensors"]): @@ -114,7 +115,7 @@ def update_device_state(data, d_dict): def pw_notification_updater(devs, d_id, d_dict, notifs): - """Helper-function for _update_gw_devices(). + """Helper-function for async_update(). Update the PW_Notification binary_sensor state. """ for idx, item in enumerate(d_dict["binary_sensors"]): @@ -123,7 +124,7 @@ def pw_notification_updater(devs, d_id, d_dict, notifs): def update_helper(data, devs, d_dict, d_id, e_type, key): - """Helper-function for _update_gw_devices().""" + """Helper-function for async_update().""" for dummy in d_dict[e_type]: if key != dummy[ATTR_ID]: continue @@ -131,8 +132,6 @@ def update_helper(data, devs, d_dict, d_id, e_type, key): if key != item[ATTR_ID]: continue devs[d_id][e_type][idx][ATTR_STATE] = data[key] - if isinstance(data[key], list): - devs[d_id][e_type][idx][ATTR_STATE] = data[key][0] def check_model(name, v_name): @@ -217,38 +216,37 @@ def power_data_energy_diff(measurement, net_string, f_val, direct_data): return direct_data -class SmileHelper: - """The SmileHelper class.""" +class SmileComm: + """The SmileComm class.""" - def __init__(self): + def __init__( + self, + host, + password, + username, + port, + timeout, + websession, + ): """Set the constructor for this class.""" - self._active_device_present = None - self._appl_data = {} - self._appliances = None - self._auth = None - self._cp_state = None - self._domain_objects = None - self._endpoint = None - self._heater_id = None - self._home_location = None - self._locations = None - self._modules = None - self._smile_legacy = False - self._host = None - self._loc_data = {} - self._port = None - self._stretch_v2 = False - self._stretch_v3 = False - self._thermo_locs = None - self._timeout = None - self._websession = None - - self.gateway_id = None - self.notifications = {} - self.smile_hostname = None - self.smile_name = None - self.smile_type = None - self.smile_version = () + if not websession: + + async def _create_session() -> aiohttp.ClientSession: + return aiohttp.ClientSession() # pragma: no cover + + loop = asyncio.get_event_loop() + if loop.is_running(): + self._websession = aiohttp.ClientSession() + else: + self._websession = loop.run_until_complete( + _create_session() + ) # pragma: no cover + else: + self._websession = websession + + self._auth = aiohttp.BasicAuth(username, password=password) + self._endpoint = f"http://{host}:{str(port)}" + self._timeout = timeout async def _request_validate(self, resp, method): """Helper-function for _request(): validate the returned data.""" @@ -289,7 +287,7 @@ async def _request( url = f"{self._endpoint}{command}" try: - with async_timeout.timeout(self._timeout): + async with async_timeout.timeout(self._timeout): if method == "get": # Work-around for Stretchv2, should not hurt the other smiles headers = {"Accept-Encoding": "gzip"} @@ -311,6 +309,14 @@ async def _request( return await self._request_validate(resp, method) + async def close_connection(self): + """Close the Plugwise connection.""" + await self._websession.close() + + +class SmileHelper: + """The SmileHelper class.""" + def _locations_legacy(self): """Helper-function for _all_locations(). Create locations for legacy devices. @@ -513,6 +519,7 @@ def _appliance_types_finder(self, appliance, appl): def _all_appliances(self): """Collect all appliances with relevant info.""" self._appl_data = {} + self._cp_state = None self._all_locations() @@ -674,29 +681,6 @@ def _rule_ids_by_tag(self, tag, loc_id): if schema_ids != {}: return schema_ids - async def _update_domain_objects(self): - """Helper-function for smile.py: full_update_device() and update_gw_devices(). - Request domain_objects data. - """ - self._domain_objects = await self._request(DOMAIN_OBJECTS) - - # If Plugwise notifications present: - self.notifications = {} - url = f"{self._endpoint}{DOMAIN_OBJECTS}" - notifications = self._domain_objects.findall(".//notification") - for notification in notifications: - try: - msg_id = notification.attrib["id"] - msg_type = notification.find("type").text - msg = notification.find("message").text - self.notifications.update({msg_id: {msg_type: msg}}) - _LOGGER.debug("Plugwise notifications: %s", self.notifications) - except AttributeError: # pragma: no cover - _LOGGER.info( - "Plugwise notification present but unable to process, manually investigate: %s", - url, - ) - def _appliance_measurements(self, appliance, data, measurements): """Helper-function for _get_appliance_data() - collect appliance measurement data.""" for measurement, attrs in measurements: @@ -729,9 +713,7 @@ def _appliance_measurements(self, appliance, data, measurements): if appliance.find(i_locator) is not None: name = f"{measurement}_interval" measure = appliance.find(i_locator).text - log_date = parse(appliance.find(i_locator).get("log_date")) - log_date = log_date.astimezone(tz.gettz("UTC")).replace(tzinfo=None) - data[name] = [format_measure(measure, ENERGY_WATT_HOUR), log_date] + data[name] = format_measure(measure, ENERGY_WATT_HOUR) return data @@ -955,8 +937,6 @@ def _power_data_peak_value(self, loc): loc.key_string = f"{loc.measurement}_{log_found}" loc.net_string = f"net_electricity_{log_found}" val = loc.logs.find(loc.locator).text - log_date = parse(loc.logs.find(loc.locator).get("log_date")) - loc.log_date = log_date.astimezone(tz.gettz("UTC")).replace(tzinfo=None) loc.f_val = power_data_local_format(loc.attrs, loc.key_string, val) return loc @@ -1002,8 +982,6 @@ def _power_data_from_location(self, loc_id): ) direct_data[loc.key_string] = loc.f_val - if "interval" in loc.key_string: - direct_data[loc.key_string] = [loc.f_val, loc.log_date] if direct_data != {}: return direct_data @@ -1178,8 +1156,6 @@ def _create_lists_from_data(self, data, bs_list, s_list, sw_list): if item[ATTR_ID] == key: data.pop(item[ATTR_ID]) item[ATTR_STATE] = value - if "interval" in item[ATTR_ID] and isinstance(value, list): - item[ATTR_STATE] = value[0] s_list.append(item) for item in SWITCHES: if item[ATTR_ID] == key: diff --git a/plugwise/smile.py b/plugwise/smile.py index c641fbec2..f024993fa 100644 --- a/plugwise/smile.py +++ b/plugwise/smile.py @@ -33,6 +33,7 @@ ) from .exceptions import ConnectionFailedError, InvalidXMLError, UnsupportedDeviceError from .helper import ( + SmileComm, SmileHelper, device_state_updater, pw_notification_updater, @@ -42,215 +43,9 @@ _LOGGER = logging.getLogger(__name__) -class Smile(SmileHelper): +class SmileData(SmileHelper): """The Plugwise Smile main class.""" - # pylint: disable=too-many-instance-attributes, too-many-public-methods - - def __init__( - self, - host, - password, - username=DEFAULT_USERNAME, - port=DEFAULT_PORT, - timeout=DEFAULT_TIMEOUT, - websession: aiohttp.ClientSession = None, - ): - """Set the constructor for this class.""" - super().__init__() - - if not websession: - - async def _create_session() -> aiohttp.ClientSession: - return aiohttp.ClientSession() # pragma: no cover - - loop = asyncio.get_event_loop() - if loop.is_running(): - self._websession = aiohttp.ClientSession() - else: - self._websession = loop.run_until_complete( - _create_session() - ) # pragma: no cover - else: - self._websession = websession - - self._auth = aiohttp.BasicAuth(username, password=password) - - self._devices = {} - self._host = host - self._port = port - self._endpoint = f"http://{self._host}:{str(self._port)}" - self._timeout = timeout - - self.gw_devices = {} - - async def connect(self): - """Connect to Plugwise device and determine its name, type and version.""" - names = [] - - result = await self._request(DOMAIN_OBJECTS) - dsmrmain = result.find(".//module/protocols/dsmrmain") - - vendor_names = result.findall(".//module/vendor_name") - for name in vendor_names: - names.append(name.text) - - if "Plugwise" not in names: - if dsmrmain is None: # pragma: no cover - _LOGGER.error( - "Connected but expected text not returned, \ - we got %s", - result, - ) - raise ConnectionFailedError - - # Determine smile specifics - await self._smile_detect(result, dsmrmain) - - # Update all endpoints on first connect - await self._full_update_device() - - return True - - async def _smile_detect_legacy(self, result, dsmrmain): - """Helper-function for _smile_detect().""" - network = result.find(".//module/protocols/network_router/network") - - # Assume legacy - self._smile_legacy = True - # Try if it is an Anna, assuming appliance thermostat - anna = result.find('.//appliance[type="thermostat"]') - # Fake insert version assuming Anna - # couldn't find another way to identify as legacy Anna - version = "1.8.0" - model = "smile_thermo" - if anna is None: - # P1 legacy: - if dsmrmain is not None: - try: - status = await self._request(STATUS) - version = status.find(".//system/version").text - model = status.find(".//system/product").text - self.smile_hostname = status.find(".//network/hostname").text - except InvalidXMLError: # pragma: no cover - # Corner case check - raise ConnectionFailedError - - # Stretch: - elif network is not None: - try: - system = await self._request(SYSTEM) - version = system.find(".//gateway/firmware").text - model = system.find(".//gateway/product").text - self.smile_hostname = system.find(".//gateway/hostname").text - self.gateway_id = network.attrib["id"] - except InvalidXMLError: # pragma: no cover - # Corner case check - raise ConnectionFailedError - else: # pragma: no cover - # No cornercase, just end of the line - _LOGGER.error("Connected but no gateway device information found") - raise ConnectionFailedError - return model, version - - async def _smile_detect(self, result, dsmrmain): - """Helper-function for connect(). - Detect which type of Smile is connected. - """ - model = None - gateway = result.find(".//gateway") - - if gateway is not None: - model = result.find(".//gateway/vendor_model").text - version = result.find(".//gateway/firmware_version").text - if gateway.find("hostname") is not None: - self.smile_hostname = gateway.find("hostname").text - else: - model, version = await self._smile_detect_legacy(result, dsmrmain) - - if model is None or version is None: # pragma: no cover - # Corner case check - _LOGGER.error("Unable to find model or version information") - raise UnsupportedDeviceError - - ver = semver.VersionInfo.parse(version) - target_smile = f"{model}_v{ver.major}" - - _LOGGER.debug("Plugwise identified as %s", target_smile) - - if target_smile not in SMILES: - _LOGGER.error( - 'Your version Smile identified as "%s" seems\ - unsupported by our plugin, please create an issue\ - on http://github.com/plugwise/python-plugwise!', - target_smile, - ) - raise UnsupportedDeviceError - - self.smile_name = SMILES[target_smile]["friendly_name"] - self.smile_type = SMILES[target_smile]["type"] - self.smile_version = (version, ver) - - if "legacy" in SMILES[target_smile]: - self._smile_legacy = SMILES[target_smile]["legacy"] - - if self.smile_type == "stretch": - self._stretch_v2 = self.smile_version[1].major == 2 - self._stretch_v3 = self.smile_version[1].major == 3 - - async def close_connection(self): - """Close the Plugwise connection.""" - await self._websession.close() - - async def _full_update_device(self): - """Perform a first fetch of all XML data, needed for initialization.""" - self._locations = await self._request(LOCATIONS) - self._modules = await self._request(MODULES) - - # P1 legacy has no appliances - if not (self.smile_type == "power" and self._smile_legacy): - self._appliances = await self._request(APPLIANCES) - - # No need to import domain_objects and modules for P1, no useful info - if self.smile_type != "power": - await self._update_domain_objects() - - async def update_gw_devices(self): - """Perform an incremental update for updating the various device states.""" - if self.smile_type != "power": - await self._update_domain_objects() - else: - self._locations = await self._request(LOCATIONS) - - # P1 legacy has no appliances - if not (self.smile_type == "power" and self._smile_legacy): - self._appliances = await self._request(APPLIANCES) - - for dev_id, dev_dict in self.gw_devices.items(): - data = self._get_device_data(dev_id) - for key, value in list(data.items()): - if key in dev_dict: - self.gw_devices[dev_id][key] = value - if "binary_sensors" in dev_dict: - for key, value in list(data.items()): - update_helper( - data, self.gw_devices, dev_dict, dev_id, "binary_sensors", key - ) - pw_notification_updater( - self.gw_devices, dev_id, dev_dict, self.notifications - ) - if "sensors" in dev_dict: - for key, value in list(data.items()): - update_helper( - data, self.gw_devices, dev_dict, dev_id, "sensors", key - ) - device_state_updater(data, self.gw_devices, dev_id, dev_dict) - if "switches" in dev_dict: - for key, value in list(data.items()): - update_helper( - data, self.gw_devices, dev_dict, dev_id, "switches", key - ) - def _append_special(self, data, d_id, bs_list, s_list): """Helper-function for smile.py: _all_device_data(). When conditions are met, the plugwise_notification binary_sensor @@ -266,7 +61,7 @@ def _append_special(self, data, d_id, bs_list, s_list): def _all_device_data(self): """Helper-function for get_all_devices(). - Collect initial data for each device and add to self.gw_devices. + Collect initial data for each device and add to self.gw_data and self.gw_devices. """ dev_id_list = [] dev_and_data_list = [] @@ -292,6 +87,12 @@ def _all_device_data(self): self.gw_devices = dict(zip(dev_id_list, dev_and_data_list)) + self.gw_data["gateway_id"] = self.gateway_id + self.gw_data["heater_id"] = self._heater_id + self.gw_data["smile_name"] = self.smile_name + self.gw_data["active_device"] = self._active_device_present + self.gw_data["single_master_thermostat"] = self.single_master_thermostat() + def get_all_devices(self): """Determine the devices present from the obtained XML-data.""" self._devices = {} @@ -389,7 +190,7 @@ def _device_data_climate(self, details, device_data): return device_data def _get_device_data(self, dev_id): - """Helper-function for _all_device_data() and update_gw_devices(). + """Helper-function for _all_device_data() and async_update(). Provide device-data, based on Location ID (= dev_id), from APPLIANCES. """ devices = self._devices @@ -447,6 +248,244 @@ def single_master_thermostat(self): return True return False + +class Smile(SmileComm, SmileData): + """The Plugwise SmileConnect class.""" + + # pylint: disable=too-many-instance-attributes, too-many-public-methods + + def __init__( + self, + host, + password, + username=DEFAULT_USERNAME, + port=DEFAULT_PORT, + timeout=DEFAULT_TIMEOUT, + websession: aiohttp.ClientSession = None, + ): + """Set the constructor for this class.""" + super().__init__( + host, + password, + username, + port, + timeout, + websession, + ) + + self._active_device_present = None + self._appliances = None + self._appl_data = {} + self._domain_objects = None + self._heater_id = None + self._home_location = None + self._locations = None + self._modules = None + self._smile_legacy = False + self._stretch_v2 = False + self._stretch_v3 = False + self._thermo_locs = None + + self.gateway_id = None + self.gw_data = {} + self.gw_devices = {} + self.notifications = {} + self.smile_hostname = None + self.smile_name = None + self.smile_type = None + self.smile_version = () + + async def connect(self): + """Connect to Plugwise device and determine its name, type and version.""" + names = [] + + result = await self._request(DOMAIN_OBJECTS) + dsmrmain = result.find(".//module/protocols/dsmrmain") + + vendor_names = result.findall(".//module/vendor_name") + for name in vendor_names: + names.append(name.text) + + if "Plugwise" not in names: + if dsmrmain is None: # pragma: no cover + _LOGGER.error( + "Connected but expected text not returned, \ + we got %s", + result, + ) + raise ConnectionFailedError + + # Determine smile specifics + await self._smile_detect(result, dsmrmain) + + # Update all endpoints on first connect + await self._full_update_device() + + return True + + async def _smile_detect_legacy(self, result, dsmrmain): + """Helper-function for _smile_detect().""" + network = result.find(".//module/protocols/network_router/network") + + # Assume legacy + self._smile_legacy = True + # Try if it is an Anna, assuming appliance thermostat + anna = result.find('.//appliance[type="thermostat"]') + # Fake insert version assuming Anna + # couldn't find another way to identify as legacy Anna + version = "1.8.0" + model = "smile_thermo" + if anna is None: + # P1 legacy: + if dsmrmain is not None: + try: + status = await self._request(STATUS) + version = status.find(".//system/version").text + model = status.find(".//system/product").text + self.smile_hostname = status.find(".//network/hostname").text + except InvalidXMLError: # pragma: no cover + # Corner case check + raise ConnectionFailedError + + # Stretch: + elif network is not None: + try: + system = await self._request(SYSTEM) + version = system.find(".//gateway/firmware").text + model = system.find(".//gateway/product").text + self.smile_hostname = system.find(".//gateway/hostname").text + self.gateway_id = network.attrib["id"] + except InvalidXMLError: # pragma: no cover + # Corner case check + raise ConnectionFailedError + else: # pragma: no cover + # No cornercase, just end of the line + _LOGGER.error("Connected but no gateway device information found") + raise ConnectionFailedError + return model, version + + async def _smile_detect(self, result, dsmrmain): + """Helper-function for connect(). + Detect which type of Smile is connected. + """ + model = None + gateway = result.find(".//gateway") + + if gateway is not None: + model = result.find(".//gateway/vendor_model").text + version = result.find(".//gateway/firmware_version").text + if gateway.find("hostname") is not None: + self.smile_hostname = gateway.find("hostname").text + else: + model, version = await self._smile_detect_legacy(result, dsmrmain) + + if model is None or version is None: # pragma: no cover + # Corner case check + _LOGGER.error("Unable to find model or version information") + raise UnsupportedDeviceError + + ver = semver.VersionInfo.parse(version) + target_smile = f"{model}_v{ver.major}" + + _LOGGER.debug("Plugwise identified as %s", target_smile) + + if target_smile not in SMILES: + _LOGGER.error( + 'Your version Smile identified as "%s" seems\ + unsupported by our plugin, please create an issue\ + on http://github.com/plugwise/python-plugwise!', + target_smile, + ) + raise UnsupportedDeviceError + + self.smile_name = SMILES[target_smile]["friendly_name"] + self.smile_type = SMILES[target_smile]["type"] + self.smile_version = (version, ver) + + if "legacy" in SMILES[target_smile]: + self._smile_legacy = SMILES[target_smile]["legacy"] + + if self.smile_type == "stretch": + self._stretch_v2 = self.smile_version[1].major == 2 + self._stretch_v3 = self.smile_version[1].major == 3 + + async def _full_update_device(self): + """Perform a first fetch of all XML data, needed for initialization.""" + self._locations = await self._request(LOCATIONS) + self._modules = await self._request(MODULES) + + # P1 legacy has no appliances + if not (self.smile_type == "power" and self._smile_legacy): + self._appliances = await self._request(APPLIANCES) + + # No need to import domain_objects and modules for P1, no useful info + if self.smile_type != "power": + await self._update_domain_objects() + + async def _update_domain_objects(self): + """Helper-function for smile.py: full_update_device() and async_update(). + Request domain_objects data. + """ + self._domain_objects = await self._request(DOMAIN_OBJECTS) + + # If Plugwise notifications present: + self.notifications = {} + url = f"{self._endpoint}{DOMAIN_OBJECTS}" + notifications = self._domain_objects.findall(".//notification") + for notification in notifications: + try: + msg_id = notification.attrib["id"] + msg_type = notification.find("type").text + msg = notification.find("message").text + self.notifications.update({msg_id: {msg_type: msg}}) + _LOGGER.debug("Plugwise notifications: %s", self.notifications) + except AttributeError: # pragma: no cover + _LOGGER.info( + "Plugwise notification present but unable to process, manually investigate: %s", + url, + ) + + async def async_update(self): + """Perform an incremental update for updating the various device states.""" + if self.smile_type != "power": + await self._update_domain_objects() + else: + self._locations = await self._request(LOCATIONS) + + # P1 legacy has no appliances + if not (self.smile_type == "power" and self._smile_legacy): + self._appliances = await self._request(APPLIANCES) + + data = [] + self.gw_data["notifications"] = self.notifications + + for dev_id, dev_dict in self.gw_devices.items(): + data = self._get_device_data(dev_id) + for key, value in list(data.items()): + if key in dev_dict: + self.gw_devices[dev_id][key] = value + if "binary_sensors" in dev_dict: + for key, value in list(data.items()): + update_helper( + data, self.gw_devices, dev_dict, dev_id, "binary_sensors", key + ) + pw_notification_updater( + self.gw_devices, dev_id, dev_dict, self.notifications + ) + if "sensors" in dev_dict: + for key, value in list(data.items()): + update_helper( + data, self.gw_devices, dev_dict, dev_id, "sensors", key + ) + device_state_updater(data, self.gw_devices, dev_id, dev_dict) + if "switches" in dev_dict: + for key, value in list(data.items()): + update_helper( + data, self.gw_devices, dev_dict, dev_id, "switches", key + ) + + return [self.gw_data, self.gw_devices] + async def _set_schedule_state_legacy(self, name, state): """Helper-function for set_schedule_state().""" schema_rule_id = None diff --git a/tests/test_smile.py b/tests/test_smile.py index 594412b68..ed47f7812 100644 --- a/tests/test_smile.py +++ b/tests/test_smile.py @@ -1,7 +1,6 @@ """Test Plugwise Home Assistant module and generate test JSON fixtures.""" import asyncio -import datetime as dt import importlib # Fixture writing @@ -407,14 +406,15 @@ async def device_test(self, smile=pw_smile.Smile, testdata=None): ] bsw_list = ["binary_sensors", "sensors", "switches"] smile.get_all_devices() - await smile.update_gw_devices() - device_list = smile.gw_devices - self._write_json("all_devices", device_list) - self._write_json("notifications", smile.notifications) + data = await smile.async_update() + extra = data[0] + device_list = data[1] + self._write_json("all_data", data) + self._write_json("notifications", extra["notifications"]) location_list = smile._thermo_locs - _LOGGER.info("Gateway id = %s", smile.gateway_id) + _LOGGER.info("Gateway id = %s", extra["gateway_id"]) _LOGGER.info("Hostname = %s", smile.smile_hostname) self.show_setup(location_list, device_list) pp4 = PrettyPrinter(indent=4) @@ -432,16 +432,16 @@ async def device_test(self, smile=pw_smile.Smile, testdata=None): for dev_id, details in device_list.items(): if testdevice == dev_id: thermostat = None - data = smile.gw_devices[dev_id] + dev_data = device_list[dev_id] _LOGGER.info( "%s", "- Testing data for device {} ({})".format( details["name"], dev_id ), ) - _LOGGER.info(" + Device data: %s", data) - if data["class"] in MASTER_THERMOSTATS: - thermostat = pw_entities.GWThermostat(smile, dev_id) + _LOGGER.info(" + Device data: %s", dev_data) + if dev_data["class"] in MASTER_THERMOSTATS: + thermostat = pw_entities.GWThermostat(data, dev_id) thermostat.update_data() _LOGGER.info( "%s", @@ -462,29 +462,23 @@ async def device_test(self, smile=pw_smile.Smile, testdata=None): tests -= 1 for a, a_item in enumerate(measure_assert): tests += 1 - for b, b_item in enumerate(data[measure_key]): + for b, b_item in enumerate(dev_data[measure_key]): if a_item["id"] != b_item["id"]: continue - if isinstance(a_item["state"], list): - tests += 1 - asserts += 1 - assert b_item["state"] == a_item["state"][0] - asserts += 1 + asserts += 1 + if measure_key == "binary_sensors": + b_sensor = None + b_sensor = pw_entities.GWBinarySensor( + data, dev_id, a_item["id"] + ) + b_sensor.update_data() + assert ( + self.bs_prop_selector("state", b_sensor) + == a_item["state"] + ) else: - asserts += 1 - if measure_key == "binary_sensors": - b_sensor = None - b_sensor = pw_entities.GWBinarySensor( - smile, dev_id, a_item["id"] - ) - b_sensor.update_data() - assert ( - self.bs_prop_selector("state", b_sensor) - == a_item["state"] - ) - else: - assert b_item["state"] == a_item["state"] + assert b_item["state"] == a_item["state"] elif self.th_prop_selector(measure_key, thermostat): asserts += 1 assert ( @@ -492,9 +486,9 @@ async def device_test(self, smile=pw_smile.Smile, testdata=None): == measure_assert ) else: - if measure_key in data: + if measure_key in dev_data: asserts += 1 - assert data[measure_key] == measure_assert + assert dev_data[measure_key] == measure_assert assert tests == asserts @@ -1533,10 +1527,7 @@ async def test_connect_p1v3(self): "id": "electricity_consumed_off_peak_cumulative", "state": 10263.159, }, - { - "id": "electricity_consumed_peak_interval", - "state": [179, dt.datetime(2020, 3, 12, 19, 45)], - }, + {"id": "electricity_consumed_peak_interval", "state": 179}, {"id": "net_electricity_cumulative", "state": 17965.326}, ] } @@ -1778,12 +1769,7 @@ async def test_connect_stretch_v31(self): }, # Vaatwasser "aac7b735042c4832ac9ff33aae4f453b": { - "sensors": [ - { - "id": "electricity_consumed_interval", - "state": [0.71, dt.datetime(2020, 9, 6, 12, 00)], - } - ] + "sensors": [{"id": "electricity_consumed_interval", "state": 0.71}] }, } @@ -1822,12 +1808,7 @@ async def test_connect_stretch_v23(self): }, # Wasdroger 043AECA "fd1b74f59e234a9dae4e23b2b5cf07ed": { - "sensors": [ - { - "id": "electricity_consumed_interval", - "state": [0.21, dt.datetime(2020, 8, 3, 20, 00)], - } - ] + "sensors": [{"id": "electricity_consumed_interval", "state": 0.21}] }, }