diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 2fcf2033930..246ea778916 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL -from .coordinator import MillDataUpdateCoordinator +from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] @@ -41,6 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_USERNAME] conn_type = CLOUD + historic_data_coordinator = MillHistoricDataUpdateCoordinator( + hass, + mill_data_connection=mill_data_connection, + ) + historic_data_coordinator.async_add_listener(lambda: None) + await historic_data_coordinator.async_config_entry_first_refresh() try: if not await mill_data_connection.connect(): raise ConfigEntryNotReady diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index ae527f8cce5..288b341b0f9 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -4,18 +4,30 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import cast -from mill import Mill +from mill import Heater, Mill from mill_local import Mill as MillLocal +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util, slugify from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +TWO_YEARS = 2 * 365 * 24 + class MillDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Mill data.""" @@ -40,3 +52,104 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator): update_method=mill_data_connection.fetch_heater_and_sensor_data, update_interval=update_interval, ) + + +class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Mill historic data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + mill_data_connection: Mill, + ) -> None: + """Initialize global Mill data updater.""" + self.mill_data_connection = mill_data_connection + + super().__init__( + hass, + _LOGGER, + name="MillHistoricDataUpdateCoordinator", + ) + + async def _async_update_data(self): + """Update historic data via API.""" + now = dt_util.utcnow() + self.update_interval = ( + timedelta(hours=1) + now.replace(minute=1, second=0) - now + ) + + recoder_instance = get_instance(self.hass) + for dev_id, heater in self.mill_data_connection.devices.items(): + if not isinstance(heater, Heater): + continue + statistic_id = f"{DOMAIN}:energy_{slugify(dev_id)}" + + last_stats = await recoder_instance.async_add_executor_job( + get_last_statistics, self.hass, 1, statistic_id, True, set() + ) + if not last_stats or not last_stats.get(statistic_id): + hourly_data = ( + await self.mill_data_connection.fetch_historic_energy_usage( + dev_id, n_days=TWO_YEARS + ) + ) + hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0])) + _sum = 0.0 + last_stats_time = None + else: + hourly_data = ( + await self.mill_data_connection.fetch_historic_energy_usage( + dev_id, + n_days=( + now + - dt_util.utc_from_timestamp( + last_stats[statistic_id][0]["start"] + ) + ).days + + 2, + ) + ) + if not hourly_data: + continue + hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0])) + start_time = next(iter(hourly_data)) + stats = await recoder_instance.async_add_executor_job( + statistics_during_period, + self.hass, + start_time, + None, + {statistic_id}, + "hour", + None, + {"sum", "state"}, + ) + stat = stats[statistic_id][0] + + _sum = cast(float, stat["sum"]) - cast(float, stat["state"]) + last_stats_time = dt_util.utc_from_timestamp(stat["start"]) + + statistics = [] + + for start, state in hourly_data.items(): + if state is None: + continue + if (last_stats_time and start < last_stats_time) or start > now: + continue + _sum += state + statistics.append( + StatisticData( + start=start, + state=state, + sum=_sum, + ) + ) + metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{heater.name}", + source=DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + async_add_external_statistics(self.hass, metadata, statistics) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 44c1136b7d5..bfad9b48cb9 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -1,6 +1,7 @@ { "domain": "mill", "name": "Mill", + "after_dependencies": ["recorder"], "codeowners": ["@danielhiversen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mill", diff --git a/tests/components/mill/conftest.py b/tests/components/mill/conftest.py new file mode 100644 index 00000000000..28b2e58057b --- /dev/null +++ b/tests/components/mill/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the mill tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mill.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/mill/test_config_flow.py b/tests/components/mill/test_config_flow.py index 832aaef3b19..2bff9ba15e1 100644 --- a/tests/components/mill/test_config_flow.py +++ b/tests/components/mill/test_config_flow.py @@ -1,17 +1,24 @@ """Tests for Mill config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries from homeassistant.components.mill.const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL +from homeassistant.components.recorder import Recorder from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_show_config_form(hass: HomeAssistant) -> None: + +async def test_show_config_form( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -21,7 +28,9 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_create_entry(hass: HomeAssistant) -> None: +async def test_create_entry( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test create entry from user input.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,7 +65,9 @@ async def test_create_entry(hass: HomeAssistant) -> None: } -async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test user input for config_entry that already exists.""" test_data = { @@ -96,7 +107,9 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_connection_error(hass: HomeAssistant) -> None: +async def test_connection_error( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -125,7 +138,9 @@ async def test_connection_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_local_create_entry(hass: HomeAssistant) -> None: +async def test_local_create_entry( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test create entry from user input.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -165,7 +180,9 @@ async def test_local_create_entry(hass: HomeAssistant) -> None: assert result["data"] == test_data -async def test_local_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_local_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test user input for config_entry that already exists.""" test_data = { @@ -215,7 +232,9 @@ async def test_local_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_local_connection_error(hass: HomeAssistant) -> None: +async def test_local_connection_error( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test connection error.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/mill/test_coordinator.py b/tests/components/mill/test_coordinator.py new file mode 100644 index 00000000000..a2a3bd57b65 --- /dev/null +++ b/tests/components/mill/test_coordinator.py @@ -0,0 +1,225 @@ +"""Test adding external statistics from Mill.""" + +from unittest.mock import AsyncMock + +from mill import Heater, Mill, Sensor + +from homeassistant.components.mill.const import DOMAIN +from homeassistant.components.mill.coordinator import MillHistoricDataUpdateCoordinator +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done + + +async def test_mill_historic_data(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + _sum = 0 + for stat in stats[statistic_id]: + start = dt_util.utc_from_timestamp(stat["start"]) + assert start in data + assert stat["state"] == data[start] + assert stat["last_reset"] is None + + _sum += data[start] + assert stat["sum"] == _sum + + data2 = { + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4.5, + dt_util.parse_datetime("2024-12-03T03:00:00+01:00"): 5, + dt_util.parse_datetime("2024-12-03T04:00:00+01:00"): 6, + dt_util.parse_datetime("2024-12-03T05:00:00+01:00"): 7, + } + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data2) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + assert len(stats) == 1 + assert len(stats[statistic_id]) == 6 + _sum = 0 + for stat in stats[statistic_id]: + start = dt_util.utc_from_timestamp(stat["start"]) + val = data2.get(start) if start in data2 else data.get(start) + assert val is not None + assert stat["state"] == val + assert stat["last_reset"] is None + + _sum += val + assert stat["sum"] == _sum + + +async def test_mill_historic_data_no_heater( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Sensor(name="sensor_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 0 + + +async def test_mill_historic_data_no_data( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=None) + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + + +async def test_mill_historic_data_invalid_data( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): None, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("3024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 1 diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index a47e6422bf8..97b40d10d18 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -4,6 +4,7 @@ import asyncio from unittest.mock import patch from homeassistant.components import mill +from homeassistant.components.recorder import Recorder from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -11,7 +12,9 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -31,7 +34,9 @@ async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config_fails( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -47,7 +52,9 @@ async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_with_cloud_config_times_out(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config_times_out( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config will retry if timed out.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -63,7 +70,9 @@ async def test_setup_with_cloud_config_times_out(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: +async def test_setup_with_old_cloud_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of old cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -82,7 +91,9 @@ async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_setup_with_local_config(hass: HomeAssistant) -> None: +async def test_setup_with_local_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of local config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -119,7 +130,7 @@ async def test_setup_with_local_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test removing mill client.""" entry = MockConfigEntry( domain=mill.DOMAIN,