Add account sensors to electric kiwi integration (#97681)

* add account sensors

* tidy up same issues as other sensors

* add unit tests for sensors

edit and remove comments

assert state and remove HOP sensor types since they aren't being used

* try and fix tests

* add frozen true

* Update tests/components/electric_kiwi/test_sensor.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* return proper native types

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* tidy up attr unique id

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* add entities once and use native values properly

* Improve conftest

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* tidy tests/components/electric_kiwi/test_sensor.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* add assert to component_setup

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* add extra parameters to test

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update tests/components/electric_kiwi/test_sensor.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update tests/components/electric_kiwi/test_sensor.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* change coordinator name

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* tidy up sensor translation names

* Apply suggestions from code review

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Michael Arthur 2024-01-14 06:12:40 +13:00 committed by GitHub
parent 8395d84bbb
commit b1a246b817
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 284 additions and 29 deletions

View File

@ -12,8 +12,11 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import api from . import api
from .const import DOMAIN from .const import ACCOUNT_COORDINATOR, DOMAIN, HOP_COORDINATOR
from .coordinator import ElectricKiwiHOPDataCoordinator from .coordinator import (
ElectricKiwiAccountDataCoordinator,
ElectricKiwiHOPDataCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SELECT] PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SELECT]
@ -41,14 +44,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
) )
hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api)
account_coordinator = ElectricKiwiAccountDataCoordinator(hass, ek_api)
try: try:
await ek_api.set_active_session() await ek_api.set_active_session()
await hop_coordinator.async_config_entry_first_refresh() await hop_coordinator.async_config_entry_first_refresh()
await account_coordinator.async_config_entry_first_refresh()
except ApiException as err: except ApiException as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hop_coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
HOP_COORDINATOR: hop_coordinator,
ACCOUNT_COORDINATOR: account_coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -9,3 +9,6 @@ OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token"
API_BASE_URL = "https://api.electrickiwi.co.nz" API_BASE_URL = "https://api.electrickiwi.co.nz"
SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session"
HOP_COORDINATOR = "hop_coordinator"
ACCOUNT_COORDINATOR = "account_coordinator"

View File

@ -6,7 +6,7 @@ import logging
from electrickiwi_api import ElectricKiwiApi from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.exceptions import ApiException, AuthException
from electrickiwi_api.model import Hop, HopIntervals from electrickiwi_api.model import AccountBalance, Hop, HopIntervals
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
@ -14,11 +14,38 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ACCOUNT_SCAN_INTERVAL = timedelta(hours=6)
HOP_SCAN_INTERVAL = timedelta(minutes=20) HOP_SCAN_INTERVAL = timedelta(minutes=20)
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator):
"""ElectricKiwi Account Data object."""
def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None:
"""Initialize ElectricKiwiAccountDataCoordinator."""
super().__init__(
hass,
_LOGGER,
name="Electric Kiwi Account Data",
update_interval=ACCOUNT_SCAN_INTERVAL,
)
self._ek_api = ek_api
async def _async_update_data(self) -> AccountBalance:
"""Fetch data from Account balance API endpoint."""
try:
async with asyncio.timeout(60):
return await self._ek_api.get_account_balance()
except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
raise UpdateFailed(
f"Error communicating with EK API: {api_err}"
) from api_err
class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
"""ElectricKiwi Data object.""" """ElectricKiwi HOP Data object."""
def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None:
"""Initialize ElectricKiwiAccountDataCoordinator.""" """Initialize ElectricKiwiAccountDataCoordinator."""

View File

@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN from .const import ATTRIBUTION, DOMAIN, HOP_COORDINATOR
from .coordinator import ElectricKiwiHOPDataCoordinator from .coordinator import ElectricKiwiHOPDataCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -19,7 +19,7 @@ ATTR_EK_HOP_SELECT = "hop_select"
HOP_SELECT = SelectEntityDescription( HOP_SELECT = SelectEntityDescription(
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
key=ATTR_EK_HOP_SELECT, key=ATTR_EK_HOP_SELECT,
translation_key="hopselector", translation_key="hop_selector",
) )
@ -27,7 +27,9 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Electric Kiwi select setup.""" """Electric Kiwi select setup."""
hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][
HOP_COORDINATOR
]
_LOGGER.debug("Setting up select entity") _LOGGER.debug("Setting up select entity")
async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)]) async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)])

View File

@ -4,28 +4,89 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging
from electrickiwi_api.model import Hop from electrickiwi_api.model import AccountBalance, Hop
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import ATTRIBUTION, DOMAIN from .const import ACCOUNT_COORDINATOR, ATTRIBUTION, DOMAIN, HOP_COORDINATOR
from .coordinator import ElectricKiwiHOPDataCoordinator from .coordinator import (
ElectricKiwiAccountDataCoordinator,
ElectricKiwiHOPDataCoordinator,
)
_LOGGER = logging.getLogger(DOMAIN) ATTR_EK_HOP_START = "hop_power_start"
ATTR_EK_HOP_END = "hop_power_end"
ATTR_TOTAL_RUNNING_BALANCE = "total_running_balance"
ATTR_TOTAL_CURRENT_BALANCE = "total_account_balance"
ATTR_NEXT_BILLING_DATE = "next_billing_date"
ATTR_HOP_PERCENTAGE = "hop_percentage"
ATTR_EK_HOP_START = "hop_sensor_start"
ATTR_EK_HOP_END = "hop_sensor_end" @dataclass(frozen=True)
class ElectricKiwiAccountRequiredKeysMixin:
"""Mixin for required keys."""
value_func: Callable[[AccountBalance], float | datetime]
@dataclass(frozen=True)
class ElectricKiwiAccountSensorEntityDescription(
SensorEntityDescription, ElectricKiwiAccountRequiredKeysMixin
):
"""Describes Electric Kiwi sensor entity."""
ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = (
ElectricKiwiAccountSensorEntityDescription(
key=ATTR_TOTAL_RUNNING_BALANCE,
translation_key="total_running_balance",
icon="mdi:currency-usd",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=CURRENCY_DOLLAR,
value_func=lambda account_balance: float(account_balance.total_running_balance),
),
ElectricKiwiAccountSensorEntityDescription(
key=ATTR_TOTAL_CURRENT_BALANCE,
translation_key="total_current_balance",
icon="mdi:currency-usd",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=CURRENCY_DOLLAR,
value_func=lambda account_balance: float(account_balance.total_account_balance),
),
ElectricKiwiAccountSensorEntityDescription(
key=ATTR_NEXT_BILLING_DATE,
translation_key="next_billing_date",
icon="mdi:calendar",
device_class=SensorDeviceClass.DATE,
value_func=lambda account_balance: datetime.strptime(
account_balance.next_billing_date, "%Y-%m-%d"
),
),
ElectricKiwiAccountSensorEntityDescription(
key=ATTR_HOP_PERCENTAGE,
translation_key="hop_power_savings",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_func=lambda account_balance: float(
account_balance.connections[0].hop_percentage
),
),
)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -65,13 +126,13 @@ def _check_and_move_time(hop: Hop, time: str) -> datetime:
HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = (
ElectricKiwiHOPSensorEntityDescription( ElectricKiwiHOPSensorEntityDescription(
key=ATTR_EK_HOP_START, key=ATTR_EK_HOP_START,
translation_key="hopfreepowerstart", translation_key="hop_free_power_start",
device_class=SensorDeviceClass.TIMESTAMP, device_class=SensorDeviceClass.TIMESTAMP,
value_func=lambda hop: _check_and_move_time(hop, hop.start.start_time), value_func=lambda hop: _check_and_move_time(hop, hop.start.start_time),
), ),
ElectricKiwiHOPSensorEntityDescription( ElectricKiwiHOPSensorEntityDescription(
key=ATTR_EK_HOP_END, key=ATTR_EK_HOP_END,
translation_key="hopfreepowerend", translation_key="hop_free_power_end",
device_class=SensorDeviceClass.TIMESTAMP, device_class=SensorDeviceClass.TIMESTAMP,
value_func=lambda hop: _check_and_move_time(hop, hop.end.end_time), value_func=lambda hop: _check_and_move_time(hop, hop.end.end_time),
), ),
@ -81,13 +142,58 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = (
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Electric Kiwi Sensor Setup.""" """Electric Kiwi Sensors Setup."""
hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] account_coordinator: ElectricKiwiAccountDataCoordinator = hass.data[DOMAIN][
hop_entities = [ entry.entry_id
][ACCOUNT_COORDINATOR]
entities: list[SensorEntity] = [
ElectricKiwiAccountEntity(
account_coordinator,
description,
)
for description in ACCOUNT_SENSOR_TYPES
]
hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][
HOP_COORDINATOR
]
entities.extend(
[
ElectricKiwiHOPEntity(hop_coordinator, description) ElectricKiwiHOPEntity(hop_coordinator, description)
for description in HOP_SENSOR_TYPES for description in HOP_SENSOR_TYPES
] ]
async_add_entities(hop_entities) )
async_add_entities(entities)
class ElectricKiwiAccountEntity(
CoordinatorEntity[ElectricKiwiAccountDataCoordinator], SensorEntity
):
"""Entity object for Electric Kiwi sensor."""
entity_description: ElectricKiwiAccountSensorEntityDescription
_attr_has_entity_name = True
_attr_attribution = ATTRIBUTION
def __init__(
self,
coordinator: ElectricKiwiAccountDataCoordinator,
description: ElectricKiwiAccountSensorEntityDescription,
) -> None:
"""Entity object for Electric Kiwi sensor."""
super().__init__(coordinator)
self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}"
f"_{coordinator._ek_api.connection_id}_{description.key}"
)
self.entity_description = description
@property
def native_value(self) -> float | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_func(self.coordinator.data)
class ElectricKiwiHOPEntity( class ElectricKiwiHOPEntity(

View File

@ -28,9 +28,25 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"hopfreepowerstart": { "name": "Hour of free power start" }, "hop_free_power_start": {
"hopfreepowerend": { "name": "Hour of free power end" } "name": "Hour of free power start"
}, },
"select": { "hopselector": { "name": "Hour of free power" } } "hop_free_power_end": {
"name": "Hour of free power end"
},
"total_running_balance": {
"name": "Total running balance"
},
"total_current_balance": {
"name": "Total current balance"
},
"next_billing_date": {
"name": "Next billing date"
},
"hop_power_savings": {
"name": "Hour of power savings"
}
},
"select": { "hop_selector": { "name": "Hour of free power" } }
} }
} }

View File

@ -6,7 +6,7 @@ from time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import zoneinfo import zoneinfo
from electrickiwi_api.model import Hop, HopIntervals from electrickiwi_api.model import AccountBalance, Hop, HopIntervals
import pytest import pytest
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
@ -43,14 +43,18 @@ def component_setup(
async def _setup_func() -> bool: async def _setup_func() -> bool:
assert await async_setup_component(hass, "application_credentials", {}) assert await async_setup_component(hass, "application_credentials", {})
await hass.async_block_till_done()
await async_import_client_credential( await async_import_client_credential(
hass, hass,
DOMAIN, DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET), ClientCredential(CLIENT_ID, CLIENT_SECRET),
DOMAIN, DOMAIN,
) )
await hass.async_block_till_done()
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
return await hass.config_entries.async_setup(config_entry.entry_id) result = await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return result
return _setup_func return _setup_func
@ -113,4 +117,9 @@ def ek_api() -> YieldFixture:
mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( mock_ek_api.return_value.get_hop.return_value = Hop.from_dict(
load_json_value_fixture("get_hop.json", DOMAIN) load_json_value_fixture("get_hop.json", DOMAIN)
) )
mock_ek_api.return_value.get_account_balance.return_value = (
AccountBalance.from_dict(
load_json_value_fixture("account_balance.json", DOMAIN)
)
)
yield mock_ek_api yield mock_ek_api

View File

@ -0,0 +1,28 @@
{
"data": {
"connections": [
{
"hop_percentage": "3.5",
"id": 3,
"running_balance": "184.09",
"start_date": "2020-10-04",
"unbilled_days": 15
}
],
"last_billed_amount": "-66.31",
"last_billed_date": "2020-10-03",
"next_billing_date": "2020-11-03",
"is_prepay": "N",
"summary": {
"credits": "0.0",
"electricity_used": "184.09",
"other_charges": "0.00",
"payments": "-220.0"
},
"total_account_balance": "-102.22",
"total_billing_days": 30,
"total_running_balance": "184.09",
"type": "account_running_balance"
},
"status": 1
}

View File

@ -9,7 +9,11 @@ import pytest
from homeassistant.components.electric_kiwi.const import ATTRIBUTION from homeassistant.components.electric_kiwi.const import ATTRIBUTION
from homeassistant.components.electric_kiwi.sensor import _check_and_move_time from homeassistant.components.electric_kiwi.sensor import _check_and_move_time
from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -65,6 +69,58 @@ async def test_hop_sensors(
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP
@pytest.mark.parametrize(
("sensor", "sensor_state", "device_class", "state_class"),
[
(
"sensor.total_running_balance",
"184.09",
SensorDeviceClass.MONETARY,
SensorStateClass.TOTAL,
),
(
"sensor.total_current_balance",
"-102.22",
SensorDeviceClass.MONETARY,
SensorStateClass.TOTAL,
),
(
"sensor.next_billing_date",
"2020-11-03T00:00:00",
SensorDeviceClass.DATE,
None,
),
("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT),
],
)
async def test_account_sensors(
hass: HomeAssistant,
config_entry: MockConfigEntry,
ek_api: YieldFixture,
ek_auth: YieldFixture,
entity_registry: EntityRegistry,
component_setup: ComponentSetup,
sensor: str,
sensor_state: str,
device_class: str,
state_class: str,
) -> None:
"""Test Account sensors for the Electric Kiwi integration."""
assert await component_setup()
assert config_entry.state is ConfigEntryState.LOADED
entity = entity_registry.async_get(sensor)
assert entity
state = hass.states.get(sensor)
assert state
assert state.state == sensor_state
assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
assert state.attributes.get(ATTR_DEVICE_CLASS) == device_class
assert state.attributes.get(ATTR_STATE_CLASS) == state_class
async def test_check_and_move_time(ek_api: AsyncMock) -> None: async def test_check_and_move_time(ek_api: AsyncMock) -> None:
"""Test correct time is returned depending on time of day.""" """Test correct time is returned depending on time of day."""
hop = await ek_api(Mock()).get_hop() hop = await ek_api(Mock()).get_hop()