From ecf5aba48561bc438c8a6448ccd1cbb975897d66 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Thu, 26 Aug 2021 21:58:12 +1000 Subject: [PATCH 01/15] Automatically add an integration entity of power output sensor. Querying the iotawatt for accumulated energy of a power output sensor will typically return the wrong value due to how the calculations are performed. So rather than relying on the Iotawatt to perform those calculations we instead calculate the energy value locally by using the Riemann Sum Integration. The energy will be calculated after each read (performed every 30s) The generated sensor will have a name in the format: "IotaWatt Output Sensor Integral". One downside over the energy sensor (ending with .wh) is that the energy will not accumulate following a reboot and the data will be lost. This could be improved by querying the IotaWatt for the average power since it was last queried. Fixes #18 --- custom_components/iotawatt/sensor.py | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index dba99d2..892764f 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -9,6 +9,7 @@ ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, POWER_WATT, + TIME_HOURS, ) from homeassistant.core import callback from homeassistant.helpers import entity_registry @@ -18,9 +19,18 @@ from . import IotaWattEntity from .const import COORDINATOR, DOMAIN, SIGNAL_ADD_DEVICE +from homeassistant.components.integration.sensor import ( + DEFAULT_ROUND, + RIGHT_METHOD, + IntegrationSensor, +) + _LOGGER = logging.getLogger(__name__) +ICON_INTEGRATION = "mdi:chart-histogram" + + async def async_setup_entry(hass, config_entry, async_add_entities): """Add sensors for passed config_entry in HA.""" @@ -28,13 +38,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for idx, ent in enumerate(coordinator.data["sensors"]): + sensor = coordinator.data["sensors"][ent] entity = IotaWattSensor( coordinator=coordinator, entity=ent, - mac_address=coordinator.data["sensors"][ent].hub_mac_address, - name=coordinator.data["sensors"][ent].getName(), + mac_address=sensor.hub_mac_address, + name=sensor.getName(), ) entities.append(entity) + type = sensor.getType() + unit = sensor.getUnit() + if type == "Output" and unit == "Watts": + integral = IntegrationSensor( + f"sensor.iotawatt{ entity.unique_id }", + f"{ entity.name } integral", + DEFAULT_ROUND, + None, + TIME_HOURS, + None, + RIGHT_METHOD, + ) + entities.append(integral) async_add_entities(entities) @@ -134,4 +158,4 @@ def _handle_coordinator_update(self) -> None: entity_registry.async_get(self.hass).async_remove(self.entity_id) return - super()._handle_coordinator_update() \ No newline at end of file + super()._handle_coordinator_update() From 4452815f95e6d58a0440c707a18e4b33d3bc690a Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Mon, 30 Aug 2021 15:37:28 +1000 Subject: [PATCH 02/15] Creates a new *.accumulated for each power entities. Those are populated by reading at regular intervals the energy value from the iotawatt. When declaring outputs relying on a mathematical expression (using other than + and - operator), the default energy sensors can't be used While the `integration` integration could be used, the advantage of those sensors is that they won't lose data if the connection to the iotawatt is lost or if rebooting the HA instance. In these cases, the sensor will fetch the data from when it last stop. This change requires iotawattpy 0.1.0 Fixes gtdiehl#18 --- custom_components/iotawatt/__init__.py | 26 +++++-- custom_components/iotawatt/config_flow.py | 2 +- custom_components/iotawatt/manifest.json | 4 +- custom_components/iotawatt/sensor.py | 90 ++++++++++++++++------- 4 files changed, 86 insertions(+), 36 deletions(-) diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index 8b4d746..6a1b536 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -1,7 +1,9 @@ """The iotawatt integration.""" from datetime import timedelta +from decimal import Decimal, DecimalException import logging from typing import Dict, List +from . import context from httpx import AsyncClient from iotawattpy.iotawatt import Iotawatt @@ -92,6 +94,7 @@ def __init__(self, hass: HomeAssistant, api: str, name: str, update_interval: in """Initialize IotaWattUpdater object.""" self.api = api self.sensorlist: Dict[str, List[str]] = {} + self.accumulatedValues = {} super().__init__( hass=hass, @@ -107,15 +110,28 @@ async def _async_update_data(self): sensors = self.api.getSensors() for sensor in sensors["sensors"]: + entry = sensors["sensors"][sensor] if sensor not in self.sensorlist: + _LOGGER.debug(f"First read sensor:{sensor} value:{entry.getValue()}") + unit = entry.getUnit() + suffix = "" + if unit == "WattHours" and entry.getFromStart(): + suffix = " Total Wh" + elif unit == "WattHours": + suffix = ".Wh" + to_add = { "entity": sensor, - "mac_address": sensors["sensors"][sensor].hub_mac_address, - "name": sensors["sensors"][sensor].getName(), + "mac_address": entry.hub_mac_address, + "name": entry.getBaseName() + suffix, } async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE, to_add) - self.sensorlist[sensor] = sensors["sensors"][sensor] - + self.sensorlist[sensor] = entry + if sensor in self.accumulatedValues: + _LOGGER.debug( + f"Accumulating sensor:{sensor} value:{round(self.accumulatedValues[sensor], 3)} with:{entry.getValue()}" + ) + self.accumulatedValues[sensor] += Decimal(entry.getValue()) return sensors @@ -144,4 +160,4 @@ def name(self) -> str: @property def icon(self): """Return the icon for the entity.""" - return self._icon \ No newline at end of file + return self._icon diff --git a/custom_components/iotawatt/config_flow.py b/custom_components/iotawatt/config_flow.py index a81fb05..d58e770 100644 --- a/custom_components/iotawatt/config_flow.py +++ b/custom_components/iotawatt/config_flow.py @@ -99,4 +99,4 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" \ No newline at end of file + """Error to indicate there is invalid auth.""" diff --git a/custom_components/iotawatt/manifest.json b/custom_components/iotawatt/manifest.json index bca2076..e1bc647 100644 --- a/custom_components/iotawatt/manifest.json +++ b/custom_components/iotawatt/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/iotawatt", "issue_tracker": "https://github.com/gtdiehl/iotawatt_ha", "requirements": [ - "iotawattpy==0.0.9" + "iotawattpy==0.1.0" ], "ssdp": [], "zeroconf": [], @@ -14,5 +14,5 @@ "codeowners": [ "@gtdiehl" ], - "version": "0.0.8" + "version": "0.1.0" } diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 892764f..86a576d 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -1,32 +1,35 @@ """Support for IoTaWatt Energy monitor.""" +from decimal import Decimal, DecimalException import logging -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + DEVICE_CLASS_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, + ENERGY_KILO_WATT_HOUR, POWER_WATT, TIME_HOURS, ) from homeassistant.core import callback from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt from . import IotaWattEntity from .const import COORDINATOR, DOMAIN, SIGNAL_ADD_DEVICE -from homeassistant.components.integration.sensor import ( - DEFAULT_ROUND, - RIGHT_METHOD, - IntegrationSensor, -) - _LOGGER = logging.getLogger(__name__) +ATTR_SOURCE_ID = "source" ICON_INTEGRATION = "mdi:chart-histogram" @@ -35,10 +38,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Add sensors for passed config_entry in HA.""" coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + sensors = coordinator.data["sensors"] entities = [] - for idx, ent in enumerate(coordinator.data["sensors"]): - sensor = coordinator.data["sensors"][ent] + for idx, ent in enumerate(sensors): + sensor = sensors[ent] entity = IotaWattSensor( coordinator=coordinator, entity=ent, @@ -46,24 +50,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name=sensor.getName(), ) entities.append(entity) - type = sensor.getType() - unit = sensor.getUnit() - if type == "Output" and unit == "Watts": - integral = IntegrationSensor( - f"sensor.iotawatt{ entity.unique_id }", - f"{ entity.name } integral", - DEFAULT_ROUND, - None, - TIME_HOURS, - None, - RIGHT_METHOD, - ) - entities.append(integral) async_add_entities(entities) async def async_new_entities(sensor_info): - """Remove an entity.""" + """Add an entity.""" ent = sensor_info["entity"] hub_mac_address = sensor_info["mac_address"] name = sensor_info["name"] @@ -80,7 +71,7 @@ async def async_new_entities(sensor_info): async_dispatcher_connect(hass, SIGNAL_ADD_DEVICE, async_new_entities) -class IotaWattSensor(IotaWattEntity): +class IotaWattSensor(IotaWattEntity, RestoreEntity): """Defines a IoTaWatt Energy Sensor.""" def __init__(self, coordinator, entity, mac_address, name): @@ -97,6 +88,7 @@ def __init__(self, coordinator, entity, mac_address, name): self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_force_update = True + self._accumulating = False unit = sensor.getUnit() if unit == "Watts": self._attr_unit_of_measurement = POWER_WATT @@ -104,6 +96,8 @@ def __init__(self, coordinator, entity, mac_address, name): elif unit == "WattHours": self._attr_unit_of_measurement = ENERGY_WATT_HOUR self._attr_device_class = DEVICE_CLASS_ENERGY + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._accumulating = not sensor.getFromStart() elif unit == "Volts": self._attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT self._attr_device_class = DEVICE_CLASS_VOLTAGE @@ -119,19 +113,49 @@ def device_state_attributes(self): channel = "N/A" attrs = {"type": self._io_type, "channel": channel} + if self._accumulating: + attrs["last_update"] = self.coordinator.api.getLastUpdateTime().isoformat() return attrs + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + if self._accumulating: + state = await self.async_get_last_state() + newValue = Decimal(self.coordinator.data["sensors"][self._ent].getValue()) + if state: + try: + self.coordinator.accumulatedValues[self._ent] = ( + Decimal(state.state) + newValue + ) + _LOGGER.debug( + f"Entity:{self._ent} Restored:{Decimal(state.state)} newValue:{newValue}" + ) + except (DecimalException, ValueError) as err: + _LOGGER.warning("Could not restore last state: %s", err) + self.coordinator.accumulatedValues[self._ent] = newValue + else: + # No previous history (first setup), set the first one to the last read. + self.coordinator.accumulatedValues[self._ent] = newValue + @property def state(self): """Return the state of the sensor.""" - return self.coordinator.data["sensors"][self._ent].getValue() + if not self._accumulating: + return self.coordinator.data["sensors"][self._ent].getValue() + # Will return None if state hasn't yet been restored. + return ( + round(self.coordinator.accumulatedValues[self._ent], 1) + if self._ent in self.coordinator.accumulatedValues + else None + ) @property def last_reset(self): """Return the time when the sensor was last reset, if any.""" last_reset = self.coordinator.data["sensors"][self._ent].getBegin() - if last_reset is None: + if last_reset is None or self._accumulating: return None return dt.parse_datetime(last_reset) @@ -143,13 +167,23 @@ def name(self): + str(self._io_type) + " " + str(self.coordinator.data["sensors"][self._ent].getName()) + + (".accumulated" if self._accumulating else "") ) return name @property def unique_id(self) -> str: - """Return the Uniqie ID for the sensor.""" - return self.coordinator.data["sensors"][self._ent].getSensorID() + """Return the Unique ID for the sensor.""" + return self.coordinator.data["sensors"][self._ent].getSensorID() + ( + ".accumulated" if self._accumulating else "" + ) + + @property + def icon(self): + """Return the icon for the entity.""" + if self._accumulating: + return ICON_INTEGRATION + return super().icon @callback def _handle_coordinator_update(self) -> None: From 78396ff6d3bde25c4a3696f80b584af587c83b69 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Mon, 30 Aug 2021 22:25:31 +1000 Subject: [PATCH 03/15] Handle accumulation of energy read in sensors.py fly-by change, re-organise method order to be more consistent with other modules. --- custom_components/iotawatt/__init__.py | 7 +-- custom_components/iotawatt/sensor.py | 66 ++++++++++++++++---------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index 6a1b536..af1fa28 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -110,8 +110,8 @@ async def _async_update_data(self): sensors = self.api.getSensors() for sensor in sensors["sensors"]: - entry = sensors["sensors"][sensor] if sensor not in self.sensorlist: + entry = sensors["sensors"][sensor] _LOGGER.debug(f"First read sensor:{sensor} value:{entry.getValue()}") unit = entry.getUnit() suffix = "" @@ -127,11 +127,6 @@ async def _async_update_data(self): } async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE, to_add) self.sensorlist[sensor] = entry - if sensor in self.accumulatedValues: - _LOGGER.debug( - f"Accumulating sensor:{sensor} value:{round(self.accumulatedValues[sensor], 3)} with:{entry.getValue()}" - ) - self.accumulatedValues[sensor] += Decimal(entry.getValue()) return sensors diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 86a576d..47d2b70 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -89,6 +89,9 @@ def __init__(self, coordinator, entity, mac_address, name): self._attr_force_update = True self._accumulating = False + self._accumulatingValue = None + self._handle_coordinator_update_called = False + unit = sensor.getUnit() if unit == "Watts": self._attr_unit_of_measurement = POWER_WATT @@ -104,11 +107,15 @@ def __init__(self, coordinator, entity, mac_address, name): else: self._attr_unit_of_measurement = unit + @property + def _iotawattEntry(self): + return self.coordinator.data["sensors"][self._ent] + @property def device_state_attributes(self): """Return the state attributes of the device.""" if self._io_type == "Input": - channel = self.coordinator.data["sensors"][self._ent].getChannel() + channel = self._iotawattEntry.getChannel() else: channel = "N/A" @@ -118,43 +125,61 @@ def device_state_attributes(self): return attrs + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._ent not in self.coordinator.data["sensors"]: + entity_registry.async_get(self.hass).async_remove(self.entity_id) + return + + if self._accumulating: + assert ( + self._accumulatedValue is not None + ), "async_added_to_hass must have been called first" + _LOGGER.debug( + f"Accumulating sensor:{self._ent} value:{round(self._accumulatedValue, 3)} with:{self._iotawattEntry.getValue()}" + ) + self._accumulatedValue += Decimal(self._iotawattEntry.getValue()) + + self._handle_coordinator_update_called = True + super()._handle_coordinator_update() + async def async_added_to_hass(self): """Handle entity which will be added.""" + assert ( + not self._handle_coordinator_update_called + ), "_handle_coordinator_update_called must not have been called yet" + await super().async_added_to_hass() if self._accumulating: state = await self.async_get_last_state() - newValue = Decimal(self.coordinator.data["sensors"][self._ent].getValue()) + newValue = Decimal(self._iotawattEntry.getValue()) if state: try: - self.coordinator.accumulatedValues[self._ent] = ( - Decimal(state.state) + newValue - ) + self._accumulatedValue = Decimal(state.state) + newValue _LOGGER.debug( f"Entity:{self._ent} Restored:{Decimal(state.state)} newValue:{newValue}" ) except (DecimalException, ValueError) as err: _LOGGER.warning("Could not restore last state: %s", err) - self.coordinator.accumulatedValues[self._ent] = newValue + self._accumulatedValue = newValue else: # No previous history (first setup), set the first one to the last read. - self.coordinator.accumulatedValues[self._ent] = newValue + self._accumulatedValue = newValue + self.async_write_ha_state() @property def state(self): """Return the state of the sensor.""" if not self._accumulating: - return self.coordinator.data["sensors"][self._ent].getValue() + return self._iotawattEntry.getValue() # Will return None if state hasn't yet been restored. - return ( - round(self.coordinator.accumulatedValues[self._ent], 1) - if self._ent in self.coordinator.accumulatedValues - else None - ) + return round(self._accumulatedValue, 1) if self._accumulatedValue else None @property def last_reset(self): """Return the time when the sensor was last reset, if any.""" - last_reset = self.coordinator.data["sensors"][self._ent].getBegin() + last_reset = self._iotawattEntry.getBegin() if last_reset is None or self._accumulating: return None return dt.parse_datetime(last_reset) @@ -166,7 +191,7 @@ def name(self): "IoTaWatt " + str(self._io_type) + " " - + str(self.coordinator.data["sensors"][self._ent].getName()) + + str(self._iotawattEntry.getName()) + (".accumulated" if self._accumulating else "") ) return name @@ -174,7 +199,7 @@ def name(self): @property def unique_id(self) -> str: """Return the Unique ID for the sensor.""" - return self.coordinator.data["sensors"][self._ent].getSensorID() + ( + return self._iotawattEntry.getSensorID() + ( ".accumulated" if self._accumulating else "" ) @@ -184,12 +209,3 @@ def icon(self): if self._accumulating: return ICON_INTEGRATION return super().icon - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - if self._ent not in self.coordinator.data["sensors"]: - entity_registry.async_get(self.hass).async_remove(self.entity_id) - return - - super()._handle_coordinator_update() From 37fc175fa9b2c0468a7cd59b6df7070ecb94e648 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Mon, 30 Aug 2021 23:00:22 +1000 Subject: [PATCH 04/15] Apply @dgomes comments --- custom_components/iotawatt/__init__.py | 3 +-- custom_components/iotawatt/sensor.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index af1fa28..57fc62e 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -109,9 +109,8 @@ async def _async_update_data(self): await self.api.update() sensors = self.api.getSensors() - for sensor in sensors["sensors"]: + for sensor, entry in sensors["sensors"].items(): if sensor not in self.sensorlist: - entry = sensors["sensors"][sensor] _LOGGER.debug(f"First read sensor:{sensor} value:{entry.getValue()}") unit = entry.getUnit() suffix = "" diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 47d2b70..15e8d88 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -208,4 +208,4 @@ def icon(self): """Return the icon for the entity.""" if self._accumulating: return ICON_INTEGRATION - return super().icon + return super().icon() From 501fd2a804f68f286401942a5b1886b1b3f5dc4b Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Mon, 30 Aug 2021 23:05:06 +1000 Subject: [PATCH 05/15] revert earlier change, it's a property. --- custom_components/iotawatt/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 15e8d88..47d2b70 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -208,4 +208,4 @@ def icon(self): """Return the icon for the entity.""" if self._accumulating: return ICON_INTEGRATION - return super().icon() + return super().icon From 3087fd402128c9b476d0a30cd0685563c86da1c5 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Mon, 30 Aug 2021 23:40:10 +1000 Subject: [PATCH 06/15] Set the icon once only. Remove override --- custom_components/iotawatt/__init__.py | 9 ++------- custom_components/iotawatt/sensor.py | 11 +++-------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index 57fc62e..4a4240d 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -94,7 +94,6 @@ def __init__(self, hass: HomeAssistant, api: str, name: str, update_interval: in """Initialize IotaWattUpdater object.""" self.api = api self.sensorlist: Dict[str, List[str]] = {} - self.accumulatedValues = {} super().__init__( hass=hass, @@ -138,9 +137,10 @@ def __init__(self, coordinator: IotawattUpdater, entity, mac_address, name): self._entity = entity self._name = name - self._icon = DEFAULT_ICON self._mac_address = mac_address + self._attr_icon = DEFAULT_ICON + @property def unique_id(self) -> str: """Return a unique, Home Assistant friendly identifier for this entity.""" @@ -150,8 +150,3 @@ def unique_id(self) -> str: def name(self) -> str: """Return the name of the entity.""" return self._name - - @property - def icon(self): - """Return the icon for the entity.""" - return self._icon diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 47d2b70..fbed5e1 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -100,7 +100,9 @@ def __init__(self, coordinator, entity, mac_address, name): self._attr_unit_of_measurement = ENERGY_WATT_HOUR self._attr_device_class = DEVICE_CLASS_ENERGY self._attr_state_class = STATE_CLASS_TOTAL_INCREASING - self._accumulating = not sensor.getFromStart() + if not sensor.getFromStart(): + self._accumulating = True + self._attr_icon = ICON_INTEGRATION elif unit == "Volts": self._attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT self._attr_device_class = DEVICE_CLASS_VOLTAGE @@ -202,10 +204,3 @@ def unique_id(self) -> str: return self._iotawattEntry.getSensorID() + ( ".accumulated" if self._accumulating else "" ) - - @property - def icon(self): - """Return the icon for the entity.""" - if self._accumulating: - return ICON_INTEGRATION - return super().icon From 44295f9c78df9e890dd8339f558f3a9703340c6f Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Tue, 31 Aug 2021 00:38:55 +1000 Subject: [PATCH 07/15] Calculate accumulated energy since HA last shutdown to ensure there's no loss of data across reboot. --- custom_components/iotawatt/__init__.py | 16 +++++++++++++- custom_components/iotawatt/sensor.py | 30 +++++++++++++++----------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index 4a4240d..b08c9df 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -94,6 +94,8 @@ def __init__(self, hass: HomeAssistant, api: str, name: str, update_interval: in """Initialize IotaWattUpdater object.""" self.api = api self.sensorlist: Dict[str, List[str]] = {} + self.first_run = True + self.last_run = None super().__init__( hass=hass, @@ -102,10 +104,19 @@ def __init__(self, hass: HomeAssistant, api: str, name: str, update_interval: in update_interval=timedelta(seconds=update_interval), ) + def updateLastRun(self, last_run): + # We want to fetch the data from the iotawatt since HA was last shutdown. + # We retrieve from the sensor last updated. + # This method is called from each sensor upon their state being restored. + if self.last_run is None or last_run > self.last_run: + self.last_run = last_run + async def _async_update_data(self): """Fetch sensors from IoTaWatt device.""" - await self.api.update() + # During the first run, we will only create the sensors and retrieve + # their previous values if any. + await self.api.update(lastUpdate=self.last_run if self.first_run else None) sensors = self.api.getSensors() for sensor, entry in sensors["sensors"].items(): @@ -125,6 +136,9 @@ async def _async_update_data(self): } async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE, to_add) self.sensorlist[sensor] = entry + + self.first_run = self.last_run is None + return sensors diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index fbed5e1..c4e25df 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_SOURCE_ID = "source" +ATTR_LAST_UPDATE = "last_update" ICON_INTEGRATION = "mdi:chart-histogram" @@ -116,6 +117,9 @@ def _iotawattEntry(self): @property def device_state_attributes(self): """Return the state attributes of the device.""" + if self.coordinator.first_run: + return None + if self._io_type == "Input": channel = self._iotawattEntry.getChannel() else: @@ -123,7 +127,9 @@ def device_state_attributes(self): attrs = {"type": self._io_type, "channel": channel} if self._accumulating: - attrs["last_update"] = self.coordinator.api.getLastUpdateTime().isoformat() + attrs[ + ATTR_LAST_UPDATE + ] = self.coordinator.api.getLastUpdateTime().isoformat() return attrs @@ -155,28 +161,28 @@ async def async_added_to_hass(self): await super().async_added_to_hass() if self._accumulating: state = await self.async_get_last_state() - newValue = Decimal(self._iotawattEntry.getValue()) + self._accumulatedValue = Decimal(0) if state: try: - self._accumulatedValue = Decimal(state.state) + newValue - _LOGGER.debug( - f"Entity:{self._ent} Restored:{Decimal(state.state)} newValue:{newValue}" - ) + self._accumulatedValue = Decimal(state.state) + _LOGGER.debug(f"Entity:{self._ent} Restored:{Decimal(state.state)}") + if ATTR_LAST_UPDATE in state.attributes: + self.coordinator.updateLastRun( + dt.parse_datetime(state.attributes.get(ATTR_LAST_UPDATE)) + ) except (DecimalException, ValueError) as err: _LOGGER.warning("Could not restore last state: %s", err) - self._accumulatedValue = newValue else: - # No previous history (first setup), set the first one to the last read. - self._accumulatedValue = newValue - self.async_write_ha_state() + self.coordinator.updateLastRun(self.coordinator.api.getLastUpdateTime()) @property def state(self): """Return the state of the sensor.""" if not self._accumulating: return self._iotawattEntry.getValue() - # Will return None if state hasn't yet been restored. - return round(self._accumulatedValue, 1) if self._accumulatedValue else None + return ( + round(self._accumulatedValue, 1) if not self.coordinator.first_run else None + ) @property def last_reset(self): From 86eb8969bfcd40d31062a90aa1f1f0e1301e23ce Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Tue, 31 Aug 2021 01:18:38 +1000 Subject: [PATCH 08/15] Simplify Coordinator's first_run detection, force an immediate pull from iotawatt to quickly populate the accumulated energy sensors rather than wait for an extra 30s --- custom_components/iotawatt/__init__.py | 25 +++++++++++++++++++------ custom_components/iotawatt/sensor.py | 2 ++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index b08c9df..9bb5895 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -94,8 +94,10 @@ def __init__(self, hass: HomeAssistant, api: str, name: str, update_interval: in """Initialize IotaWattUpdater object.""" self.api = api self.sensorlist: Dict[str, List[str]] = {} - self.first_run = True - self.last_run = None + + self._last_run = None + self._run_count = 0 + self._refresh_requested = False super().__init__( hass=hass, @@ -104,19 +106,29 @@ def __init__(self, hass: HomeAssistant, api: str, name: str, update_interval: in update_interval=timedelta(seconds=update_interval), ) + @property + def first_run(self): + return self._run_count <= 1 + def updateLastRun(self, last_run): # We want to fetch the data from the iotawatt since HA was last shutdown. # We retrieve from the sensor last updated. # This method is called from each sensor upon their state being restored. - if self.last_run is None or last_run > self.last_run: - self.last_run = last_run + if self._last_run is None or last_run > self._last_run: + self._last_run = last_run + + async def request_refresh(self): + if self._refresh_requested: + return + self._refresh_requested = True + await self.async_request_refresh() async def _async_update_data(self): """Fetch sensors from IoTaWatt device.""" # During the first run, we will only create the sensors and retrieve # their previous values if any. - await self.api.update(lastUpdate=self.last_run if self.first_run else None) + await self.api.update(lastUpdate=self._last_run if self.first_run else None) sensors = self.api.getSensors() for sensor, entry in sensors["sensors"].items(): @@ -137,7 +149,8 @@ async def _async_update_data(self): async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE, to_add) self.sensorlist[sensor] = entry - self.first_run = self.last_run is None + self._run_count += 1 + self._refresh_requested = False return sensors diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index c4e25df..b3dce36 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -174,6 +174,8 @@ async def async_added_to_hass(self): _LOGGER.warning("Could not restore last state: %s", err) else: self.coordinator.updateLastRun(self.coordinator.api.getLastUpdateTime()) + # Force a second update from the iotawatt to ensure that sensors are up to date. + await self.coordinator.request_refresh() @property def state(self): From 979f335dbedfe1fdf61d58827b72278147ed3d2b Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Tue, 31 Aug 2021 13:53:30 +1000 Subject: [PATCH 09/15] remove dev code. --- custom_components/iotawatt/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index 9bb5895..c1c65cb 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -3,7 +3,6 @@ from decimal import Decimal, DecimalException import logging from typing import Dict, List -from . import context from httpx import AsyncClient from iotawattpy.iotawatt import Iotawatt From c8869185e21d78cc335fdeb325fb30f10b04c4bb Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Tue, 31 Aug 2021 18:19:15 +1000 Subject: [PATCH 10/15] Rebase on top of the official Home Assistant's iotawatt integration code. --- custom_components/iotawatt/__init__.py | 180 +--------- custom_components/iotawatt/config_flow.py | 109 ++++--- custom_components/iotawatt/const.py | 19 +- custom_components/iotawatt/coordinator.py | 75 +++++ custom_components/iotawatt/manifest.json | 3 +- custom_components/iotawatt/sensor.py | 308 ++++++++++-------- custom_components/iotawatt/strings.json | 7 +- .../iotawatt/translations/ca.json | 23 ++ .../iotawatt/translations/en.json | 9 +- .../iotawatt/translations/pl.json | 22 ++ 10 files changed, 386 insertions(+), 369 deletions(-) create mode 100644 custom_components/iotawatt/coordinator.py create mode 100644 custom_components/iotawatt/translations/ca.json create mode 100644 custom_components/iotawatt/translations/pl.json diff --git a/custom_components/iotawatt/__init__.py b/custom_components/iotawatt/__init__.py index c1c65cb..7987004 100644 --- a/custom_components/iotawatt/__init__.py +++ b/custom_components/iotawatt/__init__.py @@ -1,178 +1,24 @@ """The iotawatt integration.""" -from datetime import timedelta -from decimal import Decimal, DecimalException -import logging -from typing import Dict, List - -from httpx import AsyncClient -from iotawattpy.iotawatt import Iotawatt -import voluptuous as vol - -from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import ( - COORDINATOR, - DEFAULT_ICON, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - IOTAWATT_API, - SIGNAL_ADD_DEVICE, -) - -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +from .const import DOMAIN +from .coordinator import IotawattUpdater - -async def async_setup(hass: HomeAssistant, config: dict): - """Set up the iotawatt component.""" - hass.data.setdefault(DOMAIN, {}) - return True +PLATFORMS = ("sensor",) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up iotawatt from a config entry.""" - polling_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - - session = AsyncClient() - if "username" in entry.data.keys(): - api = Iotawatt( - entry.data["name"], - entry.data["host"], - session, - entry.data["username"], - entry.data["password"], - ) - else: - api = Iotawatt( - entry.data["name"], - entry.data["host"], - session, - ) - - coordinator = IotawattUpdater( - hass, - api=api, - name="IoTaWatt", - update_interval=polling_interval, - ) - - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - IOTAWATT_API: api, - } - - for component in PLATFORMS: - _LOGGER.info(f"Setting up platform: {component}") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - + coordinator = IotawattUpdater(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -class IotawattUpdater(DataUpdateCoordinator): - """Class to manage fetching update data from the IoTaWatt Energy Device.""" - - def __init__(self, hass: HomeAssistant, api: str, name: str, update_interval: int): - """Initialize IotaWattUpdater object.""" - self.api = api - self.sensorlist: Dict[str, List[str]] = {} - - self._last_run = None - self._run_count = 0 - self._refresh_requested = False - - super().__init__( - hass=hass, - logger=_LOGGER, - name=name, - update_interval=timedelta(seconds=update_interval), - ) - - @property - def first_run(self): - return self._run_count <= 1 - - def updateLastRun(self, last_run): - # We want to fetch the data from the iotawatt since HA was last shutdown. - # We retrieve from the sensor last updated. - # This method is called from each sensor upon their state being restored. - if self._last_run is None or last_run > self._last_run: - self._last_run = last_run - - async def request_refresh(self): - if self._refresh_requested: - return - self._refresh_requested = True - await self.async_request_refresh() - - async def _async_update_data(self): - """Fetch sensors from IoTaWatt device.""" - - # During the first run, we will only create the sensors and retrieve - # their previous values if any. - await self.api.update(lastUpdate=self._last_run if self.first_run else None) - sensors = self.api.getSensors() - - for sensor, entry in sensors["sensors"].items(): - if sensor not in self.sensorlist: - _LOGGER.debug(f"First read sensor:{sensor} value:{entry.getValue()}") - unit = entry.getUnit() - suffix = "" - if unit == "WattHours" and entry.getFromStart(): - suffix = " Total Wh" - elif unit == "WattHours": - suffix = ".Wh" - - to_add = { - "entity": sensor, - "mac_address": entry.hub_mac_address, - "name": entry.getBaseName() + suffix, - } - async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE, to_add) - self.sensorlist[sensor] = entry - - self._run_count += 1 - self._refresh_requested = False - - return sensors - - -class IotaWattEntity(CoordinatorEntity, SensorEntity): - """Defines the base IoTaWatt Energy Device entity.""" - - def __init__(self, coordinator: IotawattUpdater, entity, mac_address, name): - """Initialize the IoTaWatt Entity.""" - super().__init__(coordinator) - - self._entity = entity - self._name = name - self._mac_address = mac_address - - self._attr_icon = DEFAULT_ICON - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._mac_address - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/custom_components/iotawatt/config_flow.py b/custom_components/iotawatt/config_flow.py index d58e770..9ec860e 100644 --- a/custom_components/iotawatt/config_flow.py +++ b/custom_components/iotawatt/config_flow.py @@ -1,97 +1,102 @@ """Config flow for iotawatt integration.""" -import json +from __future__ import annotations + import logging -import httpx -from httpx import AsyncClient from iotawattpy.iotawatt import Iotawatt import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import httpx_client -from .const import DOMAIN +from .const import CONNECTION_ERRORS, DOMAIN _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): str, - vol.Required(CONF_HOST): str, - } -) - - -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect. - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - session = AsyncClient() - iotawatt = Iotawatt(data["name"], data["host"], session) +async def validate_input( + hass: core.HomeAssistant, data: dict[str, str] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + iotawatt = Iotawatt( + "", + data[CONF_HOST], + httpx_client.get_async_client(hass), + data.get(CONF_USERNAME), + data.get(CONF_PASSWORD), + ) try: is_connected = await iotawatt.connect() - _LOGGER.debug("isConnected: %s", is_connected) - except (KeyError, json.JSONDecodeError, httpx.HTTPError): - raise CannotConnect + except CONNECTION_ERRORS: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"} if not is_connected: - raise InvalidAuth + return {"base": "invalid_auth"} - # Return info that you want to store in the config entry. - return {"title": data["name"]} + return {} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for iotawatt.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL def __init__(self): """Initialize.""" self._data = {} - self._errors = {} async def async_step_user(self, user_input=None): """Handle the initial step.""" if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + user_input = {} - errors = {} - self._data.update(user_input) + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + } + ) + if not user_input: + return self.async_show_form(step_id="user", data_schema=schema) - try: - await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: + if not (errors := await validate_input(self.hass, user_input)): + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + if errors == {"base": "invalid_auth"}: + self._data.update(user_input) return await self.async_step_auth() - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry(title=self._data["name"], data=user_input) - - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_auth(self, user_input=None): """Authenticate user if authentication is enabled on the IoTaWatt device.""" + if user_input is None: + user_input = {} + data_schema = vol.Schema( { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, } ) - _LOGGER.debug("Data: %s", self._data) - if user_input is None: + if not user_input: return self.async_show_form(step_id="auth", data_schema=data_schema) - self._data.update(user_input) - return self.async_create_entry(title=self._data["name"], data=self._data) + + data = {**self._data, **user_input} + + if errors := await validate_input(self.hass, data): + return self.async_show_form( + step_id="auth", data_schema=data_schema, errors=errors + ) + + return self.async_create_entry(title=data[CONF_HOST], data=data) class CannotConnect(exceptions.HomeAssistantError): diff --git a/custom_components/iotawatt/const.py b/custom_components/iotawatt/const.py index 43a1928..0b80e10 100644 --- a/custom_components/iotawatt/const.py +++ b/custom_components/iotawatt/const.py @@ -1,9 +1,14 @@ -"""Constants for the iotawatt integration.""" +"""Constants for the IoTaWatt integration.""" +from __future__ import annotations + +import json + +import httpx -DEFAULT_ICON = "mdi:flash" -DEFAULT_SCAN_INTERVAL = 30 DOMAIN = "iotawatt" -COORDINATOR = "coordinator" -IOTAWATT_API = "iotawatt_api" -SIGNAL_ADD_DEVICE = "iotawatt_add_device" -SIGNAL_DELETE_DEVICE = "iotawatt_delete_device" +VOLT_AMPERE_REACTIVE = "VAR" +VOLT_AMPERE_REACTIVE_HOURS = "VARh" + +ATTR_LAST_UPDATE = "last_update" + +CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/custom_components/iotawatt/coordinator.py b/custom_components/iotawatt/coordinator.py new file mode 100644 index 0000000..6230b0a --- /dev/null +++ b/custom_components/iotawatt/coordinator.py @@ -0,0 +1,75 @@ +"""IoTaWatt DataUpdateCoordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from iotawattpy.iotawatt import Iotawatt + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import httpx_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONNECTION_ERRORS + +_LOGGER = logging.getLogger(__name__) + + +class IotawattUpdater(DataUpdateCoordinator): + """Class to manage fetching update data from the IoTaWatt Energy Device.""" + + api: Iotawatt | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize IotaWattUpdater object.""" + self.entry = entry + super().__init__( + hass=hass, + logger=_LOGGER, + name=entry.title, + update_interval=timedelta(seconds=30), + ) + + self._last_run = None + self._refresh_requested = False + + def updateLastRun(self, last_run): + # We want to fetch the data from the iotawatt since HA was last shutdown. + # We retrieve from the sensor last updated. + # This method is called from each sensor upon their state being restored. + if self._last_run is None or last_run > self._last_run: + self._last_run = last_run + + async def request_refresh(self): + """Request a refresh of the iotawatt sensors""" + if self._refresh_requested: + return + self._refresh_requested = True + await self.async_request_refresh() + + async def _async_update_data(self): + """Fetch sensors from IoTaWatt device.""" + if self.api is None: + api = Iotawatt( + self.entry.title, + self.entry.data[CONF_HOST], + httpx_client.get_async_client(self.hass), + self.entry.data.get(CONF_USERNAME), + self.entry.data.get(CONF_PASSWORD), + ) + try: + is_authenticated = await api.connect() + except CONNECTION_ERRORS as err: + raise UpdateFailed("Connection failed") from err + + if not is_authenticated: + raise UpdateFailed("Authentication error") + + self.api = api + + await self.api.update(lastUpdate=self._last_run) + self._last_run = None + self._refresh_requested = False + return self.api.getSensors() diff --git a/custom_components/iotawatt/manifest.json b/custom_components/iotawatt/manifest.json index e1bc647..8fb5b99 100644 --- a/custom_components/iotawatt/manifest.json +++ b/custom_components/iotawatt/manifest.json @@ -14,5 +14,6 @@ "codeowners": [ "@gtdiehl" ], - "version": "0.1.0" + "version": "0.1.0", + "iot_class": "local_polling" } diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index b3dce36..5446724 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -1,163 +1,236 @@ """Support for IoTaWatt Energy monitor.""" +from __future__ import annotations + +from dataclasses import dataclass from decimal import Decimal, DecimalException import logging +from typing import Callable + +from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, - ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, - TIME_HOURS, ) from homeassistant.core import callback -from homeassistant.helpers import entity_registry -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity, entity_registry, update_coordinator +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt -from . import IotaWattEntity -from .const import COORDINATOR, DOMAIN, SIGNAL_ADD_DEVICE +from .const import ( + DOMAIN, + VOLT_AMPERE_REACTIVE, + VOLT_AMPERE_REACTIVE_HOURS, + ATTR_LAST_UPDATE, +) +from .coordinator import IotawattUpdater _LOGGER = logging.getLogger(__name__) -ATTR_SOURCE_ID = "source" -ATTR_LAST_UPDATE = "last_update" -ICON_INTEGRATION = "mdi:chart-histogram" +@dataclass +class IotaWattSensorEntityDescription(SensorEntityDescription): + """Class describing IotaWatt sensor entities.""" + + value: Callable | None = None + + +ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { + "Amps": IotaWattSensorEntityDescription( + "Amps", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + ), + "Hz": IotaWattSensorEntityDescription( + "Hz", + native_unit_of_measurement=FREQUENCY_HERTZ, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "PF": IotaWattSensorEntityDescription( + "PF", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER_FACTOR, + value=lambda value: value * 100, + ), + "Watts": IotaWattSensorEntityDescription( + "Watts", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER, + ), + "WattHours": IotaWattSensorEntityDescription( + "WattHours", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + icon="mdi:chart-histogram", + ), + "VA": IotaWattSensorEntityDescription( + "VA", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VAR": IotaWattSensorEntityDescription( + "VAR", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VARh": IotaWattSensorEntityDescription( + "VARh", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "Volts": IotaWattSensorEntityDescription( + "Volts", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + ), +} async def async_setup_entry(hass, config_entry, async_add_entities): """Add sensors for passed config_entry in HA.""" + coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] + created = set() - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - sensors = coordinator.data["sensors"] - entities = [] - - for idx, ent in enumerate(sensors): - sensor = sensors[ent] - entity = IotaWattSensor( + @callback + def _create_entity(key: str) -> IotaWattSensor: + """Create a sensor entity.""" + created.add(key) + return IotaWattSensor( coordinator=coordinator, - entity=ent, - mac_address=sensor.hub_mac_address, - name=sensor.getName(), + key=key, + entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( + coordinator.data["sensors"][key].getUnit(), + IotaWattSensorEntityDescription("base_sensor"), + ), ) - entities.append(entity) - async_add_entities(entities) + async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) - async def async_new_entities(sensor_info): - """Add an entity.""" - ent = sensor_info["entity"] - hub_mac_address = sensor_info["mac_address"] - name = sensor_info["name"] - - entity = IotaWattSensor( - coordinator=coordinator, - entity=ent, - mac_address=hub_mac_address, - name=name, - ) - entities = [entity] - async_add_entities(entities) + @callback + def new_data_received(): + """Check for new sensors.""" + entities = [ + _create_entity(key) + for key in coordinator.data["sensors"] + if key not in created + ] + if entities: + async_add_entities(entities) - async_dispatcher_connect(hass, SIGNAL_ADD_DEVICE, async_new_entities) + coordinator.async_add_listener(new_data_received) -class IotaWattSensor(IotaWattEntity, RestoreEntity): +class IotaWattSensor(update_coordinator.CoordinatorEntity, RestoreEntity, SensorEntity): """Defines a IoTaWatt Energy Sensor.""" - def __init__(self, coordinator, entity, mac_address, name): - """Initialize the sensor.""" - super().__init__( - coordinator=coordinator, entity=entity, mac_address=mac_address, name=name - ) + entity_description: IotaWattSensorEntityDescription + _attr_force_update = True - sensor = self.coordinator.data["sensors"][entity] - self._ent = entity - self._name = name - self._io_type = sensor.getType() - self._state = None - self._attr_state_class = STATE_CLASS_MEASUREMENT - self._attr_force_update = True + def __init__( + self, + coordinator, + key, + entity_description: IotaWattSensorEntityDescription, + ): + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) - self._accumulating = False + self._key = key + data = self._sensor_data + self._accumulating = data.getUnit() == "WattHours" and not data.getFromStart() self._accumulatingValue = None - self._handle_coordinator_update_called = False - - unit = sensor.getUnit() - if unit == "Watts": - self._attr_unit_of_measurement = POWER_WATT - self._attr_device_class = DEVICE_CLASS_POWER - elif unit == "WattHours": - self._attr_unit_of_measurement = ENERGY_WATT_HOUR - self._attr_device_class = DEVICE_CLASS_ENERGY - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING - if not sensor.getFromStart(): - self._accumulating = True - self._attr_icon = ICON_INTEGRATION - elif unit == "Volts": - self._attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT - self._attr_device_class = DEVICE_CLASS_VOLTAGE - else: - self._attr_unit_of_measurement = unit + if data.getType() == "Input": + unit = data.getUnit() + self._name_suffix + self._attr_unique_id = ( + f"{data.hub_mac_address}-input-{data.getChannel()}-{unit}" + ) + self.entity_description = entity_description @property - def _iotawattEntry(self): - return self.coordinator.data["sensors"][self._ent] + def _sensor_data(self) -> Sensor: + """Return sensor data.""" + return self.coordinator.data["sensors"][self._key] @property - def device_state_attributes(self): - """Return the state attributes of the device.""" - if self.coordinator.first_run: - return None + def _name_suffix(self) -> str: + return ".accumulated" if self._accumulating else "" - if self._io_type == "Input": - channel = self._iotawattEntry.getChannel() - else: - channel = "N/A" - - attrs = {"type": self._io_type, "channel": channel} - if self._accumulating: - attrs[ - ATTR_LAST_UPDATE - ] = self.coordinator.api.getLastUpdateTime().isoformat() + @property + def name(self) -> str | None: + """Return name of the entity.""" + return self._sensor_data.getName() + self._name_suffix - return attrs + @property + def device_info(self) -> entity.DeviceInfo | None: + """Return device info.""" + return { + "connections": { + (CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, + "manufacturer": "IoTaWatt", + "model": "IoTaWatt", + } @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if self._ent not in self.coordinator.data["sensors"]: - entity_registry.async_get(self.hass).async_remove(self.entity_id) + if self._key not in self.coordinator.data["sensors"]: + if self._attr_unique_id: + entity_registry.async_get(self.hass).async_remove(self.entity_id) + else: + self.hass.async_create_task(self.async_remove()) return if self._accumulating: assert ( self._accumulatedValue is not None ), "async_added_to_hass must have been called first" - _LOGGER.debug( - f"Accumulating sensor:{self._ent} value:{round(self._accumulatedValue, 3)} with:{self._iotawattEntry.getValue()}" - ) - self._accumulatedValue += Decimal(self._iotawattEntry.getValue()) + self._accumulatedValue += Decimal(self._sensor_data.getValue()) - self._handle_coordinator_update_called = True super()._handle_coordinator_update() - async def async_added_to_hass(self): - """Handle entity which will be added.""" - assert ( - not self._handle_coordinator_update_called - ), "_handle_coordinator_update_called must not have been called yet" + @property + def extra_state_attributes(self): + """Return the extra state attributes of the entity.""" + data = self._sensor_data + attrs = {"type": data.getType()} + if attrs["type"] == "Input": + attrs["channel"] = data.getChannel() + if self._accumulating: + attrs[ + ATTR_LAST_UPDATE + ] = self.coordinator.api.getLastUpdateTime().isoformat() + + return attrs + async def async_added_to_hass(self): + """Load the last known state value of the entity if the accumulated type""" await super().async_added_to_hass() if self._accumulating: state = await self.async_get_last_state() @@ -165,7 +238,6 @@ async def async_added_to_hass(self): if state: try: self._accumulatedValue = Decimal(state.state) - _LOGGER.debug(f"Entity:{self._ent} Restored:{Decimal(state.state)}") if ATTR_LAST_UPDATE in state.attributes: self.coordinator.updateLastRun( dt.parse_datetime(state.attributes.get(ATTR_LAST_UPDATE)) @@ -178,37 +250,15 @@ async def async_added_to_hass(self): await self.coordinator.request_refresh() @property - def state(self): + def native_value(self) -> entity.StateType: """Return the state of the sensor.""" + if func := self.entity_description.value: + return func(self._sensor_data.getValue()) + if not self._accumulating: - return self._iotawattEntry.getValue() + return self._sensor_data.getValue() return ( - round(self._accumulatedValue, 1) if not self.coordinator.first_run else None - ) - - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - last_reset = self._iotawattEntry.getBegin() - if last_reset is None or self._accumulating: - return None - return dt.parse_datetime(last_reset) - - @property - def name(self): - """Return the name of the sensor.""" - name = ( - "IoTaWatt " - + str(self._io_type) - + " " - + str(self._iotawattEntry.getName()) - + (".accumulated" if self._accumulating else "") - ) - return name - - @property - def unique_id(self) -> str: - """Return the Unique ID for the sensor.""" - return self._iotawattEntry.getSensorID() + ( - ".accumulated" if self._accumulating else "" + round(self._accumulatedValue, 1) + if self._accumulatedValue is not None + else None ) diff --git a/custom_components/iotawatt/strings.json b/custom_components/iotawatt/strings.json index 13f3832..f21dfe0 100644 --- a/custom_components/iotawatt/strings.json +++ b/custom_components/iotawatt/strings.json @@ -1,10 +1,8 @@ { - "title": "iotawatt", "config": { "step": { "user": { "data": { - "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" } }, @@ -20,9 +18,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } -} \ No newline at end of file +} diff --git a/custom_components/iotawatt/translations/ca.json b/custom_components/iotawatt/translations/ca.json new file mode 100644 index 0000000..d6a771b --- /dev/null +++ b/custom_components/iotawatt/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "auth": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "El dispositiu IoTawatt necessita autenticaci\u00f3. Introdueix el nom d'usuari i la contrasenya i fes clic al bot\u00f3 Envia." + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/en.json b/custom_components/iotawatt/translations/en.json index 70db1d5..f0ac17e 100644 --- a/custom_components/iotawatt/translations/en.json +++ b/custom_components/iotawatt/translations/en.json @@ -1,8 +1,5 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" - }, "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", @@ -18,11 +15,9 @@ }, "user": { "data": { - "host": "Host", - "name": "Name" + "host": "Host" + } } } } - }, - "title": "iotawatt" } \ No newline at end of file diff --git a/custom_components/iotawatt/translations/pl.json b/custom_components/iotawatt/translations/pl.json new file mode 100644 index 0000000..2ea6be8 --- /dev/null +++ b/custom_components/iotawatt/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "auth": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file From 4d3132946727bc8ce103aa4f28736dc7c83bf205 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Tue, 31 Aug 2021 23:08:00 +1000 Subject: [PATCH 11/15] Pass HA git commit pre-hook --- custom_components/iotawatt/coordinator.py | 6 +++--- custom_components/iotawatt/sensor.py | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/custom_components/iotawatt/coordinator.py b/custom_components/iotawatt/coordinator.py index 6230b0a..dd893cb 100644 --- a/custom_components/iotawatt/coordinator.py +++ b/custom_components/iotawatt/coordinator.py @@ -4,13 +4,12 @@ from datetime import timedelta import logging -from iotawattpy.iotawatt import Iotawatt - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from iotawattpy.iotawatt import Iotawatt from .const import CONNECTION_ERRORS @@ -36,6 +35,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._refresh_requested = False def updateLastRun(self, last_run): + """Notify coordinator of a sensor last update time.""" # We want to fetch the data from the iotawatt since HA was last shutdown. # We retrieve from the sensor last updated. # This method is called from each sensor upon their state being restored. @@ -43,7 +43,7 @@ def updateLastRun(self, last_run): self._last_run = last_run async def request_refresh(self): - """Request a refresh of the iotawatt sensors""" + """Request a refresh of the iotawatt sensors.""" if self._refresh_requested: return self._refresh_requested = True diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 5446724..20b207f 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -6,8 +6,6 @@ import logging from typing import Callable -from iotawattpy.sensor import Sensor - from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, @@ -33,12 +31,13 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt +from iotawattpy.sensor import Sensor from .const import ( + ATTR_LAST_UPDATE, DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS, - ATTR_LAST_UPDATE, ) from .coordinator import IotawattUpdater @@ -230,7 +229,7 @@ def extra_state_attributes(self): return attrs async def async_added_to_hass(self): - """Load the last known state value of the entity if the accumulated type""" + """Load the last known state value of the entity if the accumulated type.""" await super().async_added_to_hass() if self._accumulating: state = await self.async_get_last_state() From 50dd8480a409a84cf4977579394a587051a30d89 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Wed, 1 Sep 2021 14:09:58 +1000 Subject: [PATCH 12/15] Update following change to iotawattpy --- custom_components/iotawatt/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 20b207f..d29d5ae 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -183,7 +183,7 @@ def _name_suffix(self) -> str: @property def name(self) -> str | None: """Return name of the entity.""" - return self._sensor_data.getName() + self._name_suffix + return self._sensor_data.getSourceName() + self._name_suffix @property def device_info(self) -> entity.DeviceInfo | None: From f07107502e124257db15d11f06754acbf6f6c8e0 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Wed, 1 Sep 2021 23:25:15 +1000 Subject: [PATCH 13/15] Remove use of Decimal class; it's unneeded as we only perform additions. There can't be accuracy loss resulting from this operation, making it unnecessary to use fractionals. --- custom_components/iotawatt/sensor.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index d29d5ae..504b6f0 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -2,10 +2,11 @@ from __future__ import annotations from dataclasses import dataclass -from decimal import Decimal, DecimalException import logging from typing import Callable +from iotawattpy.sensor import Sensor + from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, @@ -31,7 +32,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt -from iotawattpy.sensor import Sensor from .const import ( ATTR_LAST_UPDATE, @@ -210,7 +210,7 @@ def _handle_coordinator_update(self) -> None: assert ( self._accumulatedValue is not None ), "async_added_to_hass must have been called first" - self._accumulatedValue += Decimal(self._sensor_data.getValue()) + self._accumulatedValue += float(self._sensor_data.getValue()) super()._handle_coordinator_update() @@ -233,18 +233,16 @@ async def async_added_to_hass(self): await super().async_added_to_hass() if self._accumulating: state = await self.async_get_last_state() - self._accumulatedValue = Decimal(0) + self._accumulatedValue = 0.0 if state: try: - self._accumulatedValue = Decimal(state.state) + self._accumulatedValue = float(state.state) if ATTR_LAST_UPDATE in state.attributes: self.coordinator.updateLastRun( dt.parse_datetime(state.attributes.get(ATTR_LAST_UPDATE)) ) - except (DecimalException, ValueError) as err: + except (ValueError) as err: _LOGGER.warning("Could not restore last state: %s", err) - else: - self.coordinator.updateLastRun(self.coordinator.api.getLastUpdateTime()) # Force a second update from the iotawatt to ensure that sensors are up to date. await self.coordinator.request_refresh() From 72d2119938fadf6c43e1c970a7928fe51fb8d443 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Thu, 2 Sep 2021 00:20:15 +1000 Subject: [PATCH 14/15] Backport HA #55510 --- custom_components/iotawatt/coordinator.py | 3 ++- custom_components/iotawatt/sensor.py | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/custom_components/iotawatt/coordinator.py b/custom_components/iotawatt/coordinator.py index dd893cb..1b275c8 100644 --- a/custom_components/iotawatt/coordinator.py +++ b/custom_components/iotawatt/coordinator.py @@ -4,12 +4,13 @@ from datetime import timedelta import logging +from iotawattpy.iotawatt import Iotawatt + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from iotawattpy.iotawatt import Iotawatt from .const import CONNECTION_ERRORS diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 504b6f0..f3d5bbc 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -57,12 +56,14 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_CURRENT, + entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( "Hz", native_unit_of_measurement=FREQUENCY_HERTZ, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( "PF", @@ -70,6 +71,7 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_POWER_FACTOR, value=lambda value: value * 100, + entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( "Watts", @@ -80,7 +82,6 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): "WattHours": IotaWattSensorEntityDescription( "WattHours", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, icon="mdi:chart-histogram", ), @@ -89,24 +90,28 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=POWER_VOLT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( "VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( "VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( "Volts", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, ), } From a362fb8a33b2e6328baf2269a8699f5e84c2c793 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Thu, 2 Sep 2021 11:11:38 +1000 Subject: [PATCH 15/15] merge with HA + fixes --- custom_components/iotawatt/coordinator.py | 6 ++--- custom_components/iotawatt/sensor.py | 24 +++++++++---------- .../iotawatt/translations/cs.json | 22 +++++++++++++++++ .../iotawatt/translations/de.json | 23 ++++++++++++++++++ .../iotawatt/translations/en.json | 2 +- .../iotawatt/translations/et.json | 23 ++++++++++++++++++ .../iotawatt/translations/ru.json | 23 ++++++++++++++++++ .../iotawatt/translations/zh-Hant.json | 23 ++++++++++++++++++ 8 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 custom_components/iotawatt/translations/cs.json create mode 100644 custom_components/iotawatt/translations/de.json create mode 100644 custom_components/iotawatt/translations/et.json create mode 100644 custom_components/iotawatt/translations/ru.json create mode 100644 custom_components/iotawatt/translations/zh-Hant.json diff --git a/custom_components/iotawatt/coordinator.py b/custom_components/iotawatt/coordinator.py index 1b275c8..ef89d37 100644 --- a/custom_components/iotawatt/coordinator.py +++ b/custom_components/iotawatt/coordinator.py @@ -1,7 +1,7 @@ """IoTaWatt DataUpdateCoordinator.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from iotawattpy.iotawatt import Iotawatt @@ -35,13 +35,13 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._last_run = None self._refresh_requested = False - def updateLastRun(self, last_run): + def update_last_run(self, last_run: datetime): """Notify coordinator of a sensor last update time.""" # We want to fetch the data from the iotawatt since HA was last shutdown. # We retrieve from the sensor last updated. # This method is called from each sensor upon their state being restored. if self._last_run is None or last_run > self._last_run: - self._last_run = last_run + self._last_run = last_run # type: ignore async def request_refresh(self): """Request a refresh of the iotawatt sensors.""" diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index f3d5bbc..fd37e55 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -83,7 +84,6 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): "WattHours", native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - icon="mdi:chart-histogram", ), "VA": IotaWattSensorEntityDescription( "VA", @@ -168,12 +168,14 @@ def __init__( self._key = key data = self._sensor_data self._accumulating = data.getUnit() == "WattHours" and not data.getFromStart() - self._accumulatingValue = None + self._accumulated_value = None if data.getType() == "Input": unit = data.getUnit() + self._name_suffix self._attr_unique_id = ( f"{data.hub_mac_address}-input-{data.getChannel()}-{unit}" ) + if self._accumulating: + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self.entity_description = entity_description @property @@ -213,9 +215,9 @@ def _handle_coordinator_update(self) -> None: if self._accumulating: assert ( - self._accumulatedValue is not None + self._accumulated_value is not None ), "async_added_to_hass must have been called first" - self._accumulatedValue += float(self._sensor_data.getValue()) + self._accumulated_value += float(self._sensor_data.getValue()) super()._handle_coordinator_update() @@ -238,12 +240,12 @@ async def async_added_to_hass(self): await super().async_added_to_hass() if self._accumulating: state = await self.async_get_last_state() - self._accumulatedValue = 0.0 + self._accumulated_value = 0.0 if state: try: - self._accumulatedValue = float(state.state) + self._accumulated_value = float(state.state) if ATTR_LAST_UPDATE in state.attributes: - self.coordinator.updateLastRun( + self.coordinator.update_last_run( dt.parse_datetime(state.attributes.get(ATTR_LAST_UPDATE)) ) except (ValueError) as err: @@ -259,8 +261,6 @@ def native_value(self) -> entity.StateType: if not self._accumulating: return self._sensor_data.getValue() - return ( - round(self._accumulatedValue, 1) - if self._accumulatedValue is not None - else None - ) + if self._accumulated_value is None: + return None + return round(self._accumulated_value, 1) diff --git a/custom_components/iotawatt/translations/cs.json b/custom_components/iotawatt/translations/cs.json new file mode 100644 index 0000000..4223dcf --- /dev/null +++ b/custom_components/iotawatt/translations/cs.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "auth": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/de.json b/custom_components/iotawatt/translations/de.json new file mode 100644 index 0000000..b1dda29 --- /dev/null +++ b/custom_components/iotawatt/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "auth": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Das IoTawatt-Ger\u00e4t erfordert eine Authentifizierung. Bitte gib den Benutzernamen und das Passwort ein und klicke auf die Schaltfl\u00e4che Senden." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/en.json b/custom_components/iotawatt/translations/en.json index f0ac17e..679fc6c 100644 --- a/custom_components/iotawatt/translations/en.json +++ b/custom_components/iotawatt/translations/en.json @@ -17,7 +17,7 @@ "data": { "host": "Host" } - } } } + } } \ No newline at end of file diff --git a/custom_components/iotawatt/translations/et.json b/custom_components/iotawatt/translations/et.json new file mode 100644 index 0000000..786e73a --- /dev/null +++ b/custom_components/iotawatt/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Viga tuvastamisel", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "auth": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "IoTawatt seade n\u00f5uab tuvastamist. Sisesta kasutajanimi ja salas\u00f5na ning kl\u00f5psa nuppu Edasta." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/ru.json b/custom_components/iotawatt/translations/ru.json new file mode 100644 index 0000000..d004298 --- /dev/null +++ b/custom_components/iotawatt/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "auth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e IoTawatt \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c." + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/zh-Hant.json b/custom_components/iotawatt/translations/zh-Hant.json new file mode 100644 index 0000000..d30fb42 --- /dev/null +++ b/custom_components/iotawatt/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "auth": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "IoTawatt \u88dd\u7f6e\u9700\u8981\u8a8d\u8b49\uff0c\u8acb\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u3001\u4e26\u9ede\u9078\u50b3\u9001\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + } +} \ No newline at end of file