Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 79 additions & 6 deletions homeassistant/components/nuheat/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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
Comment on lines +104 to +111

Copilot AI Jan 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional logic prioritizes user-configured floor area over API-provided kWh data. This may be unexpected behavior. Consider documenting this precedence decision or validating that this is the intended priority.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional.

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:
Expand Down
6 changes: 5 additions & 1 deletion homeassistant/components/nuheat/climate.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Support for NuHeat thermostats."""

from __future__ import annotations

import logging
from typing import Any

Expand Down Expand Up @@ -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)
Expand Down
58 changes: 55 additions & 3 deletions homeassistant/components/nuheat/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
}
),
)
7 changes: 6 additions & 1 deletion homeassistant/components/nuheat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
130 changes: 130 additions & 0 deletions homeassistant/components/nuheat/sensor.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading