Add powerfox integration (#131640)

This commit is contained in:
Klaas Schoute
2024-12-04 00:35:50 +01:00
committed by GitHub
parent 535b47789f
commit abd3466d19
23 changed files with 1228 additions and 0 deletions

View File

@ -365,6 +365,7 @@ homeassistant.components.persistent_notification.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
homeassistant.components.powerfox.*
homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*

View File

@ -1133,6 +1133,8 @@ build.json @home-assistant/supervisor
/tests/components/point/ @fredrike
/homeassistant/components/poolsense/ @haemishkyd
/tests/components/poolsense/ @haemishkyd
/homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/private_ble_device/ @Jc2k

View File

@ -0,0 +1,55 @@
"""The Powerfox integration."""
from __future__ import annotations
import asyncio
from powerfox import Powerfox, PowerfoxConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import PowerfoxDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]]
async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool:
"""Set up Powerfox from a config entry."""
client = Powerfox(
username=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
session=async_get_clientsession(hass),
)
try:
devices = await client.all_devices()
except PowerfoxConnectionError as err:
await client.close()
raise ConfigEntryNotReady from err
coordinators: list[PowerfoxDataUpdateCoordinator] = [
PowerfoxDataUpdateCoordinator(hass, client, device) for device in devices
]
await asyncio.gather(
*[
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators
]
)
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,57 @@
"""Config flow for Powerfox integration."""
from __future__ import annotations
from typing import Any
from powerfox import Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)
class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Powerfox."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
client = Powerfox(
username=user_input[CONF_EMAIL],
password=user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
try:
await client.all_devices()
except PowerfoxAuthenticationError:
errors["base"] = "invalid_auth"
except PowerfoxConnectionError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=user_input[CONF_EMAIL],
data={
CONF_EMAIL: user_input[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=STEP_USER_DATA_SCHEMA,
)

View File

@ -0,0 +1,11 @@
"""Constants for the Powerfox integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Final
DOMAIN: Final = "powerfox"
LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(minutes=5)

View File

@ -0,0 +1,40 @@
"""Coordinator for Powerfox integration."""
from __future__ import annotations
from powerfox import Device, Powerfox, PowerfoxConnectionError, Poweropti
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]):
"""Class to manage fetching Powerfox data from the API."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: Powerfox,
device: Device,
) -> None:
"""Initialize global Powerfox data updater."""
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.client = client
self.device = device
async def _async_update_data(self) -> Poweropti:
"""Fetch data from Powerfox API."""
try:
return await self.client.device(device_id=self.device.id)
except PowerfoxConnectionError as error:
raise UpdateFailed(error) from error

View File

@ -0,0 +1,32 @@
"""Generic entity for Powerfox."""
from __future__ import annotations
from powerfox import Device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import PowerfoxDataUpdateCoordinator
class PowerfoxEntity(CoordinatorEntity[PowerfoxDataUpdateCoordinator]):
"""Base entity for Powerfox."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: PowerfoxDataUpdateCoordinator,
device: Device,
) -> None:
"""Initialize Powerfox entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
manufacturer="Powerfox",
model=device.type.human_readable,
name=device.name,
serial_number=device.id,
)

View File

@ -0,0 +1,16 @@
{
"domain": "powerfox",
"name": "Powerfox",
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/powerfox",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["powerfox==1.0.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
"name": "powerfox*"
}
]
}

View File

@ -0,0 +1,92 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |
This integration uses a coordinator to handle updates.
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration is connecting to a cloud service.
discovery:
status: exempt
comment: |
It can find poweropti devices via zeroconf, but will start a normal user flow.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration does not have any entities that should disabled by default.
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: |
There is no need for icon translations.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration doesn't have any cases where raising an issue is needed.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@ -0,0 +1,147 @@
"""Sensors for Powerfox integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar
from powerfox import Device, PowerMeter, WaterMeter
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import PowerfoxConfigEntry
from .coordinator import PowerfoxDataUpdateCoordinator
from .entity import PowerfoxEntity
T = TypeVar("T", PowerMeter, WaterMeter)
@dataclass(frozen=True, kw_only=True)
class PowerfoxSensorEntityDescription(Generic[T], SensorEntityDescription):
"""Describes Poweropti sensor entity."""
value_fn: Callable[[T], float | int | None]
SENSORS_POWER: tuple[PowerfoxSensorEntityDescription[PowerMeter], ...] = (
PowerfoxSensorEntityDescription[PowerMeter](
key="power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda meter: meter.power,
),
PowerfoxSensorEntityDescription[PowerMeter](
key="energy_usage",
translation_key="energy_usage",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda meter: meter.energy_usage,
),
PowerfoxSensorEntityDescription[PowerMeter](
key="energy_usage_low_tariff",
translation_key="energy_usage_low_tariff",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda meter: meter.energy_usage_low_tariff,
),
PowerfoxSensorEntityDescription[PowerMeter](
key="energy_usage_high_tariff",
translation_key="energy_usage_high_tariff",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda meter: meter.energy_usage_high_tariff,
),
PowerfoxSensorEntityDescription[PowerMeter](
key="energy_return",
translation_key="energy_return",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda meter: meter.energy_return,
),
)
SENSORS_WATER: tuple[PowerfoxSensorEntityDescription[WaterMeter], ...] = (
PowerfoxSensorEntityDescription[WaterMeter](
key="cold_water",
translation_key="cold_water",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda meter: meter.cold_water,
),
PowerfoxSensorEntityDescription[WaterMeter](
key="warm_water",
translation_key="warm_water",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda meter: meter.warm_water,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerfoxConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Powerfox sensors based on a config entry."""
entities: list[SensorEntity] = []
for coordinator in entry.runtime_data:
if isinstance(coordinator.data, PowerMeter):
entities.extend(
PowerfoxSensorEntity(
coordinator=coordinator,
description=description,
device=coordinator.device,
)
for description in SENSORS_POWER
if description.value_fn(coordinator.data) is not None
)
if isinstance(coordinator.data, WaterMeter):
entities.extend(
PowerfoxSensorEntity(
coordinator=coordinator,
description=description,
device=coordinator.device,
)
for description in SENSORS_WATER
)
async_add_entities(entities)
class PowerfoxSensorEntity(PowerfoxEntity, SensorEntity):
"""Defines a powerfox power meter sensor."""
entity_description: PowerfoxSensorEntityDescription
def __init__(
self,
coordinator: PowerfoxDataUpdateCoordinator,
device: Device,
description: PowerfoxSensorEntityDescription,
) -> None:
"""Initialize Powerfox power meter sensor."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.id}_{description.key}"
@property
def native_value(self) -> float | int | None:
"""Return the state of the entity."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,46 @@
{
"config": {
"step": {
"user": {
"description": "Connect to your Powerfox account to get information about your energy, heat or water consumption.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "The email address of your Powerfox account.",
"password": "The password of your Powerfox account."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
},
"entity": {
"sensor": {
"energy_usage": {
"name": "Energy usage"
},
"energy_usage_low_tariff": {
"name": "Energy usage low tariff"
},
"energy_usage_high_tariff": {
"name": "Energy usage high tariff"
},
"energy_return": {
"name": "Energy return"
},
"cold_water": {
"name": "Cold water"
},
"warm_water": {
"name": "Warm water"
}
}
}
}

View File

@ -461,6 +461,7 @@ FLOWS = {
"plum_lightpad",
"point",
"poolsense",
"powerfox",
"powerwall",
"private_ble_device",
"profiler",

View File

@ -4763,6 +4763,12 @@
"integration_type": "virtual",
"supported_by": "opower"
},
"powerfox": {
"name": "Powerfox",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"private_ble_device": {
"name": "Private BLE Device",
"integration_type": "hub",

View File

@ -542,6 +542,10 @@ ZEROCONF = {
"manufacturer": "nettigo",
},
},
{
"domain": "powerfox",
"name": "powerfox*",
},
{
"domain": "pure_energie",
"name": "smartbridge*",

View File

@ -3406,6 +3406,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.powerfox.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.powerwall.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -1633,6 +1633,9 @@ pmsensor==0.4
# homeassistant.components.poolsense
poolsense==0.0.8
# homeassistant.components.powerfox
powerfox==1.0.0
# homeassistant.components.reddit
praw==7.5.0

View File

@ -1340,6 +1340,9 @@ plumlightpad==0.0.11
# homeassistant.components.poolsense
poolsense==0.0.8
# homeassistant.components.powerfox
powerfox==1.0.0
# homeassistant.components.reddit
praw==7.5.0

View File

@ -0,0 +1,14 @@
"""Tests for the Powerfox integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
MOCK_DIRECT_HOST = "1.1.1.1"
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the integration."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)

View File

@ -0,0 +1,87 @@
"""Common fixtures for the Powerfox tests."""
from collections.abc import Generator
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
from powerfox import Device, DeviceType, PowerMeter, WaterMeter
import pytest
from homeassistant.components.powerfox.const import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.powerfox.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_powerfox_client() -> Generator[AsyncMock]:
"""Mock a Powerfox client."""
with (
patch(
"homeassistant.components.powerfox.Powerfox",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.powerfox.config_flow.Powerfox",
new=mock_client,
),
):
client = mock_client.return_value
client.all_devices.return_value = [
Device(
id="9x9x1f12xx3x",
date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC),
main_device=True,
bidirectional=True,
type=DeviceType.POWER_METER,
name="Poweropti",
),
Device(
id="9x9x1f12xx4x",
date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC),
main_device=False,
bidirectional=False,
type=DeviceType.COLD_WATER_METER,
name="Wateropti",
),
]
client.device.side_effect = [
PowerMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
power=111,
energy_usage=1111.111,
energy_return=111.111,
energy_usage_high_tariff=111.111,
energy_usage_low_tariff=111.111,
),
WaterMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
cold_water=1111.111,
warm_water=0.0,
),
]
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a Powerfox config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Powerfox",
data={
CONF_EMAIL: "test@powerfox.test",
CONF_PASSWORD: "test-password",
},
)

View File

@ -0,0 +1,358 @@
# serializer version: 1
# name: test_all_sensors[sensor.poweropti_energy_return-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.poweropti_energy_return',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy return',
'platform': 'powerfox',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'energy_return',
'unique_id': '9x9x1f12xx3x_energy_return',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.poweropti_energy_return-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Poweropti Energy return',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.poweropti_energy_return',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '111.111',
})
# ---
# name: test_all_sensors[sensor.poweropti_energy_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.poweropti_energy_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy usage',
'platform': 'powerfox',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'energy_usage',
'unique_id': '9x9x1f12xx3x_energy_usage',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.poweropti_energy_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Poweropti Energy usage',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.poweropti_energy_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1111.111',
})
# ---
# name: test_all_sensors[sensor.poweropti_energy_usage_high_tariff-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.poweropti_energy_usage_high_tariff',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy usage high tariff',
'platform': 'powerfox',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'energy_usage_high_tariff',
'unique_id': '9x9x1f12xx3x_energy_usage_high_tariff',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.poweropti_energy_usage_high_tariff-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Poweropti Energy usage high tariff',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.poweropti_energy_usage_high_tariff',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '111.111',
})
# ---
# name: test_all_sensors[sensor.poweropti_energy_usage_low_tariff-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.poweropti_energy_usage_low_tariff',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy usage low tariff',
'platform': 'powerfox',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'energy_usage_low_tariff',
'unique_id': '9x9x1f12xx3x_energy_usage_low_tariff',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.poweropti_energy_usage_low_tariff-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Poweropti Energy usage low tariff',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.poweropti_energy_usage_low_tariff',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '111.111',
})
# ---
# name: test_all_sensors[sensor.poweropti_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.poweropti_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Power',
'platform': 'powerfox',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '9x9x1f12xx3x_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_all_sensors[sensor.poweropti_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Poweropti Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.poweropti_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '111',
})
# ---
# name: test_all_sensors[sensor.wateropti_cold_water-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.wateropti_cold_water',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': None,
'original_name': 'Cold water',
'platform': 'powerfox',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'cold_water',
'unique_id': '9x9x1f12xx4x_cold_water',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
})
# ---
# name: test_all_sensors[sensor.wateropti_cold_water-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Wateropti Cold water',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
}),
'context': <ANY>,
'entity_id': 'sensor.wateropti_cold_water',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1111.111',
})
# ---
# name: test_all_sensors[sensor.wateropti_warm_water-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.wateropti_warm_water',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': None,
'original_name': 'Warm water',
'platform': 'powerfox',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'warm_water',
'unique_id': '9x9x1f12xx4x_warm_water',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
})
# ---
# name: test_all_sensors[sensor.wateropti_warm_water-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Wateropti Warm water',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
}),
'context': <ANY>,
'entity_id': 'sensor.wateropti_warm_water',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---

View File

@ -0,0 +1,145 @@
"""Test the Powerfox config flow."""
from unittest.mock import AsyncMock
from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError
import pytest
from homeassistant.components import zeroconf
from homeassistant.components.powerfox.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import MOCK_DIRECT_HOST
from tests.common import MockConfigEntry
MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
ip_address=MOCK_DIRECT_HOST,
ip_addresses=[MOCK_DIRECT_HOST],
hostname="powerfox.local",
name="Powerfox",
port=443,
type="_http._tcp",
properties={},
)
async def test_full_user_flow(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the full user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
assert not result.get("errors")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == "test@powerfox.test"
assert result.get("data") == {
CONF_EMAIL: "test@powerfox.test",
CONF_PASSWORD: "test-password",
}
assert len(mock_powerfox_client.all_devices.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_discovery(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test zeroconf discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=MOCK_ZEROCONF_DISCOVERY_INFO,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
assert not result.get("errors")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == "test@powerfox.test"
assert result.get("data") == {
CONF_EMAIL: "test@powerfox.test",
CONF_PASSWORD: "test-password",
}
assert len(mock_powerfox_client.all_devices.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_duplicate_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_powerfox_client: AsyncMock,
) -> None:
"""Test abort when setting up duplicate entry."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.FORM
assert not result.get("errors")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
@pytest.mark.parametrize(
("exception", "error"),
[
(PowerfoxConnectionError, "cannot_connect"),
(PowerfoxAuthenticationError, "invalid_auth"),
],
)
async def test_exceptions(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test exceptions during config flow."""
mock_powerfox_client.all_devices.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": error}
mock_powerfox_client.all_devices.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY

View File

@ -0,0 +1,45 @@
"""Test the Powerfox init module."""
from __future__ import annotations
from unittest.mock import AsyncMock
from powerfox import PowerfoxConnectionError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
async def test_load_unload_entry(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test load and unload entry."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_remove(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Powerfox configuration entry not ready."""
mock_powerfox_client.all_devices.side_effect = PowerfoxConnectionError
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@ -0,0 +1,53 @@
"""Test the sensors provided by the Powerfox integration."""
from __future__ import annotations
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from powerfox import PowerfoxConnectionError
from syrupy import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_all_sensors(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Powerfox sensors."""
with patch("homeassistant.components.powerfox.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_update_failed(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entities become unavailable after failed update."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert hass.states.get("sensor.poweropti_energy_usage").state is not None
mock_powerfox_client.device.side_effect = PowerfoxConnectionError
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("sensor.poweropti_energy_usage").state == STATE_UNAVAILABLE