Add iotawatt high-accuracy energy readout sensors (#55512)

This commit is contained in:
Jean-Yves Avenard 2021-09-09 23:32:43 +10:00 committed by GitHub
parent 011817b122
commit 556dcf6abb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 334 additions and 34 deletions

View File

@ -248,7 +248,7 @@ homeassistant/components/integration/* @dgomes
homeassistant/components/intent/* @home-assistant/core homeassistant/components/intent/* @home-assistant/core
homeassistant/components/intesishome/* @jnimmo homeassistant/components/intesishome/* @jnimmo
homeassistant/components/ios/* @robbiet480 homeassistant/components/ios/* @robbiet480
homeassistant/components/iotawatt/* @gtdiehl homeassistant/components/iotawatt/* @gtdiehl @jyavenard
homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/iperf3/* @rohankapoorcom
homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipma/* @dgomes @abmantis
homeassistant/components/ipp/* @ctalkington homeassistant/components/ipp/* @ctalkington

View File

@ -9,4 +9,6 @@ DOMAIN = "iotawatt"
VOLT_AMPERE_REACTIVE = "VAR" VOLT_AMPERE_REACTIVE = "VAR"
VOLT_AMPERE_REACTIVE_HOURS = "VARh" VOLT_AMPERE_REACTIVE_HOURS = "VARh"
ATTR_LAST_UPDATE = "last_update"
CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError)

View File

@ -1,7 +1,7 @@
"""IoTaWatt DataUpdateCoordinator.""" """IoTaWatt DataUpdateCoordinator."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
from iotawattpy.iotawatt import Iotawatt from iotawattpy.iotawatt import Iotawatt
@ -32,6 +32,16 @@ class IotawattUpdater(DataUpdateCoordinator):
update_interval=timedelta(seconds=30), 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): async def _async_update_data(self):
"""Fetch sensors from IoTaWatt device.""" """Fetch sensors from IoTaWatt device."""
if self.api is None: if self.api is None:
@ -52,5 +62,6 @@ class IotawattUpdater(DataUpdateCoordinator):
self.api = api self.api = api
await self.api.update() await self.api.update(lastUpdate=self._last_run)
self._last_run = None
return self.api.getSensors() return self.api.getSensors()

View File

@ -4,10 +4,11 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iotawatt", "documentation": "https://www.home-assistant.io/integrations/iotawatt",
"requirements": [ "requirements": [
"iotawattpy==0.0.8" "iotawattpy==0.1.0"
], ],
"codeowners": [ "codeowners": [
"@gtdiehl" "@gtdiehl",
"@jyavenard"
], ],
"iot_class": "local_polling" "iot_class": "local_polling"
} }

View File

@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Callable from typing import Callable
from iotawattpy.sensor import Sensor from iotawattpy.sensor import Sensor
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT, STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
) )
@ -28,10 +30,19 @@ from homeassistant.const import (
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import entity, entity_registry, update_coordinator from homeassistant.helpers import entity, entity_registry, update_coordinator
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC 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 from .coordinator import IotawattUpdater
_LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class IotaWattSensorEntityDescription(SensorEntityDescription): class IotaWattSensorEntityDescription(SensorEntityDescription):
@ -114,15 +125,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
def _create_entity(key: str) -> IotaWattSensor: def _create_entity(key: str) -> IotaWattSensor:
"""Create a sensor entity.""" """Create a sensor entity."""
created.add(key) 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( return IotaWattSensor(
coordinator=coordinator, coordinator=coordinator,
key=key, key=key,
mac_address=coordinator.data["sensors"][key].hub_mac_address, entity_description=description,
name=coordinator.data["sensors"][key].getName(),
entity_description=ENTITY_DESCRIPTION_KEY_MAP.get(
coordinator.data["sensors"][key].getUnit(),
IotaWattSensorEntityDescription("base_sensor"),
),
) )
async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) 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.""" """Defines a IoTaWatt Energy Sensor."""
entity_description: IotaWattSensorEntityDescription entity_description: IotaWattSensorEntityDescription
_attr_force_update = True coordinator: IotawattUpdater
def __init__( def __init__(
self, self,
coordinator, coordinator: IotawattUpdater,
key, key: str,
mac_address,
name,
entity_description: IotaWattSensorEntityDescription, entity_description: IotaWattSensorEntityDescription,
): ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator=coordinator) super().__init__(coordinator=coordinator)
@ -196,17 +209,15 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity):
else: else:
self.hass.async_create_task(self.async_remove()) self.hass.async_create_task(self.async_remove())
return return
super()._handle_coordinator_update() super()._handle_coordinator_update()
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, str]:
"""Return the extra state attributes of the entity.""" """Return the extra state attributes of the entity."""
data = self._sensor_data data = self._sensor_data
attrs = {"type": data.getType()} attrs = {"type": data.getType()}
if attrs["type"] == "Input": if attrs["type"] == "Input":
attrs["channel"] = data.getChannel() attrs["channel"] = data.getChannel()
return attrs return attrs
@property @property
@ -216,3 +227,78 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity):
return func(self._sensor_data.getValue()) return func(self._sensor_data.getValue())
return 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

View File

@ -865,7 +865,7 @@ influxdb-client==1.14.0
influxdb==5.2.3 influxdb==5.2.3
# homeassistant.components.iotawatt # homeassistant.components.iotawatt
iotawattpy==0.0.8 iotawattpy==0.1.0
# homeassistant.components.iperf3 # homeassistant.components.iperf3
iperf3==0.1.11 iperf3==0.1.11

View File

@ -505,7 +505,7 @@ influxdb-client==1.14.0
influxdb==5.2.3 influxdb==5.2.3
# homeassistant.components.iotawatt # homeassistant.components.iotawatt
iotawattpy==0.0.8 iotawattpy==0.1.0
# homeassistant.components.gogogate2 # homeassistant.components.gogogate2
ismartgate==4.0.0 ismartgate==4.0.0

View File

@ -3,19 +3,46 @@ from iotawattpy.sensor import Sensor
INPUT_SENSOR = Sensor( INPUT_SENSOR = Sensor(
channel="1", channel="1",
name="My Sensor", base_name="My Sensor",
suffix=None,
io_type="Input", io_type="Input",
unit="WattHours", unit="Watts",
value="23", value=23,
begin="", begin="",
mac_addr="mock-mac", mac_addr="mock-mac",
) )
OUTPUT_SENSOR = Sensor( OUTPUT_SENSOR = Sensor(
channel="N/A", channel="N/A",
name="My WattHour Sensor", base_name="My WattHour Sensor",
suffix=None,
io_type="Output", io_type="Output",
unit="WattHours", unit="WattHours",
value="243", value=243,
begin="", begin="",
mac_addr="mock-mac", 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,
) )

View File

@ -1,19 +1,33 @@
"""Test setting up sensors.""" """Test setting up sensors."""
from datetime import timedelta 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 ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_POWER,
ENERGY_WATT_HOUR, ENERGY_WATT_HOUR,
POWER_WATT,
) )
from homeassistant.core import State
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow 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): 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") state = hass.states.get("sensor.my_sensor")
assert state is not None assert state is not None
assert state.state == "23" 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_FRIENDLY_NAME] == "My Sensor"
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER
assert state.attributes["channel"] == "1" assert state.attributes["channel"] == "1"
assert state.attributes["type"] == "Input" 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") state = hass.states.get("sensor.my_watthour_sensor")
assert state is not None assert state is not None
assert state.state == "243" 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_FRIENDLY_NAME] == "My WattHour Sensor"
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY 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() await hass.async_block_till_done()
assert hass.states.get("sensor.my_watthour_sensor") is None 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