Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Aprilaire integration #95093

Merged
merged 26 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bb2f006
Add Aprilaire integration
chamberlain2007 Dec 14, 2023
95f7640
Fix test errors
chamberlain2007 Jul 3, 2023
1e2c66c
Update constants
chamberlain2007 Jul 3, 2023
65ae434
Code review cleanup
chamberlain2007 Jul 3, 2023
0fab898
Reuse coordinator from config flow
chamberlain2007 Jul 4, 2023
5037112
Code review fixes
chamberlain2007 Sep 14, 2023
a756132
Remove unneeded tests
chamberlain2007 Sep 14, 2023
ea2966a
Improve translation
chamberlain2007 Sep 15, 2023
2b944ed
Code review fixes
chamberlain2007 Sep 15, 2023
e5e7103
Remove unneeded fixture
chamberlain2007 Sep 16, 2023
99ede37
Code review fixes
chamberlain2007 Sep 20, 2023
ac910be
Code review updates
chamberlain2007 Nov 28, 2023
615ea3a
Use base data coordinator
chamberlain2007 Nov 29, 2023
c570c4e
Deduplicate based on MAC
chamberlain2007 Dec 14, 2023
d397433
Fix tests
chamberlain2007 Dec 14, 2023
57b8ec2
Check mac address on init
chamberlain2007 Dec 15, 2023
edc3cdf
Fix mypy error
chamberlain2007 Dec 15, 2023
519ff8a
Use config entry ID for entity unique ID
chamberlain2007 Dec 16, 2023
e953f54
Fix tests
chamberlain2007 Dec 16, 2023
61fca5b
Code review updates
chamberlain2007 Jan 24, 2024
11b3897
Fix mypy errors
chamberlain2007 Jan 25, 2024
2ec2d3c
Code review updates
chamberlain2007 Jan 28, 2024
8f57d30
Add data_description
chamberlain2007 Jan 28, 2024
45d335b
Update homeassistant/components/aprilaire/coordinator.py
emontnemery Feb 15, 2024
73362b3
Update .coveragerc
emontnemery Feb 15, 2024
842cb0a
Update homeassistant/components/aprilaire/coordinator.py
emontnemery Feb 15, 2024
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
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ build.json @home-assistant/supervisor
/tests/components/application_credentials/ @home-assistant/core
/homeassistant/components/apprise/ @caronc
/tests/components/apprise/ @caronc
/homeassistant/components/aprilaire/ @chamberlain2007
/tests/components/aprilaire/ @chamberlain2007
/homeassistant/components/aprs/ @PhilRW
/tests/components/aprs/ @PhilRW
/homeassistant/components/aranet/ @aschmitz @thecode
Expand Down
69 changes: 69 additions & 0 deletions homeassistant/components/aprilaire/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""The Aprilaire integration."""

from __future__ import annotations

import logging

from pyaprilaire.const import Attribute

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.device_registry import format_mac

from .const import DOMAIN
from .coordinator import AprilaireCoordinator

PLATFORMS: list[Platform] = [Platform.CLIMATE]

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry for Aprilaire."""

host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]

coordinator = AprilaireCoordinator(hass, entry.unique_id, host, port)
await coordinator.start_listen()

hass.data.setdefault(DOMAIN, {})[entry.unique_id] = coordinator

async def ready_callback(ready: bool):
if ready:
mac_address = format_mac(coordinator.data[Attribute.MAC_ADDRESS])

if mac_address != entry.unique_id:
raise ConfigEntryAuthFailed("Invalid MAC address")

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

async def _async_close(_: Event) -> None:
coordinator.stop_listen()

entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close)
)
else:
_LOGGER.error("Failed to wait for ready")
chamberlain2007 marked this conversation as resolved.
Show resolved Hide resolved

coordinator.stop_listen()

raise ConfigEntryNotReady()

await coordinator.wait_for_ready(ready_callback)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

if unload_ok:
coordinator: AprilaireCoordinator = hass.data[DOMAIN].pop(entry.unique_id)
coordinator.stop_listen()

return unload_ok
302 changes: 302 additions & 0 deletions homeassistant/components/aprilaire/climate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
"""The Aprilaire climate component."""

from __future__ import annotations

from typing import Any

from pyaprilaire.const import Attribute

from homeassistant.components.climate import (
FAN_AUTO,
FAN_ON,
PRESET_AWAY,
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import (
DOMAIN,
FAN_CIRCULATE,
PRESET_PERMANENT_HOLD,
PRESET_TEMPORARY_HOLD,
PRESET_VACATION,
)
from .coordinator import AprilaireCoordinator
from .entity import BaseAprilaireEntity

HVAC_MODE_MAP = {
1: HVACMode.OFF,
2: HVACMode.HEAT,
3: HVACMode.COOL,
4: HVACMode.HEAT,
5: HVACMode.AUTO,
}

HVAC_MODES_MAP = {
1: [HVACMode.OFF, HVACMode.HEAT],
2: [HVACMode.OFF, HVACMode.COOL],
3: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL],
4: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL],
5: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO],
6: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO],
}

PRESET_MODE_MAP = {
1: PRESET_TEMPORARY_HOLD,
2: PRESET_PERMANENT_HOLD,
3: PRESET_AWAY,
4: PRESET_VACATION,
}

FAN_MODE_MAP = {
1: FAN_ON,
2: FAN_AUTO,
3: FAN_CIRCULATE,
}


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add climates for passed config_entry in HA."""

coordinator: AprilaireCoordinator = hass.data[DOMAIN][config_entry.unique_id]

async_add_entities([AprilaireClimate(coordinator, config_entry.unique_id)])


class AprilaireClimate(BaseAprilaireEntity, ClimateEntity):
"""Climate entity for Aprilaire."""

_attr_fan_modes = [FAN_AUTO, FAN_ON, FAN_CIRCULATE]
_attr_min_humidity = 10
_attr_max_humidity = 50
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "thermostat"

@property
def precision(self) -> float:
"""Get the precision based on the unit."""
return (
PRECISION_HALVES
if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS
else PRECISION_WHOLE
)

@property
def supported_features(self) -> ClimateEntityFeature:
"""Get supported features."""
features = 0

if self.coordinator.data.get(Attribute.MODE) == 5:
emontnemery marked this conversation as resolved.
Show resolved Hide resolved
features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
else:
features = features | ClimateEntityFeature.TARGET_TEMPERATURE

if self.coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2:
features = features | ClimateEntityFeature.TARGET_HUMIDITY

features = features | ClimateEntityFeature.PRESET_MODE

features = features | ClimateEntityFeature.FAN_MODE

return features

@property
def current_humidity(self) -> int | None:
"""Get current humidity."""
return self.coordinator.data.get(
Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE
)

@property
def target_humidity(self) -> int | None:
"""Get current target humidity."""
return self.coordinator.data.get(Attribute.HUMIDIFICATION_SETPOINT)

@property
def hvac_mode(self) -> HVACMode | None:
"""Get HVAC mode."""

if mode := self.coordinator.data.get(Attribute.MODE):
if hvac_mode := HVAC_MODE_MAP.get(mode):
return hvac_mode

return None

@property
def hvac_modes(self) -> list[HVACMode]:
"""Get supported HVAC modes."""

if modes := self.coordinator.data.get(Attribute.THERMOSTAT_MODES):
if thermostat_modes := HVAC_MODES_MAP.get(modes):
return thermostat_modes

return []

@property
def hvac_action(self) -> HVACAction | None:
"""Get the current HVAC action."""

if self.coordinator.data.get(Attribute.HEATING_EQUIPMENT_STATUS, 0):
return HVACAction.HEATING

if self.coordinator.data.get(Attribute.COOLING_EQUIPMENT_STATUS, 0):
return HVACAction.COOLING

return HVACAction.IDLE

@property
def current_temperature(self) -> float | None:
"""Get current temperature."""
return self.coordinator.data.get(
Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_VALUE
)

@property
def target_temperature(self) -> float | None:
"""Get the target temperature."""

hvac_mode = self.hvac_mode

if hvac_mode == HVACMode.COOL:
return self.target_temperature_high
if hvac_mode == HVACMode.HEAT:
return self.target_temperature_low

return None

@property
def target_temperature_step(self) -> float | None:
"""Get the step for the target temperature based on the unit."""
return (
0.5
if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS
else 1
)

@property
def target_temperature_high(self) -> float | None:
"""Get cool setpoint."""
return self.coordinator.data.get(Attribute.COOL_SETPOINT)

@property
def target_temperature_low(self) -> float | None:
"""Get heat setpoint."""
return self.coordinator.data.get(Attribute.HEAT_SETPOINT)

@property
def preset_mode(self) -> str | None:
"""Get the current preset mode."""
if hold := self.coordinator.data.get(Attribute.HOLD):
if preset_mode := PRESET_MODE_MAP.get(hold):
return preset_mode

return PRESET_NONE

@property
def preset_modes(self) -> list[str] | None:
"""Get the supported preset modes."""
presets = [PRESET_NONE, PRESET_VACATION]

if self.coordinator.data.get(Attribute.AWAY_AVAILABLE) == 1:
presets.append(PRESET_AWAY)

hold = self.coordinator.data.get(Attribute.HOLD, 0)

if hold == 1:
presets.append(PRESET_TEMPORARY_HOLD)
elif hold == 2:
presets.append(PRESET_PERMANENT_HOLD)

return presets

@property
def fan_mode(self) -> str | None:
"""Get fan mode."""

if mode := self.coordinator.data.get(Attribute.FAN_MODE):
if fan_mode := FAN_MODE_MAP.get(mode):
return fan_mode

return None

async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""

cool_setpoint = 0
heat_setpoint = 0

if temperature := kwargs.get("temperature"):
if self.coordinator.data.get(Attribute.MODE) == 3:
cool_setpoint = temperature
else:
heat_setpoint = temperature
else:
if target_temp_low := kwargs.get("target_temp_low"):
heat_setpoint = target_temp_low
if target_temp_high := kwargs.get("target_temp_high"):
cool_setpoint = target_temp_high

if cool_setpoint == 0 and heat_setpoint == 0:
return

await self.coordinator.client.update_setpoint(cool_setpoint, heat_setpoint)

await self.coordinator.client.read_control()

async def async_set_humidity(self, humidity: int) -> None:
"""Set the target humidification setpoint."""

await self.coordinator.client.set_humidification_setpoint(humidity)

async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""

try:
fan_mode_value_index = list(FAN_MODE_MAP.values()).index(fan_mode)
except ValueError as exc:
raise ValueError(f"Unsupported fan mode {fan_mode}") from exc

fan_mode_value = list(FAN_MODE_MAP.keys())[fan_mode_value_index]

await self.coordinator.client.update_fan_mode(fan_mode_value)

await self.coordinator.client.read_control()

async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""

try:
mode_value_index = list(HVAC_MODE_MAP.values()).index(hvac_mode)
except ValueError as exc:
raise ValueError(f"Unsupported HVAC mode {hvac_mode}") from exc

mode_value = list(HVAC_MODE_MAP.keys())[mode_value_index]

await self.coordinator.client.update_mode(mode_value)

await self.coordinator.client.read_control()

async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""

if preset_mode == PRESET_AWAY:
await self.coordinator.client.set_hold(3)
elif preset_mode == PRESET_VACATION:
await self.coordinator.client.set_hold(4)
elif preset_mode == PRESET_NONE:
await self.coordinator.client.set_hold(0)
else:
raise ValueError(f"Unsupported preset mode {preset_mode}")

await self.coordinator.client.read_scheduling()
Loading
Loading