mirror of
https://github.com/home-assistant/core.git
synced 2026-02-21 05:09:21 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6bc29a6a | ||
|
|
28027ddca4 | ||
|
|
3e8923f105 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.2"
|
||||
|
||||
@@ -58,12 +58,11 @@ C4_TO_HA_HVAC_MODE = {
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map Control4 HVAC states to Home Assistant HVAC actions
|
||||
# Map the five known Control4 HVAC states to Home Assistant HVAC actions
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"off": HVACAction.OFF,
|
||||
"heat": HVACAction.HEATING,
|
||||
"cool": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"dry": HVACAction.DRYING,
|
||||
"fan": HVACAction.FAN,
|
||||
}
|
||||
@@ -237,14 +236,8 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
c4_state = data.get(CONTROL4_HVAC_STATE)
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
# Substring match for multi-stage systems that report
|
||||
# e.g. "Stage 1 Heat", "Stage 2 Cool"
|
||||
if action is None:
|
||||
if "heat" in str(c4_state).lower():
|
||||
action = HVACAction.HEATING
|
||||
elif "cool" in str(c4_state).lower():
|
||||
action = HVACAction.COOLING
|
||||
if action is None:
|
||||
_LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state)
|
||||
return action
|
||||
|
||||
@@ -53,7 +53,6 @@ class EheimDigitalUpdateCoordinator(
|
||||
main_device_added_event=self.main_device_added_event,
|
||||
)
|
||||
self.known_devices: set[str] = set()
|
||||
self.incomplete_devices: set[str] = set()
|
||||
self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
|
||||
|
||||
def add_platform_callback(
|
||||
@@ -71,26 +70,11 @@ class EheimDigitalUpdateCoordinator(
|
||||
This function is called from the library whenever a new device is added.
|
||||
"""
|
||||
|
||||
if self.hub.devices[device_address].is_missing_data:
|
||||
self.incomplete_devices.add(device_address)
|
||||
return
|
||||
|
||||
if (
|
||||
device_address not in self.known_devices
|
||||
or device_address in self.incomplete_devices
|
||||
):
|
||||
if device_address not in self.known_devices:
|
||||
for platform_callback in self.platform_callbacks:
|
||||
platform_callback({device_address: self.hub.devices[device_address]})
|
||||
if device_address in self.incomplete_devices:
|
||||
self.incomplete_devices.remove(device_address)
|
||||
|
||||
async def _async_receive_callback(self) -> None:
|
||||
if any(self.incomplete_devices):
|
||||
for device_address in self.incomplete_devices.copy():
|
||||
if not self.hub.devices[device_address].is_missing_data:
|
||||
await self._async_device_found(
|
||||
device_address, EheimDeviceType.VERSION_UNDEFINED
|
||||
)
|
||||
self.async_set_updated_data(self.hub.devices)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.6.0"],
|
||||
"requirements": ["eheimdigital==1.5.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -152,8 +152,6 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_addon(
|
||||
self.hass, self._addon_slug, backup, self.title, self.installed_version
|
||||
)
|
||||
@@ -310,8 +308,6 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_core(self.hass, version, backup)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -31,7 +31,6 @@ HOMEE_UNIT_TO_HA_UNIT = {
|
||||
"n/a": None,
|
||||
"text": None,
|
||||
"%": PERCENTAGE,
|
||||
"Lux": LIGHT_LUX,
|
||||
"lx": LIGHT_LUX,
|
||||
"klx": LIGHT_LUX,
|
||||
"1/min": REVOLUTIONS_PER_MINUTE,
|
||||
|
||||
@@ -161,11 +161,6 @@ class HomematicipHAP:
|
||||
_LOGGER.error("HMIP access point has lost connection with the cloud")
|
||||
self._ws_connection_closed.set()
|
||||
self.set_all_to_unavailable()
|
||||
elif self._ws_connection_closed.is_set():
|
||||
_LOGGER.info("HMIP access point has reconnected to the cloud")
|
||||
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||
self._ws_connection_closed.clear()
|
||||
|
||||
@callback
|
||||
def async_create_entity(self, *args, **kwargs) -> None:
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.11", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.9", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==13.2.0"]
|
||||
"requirements": ["ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==13.2.0"]
|
||||
"requirements": ["ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
no_program = 0, -1
|
||||
intensive = 1, 26, 205
|
||||
maintenance = 2, 27, 214
|
||||
eco = 3, 22, 28, 200
|
||||
eco = 3, 28, 200
|
||||
automatic = 6, 7, 31, 32, 202
|
||||
solar_save = 9, 34
|
||||
gentle = 10, 35, 210
|
||||
|
||||
@@ -7,8 +7,6 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from nrgkick_api import ChargingStatus
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -634,18 +632,11 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
|
||||
key="vehicle_connected_since",
|
||||
translation_key="vehicle_connected_since",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda data: (
|
||||
_seconds_to_stable_timestamp(
|
||||
cast(
|
||||
StateType,
|
||||
_get_nested_dict_value(
|
||||
data.values, "general", "vehicle_connect_time"
|
||||
),
|
||||
)
|
||||
value_fn=lambda data: _seconds_to_stable_timestamp(
|
||||
cast(
|
||||
StateType,
|
||||
_get_nested_dict_value(data.values, "general", "vehicle_connect_time"),
|
||||
)
|
||||
if _get_nested_dict_value(data.values, "general", "status")
|
||||
!= ChargingStatus.STANDBY
|
||||
else None
|
||||
),
|
||||
),
|
||||
NRGkickSensorEntityDescription(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyrainbird"],
|
||||
"requirements": ["pyrainbird==6.0.5"]
|
||||
"requirements": ["pyrainbird==6.0.1"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Calendar platform for a Remote Calendar."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from ical.event import Event
|
||||
from ical.timeline import Timeline, materialize_timeline
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -21,14 +20,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Every coordinator update refresh, we materialize a timeline of upcoming
|
||||
# events for determining state. This is done in the background to avoid blocking
|
||||
# the event loop. When a state update happens we can scan for active events on
|
||||
# the materialized timeline. These parameters control the maximum lookahead
|
||||
# window and number of events we materialize from the calendar.
|
||||
MAX_LOOKAHEAD_EVENTS = 20
|
||||
MAX_LOOKAHEAD_TIME = timedelta(days=365)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -57,18 +48,12 @@ class RemoteCalendarEntity(
|
||||
super().__init__(coordinator)
|
||||
self._attr_name = entry.data[CONF_CALENDAR_NAME]
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._timeline: Timeline | None = None
|
||||
self._event: CalendarEvent | None = None
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
if self._timeline is None:
|
||||
return None
|
||||
now = dt_util.now()
|
||||
events = self._timeline.active_after(now)
|
||||
if event := next(events, None):
|
||||
return _get_calendar_event(event)
|
||||
return None
|
||||
return self._event
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
@@ -94,18 +79,14 @@ class RemoteCalendarEntity(
|
||||
"""
|
||||
await super().async_update()
|
||||
|
||||
def _get_timeline() -> Timeline | None:
|
||||
"""Return a materialized timeline with upcoming events."""
|
||||
def next_event() -> CalendarEvent | None:
|
||||
now = dt_util.now()
|
||||
timeline = self.coordinator.data.timeline_tz(now.tzinfo)
|
||||
return materialize_timeline(
|
||||
timeline,
|
||||
start=now,
|
||||
stop=now + MAX_LOOKAHEAD_TIME,
|
||||
max_number_of_events=MAX_LOOKAHEAD_EVENTS,
|
||||
)
|
||||
events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now)
|
||||
if event := next(events, None):
|
||||
return _get_calendar_event(event)
|
||||
return None
|
||||
|
||||
self._timeline = await self.hass.async_add_executor_job(_get_timeline)
|
||||
self._event = await self.hass.async_add_executor_job(next_event)
|
||||
|
||||
|
||||
def _get_calendar_event(event: Event) -> CalendarEvent:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==13.2.0"]
|
||||
"requirements": ["ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -29,24 +29,17 @@ from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from . import RoborockConfigEntry
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_ENTRY_CODE,
|
||||
CONF_REGION,
|
||||
CONF_SHOW_BACKGROUND,
|
||||
CONF_USER_DATA,
|
||||
DEFAULT_DRAWABLES,
|
||||
DOMAIN,
|
||||
DRAWABLES,
|
||||
REGION_OPTIONS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -71,35 +64,17 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
username = user_input[CONF_USERNAME]
|
||||
region = user_input[CONF_REGION]
|
||||
self._username = username
|
||||
_LOGGER.debug("Requesting code for Roborock account")
|
||||
base_url = None
|
||||
if region != "auto":
|
||||
base_url = f"https://{region}iot.roborock.com"
|
||||
self._client = RoborockApiClient(
|
||||
username,
|
||||
base_url=base_url,
|
||||
session=async_get_clientsession(self.hass),
|
||||
username, session=async_get_clientsession(self.hass)
|
||||
)
|
||||
errors = await self._request_code()
|
||||
if not errors:
|
||||
return await self.async_step_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_REGION, default="auto"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=REGION_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -139,8 +114,6 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_data = await self._client.code_login_v4(code)
|
||||
except RoborockInvalidCode:
|
||||
errors["base"] = "invalid_code"
|
||||
except RoborockAccountDoesNotExist:
|
||||
errors["base"] = "invalid_email_or_region"
|
||||
except RoborockException:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown_roborock"
|
||||
|
||||
@@ -11,8 +11,7 @@ CONF_ENTRY_CODE = "code"
|
||||
CONF_BASE_URL = "base_url"
|
||||
CONF_USER_DATA = "user_data"
|
||||
CONF_SHOW_BACKGROUND = "show_background"
|
||||
CONF_REGION = "region"
|
||||
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
|
||||
|
||||
# Option Flow steps
|
||||
DRAWABLES = "drawables"
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"invalid_code": "The code you entered was incorrect, please check it and try again.",
|
||||
"invalid_email": "There is no account associated with the email you entered, please try again.",
|
||||
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
|
||||
"invalid_email_or_region": "Either there is no account associated with the email you entered, or there is no account in the selected region.",
|
||||
"too_frequent_code_requests": "You have attempted to request too many codes. Try again later.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_roborock": "There was an unknown Roborock exception - please check your logs.",
|
||||
@@ -31,11 +30,9 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"region": "Roborock server region",
|
||||
"username": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"data_description": {
|
||||
"region": "The server region your Roborock account is registered in when setting up the app. Auto is recommended unless you are having issues.",
|
||||
"username": "The email address used to sign in to the Roborock app."
|
||||
},
|
||||
"description": "Enter your Roborock email address."
|
||||
@@ -548,17 +545,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"region": {
|
||||
"options": {
|
||||
"auto": "Auto",
|
||||
"cn": "CN",
|
||||
"eu": "EU",
|
||||
"ru": "RU",
|
||||
"us": "US"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_maps": {
|
||||
"description": "Retrieves the map and room information of your device.",
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.5.3"]
|
||||
"requirements": ["pysmartthings==3.5.2"]
|
||||
}
|
||||
|
||||
@@ -35,8 +35,4 @@ class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.zone_id in self.coordinator.data.zones
|
||||
and self.zone.alarm is None
|
||||
)
|
||||
return super().available and self.zone_id in self.coordinator.data.zones
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
from pythonxbox.api.client import XboxLiveClient
|
||||
from pythonxbox.authentication.manager import AuthenticationManager
|
||||
from pythonxbox.authentication.models import OAuth2TokenResponse
|
||||
@@ -19,7 +20,6 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -67,14 +67,14 @@ class OAuth2FlowHandler(
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow."""
|
||||
|
||||
session = get_async_client(self.hass)
|
||||
auth = AuthenticationManager(session, "", "", "")
|
||||
auth.oauth = OAuth2TokenResponse(**data["token"])
|
||||
await auth.refresh_tokens()
|
||||
async with AsyncClient() as session:
|
||||
auth = AuthenticationManager(session, "", "", "")
|
||||
auth.oauth = OAuth2TokenResponse(**data["token"])
|
||||
await auth.refresh_tokens()
|
||||
|
||||
client = XboxLiveClient(auth)
|
||||
client = XboxLiveClient(auth)
|
||||
|
||||
me = await client.people.get_friends_by_xuid(client.xuid)
|
||||
me = await client.people.get_friends_by_xuid(client.xuid)
|
||||
|
||||
await self.async_set_unique_id(client.xuid)
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool mode."""
|
||||
if self._current_mode is None:
|
||||
# Thermostat(valve) with no support for setting
|
||||
@@ -292,10 +292,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
if self._current_mode.value is None:
|
||||
# guard missing value
|
||||
return HVACMode.HEAT
|
||||
mode = ZW_HVAC_MODE_MAP.get(int(self._current_mode.value))
|
||||
if mode is not None and mode not in self._hvac_modes:
|
||||
return None
|
||||
return mode
|
||||
return ZW_HVAC_MODE_MAP.get(int(self._current_mode.value), HVACMode.HEAT_COOL)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
@@ -551,17 +548,12 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
"""Set new target preset mode."""
|
||||
assert self._current_mode is not None
|
||||
if preset_mode == PRESET_NONE:
|
||||
# Try to restore to the (translated) main hvac mode.
|
||||
if (hvac_mode := self.hvac_mode) is None:
|
||||
# Current preset mode doesn't map to a supported HVAC mode.
|
||||
# Pick the first supported non-off mode.
|
||||
hvac_mode = next(
|
||||
mode for mode in self._hvac_modes if mode != HVACMode.OFF
|
||||
)
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
# try to restore to the (translated) main hvac mode
|
||||
await self.async_set_hvac_mode(self.hvac_mode)
|
||||
return
|
||||
|
||||
preset_mode_value = self._hvac_presets[preset_mode]
|
||||
preset_mode_value = self._hvac_presets.get(preset_mode)
|
||||
if preset_mode_value is None:
|
||||
raise ValueError(f"Received an invalid preset mode: {preset_mode}")
|
||||
|
||||
await self._async_set_value(self._current_mode, preset_mode_value)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "3"
|
||||
PATCH_VERSION: Final = "2"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from functools import lru_cache
|
||||
from ipaddress import ip_address
|
||||
import socket
|
||||
from ssl import SSLContext
|
||||
import sys
|
||||
@@ -14,11 +12,10 @@ from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientMiddlewareType, hdrs, web
|
||||
from aiohttp import web
|
||||
from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT
|
||||
from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout
|
||||
from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import zeroconf
|
||||
@@ -28,7 +25,6 @@ from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import ssl as ssl_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import json_loads
|
||||
from homeassistant.util.network import is_loopback
|
||||
|
||||
from .frame import warn_use
|
||||
from .json import json_dumps
|
||||
@@ -53,92 +49,6 @@ SERVER_SOFTWARE = (
|
||||
|
||||
WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session"
|
||||
|
||||
_LOCALHOST = "localhost"
|
||||
_TRAILING_LOCAL_HOST = f".{_LOCALHOST}"
|
||||
|
||||
|
||||
class SSRFRedirectError(aiohttp.ClientError):
|
||||
"""SSRF redirect protection.
|
||||
|
||||
Raised when a redirect targets a blocked address (loopback or unspecified).
|
||||
"""
|
||||
|
||||
|
||||
async def _ssrf_redirect_middleware(
|
||||
request: aiohttp.ClientRequest,
|
||||
handler: aiohttp.ClientHandlerType,
|
||||
) -> aiohttp.ClientResponse:
|
||||
"""Block redirects from non-loopback origins to loopback targets."""
|
||||
resp = await handler(request)
|
||||
|
||||
# Return early if not a redirect or already loopback to allow loopback origins
|
||||
connector = request.session.connector
|
||||
if not (300 <= resp.status < 400) or await _async_is_blocked_host(
|
||||
request.url.host, connector
|
||||
):
|
||||
return resp
|
||||
|
||||
location = resp.headers.get(hdrs.LOCATION, "")
|
||||
if not location:
|
||||
return resp
|
||||
|
||||
redirect_url = URL(location)
|
||||
if not redirect_url.is_absolute():
|
||||
# Relative redirects stay on the same host - always safe
|
||||
return resp
|
||||
|
||||
host = redirect_url.host
|
||||
if await _async_is_blocked_host(host, connector):
|
||||
resp.close()
|
||||
raise SSRFRedirectError(
|
||||
f"Redirect from {request.url.host} to a blocked address"
|
||||
f" is not allowed: {host}"
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _is_ssrf_address(address: str) -> bool:
|
||||
"""Check if an IP address is a potential SSRF target.
|
||||
|
||||
Returns True for loopback and unspecified addresses.
|
||||
"""
|
||||
ip = ip_address(address)
|
||||
return is_loopback(ip) or ip.is_unspecified
|
||||
|
||||
|
||||
async def _async_is_blocked_host(
|
||||
host: str | None, connector: aiohttp.BaseConnector | None
|
||||
) -> bool:
|
||||
"""Check if a host is blocked by hostname or by resolved IP.
|
||||
|
||||
First does a fast sync check on the hostname string, then resolves
|
||||
the hostname via the connector and checks each resolved IP address.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Strip FQDN trailing dot (RFC 1035) since yarl preserves it,
|
||||
# preventing an attacker from bypassing the check with "localhost."
|
||||
stripped_host = host.strip().removesuffix(".")
|
||||
if stripped_host == _LOCALHOST or stripped_host.endswith(_TRAILING_LOCAL_HOST):
|
||||
return True
|
||||
|
||||
with suppress(ValueError):
|
||||
return _is_ssrf_address(host)
|
||||
|
||||
if not isinstance(connector, HomeAssistantTCPConnector):
|
||||
return False
|
||||
|
||||
try:
|
||||
results = await connector.async_resolve_host(host)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
return any(_is_ssrf_address(result["host"]) for result in results)
|
||||
|
||||
|
||||
#
|
||||
# The default connection limit of 100 meant that you could only have
|
||||
# 100 concurrent connections.
|
||||
@@ -281,16 +191,10 @@ def _async_create_clientsession(
|
||||
**kwargs: Any,
|
||||
) -> aiohttp.ClientSession:
|
||||
"""Create a new ClientSession with kwargs, i.e. for cookies."""
|
||||
middlewares: Sequence[ClientMiddlewareType] = (
|
||||
_ssrf_redirect_middleware,
|
||||
*kwargs.pop("middlewares", ()),
|
||||
)
|
||||
|
||||
clientsession = aiohttp.ClientSession(
|
||||
connector=_async_get_connector(hass, verify_ssl, family, ssl_cipher),
|
||||
json_serialize=json_dumps,
|
||||
response_class=HassClientResponse,
|
||||
middlewares=middlewares,
|
||||
**kwargs,
|
||||
)
|
||||
# Prevent packages accidentally overriding our default headers
|
||||
@@ -439,10 +343,6 @@ class HomeAssistantTCPConnector(aiohttp.TCPConnector):
|
||||
# abort transport after 60 seconds (cleanup broken connections)
|
||||
_cleanup_closed_period = 60.0
|
||||
|
||||
async def async_resolve_host(self, host: str) -> list[aiohttp.abc.ResolveResult]:
|
||||
"""Resolve a host to a list of addresses."""
|
||||
return await self._resolve_host(host, 0)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_get_connector(
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.2.3"
|
||||
version = "2026.2.2"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -854,7 +854,7 @@ ecoaliface==0.4.0
|
||||
egauge-async==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.6.0
|
||||
eheimdigital==1.5.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.1
|
||||
@@ -1258,7 +1258,7 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==13.2.0
|
||||
ical==12.1.3
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
@@ -2316,7 +2316,7 @@ pypaperless==4.1.1
|
||||
pypca==0.0.7
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.11
|
||||
pypck==0.9.9
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -2358,7 +2358,7 @@ pyqwikswitch==0.93
|
||||
pyrail==0.4.1
|
||||
|
||||
# homeassistant.components.rainbird
|
||||
pyrainbird==6.0.5
|
||||
pyrainbird==6.0.1
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
pyrate-limiter==3.9.0
|
||||
@@ -2434,7 +2434,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.3
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.5.3
|
||||
pysmartthings==3.5.2
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -754,7 +754,7 @@ easyenergy==2.2.0
|
||||
egauge-async==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.6.0
|
||||
eheimdigital==1.5.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.1
|
||||
@@ -1110,7 +1110,7 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==13.2.0
|
||||
ical==12.1.3
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
@@ -1963,7 +1963,7 @@ pypalazzetti==0.1.20
|
||||
pypaperless==4.1.1
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.11
|
||||
pypck==0.9.9
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -2002,7 +2002,7 @@ pyqwikswitch==0.93
|
||||
pyrail==0.4.1
|
||||
|
||||
# homeassistant.components.rainbird
|
||||
pyrainbird==6.0.5
|
||||
pyrainbird==6.0.1
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
pyrate-limiter==3.9.0
|
||||
@@ -2060,7 +2060,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.3
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.5.3
|
||||
pysmartthings==3.5.2
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
|
||||
@@ -203,6 +203,11 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
|
||||
"sense": {"sense-energy": {"async-timeout"}},
|
||||
"slimproto": {"aioslimproto": {"async-timeout"}},
|
||||
"surepetcare": {"surepy": {"async-timeout"}},
|
||||
"tami4": {
|
||||
# https://github.com/SeleniumHQ/selenium/issues/16943
|
||||
# tami4 > selenium > types*
|
||||
"selenium": {"types-certifi", "types-urllib3"},
|
||||
},
|
||||
"travisci": {
|
||||
# https://github.com/menegazzo/travispy seems to be unmaintained
|
||||
# and unused https://www.home-assistant.io/integrations/travisci
|
||||
|
||||
@@ -110,21 +110,6 @@ async def test_climate_entities(
|
||||
HVACAction.FAN,
|
||||
id="fan",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Idle"),
|
||||
HVACAction.IDLE,
|
||||
id="idle",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Stage 1 Heat"),
|
||||
HVACAction.HEATING,
|
||||
id="stage_1_heat",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Stage 2 Cool", hvac_mode="Cool"),
|
||||
HVACAction.COOLING,
|
||||
id="stage_2_cool",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""Tests for the init module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from eheimdigital.types import EheimDeviceType, EheimDigitalClientError
|
||||
|
||||
from homeassistant.components.eheimdigital.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import init_integration
|
||||
@@ -17,52 +15,6 @@ from tests.common import MockConfigEntry
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
async def test_dynamic_entities(
|
||||
hass: HomeAssistant,
|
||||
eheimdigital_hub_mock: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test dynamic adding of entities."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
heater_data = eheimdigital_hub_mock.return_value.devices[
|
||||
"00:00:00:00:00:02"
|
||||
].heater_data
|
||||
eheimdigital_hub_mock.return_value.devices["00:00:00:00:00:02"].heater_data = None
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.eheimdigital.coordinator.asyncio.Event",
|
||||
new=AsyncMock,
|
||||
),
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
for device in eheimdigital_hub_mock.return_value.devices:
|
||||
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
|
||||
device, eheimdigital_hub_mock.return_value.devices[device].device_type
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(
|
||||
DOMAIN, Platform.NUMBER, "mock_heater_night_temperature_offset"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
eheimdigital_hub_mock.return_value.devices[
|
||||
"00:00:00:00:00:02"
|
||||
].heater_data = heater_data
|
||||
|
||||
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
|
||||
|
||||
assert hass.states.get("number.mock_heater_night_temperature_offset").state == str(
|
||||
eheimdigital_hub_mock.return_value.devices[
|
||||
"00:00:00:00:00:02"
|
||||
].night_temperature_offset
|
||||
)
|
||||
|
||||
|
||||
async def test_remove_device(
|
||||
hass: HomeAssistant,
|
||||
eheimdigital_hub_mock: MagicMock,
|
||||
|
||||
@@ -1044,173 +1044,6 @@ async def test_update_core_with_backup(
|
||||
)
|
||||
|
||||
|
||||
async def test_update_core_sets_progress_immediately(
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test core update sets in_progress immediately when install starts."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("update.home_assistant_core_update")
|
||||
assert state.attributes.get("in_progress") is False
|
||||
|
||||
# Mock update_core to verify in_progress is set before it's called
|
||||
async def check_progress(
|
||||
hass: HomeAssistant, version: str | None, backup: bool
|
||||
) -> None:
|
||||
assert (
|
||||
hass.states.get("update.home_assistant_core_update").attributes.get(
|
||||
"in_progress"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.update.update_core",
|
||||
side_effect=check_progress,
|
||||
) as mock_update:
|
||||
await hass.services.async_call(
|
||||
"update",
|
||||
"install",
|
||||
{"entity_id": "update.home_assistant_core_update", "backup": True},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_update.assert_called_once()
|
||||
|
||||
|
||||
async def test_update_core_resets_progress_on_error(
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test core update resets in_progress to False when update fails."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("update.home_assistant_core_update")
|
||||
assert state.attributes.get("in_progress") is False
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hassio.update.update_core",
|
||||
side_effect=HomeAssistantError,
|
||||
),
|
||||
pytest.raises(HomeAssistantError),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"update",
|
||||
"install",
|
||||
{"entity_id": "update.home_assistant_core_update", "backup": True},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get("update.home_assistant_core_update")
|
||||
assert state.attributes.get("in_progress") is False, (
|
||||
"in_progress should be reset to False after error"
|
||||
)
|
||||
|
||||
|
||||
async def test_update_addon_sets_progress_immediately(
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test addon update sets in_progress immediately when install starts."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("update.test_update")
|
||||
assert state.attributes.get("in_progress") is False
|
||||
|
||||
# Mock update_addon to verify in_progress is set before it's called
|
||||
async def check_progress(
|
||||
hass: HomeAssistant,
|
||||
addon: str,
|
||||
backup: bool,
|
||||
addon_name: str | None,
|
||||
installed_version: str | None,
|
||||
) -> None:
|
||||
assert (
|
||||
hass.states.get("update.test_update").attributes.get("in_progress") is True
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hassio.update.update_addon",
|
||||
side_effect=check_progress,
|
||||
) as mock_update:
|
||||
await hass.services.async_call(
|
||||
"update",
|
||||
"install",
|
||||
{"entity_id": "update.test_update", "backup": True},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_update.assert_called_once()
|
||||
|
||||
|
||||
async def test_update_addon_resets_progress_on_error(
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test addon update resets in_progress to False when update fails."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("update.test_update")
|
||||
assert state.attributes.get("in_progress") is False
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hassio.update.update_addon",
|
||||
side_effect=HomeAssistantError,
|
||||
),
|
||||
pytest.raises(HomeAssistantError),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"update",
|
||||
"install",
|
||||
{"entity_id": "update.test_update", "backup": True},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get("update.test_update")
|
||||
assert state.attributes.get("in_progress") is False, (
|
||||
"in_progress should be reset to False after error"
|
||||
)
|
||||
|
||||
|
||||
async def test_update_supervisor(
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
|
||||
@@ -111,27 +111,6 @@
|
||||
"current_value": 175.0,
|
||||
"target_value": 175.0,
|
||||
"last_value": 66.0,
|
||||
"unit": "Lux",
|
||||
"step_value": 1.0,
|
||||
"editable": 0,
|
||||
"type": 11,
|
||||
"state": 1,
|
||||
"last_changed": 1709982926,
|
||||
"changed_by": 1,
|
||||
"changed_by_id": 0,
|
||||
"based_on": 1,
|
||||
"data": "",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"node_id": 1,
|
||||
"instance": 1,
|
||||
"minimum": 0,
|
||||
"maximum": 65000,
|
||||
"current_value": 175.0,
|
||||
"target_value": 175.0,
|
||||
"last_value": 66.0,
|
||||
"unit": "lx",
|
||||
"step_value": 1.0,
|
||||
"editable": 0,
|
||||
@@ -145,7 +124,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"id": 6,
|
||||
"node_id": 1,
|
||||
"instance": 2,
|
||||
"minimum": 1,
|
||||
@@ -166,7 +145,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"id": 7,
|
||||
"node_id": 1,
|
||||
"instance": 1,
|
||||
"minimum": 0,
|
||||
@@ -187,7 +166,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"id": 8,
|
||||
"node_id": 1,
|
||||
"instance": 2,
|
||||
"minimum": 0,
|
||||
@@ -208,7 +187,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"id": 9,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -229,7 +208,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"id": 10,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -250,7 +229,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"id": 11,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": -40,
|
||||
@@ -271,7 +250,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"id": 12,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -292,7 +271,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"id": 13,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -313,7 +292,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"id": 14,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": -64,
|
||||
@@ -334,7 +313,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"id": 15,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -355,7 +334,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"id": 16,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -376,7 +355,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"id": 17,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -397,7 +376,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"id": 18,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -418,7 +397,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"id": 19,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -439,7 +418,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"id": 20,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": -64,
|
||||
@@ -460,7 +439,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"id": 21,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -481,7 +460,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"id": 22,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -502,7 +481,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"id": 23,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": -50,
|
||||
@@ -523,7 +502,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"id": 24,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -544,7 +523,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"id": 25,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -565,7 +544,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"id": 26,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -586,7 +565,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"id": 27,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -607,7 +586,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"id": 28,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -628,7 +607,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"id": 29,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -649,7 +628,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"id": 30,
|
||||
"node_id": 1,
|
||||
"instance": 1,
|
||||
"minimum": 0,
|
||||
@@ -670,7 +649,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"id": 31,
|
||||
"node_id": 1,
|
||||
"instance": 2,
|
||||
"minimum": 0,
|
||||
@@ -691,7 +670,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"id": 32,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -712,7 +691,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"id": 33,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": 0,
|
||||
@@ -733,7 +712,7 @@
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"id": 34,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": -50,
|
||||
@@ -761,7 +740,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"id": 35,
|
||||
"node_id": 1,
|
||||
"instance": 0,
|
||||
"minimum": -50,
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'current_instance',
|
||||
'unique_id': '00055511EECC-1-8',
|
||||
'unique_id': '00055511EECC-1-7',
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
})
|
||||
# ---
|
||||
@@ -147,7 +147,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'current_instance',
|
||||
'unique_id': '00055511EECC-1-9',
|
||||
'unique_id': '00055511EECC-1-8',
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
})
|
||||
# ---
|
||||
@@ -201,7 +201,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'dawn',
|
||||
'unique_id': '00055511EECC-1-11',
|
||||
'unique_id': '00055511EECC-1-10',
|
||||
'unit_of_measurement': 'lx',
|
||||
})
|
||||
# ---
|
||||
@@ -258,7 +258,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'device_temperature',
|
||||
'unique_id': '00055511EECC-1-12',
|
||||
'unique_id': '00055511EECC-1-11',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
@@ -426,7 +426,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'exhaust_motor_revs',
|
||||
'unique_id': '00055511EECC-1-13',
|
||||
'unique_id': '00055511EECC-1-12',
|
||||
'unit_of_measurement': 'rpm',
|
||||
})
|
||||
# ---
|
||||
@@ -482,7 +482,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'external_temperature',
|
||||
'unique_id': '00055511EECC-1-35',
|
||||
'unique_id': '00055511EECC-1-34',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
@@ -539,7 +539,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'floor_temperature',
|
||||
'unique_id': '00055511EECC-1-36',
|
||||
'unique_id': '00055511EECC-1-35',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
@@ -593,7 +593,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'humidity',
|
||||
'unique_id': '00055511EECC-1-23',
|
||||
'unique_id': '00055511EECC-1-22',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
@@ -720,60 +720,6 @@
|
||||
'state': '175.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1_2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.test_multisensor_illuminance_1_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Illuminance 1',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ILLUMINANCE: 'illuminance'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Illuminance 1',
|
||||
'platform': 'homee',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'brightness_instance',
|
||||
'unique_id': '00055511EECC-1-6',
|
||||
'unit_of_measurement': 'lx',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1_2-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'illuminance',
|
||||
'friendly_name': 'Test MultiSensor Illuminance 1',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'lx',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_multisensor_illuminance_1_2',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '175.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -808,7 +754,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'brightness_instance',
|
||||
'unique_id': '00055511EECC-1-7',
|
||||
'unique_id': '00055511EECC-1-6',
|
||||
'unit_of_measurement': 'lx',
|
||||
})
|
||||
# ---
|
||||
@@ -862,7 +808,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'indoor_humidity',
|
||||
'unique_id': '00055511EECC-1-14',
|
||||
'unique_id': '00055511EECC-1-13',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
@@ -919,7 +865,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'indoor_temperature',
|
||||
'unique_id': '00055511EECC-1-15',
|
||||
'unique_id': '00055511EECC-1-14',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
@@ -973,7 +919,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'intake_motor_revs',
|
||||
'unique_id': '00055511EECC-1-16',
|
||||
'unique_id': '00055511EECC-1-15',
|
||||
'unit_of_measurement': 'rpm',
|
||||
})
|
||||
# ---
|
||||
@@ -1029,7 +975,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'level',
|
||||
'unique_id': '00055511EECC-1-17',
|
||||
'unique_id': '00055511EECC-1-16',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1083,7 +1029,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'link_quality',
|
||||
'unique_id': '00055511EECC-1-18',
|
||||
'unique_id': '00055511EECC-1-17',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1221,7 +1167,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'operating_hours',
|
||||
'unique_id': '00055511EECC-1-19',
|
||||
'unique_id': '00055511EECC-1-18',
|
||||
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1275,7 +1221,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'outdoor_humidity',
|
||||
'unique_id': '00055511EECC-1-20',
|
||||
'unique_id': '00055511EECC-1-19',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
@@ -1332,7 +1278,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'outdoor_temperature',
|
||||
'unique_id': '00055511EECC-1-21',
|
||||
'unique_id': '00055511EECC-1-20',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1386,7 +1332,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'position',
|
||||
'unique_id': '00055511EECC-1-22',
|
||||
'unique_id': '00055511EECC-1-21',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
@@ -1445,7 +1391,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'up_down',
|
||||
'unique_id': '00055511EECC-1-29',
|
||||
'unique_id': '00055511EECC-1-28',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1507,7 +1453,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'temperature',
|
||||
'unique_id': '00055511EECC-1-24',
|
||||
'unique_id': '00055511EECC-1-23',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1564,7 +1510,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'total_current',
|
||||
'unique_id': '00055511EECC-1-26',
|
||||
'unique_id': '00055511EECC-1-25',
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1621,7 +1567,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'total_energy',
|
||||
'unique_id': '00055511EECC-1-25',
|
||||
'unique_id': '00055511EECC-1-24',
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1678,7 +1624,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'total_power',
|
||||
'unique_id': '00055511EECC-1-27',
|
||||
'unique_id': '00055511EECC-1-26',
|
||||
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1735,7 +1681,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'total_voltage',
|
||||
'unique_id': '00055511EECC-1-28',
|
||||
'unique_id': '00055511EECC-1-27',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1789,7 +1735,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'uv',
|
||||
'unique_id': '00055511EECC-1-30',
|
||||
'unique_id': '00055511EECC-1-29',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1844,7 +1790,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'voltage_instance',
|
||||
'unique_id': '00055511EECC-1-31',
|
||||
'unique_id': '00055511EECC-1-30',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1901,7 +1847,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'voltage_instance',
|
||||
'unique_id': '00055511EECC-1-32',
|
||||
'unique_id': '00055511EECC-1-31',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
@@ -1961,7 +1907,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'wind_speed',
|
||||
'unique_id': '00055511EECC-1-33',
|
||||
'unique_id': '00055511EECC-1-32',
|
||||
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
|
||||
})
|
||||
# ---
|
||||
@@ -2019,7 +1965,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'window_position',
|
||||
'unique_id': '00055511EECC-1-34',
|
||||
'unique_id': '00055511EECC-1-33',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -49,7 +49,7 @@ async def test_up_down_values(
|
||||
|
||||
assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0]
|
||||
|
||||
attribute = mock_homee.nodes[0].attributes[28]
|
||||
attribute = mock_homee.nodes[0].attributes[27]
|
||||
for i in range(1, 5):
|
||||
await async_update_attribute_value(hass, attribute, i)
|
||||
assert (
|
||||
@@ -79,7 +79,7 @@ async def test_window_position(
|
||||
== WINDOW_MAP[0]
|
||||
)
|
||||
|
||||
attribute = mock_homee.nodes[0].attributes[33]
|
||||
attribute = mock_homee.nodes[0].attributes[32]
|
||||
for i in range(1, 3):
|
||||
await async_update_attribute_value(hass, attribute, i)
|
||||
assert (
|
||||
@@ -137,7 +137,7 @@ async def test_entity_update_action(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_homee.update_attribute.assert_called_once_with(1, 24)
|
||||
mock_homee.update_attribute.assert_called_once_with(1, 23)
|
||||
|
||||
|
||||
async def test_sensor_snapshot(
|
||||
|
||||
@@ -269,46 +269,6 @@ async def test_get_state_after_disconnect(
|
||||
mock_sleep.assert_awaited_with(2)
|
||||
|
||||
|
||||
async def test_get_state_after_ap_reconnect(
|
||||
hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home
|
||||
) -> None:
|
||||
"""Test state recovery after access point reconnects to cloud.
|
||||
|
||||
When the access point loses its cloud connection, async_update sets all
|
||||
devices to unavailable. When the access point reconnects (home.connected
|
||||
becomes True), async_update should trigger a state refresh to restore
|
||||
entity availability.
|
||||
"""
|
||||
hass.config.components.add(DOMAIN)
|
||||
hap = HomematicipHAP(hass, hmip_config_entry)
|
||||
assert hap
|
||||
|
||||
simple_mock_home = MagicMock(spec=AsyncHome)
|
||||
simple_mock_home.devices = []
|
||||
simple_mock_home.websocket_is_connected = Mock(return_value=True)
|
||||
hap.home = simple_mock_home
|
||||
|
||||
with patch.object(hap, "get_state") as mock_get_state:
|
||||
# Initially not disconnected
|
||||
assert not hap._ws_connection_closed.is_set()
|
||||
|
||||
# Access point loses cloud connection
|
||||
hap.home.connected = False
|
||||
hap.async_update()
|
||||
assert hap._ws_connection_closed.is_set()
|
||||
mock_get_state.assert_not_called()
|
||||
|
||||
# Access point reconnects to cloud
|
||||
hap.home.connected = True
|
||||
hap.async_update()
|
||||
|
||||
# Let _try_get_state run
|
||||
await hass.async_block_till_done()
|
||||
mock_get_state.assert_called_once()
|
||||
|
||||
assert not hap._ws_connection_closed.is_set()
|
||||
|
||||
|
||||
async def test_try_get_state_exponential_backoff() -> None:
|
||||
"""Test _try_get_state waits for websocket connection."""
|
||||
|
||||
|
||||
@@ -63,19 +63,3 @@ async def test_cellular_and_gps_entities_are_gated_by_model_type(
|
||||
assert hass.states.get("sensor.nrgkick_test_cellular_mode") is None
|
||||
assert hass.states.get("sensor.nrgkick_test_cellular_signal_strength") is None
|
||||
assert hass.states.get("sensor.nrgkick_test_cellular_operator") is None
|
||||
|
||||
|
||||
async def test_vehicle_connected_since_none_when_standby(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_nrgkick_api: AsyncMock,
|
||||
) -> None:
|
||||
"""Test vehicle connected since is unknown when vehicle is not connected."""
|
||||
mock_nrgkick_api.get_values.return_value["general"]["status"] = (
|
||||
ChargingStatus.STANDBY
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert (state := hass.states.get("sensor.nrgkick_test_vehicle_connected_since"))
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
@@ -4,7 +4,6 @@ from datetime import datetime
|
||||
import pathlib
|
||||
import textwrap
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from httpx import Response
|
||||
import pytest
|
||||
import respx
|
||||
@@ -22,7 +21,7 @@ from .conftest import (
|
||||
event_fields,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
# Test data files with known calendars from various sources. You can add a new file
|
||||
# in the testdata directory and add it will be parsed and tested.
|
||||
@@ -423,110 +422,3 @@ async def test_calendar_examples(
|
||||
await setup_integration(hass, config_entry)
|
||||
events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00")
|
||||
assert events == snapshot
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.freeze_time("2023-01-01 09:59:00+00:00")
|
||||
async def test_event_lifecycle(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test the lifecycle of an event from upcoming to active to finished."""
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text=textwrap.dedent(
|
||||
"""\
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Test Event
|
||||
DTSTART:20230101T100000Z
|
||||
DTEND:20230101T110000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
# An upcoming event is off
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes.get("message") == "Test Event"
|
||||
|
||||
# Advance time to the start of the event
|
||||
freezer.move_to(datetime.fromisoformat("2023-01-01T10:00:00+00:00"))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The event is active
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes.get("message") == "Test Event"
|
||||
|
||||
# Advance time to the end of the event
|
||||
freezer.move_to(datetime.fromisoformat("2023-01-01T11:00:00+00:00"))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The event is finished
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.freeze_time("2023-01-01 09:59:00+00:00")
|
||||
async def test_event_edge_during_refresh_interval(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test the lifecycle of multiple sequential events."""
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text=textwrap.dedent(
|
||||
"""\
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Event One
|
||||
DTSTART:20230101T100000Z
|
||||
DTEND:20230101T110000Z
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Event Two
|
||||
DTSTART:20230102T190000Z
|
||||
DTEND:20230102T200000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
# Event One is upcoming
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes.get("message") == "Event One"
|
||||
|
||||
# Advance time to after the end of the first event
|
||||
freezer.move_to(datetime.fromisoformat("2023-01-01T11:01:00+00:00"))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Event Two is upcoming
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes.get("message") == "Event Two"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Test Roborock config flow."""
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from roborock import RoborockTooFrequentCodeRequests
|
||||
@@ -16,17 +15,10 @@ from roborock.exceptions import (
|
||||
from vacuum_map_parser_base.config.drawable import Drawable
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.roborock.const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_ENTRY_CODE,
|
||||
CONF_REGION,
|
||||
DOMAIN,
|
||||
DRAWABLES,
|
||||
)
|
||||
from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN, DRAWABLES
|
||||
from homeassistant.const import CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .mock_data import MOCK_CONFIG, NETWORK_INFO, ROBOROCK_RRUID, USER_DATA, USER_EMAIL
|
||||
@@ -156,7 +148,6 @@ async def test_config_flow_failures_request_code(
|
||||
[
|
||||
(RoborockException(), {"base": "unknown_roborock"}),
|
||||
(RoborockInvalidCode(), {"base": "invalid_code"}),
|
||||
(RoborockAccountDoesNotExist(), {"base": "invalid_email_or_region"}),
|
||||
(Exception(), {"base": "unknown"}),
|
||||
],
|
||||
)
|
||||
@@ -407,54 +398,3 @@ async def test_discovery_already_setup(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_config_flow_with_region(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Handle the config flow with a specific region."""
|
||||
with patch(
|
||||
"homeassistant.components.roborock.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
with patch(
|
||||
"homeassistant.components.roborock.config_flow.RoborockApiClient"
|
||||
) as mock_client_cls:
|
||||
mock_client = mock_client_cls.return_value
|
||||
mock_client.request_code_v4 = AsyncMock(return_value=None)
|
||||
mock_client.code_login_v4 = AsyncMock(return_value=USER_DATA)
|
||||
|
||||
# base_url is awaited in config_flow, so it needs to be an awaitable
|
||||
future_base_url = asyncio.Future()
|
||||
future_base_url.set_result("https://usiot.roborock.com")
|
||||
mock_client.base_url = future_base_url
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_USERNAME: USER_EMAIL, CONF_REGION: "us"}
|
||||
)
|
||||
|
||||
# Check that the client was initialized with the correct base_url
|
||||
mock_client_cls.assert_called_with(
|
||||
USER_EMAIL,
|
||||
base_url="https://usiot.roborock.com",
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "code"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["context"]["unique_id"] == ROBOROCK_RRUID
|
||||
assert result["title"] == USER_EMAIL
|
||||
assert result["data"][CONF_BASE_URL] == "https://usiot.roborock.com"
|
||||
assert result["result"]
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import NamedTuple
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -19,39 +19,6 @@ class FakeModule(NamedTuple):
|
||||
id: str
|
||||
|
||||
|
||||
def make_mock_zone(
|
||||
zone_id: int = 1, name: str = "Zone 1", alarm: str | None = None
|
||||
) -> MagicMock:
|
||||
"""Return a mock Zone with configurable alarm state."""
|
||||
zone = MagicMock()
|
||||
zone.id = zone_id
|
||||
zone.name = name
|
||||
zone.temperature = 21.5
|
||||
zone.target_temperature = 22.0
|
||||
zone.humidity = 45
|
||||
zone.mode = "constantTemp"
|
||||
zone.algorithm = "heating"
|
||||
zone.relay_on = False
|
||||
zone.alarm = alarm
|
||||
zone.schedule = None
|
||||
zone.enabled = True
|
||||
zone.signal_strength = 100
|
||||
zone.battery_level = None
|
||||
return zone
|
||||
|
||||
|
||||
def make_mock_module(zones: list) -> MagicMock:
|
||||
"""Return a mock module with the given zones."""
|
||||
module = MagicMock()
|
||||
module.id = "deadbeef"
|
||||
module.name = "Foobar"
|
||||
module.type = "SL"
|
||||
module.version = "1.0"
|
||||
module.zones = AsyncMock(return_value=zones)
|
||||
module.schedules = AsyncMock(return_value=[])
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Tests for the Roth Touchline SL climate platform."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.climate import HVACMode
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import make_mock_module, make_mock_zone
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
ENTITY_ID = "climate.zone_1"
|
||||
|
||||
|
||||
async def test_climate_zone_available(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_touchlinesl_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that the climate entity is available when zone has no alarm."""
|
||||
zone = make_mock_zone(alarm=None)
|
||||
module = make_mock_module([zone])
|
||||
mock_touchlinesl_client.modules = AsyncMock(return_value=[module])
|
||||
|
||||
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(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == HVACMode.HEAT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alarm", ["no_communication", "sensor_damaged"])
|
||||
async def test_climate_zone_unavailable_on_alarm(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_touchlinesl_client: MagicMock,
|
||||
alarm: str,
|
||||
) -> None:
|
||||
"""Test that the climate entity is unavailable when zone reports an alarm state."""
|
||||
zone = make_mock_zone(alarm=alarm)
|
||||
module = make_mock_module([zone])
|
||||
mock_touchlinesl_client.modules = AsyncMock(return_value=[module])
|
||||
|
||||
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(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
@@ -176,12 +176,6 @@ def climate_eurotronic_spirit_z_state_fixture() -> dict[str, Any]:
|
||||
return load_json_object_fixture("climate_eurotronic_spirit_z_state.json", DOMAIN)
|
||||
|
||||
|
||||
@pytest.fixture(name="climate_eurotronic_comet_z_state", scope="package")
|
||||
def climate_eurotronic_comet_z_state_fixture() -> dict[str, Any]:
|
||||
"""Load the climate Eurotronic Comet Z thermostat node state fixture data."""
|
||||
return load_json_object_fixture("climate_eurotronic_comet_z_state.json", DOMAIN)
|
||||
|
||||
|
||||
@pytest.fixture(name="climate_heatit_z_trm6_state", scope="package")
|
||||
def climate_heatit_z_trm6_state_fixture() -> dict[str, Any]:
|
||||
"""Load the climate HEATIT Z-TRM6 thermostat node state fixture data."""
|
||||
@@ -857,16 +851,6 @@ def climate_eurotronic_spirit_z_fixture(
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture(name="climate_eurotronic_comet_z")
|
||||
def climate_eurotronic_comet_z_fixture(
|
||||
client: MagicMock, climate_eurotronic_comet_z_state: dict[str, Any]
|
||||
) -> Node:
|
||||
"""Mock a climate Eurotronic Comet Z node."""
|
||||
node = Node(client, copy.deepcopy(climate_eurotronic_comet_z_state))
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture(name="climate_heatit_z_trm6")
|
||||
def climate_heatit_z_trm6_fixture(client, climate_heatit_z_trm6_state) -> Node:
|
||||
"""Mock a climate radio HEATIT Z-TRM6 node."""
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
{
|
||||
"nodeId": 2,
|
||||
"index": 0,
|
||||
"installerIcon": 4608,
|
||||
"userIcon": 4608,
|
||||
"status": 4,
|
||||
"ready": true,
|
||||
"isListening": false,
|
||||
"isRouting": true,
|
||||
"isSecure": true,
|
||||
"manufacturerId": 328,
|
||||
"productId": 3,
|
||||
"productType": 4,
|
||||
"firmwareVersion": "14.1.4",
|
||||
"zwavePlusVersion": 2,
|
||||
"deviceConfig": {
|
||||
"filename": "/data/db/devices/0x0148/cometz_700.json",
|
||||
"isEmbedded": true,
|
||||
"manufacturer": "Eurotronics",
|
||||
"manufacturerId": 328,
|
||||
"label": "Comet Z",
|
||||
"description": "Radiator Thermostat",
|
||||
"devices": [
|
||||
{
|
||||
"productType": 4,
|
||||
"productId": 3
|
||||
}
|
||||
],
|
||||
"firmwareVersion": {
|
||||
"min": "0.0",
|
||||
"max": "255.255"
|
||||
},
|
||||
"preferred": false,
|
||||
"associations": {},
|
||||
"paramInformation": {
|
||||
"_map": {}
|
||||
}
|
||||
},
|
||||
"label": "Comet Z",
|
||||
"interviewAttempts": 0,
|
||||
"isFrequentListening": "1000ms",
|
||||
"maxDataRate": 100000,
|
||||
"supportedDataRates": [40000, 100000],
|
||||
"protocolVersion": 3,
|
||||
"supportsBeaming": true,
|
||||
"supportsSecurity": false,
|
||||
"nodeType": 1,
|
||||
"zwavePlusNodeType": 0,
|
||||
"zwavePlusRoleType": 7,
|
||||
"deviceClass": {
|
||||
"basic": {
|
||||
"key": 4,
|
||||
"label": "Routing End Node"
|
||||
},
|
||||
"generic": {
|
||||
"key": 8,
|
||||
"label": "Thermostat"
|
||||
},
|
||||
"specific": {
|
||||
"key": 6,
|
||||
"label": "General Thermostat V2"
|
||||
}
|
||||
},
|
||||
"interviewStage": "Complete",
|
||||
"deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0148:0x0004:0x0003:14.1.4",
|
||||
"statistics": {
|
||||
"commandsTX": 23,
|
||||
"commandsRX": 21,
|
||||
"commandsDroppedRX": 0,
|
||||
"commandsDroppedTX": 0,
|
||||
"timeoutResponse": 3,
|
||||
"rtt": 877.4,
|
||||
"rssi": -79,
|
||||
"lwr": {
|
||||
"protocolDataRate": 2,
|
||||
"repeaters": [],
|
||||
"rssi": -78,
|
||||
"repeaterRSSI": []
|
||||
}
|
||||
},
|
||||
"highestSecurityClass": 0,
|
||||
"isControllerNode": false,
|
||||
"keepAwake": false,
|
||||
"protocol": 0,
|
||||
"sdkVersion": "7.15.4",
|
||||
"endpoints": [
|
||||
{
|
||||
"nodeId": 2,
|
||||
"index": 0,
|
||||
"installerIcon": 4608,
|
||||
"userIcon": 4608,
|
||||
"deviceClass": {
|
||||
"basic": {
|
||||
"key": 4,
|
||||
"label": "Routing End Node"
|
||||
},
|
||||
"generic": {
|
||||
"key": 8,
|
||||
"label": "Thermostat"
|
||||
},
|
||||
"specific": {
|
||||
"key": 6,
|
||||
"label": "General Thermostat V2"
|
||||
}
|
||||
},
|
||||
"commandClasses": [
|
||||
{
|
||||
"id": 49,
|
||||
"name": "Multilevel Sensor",
|
||||
"version": 11,
|
||||
"isSecure": true
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"name": "Multilevel Switch",
|
||||
"version": 1,
|
||||
"isSecure": true
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"name": "Thermostat Mode",
|
||||
"version": 3,
|
||||
"isSecure": true
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"name": "Thermostat Setpoint",
|
||||
"version": 3,
|
||||
"isSecure": true
|
||||
},
|
||||
{
|
||||
"id": 128,
|
||||
"name": "Battery",
|
||||
"version": 1,
|
||||
"isSecure": true
|
||||
},
|
||||
{
|
||||
"id": 112,
|
||||
"name": "Configuration",
|
||||
"version": 1,
|
||||
"isSecure": true
|
||||
},
|
||||
{
|
||||
"id": 114,
|
||||
"name": "Manufacturer Specific",
|
||||
"version": 2,
|
||||
"isSecure": true
|
||||
},
|
||||
{
|
||||
"id": 134,
|
||||
"name": "Version",
|
||||
"version": 3,
|
||||
"isSecure": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"values": [
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 38,
|
||||
"commandClassName": "Multilevel Switch",
|
||||
"property": "currentValue",
|
||||
"propertyName": "currentValue",
|
||||
"ccVersion": 1,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Current value",
|
||||
"min": 0,
|
||||
"max": 99,
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 38,
|
||||
"commandClassName": "Multilevel Switch",
|
||||
"property": "targetValue",
|
||||
"propertyName": "targetValue",
|
||||
"ccVersion": 1,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": true,
|
||||
"label": "Target value",
|
||||
"valueChangeOptions": ["transitionDuration"],
|
||||
"min": 0,
|
||||
"max": 99,
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 38,
|
||||
"commandClassName": "Multilevel Switch",
|
||||
"property": "Up",
|
||||
"propertyName": "Up",
|
||||
"ccVersion": 1,
|
||||
"metadata": {
|
||||
"type": "boolean",
|
||||
"readable": false,
|
||||
"writeable": true,
|
||||
"label": "Perform a level change (Up)",
|
||||
"ccSpecific": {
|
||||
"switchType": 2
|
||||
},
|
||||
"valueChangeOptions": ["transitionDuration"],
|
||||
"states": {
|
||||
"true": "Start",
|
||||
"false": "Stop"
|
||||
},
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 38,
|
||||
"commandClassName": "Multilevel Switch",
|
||||
"property": "Down",
|
||||
"propertyName": "Down",
|
||||
"ccVersion": 1,
|
||||
"metadata": {
|
||||
"type": "boolean",
|
||||
"readable": false,
|
||||
"writeable": true,
|
||||
"label": "Perform a level change (Down)",
|
||||
"ccSpecific": {
|
||||
"switchType": 2
|
||||
},
|
||||
"valueChangeOptions": ["transitionDuration"],
|
||||
"states": {
|
||||
"true": "Start",
|
||||
"false": "Stop"
|
||||
},
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 38,
|
||||
"commandClassName": "Multilevel Switch",
|
||||
"property": "duration",
|
||||
"propertyName": "duration",
|
||||
"ccVersion": 1,
|
||||
"metadata": {
|
||||
"type": "duration",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Remaining duration",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 38,
|
||||
"commandClassName": "Multilevel Switch",
|
||||
"property": "restorePrevious",
|
||||
"propertyName": "restorePrevious",
|
||||
"ccVersion": 1,
|
||||
"metadata": {
|
||||
"type": "boolean",
|
||||
"readable": false,
|
||||
"writeable": true,
|
||||
"label": "Restore previous value",
|
||||
"states": {
|
||||
"true": "Restore"
|
||||
},
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 49,
|
||||
"commandClassName": "Multilevel Sensor",
|
||||
"property": "Air temperature",
|
||||
"propertyName": "Air temperature",
|
||||
"ccVersion": 11,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Air temperature",
|
||||
"ccSpecific": {
|
||||
"sensorType": 1,
|
||||
"scale": 0
|
||||
},
|
||||
"unit": "\u00b0C",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 18.5
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 64,
|
||||
"commandClassName": "Thermostat Mode",
|
||||
"property": "mode",
|
||||
"propertyName": "mode",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": true,
|
||||
"label": "Thermostat mode",
|
||||
"min": 0,
|
||||
"max": 255,
|
||||
"states": {
|
||||
"0": "Off",
|
||||
"1": "Heat",
|
||||
"11": "Energy heat",
|
||||
"15": "Full power",
|
||||
"31": "Manufacturer specific"
|
||||
},
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 64,
|
||||
"commandClassName": "Thermostat Mode",
|
||||
"property": "manufacturerData",
|
||||
"propertyName": "manufacturerData",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
"type": "buffer",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Manufacturer data",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": {
|
||||
"type": "Buffer",
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 67,
|
||||
"commandClassName": "Thermostat Setpoint",
|
||||
"property": "setpoint",
|
||||
"propertyKey": 1,
|
||||
"propertyName": "setpoint",
|
||||
"propertyKeyName": "Heating",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": true,
|
||||
"label": "Setpoint (Heating)",
|
||||
"ccSpecific": {
|
||||
"setpointType": 1
|
||||
},
|
||||
"min": 8,
|
||||
"max": 28,
|
||||
"unit": "\u00b0C",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 21
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 67,
|
||||
"commandClassName": "Thermostat Setpoint",
|
||||
"property": "setpoint",
|
||||
"propertyKey": 11,
|
||||
"propertyName": "setpoint",
|
||||
"propertyKeyName": "Energy Save Heating",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": true,
|
||||
"label": "Setpoint (Energy Save Heating)",
|
||||
"ccSpecific": {
|
||||
"setpointType": 11
|
||||
},
|
||||
"min": 8,
|
||||
"max": 28,
|
||||
"unit": "\u00b0C",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 16
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 114,
|
||||
"commandClassName": "Manufacturer Specific",
|
||||
"property": "manufacturerId",
|
||||
"propertyName": "manufacturerId",
|
||||
"ccVersion": 2,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Manufacturer ID",
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 328
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 114,
|
||||
"commandClassName": "Manufacturer Specific",
|
||||
"property": "productType",
|
||||
"propertyName": "productType",
|
||||
"ccVersion": 2,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Product type",
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 4
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 114,
|
||||
"commandClassName": "Manufacturer Specific",
|
||||
"property": "productId",
|
||||
"propertyName": "productId",
|
||||
"ccVersion": 2,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Product ID",
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 128,
|
||||
"commandClassName": "Battery",
|
||||
"property": "level",
|
||||
"propertyName": "level",
|
||||
"ccVersion": 1,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Battery level",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"unit": "%",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 45
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 134,
|
||||
"commandClassName": "Version",
|
||||
"property": "hardwareVersion",
|
||||
"propertyName": "hardwareVersion",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Z-Wave chip hardware version",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 134,
|
||||
"commandClassName": "Version",
|
||||
"property": "firmwareVersions",
|
||||
"propertyName": "firmwareVersions",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
"type": "string[]",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Z-Wave chip firmware versions",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": ["14.1", "1.6"]
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 134,
|
||||
"commandClassName": "Version",
|
||||
"property": "protocolVersion",
|
||||
"propertyName": "protocolVersion",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
"type": "string",
|
||||
"readable": true,
|
||||
"writeable": false,
|
||||
"label": "Z-Wave protocol version",
|
||||
"stateful": true,
|
||||
"secret": false
|
||||
},
|
||||
"value": "7.15"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test the Z-Wave JS climate platform."""
|
||||
|
||||
import copy
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from zwave_js_server.const import CommandClass
|
||||
@@ -57,8 +56,6 @@ from .common import (
|
||||
replace_value_of_zwave_value,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
@@ -1004,329 +1001,3 @@ async def test_thermostat_unknown_values(
|
||||
state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY)
|
||||
|
||||
assert ATTR_HVAC_ACTION not in state.attributes
|
||||
|
||||
|
||||
async def test_set_preset_mode_manufacturer_specific(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
climate_eurotronic_comet_z: Node,
|
||||
integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setting preset mode to manufacturer specific.
|
||||
|
||||
This tests the Eurotronic Comet Z thermostat which has a
|
||||
"Manufacturer specific" thermostat mode (value 31) that is
|
||||
exposed as a preset mode.
|
||||
"""
|
||||
node = climate_eurotronic_comet_z
|
||||
entity_id = "climate.radiator_thermostat"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == HVACMode.HEAT
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 21
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
|
||||
|
||||
# Test setting preset mode to "Manufacturer specific"
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: "Manufacturer specific",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert len(client.async_send_command.call_args_list) == 1
|
||||
args = client.async_send_command.call_args[0][0]
|
||||
assert args["command"] == "node.set_value"
|
||||
assert args["nodeId"] == 2
|
||||
assert args["valueId"] == {
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
}
|
||||
assert args["value"] == 31
|
||||
|
||||
client.async_send_command.reset_mock()
|
||||
|
||||
# Simulate the device updating to manufacturer specific mode
|
||||
event = Event(
|
||||
type="value updated",
|
||||
data={
|
||||
"source": "node",
|
||||
"event": "value updated",
|
||||
"nodeId": 2,
|
||||
"args": {
|
||||
"commandClassName": "Thermostat Mode",
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
"propertyName": "mode",
|
||||
"newValue": 31,
|
||||
"prevValue": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
node.receive_event(event)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
# Mode 31 is not in ZW_HVAC_MODE_MAP, so hvac_mode is unknown.
|
||||
assert state.state == "unknown"
|
||||
assert state.attributes[ATTR_PRESET_MODE] == "Manufacturer specific"
|
||||
|
||||
# Test restoring hvac mode by setting preset to none.
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: PRESET_NONE,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert client.async_send_command.call_count == 1
|
||||
args = client.async_send_command.call_args[0][0]
|
||||
assert args["command"] == "node.set_value"
|
||||
assert args["nodeId"] == 2
|
||||
assert args["valueId"] == {
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
}
|
||||
assert args["value"] == 1
|
||||
|
||||
client.async_send_command.reset_mock()
|
||||
|
||||
|
||||
async def test_preset_mode_mapped_to_unsupported_hvac_mode(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
climate_eurotronic_comet_z: Node,
|
||||
integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test preset mapping to an HVAC mode the entity doesn't support.
|
||||
|
||||
The Away mode (13) maps to HVACMode.HEAT_COOL in ZW_HVAC_MODE_MAP,
|
||||
but the Comet Z only supports OFF and HEAT. The hvac_mode property
|
||||
should return None for this unsupported mapping.
|
||||
"""
|
||||
node = climate_eurotronic_comet_z
|
||||
entity_id = "climate.radiator_thermostat"
|
||||
|
||||
# Simulate the device being set to Away mode (13).
|
||||
event = Event(
|
||||
type="value updated",
|
||||
data={
|
||||
"source": "node",
|
||||
"event": "value updated",
|
||||
"nodeId": 2,
|
||||
"args": {
|
||||
"commandClassName": "Thermostat Mode",
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
"propertyName": "mode",
|
||||
"newValue": 13,
|
||||
"prevValue": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
node.receive_event(event)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
# Away maps to HEAT_COOL which the device doesn't support,
|
||||
# so hvac_mode returns None.
|
||||
assert state.state == "unknown"
|
||||
|
||||
|
||||
async def test_set_preset_mode_mapped_preset(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
climate_eurotronic_comet_z: Node,
|
||||
integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that a preset mapping to a supported HVAC mode shows that mode.
|
||||
|
||||
The Eurotronic Comet Z has "Energy heat" (mode 11 = HEATING_ECON) which
|
||||
maps to HVACMode.HEAT in ZW_HVAC_MODE_MAP. Since the device supports
|
||||
heat, hvac_mode should return heat while in this preset.
|
||||
"""
|
||||
node = climate_eurotronic_comet_z
|
||||
entity_id = "climate.radiator_thermostat"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == HVACMode.HEAT
|
||||
|
||||
# Set preset mode to "Energy heat"
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: "Energy heat",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert len(client.async_send_command.call_args_list) == 1
|
||||
args = client.async_send_command.call_args[0][0]
|
||||
assert args["command"] == "node.set_value"
|
||||
assert args["value"] == 11
|
||||
|
||||
client.async_send_command.reset_mock()
|
||||
|
||||
# Simulate the device updating to energy heat mode
|
||||
event = Event(
|
||||
type="value updated",
|
||||
data={
|
||||
"source": "node",
|
||||
"event": "value updated",
|
||||
"nodeId": 2,
|
||||
"args": {
|
||||
"commandClassName": "Thermostat Mode",
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
"propertyName": "mode",
|
||||
"newValue": 11,
|
||||
"prevValue": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
node.receive_event(event)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
# Energy heat (HEATING_ECON) maps to HVACMode.HEAT which the device
|
||||
# supports, so hvac_mode returns heat.
|
||||
assert state.state == HVACMode.HEAT
|
||||
assert state.attributes[ATTR_PRESET_MODE] == "Energy heat"
|
||||
|
||||
# Clear preset - should restore to heat (the mapped mode).
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: PRESET_NONE,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert client.async_send_command.call_count == 1
|
||||
args = client.async_send_command.call_args[0][0]
|
||||
assert args["command"] == "node.set_value"
|
||||
assert args["value"] == 1
|
||||
|
||||
client.async_send_command.reset_mock()
|
||||
|
||||
|
||||
async def test_set_preset_mode_none_while_in_hvac_mode(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
climate_eurotronic_comet_z: Node,
|
||||
integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setting preset mode to none while already in an HVAC mode."""
|
||||
entity_id = "climate.radiator_thermostat"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == HVACMode.HEAT
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
|
||||
|
||||
# Setting preset to none while already in an HVAC mode restores
|
||||
# the current hvac mode.
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: PRESET_NONE,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert client.async_send_command.call_count == 1
|
||||
args = client.async_send_command.call_args[0][0]
|
||||
assert args["command"] == "node.set_value"
|
||||
assert args["nodeId"] == 2
|
||||
assert args["valueId"] == {
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
}
|
||||
assert args["value"] == 1
|
||||
|
||||
|
||||
async def test_set_preset_mode_none_unmapped_preset(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
climate_eurotronic_comet_z: Node,
|
||||
integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test clearing an unmapped preset falls back to first supported HVAC mode.
|
||||
|
||||
When the device is in a preset mode that has no mapping in ZW_HVAC_MODE_MAP
|
||||
(e.g. "Manufacturer specific"), hvac_mode returns None. Setting preset to
|
||||
none should fall back to the first supported non-off HVAC mode.
|
||||
"""
|
||||
node = climate_eurotronic_comet_z
|
||||
entity_id = "climate.radiator_thermostat"
|
||||
|
||||
# Simulate the device being externally changed to "Manufacturer specific"
|
||||
# mode without HA having set a preset first.
|
||||
event = Event(
|
||||
type="value updated",
|
||||
data={
|
||||
"source": "node",
|
||||
"event": "value updated",
|
||||
"nodeId": 2,
|
||||
"args": {
|
||||
"commandClassName": "Thermostat Mode",
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
"propertyName": "mode",
|
||||
"newValue": 31,
|
||||
"prevValue": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
node.receive_event(event)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == "unknown"
|
||||
assert state.attributes[ATTR_PRESET_MODE] == "Manufacturer specific"
|
||||
|
||||
client.async_send_command.reset_mock()
|
||||
|
||||
# Setting preset to none should default to heat since there is no
|
||||
# stored previous HVAC mode.
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: PRESET_NONE,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert client.async_send_command.call_count == 1
|
||||
args = client.async_send_command.call_args[0][0]
|
||||
assert args["command"] == "node.set_value"
|
||||
assert args["nodeId"] == 2
|
||||
assert args["valueId"] == {
|
||||
"commandClass": 64,
|
||||
"endpoint": 0,
|
||||
"property": "mode",
|
||||
}
|
||||
assert args["value"] == 1
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Test the aiohttp client helper."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import socket
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from aiohttp.test_utils import TestClient
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.mjpeg import (
|
||||
@@ -442,179 +440,3 @@ async def test_connector_no_verify_uses_http11_alpn(hass: HomeAssistant) -> None
|
||||
mock_client_context_no_verify.assert_called_once_with(
|
||||
SSLCipherList.PYTHON_DEFAULT, ssl_util.SSL_ALPN_HTTP11
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def redirect_server() -> AsyncGenerator[TestServer]:
|
||||
"""Start a test server that redirects based on query parameters."""
|
||||
|
||||
async def handle_redirect(request: web.Request) -> web.Response:
|
||||
"""Redirect to the URL specified in the 'to' query parameter."""
|
||||
location = request.query["to"]
|
||||
return web.Response(status=307, headers={"Location": location})
|
||||
|
||||
async def handle_ok(request: web.Request) -> web.Response:
|
||||
"""Return a 200 OK response."""
|
||||
return web.Response(text="ok")
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/redirect", handle_redirect)
|
||||
app.router.add_get("/ok", handle_ok)
|
||||
|
||||
async def _mock_resolve_host(
|
||||
self: aiohttp.TCPConnector,
|
||||
host: str,
|
||||
port: int,
|
||||
traces: object = None,
|
||||
) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"hostname": host,
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"family": socket.AF_INET,
|
||||
"proto": 6,
|
||||
"flags": 0,
|
||||
}
|
||||
]
|
||||
|
||||
server = TestServer(app)
|
||||
await server.start_server()
|
||||
# Route all TCP connections to the local test server
|
||||
# This allows us to test redirect behavior of external URLs
|
||||
# without actually making network requests
|
||||
with patch.object(aiohttp.TCPConnector, "_resolve_host", _mock_resolve_host):
|
||||
yield server
|
||||
await server.close()
|
||||
|
||||
|
||||
def _resolve_result(host: str, addr: str) -> list[dict[str, object]]:
|
||||
"""Build a mock DNS resolve result for the SSRF check."""
|
||||
return [
|
||||
{
|
||||
"hostname": host,
|
||||
"host": addr,
|
||||
"port": 0,
|
||||
"family": socket.AF_INET,
|
||||
"proto": 6,
|
||||
"flags": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("socket_enabled")
|
||||
async def test_redirect_loopback_to_loopback_allowed(
|
||||
hass: HomeAssistant, redirect_server: TestServer
|
||||
) -> None:
|
||||
"""Test that redirects from loopback to loopback are allowed."""
|
||||
session = client.async_get_clientsession(hass)
|
||||
target = str(redirect_server.make_url("/ok"))
|
||||
redirect_url = redirect_server.make_url(f"/redirect?to={target}")
|
||||
|
||||
# Both origin and target are on 127.0.0.1 — should be allowed
|
||||
resp = await session.get(redirect_url)
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("socket_enabled")
|
||||
async def test_redirect_relative_url_allowed(
|
||||
hass: HomeAssistant, redirect_server: TestServer
|
||||
) -> None:
|
||||
"""Test that relative redirects are allowed (they stay on the same host)."""
|
||||
session = client.async_create_clientsession(hass)
|
||||
server_port = redirect_server.port
|
||||
|
||||
# Redirect from an external origin to a relative path
|
||||
redirect_url = f"http://external.example.com:{server_port}/redirect?to=/ok"
|
||||
|
||||
async def mock_async_resolve_host(host: str) -> list[dict[str, object]]:
|
||||
"""Return public IPs for all hosts."""
|
||||
return _resolve_result(host, "93.184.216.34")
|
||||
|
||||
connector = session.connector
|
||||
with patch.object(connector, "async_resolve_host", mock_async_resolve_host):
|
||||
resp = await session.get(redirect_url)
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("socket_enabled")
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
"http://other.example.com:{port}/ok",
|
||||
"http://safe.example.com:{port}/ok",
|
||||
"http://notlocalhost:{port}/ok",
|
||||
],
|
||||
)
|
||||
async def test_redirect_to_non_loopback_allowed(
|
||||
hass: HomeAssistant, redirect_server: TestServer, target: str
|
||||
) -> None:
|
||||
"""Test that redirects to non-loopback addresses are allowed."""
|
||||
session = client.async_create_clientsession(hass)
|
||||
server_port = redirect_server.port
|
||||
|
||||
location = target.format(port=server_port)
|
||||
redirect_url = f"http://external.example.com:{server_port}/redirect?to={location}"
|
||||
|
||||
async def mock_async_resolve_host(host: str) -> list[dict[str, object]]:
|
||||
"""Return public IPs for all hosts."""
|
||||
return _resolve_result(host, "93.184.216.34")
|
||||
|
||||
connector = session.connector
|
||||
with patch.object(connector, "async_resolve_host", mock_async_resolve_host):
|
||||
resp = await session.get(redirect_url)
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("socket_enabled")
|
||||
@pytest.mark.parametrize(
|
||||
("location", "target_resolved_addr"),
|
||||
[
|
||||
# Loopback IPs and hostnames — blocked before DNS resolution
|
||||
("http://127.0.0.1/evil", None),
|
||||
("http://[::1]/evil", None),
|
||||
("http://localhost/evil", None),
|
||||
("http://localhost./evil", None),
|
||||
("http://example.localhost/evil", None),
|
||||
("http://example.localhost./evil", None),
|
||||
("http://app.localhost/evil", None),
|
||||
("http://sub.domain.localhost/evil", None),
|
||||
# Benign hostnames resolving to blocked IPs — blocked after DNS
|
||||
("http://evil.example.com:{port}/steal", "127.0.0.1"),
|
||||
("http://evil.example.com:{port}/steal", "127.0.0.2"),
|
||||
("http://evil.example.com:{port}/steal", "::1"),
|
||||
("http://evil.example.com:{port}/steal", "0.0.0.0"),
|
||||
("http://evil.example.com:{port}/steal", "::"),
|
||||
],
|
||||
)
|
||||
async def test_redirect_to_blocked_address(
|
||||
hass: HomeAssistant,
|
||||
redirect_server: TestServer,
|
||||
location: str,
|
||||
target_resolved_addr: str | None,
|
||||
) -> None:
|
||||
"""Test that redirects to blocked addresses are blocked.
|
||||
|
||||
Covers both cases: targets blocked by hostname/IP (before DNS) and
|
||||
targets blocked after DNS resolution reveals a loopback/unspecified IP.
|
||||
"""
|
||||
session = client.async_create_clientsession(hass)
|
||||
server_port = redirect_server.port
|
||||
|
||||
target = location.format(port=server_port)
|
||||
redirect_url = f"http://external.example.com:{server_port}/redirect?to={target}"
|
||||
|
||||
async def mock_async_resolve_host(host: str) -> list[dict[str, object]]:
|
||||
"""Return public IP for origin, optional blocked IP for target."""
|
||||
if host == "external.example.com":
|
||||
return _resolve_result(host, "93.184.216.34")
|
||||
if target_resolved_addr is not None:
|
||||
return _resolve_result(host, target_resolved_addr)
|
||||
return []
|
||||
|
||||
connector = session.connector
|
||||
with (
|
||||
patch.object(connector, "async_resolve_host", mock_async_resolve_host),
|
||||
pytest.raises(client.SSRFRedirectError),
|
||||
):
|
||||
await session.get(redirect_url)
|
||||
|
||||
Reference in New Issue
Block a user