diff --git a/.coveragerc b/.coveragerc index 5003e179b80..23b00aed6de 100644 --- a/.coveragerc +++ b/.coveragerc @@ -606,6 +606,7 @@ omit = homeassistant/components/plex/sensor.py homeassistant/components/plugwise/__init__.py homeassistant/components/plugwise/climate.py + homeassistant/components/plugwise/sensor.py homeassistant/components/plum_lightpad/* homeassistant/components/pocketcasts/sensor.py homeassistant/components/point/* diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index c6509091e1c..610acfc3e23 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -3,13 +3,14 @@ import asyncio from datetime import timedelta import logging +from typing import Dict from Plugwise_Smile.Smile import Smile import async_timeout import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,7 +23,8 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) _LOGGER = logging.getLogger(__name__) -ALL_PLATFORMS = ["climate"] +SENSOR_PLATFORMS = ["sensor"] +ALL_PLATFORMS = ["climate", "sensor"] async def async_setup(hass: HomeAssistant, config: dict): @@ -100,7 +102,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=api.smile_version[0], ) - for component in ALL_PLATFORMS: + platforms = ALL_PLATFORMS + + single_master_thermostat = api.single_master_thermostat() + if single_master_thermostat is None: + platforms = SENSOR_PLATFORMS + + for component in platforms: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) @@ -127,11 +135,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class SmileGateway(Entity): """Represent Smile Gateway.""" - def __init__(self, api, coordinator): - """Initialise the sensor.""" + def __init__(self, api, coordinator, name, dev_id): + """Initialise the gateway.""" self._api = api self._coordinator = coordinator + self._name = name + self._dev_id = dev_id + self._unique_id = None + self._model = None + + self._entity_name = self._name @property def unique_id(self): @@ -148,11 +162,40 @@ class SmileGateway(Entity): """Return True if entity is available.""" return self._coordinator.last_update_success + @property + def name(self): + """Return the name of the entity, if any.""" + if not self._name: + return None + return self._name + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + + device_information = { + "identifiers": {(DOMAIN, self._dev_id)}, + "name": self._entity_name, + "manufacturer": "Plugwise", + } + + if self._model is not None: + device_information["model"] = self._model.replace("_", " ").title() + + if self._dev_id != self._api.gateway_id: + device_information["via_device"] = (DOMAIN, self._api.gateway_id) + + return device_information + async def async_added_to_hass(self): """Subscribe to updates.""" - self.async_on_remove(self._coordinator.async_add_listener(self._process_data)) + self._async_process_data() + self.async_on_remove( + self._coordinator.async_add_listener(self._async_process_data) + ) - def _process_data(self): + @callback + def _async_process_data(self): """Interpret and process API data.""" raise NotImplementedError diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 209fdcdd242..42d4aa462b6 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -1,7 +1,6 @@ """Plugwise Climate component for Home Assistant.""" import logging -from typing import Dict from Plugwise_Smile.Smile import Smile @@ -17,16 +16,10 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback from . import SmileGateway -from .const import ( - DEFAULT_MAX_TEMP, - DEFAULT_MIN_TEMP, - DOMAIN, - SCHEDULE_OFF, - SCHEDULE_ON, - THERMOSTAT_ICON, -) +from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SCHEDULE_OFF, SCHEDULE_ON HVAC_MODES_HEAT_ONLY = [HVAC_MODE_HEAT, HVAC_MODE_AUTO] HVAC_MODES_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO] @@ -47,20 +40,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "zone_thermostat", "thermostatic_radiator_valve", ] - all_entities = api.get_all_devices() + all_devices = api.get_all_devices() - for dev_id, device in all_entities.items(): + for dev_id, device_properties in all_devices.items(): - if device["class"] not in thermostat_classes: + if device_properties["class"] not in thermostat_classes: continue thermostat = PwThermostat( api, coordinator, - device["name"], + device_properties["name"], dev_id, - device["location"], - device["class"], + device_properties["location"], + device_properties["class"], DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, ) @@ -77,11 +70,9 @@ class PwThermostat(SmileGateway, ClimateEntity): self, api, coordinator, name, dev_id, loc_id, model, min_temp, max_temp ): """Set up the Plugwise API.""" - super().__init__(api, coordinator) + super().__init__(api, coordinator, name, dev_id) self._api = api - self._name = name - self._dev_id = dev_id self._loc_id = loc_id self._model = model self._min_temp = min_temp @@ -92,9 +83,9 @@ class PwThermostat(SmileGateway, ClimateEntity): self._preset_mode = None self._presets = None self._presets_list = None - self._boiler_state = None self._heating_state = None self._cooling_state = None + self._compressor_state = None self._dhw_state = None self._hvac_mode = None self._schema_names = None @@ -111,43 +102,16 @@ class PwThermostat(SmileGateway, ClimateEntity): def hvac_action(self): """Return the current action.""" if self._single_thermostat: - if self._heating_state or self._boiler_state: + if self._heating_state: return CURRENT_HVAC_HEAT if self._cooling_state: return CURRENT_HVAC_COOL return CURRENT_HVAC_IDLE - if self._heating_state is not None or self._boiler_state is not None: + if self._heating_state is not None: if self._setpoint > self._temperature: return CURRENT_HVAC_HEAT return CURRENT_HVAC_IDLE - @property - def name(self): - """Return the name of the thermostat, if any.""" - return self._name - - @property - def device_info(self) -> Dict[str, any]: - """Return the device information.""" - - device_information = { - "identifiers": {(DOMAIN, self._dev_id)}, - "name": self._name, - "manufacturer": "Plugwise", - "model": self._model.replace("_", " ").title(), - } - - if self._dev_id != self._api.gateway_id: - device_information["via_device"] = (DOMAIN, self._api.gateway_id) - del device_information["via_device"] - - return device_information - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return THERMOSTAT_ICON - @property def supported_features(self): """Return the list of supported features.""" @@ -172,8 +136,8 @@ class PwThermostat(SmileGateway, ClimateEntity): @property def hvac_modes(self): """Return the available hvac modes list.""" - if self._heating_state is not None or self._boiler_state is not None: - if self._cooling_state is not None: + if self._heating_state is not None: + if self._compressor_state is not None: return HVAC_MODES_HEAT_COOL return HVAC_MODES_HEAT_ONLY @@ -258,7 +222,8 @@ class PwThermostat(SmileGateway, ClimateEntity): except Smile.PlugwiseError: _LOGGER.error("Error while communicating to device") - def _process_data(self): + @callback + def _async_process_data(self): """Update the data for this climate device.""" climate_data = self._api.get_device_data(self._dev_id) heater_central_data = self._api.get_device_data(self._api.heater_id) @@ -286,21 +251,18 @@ class PwThermostat(SmileGateway, ClimateEntity): if "active_preset" in climate_data: self._preset_mode = climate_data["active_preset"] - if "boiler_state" in heater_central_data: - if heater_central_data["boiler_state"] is not None: - self._boiler_state = heater_central_data["boiler_state"] - if "heating_state" in heater_central_data: - if heater_central_data["heating_state"] is not None: - self._heating_state = heater_central_data["heating_state"] - if "cooling_state" in heater_central_data: - if heater_central_data["cooling_state"] is not None: - self._cooling_state = heater_central_data["cooling_state"] + if heater_central_data.get("heating_state") is not None: + self._heating_state = heater_central_data["heating_state"] + if heater_central_data.get("cooling_state") is not None: + self._cooling_state = heater_central_data["cooling_state"] + if heater_central_data.get("compressor_state") is not None: + self._compressor_state = heater_central_data["compressor_state"] if self._schema_status: self._hvac_mode = HVAC_MODE_AUTO - elif self._heating_state is not None or self._boiler_state is not None: + elif self._heating_state is not None: self._hvac_mode = HVAC_MODE_HEAT - if self._cooling_state is not None: + if self._compressor_state is not None: self._hvac_mode = HVAC_MODE_HEAT_COOL self.async_write_ha_state() diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 7182665a506..8f82a107576 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -47,7 +47,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} - api = None + if user_input is not None: try: diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 2f804ef09a3..b57532b9192 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -1,6 +1,11 @@ """Constant for Plugwise component.""" DOMAIN = "plugwise" +# Sensor mapping +SENSOR_MAP_MODEL = 0 +SENSOR_MAP_UOM = 1 +SENSOR_MAP_DEVICE_CLASS = 2 + # Default directives DEFAULT_NAME = "Smile" DEFAULT_USERNAME = "smile" @@ -10,8 +15,6 @@ DEFAULT_MIN_TEMP = 4 DEFAULT_MAX_TEMP = 30 DEFAULT_SCAN_INTERVAL = {"thermostat": 60, "power": 10} -DEVICE_CLASS_GAS = "gas" - # Configuration directives CONF_MIN_TEMP = "min_temp" CONF_MAX_TEMP = "max_temp" @@ -22,21 +25,15 @@ CONF_SOLAR = "solar" CONF_GAS = "gas" ATTR_ILLUMINANCE = "illuminance" +UNIT_LUMEN = "lm" + CURRENT_HVAC_DHW = "hot_water" + DEVICE_STATE = "device_state" SCHEDULE_ON = "true" SCHEDULE_OFF = "false" -# Icons -SWITCH_ICON = "mdi:electric-switch" -THERMOSTAT_ICON = "mdi:thermometer" -WATER_ICON = "mdi:water-pump" -FLAME_ICON = "mdi:fire" COOL_ICON = "mdi:snowflake" +FLAME_ICON = "mdi:fire" IDLE_ICON = "mdi:circle-off-outline" -GAS_ICON = "mdi:fire" -POWER_ICON = "mdi:flash" -POWER_FAILURE_ICON = "mdi:flash-off" -SWELL_SAG_ICON = "mdi:pulse" -VALVE_ICON = "mdi:valve" diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 2a0d5a1e0fc..baecf485682 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -3,7 +3,6 @@ "name": "Plugwise", "documentation": "https://www.home-assistant.io/integrations/plugwise", "requirements": ["Plugwise_Smile==0.2.10"], - "dependencies": [], "codeowners": ["@CoMPaTech", "@bouwew"], "config_flow": true } diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py new file mode 100644 index 00000000000..eabb5c6655f --- /dev/null +++ b/homeassistant/components/plugwise/sensor.py @@ -0,0 +1,354 @@ +"""Plugwise Sensor component for Home Assistant.""" + +import logging + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, + POWER_WATT, + PRESSURE_BAR, + TEMP_CELSIUS, + UNIT_PERCENTAGE, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity + +from . import SmileGateway +from .const import ( + COOL_ICON, + DEVICE_STATE, + DOMAIN, + FLAME_ICON, + IDLE_ICON, + SENSOR_MAP_DEVICE_CLASS, + SENSOR_MAP_MODEL, + SENSOR_MAP_UOM, + UNIT_LUMEN, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_TEMPERATURE = [ + "Temperature", + TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, +] +ATTR_BATTERY_LEVEL = [ + "Charge", + UNIT_PERCENTAGE, + DEVICE_CLASS_BATTERY, +] +ATTR_ILLUMINANCE = [ + "Illuminance", + UNIT_LUMEN, + DEVICE_CLASS_ILLUMINANCE, +] +ATTR_PRESSURE = ["Pressure", PRESSURE_BAR, DEVICE_CLASS_PRESSURE] + +TEMP_SENSOR_MAP = { + "setpoint": ATTR_TEMPERATURE, + "temperature": ATTR_TEMPERATURE, + "intended_boiler_temperature": ATTR_TEMPERATURE, + "temperature_difference": ATTR_TEMPERATURE, + "outdoor_temperature": ATTR_TEMPERATURE, + "water_temperature": ATTR_TEMPERATURE, + "return_temperature": ATTR_TEMPERATURE, +} + +ENERGY_SENSOR_MAP = { + "electricity_consumed": ["Current Consumed Power", POWER_WATT, DEVICE_CLASS_POWER], + "electricity_produced": ["Current Produced Power", POWER_WATT, DEVICE_CLASS_POWER], + "electricity_consumed_interval": [ + "Consumed Power Interval", + ENERGY_WATT_HOUR, + DEVICE_CLASS_POWER, + ], + "electricity_produced_interval": [ + "Produced Power Interval", + ENERGY_WATT_HOUR, + DEVICE_CLASS_POWER, + ], + "electricity_consumed_off_peak_point": [ + "Current Consumed Power (off peak)", + POWER_WATT, + DEVICE_CLASS_POWER, + ], + "electricity_consumed_peak_point": [ + "Current Consumed Power", + POWER_WATT, + DEVICE_CLASS_POWER, + ], + "electricity_consumed_off_peak_cumulative": [ + "Cumulative Consumed Power (off peak)", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_POWER, + ], + "electricity_consumed_peak_cumulative": [ + "Cumulative Consumed Power", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_POWER, + ], + "electricity_produced_off_peak_point": [ + "Current Consumed Power (off peak)", + POWER_WATT, + DEVICE_CLASS_POWER, + ], + "electricity_produced_peak_point": [ + "Current Consumed Power", + POWER_WATT, + DEVICE_CLASS_POWER, + ], + "electricity_produced_off_peak_cumulative": [ + "Cumulative Consumed Power (off peak)", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_POWER, + ], + "electricity_produced_peak_cumulative": [ + "Cumulative Consumed Power", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_POWER, + ], + "gas_consumed_interval": ["Current Consumed Gas", VOLUME_CUBIC_METERS, None], + "gas_consumed_cumulative": ["Cumulative Consumed Gas", VOLUME_CUBIC_METERS, None], + "net_electricity_point": ["Current net Power", POWER_WATT, DEVICE_CLASS_POWER], + "net_electricity_cumulative": [ + "Cumulative net Power", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_POWER, + ], +} + +MISC_SENSOR_MAP = { + "battery": ATTR_BATTERY_LEVEL, + "illuminance": ATTR_ILLUMINANCE, + "modulation_level": ["Heater Modulation Level", UNIT_PERCENTAGE, None], + "valve_position": ["Valve Position", UNIT_PERCENTAGE, None], + "water_pressure": ATTR_PRESSURE, +} + +INDICATE_ACTIVE_LOCAL_DEVICE = [ + "cooling_state", + "flame_state", +] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Smile sensors from a config entry.""" + api = hass.data[DOMAIN][config_entry.entry_id]["api"] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + + entities = [] + all_devices = api.get_all_devices() + single_thermostat = api.single_master_thermostat() + for dev_id, device_properties in all_devices.items(): + data = api.get_device_data(dev_id) + for sensor, sensor_type in { + **TEMP_SENSOR_MAP, + **ENERGY_SENSOR_MAP, + **MISC_SENSOR_MAP, + }.items(): + if sensor in data: + if data[sensor] is None: + continue + + if "power" in device_properties["types"]: + model = None + + if "plug" in device_properties["types"]: + model = "Metered Switch" + + entities.append( + PwPowerSensor( + api, + coordinator, + device_properties["name"], + dev_id, + sensor, + sensor_type, + model, + ) + ) + else: + entities.append( + PwThermostatSensor( + api, + coordinator, + device_properties["name"], + dev_id, + sensor, + sensor_type, + ) + ) + + if single_thermostat is False: + for state in INDICATE_ACTIVE_LOCAL_DEVICE: + if state in data: + entities.append( + PwAuxDeviceSensor( + api, + coordinator, + device_properties["name"], + dev_id, + DEVICE_STATE, + ) + ) + break + + async_add_entities(entities, True) + + +class SmileSensor(SmileGateway): + """Represent Smile Sensors.""" + + def __init__(self, api, coordinator, name, dev_id, sensor): + """Initialise the sensor.""" + super().__init__(api, coordinator, name, dev_id) + + self._sensor = sensor + + self._dev_class = None + self._state = None + self._unit_of_measurement = None + + if dev_id == self._api.heater_id: + self._entity_name = "Auxiliary" + + sensorname = sensor.replace("_", " ").title() + self._name = f"{self._entity_name} {sensorname}" + + if dev_id == self._api.gateway_id: + self._entity_name = f"Smile {self._entity_name}" + + self._unique_id = f"{dev_id}-{sensor}" + + @property + def device_class(self): + """Device class of this entity.""" + return self._dev_class + + @property + def state(self): + """Device class of this entity.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + +class PwThermostatSensor(SmileSensor, Entity): + """Thermostat and climate sensor entities.""" + + def __init__(self, api, coordinator, name, dev_id, sensor, sensor_type): + """Set up the Plugwise API.""" + super().__init__(api, coordinator, name, dev_id, sensor) + + self._model = sensor_type[SENSOR_MAP_MODEL] + self._unit_of_measurement = sensor_type[SENSOR_MAP_UOM] + self._dev_class = sensor_type[SENSOR_MAP_DEVICE_CLASS] + + @callback + def _async_process_data(self): + """Update the entity.""" + data = self._api.get_device_data(self._dev_id) + + if not data: + _LOGGER.error("Received no data for device %s.", self._entity_name) + self.async_write_ha_state() + return + + if data.get(self._sensor) is not None: + measurement = data[self._sensor] + if self._sensor == "battery" or self._sensor == "valve_position": + measurement = measurement * 100 + if self._unit_of_measurement == UNIT_PERCENTAGE: + measurement = int(measurement) + self._state = measurement + + self.async_write_ha_state() + + +class PwAuxDeviceSensor(SmileSensor, Entity): + """Auxiliary sensor entities for the heating/cooling device.""" + + def __init__(self, api, coordinator, name, dev_id, sensor): + """Set up the Plugwise API.""" + super().__init__(api, coordinator, name, dev_id, sensor) + + self._cooling_state = False + self._heating_state = False + self._icon = None + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._icon + + @callback + def _async_process_data(self): + """Update the entity.""" + data = self._api.get_device_data(self._dev_id) + + if not data: + _LOGGER.error("Received no data for device %s.", self._entity_name) + self.async_write_ha_state() + return + + if data.get("heating_state") is not None: + self._heating_state = data["heating_state"] + if data.get("cooling_state") is not None: + self._cooling_state = data["cooling_state"] + + self._state = "idle" + self._icon = IDLE_ICON + if self._heating_state: + self._state = "heating" + self._icon = FLAME_ICON + if self._cooling_state: + self._state = "cooling" + self._icon = COOL_ICON + + self.async_write_ha_state() + + +class PwPowerSensor(SmileSensor, Entity): + """Power sensor entities.""" + + def __init__(self, api, coordinator, name, dev_id, sensor, sensor_type, model): + """Set up the Plugwise API.""" + super().__init__(api, coordinator, name, dev_id, sensor) + + self._model = model + if model is None: + self._model = sensor_type[SENSOR_MAP_MODEL] + + self._unit_of_measurement = sensor_type[SENSOR_MAP_UOM] + self._dev_class = sensor_type[SENSOR_MAP_DEVICE_CLASS] + + if dev_id == self._api.gateway_id: + self._model = "P1 DSMR" + + @callback + def _async_process_data(self): + """Update the entity.""" + data = self._api.get_device_data(self._dev_id) + + if not data: + _LOGGER.error("Received no data for device %s.", self._entity_name) + self.async_write_ha_state() + return + + if data.get(self._sensor) is not None: + measurement = data[self._sensor] + if self._unit_of_measurement == ENERGY_KILO_WATT_HOUR: + measurement = int(measurement / 1000) + self._state = measurement + + self.async_write_ha_state()