mirror of
https://github.com/home-assistant/core.git
synced 2026-07-09 07:45:11 +01:00
Add Trane Local integration (#163301)
This commit is contained in:
Generated
+2
@@ -1743,6 +1743,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trafikverket_train/ @gjohansson-ST
|
||||
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/homeassistant/components/trane/ @bdraco
|
||||
/tests/components/trane/ @bdraco
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/homeassistant/components/trend/ @jpbede
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "american_standard",
|
||||
"name": "American Standard",
|
||||
"integrations": ["nexia", "trane"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "trane",
|
||||
"name": "Trane",
|
||||
"integrations": ["nexia", "trane"]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Integration for Trane Local thermostats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from steamloop import (
|
||||
AuthenticationError,
|
||||
SteamloopConnectionError,
|
||||
ThermostatConnection,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_SECRET_KEY, DOMAIN, MANUFACTURER, PLATFORMS
|
||||
from .types import TraneConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TraneConfigEntry) -> bool:
|
||||
"""Set up Trane Local from a config entry."""
|
||||
conn = ThermostatConnection(
|
||||
entry.data[CONF_HOST],
|
||||
secret_key=entry.data[CONF_SECRET_KEY],
|
||||
)
|
||||
|
||||
try:
|
||||
await conn.connect()
|
||||
await conn.login()
|
||||
except (SteamloopConnectionError, TimeoutError) as err:
|
||||
await conn.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except AuthenticationError as err:
|
||||
await conn.disconnect()
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from err
|
||||
|
||||
conn.start_background_tasks()
|
||||
entry.runtime_data = conn
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
translation_key="thermostat",
|
||||
translation_placeholders={"host": entry.data[CONF_HOST]},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: TraneConfigEntry) -> bool:
|
||||
"""Unload a Trane Local config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
await entry.runtime_data.disconnect()
|
||||
return unload_ok
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Config flow for the Trane Local integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from steamloop import PairingError, SteamloopConnectionError, ThermostatConnection
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from .const import CONF_SECRET_KEY, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TraneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Trane Local."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
self._async_abort_entries_match({CONF_HOST: host})
|
||||
conn = ThermostatConnection(host, secret_key="")
|
||||
try:
|
||||
await conn.connect()
|
||||
await conn.pair()
|
||||
except SteamloopConnectionError, PairingError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during pairing")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"Thermostat ({host})",
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_SECRET_KEY: conn.secret_key,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await conn.disconnect()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Constants for the Trane Local integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "trane"
|
||||
|
||||
PLATFORMS = [Platform.SWITCH]
|
||||
|
||||
CONF_SECRET_KEY = "secret_key"
|
||||
|
||||
MANUFACTURER = "Trane"
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Base entity for the Trane Local integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from steamloop import ThermostatConnection, Zone
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
|
||||
|
||||
class TraneEntity(Entity):
|
||||
"""Base class for all Trane entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, conn: ThermostatConnection) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._conn = conn
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register event callback when added to hass."""
|
||||
self.async_on_remove(self._conn.add_event_callback(self._handle_event))
|
||||
|
||||
@callback
|
||||
def _handle_event(self, _event: dict[str, Any]) -> None:
|
||||
"""Handle a thermostat event."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class TraneZoneEntity(TraneEntity):
|
||||
"""Base class for Trane zone-level entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: ThermostatConnection,
|
||||
entry_id: str,
|
||||
zone_id: str,
|
||||
unique_id_suffix: str,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(conn)
|
||||
self._zone_id = zone_id
|
||||
self._attr_unique_id = f"{entry_id}_{zone_id}_{unique_id_suffix}"
|
||||
zone_name = self._zone.name or f"Zone {zone_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{entry_id}_{zone_id}")},
|
||||
manufacturer=MANUFACTURER,
|
||||
name=zone_name,
|
||||
suggested_area=zone_name,
|
||||
via_device=(DOMAIN, entry_id),
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the zone is available."""
|
||||
return self._zone_id in self._conn.state.zones
|
||||
|
||||
@property
|
||||
def _zone(self) -> Zone:
|
||||
"""Return the current zone state."""
|
||||
return self._conn.state.zones[self._zone_id]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"hold": {
|
||||
"default": "mdi:timer",
|
||||
"state": {
|
||||
"on": "mdi:timer-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "trane",
|
||||
"name": "Trane Local",
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/trane",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["steamloop"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["steamloop==1.2.0"]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a local push integration that uses event callbacks.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address of the thermostat"
|
||||
},
|
||||
"description": "Put the thermostat in pairing mode (Menu > Settings > Network > Advanced Setup > Remote Connection > Pair). The thermostat must have a static IP address assigned."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"thermostat": {
|
||||
"name": "Thermostat ({host})"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"hold": {
|
||||
"name": "Hold"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed with thermostat"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Failed to connect to thermostat"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Switch platform for the Trane Local integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from steamloop import HoldType, ThermostatConnection
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import TraneZoneEntity
|
||||
from .types import TraneConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: TraneConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Trane Local switch entities."""
|
||||
conn = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
TraneHoldSwitch(conn, config_entry.entry_id, zone_id)
|
||||
for zone_id in conn.state.zones
|
||||
)
|
||||
|
||||
|
||||
class TraneHoldSwitch(TraneZoneEntity, SwitchEntity):
|
||||
"""Switch to control the hold mode of a thermostat zone."""
|
||||
|
||||
_attr_translation_key = "hold"
|
||||
|
||||
def __init__(self, conn: ThermostatConnection, entry_id: str, zone_id: str) -> None:
|
||||
"""Initialize the hold switch."""
|
||||
super().__init__(conn, entry_id, zone_id, "hold")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the zone is in permanent hold."""
|
||||
return self._zone.hold_type == HoldType.MANUAL
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Enable permanent hold."""
|
||||
self._conn.set_temperature_setpoint(self._zone_id, hold_type=HoldType.MANUAL)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Return to schedule."""
|
||||
self._conn.set_temperature_setpoint(self._zone_id, hold_type=HoldType.SCHEDULE)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Types for the Trane Local integration."""
|
||||
|
||||
from steamloop import ThermostatConnection
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
type TraneConfigEntry = ConfigEntry[ThermostatConnection]
|
||||
Generated
+1
@@ -736,6 +736,7 @@ FLOWS = {
|
||||
"trafikverket_ferry",
|
||||
"trafikverket_train",
|
||||
"trafikverket_weatherstation",
|
||||
"trane",
|
||||
"transmission",
|
||||
"triggercmd",
|
||||
"tuya",
|
||||
|
||||
@@ -303,6 +303,23 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"american_standard": {
|
||||
"name": "American Standard",
|
||||
"integrations": {
|
||||
"nexia": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "Nexia/American Standard/Trane"
|
||||
},
|
||||
"trane": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"name": "Trane Local"
|
||||
}
|
||||
}
|
||||
},
|
||||
"amp_motorization": {
|
||||
"name": "AMP Motorization",
|
||||
"integration_type": "virtual",
|
||||
@@ -4526,12 +4543,6 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"nexia": {
|
||||
"name": "Nexia/American Standard/Trane",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"nexity": {
|
||||
"name": "Nexity Eug\u00e9nie",
|
||||
"integration_type": "virtual",
|
||||
@@ -7185,6 +7196,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trane": {
|
||||
"name": "Trane",
|
||||
"integrations": {
|
||||
"nexia": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "Nexia/American Standard/Trane"
|
||||
},
|
||||
"trane": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"name": "Trane Local"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transmission": {
|
||||
"name": "Transmission",
|
||||
"integration_type": "service",
|
||||
|
||||
Generated
+3
@@ -2986,6 +2986,9 @@ starlink-grpc-core==1.2.3
|
||||
# homeassistant.components.statsd
|
||||
statsd==3.2.1
|
||||
|
||||
# homeassistant.components.trane
|
||||
steamloop==1.2.0
|
||||
|
||||
# homeassistant.components.steam_online
|
||||
steamodd==4.21
|
||||
|
||||
|
||||
Generated
+3
@@ -2516,6 +2516,9 @@ starlink-grpc-core==1.2.3
|
||||
# homeassistant.components.statsd
|
||||
statsd==3.2.1
|
||||
|
||||
# homeassistant.components.trane
|
||||
steamloop==1.2.0
|
||||
|
||||
# homeassistant.components.steam_online
|
||||
steamodd==4.21
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Trane Local integration."""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Fixtures for the Trane Local integration tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from steamloop import FanMode, HoldType, ThermostatState, Zone, ZoneMode
|
||||
|
||||
from homeassistant.components.trane.const import CONF_SECRET_KEY, DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_HOST = "192.168.1.100"
|
||||
MOCK_SECRET_KEY = "test_secret_key"
|
||||
MOCK_ENTRY_ID = "test_entry_id"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
entry_id=MOCK_ENTRY_ID,
|
||||
title=f"Thermostat ({MOCK_HOST})",
|
||||
data={
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_SECRET_KEY: MOCK_SECRET_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_state() -> ThermostatState:
|
||||
"""Create a mock thermostat state."""
|
||||
return ThermostatState(
|
||||
zones={
|
||||
"1": Zone(
|
||||
zone_id="1",
|
||||
name="Living Room",
|
||||
mode=ZoneMode.AUTO,
|
||||
indoor_temperature="72",
|
||||
heat_setpoint="68",
|
||||
cool_setpoint="76",
|
||||
deadband="3",
|
||||
hold_type=HoldType.MANUAL,
|
||||
),
|
||||
},
|
||||
supported_modes=[ZoneMode.OFF, ZoneMode.AUTO, ZoneMode.COOL, ZoneMode.HEAT],
|
||||
fan_mode=FanMode.AUTO,
|
||||
relative_humidity="45",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> Generator[MagicMock]:
|
||||
"""Return a mocked ThermostatConnection."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.trane.ThermostatConnection",
|
||||
autospec=True,
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"homeassistant.components.trane.config_flow.ThermostatConnection",
|
||||
new=mock_cls,
|
||||
),
|
||||
):
|
||||
conn = mock_cls.return_value
|
||||
conn.connect = AsyncMock()
|
||||
conn.login = AsyncMock()
|
||||
conn.pair = AsyncMock()
|
||||
conn.disconnect = AsyncMock()
|
||||
conn.start_background_tasks = MagicMock()
|
||||
conn.set_temperature_setpoint = MagicMock()
|
||||
conn.set_zone_mode = MagicMock()
|
||||
conn.set_fan_mode = MagicMock()
|
||||
conn.set_emergency_heat = MagicMock()
|
||||
conn.add_event_callback = MagicMock(return_value=MagicMock())
|
||||
conn.state = _make_state()
|
||||
conn.secret_key = MOCK_SECRET_KEY
|
||||
yield conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Mock setup entry."""
|
||||
with patch(
|
||||
"homeassistant.components.trane.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup:
|
||||
yield mock_setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_connection: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Trane Local integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
@@ -0,0 +1,50 @@
|
||||
# serializer version: 1
|
||||
# name: test_switch_entities[switch.living_room_hold-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.living_room_hold',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Hold',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Hold',
|
||||
'platform': 'trane',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'hold',
|
||||
'unique_id': 'test_entry_id_1_hold',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switch_entities[switch.living_room_hold-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Living Room Hold',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.living_room_hold',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for the Trane Local config flow."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from steamloop import PairingError, SteamloopConnectionError
|
||||
|
||||
from homeassistant.components.trane.const import CONF_SECRET_KEY, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import MOCK_HOST, MOCK_SECRET_KEY
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_full_user_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_connection: MagicMock,
|
||||
) -> None:
|
||||
"""Test the full user config flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_HOST: MOCK_HOST},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"Thermostat ({MOCK_HOST})"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_SECRET_KEY: MOCK_SECRET_KEY,
|
||||
}
|
||||
assert result["result"].unique_id is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error_key"),
|
||||
[
|
||||
(SteamloopConnectionError, "cannot_connect"),
|
||||
(PairingError, "cannot_connect"),
|
||||
(RuntimeError, "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_form_errors_can_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_connection: MagicMock,
|
||||
side_effect: Exception,
|
||||
error_key: str,
|
||||
) -> None:
|
||||
"""Test errors and recovery during config flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_connection.pair.side_effect = side_effect
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_HOST: MOCK_HOST},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error_key}
|
||||
|
||||
mock_connection.pair.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_HOST: MOCK_HOST},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"Thermostat ({MOCK_HOST})"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_SECRET_KEY: MOCK_SECRET_KEY,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_connection: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config flow aborts when already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_HOST: MOCK_HOST},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for the Trane Local integration setup."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from steamloop import AuthenticationError, SteamloopConnectionError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_load_unload(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test loading and unloading the integration."""
|
||||
entry = init_integration
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_setup_connection_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_connection: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup retries on connection error."""
|
||||
mock_connection.connect.side_effect = SteamloopConnectionError
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_auth_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_connection: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup fails on authentication error."""
|
||||
mock_connection.login.side_effect = AuthenticationError
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_setup_timeout_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_connection: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup retries on timeout."""
|
||||
mock_connection.connect.side_effect = TimeoutError
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for the Trane Local switch platform."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from steamloop import HoldType
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_switch_entities(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Snapshot all switch entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)
|
||||
|
||||
|
||||
async def test_hold_switch_off(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_connection: MagicMock,
|
||||
) -> None:
|
||||
"""Test hold switch reports off when following schedule."""
|
||||
mock_connection.state.zones["1"].hold_type = HoldType.SCHEDULE
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("switch.living_room_hold")
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "expected_hold_type"),
|
||||
[
|
||||
(SERVICE_TURN_ON, HoldType.MANUAL),
|
||||
(SERVICE_TURN_OFF, HoldType.SCHEDULE),
|
||||
],
|
||||
)
|
||||
async def test_hold_switch_service(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_connection: MagicMock,
|
||||
service: str,
|
||||
expected_hold_type: HoldType,
|
||||
) -> None:
|
||||
"""Test turning on and off the hold switch."""
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "switch.living_room_hold"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_connection.set_temperature_setpoint.assert_called_once_with(
|
||||
"1", hold_type=expected_hold_type
|
||||
)
|
||||
Reference in New Issue
Block a user