Skip to content

Commit

Permalink
Add sensor platform to laundrify integration (#121378)
Browse files Browse the repository at this point in the history
* feat: initial implementation of sensor platform

* refactor(tests): await setup of config_entry in parent function

* feat(tests): add tests for laundrify sensor platform

* refactor: set name property for laundrify binary_sensor

* refactor(tests): add missing type hints

* refactor(tests): remove global change of the logging level

* refactor: address minor changes from code review

* refactor(tests): transform setup_config_entry into fixture

* refactor: leverage entity descriptions to define common entity properties

* refactor: change native unit to Wh

* fix(tests): use fixture to create the config entry

* fix: remove redundant raise of LaundrifyDeviceException

* fix(tests): raise a LaundrifyDeviceException to test the update failure behavior

* refactor(tests): merge several library fixtures into a single one

* refactor(tests): create a separate UpdateCoordinator instead of using the internal

* refactor(tests): avoid using LaundrifyPowerSensor

* refactor: simplify value retrieval by directly accessing the coordinator

* refactor: remove non-raising code from try-block

* refactor(sensor): revert usage of entity descriptions

* refactor(sensor): consolidate common attributes and init func to LaundrifyBaseSensor

* refactor(sensor): instantiate DeviceInfo obj instead of using dict

* refactor(tests): use freezer to trigger coordinator update

* refactor(tests): assert on entity state instead of coordinator

* refactor(tests): make use of freezer

* chore(tests): typo in comment
  • Loading branch information
xLarry authored Sep 16, 2024
1 parent 587ebd5 commit 7ada2f8
Show file tree
Hide file tree
Showing 10 changed files with 328 additions and 128 deletions.
2 changes: 1 addition & 1 deletion homeassistant/components/laundrify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from .const import DEFAULT_POLL_INTERVAL, DOMAIN
from .coordinator import LaundrifyUpdateCoordinator

PLATFORMS = [Platform.BINARY_SENSOR]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/laundrify/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ class LaundrifyPowerPlug(
_attr_device_class = BinarySensorDeviceClass.RUNNING
_attr_unique_id: str
_attr_has_entity_name = True
_attr_name = None
_attr_translation_key = "wash_cycle"

def __init__(
Expand Down
99 changes: 99 additions & 0 deletions homeassistant/components/laundrify/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Platform for sensor integration."""

import logging

from laundrify_aio import LaundrifyDevice
from laundrify_aio.exceptions import LaundrifyDeviceException

from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import LaundrifyUpdateCoordinator

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add power sensor for passed config_entry in HA."""

coordinator: LaundrifyUpdateCoordinator = hass.data[DOMAIN][config.entry_id][
"coordinator"
]

sensor_entities: list[LaundrifyPowerSensor | LaundrifyEnergySensor] = []
for device in coordinator.data.values():
sensor_entities.append(LaundrifyPowerSensor(device))
sensor_entities.append(LaundrifyEnergySensor(coordinator, device))

async_add_entities(sensor_entities)


class LaundrifyBaseSensor(SensorEntity):
"""Base class for Laundrify sensors."""

_attr_has_entity_name = True

def __init__(self, device: LaundrifyDevice) -> None:
"""Initialize the sensor."""
self._device = device
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.id)})
self._attr_unique_id = f"{device.id}_{self._attr_device_class}"


class LaundrifyPowerSensor(LaundrifyBaseSensor):
"""Representation of a Power sensor."""

_attr_device_class = SensorDeviceClass.POWER
_attr_native_unit_of_measurement = UnitOfPower.WATT
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_suggested_display_precision = 0

async def async_update(self) -> None:
"""Fetch latest power measurement from the device."""
try:
power = await self._device.get_power()
except LaundrifyDeviceException as err:
_LOGGER.debug("Couldn't load power for %s: %s", self._attr_unique_id, err)
self._attr_available = False
else:
_LOGGER.debug("Retrieved power for %s: %s", self._attr_unique_id, power)
if power is not None:
self._attr_available = True
self._attr_native_value = power


class LaundrifyEnergySensor(
CoordinatorEntity[LaundrifyUpdateCoordinator], LaundrifyBaseSensor
):
"""Representation of an Energy sensor."""

_attr_device_class = SensorDeviceClass.ENERGY
_attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
_attr_state_class = SensorStateClass.TOTAL
_attr_suggested_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
_attr_suggested_display_precision = 2

def __init__(
self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice
) -> None:
"""Initialize the sensor."""
CoordinatorEntity.__init__(self, coordinator)
LaundrifyBaseSensor.__init__(self, device)

@property
def native_value(self) -> float:
"""Return the total energy of the device."""
device = self.coordinator.data[self._device.id]
return float(device.totalEnergy)
21 changes: 0 additions & 21 deletions tests/components/laundrify/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1 @@
"""Tests for the laundrify integration."""

from homeassistant.components.laundrify import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant

from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID

from tests.common import MockConfigEntry


def create_entry(
hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN
) -> MockConfigEntry:
"""Create laundrify entry in Home Assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=VALID_ACCOUNT_ID,
data={CONF_ACCESS_TOKEN: access_token},
)
entry.add_to_hass(hass)
return entry
74 changes: 45 additions & 29 deletions tests/components/laundrify/conftest.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,75 @@
"""Configure py.test."""

import json
from unittest.mock import patch
from unittest.mock import AsyncMock, patch

from laundrify_aio import LaundrifyAPI, LaundrifyDevice
import pytest

from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID
from homeassistant.components.laundrify import DOMAIN
from homeassistant.components.laundrify.const import MANUFACTURER
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant

from tests.common import load_fixture
from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID

from tests.common import MockConfigEntry, load_fixture
from tests.typing import ClientSessionGenerator

@pytest.fixture(name="laundrify_setup_entry")
def laundrify_setup_entry_fixture():
"""Mock laundrify setup entry function."""
with patch(
"homeassistant.components.laundrify.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

@pytest.fixture(name="mock_device")
def laundrify_sensor_fixture() -> LaundrifyDevice:
"""Return a default Laundrify power sensor mock."""
# Load test data from machines.json
machine_data = json.loads(load_fixture("laundrify/machines.json"))[0]

@pytest.fixture(name="laundrify_exchange_code")
def laundrify_exchange_code_fixture():
"""Mock laundrify exchange_auth_code function."""
with patch(
"laundrify_aio.LaundrifyAPI.exchange_auth_code",
return_value=VALID_ACCESS_TOKEN,
) as exchange_code_mock:
yield exchange_code_mock
mock_device = AsyncMock(spec=LaundrifyDevice)
mock_device.id = machine_data["id"]
mock_device.manufacturer = MANUFACTURER
mock_device.model = machine_data["model"]
mock_device.name = machine_data["name"]
mock_device.firmwareVersion = machine_data["firmwareVersion"]
return mock_device


@pytest.fixture(name="laundrify_validate_token")
def laundrify_validate_token_fixture():
"""Mock laundrify validate_token function."""
with patch(
"laundrify_aio.LaundrifyAPI.validate_token",
return_value=True,
) as validate_token_mock:
yield validate_token_mock
@pytest.fixture(name="laundrify_config_entry")
async def laundrify_setup_config_entry(
hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN
) -> MockConfigEntry:
"""Create laundrify entry in Home Assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=VALID_ACCOUNT_ID,
data={CONF_ACCESS_TOKEN: access_token},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry


@pytest.fixture(name="laundrify_api_mock", autouse=True)
def laundrify_api_fixture(laundrify_exchange_code, laundrify_validate_token):
def laundrify_api_fixture(hass_client: ClientSessionGenerator):
"""Mock valid laundrify API responses."""
with (
patch(
"laundrify_aio.LaundrifyAPI.get_account_id",
return_value=VALID_ACCOUNT_ID,
),
patch(
"laundrify_aio.LaundrifyAPI.validate_token",
return_value=True,
),
patch(
"laundrify_aio.LaundrifyAPI.exchange_auth_code",
return_value=VALID_ACCESS_TOKEN,
),
patch(
"laundrify_aio.LaundrifyAPI.get_machines",
return_value=[
LaundrifyDevice(machine, LaundrifyAPI)
for machine in json.loads(load_fixture("laundrify/machines.json"))
],
) as get_machines_mock,
),
):
yield get_machines_mock
yield LaundrifyAPI(VALID_ACCESS_TOKEN, hass_client)
3 changes: 2 additions & 1 deletion tests/components/laundrify/fixtures/machines.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"status": "OFF",
"internalIP": "192.168.0.123",
"model": "SU02",
"firmwareVersion": "2.1.0"
"firmwareVersion": "2.1.0",
"totalEnergy": 1337.0
}
]
42 changes: 20 additions & 22 deletions tests/components/laundrify/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType

from . import create_entry
from .const import VALID_ACCESS_TOKEN, VALID_AUTH_CODE, VALID_USER_INPUT

from tests.common import MockConfigEntry

async def test_form(hass: HomeAssistant, laundrify_setup_entry) -> None:

async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
Expand All @@ -31,14 +32,11 @@ async def test_form(hass: HomeAssistant, laundrify_setup_entry) -> None:
assert result["data"] == {
CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN,
}
assert len(laundrify_setup_entry.mock_calls) == 1


async def test_form_invalid_format(
hass: HomeAssistant, laundrify_exchange_code
) -> None:
async def test_form_invalid_format(hass: HomeAssistant, laundrify_api_mock) -> None:
"""Test we handle invalid format."""
laundrify_exchange_code.side_effect = exceptions.InvalidFormat
laundrify_api_mock.exchange_auth_code.side_effect = exceptions.InvalidFormat

result = await hass.config_entries.flow.async_init(
DOMAIN,
Expand All @@ -50,9 +48,9 @@ async def test_form_invalid_format(
assert result["errors"] == {CONF_CODE: "invalid_format"}


async def test_form_invalid_auth(hass: HomeAssistant, laundrify_exchange_code) -> None:
async def test_form_invalid_auth(hass: HomeAssistant, laundrify_api_mock) -> None:
"""Test we handle invalid auth."""
laundrify_exchange_code.side_effect = exceptions.UnknownAuthCode
laundrify_api_mock.exchange_auth_code.side_effect = exceptions.UnknownAuthCode
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_USER},
Expand All @@ -63,11 +61,11 @@ async def test_form_invalid_auth(hass: HomeAssistant, laundrify_exchange_code) -
assert result["errors"] == {CONF_CODE: "invalid_auth"}


async def test_form_cannot_connect(
hass: HomeAssistant, laundrify_exchange_code
) -> None:
async def test_form_cannot_connect(hass: HomeAssistant, laundrify_api_mock) -> None:
"""Test we handle cannot connect error."""
laundrify_exchange_code.side_effect = exceptions.ApiConnectionException
laundrify_api_mock.exchange_auth_code.side_effect = (
exceptions.ApiConnectionException
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_USER},
Expand All @@ -78,11 +76,9 @@ async def test_form_cannot_connect(
assert result["errors"] == {"base": "cannot_connect"}


async def test_form_unkown_exception(
hass: HomeAssistant, laundrify_exchange_code
) -> None:
async def test_form_unkown_exception(hass: HomeAssistant, laundrify_api_mock) -> None:
"""Test we handle all other errors."""
laundrify_exchange_code.side_effect = Exception
laundrify_api_mock.exchange_auth_code.side_effect = Exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_USER},
Expand All @@ -93,10 +89,11 @@ async def test_form_unkown_exception(
assert result["errors"] == {"base": "unknown"}


async def test_step_reauth(hass: HomeAssistant) -> None:
async def test_step_reauth(
hass: HomeAssistant, laundrify_config_entry: MockConfigEntry
) -> None:
"""Test the reauth form is shown."""
config_entry = create_entry(hass)
result = await config_entry.start_reauth_flow(hass)
result = await laundrify_config_entry.start_reauth_flow(hass)

assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
Expand All @@ -110,9 +107,10 @@ async def test_step_reauth(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM


async def test_integration_already_exists(hass: HomeAssistant) -> None:
async def test_integration_already_exists(
hass: HomeAssistant, laundrify_config_entry: MockConfigEntry
) -> None:
"""Test we only allow a single config flow."""
create_entry(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
)
Expand Down
Loading

0 comments on commit 7ada2f8

Please sign in to comment.