diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index fcda7227945..aeb9e59e286 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import date import ipaddress import logging from typing import Any, NamedTuple @@ -9,7 +10,10 @@ from uuid import UUID from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox from vallox_websocket_api.exceptions import ValloxApiException -from vallox_websocket_api.vallox import get_uuid as calculate_uuid +from vallox_websocket_api.vallox import ( + get_next_filter_change_date as calculate_next_filter_change_date, + get_uuid as calculate_uuid, +) import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -117,6 +121,15 @@ class ValloxState: raise ValueError return uuid + def get_next_filter_change_date(self) -> date | None: + """Return the next filter change date.""" + next_filter_change_date = calculate_next_filter_change_date(self.metric_cache) + + if not isinstance(next_filter_change_date, date): + return None + + return next_filter_change_date + class ValloxDataUpdateCoordinator(DataUpdateCoordinator): """The DataUpdateCoordinator for Vallox.""" diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index aed87e9239d..71b0750e2f2 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -2,7 +2,7 @@ "domain": "vallox", "name": "Vallox", "documentation": "https://www.home-assistant.io/integrations/vallox", - "requirements": ["vallox-websocket-api==2.9.0"], + "requirements": ["vallox-websocket-api==2.11.0"], "codeowners": ["@andre-richter", "@slovdahl", "@viiru-"], "config_flow": true, "iot_class": "local_polling", diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 44dfb56fafc..eece054c82e 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, time from homeassistant.components.sensor import ( SensorDeviceClass, @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util +from homeassistant.util import dt from . import ValloxDataUpdateCoordinator from .const import ( @@ -95,18 +95,15 @@ class ValloxFilterRemainingSensor(ValloxSensor): @property def native_value(self) -> StateType | datetime: """Return the value reported by the sensor.""" - super_native_value = super().native_value + next_filter_change_date = self.coordinator.data.get_next_filter_change_date() - if not isinstance(super_native_value, (int, float)): + if next_filter_change_date is None: return None - # Since only a delta of days is received from the device, fix the time so the timestamp does - # not change with every update. - days_remaining = float(super_native_value) - days_remaining_delta = timedelta(days=days_remaining) - now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) - - return (now + days_remaining_delta).astimezone(dt_util.UTC) + return datetime.combine( + next_filter_change_date, + time(hour=13, minute=0, second=0, tzinfo=dt.DEFAULT_TIME_ZONE), + ) class ValloxCellStateSensor(ValloxSensor): @@ -150,7 +147,6 @@ SENSORS: tuple[ValloxSensorEntityDescription, ...] = ( ValloxSensorEntityDescription( key="remaining_time_for_filter", name="Remaining Time For Filter", - metric_key="A_CYC_REMAINING_TIME_FOR_FILTER", device_class=SensorDeviceClass.TIMESTAMP, sensor_type=ValloxFilterRemainingSensor, ), diff --git a/requirements_all.txt b/requirements_all.txt index 511f8abb7c5..ef0210e3067 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2425,7 +2425,7 @@ uscisstatus==0.1.1 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==2.9.0 +vallox-websocket-api==2.11.0 # homeassistant.components.rdw vehicle==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 926d5e0d6ee..fe0f3a638bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1495,7 +1495,7 @@ url-normalize==1.4.1 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==2.9.0 +vallox-websocket-api==2.11.0 # homeassistant.components.rdw vehicle==0.3.1 diff --git a/tests/components/vallox/conftest.py b/tests/components/vallox/conftest.py new file mode 100644 index 00000000000..e7ea6ee6d6e --- /dev/null +++ b/tests/components/vallox/conftest.py @@ -0,0 +1,65 @@ +"""Common utilities for Vallox tests.""" + +import random +import string +from typing import Any +from unittest.mock import patch +from uuid import UUID + +import pytest +from vallox_websocket_api.vallox import PROFILE + +from homeassistant.components.vallox.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked Vallox config entry.""" + vallox_mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.100.50", + CONF_NAME: "Vallox", + }, + ) + vallox_mock_entry.add_to_hass(hass) + + return vallox_mock_entry + + +def patch_metrics(metrics: dict[str, Any]): + """Patch the Vallox metrics response.""" + return patch( + "homeassistant.components.vallox.Vallox.fetch_metrics", + return_value=metrics, + ) + + +@pytest.fixture(autouse=True) +def patch_profile_home(): + """Patch the Vallox profile response.""" + with patch( + "homeassistant.components.vallox.Vallox.get_profile", + return_value=PROFILE.HOME, + ): + yield + + +@pytest.fixture(autouse=True) +def patch_uuid(): + """Patch the Vallox entity UUID.""" + with patch( + "homeassistant.components.vallox.calculate_uuid", + return_value=_random_uuid(), + ): + yield + + +def _random_uuid(): + """Generate a random UUID.""" + uuid = "".join(random.choices(string.hexdigits, k=32)) + return UUID(uuid) diff --git a/tests/components/vallox/test_sensor.py b/tests/components/vallox/test_sensor.py new file mode 100644 index 00000000000..bd8ecbea905 --- /dev/null +++ b/tests/components/vallox/test_sensor.py @@ -0,0 +1,175 @@ +"""Tests for Vallox sensor platform.""" + +from datetime import datetime, timedelta, tzinfo +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from .conftest import patch_metrics + +from tests.common import MockConfigEntry + +ORIG_TZ = dt.DEFAULT_TIME_ZONE + + +@pytest.fixture(autouse=True) +def reset_tz(): + """Restore the default TZ after test runs.""" + yield + dt.DEFAULT_TIME_ZONE = ORIG_TZ + + +@pytest.fixture +def set_tz(request): + """Set the default TZ to the one requested.""" + return request.getfixturevalue(request.param) + + +@pytest.fixture +def utc() -> tzinfo: + """Set the default TZ to UTC.""" + tz = dt.get_time_zone("UTC") + dt.set_default_time_zone(tz) + return tz + + +@pytest.fixture +def helsinki() -> tzinfo: + """Set the default TZ to Europe/Helsinki.""" + tz = dt.get_time_zone("Europe/Helsinki") + dt.set_default_time_zone(tz) + return tz + + +@pytest.fixture +def new_york() -> tzinfo: + """Set the default TZ to America/New_York.""" + tz = dt.get_time_zone("America/New_York") + dt.set_default_time_zone(tz) + return tz + + +def _sensor_to_datetime(sensor): + return datetime.fromisoformat(sensor.state) + + +def _now_at_13(): + return dt.now().timetz().replace(hour=13, minute=0, second=0, microsecond=0) + + +async def test_remaining_filter_returns_timestamp( + mock_entry: MockConfigEntry, hass: HomeAssistant +): + """Test that the remaining time for filter sensor returns a timestamp.""" + # Act + with patch( + "homeassistant.components.vallox.calculate_next_filter_change_date", + return_value=dt.now().date(), + ), patch_metrics(metrics={}): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert + sensor = hass.states.get("sensor.vallox_remaining_time_for_filter") + assert sensor.attributes["device_class"] == "timestamp" + + +async def test_remaining_time_for_filter_none_returned_from_vallox( + mock_entry: MockConfigEntry, hass: HomeAssistant +): + """Test that the remaining time for filter sensor returns 'unknown' when Vallox returns None.""" + # Act + with patch( + "homeassistant.components.vallox.calculate_next_filter_change_date", + return_value=None, + ), patch_metrics(metrics={}): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert + sensor = hass.states.get("sensor.vallox_remaining_time_for_filter") + assert sensor.state == "unknown" + + +@pytest.mark.parametrize( + "set_tz", + [ + "utc", + "helsinki", + "new_york", + ], + indirect=True, +) +async def test_remaining_time_for_filter_in_the_future( + mock_entry: MockConfigEntry, set_tz: tzinfo, hass: HomeAssistant +): + """Test remaining time for filter when Vallox returns a date in the future.""" + # Arrange + remaining_days = 112 + mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + + # Act + with patch( + "homeassistant.components.vallox.calculate_next_filter_change_date", + return_value=mocked_filter_end_date, + ), patch_metrics(metrics={}): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert + sensor = hass.states.get("sensor.vallox_remaining_time_for_filter") + assert _sensor_to_datetime(sensor) == datetime.combine( + mocked_filter_end_date, + _now_at_13(), + ) + + +async def test_remaining_time_for_filter_today( + mock_entry: MockConfigEntry, hass: HomeAssistant +): + """Test remaining time for filter when Vallox returns today.""" + # Arrange + remaining_days = 0 + mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + + # Act + with patch( + "homeassistant.components.vallox.calculate_next_filter_change_date", + return_value=mocked_filter_end_date, + ), patch_metrics(metrics={}): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert + sensor = hass.states.get("sensor.vallox_remaining_time_for_filter") + assert _sensor_to_datetime(sensor) == datetime.combine( + mocked_filter_end_date, + _now_at_13(), + ) + + +async def test_remaining_time_for_filter_in_the_past( + mock_entry: MockConfigEntry, hass: HomeAssistant +): + """Test remaining time for filter when Vallox returns a date in the past.""" + # Arrange + remaining_days = -3 + mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + + # Act + with patch( + "homeassistant.components.vallox.calculate_next_filter_change_date", + return_value=mocked_filter_end_date, + ), patch_metrics(metrics={}): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert + sensor = hass.states.get("sensor.vallox_remaining_time_for_filter") + assert _sensor_to_datetime(sensor) == datetime.combine( + mocked_filter_end_date, + _now_at_13(), + )