From 14fcd05ad70641cef9cff19f68e3ceb6dc22a324 Mon Sep 17 00:00:00 2001 From: Eric Lindvall Date: Sun, 11 Jan 2026 15:06:13 -0800 Subject: [PATCH] Add minutes and energy sensors to Nuheat --- homeassistant/components/nuheat/__init__.py | 85 +++++++++++- homeassistant/components/nuheat/climate.py | 6 +- .../components/nuheat/config_flow.py | 58 +++++++- homeassistant/components/nuheat/const.py | 7 +- homeassistant/components/nuheat/sensor.py | 130 ++++++++++++++++++ homeassistant/components/nuheat/strings.json | 27 +++- tests/components/nuheat/mocks.py | 12 ++ tests/components/nuheat/test_config_flow.py | 39 +++++- tests/components/nuheat/test_init.py | 96 ++++++++++++- 9 files changed, 442 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/nuheat/sensor.py diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index fb17e6b45bf4b..3c18d01c729bf 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -1,8 +1,12 @@ """Support for NuHeat thermostats.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta from http import HTTPStatus import logging +from typing import Any import nuheat import requests @@ -11,22 +15,41 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS +from .const import ( + CONF_FLOOR_AREA, + CONF_SERIAL_NUMBER, + CONF_WATT_DENSITY, + DEFAULT_WATT_DENSITY, + DOMAIN, + PLATFORMS, +) _LOGGER = logging.getLogger(__name__) +@dataclass +class NuHeatEnergyData: + """Energy data from NuHeat API.""" + + energy_kwh: float | None + heating_minutes: int + + +type NuHeatConfigEntry = ConfigEntry[ + tuple[Any, DataUpdateCoordinator, DataUpdateCoordinator[NuHeatEnergyData]] +] + + def _get_thermostat(api, serial_number): """Authenticate and create the thermostat object.""" api.authenticate() return api.get_thermostat(serial_number) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NuHeatConfigEntry) -> bool: """Set up NuHeat from a config entry.""" - conf = entry.data username = conf[CONF_USERNAME] @@ -66,15 +89,65 @@ async def _async_update_data(): update_interval=timedelta(minutes=5), ) + # Energy coordinator + async def _async_update_energy_data() -> NuHeatEnergyData: + """Fetch energy data from NuHeat API.""" + try: + energy = await hass.async_add_executor_job(thermostat.get_energy_usage) + except requests.exceptions.RequestException as err: + raise UpdateFailed(f"Error fetching energy data: {err}") from err + + total_minutes = energy.heating_minutes + api_kwh = energy.energy_kwh + + # Calculate energy from floor area and watt density if configured + floor_area = entry.options.get(CONF_FLOOR_AREA) + if floor_area: + watt_density = entry.options.get(CONF_WATT_DENSITY, DEFAULT_WATT_DENSITY) + # Power (watts) = floor_area (sqft) * watt_density (watts/sqft) + # Energy (kWh) = power (watts) * time (minutes) / 60 / 1000 + power_watts = floor_area * watt_density + calculated_kwh = (power_watts * total_minutes) / 60 / 1000 + energy_kwh: float | None = calculated_kwh + elif api_kwh is not None: + energy_kwh = api_kwh + else: + energy_kwh = None + + return NuHeatEnergyData( + energy_kwh=energy_kwh, + heating_minutes=total_minutes, + ) + + energy_coordinator: DataUpdateCoordinator[NuHeatEnergyData] = DataUpdateCoordinator( + hass, + _LOGGER, + config_entry=entry, + name=f"nuheat energy {serial_number}", + update_method=_async_update_energy_data, + update_interval=timedelta(minutes=30), + ) + + # Initial refresh + await coordinator.async_config_entry_first_refresh() + await energy_coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = (thermostat, coordinator) + hass.data[DOMAIN][entry.entry_id] = (thermostat, coordinator, energy_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def update_listener(hass: HomeAssistant, entry: NuHeatConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: NuHeatConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 85e24c116f90f..eb2b7259e5781 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -1,5 +1,7 @@ """Support for NuHeat thermostats.""" +from __future__ import annotations + import logging from typing import Any @@ -58,7 +60,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NuHeat thermostat(s).""" - thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id] + thermostat, coordinator, _energy_coordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] temperature_unit = hass.config.units.temperature_unit entity = NuHeatThermostat(coordinator, thermostat, temperature_unit) diff --git a/homeassistant/components/nuheat/config_flow.py b/homeassistant/components/nuheat/config_flow.py index 6ea8ea9d12ae5..f6aaff477b55e 100644 --- a/homeassistant/components/nuheat/config_flow.py +++ b/homeassistant/components/nuheat/config_flow.py @@ -8,12 +8,23 @@ import requests.exceptions import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .const import CONF_SERIAL_NUMBER, DOMAIN +from .const import ( + CONF_FLOOR_AREA, + CONF_SERIAL_NUMBER, + CONF_WATT_DENSITY, + DEFAULT_WATT_DENSITY, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -65,6 +76,12 @@ class NuHeatConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return NuHeatOptionsFlow() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -106,3 +123,38 @@ class InvalidAuth(HomeAssistantError): class InvalidThermostat(HomeAssistantError): """Error to indicate there is invalid thermostat.""" + + +class NuHeatOptionsFlow(OptionsFlow): + """Handle NuHeat options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_FLOOR_AREA, + description={ + "suggested_value": self.config_entry.options.get( + CONF_FLOOR_AREA + ) + }, + ): vol.Coerce(float), + vol.Optional( + CONF_WATT_DENSITY, + description={ + "suggested_value": self.config_entry.options.get( + CONF_WATT_DENSITY, DEFAULT_WATT_DENSITY + ) + }, + ): vol.Coerce(float), + } + ), + ) diff --git a/homeassistant/components/nuheat/const.py b/homeassistant/components/nuheat/const.py index 96e7cdc2d60f1..80b20510a128a 100644 --- a/homeassistant/components/nuheat/const.py +++ b/homeassistant/components/nuheat/const.py @@ -4,10 +4,15 @@ DOMAIN = "nuheat" -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] CONF_SERIAL_NUMBER = "serial_number" +# Options for energy calculation +CONF_FLOOR_AREA = "floor_area" +CONF_WATT_DENSITY = "watt_density" +DEFAULT_WATT_DENSITY = 12 # watts per square foot + MANUFACTURER = "NuHeat" NUHEAT_API_STATE_SHIFT_DELAY = 2 diff --git a/homeassistant/components/nuheat/sensor.py b/homeassistant/components/nuheat/sensor.py new file mode 100644 index 0000000000000..fb83923b13b02 --- /dev/null +++ b/homeassistant/components/nuheat/sensor.py @@ -0,0 +1,130 @@ +"""Sensor platform for NuHeat integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import NuHeatEnergyData +from .const import DOMAIN, MANUFACTURER + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class NuHeatSensorEntityDescription(SensorEntityDescription): + """Describes NuHeat sensor entity.""" + + value_fn: Callable[[NuHeatEnergyData], StateType] + + +SENSOR_DESCRIPTIONS: tuple[NuHeatSensorEntityDescription, ...] = ( + NuHeatSensorEntityDescription( + key="energy", + translation_key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + value_fn=lambda data: data.energy_kwh, + ), + NuHeatSensorEntityDescription( + key="heating_time", + translation_key="heating_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.MINUTES, + value_fn=lambda data: data.heating_minutes, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NuHeat sensor entities.""" + thermostat, _coordinator, energy_coordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[NuHeatSensor] = [] + + for description in SENSOR_DESCRIPTIONS: + # Only add energy sensor if we have valid kWh data + if description.key == "energy" and energy_coordinator.data.energy_kwh is None: + continue + + entities.append( + NuHeatSensor( + energy_coordinator, + thermostat, + description, + ) + ) + + async_add_entities(entities) + + +class NuHeatSensor( + CoordinatorEntity[DataUpdateCoordinator[NuHeatEnergyData]], SensorEntity +): + """NuHeat sensor entity.""" + + _attr_has_entity_name = True + entity_description: NuHeatSensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[NuHeatEnergyData], + thermostat: Any, + description: NuHeatSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._thermostat = thermostat + self.entity_description = description + self._attr_unique_id = f"{thermostat.serial_number}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + if self.entity_description.key == "energy": + return self.coordinator.data.energy_kwh is not None + return True + + @property + def device_info(self) -> DeviceInfo: + """Return the device_info of the device.""" + return DeviceInfo( + identifiers={(DOMAIN, self._thermostat.serial_number)}, + serial_number=self._thermostat.serial_number, + name=self._thermostat.room, + model="nVent Signature", + manufacturer=MANUFACTURER, + suggested_area=self._thermostat.room, + ) diff --git a/homeassistant/components/nuheat/strings.json b/homeassistant/components/nuheat/strings.json index 9cf3761d9396a..e3005ffa09951 100644 --- a/homeassistant/components/nuheat/strings.json +++ b/homeassistant/components/nuheat/strings.json @@ -17,7 +17,32 @@ "username": "[%key:common::config_flow::data::username%]" }, "description": "You will need to obtain your thermostat\u2019s numeric serial number or ID by logging into {nuheat_url} and selecting your thermostat(s).", - "title": "Connect to the NuHeat" + "title": "Connect to Nuheat" + } + } + }, + "options": { + "step": { + "init": { + "description": "The Nuheat API only reports energy usage if you have configured watt density in the Nuheat app. If you haven't done that, you can enter your floor specifications here to calculate energy from heating time instead.", + "data": { + "floor_area": "Heated floor area (square feet)", + "watt_density": "Watt density (watts per square foot)" + }, + "data_description": { + "floor_area": "The total area covered by your heated floor mat. Check your installation documentation or measure the heated area.", + "watt_density": "Power output per square foot. Most Nuheat mats are 12 watts/sqft. Check your mat's specifications if unsure." + } + } + } + }, + "entity": { + "sensor": { + "energy": { + "name": "Energy" + }, + "heating_time": { + "name": "Heating time" } } } diff --git a/tests/components/nuheat/mocks.py b/tests/components/nuheat/mocks.py index 091734b8075b9..eeb690c9db695 100644 --- a/tests/components/nuheat/mocks.py +++ b/tests/components/nuheat/mocks.py @@ -14,6 +14,14 @@ } +def _create_mock_energy_usage(heating_minutes=210, energy_kwh=2.6): + """Create a mock EnergyUsage object.""" + energy = Mock() + energy.heating_minutes = heating_minutes + energy.energy_kwh = energy_kwh + return energy + + def _get_mock_thermostat_run(): serial_number = "12345" thermostat = Mock( @@ -35,6 +43,7 @@ def _get_mock_thermostat_run(): ) thermostat.get_data = Mock() + thermostat.get_energy_usage = Mock(return_value=_create_mock_energy_usage()) thermostat.resume_schedule = Mock() thermostat.schedule_mode = Mock() return thermostat @@ -61,6 +70,7 @@ def _get_mock_thermostat_schedule_hold_unavailable(): ) thermostat.get_data = Mock() + thermostat.get_energy_usage = Mock(return_value=_create_mock_energy_usage()) thermostat.resume_schedule = Mock() thermostat.schedule_mode = Mock() return thermostat @@ -87,6 +97,7 @@ def _get_mock_thermostat_schedule_hold_available(): ) thermostat.get_data = Mock() + thermostat.get_energy_usage = Mock(return_value=_create_mock_energy_usage()) thermostat.resume_schedule = Mock() thermostat.schedule_mode = Mock() return thermostat @@ -115,6 +126,7 @@ def _get_mock_thermostat_schedule_temporary_hold(): ) thermostat.get_data = Mock() + thermostat.get_energy_usage = Mock(return_value=_create_mock_energy_usage()) thermostat.resume_schedule = Mock() thermostat.schedule_mode = Mock() return thermostat diff --git a/tests/components/nuheat/test_config_flow.py b/tests/components/nuheat/test_config_flow.py index f96edf82c0bc4..5212affcdae7c 100644 --- a/tests/components/nuheat/test_config_flow.py +++ b/tests/components/nuheat/test_config_flow.py @@ -6,12 +6,19 @@ import requests from homeassistant import config_entries -from homeassistant.components.nuheat.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.nuheat.const import ( + CONF_FLOOR_AREA, + CONF_SERIAL_NUMBER, + CONF_WATT_DENSITY, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .mocks import _get_mock_thermostat_run +from .mocks import MOCK_CONFIG_ENTRY, _get_mock_nuheat, _get_mock_thermostat_run + +from tests.common import MockConfigEntry async def test_form_user(hass: HomeAssistant) -> None: @@ -152,3 +159,31 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + mock_thermostat = _get_mock_thermostat_run() + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) + + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", + return_value=mock_nuheat, + ): + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_ENTRY) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Start options flow + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Submit options + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_FLOOR_AREA: 50.0, CONF_WATT_DENSITY: 12.0}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_FLOOR_AREA: 50.0, CONF_WATT_DENSITY: 12.0} diff --git a/tests/components/nuheat/test_init.py b/tests/components/nuheat/test_init.py index 15829935dabf3..d0de9d562ab0e 100644 --- a/tests/components/nuheat/test_init.py +++ b/tests/components/nuheat/test_init.py @@ -1,11 +1,20 @@ """NuHeat component tests.""" -from unittest.mock import patch +from unittest.mock import Mock, patch -from homeassistant.components.nuheat.const import DOMAIN +from homeassistant.components.nuheat.const import ( + CONF_FLOOR_AREA, + CONF_WATT_DENSITY, + DOMAIN, +) from homeassistant.core import HomeAssistant -from .mocks import MOCK_CONFIG_ENTRY, _get_mock_nuheat +from .mocks import ( + MOCK_CONFIG_ENTRY, + _create_mock_energy_usage, + _get_mock_nuheat, + _get_mock_thermostat_run, +) from tests.common import MockConfigEntry @@ -17,7 +26,8 @@ async def test_init_success(hass: HomeAssistant) -> None: """Test that we can setup with valid config.""" - mock_nuheat = _get_mock_nuheat() + mock_thermostat = _get_mock_thermostat_run() + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) with patch( "homeassistant.components.nuheat.nuheat.NuHeat", @@ -27,3 +37,81 @@ async def test_init_success(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + # Check that both climate and sensor entities are created + state = hass.states.get("climate.master_bathroom") + assert state is not None + + # Check heating time sensor (underscore naming convention) + heating_time_state = hass.states.get("sensor.master_bathroom_heating_time") + assert heating_time_state is not None + assert int(heating_time_state.state) == 210 # 120 + 90 + + # Check energy sensor + energy_state = hass.states.get("sensor.master_bathroom_energy") + assert energy_state is not None + assert float(energy_state.state) == 2.6 # 1.5 + 1.1 + + +async def test_init_energy_api_no_kwh_data(hass: HomeAssistant) -> None: + """Test that energy sensor is not created when API returns no kWh data.""" + mock_thermostat = _get_mock_thermostat_run() + # Override the mock to return no kWh data (user hasn't configured watt density) + mock_thermostat.get_energy_usage = Mock( + return_value=_create_mock_energy_usage(heating_minutes=120, energy_kwh=None) + ) + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) + + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", + return_value=mock_nuheat, + ): + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_ENTRY) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Heating time sensor should still be created + heating_time_state = hass.states.get("sensor.master_bathroom_heating_time") + assert heating_time_state is not None + assert int(heating_time_state.state) == 120 + + # Energy sensor should not be created (no kWh data) + energy_state = hass.states.get("sensor.master_bathroom_energy") + assert energy_state is None + + +async def test_init_energy_calculated_from_options(hass: HomeAssistant) -> None: + """Test that energy is calculated from floor area when configured.""" + mock_thermostat = _get_mock_thermostat_run() + # Override the mock to return 60 minutes and no kWh data + mock_thermostat.get_energy_usage = Mock( + return_value=_create_mock_energy_usage(heating_minutes=60, energy_kwh=None) + ) + mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat) + + with patch( + "homeassistant.components.nuheat.nuheat.NuHeat", + return_value=mock_nuheat, + ): + # Configure options for energy calculation + # 100 sqft * 12 watts/sqft = 1200 watts + # 1200 watts * 60 minutes / 60 / 1000 = 1.2 kWh + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_ENTRY, + options={CONF_FLOOR_AREA: 100.0, CONF_WATT_DENSITY: 12.0}, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Energy sensor should be created with calculated value + energy_state = hass.states.get("sensor.master_bathroom_energy") + assert energy_state is not None + assert float(energy_state.state) == 1.2 # Calculated from floor area + + # Heating time should also be present + heating_time_state = hass.states.get("sensor.master_bathroom_heating_time") + assert heating_time_state is not None + assert int(heating_time_state.state) == 60