Mill, add statistics (#130406)

* Mill, new features

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* typo

* tests

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* mill

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* Update const.py

* Update sensor.py

* Update sensor.py

* Add test

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* Add test

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* mill

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* mock_setup_entry

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* after_depencies

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* Mill

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* mill stats

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* mill stats

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* format

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* mill

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* Add test

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* tests

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

* mill

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>

---------

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
This commit is contained in:
Daniel Hjelseth Høyer 2025-04-28 21:59:42 +02:00 committed by GitHub
parent 245eb64405
commit c797e7a973
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 406 additions and 16 deletions

View File

@ -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

View File

@ -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)

View File

@ -1,6 +1,7 @@
{
"domain": "mill",
"name": "Mill",
"after_dependencies": ["recorder"],
"codeowners": ["@danielhiversen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mill",

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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,