diff --git a/CODEOWNERS b/CODEOWNERS index db4a5b04069..54ce1818ce4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -248,7 +248,7 @@ homeassistant/components/integration/* @dgomes homeassistant/components/intent/* @home-assistant/core homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 -homeassistant/components/iotawatt/* @gtdiehl +homeassistant/components/iotawatt/* @gtdiehl @jyavenard homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py index db847f3dfe8..0b80e108238 100644 --- a/homeassistant/components/iotawatt/const.py +++ b/homeassistant/components/iotawatt/const.py @@ -9,4 +9,6 @@ DOMAIN = "iotawatt" VOLT_AMPERE_REACTIVE = "VAR" VOLT_AMPERE_REACTIVE_HOURS = "VARh" +ATTR_LAST_UPDATE = "last_update" + CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index 1a722d52a1e..ada9c9fb346 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/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 @@ -32,6 +32,16 @@ class IotawattUpdater(DataUpdateCoordinator): update_interval=timedelta(seconds=30), ) + self._last_run: datetime | None = None + + def update_last_run(self, last_run: datetime) -> None: + """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 + async def _async_update_data(self): """Fetch sensors from IoTaWatt device.""" if self.api is None: @@ -52,5 +62,6 @@ class IotawattUpdater(DataUpdateCoordinator): self.api = api - await self.api.update() + await self.api.update(lastUpdate=self._last_run) + self._last_run = None return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index d78e546d71f..42e1e074c8e 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -4,10 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iotawatt", "requirements": [ - "iotawattpy==0.0.8" + "iotawattpy==0.1.0" ], "codeowners": [ - "@gtdiehl" + "@gtdiehl", + "@jyavenard" ], "iot_class": "local_polling" } \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 1b4c166eb27..c52f8cb9189 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Callable from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -28,10 +30,19 @@ from homeassistant.const import ( from homeassistant.core import callback 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 .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .const import ( + ATTR_LAST_UPDATE, + DOMAIN, + VOLT_AMPERE_REACTIVE, + VOLT_AMPERE_REACTIVE_HOURS, +) from .coordinator import IotawattUpdater +_LOGGER = logging.getLogger(__name__) + @dataclass class IotaWattSensorEntityDescription(SensorEntityDescription): @@ -114,15 +125,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def _create_entity(key: str) -> IotaWattSensor: """Create a sensor entity.""" created.add(key) + data = coordinator.data["sensors"][key] + description = ENTITY_DESCRIPTION_KEY_MAP.get( + data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + ) + if data.getUnit() == "WattHours" and not data.getFromStart(): + return IotaWattAccumulatingSensor( + coordinator=coordinator, key=key, entity_description=description + ) + return IotaWattSensor( coordinator=coordinator, key=key, - mac_address=coordinator.data["sensors"][key].hub_mac_address, - name=coordinator.data["sensors"][key].getName(), - entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( - coordinator.data["sensors"][key].getUnit(), - IotaWattSensorEntityDescription("base_sensor"), - ), + entity_description=description, ) async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) @@ -145,16 +160,14 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Defines a IoTaWatt Energy Sensor.""" entity_description: IotaWattSensorEntityDescription - _attr_force_update = True + coordinator: IotawattUpdater def __init__( self, - coordinator, - key, - mac_address, - name, + coordinator: IotawattUpdater, + key: str, entity_description: IotaWattSensorEntityDescription, - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator) @@ -196,17 +209,15 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): else: self.hass.async_create_task(self.async_remove()) return - super()._handle_coordinator_update() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the extra state attributes of the entity.""" data = self._sensor_data attrs = {"type": data.getType()} if attrs["type"] == "Input": attrs["channel"] = data.getChannel() - return attrs @property @@ -216,3 +227,78 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): return func(self._sensor_data.getValue()) return self._sensor_data.getValue() + + +class IotaWattAccumulatingSensor(IotaWattSensor, RestoreEntity): + """Defines a IoTaWatt Accumulative Energy (High Accuracy) Sensor.""" + + def __init__( + self, + coordinator: IotawattUpdater, + key: str, + entity_description: IotaWattSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + + super().__init__(coordinator, key, entity_description) + + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + if self._attr_unique_id is not None: + self._attr_unique_id += ".accumulated" + + self._accumulated_value: float | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + assert ( + self._accumulated_value is not None + ), "async_added_to_hass must have been called first" + self._accumulated_value += float(self._sensor_data.getValue()) + + super()._handle_coordinator_update() + + @property + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" + if self._accumulated_value is None: + return None + return round(self._accumulated_value, 1) + + async def async_added_to_hass(self) -> None: + """Load the last known state value of the entity if the accumulated type.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + self._accumulated_value = 0.0 + if state: + try: + # Previous value could be `unknown` if the connection didn't originally + # complete. + self._accumulated_value = float(state.state) + except (ValueError) as err: + _LOGGER.warning("Could not restore last state: %s", err) + else: + if ATTR_LAST_UPDATE in state.attributes: + last_run = dt.parse_datetime(state.attributes[ATTR_LAST_UPDATE]) + if last_run is not None: + self.coordinator.update_last_run(last_run) + # Force a second update from the iotawatt to ensure that sensors are up to date. + await self.coordinator.async_request_refresh() + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return f"{self._sensor_data.getSourceName()} Accumulated" + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the extra state attributes of the entity.""" + attrs = super().extra_state_attributes + + assert ( + self.coordinator.api is not None + and self.coordinator.api.getLastUpdateTime() is not None + ) + attrs[ATTR_LAST_UPDATE] = self.coordinator.api.getLastUpdateTime().isoformat() + + return attrs diff --git a/requirements_all.txt b/requirements_all.txt index 788698ccf66..7bf094727be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -865,7 +865,7 @@ influxdb-client==1.14.0 influxdb==5.2.3 # homeassistant.components.iotawatt -iotawattpy==0.0.8 +iotawattpy==0.1.0 # homeassistant.components.iperf3 iperf3==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc673eb2d33..62e13269a31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -505,7 +505,7 @@ influxdb-client==1.14.0 influxdb==5.2.3 # homeassistant.components.iotawatt -iotawattpy==0.0.8 +iotawattpy==0.1.0 # homeassistant.components.gogogate2 ismartgate==4.0.0 diff --git a/tests/components/iotawatt/__init__.py b/tests/components/iotawatt/__init__.py index 3d1afe1b88b..07ea6dfc15c 100644 --- a/tests/components/iotawatt/__init__.py +++ b/tests/components/iotawatt/__init__.py @@ -3,19 +3,46 @@ from iotawattpy.sensor import Sensor INPUT_SENSOR = Sensor( channel="1", - name="My Sensor", + base_name="My Sensor", + suffix=None, io_type="Input", - unit="WattHours", - value="23", + unit="Watts", + value=23, begin="", mac_addr="mock-mac", ) OUTPUT_SENSOR = Sensor( channel="N/A", - name="My WattHour Sensor", + base_name="My WattHour Sensor", + suffix=None, io_type="Output", unit="WattHours", - value="243", + value=243, begin="", mac_addr="mock-mac", + fromStart=True, +) + +INPUT_ACCUMULATED_SENSOR = Sensor( + channel="N/A", + base_name="My WattHour Accumulated Input Sensor", + suffix=".wh", + io_type="Input", + unit="WattHours", + value=500, + begin="", + mac_addr="mock-mac", + fromStart=False, +) + +OUTPUT_ACCUMULATED_SENSOR = Sensor( + channel="N/A", + base_name="My WattHour Accumulated Output Sensor", + suffix=".wh", + io_type="Output", + unit="WattHours", + value=200, + begin="", + mac_addr="mock-mac", + fromStart=False, ) diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index a5fc2250b84..8928c012d48 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,19 +1,33 @@ """Test setting up sensors.""" from datetime import timedelta -from homeassistant.components.sensor import ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY +from homeassistant.components.iotawatt.const import ATTR_LAST_UPDATE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, + POWER_WATT, ) +from homeassistant.core import State from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import INPUT_SENSOR, OUTPUT_SENSOR +from . import ( + INPUT_ACCUMULATED_SENSOR, + INPUT_SENSOR, + OUTPUT_ACCUMULATED_SENSOR, + OUTPUT_SENSOR, +) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache async def test_sensor_type_input(hass, mock_iotawatt): @@ -33,10 +47,10 @@ async def test_sensor_type_input(hass, mock_iotawatt): state = hass.states.get("sensor.my_sensor") assert state is not None assert state.state == "23" - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER assert state.attributes["channel"] == "1" assert state.attributes["type"] == "Input" @@ -60,6 +74,7 @@ async def test_sensor_type_output(hass, mock_iotawatt): state = hass.states.get("sensor.my_watthour_sensor") assert state is not None assert state.state == "243" + assert ATTR_STATE_CLASS not in state.attributes assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY @@ -70,3 +85,161 @@ async def test_sensor_type_output(hass, mock_iotawatt): await hass.async_block_till_done() assert hass.states.get("sensor.my_watthour_sensor") is None + + +async def test_sensor_type_accumulated_output(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "100.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "300.0" # 100 + 200 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + +async def test_sensor_type_accumulated_output_error_restore(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "unknown", + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "200.0" # Returns the new read as restore failed. + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + +async def test_sensor_type_multiple_accumulated_output(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_input_sensor_key" + ] = INPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "100.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + State( + "sensor.my_watthour_accumulated_input_sensor_wh_accumulated", + "50.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 2 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "300.0" # 100 + 200 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + state = hass.states.get( + "sensor.my_watthour_accumulated_input_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "550.0" # 50 + 500 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Input Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE