From 0364cd8db5f5fdd1eb9332d66618598add2df9e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2020 19:23:47 -1000 Subject: [PATCH] =?UTF-8?q?Add=20usage=20sensors=20for=20each=20device=20s?= =?UTF-8?q?ense=20detects=20that=20show=20powe=E2=80=A6=20(#32206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init * const * update docs string * update docs string * restore binary sensors * Restore binary sensors * match name * pylint * Fix bug in conf migration * Fix refactoring error * Address review items Imporve performance * Fix devices never turning back off --- homeassistant/components/sense/__init__.py | 42 ++- .../components/sense/binary_sensor.py | 95 +++--- homeassistant/components/sense/const.py | 52 ++++ homeassistant/components/sense/sensor.py | 279 +++++++++++++++--- 4 files changed, 357 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index f54e4092178..d7887f7ab01 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -24,11 +24,13 @@ from .const import ( DOMAIN, SENSE_DATA, SENSE_DEVICE_UPDATE, + SENSE_DEVICES_DATA, + SENSE_DISCOVERED_DEVICES_DATA, ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "binary_sensor"] +PLATFORMS = ["binary_sensor", "sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -44,6 +46,24 @@ CONFIG_SCHEMA = vol.Schema( ) +class SenseDevicesData: + """Data for each sense device.""" + + def __init__(self): + """Create.""" + self._data_by_device = {} + + def set_devices_data(self, devices): + """Store a device update.""" + self._data_by_device = {} + for device in devices: + self._data_by_device[device["id"]] = device + + def get_device_by_id(self, sense_device_id): + """Get the latest device data.""" + return self._data_by_device.get(sense_device_id) + + async def async_setup(hass: HomeAssistant, config: dict): """Set up the Sense component.""" hass.data.setdefault(DOMAIN, {}) @@ -58,7 +78,7 @@ async def async_setup(hass: HomeAssistant, config: dict): data={ CONF_EMAIL: conf[CONF_EMAIL], CONF_PASSWORD: conf[CONF_PASSWORD], - CONF_TIMEOUT: conf.get[CONF_TIMEOUT], + CONF_TIMEOUT: conf[CONF_TIMEOUT], }, ) ) @@ -84,7 +104,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except SenseAPITimeoutException: raise ConfigEntryNotReady - hass.data[DOMAIN][entry.entry_id] = {SENSE_DATA: gateway} + sense_devices_data = SenseDevicesData() + sense_discovered_devices = await gateway.get_discovered_device_data() + + hass.data[DOMAIN][entry.entry_id] = { + SENSE_DATA: gateway, + SENSE_DEVICES_DATA: sense_devices_data, + SENSE_DISCOVERED_DEVICES_DATA: sense_discovered_devices, + } for component in PLATFORMS: hass.async_create_task( @@ -94,14 +121,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_sense_update(now): """Retrieve latest state.""" try: - gateway = hass.data[DOMAIN][entry.entry_id][SENSE_DATA] await gateway.update_realtime() - async_dispatcher_send( - hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}" - ) except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") + data = gateway.get_realtime() + if "devices" in data: + sense_devices_data.set_devices_data(data["devices"]) + async_dispatcher_send(hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}") + hass.data[DOMAIN][entry.entry_id][ "track_time_remove_callback" ] = async_track_time_interval( diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 2ae79d71e5a..50fb3fd7dc7 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -2,69 +2,36 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import DEVICE_CLASS_POWER from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_registry import async_get_registry -from .const import DOMAIN, SENSE_DATA, SENSE_DEVICE_UPDATE +from .const import ( + DOMAIN, + MDI_ICONS, + SENSE_DATA, + SENSE_DEVICE_UPDATE, + SENSE_DEVICES_DATA, + SENSE_DISCOVERED_DEVICES_DATA, +) _LOGGER = logging.getLogger(__name__) -ATTR_WATTS = "watts" -DEVICE_ID_SOLAR = "solar" -BIN_SENSOR_CLASS = "power" -MDI_ICONS = { - "ac": "air-conditioner", - "aquarium": "fish", - "car": "car-electric", - "computer": "desktop-classic", - "cup": "coffee", - "dehumidifier": "water-off", - "dishes": "dishwasher", - "drill": "toolbox", - "fan": "fan", - "freezer": "fridge-top", - "fridge": "fridge-bottom", - "game": "gamepad-variant", - "garage": "garage", - "grill": "stove", - "heat": "fire", - "heater": "radiatior", - "humidifier": "water", - "kettle": "kettle", - "leafblower": "leaf", - "lightbulb": "lightbulb", - "media_console": "set-top-box", - "modem": "router-wireless", - "outlet": "power-socket-us", - "papershredder": "shredder", - "printer": "printer", - "pump": "water-pump", - "settings": "settings", - "skillet": "pot", - "smartcamera": "webcam", - "socket": "power-plug", - "solar_alt": "solar-power", - "sound": "speaker", - "stove": "stove", - "trash": "trash-can", - "tv": "television", - "vacuum": "robot-vacuum", - "washer": "washing-machine", -} - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense binary sensor.""" data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] + sense_devices_data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DEVICES_DATA] sense_monitor_id = data.sense_monitor_id - sense_devices = await data.get_discovered_device_data() + sense_devices = hass.data[DOMAIN][config_entry.entry_id][ + SENSE_DISCOVERED_DEVICES_DATA + ] devices = [ - SenseDevice(data, device, sense_monitor_id) + SenseDevice(sense_devices_data, device, sense_monitor_id) for device in sense_devices - if device["id"] == DEVICE_ID_SOLAR - or device["tags"]["DeviceListAllowed"] == "true" + if device["tags"]["DeviceListAllowed"] == "true" ] await _migrate_old_unique_ids(hass, devices) @@ -96,20 +63,27 @@ def sense_to_mdi(sense_icon): class SenseDevice(BinarySensorDevice): """Implementation of a Sense energy device binary sensor.""" - def __init__(self, data, device, sense_monitor_id): + def __init__(self, sense_devices_data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" self._name = device["name"] self._id = device["id"] self._sense_monitor_id = sense_monitor_id self._unique_id = f"{sense_monitor_id}-{self._id}" self._icon = sense_to_mdi(device["icon"]) - self._data = data + self._sense_devices_data = sense_devices_data self._undo_dispatch_subscription = None + self._state = None + self._available = False @property def is_on(self): """Return true if the binary sensor is on.""" - return self._name in self._data.active_devices + return self._state + + @property + def available(self): + """Return the availability of the binary sensor.""" + return self._available @property def name(self): @@ -134,7 +108,7 @@ class SenseDevice(BinarySensorDevice): @property def device_class(self): """Return the device class of the binary sensor.""" - return BIN_SENSOR_CLASS + return DEVICE_CLASS_POWER @property def should_poll(self): @@ -143,17 +117,20 @@ class SenseDevice(BinarySensorDevice): async def async_added_to_hass(self): """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", update + self.hass, + f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", + self._async_update_from_data, ) async def async_will_remove_from_hass(self): """Undo subscription.""" if self._undo_dispatch_subscription: self._undo_dispatch_subscription() + + @callback + def _async_update_from_data(self): + """Get the latest data, update state. Must not do I/O.""" + self._available = True + self._state = bool(self._sense_devices_data.get_device_by_id(self._id)) + self.async_write_ha_state() diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index cc30591e02a..619956903f2 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -5,3 +5,55 @@ ACTIVE_UPDATE_RATE = 60 DEFAULT_NAME = "Sense" SENSE_DATA = "sense_data" SENSE_DEVICE_UPDATE = "sense_devices_update" +SENSE_DEVICES_DATA = "sense_devices_data" +SENSE_DISCOVERED_DEVICES_DATA = "sense_discovered_devices" + +ACTIVE_NAME = "Energy" +ACTIVE_TYPE = "active" + +CONSUMPTION_NAME = "Usage" +CONSUMPTION_ID = "usage" +PRODUCTION_NAME = "Production" +PRODUCTION_ID = "production" + +ICON = "mdi:flash" + +MDI_ICONS = { + "ac": "air-conditioner", + "aquarium": "fish", + "car": "car-electric", + "computer": "desktop-classic", + "cup": "coffee", + "dehumidifier": "water-off", + "dishes": "dishwasher", + "drill": "toolbox", + "fan": "fan", + "freezer": "fridge-top", + "fridge": "fridge-bottom", + "game": "gamepad-variant", + "garage": "garage", + "grill": "stove", + "heat": "fire", + "heater": "radiatior", + "humidifier": "water", + "kettle": "kettle", + "leafblower": "leaf", + "lightbulb": "lightbulb", + "media_console": "set-top-box", + "modem": "router-wireless", + "outlet": "power-socket-us", + "papershredder": "shredder", + "printer": "printer", + "pump": "water-pump", + "settings": "settings", + "skillet": "pot", + "smartcamera": "webcam", + "socket": "power-plug", + "solar_alt": "solar-power", + "sound": "speaker", + "stove": "stove", + "trash": "trash-can", + "tv": "television", + "vacuum": "robot-vacuum", + "washer": "washing-machine", +} diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 8d3c8f9e171..6fe7b59c46c 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -4,24 +4,32 @@ import logging from sense_energy import SenseAPITimeoutException -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from .const import DOMAIN, SENSE_DATA - -_LOGGER = logging.getLogger(__name__) - -ACTIVE_NAME = "Energy" -ACTIVE_TYPE = "active" - -CONSUMPTION_NAME = "Usage" - -ICON = "mdi:flash" +from .const import ( + ACTIVE_NAME, + ACTIVE_TYPE, + CONSUMPTION_ID, + CONSUMPTION_NAME, + DOMAIN, + ICON, + MDI_ICONS, + PRODUCTION_ID, + PRODUCTION_NAME, + SENSE_DATA, + SENSE_DEVICE_UPDATE, + SENSE_DEVICES_DATA, + SENSE_DISCOVERED_DEVICES_DATA, +) MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) -PRODUCTION_NAME = "Production" + +_LOGGER = logging.getLogger(__name__) class SensorConfig: @@ -34,8 +42,10 @@ class SensorConfig: # Sensor types/ranges -SENSOR_TYPES = { - "active": SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), +ACTIVE_SENSOR_TYPE = SensorConfig(ACTIVE_NAME, ACTIVE_TYPE) + +# Sensor types/ranges +TRENDS_SENSOR_TYPES = { "daily": SensorConfig("Daily", "DAY"), "weekly": SensorConfig("Weekly", "WEEK"), "monthly": SensorConfig("Monthly", "MONTH"), @@ -43,47 +53,157 @@ SENSOR_TYPES = { } # Production/consumption variants -SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] +SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID] + + +def sense_to_mdi(sense_icon): + """Convert sense icon to mdi icon.""" + return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug")) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense sensor.""" data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] + sense_devices_data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DEVICES_DATA] @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) async def update_trends(): """Update the daily power usage.""" await data.update_trend_data() - async def update_active(): - """Update the active power usage.""" - await data.update_realtime() - sense_monitor_id = data.sense_monitor_id + sense_devices = hass.data[DOMAIN][config_entry.entry_id][ + SENSE_DISCOVERED_DEVICES_DATA + ] + await data.update_trend_data() - devices = [] - for type_id in SENSOR_TYPES: - typ = SENSOR_TYPES[type_id] + devices = [ + SenseEnergyDevice(sense_devices_data, device, sense_monitor_id) + for device in sense_devices + if device["tags"]["DeviceListAllowed"] == "true" + ] + + for var in SENSOR_VARIANTS: + name = ACTIVE_SENSOR_TYPE.name + sensor_type = ACTIVE_SENSOR_TYPE.sensor_type + is_production = var == PRODUCTION_ID + + unique_id = f"{sense_monitor_id}-active-{var}" + devices.append( + SenseActiveSensor( + data, name, sensor_type, is_production, sense_monitor_id, var, unique_id + ) + ) + + for type_id in TRENDS_SENSOR_TYPES: + typ = TRENDS_SENSOR_TYPES[type_id] for var in SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type - is_production = var == PRODUCTION_NAME.lower() - if sensor_type == ACTIVE_TYPE: - update_call = update_active - else: - update_call = update_trends + is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-{type_id}-{var}".lower() + unique_id = f"{sense_monitor_id}-{type_id}-{var}" devices.append( - Sense( - data, name, sensor_type, is_production, update_call, var, unique_id + SenseTrendsSensor( + data, + name, + sensor_type, + is_production, + update_trends, + var, + unique_id, ) ) async_add_entities(devices) -class Sense(Entity): +class SenseActiveSensor(Entity): + """Implementation of a Sense energy sensor.""" + + def __init__( + self, + data, + name, + sensor_type, + is_production, + sense_monitor_id, + sensor_id, + unique_id, + ): + """Initialize the Sense sensor.""" + name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME + self._name = f"{name} {name_type}" + self._unique_id = unique_id + self._available = False + self._data = data + self._sense_monitor_id = sense_monitor_id + self._sensor_type = sensor_type + self._is_production = is_production + self._state = None + self._undo_dispatch_subscription = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return POWER_WATT + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def should_poll(self): + """Return the device should not poll for updates.""" + return False + + async def async_added_to_hass(self): + """Register callbacks.""" + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, + f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", + self._async_update_from_data, + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() + + @callback + def _async_update_from_data(self): + """Update the sensor from the data. Must not do I/O.""" + self._state = round( + self._data.active_solar_power + if self._is_production + else self._data.active_power + ) + self._available = True + self.async_write_ha_state() + + +class SenseTrendsSensor(Entity): """Implementation of a Sense energy sensor.""" def __init__( @@ -99,11 +219,7 @@ class Sense(Entity): self.update_sensor = update_call self._is_production = is_production self._state = None - - if sensor_type == ACTIVE_TYPE: - self._unit_of_measurement = POWER_WATT - else: - self._unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR @property def name(self): @@ -144,13 +260,86 @@ class Sense(Entity): _LOGGER.error("Timeout retrieving data") return - if self._sensor_type == ACTIVE_TYPE: - if self._is_production: - self._state = round(self._data.active_solar_power) - else: - self._state = round(self._data.active_power) - else: - state = self._data.get_trend(self._sensor_type, self._is_production) - self._state = round(state, 1) - + state = self._data.get_trend(self._sensor_type, self._is_production) + self._state = round(state, 1) self._available = True + + +class SenseEnergyDevice(Entity): + """Implementation of a Sense energy device.""" + + def __init__(self, sense_devices_data, device, sense_monitor_id): + """Initialize the Sense binary sensor.""" + self._name = f"{device['name']} {CONSUMPTION_NAME}" + self._id = device["id"] + self._available = False + self._sense_monitor_id = sense_monitor_id + self._unique_id = f"{sense_monitor_id}-{self._id}-{CONSUMPTION_ID}" + self._icon = sense_to_mdi(device["icon"]) + self._sense_devices_data = sense_devices_data + self._undo_dispatch_subscription = None + self._state = None + + @property + def state(self): + """Return the wattage of the sensor.""" + return self._state + + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + + @property + def name(self): + """Return the name of the power sensor.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the power sensor.""" + return self._unique_id + + @property + def icon(self): + """Return the icon of the power sensor.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return POWER_WATT + + @property + def device_class(self): + """Return the device class of the power sensor.""" + return DEVICE_CLASS_POWER + + @property + def should_poll(self): + """Return the device should not poll for updates.""" + return False + + async def async_added_to_hass(self): + """Register callbacks.""" + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, + f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", + self._async_update_from_data, + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() + + @callback + def _async_update_from_data(self): + """Get the latest data, update state. Must not do I/O.""" + device_data = self._sense_devices_data.get_device_by_id(self._id) + if not device_data or "w" not in device_data: + self._state = 0 + else: + self._state = int(device_data["w"]) + self._available = True + self.async_write_ha_state()