Compare commits

..

3 Commits

Author SHA1 Message Date
abmantis
c2229bfbdf Add translated reasons to Govee Light Local setup failures 2026-02-19 22:11:18 +00:00
Patrick Vorgers
0996ad4d1d Add pagination support for IDrive e2 (#162960) 2026-02-19 22:42:04 +01:00
wollew
e8885de8c2 add number platform to Velux integration for ExteriorHeating nodes (#162857) 2026-02-19 19:58:13 +01:00
19 changed files with 434 additions and 706 deletions

View File

@@ -15,7 +15,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DISCOVERY_TIMEOUT
from .const import DISCOVERY_TIMEOUT, DOMAIN
from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -52,7 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
_LOGGER.error("Start failed, errno: %d", ex.errno)
return False
_LOGGER.error("Port %s already in use", LISTENING_PORT)
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="port_in_use",
translation_placeholders={"port": LISTENING_PORT},
) from ex
await coordinator.async_config_entry_first_refresh()
@@ -61,7 +65,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
while not coordinator.devices:
await asyncio.sleep(delay=1)
except TimeoutError as ex:
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="no_devices_found"
) from ex
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -33,5 +33,13 @@
}
}
}
},
"exceptions": {
"no_devices_found": {
"message": "[%key:common::config_flow::abort::no_devices_found%]"
},
"port_in_use": {
"message": "Port {port} is already in use"
}
}
}

View File

@@ -329,14 +329,14 @@ class IDriveE2BackupAgent(BackupAgent):
return self._backup_cache
backups = {}
response = await cast(Any, self._client).list_objects_v2(Bucket=self._bucket)
# Filter for metadata files only
metadata_files = [
obj
for obj in response.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
]
paginator = self._client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
async for page in paginator.paginate(Bucket=self._bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
)
for metadata_file in metadata_files:
try:

View File

@@ -8,16 +8,14 @@ from steamloop import (
ThermostatConnection,
)
from homeassistant.const import CONF_HOST, Platform
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
from .const import CONF_SECRET_KEY, DOMAIN, MANUFACTURER, PLATFORMS
from .types import TraneConfigEntry
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: TraneConfigEntry) -> bool:
"""Set up Trane Local from a config entry."""

View File

@@ -1,200 +0,0 @@
"""Climate platform for the Trane Local integration."""
from __future__ import annotations
from typing import Any
from steamloop import FanMode, HoldType, ThermostatConnection, ZoneMode
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import TraneZoneEntity
from .types import TraneConfigEntry
PARALLEL_UPDATES = 0
HA_TO_ZONE_MODE = {
HVACMode.OFF: ZoneMode.OFF,
HVACMode.HEAT: ZoneMode.HEAT,
HVACMode.COOL: ZoneMode.COOL,
HVACMode.HEAT_COOL: ZoneMode.AUTO,
HVACMode.AUTO: ZoneMode.AUTO,
}
ZONE_MODE_TO_HA = {
ZoneMode.OFF: HVACMode.OFF,
ZoneMode.HEAT: HVACMode.HEAT,
ZoneMode.COOL: HVACMode.COOL,
ZoneMode.AUTO: HVACMode.AUTO,
}
HA_TO_FAN_MODE = {
"auto": FanMode.AUTO,
"on": FanMode.ALWAYS_ON,
"circulate": FanMode.CIRCULATE,
}
FAN_MODE_TO_HA = {v: k for k, v in HA_TO_FAN_MODE.items()}
SINGLE_SETPOINT_MODES = frozenset({ZoneMode.COOL, ZoneMode.HEAT})
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TraneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Trane Local climate entities."""
conn = config_entry.runtime_data
async_add_entities(
TraneClimateEntity(conn, config_entry.entry_id, zone_id)
for zone_id in conn.state.zones
)
class TraneClimateEntity(TraneZoneEntity, ClimateEntity):
"""Climate entity for a Trane thermostat zone."""
_attr_name = None
_attr_translation_key = "zone"
_attr_fan_modes = list(HA_TO_FAN_MODE)
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_target_temperature_step = 1.0
def __init__(self, conn: ThermostatConnection, entry_id: str, zone_id: str) -> None:
"""Initialize the climate entity."""
super().__init__(conn, entry_id, zone_id, "zone")
modes: list[HVACMode] = []
for zone_mode in conn.state.supported_modes:
ha_mode = ZONE_MODE_TO_HA.get(zone_mode)
if ha_mode is None:
continue
modes.append(ha_mode)
# AUTO in steamloop maps to both AUTO (schedule) and HEAT_COOL (manual hold)
if zone_mode == ZoneMode.AUTO:
modes.append(HVACMode.HEAT_COOL)
self._attr_hvac_modes = modes
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
# indoor_temperature is a string from the protocol (e.g. "72.00")
# or empty string if not yet received
if temp := self._zone.indoor_temperature:
return float(temp)
return None
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
# relative_humidity is a string from the protocol (e.g. "45")
# or empty string if not yet received
if humidity := self._conn.state.relative_humidity:
return int(humidity)
return None
@property
def hvac_mode(self) -> HVACMode:
"""Return the current HVAC mode."""
zone = self._zone
if zone.mode == ZoneMode.AUTO and zone.hold_type == HoldType.MANUAL:
return HVACMode.HEAT_COOL
return ZONE_MODE_TO_HA.get(zone.mode, HVACMode.OFF)
@property
def hvac_action(self) -> HVACAction:
"""Return the current HVAC action."""
# heating_active and cooling_active are system-level strings from the
# protocol ("0"=off, "1"=idle, "2"=running); filter by zone mode so
# a zone in COOL never reports HEATING and vice versa
zone_mode = self._zone.mode
if zone_mode == ZoneMode.OFF:
return HVACAction.OFF
state = self._conn.state
if zone_mode != ZoneMode.HEAT and state.cooling_active == "2":
return HVACAction.COOLING
if zone_mode != ZoneMode.COOL and state.heating_active == "2":
return HVACAction.HEATING
return HVACAction.IDLE
@property
def target_temperature(self) -> float | None:
"""Return target temperature for single-setpoint modes."""
# Setpoints are strings from the protocol or empty string if not yet received
zone = self._zone
if zone.mode == ZoneMode.COOL:
return float(zone.cool_setpoint) if zone.cool_setpoint else None
if zone.mode == ZoneMode.HEAT:
return float(zone.heat_setpoint) if zone.heat_setpoint else None
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the upper bound target temperature."""
zone = self._zone
if zone.mode in SINGLE_SETPOINT_MODES:
return None
return float(zone.cool_setpoint) if zone.cool_setpoint else None
@property
def target_temperature_low(self) -> float | None:
"""Return the lower bound target temperature."""
zone = self._zone
if zone.mode in SINGLE_SETPOINT_MODES:
return None
return float(zone.heat_setpoint) if zone.heat_setpoint else None
@property
def fan_mode(self) -> str:
"""Return the current fan mode."""
return FAN_MODE_TO_HA.get(self._conn.state.fan_mode, "auto")
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
if hvac_mode == HVACMode.OFF:
self._conn.set_zone_mode(self._zone_id, ZoneMode.OFF)
return
hold_type = HoldType.SCHEDULE if hvac_mode == HVACMode.AUTO else HoldType.MANUAL
self._conn.set_temperature_setpoint(self._zone_id, hold_type=hold_type)
self._conn.set_zone_mode(self._zone_id, HA_TO_ZONE_MODE[hvac_mode])
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set target temperature."""
heat_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
set_temp = kwargs.get(ATTR_TEMPERATURE)
if set_temp is not None:
if self._zone.mode == ZoneMode.COOL:
cool_temp = set_temp
elif self._zone.mode == ZoneMode.HEAT:
heat_temp = set_temp
self._conn.set_temperature_setpoint(
self._zone_id,
heat_setpoint=str(round(heat_temp)) if heat_temp is not None else None,
cool_setpoint=str(round(cool_temp)) if cool_temp is not None else None,
)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
self._conn.set_fan_mode(HA_TO_FAN_MODE[fan_mode])

View File

@@ -25,6 +25,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
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:

View File

@@ -1,7 +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"

View File

@@ -25,19 +25,6 @@
}
},
"entity": {
"climate": {
"zone": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"circulate": "Circulate",
"on": "[%key:common::state::on%]"
}
}
}
}
},
"switch": {
"hold": {
"name": "Hold"

View File

@@ -10,6 +10,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.COVER,
Platform.LIGHT,
Platform.NUMBER,
Platform.SCENE,
Platform.SWITCH,
]

View File

@@ -0,0 +1,56 @@
"""Support for Velux exterior heating number entities."""
from __future__ import annotations
from pyvlx import ExteriorHeating, Intensity
from homeassistant.components.number import NumberEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .entity import VeluxEntity, wrap_pyvlx_call_exceptions
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities for the Velux platform."""
pyvlx = config_entry.runtime_data
async_add_entities(
VeluxExteriorHeatingNumber(node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, ExteriorHeating)
)
class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity):
"""Representation of an exterior heating intensity control."""
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
_attr_native_unit_of_measurement = PERCENTAGE
_attr_name = None
node: ExteriorHeating
@property
def native_value(self) -> float | None:
"""Return the current heating intensity in percent."""
return (
self.node.intensity.intensity_percent if self.node.intensity.known else None
)
@wrap_pyvlx_call_exceptions
async def async_set_native_value(self, value: float) -> None:
"""Set the heating intensity."""
await self.node.set_intensity(
Intensity(intensity_percent=round(value)),
wait_for_completion=True,
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Generator
import json
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -53,9 +53,11 @@ def mock_client(agent_backup: AgentBackup) -> Generator[AsyncMock]:
client = create_client.return_value
tar_file, metadata_file = suggested_filenames(agent_backup)
client.list_objects_v2.return_value = {
"Contents": [{"Key": tar_file}, {"Key": metadata_file}]
}
# Mock the paginator for list_objects_v2
client.get_paginator = MagicMock()
client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": [{"Key": tar_file}, {"Key": metadata_file}]}
]
client.create_multipart_upload.return_value = {"UploadId": "upload_id"}
client.upload_part.return_value = {"ETag": "etag"}
client.list_buckets.return_value = {

View File

@@ -179,7 +179,9 @@ async def test_agents_get_backup_does_not_throw_on_not_found(
mock_client: MagicMock,
) -> None:
"""Test agent get backup does not throw on a backup not found."""
mock_client.list_objects_v2.return_value = {"Contents": []}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": []}
]
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"})
@@ -202,18 +204,20 @@ async def test_agents_list_backups_with_corrupted_metadata(
agent = IDriveE2BackupAgent(hass, mock_config_entry)
# Set up mock responses for both valid and corrupted metadata files
mock_client.list_objects_v2.return_value = {
"Contents": [
{
"Key": "valid_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{
"Key": "corrupted_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
]
}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{
"Contents": [
{
"Key": "valid_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{
"Key": "corrupted_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
]
}
]
# Mock responses for get_object calls
valid_metadata = json.dumps(agent_backup.as_dict())
@@ -270,7 +274,9 @@ async def test_agents_delete_not_throwing_on_not_found(
mock_client: MagicMock,
) -> None:
"""Test agent delete backup does not throw on a backup not found."""
mock_client.list_objects_v2.return_value = {"Contents": []}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": []}
]
client = await hass_ws_client(hass)
@@ -284,7 +290,7 @@ async def test_agents_delete_not_throwing_on_not_found(
assert response["success"]
assert response["result"] == {"agent_errors": {}}
assert mock_client.delete_object.call_count == 0
assert mock_client.delete_objects.call_count == 0
async def test_agents_upload(
@@ -490,20 +496,27 @@ async def test_cache_expiration(
metadata_content = json.dumps(agent_backup.as_dict())
mock_body = AsyncMock()
mock_body.read.return_value = metadata_content.encode()
mock_client.list_objects_v2.return_value = {
"Contents": [
{"Key": "test.metadata.json", "LastModified": "2023-01-01T00:00:00+00:00"}
]
}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{
"Contents": [
{
"Key": "test.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
}
]
}
]
mock_client.get_object.return_value = {"Body": mock_body}
# First call should query IDrive e2
await agent.async_list_backups()
assert mock_client.list_objects_v2.call_count == 1
assert mock_client.get_paginator.call_count == 1
assert mock_client.get_object.call_count == 1
# Second call should use cache
await agent.async_list_backups()
assert mock_client.list_objects_v2.call_count == 1
assert mock_client.get_paginator.call_count == 1
assert mock_client.get_object.call_count == 1
# Set cache to expire
@@ -511,7 +524,7 @@ async def test_cache_expiration(
# Third call should query IDrive e2 again
await agent.async_list_backups()
assert mock_client.list_objects_v2.call_count == 2
assert mock_client.get_paginator.call_count == 2
assert mock_client.get_object.call_count == 2
@@ -526,3 +539,88 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None:
remove_listener()
assert DATA_BACKUP_AGENT_LISTENERS not in hass.data
async def test_list_backups_with_pagination(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test listing backups when paginating through multiple pages."""
# Create agent
agent = IDriveE2BackupAgent(hass, mock_config_entry)
# Create two different backups
backup1 = AgentBackup(
backup_id="backup1",
date="2023-01-01T00:00:00+00:00",
addons=[],
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=False,
homeassistant_version=None,
name="Backup 1",
protected=False,
size=0,
)
backup2 = AgentBackup(
backup_id="backup2",
date="2023-01-02T00:00:00+00:00",
addons=[],
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=False,
homeassistant_version=None,
name="Backup 2",
protected=False,
size=0,
)
# Setup two pages of results
page1 = {
"Contents": [
{
"Key": "backup1.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{"Key": "backup1.tar", "LastModified": "2023-01-01T00:00:00+00:00"},
]
}
page2 = {
"Contents": [
{
"Key": "backup2.metadata.json",
"LastModified": "2023-01-02T00:00:00+00:00",
},
{"Key": "backup2.tar", "LastModified": "2023-01-02T00:00:00+00:00"},
]
}
# Setup mock client
mock_client = mock_config_entry.runtime_data
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
page1,
page2,
]
# Mock get_object responses based on the key
async def mock_get_object(**kwargs):
"""Mock get_object with different responses based on the key."""
key = kwargs.get("Key", "")
if "backup1" in key:
mock_body = AsyncMock()
mock_body.read.return_value = json.dumps(backup1.as_dict()).encode()
return {"Body": mock_body}
# backup2
mock_body = AsyncMock()
mock_body.read.return_value = json.dumps(backup2.as_dict()).encode()
return {"Body": mock_body}
mock_client.get_object.side_effect = mock_get_object
# List backups and verify we got both
backups = await agent.async_list_backups()
assert len(backups) == 2
backup_ids = {backup.backup_id for backup in backups}
assert backup_ids == {"backup1", "backup2"}

View File

@@ -1,14 +1,13 @@
"""Fixtures for the Trane Local integration tests."""
from collections.abc import AsyncGenerator, Generator
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 import PLATFORMS
from homeassistant.components.trane.const import CONF_SECRET_KEY, DOMAIN
from homeassistant.const import CONF_HOST, Platform
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -32,19 +31,6 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Platforms, which should be loaded during the test."""
return PLATFORMS
@pytest.fixture(autouse=True)
async def mock_patch_platforms(platforms: list[Platform]) -> AsyncGenerator[None]:
"""Fixture to set up platforms for tests."""
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
yield
def _make_state() -> ThermostatState:
"""Create a mock thermostat state."""
return ThermostatState(
@@ -63,8 +49,6 @@ def _make_state() -> ThermostatState:
supported_modes=[ZoneMode.OFF, ZoneMode.AUTO, ZoneMode.COOL, ZoneMode.HEAT],
fan_mode=FanMode.AUTO,
relative_humidity="45",
heating_active="0",
cooling_active="0",
)

View File

@@ -1,89 +0,0 @@
# serializer version: 1
# name: test_climate_entities[climate.living_room-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'auto',
'on',
'circulate',
]),
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 95,
'min_temp': 45,
'target_temp_step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.living_room',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'trane',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 395>,
'translation_key': 'zone',
'unique_id': 'test_entry_id_1_zone',
'unit_of_measurement': None,
})
# ---
# name: test_climate_entities[climate.living_room-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_humidity': 45,
'current_temperature': 72,
'fan_mode': 'auto',
'fan_modes': list([
'auto',
'on',
'circulate',
]),
'friendly_name': 'Living Room',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 95,
'min_temp': 45,
'supported_features': <ClimateEntityFeature: 395>,
'target_temp_high': 76,
'target_temp_low': 68,
'target_temp_step': 1.0,
'temperature': None,
}),
'context': <ANY>,
'entity_id': 'climate.living_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat_cool',
})
# ---

View File

@@ -1,335 +0,0 @@
"""Tests for the Trane Local climate platform."""
from unittest.mock import MagicMock
import pytest
from steamloop import FanMode, HoldType, ZoneMode
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACAction,
HVACMode,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Platforms, which should be loaded during the test."""
return [Platform.CLIMATE]
@pytest.fixture(autouse=True)
def set_us_customary(hass: HomeAssistant) -> None:
"""Set US customary unit system for Trane (Fahrenheit thermostats)."""
hass.config.units = US_CUSTOMARY_SYSTEM
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_climate_entities(
hass: HomeAssistant,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Snapshot all climate entities."""
await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)
async def test_hvac_mode_auto(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test HVAC mode is AUTO 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("climate.living_room")
assert state is not None
assert state.state == HVACMode.AUTO
async def test_current_temperature_not_available(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test current temperature is None when not yet received."""
mock_connection.state.zones["1"].indoor_temperature = ""
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("climate.living_room")
assert state is not None
assert state.attributes["current_temperature"] is None
async def test_current_humidity_not_available(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test current humidity is omitted when not yet received."""
mock_connection.state.relative_humidity = ""
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("climate.living_room")
assert state is not None
assert "current_humidity" not in state.attributes
async def test_set_hvac_mode_off(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test setting HVAC mode to off."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.OFF},
blocking=True,
)
mock_connection.set_temperature_setpoint.assert_not_called()
mock_connection.set_zone_mode.assert_called_once_with("1", ZoneMode.OFF)
@pytest.mark.parametrize(
("hvac_mode", "expected_hold", "expected_zone_mode"),
[
(HVACMode.AUTO, HoldType.SCHEDULE, ZoneMode.AUTO),
(HVACMode.HEAT_COOL, HoldType.MANUAL, ZoneMode.AUTO),
(HVACMode.HEAT, HoldType.MANUAL, ZoneMode.HEAT),
(HVACMode.COOL, HoldType.MANUAL, ZoneMode.COOL),
],
)
async def test_set_hvac_mode(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_connection: MagicMock,
hvac_mode: HVACMode,
expected_hold: HoldType,
expected_zone_mode: ZoneMode,
) -> None:
"""Test setting HVAC mode."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
mock_connection.set_temperature_setpoint.assert_called_once_with(
"1", hold_type=expected_hold
)
mock_connection.set_zone_mode.assert_called_once_with("1", expected_zone_mode)
async def test_set_temperature_range(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test setting temperature range in heat_cool mode."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "climate.living_room",
ATTR_TARGET_TEMP_LOW: 65,
ATTR_TARGET_TEMP_HIGH: 78,
},
blocking=True,
)
mock_connection.set_temperature_setpoint.assert_called_once_with(
"1",
heat_setpoint="65",
cool_setpoint="78",
)
async def test_set_temperature_single_heat(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test setting single temperature in heat mode."""
mock_connection.state.zones["1"].mode = ZoneMode.HEAT
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "climate.living_room",
ATTR_TEMPERATURE: 70,
},
blocking=True,
)
mock_connection.set_temperature_setpoint.assert_called_once_with(
"1",
heat_setpoint="70",
cool_setpoint=None,
)
async def test_set_temperature_single_cool(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test setting single temperature in cool mode."""
mock_connection.state.zones["1"].mode = ZoneMode.COOL
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "climate.living_room",
ATTR_TEMPERATURE: 78,
},
blocking=True,
)
mock_connection.set_temperature_setpoint.assert_called_once_with(
"1",
heat_setpoint=None,
cool_setpoint="78",
)
@pytest.mark.parametrize(
("fan_mode", "expected_fan_mode"),
[
("auto", FanMode.AUTO),
("on", FanMode.ALWAYS_ON),
("circulate", FanMode.CIRCULATE),
],
)
async def test_set_fan_mode(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_connection: MagicMock,
fan_mode: str,
expected_fan_mode: FanMode,
) -> None:
"""Test setting fan mode."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_FAN_MODE: fan_mode},
blocking=True,
)
mock_connection.set_fan_mode.assert_called_once_with(expected_fan_mode)
@pytest.mark.parametrize(
("cooling_active", "heating_active", "zone_mode", "expected_action"),
[
("0", "0", ZoneMode.OFF, HVACAction.OFF),
("0", "2", ZoneMode.AUTO, HVACAction.HEATING),
("2", "0", ZoneMode.AUTO, HVACAction.COOLING),
("0", "0", ZoneMode.AUTO, HVACAction.IDLE),
("0", "1", ZoneMode.AUTO, HVACAction.IDLE),
("1", "0", ZoneMode.AUTO, HVACAction.IDLE),
("0", "2", ZoneMode.COOL, HVACAction.IDLE),
("2", "0", ZoneMode.HEAT, HVACAction.IDLE),
("2", "2", ZoneMode.AUTO, HVACAction.COOLING),
],
)
async def test_hvac_action(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection: MagicMock,
cooling_active: str,
heating_active: str,
zone_mode: ZoneMode,
expected_action: HVACAction,
) -> None:
"""Test HVAC action reflects thermostat state."""
mock_connection.state.cooling_active = cooling_active
mock_connection.state.heating_active = heating_active
mock_connection.state.zones["1"].mode = zone_mode
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("climate.living_room")
assert state is not None
assert state.attributes["hvac_action"] == expected_action
async def test_turn_on(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test turn on defaults to heat_cool mode."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "climate.living_room"},
blocking=True,
)
mock_connection.set_temperature_setpoint.assert_called_once_with(
"1", hold_type=HoldType.MANUAL
)
mock_connection.set_zone_mode.assert_called_once_with("1", ZoneMode.AUTO)
async def test_turn_off(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_connection: MagicMock,
) -> None:
"""Test turn off sets mode to off."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "climate.living_room"},
blocking=True,
)
mock_connection.set_temperature_setpoint.assert_not_called()
mock_connection.set_zone_mode.assert_called_once_with("1", ZoneMode.OFF)

View File

@@ -12,7 +12,6 @@ from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -20,12 +19,6 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Platforms, which should be loaded during the test."""
return [Platform.SWITCH]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_switch_entities(
hass: HomeAssistant,

View File

@@ -4,8 +4,16 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pyvlx import Light, OnOffLight, OnOffSwitch, Scene
from pyvlx.opening_device import Blind, DualRollerShutter, Window
from pyvlx import (
Blind,
DualRollerShutter,
ExteriorHeating,
Light,
OnOffLight,
OnOffSwitch,
Scene,
Window,
)
from homeassistant.components.velux import DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform
@@ -131,6 +139,18 @@ def mock_onoff_light() -> AsyncMock:
return light
# an exterior heating device
@pytest.fixture
def mock_exterior_heating() -> AsyncMock:
"""Create a mock Velux exterior heating device."""
exterior_heating = AsyncMock(spec=ExteriorHeating, autospec=True)
exterior_heating.name = "Test Exterior Heating"
exterior_heating.serial_number = "1984"
exterior_heating.intensity = MagicMock(intensity_percent=33)
exterior_heating.pyvlx = MagicMock()
return exterior_heating
# an on/off switch
@pytest.fixture
def mock_onoff_switch() -> AsyncMock:
@@ -168,6 +188,7 @@ def mock_pyvlx(
mock_onoff_switch: AsyncMock,
mock_window: AsyncMock,
mock_blind: AsyncMock,
mock_exterior_heating: AsyncMock,
mock_dual_roller_shutter: AsyncMock,
request: pytest.FixtureRequest,
) -> Generator[MagicMock]:
@@ -190,6 +211,7 @@ def mock_pyvlx(
mock_onoff_switch,
mock_blind,
mock_window,
mock_exterior_heating,
mock_cover_type,
]

View File

@@ -0,0 +1,60 @@
# serializer version: 1
# name: test_number_setup[number.test_exterior_heating-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.test_exterior_heating',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'velux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '1984',
'unit_of_measurement': '%',
})
# ---
# name: test_number_setup[number.test_exterior_heating-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Exterior Heating',
'max': 100,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.test_exterior_heating',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '33',
})
# ---

View File

@@ -0,0 +1,131 @@
"""Test Velux number entities."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from pyvlx import Intensity
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.components.velux.const import DOMAIN
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import update_callback_entity
from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_integration")
@pytest.fixture
def platform() -> Platform:
"""Fixture to specify platform to test."""
return Platform.NUMBER
def get_number_entity_id(mock: AsyncMock) -> str:
"""Helper to get the entity ID for a given mock node."""
return f"number.{mock.name.lower().replace(' ', '_')}"
async def test_number_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot the entity and validate registry metadata."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
async def test_number_device_association(
hass: HomeAssistant,
mock_exterior_heating: AsyncMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Ensure exterior heating number entity is associated with a device."""
entity_id = get_number_entity_id(mock_exterior_heating)
entry = entity_registry.async_get(entity_id)
assert entry is not None
assert entry.device_id is not None
device_entry = device_registry.async_get(entry.device_id)
assert device_entry is not None
assert (DOMAIN, mock_exterior_heating.serial_number) in device_entry.identifiers
async def test_get_intensity(
hass: HomeAssistant,
mock_exterior_heating: AsyncMock,
) -> None:
"""Entity state follows intensity value and becomes unknown when not known."""
entity_id = get_number_entity_id(mock_exterior_heating)
# Set initial intensity values
mock_exterior_heating.intensity.intensity_percent = 20
await update_callback_entity(hass, mock_exterior_heating)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "20"
mock_exterior_heating.intensity.known = False
await update_callback_entity(hass, mock_exterior_heating)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN
async def test_set_value_sets_intensity(
hass: HomeAssistant,
mock_exterior_heating: AsyncMock,
) -> None:
"""Calling set_value forwards to set_intensity."""
entity_id = get_number_entity_id(mock_exterior_heating)
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_VALUE: 30, "entity_id": entity_id},
blocking=True,
)
mock_exterior_heating.set_intensity.assert_awaited_once()
args, kwargs = mock_exterior_heating.set_intensity.await_args
intensity = args[0]
assert isinstance(intensity, Intensity)
assert intensity.intensity_percent == 30
assert kwargs.get("wait_for_completion") is True
async def test_set_invalid_value_fails(
hass: HomeAssistant,
mock_exterior_heating: AsyncMock,
) -> None:
"""Values outside the valid range raise ServiceValidationError and do not call set_intensity."""
entity_id = get_number_entity_id(mock_exterior_heating)
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_VALUE: 101, "entity_id": entity_id},
blocking=True,
)
mock_exterior_heating.set_intensity.assert_not_awaited()