Compare commits

..

3 Commits
rc ... 2026.2.2

Author SHA1 Message Date
Franck Nijhof
3e6bc29a6a 2026.2.2 (#162950) 2026-02-13 21:05:06 +01:00
Franck Nijhof
28027ddca4 2026.2.1 (#162450) 2026-02-06 22:44:07 +01:00
Franck Nijhof
3e8923f105 2026.2.0 (#162224) 2026-02-04 20:35:11 +01:00
45 changed files with 132 additions and 2010 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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." }
]

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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(

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==6.0.5"]
"requirements": ["pyrainbird==6.0.1"]
}

View File

@@ -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:

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==13.2.0"]
"requirements": ["ical==12.1.3"]
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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.",

View File

@@ -31,5 +31,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.5.3"]
"requirements": ["pysmartthings==3.5.2"]
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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:

View File

@@ -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,

View File

@@ -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,
})
# ---

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"
}
]
}

View File

@@ -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

View File

@@ -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)